feat: harden goView iframe/video import and editor selection parity

This commit is contained in:
clawdbot 2026-01-27 21:43:01 +08:00
parent e0d39b1a8c
commit 0e1c1b47c6
5 changed files with 196 additions and 31 deletions

View File

@ -425,7 +425,6 @@ 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.
@ -437,6 +436,12 @@ export function Canvas(props: CanvasProps) {
return; 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,6 +536,8 @@ 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' ? ( {node.type === 'text' ? (
@ -648,7 +655,7 @@ function NodeView(props: {
</div> </div>
) : null} ) : null}
{props.selected && <ResizeHandles onPointerDown={props.onResizePointerDown} />} {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': {
@ -584,11 +604,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;
@ -636,10 +656,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 +697,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 +761,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

@ -180,6 +180,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,18 +273,21 @@ 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 optSize = pickSizeLike(option);
const rect = c.attr const rect = c.attr
? { ? {
x: toNumber((c.attr as unknown as Record<string, unknown>).x, 0), x: toNumber((c.attr as unknown as Record<string, unknown>).x, 0),
y: toNumber((c.attr as unknown as Record<string, unknown>).y, 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), // 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((c.attr as unknown as Record<string, unknown>).w, optSize.w ?? 320),
h: toNumber((c.attr as unknown as Record<string, unknown>).h, optSize.h ?? 60),
} }
: { x: 0, y: 0, w: 320, h: 60 }; : { x: 0, y: 0, w: optSize.w ?? 320, h: optSize.h ?? 60 };
if (isTextCommon(c)) { if (isTextCommon(c)) {
const props = convertGoViewTextOptionToNodeProps(optionOf(c) as GoViewTextOption); 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',
@ -257,7 +301,7 @@ export function convertGoViewProjectToScreen(input: GoViewProjectLike | GoViewSt
} }
if (isImage(c)) { if (isImage(c)) {
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',
@ -270,8 +314,6 @@ export function convertGoViewProjectToScreen(input: GoViewProjectLike | GoViewSt
continue; continue;
} }
const option = optionOf(c);
if (isIframe(c) || looksLikeIframeOption(option)) { if (isIframe(c) || looksLikeIframeOption(option)) {
const props = convertGoViewIframeOptionToNodeProps(option as GoViewIframeOption); const props = convertGoViewIframeOptionToNodeProps(option as GoViewIframeOption);
nodes.push({ nodes.push({

View File

@ -22,10 +22,41 @@ function pickSrc(option: GoViewIframeOption): string {
return pickUrlLike(option) || pickUrlLike(option.dataset) || pickUrlLike(option.src) || pickUrlLike(option.url); return pickUrlLike(option) || pickUrlLike(option.dataset) || pickUrlLike(option.src) || pickUrlLike(option.url);
} }
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

@ -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),
}; };
} }