art: desktop zoom in the lightbox (pan-by-cursor), mobile keeps pinch
The full-screen lightbox showed the framed piece capped at ~66vh, so on desktop it was barely larger than the page view and there was no way to inspect detail (mobile can pinch). Add a "Zoom in" affordance: it swaps to a magnified inspection view (full-res image scaled 2.5×) where moving the cursor pans via transform-origin; click or Escape steps back to the framed view, Escape/✕/backdrop close. Restructured the lightbox from a single <button> to a dialog (backdrop button + close button + stage) so the controls are valid/accessible. Zoom button hidden on touch (hover:none) — native pinch covers mobile. Uses the already-cached full-res copy (/api/art/image/<id>?size=full); fade-in, frame/thickness, rotate-on-portrait all preserved. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -27,9 +27,25 @@
|
|||||||
// screens; desktop and portrait art stay upright). Aspect is read off the loaded image.
|
// screens; desktop and portrait art stay upright). Aspect is read off the loaded image.
|
||||||
let landscape = $state(false);
|
let landscape = $state(false);
|
||||||
|
|
||||||
function onKey(e) {
|
// Desktop zoom: inside the lightbox, magnify the artwork and pan by moving the cursor
|
||||||
if (e.key === 'Escape' && zoom) zoom = false;
|
// (transform-origin tracks the pointer). Mobile keeps native pinch — the Zoom button is
|
||||||
|
// hidden there. ox/oy are the transform-origin in %.
|
||||||
|
let zoomed = $state(false);
|
||||||
|
let ox = $state(50), oy = $state(50);
|
||||||
|
function enterZoom() { ox = 50; oy = 50; zoomed = true; }
|
||||||
|
function panZoom(e) {
|
||||||
|
const r = e.currentTarget.getBoundingClientRect();
|
||||||
|
ox = Math.max(0, Math.min(100, ((e.clientX - r.left) / r.width) * 100));
|
||||||
|
oy = Math.max(0, Math.min(100, ((e.clientY - r.top) / r.height) * 100));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onKey(e) {
|
||||||
|
if (e.key !== 'Escape') return;
|
||||||
|
if (zoomed) zoomed = false; // Escape steps out of zoom first, then closes
|
||||||
|
else if (zoom) zoom = false;
|
||||||
|
}
|
||||||
|
// Leaving the lightbox always drops zoom too, so re-opening starts framed.
|
||||||
|
$effect(() => { if (!zoom) zoomed = false; });
|
||||||
// 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(); });
|
||||||
|
|
||||||
@@ -218,15 +234,33 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if zoom && art}
|
{#if zoom && art}
|
||||||
<button class="lightbox" class:rotate={landscape} bind:this={lightboxEl} onclick={() => (zoom = false)} aria-label="Close artwork">
|
<div class="lightbox" class:rotate={landscape && !zoomed} class:zoomed bind:this={lightboxEl}
|
||||||
<span class="lb-stage">
|
tabindex="-1" role="dialog" aria-modal="true" aria-label="{art.title}, full screen">
|
||||||
<span class="frame frame--{frame} lb-frame" style="--frame-scale:{thickness}">
|
<button class="lb-backdrop" onclick={() => (zoom = false)} aria-label="Close artwork"></button>
|
||||||
{#if isWood}{@render woodRails()}{/if}
|
<button class="lb-close" onclick={() => (zoom = false)} aria-label="Close">
|
||||||
<span class="mat"><img src={art.image_url_large || art.image_url} alt={art.title} /></span>
|
<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>
|
||||||
|
{#if zoomed}
|
||||||
|
<!-- magnified inspection view: move the cursor to pan, click to zoom back out -->
|
||||||
|
<button class="lb-zoom" onpointermove={panZoom} onclick={() => (zoomed = false)} aria-label="Zoom out">
|
||||||
|
<img src={art.image_url_large || art.image_url} alt={art.title}
|
||||||
|
style="transform-origin:{ox}% {oy}%" draggable="false" />
|
||||||
|
</button>
|
||||||
|
<span class="lb-hint">Move to look closer · click to zoom out</span>
|
||||||
|
{:else}
|
||||||
|
<span class="lb-stage">
|
||||||
|
<span class="frame frame--{frame} lb-frame" style="--frame-scale:{thickness}">
|
||||||
|
{#if isWood}{@render woodRails()}{/if}
|
||||||
|
<span class="mat"><img src={art.image_url_large || art.image_url} alt={art.title} /></span>
|
||||||
|
</span>
|
||||||
|
<span class="lb-cap">{art.title}{#if art.artist}<span class="sep">·</span>{art.artist}{/if}</span>
|
||||||
|
<button class="lb-zoombtn" onclick={enterZoom}>
|
||||||
|
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="7" /><path d="M21 21l-3.5-3.5M11 8v6M8 11h6" /></svg>
|
||||||
|
Zoom in
|
||||||
|
</button>
|
||||||
</span>
|
</span>
|
||||||
<span class="lb-cap">{art.title}{#if art.artist}<span class="sep">·</span>{art.artist}{/if}</span>
|
{/if}
|
||||||
</span>
|
</div>
|
||||||
</button>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -491,20 +525,48 @@
|
|||||||
.note { color: var(--muted); font-size: 1.05rem; margin-top: 40px; }
|
.note { color: var(--muted); font-size: 1.05rem; margin-top: 40px; }
|
||||||
|
|
||||||
.lightbox {
|
.lightbox {
|
||||||
position: fixed; inset: 0; z-index: 50; border: none; cursor: zoom-out;
|
position: fixed; inset: 0; z-index: 50;
|
||||||
/* A soft, top-lit gallery wall — lighter than the page so every frame (Black
|
/* A soft, top-lit gallery wall — lighter than the page so every frame (Black
|
||||||
included) reads, like a piece hung on a real wall. */
|
included) reads, like a piece hung on a real wall. */
|
||||||
background: linear-gradient(180deg, #efe9dd, #e2dbcc);
|
background: linear-gradient(180deg, #efe9dd, #e2dbcc);
|
||||||
display: flex; flex-direction: column;
|
display: flex; flex-direction: column;
|
||||||
align-items: center; justify-content: center; padding: 2.5vmin;
|
align-items: center; justify-content: center; padding: 2.5vmin;
|
||||||
}
|
}
|
||||||
|
/* full-bleed click target behind the content — clicking the wall closes */
|
||||||
|
.lb-backdrop { position: absolute; inset: 0; z-index: 0; border: none; background: none; padding: 0; cursor: zoom-out; }
|
||||||
|
.lb-close {
|
||||||
|
position: absolute; top: clamp(10px, 2vw, 20px); right: clamp(10px, 2vw, 20px); z-index: 3;
|
||||||
|
width: 40px; height: 40px; border-radius: 999px; border: 1px solid rgba(40, 30, 20, 0.12);
|
||||||
|
background: rgba(255, 255, 255, 0.72); color: #3a3a3a; cursor: pointer;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.lb-close:hover { background: #fff; }
|
||||||
/* frame + caption travel together so a rotated view turns as one piece */
|
/* frame + caption travel together so a rotated view turns as one piece */
|
||||||
.lb-stage { display: flex; flex-direction: column; align-items: center; gap: 12px; }
|
.lb-stage { position: relative; z-index: 1; display: flex; flex-direction: column; align-items: center; gap: 12px; }
|
||||||
|
/* magnified inspection view (desktop): cursor pans via transform-origin */
|
||||||
|
.lb-zoom {
|
||||||
|
position: relative; z-index: 1; width: 96vw; height: 88vh; overflow: hidden;
|
||||||
|
border: none; background: none; padding: 0; cursor: zoom-out;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.lb-zoom img {
|
||||||
|
max-width: 100%; max-height: 100%; width: auto; height: auto; object-fit: contain;
|
||||||
|
transform: scale(2.5); will-change: transform; user-select: none; -webkit-user-drag: none;
|
||||||
|
}
|
||||||
|
.lb-hint { position: relative; z-index: 1; margin-top: 10px; color: #5b636e; font-size: 0.85rem; }
|
||||||
|
.lb-zoombtn {
|
||||||
|
display: inline-flex; align-items: center; gap: 7px; cursor: pointer;
|
||||||
|
border: 1px solid #d9cdeb; background: #fff; color: var(--accent);
|
||||||
|
border-radius: 999px; padding: 8px 16px; font-family: inherit; font-size: 0.9rem; font-weight: 600;
|
||||||
|
transition: background 0.14s ease, color 0.14s ease;
|
||||||
|
}
|
||||||
|
.lb-zoombtn:hover { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||||||
|
@media (hover: none) { .lb-zoombtn { display: none; } } /* mobile keeps native pinch-zoom */
|
||||||
/* The full-screen view wears the same frame. Rail + mat are only a touch larger than the
|
/* The full-screen view wears the same frame. Rail + mat are only a touch larger than the
|
||||||
page (keeping the ~1:1 wood:white proportion), and the image is capped so the whole
|
page (keeping the ~1:1 wood:white proportion), and the image is capped so the whole
|
||||||
framed piece — including the bottom rail — always fits on screen. */
|
framed piece — including the bottom rail — always fits on screen. */
|
||||||
.lb-frame {
|
.lb-frame {
|
||||||
cursor: zoom-out; max-width: 92vw;
|
max-width: 92vw;
|
||||||
--rail: calc(clamp(18px, 1.9vw, 36px) * var(--frame-scale, 1));
|
--rail: calc(clamp(18px, 1.9vw, 36px) * var(--frame-scale, 1));
|
||||||
--mat: calc(clamp(16px, 1.6vw, 30px) * min(var(--frame-scale, 1), 1.5));
|
--mat: calc(clamp(16px, 1.6vw, 30px) * min(var(--frame-scale, 1), 1.5));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user