editor: context menu copy/paste + bring forward/backward

This commit is contained in:
clawdbot 2026-01-29 03:14:45 +08:00
parent 6b18665ef9
commit efeb626efb
4 changed files with 216 additions and 30 deletions

View File

@ -29,14 +29,19 @@ export function ContextMenu(props: {
state: ContextMenuState | null;
nodes: WidgetNode[];
selectionIds: string[];
clipboardHasNodes: boolean;
onClose: () => void;
onAddTextAt: (x: number, y: number) => void;
onSelectSingle?: (id?: string) => void;
onSelectAll?: () => void;
onCopySelected?: () => void;
onPasteAt?: (x: number, y: number) => void;
onDuplicateSelected?: () => void;
onToggleLockSelected?: () => void;
onToggleHideSelected?: () => void;
onBringToFrontSelected?: () => void;
onBringForwardSelected?: () => void;
onSendBackwardSelected?: () => void;
onSendToBackSelected?: () => void;
onDeleteSelected?: () => void;
}) {
@ -194,6 +199,24 @@ export function ContextMenu(props: {
<div style={{ height: 1, margin: '6px 0', background: 'rgba(255,255,255,0.08)' }} />
<MenuItem
label="Copy"
disabled={!hasSelection || !props.onCopySelected}
onClick={() => {
props.onCopySelected?.();
onClose();
}}
/>
<MenuItem
label="Paste"
disabled={!props.clipboardHasNodes || !props.onPasteAt}
onClick={() => {
props.onPasteAt?.(ctx.worldX, ctx.worldY);
onClose();
}}
/>
<MenuItem
label="Duplicate"
disabled={!canModifySelection || !props.onDuplicateSelected}
@ -242,6 +265,22 @@ export function ContextMenu(props: {
onClose();
}}
/>
<MenuItem
label="Bring Forward"
disabled={!canModifySelection || !props.onBringForwardSelected}
onClick={() => {
props.onBringForwardSelected?.();
onClose();
}}
/>
<MenuItem
label="Send Backward"
disabled={!canModifySelection || !props.onSendBackwardSelected}
onClick={() => {
props.onSendBackwardSelected?.();
onClose();
}}
/>
<MenuItem
label="Send To Back"
disabled={!canModifySelection || !props.onSendToBackSelected}

View File

@ -636,14 +636,19 @@ export function EditorApp() {
state={ctxMenu}
nodes={state.doc.screen.nodes}
selectionIds={state.selection.ids}
clipboardHasNodes={state.clipboard.nodes.length > 0}
onClose={closeContextMenu}
onAddTextAt={(x, y) => dispatch({ type: 'addTextAt', x, y })}
onSelectSingle={(id) => dispatch({ type: 'selectSingle', id })}
onSelectAll={() => dispatch({ type: 'selectAll' })}
onCopySelected={() => dispatchWithMenuSelection({ type: 'copySelected' })}
onPasteAt={(x, y) => dispatch({ type: 'pasteClipboard', at: { x, y } })}
onDuplicateSelected={() => dispatchWithMenuSelection({ type: 'duplicateSelected' })}
onToggleLockSelected={() => dispatchWithMenuSelection({ type: 'toggleLockSelected' })}
onToggleHideSelected={() => dispatchWithMenuSelection({ type: 'toggleHideSelected' })}
onBringToFrontSelected={() => dispatchWithMenuSelection({ type: 'bringToFrontSelected' })}
onBringForwardSelected={() => dispatchWithMenuSelection({ type: 'bringForwardSelected' })}
onSendBackwardSelected={() => dispatchWithMenuSelection({ type: 'sendBackwardSelected' })}
onSendToBackSelected={() => dispatchWithMenuSelection({ type: 'sendToBackSelected' })}
onDeleteSelected={() => dispatchWithMenuSelection({ type: 'deleteSelected' })}
/>

View File

@ -59,9 +59,13 @@ export type EditorAction =
| { type: 'deleteSelected' }
| { type: 'nudgeSelected'; dx: number; dy: number }
| { type: 'duplicateSelected' }
| { type: 'copySelected' }
| { type: 'pasteClipboard'; at?: { x: number; y: number } }
| { type: 'toggleLockSelected' }
| { type: 'toggleHideSelected' }
| { type: 'bringToFrontSelected' }
| { type: 'bringForwardSelected' }
| { type: 'sendBackwardSelected' }
| { type: 'sendToBackSelected' }
| UpdateWidgetPropsAction;
@ -155,6 +159,66 @@ function ensureSelected(state: EditorRuntimeState, id: string): EditorRuntimeSta
return { ...state, selection: { ids: [id] } };
}
function sortNodesBackToFront(nodes: WidgetNode[]): WidgetNode[] {
return nodes
.map((n, index) => ({ n, index, z: n.zIndex ?? index }))
.sort((a, b) => {
if (a.z !== b.z) return a.z - b.z;
return a.index - b.index;
})
.map((e) => e.n);
}
function normalizeZIndexBackToFront(nodesBackToFront: WidgetNode[]): WidgetNode[] {
return nodesBackToFront.map((n, idx) => ({ ...n, zIndex: idx }));
}
function reorderSelected(
nodes: WidgetNode[],
selectedIds: Set<string>,
mode: 'toFront' | 'toBack' | 'forward' | 'backward',
): WidgetNode[] {
const ordered = sortNodesBackToFront(nodes);
const isSelected = (n: WidgetNode) => selectedIds.has(n.id);
if (mode === 'toFront') {
const rest = ordered.filter((n) => !isSelected(n));
const picked = ordered.filter((n) => isSelected(n));
return normalizeZIndexBackToFront([...rest, ...picked]);
}
if (mode === 'toBack') {
const picked = ordered.filter((n) => isSelected(n));
const rest = ordered.filter((n) => !isSelected(n));
return normalizeZIndexBackToFront([...picked, ...rest]);
}
const arr = [...ordered];
if (mode === 'forward') {
// swap with next non-selected, from front backwards
for (let i = arr.length - 2; i >= 0; i--) {
if (!isSelected(arr[i]!)) continue;
if (isSelected(arr[i + 1]!)) continue;
const tmp = arr[i]!;
arr[i] = arr[i + 1]!;
arr[i + 1] = tmp;
}
return normalizeZIndexBackToFront(arr);
}
// mode === 'backward'
for (let i = 1; i < arr.length; i++) {
if (!isSelected(arr[i]!)) continue;
if (isSelected(arr[i - 1]!)) continue;
const tmp = arr[i]!;
arr[i] = arr[i - 1]!;
arr[i - 1] = tmp;
}
return normalizeZIndexBackToFront(arr);
}
export function createInitialState(): EditorRuntimeState {
const screen = createEmptyScreen({
width: 1920,
@ -193,6 +257,7 @@ export function createInitialState(): EditorRuntimeState {
},
},
keyboard: { ctrl: false, space: false },
clipboard: { nodes: [] },
history: { past: [], future: [] },
};
}
@ -340,6 +405,56 @@ export function editorReducer(state: EditorRuntimeState, action: EditorAction):
};
}
case 'copySelected': {
if (!state.selection.ids.length) return state;
const ids = new Set(state.selection.ids);
const nodes = state.doc.screen.nodes.filter((n) => ids.has(n.id));
if (!nodes.length) return state;
return {
...state,
clipboard: {
nodes: nodes.map((n) => ({ ...n, rect: { ...n.rect } })),
},
};
}
case 'pasteClipboard': {
const clip = state.clipboard.nodes;
if (!clip.length) return state;
// Compute bounds of clipboard content.
let minX = Infinity;
let minY = Infinity;
for (const n of clip) {
minX = Math.min(minX, n.rect.x);
minY = Math.min(minY, n.rect.y);
}
const bounds = { w: state.doc.screen.width, h: state.doc.screen.height };
const dx = action.at ? Math.round(action.at.x - minX) : 20;
const dy = action.at ? Math.round(action.at.y - minY) : 20;
const clones: WidgetNode[] = clip.map((n) => {
const id = `${n.type}_${Date.now()}_${Math.random().toString(16).slice(2)}`;
const rect = clampRectToBounds({ ...n.rect, x: n.rect.x + dx, y: n.rect.y + dy }, bounds, 50);
return {
...n,
id,
rect,
locked: false,
hidden: false,
};
});
return {
...historyPush(state),
doc: { screen: { ...state.doc.screen, nodes: [...state.doc.screen.nodes, ...clones] } },
selection: { ids: clones.map((c) => c.id) },
};
}
case 'toggleLockSelected': {
if (!state.selection.ids.length) return state;
const ids = new Set(state.selection.ids);
@ -382,27 +497,59 @@ export function editorReducer(state: EditorRuntimeState, action: EditorAction):
case 'bringToFrontSelected': {
if (!state.selection.ids.length) return state;
const ids = new Set(state.selection.ids);
const nodes = state.doc.screen.nodes;
const ids = new Set(
state.doc.screen.nodes
.filter((n) => state.selection.ids.includes(n.id) && !n.locked)
.map((n) => n.id),
);
if (!ids.size) return state;
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 };
}),
nodes: reorderSelected(state.doc.screen.nodes, ids, 'toFront'),
},
},
};
}
case 'bringForwardSelected': {
if (!state.selection.ids.length) return state;
const ids = new Set(
state.doc.screen.nodes
.filter((n) => state.selection.ids.includes(n.id) && !n.locked)
.map((n) => n.id),
);
if (!ids.size) return state;
return {
...historyPush(state),
doc: {
screen: {
...state.doc.screen,
nodes: reorderSelected(state.doc.screen.nodes, ids, 'forward'),
},
},
};
}
case 'sendBackwardSelected': {
if (!state.selection.ids.length) return state;
const ids = new Set(
state.doc.screen.nodes
.filter((n) => state.selection.ids.includes(n.id) && !n.locked)
.map((n) => n.id),
);
if (!ids.size) return state;
return {
...historyPush(state),
doc: {
screen: {
...state.doc.screen,
nodes: reorderSelected(state.doc.screen.nodes, ids, 'backward'),
},
},
};
@ -410,27 +557,19 @@ export function editorReducer(state: EditorRuntimeState, action: EditorAction):
case 'sendToBackSelected': {
if (!state.selection.ids.length) return state;
const ids = new Set(state.selection.ids);
const nodes = state.doc.screen.nodes;
const ids = new Set(
state.doc.screen.nodes
.filter((n) => state.selection.ids.includes(n.id) && !n.locked)
.map((n) => n.id),
);
if (!ids.size) return state;
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 };
}),
nodes: reorderSelected(state.doc.screen.nodes, ids, 'toBack'),
},
},
};

View File

@ -55,6 +55,9 @@ export interface EditorState {
ctrl: boolean;
space: boolean;
};
clipboard: {
nodes: WidgetNode[];
};
history: {
past: HistoryEntry[];
future: HistoryEntry[];