An edge to a vanished port needs a handle, so the port stays as a ghost (#60)
game-maker / suite (push) Successful in 10m7s

Four ways erase a derived port and take no edge with them: deleting a state,
deleting the `State machine` literal, deleting `Behavior start`/`end`, deleting
the asset. The common cause is `RemoveNodeCommand` clearing only its own graph,
so the fix sits in `drawnDef` (graph/nested.ts) and touches no delete site.

A right-click on an edge goes through `view.portAt` only — there is no curve
hit-test. Without a port row such an edge is not merely invisible, it is
unreachable, so the ghost row is the handle Disconnect hangs on.

`resolveDef` stays untouched: it is the truth for interpreter, compiler,
ConnectCommand and dropTarget, so nothing runs or connects through a ghost
without a second guard.
This commit is contained in:
root committed 2026-09-14 11:50:36 +02:00
1 parent d5e67cd80c
commit a041322a0e
8 files changed
+372 -16

No files matched your search

+13 -6
View File
@@ -13,10 +13,12 @@ import {
callProblem,
canConnect,
defAcceptsFrom,
drawnDef,
dropTargetPort,
edgesInto,
edgesOutOf,
firstPortFor,
ghostProblem,
type Graph,
type GraphNode,
type GraphLookup,
@@ -229,8 +231,9 @@ export class GraphCanvas extends Widget {
? particleLibrary.buildFor(asset).problems
: null;
for (const node of asset.graph.nodes) {
const def = resolveDef(node, asset.graph, lookup);
if (!def) continue;
const resolved = resolveDef(node, asset.graph, lookup);
if (!resolved) continue;
const def = drawnDef(node, asset.graph, resolved);
seen.add(node.id);
let view = this.views.get(node.id);
if (!view || view.node !== node) {
@@ -240,9 +243,9 @@ export class GraphCanvas extends Widget {
this.views.set(node.id, view);
this.nodeLayer.addChild(view);
}
const problem = compilerProblems
? (compilerProblems[node.id] ?? null)
: callProblem(node, lookup, asset.id);
const problem =
(compilerProblems ? (compilerProblems[node.id] ?? null) : callProblem(node, lookup, asset.id)) ??
ghostProblem(node, asset.graph, resolved);
view.sync(node, asset.graph, this.state.project, def, problem);
}
for (const [id, view] of this.views) {
@@ -358,6 +361,9 @@ export class GraphCanvas extends Widget {
return;
}
if (e.button !== 0) return;
// A ghost pin is a handle for the right-click menu only: nothing to drag from, and dragging the
// node instead would make a press on the very edge of it move the whole node.
if (hit?.port.ghost) return;
if (hit) {
const p = this.toLocal(e.global);
this.connecting = {
@@ -595,7 +601,8 @@ export class GraphCanvas extends Widget {
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 };
// A ghost is no target, and it must not shadow the node body underneath it either.
if (hit && !hit.port.ghost) return { nodeId: view.node.id, port: hit.port.key, isOutput: hit.isOutput };
}
return null;
}