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}
</span>
</div>
) : (
<div>{node.type}</div>
)}
) : node.type === 'image' ? (
<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} />}
</div>

View File

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

View File

@ -1,9 +1,12 @@
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: {
selected?: WidgetNode;
onUpdateTextProps: (id: string, patch: Partial<TextWidgetNode['props']>) => void;
onUpdateImageProps: (id: string, patch: Partial<ImageWidgetNode['props']>) => void;
}) {
const node = props.selected;
@ -11,8 +14,50 @@ export function Inspector(props: {
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') {
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;
@ -144,7 +189,7 @@ export function Inspector(props: {
style={{ width: '100%', marginBottom: 12 }}
options={[
{ value: 'horizontal-tb', label: 'Horizontal' },
{ value: 'vertical-rl', label: 'Vertical' }
{ value: 'vertical-rl', label: 'Vertical' },
]}
/>

View File

@ -3,6 +3,7 @@ import {
createEmptyScreen,
migrateScreen,
type Rect,
type ImageWidgetNode,
type Screen,
type TextWidgetNode,
type WidgetNode,
@ -39,7 +40,8 @@ export type EditorAction =
| { type: 'deleteSelected' }
| { type: 'nudgeSelected'; dx: number; dy: number }
| { 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 {
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': {
if (!state.selection.ids.length) return state;
const ids = new Set(state.selection.ids);

View File

@ -4,4 +4,19 @@ import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
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