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
co-authored by Claude Opus 4.8
parent 204ea5e078
commit a698f014b0
9 changed files with 1040 additions and 108 deletions
+53 -17
View File
@@ -4,7 +4,7 @@ import { createRoot, Root } from 'react-dom/client';
import { act } from 'react-dom/test-utils';
import type { NodeTree, Node } from '@craftjs/core';
import { useKeyboardShortcuts } from './useKeyboardShortcuts';
import { getClipboardNodeId, setClipboardNodeId } from './clipboard';
import { getClipboardTree, setClipboardTree } from './clipboard';
/**
* Regression coverage: Ctrl/Cmd+V must run the copied subtree through
@@ -15,6 +15,13 @@ import { getClipboardNodeId, setClipboardNodeId } from './clipboard';
* ROOT-fallback targeting, the empty-clipboard no-op, and the existing
* input-focus guard.
*
* Also covers the cross-page clipboard fix: copy stores a detached TREE
* SNAPSHOT (`setClipboardTree`), not a bare node id -- so paste never needs
* to re-resolve the original node via `query.node(id)`, which is exactly
* what breaks once the canvas has been re-deserialized to a different page
* (see `hooks/clipboard.ts` and the cross-page integration test in
* `test-utils/integration/duplicate-paste.integration.test.tsx`).
*
* Mock pattern mirrors PageContext.pure-updaters.test.tsx /
* PageContext.slug.test.tsx: a fake `useEditor` exposing `query`/`actions`,
* mounted via a bare consumer component, with REAL `keydown` events
@@ -27,6 +34,7 @@ import { getClipboardNodeId, setClipboardNodeId } from './clipboard';
*/
const addNodeTreeMock = vi.fn();
const selectNodeMock = vi.fn();
let selectedIds: string[] = [];
function makeNode(id: string, parent: string | null, children: string[] = []): Node {
@@ -62,9 +70,10 @@ const COPIED_TREE: NodeTree = {
},
};
const nodeStore: Record<string, { data: { parent: string | null } }> = {
const nodeStore: Record<string, { data: { parent: string | null; nodes?: string[] } }> = {
'selected-1': { data: { parent: 'parent-container-1' } },
ROOT: { data: { parent: null } },
'parent-container-1': { data: { parent: null, nodes: ['other-sibling', 'selected-1'] } },
ROOT: { data: { parent: null, nodes: [] } },
'copied-root-1': { data: { parent: 'wherever-it-originally-lived' } },
};
@@ -88,6 +97,7 @@ vi.mock('@craftjs/core', () => ({
},
actions: {
addNodeTree: addNodeTreeMock,
selectNode: selectNodeMock,
history: { undo: vi.fn(), redo: vi.fn() },
delete: vi.fn(),
clearEvents: vi.fn(),
@@ -145,44 +155,51 @@ const Consumer: React.FC = () => {
beforeEach(() => {
selectedIds = [];
addNodeTreeMock.mockClear();
selectNodeMock.mockClear();
regenerateTreeIdsMock.mockClear();
setClipboardNodeId(null);
setClipboardTree(null);
});
afterEach(() => {
setClipboardNodeId(null);
setClipboardTree(null);
if (root) unmount();
});
describe('useKeyboardShortcuts: Ctrl/Cmd+C / Ctrl/Cmd+V', () => {
test('Ctrl+C copies the selected node id to the clipboard', () => {
test('Ctrl+C copies the selected node`s subtree (a tree snapshot, not a bare id) to the clipboard', () => {
render(<Consumer />);
selectedIds = ['selected-1'];
selectedIds = ['copied-root-1'];
pressKey('c');
expect(getClipboardNodeId()).toBe('selected-1');
const clip = getClipboardTree();
expect(clip).not.toBeNull();
expect(clip!.rootNodeId).toBe('copied-root-1');
expect(Object.keys(clip!.nodes).sort()).toEqual(['copied-child-1', 'copied-root-1']);
});
test('Ctrl+V pastes as a sibling of the selection (selected node`s data.parent) with FRESH ids', () => {
test('Ctrl+V pastes as a sibling of the selection, immediately after it, with FRESH ids', () => {
render(<Consumer />);
selectedIds = ['copied-root-1'];
pressKey('c');
expect(getClipboardNodeId()).toBe('copied-root-1');
expect(getClipboardTree()!.rootNodeId).toBe('copied-root-1');
selectedIds = ['selected-1'];
pressKey('v');
// regenerateTreeIds actually ran before the tree was handed to Craft.js.
expect(regenerateTreeIdsMock).toHaveBeenCalledTimes(1);
expect(regenerateTreeIdsMock).toHaveBeenCalledWith(COPIED_TREE);
expect(regenerateTreeIdsMock).toHaveBeenCalledWith(getClipboardTree());
expect(addNodeTreeMock).toHaveBeenCalledTimes(1);
const [pastedTree, targetParent] = addNodeTreeMock.mock.calls[0];
const [pastedTree, targetParent, insertIndex] = addNodeTreeMock.mock.calls[0];
// Sibling of the current selection: selected-1's data.parent.
expect(targetParent).toBe('parent-container-1');
// Immediately after selected-1 (index 1 among parent-container-1's
// children), matching duplicate()'s "insert right after" UX.
expect(insertIndex).toBe(2);
// The regression this guards: pasted ids must be fresh, never reuse the
// ids the copied node already occupies in the live Craft.js tree.
@@ -193,9 +210,12 @@ describe('useKeyboardShortcuts: Ctrl/Cmd+C / Ctrl/Cmd+V', () => {
for (const id of pastedIds) {
expect(originalIds.has(id)).toBe(false);
}
// The new copy is selected, same as duplicate()'s existing UX.
expect(selectNodeMock).toHaveBeenCalledWith(pastedTree.rootNodeId);
});
test('Ctrl+V with selection at ROOT falls back to ROOT as the insertion parent', () => {
test('Ctrl+V with selection at ROOT falls back to appending into ROOT', () => {
render(<Consumer />);
selectedIds = ['copied-root-1'];
@@ -205,8 +225,24 @@ describe('useKeyboardShortcuts: Ctrl/Cmd+C / Ctrl/Cmd+V', () => {
pressKey('v');
expect(addNodeTreeMock).toHaveBeenCalledTimes(1);
const [, targetParent] = addNodeTreeMock.mock.calls[0];
const [, targetParent, insertIndex] = addNodeTreeMock.mock.calls[0];
expect(targetParent).toBe('ROOT');
expect(insertIndex).toBeUndefined();
});
test('Ctrl+V with nothing selected falls back to appending into ROOT', () => {
render(<Consumer />);
selectedIds = ['copied-root-1'];
pressKey('c');
selectedIds = [];
pressKey('v');
expect(addNodeTreeMock).toHaveBeenCalledTimes(1);
const [, targetParent, insertIndex] = addNodeTreeMock.mock.calls[0];
expect(targetParent).toBe('ROOT');
expect(insertIndex).toBeUndefined();
});
test('Ctrl+V with an empty clipboard is a no-op', () => {
@@ -229,9 +265,9 @@ describe('useKeyboardShortcuts: Ctrl/Cmd+C / Ctrl/Cmd+V', () => {
selectedIds = ['selected-1'];
pressKey('c');
expect(getClipboardNodeId()).toBeNull();
expect(getClipboardTree()).toBeNull();
setClipboardNodeId('copied-root-1');
setClipboardTree(COPIED_TREE);
pressKey('v');
expect(addNodeTreeMock).not.toHaveBeenCalled();
@@ -252,7 +288,7 @@ describe('useKeyboardShortcuts: Ctrl/Cmd+C / Ctrl/Cmd+V', () => {
selectedIds = ['selected-1'];
pressKey('c');
expect(getClipboardNodeId()).toBeNull();
expect(getClipboardTree()).toBeNull();
activeElementSpy.mockRestore();
});