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 type { Screen, WidgetNode } from '@astralview/sdk';
import { assertNever } 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 type { ResizeHandle } from './types';
import { rectFromPoints } from './geometry'; import { rectFromPoints } from './geometry';
import type { ContextMenuState } from './ContextMenu';
export interface CanvasProps { export interface CanvasProps {
screen: Screen; screen: Screen;
@ -30,12 +31,8 @@ export interface CanvasProps {
onAddTextAt(x: number, y: number): void; onAddTextAt(x: number, y: number): void;
onWheelPan(dx: number, dy: number): void; onWheelPan(dx: number, dy: number): void;
onZoomAt(scale: number, anchorX: number, anchorY: number): void; onZoomAt(scale: number, anchorX: number, anchorY: number): void;
onDeleteSelected?(): void; onOpenContextMenu(state: ContextMenuState): void;
onDuplicateSelected?(): void; onCloseContextMenu(): void;
onToggleLockSelected?(): void;
onToggleHideSelected?(): void;
onBringToFrontSelected?(): void;
onSendToBackSelected?(): void;
} }
function isPrimaryButton(e: React.PointerEvent | PointerEvent): boolean { 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; 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) { export function Canvas(props: CanvasProps) {
const ref = useRef<HTMLDivElement | null>(null); const ref = useRef<HTMLDivElement | null>(null);
const [box, setBox] = useState<{ x1: number; y1: number; x2: number; y2: number } | 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 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 clientToCanvas = useCallback((clientX: number, clientY: number) => {
const el = ref.current; const el = ref.current;
if (!el) return null; if (!el) return null;
@ -175,36 +131,6 @@ export function Canvas(props: CanvasProps) {
onUpdateResize, 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) => { const openContextMenu = (e: React.MouseEvent | React.PointerEvent, targetId?: string) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
@ -230,12 +156,12 @@ export function Canvas(props: CanvasProps) {
if (!additive) props.onSelectSingle(undefined); 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) => { const onBackgroundPointerDown = (e: React.PointerEvent) => {
// close any open context menu on normal interactions // 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) // middle click: do nothing (goView returns)
if (e.button === 1) return; if (e.button === 1) return;
@ -309,97 +235,6 @@ export function Canvas(props: CanvasProps) {
cursor: props.keyboard.space ? 'grab' : 'default', 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 <div
style={{ style={{
transform: `translate(${props.panX}px, ${props.panY}px) scale(${props.scale})`, 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: { function NodeView(props: {
node: WidgetNode; node: WidgetNode;
selected: boolean; selected: boolean;
@ -578,7 +377,16 @@ function NodeView(props: {
> >
{(() => { {(() => {
switch (node.type) { 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 ( return (
<div <div
style={{ style={{
@ -600,18 +408,18 @@ function NodeView(props: {
boxSizing: 'border-box', boxSizing: 'border-box',
padding: `${node.props.paddingY ?? 0}px ${node.props.paddingX ?? 0}px`, padding: `${node.props.paddingY ?? 0}px ${node.props.paddingX ?? 0}px`,
overflow: 'hidden', overflow: 'hidden',
}}
>
<span
style={{
whiteSpace: 'pre-wrap',
fontSize: node.props.fontSize ?? 24,
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'],
cursor: node.props.link ? 'pointer' : 'default',
}} }}
>
<span
style={{
whiteSpace: 'pre-wrap',
fontSize: node.props.fontSize ?? 24,
color: node.props.color ?? '#fff',
fontWeight: node.props.fontWeight ?? 400,
letterSpacing: `${node.props.letterSpacing ?? 0}px`,
writingMode: writingModeStyle,
cursor: node.props.link ? 'pointer' : 'default',
}}
onClick={() => { onClick={() => {
if (!node.props.link) return; if (!node.props.link) return;
const head = node.props.linkHead ?? 'http://'; const head = node.props.linkHead ?? 'http://';
@ -622,6 +430,7 @@ function NodeView(props: {
</span> </span>
</div> </div>
); );
}
case 'image': case 'image':
return ( return (
@ -687,7 +496,7 @@ function NodeView(props: {
src={node.props.src} src={node.props.src}
width={rect.w} width={rect.w}
height={rect.h} height={rect.h}
autoPlay autoPlay={node.props.autoplay ?? true}
playsInline playsInline
loop={node.props.loop ?? false} loop={node.props.loop ?? false}
muted={node.props.muted ?? 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: { export function ContextMenu(props: {
x: number; state: ContextMenuState | null;
y: number; selectionIds: string[];
onAction: (a: ContextMenuAction) => void; selectionAllLocked: boolean;
selectionAllHidden: boolean;
onClose: () => void; onClose: () => void;
onAddTextAt: (x: number, y: number) => void;
onDuplicateSelected?: () => void;
onToggleLockSelected?: () => void;
onToggleHideSelected?: () => void;
onBringToFrontSelected?: () => void;
onSendToBackSelected?: () => void;
onDeleteSelected?: () => void;
}) { }) {
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;
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 ( return (
<> <div
{/* click-away backdrop */} ref={menuRef}
<div style={{
onMouseDown={(e) => { position: 'fixed',
e.preventDefault(); left: position.x,
props.onClose(); 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();
}}
>
<MenuItem
label="Add Text Here"
onClick={() => {
props.onAddTextAt(ctx.worldX, ctx.worldY);
onClose();
}} }}
style={{ position: 'fixed', inset: 0, zIndex: 999 }}
/> />
<Card <div style={{ height: 1, margin: '6px 0', background: 'rgba(255,255,255,0.08)' }} />
size="small"
style={{ <MenuItem
position: 'fixed', label="Duplicate"
left: props.x, disabled={!hasSelection || !props.onDuplicateSelected}
top: props.y, onClick={() => {
zIndex: 1000, props.onDuplicateSelected?.();
width: 160, onClose();
}} }}
styles={{ body: { padding: 8 } }} />
>
<Space direction="vertical" style={{ width: '100%' }}> <MenuItem
<Button block onClick={() => props.onAction('duplicate')}> label={props.selectionAllLocked ? 'Unlock' : 'Lock'}
Duplicate disabled={!hasSelection || !props.onToggleLockSelected}
</Button> onClick={() => {
<Button danger block onClick={() => props.onAction('delete')}> props.onToggleLockSelected?.();
Delete onClose();
</Button> }}
</Space> />
</Card> <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, Tabs,
Typography, Typography,
} from 'antd'; } from 'antd';
import { useEffect, useMemo, useReducer, useState } from 'react'; import { useCallback, useEffect, useMemo, useReducer, useState } from 'react';
import { bindEditorHotkeys } from './hotkeys'; import { bindEditorHotkeys } from './hotkeys';
import { Canvas } from './Canvas'; import { Canvas } from './Canvas';
import { ContextMenu, type ContextMenuState } from './ContextMenu';
import { Inspector } from './Inspector'; import { Inspector } from './Inspector';
import { createInitialState, editorReducer, exportScreenJSON } from './store'; import { createInitialState, editorReducer, exportScreenJSON } from './store';
@ -42,15 +43,36 @@ export function EditorApp() {
const [importText, setImportText] = useState(''); const [importText, setImportText] = useState('');
const [leftCategory, setLeftCategory] = useState<LeftCategoryKey>('charts'); const [leftCategory, setLeftCategory] = useState<LeftCategoryKey>('charts');
const [rightTab, setRightTab] = useState<RightPanelTabKey>('custom'); 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 selected = state.doc.screen.nodes.find((n) => n.id === state.selection.ids[0]);
const hasSelection = state.selection.ids.length > 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( const bounds = useMemo(
() => ({ w: state.doc.screen.width, h: state.doc.screen.height }), () => ({ w: state.doc.screen.width, h: state.doc.screen.height }),
[state.doc.screen.width, 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(() => { useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => { const onKeyDown = (e: KeyboardEvent) => {
dispatch({ type: 'keyboard', ctrl: e.ctrlKey || e.metaKey, space: e.code === 'Space' || state.keyboard.space }); 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} guides={state.canvas.guides}
onSelectSingle={(id) => dispatch({ type: 'selectSingle', id })} onSelectSingle={(id) => dispatch({ type: 'selectSingle', id })}
onToggleSelect={(id) => dispatch({ type: 'toggleSelect', id })} onToggleSelect={(id) => dispatch({ type: 'toggleSelect', id })}
onDeleteSelected={() => dispatch({ type: 'deleteSelected' })} onOpenContextMenu={(next) => setCtxMenu(next)}
onDuplicateSelected={() => dispatch({ type: 'duplicateSelected' })} onCloseContextMenu={closeContextMenu}
onToggleLockSelected={() => dispatch({ type: 'toggleLockSelected' })}
onToggleHideSelected={() => dispatch({ type: 'toggleHideSelected' })}
onBringToFrontSelected={() => dispatch({ type: 'bringToFrontSelected' })}
onSendToBackSelected={() => dispatch({ type: 'sendToBackSelected' })}
onBeginPan={(e) => dispatch({ type: 'beginPan', start: { screenX: e.screenX, screenY: e.screenY } })} 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 } })} onUpdatePan={(e: PointerEvent) => dispatch({ type: 'updatePan', current: { screenX: e.screenX, screenY: e.screenY } })}
onEndPan={() => dispatch({ type: 'endPan' })} onEndPan={() => dispatch({ type: 'endPan' })}
@ -408,8 +426,91 @@ export function EditorApp() {
<Typography.Paragraph style={{ color: '#64748b' }}> / / </Typography.Paragraph> <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> </Sider>
</Layout> </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> </Layout>
); );
} }

View File

@ -140,6 +140,21 @@ export function Inspector(props: {
</div> </div>
</Space> </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> <Typography.Text type="secondary">Border radius</Typography.Text>
<InputNumber <InputNumber
value={node.props.borderRadius ?? 0} value={node.props.borderRadius ?? 0}

View File

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

View File

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

View File

@ -72,6 +72,7 @@ export interface VideoWidgetNode extends WidgetNodeBase {
type: 'video'; type: 'video';
props: { props: {
src: string; src: string;
autoplay?: boolean;
loop?: boolean; loop?: boolean;
muted?: boolean; muted?: boolean;
fit?: 'contain' | 'cover' | 'fill' | 'none' | 'scale-down'; 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)}`; 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 { function pickSrc(option: GoViewIframeOption): string {
// Prefer the whole option first (covers iframeUrl/embedUrl variants directly on the object). // 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); 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. // Some goView / low-code exports store raw HTML instead of a URL.
const html = pickFromNested( const html = pickFromNested(

View File

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