feat(site-builder): page duplicate/reorder/set-landing + fix cross-page node copy/paste

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-14 07:36:00 -07:00
parent 204ea5e078
commit a698f014b0
9 changed files with 1040 additions and 108 deletions
+35 -13
View File
@@ -2,7 +2,7 @@ import { useEffect } from 'react';
import { useEditor } from '@craftjs/core';
import { findDeletableTarget } from '../utils/craft-helpers';
import { regenerateTreeIds } from '../utils/craft-tree';
import { getClipboardNodeId, setClipboardNodeId } from './clipboard';
import { getClipboardTree, setClipboardTree } from './clipboard';
function isInputFocused(): boolean {
const el = document.activeElement;
@@ -86,13 +86,16 @@ export function useKeyboardShortcuts() {
return;
}
// Ctrl+C: copy selected node id to the shared clipboard
// Ctrl+C: copy the selected node's subtree (a detached snapshot, not
// just its id -- see clipboard.ts for why: an id-based clipboard can't
// survive a page switch, since the copied id no longer exists in
// `query` once the canvas is re-deserialized to a different page).
if (ctrl && (e.key === 'c' || e.key === 'C')) {
e.preventDefault();
try {
const selected = query.getEvent('selected').all();
if (selected.length > 0 && selected[0] !== 'ROOT') {
setClipboardNodeId(selected[0]);
setClipboardTree(query.node(selected[0]).toNodeTree());
}
} catch (err) {
console.error('Copy failed:', err);
@@ -100,25 +103,44 @@ export function useKeyboardShortcuts() {
return;
}
// Ctrl+V: paste the clipboard node as a sibling of the current selection
// Ctrl+V: paste the clipboard tree as a sibling of the current
// selection (immediately after it, matching duplicate()'s UX), or
// append to ROOT when nothing is selected. Works regardless of which
// page is currently on the canvas -- the clipboard tree is a detached
// snapshot, not a reference to a node that may no longer exist here.
if (ctrl && (e.key === 'v' || e.key === 'V')) {
e.preventDefault();
try {
const sourceId = getClipboardNodeId();
if (!sourceId || !query.node(sourceId).get()) return;
const clip = getClipboardTree();
if (!clip) return;
const selected = query.getEvent('selected').all();
if (selected.length === 0) return;
const selectedId = selected[0];
const selectedId = selected.length > 0 ? selected[0] : null;
let targetParent = 'ROOT';
if (selectedId !== 'ROOT') {
let targetParentId = 'ROOT';
let insertIndex: number | undefined;
if (selectedId && selectedId !== 'ROOT') {
const node = query.node(selectedId).get();
targetParent = node?.data?.parent || 'ROOT';
const parentId: string | null | undefined = node?.data?.parent;
if (parentId) {
targetParentId = parentId;
try {
const siblings: string[] = query.node(parentId).get()?.data?.nodes || [];
const idx = siblings.indexOf(selectedId);
if (idx !== -1) insertIndex = idx + 1;
} catch {
// Leave insertIndex undefined -- addNodeTree appends when omitted.
}
}
}
const tree = regenerateTreeIds(query.node(sourceId).toNodeTree());
actions.addNodeTree(tree, targetParent);
const tree = regenerateTreeIds(clip);
if (insertIndex !== undefined) {
actions.addNodeTree(tree, targetParentId, insertIndex);
} else {
actions.addNodeTree(tree, targetParentId);
}
actions.selectNode(tree.rootNodeId);
} catch (err) {
console.error('Paste failed:', err);
}