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
+64
View File
@@ -0,0 +1,64 @@
import type { NodeTree, Node, NodeId } from '@craftjs/core';
import { getRandomId } from '@craftjs/utils';
/**
* Returns a deep-cloned NodeTree where every node id in the tree
* (rootNodeId, the `nodes` map keys, each `node.id`, each internal
* `node.data.parent`, every `node.data.nodes` entry, and every
* `node.data.linkedNodes` value) has been remapped to a fresh,
* collision-free id generated by Craft.js's own id generator.
*
* The subtree root's `data.parent` is deliberately left untouched --
* `actions.addNodeTree(tree, parentId)` sets that itself when the tree is
* attached to its new parent.
*
* The input tree is not mutated.
*/
export function regenerateTreeIds(tree: NodeTree): NodeTree {
const oldIds = Object.keys(tree.nodes);
// Build old -> new id map first so all cross-references can be rewritten
// consistently regardless of iteration order.
const idMap = new Map<NodeId, NodeId>();
for (const oldId of oldIds) {
idMap.set(oldId, getRandomId());
}
const remapId = (id: NodeId): NodeId => idMap.get(id) ?? id;
const newNodes: Record<NodeId, Node> = {};
for (const oldId of oldIds) {
const oldNode = tree.nodes[oldId];
const newId = idMap.get(oldId)!;
const newParent =
oldId === tree.rootNodeId
? oldNode.data.parent // subtree root's parent is set by the caller
: oldNode.data.parent !== null && oldNode.data.parent !== undefined
? remapId(oldNode.data.parent)
: oldNode.data.parent;
const newLinkedNodes: Record<string, NodeId> = {};
for (const [slot, linkedId] of Object.entries(oldNode.data.linkedNodes || {})) {
newLinkedNodes[slot] = remapId(linkedId);
}
const newNode: Node = {
...oldNode,
id: newId,
data: {
...oldNode.data,
parent: newParent,
nodes: (oldNode.data.nodes || []).map(remapId),
linkedNodes: newLinkedNodes,
},
};
newNodes[newId] = newNode;
}
return {
rootNodeId: remapId(tree.rootNodeId),
nodes: newNodes,
};
}