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

+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),
);
});
});