Undo/Redo With Non Realtime Dragging and Shifting

Hi,

I have a question in regards to a code snippet that I have been using ever since Walter shared it for a previous question of mine.

https://forum.nwoods.com/t/dynamic-layer-spacing-for-tree-layout/16571/4

It works almost 100% however there seems to be an issue with undoing drag and drop operations. Specifically, when a node is successfully dropped and reordered, undoing the transaction leads to a weird layout. Is there a way to resolve the shifted subtree always being correct after an undo?

Just to show the issue in action, here is the starting layout for the sample:

Here is after Delta is reordered in front of Gamma:

Here is an immediate undo of the drop:

Redoing the transaction gets the layout back to the second screenshot which is correct. But then another undo to try and fix the third screenshot doesn’t work and the layout is still not right.

Thanks for the help in advance!

We’ll investigate this.

Add this removeAll statement to the DropZoneDraggingTool.doDragOver method override, after the call to Node.findTreeParts:

          this._subtree = link.toNode.findTreeParts();
          this._subtree.removeAll(this.diagram.selection);
          // shift subtree rightward ...

I have also updated the original code in that other topic: Dynamic Layer Spacing for Tree Layout

There does seem to be a regression during the drag but not sure if this is intentional. In the sample you provided, let’s say you drag Delta to be in front of Gamma. While hovering on the drop zone, Delta no longer shifts with the rest of the subtree to the right and as a result, Gamma and Delta now overlap. Anyway to avoid this regression?

I have dragging from a palette as well so an item in the main canvas can be selected and when I try to insert a new node from the palette, the selected node doesn’t shift even though it’s not being dragged.

Ah, yes, I see what you mean. I will look at it.

[EDIT] I hope I have fixed it and made sure that drag-and-drop from a Palette works. The code is in that other topic: Dynamic Layer Spacing for Tree Layout

Hey Walter, your example seems to work great, that is the exact functionality I am looking for. Unfortunately, it seems like I must be doing something wrong on my end so I am not getting the same result. I am investigating a bit more to see where I differ but I may followup.

Ok, so after some further digging, I had missed a few changes that helped fixed most of the issues. After getting these changes on my end, all I had was one final issue and I can reproduce it with your example. In my link template, I had defined

routing: go.Link.AvoidsNodes

Setting this value for routing for some reason breaks the undo when dragging a new element from the palette and trying to splice it by dropping on a link. Is this expected for this type of routing and I can’t use it or is there something else we can do?

Thanks

<!DOCTYPE html>
<html>

<head>
  <title>Splice Node into Link with DropZone</title>
  <!-- Copyright 1998-2024 by Northwoods Software Corporation. -->
</head>

<body>
  <div style="width: 100%; display: flex; justify-content: space-between">
    <div style="display: flex; flex-direction: column; margin: 0 2px 0 0">
      <div id="myPaletteDiv" style="flex-grow: 1; width: 100px; background-color: floralwhite; border: solid 1px black"></div>
      <div id="myOverviewDiv" style="margin: 2px 0 0 0; width: 100px; height: 100px; background-color: whitesmoke; border: solid 1px black"></div>
    </div>
    <div id="myDiagramDiv" style="flex-grow: 1; height: 400px; border: solid 1px black"></div>
  </div>
  <textarea id="mySavedModel" style="width:100%;height:250px"></textarea>

  <script src="go.js"></script>
  <script src="../site/extensions/NonRealtimeDraggingTool.js"></script>
  <!-- <script src="https://unpkg.com/gojs"></script>
  <script src="https://unpkg.com/gojs/extensions/NonRealtimeDraggingTool.js"></script> -->
  <script id="code">
class DropZoneDraggingTool extends NonRealtimeDraggingTool {
  constructor(init) {
    super();
    this.subtreeOffset = new go.Point(100, 0);
    const $ = go.GraphObject.make;
    this.DropZone =
      $(go.Adornment, "Auto",
        { locationSpot: go.Spot.Center },
        $(go.Shape, "RoundedRectangle",
          { fill: "white", strokeWidth: 5, stroke: "lightblue" }),
        $(go.Panel, "Auto",
          { width: 100, height: 60, margin: 5 },
          $(go.Shape, "RoundedRectangle",
            { fill: "white", stroke: "lightblue", strokeDashArray: [4, 2] }),
          $(go.Panel, "Spot",
            $(go.Shape,
              { fill: "white", stroke: "lightgray", strokeDashArray: [4, 2], width: 30, height: 20 }),
            $(go.Shape,
              {
                alignment: new go.Spot(0.5, 0.5, 0, -10),
                geometryString: "M5 0L5 20 M0 15 L5 20 10 15",
                stroke: "lightgray", strokeWidth: 2
              })
          )
        )
      );
    this.DropZone.ensureBounds();
    // internal state
    this._subtree = null;
    this._draggedNode = null;
    if (init) Object.assign(this, init);
  }

