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

+8 -1
View File
@@ -90,6 +90,13 @@ shown on the node and editable in the inspector.
(types are checked; a data input holds one link and is replaced), drag nodes, right-click a
node or port to delete/disconnect, `Delete` removes the selected node, wheel zooms, dragging
the empty canvas pans. Everything is undoable.
- **Dragging an edge tells you where it would land** before you let go: the port it would connect
to wears a white ring and the line snaps to it. Dropping on a node's *body* rather than a port
takes that node's first free compatible port – or, when none is free, the last compatible one,
whose link is then replaced. A drop into empty space opens the palette filtered to the node
types this edge could reach at all, and the picked node arrives already wired; node and link go
in as **one** undo step (`core/graph/dropTarget.ts` decides all of this, the canvas only draws
it).
- **Header bar** (the same one the Texture and Container tabs have): the behavior picker,
**Add node…** (the palette, dropping the node in the middle of the view – the right-click nobody
finds), **Fit**, and the size of the graph as "N nodes · M links".
@@ -408,7 +415,7 @@ npm run format # Prettier over src, tests and tools
npm run check # typecheck + lint + format:check + tests, what CI would run
npm run build # writes dist/ and regenerates index.html
export GM_REMOTE=user@host # where the browser runs; see below
node tools/seed-e2e.mjs && node tools/e2e.mjs # 275 editor checks, screenshots in /tmp/gm-e2e-*.png
node tools/seed-e2e.mjs && node tools/e2e.mjs # 284 editor checks, screenshots in /tmp/gm-e2e-*.png
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs # 24 checks: the example game, played end to end
```
+84
View File
@@ -0,0 +1,84 @@
import { edgesInto, findNode, type Graph, type PortType, portsCompatible } from './Graph';
import { type NodeDef, nodeDef } from './NodeRegistry';
/** One end of an edge being dragged: a port on a node, seen from its own side. */
export interface PortSide {
nodeId: string;
port: string;
isOutput: boolean;
}
export function portTypeOf(graph: Graph, side: PortSide): PortType | undefined {
const node = findNode(graph, side.nodeId);
const def = node && nodeDef(node.type);
const list = side.isOutput ? def?.outputs : def?.inputs;
return list?.find((p) => p.key === side.port)?.type;
}
/** Whether a drag from `from` may land on `to`, i.e. whether ConnectCommand would accept it. */
export function canConnect(graph: Graph, from: PortSide, to: PortSide): boolean {
if (from.isOutput === to.isOutput || from.nodeId === to.nodeId) return false;
const fromType = portTypeOf(graph, from);
const toType = portTypeOf(graph, to);
if (fromType === undefined || toType === undefined) return false;
const [out, inp] = from.isOutput ? [fromType, toType] : [toType, fromType];
return portsCompatible(out, inp) && !linked(graph, from, to);
}
/**
* Which port a drag from `from` lands on when it is dropped on node `nodeId` rather than on a port:
* the first free compatible one, or – when none is free – the last compatible one, whose edge is
* then replaced. Returns null when the node offers nothing to connect to.
*/
export function dropTargetPort(graph: Graph, from: PortSide, nodeId: string): PortSide | null {
if (nodeId === from.nodeId) return null;
const node = findNode(graph, nodeId);
const def = node && nodeDef(node.type);
const fromType = portTypeOf(graph, from);
if (!def || fromType === undefined) return null;
const candidates = compatiblePorts(def, fromType, from.isOutput)
.map((p) => ({ side: { nodeId, port: p.key, isOutput: !from.isOutput }, type: p.type }))
.filter((c) => !linked(graph, from, c.side));
const free = candidates.find((c) => isFree(graph, c.side, c.type));
return (free ?? candidates.at(-1))?.side ?? null;
}
/**
* Whether a node of this type has any port a drag from `fromType` could land on – the filter for
* the palette that opens when an edge is dropped into empty space.
*/
export function defAcceptsFrom(def: NodeDef, fromType: PortType, fromIsOutput: boolean): boolean {
return compatiblePorts(def, fromType, fromIsOutput).length > 0;
}
/**
* The port a *fresh* node of this type offers such a drag. Every port of a node that is not in the
* graph yet is free, so the first compatible one is what `dropTargetPort` would pick for it.
*/
export function firstPortFor(def: NodeDef, fromType: PortType, fromIsOutput: boolean): string | null {
return compatiblePorts(def, fromType, fromIsOutput)[0]?.key ?? null;
}
function compatiblePorts(def: NodeDef, fromType: PortType, fromIsOutput: boolean) {
const ports = fromIsOutput ? def.inputs : def.outputs;
return ports.filter((p) =>
fromIsOutput ? portsCompatible(fromType, p.type) : portsCompatible(p.type, fromType),
);
}
/** Only a data input is exclusive – exec inputs and every output take any number of edges. */
function isFree(graph: Graph, side: PortSide, type: PortType): boolean {
if (side.isOutput || type === 'exec') return true;
return edgesInto(graph, side.nodeId, side.port).length === 0;
}
function linked(graph: Graph, a: PortSide, b: PortSide): boolean {
const [out, inp] = a.isOutput ? [a, b] : [b, a];
return graph.edges.some(
(e) =>
e.from.node === out.nodeId &&
e.from.port === out.port &&
e.to.node === inp.nodeId &&
e.to.port === inp.port,
);
}
+1
View File
@@ -1,6 +1,7 @@
// Importing the graph package registers the built-in node set as a side effect.
import './nodes';
export * from './dropTarget';
export * from './Graph';
export * from './NodeContext';
export * from './NodeRegistry';
+1
View File
@@ -151,6 +151,7 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
portAt: (nodeId: string, port: string, isOutput: boolean) =>
graphEditor.canvas.portGlobal(nodeId, port, isOutput),
nodeAt: (nodeId: string) => graphEditor.canvas.nodeGlobal(nodeId),
dropTarget: () => graphEditor.canvas.pendingDropTarget(),
// The canvas, not the whole tab: a centre measured through the header sits above the
// drawing area.
center: () => centreOf(graphEditor.canvas),
+122 -22
View File
@@ -1,4 +1,6 @@
import { Container, type FederatedPointerEvent, type FederatedWheelEvent, Graphics, Text } from 'pixi.js';
import type { Command } from '../../core/commands/Command';
import { CompoundCommand } from '../../core/commands/CompoundCommand';
import {
AddNodeCommand,
ConnectCommand,
@@ -7,12 +9,19 @@ import {
RemoveNodeCommand,
} from '../../core/commands/graphCommands';
import {
canConnect,
defAcceptsFrom,
dropTargetPort,
edgesInto,
edgesOutOf,
firstPortFor,
type GraphEdge,
type NodeDef,
nodeDef,
type PortSide,
type PortType,
portsCompatible,
portTypeOf,
} from '../../core/graph';
import type { BehaviorAsset } from '../../core/model/Asset';
import { findAsset } from '../../core/model/Project';
@@ -30,12 +39,6 @@ const MAX_ZOOM = 2.5;
/** Stand-in node height for centring the view – real nodes vary with their port count. */
const NODE_HEIGHT_ESTIMATE = 80;
interface PortAnchor {
nodeId: string;
port: string;
isOutput: boolean;
}
/**
* The node graph of one behavior asset. Which behavior that is comes from the outside (`show`),
* so the canvas never has an opinion about the selection – the GraphEditor around it does.
@@ -60,7 +63,9 @@ export class GraphCanvas extends Widget {
pointer: { x: number; y: number };
moved: boolean;
} | null = null;
private connecting: { from: PortAnchor; pointer: { x: number; y: number } } | null = null;
private connecting: { from: PortSide; pointer: { x: number; y: number } } | null = null;
/** Where the running drag would land, recomputed on every move so the drop holds no surprise. */
private dropTarget: PortSide | null = null;
/** Fit the view once the editor has a real size (it may be hidden behind the Scene tab). */
private needsFit = true;
/** Supplies live execution data while playing (null otherwise). */
@@ -231,6 +236,7 @@ export class GraphCanvas extends Widget {
from: { nodeId: view.node.id, port: hit.port.key, isOutput: hit.isOutput },
pointer: this.screenToGraph(p.x, p.y),
};
this.dropTarget = null;
this.redraw();
return;
}
@@ -266,6 +272,7 @@ export class GraphCanvas extends Widget {
this.state.preview();
} else if (this.connecting) {
this.connecting.pointer = g;
this.dropTarget = this.resolveDrop(e.global.x, e.global.y);
this.redraw();
}
}
@@ -285,26 +292,61 @@ export class GraphCanvas extends Widget {
}
}
const connecting = this.connecting;
const target = connecting ? (this.dropTarget ?? this.resolveDrop(e.global.x, e.global.y)) : null;
this.connecting = null;
this.dropTarget = null;
if (connecting && this.assetId) {
const target = this.portUnder(e.global.x, e.global.y);
if (target && target.isOutput !== connecting.from.isOutput) {
const out = connecting.from.isOutput ? connecting.from : target;
const inp = connecting.from.isOutput ? target : connecting.from;
this.state.execute(
new ConnectCommand(
this.assetId,
{ node: out.nodeId, port: out.port },
{ node: inp.nodeId, port: inp.port },
),
);
}
if (target) this.connect(connecting.from, target);
else if (!this.nodeUnder(e.global.x, e.global.y))
this.openPaletteForDrop(connecting.from, e.global.x, e.global.y);
}
this.redraw();
}
private portUnder(globalX: number, globalY: number): PortAnchor | null {
for (const view of this.views.values()) {
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 },
),
);
}
/**
* Dropped into empty space: offer the node types this edge could reach, then wire the new node
* up the same way a drop on its body would.
*/
private openPaletteForDrop(from: PortSide, globalX: number, globalY: number): void {
const graph = this.asset()?.graph;
const fromType = graph && portTypeOf(graph, from);
if (!graph || fromType === undefined) return;
const local = this.toLocal({ x: globalX, y: globalY });
const at = this.screenToGraph(local.x, local.y);
new NodePalette(
(def: NodeDef) => this.addConnectedNode(def, from, fromType, at),
(def) => defAcceptsFrom(def, fromType, from.isOutput),
).open(globalX, globalY);
}
/**
* Where the running drag would connect: the port under the pointer, or – dropped on a node's
* body – the port that node picks for it. Null means the drop does nothing.
*/
private resolveDrop(globalX: number, globalY: number): PortSide | null {
const graph = this.asset()?.graph;
const from = this.connecting?.from;
if (!graph || !from) return null;
const hit = this.portUnder(globalX, globalY);
if (hit) return canConnect(graph, from, hit) ? hit : null;
const node = this.nodeUnder(globalX, globalY);
return node ? dropTargetPort(graph, from, node) : null;
}
private portUnder(globalX: number, globalY: number): PortSide | null {
for (const view of this.topDownViews()) {
const local = view.toLocal({ x: globalX, y: globalY });
const hit = view.portAt(local.x, local.y);
if (hit) return { nodeId: view.node.id, port: hit.port.key, isOutput: hit.isOutput };
@@ -312,6 +354,22 @@ export class GraphCanvas extends Widget {
return null;
}
private nodeUnder(globalX: number, globalY: number): string | null {
for (const view of this.topDownViews()) {
const p = view.toLocal({ x: globalX, y: globalY });
if (p.x >= 0 && p.x <= view.w && p.y >= 0 && p.y <= view.h) return view.node.id;
}
return null;
}
/** Views in stacking order, topmost first – a node drawn over another one wins the pointer. */
private *topDownViews(): Iterable<NodeView> {
for (let i = this.nodeLayer.children.length - 1; i >= 0; i--) {
const child = this.nodeLayer.children[i];
if (child instanceof NodeView) yield child;
}
}
// --- menus -------------------------------------------------------------------------------
private openPalette(globalX: number, globalY: number, at: { x: number; y: number }): void {
@@ -325,6 +383,31 @@ export class GraphCanvas extends Widget {
this.state.select({ kind: 'node', assetId: this.assetId, nodeId: command.node.id });
}
/** Adding and wiring go in as one command, so a single undo takes the whole gesture back. */
private addConnectedNode(
def: NodeDef,
from: PortSide,
fromType: PortType,
at: { x: number; y: number },
): void {
if (!this.assetId) return;
const add = new AddNodeCommand(this.assetId, def.type, Math.round(at.x), Math.round(at.y));
const port = firstPortFor(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 },
),
);
if (this.state.execute(new CompoundCommand(add.label, commands)))
this.state.select({ kind: 'node', assetId: this.assetId, nodeId: add.node.id });
}
private openNodeMenu(view: NodeView, globalX: number, globalY: number): void {
const assetId = this.assetId;
if (!assetId) return;
@@ -390,9 +473,12 @@ export class GraphCanvas extends Widget {
return t !== undefined && now - t <= TRACE_FADE;
};
const selectedNode = this.state.selection?.kind === 'node' ? this.state.selection.nodeId : null;
const target = this.dropTarget;
for (const view of this.views.values()) {
view.selected = view.node.id === selectedNode;
view.hot = isHot(trace?.nodes, view.node.id);
view.targetPort =
target && target.nodeId === view.node.id ? { port: target.port, isOutput: target.isOutput } : null;
view.redraw();
}
@@ -405,7 +491,8 @@ export class GraphCanvas extends Widget {
const p = view?.portPosition(this.connecting.from.port, this.connecting.from.isOutput);
if (view && p) {
const a = { x: view.x + p.x, y: view.y + p.y };
const b = this.connecting.pointer;
// Snapping to the resolved port makes the prediction the ring shows unmistakable.
const b = this.anchorPoint(target) ?? this.connecting.pointer;
const [from, to] = this.connecting.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 });
@@ -413,6 +500,14 @@ export class GraphCanvas extends Widget {
}
}
/** A port's centre in graph coordinates. */
private anchorPoint(side: PortSide | null): { x: number; y: number } | null {
if (!side) return null;
const view = this.views.get(side.nodeId);
const p = view?.portPosition(side.port, side.isOutput);
return view && p ? { x: view.x + p.x, y: view.y + p.y } : null;
}
private drawEdge(edge: GraphEdge, hot: boolean): void {
const from = this.views.get(edge.from.node);
const to = this.views.get(edge.to.node);
@@ -456,6 +551,11 @@ export class GraphCanvas extends Widget {
return { x: g.x, y: g.y };
}
/** The port a running edge drag would connect to, or null when the drop would do nothing. */
pendingDropTarget(): PortSide | null {
return this.dropTarget;
}
/** Global screen position of a node's title bar centre. */
nodeGlobal(nodeId: string): { x: number; y: number } | null {
const view = this.views.get(nodeId);
+9 -1
View File
@@ -12,7 +12,14 @@ export class NodePalette extends Popup {
/** Guards against a second pick per opening (e.g. Enter and a row click racing). */
private picked = false;
constructor(private readonly onPick: (def: NodeDef) => void) {
/**
* @param accepts restricts the list, e.g. to the node types an edge dropped into empty space
* could actually connect to.
*/
constructor(
private readonly onPick: (def: NodeDef) => void,
private readonly accepts: (def: NodeDef) => boolean = () => true,
) {
super({ padding: 4, gap: 4 });
this.minWidth = 240;
this.search = new TextField('', {
@@ -47,6 +54,7 @@ export class NodePalette extends Popup {
private refresh(): void {
const query = this.search.value.trim().toLowerCase();
this.filtered = allNodeDefs()
.filter(this.accepts)
.filter(
(d) =>
!query ||
+8 -1
View File
@@ -76,6 +76,8 @@ export class NodeView extends Widget {
private paramLines: string[] = [];
selected = false;
hot = false;
/** Port a running edge drag would connect to, drawn with a ring so the drop is predictable. */
targetPort: { port: string; isOutput: boolean } | null = null;
/** Data inputs that currently have a connection (their inline value is hidden). */
private connected = new Set<string>();
@@ -215,7 +217,12 @@ export class NodeView extends Widget {
this.ports.clear();
const r = PORT_RADIUS;
for (const { port, x, y } of this.portCentres()) {
for (const { port, isOutput, x, y } of this.portCentres()) {
if (this.targetPort?.port === port.key && this.targetPort.isOutput === isOutput) {
// A gap in the node colour first, so the ring stays visible on the white exec pin too.
this.ports.circle(x, y, r + 2).stroke({ width: 2, color: c.node });
this.ports.circle(x, y, r + 4).stroke({ width: 2, color: c.portTarget });
}
if (port.type === 'exec') {
// A pentagon pointing right: the Blueprint-style exec pin.
this.ports
+2
View File
@@ -59,6 +59,8 @@ export const Theme = {
portString: 0xe6a8ff,
portObject: 0x56ccf2,
portAny: 0xbdbdbd,
/** Ring around the port a running edge drag would connect to. */
portTarget: 0xffffff,
hot: 0xffc857,
},
} as const;
+114 -1
View File
@@ -11,7 +11,17 @@ import {
RemoveVariableCommand,
RenameVariableCommand,
} from '../../src/core/commands/variableCommands';
import { GraphInterpreter, allNodeDefs, findNode, portsCompatible } from '../../src/core/graph';
import {
GraphInterpreter,
allNodeDefs,
defAcceptsFrom,
dropTargetPort,
findNode,
firstPortFor,
type PortType,
portsCompatible,
requireNodeDef,
} from '../../src/core/graph';
import type { BehaviorAsset } from '../../src/core/model/Asset';
import { findAsset } from '../../src/core/model/Project';
import { deserializeProject, serializeProject } from '../../src/core/store/serialize';
@@ -352,3 +362,106 @@ describe('unknown node types', () => {
expect(behavior.graph.nodes).toHaveLength(0);
});
});
describe('dropping an edge on a node', () => {
/** A behavior with a number source and a Clamp, so a drag has several compatible inputs. */
function fixtureWithClamp() {
const fixture = makeProject();
const behavior = makeBehavior(fixture, 'B');
const edit = graphEditor(fixture, behavior);
return { graph: behavior.graph, edit, number: edit.addNode('Number'), clamp: edit.addNode('Clamp') };
}
it('picks the first compatible input', () => {
const { graph, number, clamp } = fixtureWithClamp();
const from = { nodeId: number.id, port: 'value', isOutput: true };
expect(dropTargetPort(graph, from, clamp.id)).toEqual({
nodeId: clamp.id,
port: 'value',
isOutput: false,
});
});
it('skips inputs that already hold an edge', () => {
const { graph, edit, number, clamp } = fixtureWithClamp();
const other = edit.addNode('Number');
edit.connect(other, 'value', clamp, 'value');
const from = { nodeId: number.id, port: 'value', isOutput: true };
expect(dropTargetPort(graph, from, clamp.id)?.port).toBe('min');
});
it('falls back to the last compatible input once every one is taken', () => {
const { graph, edit, number, clamp } = fixtureWithClamp();
for (const port of ['value', 'min', 'max']) edit.connect(edit.addNode('Number'), 'value', clamp, port);
const from = { nodeId: number.id, port: 'value', isOutput: true };
// Not free, but the drop still connects – it replaces the edge, as dropping on the port would.
expect(dropTargetPort(graph, from, clamp.id)?.port).toBe('max');
});
it('keeps offering an exec input, which is never exclusive', () => {
const fixture = makeProject();
const behavior = makeBehavior(fixture, 'B');
const edit = graphEditor(fixture, behavior);
const held = edit.addNode('OnKeyHeld');
const move = edit.addNode('SetPosition');
edit.connect(edit.addNode('OnKeyHeld'), 'exec', move, 'exec');
const from = { nodeId: held.id, port: 'exec', isOutput: true };
expect(dropTargetPort(behavior.graph, from, move.id)?.port).toBe('exec');
});
it('refuses a node that offers nothing compatible, and the node itself', () => {
const { graph, edit, number, clamp } = fixtureWithClamp();
const boolean = edit.addNode('Boolean');
const from = { nodeId: number.id, port: 'value', isOutput: true };
expect(dropTargetPort(graph, from, boolean.id)).toBeNull();
expect(dropTargetPort(graph, from, number.id)).toBeNull();
// A pair that is already wired is not offered again either.
edit.connect(number, 'value', clamp, 'value');
edit.connect(number, 'value', clamp, 'min');
edit.connect(number, 'value', clamp, 'max');
expect(dropTargetPort(graph, from, clamp.id)).toBeNull();
});
it('resolves a drag started at an input to an output', () => {
const { graph, number, clamp } = fixtureWithClamp();
const from = { nodeId: clamp.id, port: 'value', isOutput: false };
expect(dropTargetPort(graph, from, number.id)).toEqual({
nodeId: number.id,
port: 'value',
isOutput: true,
});
});
});
describe('the palette offered when an edge is dropped into empty space', () => {
const offers = (type: string, fromType: PortType, fromIsOutput: boolean) =>
defAcceptsFrom(requireNodeDef(type), fromType, fromIsOutput);
it('offers only nodes with a matching input when the drag started at an output', () => {
expect(offers('Move', 'exec', true)).toBe(true);
expect(offers('Clamp', 'number', true)).toBe(true);
// An exec output has no business at a data node, nor a number at a node without number inputs.
expect(offers('Number', 'exec', true)).toBe(false);
expect(offers('OnUpdate', 'number', true)).toBe(false);
});
it('looks at outputs instead when the drag started at an input', () => {
expect(offers('Number', 'number', false)).toBe(true);
expect(offers('OnUpdate', 'exec', false)).toBe(true);
expect(offers('Number', 'exec', false)).toBe(false);
expect(offers('StopGame', 'number', false)).toBe(false);
});
it('lets `any` match every data type, but never exec', () => {
expect(offers('Log', 'number', true)).toBe(true);
expect(offers('Log', 'string', true)).toBe(true);
expect(offers('ToText', 'exec', true)).toBe(false);
});
it('wires the picked node to its first compatible port', () => {
expect(firstPortFor(requireNodeDef('Clamp'), 'number', true)).toBe('value');
expect(firstPortFor(requireNodeDef('Move'), 'exec', true)).toBe('exec');
expect(firstPortFor(requireNodeDef('Number'), 'number', false)).toBe('value');
expect(firstPortFor(requireNodeDef('Number'), 'exec', true)).toBeNull();
});
});
@@ -0,0 +1,28 @@
1. wenn ich gerade einen edge dragge und er würde nicht ins leere gehen (sprich ein passender input/ouput gehovered) wird, dann soll der input/ouput gehightlighted werden
2. wenn ich auf das node selbst droppe, soll er das erste freie passende bzw. wenn keines mehr frei ist einfach das letzt passende input/out verbinunden - auch hier gilt die prediction vom highlight (sodass man wärend dem draggen schon sieht welches input/output angeschlossen wird falls man droppt)
EDIT:
3. Wenn ich ins leere droppe, soll sich das create node feld aufgehen und nach passenden nodes filtern die kompatibell sind (wenn von output weg, dann passende nodes vorschlagen die einen passenden input haben und umgekehrt)
---
**Umgesetzt (2026-09-09), alle drei Punkte.**
1. Der Port, auf dem die Kante landen wuerde, bekommt waehrend des Drags einen weissen Ring
(Theme-Token `portTarget`), und die gezogene Linie schnappt darauf — die Vorhersage ist damit
nicht nur farbig, sondern auch geometrisch eindeutig.
2. Drop auf den Node-Koerper nimmt den ersten **freien** passenden Port; ist keiner mehr frei, den
letzten passenden, dessen Kante dann ersetzt wird (genau wie ein Drop auf den Port selbst).
Der Ring zeigt waehrend des Drags schon genau diesen Port.
3. Drop ins Leere oeffnet die Palette, gefiltert auf die Node-Typen, die diese Kante ueberhaupt
erreichen kann (vom Output aus: passender Input, vom Input aus: passender Output; `exec` passt
nur zu `exec`, `any` zu jedem Datentyp). Der gewaehlte Node kommt fertig verdrahtet an.
Die Entscheidung "was passt hier zusammen" sitzt komplett in `src/core/graph/dropTarget.ts` (rein,
getestet); der Canvas zeichnet sie nur. Zwei Dinge, die beim Bauen dazukamen:
- **Node und Kante gehen als ein `CompoundCommand` rein.** Zwei getrennte Kommandos haetten fuer
eine Geste zwei Undos gebraucht.
- **`firstPortFor` statt `dropTargetPort` beim Palette-Weg**: der neue Node ist noch gar nicht im
Graph, `dropTargetPort` koennte ihn also nicht finden. Er hat noch keine Kanten, also ist der
erste passende Port genau der, den `dropTargetPort` spaeter waehlen wuerde.
@@ -1,2 +0,0 @@
1. wenn ich gerade einen edge dragge und er würde nicht ins leere gehen (sprich ein passender input/ouput gehovered) wird, dann soll der input/ouput gehightlighted werden
2. wenn ich auf das node selbst droppe, soll er das erste freie passende bzw. wenn keines mehr frei ist einfach das letzt passende input/out verbinunden - auch hier gilt die prediction vom highlight (sodass man wärend dem draggen schon sieht welches input/output angeschlossen wird falls man droppt)
+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');