Possible to click endpoints to create link via LinkingTool?

TL;DR: “The LinkingTool lets a user draw a new Link between two ports, using a mouse-drag operation.” - Can I use this using mouse-click operations?

For the system I’m working on, we need to be able to create links by clicking between two points rather than dragging. I’ll be able to handle this outside GoJS by creating my own system to manage state of a linkingTool-esque state machine, but I would rather be able to do so using the levers GoJS provides.

The sort of functionality we’re looking for is as follows:

  • User clicks on port of Node A and then port of Node B. A link is drawn
  • User clicks on diagram, a placeholder node is created, user clicks on port of Node B. A link is drawn between placeholder and Node B.
  • User clicks on diagram, a placeholder node is created, user clicks on diagram again and a second placeholder is created. A link is drawn between placeholders.

We don’t need any sort of “virtual” link drawn; only the true link after the second click is needed.

Is this something that would be reasonably do-able with GoJS or would it be more effective for me to create a system outside of GoJS to handle the state and link/node creation?

Any feedback would be appreciated, thanks!

Well, this sample doesn’t implement anything like the “link placeholder” that you describe, but it at least demonstrates dragging from the source port, clicking at various places in the diagram, and finally clicking on the destination port:

Basically to do what you want, I think you’ll need a custom Tool that keeps track of the state of the operation.

Creating placeholder nodes on a click is easy enough – see the ClickCreatingTool: ClickCreatingTool | GoJS API. There are several example usages of this tool, such as System Dynamics. But you’ll want to do this node creation as just one step in this new tool that you’ll define to also draw links.

You’ll also need to handle what should happen when the user does something else as the second step. And handle cancellation.

Thanks, that helps a ton!

Side question: I would prefer to write this tool using Typescript rather than Javascript. Have you seen that done before? I’m struggling to convert to GoJS inheritance model to something that is translatable to Typescript.

In case it wasn’t clear from what I wrote, I think you want to inherit from LinkingTool.

To have the same effect as ClickCreatingTool.insertPart (but without raising the “PartCreated” DiagramEvent), do something like:

  var newdata = { ... };
  myDiagram.model.addNodeData(newdata);
  var newnode = myDiagram.findNodeForData(newdata);
  newnode.location = ...;
  myDiagram.select(newnode);

Sure you can use TypeScript. We used to have a test file in the GitHub DefinitelyTyped repository, but that’s many years old now. (Time flies!) Caution: I haven’t tried compiling this in TypeScript in years. Here it is:

// Test file for go.d.ts
// This is taken and adapted from https://gojs.net/latest/samples/basic.html

/* Copyright (C) 1998-2017 by Northwoods Software Corporation. */

import * as go from "go";

class CustomLink extends go.Link {
    constructor() {
        super();
        this.routing = go.Link.Orthogonal;
    }

    hasCurviness(): boolean {
        if (isNaN(this.curviness)) return true;
        return super.hasCurviness();
    }
 
    computeCurviness(): number {
        if (isNaN(this.curviness)) {
            var links = this.fromNode.findLinksTo(this.toNode);
            if (links.count < 2) return 0;
            var i = 0;
            while (links.next()) { if (links.value === this) break; i++; }
            return 10 * (i - (links.count - 1) / 2);
        }
        return super.computeCurviness();
    }
}

class CustomTreeLayout extends go.TreeLayout {
    constructor() {
        super();
        this.extraProp = 3;
    }

    extraProp: number;

    // override various methods

    cloneProtected(copy: CustomTreeLayout): void {
        super.cloneProtected(copy);
        copy.extraProp = this.extraProp;
    }

    createNetwork(): CustomTreeNetwork {
        return new CustomTreeNetwork();
    }

    assignTreeVertexValues(v: CustomTreeVertex): void {
        super.assignTreeVertexValues(v);
        v.someProp = Math.random() * 100;
    }

