aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-01 18:44:58 -0700
committerAdam Soltys <[email protected]>2026-10-01 18:44:58 -0700
commit0b08dfab22048c98562bab32f7758a25ee257ad9 (patch)
tree52f7148b3a605d7b5977bf74244946dc00a54493
parent6fa98bd4da4da341d48bf95012ad480354ecf32e (diff)
downloadcoinosv3-0b08dfab22048c98562bab32f7758a25ee257ad9.tar.gz
coinosv3-0b08dfab22048c98562bab32f7758a25ee257ad9.zip
Dismiss photos immediately on tap at any zoom level
-rw-r--r--src/app.js38
-rw-r--r--tools/image-zoom-test.js80
-rw-r--r--tools/profile-actions-test.js28
3 files changed, 105 insertions, 41 deletions
diff --git a/src/app.js b/src/app.js
index d3222f9..7131476 100644
--- a/src/app.js
+++ b/src/app.js
@@ -584,9 +584,17 @@ setTimeout(() => { if (_bootDeciding) { _bootDeciding = false; render(); } }, 40
// anywhere (or Escape, or Back) to close — no chrome, nothing to aim at.
function imageViewer() {
if (!ui.lightbox) return null;
- const close = () => goBack(() => { ui.lightbox = null; });
- // 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 close = () => {
+ if (!ui.lightbox) return;
+ ui.lightbox = null;
+ // Dismiss before history traversal and its page render finish. Read the
+ // mounted viewer: morphing can replace handlers with a new render's ones.
+ document.querySelector('.lightbox')?.remove();
+ document.documentElement.classList.remove('no-scroll');
+ goBack(() => { ui.lightbox = null; });
+ };
+ // A tap on the picture closes it at every zoom level; gestures are filtered
+ // by attachZoom before this click handler runs.
const img = h('img', { src: ui.lightbox, alt: '', draggable: 'false' });
const box = h('div', {
class: 'lightbox', onClick: close,
@@ -594,19 +602,19 @@ function imageViewer() {
}, 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);
+ attachZoom(box, img);
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
+// the wheel to zoom on a desktop. Single taps dismiss immediately rather
+// than waiting to distinguish a double-tap. The viewport 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) {
+function attachZoom(box, img) {
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;
+ let s = 1, x = 0, y = 0, pinch = null, pan = null, moved = false;
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; }
@@ -634,6 +642,7 @@ function attachZoom(box, img, close) {
const begin = () => {
const p = [...pts.values()];
if (p.length >= 2) {
+ moved = true; // two fingers are a gesture even before either moves
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 };
@@ -663,6 +672,7 @@ function attachZoom(box, img, close) {
});
const end = (e) => {
if (!pts.delete(e.pointerId)) return;
+ if (e.type === 'pointercancel') moved = true;
if (pts.size) { begin(); return; } // one finger lifted mid-pinch: carry on as a pan
pinch = pan = null;
clamp(); apply(true);
@@ -671,18 +681,8 @@ function attachZoom(box, img, close) {
box.addEventListener('pointercancel', end);
// a drag or a pinch is not a tap: nothing closes after one
box.addEventListener('click', (e) => {
+ if (e.target.closest('.lightbox-x')) return;
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();
diff --git a/tools/image-zoom-test.js b/tools/image-zoom-test.js
index 3be5798..f1a57c5 100644
--- a/tools/image-zoom-test.js
+++ b/tools/image-zoom-test.js
@@ -1,6 +1,6 @@
-// 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.
+// Pinch and pan keep the viewer open; taps dismiss at any zoom immediately,
+// even before navigation finishes. Background renders preserve zoom and
+// dismissal; the close button bypasses gesture suppression.
// Run: bun tools/image-zoom-test.js
import assert from 'node:assert/strict';
import puppeteer from 'puppeteer-core';
@@ -13,9 +13,17 @@ ${dom}
${viewer}
const ui = { lightbox: 'data:image/svg+xml,${svg}' };
const t = (k) => k;
-const goBack = (fn) => { fn(); render(); };
+let backCalls = 0, backDelay = 0, backSawViewer = null;
+const goBack = (fn) => {
+ backCalls++; backSawViewer = !!document.querySelector('.lightbox');
+ if (backDelay) setTimeout(() => { fn(); render(); }, backDelay);
+ else { fn(); render(); }
+};
function render() { morphChildren(document.querySelector('#app'), ui.lightbox ? [imageViewer()] : []); }
-window.test = { ui, render, open() { ui.lightbox = 'data:image/svg+xml,${svg}'; render(); } };
+window.test = { ui, render, get backCalls() { return backCalls; }, get backSawViewer() { return backSawViewer; },
+ delayBack(ms) { backDelay = ms; },
+ open() { ui.lightbox = 'data:image/svg+xml,${svg}'; document.documentElement.classList.add('no-scroll'); render(); } };
+document.addEventListener('click', () => { window.closedDuringClick = !document.querySelector('.lightbox'); });
render();
`;
const bundle = await Bun.build({ entrypoints: ['zoom-entry'], target: 'browser', plugins: [{ name: 'zoom', setup(build) {
@@ -31,6 +39,11 @@ 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'));
+const assertDismissed = async (label) => {
+ assert(!(await open()), label);
+ assert(await page.evaluate(() => closedDuringClick && test.backSawViewer === false
+ && !document.documentElement.classList.contains('no-scroll')), 'viewer disappears during the click, before navigation, and unlocks page scrolling');
+};
try {
await page.setViewport({ width: 390, height: 844, isMobile: true, hasTouch: true });
await page.goto(server.url.href);
@@ -57,20 +70,43 @@ try {
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');
+ await page.evaluate(() => test.render());
+ await page.touchscreen.tap(cx, cy);
+ await assertDismissed('a tap closes the zoomed, panned picture immediately after a background render');
+
+ // No 300ms double-tap wait, nor a wait for history and page rendering.
+ await page.evaluate(() => { test.open(); test.delayBack(200); });
+ await page.waitForFunction(() => document.querySelector('.lightbox img')?.complete);
+ await page.touchscreen.tap(cx, cy);
+ await assertDismissed('a tap at 1× immediately closes even with slow navigation');
+ await sleep(250);
+ await page.evaluate(() => test.delayBack(0));
+
+ await page.evaluate(() => test.open());
+ await page.touchscreen.tap(10, 50);
+ await assertDismissed('a tap on the backdrop closes immediately');
+
+ // Two fingers without motion and a cancelled gesture aren't dismissals.
+ await page.evaluate(() => test.open());
+ await touch('touchStart', [[cx - 40, cy], [cx + 40, cy]]);
+ await touch('touchEnd', []);
+ assert(await open(), 'a two-finger touch never closes the picture');
+ await touch('touchStart', [[cx, cy]]);
+ await touch('touchCancel', []);
+ await page.evaluate(() => document.querySelector('.lightbox img').click());
+ assert(await open(), 'a cancelled gesture suppresses its click');
+ await page.touchscreen.tap(cx, cy);
+ await assertDismissed('a fresh tap after cancellation closes normally');
+
+ await page.evaluate(() => test.open());
+ await touch('touchStart', [[cx - 40, cy], [cx + 40, cy]]);
+ await touch('touchMove', [[cx - 100, cy], [cx + 100, cy]]);
+ await touch('touchEnd', []);
+ await page.evaluate(() => test.render());
+ const beforeX = await page.evaluate(() => test.backCalls);
+ await page.click('.lightbox-x');
+ assert(!(await open()), 'the close button works immediately after a pinch and render');
+ assert.equal(await page.evaluate(() => test.backCalls), beforeX + 1, 'close button navigates exactly once');
// desktop: the wheel zooms toward the cursor
await page.setViewport({ width: 1280, height: 800 });
await page.evaluate(() => test.open()); await page.waitForSelector('.lightbox img');
@@ -79,6 +115,10 @@ try {
z = await zoom();
assert(z.s > 1.5, 'the wheel zooms in: ' + JSON.stringify(z));
assert(await open(), 'wheel zoom leaves the picture open');
+ await page.mouse.click(700, 400);
+ await assertDismissed('a desktop click closes the wheel-zoomed picture immediately');
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');
+ console.log('✓ Pinch zoom, clamped panning and wheel zoom work; background renders preserve zoom');
+ console.log('✓ Taps close immediately at every zoom, before slow navigation; backdrop and close button work');
+ console.log('✓ Pinches, two-finger touches and cancellations stay open; a fresh tap dismisses; no browser errors');
} finally { await browser.close(); server.stop(true); }
diff --git a/tools/profile-actions-test.js b/tools/profile-actions-test.js
index 9e98e27..830d7a0 100644
--- a/tools/profile-actions-test.js
+++ b/tools/profile-actions-test.js
@@ -12,7 +12,7 @@ const searchPeer = getPublicKey(generateSecretKey());
const bundle = await Bun.build({ entrypoints: ['src/app.js'], target: 'browser', minify: true,
plugins: [{ name: 'profile-actions-test', setup(build) {
build.onLoad({ filter: /src\/app\.js$/ }, async ({ path }) => ({ loader: 'js', contents: await Bun.file(path).text() + `
-window.profileActions = { ui, wallet, render, showSend, enterWallet,
+window.profileActions = { ui, wallet, render, showSend, enterWallet, openImage: ctx.openImage,
get ready() { return !_bootDeciding; },
openProfile: (pk) => featureHook('openProfile', pk) };
` }));
@@ -229,6 +229,30 @@ try {
await page.click('.chat-head .chat-back');
await page.waitForFunction(() => profileActions.ui.msgView === 'home');
assert.equal(await page.evaluate(() => profileActions.ui.profilePk), null);
+ console.log('✓ Conversations opened from the chat list return to it');
+
+ // Photo dismissal removes the overlay synchronously, then pops its history
+ // entry. Background morphs must keep the handler tied to the mounted box.
+ await page.setViewport({ width: 390, height: 844, isMobile: true, hasTouch: true });
+ const photoParent = await page.evaluate(() => ({ i: history.state.i, nav: history.state.nav, path: location.pathname }));
+ await page.evaluate(() => { profileActions.openImage('/recipient-portrait.svg'); profileActions.render(); });
+ await page.waitForSelector('.lightbox img');
+ assert.equal(await page.evaluate(() => history.state.i), photoParent.i + 1);
+ await page.evaluate(() => document.addEventListener('click', () => {
+ window.photoClosedDuringClick = !document.querySelector('.lightbox') && !document.documentElement.classList.contains('no-scroll');
+ }, { once: true }));
+ const photoCenter = await page.$eval('.lightbox img', e => { const r = e.getBoundingClientRect(); return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; });
+ await page.touchscreen.tap(photoCenter.x, photoCenter.y);
+ assert(await page.evaluate(() => photoClosedDuringClick), 'photo and scroll lock disappear during the tap, without waiting for popstate');
+ await page.waitForFunction(i => history.state.i === i && !document.querySelector('.lightbox'), {}, photoParent.i);
+ assert.deepEqual(await page.evaluate(() => ({ i: history.state.i, nav: history.state.nav, path: location.pathname })), photoParent,
+ 'photo dismissal restores the exact originating page');
+ await page.goForward();
+ await page.waitForSelector('.lightbox');
+ assert(await page.evaluate(() => !!profileActions.ui.lightbox && document.documentElement.classList.contains('no-scroll')), 'Forward reopens the viewer and locks scrolling');
+ await page.goBack();
+ await page.waitForFunction(() => !profileActions.ui.lightbox && !document.querySelector('.lightbox') && !document.documentElement.classList.contains('no-scroll'));
+ assert.equal(await page.evaluate(() => history.state.i), photoParent.i);
assert.deepEqual(errors, []);
- console.log('✓ Conversations opened from the chat list return to it; no browser errors');
+ console.log('✓ Photo tap dismisses immediately after a render, restores its originating page, and preserves native Back/Forward; no browser errors');
} finally { await browser.close(); server.stop(true); }