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

+46 -8
View File
@@ -20,18 +20,28 @@ export type Selection =
* holds the whole selection, primary included.
*/
| { kind: 'object'; holderId: string; objectId: string; objectIds: string[] }
/** A node inside a behavior's graph. */
| { kind: 'node'; assetId: string; nodeId: string }
/** One or more nodes of a behavior's graph, `nodeId` being the primary one (as with objects). */
| { kind: 'node'; assetId: string; nodeId: string; nodeIds: string[] }
/** A frame rectangle inside a texture asset (the Texture tab). */
| { kind: 'frame'; assetId: string; frameId: string }
| null;
/** Builds an object selection; the last id is the primary one unless given explicitly. */
export function objectSelection(holderId: string, objectIds: readonly string[], primary?: string): Selection {
const ids = [...new Set(objectIds)];
const picked = pickIds(objectIds, primary);
return picked && { kind: 'object', holderId, objectId: picked.primary, objectIds: picked.ids };
}
/** Builds a node selection; the last id is the primary one unless given explicitly. */
export function nodeSelection(assetId: string, nodeIds: readonly string[], primary?: string): Selection {
const picked = pickIds(nodeIds, primary);
return picked && { kind: 'node', assetId, nodeId: picked.primary, nodeIds: picked.ids };
}
function pickIds(input: readonly string[], primary?: string): { ids: string[]; primary: string } | null {
const ids = [...new Set(input)];
if (ids.length === 0) return null;
const objectId = primary && ids.includes(primary) ? primary : ids[ids.length - 1]!;
return { kind: 'object', holderId, objectId, objectIds: ids };
return { ids, primary: primary && ids.includes(primary) ? primary : ids[ids.length - 1]! };
}
export type EditorMode = 'edit' | 'play';
@@ -206,6 +216,9 @@ export class EditorState {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (selection?.kind === 'object' && !selection.objectIds)
selection = objectSelection(selection.holderId, [selection.objectId]);
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (selection?.kind === 'node' && !selection.nodeIds)
selection = nodeSelection(selection.assetId, [selection.nodeId]);
if (sameSelection(this.selection, selection)) return;
this.selection = selection;
this.emit('selection');
@@ -221,11 +234,26 @@ export class EditorState {
this.select(objectSelection(holderId, next, current.includes(objectId) ? undefined : objectId));
}
/** Ctrl/Shift-click: adds the node to the selection or removes it again. */
toggleNode(assetId: string, nodeId: string): void {
const s = this.selection;
const current = s?.kind === 'node' && s.assetId === assetId ? s.nodeIds : [];
const held = current.includes(nodeId);
const next = held ? current.filter((id) => id !== nodeId) : [...current, nodeId];
// Emptied by the toggle, the behavior itself stays selected – the graph must not close.
this.select(nodeSelection(assetId, next, held ? undefined : nodeId) ?? { kind: 'asset', assetId });
}
/** Ids of the selected scene objects (empty unless objects are selected). */
get selectedObjectIds(): string[] {
return this.selection?.kind === 'object' ? this.selection.objectIds : [];
}
/** Ids of the selected graph nodes (empty unless nodes are selected). */
get selectedNodeIds(): string[] {
return this.selection?.kind === 'node' ? this.selection.nodeIds : [];
}
/** Drops selected targets that no longer exist (after delete/undo). */
private validateSelection(): void {
const s = this.selection;
@@ -241,8 +269,11 @@ export class EditorState {
const asset = findAsset(this.project, s.assetId);
// Fall back to the behavior asset so the graph stays open when its node vanished (undo).
if (!asset || asset.type !== 'behavior') this.select(null);
else if (!asset.graph.nodes.some((n) => n.id === s.nodeId))
this.select({ kind: 'asset', assetId: s.assetId });
else {
const alive = s.nodeIds.filter((id) => asset.graph.nodes.some((n) => n.id === id));
if (alive.length !== s.nodeIds.length)
this.select(nodeSelection(s.assetId, alive, s.nodeId) ?? { kind: 'asset', assetId: s.assetId });
}
}
if (s.kind === 'frame') {
const asset = findAsset(this.project, s.assetId);
@@ -321,7 +352,14 @@ export function sameSelection(a: Selection, b: Selection): boolean {
a.objectIds.every((id, i) => id === b.objectIds[i])
);
}
if (a.kind === 'node' && b.kind === 'node') return a.assetId === b.assetId && a.nodeId === b.nodeId;
if (a.kind === 'node' && b.kind === 'node') {
return (
a.assetId === b.assetId &&
a.nodeId === b.nodeId &&
a.nodeIds.length === b.nodeIds.length &&
a.nodeIds.every((id, i) => id === b.nodeIds[i])
);
}
if (a.kind === 'frame' && b.kind === 'frame') return a.assetId === b.assetId && a.frameId === b.frameId;
return false;
}