Hmmm, a lot of questions remain, but presumably you know what you want and can specify all possible behaviors the way that you want.
<!DOCTYPE html>
<html>
<head>
<title>Minimal GoJS Sample</title>
<!-- Copyright 1998-2026 by Northwoods Software Corporation. -->
</head>
<body>
<div id="myDiagramDiv" style="border: solid 1px black; width:100%; height:400px"></div>
Double-click on a Node to toggle its color.
Drag Beta into Group A and see Beta's double created in Group B.
Drag Beta out of Group A and see Beta's double deleted.
<textarea id="mySavedModel" style="width:100%;height:250px"></textarea>
<script src="https://cdn.jsdelivr.net/npm/gojs/release/go-debug.js"></script>
<script id="code">
const myDiagram =
new go.Diagram("myDiagramDiv", {
mouseDrop: e => {
e.diagram.commandHandler.addTopLevelParts(e.diagram.selection, true);
},
"undoManager.isEnabled": true,
"ModelChanged": e => {
const GroupAKey = 5;
const GroupBKey = 7;
if (e.change === go.ChangeType.Property) {
// original data object in Group A
const d = e.object;
// G is the original Group A data object
const g = e.model.findNodeDataForKey(GroupAKey);
if (!g) return;
// GC is the copied group data object
const gc = e.model.findNodeDataForKey(GroupBKey);
if (!gc) return;
// now copy the property from D to DC appropriately
const pname = e.propertyName;
if (pname === "group" || e.modelChange === "nodeGroupKey") {
if (e.newValue === GroupAKey) { // added to Group A
// DC is the copied data object in Group B
if (d.copy === undefined) { // not already copied
const dc = e.model.copyNodeData(d);
e.model.addNodeData(dc);
// fix "loc" to be relative to group
const p = go.Point.parse(d.loc);
p.subtract(go.Point.parse(g.loc)).add(go.Point.parse(gc.loc));
e.model.set(dc, "loc", go.Point.stringify(p));
e.model.setGroupKeyForNodeData(dc, GroupBKey);
// also indicate that D now has a double in Group B
e.model.set(d, "copy", e.model.getKeyForNodeData(dc));
}
} else if (e.oldValue === GroupAKey) { // removed from Group A
// DC is the copied data object in Group B
const dc = e.model.findNodeDataForKey(d.copy);
if (!dc) return;
if (dc.group !== GroupBKey) return;
// is this what you want???
e.model.removeNodeData(dc);
e.model.set(d, "copy", undefined); // no more double
}
} else {
if (d.group !== GroupAKey) return; // not in group A?
if (d.copy === undefined) return; // not copied? ignore change
// DC is the copied data object in Group B
const dc = e.model.findNodeDataForKey(d.copy);
if (!dc) return;
if (dc.group !== GroupBKey) return;
if (pname === "loc") {
const p = go.Point.parse(d[pname]);
p.subtract(go.Point.parse(g.loc)).add(go.Point.parse(gc.loc));
e.model.set(dc, pname, go.Point.stringify(p));
} else { // just copy all other property settings
e.model.set(dc, pname, d[pname]);
}
}
} else { // 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 =
new go.Node("Auto", {
doubleClick: (e, node) => {
e.diagram.commit(diag => {
diag.model.set(node.data, "color", node.data.color === "yellow" ? "orange" : "yellow");
});
}
})
.bindTwoWay("location", "loc", go.Point.parse, go.Point.stringify)
.add(
new go.Shape({ fill: "white" })
.bind("fill", "color"),
new go.TextBlock({ margin: 8 })
.bind("text")
);
myDiagram.groupTemplate =
new go.Group("Vertical", {
computesBoundsAfterDrag: true, // don't stretch the Placeholder while dragging
mouseDrop: (e, grp) => {
grp.addMembers(grp.diagram.selection, true);
},
handlesDragDropForMembers: true, // don't need to define handlers on member Nodes and Links
})
.bindTwoWay("location", "loc", go.Point.parse, go.Point.stringify)
.add(
new go.TextBlock()
.bind("text"),
new go.Panel("Auto")
.add(
new go.Shape({ fill: "transparent", strokeWidth: 3 })
.bind("stroke", "color"),
new go.Placeholder({ padding: 10 })
)
);
myDiagram.model = new go.GraphLinksModel({
"class": "GraphLinksModel",
"nodeDataArray": [
{"key":1,"text":"Alpha","color":"lightblue","loc":"0 0"},
{"key":2,"text":"Beta","color":"orange","loc":"70.28947448730469 0"},
{"key":3,"text":"Gamma","color":"lightgreen","group":5,"loc":"-3.70001220703125 96.86141424179077","copy":6},
{"key":4,"text":"Delta","color":"pink","group":5,"loc":"139.12278747558594 127.26140813827514","copy":8},
{"key":5,"text":"Group A","isGroup":true,"loc":"-13.70001220703125 86.86141424179077","copy":7},
{"key":6,"text":"Gamma","color":"lightgreen","group":7,"loc":"247.7332763671875 40.494745540618894"},
{"key":7,"text":"Group B","isGroup":true,"loc":"237.7332763671875 30.494745540618894"},
{"key":8,"text":"Delta","color":"pink","group":7,"loc":"390.5560760498047 70.89473943710327"}
],
"linkDataArray": [
{"from":1,"to":2},
{"from":1,"to":3},
{"from":3,"to":4},
{"from":6,"to":8}
]});
</script>
</body>
</html>