Yes, it’s possible, but it’s a bit of work. I had to override the CommandHandler.collapseSubGraph and expandSubGraph methods in order to re-implement them. Here’s the complete code in a copy of the Regrouping sample.
<!DOCTYPE html>
<html><body>
<div id="sample">
<div style="width: 100%; display: flex; justify-content: space-between">
<div id="myPaletteDiv" style="width: 130px; margin-right: 2px; background-color: rgb(39, 29, 29); border: solid 1px black"></div>
<div id="myDiagramDiv" style="flex-grow: 1; height: 500px; background-color: rgb(39, 29, 29); border: solid 1px black"></div>
</div>
<div id="buttons">
<button id="saveModel" onclick="save()">Save</button>
<button id="loadModel" onclick="load()">Load</button>
Diagram Model saved in JSON format:
</div>
<textarea id="mySavedModel" style="width:100%;height:300px">
{ "class": "go.GraphLinksModel",
"nodeDataArray": [
{"key":1, "isGroup":true, "text":"Main 1", "horiz":true},
{"key":2, "isGroup":true, "text":"Main 2", "horiz":true},
{"key":3, "isGroup":true, "text":"Group A", "group":1},
{"key":4, "isGroup":true, "text":"Group B", "group":1},
{"key":5, "isGroup":true, "text":"Group C", "group":2},
{"key":6, "isGroup":true, "text":"Group D", "group":2},
{"key":7, "isGroup":true, "text":"Group E", "group":6},
{"text":"first A", "group":3, "key":-7},
{"text":"second A", "group":3, "key":-8},
{"text":"first B", "group":4, "key":-9},
{"text":"second B", "group":4, "key":-10},
{"text":"third B", "group":4, "key":-11},
{"text":"first C", "group":5, "key":-12},
{"text":"second C", "group":5, "key":-13},
{"text":"first D", "group":6, "key":-14},
{"text":"first E", "group":7, "key":-15}
],
"linkDataArray": [ ]}
</textarea>
</div>
<script src="https://unpkg.com/gojs@2.3.7"></script>
<script id="code">
const myDiagram = new go.Diagram("myDiagramDiv",
{
// when a drag-drop occurs in the Diagram's background, make it a top-level node
mouseDrop: e => finishDrop(e, null),
layout: // Diagram has simple horizontal layout
new go.GridLayout(
{ wrappingWidth: Infinity, alignment: go.GridLayout.Position, cellSize: new go.Size(1, 1) }),
"commandHandler.archetypeGroupData": { isGroup: true, text: "Group", horiz: false },
"undoManager.isEnabled": true
});
// override the two CommandHandler commands for collapsing and expanding Groups
myDiagram.commandHandler.collapseSubGraph = function(group) {
if (group === undefined) group = null;
const diagram = this.diagram;
const reason = 'Collapse SubGraph';
const groups = new go.List();
const map = new go.Map();
try {
diagram.startTransaction(reason);
if (group instanceof go.Group && group.isSubGraphExpanded) {
_collapseGroup(group, group, map);
group.collapseSubGraph();
groups.add(group);
} else if (group === null) {
const it = diagram.selection.iterator;
while (it.next()) {
const part = it.value;
if (part instanceof go.Group && part.isSubGraphExpanded) {
_collapseGroup(part, part, map);
part.collapseSubGraph();
groups.add(part);
}
}
// ?? also change selection
}
diagram.raiseDiagramEvent('SubGraphCollapsed', groups);
} finally {
diagram.commitTransaction(reason);
// compute end points for each collapsed group, but only after everything has been collapsed
const mappts = new go.Map();
groups.each(grp => mappts.add(grp, new go.Point(grp.actualBounds.centerX, grp.actualBounds.y)));
// now determine Animations for each disappearing member part
const an = new go.Animation();
map.each(kvp => {
const part = kvp.key;
const info = kvp.value;
const root = info.group;
const temp = part.copy();
temp.isLayoutPositioned = false;
temp.layerName = "Grid"; // so that the temporary parts are behind the groups, so they appear to slide under
an.addTemporaryPart(temp, diagram);
const grppt = mappts.get(root);
an.add(temp, 'position', part.position, new go.Point(Math.max(root.actualBounds.x, grppt.x - part.actualBounds.width/2), grppt.y));
});
an.start();
}
}
function _collapseGroup(group, root, map) { // gets called before the collapse
group.findSubGraphParts().each(part => {
if (!(part instanceof go.Link) && part.isVisible()) {
const info = new go.DraggingInfo(part.position.copy());
info.group = root;
map.add(part, info);
}
});
}
myDiagram.commandHandler.expandSubGraph = function(group) {
if (group === undefined) group = null;
const diagram = this.diagram;
const reason = 'Expand SubGraph';
const groups = new go.List();
const map = new go.Map();
// compute start points for each collapsed group, but only before each one has been expanded
const mappts = new go.Map();
try {
diagram.startTransaction(reason);
if (group instanceof go.Group && !group.isSubGraphExpanded) {
mappts.add(group, new go.Point(group.actualBounds.centerX, group.actualBounds.y));
_expandGroup(group, group, map);
group.expandSubGraph();
groups.add(group);
} else if (group === null) {
const it = diagram.selection.iterator;
while (it.next()) {
const part = it.value;
if (part instanceof go.Group && !part.isSubGraphExpanded) {
mappts.add(part, new go.Point(part.actualBounds.centerX, part.actualBounds.y));
_expandGroup(part, part, map);
part.expandSubGraph();
groups.add(part);
}
}
// ?? also change selection
}
diagram.raiseDiagramEvent('SubGraphExpanded', groups);
} finally {
diagram.commitTransaction(reason);
// now determine Animations for each disappearing member part
const an = new go.Animation();
map.each(kvp => {
const part = kvp.key;
const info = kvp.value;
const root = info.group;
const temp = part.copy();
temp.isLayoutPositioned = false;
temp.layerName = "Grid"; // so that the temporary parts are behind the groups, so they appear to slide under
an.addTemporaryPart(temp, diagram);
const grppt = mappts.get(root);
an.add(temp, 'position', new go.Point(Math.max(root.actualBounds.x, grppt.x - part.actualBounds.width/2), grppt.y), part.position);
part.opacity = 0.0; // hide real members
});
an.finished = () => {
map.each(kvp => {
const part = kvp.key;
part.opacity = 1.0; // show real members ??? assuming opacity == 1.0
});
};
an.start();
}
}
function _expandGroup(group, root, map) { // gets called before the expand
group.memberParts.each(part => {
if (!(part instanceof go.Link) && !part.isVisible()) {
const info = new go.DraggingInfo(part.position.copy());
info.group = root;
map.add(part, info);
if (part instanceof go.Group && part.wasSubGraphExpanded && !part.isSubGraphExpanded) {
_expandGroup(part, root, map);
}
}
});
}
// The one template for Groups can be configured to be either layout out its members
// horizontally or vertically, each with a different default color.
function makeLayout(horiz) { // a Binding conversion function
if (horiz) {
return new go.GridLayout(
{
wrappingWidth: Infinity, alignment: go.GridLayout.Position,
cellSize: new go.Size(1, 1), spacing: new go.Size(4, 4)
});
} else {
return new go.GridLayout(
{
wrappingColumn: 1, alignment: go.GridLayout.Position,
cellSize: new go.Size(1, 1), spacing: new go.Size(4, 4)
});
}
}
function defaultColor(horiz) { // a Binding conversion function
return horiz ? "rgba(255, 221, 51, 0.55)" : "rgba(51,211,229, 0.5)";
}
function defaultFont(horiz) { // a Binding conversion function
return horiz ? "bold 20px sans-serif" : "bold 16px sans-serif";
}
// this function is used to highlight a Group that the selection may be dropped into
function highlightGroup(e, grp, show) {
if (!grp) return;
e.handled = true;
if (show) {
// cannot depend on the grp.diagram.selection in the case of external drag-and-drops;
// instead depend on the DraggingTool.draggedParts or .copiedParts
var tool = grp.diagram.toolManager.draggingTool;
var map = tool.draggedParts || tool.copiedParts; // this is a Map
// now we can check to see if the Group will accept membership of the dragged Parts
if (grp.canAddMembers(map.toKeySet())) {
grp.isHighlighted = true;
return;
}
}
grp.isHighlighted = false;
}
// Upon a drop onto a Group, we try to add the selection as members of the Group.
// Upon a drop onto the background, or onto a top-level Node, make selection top-level.
// If this is OK, we're done; otherwise we cancel the operation to rollback everything.
function finishDrop(e, grp) {
var ok = (grp !== null
? grp.addMembers(grp.diagram.selection, true)
: e.diagram.commandHandler.addTopLevelParts(e.diagram.selection, true));
if (!ok) e.diagram.currentTool.doCancel();
}
myDiagram.groupTemplate =
new go.Group("Auto",
{
background: "blue",
ungroupable: true,
// highlight when dragging into the Group
mouseDragEnter: (e, grp, prev) => highlightGroup(e, grp, true),
mouseDragLeave: (e, grp, next) => highlightGroup(e, grp, false),
computesBoundsAfterDrag: true,
computesBoundsIncludingLocation: true,
// when the selection is dropped into a Group, add the selected Parts into that Group;
// if it fails, cancel the tool, rolling back any changes
mouseDrop: finishDrop,
handlesDragDropForMembers: true, // don't need to define handlers on member Nodes and Links
// Groups containing Groups lay out their members horizontally
layout: makeLayout(false)
})
.bind("layout", "horiz", makeLayout)
.bind(new go.Binding("background", "isHighlighted", h => h ? "rgba(255,0,0,0.2)" : "transparent").ofObject())
.add(new go.Shape("Rectangle",
{ fill: null, stroke: defaultColor(false), fill: defaultColor(false), strokeWidth: 2 })
.bind("stroke", "horiz", defaultColor)
.bind("fill", "horiz", defaultColor))
.add(
new go.Panel("Vertical") // title above Placeholder
.add(new go.Panel("Horizontal", // button next to TextBlock
{ stretch: go.GraphObject.Horizontal, background: defaultColor(false) })
.bind("background", "horiz", defaultColor)
.add(go.GraphObject.make("SubGraphExpanderButton", { alignment: go.Spot.Right, margin: 5 }))
.add(new go.TextBlock(
{
alignment: go.Spot.Left,
editable: true,
margin: 5,
font: defaultFont(false),
opacity: 0.95, // allow some color to show through
stroke: "#404040"
})
.bind("font", "horiz", defaultFont)
.bind("text", "text", null, null)) // `null` as the fourth argument makes this a two-way binding
) // end Horizontal Panel
.add(new go.Placeholder({ padding: 5, alignment: go.Spot.TopLeft }))
) // end Vertical Panel
myDiagram.nodeTemplate =
new go.Node("Auto",
{ // dropping on a Node is the same as dropping on its containing Group, even if it's top-level
mouseDrop: (e, node) => finishDrop(e, node.containingGroup)
})
.add(new go.Shape("RoundedRectangle", { fill: "rgba(172, 230, 0, 0.9)", stroke: "white", strokeWidth: 0.5 }))
.add(new go.TextBlock(
{
margin: 7,
editable: true,
font: "bold 13px sans-serif",
opacity: 0.90
})
.bind("text", "text", null, null)); // `null` as the fourth argument makes this a two-way binding
// initialize the Palette and its contents
myPalette =
new go.Palette("myPaletteDiv",
{
nodeTemplateMap: myDiagram.nodeTemplateMap,
groupTemplateMap: myDiagram.groupTemplateMap
});
myPalette.model = new go.GraphLinksModel([
{ text: "New Node", color: "#ACE600" },
{ isGroup: true, text: "H Group", horiz: true },
{ isGroup: true, text: "V Group", horiz: false }
]);
load();
// save a model to and load a model from JSON text, displayed below the Diagram
function save() {
document.getElementById("mySavedModel").value = myDiagram.model.toJson();
myDiagram.isModified = false;
}
function load() {
myDiagram.model = go.Model.fromJson(document.getElementById("mySavedModel").value);
}
</script>
</body></html>