art: drag-to-pan the zoomed artwork (persistent position; zoom holds the spot)
Per request: the inspector now pans only while the mouse button is held (grab/grabbing), using a persistent translate rather than cursor-follow — so you place a detail where you want it and it stays put. Zooming (slider/±/arrows) scales the translate by the same ratio, keeping the viewport-centred spot fixed so you can keep magnifying that exact area without it recentering. Pan is clamped to the image bounds (pointer-capture drag); 1× recenters. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -28,27 +28,54 @@
|
|||||||
let landscape = $state(false);
|
let landscape = $state(false);
|
||||||
|
|
||||||
// Desktop zoom: a gallery inspector inside the lightbox. Variable 1×–4× via a floating
|
// Desktop zoom: a gallery inspector inside the lightbox. Variable 1×–4× via a floating
|
||||||
// toolbar (− / slider / + / % / Fit); moving the cursor pans (transform-origin tracks the
|
// toolbar (− / slider / + / % / Fit); DRAG the artwork to pan (translate persists), so you
|
||||||
// pointer). Mobile keeps native pinch — the Zoom button + toolbar are hidden there.
|
// can place a spot and keep zooming into it — zoom holds the viewport-centred point fixed.
|
||||||
|
// Mobile keeps native pinch — the Zoom button + toolbar are hidden there.
|
||||||
let zoomed = $state(false);
|
let zoomed = $state(false);
|
||||||
let zoomLevel = $state(1.5); // scale factor; enter at a gentle 1.5×
|
let zoomLevel = $state(1.5); // scale factor; enter at a gentle 1.5×
|
||||||
let ox = $state(50), oy = $state(50); // transform-origin (%) — the panned-to point
|
let tx = $state(0), ty = $state(0); // pan offset in px (translate)
|
||||||
function enterZoom() { zoomLevel = 1.5; ox = 50; oy = 50; zoomed = true; }
|
let dragging = $state(false);
|
||||||
function fit() { zoomed = false; } // back to the framed gallery view
|
let zoomBoxEl, zoomImgEl; // refs for clamping pan to the image bounds
|
||||||
function setZoom(v) { zoomLevel = Math.max(1, Math.min(4, Math.round(v * 10) / 10)); }
|
let lastX = 0, lastY = 0;
|
||||||
function panZoom(e) {
|
|
||||||
const r = e.currentTarget.getBoundingClientRect();
|
function enterZoom() { zoomLevel = 1.5; tx = 0; ty = 0; zoomed = true; }
|
||||||
ox = Math.max(0, Math.min(100, ((e.clientX - r.left) / r.width) * 100));
|
function fit() { zoomed = false; } // back to the framed gallery view
|
||||||
oy = Math.max(0, Math.min(100, ((e.clientY - r.top) / r.height) * 100));
|
function clampPan() {
|
||||||
|
if (!zoomImgEl || !zoomBoxEl) return;
|
||||||
|
const maxX = Math.max(0, (zoomImgEl.offsetWidth * zoomLevel - zoomBoxEl.clientWidth) / 2);
|
||||||
|
const maxY = Math.max(0, (zoomImgEl.offsetHeight * zoomLevel - zoomBoxEl.clientHeight) / 2);
|
||||||
|
tx = Math.max(-maxX, Math.min(maxX, tx));
|
||||||
|
ty = Math.max(-maxY, Math.min(maxY, ty));
|
||||||
|
}
|
||||||
|
function setZoom(v) {
|
||||||
|
const z = Math.max(1, Math.min(4, Math.round(v * 10) / 10));
|
||||||
|
if (z === zoomLevel) return;
|
||||||
|
const ratio = z / zoomLevel; // keep the centred spot fixed as we zoom
|
||||||
|
tx *= ratio; ty *= ratio; zoomLevel = z;
|
||||||
|
clampPan();
|
||||||
|
}
|
||||||
|
function dragStart(e) {
|
||||||
|
dragging = true; lastX = e.clientX; lastY = e.clientY;
|
||||||
|
e.currentTarget.setPointerCapture?.(e.pointerId);
|
||||||
|
}
|
||||||
|
function dragMove(e) {
|
||||||
|
if (!dragging) return;
|
||||||
|
tx += e.clientX - lastX; ty += e.clientY - lastY;
|
||||||
|
lastX = e.clientX; lastY = e.clientY;
|
||||||
|
clampPan();
|
||||||
|
}
|
||||||
|
function dragEnd(e) {
|
||||||
|
dragging = false;
|
||||||
|
e.currentTarget.releasePointerCapture?.(e.pointerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
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; // 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; } });
|
$effect(() => { if (!zoom) { zoomed = false; 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(); });
|
||||||
|
|
||||||
@@ -244,14 +271,17 @@
|
|||||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18" /></svg>
|
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18" /></svg>
|
||||||
</button>
|
</button>
|
||||||
{#if zoomed}
|
{#if zoomed}
|
||||||
<!-- gallery inspector: move the cursor 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" onpointermove={panZoom} role="img" aria-label="{art.title}, magnified">
|
<div class="lb-zoom" class:dragging bind:this={zoomBoxEl}
|
||||||
<img src={art.image_url_large || art.image_url} alt={art.title}
|
onpointerdown={dragStart} onpointermove={dragMove} onpointerup={dragEnd} onpointerleave={dragEnd}
|
||||||
style="transform:scale({zoomLevel});transform-origin:{ox}% {oy}%" draggable="false" />
|
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" />
|
||||||
</div>
|
</div>
|
||||||
<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" bind:value={zoomLevel} aria-label="Zoom level" />
|
<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" />
|
||||||
<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>
|
||||||
@@ -555,10 +585,11 @@
|
|||||||
/* magnified inspection view (desktop): cursor pans via transform-origin */
|
/* magnified inspection view (desktop): cursor pans via transform-origin */
|
||||||
.lb-zoom {
|
.lb-zoom {
|
||||||
position: relative; z-index: 1; width: 96vw; height: 88vh; overflow: hidden;
|
position: relative; z-index: 1; width: 96vw; height: 88vh; overflow: hidden;
|
||||||
cursor: move; display: flex; align-items: center; justify-content: center;
|
cursor: grab; display: flex; align-items: center; justify-content: center; touch-action: none;
|
||||||
}
|
}
|
||||||
|
.lb-zoom.dragging { cursor: grabbing; }
|
||||||
.lb-zoom img {
|
.lb-zoom img {
|
||||||
max-width: 100%; max-height: 100%; width: auto; height: auto; object-fit: contain;
|
max-width: 100%; max-height: 100%; width: auto; height: auto;
|
||||||
will-change: transform; user-select: none; -webkit-user-drag: none;
|
will-change: transform; user-select: none; -webkit-user-drag: none;
|
||||||
}
|
}
|
||||||
/* floating gallery-inspector toolbar — a quiet translucent dark pill, white controls */
|
/* floating gallery-inspector toolbar — a quiet translucent dark pill, white controls */
|
||||||
|
|||||||
Reference in New Issue
Block a user