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