From 14562cf1575e72a7d80afb98404a0982aa257b64 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 9 Sep 2026 12:42:40 +0200 Subject: [PATCH] Keep the dragged edge on screen while the palette picks its node Releasing an edge over empty space opened the node palette but dropped the edge drawing, so the connection being made was invisible exactly while its target was being chosen. The finished drag is now held in `pendingDrop` and drawn like a running one until the palette closes, by pick or by Escape. The e2e check for the Escape path exposed a real defect underneath: `Popup.open()` blurs the focused widget, `close()` did not. A field inside the popup therefore kept the keyboard bridge's DOM focus after the popup was gone, and every later Delete or Ctrl+Z went into that invisible input. The suite caught it one check later ("Delete removes the node and its edge" found the node still there). `close()` now blurs, mirroring `open()`. Verified remotely on the Windows host: 287 editor checks and 24 jump-and-run smoke checks, no failures. --- README.md | 5 ++-- src/editor/debugHook.ts | 1 + src/editor/graph/GraphCanvas.ts | 42 ++++++++++++++++++++++++++------- src/ui/widgets/Popup.ts | 3 +++ tools/e2e/06-graph.mjs | 25 ++++++++++++++++++++ 5 files changed, 65 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index f125c9c2..cbd646cb 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,8 @@ shown on the node and editable in the inspector. 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). + it). The dangling edge stays on screen while that palette is up, so the connection being made is + still visible while its node is chosen. - **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". @@ -415,7 +416,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 # 284 editor checks, screenshots in /tmp/gm-e2e-*.png +node tools/seed-e2e.mjs && node tools/e2e.mjs # 287 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 ``` diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index 86fac4c6..6ce94f0b 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -152,6 +152,7 @@ export function installDebugHook(app: Application, state: EditorState, editor: E graphEditor.canvas.portGlobal(nodeId, port, isOutput), nodeAt: (nodeId: string) => graphEditor.canvas.nodeGlobal(nodeId), dropTarget: () => graphEditor.canvas.pendingDropTarget(), + danglingEdge: () => graphEditor.canvas.danglingEdgeVisible(), // The canvas, not the whole tab: a centre measured through the header sits above the // drawing area. center: () => centreOf(graphEditor.canvas), diff --git a/src/editor/graph/GraphCanvas.ts b/src/editor/graph/GraphCanvas.ts index 959477d1..1fc18c3b 100644 --- a/src/editor/graph/GraphCanvas.ts +++ b/src/editor/graph/GraphCanvas.ts @@ -64,8 +64,13 @@ export class GraphCanvas extends Widget { moved: boolean; } | null = null; private connecting: { from: PortSide; pointer: { x: number; y: number } } | null = null; + /** A finished drag whose palette is still open – drawn like the running one, so the connection + * being made stays visible while the node type is chosen. */ + private pendingDrop: { 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; + /** Whether the last redraw actually stroked the dangling edge. */ + private tempEdgeShown = false; /** 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). */ @@ -298,7 +303,7 @@ export class GraphCanvas extends Widget { if (connecting && this.assetId) { 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.openPaletteForDrop(connecting, e.global.x, e.global.y); } this.redraw(); } @@ -319,16 +324,27 @@ export class GraphCanvas extends Widget { * 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 { + private openPaletteForDrop( + drag: { from: PortSide; pointer: { x: number; y: number } }, + globalX: number, + globalY: number, + ): void { const graph = this.asset()?.graph; + const from = drag.from; 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( + this.pendingDrop = drag; + const palette = new NodePalette( (def: NodeDef) => this.addConnectedNode(def, from, fromType, at), (def) => defAcceptsFrom(def, fromType, from.isOutput), - ).open(globalX, globalY); + ); + palette.onClose = () => { + this.pendingDrop = null; + this.redraw(); + }; + palette.open(globalX, globalY); } /** @@ -486,16 +502,19 @@ export class GraphCanvas extends Widget { for (const edge of asset.graph.edges) this.drawEdge(edge, isHot(trace?.edges, edge.id)); this.tempEdge.clear(); - if (this.connecting) { - const view = this.views.get(this.connecting.from.nodeId); - const p = view?.portPosition(this.connecting.from.port, this.connecting.from.isOutput); + this.tempEdgeShown = false; + const drag = this.connecting ?? this.pendingDrop; + if (drag) { + const view = this.views.get(drag.from.nodeId); + const p = view?.portPosition(drag.from.port, drag.from.isOutput); if (view && p) { const a = { x: view.x + p.x, y: view.y + p.y }; // 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]; + const b = this.anchorPoint(target) ?? drag.pointer; + const [from, to] = drag.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 }); + this.tempEdgeShown = true; } } } @@ -556,6 +575,11 @@ export class GraphCanvas extends Widget { return this.dropTarget; } + /** Whether the dangling edge is on screen right now (running drag, or a drop awaiting its node). */ + danglingEdgeVisible(): boolean { + return this.tempEdgeShown; + } + /** Global screen position of a node's title bar centre. */ nodeGlobal(nodeId: string): { x: number; y: number } | null { const view = this.views.get(nodeId); diff --git a/src/ui/widgets/Popup.ts b/src/ui/widgets/Popup.ts index 284be0bc..c726ce9d 100644 --- a/src/ui/widgets/Popup.ts +++ b/src/ui/widgets/Popup.ts @@ -90,6 +90,9 @@ export class Popup extends Widget { if (!this.isOpen) return; window.removeEventListener('keydown', this.onEscape, { capture: true }); ui().overlay.removeChild(this.backdrop, this); + // A field inside the popup would keep the keyboard bridge's DOM focus after the popup is gone, + // swallowing every later Delete or Ctrl+Z. Blurring here mirrors the blur in `open()`. + ui().focus.blur(); this.onClose?.(); } diff --git a/tools/e2e/06-graph.mjs b/tools/e2e/06-graph.mjs index da04d409..f5840159 100644 --- a/tools/e2e/06-graph.mjs +++ b/tools/e2e/06-graph.mjs @@ -179,6 +179,10 @@ export async function graph(t) { 'it lists a node that takes a number, not one that cannot', Boolean(await evaluate(`__gm.locate('Clamp')`)) && !(await evaluate(`__gm.locate('Restart game')`)), ); + check( + 'the dragged edge stays on screen while the palette decides', + (await json(`__gm.graph.danglingEdge()`)) === true, + ); await shot('18c-drop-into-empty'); const clampRow = await locate('Clamp'); await click(clampRow.x, clampRow.y); @@ -195,6 +199,10 @@ export async function graph(t) { clampEdge?.to.port === 'value', JSON.stringify({ afterDrop, clampEdge }), ); + check( + 'and the dangling edge gives way to the real one', + (await json(`__gm.graph.danglingEdge()`)) === false, + ); await undo(); const undone = await graphCounts(); check( @@ -203,6 +211,23 @@ export async function graph(t) { JSON.stringify(undone), ); + // Cancelling the palette has to take the dangling edge with it, or it would hang there forever. + 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 mouse('mouseReleased', empty.x, empty.y); + await sleep(150); + await press('Escape'); + await sleep(150); + const cancelled = await graphCounts(); + check( + 'escaping the palette clears the edge and adds nothing', + (await json(`__gm.graph.danglingEdge()`)) === false && + cancelled.nodes === beforeDrop.nodes && + cancelled.edges === beforeDrop.edges, + JSON.stringify(cancelled), + ); + // 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');