From 8499f388af8c1b8a1361825589acbc570fd865a5 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sat, 4 Jul 2026 19:08:48 +0200 Subject: [PATCH] fix(ui): pan the zoomed board via window listeners, not pointer capture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mouse drag-to-pan of the zoomed board captured the pointer on the scroll container (vp.setPointerCapture) and wrote that same element's scrollLeft/Top. On an older Chromium — the Chrome 74 Android 10 System WebView — capturing the pointer on the very element being scrolled drops or displaces the capture as the scroll offset changes mid-drag, so the board juddered under the cursor and would not pan (modern engines cope). Track the drag with window pointermove/up/cancel listeners, added on pointerdown and removed on release, instead of capturing — the same "keep receiving moves past the element edge" without holding the scroll container. The path is mouse-only (touch scrolls the overflow viewport natively) and its behaviour on modern engines is unchanged; svelte-check and the mock e2e (incl. the zoom specs) are green. Speculative, not locally reproducible (needs Chrome 74): pending the owner's on-device confirmation. The unrelated auto-zoom-on-placement gap on the same engine is left as-is (minor). --- ui/src/game/Board.svelte | 44 +++++++++++++++++++++++----------------- 1 file changed, 25 insertions(+), 19 deletions(-) diff --git a/ui/src/game/Board.svelte b/ui/src/game/Board.svelte index 3e94fa3..0428c5d 100644 --- a/ui/src/game/Board.svelte +++ b/ui/src/game/Board.svelte @@ -228,16 +228,11 @@ let sy = 0; let sl = 0; let st = 0; - const onDown = (e: PointerEvent) => { - if (e.pointerType === 'touch' || e.button !== 0 || !zoomed) return; - if ((e.target as HTMLElement | null)?.closest('.cell.pending')) return; - armed = true; - panning = false; - sx = e.clientX; - sy = e.clientY; - sl = vp.scrollLeft; - st = vp.scrollTop; - }; + // Track the drag on window rather than vp.setPointerCapture. Capturing the pointer on the very + // element being scrolled makes an older Chromium — the Chrome 74 Android 10 System WebView — + // drop or displace the capture as scrollLeft/Top are written mid-drag, so the board judders and + // will not pan. Window listeners give the same "keep receiving moves past the element edge" + // without capturing the scroll container; modern engines behave identically. const onMove = (e: PointerEvent) => { if (!armed) return; const dx = e.clientX - sx; @@ -245,16 +240,17 @@ if (!panning) { if (Math.hypot(dx, dy) < 4) return; // a small move is still a click, not a pan panning = true; - vp.setPointerCapture(e.pointerId); } vp.scrollLeft = sl - dx; vp.scrollTop = st - dy; }; - const onUp = (e: PointerEvent) => { + const onUp = () => { + window.removeEventListener('pointermove', onMove); + window.removeEventListener('pointerup', onUp); + window.removeEventListener('pointercancel', onUp); armed = false; if (!panning) return; panning = false; - if (vp.hasPointerCapture(e.pointerId)) vp.releasePointerCapture(e.pointerId); // Swallow the click the drag would otherwise produce (capture phase, before it reaches the // cell); self-remove on that click, and clean up on the next tick if none fired. const swallow = (ev: Event) => { @@ -264,15 +260,25 @@ vp.addEventListener('click', swallow, true); setTimeout(() => vp.removeEventListener('click', swallow, true), 0); }; + const onDown = (e: PointerEvent) => { + if (e.pointerType === 'touch' || e.button !== 0 || !zoomed) return; + if ((e.target as HTMLElement | null)?.closest('.cell.pending')) return; + armed = true; + panning = false; + sx = e.clientX; + sy = e.clientY; + sl = vp.scrollLeft; + st = vp.scrollTop; + window.addEventListener('pointermove', onMove); + window.addEventListener('pointerup', onUp); + window.addEventListener('pointercancel', onUp); + }; vp.addEventListener('pointerdown', onDown); - vp.addEventListener('pointermove', onMove); - vp.addEventListener('pointerup', onUp); - vp.addEventListener('pointercancel', onUp); return () => { vp.removeEventListener('pointerdown', onDown); - vp.removeEventListener('pointermove', onMove); - vp.removeEventListener('pointerup', onUp); - vp.removeEventListener('pointercancel', onUp); + window.removeEventListener('pointermove', onMove); + window.removeEventListener('pointerup', onUp); + window.removeEventListener('pointercancel', onUp); }; });