feat: improve goView iframe/video import + editor parity

This commit is contained in:
clawdbot 2026-01-28 20:44:51 +08:00
parent 947946edc6
commit acf61d9bee
8 changed files with 572 additions and 461 deletions

View File

@ -115,7 +115,7 @@ export function ContextMenu(props: {
const selectionIds = selectionInSync ? props.selectionIds : ctx.selectionIds; const selectionIds = selectionInSync ? props.selectionIds : ctx.selectionIds;
const hasSelection = selectionIds.length > 0; const hasSelection = selectionIds.length > 0;
const canModifySelection = selectionInSync && hasSelection && props.selectionHasUnlocked; const canModifySelection = hasSelection && props.selectionHasUnlocked;
const hasTarget = ctx.kind === 'node'; const hasTarget = ctx.kind === 'node';
const targetId = hasTarget ? ctx.targetId : undefined; const targetId = hasTarget ? ctx.targetId : undefined;
const targetInSelection = !!targetId && selectionIds.includes(targetId); const targetInSelection = !!targetId && selectionIds.includes(targetId);
@ -198,7 +198,7 @@ export function ContextMenu(props: {
? 'Toggle Lock' ? 'Toggle Lock'
: 'Lock' : 'Lock'
} }
disabled={!selectionInSync || !hasSelection || !props.onToggleLockSelected} disabled={!hasSelection || !props.onToggleLockSelected}
onClick={() => { onClick={() => {
props.onToggleLockSelected?.(); props.onToggleLockSelected?.();
onClose(); onClose();
@ -212,7 +212,7 @@ export function ContextMenu(props: {
? 'Toggle Visibility' ? 'Toggle Visibility'
: 'Hide' : 'Hide'
} }
disabled={!selectionInSync || !hasSelection || !props.onToggleHideSelected} disabled={!hasSelection || !props.onToggleHideSelected}
onClick={() => { onClick={() => {
props.onToggleHideSelected?.(); props.onToggleHideSelected?.();
onClose(); onClose();
@ -223,7 +223,7 @@ export function ContextMenu(props: {
<MenuItem <MenuItem
label="Bring To Front" label="Bring To Front"
disabled={!selectionInSync || !hasSelection || !props.onBringToFrontSelected} disabled={!hasSelection || !props.onBringToFrontSelected}
onClick={() => { onClick={() => {
props.onBringToFrontSelected?.(); props.onBringToFrontSelected?.();
onClose(); onClose();
@ -231,7 +231,7 @@ export function ContextMenu(props: {
/> />
<MenuItem <MenuItem
label="Send To Back" label="Send To Back"
disabled={!selectionInSync || !hasSelection || !props.onSendToBackSelected} disabled={!hasSelection || !props.onSendToBackSelected}
onClick={() => { onClick={() => {
props.onSendToBackSelected?.(); props.onSendToBackSelected?.();
onClose(); onClose();

View File

@ -460,10 +460,18 @@ export function EditorApp() {
<Inspector <Inspector
selected={selected} selected={selected}
onUpdateTextProps={(id, props) => dispatch({ type: 'updateTextProps', id, props })} onUpdateTextProps={(id, props) =>
onUpdateImageProps={(id, props) => dispatch({ type: 'updateImageProps', id, props })} dispatch({ type: 'updateWidgetProps', widgetType: 'text', id, props })
onUpdateIframeProps={(id, props) => dispatch({ type: 'updateIframeProps', id, props })} }
onUpdateVideoProps={(id, props) => dispatch({ type: 'updateVideoProps', id, props })} onUpdateImageProps={(id, props) =>
dispatch({ type: 'updateWidgetProps', widgetType: 'image', id, props })
}
onUpdateIframeProps={(id, props) =>
dispatch({ type: 'updateWidgetProps', widgetType: 'iframe', id, props })
}
onUpdateVideoProps={(id, props) =>
dispatch({ type: 'updateWidgetProps', widgetType: 'video', id, props })
}
/> />
</div> </div>
</> </>

View File

@ -1,9 +1,9 @@
import { Input, InputNumber, Select, Space, Typography } from 'antd'; import { Input, InputNumber, Select, Space, Typography } from 'antd';
import type { WidgetNode, TextWidgetNode } from '@astralview/sdk'; import { assertNever, type WidgetNode, type TextWidgetNode, type WidgetNodeByType } from '@astralview/sdk';
type ImageWidgetNode = Extract<WidgetNode, { type: 'image' }>; type ImageWidgetNode = WidgetNodeByType['image'];
type IframeWidgetNode = Extract<WidgetNode, { type: 'iframe' }>; type IframeWidgetNode = WidgetNodeByType['iframe'];
type VideoWidgetNode = Extract<WidgetNode, { type: 'video' }>; type VideoWidgetNode = WidgetNodeByType['video'];
export function Inspector(props: { export function Inspector(props: {
selected?: WidgetNode; selected?: WidgetNode;
@ -18,356 +18,354 @@ 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') { switch (node.type) {
return ( case 'image':
<div> return (
<Typography.Title level={5} style={{ marginTop: 0 }}> <div>
Image <Typography.Title level={5} style={{ marginTop: 0 }}>
</Typography.Title> Image
</Typography.Title>
<Typography.Text type="secondary">Source</Typography.Text> <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 (node.type === 'iframe') {
return (
<div>
<Typography.Title level={5} style={{ marginTop: 0 }}>
Iframe
</Typography.Title>
<Typography.Text type="secondary">Source</Typography.Text>
<Input
value={node.props.src}
onChange={(e) => props.onUpdateIframeProps(node.id, { src: e.target.value })}
placeholder="https://..."
style={{ marginBottom: 12 }}
/>
<Typography.Text type="secondary">Border radius</Typography.Text>
<InputNumber
value={node.props.borderRadius ?? 0}
min={0}
onChange={(v) => props.onUpdateIframeProps(node.id, { borderRadius: typeof v === 'number' ? v : 0 })}
style={{ width: '100%' }}
/>
</div>
);
}
if (node.type === 'video') {
return (
<div>
<Typography.Title level={5} style={{ marginTop: 0 }}>
Video
</Typography.Title>
<Typography.Text type="secondary">Source</Typography.Text>
<Input
value={node.props.src}
onChange={(e) => props.onUpdateVideoProps(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.onUpdateVideoProps(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' },
]}
/>
<Space style={{ width: '100%', marginBottom: 12 }} size={12}>
<div style={{ flex: 1 }}>
<Typography.Text type="secondary">Loop</Typography.Text>
<Select
value={String(node.props.loop ?? false)}
onChange={(v) => props.onUpdateVideoProps(node.id, { loop: v === 'true' })}
style={{ width: '100%' }}
options={[
{ value: 'true', label: 'true' },
{ value: 'false', label: 'false' },
]}
/>
</div>
<div style={{ flex: 1 }}>
<Typography.Text type="secondary">Muted</Typography.Text>
<Select
value={String(node.props.muted ?? false)}
onChange={(v) => props.onUpdateVideoProps(node.id, { muted: v === 'true' })}
style={{ width: '100%' }}
options={[
{ value: 'true', label: 'true' },
{ value: 'false', label: 'false' },
]}
/>
</div>
</Space>
<Space style={{ width: '100%', marginBottom: 12 }} size={12}>
<div style={{ flex: 1 }}>
<Typography.Text type="secondary">Autoplay</Typography.Text>
<Select
value={String(node.props.autoplay ?? true)}
onChange={(v) => props.onUpdateVideoProps(node.id, { autoplay: v === 'true' })}
style={{ width: '100%' }}
options={[
{ value: 'true', label: 'true' },
{ value: 'false', label: 'false' },
]}
/>
</div>
</Space>
<Typography.Text type="secondary">Border radius</Typography.Text>
<InputNumber
value={node.props.borderRadius ?? 0}
min={0}
onChange={(v) => props.onUpdateVideoProps(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/iframe/video.
if (node.type !== 'text') {
return <Typography.Paragraph style={{ color: '#666' }}>Unsupported widget type.</Typography.Paragraph>;
}
const fontWeight = node.props.fontWeight ?? 400;
return (
<div>
<Typography.Title level={5} style={{ marginTop: 0 }}>
Text
</Typography.Title>
<Typography.Text type="secondary">Content</Typography.Text>
<Input
value={node.props.text}
onChange={(e) => props.onUpdateTextProps(node.id, { text: e.target.value })}
placeholder="Text"
style={{ marginBottom: 12 }}
/>
<Space style={{ width: '100%' }} size={12}>
<div style={{ flex: 1 }}>
<Typography.Text type="secondary">Font size</Typography.Text>
<div style={{ marginBottom: 12 }}>
<InputNumber
value={node.props.fontSize ?? 24}
min={1}
onChange={(v) => props.onUpdateTextProps(node.id, { fontSize: typeof v === 'number' ? v : 24 })}
style={{ width: '100%' }}
/>
</div>
</div>
<div style={{ flex: 1 }}>
<Typography.Text type="secondary">Weight</Typography.Text>
<div style={{ marginBottom: 12 }}>
<Select
value={String(fontWeight)}
onChange={(v) => {
const asNum = Number(v);
props.onUpdateTextProps(node.id, { fontWeight: Number.isFinite(asNum) ? asNum : v });
}}
style={{ width: '100%' }}
options={[
{ value: '300', label: 'Light (300)' },
{ value: '400', label: 'Regular (400)' },
{ value: '500', label: 'Medium (500)' },
{ value: '600', label: 'Semibold (600)' },
{ value: '700', label: 'Bold (700)' },
{ value: '800', label: 'Extrabold (800)' },
]}
/>
</div>
</div>
</Space>
<Typography.Text type="secondary">Color</Typography.Text>
<Input
value={node.props.color ?? '#ffffff'}
onChange={(e) => props.onUpdateTextProps(node.id, { color: e.target.value })}
placeholder="#ffffff"
style={{ marginBottom: 12 }}
/>
<Space style={{ width: '100%' }} size={12}>
<div style={{ flex: 1 }}>
<Typography.Text type="secondary">Padding X</Typography.Text>
<div style={{ marginBottom: 12 }}>
<InputNumber
value={node.props.paddingX ?? 0}
min={0}
onChange={(v) => props.onUpdateTextProps(node.id, { paddingX: typeof v === 'number' ? v : 0 })}
style={{ width: '100%' }}
/>
</div>
</div>
<div style={{ flex: 1 }}>
<Typography.Text type="secondary">Padding Y</Typography.Text>
<div style={{ marginBottom: 12 }}>
<InputNumber
value={node.props.paddingY ?? 0}
min={0}
onChange={(v) => props.onUpdateTextProps(node.id, { paddingY: typeof v === 'number' ? v : 0 })}
style={{ width: '100%' }}
/>
</div>
</div>
</Space>
<Space style={{ width: '100%' }} size={12}>
<div style={{ flex: 1 }}>
<Typography.Text type="secondary">Letter spacing</Typography.Text>
<div style={{ marginBottom: 12 }}>
<InputNumber
value={node.props.letterSpacing ?? 0}
min={0}
onChange={(v) => props.onUpdateTextProps(node.id, { letterSpacing: typeof v === 'number' ? v : 0 })}
style={{ width: '100%' }}
/>
</div>
</div>
<div style={{ flex: 1 }}>
<Typography.Text type="secondary">Align</Typography.Text>
<div style={{ marginBottom: 12 }}>
<Select
value={node.props.textAlign ?? 'center'}
onChange={(v) => props.onUpdateTextProps(node.id, { textAlign: v })}
style={{ width: '100%' }}
options={[
{ value: 'left', label: 'Left' },
{ value: 'center', label: 'Center' },
{ value: 'right', label: 'Right' },
]}
/>
</div>
</div>
</Space>
<Typography.Text type="secondary">Background</Typography.Text>
<Input
value={node.props.backgroundColor ?? 'transparent'}
onChange={(e) => props.onUpdateTextProps(node.id, { backgroundColor: e.target.value })}
placeholder="transparent or #00000000"
style={{ marginBottom: 12 }}
/>
<Typography.Text type="secondary">Writing mode</Typography.Text>
<Select
value={node.props.writingMode ?? 'horizontal-tb'}
onChange={(v) => props.onUpdateTextProps(node.id, { writingMode: v })}
style={{ width: '100%', marginBottom: 12 }}
options={[
{ value: 'horizontal-tb', label: 'Horizontal' },
{ value: 'vertical-rl', label: 'Vertical' },
]}
/>
<Typography.Text type="secondary">Link</Typography.Text>
<Space style={{ width: '100%', marginBottom: 12 }} size={8}>
<Input
value={node.props.linkHead ?? 'http://'}
onChange={(e) => props.onUpdateTextProps(node.id, { linkHead: e.target.value })}
style={{ width: 120 }}
/>
<Input
value={node.props.link ?? ''}
onChange={(e) => props.onUpdateTextProps(node.id, { link: e.target.value })}
placeholder="example.com"
/>
</Space>
<Typography.Text type="secondary">Border</Typography.Text>
<Space style={{ width: '100%' }} size={12}>
<div style={{ flex: 1 }}>
<InputNumber
value={node.props.borderWidth ?? 0}
min={0}
onChange={(v) => props.onUpdateTextProps(node.id, { borderWidth: typeof v === 'number' ? v : 0 })}
style={{ width: '100%' }}
addonBefore="W"
/>
</div>
<div style={{ flex: 2 }}>
<Input <Input
value={node.props.borderColor ?? '#ffffff'} value={node.props.src}
onChange={(e) => props.onUpdateTextProps(node.id, { borderColor: e.target.value })} onChange={(e) => props.onUpdateImageProps(node.id, { src: e.target.value })}
placeholder="#ffffff" placeholder="https://..."
addonBefore="C" 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> </div>
</Space> );
<div style={{ marginTop: 8, marginBottom: 12 }}>
<InputNumber
value={node.props.borderRadius ?? 0}
min={0}
onChange={(v) => props.onUpdateTextProps(node.id, { borderRadius: typeof v === 'number' ? v : 0 })}
style={{ width: '100%' }}
addonBefore="R"
/>
</div>
<Typography.Text type="secondary">Quick colors</Typography.Text> case 'iframe':
<div style={{ marginTop: 6, display: 'flex', gap: 8, flexWrap: 'wrap' }}> return (
{['#ffffff', '#d1d5db', '#93c5fd', '#a7f3d0', '#fca5a5', '#fbbf24'].map((c) => ( <div>
<button <Typography.Title level={5} style={{ marginTop: 0 }}>
key={c} Iframe
type="button" </Typography.Title>
onClick={() => props.onUpdateTextProps(node.id, { color: c })}
style={{ <Typography.Text type="secondary">Source</Typography.Text>
width: 22, <Input
height: 22, value={node.props.src}
borderRadius: 6, onChange={(e) => props.onUpdateIframeProps(node.id, { src: e.target.value })}
background: c, placeholder="https://..."
border: '1px solid rgba(0,0,0,0.2)', style={{ marginBottom: 12 }}
cursor: 'pointer',
}}
title={c}
/> />
))}
</div> <Typography.Text type="secondary">Border radius</Typography.Text>
</div> <InputNumber
); value={node.props.borderRadius ?? 0}
min={0}
onChange={(v) => props.onUpdateIframeProps(node.id, { borderRadius: typeof v === 'number' ? v : 0 })}
style={{ width: '100%' }}
/>
</div>
);
case 'video':
return (
<div>
<Typography.Title level={5} style={{ marginTop: 0 }}>
Video
</Typography.Title>
<Typography.Text type="secondary">Source</Typography.Text>
<Input
value={node.props.src}
onChange={(e) => props.onUpdateVideoProps(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.onUpdateVideoProps(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' },
]}
/>
<Space style={{ width: '100%', marginBottom: 12 }} size={12}>
<div style={{ flex: 1 }}>
<Typography.Text type="secondary">Loop</Typography.Text>
<Select
value={String(node.props.loop ?? false)}
onChange={(v) => props.onUpdateVideoProps(node.id, { loop: v === 'true' })}
style={{ width: '100%' }}
options={[
{ value: 'true', label: 'true' },
{ value: 'false', label: 'false' },
]}
/>
</div>
<div style={{ flex: 1 }}>
<Typography.Text type="secondary">Muted</Typography.Text>
<Select
value={String(node.props.muted ?? false)}
onChange={(v) => props.onUpdateVideoProps(node.id, { muted: v === 'true' })}
style={{ width: '100%' }}
options={[
{ value: 'true', label: 'true' },
{ value: 'false', label: 'false' },
]}
/>
</div>
</Space>
<Space style={{ width: '100%', marginBottom: 12 }} size={12}>
<div style={{ flex: 1 }}>
<Typography.Text type="secondary">Autoplay</Typography.Text>
<Select
value={String(node.props.autoplay ?? true)}
onChange={(v) => props.onUpdateVideoProps(node.id, { autoplay: v === 'true' })}
style={{ width: '100%' }}
options={[
{ value: 'true', label: 'true' },
{ value: 'false', label: 'false' },
]}
/>
</div>
</Space>
<Typography.Text type="secondary">Border radius</Typography.Text>
<InputNumber
value={node.props.borderRadius ?? 0}
min={0}
onChange={(v) => props.onUpdateVideoProps(node.id, { borderRadius: typeof v === 'number' ? v : 0 })}
style={{ width: '100%' }}
/>
</div>
);
case 'text': {
const fontWeight = node.props.fontWeight ?? 400;
return (
<div>
<Typography.Title level={5} style={{ marginTop: 0 }}>
Text
</Typography.Title>
<Typography.Text type="secondary">Content</Typography.Text>
<Input
value={node.props.text}
onChange={(e) => props.onUpdateTextProps(node.id, { text: e.target.value })}
placeholder="Text"
style={{ marginBottom: 12 }}
/>
<Space style={{ width: '100%' }} size={12}>
<div style={{ flex: 1 }}>
<Typography.Text type="secondary">Font size</Typography.Text>
<div style={{ marginBottom: 12 }}>
<InputNumber
value={node.props.fontSize ?? 24}
min={1}
onChange={(v) => props.onUpdateTextProps(node.id, { fontSize: typeof v === 'number' ? v : 24 })}
style={{ width: '100%' }}
/>
</div>
</div>
<div style={{ flex: 1 }}>
<Typography.Text type="secondary">Weight</Typography.Text>
<div style={{ marginBottom: 12 }}>
<Select
value={String(fontWeight)}
onChange={(v) => {
const asNum = Number(v);
props.onUpdateTextProps(node.id, { fontWeight: Number.isFinite(asNum) ? asNum : v });
}}
style={{ width: '100%' }}
options={[
{ value: '300', label: 'Light (300)' },
{ value: '400', label: 'Regular (400)' },
{ value: '500', label: 'Medium (500)' },
{ value: '600', label: 'Semibold (600)' },
{ value: '700', label: 'Bold (700)' },
{ value: '800', label: 'Extrabold (800)' },
]}
/>
</div>
</div>
</Space>
<Typography.Text type="secondary">Color</Typography.Text>
<Input
value={node.props.color ?? '#ffffff'}
onChange={(e) => props.onUpdateTextProps(node.id, { color: e.target.value })}
placeholder="#ffffff"
style={{ marginBottom: 12 }}
/>
<Space style={{ width: '100%' }} size={12}>
<div style={{ flex: 1 }}>
<Typography.Text type="secondary">Padding X</Typography.Text>
<div style={{ marginBottom: 12 }}>
<InputNumber
value={node.props.paddingX ?? 0}
min={0}
onChange={(v) => props.onUpdateTextProps(node.id, { paddingX: typeof v === 'number' ? v : 0 })}
style={{ width: '100%' }}
/>
</div>
</div>
<div style={{ flex: 1 }}>
<Typography.Text type="secondary">Padding Y</Typography.Text>
<div style={{ marginBottom: 12 }}>
<InputNumber
value={node.props.paddingY ?? 0}
min={0}
onChange={(v) => props.onUpdateTextProps(node.id, { paddingY: typeof v === 'number' ? v : 0 })}
style={{ width: '100%' }}
/>
</div>
</div>
</Space>
<Space style={{ width: '100%' }} size={12}>
<div style={{ flex: 1 }}>
<Typography.Text type="secondary">Letter spacing</Typography.Text>
<div style={{ marginBottom: 12 }}>
<InputNumber
value={node.props.letterSpacing ?? 0}
min={0}
onChange={(v) => props.onUpdateTextProps(node.id, { letterSpacing: typeof v === 'number' ? v : 0 })}
style={{ width: '100%' }}
/>
</div>
</div>
<div style={{ flex: 1 }}>
<Typography.Text type="secondary">Align</Typography.Text>
<div style={{ marginBottom: 12 }}>
<Select
value={node.props.textAlign ?? 'center'}
onChange={(v) => props.onUpdateTextProps(node.id, { textAlign: v })}
style={{ width: '100%' }}
options={[
{ value: 'left', label: 'Left' },
{ value: 'center', label: 'Center' },
{ value: 'right', label: 'Right' },
]}
/>
</div>
</div>
</Space>
<Typography.Text type="secondary">Background</Typography.Text>
<Input
value={node.props.backgroundColor ?? 'transparent'}
onChange={(e) => props.onUpdateTextProps(node.id, { backgroundColor: e.target.value })}
placeholder="transparent or #00000000"
style={{ marginBottom: 12 }}
/>
<Typography.Text type="secondary">Writing mode</Typography.Text>
<Select
value={node.props.writingMode ?? 'horizontal-tb'}
onChange={(v) => props.onUpdateTextProps(node.id, { writingMode: v })}
style={{ width: '100%', marginBottom: 12 }}
options={[
{ value: 'horizontal-tb', label: 'Horizontal' },
{ value: 'vertical-rl', label: 'Vertical' },
]}
/>
<Typography.Text type="secondary">Link</Typography.Text>
<Space style={{ width: '100%', marginBottom: 12 }} size={8}>
<Input
value={node.props.linkHead ?? 'http://'}
onChange={(e) => props.onUpdateTextProps(node.id, { linkHead: e.target.value })}
style={{ width: 120 }}
/>
<Input
value={node.props.link ?? ''}
onChange={(e) => props.onUpdateTextProps(node.id, { link: e.target.value })}
placeholder="example.com"
/>
</Space>
<Typography.Text type="secondary">Border</Typography.Text>
<Space style={{ width: '100%' }} size={12}>
<div style={{ flex: 1 }}>
<InputNumber
value={node.props.borderWidth ?? 0}
min={0}
onChange={(v) => props.onUpdateTextProps(node.id, { borderWidth: typeof v === 'number' ? v : 0 })}
style={{ width: '100%' }}
addonBefore="W"
/>
</div>
<div style={{ flex: 2 }}>
<Input
value={node.props.borderColor ?? '#ffffff'}
onChange={(e) => props.onUpdateTextProps(node.id, { borderColor: e.target.value })}
placeholder="#ffffff"
addonBefore="C"
/>
</div>
</Space>
<div style={{ marginTop: 8, marginBottom: 12 }}>
<InputNumber
value={node.props.borderRadius ?? 0}
min={0}
onChange={(v) => props.onUpdateTextProps(node.id, { borderRadius: typeof v === 'number' ? v : 0 })}
style={{ width: '100%' }}
addonBefore="R"
/>
</div>
<Typography.Text type="secondary">Quick colors</Typography.Text>
<div style={{ marginTop: 6, display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{['#ffffff', '#d1d5db', '#93c5fd', '#a7f3d0', '#fca5a5', '#fbbf24'].map((c) => (
<button
key={c}
type="button"
onClick={() => props.onUpdateTextProps(node.id, { color: c })}
style={{
width: 22,
height: 22,
borderRadius: 6,
background: c,
border: '1px solid rgba(0,0,0,0.2)',
cursor: 'pointer',
}}
title={c}
/>
))}
</div>
</div>
);
}
default:
return assertNever(node);
}
} }

View File

@ -4,12 +4,12 @@ import {
createEmptyScreen, createEmptyScreen,
migrateScreen, migrateScreen,
type Rect, type Rect,
type ImageWidgetNode,
type IframeWidgetNode,
type VideoWidgetNode,
type Screen, type Screen,
type TextWidgetNode, type TextWidgetNode,
type WidgetNode, type WidgetNode,
type WidgetKind,
type WidgetNodeByType,
type WidgetPropsByType,
} from '@astralview/sdk'; } from '@astralview/sdk';
import { rectContains, rectFromPoints } from './geometry'; import { rectContains, rectFromPoints } from './geometry';
import { didRectsChange } from './history'; import { didRectsChange } from './history';
@ -53,10 +53,13 @@ export type EditorAction =
| { type: 'toggleHideSelected' } | { type: 'toggleHideSelected' }
| { type: 'bringToFrontSelected' } | { type: 'bringToFrontSelected' }
| { type: 'sendToBackSelected' } | { type: 'sendToBackSelected' }
| { type: 'updateTextProps'; id: string; props: Partial<TextWidgetNode['props']> } | UpdateWidgetPropsAction;
| { type: 'updateImageProps'; id: string; props: Partial<ImageWidgetNode['props']> }
| { type: 'updateIframeProps'; id: string; props: Partial<IframeWidgetNode['props']> } type UpdateWidgetPropsAction =
| { type: 'updateVideoProps'; id: string; props: Partial<VideoWidgetNode['props']> }; | { type: 'updateWidgetProps'; widgetType: 'text'; id: string; props: Partial<WidgetPropsByType['text']> }
| { type: 'updateWidgetProps'; widgetType: 'image'; id: string; props: Partial<WidgetPropsByType['image']> }
| { type: 'updateWidgetProps'; widgetType: 'iframe'; id: string; props: Partial<WidgetPropsByType['iframe']> }
| { type: 'updateWidgetProps'; widgetType: 'video'; id: string; props: Partial<WidgetPropsByType['video']> };
interface DragSession { interface DragSession {
kind: 'move' | 'resize'; kind: 'move' | 'resize';
@ -86,6 +89,39 @@ type EditorRuntimeState = EditorState & {
__drag?: DragSession; __drag?: DragSession;
}; };
function isWidgetType<K extends WidgetKind>(
node: WidgetNode,
widgetType: K,
): node is WidgetNodeByType[K] {
return node.type === widgetType;
}
function updateWidgetProps<K extends WidgetKind>(
state: EditorRuntimeState,
action: Extract<UpdateWidgetPropsAction, { widgetType: K }>,
): EditorRuntimeState {
const target = state.doc.screen.nodes.find(
(n): n is WidgetNodeByType[K] => n.id === action.id && n.type === action.widgetType,
);
if (!target) return state;
return {
...historyPush(state),
doc: {
screen: {
...state.doc.screen,
nodes: state.doc.screen.nodes.map((n) => {
if (n.id !== action.id || !isWidgetType(n, action.widgetType)) return n;
return {
...n,
props: { ...n.props, ...action.props },
};
}),
},
},
};
}
function historyPush(state: EditorRuntimeState): EditorRuntimeState { function historyPush(state: EditorRuntimeState): EditorRuntimeState {
return { return {
...state, ...state,
@ -217,72 +253,19 @@ export function editorReducer(state: EditorRuntimeState, action: EditorAction):
}; };
} }
case 'updateTextProps': { case 'updateWidgetProps': {
const node = state.doc.screen.nodes.find((n) => n.id === action.id); switch (action.widgetType) {
if (!node || node.type !== 'text') return state; case 'text':
return { return updateWidgetProps(state, action);
...historyPush(state), case 'image':
doc: { return updateWidgetProps(state, action);
screen: { case 'iframe':
...state.doc.screen, return updateWidgetProps(state, action);
nodes: state.doc.screen.nodes.map((n) => { case 'video':
if (n.id !== action.id || n.type !== 'text') return n; return updateWidgetProps(state, action);
return { ...n, props: { ...n.props, ...action.props } }; default:
}), return assertNever(action.widgetType);
}, }
},
};
}
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 'updateIframeProps': {
const node = state.doc.screen.nodes.find((n) => n.id === action.id);
if (!node || node.type !== 'iframe') return state;
return {
...historyPush(state),
doc: {
screen: {
...state.doc.screen,
nodes: state.doc.screen.nodes.map((n) => {
if (n.id !== action.id || n.type !== 'iframe') return n;
return { ...n, props: { ...n.props, ...action.props } };
}),
},
},
};
}
case 'updateVideoProps': {
const node = state.doc.screen.nodes.find((n) => n.id === action.id);
if (!node || node.type !== 'video') return state;
return {
...historyPush(state),
doc: {
screen: {
...state.doc.screen,
nodes: state.doc.screen.nodes.map((n) => {
if (n.id !== action.id || n.type !== 'video') return n;
return { ...n, props: { ...n.props, ...action.props } };
}),
},
},
};
} }
case 'deleteSelected': { case 'deleteSelected': {
@ -823,7 +806,7 @@ export function editorReducer(state: EditorRuntimeState, action: EditorAction):
} }
case 'importJSON': { case 'importJSON': {
const parsed = JSON.parse(action.json) as unknown; const parsed: unknown = JSON.parse(action.json);
try { try {
const screen = migrateScreen(parsed); const screen = migrateScreen(parsed);
return { return {

View File

@ -90,6 +90,19 @@ export interface VideoWidgetNode extends WidgetNodeBase {
export type WidgetNode = TextWidgetNode | ImageWidgetNode | IframeWidgetNode | VideoWidgetNode; export type WidgetNode = TextWidgetNode | ImageWidgetNode | IframeWidgetNode | VideoWidgetNode;
export type WidgetKind = WidgetNode['type'];
export type WidgetNodeByType = {
text: TextWidgetNode;
image: ImageWidgetNode;
iframe: IframeWidgetNode;
video: VideoWidgetNode;
};
export type WidgetPropsByType = {
[K in WidgetKind]: WidgetNodeByType[K]['props'];
};
export interface Screen { export interface Screen {
version: SchemaVersion; version: SchemaVersion;
id: string; id: string;

View File

@ -19,6 +19,15 @@ export interface GoViewIframeOption {
webUrl?: unknown; webUrl?: unknown;
webpageUrl?: unknown; webpageUrl?: unknown;
// HTML/embed variants
html?: unknown;
htmlString?: unknown;
embedCode?: unknown;
template?: unknown;
content?: unknown;
srcdoc?: unknown;
srcDoc?: unknown;
// list-ish shapes (some low-code editors model embeds as a list even for a single item) // list-ish shapes (some low-code editors model embeds as a list even for a single item)
sources?: unknown; sources?: unknown;
sourceList?: unknown; sourceList?: unknown;
@ -54,6 +63,12 @@ function looksLikeHtml(input: string): boolean {
return trimmed.startsWith('<') && trimmed.includes('>'); return trimmed.startsWith('<') && trimmed.includes('>');
} }
function extractHtmlAttribute(html: string, name: string): string | undefined {
const re = new RegExp(`\\b${name}\\s*=\\s*(?:"([^"]+)"|'([^']+)'|([^\\s>]+))`, 'i');
const match = re.exec(html);
return match?.[1] ?? match?.[2] ?? match?.[3];
}
function extractSrcFromEmbedHtml(html: string): string { function extractSrcFromEmbedHtml(html: string): string {
// Many low-code editors store iframe widgets as an embed code string. // Many low-code editors store iframe widgets as an embed code string.
// Prefer extracting the actual src to keep the resulting screen portable. // Prefer extracting the actual src to keep the resulting screen portable.
@ -69,6 +84,21 @@ function extractSrcFromEmbedHtml(html: string): string {
return typeof src === 'string' ? src.trim() : ''; return typeof src === 'string' ? src.trim() : '';
} }
function extractIframeAttrsFromHtml(html: string): {
src?: string;
allow?: string;
sandbox?: string;
title?: string;
} {
if (!looksLikeHtml(html)) return {};
return {
src: extractSrcFromEmbedHtml(html) || undefined,
allow: extractHtmlAttribute(html, 'allow'),
sandbox: extractHtmlAttribute(html, 'sandbox'),
title: extractHtmlAttribute(html, 'title') ?? extractHtmlAttribute(html, 'name'),
};
}
function toMaybeNumber(v: unknown): number | undefined { function toMaybeNumber(v: unknown): number | undefined {
if (typeof v === 'number' && Number.isFinite(v)) return v; if (typeof v === 'number' && Number.isFinite(v)) return v;
if (typeof v === 'string') { if (typeof v === 'string') {
@ -134,6 +164,31 @@ function pickFirstUrlFromList(input: unknown): string {
return pickUrlLike(input, 2); return pickUrlLike(input, 2);
} }
function pickHtmlString(option: GoViewIframeOption): string | undefined {
if (typeof option === 'string' && looksLikeHtml(option)) return option;
return pickFromNested(
option,
(obj) => {
const v =
obj.srcdoc ??
obj.srcDoc ??
obj.html ??
obj.htmlContent ??
obj.htmlString ??
obj.embedHtml ??
obj.iframeHtml ??
obj.embedCode ??
obj.iframeCode ??
obj.code ??
obj.content ??
obj.template;
return typeof v === 'string' ? v : undefined;
},
3,
);
}
function pickSrc(option: GoViewIframeOption): string { function pickSrc(option: GoViewIframeOption): string {
// 1) Prefer explicit iframe-ish URL fields. // 1) Prefer explicit iframe-ish URL fields.
const url = const url =
@ -160,26 +215,7 @@ function pickSrc(option: GoViewIframeOption): string {
} }
// 2) Some exports store raw HTML instead of a URL. // 2) Some exports store raw HTML instead of a URL.
const html = pickFromNested( const html = pickHtmlString(option);
option,
(obj) => {
const v =
obj.srcdoc ??
obj.srcDoc ??
obj.html ??
obj.htmlContent ??
obj.htmlString ??
obj.embedHtml ??
obj.iframeHtml ??
obj.embedCode ??
obj.iframeCode ??
obj.code ??
obj.content ??
obj.template;
return typeof v === 'string' ? v : undefined;
},
3,
);
if (html) { if (html) {
const extracted = extractSrcFromEmbedHtml(html); const extracted = extractSrcFromEmbedHtml(html);
return extracted || toDataHtmlUrl(html); return extracted || toDataHtmlUrl(html);
@ -251,11 +287,14 @@ function pickBorderRadius(option: GoViewIframeOption): number | undefined {
} }
export function convertGoViewIframeOptionToNodeProps(option: GoViewIframeOption): IframeWidgetNode['props'] { export function convertGoViewIframeOptionToNodeProps(option: GoViewIframeOption): IframeWidgetNode['props'] {
const html = pickHtmlString(option);
const htmlAttrs = html ? extractIframeAttrsFromHtml(html) : undefined;
return { return {
src: pickSrc(option), src: pickSrc(option),
allow: pickStringLike(option, ['allow', 'allowList', 'permissions', 'permission']), allow: pickStringLike(option, ['allow', 'allowList', 'permissions', 'permission']) ?? htmlAttrs?.allow,
sandbox: pickStringLike(option, ['sandbox', 'sandboxList']), sandbox: pickStringLike(option, ['sandbox', 'sandboxList']) ?? htmlAttrs?.sandbox,
title: pickStringLike(option, ['title', 'name', 'label']), title: pickStringLike(option, ['title', 'name', 'label']) ?? htmlAttrs?.title,
fit: pickFit(option), fit: pickFit(option),
aspectRatio: pickAspectRatio(option), aspectRatio: pickAspectRatio(option),
borderRadius: pickBorderRadius(option), borderRadius: pickBorderRadius(option),

View File

@ -38,6 +38,15 @@ export interface GoViewVideoOption {
thumbnail?: unknown; thumbnail?: unknown;
thumbnailUrl?: unknown; thumbnailUrl?: unknown;
// HTML/embed variants
html?: unknown;
htmlString?: unknown;
embedCode?: unknown;
template?: unknown;
content?: unknown;
srcdoc?: unknown;
srcDoc?: unknown;
fit?: unknown; fit?: unknown;
objectFit?: unknown; objectFit?: unknown;
@ -63,6 +72,24 @@ function looksLikeHtml(input: string): boolean {
return trimmed.startsWith('<') && trimmed.includes('>'); return trimmed.startsWith('<') && trimmed.includes('>');
} }
function extractHtmlAttribute(html: string, name: string): string | undefined {
const re = new RegExp(`\\b${name}\\s*=\\s*(?:"([^"]+)"|'([^']+)'|([^\\s>]+))`, 'i');
const match = re.exec(html);
return match?.[1] ?? match?.[2] ?? match?.[3];
}
function extractHtmlBooleanAttribute(html: string, name: string): boolean | undefined {
const re = new RegExp(`\\b${name}\\b(\\s*=\\s*(?:"([^"]+)"|'([^']+)'|([^\\s>]+)))?`, 'i');
const match = re.exec(html);
if (!match) return undefined;
const value = match[2] ?? match[3] ?? match[4];
if (!value) return true;
const normalized = value.trim().toLowerCase();
if (normalized === 'true' || normalized === '1') return true;
if (normalized === 'false' || normalized === '0') return false;
return true;
}
function extractSrcFromVideoHtml(html: string): string { function extractSrcFromVideoHtml(html: string): string {
// Some low-code exports store a whole <video> or <source> tag string. // Some low-code exports store a whole <video> or <source> tag string.
// Examples: // Examples:
@ -81,6 +108,25 @@ function extractSrcFromVideoHtml(html: string): string {
return typeof videoSrc === 'string' ? videoSrc.trim() : ''; return typeof videoSrc === 'string' ? videoSrc.trim() : '';
} }
function extractVideoAttrsFromHtml(html: string): {
src?: string;
autoplay?: boolean;
controls?: boolean;
muted?: boolean;
loop?: boolean;
poster?: string;
} {
if (!looksLikeHtml(html)) return {};
return {
src: extractSrcFromVideoHtml(html) || undefined,
autoplay: extractHtmlBooleanAttribute(html, 'autoplay'),
controls: extractHtmlBooleanAttribute(html, 'controls'),
muted: extractHtmlBooleanAttribute(html, 'muted'),
loop: extractHtmlBooleanAttribute(html, 'loop'),
poster: extractHtmlAttribute(html, 'poster'),
};
}
function toMaybeBoolean(v: unknown): boolean | undefined { function toMaybeBoolean(v: unknown): boolean | undefined {
if (typeof v === 'boolean') return v; if (typeof v === 'boolean') return v;
if (typeof v === 'number') return v !== 0; if (typeof v === 'number') return v !== 0;
@ -363,13 +409,34 @@ function pickAspectRatio(option: GoViewVideoOption): number | undefined {
} }
export function convertGoViewVideoOptionToNodeProps(option: GoViewVideoOption): VideoWidgetNode['props'] { export function convertGoViewVideoOptionToNodeProps(option: GoViewVideoOption): VideoWidgetNode['props'] {
const html =
typeof option === 'string' && looksLikeHtml(option)
? option
: pickFromNested(
option,
(obj) => {
const v =
obj.html ??
obj.htmlString ??
obj.code ??
obj.embedCode ??
obj.template ??
obj.content ??
obj.srcdoc ??
obj.srcDoc;
return typeof v === 'string' ? v : undefined;
},
3,
);
const htmlAttrs = html ? extractVideoAttrsFromHtml(html) : undefined;
return { return {
src: pickSrc(option), src: pickSrc(option),
autoplay: pickAutoplay(option), autoplay: pickAutoplay(option) ?? htmlAttrs?.autoplay,
controls: pickControls(option), controls: pickControls(option) ?? htmlAttrs?.controls,
loop: pickBooleanLike(option, ['loop', 'isLoop']), loop: pickBooleanLike(option, ['loop', 'isLoop']) ?? htmlAttrs?.loop,
muted: pickBooleanLike(option, ['muted', 'isMuted', 'mute']), muted: pickBooleanLike(option, ['muted', 'isMuted', 'mute']) ?? htmlAttrs?.muted,
poster: pickPoster(option), poster: pickPoster(option) ?? htmlAttrs?.poster,
fit: pickFit(option), fit: pickFit(option),
aspectRatio: pickAspectRatio(option), aspectRatio: pickAspectRatio(option),
borderRadius: pickBorderRadius(option), borderRadius: pickBorderRadius(option),

View File

@ -13,6 +13,9 @@ export type {
Transform, Transform,
Screen, Screen,
WidgetNode, WidgetNode,
WidgetKind,
WidgetNodeByType,
WidgetPropsByType,
TextWidgetNode, TextWidgetNode,
ImageWidgetNode, ImageWidgetNode,
IframeWidgetNode, IframeWidgetNode,