From 4b3e587f6cf6129b84448162172b2ea91326b276 Mon Sep 17 00:00:00 2001 From: "claude (blind_chess)" Date: Mon, 18 May 2026 20:32:20 -0400 Subject: [PATCH] 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) --- .../src/lib/stores/phantom-drag.svelte.ts | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/packages/client/src/lib/stores/phantom-drag.svelte.ts b/packages/client/src/lib/stores/phantom-drag.svelte.ts index d2f9756..572b0e5 100644 --- a/packages/client/src/lib/stores/phantom-drag.svelte.ts +++ b/packages/client/src/lib/stores/phantom-drag.svelte.ts @@ -27,6 +27,12 @@ function makeDrag() { let suppressClickOn: Square | null = null; const THRESHOLD = 6; + function detach() { + window.removeEventListener('pointermove', onMove); + window.removeEventListener('pointerup', onUp); + window.removeEventListener('pointercancel', onCancel); + } + function onMove(e: PointerEvent) { if (!state.active) return; 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) { - window.removeEventListener('pointermove', onMove); - window.removeEventListener('pointerup', onUp); + detach(); const src = state.active; const wasDrag = state.moved; state.active = null; state.moved = false; 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 sqEl = el?.closest('[data-square]') as HTMLElement | null; const target = sqEl?.dataset.square as Square | undefined; @@ -61,6 +75,7 @@ function makeDrag() { } function start(src: DragSource, e: PointerEvent) { + detach(); // idempotency — drop any listeners from an unfinished prior drag suppressClickOn = null; state.active = src; state.x = startX = e.clientX; @@ -68,6 +83,7 @@ function makeDrag() { state.moved = false; window.addEventListener('pointermove', onMove); window.addEventListener('pointerup', onUp); + window.addEventListener('pointercancel', onCancel); } /** The board calls this first in its square-click handler. */