fix(canvas): a lost pointerup left the resize drag stuck to the cursor

Reported as "the resize is linked to my mouse and resizes as I move my mouse
around without holding, and can't be interrupted no matter what I do or press."

That is one state — `dragging` stuck true — and it was reachable whenever a
single `pointerup` was missed. Three things combined:

`setPointerCapture` reads like the right call and is what stranded the drag.
Capture retargets every later pointer event to the grip, INCLUDING `pointerup`.
The editor re-renders while it resizes, and a captured element that gets
replaced is detached — so the `pointerup` was delivered to a node no longer in
the tree and never bubbled to `window`. Removed: the listeners are on `window`,
which sees the events regardless of what is under the cursor, so capture bought
nothing here.

The listeners were in bubble phase, so a `stopPropagation()` anywhere between
the target and the window hid the release too. ComfyUI's node layer does exactly
that to stop the canvas panning under a widget. All three are capture phase now,
which fires before anything downstream can swallow them.

Nothing ever cleared `dragging` afterwards, which is the "can't be interrupted"
half — there was no path back to a stopped state at all. Now `pointerdown`,
`blur` and Escape all end a drag, and `onResizeMove` bails when `ev.buttons` is
0. That last one is the one that cannot be outsmarted: every pointermove reports
which buttons are down, so a move with none held means the release was lost
whatever swallowed it. It ends the state even for a cause we have not found.

Two existing tests drove a drag with a synthetic `PointerEvent` whose `buttons`
defaults to 0, which the new guard correctly reads as a release. They now pass
`buttons: 1`, which is what a held drag actually reports and what they meant to
simulate.

