feat: improve goView import and editor selection/context menu

This commit is contained in:
ErSan 2026-01-27 20:23:47 +08:00
parent acfc780bec
commit 1bab5905b8
4 changed files with 232 additions and 21 deletions

View File

@ -20,7 +20,7 @@ export interface CanvasProps {
onBeginMove(e: React.PointerEvent): void; onBeginMove(e: React.PointerEvent): void;
onUpdateMove(e: PointerEvent): void; onUpdateMove(e: PointerEvent): void;
onEndMove(): 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; onUpdateBoxSelect(e: PointerEvent, offsetX: number, offsetY: number): void;
onEndBoxSelect(): void; onEndBoxSelect(): void;
onBeginResize(e: React.PointerEvent, id: string, handle: ResizeHandle): 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; onZoomAt(scale: number, anchorX: number, anchorY: number): void;
onDeleteSelected?(): void; onDeleteSelected?(): void;
onDuplicateSelected?(): void; onDuplicateSelected?(): void;
onToggleLockSelected?(): void;
onToggleHideSelected?(): void;
onBringToFrontSelected?(): void;
onSendToBackSelected?(): void;
} }
function isPrimaryButton(e: React.PointerEvent | PointerEvent): boolean { 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 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 clientToCanvas = useCallback((clientX: number, clientY: number) => {
const el = ref.current; const el = ref.current;
if (!el) return null; if (!el) return null;
@ -195,8 +207,9 @@ 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;
props.onSelectSingle(undefined); const additive = e.ctrlKey || e.metaKey;
props.onBeginBoxSelect(e, p.x, p.y); 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 })); 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); setCtx(null);
}} }}
/> />
<MenuItem
label={selectionAllLocked ? 'Unlock' : 'Lock'}
disabled={!props.selectionIds.length || !props.onToggleLockSelected}
onClick={() => {
props.onToggleLockSelected?.();
setCtx(null);
}}
/>
<MenuItem
label={selectionAllHidden ? 'Show' : 'Hide'}
disabled={!props.selectionIds.length || !props.onToggleHideSelected}
onClick={() => {
props.onToggleHideSelected?.();
setCtx(null);
}}
/>
<div style={{ height: 1, margin: '6px 0', background: 'rgba(255,255,255,0.08)' }} />
<MenuItem
label="Bring To Front"
disabled={!props.selectionIds.length || !props.onBringToFrontSelected}
onClick={() => {
props.onBringToFrontSelected?.();
setCtx(null);
}}
/>
<MenuItem
label="Send To Back"
disabled={!props.selectionIds.length || !props.onSendToBackSelected}
onClick={() => {
props.onSendToBackSelected?.();
setCtx(null);
}}
/>
<MenuItem <MenuItem
label="Delete" label="Delete"
danger danger

View File

