Compare commits
12 Commits
f0a6810c54
...
6f5e587064
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f5e587064 | ||
|
|
4cee6bc6f5 | ||
|
|
67a61d92f5 | ||
|
|
c20e4796ae | ||
|
|
102c4d67c6 | ||
|
|
e58be35cee | ||
|
|
2d032fe050 | ||
|
|
534516d17e | ||
|
|
961f5d3bdc | ||
|
|
5e21e87696 | ||
|
|
01e43a0021 | ||
|
|
0e1c1b47c6 |
@ -6,6 +6,7 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"typecheck": "tsc -b --noEmit",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
|
||||
@ -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 { assertNever } from '@astralview/sdk';
|
||||
import { Button, Space, Typography } from 'antd';
|
||||
import type { ResizeHandle } from './types';
|
||||
import { rectFromPoints } from './geometry';
|
||||
@ -54,6 +55,8 @@ 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]);
|
||||
|
||||
@ -65,22 +68,27 @@ export function Canvas(props: CanvasProps) {
|
||||
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;
|
||||
|
||||
// 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 vh = typeof window === 'undefined' ? 10_000 : window.innerHeight;
|
||||
|
||||
return {
|
||||
x: Math.max(8, Math.min(ctx.clientX, vw - w - 8)),
|
||||
y: Math.max(8, Math.min(ctx.clientY, vh - h - 8)),
|
||||
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]);
|
||||
}, [ctx, ctxMenuSize.h, ctxMenuSize.w]);
|
||||
|
||||
const clientToCanvas = useCallback((clientX: number, clientY: number) => {
|
||||
const el = ref.current;
|
||||
@ -174,14 +182,26 @@ export function Canvas(props: CanvasProps) {
|
||||
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('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]);
|
||||
|
||||
@ -192,7 +212,8 @@ export function Canvas(props: CanvasProps) {
|
||||
const p = clientToWorld(e.clientX, e.clientY);
|
||||
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 (!props.selectionIds.includes(targetId)) {
|
||||
@ -231,7 +252,7 @@ export function Canvas(props: CanvasProps) {
|
||||
const p = clientToWorld(e.clientX, e.clientY);
|
||||
if (!p) return;
|
||||
|
||||
const additive = e.ctrlKey || e.metaKey;
|
||||
const additive = e.ctrlKey || e.metaKey || e.shiftKey;
|
||||
if (!additive) props.onSelectSingle(undefined);
|
||||
props.onBeginBoxSelect(e, p.x, p.y, additive);
|
||||
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 && (
|
||||
<div
|
||||
ref={ctxMenuRef}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: ctxMenuPos.x,
|
||||
@ -425,18 +447,37 @@ export function Canvas(props: CanvasProps) {
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (node.locked) return;
|
||||
|
||||
// Right-click selection is handled by the contextmenu handler.
|
||||
// Important: don't collapse a multi-selection on pointerdown.
|
||||
if (e.button === 2) return;
|
||||
|
||||
// ctrl click: multi-select toggle
|
||||
if (props.keyboard.ctrl) {
|
||||
const additive = e.ctrlKey || e.metaKey || e.shiftKey;
|
||||
|
||||
// Ctrl/Cmd/Shift click: multi-select toggle
|
||||
if (additive) {
|
||||
props.onToggleSelect(node.id);
|
||||
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.onBeginMove(e);
|
||||
}}
|
||||
@ -531,9 +572,14 @@ function NodeView(props: {
|
||||
color: '#fff',
|
||||
boxSizing: 'border-box',
|
||||
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
|
||||
style={{
|
||||
width: '100%',
|
||||
@ -575,7 +621,10 @@ function NodeView(props: {
|
||||
{node.props.text}
|
||||
</span>
|
||||
</div>
|
||||
) : node.type === 'image' ? (
|
||||
);
|
||||
|
||||
case 'image':
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
@ -597,7 +646,10 @@ function NodeView(props: {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : node.type === 'iframe' ? (
|
||||
);
|
||||
|
||||
case 'iframe':
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
@ -619,7 +671,10 @@ function NodeView(props: {
|
||||
title={node.id}
|
||||
/>
|
||||
</div>
|
||||
) : node.type === 'video' ? (
|
||||
);
|
||||
|
||||
case 'video':
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
@ -646,9 +701,14 @@ function NodeView(props: {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
);
|
||||
|
||||
{props.selected && <ResizeHandles onPointerDown={props.onResizePointerDown} />}
|
||||
default:
|
||||
return assertNever(node);
|
||||
}
|
||||
})()}
|
||||
|
||||
{props.selected && !node.locked && !node.hidden && <ResizeHandles onPointerDown={props.onResizePointerDown} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -65,6 +65,24 @@ interface DragSession {
|
||||
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 {
|
||||
return {
|
||||
...state,
|
||||
@ -123,6 +141,7 @@ export function createInitialState(): EditorState {
|
||||
}
|
||||
|
||||
export function editorReducer(state: EditorState, action: EditorAction): EditorState {
|
||||
const s = state as InternalEditorState;
|
||||
switch (action.type) {
|
||||
case 'keyboard':
|
||||
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)),
|
||||
},
|
||||
},
|
||||
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,
|
||||
startPanY: state.canvas.panY,
|
||||
},
|
||||
} as EditorState & { __pan: { startScreenX: number; startScreenY: number; startPanX: number; startPanY: number } };
|
||||
} as EditorState;
|
||||
}
|
||||
|
||||
case 'updatePan': {
|
||||
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;
|
||||
const scale = state.canvas.scale;
|
||||
const dx = (action.current.screenX - pan.startScreenX) / scale;
|
||||
@ -456,14 +476,14 @@ export function editorReducer(state: EditorState, action: EditorAction): EditorS
|
||||
case 'endPan': {
|
||||
if (!state.canvas.isPanning) return state;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { __pan: _pan, ...rest } = state as EditorState & { __pan?: unknown };
|
||||
const { __pan: _pan, ...rest } = s;
|
||||
return {
|
||||
...rest,
|
||||
canvas: {
|
||||
...state.canvas,
|
||||
isPanning: false,
|
||||
},
|
||||
};
|
||||
} as EditorState;
|
||||
}
|
||||
|
||||
case 'selectSingle':
|
||||
@ -501,7 +521,7 @@ export function editorReducer(state: EditorState, action: EditorAction): EditorS
|
||||
},
|
||||
selection: { ids: baseIds },
|
||||
__boxSelect: { additive: action.additive, baseIds },
|
||||
} as EditorState & { __boxSelect: { additive: boolean; baseIds: string[] } };
|
||||
} as EditorState;
|
||||
}
|
||||
|
||||
case 'updateBoxSelect': {
|
||||
@ -522,7 +542,7 @@ export function editorReducer(state: EditorState, action: EditorAction): EditorS
|
||||
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;
|
||||
|
||||
return {
|
||||
@ -544,7 +564,7 @@ export function editorReducer(state: EditorState, action: EditorAction): EditorS
|
||||
case 'endBoxSelect': {
|
||||
if (!state.canvas.isBoxSelecting) return state;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { __boxSelect: _box, ...rest } = state as EditorState & { __boxSelect?: unknown };
|
||||
const { __boxSelect: _box, ...rest } = s;
|
||||
return {
|
||||
...rest,
|
||||
canvas: {
|
||||
@ -552,7 +572,7 @@ export function editorReducer(state: EditorState, action: EditorAction): EditorS
|
||||
isBoxSelecting: false,
|
||||
mouse: { ...state.canvas.mouse, offsetX: 0, offsetY: 0, offsetStartX: 0, offsetStartY: 0 },
|
||||
},
|
||||
};
|
||||
} as EditorState;
|
||||
}
|
||||
|
||||
case 'beginMove': {
|
||||
@ -564,12 +584,18 @@ export function editorReducer(state: EditorState, action: EditorAction): EditorS
|
||||
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) {
|
||||
const node = state.doc.screen.nodes.find((n) => n.id === id);
|
||||
if (!node) continue;
|
||||
if (node.locked || node.hidden) continue;
|
||||
drag.snapshot.set(id, { ...node.rect });
|
||||
}
|
||||
|
||||
// Nothing movable selected → no-op (avoid entering a dragging state).
|
||||
if (!drag.snapshot.size) return state;
|
||||
|
||||
return {
|
||||
...state,
|
||||
canvas: {
|
||||
@ -584,11 +610,11 @@ export function editorReducer(state: EditorState, action: EditorAction): EditorS
|
||||
},
|
||||
},
|
||||
__drag: drag,
|
||||
} as EditorState & { __drag: DragSession };
|
||||
} as EditorState;
|
||||
}
|
||||
|
||||
case 'updateMove': {
|
||||
const drag = (state as EditorState & { __drag?: DragSession }).__drag;
|
||||
const drag = s.__drag;
|
||||
if (!state.canvas.isDragging || !drag || drag.kind !== 'move') return state;
|
||||
const scale = state.canvas.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) => {
|
||||
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);
|
||||
if (!snap0) return n;
|
||||
|
||||
@ -636,10 +664,9 @@ export function editorReducer(state: EditorState, action: EditorAction): EditorS
|
||||
}
|
||||
|
||||
case 'endMove': {
|
||||
const s = state as EditorState & { __drag?: DragSession };
|
||||
const drag = s.__drag;
|
||||
// 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') {
|
||||
return { ...rest, canvas: { ...state.canvas, isDragging: false, guides: { xs: [], ys: [] } } };
|
||||
@ -678,11 +705,11 @@ export function editorReducer(state: EditorState, action: EditorAction): EditorS
|
||||
...next,
|
||||
canvas: { ...next.canvas, isDragging: true, guides: { xs: [], ys: [] } },
|
||||
__drag: drag,
|
||||
} as EditorState & { __drag: DragSession };
|
||||
} as EditorState;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
const scale = state.canvas.scale;
|
||||
@ -742,10 +769,9 @@ export function editorReducer(state: EditorState, action: EditorAction): EditorS
|
||||
}
|
||||
|
||||
case 'endResize': {
|
||||
const s = state as EditorState & { __drag?: DragSession };
|
||||
const drag = s.__drag;
|
||||
// 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') {
|
||||
return { ...rest, canvas: { ...state.canvas, isDragging: false, guides: { xs: [], ys: [] } } };
|
||||
|
||||
@ -63,17 +63,26 @@ export interface GoViewProjectLike {
|
||||
}
|
||||
|
||||
function unwrapComponent(c: GoViewComponentLike): GoViewComponentLike {
|
||||
// Prefer the nested component shape but keep outer fields as fallback.
|
||||
// This handles exports like: { id, attr, component: { chartConfig, option } }
|
||||
const inner = c.component;
|
||||
if (!inner) return c;
|
||||
return {
|
||||
// Prefer nested component shapes but keep outer fields as fallback.
|
||||
// Some exports wrap components multiple times like:
|
||||
// { id, attr, component: { component: { chartConfig, option } } }
|
||||
// We unwrap iteratively to avoid recursion pitfalls.
|
||||
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.
|
||||
...c,
|
||||
...out,
|
||||
...inner,
|
||||
// ensure the nested component doesn't get lost
|
||||
// keep unwrapping if there are more layers
|
||||
component: inner.component,
|
||||
};
|
||||
depth++;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function keyOf(cIn: GoViewComponentLike): string {
|
||||
@ -106,7 +115,17 @@ function isIframe(c: GoViewComponentLike): boolean {
|
||||
if (k === 'iframe' || k.includes('iframe')) return true;
|
||||
|
||||
// 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 {
|
||||
@ -114,39 +133,97 @@ function isVideo(c: GoViewComponentLike): boolean {
|
||||
// goView variants: "Video", "VideoCommon", etc.
|
||||
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.
|
||||
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 {
|
||||
if (!option || typeof option !== 'object') return false;
|
||||
const o = option as Record<string, unknown>;
|
||||
if (!option) return false;
|
||||
|
||||
// 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;
|
||||
|
||||
// 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);
|
||||
if (!url) return false;
|
||||
|
||||
// If it isn't an obvious media URL, it's often an iframe/embed.
|
||||
// (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 {
|
||||
if (!option || typeof option !== 'object') return false;
|
||||
const o = option as Record<string, unknown>;
|
||||
if (!option) return false;
|
||||
|
||||
// Prefer explicit video-ish keys.
|
||||
if ('videoUrl' in o || 'videoSrc' in o || 'mp4' in o || 'm3u8' in o || 'flv' in o || 'hls' in o || 'rtsp' in o) return true;
|
||||
// Prefer explicit video-ish keys when option is an object.
|
||||
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);
|
||||
if (!url) return false;
|
||||
|
||||
// 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;
|
||||
|
||||
// Streaming-ish protocols (seen in CCTV/camera widgets).
|
||||
if (/^(rtsp|rtmp):\/\//i.test(url)) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -180,6 +257,47 @@ function toNumber(v: unknown, fallback: number): number {
|
||||
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 {
|
||||
// goView exports vary a lot; attempt a few common nesting shapes.
|
||||
const root = input as unknown as Record<string, unknown>;
|
||||
@ -232,23 +350,58 @@ export function convertGoViewProjectToScreen(input: GoViewProjectLike | GoViewSt
|
||||
|
||||
for (const raw of componentList) {
|
||||
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),
|
||||
y: toNumber((c.attr as unknown as Record<string, unknown>).y, 0),
|
||||
w: toNumber((c.attr as unknown as Record<string, unknown>).w, 320),
|
||||
h: toNumber((c.attr as unknown as Record<string, unknown>).h, 60),
|
||||
x: toNumber(attr.x, 0),
|
||||
y: toNumber(attr.y, 0),
|
||||
// Prefer explicit attr sizing, but fall back to option sizing when missing.
|
||||
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 props = convertGoViewTextOptionToNodeProps(optionOf(c) as GoViewTextOption);
|
||||
const zIndex = attr?.zIndex === undefined ? undefined : toNumber(attr.zIndex, 0);
|
||||
|
||||
if (inferredType === 'text') {
|
||||
const props = convertGoViewTextOptionToNodeProps(option as GoViewTextOption);
|
||||
nodes.push({
|
||||
id: c.id ?? `import_text_${Math.random().toString(16).slice(2)}`,
|
||||
type: 'text',
|
||||
rect,
|
||||
zIndex: c.attr?.zIndex === undefined ? undefined : toNumber((c.attr as unknown as Record<string, unknown>).zIndex, 0),
|
||||
zIndex,
|
||||
locked: c.status?.lock ?? false,
|
||||
hidden: c.status?.hide ?? false,
|
||||
props,
|
||||
@ -256,13 +409,13 @@ export function convertGoViewProjectToScreen(input: GoViewProjectLike | GoViewSt
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isImage(c)) {
|
||||
const props = convertGoViewImageOptionToNodeProps(optionOf(c) as GoViewImageOption);
|
||||
if (inferredType === 'image') {
|
||||
const props = convertGoViewImageOptionToNodeProps(option as GoViewImageOption);
|
||||
nodes.push({
|
||||
id: c.id ?? `import_image_${Math.random().toString(16).slice(2)}`,
|
||||
type: 'image',
|
||||
rect,
|
||||
zIndex: c.attr?.zIndex === undefined ? undefined : toNumber((c.attr as unknown as Record<string, unknown>).zIndex, 0),
|
||||
zIndex,
|
||||
locked: c.status?.lock ?? false,
|
||||
hidden: c.status?.hide ?? false,
|
||||
props,
|
||||
@ -270,15 +423,13 @@ export function convertGoViewProjectToScreen(input: GoViewProjectLike | GoViewSt
|
||||
continue;
|
||||
}
|
||||
|
||||
const option = optionOf(c);
|
||||
|
||||
if (isIframe(c) || looksLikeIframeOption(option)) {
|
||||
if (inferredType === 'iframe') {
|
||||
const props = convertGoViewIframeOptionToNodeProps(option as GoViewIframeOption);
|
||||
nodes.push({
|
||||
id: c.id ?? `import_iframe_${Math.random().toString(16).slice(2)}`,
|
||||
type: 'iframe',
|
||||
rect,
|
||||
zIndex: c.attr?.zIndex === undefined ? undefined : toNumber((c.attr as unknown as Record<string, unknown>).zIndex, 0),
|
||||
zIndex,
|
||||
locked: c.status?.lock ?? false,
|
||||
hidden: c.status?.hide ?? false,
|
||||
props,
|
||||
@ -286,13 +437,13 @@ export function convertGoViewProjectToScreen(input: GoViewProjectLike | GoViewSt
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isVideo(c) || looksLikeVideoOption(option)) {
|
||||
if (inferredType === 'video') {
|
||||
const props = convertGoViewVideoOptionToNodeProps(option as GoViewVideoOption);
|
||||
nodes.push({
|
||||
id: c.id ?? `import_video_${Math.random().toString(16).slice(2)}`,
|
||||
type: 'video',
|
||||
rect,
|
||||
zIndex: c.attr?.zIndex === undefined ? undefined : toNumber((c.attr as unknown as Record<string, unknown>).zIndex, 0),
|
||||
zIndex,
|
||||
locked: c.status?.lock ?? false,
|
||||
hidden: c.status?.hide ?? false,
|
||||
props,
|
||||
|
||||
@ -17,15 +17,66 @@ export interface 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 {
|
||||
// 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'] {
|
||||
return {
|
||||
src: pickSrc(option),
|
||||
borderRadius: option.borderRadius,
|
||||
borderRadius: pickBorderRadius(option),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -34,19 +34,33 @@ function pickUrlLikeInner(input: unknown, depth: number): string {
|
||||
for (const key of [
|
||||
'value',
|
||||
'url',
|
||||
'uri',
|
||||
'src',
|
||||
'href',
|
||||
'link',
|
||||
'path',
|
||||
'source',
|
||||
'address',
|
||||
// common aliases
|
||||
'srcUrl',
|
||||
'sourceUrl',
|
||||
'playUrl',
|
||||
// iframe-ish
|
||||
'iframeUrl',
|
||||
'iframeSrc',
|
||||
'embedUrl',
|
||||
'frameUrl',
|
||||
'frameSrc',
|
||||
'htmlUrl',
|
||||
'htmlSrc',
|
||||
// generic web-ish
|
||||
'webUrl',
|
||||
'webSrc',
|
||||
// video-ish
|
||||
'videoUrl',
|
||||
'videoSrc',
|
||||
'vedioUrl',
|
||||
'vedioSrc',
|
||||
'mp4',
|
||||
'm3u8',
|
||||
'flv',
|
||||
@ -57,6 +71,11 @@ function pickUrlLikeInner(input: unknown, depth: number): string {
|
||||
'streamUrl',
|
||||
'rtsp',
|
||||
'rtspUrl',
|
||||
'rtmp',
|
||||
'rtmpUrl',
|
||||
// camera-ish
|
||||
'cameraUrl',
|
||||
'cameraSrc',
|
||||
]) {
|
||||
const v = obj[key];
|
||||
if (typeof v === 'string' && v) return v;
|
||||
@ -65,7 +84,25 @@ function pickUrlLikeInner(input: unknown, depth: number): string {
|
||||
if (depth <= 0) return '';
|
||||
|
||||
// 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 nested = pickUrlLikeInner(v, depth - 1);
|
||||
if (nested) return nested;
|
||||
|
||||
@ -12,6 +12,8 @@ export interface GoViewVideoOption {
|
||||
|
||||
loop?: boolean;
|
||||
muted?: boolean;
|
||||
autoplay?: boolean;
|
||||
autoPlay?: boolean;
|
||||
|
||||
fit?: unknown;
|
||||
objectFit?: unknown;
|
||||
@ -35,8 +37,73 @@ function asString(v: unknown): string {
|
||||
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 {
|
||||
const raw = asString(option.fit) || asString(option.objectFit);
|
||||
const raw = pickFitFromNested(option);
|
||||
if (!raw) return undefined;
|
||||
|
||||
// normalize common variants
|
||||
@ -51,10 +118,10 @@ function pickFit(option: GoViewVideoOption): VideoWidgetNode['props']['fit'] | u
|
||||
export function convertGoViewVideoOptionToNodeProps(option: GoViewVideoOption): VideoWidgetNode['props'] {
|
||||
return {
|
||||
src: pickSrc(option),
|
||||
loop: option.loop,
|
||||
muted: option.muted,
|
||||
loop: pickBooleanLike(option, ['loop', 'isLoop']),
|
||||
muted: pickBooleanLike(option, ['muted', 'isMuted', 'mute']),
|
||||
fit: pickFit(option),
|
||||
borderRadius: option.borderRadius,
|
||||
borderRadius: pickBorderRadius(option),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user