  // User must drag a single Node
  findDraggablePart() {
    if (this.diagram.selection.count > 1) return null;
    const part = super.findDraggablePart();
    if (part instanceof go.Node) {
      this._draggedNode = part;
      return part;
    }
    return null;
  }

  // Show a DropZone if dragging over a Link (other than one connected with _draggedNode)
  doDragOver(pt, obj) {  // method override
    // find Part at PT, ignoring temporary Parts except for the DropZone (temporary because it's an Adornment)
    var trgt = this.diagram.findObjectAt(pt, x => x.part, x => x === this.DropZone || !x.layer.isTemporary);
    if (trgt instanceof go.Link) {
      const link = trgt;
      if (link.fromNode === this._draggedNode || link.toNode === this._draggedNode) return;
      this._link = link;
      if (this.DropZone.adornedPart === null) {
        const needsShift = link.routeBounds.width < this.DropZone.actualBounds.width + 100 ||
                           link.routeBounds.height < this.DropZone.actualBounds.height + 100;
        const oldskips = this.diagram.skipsUndoManager;
        this.diagram.skipsUndoManager = true;
        if (needsShift) {
          this._subtree = link.toNode.findTreeParts();
          // shift subtree rightward
          this.diagram.moveParts(this._subtree, this.subtreeOffset);
        }
        link.isHighlighted = true;
        this.DropZone.adornedObject = link;
        link.addAdornment("DropZone", this.DropZone);
        this.diagram.skipsUndoManager = oldskips;
        // if nodes were moved, the link will be re-routed later, so the Link.midPoint will only be valid later
        if (needsShift) setTimeout(() => this.DropZone.location = link.midPoint);
      }
    } else if (trgt !== this.DropZone) {
      this.cleanup();
    }
  }

  // this assumes Nodes are avoidable
  doDropOnto(pt, obj) {
    if (this.isActive) return;
    // just handle external drag-and-drop,
    // to avoid accidentally rerouting links that cross where the new node is dropped
    const node = this.diagram.selection.first();
    if (node instanceof go.Node) node.avoidable = false;
    super.doDropOnto(pt, obj);
  }

  cleanup() {
    const link = this.DropZone.adornedPart;
    if (link) {
      const oldskips = this.diagram.skipsUndoManager;
      this.diagram.skipsUndoManager = true;
      if (this._subtree) {
        // shift subtree leftward
        this.diagram.moveParts(this._subtree, new go.Point(-this.subtreeOffset.x, -this.subtreeOffset.y));
        this._subtree = null;
      }
      if (!this.isActive) {  // just handle external drag-and-drop; only needed if dropped nodes are avoidable
        const node = this.diagram.selection.first();
        if (node instanceof go.Node) node.avoidable = true;
      }
      link.isHighlighted = false;
      link.removeAdornment("DropZone");
      this.DropZone.adornedObject = null;
      this.diagram.skipsUndoManager = oldskips;
    }
  }

  // If dropped into DropZone, splice it into the corresponding Link
  // (Note, not using doDropOnto due to undo problems.
  // Overriding doMouseUp means needing "ExternalObjectsDropped" listener too,
  // duplicating some of the work.)
  doMouseUp() {  // method override
    const link = this.DropZone.adornedPart;
    const node = this._draggedNode;
    const pt = this.diagram.lastInput.documentPoint;
    const trgt = this.diagram.findObjectAt(pt, x => x.part, x => x === this.DropZone);
    if (trgt === this.DropZone) {
      this.cleanup();
      this.spliceIntoLink(link, node);
    } else {
      this.cleanup();
    }
    super.doMouseUp();
  }

  // Splice the _draggedNode into the dropped-onto Link
  spliceIntoLink(link, node) {
    if (!link || !node) return;
    const diag = this.diagram;
    if (!diag) return;
    // disconnect node being dropped (copy collection to avoid iterating over modifications)
    new go.List(node.findLinksConnected()).each(l => diag.remove(l));
    const to = link.toNode;
    const linkdata = {};
    diag.model.addLinkData(linkdata);
    const newlink = diag.findLinkForData(linkdata);
    if (newlink !== null) {
      // splice in that node
      link.toNode = node;
      newlink.fromNode = node;
      newlink.toNode = to;
    }
  }

  doDeactivate() {
    this._subtree = null;
    this._draggedNode = null;
    super.doDeactivate();
  }

