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:
1 parent
631cf54c72
commit
1e2285c545
12 files changed
+366
-14
No files matched your search
@@ -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 "?". */
|
||||
|
||||
Reference in new issue
Block a user