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": {
|
"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"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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: [] } } };
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user