aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-09-28 22:28:05 -0700
committerAdam Soltys <[email protected]>2026-09-28 22:28:05 -0700
commita31d97220c8acb858c71301972f38597c1eb2270 (patch)
tree1fdacd2a9fa349e28158e1c26a0d1eea620d91ac
parent2bbff9ac91bf310f63216523cb418474ca64db64 (diff)
downloadcoinosv3-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]>
-rw-r--r--src/app.js102
-rw-r--r--src/style.css2
-rw-r--r--tools/image-zoom-test.js84
3 files changed, 186 insertions, 2 deletions
diff --git a/src/app.js b/src/app.js
index febb2cd..4003e1b 100644
--- a/src/app.js
+++ b/src/app.js
@@ -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;
diff --git a/tools/image-zoom-test.js b/tools/image-zoom-test.js
new file mode 100644
index 0000000..3be5798
--- /dev/null
+++ b/tools/image-zoom-test.js
@@ -0,0 +1,84 @@
+// The full-screen picture: pinch to zoom, pan when zoomed, double-tap to zoom
+// and reset, a background render keeps the zoom, taps after a gesture don't
+// close it, a plain tap still does, and the wheel zooms on a desktop.
+// Run: bun tools/image-zoom-test.js
+import assert from 'node:assert/strict';
+import puppeteer from 'puppeteer-core';
+const app = await Bun.file('src/app.js').text();
+const dom = app.slice(app.indexOf('function h(tag,'), app.indexOf("const root = document.getElementById('app');"));
+const viewer = app.slice(app.indexOf('function imageViewer()'), app.indexOf("if (typeof window !== 'undefined') {\n window.addEventListener('keydown'"));
+const svg = encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="800" height="600"><rect width="800" height="600" fill="#48a"/><circle cx="400" cy="300" r="120" fill="#fc3"/></svg>');
+const entry = `
+${dom}
+${viewer}
+const ui = { lightbox: 'data:image/svg+xml,${svg}' };
+const t = (k) => k;
+const goBack = (fn) => { fn(); render(); };
+function render() { morphChildren(document.querySelector('#app'), ui.lightbox ? [imageViewer()] : []); }
+window.test = { ui, render, open() { ui.lightbox = 'data:image/svg+xml,${svg}'; render(); } };
+render();
+`;
+const bundle = await Bun.build({ entrypoints: ['zoom-entry'], target: 'browser', plugins: [{ name: 'zoom', setup(build) {
+ build.onResolve({ filter: /^zoom-entry$/ }, () => ({ path: 'entry', namespace: 'zoom' }));
+ build.onLoad({ filter: /.*/, namespace: 'zoom' }, () => ({ contents: entry, loader: 'js', resolveDir: process.cwd() }));
+} }] });
+assert(bundle.success, bundle.logs.join('\n'));
+const html = `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1"><style>${await Bun.file('src/style.css').text()}</style><div id="app"></div><script type="module">${await bundle.outputs[0].text()}</script>`;
+const server = Bun.serve({ port: 0, fetch: () => new Response(html, { headers: { 'content-type': 'text/html' } }) });
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: true, args: ['--no-sandbox'] });
+const page = await browser.newPage(), errors = [];
+page.on('pageerror', (e) => errors.push(e.message));
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+const zoom = () => page.evaluate(() => { const m = new DOMMatrix(getComputedStyle(document.querySelector('.lightbox img')).transform); return { s: +m.a.toFixed(2), x: Math.round(m.e), y: Math.round(m.f) }; });
+const open = () => page.evaluate(() => !!test.ui.lightbox && !!document.querySelector('.lightbox'));
+try {
+ await page.setViewport({ width: 390, height: 844, isMobile: true, hasTouch: true });
+ await page.goto(server.url.href);
+ await page.waitForSelector('.lightbox img');
+ const cdp = await page.target().createCDPSession();
+ const touch = (type, points) => cdp.send('Input.dispatchTouchEvent', { type, touchPoints: points.map(([x, y], id) => ({ x, y, id })) });
+ const cx = 195, cy = 422;
+ // pinch out around the centre
+ await touch('touchStart', [[cx - 40, cy], [cx + 40, cy]]);
+ for (let i = 1; i <= 8; i++) { await touch('touchMove', [[cx - 40 - i * 15, cy], [cx + 40 + i * 15, cy]]); await sleep(16); }
+ await touch('touchEnd', []); await sleep(350);
+ let z = await zoom();
+ assert(z.s > 3.8 && z.s < 4.2, 'pinch zooms with the fingers (80px → 320px apart = 4×): ' + JSON.stringify(z));
+ assert(await open(), 'a pinch does not close the picture');
+ // a background render keeps the zoom
+ await page.evaluate(() => test.render()); await sleep(50);
+ assert.equal((await zoom()).s, z.s, 'a background render keeps the zoom');
+ // one finger pans the zoomed picture, clamped to its edges
+ await touch('touchStart', [[cx, cy]]);
+ for (let i = 1; i <= 10; i++) { await touch('touchMove', [[cx + i * 20, cy + i * 5]]); await sleep(16); }
+ await touch('touchEnd', []); await sleep(350);
+ const panned = await zoom();
+ assert(panned.x > z.x + 50, 'drag pans: ' + JSON.stringify(panned));
+ const maxX = await page.evaluate((s) => (document.querySelector('.lightbox img').offsetWidth * s - innerWidth) / 2, panned.s);
+ assert(panned.x <= maxX + 1, 'pan is held to the picture edge');
+ assert(await open(), 'a drag does not close the picture');
+ // a tap while zoomed does not close; a double-tap resets
+ await page.touchscreen.tap(cx, cy); await sleep(400);
+ assert(await open(), 'a tap while zoomed does not close');
+ await page.touchscreen.tap(cx, cy); await sleep(80); await page.touchscreen.tap(cx, cy); await sleep(350);
+ assert.equal((await zoom()).s, 1, 'double-tap resets the zoom');
+ // double-tap at 1× zooms in at the spot, without closing
+ await page.touchscreen.tap(cx + 60, cy); await sleep(80); await page.touchscreen.tap(cx + 60, cy); await sleep(400);
+ z = await zoom();
+ assert(z.s === 2.5 && z.x < 0, 'double-tap zooms in toward the tapped spot: ' + JSON.stringify(z));
+ assert(await open(), 'a double-tap does not close');
+ await page.touchscreen.tap(cx, cy); await sleep(80); await page.touchscreen.tap(cx, cy); await sleep(350);
+ // a single tap at 1× still closes
+ await page.touchscreen.tap(cx, cy); await sleep(450);
+ assert(!(await open()), 'a single tap closes the picture');
+ // desktop: the wheel zooms toward the cursor
+ await page.setViewport({ width: 1280, height: 800 });
+ await page.evaluate(() => test.open()); await page.waitForSelector('.lightbox img');
+ await page.mouse.move(700, 400);
+ for (let i = 0; i < 5; i++) { await page.mouse.wheel({ deltaY: -120 }); await sleep(30); }
+ z = await zoom();
+ assert(z.s > 1.5, 'the wheel zooms in: ' + JSON.stringify(z));
+ assert(await open(), 'wheel zoom leaves the picture open');
+ assert.deepEqual(errors, []);
+ console.log('✓ Image viewer: pinch zooms, drag pans within the edges, double-tap zooms and resets, renders keep the zoom, gestures never close it, a tap does, the wheel zooms on desktop');
+} finally { await browser.close(); server.stop(true); }