fix(client): handle pointercancel and make drag-start idempotent

Add onCancel handler for browser/OS gesture takeover (scroll, palm
rejection) that previously leaked window listeners and left drag stuck.
Extract detach() helper called by onUp, onCancel, and start() for
idempotency.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
claude (blind_chess)
2026-05-18 20:32:20 -04:00
parent f52f7dbb8f
commit 4b3e587f6c
@@ -27,6 +27,12 @@ function makeDrag() {
let suppressClickOn: Square | null = null; let suppressClickOn: Square | null = null;
const THRESHOLD = 6; const THRESHOLD = 6;
function detach() {
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
window.removeEventListener('pointercancel', onCancel);
}
function onMove(e: PointerEvent) { function onMove(e: PointerEvent) {
if (!state.active) return; if (!state.active) return;
state.x = e.clientX; state.x = e.clientX;
@@ -36,15 +42,23 @@ function makeDrag() {
} }
} }
// pointercancel fires instead of pointerup when the browser/OS takes over
// the gesture (common on touch). Abort the drag: clean up, drop nothing.
function onCancel() {
detach();
state.active = null;
state.moved = false;
}
function onUp(e: PointerEvent) { function onUp(e: PointerEvent) {
window.removeEventListener('pointermove', onMove); detach();
window.removeEventListener('pointerup', onUp);
const src = state.active; const src = state.active;
const wasDrag = state.moved; const wasDrag = state.moved;
state.active = null; state.active = null;
state.moved = false; state.moved = false;
if (!src || !wasDrag) return; // a tap — the board click handler deals with it if (!src || !wasDrag) return; // a tap — the board click handler deals with it
// elementFromPoint returns null off-viewport — treated as an off-board drop.
const el = document.elementFromPoint(e.clientX, e.clientY); const el = document.elementFromPoint(e.clientX, e.clientY);
const sqEl = el?.closest('[data-square]') as HTMLElement | null; const sqEl = el?.closest('[data-square]') as HTMLElement | null;
const target = sqEl?.dataset.square as Square | undefined; const target = sqEl?.dataset.square as Square | undefined;
@@ -61,6 +75,7 @@ function makeDrag() {
} }
function start(src: DragSource, e: PointerEvent) { function start(src: DragSource, e: PointerEvent) {
detach(); // idempotency — drop any listeners from an unfinished prior drag
suppressClickOn = null; suppressClickOn = null;
state.active = src; state.active = src;
state.x = startX = e.clientX; state.x = startX = e.clientX;
@@ -68,6 +83,7 @@ function makeDrag() {
state.moved = false; state.moved = false;
window.addEventListener('pointermove', onMove); window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp); window.addEventListener('pointerup', onUp);
window.addEventListener('pointercancel', onCancel);
} }
/** The board calls this first in its square-click handler. */ /** The board calls this first in its square-click handler. */