Pan with the middle button, select several nodes, and fit properly

Three things the graph view did differently from the scene view, or not
at all:

Panning moves to the middle button, which works from anywhere – a node
under the pointer included, since NodeView now lets that one button
bubble up. Shift-drag on empty canvas pans too. The left button pulls a
rubber band instead and no longer pans.

Nodes can be selected several at a time: by band, by ctrl/shift-click,
moved together, deleted together, each as one undo step. The selection
model gains `nodeIds` alongside the objects' `objectIds`.

Fit really fits. It used to pin the zoom at 1 and merely centre, so a
graph larger than the view always left nodes hanging out, and it sized
every node with one guessed height although real ones vary with their
port count. Both sums now live in editor/graph/framing.ts as pure
functions, which is what made the defect testable at all.

The band selects any node it overlaps rather than one whose position it
contains, unlike the scene view: nodes are large rectangles, and a band
drawn visibly across one has to catch it.
This commit is contained in:
root committed 2026-09-09 14:29:30 +02:00
1 parent ed1fe31c77
commit e0d43e12aa
12 files changed
+656 -61

No files matched your search

+172 -40
View File
@@ -6,7 +6,6 @@ import {
ConnectCommand,
DisconnectCommand,
MoveNodeCommand,
RemoveNodeCommand,
} from '../../core/commands/graphCommands';
import {
canConnect,
@@ -16,6 +15,7 @@ import {
edgesOutOf,
firstPortFor,
type GraphEdge,
type GraphNode,
type NodeDef,
nodeDef,
type PortSide,
@@ -27,7 +27,9 @@ import type { BehaviorAsset } from '../../core/model/Asset';
import { findAsset } from '../../core/model/Project';
import { type Trace, TRACE_FADE } from '../../runtime/systems/BehaviorSystem';
import { clamp, drawGrid, Menu, type Size, Theme, textStyle, Widget, zoomFactor } from '../../ui';
import type { EditorState } from '../state';
import { removeNodes } from '../actions';
import { type EditorState, nodeSelection } from '../state';
import { type Box, boxesInBand, frameBoxes } from './framing';
import { NodePalette } from './NodePalette';
import { NODE_WIDTH, NodeView, portColor, TITLE_HEIGHT } from './NodeView';
@@ -36,8 +38,12 @@ const GRID_STEP = 24;
const MIN_GRID_STEP = 8;
const MIN_ZOOM = 0.25;
const MAX_ZOOM = 2.5;
/** Stand-in node height for centring the view – real nodes vary with their port count. */
/** Stand-in node height for a node with no view yet – real nodes vary with their port count. */
const NODE_HEIGHT_ESTIMATE = 80;
/** Breathing room around the framed graph, so nodes do not touch the edge. */
const FIT_MARGIN = 24;
/** Pointer travel (screen pixels) before a press turns into a drag rather than a click. */
const DRAG_THRESHOLD = 3;
/**
* The node graph of one behavior asset. Which behavior that is comes from the outside (`show`),
@@ -51,6 +57,8 @@ export class GraphCanvas extends Widget {
private readonly edges = new Graphics();
private readonly nodeLayer = new Container();
private readonly tempEdge = new Graphics();
/** Drawn in screen space, so the band keeps a hairline border at any zoom. */
private readonly band = new Graphics();
private readonly hint = new Text({ text: '', style: textStyle('muted') });
private readonly views = new Map<string, NodeView>();
private assetId: string | null = null;
@@ -58,11 +66,16 @@ export class GraphCanvas extends Widget {
private zoom = 1;
private panning: { startX: number; startY: number; panX: number; panY: number } | null = null;
private dragging: {
nodeId: string;
start: { x: number; y: number };
items: { nodeId: string; start: { x: number; y: number } }[];
pointer: { x: number; y: number };
moved: boolean;
} | null = null;
/** A rubber-band selection in progress, in graph coordinates. */
private marquee: {
start: { x: number; y: number };
current: { x: number; y: number };
active: boolean;
} | null = null;
private connecting: { from: PortSide; pointer: { x: number; y: number } } | null = null;
/** A finished drag whose palette is still open – drawn like the running one, so the connection
* being made stays visible while the node type is chosen. */
@@ -80,7 +93,7 @@ export class GraphCanvas extends Widget {
constructor(private readonly state: EditorState) {
super();
this.canvas.addChild(this.edges, this.nodeLayer, this.tempEdge);
this.addChild(this.bg, this.grid, this.canvas, this.hint, this.clip);
this.addChild(this.bg, this.grid, this.canvas, this.band, this.hint, this.clip);
this.canvas.mask = this.clip;
this.cursor = 'default';
@@ -178,7 +191,7 @@ export class GraphCanvas extends Widget {
this.redraw();
}
/** Centres the graph's nodes (or the origin) in the view. Waits for a real size. */
/** Frames the graph's nodes (or the origin) in the view. Waits for a real size. */
private fitView(): void {
if (this.w === 0 || this.h === 0) return;
this.needsFit = false;
@@ -186,17 +199,32 @@ export class GraphCanvas extends Widget {
if (!asset || asset.graph.nodes.length === 0) {
this.pan = { x: this.w / 2 - NODE_WIDTH / 2, y: this.h / 3 };
this.zoom = 1;
} else {
const xs = asset.graph.nodes.map((n) => n.x);
const ys = asset.graph.nodes.map((n) => n.y);
const cx = (Math.min(...xs) + Math.max(...xs) + NODE_WIDTH) / 2;
const cy = (Math.min(...ys) + Math.max(...ys) + NODE_HEIGHT_ESTIMATE) / 2;
this.zoom = 1;
this.pan = { x: this.w / 2 - cx, y: this.h / 2 - cy };
this.applyView();
return;
}
const framing = frameBoxes(this.nodeBoxes(), { w: this.w, h: this.h }, FIT_MARGIN, {
min: MIN_ZOOM,
max: MAX_ZOOM,
});
this.zoom = framing.zoom;
this.pan = { x: framing.panX, y: framing.panY };
this.applyView();
}
/** Each node as the rectangle it is actually drawn as (a view knows its own height). */
private nodeBoxes(): Box[] {
return (this.asset()?.graph.nodes ?? []).map((node) => {
const view = this.views.get(node.id);
return {
id: node.id,
x: node.x,
y: node.y,
w: view?.w ?? NODE_WIDTH,
h: view?.h ?? NODE_HEIGHT_ESTIMATE,
};
});
}
private applyView(): void {
this.canvas.position.set(Math.round(this.pan.x), Math.round(this.pan.y));
this.canvas.scale.set(this.zoom);
@@ -207,24 +235,42 @@ export class GraphCanvas extends Widget {
return { x: (sx - this.pan.x) / this.zoom, y: (sy - this.pan.y) / this.zoom };
}
private graphToScreen(gx: number, gy: number): { x: number; y: number } {
return { x: gx * this.zoom + this.pan.x, y: gy * this.zoom + this.pan.y };
}
// --- pointer -----------------------------------------------------------------------------
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) {
this.panning = { startX: e.global.x, startY: e.global.y, panX: this.pan.x, panY: this.pan.y };
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 };
return;
}
const local = this.toLocal(e.global);
if (e.button === 2) {
if (this.assetId && this.state.mode === 'edit')
this.openPalette(e.global.x, e.global.y, this.screenToGraph(local.x, local.y));
return;
}
if (e.button === 0 || e.button === 1) {
if (e.button === 0 && this.assetId && this.state.mode === 'edit')
this.state.select({ kind: 'asset', assetId: this.assetId });
this.panning = { startX: e.global.x, startY: e.global.y, panX: this.pan.x, panY: this.pan.y };
if (e.button === 0) {
// Empty space: a click selects the behavior, a drag becomes a rubber band – decided on release.
const g = this.screenToGraph(local.x, local.y);
this.marquee = { start: g, current: g, active: false };
}
}
private onNodePointerDown(view: NodeView, e: FederatedPointerEvent): void {
// Panning takes precedence over the node: let the middle button bubble up to the canvas.
if (e.button === 1) return;
if (!this.assetId || this.state.mode !== 'edit') return;
e.stopPropagation();
const local = view.toLocal(e.global);
@@ -245,17 +291,29 @@ export class GraphCanvas extends Widget {
this.redraw();
return;
}
this.state.select({ kind: 'node', assetId: this.assetId, nodeId: view.node.id });
if (e.ctrlKey || e.metaKey || e.shiftKey) {
this.state.toggleNode(this.assetId, view.node.id);
return;
}
// Clicking a selected node keeps the selection so the whole group can be dragged.
if (!this.state.selectedNodeIds.includes(view.node.id))
this.state.select(nodeSelection(this.assetId, [view.node.id]));
const p = this.toLocal(e.global);
this.dragging = {
nodeId: view.node.id,
start: { x: view.node.x, y: view.node.y },
items: this.selectedNodes().map((n) => ({ nodeId: n.id, start: { x: n.x, y: n.y } })),
pointer: this.screenToGraph(p.x, p.y),
moved: false,
};
this.nodeLayer.addChild(view); // raise
}
private selectedNodes(): GraphNode[] {
const graph = this.asset()?.graph;
if (!graph) return [];
const ids = new Set(this.state.selectedNodeIds);
return graph.nodes.filter((n) => ids.has(n.id));
}
private onPointerMove(e: FederatedPointerEvent): void {
if (this.panning) {
this.pan.x = this.panning.panX + (e.global.x - this.panning.startX);
@@ -268,33 +326,54 @@ export class GraphCanvas extends Widget {
if (this.dragging) {
const dx = g.x - this.dragging.pointer.x;
const dy = g.y - this.dragging.pointer.y;
if (!this.dragging.moved && Math.hypot(dx, dy) * this.zoom < 3) return;
if (!this.dragging.moved && Math.hypot(dx, dy) * this.zoom < DRAG_THRESHOLD) return;
this.dragging.moved = true;
const node = this.asset()?.graph.nodes.find((n) => n.id === this.dragging?.nodeId);
if (!node) return;
node.x = Math.round(this.dragging.start.x + dx);
node.y = Math.round(this.dragging.start.y + dy);
const graph = this.asset()?.graph;
if (!graph) return;
for (const item of this.dragging.items) {
const node = graph.nodes.find((n) => n.id === item.nodeId);
if (!node) continue;
node.x = Math.round(item.start.x + dx);
node.y = Math.round(item.start.y + dy);
}
this.state.preview();
} else if (this.connecting) {
this.connecting.pointer = g;
this.dropTarget = this.resolveDrop(e.global.x, e.global.y);
this.redraw();
} else if (this.marquee) {
this.marquee.current = g;
const d = Math.hypot(g.x - this.marquee.start.x, g.y - this.marquee.start.y) * this.zoom;
if (!this.marquee.active && d < DRAG_THRESHOLD) return;
this.marquee.active = true;
this.redraw();
}
}
private onPointerUp(e: FederatedPointerEvent): void {
this.panning = null;
const marquee = this.marquee;
this.marquee = null;
if (marquee && this.assetId && this.state.mode === 'edit') {
if (marquee.active) this.selectInMarquee(marquee);
else this.state.select({ kind: 'asset', assetId: this.assetId });
}
const drag = this.dragging;
this.dragging = null;
if (drag && this.assetId) {
const node = this.asset()?.graph.nodes.find((n) => n.id === drag.nodeId);
if (node && drag.moved) {
if (drag?.moved && this.assetId) {
const graph = this.asset()?.graph;
const moves: MoveNodeCommand[] = [];
for (const item of drag.items) {
const node = graph?.nodes.find((n) => n.id === item.nodeId);
if (!node) continue;
// The preview moved the node already; put it back so the command records the start.
const next = { x: node.x, y: node.y };
node.x = drag.start.x;
node.y = drag.start.y;
this.state.execute(new MoveNodeCommand(this.assetId, drag.nodeId, next.x, next.y));
node.x = item.start.x;
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));
}
const connecting = this.connecting;
const target = connecting ? (this.dropTarget ?? this.resolveDrop(e.global.x, e.global.y)) : null;
@@ -308,6 +387,21 @@ export class GraphCanvas extends Widget {
this.redraw();
}
private selectInMarquee(marquee: {
start: { x: number; y: number };
current: { x: number; y: number };
}): void {
if (!this.assetId) return;
const ids = boxesInBand(this.nodeBoxes(), {
x1: marquee.start.x,
y1: marquee.start.y,
x2: marquee.current.x,
y2: marquee.current.y,
});
// An empty band clears the nodes but keeps the behavior selected, as a plain click does.
this.state.select(nodeSelection(this.assetId, ids) ?? { kind: 'asset', assetId: this.assetId });
}
private connect(from: PortSide, to: PortSide): void {
if (!this.assetId) return;
const [out, inp] = from.isOutput ? [from, to] : [to, from];
@@ -395,8 +489,7 @@ export class GraphCanvas extends Widget {
private addNode(def: NodeDef, at: { x: number; y: number }): void {
if (!this.assetId) return;
const command = new AddNodeCommand(this.assetId, def.type, Math.round(at.x), Math.round(at.y));
if (this.state.execute(command))
this.state.select({ kind: 'node', assetId: this.assetId, nodeId: command.node.id });
if (this.state.execute(command)) this.state.select(nodeSelection(this.assetId, [command.node.id]));
}
/** Adding and wiring go in as one command, so a single undo takes the whole gesture back. */
@@ -421,18 +514,21 @@ export class GraphCanvas extends Widget {
),
);
if (this.state.execute(new CompoundCommand(add.label, commands)))
this.state.select({ kind: 'node', assetId: this.assetId, nodeId: add.node.id });
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;
this.state.select({ kind: 'node', assetId, nodeId: view.node.id });
// 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;
new Menu([
{
label: 'Delete node',
label: ids.length > 1 ? `Delete ${ids.length} nodes` : 'Delete node',
danger: true,
onSelect: () => this.state.execute(new RemoveNodeCommand(assetId, view.node.id)),
onSelect: () => removeNodes(this.state, assetId, ids),
},
]).open(globalX, globalY);
}
@@ -488,10 +584,10 @@ export class GraphCanvas extends Widget {
const t = map?.get(id);
return t !== undefined && now - t <= TRACE_FADE;
};
const selectedNode = this.state.selection?.kind === 'node' ? this.state.selection.nodeId : null;
const selectedNodes = new Set(this.state.selectedNodeIds);
const target = this.dropTarget;
for (const view of this.views.values()) {
view.selected = view.node.id === selectedNode;
view.selected = selectedNodes.has(view.node.id);
view.hot = isHot(trace?.nodes, view.node.id);
view.targetPort =
target && target.nodeId === view.node.id ? { port: target.port, isOutput: target.isOutput } : null;
@@ -517,6 +613,17 @@ export class GraphCanvas extends Widget {
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 });
}
}
/** A port's centre in graph coordinates. */
@@ -580,6 +687,31 @@ export class GraphCanvas extends Widget {
return this.tempEdgeShown;
}
/** 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 {
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,
};
}
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,
};
}
/** Global screen position of a node's title bar centre. */
nodeGlobal(nodeId: string): { x: number; y: number } | null {
const view = this.views.get(nodeId);
+73
View File
@@ -0,0 +1,73 @@
/**
* Geometry of the graph view: which nodes a rubber band caught, and what view frames them all.
* Both are decisions about rectangles, so they live here rather than in the canvas – the "fit"
* that left nodes hanging out of the view was a sum that no test could reach.
*/
export interface Box {
id: string;
x: number;
y: number;
w: number;
h: number;
}
export interface Band {
x1: number;
y1: number;
x2: number;
y2: number;
}
export interface Framing {
zoom: number;
panX: number;
panY: number;
}
/**
* Ids of every box the band overlaps. Nodes are large rectangles, so – unlike the scene view,
* which tests an object's position – touching counts: a band drawn across a node that visibly
* caught it would otherwise leave it out.
*/
export function boxesInBand(boxes: readonly Box[], band: Band): string[] {
const minX = Math.min(band.x1, band.x2);
const maxX = Math.max(band.x1, band.x2);
const minY = Math.min(band.y1, band.y2);
const maxY = Math.max(band.y1, band.y2);
return boxes
.filter((b) => b.x <= maxX && b.x + b.w >= minX && b.y <= maxY && b.y + b.h >= minY)
.map((b) => b.id);
}
/**
* The view that shows every box at once, centred, with `margin` screen pixels to spare.
* Never zooms past 1: a two-node graph blown up to fill the screen helps nobody.
*/
export function frameBoxes(
boxes: readonly Box[],
view: { w: number; h: number },
margin: number,
limits: { min: number; max: number },
): Framing {
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const b of boxes) {
minX = Math.min(minX, b.x);
minY = Math.min(minY, b.y);
maxX = Math.max(maxX, b.x + b.w);
maxY = Math.max(maxY, b.y + b.h);
}
if (!Number.isFinite(minX)) return { zoom: 1, panX: view.w / 2, panY: view.h / 2 };
const width = Math.max(maxX - minX, 1);
const height = Math.max(maxY - minY, 1);
const fitted = Math.min((view.w - margin * 2) / width, (view.h - margin * 2) / height, 1);
const zoom = Math.min(Math.max(fitted, limits.min), limits.max);
return {
zoom,
panX: view.w / 2 - ((minX + maxX) / 2) * zoom,
panY: view.h / 2 - ((minY + maxY) / 2) * zoom,
};
}