editor: context menu copy/paste + bring forward/backward
This commit is contained in:
parent
6b18665ef9
commit
efeb626efb
@ -29,14 +29,19 @@ export function ContextMenu(props: {
|
|||||||
state: ContextMenuState | null;
|
state: ContextMenuState | null;
|
||||||
nodes: WidgetNode[];
|
nodes: WidgetNode[];
|
||||||
selectionIds: string[];
|
selectionIds: string[];
|
||||||
|
clipboardHasNodes: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onAddTextAt: (x: number, y: number) => void;
|
onAddTextAt: (x: number, y: number) => void;
|
||||||
onSelectSingle?: (id?: string) => void;
|
onSelectSingle?: (id?: string) => void;
|
||||||
onSelectAll?: () => void;
|
onSelectAll?: () => void;
|
||||||
|
onCopySelected?: () => void;
|
||||||
|
onPasteAt?: (x: number, y: number) => void;
|
||||||
onDuplicateSelected?: () => void;
|
onDuplicateSelected?: () => void;
|
||||||
onToggleLockSelected?: () => void;
|
onToggleLockSelected?: () => void;
|
||||||
onToggleHideSelected?: () => void;
|
onToggleHideSelected?: () => void;
|
||||||
onBringToFrontSelected?: () => void;
|
onBringToFrontSelected?: () => void;
|
||||||
|
onBringForwardSelected?: () => void;
|
||||||
|
onSendBackwardSelected?: () => void;
|
||||||
onSendToBackSelected?: () => void;
|
onSendToBackSelected?: () => void;
|
||||||
onDeleteSelected?: () => 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)' }} />
|
<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
|
<MenuItem
|
||||||
label="Duplicate"
|
label="Duplicate"
|
||||||
disabled={!canModifySelection || !props.onDuplicateSelected}
|
disabled={!canModifySelection || !props.onDuplicateSelected}
|
||||||
@ -242,6 +265,22 @@ export function ContextMenu(props: {
|
|||||||
onClose();
|
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
|
<MenuItem
|
||||||
label="Send To Back"
|
label="Send To Back"
|
||||||
disabled={!canModifySelection || !props.onSendToBackSelected}
|
disabled={!canModifySelection || !props.onSendToBackSelected}
|
||||||
|
|||||||
@ -636,14 +636,19 @@ export function EditorApp() {
|
|||||||
state={ctxMenu}
|
state={ctxMenu}
|
||||||
nodes={state.doc.screen.nodes}
|
nodes={state.doc.screen.nodes}
|
||||||
selectionIds={state.selection.ids}
|
selectionIds={state.selection.ids}
|
||||||
|
clipboardHasNodes={state.clipboard.nodes.length > 0}
|
||||||
onClose={closeContextMenu}
|
onClose={closeContextMenu}
|
||||||
onAddTextAt={(x, y) => dispatch({ type: 'addTextAt', x, y })}
|
onAddTextAt={(x, y) => dispatch({ type: 'addTextAt', x, y })}
|
||||||
onSelectSingle={(id) => dispatch({ type: 'selectSingle', id })}
|
onSelectSingle={(id) => dispatch({ type: 'selectSingle', id })}
|
||||||
onSelectAll={() => dispatch({ type: 'selectAll' })}
|
onSelectAll={() => dispatch({ type: 'selectAll' })}
|
||||||
|
onCopySelected={() => dispatchWithMenuSelection({ type: 'copySelected' })}
|
||||||
|
onPasteAt={(x, y) => dispatch({ type: 'pasteClipboard', at: { x, y } })}
|
||||||
onDuplicateSelected={() => dispatchWithMenuSelection({ type: 'duplicateSelected' })}
|
onDuplicateSelected={() => dispatchWithMenuSelection({ type: 'duplicateSelected' })}
|
||||||
onToggleLockSelected={() => dispatchWithMenuSelection({ type: 'toggleLockSelected' })}
|
onToggleLockSelected={() => dispatchWithMenuSelection({ type: 'toggleLockSelected' })}
|
||||||
onToggleHideSelected={() => dispatchWithMenuSelection({ type: 'toggleHideSelected' })}
|
onToggleHideSelected={() => dispatchWithMenuSelection({ type: 'toggleHideSelected' })}
|
||||||
onBringToFrontSelected={() => dispatchWithMenuSelection({ type: 'bringToFrontSelected' })}
|
onBringToFrontSelected={() => dispatchWithMenuSelection({ type: 'bringToFrontSelected' })}
|
||||||
|
onBringForwardSelected={() => dispatchWithMenuSelection({ type: 'bringForwardSelected' })}
|
||||||
|
onSendBackwardSelected={() => dispatchWithMenuSelection({ type: 'sendBackwardSelected' })}
|
||||||
onSendToBackSelected={() => dispatchWithMenuSelection({ type: 'sendToBackSelected' })}
|
onSendToBackSelected={() => dispatchWithMenuSelection({ type: 'sendToBackSelected' })}
|
||||||
onDeleteSelected={() => dispatchWithMenuSelection({ type: 'deleteSelected' })}
|
onDeleteSelected={() => dispatchWithMenuSelection({ type: 'deleteSelected' })}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -59,9 +59,13 @@ export type EditorAction =
|
|||||||
| { type: 'deleteSelected' }
|
| { type: 'deleteSelected' }
|
||||||
| { type: 'nudgeSelected'; dx: number; dy: number }
|
| { type: 'nudgeSelected'; dx: number; dy: number }
|
||||||
| { type: 'duplicateSelected' }
|
| { type: 'duplicateSelected' }
|
||||||
|
| { type: 'copySelected' }
|
||||||
|
| { type: 'pasteClipboard'; at?: { x: number; y: number } }
|
||||||
| { type: 'toggleLockSelected' }
|
| { type: 'toggleLockSelected' }
|
||||||
| { type: 'toggleHideSelected' }
|
| { type: 'toggleHideSelected' }
|
||||||
| { type: 'bringToFrontSelected' }
|
| { type: 'bringToFrontSelected' }
|
||||||
|
| { type: 'bringForwardSelected' }
|
||||||
|
| { type: 'sendBackwardSelected' }
|
||||||
| { type: 'sendToBackSelected' }
|
| { type: 'sendToBackSelected' }
|
||||||
| UpdateWidgetPropsAction;
|
| UpdateWidgetPropsAction;
|
||||||
|
|
||||||
@ -155,6 +159,66 @@ function ensureSelected(state: EditorRuntimeState, id: string): EditorRuntimeSta
|
|||||||
return { ...state, selection: { ids: [id] } };
|
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 {
|
export function createInitialState(): EditorRuntimeState {
|
||||||
const screen = createEmptyScreen({
|
const screen = createEmptyScreen({
|
||||||
width: 1920,
|
width: 1920,
|
||||||
@ -193,6 +257,7 @@ export function createInitialState(): EditorRuntimeState {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
keyboard: { ctrl: false, space: false },
|
keyboard: { ctrl: false, space: false },
|
||||||
|
clipboard: { nodes: [] },
|
||||||
history: { past: [], future: [] },
|
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': {
|
case 'toggleLockSelected': {
|
||||||
if (!state.selection.ids.length) return state;
|
if (!state.selection.ids.length) return state;
|
||||||
const ids = new Set(state.selection.ids);
|
const ids = new Set(state.selection.ids);
|
||||||
@ -382,27 +497,59 @@ export function editorReducer(state: EditorRuntimeState, action: EditorAction):
|
|||||||
|
|
||||||
case 'bringToFrontSelected': {
|
case 'bringToFrontSelected': {
|
||||||
if (!state.selection.ids.length) return state;
|
if (!state.selection.ids.length) return state;
|
||||||
const ids = new Set(state.selection.ids);
|
const ids = new Set(
|
||||||
const nodes = state.doc.screen.nodes;
|
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 {
|
return {
|
||||||
...historyPush(state),
|
...historyPush(state),
|
||||||
doc: {
|
doc: {
|
||||||
screen: {
|
screen: {
|
||||||
...state.doc.screen,
|
...state.doc.screen,
|
||||||
nodes: nodes.map((n) => {
|
nodes: reorderSelected(state.doc.screen.nodes, ids, 'toFront'),
|
||||||
if (!ids.has(n.id)) return n;
|
},
|
||||||
bump += 1;
|
},
|
||||||
return { ...n, zIndex: maxZ + bump };
|
};
|
||||||
}),
|
}
|
||||||
|
|
||||||
|
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': {
|
case 'sendToBackSelected': {
|
||||||
if (!state.selection.ids.length) return state;
|
if (!state.selection.ids.length) return state;
|
||||||
const ids = new Set(state.selection.ids);
|
const ids = new Set(
|
||||||
const nodes = state.doc.screen.nodes;
|
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 {
|
return {
|
||||||
...historyPush(state),
|
...historyPush(state),
|
||||||
doc: {
|
doc: {
|
||||||
screen: {
|
screen: {
|
||||||
...state.doc.screen,
|
...state.doc.screen,
|
||||||
nodes: nodes.map((n) => {
|
nodes: reorderSelected(state.doc.screen.nodes, ids, 'toBack'),
|
||||||
if (!ids.has(n.id)) return n;
|
|
||||||
bump += 1;
|
|
||||||
return { ...n, zIndex: minZ - bump };
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -55,6 +55,9 @@ export interface EditorState {
|
|||||||
ctrl: boolean;
|
ctrl: boolean;
|
||||||
space: boolean;
|
space: boolean;
|
||||||
};
|
};
|
||||||
|
clipboard: {
|
||||||
|
nodes: WidgetNode[];
|
||||||
|
};
|
||||||
history: {
|
history: {
|
||||||
past: HistoryEntry[];
|
past: HistoryEntry[];
|
||||||
future: HistoryEntry[];
|
future: HistoryEntry[];
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user