Compare commits

...

12 Commits

7 changed files with 588 additions and 195 deletions

View File

@ -6,6 +6,7 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"typecheck": "tsc -b --noEmit",
"lint": "eslint .", "lint": "eslint .",
"preview": "vite preview" "preview": "vite preview"
}, },

View File

@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useLayoutEffect, 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 { Button, Space, Typography } from 'antd'; import { Button, Space, Typography } from 'antd';
import type { ResizeHandle } from './types'; import type { ResizeHandle } from './types';
import { rectFromPoints } from './geometry'; import { rectFromPoints } from './geometry';
@ -54,6 +55,8 @@ 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 [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]);
@ -65,22 +68,27 @@ export function Canvas(props: CanvasProps) {
const selectionAllLocked = selection.length > 0 && selection.every((n) => n.locked); const selectionAllLocked = selection.length > 0 && selection.every((n) => n.locked);
const selectionAllHidden = selection.length > 0 && selection.every((n) => n.hidden); 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(() => { const ctxMenuPos = useMemo(() => {
if (!ctx) return null; if (!ctx) return null;
// Rough clamp so the menu stays inside the viewport.
// (We don't measure actual size to keep this simple + stable.)
const w = 220;
const h = 320;
const vw = typeof window === 'undefined' ? 10_000 : window.innerWidth; const vw = typeof window === 'undefined' ? 10_000 : window.innerWidth;
const vh = typeof window === 'undefined' ? 10_000 : window.innerHeight; const vh = typeof window === 'undefined' ? 10_000 : window.innerHeight;
return { return {
x: Math.max(8, Math.min(ctx.clientX, vw - w - 8)), x: Math.max(8, Math.min(ctx.clientX, vw - ctxMenuSize.w - 8)),
y: Math.max(8, Math.min(ctx.clientY, vh - h - 8)), y: Math.max(8, Math.min(ctx.clientY, vh - ctxMenuSize.h - 8)),
}; };
}, [ctx]); }, [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;
@ -174,14 +182,26 @@ export function Canvas(props: CanvasProps) {
if (e.key === 'Escape') setCtx(null); if (e.key === 'Escape') setCtx(null);
}; };
const onAnyPointerDown = () => 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('keydown', onKeyDown);
window.addEventListener('pointerdown', onAnyPointerDown); window.addEventListener('pointerdown', onAnyPointerDown);
window.addEventListener('wheel', onAnyWheel, { passive: true });
window.addEventListener('scroll', onScroll, true);
window.addEventListener('blur', onBlur);
return () => { return () => {
window.removeEventListener('keydown', onKeyDown); window.removeEventListener('keydown', onKeyDown);
window.removeEventListener('pointerdown', onAnyPointerDown); window.removeEventListener('pointerdown', onAnyPointerDown);
window.removeEventListener('wheel', onAnyWheel);
window.removeEventListener('scroll', onScroll, true);
window.removeEventListener('blur', onBlur);
}; };
}, [ctx]); }, [ctx]);
@ -192,7 +212,8 @@ export function Canvas(props: CanvasProps) {
const p = clientToWorld(e.clientX, e.clientY); const p = clientToWorld(e.clientX, e.clientY);
if (!p) return; if (!p) return;
const additive = (e as React.MouseEvent).ctrlKey || (e as React.MouseEvent).metaKey; const additive =
(e as React.MouseEvent).ctrlKey || (e as React.MouseEvent).metaKey || (e as React.MouseEvent).shiftKey;
if (targetId) { if (targetId) {
if (!props.selectionIds.includes(targetId)) { if (!props.selectionIds.includes(targetId)) {
@ -231,7 +252,7 @@ export function Canvas(props: CanvasProps) {
const p = clientToWorld(e.clientX, e.clientY); const p = clientToWorld(e.clientX, e.clientY);
if (!p) return; if (!p) return;
const additive = e.ctrlKey || e.metaKey; const additive = e.ctrlKey || e.metaKey || e.shiftKey;
if (!additive) props.onSelectSingle(undefined); if (!additive) props.onSelectSingle(undefined);
props.onBeginBoxSelect(e, p.x, p.y, additive); props.onBeginBoxSelect(e, p.x, p.y, additive);
setBox(rectFromPoints({ x: p.x, y: p.y }, { x: p.x, y: p.y })); setBox(rectFromPoints({ x: p.x, y: p.y }, { x: p.x, y: p.y }));
@ -290,6 +311,7 @@ export function Canvas(props: CanvasProps) {
> >
{ctx && ctxMenuPos && ( {ctx && ctxMenuPos && (
<div <div
ref={ctxMenuRef}
style={{ style={{
position: 'fixed', position: 'fixed',
left: ctxMenuPos.x, left: ctxMenuPos.x,
@ -425,18 +447,37 @@ export function Canvas(props: CanvasProps) {
onPointerDown={(e) => { onPointerDown={(e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
if (node.locked) return;
// Right-click selection is handled by the contextmenu handler. // Right-click selection is handled by the contextmenu handler.
// Important: don't collapse a multi-selection on pointerdown. // Important: don't collapse a multi-selection on pointerdown.
if (e.button === 2) return; if (e.button === 2) return;
// ctrl click: multi-select toggle const additive = e.ctrlKey || e.metaKey || e.shiftKey;
if (props.keyboard.ctrl) {
// Ctrl/Cmd/Shift click: multi-select toggle
if (additive) {
props.onToggleSelect(node.id); props.onToggleSelect(node.id);
return; return;
} }
// If the node is already in the current selection, keep the selection as-is.
// This matches goView-ish multi-selection behavior (drag moves the whole selection).
if (props.selectionIds.includes(node.id)) {
if (node.locked) {
// Locked nodes are selectable, but should not start a move drag.
props.onSelectSingle(node.id);
return;
}
props.onBeginMove(e);
return;
}
// Locked nodes should still be selectable, but not movable.
if (node.locked) {
props.onSelectSingle(node.id);
return;
}
props.onSelectSingle(node.id); props.onSelectSingle(node.id);
props.onBeginMove(e); props.onBeginMove(e);
}} }}
@ -531,9 +572,14 @@ function NodeView(props: {
color: '#fff', color: '#fff',
boxSizing: 'border-box', boxSizing: 'border-box',
background: 'rgba(255,255,255,0.02)', background: 'rgba(255,255,255,0.02)',
opacity: node.hidden ? 0.28 : 1,
borderStyle: node.hidden ? 'dashed' : 'solid',
}} }}
> >
{node.type === 'text' ? ( {(() => {
switch (node.type) {
case 'text':
return (
<div <div
style={{ style={{
width: '100%', width: '100%',
@ -575,7 +621,10 @@ function NodeView(props: {
{node.props.text} {node.props.text}
</span> </span>
</div> </div>
) : node.type === 'image' ? ( );
case 'image':
return (
<div <div
style={{ style={{
width: '100%', width: '100%',
@ -597,7 +646,10 @@ function NodeView(props: {
}} }}
/> />
</div> </div>
) : node.type === 'iframe' ? ( );
case 'iframe':
return (
<div <div
style={{ style={{
width: '100%', width: '100%',
@ -619,7 +671,10 @@ function NodeView(props: {
title={node.id} title={node.id}
/> />
</div> </div>
) : node.type === 'video' ? ( );
case 'video':
return (
<div <div
style={{ style={{
width: '100%', width: '100%',
@ -646,9 +701,14 @@ function NodeView(props: {
}} }}
/> />
</div> </div>
) : null} );
{props.selected && <ResizeHandles onPointerDown={props.onResizePointerDown} />} default:
return assertNever(node);
}
})()}
{props.selected && !node.locked && !node.hidden && <ResizeHandles onPointerDown={props.onResizePointerDown} />}
</div> </div>
); );
} }

View File

@ -65,6 +65,24 @@ interface DragSession {
snapshot: Map<string, Rect>; snapshot: Map<string, Rect>;
} }
interface PanSession {
startScreenX: number;
startScreenY: number;
startPanX: number;
startPanY: number;
}
interface BoxSelectSession {
additive: boolean;
baseIds: string[];
}
type InternalEditorState = EditorState & {
__pan?: PanSession;
__boxSelect?: BoxSelectSession;
__drag?: DragSession;
};
function historyPush(state: EditorState): EditorState { function historyPush(state: EditorState): EditorState {
return { return {
...state, ...state,
@ -123,6 +141,7 @@ export function createInitialState(): EditorState {
} }
export function editorReducer(state: EditorState, action: EditorAction): EditorState { export function editorReducer(state: EditorState, action: EditorAction): EditorState {
const s = state as InternalEditorState;
switch (action.type) { switch (action.type) {
case 'keyboard': case 'keyboard':
return { ...state, keyboard: { ctrl: action.ctrl, space: action.space } }; return { ...state, keyboard: { ctrl: action.ctrl, space: action.space } };
@ -359,7 +378,8 @@ export function editorReducer(state: EditorState, action: EditorAction): EditorS
nodes: state.doc.screen.nodes.map((n) => (ids.has(n.id) ? { ...n, hidden: shouldHide } : n)), nodes: state.doc.screen.nodes.map((n) => (ids.has(n.id) ? { ...n, hidden: shouldHide } : n)),
}, },
}, },
selection: shouldHide ? { ids: [] } : state.selection, // Keep selection so "Show" can be toggled back immediately via context menu.
selection: state.selection,
}; };
} }
@ -433,12 +453,12 @@ export function editorReducer(state: EditorState, action: EditorAction): EditorS
startPanX: state.canvas.panX, startPanX: state.canvas.panX,
startPanY: state.canvas.panY, startPanY: state.canvas.panY,
}, },
} as EditorState & { __pan: { startScreenX: number; startScreenY: number; startPanX: number; startPanY: number } }; } as EditorState;
} }
case 'updatePan': { case 'updatePan': {
if (!state.canvas.isPanning) return state; if (!state.canvas.isPanning) return state;
const pan = (state as EditorState & { __pan?: { startScreenX: number; startScreenY: number; startPanX: number; startPanY: number } }).__pan; const pan = s.__pan;
if (!pan) return state; if (!pan) return state;
const scale = state.canvas.scale; const scale = state.canvas.scale;
const dx = (action.current.screenX - pan.startScreenX) / scale; const dx = (action.current.screenX - pan.startScreenX) / scale;
@ -456,14 +476,14 @@ export function editorReducer(state: EditorState, action: EditorAction): EditorS
case 'endPan': { case 'endPan': {
if (!state.canvas.isPanning) return state; if (!state.canvas.isPanning) return state;
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
const { __pan: _pan, ...rest } = state as EditorState & { __pan?: unknown }; const { __pan: _pan, ...rest } = s;
return { return {
...rest, ...rest,
canvas: { canvas: {
...state.canvas, ...state.canvas,
isPanning: false, isPanning: false,
}, },
}; } as EditorState;
} }
case 'selectSingle': case 'selectSingle':
@ -501,7 +521,7 @@ export function editorReducer(state: EditorState, action: EditorAction): EditorS
}, },
selection: { ids: baseIds }, selection: { ids: baseIds },
__boxSelect: { additive: action.additive, baseIds }, __boxSelect: { additive: action.additive, baseIds },
} as EditorState & { __boxSelect: { additive: boolean; baseIds: string[] } }; } as EditorState;
} }
case 'updateBoxSelect': { case 'updateBoxSelect': {
@ -522,7 +542,7 @@ export function editorReducer(state: EditorState, action: EditorAction): EditorS
if (rectContains(box, node.rect)) selected.push(node.id); if (rectContains(box, node.rect)) selected.push(node.id);
} }
const boxState = (state as EditorState & { __boxSelect?: { additive: boolean; baseIds: string[] } }).__boxSelect; const boxState = s.__boxSelect;
const ids = boxState?.additive ? Array.from(new Set([...(boxState.baseIds ?? []), ...selected])) : selected; const ids = boxState?.additive ? Array.from(new Set([...(boxState.baseIds ?? []), ...selected])) : selected;
return { return {
@ -544,7 +564,7 @@ export function editorReducer(state: EditorState, action: EditorAction): EditorS
case 'endBoxSelect': { case 'endBoxSelect': {
if (!state.canvas.isBoxSelecting) return state; if (!state.canvas.isBoxSelecting) return state;
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
const { __boxSelect: _box, ...rest } = state as EditorState & { __boxSelect?: unknown }; const { __boxSelect: _box, ...rest } = s;
return { return {
...rest, ...rest,
canvas: { canvas: {
@ -552,7 +572,7 @@ export function editorReducer(state: EditorState, action: EditorAction): EditorS
isBoxSelecting: false, isBoxSelecting: false,
mouse: { ...state.canvas.mouse, offsetX: 0, offsetY: 0, offsetStartX: 0, offsetStartY: 0 }, mouse: { ...state.canvas.mouse, offsetX: 0, offsetY: 0, offsetStartX: 0, offsetStartY: 0 },
}, },
}; } as EditorState;
} }
case 'beginMove': { case 'beginMove': {
@ -564,12 +584,18 @@ export function editorReducer(state: EditorState, action: EditorAction): EditorS
snapshot: new Map<string, Rect>(), snapshot: new Map<string, Rect>(),
}; };
// Only snapshot movable nodes.
// Locked/hidden nodes can remain selected (for context-menu ops), but they should not move.
for (const id of state.selection.ids) { for (const id of state.selection.ids) {
const node = state.doc.screen.nodes.find((n) => n.id === id); const node = state.doc.screen.nodes.find((n) => n.id === id);
if (!node) continue; if (!node) continue;
if (node.locked || node.hidden) continue;
drag.snapshot.set(id, { ...node.rect }); drag.snapshot.set(id, { ...node.rect });
} }
// Nothing movable selected → no-op (avoid entering a dragging state).
if (!drag.snapshot.size) return state;
return { return {
...state, ...state,
canvas: { canvas: {
@ -584,11 +610,11 @@ export function editorReducer(state: EditorState, action: EditorAction): EditorS
}, },
}, },
__drag: drag, __drag: drag,
} as EditorState & { __drag: DragSession }; } as EditorState;
} }
case 'updateMove': { case 'updateMove': {
const drag = (state as EditorState & { __drag?: DragSession }).__drag; const drag = s.__drag;
if (!state.canvas.isDragging || !drag || drag.kind !== 'move') return state; if (!state.canvas.isDragging || !drag || drag.kind !== 'move') return state;
const scale = state.canvas.scale; const scale = state.canvas.scale;
const dx = (action.current.screenX - drag.startScreenX) / scale; const dx = (action.current.screenX - drag.startScreenX) / scale;
@ -610,6 +636,8 @@ export function editorReducer(state: EditorState, action: EditorAction): EditorS
const nodes = state.doc.screen.nodes.map((n) => { const nodes = state.doc.screen.nodes.map((n) => {
if (!state.selection.ids.includes(n.id)) return n; if (!state.selection.ids.includes(n.id)) return n;
// Locked/hidden nodes should not move, even if selected.
if (n.locked || n.hidden) return n;
const snap0 = drag.snapshot.get(n.id); const snap0 = drag.snapshot.get(n.id);
if (!snap0) return n; if (!snap0) return n;
@ -636,10 +664,9 @@ export function editorReducer(state: EditorState, action: EditorAction): EditorS
} }
case 'endMove': { case 'endMove': {
const s = state as EditorState & { __drag?: DragSession };
const drag = s.__drag; const drag = s.__drag;
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
const { __drag: _drag, ...rest } = s as EditorState & { __drag?: unknown }; const { __drag: _drag, ...rest } = s;
if (!drag || drag.kind !== 'move') { if (!drag || drag.kind !== 'move') {
return { ...rest, canvas: { ...state.canvas, isDragging: false, guides: { xs: [], ys: [] } } }; return { ...rest, canvas: { ...state.canvas, isDragging: false, guides: { xs: [], ys: [] } } };
@ -678,11 +705,11 @@ export function editorReducer(state: EditorState, action: EditorAction): EditorS
...next, ...next,
canvas: { ...next.canvas, isDragging: true, guides: { xs: [], ys: [] } }, canvas: { ...next.canvas, isDragging: true, guides: { xs: [], ys: [] } },
__drag: drag, __drag: drag,
} as EditorState & { __drag: DragSession }; } as EditorState;
} }
case 'updateResize': { case 'updateResize': {
const drag = (state as EditorState & { __drag?: DragSession }).__drag; const drag = s.__drag;
if (!state.canvas.isDragging || !drag || drag.kind !== 'resize' || !drag.targetId || !drag.handle) return state; if (!state.canvas.isDragging || !drag || drag.kind !== 'resize' || !drag.targetId || !drag.handle) return state;
const scale = state.canvas.scale; const scale = state.canvas.scale;
@ -742,10 +769,9 @@ export function editorReducer(state: EditorState, action: EditorAction): EditorS
} }
case 'endResize': { case 'endResize': {
const s = state as EditorState & { __drag?: DragSession };
const drag = s.__drag; const drag = s.__drag;
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
const { __drag: _drag, ...rest } = s as EditorState & { __drag?: unknown }; const { __drag: _drag, ...rest } = s;
if (!drag || drag.kind !== 'resize') { if (!drag || drag.kind !== 'resize') {
return { ...rest, canvas: { ...state.canvas, isDragging: false, guides: { xs: [], ys: [] } } }; return { ...rest, canvas: { ...state.canvas, isDragging: false, guides: { xs: [], ys: [] } } };

View File

@ -63,17 +63,26 @@ export interface GoViewProjectLike {
} }
function unwrapComponent(c: GoViewComponentLike): GoViewComponentLike { function unwrapComponent(c: GoViewComponentLike): GoViewComponentLike {
// Prefer the nested component shape but keep outer fields as fallback. // Prefer nested component shapes but keep outer fields as fallback.
// This handles exports like: { id, attr, component: { chartConfig, option } } // Some exports wrap components multiple times like:
const inner = c.component; // { id, attr, component: { component: { chartConfig, option } } }
if (!inner) return c; // We unwrap iteratively to avoid recursion pitfalls.
return { let out: GoViewComponentLike = c;
let depth = 0;
while (out.component && depth < 6) {
const inner = out.component;
out = {
// Prefer outer for geometry/id, but prefer inner for identity/option when present. // Prefer outer for geometry/id, but prefer inner for identity/option when present.
...c, ...out,
...inner, ...inner,
// ensure the nested component doesn't get lost // keep unwrapping if there are more layers
component: inner.component, component: inner.component,
}; };
depth++;
}
return out;
} }
function keyOf(cIn: GoViewComponentLike): string { function keyOf(cIn: GoViewComponentLike): string {
@ -106,7 +115,17 @@ function isIframe(c: GoViewComponentLike): boolean {
if (k === 'iframe' || k.includes('iframe')) return true; if (k === 'iframe' || k.includes('iframe')) return true;
// Other names seen in low-code editors for embedded web content. // Other names seen in low-code editors for embedded web content.
return k.includes('embed') || k.includes('web') || k.includes('webview') || k.includes('html'); return (
k.includes('embed') ||
k.includes('webview') ||
k.includes('html') ||
k.includes('browser') ||
k.includes('webpage') ||
// keep the plain 'web' check last; it's broad and may overlap other widgets.
k === 'web' ||
k.endsWith('_web') ||
k.startsWith('web_')
);
} }
function isVideo(c: GoViewComponentLike): boolean { function isVideo(c: GoViewComponentLike): boolean {
@ -114,39 +133,97 @@ function isVideo(c: GoViewComponentLike): boolean {
// goView variants: "Video", "VideoCommon", etc. // goView variants: "Video", "VideoCommon", etc.
if (k === 'video' || k.includes('video')) return true; if (k === 'video' || k.includes('video')) return true;
// Misspellings / aliases seen in forks.
if (k.includes('vedio')) return true;
// Other names seen in the wild. // Other names seen in the wild.
return k.includes('mp4') || k.includes('media') || k.includes('player') || k.includes('stream'); return (
k.includes('mp4') ||
k.includes('media') ||
k.includes('player') ||
k.includes('stream') ||
k.includes('rtsp') ||
k.includes('hls') ||
// common low-code names for live streams
k.includes('camera') ||
k.includes('cctv') ||
k.includes('monitor')
);
}
function looksLikeImageOption(option: unknown): boolean {
const url = pickUrlLike(option);
if (!url) return false;
// Common direct image URLs.
if (/\.(png|jpe?g|gif|webp|bmp|svg)(\?|#|$)/i.test(url)) return true;
// data urls
if (/^data:image\//i.test(url)) return true;
return false;
} }
function looksLikeIframeOption(option: unknown): boolean { function looksLikeIframeOption(option: unknown): boolean {
if (!option || typeof option !== 'object') return false; if (!option) return false;
const o = option as Record<string, unknown>;
// Prefer explicit iframe-ish keys. // Prefer explicit iframe-ish keys when option is an object.
if (typeof option === 'object') {
const o = option as Record<string, unknown>;
if ('iframeUrl' in o || 'iframeSrc' in o || 'embedUrl' in o) return true; if ('iframeUrl' in o || 'iframeSrc' in o || 'embedUrl' in o) return true;
// Some exports store raw HTML instead of a URL.
if ('html' in o || 'htmlContent' in o || 'content' in o || 'template' in o) return true;
}
const url = pickUrlLike(option); const url = pickUrlLike(option);
if (!url) return false; if (!url) return false;
// If it isn't an obvious media URL, it's often an iframe/embed. // If it isn't an obvious media URL, it's often an iframe/embed.
// (We deliberately keep this conservative; image/video are handled earlier.) // (We deliberately keep this conservative; image/video are handled earlier.)
return /^https?:\/\//i.test(url) && !/\.(mp4|m3u8|flv)(\?|#|$)/i.test(url); return (
(/^https?:\/\//i.test(url) || /^data:text\/html/i.test(url)) &&
!/\.(png|jpe?g|gif|webp|bmp|svg)(\?|#|$)/i.test(url) &&
// Avoid misclassifying video streams as iframes.
!/\.(mp4|m3u8|flv|webm|mov|m4v|ogv)(\?|#|$)/i.test(url) &&
!/\bm3u8\b/i.test(url) &&
!/^(rtsp|rtmp):\/\//i.test(url)
);
} }
function looksLikeVideoOption(option: unknown): boolean { function looksLikeVideoOption(option: unknown): boolean {
if (!option || typeof option !== 'object') return false; if (!option) return false;
const o = option as Record<string, unknown>;
// Prefer explicit video-ish keys. // Prefer explicit video-ish keys when option is an object.
if ('videoUrl' in o || 'videoSrc' in o || 'mp4' in o || 'm3u8' in o || 'flv' in o || 'hls' in o || 'rtsp' in o) return true; if (typeof option === 'object') {
const o = option as Record<string, unknown>;
if (
'videoUrl' in o ||
'videoSrc' in o ||
'mp4' in o ||
'm3u8' in o ||
'flv' in o ||
'hls' in o ||
'rtsp' in o ||
// list-ish shapes
'sources' in o ||
'sourceList' in o ||
'urlList' in o
) {
return true;
}
}
const url = pickUrlLike(option); const url = pickUrlLike(option);
if (!url) return false; if (!url) return false;
// Common direct URL patterns. // Common direct URL patterns.
if (/\.(mp4|m3u8|flv)(\?|#|$)/i.test(url)) return true; if (/\.(mp4|m3u8|flv|webm|mov|m4v|ogv)(\?|#|$)/i.test(url)) return true;
if (/\bm3u8\b/i.test(url)) return true; if (/\bm3u8\b/i.test(url)) return true;
// Streaming-ish protocols (seen in CCTV/camera widgets).
if (/^(rtsp|rtmp):\/\//i.test(url)) return true;
return false; return false;
} }
@ -180,6 +257,47 @@ function toNumber(v: unknown, fallback: number): number {
return fallback; return fallback;
} }
function toMaybeNumber(v: unknown): number | undefined {
if (typeof v === 'number' && Number.isFinite(v)) return v;
if (typeof v === 'string') {
const n = Number(v);
if (Number.isFinite(n)) return n;
}
return undefined;
}
function pickNumberLike(input: unknown, keys: string[], maxDepth = 2): number | undefined {
return pickNumberLikeInner(input, keys, maxDepth);
}
function pickNumberLikeInner(input: unknown, keys: string[], depth: number): number | undefined {
if (!input) return undefined;
if (typeof input !== 'object') return toMaybeNumber(input);
const obj = input as Record<string, unknown>;
for (const key of keys) {
const v = toMaybeNumber(obj[key]);
if (v !== undefined) return v;
}
if (depth <= 0) return undefined;
for (const key of ['style', 'config', 'option', 'options', 'props', 'dataset', 'data']) {
const nested = pickNumberLikeInner(obj[key], keys, depth - 1);
if (nested !== undefined) return nested;
}
return undefined;
}
function pickSizeLike(option: unknown): { w?: number; h?: number } {
// goView-ish variants use different keys and sometimes nest them under style/config.
const w = pickNumberLike(option, ['width', 'w']);
const h = pickNumberLike(option, ['height', 'h']);
return { w, h };
}
export function convertGoViewProjectToScreen(input: GoViewProjectLike | GoViewStorageLike): Screen { export function convertGoViewProjectToScreen(input: GoViewProjectLike | GoViewStorageLike): Screen {
// goView exports vary a lot; attempt a few common nesting shapes. // goView exports vary a lot; attempt a few common nesting shapes.
const root = input as unknown as Record<string, unknown>; const root = input as unknown as Record<string, unknown>;
@ -232,23 +350,58 @@ export function convertGoViewProjectToScreen(input: GoViewProjectLike | GoViewSt
for (const raw of componentList) { for (const raw of componentList) {
const c = unwrapComponent(raw); const c = unwrapComponent(raw);
const option = optionOf(c);
const rect = c.attr // We try to infer the widget kind early so we can pick better default sizes
// when exports omit sizing information.
// Important: run media/embed checks before text checks.
// Some goView/fork widgets have misleading keys that contain "text" even though
// the option payload is clearly video/iframe.
const inferredType: 'text' | 'image' | 'iframe' | 'video' | undefined =
isImage(c) || looksLikeImageOption(option)
? 'image'
: // Important: run video checks before iframe checks; iframe URL detection is broader.
isVideo(c) || looksLikeVideoOption(option)
? 'video'
: isIframe(c) || looksLikeIframeOption(option)
? 'iframe'
: isTextCommon(c)
? 'text'
: undefined;
const defaults =
inferredType === 'text'
? { w: 320, h: 60 }
: inferredType === 'image'
? { w: 320, h: 180 }
: inferredType === 'iframe'
? { w: 480, h: 270 }
: inferredType === 'video'
? { w: 480, h: 270 }
: { w: 320, h: 60 };
const optSize = pickSizeLike(option);
const attr = c.attr as unknown as Record<string, unknown> | undefined;
const rect = attr
? { ? {
x: toNumber((c.attr as unknown as Record<string, unknown>).x, 0), x: toNumber(attr.x, 0),
y: toNumber((c.attr as unknown as Record<string, unknown>).y, 0), y: toNumber(attr.y, 0),
w: toNumber((c.attr as unknown as Record<string, unknown>).w, 320), // Prefer explicit attr sizing, but fall back to option sizing when missing.
h: toNumber((c.attr as unknown as Record<string, unknown>).h, 60), w: toNumber(attr.w, optSize.w ?? defaults.w),
h: toNumber(attr.h, optSize.h ?? defaults.h),
} }
: { x: 0, y: 0, w: 320, h: 60 }; : { x: 0, y: 0, w: optSize.w ?? defaults.w, h: optSize.h ?? defaults.h };
if (isTextCommon(c)) { const zIndex = attr?.zIndex === undefined ? undefined : toNumber(attr.zIndex, 0);
const props = convertGoViewTextOptionToNodeProps(optionOf(c) as GoViewTextOption);
if (inferredType === 'text') {
const props = convertGoViewTextOptionToNodeProps(option as GoViewTextOption);
nodes.push({ nodes.push({
id: c.id ?? `import_text_${Math.random().toString(16).slice(2)}`, id: c.id ?? `import_text_${Math.random().toString(16).slice(2)}`,
type: 'text', type: 'text',
rect, rect,
zIndex: c.attr?.zIndex === undefined ? undefined : toNumber((c.attr as unknown as Record<string, unknown>).zIndex, 0), zIndex,
locked: c.status?.lock ?? false, locked: c.status?.lock ?? false,
hidden: c.status?.hide ?? false, hidden: c.status?.hide ?? false,
props, props,
@ -256,13 +409,13 @@ export function convertGoViewProjectToScreen(input: GoViewProjectLike | GoViewSt
continue; continue;
} }
if (isImage(c)) { if (inferredType === 'image') {
const props = convertGoViewImageOptionToNodeProps(optionOf(c) as GoViewImageOption); const props = convertGoViewImageOptionToNodeProps(option as GoViewImageOption);
nodes.push({ nodes.push({
id: c.id ?? `import_image_${Math.random().toString(16).slice(2)}`, id: c.id ?? `import_image_${Math.random().toString(16).slice(2)}`,
type: 'image', type: 'image',
rect, rect,
zIndex: c.attr?.zIndex === undefined ? undefined : toNumber((c.attr as unknown as Record<string, unknown>).zIndex, 0), zIndex,
locked: c.status?.lock ?? false, locked: c.status?.lock ?? false,
hidden: c.status?.hide ?? false, hidden: c.status?.hide ?? false,
props, props,
@ -270,15 +423,13 @@ export function convertGoViewProjectToScreen(input: GoViewProjectLike | GoViewSt
continue; continue;
} }
const option = optionOf(c); if (inferredType === 'iframe') {
if (isIframe(c) || looksLikeIframeOption(option)) {
const props = convertGoViewIframeOptionToNodeProps(option as GoViewIframeOption); const props = convertGoViewIframeOptionToNodeProps(option as GoViewIframeOption);
nodes.push({ nodes.push({
id: c.id ?? `import_iframe_${Math.random().toString(16).slice(2)}`, id: c.id ?? `import_iframe_${Math.random().toString(16).slice(2)}`,
type: 'iframe', type: 'iframe',
rect, rect,
zIndex: c.attr?.zIndex === undefined ? undefined : toNumber((c.attr as unknown as Record<string, unknown>).zIndex, 0), zIndex,
locked: c.status?.lock ?? false, locked: c.status?.lock ?? false,
hidden: c.status?.hide ?? false, hidden: c.status?.hide ?? false,
props, props,
@ -286,13 +437,13 @@ export function convertGoViewProjectToScreen(input: GoViewProjectLike | GoViewSt
continue; continue;
} }
if (isVideo(c) || looksLikeVideoOption(option)) { if (inferredType === 'video') {
const props = convertGoViewVideoOptionToNodeProps(option as GoViewVideoOption); const props = convertGoViewVideoOptionToNodeProps(option as GoViewVideoOption);
nodes.push({ nodes.push({
id: c.id ?? `import_video_${Math.random().toString(16).slice(2)}`, id: c.id ?? `import_video_${Math.random().toString(16).slice(2)}`,
type: 'video', type: 'video',
rect, rect,
zIndex: c.attr?.zIndex === undefined ? undefined : toNumber((c.attr as unknown as Record<string, unknown>).zIndex, 0), zIndex,
locked: c.status?.lock ?? false, locked: c.status?.lock ?? false,
hidden: c.status?.hide ?? false, hidden: c.status?.hide ?? false,
props, props,

View File

@ -17,15 +17,66 @@ export interface GoViewIframeOption {
*/ */
export type LegacyIframeOption = GoViewIframeOption; export type LegacyIframeOption = GoViewIframeOption;
function toDataHtmlUrl(html: string): string {
// Keep this simple and safe: use a data URL so we can render the provided HTML
// without needing an external host.
// NOTE: encodeURIComponent is important to preserve characters + avoid breaking the URL.
return `data:text/html;charset=utf-8,${encodeURIComponent(html)}`;
}
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).
return 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;
// Some goView / low-code exports store raw HTML instead of a URL.
const html = pickFromNested(
option,
(obj) => {
const v = obj.html ?? obj.htmlContent ?? obj.content ?? obj.template;
return typeof v === 'string' ? v : undefined;
},
2,
);
return html ? toDataHtmlUrl(html) : '';
}
function toMaybeNumber(v: unknown): number | undefined {
if (typeof v === 'number' && Number.isFinite(v)) return v;
if (typeof v === 'string') {
const n = Number(v);
if (Number.isFinite(n)) return n;
}
return undefined;
}
function pickFromNested<T>(input: unknown, picker: (obj: Record<string, unknown>) => T | undefined, depth: number): T | undefined {
if (!input || typeof input !== 'object') return undefined;
const obj = input as Record<string, unknown>;
const direct = picker(obj);
if (direct !== undefined) return direct;
if (depth <= 0) return undefined;
for (const key of ['style', 'config', 'option', 'options', 'props', 'dataset', 'data']) {
const nested = pickFromNested(obj[key], picker, depth - 1);
if (nested !== undefined) return nested;
}
return undefined;
}
function pickBorderRadius(option: GoViewIframeOption): number | undefined {
const direct = toMaybeNumber(option.borderRadius);
if (direct !== undefined) return direct;
return pickFromNested(option, (obj) => toMaybeNumber(obj.borderRadius ?? obj.radius ?? obj.r), 2);
} }
export function convertGoViewIframeOptionToNodeProps(option: GoViewIframeOption): IframeWidgetNode['props'] { export function convertGoViewIframeOptionToNodeProps(option: GoViewIframeOption): IframeWidgetNode['props'] {
return { return {
src: pickSrc(option), src: pickSrc(option),
borderRadius: option.borderRadius, borderRadius: pickBorderRadius(option),
}; };
} }

View File

@ -34,19 +34,33 @@ function pickUrlLikeInner(input: unknown, depth: number): string {
for (const key of [ for (const key of [
'value', 'value',
'url', 'url',
'uri',
'src', 'src',
'href', 'href',
'link', 'link',
'path', 'path',
'source', 'source',
'address', 'address',
// common aliases
'srcUrl',
'sourceUrl',
'playUrl',
// iframe-ish // iframe-ish
'iframeUrl', 'iframeUrl',
'iframeSrc', 'iframeSrc',
'embedUrl', 'embedUrl',
'frameUrl',
'frameSrc',
'htmlUrl',
'htmlSrc',
// generic web-ish
'webUrl',
'webSrc',
// video-ish // video-ish
'videoUrl', 'videoUrl',
'videoSrc', 'videoSrc',
'vedioUrl',
'vedioSrc',
'mp4', 'mp4',
'm3u8', 'm3u8',
'flv', 'flv',
@ -57,6 +71,11 @@ function pickUrlLikeInner(input: unknown, depth: number): string {
'streamUrl', 'streamUrl',
'rtsp', 'rtsp',
'rtspUrl', 'rtspUrl',
'rtmp',
'rtmpUrl',
// camera-ish
'cameraUrl',
'cameraSrc',
]) { ]) {
const v = obj[key]; const v = obj[key];
if (typeof v === 'string' && v) return v; if (typeof v === 'string' && v) return v;
@ -65,7 +84,25 @@ function pickUrlLikeInner(input: unknown, depth: number): string {
if (depth <= 0) return ''; if (depth <= 0) return '';
// Common nesting keys. // Common nesting keys.
for (const key of ['dataset', 'data', 'config', 'option', 'options', 'props', 'source', 'media']) { for (const key of [
'dataset',
'data',
'config',
'option',
'options',
'props',
'source',
'media',
// common list-ish wrappers for media sources
'sources',
'sourceList',
'urlList',
// widget-ish wrappers seen in exports
'iframe',
'video',
'image',
'img',
]) {
const v = obj[key]; const v = obj[key];
const nested = pickUrlLikeInner(v, depth - 1); const nested = pickUrlLikeInner(v, depth - 1);
if (nested) return nested; if (nested) return nested;

View File

@ -12,6 +12,8 @@ export interface GoViewVideoOption {
loop?: boolean; loop?: boolean;
muted?: boolean; muted?: boolean;
autoplay?: boolean;
autoPlay?: boolean;
fit?: unknown; fit?: unknown;
objectFit?: unknown; objectFit?: unknown;
@ -35,8 +37,73 @@ function asString(v: unknown): string {
return ''; return '';
} }
function toMaybeBoolean(v: unknown): boolean | undefined {
if (typeof v === 'boolean') return v;
if (typeof v === 'number') return v !== 0;
if (typeof v === 'string') {
const s = v.trim().toLowerCase();
if (s === 'true' || s === '1') return true;
if (s === 'false' || s === '0') return false;
}
return undefined;
}
function toMaybeNumber(v: unknown): number | undefined {
if (typeof v === 'number' && Number.isFinite(v)) return v;
if (typeof v === 'string') {
const n = Number(v);
if (Number.isFinite(n)) return n;
}
return undefined;
}
function pickFromNested<T>(input: unknown, picker: (obj: Record<string, unknown>) => T | undefined, depth: number): T | undefined {
if (!input || typeof input !== 'object') return undefined;
const obj = input as Record<string, unknown>;
const direct = picker(obj);
if (direct !== undefined) return direct;
if (depth <= 0) return undefined;
for (const key of ['style', 'config', 'option', 'options', 'props', 'dataset', 'data']) {
const nested = pickFromNested(obj[key], picker, depth - 1);
if (nested !== undefined) return nested;
}
return undefined;
}
function pickBorderRadius(option: GoViewVideoOption): number | undefined {
const direct = toMaybeNumber(option.borderRadius);
if (direct !== undefined) return direct;
return pickFromNested(option, (obj) => toMaybeNumber(obj.borderRadius ?? obj.radius ?? obj.r), 2);
}
function pickBooleanLike(option: GoViewVideoOption, keys: string[]): boolean | undefined {
return pickFromNested(
option,
(obj) => {
for (const key of keys) {
const v = toMaybeBoolean(obj[key]);
if (v !== undefined) return v;
}
return undefined;
},
2,
);
}
function pickFitFromNested(option: GoViewVideoOption): string {
const direct = asString(option.fit) || asString(option.objectFit);
if (direct) return direct;
const nested = pickFromNested(option, (obj) => asString(obj.fit) || asString(obj.objectFit), 2);
return nested ?? '';
}
function pickFit(option: GoViewVideoOption): VideoWidgetNode['props']['fit'] | undefined { function pickFit(option: GoViewVideoOption): VideoWidgetNode['props']['fit'] | undefined {
const raw = asString(option.fit) || asString(option.objectFit); const raw = pickFitFromNested(option);
if (!raw) return undefined; if (!raw) return undefined;
// normalize common variants // normalize common variants
@ -51,10 +118,10 @@ 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),
loop: option.loop, loop: pickBooleanLike(option, ['loop', 'isLoop']),
muted: option.muted, muted: pickBooleanLike(option, ['muted', 'isMuted', 'mute']),
fit: pickFit(option), fit: pickFit(option),
borderRadius: option.borderRadius, borderRadius: pickBorderRadius(option),
}; };
} }