fix(builder): regenerate node ids on duplicate/paste to prevent state corruption

Craft.js duplicate (ContextMenu + keyboard shortcut) and paste were reusing
the original node's toNodeTree() output verbatim, so addNodeTree() inserted
duplicate node ids into the editor tree. Added regenerateTreeIds() which
deep-clones a NodeTree and remaps rootNodeId, node map keys, node.id,
internal node.data.parent, node.data.nodes, and node.data.linkedNodes via
Craft.js's own getRandomId(). Also fixed pasteNode to insert as a sibling
of the right-clicked node (using its parent) instead of using a leaf node
as the new parent, which previously threw.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-12 12:18:44 -07:00
parent 4c001e1af4
commit 97123c4c58
4 changed files with 219 additions and 10 deletions
+21 -9
View File
@@ -3,6 +3,7 @@ import { useEditor } from '@craftjs/core';
import { findDeletableTarget } from '../../utils/craft-helpers';
import { useSitesmithModal } from '../../state/SitesmithContext';
import { buildSitesmithTarget } from '../../utils/sitesmith-target';
import { regenerateTreeIds } from '../../utils/craft-tree';
interface ContextMenuProps {
visible: boolean;
@@ -65,15 +66,11 @@ export const ContextMenu: React.FC<ContextMenuProps> = ({
const duplicate = useCallback(() => {
if (!nodeId || nodeId === 'ROOT') return;
try {
const tree = query.node(nodeId).toSerializedNode();
const parentId = getParentId();
if (!parentId) return;
// Get the full subtree
const freshTree = query.node(nodeId).toNodeTree();
const clonedTree = query.parseSerializedNode(freshTree.nodes[freshTree.rootNodeId].data).toNode();
actions.addNodeTree(freshTree, parentId);
const tree = regenerateTreeIds(query.node(nodeId).toNodeTree());
actions.addNodeTree(tree, parentId);
} catch (e) {
console.error('Duplicate failed:', e);
}
@@ -92,10 +89,25 @@ export const ContextMenu: React.FC<ContextMenuProps> = ({
const pasteNode = useCallback(() => {
const sourceId = clipboardRef.current;
if (!sourceId) return;
if (!sourceId) {
onClose();
return;
}
try {
const targetParent = nodeId || 'ROOT';
const tree = query.node(sourceId).toNodeTree();
if (!query.node(sourceId).get()) {
onClose();
return;
}
// Paste as a SIBLING of the right-clicked node, not as its child --
// using the clicked node itself as the parent throws when it's a leaf.
let targetParent = 'ROOT';
if (nodeId && nodeId !== 'ROOT') {
const clickedNode = query.node(nodeId).get();
targetParent = clickedNode?.data?.parent || 'ROOT';
}
const tree = regenerateTreeIds(query.node(sourceId).toNodeTree());
actions.addNodeTree(tree, targetParent);
} catch (e) {
console.error('Paste failed:', e);