art: harden zoom pointer lifecycle (Codex sign-off)

- 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 <noreply@anthropic.com>
This commit is contained in:
jay
2026-06-30 16:45:03 -04:00
parent 494e9dfcdd
commit c42f29537b
+16 -9
View File
@@ -36,10 +36,10 @@
let tx = $state(0), ty = $state(0); // pan offset in px (translate) let tx = $state(0), ty = $state(0); // pan offset in px (translate)
let dragging = $state(false); let dragging = $state(false);
let zoomBoxEl, zoomImgEl; // refs for clamping pan to the image bounds 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 enterZoom() { zoomLevel = 1.5; tx = 0; ty = 0; dragging = false; zoomed = true; }
function fit() { zoomed = false; } // back to the framed gallery view function fit() { dragging = false; zoomed = false; } // back to the framed gallery view
function clampPan() { function clampPan() {
if (!zoomImgEl || !zoomBoxEl) return; if (!zoomImgEl || !zoomBoxEl) return;
const maxX = Math.max(0, (zoomImgEl.offsetWidth * zoomLevel - zoomBoxEl.clientWidth) / 2); const maxX = Math.max(0, (zoomImgEl.offsetWidth * zoomLevel - zoomBoxEl.clientWidth) / 2);
@@ -55,7 +55,8 @@
clampPan(); clampPan();
} }
function dragStart(e) { 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); e.currentTarget.setPointerCapture?.(e.pointerId);
} }
function dragMove(e) { function dragMove(e) {
@@ -64,18 +65,22 @@
lastX = e.clientX; lastY = e.clientY; lastX = e.clientX; lastY = e.clientY;
clampPan(); 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) { function dragEnd(e) {
dragging = false; 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) { function onKey(e) {
if (e.key !== 'Escape') return; 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; else if (zoom) zoom = false;
} }
// Leaving the lightbox always resets the inspector, so re-opening starts framed. // 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. // Move focus to the lightbox when it opens, so Escape/Enter work and focus is trapped sanely.
$effect(() => { if (zoom && lightboxEl) lightboxEl.focus(); }); $effect(() => { if (zoom && lightboxEl) lightboxEl.focus(); });
@@ -273,7 +278,8 @@
{#if zoomed} {#if zoomed}
<!-- gallery inspector: DRAG to pan; zoom with the toolbar; Fit/Esc to exit --> <!-- gallery inspector: DRAG to pan; zoom with the toolbar; Fit/Esc to exit -->
<div class="lb-zoom" class:dragging bind:this={zoomBoxEl} <div class="lb-zoom" class:dragging bind:this={zoomBoxEl}
onpointerdown={dragStart} onpointermove={dragMove} onpointerup={dragEnd} onpointerleave={dragEnd} onpointerdown={dragStart} onpointermove={dragMove} onpointerup={dragEnd}
onpointercancel={dragEnd} onlostpointercapture={dragEnd}
role="img" aria-label="{art.title}, magnified — drag to pan"> role="img" aria-label="{art.title}, magnified — drag to pan">
<img bind:this={zoomImgEl} src={art.image_url_large || art.image_url} alt={art.title} <img bind:this={zoomImgEl} src={art.image_url_large || art.image_url} alt={art.title}
style="transform:translate({tx}px,{ty}px) scale({zoomLevel})" draggable="false" /> style="transform:translate({tx}px,{ty}px) scale({zoomLevel})" draggable="false" />
@@ -281,7 +287,8 @@
<div class="lb-tools" role="group" aria-label="Zoom controls"> <div class="lb-tools" role="group" aria-label="Zoom controls">
<button class="lb-t" onclick={() => setZoom(zoomLevel - 0.1)} aria-label="Zoom out"></button> <button class="lb-t" onclick={() => setZoom(zoomLevel - 0.1)} aria-label="Zoom out"></button>
<input class="lb-slider" type="range" min="1" max="4" step="0.1" value={zoomLevel} <input class="lb-slider" type="range" min="1" max="4" step="0.1" value={zoomLevel}
oninput={(e) => setZoom(+e.currentTarget.value)} aria-label="Zoom level" /> oninput={(e) => setZoom(+e.currentTarget.value)}
aria-label="Zoom level" aria-valuetext="{Math.round(zoomLevel * 100)} percent" />
<button class="lb-t" onclick={() => setZoom(zoomLevel + 0.1)} aria-label="Zoom in">+</button> <button class="lb-t" onclick={() => setZoom(zoomLevel + 0.1)} aria-label="Zoom in">+</button>
<span class="lb-pct">{Math.round(zoomLevel * 100)}%</span> <span class="lb-pct">{Math.round(zoomLevel * 100)}%</span>
<button class="lb-fit" onclick={fit}>Fit</button> <button class="lb-fit" onclick={fit}>Fit</button>