itemIndex based position/geometry with undo/redo

Hello.

I’m looking for assistance with port positioning as a result of undo/redo. I’m running into the following scenario where we start out with three ports…

After I select Output table 2, delete it, and trigger undo, I end up with the following:

In this case, it looks like the port is recreated, but it’s position & geometry are off. From what I gather, that’s because of these two bindings…

new Binding(
  'geometry',
  'itemIndex',
  (i: number, shape: Shape) => {
    return new Geometry().add(
      new PathFigure(0, 0)
        .add(
          new PathSegment(/* a calculation calculation involving i & shape */)
        )
        .add(
          new PathSegment(/* a calculation calculation involving i & shape */)
        )
    );
  }
).ofObject(),
new Binding(
  'position',
  'itemIndex',
  (i: number) => {}
).ofObject(),

When clicking “undo” to recreate the port, itemIndex is NaN. To work around this in our code, we end up doing something like i ||= 0 , but that leads to the issue above.

One thing I have found that works is if we do something like…

// diagram.model.nodeDataArray[0] _just_ represents our left node above
diagram.findPartForData(diagram.model.nodeDataArray[0])?.updateTargetBindings();

However, this only works after setting a timeout after undo/redo are complete, which feels a bit hacky.

Is there a better way to figure out what the “future” item index might be in this case, or some other way of knowing when to call updateTargetBindings?

Bindings are not supposed to be evaluated during undo or redo.

Your second Binding’s conversion function isn’t returning a value – it’s just a no-op. Or did you elide that code?

You could try calling updateTargetBindings in a “FinishedUndo” or “FinishedRedo” Model Changed event listener.

Bindings are not supposed to be evaluated during undo or redo.

Hmm. It seems the functions are being called, but maybe something else is responsible for doing this

Your second Binding’s conversion function isn’t returning a value – it’s just a no-op. Or did you elide that code?

Yeah, I just left that one empty. It returns a new Point

You could try calling updateTargetBindings in a “FinishedUndo” or “FinishedRedo” Model Changed event listener.

This is what we’re doing at the moment, but it doesn’t seem to do anything. If we wait (i.e. a setTimeout w/ 200ms delay) and call updateTargetBindings, things work as expected

UPDATE: We are calling updateTargetBindings when a link is connected, and during FinishedUndo / FinishedRedo . I’m guessing that is why the bindings are being evaluated during undo/redo

I wonder if the explicit call to updateTargetBindings when a link is connected is messing up the order in which state changes are being recorded, causing the problem with undo. How do you detect that a link has been connected, so that you call updateTargetBindings?

It’s part of our node setup, like…

{
  /* ... */
  alignmentFocusName: 'SHAPE',
  layoutConditions: LayoutConditions.Standard & ~LayoutConditions.NodeSized,
  linkConnected: (node: GoJSNode) => {
    // this is to handle magic ports- after a connection is made, they require the node to redraw the ports
    node.updateTargetBindings();
  },
  linkDisconnected: (node: GoJSNode) => {
    // this is to handle magic ports- after a connection is removed, they require the node to redraw the ports
    node.updateTargetBindings();
  },
  /* ... */
}

I modified the code I gave you folks before, adding context menu support for adding and removing ports. I haven’t encountered any problems with undo or redo after adding or removing ports. But note that the call to updateTargetBindings happens immediately with the added or removed port. Here’s the updated code:

