Copying links between nodes key problem

Hi,

I am using the makeUniqueLinkKeyFunction on the go.GraphLinksModel to create a link key based on the the link data. This works fine when I initialize the model and when I create new links. When creating a new link, the link data is filled based on the properties that I have set on the model:

linkFromKeyProperty
linkToKeyProperty

I am using these properties in the link data to create the unique key in the makeUniqueLinkKeyFunction function.

However, when I am copying nodes that have links, the makeUniqueLinkKeyFunction is called with the old properties. So the properties from the source nodes that I am copying (instead of the newly created copied nodes). This results in a duplicated link key and therefore gojs appends a numeric value.

Would it be possible to received the copied node properties in the makeUniqueLinkKeyFunction or how should I address this issue?

For clarification: my link key is simply: ${fromNode}-${toNode} based on the linkFromKeyProperty and linkToKeyProperty properties in the linkdata.

If I create a model such as:

$(go.GraphLinksModel,
  {
    linkKeyProperty: "key",
    makeUniqueLinkKeyFunction: (m, d) => `${d.from}-${d.to}`,
    nodeDataArray: [
      { key: 1, text: "Alpha", color: "lightblue" },
      { key: 2, text: "Beta", color: "orange" },
      { key: 3, text: "Gamma", color: "lightgreen" },
      { key: 4, text: "Delta", color: "pink" }
    ],
    linkDataArray: [
      { from: 1, to: 2 },
      { from: 1, to: 3 },
      { from: 2, to: 2 },
      { from: 3, to: 4 },
      { from: 4, to: 1 }
    ]
  });

And then I copy some nodes and links, I get link keys such as:

{"from":-5,"to":-5,"key":"undefined-undefined"},
{"from":-6,"to":-5,"key":"undefined-undefined2"},

That’s because when links are copied they do not have any key references copied. Such references are determined by the policies of the copying code, since that is what needs to decide whether or not to refer to an original or to a copy or to some other node altogether.

