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');