From 5b708faf7bd5becc5c8911b135a3a6ecab9424f8 Mon Sep 17 00:00:00 2001 From: ErSan Date: Tue, 27 Jan 2026 20:23:47 +0800 Subject: [PATCH] feat: improve legacy import and editor selection/context menu --- packages/editor/src/editor/Canvas.tsx | 56 ++++++++++- packages/editor/src/editor/EditorApp.tsx | 12 ++- packages/editor/src/editor/store.ts | 122 ++++++++++++++++++++++- packages/sdk/src/core/goview/convert.ts | 63 ++++++++++-- 4 files changed, 232 insertions(+), 21 deletions(-) diff --git a/packages/editor/src/editor/Canvas.tsx b/packages/editor/src/editor/Canvas.tsx index 15b50d9..4d37546 100644 --- a/packages/editor/src/editor/Canvas.tsx +++ b/packages/editor/src/editor/Canvas.tsx @@ -20,7 +20,7 @@ export interface CanvasProps { onBeginMove(e: React.PointerEvent): void; onUpdateMove(e: PointerEvent): void; onEndMove(): void; - onBeginBoxSelect(e: React.PointerEvent, offsetX: number, offsetY: number): void; + onBeginBoxSelect(e: React.PointerEvent, offsetX: number, offsetY: number, additive: boolean): void; onUpdateBoxSelect(e: PointerEvent, offsetX: number, offsetY: number): void; onEndBoxSelect(): void; onBeginResize(e: React.PointerEvent, id: string, handle: ResizeHandle): void; @@ -31,6 +31,10 @@ export interface CanvasProps { onZoomAt(scale: number, anchorX: number, anchorY: number): void; onDeleteSelected?(): void; onDuplicateSelected?(): void; + onToggleLockSelected?(): void; + onToggleHideSelected?(): void; + onBringToFrontSelected?(): void; + onSendToBackSelected?(): void; } function isPrimaryButton(e: React.PointerEvent | PointerEvent): boolean { @@ -53,6 +57,14 @@ export function Canvas(props: CanvasProps) { const bounds = useMemo(() => ({ w: props.screen.width, h: props.screen.height }), [props.screen.width, props.screen.height]); + const selection = useMemo(() => { + const ids = new Set(props.selectionIds); + return props.screen.nodes.filter((n) => ids.has(n.id)); + }, [props.screen.nodes, props.selectionIds]); + + const selectionAllLocked = selection.length > 0 && selection.every((n) => n.locked); + const selectionAllHidden = selection.length > 0 && selection.every((n) => n.hidden); + const clientToCanvas = useCallback((clientX: number, clientY: number) => { const el = ref.current; if (!el) return null; @@ -195,8 +207,9 @@ export function Canvas(props: CanvasProps) { const p = clientToWorld(e.clientX, e.clientY); if (!p) return; - props.onSelectSingle(undefined); - props.onBeginBoxSelect(e, p.x, p.y); + const additive = e.ctrlKey || e.metaKey; + 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 })); }; @@ -288,6 +301,43 @@ export function Canvas(props: CanvasProps) { setCtx(null); }} /> + + { + props.onToggleLockSelected?.(); + setCtx(null); + }} + /> + { + props.onToggleHideSelected?.(); + setCtx(null); + }} + /> + +
+ + { + props.onBringToFrontSelected?.(); + setCtx(null); + }} + /> + { + props.onSendToBackSelected?.(); + setCtx(null); + }} + /> + dispatch({ type: 'toggleSelect', id })} onDeleteSelected={() => dispatch({ type: 'deleteSelected' })} onDuplicateSelected={() => dispatch({ type: 'duplicateSelected' })} + onToggleLockSelected={() => dispatch({ type: 'toggleLockSelected' })} + onToggleHideSelected={() => dispatch({ type: 'toggleHideSelected' })} + onBringToFrontSelected={() => dispatch({ type: 'bringToFrontSelected' })} + onSendToBackSelected={() => dispatch({ type: 'sendToBackSelected' })} onBeginPan={(e) => { dispatch({ type: 'beginPan', start: { screenX: e.screenX, screenY: e.screenY } }); }} @@ -140,8 +144,12 @@ export function EditorApp() { dispatch({ type: 'updateMove', current: { screenX: e.screenX, screenY: e.screenY }, bounds }); }} onEndMove={() => dispatch({ type: 'endMove' })} - onBeginBoxSelect={(e, offsetX, offsetY) => { - dispatch({ type: 'beginBoxSelect', start: { offsetX, offsetY, screenX: e.screenX, screenY: e.screenY } }); + onBeginBoxSelect={(e, offsetX, offsetY, additive) => { + dispatch({ + type: 'beginBoxSelect', + additive, + start: { offsetX, offsetY, screenX: e.screenX, screenY: e.screenY }, + }); }} onUpdateBoxSelect={(e: PointerEvent, offsetX, offsetY) => { dispatch({ type: 'updateBoxSelect', current: { offsetX, offsetY, screenX: e.screenX, screenY: e.screenY } }); diff --git a/packages/editor/src/editor/store.ts b/packages/editor/src/editor/store.ts index 691213c..f9b84d7 100644 --- a/packages/editor/src/editor/store.ts +++ b/packages/editor/src/editor/store.ts @@ -28,7 +28,11 @@ export type EditorAction = | { type: 'endPan' } | { type: 'selectSingle'; id?: string } | { type: 'toggleSelect'; id: string } - | { type: 'beginBoxSelect'; start: { offsetX: number; offsetY: number; screenX: number; screenY: number } } + | { + type: 'beginBoxSelect'; + additive: boolean; + start: { offsetX: number; offsetY: number; screenX: number; screenY: number }; + } | { type: 'updateBoxSelect'; current: { offsetX: number; offsetY: number; screenX: number; screenY: number } } | { type: 'endBoxSelect' } | { type: 'beginMove'; start: { screenX: number; screenY: number }; bounds: { w: number; h: number } } @@ -42,6 +46,10 @@ export type EditorAction = | { type: 'deleteSelected' } | { type: 'nudgeSelected'; dx: number; dy: number } | { type: 'duplicateSelected' } + | { type: 'toggleLockSelected' } + | { type: 'toggleHideSelected' } + | { type: 'bringToFrontSelected' } + | { type: 'sendToBackSelected' } | { type: 'updateTextProps'; id: string; props: Partial } | { type: 'updateImageProps'; id: string; props: Partial } | { type: 'updateIframeProps'; id: string; props: Partial } @@ -316,6 +324,101 @@ export function editorReducer(state: EditorState, action: EditorAction): EditorS }; } + case 'toggleLockSelected': { + if (!state.selection.ids.length) return state; + const ids = new Set(state.selection.ids); + const selected = state.doc.screen.nodes.filter((n) => ids.has(n.id)); + if (!selected.length) return state; + + const shouldLock = selected.some((n) => !n.locked); + + return { + ...historyPush(state), + doc: { + screen: { + ...state.doc.screen, + nodes: state.doc.screen.nodes.map((n) => (ids.has(n.id) ? { ...n, locked: shouldLock } : n)), + }, + }, + }; + } + + case 'toggleHideSelected': { + if (!state.selection.ids.length) return state; + const ids = new Set(state.selection.ids); + const selected = state.doc.screen.nodes.filter((n) => ids.has(n.id)); + if (!selected.length) return state; + + const shouldHide = selected.some((n) => !n.hidden); + + return { + ...historyPush(state), + doc: { + screen: { + ...state.doc.screen, + nodes: state.doc.screen.nodes.map((n) => (ids.has(n.id) ? { ...n, hidden: shouldHide } : n)), + }, + }, + selection: shouldHide ? { ids: [] } : state.selection, + }; + } + + case 'bringToFrontSelected': { + if (!state.selection.ids.length) return state; + const ids = new Set(state.selection.ids); + const nodes = state.doc.screen.nodes; + + let maxZ = 0; + for (let i = 0; i < nodes.length; i++) { + const n = nodes[i]!; + const z = n.zIndex ?? i; + if (z > maxZ) maxZ = z; + } + + let bump = 0; + return { + ...historyPush(state), + doc: { + screen: { + ...state.doc.screen, + nodes: nodes.map((n) => { + if (!ids.has(n.id)) return n; + bump += 1; + return { ...n, zIndex: maxZ + bump }; + }), + }, + }, + }; + } + + case 'sendToBackSelected': { + if (!state.selection.ids.length) return state; + const ids = new Set(state.selection.ids); + const nodes = state.doc.screen.nodes; + + let minZ = 0; + for (let i = 0; i < nodes.length; i++) { + const n = nodes[i]!; + const z = n.zIndex ?? i; + if (z < minZ) minZ = z; + } + + let bump = 0; + return { + ...historyPush(state), + doc: { + screen: { + ...state.doc.screen, + nodes: nodes.map((n) => { + if (!ids.has(n.id)) return n; + bump += 1; + return { ...n, zIndex: minZ - bump }; + }), + }, + }, + }; + } + case 'beginPan': { return { ...state, @@ -376,6 +479,9 @@ export function editorReducer(state: EditorState, action: EditorAction): EditorS case 'beginBoxSelect': { if (action.start.screenX === 0 && action.start.screenY === 0) return state; + + const baseIds = action.additive ? state.selection.ids : []; + return { ...state, canvas: { @@ -393,8 +499,9 @@ export function editorReducer(state: EditorState, action: EditorAction): EditorS screenY: action.start.screenY, }, }, - selection: { ids: [] }, - }; + selection: { ids: baseIds }, + __boxSelect: { additive: action.additive, baseIds }, + } as EditorState & { __boxSelect: { additive: boolean; baseIds: string[] } }; } case 'updateBoxSelect': { @@ -415,9 +522,12 @@ 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 ids = boxState?.additive ? Array.from(new Set([...(boxState.baseIds ?? []), ...selected])) : selected; + return { ...state, - selection: { ids: selected }, + selection: { ids }, canvas: { ...state.canvas, mouse: { @@ -433,8 +543,10 @@ 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 }; return { - ...state, + ...rest, canvas: { ...state.canvas, isBoxSelecting: false, diff --git a/packages/sdk/src/core/goview/convert.ts b/packages/sdk/src/core/goview/convert.ts index 204c944..e5597cc 100644 --- a/packages/sdk/src/core/goview/convert.ts +++ b/packages/sdk/src/core/goview/convert.ts @@ -17,7 +17,14 @@ export interface GoViewComponentLike { // component identity key?: string; // e.g. "TextCommon" (sometimes) - chartConfig?: { key?: string }; + componentKey?: string; + chartConfig?: { + key?: string; + chartKey?: string; + type?: string; + option?: unknown; + data?: unknown; + }; // geometry attr?: { x: number; y: number; w: number; h: number; zIndex?: number }; @@ -52,7 +59,14 @@ export interface GoViewProjectLike { } function keyOf(c: GoViewComponentLike): string { - return (c.chartConfig?.key ?? c.key ?? '').toLowerCase(); + return ( + c.chartConfig?.key ?? + c.chartConfig?.chartKey ?? + c.chartConfig?.type ?? + c.componentKey ?? + c.key ?? + '' + ).toLowerCase(); } function isTextCommon(c: GoViewComponentLike): boolean { @@ -92,6 +106,28 @@ function pick(...values: Array): T | undefined { return undefined; } +function optionOf(c: GoViewComponentLike): unknown { + const chartData = c.chartConfig?.data as Record | undefined; + return ( + c.option ?? + c.chartConfig?.option ?? + chartData?.option ?? + chartData?.options ?? + chartData?.config ?? + chartData ?? + {} + ); +} + +function toNumber(v: unknown, fallback: number): number { + if (typeof v === 'number' && Number.isFinite(v)) return v; + if (typeof v === 'string') { + const n = Number(v); + if (Number.isFinite(n)) return n; + } + return fallback; +} + 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; @@ -144,16 +180,21 @@ export function convertGoViewProjectToScreen(input: GoViewProjectLike | GoViewSt for (const c of componentList) { const rect = c.attr - ? { x: c.attr.x, y: c.attr.y, w: c.attr.w, h: c.attr.h } + ? { + x: toNumber((c.attr as unknown as Record).x, 0), + y: toNumber((c.attr as unknown as Record).y, 0), + w: toNumber((c.attr as unknown as Record).w, 320), + h: toNumber((c.attr as unknown as Record).h, 60), + } : { x: 0, y: 0, w: 320, h: 60 }; if (isTextCommon(c)) { - const props = convertGoViewTextOptionToNodeProps((c.option ?? {}) as GoViewTextOption); + const props = convertGoViewTextOptionToNodeProps(optionOf(c) as GoViewTextOption); nodes.push({ id: c.id ?? `import_text_${Math.random().toString(16).slice(2)}`, type: 'text', rect, - zIndex: c.attr?.zIndex, + zIndex: c.attr?.zIndex === undefined ? undefined : toNumber((c.attr as unknown as Record).zIndex, 0), locked: c.status?.lock ?? false, hidden: c.status?.hide ?? false, props, @@ -162,12 +203,12 @@ export function convertGoViewProjectToScreen(input: GoViewProjectLike | GoViewSt } if (isImage(c)) { - const props = convertGoViewImageOptionToNodeProps((c.option ?? {}) as GoViewImageOption); + const props = convertGoViewImageOptionToNodeProps(optionOf(c) as GoViewImageOption); nodes.push({ id: c.id ?? `import_image_${Math.random().toString(16).slice(2)}`, type: 'image', rect, - zIndex: c.attr?.zIndex, + zIndex: c.attr?.zIndex === undefined ? undefined : toNumber((c.attr as unknown as Record).zIndex, 0), locked: c.status?.lock ?? false, hidden: c.status?.hide ?? false, props, @@ -176,12 +217,12 @@ export function convertGoViewProjectToScreen(input: GoViewProjectLike | GoViewSt } if (isIframe(c)) { - const props = convertGoViewIframeOptionToNodeProps((c.option ?? {}) as GoViewIframeOption); + const props = convertGoViewIframeOptionToNodeProps(optionOf(c) as GoViewIframeOption); nodes.push({ id: c.id ?? `import_iframe_${Math.random().toString(16).slice(2)}`, type: 'iframe', rect, - zIndex: c.attr?.zIndex, + zIndex: c.attr?.zIndex === undefined ? undefined : toNumber((c.attr as unknown as Record).zIndex, 0), locked: c.status?.lock ?? false, hidden: c.status?.hide ?? false, props, @@ -190,12 +231,12 @@ export function convertGoViewProjectToScreen(input: GoViewProjectLike | GoViewSt } if (isVideo(c)) { - const props = convertGoViewVideoOptionToNodeProps((c.option ?? {}) as GoViewVideoOption); + const props = convertGoViewVideoOptionToNodeProps(optionOf(c) as GoViewVideoOption); nodes.push({ id: c.id ?? `import_video_${Math.random().toString(16).slice(2)}`, type: 'video', rect, - zIndex: c.attr?.zIndex, + zIndex: c.attr?.zIndex === undefined ? undefined : toNumber((c.attr as unknown as Record).zIndex, 0), locked: c.status?.lock ?? false, hidden: c.status?.hide ?? false, props,