So if you want the link key to be dependent on the connected node keys, you’ll need to reassign the key later by calling GraphLinksModel.setKeyForLinkData.

      $(go.Diagram, . . .,
        {
          "SelectionCopied": function(e) {
            e.subject.each(p => {
              if (p instanceof go.Link) {
                const d = p.data;
                p.diagram.model.setKeyForLinkData(d, `${d.from}-${d.to}`);
              }
            });
          },

As another situation where you would have to do this extra work, say you called Model.setKeyForNodeData on some node data. Then all of the links connected with that node would have updated “from” and “to” key values, but the out-of-date “key” values, and you would need to call setKeyForLinkData on each one.

I tried your approach but this will not work for my case because I sync the gojs state via the addModelChangedListener that is called before the SelectionCopied callback. This way, I am propagating wrong keys to the rest of my app.

However, I do not totally understand why the copy code is not aware of any node references since apparently the diagram renders the link properly. If it would first copy the nodes and then create the links it could pass the right info the the makeUniqueLinkKeyFunction right. Anyhow, would you advice to take another approach here? I could validate the keys according to the makeUniqueLinkKeyFunction function in the modelChangeCallback and fix the case there with the method you mentioned but this also seems a bit like a workaround.

What is your Model Changed listener looking for? Maybe you need to wait until the transaction has finished – check for ChangedEvent.isTransactionFinished, which will be true only for “Transaction” ChangedEvents.

That is exactly what my mode changed listener does. I use the listener to sync the diagram state with the app state using incremental data. When I tried a little bit further with your approach, it seems that the SelectionCopied callback is not fired at all:

diagram.addDiagramListener('SelectionCopied', (e) => {
  debugger;
});

How is the user doing the copy? Maybe “ClipboardPasted” as well?

That worked; didn’t know there was a difference between the two. Implemented the approach you mentioned and it seems to work nicely

Sorry for continuing on this thread of 9 months ago but I am still experiencing a problem related to this question.

My example to reproduce:

<!DOCTYPE html>
<html>
  <head>
    <title>Copy paste undo removed link keys</title>
    <meta charset="UTF-8" />
  </head>

  <body onload="init()">
    <div id="app">
      <div
        id="diagram"
        style="border: solid 1px black; width: 100%; height: 300px"
      ></div>
      <ul>
        <li>Select 1, 3 and 1###3</li>
        <li>Copy (CTRL+C)</li>
        <li>
          Paste (CTRL+V)
          <pre>Data changes shows "insertedLinkKeys": ["-6####-5"]</pre>
        </li>
        <li>Undo (CTRL+Z</li>
        <li>
          Removed link key wrong:
          <pre>
Data changes shows "removedLinkKeys": ["undefined####undefined"]
</pre
          >
        </li>
      </ul>
      <div style="display: flex">
        <div style="width: 50%">
          <h2>Model data</h2>
          <pre id="modeldata" style="height: 300px; overflow: scroll"></pre>
        </div>
        <div style="width: 50%">
          <h2>Datachanges</h2>
          <pre id="datachanges" style="height: 300px; overflow: scroll"></pre>
        </div>
      </div>
    </div>

    <script src="https://unpkg.com/gojs"></script>
    <script id="code">
      function init() {
        var $ = go.GraphObject.make; // for conciseness in defining templates

        myDiagram = $(
          go.Diagram,
          "diagram", // create a Diagram for the DIV HTML element
          {
            "undoManager.isEnabled": true, // enable undo & redo
          }
        );

        const fixLinkKeysOnCopy = (e) => {
          e.subject.each((part) => {
            if (part instanceof go.Link) {
              const ld = part.data;
              const model = part.diagram?.model;
              if (model !== null && model !== undefined) {
                model.setKeyForLinkData(ld, model.getKeyForLinkData(ld));
              }
            }
          });
        };
        myDiagram.addDiagramListener("ClipboardPasted", fixLinkKeysOnCopy);
        myDiagram.addDiagramListener("SelectionCopied", fixLinkKeysOnCopy);

        // define a simple Node template
        myDiagram.nodeTemplate = $(
          go.Node,
          "Auto", // the Shape will go around the TextBlock
          $(
            go.Shape,
            "RoundedRectangle",
            { strokeWidth: 0, fill: "white" },
            // Shape.fill is bound to Node.data.color
            new go.Binding("fill", "color")
          ),
          $(
            go.TextBlock,
            { margin: 8, font: "bold 14px sans-serif", stroke: "#333" }, // Specify a margin to add some room around the text
            // TextBlock.text is bound to Node.data.key
            new go.Binding("text", "key")
          )
        );

        const getKeyForLinkData = (d) => `${d.from}####${d.to}`;

        // create the model data that will be represented by Nodes and Links
        myDiagram.model = $(go.GraphLinksModel, {
          linkKeyProperty: "key",
          getKeyForLinkData,
          makeUniqueLinkKeyFunction: (m, d) => getKeyForLinkData(d),
          nodeDataArray: [
            { key: 1, text: "Alpha", color: "lightblue" },
            { key: 2, text: "Beta", color: "orange" },
            { key: 3, text: "Gamma", color: "lightgreen" },
            { key: 4, text: "Delta", color: "pink" },
          ],
          linkDataArray: [
            { from: 1, to: 2 },
            { from: 1, to: 3 },
            { from: 2, to: 2 },
            { from: 3, to: 4 },
            { from: 4, to: 1 },
          ],
        });

        myDiagram.addModelChangedListener((e) => {
          if (e.isTransactionFinished && e.model) {
            const dataChanges = e.model.toIncrementalJson(e);
            if (dataChanges !== null) {
              document.getElementById("modeldata").innerHTML =
                myDiagram.model.toJson();
              document.getElementById("datachanges").innerHTML = dataChanges;
            }
          }
        });
      }
    </script>
  </body>
</html>

The reproduction steps are listed in the example. The problem is that when we undo the paste action, the dataChanges removedLinkKeys is not returning the correct link key which causes trouble syncing back the gojs state to our app state. Any ideas on how we can fix this?

Thanks, for the reproduction. We’re looking into this.

The problem in this scenario is that when an undo occurs, there is no model change that is isTransactionFinished until the undo has completed. At this point, the link data has already been rolled back to its original state with undefined from, to, key, etc.

A workaround for your scenario where your keys are reliant on the from and to values could be the following:

// reverse from/to changes in the undo such that
// toIncremental has the removed link's values
const fixUndoneLinks = (e) => {
  e.object.changes.each((c) => {
    if (c.modelChange === "linkFromKey") {
      c.object[c.model.linkFromKeyProperty] = c.newValue;
    }
    if (c.modelChange === "linkToKey") {
      c.object[c.model.linkToKeyProperty] = c.newValue;
    }
  });
};

myDiagram.addModelChangedListener((e) => {
  if (e.isTransactionFinished && e.model) {
    if (e.propertyName === "FinishedUndo") fixUndoneLinks(e);
    const dataChanges = e.model.toIncrementalJson(e);
    if (dataChanges !== null) {
      document.getElementById("modeldata").innerHTML =
        myDiagram.model.toJson();
      document.getElementById("datachanges").innerHTML = dataChanges;
    }
  }
});

Let us know if this works for you.

Thanks, I tried adding your code to my example but it does not seem to work:

<!DOCTYPE html>
<html>
  <head>
    <title>Copy paste undo removed link keys</title>
    <meta charset="UTF-8" />
  </head>

  <body onload="init()">
    <div id="app">
      <div
        id="diagram"
        style="border: solid 1px black; width: 100%; height: 300px"
      ></div>
      <ul>
        <li>Select 1, 3 and 1###3</li>
        <li>Copy (CTRL+C)</li>
        <li>
          Paste (CTRL+V)
          <pre>Data changes shows "insertedLinkKeys": ["-6####-5"]</pre>
        </li>
        <li>Undo (CTRL+Z</li>
        <li>
          Removed link key wrong:
          <pre>
Data changes shows "removedLinkKeys": ["undefined####undefined"]
</pre
          >
        </li>
      </ul>
      <div style="display: flex">
        <div style="width: 50%">
          <h2>Model data</h2>
          <pre id="modeldata" style="height: 300px; overflow: scroll"></pre>
        </div>
        <div style="width: 50%">
          <h2>Datachanges</h2>
          <pre id="datachanges" style="height: 300px; overflow: scroll"></pre>
        </div>
      </div>
    </div>

    <script src="https://unpkg.com/gojs"></script>
    <script id="code">
      function init() {
        var $ = go.GraphObject.make; // for conciseness in defining templates

        myDiagram = $(
          go.Diagram,
          "diagram", // create a Diagram for the DIV HTML element
          {
            "undoManager.isEnabled": true, // enable undo & redo
          }
        );

        const fixLinkKeysOnCopy = (e) => {
          e.subject.each((part) => {
            if (part instanceof go.Link) {
              const ld = part.data;
              const model = part.diagram?.model;
              if (model !== null && model !== undefined) {
                model.setKeyForLinkData(ld, model.getKeyForLinkData(ld));
              }
            }
          });
        };
        myDiagram.addDiagramListener("ClipboardPasted", fixLinkKeysOnCopy);
        myDiagram.addDiagramListener("SelectionCopied", fixLinkKeysOnCopy);

        // define a simple Node template
        myDiagram.nodeTemplate = $(
          go.Node,
          "Auto", // the Shape will go around the TextBlock
          $(
            go.Shape,
            "RoundedRectangle",
            { strokeWidth: 0, fill: "white" },
            // Shape.fill is bound to Node.data.color
            new go.Binding("fill", "color")
          ),
          $(
            go.TextBlock,
            { margin: 8, font: "bold 14px sans-serif", stroke: "#333" }, // Specify a margin to add some room around the text
            // TextBlock.text is bound to Node.data.key
            new go.Binding("text", "key")
          )
        );

        const getKeyForLinkData = (d) => `${d.from}####${d.to}`;

        // create the model data that will be represented by Nodes and Links
        myDiagram.model = $(go.GraphLinksModel, {
          linkKeyProperty: "key",
          getKeyForLinkData,
          makeUniqueLinkKeyFunction: (m, d) => getKeyForLinkData(d),
          nodeDataArray: [
            { key: 1, text: "Alpha", color: "lightblue" },
            { key: 2, text: "Beta", color: "orange" },
            { key: 3, text: "Gamma", color: "lightgreen" },
            { key: 4, text: "Delta", color: "pink" },
          ],
          linkDataArray: [
            { from: 1, to: 2 },
            { from: 1, to: 3 },
            { from: 2, to: 2 },
            { from: 3, to: 4 },
            { from: 4, to: 1 },
          ],
        });

        const fixUndoneLinks = (e) => {
          e.object.changes.each((c) => {
            if (c.modelChange === "linkFromKey") {
              c.object[c.model.linkFromKeyProperty] = c.newValue;
            }
            if (c.modelChange === "linkToKey") {
              c.object[c.model.linkToKeyProperty] = c.newValue;
            }
          });
        };

        myDiagram.addModelChangedListener((e) => {
          if (e.isTransactionFinished && e.model) {
            if (e.propertyName === "FinishedUndo") fixUndoneLinks(e);
            const dataChanges = e.model.toIncrementalJson(e);
            if (dataChanges !== null) {
              document.getElementById("modeldata").innerHTML =
                myDiagram.model.toJson();
              document.getElementById("datachanges").innerHTML = dataChanges;
            }
          }
        });
      }
    </script>
  </body>
</html>

Am I missing something?

I think your key naming scheme for links is inadequate. What if there are duplicate links between nodes? And it doesn’t handle disconnected links. Note that all links are initially unconnected with any nodes.

You aren’t calling GraphLinksModel.setKeyForLinkData in fixUndoneLinks.

Nor upon a redo.

I suspect it would be better if you implemented a model Changed listener that called GraphLinksModel.setKeyForLinkData whenever “from” or “to” changed.

Sorry about that. When I posted a solution earlier, I didn’t notice that you were attempting to override getKeyForLinkData. This method is not overridable. This led to different behavior in my testing with our local tools.

<!DOCTYPE html>
<html>
  <head>
    <title>Copy paste undo removed link keys</title>
    <meta charset="UTF-8" />
  </head>

  <body onload="init()">
    <div id="app">
      <div
        id="diagram"
        style="border: solid 1px black; width: 100%; height: 300px"
      ></div>
      <ul>
        <li>Select 1, 3 and 1###3</li>
        <li>Copy (CTRL+C)</li>
        <li>
          Paste (CTRL+V)
          <pre>Data changes shows "insertedLinkKeys": ["-6####-5"]</pre>
        </li>
        <li>Undo (CTRL+Z</li>
        <li>
          Removed link key wrong:
          <pre>
Data changes shows "removedLinkKeys": ["undefined####undefined"]
</pre
          >
        </li>
      </ul>
      <div style="display: flex">
        <div style="width: 50%">
          <h2>Model data</h2>
          <pre id="modeldata" style="height: 300px; overflow: scroll"></pre>
        </div>
        <div style="width: 50%">
          <h2>Datachanges</h2>
          <pre id="datachanges" style="height: 300px; overflow: scroll"></pre>
        </div>
      </div>
    </div>

    <script src="https://unpkg.com/gojs"></script>
    <script id="code">
      function init() {
        var $ = go.GraphObject.make; // for conciseness in defining templates

        myDiagram = $(
          go.Diagram,
          "diagram", // create a Diagram for the DIV HTML element
          {
            "undoManager.isEnabled": true, // enable undo & redo
          }
        );

        const fixLinkKeysOnCopy = (e) => {
          e.subject.each((part) => {
            if (part instanceof go.Link) {
              const ld = part.data;
              const model = part.diagram?.model;
              if (model !== null && model !== undefined) {
                model.setKeyForLinkData(ld, getKeyForLinkData(ld));
              }
            }
          });
        };
        myDiagram.addDiagramListener("ClipboardPasted", fixLinkKeysOnCopy);
        myDiagram.addDiagramListener("SelectionCopied", fixLinkKeysOnCopy);

        // define a simple Node template
        myDiagram.nodeTemplate = $(
          go.Node,
          "Auto", // the Shape will go around the TextBlock
          $(
            go.Shape,
            "RoundedRectangle",
            { strokeWidth: 0, fill: "white" },
            // Shape.fill is bound to Node.data.color
            new go.Binding("fill", "color")
          ),
          $(
            go.TextBlock,
            { margin: 8, font: "bold 14px sans-serif", stroke: "#333" }, // Specify a margin to add some room around the text
            // TextBlock.text is bound to Node.data.key
            new go.Binding("text", "key")
          )
        );

        const getKeyForLinkData = (d) => `${d.from}####${d.to}`;

        // create the model data that will be represented by Nodes and Links
        myDiagram.model = $(go.GraphLinksModel, {
          linkKeyProperty: "key",
          makeUniqueLinkKeyFunction: (m, d) => getKeyForLinkData(d),
          nodeDataArray: [
            { key: 1, text: "Alpha", color: "lightblue" },
            { key: 2, text: "Beta", color: "orange" },
            { key: 3, text: "Gamma", color: "lightgreen" },
            { key: 4, text: "Delta", color: "pink" },
          ],
          linkDataArray: [
            { from: 1, to: 2 },
            { from: 1, to: 3 },
            { from: 2, to: 2 },
            { from: 3, to: 4 },
            { from: 4, to: 1 },
          ],
        });

        const fixUndoneLinks = (e) => {
          e.object.changes.each((c) => {
            if (c.modelChange === "linkKey") {
              c.object[c.model.linkKeyProperty] = c.newValue;
            }
          });
        };

        myDiagram.addModelChangedListener((e) => {
          if (e.isTransactionFinished && e.model) {
            if (e.propertyName === "FinishedUndo") fixUndoneLinks(e);
            const dataChanges = e.model.toIncrementalJson(e);
            if (dataChanges !== null) {
              document.getElementById("modeldata").innerHTML =
                myDiagram.model.toJson();
              document.getElementById("datachanges").innerHTML = dataChanges;
            }
          }
        });
      }
    </script>
  </body>
</html>

This seems to work with both undo and redo. But as Walter says, it may be better to improve the key naming scheme for links such that this workaround isn’t needed.