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 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}
<!-- gallery inspector: DRAG to pan; zoom with the toolbar; Fit/Esc to exit -->
<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">
<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" />
@@ -281,7 +287,8 @@
<div class="lb-tools" role="group" aria-label="Zoom controls">
<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}
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>
<span class="lb-pct">{Math.round(zoomLevel * 100)}%</span>
<button class="lb-fit" onclick={fit}>Fit</button>