NorthwoodsSoftware / GoJS

JavaScript diagramming library for interactive flowcharts, org charts, design tools, planning tools, visual languages.
http://gojs.net
Other
7.7k stars 2.86k forks source link

Orthogonal + Grid Snap = Jank #195

Closed ptrmsk closed 1 year ago

ptrmsk commented 1 year ago

I was demoing the project on the official website when I noticed it's very easy to get the tool to misbehave when reshaping links. This can be done easily on diagrams with orthogonal connections and grid snap enabled, like this one.

The issue appears to happen more often when dragging a node across one axis (ie left and right) without dragging it across another (ie up and down).

Here's a couple examples:

image

image

image

WalterNorthwoods commented 1 year ago

The behavior is caused by Links that have their Link.adjusting property set to go.Link.End. That was intentional in that sample, so that users do not lose any of the manual reshaping of link routes just because either connected node is moved.

However, the sample could be better by only setting Link.adjusting when the link is actually reshaped -- not beforehand. Here's the updated code:

  function init() {
    if (window.goSamples) goSamples();  // init for these samples -- you don't need to call this
    var $ = go.GraphObject.make;  // for conciseness in defining templates

    myDiagram =
      new go.Diagram("myDiagramDiv",  // must name or refer to the DIV HTML element
        {
          // supply a simple narrow grid that manually reshaped link routes will follow
          grid: $(go.Panel, "Grid",
            { gridCellSize: new go.Size(8, 8) },
            $(go.Shape, "LineH", { stroke: "lightgray", strokeWidth: 0.5 }),
            $(go.Shape, "LineV", { stroke: "lightgray", strokeWidth: 0.5 })
          ),
          "draggingTool.isGridSnapEnabled": true,
          linkReshapingTool: $(SnapLinkReshapingTool),
          // when the user reshapes a Link, change its Link.routing from AvoidsNodes to Orthogonal,
          // so that combined with Link.adjusting == End the link will retain its reshaped mid points
          // even after nodes are moved
          "LinkReshaped": e => {
            e.subject.adjusting = go.Link.End;
            e.subject.routing = go.Link.Orthogonal;
          },
          "animationManager.isEnabled": false,
          "undoManager.isEnabled": true
        });

    // when the document is modified, add a "*" to the title and enable the "Save" button
    myDiagram.addDiagramListener("Modified", e => {
      var button = document.getElementById("SaveButton");
      if (button) button.disabled = !myDiagram.isModified;
      var idx = document.title.indexOf("*");
      if (myDiagram.isModified) {
        if (idx < 0) document.title += "*";
      } else {
        if (idx >= 0) document.title = document.title.slice(0, idx);
      }
    });

    // Define a function for creating a "port" that is normally transparent.
    // The "name" is used as the GraphObject.portId, the "spot" is used to control how links connect
    // and where the port is positioned on the node, and the boolean "output" and "input" arguments
    // control whether the user can draw links from or to the port.
    function makePort(name, spot, output, input) {
      // the port is basically just a small transparent square
      return $(go.Shape, "Circle",
        {
          fill: null,  // not seen, by default; set to a translucent gray by showSmallPorts, defined below
          stroke: null,
          desiredSize: new go.Size(7, 7),
          alignment: spot,  // align the port on the main Shape
          alignmentFocus: spot,  // just inside the Shape
          portId: name,  // declare this object to be a "port"
          fromSpot: spot, toSpot: spot,  // declare where links may connect at this port
          fromLinkable: output, toLinkable: input,  // declare whether the user may draw links to/from here
          cursor: "pointer"  // show a different cursor to indicate potential link point
        });
    }

    myDiagram.nodeTemplate =
      $(go.Node, "Spot",
        { locationSpot: go.Spot.Center },
        new go.Binding("location", "loc", go.Point.parse).makeTwoWay(go.Point.stringify),
        { selectable: true },
        { resizable: true, resizeObjectName: "PANEL" },
        // the main object is a Panel that surrounds a TextBlock with a Shape
        $(go.Panel, "Auto",
          { name: "PANEL" },
          new go.Binding("desiredSize", "size", go.Size.parse).makeTwoWay(go.Size.stringify),
          $(go.Shape, "Rectangle",  // default figure
            {
              portId: "", // the default port: if no spot on link data, use closest side
              fromLinkable: true, toLinkable: true, cursor: "pointer",
              fill: "white"  // default color
            },
            new go.Binding("figure"),
            new go.Binding("fill")),
          $(go.TextBlock,
            {
              font: "bold 11pt Helvetica, Arial, sans-serif",
              margin: 8,
              maxSize: new go.Size(160, NaN),
              wrap: go.TextBlock.WrapFit,
              editable: true
            },
            new go.Binding("text").makeTwoWay())
        ),
        // four small named ports, one on each side:
        makePort("T", go.Spot.Top, false, true),
        makePort("L", go.Spot.Left, true, true),
        makePort("R", go.Spot.Right, true, true),
        makePort("B", go.Spot.Bottom, true, false),
        { // handle mouse enter/leave events to show/hide the ports
          mouseEnter: (e, node) => showSmallPorts(node, true),
          mouseLeave: (e, node) => showSmallPorts(node, false)
        }
      );

    function showSmallPorts(node, show) {
      node.ports.each(port => {
        if (port.portId !== "") {  // don't change the default port, which is the big shape
          port.fill = show ? "rgba(0,0,0,.3)" : null;
        }
      });
    }

    myDiagram.linkTemplate =
      $(go.Link,  // the whole link panel
        { relinkableFrom: true, relinkableTo: true, reshapable: true, resegmentable: true },
        {
          routing: go.Link.AvoidsNodes,  // but this is changed to go.Link.Orthgonal when the Link is reshaped
          curve: go.Link.JumpOver,
          corner: 5,
          toShortLength: 4
        },
        new go.Binding("points").makeTwoWay(),
        // remember the Link.routing too
        new go.Binding("routing", "routing", go.Binding.parseEnum(go.Link, go.Link.AvoidsNodes))
          .makeTwoWay(go.Binding.toString),
        new go.Binding("adjusting", "adjusting", go.Binding.parseEnum(go.Link, go.Link.None))
          .makeTwoWay(go.Binding.toString),
        $(go.Shape,  // the link path shape
          { isPanelMain: true, strokeWidth: 2 }),
        $(go.Shape,  // the arrowhead
          { toArrow: "Standard", stroke: null })
      );

    load();  // load an initial diagram from some JSON text

    var link = myDiagram.links.first();
    if (link) link.isSelected = true;

    // initialize the Palette that is on the left side of the page
    myPalette =
      new go.Palette("myPaletteDiv",  // must name or refer to the DIV HTML element
        {
          maxSelectionCount: 1,
          nodeTemplateMap: myDiagram.nodeTemplateMap,  // share the templates used by myDiagram
          model: new go.GraphLinksModel([  // specify the contents of the Palette
            { text: "Start", figure: "Circle", fill: "green" },
            { text: "Step" },
            { text: "DB", figure: "Database", fill: "lightgray" },
            { text: "???", figure: "Diamond", fill: "lightskyblue" },
            { text: "End", figure: "Circle", fill: "red" },
            { text: "Comment", figure: "RoundedRectangle", fill: "lightyellow" }
          ])
        });

    document.getElementById("AvoidsNodesCheckBox").onclick = e => {
      myDiagram.toolManager.linkReshapingTool.avoidsNodes = e.target.checked;
    }
  }

  // Show the diagram's model in JSON format that the user may edit
  function save() {
    saveDiagramProperties();  // do this first, before writing to JSON
    document.getElementById("mySavedModel").value = myDiagram.model.toJson();
    myDiagram.isModified = false;
  }
  function load() {
    myDiagram.model = go.Model.fromJson(document.getElementById("mySavedModel").value);
    loadDiagramProperties();
  }

  function saveDiagramProperties() {
    myDiagram.model.modelData.position = go.Point.stringify(myDiagram.position);
  }
  // Called by "InitialLayoutCompleted" DiagramEvent listener, NOT directly by load()!
  function loadDiagramProperties(e) {
    // set Diagram.initialPosition, not Diagram.position, to handle initialization side-effects
    var pos = myDiagram.model.modelData.position;
    if (pos) myDiagram.initialPosition = go.Point.parse(pos);
  }
  window.addEventListener('DOMContentLoaded', init);

Also I have cleaned up the initial link data object in the model:

  "linkDataArray": [
{"from":-10, "to":-11, "fromPort":"R", "toPort":"L", "points":[121,240,131,240,132,240,132,240,216,240,216,176,264,176,264,104,392,104,392,240,480,240,480,280,501,280,511,280]}
  ]