Clone Node Tree
Is your feature request related to a problem? Please describe. I have a use case where it would be very useful to clone a User Element, but I need to clone the entire Node Tree and this doesn't work out of the box because all the Node IDs need to be recreated.
Describe the solution you'd like Would like a connector similar to create which can clone an existing node tree and create the node tree at the drop location
Additional context No additional context
I managed to solve using the following code (Obviously a native option would be the most ideal):
import { useNode, useEditor, Node, FreshNode } from '@craftjs/core';
const { actions, query } = useEditor();
const {
id,
parent,
} = useNode((editorNode: Node) => ({
parent: editorNode.data.parent,
}));
const insertNodeOnParent = useCallback(
(
nodeId: string,
parentId: string,
indexToInsert: number,
selectNodeAfterCreated = false,
) => {
const node: Node = query.node(nodeId).get();
const freshNode: FreshNode = {
data: {
...node.data,
nodes: [],
},
};
const nodeToAdd = query.parseFreshNode(freshNode).toNode();
actions.add(nodeToAdd, parentId, indexToInsert);
if (node.data.nodes.length === 0) {
return;
}
node.data.nodes.forEach((childNode: string, index: number) => {
insertNodeOnParent(childNode, nodeToAdd.id, index);
});
if (selectNodeAfterCreated) actions.selectNode(nodeToAdd.id);
},
[actions, query],
);
const duplicateNode = useCallback(() => {
const parentNode = query.node(parent).get();
const indexToAdd = parentNode.data.nodes.indexOf(id) + 1;
insertNodeOnParent(id, parent, indexToAdd, true);
}, [id, insertNodeOnParent, parent, query]);
@Kidoncio thanks for share your code.
But if the element to clone contains some <Element/> 's then the id of each <Element/> needs to be generated automatically, ex.
<Element id={id${uuidv4()}} canvas> otherwise them would not be editable/selectable.
@Kidoncio @nicosh this is the recursive function that I am using. Basically, rebuilding the DOM without node IDs. Works for me so far.
/*
id = Node ID
tree = Node Tree
resolver = Nodes (same value passed to Editor or this can be retrieved from useEditor hook)
*/
const buildTree = (id, tree, resolver) => {
const node = tree[id]
const children = node.nodes.map(child => buildTree(child, tree, resolver))
const Type = resolver[node.type.resolvedName]
const props = {
...node.props,
children: children.length > 0 ? children : null
}
return <Type {...props} />
}
What if a node has also linked nodes? How can we do?
@dentiluca @Kidoncio solution isn't working with linked nodes? it does in my case- By the way i have the problem that cloning an element with also clone relative id, so changing props on original node will also affect the cloned one.
@nicosh Visually they are cloned, but actually they are like pointers to the original linked nodes. For this reason they have the problem you said.
@dentiluca this is weird, because parseFreshNode will set a new id for each new element clonated
@nicosh Yes, but it does not clone linked nodes. So if I have a node "A" with 2 linked nodes, and I try to clone "A", I obtain an other node "B" (this is right), but its 2 linked nodes actually are the same of "A".
@dentiluca i'm almost there, this function almost works, the problem is that it seems to append the first linked node in the wrong place :/
const insertNodeOnParent = useCallback((nodeId,parentId,indexToInsert,selectNodeAfterCreated=false) => {
const node = query.node(nodeId).get();
const freshNode = {
data: {
...node.data,
nodes: [],
linkedNodes : {}
},
};
const nodeToAdd = query.parseFreshNode(freshNode).toNode();
actions.add(nodeToAdd, parentId, indexToInsert);
Object.values(node.data.linkedNodes).forEach((childNode, index) => {
insertNodeOnParent(childNode, nodeToAdd.id, index,selectNodeAfterCreated);
});
node.data.nodes.forEach((childNode, index) => {
insertNodeOnParent(childNode, nodeToAdd.id, index,selectNodeAfterCreated);
});
if (selectNodeAfterCreated) actions.selectNode(nodeToAdd.id);
},[actions, query])
Edit If you still have the problem of cloned elements that change props to the original ones check out this sandbox https://codesandbox.io/s/epic-colden-giwsw and uncomment line 87 here https://codesandbox.io/s/epic-colden-giwsw?file=/components/SettingsPanel.js
@dentiluca @Kidoncio @lablancas thanks to @prevwong input here is a working function that will clone an element with all of his childs and linked nodes :
import shortid from "shortid";
import { useNode, useEditor } from '@craftjs/core';
const getCloneTree = useCallback((idToClone) => {
const tree = query.node(idToClone).toNodeTree();
const newNodes = {};
const changeNodeId = (node, newParentId) => {
const newNodeId = shortid()
const childNodes = node.data.nodes.map((childId) =>
changeNodeId(tree.nodes[childId], newNodeId)
);
const linkedNodes = Object.keys(node.data.linkedNodes).reduce(
(accum, id) => {
const newNodeId = changeNodeId(
tree.nodes[node.data.linkedNodes[id]],
newNodeId
);
return {
...accum,
[id]: newNodeId
};
},
{}
);
newNodes[newNodeId] = {
...node,
id: newNodeId,
data: {
...node.data,
parent: newParentId || node.data.parent,
nodes: childNodes,
linkedNodes
}
};
return newNodeId;
};
const rootNodeId = changeNodeId(tree.nodes[tree.rootNodeId]);
return {
rootNodeId,
nodes: newNodes
};
}, []);
You can use this function like this :
const handleClone = (e,id)=>{
e.preventDefault()
const parentNode = query.node(parent).get();
const indexToAdd = parentNode.data.nodes.indexOf(id)
const tree = getCloneTree(id); // id is the node id
actions.addNodeTree(tree, parentNode.id ,indexToAdd+1);
// uncomment this code block to have the clone function to work properly
// see https://github.com/prevwong/craft.js/issues/209
/*
setTimeout(function () {
actions.deserialize(query.serialize());
actions.selectNode(tree.rootNodeId);
}, 100);
*/
}
Thanks @nicosh this is such a neat feature it should ne in its on PR! I have tried some changes to your solution, at first glance it looked like a dom issue but you covered all tracks there.
It looks like the settings are referencing the wrong node id as when you change the new node DOM the original node is not affected.
@hugominas and anyone else who need to clone elements see https://github.com/prevwong/craft.js/issues/209#issuecomment-795221484,this seems to work.
When saving to localStorage, convert data.type from a component class to its name (a string). Here’s a serialization function:
export const serializeNodeTree = (nodeTree) => {
return JSON.parse(
JSON.stringify(nodeTree, (key, value) => {
if (key === 'type' && typeof value === 'function') {
return value.name; // Convert component class to its name
}
if (['dom', 'rules'].includes(key)) {
return undefined; // Exclude Craft.js-specific fields that don’t need serialization
}
return value;
})
);
};
When loading from localStorage, restore data.type using the componentMap and ensure data.name is populated:
export const deserializeNodeTree = (serializedTree) => {
const nodeTree = JSON.parse(JSON.stringify(serializedTree)); // Deep copy
for (const nodeId in nodeTree.nodes) {
const node = nodeTree.nodes[nodeId];
if (typeof node.data.type === 'string') {
node.data.type = componentMap[node.data.type] || node.data.type; // Restore component class
}
if (!node.data.name && node.data.type && typeof node.data.type === 'function') {
node.data.name = node.data.type.name; // Set name if missing
}
}
return nodeTree;
};