  doCancel() {
    this.cleanup();
    super.doCancel();
  }
}


const $ = go.GraphObject.make;

const myDiagram =
  new go.Diagram("myDiagramDiv",
    {
      layout: new go.TreeLayout(),
      // install the replacement DraggingTool:
      draggingTool: new DropZoneDraggingTool({ duration: 400 }),
      "ExternalObjectsDropped": e => {
        const tool = e.diagram.toolManager.draggingTool;
        const pt = e.diagram.lastInput.documentPoint;
        const trgt = e.diagram.findObjectAt(pt, x => x.part, x => x === tool.DropZone);
        if (trgt === tool.DropZone) {
          const link = tool.DropZone.adornedPart;
          const node = e.diagram.selection.first();
          tool.cleanup();
          tool.spliceIntoLink(link, node);
        }
      },
      "undoManager.isEnabled": true,
      "ModelChanged": e => {     // just for demonstration purposes,
        if (e.isTransactionFinished) {  // show the model data in the page's TextArea
          document.getElementById("mySavedModel").textContent = e.model.toJson();
        }
      }
    });

myDiagram.nodeTemplate =
  $(go.Node, "Auto",
    { locationSpot: go.Spot.Center },
    $(go.Shape, { fill: "white" },
      new go.Binding("fill", "color")),
    $(go.TextBlock, { margin: 8 },
      new go.Binding("text"))
  );

myDiagram.linkTemplate =
  $(go.Link,
    { routing: go.Link.AvoidsNodes, reshapable: true },
    // the highlight path Shape
    $(go.Shape, { isPanelMain: true, strokeWidth: 7, stroke: "transparent" },
      // when highlighted, show this thick Shape in red
      new go.Binding("stroke", "isHighlighted", h => h ? "red" : "transparent").ofObject()),
    // the normal path Shape
    $(go.Shape, { isPanelMain: true, strokeWidth: 1.5 }),
    $(go.Shape, { toArrow: "OpenTriangle" })
  );

myDiagram.model = new go.GraphLinksModel(
  [
    { key: 1, text: "Alpha", color: "lightblue" },
    { key: 2, text: "Beta", color: "orange" },
    { key: 3, text: "Gamma", color: "lightgreen" },
    { key: 4, text: "Delta", color: "pink" },
    { key: 5, text: "Epsilon", color: "yellow" },
    { key: 6, text: "Zeta", color: "lightblue" },
    { key: 7, text: "Eta", color: "orange" },
    { key: 8, text: "Theta", color: "lightgreen" },
  ],
  [
    { from: 1, to: 2 },
    { from: 1, to: 3 },
    { from: 3, to: 4 },
    { from: 4, to: 5 },
    { from: 1, to: 6 },
    { from: 6, to: 7 },
    { from: 6, to: 8 },
  ]);

// initialize Palette
myPalette =
  new go.Palette("myPaletteDiv",
    {
      nodeTemplateMap: myDiagram.nodeTemplateMap,
      model: new go.GraphLinksModel([
        { text: "red node", color: "red" },
        { text: "green node", color: "green" },
        { text: "blue node", color: "blue" },
        { text: "orange node", color: "orange" }
      ])
    });

myPalette.toolManager.draggingTool.doCancel = function() {
  myDiagram.toolManager.draggingTool.doCancel();
  go.DraggingTool.prototype.doCancel.call(this);
};

// initialize Overview
myOverview =
  new go.Overview("myOverviewDiv",
    {
      observed: myDiagram,
      contentAlignment: go.Spot.Center
    });  </script>
</body>

</html>

@walter, I’m still seeing the same issue with the code snippet you shared. Is the following code above a work in progress?

Here is me using the code above inserting the red node in the middle and then undoing the action.



Sorry about that. I’ll work on it some more…

1 Like

OK, try this now.

<!DOCTYPE html>
<html>

<head>
  <title>Splice Node into Link with DropZone</title>
  <!-- Copyright 1998-2024 by Northwoods Software Corporation. -->
</head>

<body>
  <div style="width: 100%; display: flex; justify-content: space-between">
    <div style="display: flex; flex-direction: column; margin: 0 2px 0 0">
      <div id="myPaletteDiv" style="flex-grow: 1; width: 100px; background-color: floralwhite; border: solid 1px black"></div>
      <div id="myOverviewDiv" style="margin: 2px 0 0 0; width: 100px; height: 100px; background-color: whitesmoke; border: solid 1px black"></div>
    </div>
    <div id="myDiagramDiv" style="flex-grow: 1; height: 400px; border: solid 1px black"></div>
  </div>
  <textarea id="mySavedModel" style="width:100%;height:250px"></textarea>

  <script src="https://unpkg.com/gojs"></script>
  <script src="https://unpkg.com/gojs/extensions/NonRealtimeDraggingTool.js"></script>
  <script id="code">
