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.
This commit is contained in:
1 parent
ebad3fd530
commit
14562cf157
5 files changed
+65
-11
No files matched your search
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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?.();
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in new issue
Block a user