    commitNodes(): void {
        super.commitNodes();
        // ...
    }

    commitLinks(): void {
        super.commitLinks();
        this.network.edges.each(e => { e.link.path.strokeWidth = (<CustomTreeEdge>(e)).anotherProp; });
    }
}

class CustomTreeNetwork extends go.TreeNetwork {
    createVertex(): CustomTreeVertex {
        return new CustomTreeVertex();
    }

    createEdge(): CustomTreeEdge {
        return new CustomTreeEdge();
    }
}

class CustomTreeVertex extends go.TreeVertex {
    someProp: number = 17;
}

class CustomTreeEdge extends go.TreeEdge {
    anotherProp: number = 1;
}


export function init() {
    var $ = go.GraphObject.make;  // for conciseness in defining templates

    var myDiagram: go.Diagram =
        $(go.Diagram, "myDiagram",  // create a Diagram for the DIV HTML element
            {
                // position the graph in the middle of the diagram
                initialContentAlignment: go.Spot.Center,

                // allow double-click in background to create a new node
                "clickCreatingTool.archetypeNodeData": { text: "Node", color: "white" },

                // allow Ctrl-G to call groupSelection()
                "commandHandler.archetypeGroupData": { text: "Group", isGroup: true, color: "blue" },

                layout: $(CustomTreeLayout, { angle: 90 }),

                // enable undo & redo
                "undoManager.isEnabled": true
            });

    // Define the appearance and behavior for Nodes:

    // First, define the shared context menu for all Nodes, Links, and Groups.

    // To simplify this code we define a function for creating a context menu button:
    function makeButton(text: string, action: (e: go.InputEvent, obj: go.GraphObject) => void, visiblePredicate?: (obj: go.GraphObject) => boolean) {
        if (visiblePredicate === undefined) visiblePredicate = o => true;
        return $("ContextMenuButton",
                    $(go.TextBlock, text),
                    { click: action },
                    // don't bother with binding GraphObject.visible if there's no predicate
                    visiblePredicate ? new go.Binding("visible", "", visiblePredicate).ofObject() : {});
    }

    // a context menu is an Adornment with a bunch of buttons in them
    var partContextMenu =
        $(go.Adornment, "Vertical",
            makeButton("Properties",
                (e, obj) => {  // the OBJ is this Button
                    var contextmenu = <go.Adornment>obj.part;  // the Button is in the context menu Adornment
                    var part = contextmenu.adornedPart;  // the adornedPart is the Part that the context menu adorns
                    // now can do something with PART, or with its data, or with the Adornment (the context menu)
                    if (part instanceof go.Link) alert(linkInfo(part.data));
                    else if (part instanceof go.Group) alert(groupInfo(contextmenu));
                    else alert(nodeInfo(part.data));
                }),
            makeButton("Cut",
                       (e, obj) => e.diagram.commandHandler.cutSelection(),
                       o => o.diagram.commandHandler.canCutSelection()),
            makeButton("Copy",
                       (e, obj) => e.diagram.commandHandler.copySelection(),
                       o => o.diagram.commandHandler.canCopySelection()),
            makeButton("Paste",
                       (e, obj) => e.diagram.commandHandler.pasteSelection(e.diagram.lastInput.documentPoint),
                       o => o.diagram.commandHandler.canPasteSelection()),
            makeButton("Delete",
                       (e, obj) => e.diagram.commandHandler.deleteSelection(),
                       o => o.diagram.commandHandler.canDeleteSelection()),
            makeButton("Undo",
                       (e, obj) => e.diagram.commandHandler.undo(),
                       o => o.diagram.commandHandler.canUndo()),
            makeButton("Redo",
                       (e, obj) => e.diagram.commandHandler.redo(),
                       o => o.diagram.commandHandler.canRedo()),
            makeButton("Group",
                       (e, obj) => e.diagram.commandHandler.groupSelection(),
                       o => o.diagram.commandHandler.canGroupSelection()),
            makeButton("Ungroup",
                       (e, obj) => e.diagram.commandHandler.ungroupSelection(),
                       o => o.diagram.commandHandler.canUngroupSelection())
            );

    function nodeInfo(d) {  // Tooltip info for a node data object
        var str = "Node " + d.key + ": " + d.text + "\n";
        if (d.group)
            str += "member of " + d.group;
        else
            str += "top-level node";
        return str;
    }

    // These nodes have text surrounded by a rounded rectangle
    // whose fill color is bound to the node data.
    // The user can drag a node by dragging its TextBlock label.
    // Dragging from the Shape will start drawing a new link.
    myDiagram.nodeTemplate =
    $(go.Node, "Auto",
        { locationSpot: go.Spot.Center },
        $(go.Shape, "RoundedRectangle",
            {
                fill: "white", // the default fill, if there is no data-binding
                portId: "", cursor: "pointer",  // the Shape is the port, not the whole Node
                // allow all kinds of links from and to this port
                fromLinkable: true, fromLinkableSelfNode: true, fromLinkableDuplicates: true,
                toLinkable: true, toLinkableSelfNode: true, toLinkableDuplicates: true
            },
            new go.Binding("fill", "color")),
        $(go.TextBlock,
            {
                font: "bold 14px sans-serif",
                stroke: '#333',
                margin: 6,  // make some extra space for the shape around the text
                isMultiline: false,  // don't allow newlines in text
                editable: true  // allow in-place editing by user
            },
            new go.Binding("text", "text").makeTwoWay()),  // the label shows the node data's text
        { // this tooltip Adornment is shared by all nodes
            toolTip:
            $(go.Adornment, "Auto",
                $(go.Shape, { fill: "#FFFFCC" }),
                $(go.TextBlock, { margin: 4 },  // the tooltip shows the result of calling nodeInfo(data)
                    new go.Binding("text", "", nodeInfo))
                ),
            // this context menu Adornment is shared by all nodes
            contextMenu: partContextMenu
        }
    );

    // Define the appearance and behavior for Links:

    function linkInfo(d) {  // Tooltip info for a link data object
        return "Link:\nfrom " + d.from + " to " + d.to;
    }

    // The link shape and arrowhead have their stroke brush data bound to the "color" property
    myDiagram.linkTemplate =
    $(CustomLink,
        { relinkableFrom: true, relinkableTo: true },  // allow the user to relink existing links
        $(go.Shape,
            { strokeWidth: 2 },
            new go.Binding("stroke", "color")),
        $(go.Shape,
            { toArrow: "Standard", stroke: null },
            new go.Binding("fill", "color")),
        { // this tooltip Adornment is shared by all links
            toolTip:
            $(go.Adornment, "Auto",
                $(go.Shape, { fill: "#FFFFCC" }),
                $(go.TextBlock, { margin: 4 },  // the tooltip shows the result of calling linkInfo(data)
                    new go.Binding("text", "", linkInfo))
                ),
            // the same context menu Adornment is shared by all links
            contextMenu: partContextMenu
        }
    );

    // Define the appearance and behavior for Groups:

    function groupInfo(adornment: go.Adornment) {  // takes the tooltip, not a group node data object
        var g = <go.Group>adornment.adornedPart;  // get the Group that the tooltip adorns
        var mems = g.memberParts.count;
        var links = g.memberParts.filter(p => p instanceof go.Link).count;
        return "Group " + g.data.key + ": " + g.data.text + "\n" + mems + " members including " + links + " links";
    }

    // Groups consist of a title in the color given by the group node data
    // above a translucent gray rectangle surrounding the member parts
    myDiagram.groupTemplate =
    $(go.Group, "Vertical",
        {
            selectionObjectName: "PANEL",  // selection handle goes around shape, not label
            ungroupable: true,  // enable Ctrl-Shift-G to ungroup a selected Group
            layoutConditions: go.Part.LayoutStandard & ~go.Part.LayoutNodeSized
        },
        $(go.TextBlock,
            {
                font: "bold 12pt sans-serif",
                isMultiline: false,  // don't allow newlines in text
                editable: true  // allow in-place editing by user
            },
            new go.Binding("text", "text").makeTwoWay(),
            new go.Binding("stroke", "color")),
        $(go.Panel, "Auto",
            { name: "PANEL" },
            $(go.Shape, "Rectangle",  // the rectangular shape around the members
                { fill: "rgba(128,128,128,0.2)", stroke: "gray", strokeWidth: 3 }),
            $(go.Placeholder, { padding: 5 })  // represents where the members are
            ),
        { // this tooltip Adornment is shared by all groups
            toolTip:
            $(go.Adornment, "Auto",
                $(go.Shape, { fill: "#FFFFCC" }),
                $(go.TextBlock, { margin: 4 },
                    // bind to tooltip, not to Group.data, to allow access to Group properties
                    new go.Binding("text", "", groupInfo).ofObject())
                ),
            // the same context menu Adornment is shared by all groups
            contextMenu: partContextMenu
        }
    );

    // Define the behavior for the Diagram background:

    function diagramInfo(model: go.GraphLinksModel) {  // Tooltip info for the diagram's model
        return "Model:\n" + model.nodeDataArray.length + " nodes, " + model.linkDataArray.length + " links";
    }

    // provide a tooltip for the background of the Diagram, when not over any Part
    myDiagram.toolTip =
    $(go.Adornment, "Auto",
        $(go.Shape, { fill: "#FFFFCC" }),
        $(go.TextBlock, { margin: 4 },
            new go.Binding("text", "", diagramInfo))
    );

    // provide a context menu for the background of the Diagram, when not over any Part
    myDiagram.contextMenu =
    $(go.Adornment, "Vertical",
        makeButton("Paste",
                   (e, obj) => e.diagram.commandHandler.pasteSelection(e.diagram.lastInput.documentPoint),
                   o => o.diagram.commandHandler.canPasteSelection()),
        makeButton("Undo",
                   (e, obj) => e.diagram.commandHandler.undo(),
                   o => o.diagram.commandHandler.canUndo()),
        makeButton("Redo",
                   (e, obj) => e.diagram.commandHandler.redo(),
                   o => o.diagram.commandHandler.canRedo())
    );

    // Create the Diagram's Model:
    var nodeDataArray = [
        { key: 1, text: "Alpha", color: "lightblue" },
        { key: 2, text: "Beta", color: "orange" },
        { key: 3, text: "Gamma", color: "lightgreen", group: 5 },
        { key: 4, text: "Delta", color: "pink", group: 5 },
        { key: 5, text: "Epsilon", color: "green", isGroup: true }
    ];
    var linkDataArray = [
        { from: 1, to: 2, color: "blue" },
        { from: 2, to: 2 },
        { from: 3, to: 4, color: "green" },
        { from: 3, to: 1, color: "purple" }
    ];
    myDiagram.model = new go.GraphLinksModel(nodeDataArray, linkDataArray);

    var img = myDiagram.makeImageData({
      scale: 0.4, position: new go.Point(-10, -10)
    });
}

