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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user