Hmmm, that case seems to need more work…
Here’s an update from the current beta 2.2.1 of BalloonLink.makeGeometry:
BalloonLink.prototype.makeGeometry = function() {
if (this.fromNode === null) return new go.Geometry();
// assume the fromNode is the comment and the toNode is the commented-upon node
var bb = this.fromNode.actualBounds.copy().addMargin(this.fromNode.margin);
var pn = this.pointsCount === 0 ? bb.center : this.getPoint(this.pointsCount - 1);
if (this.toNode !== null && bb.intersectsRect(this.toNode.actualBounds)) {
pn = this.toNode.actualBounds.center;
} else if (this.toNode === null && this.pointsCount === 0) {
pn = new go.Point(bb.centerX, bb.bottom + 50);
}
. . . var base = Math.max(0, this.base); . . .
And here’s my derivation of the Comments sample that lets users drag-and-drop comments from a Palette and copy and paste them too.
<html>
<body>
<script src="../release/go.js"></script>
<script src="../extensions/BalloonLink.js"></script>
<script id="code">
function init() {
// Since 2.2 you can also author concise templates with method chaining instead of GraphObject.make
// For details, see https://gojs.net/latest/intro/buildingObjects.html
const $ = go.GraphObject.make;
myDiagram =
$(go.Diagram, "myDiagramDiv", // create a Diagram for the DIV HTML element
{
"animationManager.isEnabled": false,
layout: $(go.TreeLayout,
{
isOngoing: false,
angle: 90,
setsPortSpot: false,
setsChildPortSpot: false
}),
"undoManager.isEnabled": true,
// When a Node is deleted by the user, also delete all of its Comment Nodes.
// When a Comment Link is deleted, also delete the corresponding Comment Node.
"SelectionDeleting": e => {
var parts = e.subject; // the collection of Parts to be deleted, the Diagram.selection
// iterate over a copy of this collection,
// because we may add to the collection by selecting more Parts
parts.copy().each(p => {
if (p instanceof go.Node) {
var node = p;
node.findNodesConnected().each(n => {
// remove every Comment Node that is connected with this node
if (n.category === "Comment") {
n.isSelected = true; // include in normal deletion process
}
});
} else if (p instanceof go.Link && p.category === "Comment") {
var comlink = p; // a "Comment" Link
var comnode = comlink.fromNode;
// remove the Comment Node that is associated with this Comment Link,
if (comnode.category === "Comment") {
comnode.isSelected = true; // include in normal deletion process
}
}
});
},
"commandHandler.computeEffectiveCollection": function(parts, options) {
const map = go.CommandHandler.prototype.computeEffectiveCollection.call(this, parts, options); // call super method
return gatherCommentLinks(map, options);
},
"draggingTool.computeEffectiveCollection": function(parts, options) {
const map = go.DraggingTool.prototype.computeEffectiveCollection.call(this, parts, options); // call super method
return gatherCommentLinks(map, options);
},
"relinkingTool.isUnconnectedLinkValid": true,
"linkingTool.isUnconnectedLinkValid": true,
"linkingTool.direction": go.LinkingTool.ForwardsOnly,
"linkingTool.insertLink": function(fromnode, fromport, tonode, toport) {
this.archetypeLinkData = { category: fromnode.category === "Comment" ? "Comment" : "" };
return go.LinkingTool.prototype.insertLink.call(this, fromnode, fromport, tonode, toport); // call super method
},
"SelectionCopied": avoidBareComments,
"ClipboardPasted": avoidBareComments,
"ExternalObjectsDropped": avoidBareComments,
"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();
}
}
});
function gatherCommentLinks(map, options) {
const links = [];
map.iteratorKeys.each(n => {
if (n instanceof go.Node && n.category === "Comment") {
n.findLinksOutOf().each(l => {
if (l.toNode === null && l.category === "Comment") links.push(l);
});
}
});
links.forEach(l => map.add(l, new go.DraggingInfo()));
return map;
}
function avoidBareComments(e) {
e.diagram.selection.each(node => {
if (node instanceof go.Node && node.category === "Comment" && node.linksConnected.count === 0) {
const ldata = { category: "Comment", from: node.key };
e.diagram.model.addLinkData(ldata);
const link = e.diagram.findLinkForData(ldata);
if (link) link.defaultToPoint = node.location.copy().offset(50, 50);
}
});
}
myDiagram.nodeTemplate =
$("Node", "Auto",
{ toLinkable: true },
$("Shape",
{ fill: "white" },
new go.Binding("fill", "color")),
$("TextBlock",
{ margin: 6, editable: true },
new go.Binding("text").makeTwoWay())
);
myDiagram.linkTemplate =
$("Link",
{ layerName: "Background" },
$("Shape",
{ strokeWidth: 1.5 }),
$("Shape",
{ toArrow: "Standard", stroke: null })
);
myDiagram.nodeTemplateMap.add("Comment",
$(go.Node,
{
background: "transparent",
margin: 3, // used by BalloonLink.makeGeometry
fromLinkable: true, cursor: "pointer" // to allow drawing new "Comment" Links to nodes or to nowhere
},
$(go.TextBlock,
{ stroke: "brown", margin: 6, fromLinkable: false, editable: true },
new go.Binding("text").makeTwoWay())
));
myDiagram.linkTemplateMap.add("Comment",
$(BalloonLink,
{ base: 15, corner: 10, relinkableTo: true },
$(go.Shape, // the Shape.geometry will be computed to surround the comment node and
// point all the way to the commented node
{ stroke: "brown", strokeWidth: 1, fill: "lightyellow" })
));
myDiagram.model =
new go.GraphLinksModel(
{
nodeDataArray: [
{ key: 1, text: "Alpha", color: "orange" },
{ key: 2, text: "Beta", color: "lightgreen" },
{ key: 3, text: "Gamma", color: "lightgreen" },
{ key: 4, text: "Delta", color: "pink" },
{ key: -1, text: "comment\nabout Alpha", category: "Comment" },
{ key: -2, text: "comment\nabout Beta", category: "Comment" },
{ key: -3, text: "comment about Gamma", category: "Comment" }
],
linkDataArray: [
{ from: 1, to: 2 },
{ from: 1, to: 3 },
{ from: 1, to: 4 },
{ from: -1, to: 1, category: "Comment" },
{ from: -2, to: 2, category: "Comment" },
{ from: -3, to: 3, category: "Comment" }
]
});
// initialize Palette
myPalette =
$(go.Palette, "myPaletteDiv",
{
nodeTemplateMap: myDiagram.nodeTemplateMap,
model: new go.GraphLinksModel([
{ text: "red node", color: "pink" },
{ text: "green node", color: "lightgreen" },
{ text: "blue node", color: "lightblue" },
{ text: "orange node", color: "orange" },
{ text: "comment", category: "Comment" }
])
});
// initialize Overview
myOverview =
$(go.Overview, "myOverviewDiv",
{
observed: myDiagram,
contentAlignment: go.Spot.Center
});
}
window.addEventListener('DOMContentLoaded', init);
</script>
<div id="sample">
<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>
<div style="display: inline">
Current Diagram.model saved in JSON format:<br />
<pre id="mySavedModel"></pre>
</div>
</div>
</body>
</html>