class DropZoneDraggingTool extends NonRealtimeDraggingTool {
  constructor(init) {
    super();
    this.subtreeOffset = new go.Point(100, 0);
    const $ = go.GraphObject.make;
    this.DropZone =
      $(go.Adornment, "Auto",
        { locationSpot: go.Spot.Center },
        $(go.Shape, "RoundedRectangle",
          { fill: "white", strokeWidth: 5, stroke: "lightblue" }),
        $(go.Panel, "Auto",
          { width: 100, height: 60, margin: 5 },
          $(go.Shape, "RoundedRectangle",
            { fill: "white", stroke: "lightblue", strokeDashArray: [4, 2] }),
          $(go.Panel, "Spot",
            $(go.Shape,
              { fill: "white", stroke: "lightgray", strokeDashArray: [4, 2], width: 30, height: 20 }),
            $(go.Shape,
              {
                alignment: new go.Spot(0.5, 0.5, 0, -10),
                geometryString: "M5 0L5 20 M0 15 L5 20 10 15",
                stroke: "lightgray", strokeWidth: 2
              })
          )
        )
      );
    this.DropZone.ensureBounds();
    // internal state
    this._subtree = null;
    this._wasAvoidsNodes = false;
    this._draggedNode = null;
    if (init) Object.assign(this, init);
  }

  // User must drag a single Node
  findDraggablePart() {
    if (this.diagram.selection.count > 1) return null;
    const part = super.findDraggablePart();
    if (part instanceof go.Node) {
      this._draggedNode = part;
      return part;
    }
    return null;
  }

  // Show a DropZone if dragging over a Link (other than one connected with _draggedNode)
  doDragOver(pt, obj) {  // method override
    // find Part at PT, ignoring temporary Parts except for the DropZone (temporary because it's an Adornment)
    var trgt = this.diagram.findObjectAt(pt, x => x.part, x => x === this.DropZone || !x.layer.isTemporary);
    if (trgt instanceof go.Link) {
      const link = trgt;
      if (link.fromNode === this._draggedNode || link.toNode === this._draggedNode) return;
      if (this.DropZone.adornedPart === null) {
        const mid = link.routeBounds.center;
        const needsShift = link.routeBounds.width < this.DropZone.actualBounds.width + 100 ||
                           link.routeBounds.height < this.DropZone.actualBounds.height + 100;
        const oldskips = this.diagram.skipsUndoManager;
        this.diagram.skipsUndoManager = true;
        if (needsShift) {
          this._wasAvoidsNodes = link.routing === go.Link.AvoidsNodes;
          if (this._wasAvoidsNodes) link.routing = go.Link.Orthogonal;
          this._subtree = link.toNode.findTreeParts();
          // shift subtree rightward
          this.diagram.moveParts(this._subtree, this.subtreeOffset);
        }
        link.isHighlighted = true;
        this.DropZone.adornedObject = link;
        link.addAdornment("DropZone", this.DropZone);
        this.DropZone.location = new go.Point(mid.x + 50, mid.y);
        this.diagram.skipsUndoManager = oldskips;
      }
    } else if (trgt !== this.DropZone) {
      this.cleanup();
    }
  }

  cleanup() {
    const link = this.DropZone.adornedPart;
    if (link) {
      const oldskips = this.diagram.skipsUndoManager;
      this.diagram.skipsUndoManager = true;
      if (this._subtree) {
        // shift subtree leftward
        this.diagram.moveParts(this._subtree, new go.Point(-this.subtreeOffset.x, -this.subtreeOffset.y));
        this._subtree = null;
        if (this._wasAvoidsNodes) link.routing = go.Link.AvoidsNodes;
        this._wasAvoidsNodes = false;
      }
      link.isHighlighted = false;
      link.removeAdornment("DropZone");
      this.DropZone.adornedObject = null;
      this.diagram.skipsUndoManager = oldskips;
    }
  }

  // If dropped into DropZone, splice it into the corresponding Link
  // (Note, not using doDropOnto due to undo problems.
  // Overriding doMouseUp means needing "ExternalObjectsDropped" listener too,
  // duplicating some of the work.)
  doMouseUp() {  // method override
    const link = this.DropZone.adornedPart;
    const node = this._draggedNode;
    const pt = this.diagram.lastInput.documentPoint;
    const trgt = this.diagram.findObjectAt(pt, x => x.part, x => x === this.DropZone);
    if (trgt === this.DropZone) {
      this.cleanup();
      this.spliceIntoLink(link, node);
    } else {
      this.cleanup();
    }
    super.doMouseUp();
  }