```
<!DOCTYPE html>
<html>

<head>
  <title>Tethered, Expandable, Labeled Ports</title>
  <!-- Copyright 1998-2025 by Northwoods Software Corporation. -->
</head>

<body>
  <div id="myDiagramDiv" style="border: solid 1px black; width:100%; height:700px"></div>

  <script src="https://cdn.jsdelivr.net/npm/gojs/release/go-debug.js"></script>
  <script id="code">

const myDiagram =
  new go.Diagram("myDiagramDiv", {
      layout: new go.LayeredDigraphLayout(),
      "undoManager.isEnabled": true
    });

const EW = 30;
const PW = 13;
const PH = 13;
const PLW = 13;
const PLH = 32;

function portStyle(port) {
  port.set({ width: PW - 1, height: PH - 1, fill: "transparent", toLinkable: true });
}

PortTemplateSimple =
  new go.Panel("Spot")
    .bindObject("position", "itemIndex", simplePosition)
    .add(
      new go.Shape()
        .apply(portStyle)
        .bind("portId", "id")
    );

function simplePosition(i, panel) {
  if (panel.panel.itemArray.length <= 3) return new go.Point(0, i * PH);
  return new go.Point(0, 0);
}

PortTemplateExpanded =
  new go.Panel()
    .add(
      new go.Shape()
        .apply(portStyle)
        .bind("portId", "id")
        .bindObject("position", "itemIndex", i => new go.Point(0, i * PH)),
      new go.Shape({ strokeDashArray: [2, 2] })
        .bindObject("geometry", "itemIndex", indexToGeo)
    );

function indexToGeo(i, shape) {
  const tot = shape.panel.panel.itemArray.length;
  return new go.Geometry().add(new go.PathFigure(0, 0)
    .add(new go.PathSegment(go.PathSegment.Move, EW + PW, tot * PH))
    .add(new go.PathSegment(go.PathSegment.Move, EW + PW, tot * PH / 2))
    .add(new go.PathSegment(go.PathSegment.Line, PW, i * PH + PH / 2)));
}

PortTemplateExpandedLabeled =
  new go.Panel()
    .add(
      new go.Shape()
        .apply(portStyle)
        .bind("portId", "id")
        .bindObject("position", "itemIndex", i => new go.Point(0, i * PLH)),
      new go.TextBlock({ alignment: new go.Spot(0.5, 0.5, 0, 1) })
        .bind("text", "id")
        .bindObject("position", "itemIndex", i => new go.Point(0, i * PLH + PH + 1)),
      new go.Shape({ strokeDashArray: [2, 2] })
        .bindObject("geometry", "itemIndex", indexToGeoL)
    );

function indexToGeoL(i, shape) {
  const tot = shape.panel.panel.itemArray.length;
  return new go.Geometry().add(new go.PathFigure(0, 0)
    .add(new go.PathSegment(go.PathSegment.Move, EW + PLW, tot * PLH))
    .add(new go.PathSegment(go.PathSegment.Move, EW + PLW, tot * PLH / 2))
    .add(new go.PathSegment(go.PathSegment.Line, PLW, i * PLH + PH / 2)));
}

myDiagram.nodeTemplate =
  new go.Node("Spot", {
      locationSpot: go.Spot.Center, locationObjectName: "BODY",
      layoutConditions: go.Part.LayoutStandard & ~go.Part.LayoutNodeSized,
      contextMenu:
        go.GraphObject.build("ContextMenu")
          .add(
            go.GraphObject.build("ContextMenuButton", {
                click: (e, but) => {
                  const node = but.part.adornedPart;
                  let arr = node.data.ports;
                  e.diagram.model.commit(m => {
                    if (!arr) {
                      arr = [];
                      m.set(node.data, "ports", arr);
                    }
                    m.addArrayItem(arr, { id: arr.length.toString() });
                    node.updateTargetBindings("ports");
                  });
                }
              })
              .add(new go.TextBlock("Add Port")),
            go.GraphObject.build("ContextMenuButton", {
                click: (e, but) => {
                  const node = but.part.adornedPart;
                  const arr = node.data.ports;
                  if (arr.length > 0) {
                    e.diagram.model.commit(m => {
                      m.removeArrayItem(arr, arr.length-1);
                      node.updateTargetBindings("ports");
                    });
                  }
                }
              })
              .add(new go.TextBlock("Remove Last Port"))
          )
    })
    .add(
      new go.Panel("Table", { name: "BODY", alignmentFocusName: "SHAPE" })
        .add(
          new go.Shape({ name: "SHAPE", row: 0, width: 50, height: 50, fill: "lightblue", strokeWidth: 0, portId: "", fromLinkable: true }),
          new go.TextBlock({ row: 1, stretch: go.GraphObject.Horizontal, maxLines: 1, overflow: go.TextBlock.OverflowEllipsis })
            .bind("text")
        ),
      new go.Panel("Spot", { alignment: go.Spot.Left, alignmentFocus: go.Spot.Right })
        .add(
          new go.Panel({ itemTemplate: PortTemplateSimple })
            .bind("itemArray", "ports")
            .bind("itemTemplate", "", data => data.expanded
              ? (data.labeled ? PortTemplateExpandedLabeled : PortTemplateExpanded)
              : PortTemplateSimple),
          new go.TextBlock({ visible: false, alignment: new go.Spot(0.5, 0.5, 0, 1) })
            .bind("text", "ports", a => a.length.toString())
            .bind("visible", "", data => !data.expanded && data.ports.length > 3)
        )
    );

myDiagram.model = new go.GraphLinksModel({
    linkFromPortIdProperty: "fpid",
    linkToPortIdProperty: "tpid",
    nodeDataArray: [
      {
        key: 1, text: "Just 2",
        ports: [{ id: "a" }, { id: "b" }]
      },
      {
        key: 2, text: "No Labs", expanded: true,
        ports: [{ id: "a" }, { id: "b" }, { id: "c" }]
      },
      {
        key: 3, text: "Labels", expanded: true, labeled: true,
        ports: [{ id: "a" }, { id: "b" }, { id: "c" }]
      },
      {
        key: 4, text: "Collapsed", expanded: false,
        ports: [{ id: "a" }, { id: "b" }, { id: "c" }, { id: "d" }, { id: "e" }, { id: "f" }, { id: "g" }]
      },
      {
        key: 5, text: "Expanded", expanded: true,
        ports: [{ id: "a" }, { id: "b" }, { id: "c" }, { id: "d" }, { id: "e" }, { id: "f" }, { id: "g" }]
      }
    ],
    linkDataArray: [
      { from: 1, to: 2, tpid: "a" },
      { from: 1, to: 3, tpid: "c" },
      { from: 1, to: 4, tpid: "c" },
      { from: 1, to: 5, tpid: "e" }
    ]
  });
  </script>
</body>

</html>
```