feat: improve legacy import and editor selection/context menu
This commit is contained in:
parent
de4243ca10
commit
5b708faf7b
@ -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);
|
||||
}}
|
||||
/>
|
||||
|
||||
<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
|
||||
label="Delete"
|
||||
danger
|
||||
|
||||
@ -126,6 +126,10 @@ export function EditorApp() {
|
||||
onToggleSelect={(id) => 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 } });
|
||||
|
||||
@ -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<TextWidgetNode['props']> }
|
||||
| { type: 'updateImageProps'; id: string; props: Partial<ImageWidgetNode['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': {
|
||||
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,
|
||||
|
||||
@ -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<T>(...values: Array<T | undefined | null>): T | 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 {
|
||||
// goView exports vary a lot; attempt a few common nesting shapes.
|
||||
const root = input as unknown as Record<string, unknown>;
|
||||
@ -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<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 };
|
||||
|
||||
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<string, unknown>).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<string, unknown>).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<string, unknown>).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<string, unknown>).zIndex, 0),
|
||||
locked: c.status?.lock ?? false,
|
||||
hidden: c.status?.hide ?? false,
|
||||
props,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user