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

+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". */