feat: improve goview media import and editor selection

This commit is contained in:
clawdbot 2026-01-28 02:44:19 +08:00
parent d21806c836
commit 52f5cce272
9 changed files with 376 additions and 265 deletions

View File

@ -1,9 +1,10 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { Screen, WidgetNode } from '@astralview/sdk';
import { assertNever } from '@astralview/sdk';
import { Button, Space, Typography } from 'antd';
import { Button, Space } from 'antd';
import type { ResizeHandle } from './types';
import { rectFromPoints } from './geometry';
import type { ContextMenuState } from './ContextMenu';
export interface CanvasProps {
screen: Screen;
@ -30,12 +31,8 @@ export interface CanvasProps {
onAddTextAt(x: number, y: number): void;
onWheelPan(dx: number, dy: number): void;
onZoomAt(scale: number, anchorX: number, anchorY: number): void;
onDeleteSelected?(): void;
onDuplicateSelected?(): void;
onToggleLockSelected?(): void;
onToggleHideSelected?(): void;
onBringToFrontSelected?(): void;
onSendToBackSelected?(): void;
onOpenContextMenu(state: ContextMenuState): void;
onCloseContextMenu(): void;
}
function isPrimaryButton(e: React.PointerEvent | PointerEvent): boolean {
@ -43,53 +40,12 @@ function isPrimaryButton(e: React.PointerEvent | PointerEvent): boolean {
return (e as PointerEvent).buttons === 1 || (e as PointerEvent).button === 0;
}
type ContextMenuState = {
clientX: number;
clientY: number;
worldX: number;
worldY: number;
targetId?: string;
};
export function Canvas(props: CanvasProps) {
const ref = useRef<HTMLDivElement | null>(null);
const [box, setBox] = useState<{ x1: number; y1: number; x2: number; y2: number } | null>(null);
const [ctx, setCtx] = useState<ContextMenuState | null>(null);
const ctxMenuRef = useRef<HTMLDivElement | null>(null);
const [ctxMenuSize, setCtxMenuSize] = useState<{ w: number; h: number }>({ w: 220, h: 320 });
const bounds = useMemo(() => ({ w: props.screen.width, h: props.screen.height }), [props.screen.width, props.screen.height]);
const selection = useMemo(() => {
const ids = new Set(props.selectionIds);
return props.screen.nodes.filter((n) => ids.has(n.id));
}, [props.screen.nodes, props.selectionIds]);
const selectionAllLocked = selection.length > 0 && selection.every((n) => n.locked);
const selectionAllHidden = selection.length > 0 && selection.every((n) => n.hidden);
useLayoutEffect(() => {
if (!ctx) return;
const el = ctxMenuRef.current;
if (!el) return;
// Measure after render so clamping matches the real menu size.
const rect = el.getBoundingClientRect();
if (rect.width && rect.height) setCtxMenuSize({ w: rect.width, h: rect.height });
}, [ctx]);
const ctxMenuPos = useMemo(() => {
if (!ctx) return null;
const vw = typeof window === 'undefined' ? 10_000 : window.innerWidth;
const vh = typeof window === 'undefined' ? 10_000 : window.innerHeight;
return {
x: Math.max(8, Math.min(ctx.clientX, vw - ctxMenuSize.w - 8)),
y: Math.max(8, Math.min(ctx.clientY, vh - ctxMenuSize.h - 8)),
};
}, [ctx, ctxMenuSize.h, ctxMenuSize.w]);
const clientToCanvas = useCallback((clientX: number, clientY: number) => {
const el = ref.current;
if (!el) return null;
@ -175,36 +131,6 @@ export function Canvas(props: CanvasProps) {
onUpdateResize,
]);
useEffect(() => {
if (!ctx) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') setCtx(null);
};
const close = () => setCtx(null);
// Close on any outside interaction to keep UI parity with common editors.
const onAnyPointerDown = () => close();
const onAnyWheel = () => close();
const onScroll = () => close();
const onBlur = () => close();
window.addEventListener('keydown', onKeyDown);
window.addEventListener('pointerdown', onAnyPointerDown);
window.addEventListener('wheel', onAnyWheel, { passive: true });
window.addEventListener('scroll', onScroll, true);
window.addEventListener('blur', onBlur);
return () => {
window.removeEventListener('keydown', onKeyDown);
window.removeEventListener('pointerdown', onAnyPointerDown);
window.removeEventListener('wheel', onAnyWheel);
window.removeEventListener('scroll', onScroll, true);
window.removeEventListener('blur', onBlur);
};
}, [ctx]);
const openContextMenu = (e: React.MouseEvent | React.PointerEvent, targetId?: string) => {
e.preventDefault();
e.stopPropagation();
@ -230,12 +156,12 @@ export function Canvas(props: CanvasProps) {
if (!additive) props.onSelectSingle(undefined);
}
setCtx({ clientX: e.clientX, clientY: e.clientY, worldX: p.x, worldY: p.y, targetId });
props.onOpenContextMenu({ clientX: e.clientX, clientY: e.clientY, worldX: p.x, worldY: p.y, targetId });
};
const onBackgroundPointerDown = (e: React.PointerEvent) => {
// close any open context menu on normal interactions
if (ctx && e.button !== 2) setCtx(null);
if (e.button !== 2) props.onCloseContextMenu();
// middle click: do nothing (goView returns)
if (e.button === 1) return;
@ -309,97 +235,6 @@ export function Canvas(props: CanvasProps) {
cursor: props.keyboard.space ? 'grab' : 'default',
}}
>
{ctx && ctxMenuPos && (
<div
ref={ctxMenuRef}
style={{
position: 'fixed',
left: ctxMenuPos.x,
top: ctxMenuPos.y,
zIndex: 10_000,
background: '#111827',
border: '1px solid rgba(255,255,255,0.12)',
borderRadius: 8,
minWidth: 180,
padding: 6,
boxShadow: '0 12px 30px rgba(0,0,0,0.35)',
}}
onPointerDown={(e) => {
// keep it open when clicking inside
e.stopPropagation();
}}
>
<MenuItem
label="Add Text Here"
onClick={() => {
props.onAddTextAt(ctx.worldX, ctx.worldY);
setCtx(null);
}}
/>
<div style={{ height: 1, margin: '6px 0', background: 'rgba(255,255,255,0.08)' }} />
<MenuItem
label="Duplicate"
disabled={!props.selectionIds.length || !props.onDuplicateSelected}
onClick={() => {
props.onDuplicateSelected?.();
setCtx(null);
}}
/>
<MenuItem
label={selectionAllLocked ? 'Unlock' : 'Lock'}
disabled={!props.selectionIds.length || !props.onToggleLockSelected}
onClick={() => {
props.onToggleLockSelected?.();
setCtx(null);
}}
/>
<MenuItem
label={selectionAllHidden ? 'Show' : 'Hide'}
disabled={!props.selectionIds.length || !props.onToggleHideSelected}
onClick={() => {
props.onToggleHideSelected?.();
setCtx(null);
}}
/>
<div style={{ height: 1, margin: '6px 0', background: 'rgba(255,255,255,0.08)' }} />
<MenuItem
label="Bring To Front"
disabled={!props.selectionIds.length || !props.onBringToFrontSelected}
onClick={() => {
props.onBringToFrontSelected?.();
setCtx(null);
}}
/>
<MenuItem
label="Send To Back"
disabled={!props.selectionIds.length || !props.onSendToBackSelected}
onClick={() => {
props.onSendToBackSelected?.();
setCtx(null);
}}
/>
<MenuItem
label="Delete"
danger
disabled={!props.selectionIds.length || !props.onDeleteSelected}
onClick={() => {
props.onDeleteSelected?.();
setCtx(null);
}}
/>
<Typography.Text style={{ display: 'block', marginTop: 6, color: 'rgba(255,255,255,0.45)', fontSize: 11 }}>
({Math.round(ctx.worldX)}, {Math.round(ctx.worldY)})
</Typography.Text>
</div>
)}
<div
style={{
transform: `translate(${props.panX}px, ${props.panY}px) scale(${props.scale})`,
@ -510,42 +345,6 @@ export function Canvas(props: CanvasProps) {
);
}
function MenuItem(props: {
label: string;
onClick: () => void;
disabled?: boolean;
danger?: boolean;
}) {
return (
<div
role="menuitem"
onClick={() => {
if (props.disabled) return;
props.onClick();
}}
style={{
padding: '8px 10px',
borderRadius: 6,
cursor: props.disabled ? 'not-allowed' : 'pointer',
color: props.disabled
? 'rgba(255,255,255,0.35)'
: props.danger
? '#ff7875'
: 'rgba(255,255,255,0.9)',
}}
onMouseEnter={(e) => {
if (props.disabled) return;
(e.currentTarget as HTMLDivElement).style.background = 'rgba(255,255,255,0.06)';
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLDivElement).style.background = 'transparent';
}}
>
{props.label}
</div>
);
}
function NodeView(props: {
node: WidgetNode;
selected: boolean;
@ -578,7 +377,16 @@ function NodeView(props: {
>
{(() => {
switch (node.type) {
case 'text':
case 'text': {
const writingModeRaw = node.props.writingMode;
const writingModeStyle =
writingModeRaw === 'horizontal-tb' ||
writingModeRaw === 'vertical-rl' ||
writingModeRaw === 'vertical-lr' ||
writingModeRaw === 'sideways-rl' ||
writingModeRaw === 'sideways-lr'
? writingModeRaw
: undefined;
return (
<div
style={{
@ -609,7 +417,7 @@ function NodeView(props: {
color: node.props.color ?? '#fff',
fontWeight: node.props.fontWeight ?? 400,
letterSpacing: `${node.props.letterSpacing ?? 0}px`,
writingMode: node.props.writingMode as unknown as React.CSSProperties['writingMode'],
writingMode: writingModeStyle,
cursor: node.props.link ? 'pointer' : 'default',
}}
onClick={() => {
@ -622,6 +430,7 @@ function NodeView(props: {
</span>
</div>
);
}
case 'image':
return (
@ -687,7 +496,7 @@ function NodeView(props: {
src={node.props.src}
width={rect.w}
height={rect.h}
autoPlay
autoPlay={node.props.autoplay ?? true}
playsInline
loop={node.props.loop ?? false}
muted={node.props.muted ?? false}

View File

@ -1,44 +1,207 @@
import { Button, Card, Space } from 'antd';
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { Typography } from 'antd';
export type ContextMenuAction = 'delete' | 'duplicate';
export type ContextMenuState = {
clientX: number;
clientY: number;
worldX: number;
worldY: number;
targetId?: string;
};
export function ContextMenu(props: {
x: number;
y: number;
onAction: (a: ContextMenuAction) => void;
state: ContextMenuState | null;
selectionIds: string[];
selectionAllLocked: boolean;
selectionAllHidden: boolean;
onClose: () => void;
onAddTextAt: (x: number, y: number) => void;
onDuplicateSelected?: () => void;
onToggleLockSelected?: () => void;
onToggleHideSelected?: () => void;
onBringToFrontSelected?: () => void;
onSendToBackSelected?: () => void;
onDeleteSelected?: () => void;
}) {
return (
<>
{/* click-away backdrop */}
<div
onMouseDown={(e) => {
e.preventDefault();
props.onClose();
}}
style={{ position: 'fixed', inset: 0, zIndex: 999 }}
/>
const { onClose } = props;
const menuRef = useRef<HTMLDivElement | null>(null);
const [menuSize, setMenuSize] = useState<{ w: number; h: number }>({ w: 220, h: 320 });
const ctx = props.state;
<Card
size="small"
useLayoutEffect(() => {
if (!ctx) return;
const el = menuRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
if (rect.width && rect.height) setMenuSize({ w: rect.width, h: rect.height });
}, [ctx]);
const position = useMemo(() => {
if (!ctx) return null;
const vw = typeof window === 'undefined' ? 10_000 : window.innerWidth;
const vh = typeof window === 'undefined' ? 10_000 : window.innerHeight;
return {
x: Math.max(8, Math.min(ctx.clientX, vw - menuSize.w - 8)),
y: Math.max(8, Math.min(ctx.clientY, vh - menuSize.h - 8)),
};
}, [ctx, menuSize.h, menuSize.w]);
useEffect(() => {
if (!ctx) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
const close = () => onClose();
const onAnyPointerDown = () => close();
const onAnyWheel = () => close();
const onScroll = () => close();
const onBlur = () => close();
window.addEventListener('keydown', onKeyDown);
window.addEventListener('pointerdown', onAnyPointerDown);
window.addEventListener('wheel', onAnyWheel, { passive: true });
window.addEventListener('scroll', onScroll, true);
window.addEventListener('blur', onBlur);
return () => {
window.removeEventListener('keydown', onKeyDown);
window.removeEventListener('pointerdown', onAnyPointerDown);
window.removeEventListener('wheel', onAnyWheel);
window.removeEventListener('scroll', onScroll, true);
window.removeEventListener('blur', onBlur);
};
}, [ctx, onClose]);
if (!ctx || !position) return null;
const hasSelection = props.selectionIds.length > 0;
return (
<div
ref={menuRef}
style={{
position: 'fixed',
left: props.x,
top: props.y,
zIndex: 1000,
width: 160,
left: position.x,
top: position.y,
zIndex: 10_000,
background: '#111827',
border: '1px solid rgba(255,255,255,0.12)',
borderRadius: 8,
minWidth: 180,
padding: 6,
boxShadow: '0 12px 30px rgba(0,0,0,0.35)',
}}
onPointerDown={(e) => {
// keep it open when clicking inside
e.stopPropagation();
}}
styles={{ body: { padding: 8 } }}
>
<Space direction="vertical" style={{ width: '100%' }}>
<Button block onClick={() => props.onAction('duplicate')}>
Duplicate
</Button>
<Button danger block onClick={() => props.onAction('delete')}>
Delete
</Button>
</Space>
</Card>
</>
<MenuItem
label="Add Text Here"
onClick={() => {
props.onAddTextAt(ctx.worldX, ctx.worldY);
onClose();
}}
/>
<div style={{ height: 1, margin: '6px 0', background: 'rgba(255,255,255,0.08)' }} />
<MenuItem
label="Duplicate"
disabled={!hasSelection || !props.onDuplicateSelected}
onClick={() => {
props.onDuplicateSelected?.();
onClose();
}}
/>
<MenuItem
label={props.selectionAllLocked ? 'Unlock' : 'Lock'}
disabled={!hasSelection || !props.onToggleLockSelected}
onClick={() => {
props.onToggleLockSelected?.();
onClose();
}}
/>
<MenuItem
label={props.selectionAllHidden ? 'Show' : 'Hide'}
disabled={!hasSelection || !props.onToggleHideSelected}
onClick={() => {
props.onToggleHideSelected?.();
onClose();
}}
/>
<div style={{ height: 1, margin: '6px 0', background: 'rgba(255,255,255,0.08)' }} />
<MenuItem
label="Bring To Front"
disabled={!hasSelection || !props.onBringToFrontSelected}
onClick={() => {
props.onBringToFrontSelected?.();
onClose();
}}
/>
<MenuItem
label="Send To Back"
disabled={!hasSelection || !props.onSendToBackSelected}
onClick={() => {
props.onSendToBackSelected?.();
onClose();
}}
/>
<MenuItem
label="Delete"
danger
disabled={!hasSelection || !props.onDeleteSelected}
onClick={() => {
props.onDeleteSelected?.();
onClose();
}}
/>
<Typography.Text style={{ display: 'block', marginTop: 6, color: 'rgba(255,255,255,0.45)', fontSize: 11 }}>
({Math.round(ctx.worldX)}, {Math.round(ctx.worldY)})
</Typography.Text>
</div>
);
}
function MenuItem(props: {
label: string;
onClick: () => void;
disabled?: boolean;
danger?: boolean;
}) {
return (
<div
role="menuitem"
onClick={() => {
if (props.disabled) return;
props.onClick();
}}
style={{
padding: '8px 10px',
borderRadius: 6,
cursor: props.disabled ? 'not-allowed' : 'pointer',
color: props.disabled
? 'rgba(255,255,255,0.35)'
: props.danger
? '#ff7875'
: 'rgba(255,255,255,0.9)',
}}
onMouseEnter={(e) => {
if (props.disabled) return;
(e.currentTarget as HTMLDivElement).style.background = 'rgba(255,255,255,0.06)';
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLDivElement).style.background = 'transparent';
}}
>
{props.label}
</div>
);
}

View File

@ -9,9 +9,10 @@ import {
Tabs,
Typography,
} from 'antd';
import { useEffect, useMemo, useReducer, useState } from 'react';
import { useCallback, useEffect, useMemo, useReducer, useState } from 'react';
import { bindEditorHotkeys } from './hotkeys';
import { Canvas } from './Canvas';
import { ContextMenu, type ContextMenuState } from './ContextMenu';
import { Inspector } from './Inspector';
import { createInitialState, editorReducer, exportScreenJSON } from './store';
@ -42,15 +43,36 @@ export function EditorApp() {
const [importText, setImportText] = useState('');
const [leftCategory, setLeftCategory] = useState<LeftCategoryKey>('charts');
const [rightTab, setRightTab] = useState<RightPanelTabKey>('custom');
const [ctxMenu, setCtxMenu] = useState<ContextMenuState | null>(null);
const selected = state.doc.screen.nodes.find((n) => n.id === state.selection.ids[0]);
const hasSelection = state.selection.ids.length > 0;
const selection = useMemo(() => {
const ids = new Set(state.selection.ids);
return state.doc.screen.nodes.filter((n) => ids.has(n.id));
}, [state.doc.screen.nodes, state.selection.ids]);
const selectionAllLocked = selection.length > 0 && selection.every((n) => n.locked);
const selectionAllHidden = selection.length > 0 && selection.every((n) => n.hidden);
const bounds = useMemo(
() => ({ w: state.doc.screen.width, h: state.doc.screen.height }),
[state.doc.screen.width, state.doc.screen.height],
);
const orderedNodes = useMemo(() => {
return state.doc.screen.nodes
.map((node, index) => ({ node, index, z: node.zIndex ?? index }))
.sort((a, b) => {
if (a.z !== b.z) return b.z - a.z;
return a.index - b.index;
})
.map((entry) => entry.node);
}, [state.doc.screen.nodes]);
const closeContextMenu = useCallback(() => setCtxMenu(null), []);
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
dispatch({ type: 'keyboard', ctrl: e.ctrlKey || e.metaKey, space: e.code === 'Space' || state.keyboard.space });
@ -285,12 +307,8 @@ export function EditorApp() {
guides={state.canvas.guides}
onSelectSingle={(id) => dispatch({ type: 'selectSingle', id })}
onToggleSelect={(id) => dispatch({ type: 'toggleSelect', id })}
onDeleteSelected={() => dispatch({ type: 'deleteSelected' })}
onDuplicateSelected={() => dispatch({ type: 'duplicateSelected' })}
onToggleLockSelected={() => dispatch({ type: 'toggleLockSelected' })}
onToggleHideSelected={() => dispatch({ type: 'toggleHideSelected' })}
onBringToFrontSelected={() => dispatch({ type: 'bringToFrontSelected' })}
onSendToBackSelected={() => dispatch({ type: 'sendToBackSelected' })}
onOpenContextMenu={(next) => setCtxMenu(next)}
onCloseContextMenu={closeContextMenu}
onBeginPan={(e) => dispatch({ type: 'beginPan', start: { screenX: e.screenX, screenY: e.screenY } })}
onUpdatePan={(e: PointerEvent) => dispatch({ type: 'updatePan', current: { screenX: e.screenX, screenY: e.screenY } })}
onEndPan={() => dispatch({ type: 'endPan' })}
@ -408,8 +426,91 @@ export function EditorApp() {
<Typography.Paragraph style={{ color: '#64748b' }}> / / </Typography.Paragraph>
</>
)}
<Divider style={{ borderColor: 'rgba(255,255,255,0.08)' }} />
<PanelTitle
title="Layers"
extra={<Typography.Text style={{ color: '#64748b', fontSize: 12 }}>{state.doc.screen.nodes.length}</Typography.Text>}
/>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{orderedNodes.length === 0 ? (
<Typography.Text style={{ color: '#64748b', fontSize: 12 }}>No layers</Typography.Text>
) : (
orderedNodes.map((node) => {
const isSelected = state.selection.ids.includes(node.id);
const status = `${node.locked ? 'L' : ''}${node.hidden ? 'H' : ''}`;
return (
<div
key={node.id}
onPointerDown={(e) => {
if (e.button === 2) return;
e.preventDefault();
e.stopPropagation();
const additive = e.ctrlKey || e.metaKey || e.shiftKey;
if (additive) {
dispatch({ type: 'toggleSelect', id: node.id });
} else {
dispatch({ type: 'selectSingle', id: node.id });
}
}}
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
const additive = e.ctrlKey || e.metaKey || e.shiftKey;
if (!state.selection.ids.includes(node.id)) {
if (additive) {
dispatch({ type: 'toggleSelect', id: node.id });
} else {
dispatch({ type: 'selectSingle', id: node.id });
}
}
setCtxMenu({
clientX: e.clientX,
clientY: e.clientY,
worldX: node.rect.x + node.rect.w / 2,
worldY: node.rect.y + node.rect.h / 2,
targetId: node.id,
});
}}
style={{
padding: '6px 8px',
borderRadius: 8,
border: isSelected ? '1px solid rgba(24,144,255,0.7)' : '1px solid rgba(255,255,255,0.08)',
background: isSelected ? 'rgba(24,144,255,0.12)' : 'rgba(255,255,255,0.02)',
color: isSelected ? '#e2e8f0' : '#cbd5e1',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
cursor: 'pointer',
}}
>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<span style={{ fontSize: 12 }}>{node.type}</span>
<span style={{ fontSize: 10, color: 'rgba(255,255,255,0.45)' }}>{node.id}</span>
</div>
{status ? <span style={{ fontSize: 11, color: 'rgba(255,255,255,0.45)' }}>{status}</span> : null}
</div>
);
})
)}
</div>
</Sider>
</Layout>
<ContextMenu
state={ctxMenu}
selectionIds={state.selection.ids}
selectionAllLocked={selectionAllLocked}
selectionAllHidden={selectionAllHidden}
onClose={closeContextMenu}
onAddTextAt={(x, y) => dispatch({ type: 'addTextAt', x, y })}
onDuplicateSelected={() => dispatch({ type: 'duplicateSelected' })}
onToggleLockSelected={() => dispatch({ type: 'toggleLockSelected' })}
onToggleHideSelected={() => dispatch({ type: 'toggleHideSelected' })}
onBringToFrontSelected={() => dispatch({ type: 'bringToFrontSelected' })}
onSendToBackSelected={() => dispatch({ type: 'sendToBackSelected' })}
onDeleteSelected={() => dispatch({ type: 'deleteSelected' })}
/>
</Layout>
);
}

View File

@ -140,6 +140,21 @@ export function Inspector(props: {
</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}

View File

@ -332,7 +332,7 @@ export function editorReducer(state: EditorState, action: EditorAction): EditorS
...n,
id,
rect: { ...n.rect, x: n.rect.x + 10, y: n.rect.y + 10 },
} as WidgetNode);
});
}
if (!clones.length) return state;

View File

@ -167,6 +167,11 @@ function looksLikeImageOption(option: unknown): boolean {
function looksLikeIframeOption(option: unknown): boolean {
if (!option) return false;
if (typeof option === 'string') {
const trimmed = option.trim();
if (trimmed.startsWith('<') && trimmed.includes('>')) return true;
}
// Prefer explicit iframe-ish keys when option is an object.
if (typeof option === 'object') {
const o = option as Record<string, unknown>;
@ -213,7 +218,12 @@ function looksLikeVideoOption(option: unknown): boolean {
// list-ish shapes
'sources' in o ||
'sourceList' in o ||
'urlList' in o
'urlList' in o ||
'autoplay' in o ||
'autoPlay' in o ||
'isAutoPlay' in o ||
'poster' in o ||
'posterUrl' in o
) {
return true;
}

View File

@ -72,6 +72,7 @@ export interface VideoWidgetNode extends WidgetNodeBase {
type: 'video';
props: {
src: string;
autoplay?: boolean;
loop?: boolean;
muted?: boolean;
fit?: 'contain' | 'cover' | 'fill' | 'none' | 'scale-down';

View File

@ -24,10 +24,15 @@ function toDataHtmlUrl(html: string): string {
return `data:text/html;charset=utf-8,${encodeURIComponent(html)}`;
}
function looksLikeHtml(input: string): boolean {
const trimmed = input.trim();
return trimmed.startsWith('<') && trimmed.includes('>');
}
function pickSrc(option: GoViewIframeOption): string {
// Prefer the whole option first (covers iframeUrl/embedUrl variants directly on the object).
const url = pickUrlLike(option) || pickUrlLike(option.dataset) || pickUrlLike(option.src) || pickUrlLike(option.url);
if (url) return url;
if (url) return looksLikeHtml(url) ? toDataHtmlUrl(url) : url;
// Some goView / low-code exports store raw HTML instead of a URL.
const html = pickFromNested(

View File

@ -10,10 +10,12 @@ export interface GoViewVideoOption {
src?: unknown;
url?: unknown;
loop?: boolean;
muted?: boolean;
autoplay?: boolean;
autoPlay?: boolean;
isAutoPlay?: boolean;
loop?: boolean;
muted?: boolean;
fit?: unknown;
objectFit?: unknown;
@ -94,6 +96,10 @@ function pickBooleanLike(option: GoViewVideoOption, keys: string[]): boolean | u
);
}
function pickAutoplay(option: GoViewVideoOption): boolean | undefined {
return pickBooleanLike(option, ['autoplay', 'autoPlay', 'auto_play', 'isAutoPlay']);
}
function pickFitFromNested(option: GoViewVideoOption): string {
const direct = asString(option.fit) || asString(option.objectFit);
if (direct) return direct;
@ -118,6 +124,7 @@ function pickFit(option: GoViewVideoOption): VideoWidgetNode['props']['fit'] | u
export function convertGoViewVideoOptionToNodeProps(option: GoViewVideoOption): VideoWidgetNode['props'] {
return {
src: pickSrc(option),
autoplay: pickAutoplay(option),
loop: pickBooleanLike(option, ['loop', 'isLoop']),
muted: pickBooleanLike(option, ['muted', 'isMuted', 'mute']),
fit: pickFit(option),