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

+137
View File
@@ -326,5 +326,142 @@ export async function graph(t) {
'graph editor rebinds when a behavior is selected again',
(await evaluate(`__gm.graph.current()`)) === 'mover',
);
// 13i. Mouse behaviour, as in the scene view: the middle button pans from anywhere – a node
// under it included – and the left button no longer pans at all.
await clickText('Fit');
await sleep(250);
const positions = async () =>
(await asset('mover')).graph.nodes.map((n) => `${n.id}:${n.x},${n.y}`).join(' ');
const area = await json('__gm.graph.canvasRect()');
const before13i = await positions();
const axisScreenBefore = await json(`__gm.graph.nodeAt('n-axis')`);
await drag(axisScreenBefore.x, axisScreenBefore.y, axisScreenBefore.x + 120, axisScreenBefore.y + 60, {
button: 'middle',
});
const axisScreenPanned = await json(`__gm.graph.nodeAt('n-axis')`);
check(
'the middle button pans even when it starts on a node',
Math.abs(axisScreenPanned.x - axisScreenBefore.x - 120) < 3 &&
Math.abs(axisScreenPanned.y - axisScreenBefore.y - 60) < 3,
JSON.stringify({ axisScreenBefore, axisScreenPanned }),
);
check(
'panning over a node leaves the node where it was',
(await positions()) === before13i,
await positions(),
);
// An empty spot: after a fit every node sits at least a margin away from the corner.
const corner = { x: area.x + 6, y: area.y + 6 };
const opposite = { x: area.x + area.w - 6, y: area.y + area.h - 6 };
const axisBeforeBand = await json(`__gm.graph.nodeAt('n-axis')`);
await drag(corner.x, corner.y, corner.x + 60, corner.y + 40);
const axisAfterBand = await json(`__gm.graph.nodeAt('n-axis')`);
check(
'dragging with the left button no longer pans',
Math.abs(axisAfterBand.x - axisBeforeBand.x) < 3 && Math.abs(axisAfterBand.y - axisBeforeBand.y) < 3,
JSON.stringify({ axisBeforeBand, axisAfterBand }),
);
// 13j. A rubber band across the whole canvas takes every node – the count comes from the model,
// so this cannot agree with the canvas by sharing its rule.
const nodeCount = (await asset('mover')).graph.nodes.length;
await drag(corner.x, corner.y, opposite.x, opposite.y, { steps: 16 });
const banded = await json('__gm.graph.selected()');
check(
'a rubber band selects every node it spans',
banded.length === nodeCount,
`${banded.length} of ${nodeCount}`,
);
await shot('21-graph-multiselect');
// 13k. The whole selection moves as one, and one undo takes all of it back.
const beforeMove = (await asset('mover')).graph.nodes.map((n) => ({ id: n.id, x: n.x, y: n.y }));
const grabAt = await json(`__gm.graph.nodeAt('n-axis')`);
await drag(grabAt.x, grabAt.y, grabAt.x + 40, grabAt.y + 30);
const afterMove = await asset('mover');
const movedTogether = beforeMove.every((n) => {
const now = afterMove.graph.nodes.find((m) => m.id === n.id);
return now.x === n.x + 40 && now.y === n.y + 30;
});
check('dragging one of them moves the whole selection', movedTogether, await positions());
await undo();
check('one undo takes the group move back', (await positions()) === before13i, await positions());
// 13l. Delete acts on the selection, not just the primary node.
await drag(corner.x, corner.y, opposite.x, opposite.y, { steps: 16 });
const beforeDelete = await graphCounts();
await press('Delete');
await sleep(200);
const afterDelete = await graphCounts();
check(
'Delete removes every selected node, edges with them',
afterDelete.nodes === 0 && afterDelete.edges === 0,
JSON.stringify(afterDelete),
);
await undo();
const restoredAll = await graphCounts();
check(
'one undo brings the whole graph back',
restoredAll.nodes === beforeDelete.nodes && restoredAll.edges === beforeDelete.edges,
JSON.stringify(restoredAll),
);
// 13m. Ctrl-click adds and removes single nodes. Fit first, so every node is really on screen
// and a click cannot miss one that the earlier pan pushed over the edge.
await select({ kind: 'asset', assetId: 'mover' });
await clickText('Fit');
await sleep(250);
const axisRow = await json(`__gm.graph.nodeAt('n-axis')`);
await click(axisRow.x, axisRow.y);
const second = (await asset('mover')).graph.nodes.find((n) => n.id !== 'n-axis');
const secondAt = await json(`__gm.graph.nodeAt(${JSON.stringify(second.id)})`);
await click(secondAt.x, secondAt.y, 'left', { ctrl: true });
check(
'ctrl-click adds a second node to the selection',
JSON.stringify(await json('__gm.graph.selected()')) === JSON.stringify(['n-axis', second.id]),
JSON.stringify(await json('__gm.graph.selected()')),
);
await click(secondAt.x, secondAt.y, 'left', { ctrl: true });
check(
'ctrl-clicking it again removes it',
JSON.stringify(await json('__gm.graph.selected()')) === JSON.stringify(['n-axis']),
JSON.stringify(await json('__gm.graph.selected()')),
);
// 13n. Fit really fits: proven against a node parked far outside, and against the fact that the
// graph is then wider than the canvas – otherwise centring alone would pass this check.
await evaluate(
`(() => { const n = __gm.state.project.assets.find(a => a.id === 'mover').graph.nodes.find(n => n.id === 'n-axis'); n.x += 2500; __gm.state.preview(); })()`,
);
await sleep(250);
await clickText('Fit');
await sleep(300);
const rects = [];
for (const node of (await asset('mover')).graph.nodes)
rects.push(await json(`__gm.graph.nodeRect(${JSON.stringify(node.id)})`));
const span = Math.max(...rects.map((r) => r.x + r.w)) - Math.min(...rects.map((r) => r.x));
check(
'the parked node makes the graph wider than the canvas, so fitting has to zoom out',
span > area.w * 0.5,
`span=${Math.round(span)} canvas=${Math.round(area.w)}`,
);
const outside = rects.filter(
(r) => r.x < area.x || r.y < area.y || r.x + r.w > area.x + area.w || r.y + r.h > area.y + area.h,
);
check(
'Fit leaves no node hanging out of the view',
outside.length === 0,
`${outside.length} outside: ${JSON.stringify(outside.slice(0, 2))}`,
);
await shot('22-graph-fitted');
await evaluate(
`(() => { const n = __gm.state.project.assets.find(a => a.id === 'mover').graph.nodes.find(n => n.id === 'n-axis'); n.x -= 2500; __gm.state.preview(); })()`,
);
await clickText('Fit');
await sleep(250);
check('the parked node is back where it started', (await positions()) === before13i, await positions());
await evaluate(`__gm.showTab('scene')`);
}
+17 -6
View File
@@ -174,16 +174,27 @@ function makeTools({ cdp, projectId, baseUrl, consoleLog, check, shotPrefix }) {
const p = await locate(text);
await click(p.x, p.y, button);
};
const drag = async (x1, y1, x2, y2, steps = 12) => {
/** CDP reports held buttons as a bitmask, which is not the button name it takes elsewhere. */
const BUTTON_MASK = { left: 1, right: 2, middle: 4 };
/**
* Drags with the left button unless told otherwise. The fifth argument is either a step count
* (its long-standing meaning) or `{ steps, button, modifiers }`.
*/
const drag = async (x1, y1, x2, y2, options = {}) => {
const {
steps = 12,
button = 'left',
modifiers = {},
} = typeof options === 'number' ? { steps: options } : options;
const extra = { modifiers: modifierBits(modifiers) };
const held = { ...extra, buttons: BUTTON_MASK[button] };
await mouse('mouseMoved', x1, y1);
await mouse('mousePressed', x1, y1);
await mouse('mousePressed', x1, y1, button, extra);
for (let i = 1; i <= steps; i++) {
await mouse('mouseMoved', x1 + ((x2 - x1) * i) / steps, y1 + ((y2 - y1) * i) / steps, 'left', {
buttons: 1,
});
await mouse('mouseMoved', x1 + ((x2 - x1) * i) / steps, y1 + ((y2 - y1) * i) / steps, button, held);
await sleep(16);
}
await mouse('mouseReleased', x2, y2);
await mouse('mouseReleased', x2, y2, button, extra);
await sleep(150);
};