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
@@ -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>[] = [];
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 "?". */
|
||||
|
||||
@@ -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". */
|
||||
|
||||
Reference in new issue
Block a user