@ -126,6 +126,10 @@ export function EditorApp() {
onToggleSelect={(id) => dispatch({ type: 'toggleSelect', id })} onToggleSelect={(id) => dispatch({ type: 'toggleSelect', id })}
onDeleteSelected={() => dispatch({ type: 'deleteSelected' })} onDeleteSelected={() => dispatch({ type: 'deleteSelected' })}
onDuplicateSelected={() => dispatch({ type: 'duplicateSelected' })} onDuplicateSelected={() => dispatch({ type: 'duplicateSelected' })}
onToggleLockSelected={() => dispatch({ type: 'toggleLockSelected' })}
onToggleHideSelected={() => dispatch({ type: 'toggleHideSelected' })}
onBringToFrontSelected={() => dispatch({ type: 'bringToFrontSelected' })}
onSendToBackSelected={() => dispatch({ type: 'sendToBackSelected' })}
onBeginPan={(e) => { onBeginPan={(e) => {
dispatch({ type: 'beginPan', start: { screenX: e.screenX, screenY: e.screenY } }); 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 }); dispatch({ type: 'updateMove', current: { screenX: e.screenX, screenY: e.screenY }, bounds });
}} }}
onEndMove={() => dispatch({ type: 'endMove' })} onEndMove={() => dispatch({ type: 'endMove' })}
onBeginBoxSelect={(e, offsetX, offsetY) => { onBeginBoxSelect={(e, offsetX, offsetY, additive) => {
dispatch({ type: 'beginBoxSelect', start: { offsetX, offsetY, screenX: e.screenX, screenY: e.screenY } }); dispatch({
type: 'beginBoxSelect',
additive,
start: { offsetX, offsetY, screenX: e.screenX, screenY: e.screenY },
});
}} }}
onUpdateBoxSelect={(e: PointerEvent, offsetX, offsetY) => { onUpdateBoxSelect={(e: PointerEvent, offsetX, offsetY) => {
dispatch({ type: 'updateBoxSelect', current: { offsetX, offsetY, screenX: e.screenX, screenY: e.screenY } }); dispatch({ type: 'updateBoxSelect', current: { offsetX, offsetY, screenX: e.screenX, screenY: e.screenY } });

View File

@ -28,7 +28,11 @@ export type EditorAction =
| { type: 'endPan' } | { type: 'endPan' }
| { type: 'selectSingle'; id?: string } | { type: 'selectSingle'; id?: string }
| { type: 'toggleSelect'; 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: 'updateBoxSelect'; current: { offsetX: number; offsetY: number; screenX: number; screenY: number } }
| { type: 'endBoxSelect' } | { type: 'endBoxSelect' }
| { type: 'beginMove'; start: { screenX: number; screenY: number }; bounds: { w: number; h: number } } | { type: 'beginMove'; start: { screenX: number; screenY: number }; bounds: { w: number; h: number } }
@ -42,6 +46,10 @@ export type EditorAction =
| { type: 'deleteSelected' } | { type: 'deleteSelected' }
| { type: 'nudgeSelected'; dx: number; dy: number } | { type: 'nudgeSelected'; dx: number; dy: number }
| { type: 'duplicateSelected' } | { type: 'duplicateSelected' }
| { type: 'toggleLockSelected' }
| { type: 'toggleHideSelected' }
| { type: 'bringToFrontSelected' }
| { type: 'sendToBackSelected' }
| { type: 'updateTextProps'; id: string; props: Partial<TextWidgetNode['props']> } | { type: 'updateTextProps'; id: string; props: Partial<TextWidgetNode['props']> }
| { type: 'updateImageProps'; id: string; props: Partial<ImageWidgetNode['props']> } | { type: 'updateImageProps'; id: string; props: Partial<ImageWidgetNode['props']> }
| { type: 'updateIframeProps'; id: string; props: Partial<IframeWidgetNode['props']> } | { type: 'updateIframeProps'; id: string; props: Partial<IframeWidgetNode['props']> }
@ -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': { case 'beginPan': {
return { return {
...state, ...state,
@ -376,6 +479,9 @@ export function editorReducer(state: EditorState, action: EditorAction): EditorS
case 'beginBoxSelect': { case 'beginBoxSelect': {
if (action.start.screenX === 0 && action.start.screenY === 0) return state; if (action.start.screenX === 0 && action.start.screenY === 0) return state;
const baseIds = action.additive ? state.selection.ids : [];
return { return {
...state, ...state,
canvas: { canvas: {
@ -393,8 +499,9 @@ export function editorReducer(state: EditorState, action: EditorAction): EditorS
screenY: action.start.screenY, screenY: action.start.screenY,
}, },
}, },
selection: { ids: [] }, selection: { ids: baseIds },
}; __boxSelect: { additive: action.additive, baseIds },
} as EditorState & { __boxSelect: { additive: boolean; baseIds: string[] } };
} }
case 'updateBoxSelect': { case 'updateBoxSelect': {
@ -415,9 +522,12 @@ 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 ids = boxState?.additive ? Array.from(new Set([...(boxState.baseIds ?? []), ...selected])) : selected;
return { return {
...state, ...state,
selection: { ids: selected }, selection: { ids },
canvas: { canvas: {
...state.canvas, ...state.canvas,
mouse: { mouse: {
@ -433,8 +543,10 @@ 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
const { __boxSelect: _box, ...rest } = state as EditorState & { __boxSelect?: unknown };
return { return {
...state, ...rest,
canvas: { canvas: {
...state.canvas, ...state.canvas,
isBoxSelecting: false, isBoxSelecting: false,

View File

@ -17,7 +17,14 @@ export interface GoViewComponentLike {
// component identity // component identity
key?: string; // e.g. "TextCommon" (sometimes) key?: string; // e.g. "TextCommon" (sometimes)
chartConfig?: { key?: string }; componentKey?: string;
chartConfig?: {
key?: string;
chartKey?: string;
type?: string;
option?: unknown;
data?: unknown;
};
// geometry // geometry
attr?: { x: number; y: number; w: number; h: number; zIndex?: number }; attr?: { x: number; y: number; w: number; h: number; zIndex?: number };
@ -52,7 +59,14 @@ export interface GoViewProjectLike {
} }
function keyOf(c: GoViewComponentLike): string { 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 { function isTextCommon(c: GoViewComponentLike): boolean {
@ -92,6 +106,28 @@ function pick<T>(...values: Array<T | undefined | null>): T | undefined {
return undefined; return undefined;
} }
function optionOf(c: GoViewComponentLike): unknown {
const chartData = c.chartConfig?.data as Record<string, unknown> | 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 { 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>;
@ -144,16 +180,21 @@ export function convertGoViewProjectToScreen(input: GoViewProjectLike | GoViewSt
for (const c of componentList) { for (const c of componentList) {
const rect = c.attr 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<string, unknown>).x, 0),
y: toNumber((c.attr as unknown as Record<string, unknown>).y, 0),
w: toNumber((c.attr as unknown as Record<string, unknown>).w, 320),
h: toNumber((c.attr as unknown as Record<string, unknown>).h, 60),
}
: { x: 0, y: 0, w: 320, h: 60 }; : { x: 0, y: 0, w: 320, h: 60 };
if (isTextCommon(c)) { if (isTextCommon(c)) {
const props = convertGoViewTextOptionToNodeProps((c.option ?? {}) as GoViewTextOption); const props = convertGoViewTextOptionToNodeProps(optionOf(c) 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, zIndex: c.attr?.zIndex === undefined ? undefined : toNumber((c.attr as unknown as Record<string, unknown>).zIndex, 0),
locked: c.status?.lock ?? false, locked: c.status?.lock ?? false,
hidden: c.status?.hide ?? false, hidden: c.status?.hide ?? false,
props, props,
@ -162,12 +203,12 @@ export function convertGoViewProjectToScreen(input: GoViewProjectLike | GoViewSt
} }
if (isImage(c)) { if (isImage(c)) {
const props = convertGoViewImageOptionToNodeProps((c.option ?? {}) as GoViewImageOption); const props = convertGoViewImageOptionToNodeProps(optionOf(c) 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, zIndex: c.attr?.zIndex === undefined ? undefined : toNumber((c.attr as unknown as Record<string, unknown>).zIndex, 0),
locked: c.status?.lock ?? false, locked: c.status?.lock ?? false,
hidden: c.status?.hide ?? false, hidden: c.status?.hide ?? false,
props, props,
@ -176,12 +217,12 @@ export function convertGoViewProjectToScreen(input: GoViewProjectLike | GoViewSt
} }
if (isIframe(c)) { if (isIframe(c)) {
const props = convertGoViewIframeOptionToNodeProps((c.option ?? {}) as GoViewIframeOption); const props = convertGoViewIframeOptionToNodeProps(optionOf(c) 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, zIndex: c.attr?.zIndex === undefined ? undefined : toNumber((c.attr as unknown as Record<string, unknown>).zIndex, 0),
locked: c.status?.lock ?? false, locked: c.status?.lock ?? false,
hidden: c.status?.hide ?? false, hidden: c.status?.hide ?? false,
props, props,
@ -190,12 +231,12 @@ export function convertGoViewProjectToScreen(input: GoViewProjectLike | GoViewSt
} }
if (isVideo(c)) { if (isVideo(c)) {
const props = convertGoViewVideoOptionToNodeProps((c.option ?? {}) as GoViewVideoOption); const props = convertGoViewVideoOptionToNodeProps(optionOf(c) 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, zIndex: c.attr?.zIndex === undefined ? undefined : toNumber((c.attr as unknown as Record<string, unknown>).zIndex, 0),
locked: c.status?.lock ?? false, locked: c.status?.lock ?? false,
hidden: c.status?.hide ?? false, hidden: c.status?.hide ?? false,
props, props,