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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user