  // Splice the _draggedNode into the dropped-onto Link
  spliceIntoLink(link, node) {
    if (!link || !node) return;
    const diag = this.diagram;
    if (!diag) return;
    // disconnect node being dropped (copy collection to avoid iterating over modifications)
    new go.List(node.findLinksConnected()).each(l => diag.remove(l));
    const to = link.toNode;
    const linkdata = {};
    diag.model.addLinkData(linkdata);
    const newlink = diag.findLinkForData(linkdata);
    if (newlink !== null) {
      // splice in that node
      link.toNode = node;
      newlink.fromNode = node;
      newlink.toNode = to;
    }
  }

  doDeactivate() {
    this._subtree = null;
    this._draggedNode = null;
    super.doDeactivate();
  }

  doCancel() {
    this.cleanup();
    super.doCancel();
  }
}


const $ = go.GraphObject.make;

const myDiagram =
  new go.Diagram("myDiagramDiv",
    {
      layout: new go.TreeLayout(),
      // install the replacement DraggingTool:
      draggingTool: new DropZoneDraggingTool({ duration: 400 }),
      "ExternalObjectsDropped": e => {
        const tool = e.diagram.toolManager.draggingTool;
        const pt = e.diagram.lastInput.documentPoint;
        const trgt = e.diagram.findObjectAt(pt, x => x.part, x => x === tool.DropZone);
        if (trgt === tool.DropZone) {
          const link = tool.DropZone.adornedPart;
          const node = e.diagram.selection.first();
          tool.cleanup();
          tool.spliceIntoLink(link, node);
        }
      },
      "undoManager.isEnabled": true,
      "ModelChanged": e => {     // just for demonstration purposes,
        if (e.isTransactionFinished) {  // show the model data in the page's TextArea
          document.getElementById("mySavedModel").textContent = e.model.toJson();
        }
      }
    });

myDiagram.nodeTemplate =
  $(go.Node, "Auto",
    { locationSpot: go.Spot.Center },
    $(go.Shape, { fill: "white" },
      new go.Binding("fill", "color")),
    $(go.TextBlock, { margin: 8 },
      new go.Binding("text"))
  );

myDiagram.linkTemplate =
  $(go.Link,
    { routing: go.Link.AvoidsNodes, reshapable: true },
    // the highlight path Shape
    $(go.Shape, { isPanelMain: true, strokeWidth: 7, stroke: "transparent" },
      // when highlighted, show this thick Shape in red
      new go.Binding("stroke", "isHighlighted", h => h ? "red" : "transparent").ofObject()),
    // the normal path Shape
    $(go.Shape, { isPanelMain: true, strokeWidth: 1.5 }),
    $(go.Shape, { toArrow: "OpenTriangle" })
  );

myDiagram.model = new go.GraphLinksModel(
  [
    { key: 1, text: "Alpha", color: "lightblue" },
    { key: 2, text: "Beta", color: "orange" },
    { key: 3, text: "Gamma", color: "lightgreen" },
    { key: 4, text: "Delta", color: "pink" },
    { key: 41, text: "Delta", color: "pink" },
    { key: 5, text: "Epsilon", color: "yellow" },
    { key: 6, text: "Zeta", color: "lightblue" },
    { key: 7, text: "Eta", color: "orange" },
    { key: 8, text: "Theta", color: "lightgreen" },
  ],
  [
    { from: 1, to: 2 },
    { from: 1, to: 3 },
    { from: 3, to: 4 },
    { from: 3, to: 41 },
    { from: 4, to: 5 },
    { from: 1, to: 6 },
    { from: 6, to: 7 },
    { from: 6, to: 8 },
  ]);

// initialize Palette
myPalette =
  new go.Palette("myPaletteDiv",
    {
      nodeTemplateMap: myDiagram.nodeTemplateMap,
      model: new go.GraphLinksModel([
        { text: "red node", color: "red" },
        { text: "green node", color: "green" },
        { text: "blue node", color: "blue" },
        { text: "orange node", color: "orange" }
      ])
    });

myPalette.toolManager.draggingTool.doCancel = function() {
  myDiagram.toolManager.draggingTool.doCancel();
  go.DraggingTool.prototype.doCancel.call(this);
};

// initialize Overview
myOverview =
  new go.Overview("myOverviewDiv",
    {
      observed: myDiagram,
      contentAlignment: go.Spot.Center
    });  </script>
</body>

</html>

Thank you so much @walter, it works perfectly!