This is separate from bc8421fe. Those three fixes are in and correct — they
addressed the node fighting the editor over height. This is the drag never
ending, which no amount of height policy would have cured.
This commit is contained in:
DumiFlex
2026-08-07 17:19:33 +03:00
parent 09257471f8
commit 25527c37ff
2 changed files with 118 additions and 15 deletions
@@ -198,7 +198,7 @@ describe("useGrowableField — the drag itself", () => {
}) as DOMRect;
api.startResize(gripDown(500));
const move = (y: number) => window.dispatchEvent(
Object.assign(new MouseEvent("pointermove", { clientY: y }), { pointerId: 1 }),
Object.assign(new MouseEvent("pointermove", { clientY: y, buttons: 1 }), { pointerId: 1 }),
);
move(520);
expect(node.style.height).toBe("120px");
@@ -236,7 +236,7 @@ describe("useGrowableField — the drag itself", () => {
}) as DOMRect;
api.startResize(gripDown(500));
window.dispatchEvent(
Object.assign(new MouseEvent("pointermove", { clientY: 400 }), { pointerId: 1 }),
Object.assign(new MouseEvent("pointermove", { clientY: 400, buttons: 1 }), { pointerId: 1 }),
);
expect(node.style.height).toBe("34px");
wrap.unmount();
@@ -337,3 +337,79 @@ describe("useGrowableField — grip-follow only while a grip is held", () => {
scroller.remove();
});
});
describe("useGrowableField — a drag can always end", () => {
/**
* Reported symptom: "the resize is linked to my mouse and resizes as I move
* my mouse around without holding, and can't be interrupted no matter what I
* do or press."
*
* That is one state — `dragging` stuck true — reachable whenever a single
* `pointerup` is missed. It was reachable two ways: `setPointerCapture`
* retargeted every later pointer event to the grip, so a re-render that
* replaced that element delivered the `pointerup` to a detached node where it
* never bubbled; and the listeners sat in bubble phase, so any
* `stopPropagation()` in between hid it too. Nothing then ever cleared the
* flag, which is why no key or click could stop it.
*/
function beginDrag(api: ReturnType<typeof useGrowableField>) {
api.startResize({
clientY: 100, pointerId: 1, preventDefault() {}, currentTarget: null,
} as unknown as PointerEvent);
}
const move = (y: number, buttons: number) =>
window.dispatchEvent(
Object.assign(new Event("pointermove", { bubbles: true }), { clientY: y, buttons }),
);
it("stops resizing after a pointerup, even one that never reached the window", () => {
const { api, node, wrap } = mountField(100);
beginDrag(api);
move(140, 1);
const grown = node.style.height;
expect(grown).not.toBe("");
// The pointerup is simply never delivered — the stranded-capture case.
// The next move reports no buttons held, which is the only honest signal
// left that the drag is over.
move(300, 0);
const afterRelease = node.style.height;
move(500, 0);
expect(node.style.height).toBe(afterRelease);
wrap.unmount();
});
it("ends on a pointerup seen in capture phase", () => {
const { api, node, wrap } = mountField(100);
beginDrag(api);
move(140, 1);
const held = node.style.height;
window.dispatchEvent(new Event("pointerup", { bubbles: true }));
move(400, 1); // button still reported down, but the drag ended
expect(node.style.height).toBe(held);
wrap.unmount();
});
it("ends on Escape, the key a stuck user actually presses", () => {
const { api, node, wrap } = mountField(100);
beginDrag(api);
move(140, 1);
const held = node.style.height;
window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
move(400, 1);
expect(node.style.height).toBe(held);
wrap.unmount();
});
it("ends when the window loses focus", () => {
const { api, node, wrap } = mountField(100);
beginDrag(api);
move(140, 1);
const held = node.style.height;
window.dispatchEvent(new Event("blur"));
move(400, 1);
expect(node.style.height).toBe(held);
wrap.unmount();
});
});
+40 -13
View File
@@ -229,24 +229,51 @@ export function useGrowableField(
el.style.maxHeight = "none";
capLifted = true;
}
// Capture is an optimisation — it keeps move events coming when the
// pointer outruns the 16px grip — but it is allowed to fail, and it used
// to take the drag with it. `setPointerCapture` throws `NotFoundError`
// for any id with no active pointer, and this call sat BEFORE the
// listeners were attached, so one throw meant a grip that visibly did
// nothing. The listeners are on `window` and work without capture, so
// failing here should cost nothing.
try {
(ev.currentTarget as HTMLElement | null)?.setPointerCapture?.(ev.pointerId);
} catch { /* no active pointer for this id — listeners below still work */ }
window.addEventListener("pointermove", onResizeMove);
window.addEventListener("pointerup", onPointerUp);
window.addEventListener("pointercancel", onPointerUp);
/* No `setPointerCapture`.
*
* It reads like the right call and it is what stranded the drag. Capture
* retargets every subsequent pointer event to the grip — including
* `pointerup`. The editor re-renders while it resizes, and a captured
* element that gets replaced is detached: the `pointerup` is delivered to a
* node no longer in the tree, so it never bubbles to `window`, `dragging`
* stays true, and the box follows the cursor with no button held and no way
* to stop it. That is the reported symptom exactly.
*
* Capture bought nothing here anyway. The listeners below are on `window`,
* which sees the events regardless of what is under the cursor.
*
* CAPTURE PHASE on all three, so a `stopPropagation()` between the target
* and the window — ComfyUI's node layer does this to stop the canvas
* panning under a widget — cannot hide the end of the drag from us.
*/
window.addEventListener("pointermove", onResizeMove, true);
window.addEventListener("pointerup", onPointerUp, true);
window.addEventListener("pointercancel", onPointerUp, true);
// A fresh press means the previous drag is over however it ended.
window.addEventListener("pointerdown", onPointerUp, true);
window.addEventListener("blur", onPointerUp);
window.addEventListener("keydown", onDragEscape, true);
}
/** Escape ends a drag. Cheap, and it is the key a stuck user presses. */
function onDragEscape(ev: KeyboardEvent): void {
if (ev.key === "Escape") onPointerUp();
}
function onResizeMove(ev: PointerEvent): void {
const el = getEl();
if (!el || !dragging) return;
// Self-heal. Every pointermove reports which buttons are down, so a move
// with none held means the pointerup was lost — whatever swallowed it —
// and the drag should already be over. Without this the field follows the
// cursor forever and no key or click can stop it, because nothing else in
// here ever clears `dragging`. Belt and braces on top of the listener
// changes above: those address the causes we found, this ends the state
// regardless of a cause we did not.
if (ev.buttons === 0) {
onPointerUp();
return;
}
const dy = ev.clientY - lastPointerY;
lastPointerY = ev.clientY;
const cur = el.getBoundingClientRect().height;