Consolidate duplicated editor code, trim comments, refresh README

Split GraphCanvas (1076 -> 831 lines) into EdgeLayer and graphMenus;
the port and marker menus were the same list and are now one function.
Value specs of the inspector and the list tab share valueSpecs.ts, and
the remaining copies (compound commands, type glyphs, typed asset
lookup, name rule, play/editor world setup, marquee) are merged.

Four copies had already drifted apart; merging them fixes:
- "Disconnect N links" in the port menu is one undo step, not N
- "+ Add collider" is hidden on a placed container instance, as in
  the hierarchy menu, instead of always failing
- a bool property coerced from the text "false" becomes false
- a port command on an unknown node type reports instead of crashing

Comment-only edits leave the bundle byte-identical. README corrected in
35 places (step limit, jump-run clock, suite duration, 409 body, ...).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5 committed 2026-09-11 11:00:30 +02:00
1 parent 9212e61c4c
commit 8f92a06c84
96 files changed
+999 -1261

No files matched your search

+81 -326
View File
@@ -1,13 +1,11 @@
import { Container, type FederatedPointerEvent, type FederatedWheelEvent, Graphics, Text } from 'pixi.js';
import { textOf } from '../../core/model/Properties';
import type { Command } from '../../core/commands/Command';
import { CompoundCommand } from '../../core/commands/CompoundCommand';
import { oneStep } from '../../core/commands/CompoundCommand';
import {
AddNodeCommand,
CollapseNodesCommand,
ConnectCommand,
DisconnectCommand,
FoldEdgeCommand,
MoveNodeCommand,
} from '../../core/commands/graphCommands';
import {
@@ -19,12 +17,10 @@ import {
edgesInto,
edgesOutOf,
firstPortFor,
type GraphEdge,
type GraphNode,
type BehaviorLookup,
type PortSide,
type PortType,
portsCompatible,
portTypeOf,
resolveDef,
RUN_BEHAVIOR,
@@ -37,34 +33,24 @@ import {
activeDrag,
clamp,
type DragPayload,
drawBand,
drawGrid,
endDrag,
Menu,
type MenuItem,
onDragChange,
pressCount,
type Size,
Theme,
textStyle,
truncate,
Widget,
zoomFactor,
} from '../../ui';
import { removeNodes } from '../actions';
import { type EditorState, nodeSelection } from '../state';
import {
buildFoldMarks,
foldedEdgeShown,
type FoldMarkEnd,
foldMarkAt,
foldMarkKey,
foldMarkLabel,
foldMarkRadius,
} from './folding';
import { type Box, boxesInBand, dragOffset, frameBoxes } from '../framing';
import { bezier, EdgeLayer } from './EdgeLayer';
import { openLinkMenu, openNodeMenu } from './graphMenus';
import { NodePalette } from './NodePalette';
import { type PaletteEntry, paletteEntries } from './paletteEntries';
import { NODE_WIDTH, NodeView, portColor, TITLE_HEIGHT } from './NodeView';
import { NODE_WIDTH, NodeView, TITLE_HEIGHT } from './NodeView';
/** Spacing of the background grid – and, with snapping on, of the positions a drag can leave. */
const GRID_STEP = 24;
@@ -78,21 +64,6 @@ const NODE_HEIGHT_ESTIMATE = 80;
const FIT_MARGIN = 24;
/** Pointer travel (screen pixels) before a press turns into a drag rather than a click. */
const DRAG_THRESHOLD = 3;
/** How far a folded edge's stub reaches out of its port. */
const FOLD_STUB = 16;
const FOLD_MARK_RADIUS = 5;
const FOLD_LABEL_WIDTH = 96;
/** One port's hidden links as drawn: what they stand in for, and where they can be clicked. */
interface FoldMark {
edgeIds: string[];
/** The nodes at the other ends, deduped – clicking selects them all. */
others: string[];
isOutput: boolean;
x: number;
y: number;
text: string;
}
/**
* The node graph of one behavior asset. Which behavior that is comes from the outside (`show`),
@@ -103,16 +74,7 @@ export class GraphCanvas extends Widget {
private readonly grid = new Graphics();
private readonly clip = new Graphics();
private readonly canvas = new Container();
private readonly edges = new Graphics();
private readonly markChips = new Graphics();
/** Texts naming the far node of each folded edge; pooled, since a redraw rebuilds them all. */
private readonly markLayer = new Container();
private readonly markLabels: Text[] = [];
private markLabelCount = 0;
private foldMarks: FoldMark[] = [];
private foldEnds: FoldMarkEnd[] = [];
/** Edges the last redraw really stroked as a curve – a hidden one is missing from it. */
private readonly strokedEdges = new Set<string>();
private readonly edgeLayer = new EdgeLayer();
private readonly nodeLayer = new Container();
private readonly tempEdge = new Graphics();
/** Drawn in screen space, so the band keeps a hairline border at any zoom. */
@@ -153,7 +115,7 @@ export class GraphCanvas extends Widget {
constructor(private readonly state: EditorState) {
super();
this.canvas.addChild(this.edges, this.markChips, this.markLayer, this.nodeLayer, this.tempEdge);
this.canvas.addChild(this.edgeLayer, this.nodeLayer, this.tempEdge);
this.addChild(this.bg, this.grid, this.canvas, this.band, this.hint, this.clip);
this.canvas.mask = this.clip;
this.cursor = 'default';
@@ -320,22 +282,25 @@ export class GraphCanvas extends Widget {
private onCanvasPointerDown(e: FederatedPointerEvent): void {
// The middle button pans from anywhere, a node under the pointer included – it is the one
// gesture that never means "grab this". Nodes let it through for exactly that reason.
if (e.button === 1) {
const startPan = () => {
this.panning = { startX: e.global.x, startY: e.global.y, panX: this.pan.x, panY: this.pan.y };
};
if (e.button === 1) {
startPan();
e.stopPropagation();
return;
}
if (e.target !== this) return;
// Shift-drag on empty space pans as well, as it does in the scene view.
if (e.button === 0 && e.shiftKey) {
this.panning = { startX: e.global.x, startY: e.global.y, panX: this.pan.x, panY: this.pan.y };
startPan();
return;
}
const local = this.toLocal(e.global);
const g = this.screenToGraph(local.x, local.y);
const mark = this.state.mode === 'edit' ? this.markAt(g) : null;
const mark = this.state.mode === 'edit' ? this.edgeLayer.markAt(g) : null;
if (e.button === 2) {
if (mark) this.openMarkMenu(mark, e.global.x, e.global.y);
if (mark) this.openLinks((g) => g.edges.filter((it) => mark.edgeIds.includes(it.id)), e.global);
else if (this.assetId && this.state.mode === 'edit') this.openPalette(e.global.x, e.global.y, g);
return;
}
@@ -358,8 +323,15 @@ export class GraphCanvas extends Widget {
const local = view.toLocal(e.global);
const hit = view.portAt(local.x, local.y);
if (e.button === 2) {
if (hit) this.openPortMenu(view, hit.port.key, hit.isOutput, e.global.x, e.global.y);
else this.openNodeMenu(view, e.global.x, e.global.y);
const { id } = view.node;
if (hit) {
const key = hit.port.key;
this.openLinks((g) => (hit.isOutput ? edgesOutOf(g, id, key) : edgesInto(g, id, key)), e.global);
} else {
// Right-clicking inside a multi-selection acts on all of it; outside it, on that node alone.
if (!this.state.selectedNodeIds.includes(id)) this.state.select(nodeSelection(this.assetId, [id]));
openNodeMenu(this.state, this.assetId, this.selectedNodes(), e.global.x, e.global.y);
}
return;
}
if (e.button !== 0) return;
@@ -519,8 +491,7 @@ export class GraphCanvas extends Widget {
node.y = item.start.y;
moves.push(new MoveNodeCommand(this.assetId, item.nodeId, next.x, next.y));
}
if (moves.length === 1) this.state.execute(moves[0]!);
else if (moves.length > 1) this.state.execute(new CompoundCommand('Move nodes', moves));
if (moves.length > 0) this.state.execute(oneStep('Move nodes', moves));
}
const connecting = this.connecting;
const target = connecting ? (this.dropTarget ?? this.resolveDrop(e.global.x, e.global.y)) : null;
@@ -550,15 +521,7 @@ export class GraphCanvas extends Widget {
}
private connect(from: PortSide, to: PortSide): void {
if (!this.assetId) return;
const [out, inp] = from.isOutput ? [from, to] : [to, from];
this.state.execute(
new ConnectCommand(
this.assetId,
{ node: out.nodeId, port: out.port },
{ node: inp.nodeId, port: inp.port },
),
);
if (this.assetId) this.state.execute(connectSides(this.assetId, from, to));
}
/**
@@ -639,15 +602,13 @@ export class GraphCanvas extends Widget {
return asset ? paletteEntries(this.state.project, asset) : [];
}
private newNode(entry: PaletteEntry, at: { x: number; y: number }): AddNodeCommand {
return new AddNodeCommand(this.assetId, entry.def.type, Math.round(at.x), Math.round(at.y), entry.params);
}
private addNode(entry: PaletteEntry, at: { x: number; y: number }): void {
if (!this.assetId) return;
const command = new AddNodeCommand(
this.assetId,
entry.def.type,
Math.round(at.x),
Math.round(at.y),
entry.params,
);
const command = this.newNode(entry, at);
if (this.state.execute(command)) this.state.select(nodeSelection(this.assetId, [command.node.id]));
}
@@ -659,124 +620,23 @@ export class GraphCanvas extends Widget {
at: { x: number; y: number },
): void {
if (!this.assetId) return;
const add = new AddNodeCommand(
this.assetId,
entry.def.type,
Math.round(at.x),
Math.round(at.y),
entry.params,
);
const add = this.newNode(entry, at);
const port = firstPortFor(entry.def, fromType, from.isOutput);
const to = { nodeId: add.node.id, port: port ?? '', isOutput: !from.isOutput };
const [out, inp] = from.isOutput ? [from, to] : [to, from];
const commands: Command[] = [add];
if (port)
commands.push(
new ConnectCommand(
this.assetId,
{ node: out.nodeId, port: out.port },
{ node: inp.nodeId, port: inp.port },
),
connectSides(this.assetId, from, { nodeId: add.node.id, port, isOutput: !from.isOutput }),
);
if (this.state.execute(new CompoundCommand(add.label, commands)))
if (this.state.execute(oneStep(add.label, commands)))
this.state.select(nodeSelection(this.assetId, [add.node.id]));
}
private openNodeMenu(view: NodeView, globalX: number, globalY: number): void {
const assetId = this.assetId;
if (!assetId) return;
// Right-clicking inside a multi-selection acts on all of it; outside it, on that node alone.
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,
onSelect: () => removeNodes(this.state, assetId, ids),
},
]).open(globalX, globalY);
}
private openPortMenu(
view: NodeView,
port: string,
isOutput: boolean,
globalX: number,
globalY: number,
private openLinks(
pick: (graph: BehaviorAsset['graph']) => BehaviorAsset['graph']['edges'],
at: { x: number; y: number },
): void {
const asset = this.asset();
if (!asset) return;
const edges = isOutput
? edgesOutOf(asset.graph, view.node.id, port)
: edgesInto(asset.graph, view.node.id, port);
if (edges.length === 0) return;
const shown = edges.filter((e) => !e.folded);
const hidden = edges.filter((e) => e.folded);
const fold = (list: GraphEdge[], folded: boolean) => () => {
const commands = list.map((e) => new FoldEdgeCommand(asset.id, e.id, folded));
this.state.execute(
commands.length === 1
? commands[0]!
: new CompoundCommand(folded ? 'Hide links' : 'Show links', commands),
);
};
const items: MenuItem[] = [];
if (shown.length > 0)
items.push({
label: shown.length === 1 ? 'Hide link' : `Hide ${shown.length} links`,
onSelect: fold(shown, true),
});
if (hidden.length > 0)
items.push({
label: hidden.length === 1 ? 'Show link' : `Show ${hidden.length} links`,
onSelect: fold(hidden, false),
});
items.push({
label: edges.length === 1 ? 'Disconnect' : `Disconnect ${edges.length} links`,
danger: true,
onSelect: () => {
for (const edge of edges) this.state.execute(new DisconnectCommand(asset.id, edge.id));
},
});
new Menu(items).open(globalX, globalY);
}
/** The mark is the only handle a hidden edge has, so it carries the way back. */
private openMarkMenu(mark: FoldMark, globalX: number, globalY: number): void {
const assetId = this.assetId;
if (!assetId) return;
const ids = mark.edgeIds;
const n = ids.length;
const all = (title: string, make: (edgeId: string) => Command) => () => {
const commands = ids.map(make);
this.state.execute(commands.length === 1 ? commands[0]! : new CompoundCommand(title, commands));
};
new Menu([
{
label: n === 1 ? 'Show link' : `Show ${n} links`,
onSelect: all('Show links', (id) => new FoldEdgeCommand(assetId, id, false)),
},
{
label: n === 1 ? 'Disconnect' : `Disconnect ${n} links`,
danger: true,
onSelect: all('Disconnect links', (id) => new DisconnectCommand(assetId, id)),
},
]).open(globalX, globalY);
const graph = this.asset()?.graph;
if (graph) openLinkMenu(this.state, this.assetId, pick(graph), at.x, at.y);
}
override layout(): void {
@@ -814,43 +674,29 @@ export class GraphCanvas extends Widget {
view.redraw();
}
this.edges.clear();
this.markChips.clear();
this.foldMarks = [];
this.markLabelCount = 0;
this.strokedEdges.clear();
this.foldEnds = [];
for (const edge of asset.graph.edges) this.drawEdge(edge, isHot(trace?.edges, edge.id), selectedNodes);
// After the curves, so no line drawn later crosses a mark that stands for a hidden one.
this.drawFoldMarks();
for (let i = this.markLabelCount; i < this.markLabels.length; i++) this.markLabels[i]!.visible = false;
this.edgeLayer.draw(asset.graph.edges, this.views, (id) => isHot(trace?.edges, id), selectedNodes);
this.tempEdge.clear();
this.tempEdgeShown = false;
const drag = this.connecting ?? this.pendingDrop;
if (drag) {
const view = this.views.get(drag.from.nodeId);
const p = view?.portPosition(drag.from.port, drag.from.isOutput);
if (view && p) {
const a = { x: view.x + p.x, y: view.y + p.y };
// Snapping to the resolved port makes the prediction the ring shows unmistakable.
const b = this.anchorPoint(target) ?? drag.pointer;
const [from, to] = drag.from.isOutput ? [a, b] : [b, a];
bezier(this.tempEdge, from.x, from.y, to.x, to.y);
this.tempEdge.stroke({ width: 2, color: c.textMuted, alpha: 0.9 });
this.tempEdgeShown = true;
}
const a = drag && this.anchorPoint(drag.from);
if (drag && a) {
// Snapping to the resolved port makes the prediction the ring shows unmistakable.
const b = this.anchorPoint(target) ?? drag.pointer;
const [from, to] = drag.from.isOutput ? [a, b] : [b, a];
bezier(this.tempEdge, from.x, from.y, to.x, to.y);
this.tempEdge.stroke({ width: 2, color: c.textMuted, alpha: 0.9 });
this.tempEdgeShown = true;
}
this.band.clear();
const m = this.marquee;
if (m?.active) {
const a = this.graphToScreen(m.start.x, m.start.y);
const b = this.graphToScreen(m.current.x, m.current.y);
this.band
.rect(Math.min(a.x, b.x), Math.min(a.y, b.y), Math.abs(b.x - a.x), Math.abs(b.y - a.y))
.fill({ color: c.marquee, alpha: 0.12 })
.stroke({ width: 1, color: c.marquee });
drawBand(
this.band,
this.graphToScreen(m.start.x, m.start.y),
this.graphToScreen(m.current.x, m.current.y),
);
}
const drop = this.incoming;
if (drop) {
@@ -871,93 +717,6 @@ export class GraphCanvas extends Widget {
return view && p ? { x: view.x + p.x, y: view.y + p.y } : null;
}
private drawEdge(edge: GraphEdge, hot: boolean, selected: Set<string>): void {
const from = this.views.get(edge.from.node);
const to = this.views.get(edge.to.node);
const a = from?.portPosition(edge.from.port, true);
const b = to?.portPosition(edge.to.port, false);
if (!from || !to || !a || !b) return;
const type = from.def.outputs.find((p) => p.key === edge.from.port)?.type ?? 'any';
const inType = to.def.inputs.find((p) => p.key === edge.to.port)?.type ?? 'any';
const valid = portsCompatible(type, inType);
const color = hot ? Theme.colors.hot : valid ? portColor(type) : Theme.colors.danger;
const start = { x: from.x + a.x, y: from.y + a.y };
const end = { x: to.x + b.x, y: to.y + b.y };
if (edge.folded) {
this.addFoldMark(edge, start, true, to.def.label, color, hot);
this.addFoldMark(edge, end, false, from.def.label, color, hot);
if (!foldedEdgeShown(edge, selected, hot)) return;
}
bezier(this.edges, start.x, start.y, end.x, end.y);
this.edges.stroke({
width: hot ? 4 : type === 'exec' ? 2.5 : 2,
color,
// Dimmed while folded, so a curve that is only on loan is not mistaken for a plain one.
alpha: (type === 'exec' || hot ? 1 : 0.85) * (edge.folded ? 0.6 : 1),
});
this.strokedEdges.add(edge.id);
}
/** Records one end of a hidden link; `drawFoldMarks` groups them per port afterwards. */
private addFoldMark(
edge: GraphEdge,
port: { x: number; y: number },
isOutput: boolean,
otherLabel: string,
color: number,
hot: boolean,
): void {
const [side, other] = isOutput ? [edge.from, edge.to.node] : [edge.to, edge.from.node];
this.foldEnds.push({
edgeId: edge.id,
key: foldMarkKey(side, isOutput, this.views.get(side.node)?.collapsed),
other,
otherLabel,
isOutput,
portX: port.x,
portY: port.y,
color,
hot,
});
}
/** A stub out of the port, a dot, and what waits at the other end – one per port, not per link. */
private drawFoldMarks(): void {
for (const mark of buildFoldMarks(this.foldEnds, FOLD_STUB)) {
const { x, y, color, isOutput } = mark;
this.edges.moveTo(mark.portX, y).lineTo(x, y).stroke({ width: 2, color, alpha: 0.9 });
const radius = foldMarkRadius(mark.edgeIds.length, FOLD_MARK_RADIUS);
this.edges.circle(x, y, radius).fill(color);
this.edges.circle(x, y, radius).stroke({ width: 1, color: Theme.colors.node });
const label = this.markLabel();
label.text = truncate(foldMarkLabel(mark.names), textStyle('small'), FOLD_LABEL_WIDTH);
const gap = radius + 4;
const lx = isOutput ? x + gap : x - gap - label.width;
label.position.set(lx, y - label.height / 2);
// Without a plate behind it the name blurs into whatever curves pass behind the mark.
this.markChips
.roundRect(lx - 3, y - label.height / 2 - 1, label.width + 6, label.height + 2, 3)
.fill({ color: Theme.colors.node, alpha: 0.85 });
this.foldMarks.push({ edgeIds: mark.edgeIds, others: mark.others, isOutput, x, y, text: label.text });
}
}
private markLabel(): Text {
let label = this.markLabels[this.markLabelCount];
if (!label) {
label = new Text({ text: '', style: textStyle('small') });
this.markLabels.push(label);
this.markLayer.addChild(label);
}
label.visible = true;
this.markLabelCount++;
return label;
}
private markAt(g: { x: number; y: number }): FoldMark | null {
return foldMarkAt(this.foldMarks, g, FOLD_MARK_RADIUS);
}
private syncGrid(): void {
this.grid.clear();
const step = GRID_STEP * this.zoom;
@@ -992,14 +751,15 @@ export class GraphCanvas extends Widget {
return this.tempEdgeShown;
}
/** Whether the last redraw drew this edge as a curve – the claim "hidden" is about exactly this. */
/** Whether the last redraw stroked this edge as a curve. */
edgeDrawn(edgeId: string): boolean {
return this.strokedEdges.has(edgeId);
return this.edgeLayer.wasStroked(edgeId);
}
/** Every mark the last redraw drew – its length is the claim "one per port, not one per link". */
foldMarksDrawn(): { links: number; label: string; isOutput: boolean }[] {
return this.foldMarks.map((m) => ({ links: m.edgeIds.length, label: m.text, isOutput: m.isOutput }));
return this.edgeLayer
.drawnMarks()
.map((m) => ({ links: m.edgeIds.length, label: m.text, isOutput: m.isOutput }));
}
/** Global screen position of a folded edge's mark at one end, and what that mark stands for. */
@@ -1007,41 +767,24 @@ export class GraphCanvas extends Widget {
edgeId: string,
isOutput: boolean,
): { x: number; y: number; links: number; label: string } | null {
const mark = this.foldMarks.find((m) => m.edgeIds.includes(edgeId) && m.isOutput === isOutput);
const mark = this.edgeLayer
.drawnMarks()
.find((m) => m.edgeIds.includes(edgeId) && m.isOutput === isOutput);
if (!mark) return null;
const g = this.canvas.toGlobal({ x: mark.x, y: mark.y });
return { x: g.x, y: g.y, links: mark.edgeIds.length, label: mark.text };
}
/** A node's drawn rectangle on screen, and the canvas' own – "fit" is a claim about both. */
nodeRect(nodeId: string): { x: number; y: number; w: number; h: number } | null {
nodeRect(nodeId: string): Rect | null {
const view = this.views.get(nodeId);
if (!view) return null;
const topLeft = view.toGlobal({ x: 0, y: 0 });
const bottomRight = view.toGlobal({ x: view.w, y: view.h });
return {
x: topLeft.x,
y: topLeft.y,
w: bottomRight.x - topLeft.x,
h: bottomRight.y - topLeft.y,
};
return view ? globalRect(view, view.w, view.h) : null;
}
canvasRect(): { x: number; y: number; w: number; h: number } {
const topLeft = this.toGlobal({ x: 0, y: 0 });
const bottomRight = this.toGlobal({ x: this.w, y: this.h });
return {
x: topLeft.x,
y: topLeft.y,
w: bottomRight.x - topLeft.x,
h: bottomRight.y - topLeft.y,
};
canvasRect(): Rect {
return globalRect(this, this.w, this.h);
}
/**
* The title a node is drawn with. Worth reaching for from outside because a nested-behavior node
* takes its title from the behavior it runs – "which node is this" is otherwise only a guess.
*/
/** A nested-behavior node takes its title from the behavior it runs. */
nodeLabel(nodeId: string): string | null {
return this.views.get(nodeId)?.def.label ?? null;
}
@@ -1069,8 +812,20 @@ export class GraphCanvas extends Widget {
}
}
/** Horizontal S-curve between two points. */
function bezier(g: Graphics, x1: number, y1: number, x2: number, y2: number): void {
const dx = Math.max(40, Math.abs(x2 - x1) * 0.5);
g.moveTo(x1, y1).bezierCurveTo(x1 + dx, y1, x2 - dx, y2, x2, y2);
type Rect = { x: number; y: number; w: number; h: number };
function globalRect(target: Container, w: number, h: number): Rect {
const topLeft = target.toGlobal({ x: 0, y: 0 });
const bottomRight = target.toGlobal({ x: w, y: h });
return { x: topLeft.x, y: topLeft.y, w: bottomRight.x - topLeft.x, h: bottomRight.y - topLeft.y };
}
/** A drag may start at either end; the command wants the output first. */
function connectSides(assetId: string, a: PortSide, b: PortSide): ConnectCommand {
const [out, inp] = a.isOutput ? [a, b] : [b, a];
return new ConnectCommand(
assetId,
{ node: out.nodeId, port: out.port },
{ node: inp.nodeId, port: inp.port },
);
}