diff --git a/packages/editor/src/editor/ContextMenu.tsx b/packages/editor/src/editor/ContextMenu.tsx index bbb0e0a..a0d42f4 100644 --- a/packages/editor/src/editor/ContextMenu.tsx +++ b/packages/editor/src/editor/ContextMenu.tsx @@ -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: {
+ { + props.onCopySelected?.(); + onClose(); + }} + /> + + { + props.onPasteAt?.(ctx.worldX, ctx.worldY); + onClose(); + }} + /> + + { + props.onBringForwardSelected?.(); + onClose(); + }} + /> + { + props.onSendBackwardSelected?.(); + onClose(); + }} + /> 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' })} /> diff --git a/packages/editor/src/editor/store.ts b/packages/editor/src/editor/store.ts index ba19fd9..380d05c 100644 --- a/packages/editor/src/editor/store.ts +++ b/packages/editor/src/editor/store.ts @@ -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, + 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'), }, }, }; diff --git a/packages/editor/src/editor/types.ts b/packages/editor/src/editor/types.ts index d415bd8..649b1aa 100644 --- a/packages/editor/src/editor/types.ts +++ b/packages/editor/src/editor/types.ts @@ -55,6 +55,9 @@ export interface EditorState { ctrl: boolean; space: boolean; }; + clipboard: { + nodes: WidgetNode[]; + }; history: { past: HistoryEntry[]; future: HistoryEntry[];