diff options
| author | Adam Soltys <[email protected]> | 2026-09-28 22:28:05 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-09-28 22:28:05 -0700 |
| commit | a31d97220c8acb858c71301972f38597c1eb2270 (patch) | |
| tree | 1fdacd2a9fa349e28158e1c26a0d1eea620d91ac /src | |
| parent | 2bbff9ac91bf310f63216523cb418474ca64db64 (diff) | |
| download | coinosv3-a31d97220c8acb858c71301972f38597c1eb2270.tar.gz coinosv3-a31d97220c8acb858c71301972f38597c1eb2270.zip | |
Pinch to zoom a full-screen picture.
Pinch zooms around the fingers (up to 5x), one finger pans a zoomed picture
within its edges, double-tap zooms in at a spot and again to reset, and
the wheel zooms on a desktop. A tap still closes an unzoomed picture (after
a beat, so it can be the first half of a double-tap); a gesture never does.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Diffstat (limited to 'src')
| -rw-r--r-- | src/app.js | 102 | ||||
| -rw-r--r-- | src/style.css | 2 |
2 files changed, 102 insertions, 2 deletions
@@ -561,12 +561,110 @@ setTimeout(() => { if (_bootDeciding) { _bootDeciding = false; render(); } }, 40 function imageViewer() { if (!ui.lightbox) return null; const close = () => goBack(() => { ui.lightbox = null; }); - return h('div', { + // a tap on the picture closes it too (the × is small and far) — unless + // it's zoomed, or the tap is half of a double-tap (see attachZoom) + const img = h('img', { src: ui.lightbox, alt: '', draggable: 'false' }); + const box = h('div', { class: 'lightbox', onClick: close, role: 'dialog', 'aria-modal': 'true', - }, h('img', { src: ui.lightbox, alt: '', onClick: (e) => { e.stopPropagation(); close(); } }), // a tap on the picture closes it too: the × is small and far + }, img, // stop the bubble: the backdrop closes too, and two pops walk out of the room h('button', { class: 'lightbox-x', 'aria-label': t('close'), onClick: (e) => { e.stopPropagation(); close(); } }, '\u00d7')); + attachZoom(box, img, close); + return box; +} +// Pinch to zoom (up to 5×) around the fingers, drag to pan a zoomed picture, +// double-tap (or double-click) to zoom in at a spot and again to reset, the +// wheel to zoom on a desktop. The page's viewport meta turns browser zoom +// off, so the picture does its own. Only the first-mounted nodes keep these +// listeners: later renders morph into them, and the img is left out of the +// morph so its transform survives a background render. +function attachZoom(box, img, close) { + img._skipMorph = true; + const MAX = 5, pts = new Map(); + let s = 1, x = 0, y = 0, pinch = null, pan = null, moved = false, lastTap = 0, tapTimer = 0; + const base = () => ({ bx: img.offsetLeft + img.offsetWidth / 2, by: img.offsetTop + img.offsetHeight / 2 }); + const clamp = () => { + if (s <= 1) { s = 1; x = 0; y = 0; return; } + const mx = Math.max(0, (img.offsetWidth * s - window.innerWidth) / 2), my = Math.max(0, (img.offsetHeight * s - window.innerHeight) / 2); + x = Math.min(mx, Math.max(-mx, x)); y = Math.min(my, Math.max(-my, y)); + }; + const apply = (animate) => { + img.style.transition = animate ? 'transform .2s ease' : 'none'; + img.style.transform = s === 1 && !x && !y ? '' : `translate(${x}px, ${y}px) scale(${s})`; + img.style.cursor = s > 1 ? 'grab' : ''; + }; + // scale to ns keeping the picture point under (cx, cy) where it is + const zoomAt = (ns, cx, cy) => { + const { bx, by } = base(); + const px = (cx - bx - x) / s, py = (cy - by - y) / s; + s = Math.min(MAX, Math.max(1, ns)); + x = cx - bx - px * s; y = cy - by - py * s; + }; + box.addEventListener('pointerdown', (e) => { + if (e.target.closest('.lightbox-x')) return; + pts.set(e.pointerId, { x: e.clientX, y: e.clientY }); + if (pts.size === 1) moved = false; + begin(); + }); + const begin = () => { + const p = [...pts.values()]; + if (p.length >= 2) { + const { bx, by } = base(); + const mx = (p[0].x + p[1].x) / 2, my = (p[0].y + p[1].y) / 2; + pinch = { d: Math.hypot(p[0].x - p[1].x, p[0].y - p[1].y) || 1, s, px: (mx - bx - x) / s, py: (my - by - y) / s }; + pan = null; + } else if (p.length === 1) { + pinch = null; + pan = { sx: p[0].x, sy: p[0].y, x, y }; + } + }; + box.addEventListener('pointermove', (e) => { + if (!pts.has(e.pointerId)) return; + pts.set(e.pointerId, { x: e.clientX, y: e.clientY }); + const p = [...pts.values()]; + if (pinch && p.length >= 2) { + const { bx, by } = base(); + const d = Math.hypot(p[0].x - p[1].x, p[0].y - p[1].y); + const mx = (p[0].x + p[1].x) / 2, my = (p[0].y + p[1].y) / 2; + s = Math.min(MAX, Math.max(0.8, pinch.s * d / pinch.d)); // a little give below 1, snapped back on release + x = mx - bx - pinch.px * s; y = my - by - pinch.py * s; + moved = true; + apply(false); + } else if (pan && p.length === 1) { + const dx = p[0].x - pan.sx, dy = p[0].y - pan.sy; + if (Math.abs(dx) + Math.abs(dy) > 8) moved = true; + if (s > 1) { x = pan.x + dx; y = pan.y + dy; clamp(); apply(false); } + } + }); + const end = (e) => { + if (!pts.delete(e.pointerId)) return; + if (pts.size) { begin(); return; } // one finger lifted mid-pinch: carry on as a pan + pinch = pan = null; + clamp(); apply(true); + }; + box.addEventListener('pointerup', end); + box.addEventListener('pointercancel', end); + // a drag or a pinch is not a tap: nothing closes after one + box.addEventListener('click', (e) => { + if (moved) { moved = false; e.stopImmediatePropagation(); e.preventDefault(); return; } + if (e.target !== img) return; + e.stopPropagation(); + const now = Date.now(); + if (now - lastTap < 300) { + clearTimeout(tapTimer); lastTap = 0; + if (s > 1) { s = 1; x = 0; y = 0; } else { zoomAt(2.5, e.clientX, e.clientY); clamp(); } + apply(true); + return; + } + lastTap = now; + if (s === 1) tapTimer = setTimeout(close, 300); + }, true); + box.addEventListener('wheel', (e) => { + e.preventDefault(); + zoomAt(s * Math.exp(-e.deltaY * 0.002), e.clientX, e.clientY); + clamp(); apply(false); + }, { passive: false }); } if (typeof window !== 'undefined') { window.addEventListener('keydown', (e) => { if (e.key === 'Escape' && ui.lightbox) goBack(() => { ui.lightbox = null; }); }); diff --git a/src/style.css b/src/style.css index e907bd5..15fcad0 100644 --- a/src/style.css +++ b/src/style.css @@ -726,6 +726,7 @@ html:has(#app .chat-card), body:has(#app .chat-card) { overflow: hidden; } display: flex; align-items: center; justify-content: center; background: rgba(8, 10, 14, .94); padding: 16px; animation: lightbox-in .12s ease-out; + touch-action: none; /* pinch and drag are the picture's own (see attachZoom) */ } /* Nothing scrolls behind a picture that fills the screen — and the reserved scrollbar gutter (see html, top of file) stands down with it, or it shows @@ -737,6 +738,7 @@ html.no-scroll { overflow: hidden; scrollbar-gutter: auto; } .lightbox img { max-width: 100%; max-height: 100svh; max-height: 100vh; object-fit: contain; border-radius: 6px; cursor: zoom-out; + user-select: none; -webkit-user-select: none; transform-origin: center center; will-change: transform; } .lightbox-x { position: absolute; top: max(10px, env(safe-area-inset-top)); right: 10px; |