Okay, great! Thanks!

There are 2 hurdles I’m working through now that you may be able to provide some guidance on:

  1. If I override and reassign the linkingTool with my custom tool, in order to activate I’m seeing that I have to click and drag. Is there a way to just be able to click to activate? I tried assigning to the ActionTool, but saw issues as outlined in my next question
  2. With this, I would like to support panning at the same time; if a user has activated my clickLinkingTool, but then does a mouseDown and drag, it should pan the diagram rather than end the link on mouseUp. Is this possible?

Yes, you’ll need to remove the LinkingTool from the ToolManager.mouseMoveTools list and add your new tool to the ToolManager.mouseDownTools list. You might need to make changes to canStart and perhaps in other methods too, since you’ll need to handle additional states, such as mouse move events before becoming Tool.isActive.
Here’s the implementation of LinkingTool.canStart:

LinkingTool.prototype.canStart = function() {
  if (!this.isEnabled) return false;
  var diagram = this.diagram;
  if (diagram === null || diagram.isReadOnly || diagram.isModelReadOnly) return false;
  if (!diagram.allowLink) return false;
  var model = diagram.model;
  if (!(model instanceof GraphLinksModel) && !(model instanceof TreeModel)) return false;
  // require left button & that it has moved far enough away from the mouse down point, so it isn't a click
  if (!diagram.lastInput.left) return false;
  // don't include the following check when this tool is running modally
  if (diagram.currentTool !== this) {
    if (!this.isBeyondDragSize()) return false;
  }
  var port = this.findLinkablePort();
  return (port !== null);
};

