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

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