feat: add Image widget + legacy Image converter

This commit is contained in:
ErSan 2026-01-27 19:12:20 +08:00
parent f416a393d8
commit 003f23ee78
6 changed files with 106 additions and 8 deletions

View File

@ -495,9 +495,27 @@ function NodeView(props: {
{node.props.text} {node.props.text}
</span> </span>
</div> </div>
) : ( ) : node.type === 'image' ? (
<div>{node.type}</div> <div
)} style={{
width: '100%',
height: '100%',
overflow: 'hidden',
borderRadius: `${node.props.borderRadius ?? 0}px`,
}}
>
<img
src={node.props.src}
alt=""
style={{
width: '100%',
height: '100%',
objectFit: node.props.fit ?? 'contain',
display: 'block',
}}
/>
</div>
) : null}
{props.selected && <ResizeHandles onPointerDown={props.onResizePointerDown} />} {props.selected && <ResizeHandles onPointerDown={props.onResizePointerDown} />}
</div> </div>

View File

@ -79,6 +79,7 @@ export function EditorApp() {
<Inspector <Inspector
selected={state.doc.screen.nodes.find((n) => n.id === state.selection.ids[0])} selected={state.doc.screen.nodes.find((n) => n.id === state.selection.ids[0])}
onUpdateTextProps={(id, props) => dispatch({ type: 'updateTextProps', id, props })} onUpdateTextProps={(id, props) => dispatch({ type: 'updateTextProps', id, props })}
onUpdateImageProps={(id, props) => dispatch({ type: 'updateImageProps', id, props })}
/> />
<Divider /> <Divider />

View File

@ -1,9 +1,12 @@
import { Input, InputNumber, Select, Space, Typography } from 'antd'; import { Input, InputNumber, Select, Space, Typography } from 'antd';
import type { TextWidgetNode, WidgetNode } from '@astralview/sdk'; import type { WidgetNode, TextWidgetNode } from '@astralview/sdk';
type ImageWidgetNode = Extract<WidgetNode, { type: 'image' }>;
export function Inspector(props: { export function Inspector(props: {
selected?: WidgetNode; selected?: WidgetNode;
onUpdateTextProps: (id: string, patch: Partial<TextWidgetNode['props']>) => void; onUpdateTextProps: (id: string, patch: Partial<TextWidgetNode['props']>) => void;
onUpdateImageProps: (id: string, patch: Partial<ImageWidgetNode['props']>) => void;
}) { }) {
const node = props.selected; const node = props.selected;
@ -11,8 +14,50 @@ export function Inspector(props: {
return <Typography.Paragraph style={{ color: '#666' }}>No selection.</Typography.Paragraph>; return <Typography.Paragraph style={{ color: '#666' }}>No selection.</Typography.Paragraph>;
} }
if (node.type === 'image') {
return (
<div>
<Typography.Title level={5} style={{ marginTop: 0 }}>
Image
</Typography.Title>
<Typography.Text type="secondary">Source</Typography.Text>
<Input
value={node.props.src}
onChange={(e) => props.onUpdateImageProps(node.id, { src: e.target.value })}
placeholder="https://..."
style={{ marginBottom: 12 }}
/>
<Typography.Text type="secondary">Fit</Typography.Text>
<Select
value={node.props.fit ?? 'contain'}
onChange={(v) => props.onUpdateImageProps(node.id, { fit: v })}
style={{ width: '100%', marginBottom: 12 }}
options={[
{ value: 'contain', label: 'contain' },
{ value: 'cover', label: 'cover' },
{ value: 'fill', label: 'fill' },
{ value: 'none', label: 'none' },
{ value: 'scale-down', label: 'scale-down' },
]}
/>
<Typography.Text type="secondary">Border radius</Typography.Text>
<InputNumber
value={node.props.borderRadius ?? 0}
min={0}
onChange={(v) => props.onUpdateImageProps(node.id, { borderRadius: typeof v === 'number' ? v : 0 })}
style={{ width: '100%' }}
/>
</div>
);
}
// If more widget types are added, handle them above.
// For now, we only support text/image.
if (node.type !== 'text') { if (node.type !== 'text') {
return <Typography.Paragraph style={{ color: '#666' }}>Unsupported widget type: {node.type}</Typography.Paragraph>; return <Typography.Paragraph style={{ color: '#666' }}>Unsupported widget type.</Typography.Paragraph>;
} }
const fontWeight = node.props.fontWeight ?? 400; const fontWeight = node.props.fontWeight ?? 400;
@ -144,7 +189,7 @@ export function Inspector(props: {
style={{ width: '100%', marginBottom: 12 }} style={{ width: '100%', marginBottom: 12 }}
options={[ options={[
{ value: 'horizontal-tb', label: 'Horizontal' }, { value: 'horizontal-tb', label: 'Horizontal' },
{ value: 'vertical-rl', label: 'Vertical' } { value: 'vertical-rl', label: 'Vertical' },
]} ]}
/> />

View File

@ -3,6 +3,7 @@ import {
createEmptyScreen, createEmptyScreen,
migrateScreen, migrateScreen,
type Rect, type Rect,
type ImageWidgetNode,
type Screen, type Screen,
type TextWidgetNode, type TextWidgetNode,
type WidgetNode, type WidgetNode,
@ -39,7 +40,8 @@ export type EditorAction =
| { type: 'deleteSelected' } | { type: 'deleteSelected' }
| { type: 'nudgeSelected'; dx: number; dy: number } | { type: 'nudgeSelected'; dx: number; dy: number }
| { type: 'duplicateSelected' } | { type: 'duplicateSelected' }
| { type: 'updateTextProps'; id: string; props: Partial<TextWidgetNode['props']> }; | { type: 'updateTextProps'; id: string; props: Partial<TextWidgetNode['props']> }
| { type: 'updateImageProps'; id: string; props: Partial<ImageWidgetNode['props']> };
interface DragSession { interface DragSession {
kind: 'move' | 'resize'; kind: 'move' | 'resize';
@ -198,6 +200,23 @@ export function editorReducer(state: EditorState, action: EditorAction): EditorS
}; };
} }
case 'updateImageProps': {
const node = state.doc.screen.nodes.find((n) => n.id === action.id);
if (!node || node.type !== 'image') return state;
return {
...historyPush(state),
doc: {
screen: {
...state.doc.screen,
nodes: state.doc.screen.nodes.map((n) => {
if (n.id !== action.id || n.type !== 'image') return n;
return { ...n, props: { ...n.props, ...action.props } };
}),
},
},
};
}
case 'deleteSelected': { case 'deleteSelected': {
if (!state.selection.ids.length) return state; if (!state.selection.ids.length) return state;
const ids = new Set(state.selection.ids); const ids = new Set(state.selection.ids);

View File

@ -4,4 +4,19 @@ import react from '@vitejs/plugin-react'
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
build: {
rollupOptions: {
output: {
// Keep bundle sizes manageable (and make caching better) by splitting common deps.
manualChunks(id) {
if (!id.includes('node_modules')) return
// Keep this match tight: many packages contain "react" in their name.
if (/node_modules[\\/](react|react-dom|scheduler)[\\/]/.test(id)) return 'react'
return 'vendor'
},
},
},
},
}) })

File diff suppressed because one or more lines are too long