You’ll probably need to remove the isBeyondDragSize check, since that clearly only applies when starting a tool on mouse move events.

Sure, the PolylineLinkingTool demonstrates handling multiple mousedown-mouseup events already. To support panning, I suppose in your doMouseMove override you could temporarily set PanningTool.isActive to true and call PanningTool.doMouseMove.

How do I modify the ToolManager tool lists? They appear to be read-only.

What error are you getting?

Edit: It does seem to work, but the documentation is still concerning.

Typescript is telling me that the mouseDownTools on the diagram is a read-only list. This may not be enforced in the actual code, but it seems concerning. See below:

A “read-only” property cannot be set. (Actually there is a property setter, but it throws an error.)

However the value of the property is a mutable object, in this case a List that may be modified. The GoJS collection classes do support immutability, but that hasn’t been documented. And for these particular properties it doesn’t matter anyway because they may be modified by the programmer.

When in doubt, read the documentation: ToolManager | GoJS API. If there is still doubt, ask and hopefully we can improve the documentation.

Ah, wonderful, thanks for the clarification!

In my doMouseMove() override, I’m trying to determine if the left mouse button is still being held down by calling this.diagram.lastInput.left. If the left mouse button had been let up, I would expect this to return false, but am seeing a value of true. Am I doing something incorrectly?

