Let a graph node collapse to its title bar

A chevron in front of the title toggles it without touching the selection,
and right-click offers Collapse node / Expand node for the whole selection
as one undo step. A collapsed node draws its title bar alone; every link of
one side meets at a single point on it, and hidden links there share one
mark per side, since per-port marks would stack on the same pixels. It has
no ports to grab, so a link dropped on it connects nothing.

Like hiding a link this is drawing only: collapsed is saved on the node and
an expanded node leaves no trace in the project file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5 committed 2026-09-11 08:59:19 +02:00
1 parent 631cf54c72
commit 1e2285c545
12 files changed
+366 -14

No files matched your search

+8 -2
View File
@@ -257,6 +257,12 @@ shown on the node and editable in the inspector.
grows a little, is captioned `Add +2`, and one click selects *every* far end at once, which is
also what lends all of its curves back; the menu counts what it acts on (*Show 3 links*), and
does it as one undo step.
- **A node can be collapsed** to its title bar: the chevron in front of the title (it leaves the
selection alone, like the hierarchy's), or right-click → *Collapse node* / *Expand node*, which
acts on the whole selection as one undo step. Every link of one side then meets at a single
point on the title bar, and its hidden links share one mark per side. A collapsed node has no
ports to grab, so a link dropped on it goes nowhere – expand it to wire it. Like hiding a link,
this is drawing only: `collapsed` on the node is saved with the project.
- **Header bar** (the same one the Texture and Container tabs have): the behavior picker,
**Add node…** (the palette, dropping the node in the middle of the view – the right-click nobody
finds), **Fit** (which zooms out far enough to hold every node, measuring each by its own drawn
@@ -578,7 +584,7 @@ src/editor/viewport/ WorldViewport base + SceneViewport/GameViewport – editi
src/editor/inspector/ Inspector panel, field factory, previewHandlers (scrub → preview → command),
one section per selection kind (asset/object/node), objectQueries/vectorRows/parentRow
src/editor/panels/ AssetPanel, HierarchyPanel, Toolbar
src/editor/graph/ GraphEditor (header + picker), GraphCanvas, NodeView, NodePalette, folding (hidden links)
src/editor/graph/ GraphEditor (header + picker), GraphCanvas, NodeView, NodePalette, folding (hidden links, collapsed nodes)
src/editor/container/ ContainerEditor (header + picker), ContainerCanvas (a SceneViewport on a template)
src/editor/texture/ TextureEditor (header + picker), TextureCanvas (picture + frames), SliceDialog, sheetView (the grid maths)
src/editor/list/ ListEditor (a row per item), slot (which list is being edited, and how it is written back)
@@ -613,7 +619,7 @@ npm run check # typecheck + lint + format:check + tests, what CI would ru
# code is lost (tail's is reported), so a red run reads as green
npm run build # writes dist/ and regenerates index.html
export GM_REMOTE=user@host # optional: run the browser elsewhere; see below
node tools/seed-e2e.mjs && node tools/e2e.mjs # 509 editor checks, screenshots in /tmp/gm-e2e-*.png
node tools/seed-e2e.mjs && node tools/e2e.mjs # 527 editor checks, screenshots in /tmp/gm-e2e-*.png
GM_STEPS=20 node tools/e2e.mjs # or 1-12, or 6,20 – a subset while working on one step
./e2e.sh start [GM_STEPS] # the same run detached, see below
./e2e.sh status # live progress, or the last run's result
+30
View File
@@ -242,6 +242,36 @@ export class FoldEdgeCommand implements Command {
}
}
/** One undo step for the whole selection; every node is found before any of them changes. */
export class CollapseNodesCommand implements Command {
readonly label: string;
private previous: boolean[] = [];
constructor(
private readonly assetId: string,
private readonly nodeIds: readonly string[],
private readonly collapsed: boolean,
) {
const noun = nodeIds.length === 1 ? 'node' : 'nodes';
this.label = collapsed ? `Collapse ${noun}` : `Expand ${noun}`;
}
execute(project: Project): void {
const nodes = this.nodeIds.map((id) => requireNode(project, this.assetId, id));
this.previous = nodes.map((n) => n.collapsed === true);
for (const node of nodes) setCollapsed(node, this.collapsed);
}
undo(project: Project): void {
this.nodeIds.forEach((id, i) => setCollapsed(requireNode(project, this.assetId, id), this.previous[i]!));
}
}
function setCollapsed(node: GraphNode, collapsed: boolean): void {
if (collapsed) node.collapsed = true;
else delete node.collapsed;
}
export class DisconnectCommand implements Command {
readonly label = 'Disconnect';
private removed: Removed<GraphEdge>[] = [];
+2
View File
@@ -24,6 +24,8 @@ export interface GraphNode {
* behavior its signature; an Expand/Pack node is what gives a struct its fields.
*/
ports?: DeclaredPort[];
/** Drawn as its title bar alone, every link meeting at one point per side. Purely visual. */
collapsed?: boolean;
}
/** A port a start/end node declares. `kind` maps to the PortType of the same name (`text` → string). */
+1
View File
@@ -244,6 +244,7 @@ function normalizeGraph(raw: unknown): Graph {
const node: GraphNode = { id, type, x: toNumber(n.x), y: toNumber(n.y), params };
const ports = normalizePorts(n.ports);
if (ports.length > 0) node.ports = ports;
if (n.collapsed === true) node.collapsed = true;
graph.nodes.push(node);
}
const edgeIds = new Set<string>();
+1
View File
@@ -191,6 +191,7 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
portAt: (nodeId: string, port: string, isOutput: boolean) =>
graphEditor.canvas.portGlobal(nodeId, port, isOutput),
nodeAt: (nodeId: string) => graphEditor.canvas.nodeGlobal(nodeId),
chevronAt: (nodeId: string) => graphEditor.canvas.chevronGlobal(nodeId),
nodeLabel: (nodeId: string) => graphEditor.canvas.nodeLabel(nodeId),
nodeProblem: (nodeId: string) => graphEditor.canvas.nodeProblem(nodeId),
nodeSettings: (nodeId: string) => graphEditor.canvas.nodeSettings(nodeId),
+28 -2
View File
@@ -4,6 +4,7 @@ import type { Command } from '../../core/commands/Command';
import { CompoundCommand } from '../../core/commands/CompoundCommand';
import {
AddNodeCommand,
CollapseNodesCommand,
ConnectCommand,
DisconnectCommand,
FoldEdgeCommand,
@@ -372,6 +373,11 @@ export class GraphCanvas extends Widget {
this.redraw();
return;
}
// Like the hierarchy's chevron: it folds, and leaves the selection alone.
if (view.chevronAt(local.x, local.y)) {
this.state.execute(new CollapseNodesCommand(this.assetId, [view.node.id], !view.collapsed));
return;
}
if (e.ctrlKey || e.metaKey || e.shiftKey) {
this.state.toggleNode(this.assetId, view.node.id);
return;
@@ -595,7 +601,8 @@ export class GraphCanvas extends Widget {
const hit = this.portUnder(globalX, globalY);
if (hit) return canConnect(graph, from, hit, lookup) ? hit : null;
const node = this.nodeUnder(globalX, globalY);
return node ? dropTargetPort(graph, from, node, lookup) : null;
if (!node || this.views.get(node)?.collapsed) return null;
return dropTargetPort(graph, from, node, lookup);
}
private portUnder(globalX: number, globalY: number): PortSide | null {
@@ -682,7 +689,21 @@ export class GraphCanvas extends Widget {
if (!this.state.selectedNodeIds.includes(view.node.id))
this.state.select(nodeSelection(assetId, [view.node.id]));
const ids = this.state.selectedNodeIds;
const nodes = this.selectedNodes();
const expanded = nodes.filter((n) => !n.collapsed).map((n) => n.id);
const collapsed = nodes.filter((n) => n.collapsed).map((n) => n.id);
const fold = (list: string[], verb: string, to: boolean): MenuItem[] =>
list.length === 0
? []
: [
{
label: list.length > 1 ? `${verb} ${list.length} nodes` : `${verb} node`,
onSelect: () => this.state.execute(new CollapseNodesCommand(assetId, list, to)),
},
];
new Menu([
...fold(expanded, 'Collapse', true),
...fold(collapsed, 'Expand', false),
{
label: ids.length > 1 ? `Delete ${ids.length} nodes` : 'Delete node',
danger: true,
@@ -889,7 +910,7 @@ export class GraphCanvas extends Widget {
const [side, other] = isOutput ? [edge.from, edge.to.node] : [edge.to, edge.from.node];
this.foldEnds.push({
edgeId: edge.id,
key: foldMarkKey(side, isOutput),
key: foldMarkKey(side, isOutput, this.views.get(side.node)?.collapsed),
other,
otherLabel,
isOutput,
@@ -1041,6 +1062,11 @@ export class GraphCanvas extends Widget {
const g = view.toGlobal({ x: view.w / 2, y: TITLE_HEIGHT / 2 });
return { x: g.x, y: g.y };
}
chevronGlobal(nodeId: string): { x: number; y: number } | null {
const g = this.views.get(nodeId)?.chevronGlobal();
return g ? { x: g.x, y: g.y } : null;
}
}
/** Horizontal S-curve between two points. */
+65 -7
View File
@@ -28,6 +28,8 @@ export const PORT_RADIUS = 5;
/** Distance (graph units) within which a press counts as hitting a port. */
export const PORT_HIT_RADIUS = 9;
const PAD = 6;
/** Room in front of the title for the collapse chevron, as wide as the hierarchy's. */
const CHEVRON_SLOT = 14;
export function categoryColor(category: NodeCategory): number {
const c = Theme.colors;
@@ -86,6 +88,8 @@ export class NodeView extends Widget {
targetPort: { port: string; isOutput: boolean } | null = null;
/** Data inputs that currently have a connection (their inline value is hidden). */
private connected = new Set<string>();
/** Which sides carry a link – a collapsed node marks those with one dot each. */
private linked = { inputs: false, outputs: false };
/**
* Not fixed at construction: a nested-behavior node's ports come from another graph, so the
@@ -137,12 +141,28 @@ export class NodeView extends Widget {
.filter((p) => edgesInto(graph, node.id, p.key).length > 0)
.map((p) => p.key),
);
const height = this.headerHeight() + this.rowCount() * ROW_HEIGHT + PAD;
this.linked = {
inputs: graph.edges.some((e) => e.to.node === node.id),
outputs: graph.edges.some((e) => e.from.node === node.id),
};
const height = this.collapsed ? TITLE_HEIGHT : this.headerHeight() + this.rowCount() * ROW_HEIGHT + PAD;
this.rebuildTexts();
this.resize(NODE_WIDTH, height);
this.position.set(node.x, node.y);
}
get collapsed(): boolean {
return this.node.collapsed === true;
}
chevronAt(localX: number, localY: number): boolean {
return localY >= 0 && localY < TITLE_HEIGHT && localX >= PAD - 2 && localX < PAD + CHEVRON_SLOT;
}
chevronGlobal(): { x: number; y: number } {
return this.toGlobal({ x: PAD + CHEVRON_SLOT / 2, y: TITLE_HEIGHT / 2 });
}
private rowCount(): number {
return Math.max(this.def.inputs.length, this.def.outputs.length);
}
@@ -168,15 +188,16 @@ export class NodeView extends Widget {
const list = isOutput ? this.def.outputs : this.def.inputs;
const index = list.findIndex((p) => p.key === key);
if (index < 0) return null;
return { x: isOutput ? this.w : 0, y: this.rowY(index) };
return { x: isOutput ? this.w : 0, y: this.collapsed ? TITLE_HEIGHT / 2 : this.rowY(index) };
}
private rowY(index: number): number {
return this.headerHeight() + index * ROW_HEIGHT + ROW_HEIGHT / 2;
}
/** Every port with its local centre – inputs on the left edge, outputs on the right. */
/** Every port with its local centre – none while collapsed, so none can be grabbed. */
private *portCentres(): Iterable<{ port: PortDef; isOutput: boolean; x: number; y: number }> {
if (this.collapsed) return;
for (const [list, isOutput] of [
[this.def.inputs, false],
[this.def.outputs, true],
@@ -209,7 +230,15 @@ export class NodeView extends Widget {
this.addChild(node);
this.texts.push(node);
};
add(this.def.label, 'accent', PAD + 2, (TITLE_HEIGHT - 16) / 2);
add(
this.def.label,
'accent',
PAD + CHEVRON_SLOT,
(TITLE_HEIGHT - 16) / 2,
'left',
NODE_WIDTH - PAD * 2 - CHEVRON_SLOT,
);
if (this.collapsed) return;
let y = TITLE_HEIGHT + PAD;
for (const line of this.paramLines) {
add(line, 'small', PAD + 2, y + 2);
@@ -256,9 +285,11 @@ export class NodeView extends Widget {
this.box.roundRect(2, 3, this.w, this.h, Theme.radius).fill({ color: 0x000000, alpha: 0.35 });
this.box.roundRect(0, 0, this.w, this.h, Theme.radius).fill(c.node);
this.box.roundRect(0, 0, this.w, TITLE_HEIGHT, Theme.radius).fill(categoryColor(this.def.category));
this.box
.rect(0, TITLE_HEIGHT - Theme.radius, this.w, Theme.radius)
.fill(categoryColor(this.def.category));
if (!this.collapsed)
this.box
.rect(0, TITLE_HEIGHT - Theme.radius, this.w, Theme.radius)
.fill(categoryColor(this.def.category));
this.drawChevron(PAD + 4, TITLE_HEIGHT / 2);
const border = this.hot ? c.hot : this.selected ? c.nodeSelected : this.problem ? c.danger : c.nodeBorder;
this.box
.roundRect(0.5, 0.5, this.w - 1, this.h - 1, Theme.radius)
@@ -266,6 +297,16 @@ export class NodeView extends Widget {
this.ports.clear();
const r = PORT_RADIUS;
if (this.collapsed) {
for (const [linked, x] of [
[this.linked.inputs, 0],
[this.linked.outputs, this.w],
] as const) {
if (!linked) continue;
this.ports.circle(x, TITLE_HEIGHT / 2, r - 1).fill(c.textMuted);
this.ports.circle(x, TITLE_HEIGHT / 2, r - 1).stroke({ width: 1, color: c.node });
}
}
for (const { port, isOutput, x, y } of this.portCentres()) {
if (this.targetPort?.port === port.key && this.targetPort.isOutput === isOutput) {
// A gap in the node colour first, so the ring stays visible on the white exec pin too.
@@ -288,6 +329,23 @@ export class NodeView extends Widget {
}
}
}
/** The hierarchy's shape, so both folds read the same. */
private drawChevron(x: number, y: number): void {
const color = Theme.colors.text;
if (this.collapsed)
this.box
.moveTo(x - 1, y - 4)
.lineTo(x + 4, y)
.lineTo(x - 1, y + 4)
.fill(color);
else
this.box
.moveTo(x - 3, y - 2)
.lineTo(x + 5, y - 2)
.lineTo(x + 1, y + 3)
.fill(color);
}
}
/** The value of a setting as shown on the node; references that no longer resolve get a "?". */
+7 -2
View File
@@ -13,9 +13,14 @@ export function foldedEdgeShown(edge: GraphEdge, selected: ReadonlySet<string>,
* Which mark a folded edge end belongs to. One port, one mark: drawn per edge instead, several
* hidden links out of the same port stack their stub, dot and name on the very same pixels.
* The separator is NUL: a printable one merges node "a b" + port "c" with node "a" + port "b c".
* A collapsed node draws all its ports on one point per side, so there it is one mark per side.
*/
export function foldMarkKey(side: { node: string; port: string }, isOutput: boolean): string {
return `${side.node}\0${side.port}\0${isOutput ? 'out' : 'in'}`;
export function foldMarkKey(
side: { node: string; port: string },
isOutput: boolean,
collapsed = false,
): string {
return `${side.node}\0${collapsed ? '' : side.port}\0${isOutput ? 'out' : 'in'}`;
}
/** One name plus a count: three names do not fit, and the count is what says "there is more here". */
+74
View File
@@ -0,0 +1,74 @@
import { describe, expect, it } from 'vitest';
import { CommandError } from '../../src/core/commands/Command';
import { CollapseNodesCommand } from '../../src/core/commands/graphCommands';
import type { BehaviorAsset } from '../../src/core/model/Asset';
import { findAsset } from '../../src/core/model/Project';
import { deserializeProject, serializeProject } from '../../src/core/store/serialize';
import { foldMarkKey } from '../../src/editor/graph/folding';
import { graphEditor, makeBehavior, makeProject, roundTrip } from '../helpers';
function threeNodes() {
const fixture = makeProject();
const behavior = makeBehavior(fixture, 'Mover');
const edit = graphEditor(fixture, behavior);
const start = edit.addNode('OnStart');
const self = edit.addNode('Self');
const move = edit.addNode('Move', { dx: 7 });
return { ...fixture, behavior, nodes: { start, self, move } };
}
describe('collapsing nodes', () => {
it('takes a whole selection back in one undo, each node to the state it really had', () => {
const { stack, behavior, nodes } = threeNodes();
stack.execute(new CollapseNodesCommand(behavior.id, [nodes.self.id], true));
stack.execute(new CollapseNodesCommand(behavior.id, [nodes.self.id, nodes.move.id], true));
expect(nodes.move.collapsed).toBe(true);
stack.undo();
expect(nodes.self.collapsed).toBe(true);
expect(nodes.move.collapsed).toBeUndefined();
});
it('changes no node when one of them is missing', () => {
const { project, behavior, nodes } = threeNodes();
const command = new CollapseNodesCommand(behavior.id, [nodes.self.id, 'no-such-node'], true);
expect(() => command.execute(project)).toThrow(CommandError);
expect(nodes.self.collapsed).toBeUndefined();
});
it('survives the save, and expanding leaves no trace in the file', () => {
const { project, stack, behavior, nodes } = threeNodes();
stack.execute(new CollapseNodesCommand(behavior.id, [nodes.move.id], true));
const loaded = findAsset(roundTrip(project), behavior.id) as BehaviorAsset;
expect(loaded.graph.nodes.find((n) => n.id === nodes.move.id)!.collapsed).toBe(true);
expect('collapsed' in loaded.graph.nodes.find((n) => n.id === nodes.self.id)!).toBe(false);
stack.execute(new CollapseNodesCommand(behavior.id, [nodes.move.id], false));
const again = findAsset(roundTrip(project), behavior.id) as BehaviorAsset;
expect(again.graph.nodes.every((n) => !('collapsed' in n))).toBe(true);
});
it('ignores a collapsed flag in the file that is not literally true', () => {
const { project, behavior } = threeNodes();
const raw = serializeProject(project) as {
assets: { id: string; graph?: { nodes: Record<string, unknown>[] } }[];
};
const stored = raw.assets.find((a) => a.id === behavior.id)!.graph!.nodes;
stored[0]!.collapsed = 'yes';
stored[1]!.collapsed = 1;
const loaded = findAsset(deserializeProject(raw), behavior.id) as BehaviorAsset;
expect(loaded.graph.nodes.every((n) => !('collapsed' in n))).toBe(true);
});
});
describe('hidden links at a collapsed node', () => {
it('share one mark per side, because every port sits on the same point', () => {
const a = foldMarkKey({ node: 'n', port: 'x' }, false, true);
expect(foldMarkKey({ node: 'n', port: 'y' }, false, true)).toBe(a);
expect(foldMarkKey({ node: 'n', port: 'x' }, true, true)).not.toBe(a);
expect(foldMarkKey({ node: 'm', port: 'x' }, false, true)).not.toBe(a);
expect(foldMarkKey({ node: 'n', port: 'x' }, false)).not.toBe(
foldMarkKey({ node: 'n', port: 'y' }, false),
);
});
});
+15
View File
@@ -0,0 +1,15 @@
Möglichkeit Nodes zuklappen können, wichtig: soll auch gespeichert werden.
---
Gebaut. Zwei Dinge, die weder oben stehen noch aus dem Commit hervorgehen:
**Zugeklappt ist reine Darstellung, wie ein versteckter Link.** Der Graph bleibt derselbe,
`collapsed` am Node wird nur gespeichert, und ein aufgeklappter Node hinterlässt keine Spur in der
Datei. Weil ein zugeklappter Node keine Ports zum Greifen hat, verbindet ein darauf fallengelassener
Link nichts. Neue Kanten gibt es erst nach dem Aufklappen, statt einen Port zu raten.
**Versteckte Links teilen sich am zugeklappten Node eine Marke pro Seite statt pro Port.** Alle
Ports einer Seite liegen dort auf einem Punkt. Mit einer Marke pro Port stapelten sich Stummel,
Punkt und Name auf denselben Pixeln (gemessen: zwei Marken mit je `links: 1`, identische
Koordinaten).
-1
View File
@@ -1 +0,0 @@
Möglichkeit Nodes zuklappen können, wichtig: soll auch gespeichert werden.
+135
View File
@@ -22,6 +22,9 @@ export async function graph(t) {
select,
stop,
asset,
api,
navigate,
projectId,
} = t;
const nodeOf = async (id) => (await asset('mover')).graph.nodes.find((n) => n.id === id);
const graphCounts = async () => {
@@ -739,5 +742,137 @@ export async function graph(t) {
await undo();
await sleep(200);
// 13q. Collapsing a node: the title bar alone, every link meeting at one point per side. No new
// link lands on it until it is expanded, and the state is saved with the project.
await select({ kind: 'asset', assetId: 'mover' });
await clickText('Fit');
await sleep(250);
const edgesBefore13q = (await graphCounts()).edges;
const openRect = await json(`__gm.graph.nodeRect('n-move')`);
const chevron = await json(`__gm.graph.chevronAt('n-move')`);
await click(chevron.x, chevron.y);
await sleep(200);
const shut = await json(`__gm.graph.nodeRect('n-move')`);
check(
'the chevron collapses the node to its title bar, and leaves the selection alone',
(await nodeOf('n-move')).collapsed === true &&
Math.abs(shut.h / shut.w - 24 / 176) < 0.01 &&
(await json('__gm.graph.selected()')).length === 0,
JSON.stringify({ openRect, shut, collapsed: (await nodeOf('n-move')).collapsed }),
);
const title = await json(`__gm.graph.nodeAt('n-move')`);
const anchorOf = (port, out) => json(`__gm.graph.portAt('n-move', '${port}', ${out})`);
const inputs = [
await anchorOf('exec', false),
await anchorOf('object', false),
await anchorOf('dy', false),
];
const output = await anchorOf('exec', true);
check(
'every link meets at one point per side, level with the title',
inputs.every((p) => Math.abs(p.x - shut.x) < 1 && Math.abs(p.y - title.y) < 1) &&
Math.abs(output.x - (shut.x + shut.w)) < 1 &&
Math.abs(output.y - title.y) < 1,
JSON.stringify({ inputs, output, title, shut }),
);
check(
'the links are still there and still drawn',
(await graphCounts()).edges === edgesBefore13q && (await json(`__gm.graph.edgeDrawn('e3')`)) === true,
);
await shot('24b-node-collapsed');
const speedOut = await json(`__gm.graph.portAt('n-speed', 'value', true)`);
await mouse('mouseMoved', speedOut.x, speedOut.y);
await mouse('mousePressed', speedOut.x, speedOut.y);
await mouse('mouseMoved', title.x, title.y, 'left', { buttons: 1 });
await sleep(50);
const aimed = await json('__gm.graph.dropTarget()');
await mouse('mouseReleased', title.x, title.y);
await sleep(150);
await press('Escape');
check(
'a link dropped on a collapsed node goes nowhere',
aimed === null && (await graphCounts()).edges === edgesBefore13q,
JSON.stringify(aimed),
);
for (const port of ['x', 'y']) {
const at = await json(`__gm.graph.portAt('n-axis', '${port}', true)`);
await click(at.x, at.y, 'right');
await sleep(150);
await clickText('Hide link');
await sleep(200);
}
await select({ kind: 'asset', assetId: 'mover' });
await sleep(200);
const markDx = await json(`__gm.graph.foldMark('e3', false)`);
const markDy = await json(`__gm.graph.foldMark('e4', false)`);
check(
'hidden links into a collapsed node share one mark instead of stacking',
markDx !== null && markDx.x === markDy.x && markDx.y === markDy.y && markDx.links === 2,
JSON.stringify({ markDx, markDy }),
);
await undo();
await undo();
await select({ kind: 'node', assetId: 'mover', nodeId: 'n-move', nodeIds: ['n-speed', 'n-move'] });
await sleep(150);
await click(title.x, title.y, 'right');
await sleep(150);
check(
'a mixed selection is offered both ways',
Boolean(await evaluate(`__gm.locate('Collapse node', false)`)) &&
Boolean(await evaluate(`__gm.locate('Expand node', false)`)),
);
await press('Escape');
await undo();
await sleep(150);
check('undo expands it again', (await nodeOf('n-move')).collapsed === undefined);
await select({ kind: 'node', assetId: 'mover', nodeId: 'n-move', nodeIds: ['n-speed', 'n-move'] });
await sleep(150);
const openTitle = await json(`__gm.graph.nodeAt('n-move')`);
await click(openTitle.x, openTitle.y, 'right');
await sleep(150);
const collapseBoth = await json(`__gm.locate('Collapse 2 nodes', false)`);
check('two expanded nodes are offered as one', collapseBoth !== null);
if (collapseBoth) await click(collapseBoth.x, collapseBoth.y);
else await press('Escape');
await sleep(200);
const both = async () => [(await nodeOf('n-speed')).collapsed, (await nodeOf('n-move')).collapsed];
check('the menu collapses the whole selection', JSON.stringify(await both()) === '[true,true]');
await undo();
await sleep(150);
check(
'and one undo takes all of it back',
JSON.stringify(await both()) === '[null,null]',
JSON.stringify(await both()),
);
// Saved and loaded: a reload, not just the file, because the drawing is what has to come back.
await select({ kind: 'asset', assetId: 'mover' });
const again = await json(`__gm.graph.chevronAt('n-move')`);
await click(again.x, again.y);
const savedMove = async () => {
for (let i = 0; i < 30 && (await state()).save !== 'saved'; i++) await sleep(300);
const saved = await api(`projects/${projectId}`);
return saved.project.assets.find((a) => a.id === 'mover').graph.nodes.find((n) => n.id === 'n-move');
};
check('the collapse reaches the saved file', (await savedMove()).collapsed === true);
await navigate();
await sleep(500);
await select({ kind: 'asset', assetId: 'mover' });
await evaluate(`__gm.showTab('graph')`);
await sleep(250);
const reloaded = await json(`__gm.graph.nodeRect('n-move')`);
check(
'and a reloaded project draws it collapsed again',
(await nodeOf('n-move')).collapsed === true && Math.abs(reloaded.h / reloaded.w - 24 / 176) < 0.01,
JSON.stringify(reloaded),
);
const reopen = await json(`__gm.graph.chevronAt('n-move')`);
await click(reopen.x, reopen.y);
check('expanding leaves no trace in the file', !('collapsed' in (await savedMove())));
await evaluate(`__gm.showTab('scene')`);
}