craft.js icon indicating copy to clipboard operation
craft.js copied to clipboard

Clone Node Tree

Open lablancas opened this issue 5 years ago • 13 comments

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

lablancas avatar Nov 20 '20 23:11 lablancas

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 avatar Dec 04 '20 00:12 kidoncio

@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.

nicosh avatar Dec 04 '20 14:12 nicosh

@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} />
}

lablancas avatar Dec 04 '20 15:12 lablancas

What if a node has also linked nodes? How can we do?

dentiluca avatar Feb 05 '21 10:02 dentiluca

@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 avatar Feb 05 '21 10:02 nicosh

@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 avatar Feb 05 '21 10:02 dentiluca

@dentiluca this is weird, because parseFreshNode will set a new id for each new element clonated

nicosh avatar Feb 05 '21 13:02 nicosh

@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 avatar Feb 05 '21 13:02 dentiluca

@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])

nicosh avatar Feb 05 '21 16:02 nicosh

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);
    */
  }

nicosh avatar Mar 02 '21 07:03 nicosh

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 avatar Mar 03 '21 20:03 hugominas

@hugominas and anyone else who need to clone elements see https://github.com/prevwong/craft.js/issues/209#issuecomment-795221484,this seems to work.

nicosh avatar Mar 10 '21 10:03 nicosh

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;
};

murphylan avatar Mar 22 '25 03:03 murphylan