According to the documentation on InputEvent: “This property describes nothing during a mousemove event, since no button press causes the event. Instead, use the convenience properties left, middle, or right, … to determine which mouse buttons are held during mousemove events.”

We’re investigating this. There are some platform differences that make changes to that code a bit difficult to get right and to test.

I think this will be fixed in 1.7.6, probably next week, at least on all of the popular platforms.

Great, thank you.

1.7.6 is now latest. Tell me if you have any problems.

It seems to work for me, thanks!

I don’t suppose the source for the ClickCreatingTool is available, is it?

Here’s a simplified definition for ClickCreatingTool.insertPart:

ClickCreatingTool.prototype.insertPart = function(loc) {
  var diagram = this.diagram;
  if (diagram === null) return null;
  var arch = this.archetypeNodeData;
  if (arch === null) return null;

  this.startTransaction(this.name);
  if (arch !== null) {
    var data = diagram.model.copyNodeData(arch);
    if (data !== null && typeof data === 'object') {
      diagram.model.addNodeData(data);
      part = diagram.findPartForData(data);
    }
  }
  if (part !== null) {
    part.location = loc;
    if (diagram.allowSelect) {
      diagram.select(part);  // raises ChangingSelection/Finished
    }
  }

  diagram.invalidateDocumentBounds();
  // set the TransactionResult before raising event, in case it changes the result or cancels the tool
  this.transactionResult = this.name;
  diagram.raiseDiagramEvent('PartCreated', part);
  this.stopTransaction();
  return part;
};

Note that there are two calls to undocumented methods on Diagram – but you probably don’t want to do that stuff anyway when adding a Node inside your tool.