From c42f29537b7e9f12ad49df1a5420477fd51a32d5 Mon Sep 17 00:00:00 2001 From: jay Date: Tue, 30 Jun 2026 16:45:03 -0400 Subject: [PATCH] art: harden zoom pointer lifecycle (Codex sign-off) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reset dragging on every exit path (enterZoom, fit, Escape, lightbox-close effect) so a drag interrupted by Escape/Fit can't carry the grabbing state into the next session. - Drag ends on pointerup/pointercancel/lostpointercapture (dropped pointerleave, which fought the capture) so a drag genuinely continues outside the image. - dragStart guards e.button===0; track the captured pointerId and release only when hasPointerCapture() — no double-release throws. - a11y: slider aria-valuetext ("150 percent"). Co-Authored-By: Claude Opus 4.8 --- frontend/src/routes/art/+page.svelte | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/frontend/src/routes/art/+page.svelte b/frontend/src/routes/art/+page.svelte index c4988c3..54d150c 100644 --- a/frontend/src/routes/art/+page.svelte +++ b/frontend/src/routes/art/+page.svelte @@ -36,10 +36,10 @@ let tx = $state(0), ty = $state(0); // pan offset in px (translate) let dragging = $state(false); let zoomBoxEl, zoomImgEl; // refs for clamping pan to the image bounds - let lastX = 0, lastY = 0; + let lastX = 0, lastY = 0, activePointer = null; - function enterZoom() { zoomLevel = 1.5; tx = 0; ty = 0; zoomed = true; } - function fit() { zoomed = false; } // back to the framed gallery view + function enterZoom() { zoomLevel = 1.5; tx = 0; ty = 0; dragging = false; zoomed = true; } + function fit() { dragging = false; zoomed = false; } // back to the framed gallery view function clampPan() { if (!zoomImgEl || !zoomBoxEl) return; const maxX = Math.max(0, (zoomImgEl.offsetWidth * zoomLevel - zoomBoxEl.clientWidth) / 2); @@ -55,7 +55,8 @@ clampPan(); } function dragStart(e) { - dragging = true; lastX = e.clientX; lastY = e.clientY; + if (e.button !== 0) return; // primary button only + dragging = true; lastX = e.clientX; lastY = e.clientY; activePointer = e.pointerId; e.currentTarget.setPointerCapture?.(e.pointerId); } function dragMove(e) { @@ -64,18 +65,22 @@ lastX = e.clientX; lastY = e.clientY; clampPan(); } + // Ends drag on pointerup / pointercancel / lostpointercapture (NOT pointerleave, which + // fights the capture). Release only what we actually hold, so no double-release throws. function dragEnd(e) { dragging = false; - e.currentTarget.releasePointerCapture?.(e.pointerId); + const el = e.currentTarget; + if (activePointer !== null && el?.hasPointerCapture?.(activePointer)) el.releasePointerCapture(activePointer); + activePointer = null; } function onKey(e) { if (e.key !== 'Escape') return; - if (zoomed) zoomed = false; // Escape steps out of inspection first, then closes + if (zoomed) { zoomed = false; dragging = false; } // Escape steps out of inspection first, then closes else if (zoom) zoom = false; } // Leaving the lightbox always resets the inspector, so re-opening starts framed. - $effect(() => { if (!zoom) { zoomed = false; zoomLevel = 1.5; tx = 0; ty = 0; } }); + $effect(() => { if (!zoom) { zoomed = false; dragging = false; activePointer = null; zoomLevel = 1.5; tx = 0; ty = 0; } }); // Move focus to the lightbox when it opens, so Escape/Enter work and focus is trapped sanely. $effect(() => { if (zoom && lightboxEl) lightboxEl.focus(); }); @@ -273,7 +278,8 @@ {#if zoomed}