feat: harden legacy iframe/video import and editor selection parity
This commit is contained in:
parent
e0d39b1a8c
commit
ee4fc74512
@ -425,7 +425,6 @@ 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.
|
||||
@ -437,6 +436,12 @@ export function Canvas(props: CanvasProps) {
|
||||
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,6 +536,8 @@ 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' ? (
|
||||
@ -648,7 +655,7 @@ function NodeView(props: {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{props.selected && <ResizeHandles onPointerDown={props.onResizePointerDown} />}
|
||||
{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': {
|
||||
@ -584,11 +604,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;
|
||||
@ -636,10 +656,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 +697,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 +761,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: [] } } };
|
||||
|
||||
@ -180,6 +180,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,18 +273,21 @@ export function convertGoViewProjectToScreen(input: GoViewProjectLike | GoViewSt
|
||||
|
||||
for (const raw of componentList) {
|
||||
const c = unwrapComponent(raw);
|
||||
const option = optionOf(c);
|
||||
const optSize = pickSizeLike(option);
|
||||
|
||||
const rect = c.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),
|
||||
// Prefer explicit attr sizing, but fall back to option sizing when missing.
|
||||
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)) {
|
||||
const props = convertGoViewTextOptionToNodeProps(optionOf(c) as GoViewTextOption);
|
||||
const props = convertGoViewTextOptionToNodeProps(option as GoViewTextOption);
|
||||
nodes.push({
|
||||
id: c.id ?? `import_text_${Math.random().toString(16).slice(2)}`,
|
||||
type: 'text',
|
||||
@ -257,7 +301,7 @@ export function convertGoViewProjectToScreen(input: GoViewProjectLike | GoViewSt
|
||||
}
|
||||
|
||||
if (isImage(c)) {
|
||||
const props = convertGoViewImageOptionToNodeProps(optionOf(c) as GoViewImageOption);
|
||||
const props = convertGoViewImageOptionToNodeProps(option as GoViewImageOption);
|
||||
nodes.push({
|
||||
id: c.id ?? `import_image_${Math.random().toString(16).slice(2)}`,
|
||||
type: 'image',
|
||||
@ -270,8 +314,6 @@ export function convertGoViewProjectToScreen(input: GoViewProjectLike | GoViewSt
|
||||
continue;
|
||||
}
|
||||
|
||||
const option = optionOf(c);
|
||||
|
||||
if (isIframe(c) || looksLikeIframeOption(option)) {
|
||||
const props = convertGoViewIframeOptionToNodeProps(option as GoViewIframeOption);
|
||||
nodes.push({
|
||||
|
||||
@ -22,10 +22,41 @@ function pickSrc(option: GoViewIframeOption): string {
|
||||
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'] {
|
||||
return {
|
||||
src: pickSrc(option),
|
||||
borderRadius: option.borderRadius,
|
||||
borderRadius: pickBorderRadius(option),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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