Make an edge drag say where it will land, and offer nodes when it lands nowhere

Dragging an edge now rings the port it would connect to and snaps the line
there. A drop on a node's body takes that node's first free compatible port,
or the last compatible one when none is free, replacing its link. A drop into
empty space opens the palette filtered to the node types the edge could reach
at all, and the picked node arrives wired - node and link as one undo step,
so a single undo takes back the whole gesture.

The decision of what fits what lives in core/graph/dropTarget.ts as pure
functions, tested directly; the canvas only draws the answer. The palette
takes an optional filter rather than knowing about drags.

Verified remote on the Windows box (GPU): ALL PASSED, 284 checks.
This commit is contained in:
root committed 2026-09-09 12:29:57 +02:00
1 parent 633f01cc46
commit ebad3fd530
12 files changed
+453 -28

No files matched your search

+76
View File
@@ -12,6 +12,7 @@ export async function graph(t) {
click,
clickText,
drag,
mouse,
locate,
typeText,
press,
@@ -127,6 +128,81 @@ export async function graph(t) {
);
await shot('18-connected');
// 13d2. A drop on the node's body instead of a port: it takes the first free compatible input,
// and the highlight names that port while the button is still down.
const axisX = await json(`__gm.graph.portAt('n-axis', 'x', true)`);
const logBody = await json(`__gm.graph.nodeAt(${JSON.stringify(newNode.id)})`);
await mouse('mouseMoved', axisX.x, axisX.y);
await mouse('mousePressed', axisX.x, axisX.y);
await mouse('mouseMoved', toPort.x, toPort.y, 'left', { buttons: 1 });
await sleep(50);
const overExec = await json(`__gm.graph.dropTarget()`);
check('a number drag over an exec port predicts nothing', overExec === null, JSON.stringify(overExec));
await mouse('mouseMoved', logBody.x, logBody.y, 'left', { buttons: 1 });
await sleep(50);
const overBody = await json(`__gm.graph.dropTarget()`);
check(
'over the node body it predicts the first free compatible input',
overBody?.nodeId === newNode.id && overBody.port === 'message' && overBody.isOutput === false,
JSON.stringify(overBody),
);
await shot('18b-drop-prediction');
await mouse('mouseReleased', logBody.x, logBody.y);
await sleep(150);
const dropped = await graphCounts();
const messageEdge = (await asset('mover')).graph.edges.find(
(e) => e.to.node === newNode.id && e.to.port === 'message',
);
check(
'the drop connects exactly the predicted port',
dropped.edges === before13.edges + 2 && messageEdge?.from.port === 'x',
JSON.stringify({ dropped, messageEdge }),
);
check('the prediction is dropped with the drag', (await json(`__gm.graph.dropTarget()`)) === null);
await undo();
check('undo removes that edge again', (await graphCounts()).edges === before13.edges + 1);
// 13d3. Dropped into empty space, the drag opens the palette filtered to what it could reach,
// and the picked node arrives already wired – as one undo step.
const empty = { x: gcenter.x - 250, y: gcenter.y + 250 };
const beforeDrop = await graphCounts();
await mouse('mouseMoved', axisX.x, axisX.y);
await mouse('mousePressed', axisX.x, axisX.y);
await mouse('mouseMoved', empty.x, empty.y, 'left', { buttons: 1 });
await sleep(50);
await mouse('mouseReleased', empty.x, empty.y);
await sleep(150);
check('a drop into empty space opens the palette', Boolean(await evaluate(`__gm.locate('Search nodes…')`)));
// "Restart game" takes exec only. Its label must not appear – and unlike "On update" it is not
// a node on the canvas, where `locate` would find it whatever the palette lists.
check(
'it lists a node that takes a number, not one that cannot',
Boolean(await evaluate(`__gm.locate('Clamp')`)) && !(await evaluate(`__gm.locate('Restart game')`)),
);
await shot('18c-drop-into-empty');
const clampRow = await locate('Clamp');
await click(clampRow.x, clampRow.y);
await sleep(200);
const afterDrop = await graphCounts();
const wired = await state();
const clampEdge = (await asset('mover')).graph.edges.find(
(e) => e.from.node === 'n-axis' && e.from.port === 'x' && e.to.node === wired.selection?.nodeId,
);
check(
'the new node arrives connected to its first compatible input',
afterDrop.nodes === beforeDrop.nodes + 1 &&
afterDrop.edges === beforeDrop.edges + 1 &&
clampEdge?.to.port === 'value',
JSON.stringify({ afterDrop, clampEdge }),
);
await undo();
const undone = await graphCounts();
check(
'one undo takes back both the node and its edge',
undone.nodes === beforeDrop.nodes && undone.edges === beforeDrop.edges,
JSON.stringify(undone),
);
// 13e. Delete removes the selected node together with its edge; undo brings both back.
await select({ kind: 'node', assetId: 'mover', nodeId: newNode.id });
await press('Delete');