eleven / ocho Public

web: one lightbox — extract the twin verbatim copies into lightbox.js (#928)

shell.js and chat.js each carried a byte-identical 410-line openLightbox
(the FLIP fly-in/out + pinch/pan/flick gesture viewer) — duplicated when
the chat app took over message rendering, with the shell copy left
serving only the header-avatar zoom. One module now, imported by both.
The body is fully self-contained (zero module-scoped references — which
is how the same bytes worked in two files to begin with).
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 3afb40a140bafd7ac42aa15aaee4ad4200ab6df2 parent a01f47f
4 files changed, +421 −820
  • CLAUDE.md +3 −0
  • web/static/apps/chat.js +1 −410
  • web/static/lightbox.js +416 −0
  • web/static/shell.js +1 −410
diff --git a/CLAUDE.md b/CLAUDE.md
index fc0ec31..fc78aee 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -177,6 +177,9 @@ Frontend module layout:
- `theme.js` — colour theme / appearance / desktop text size (local-only
prefs; applies them at import time). First slice of breaking `shell.js`
into modules — extract sections verbatim, leaf-most first.
+- `lightbox.js` — the shared photo lightbox (FLIP fly-in/out + gestures);
+ one copy, imported by both `shell.js` (avatar zoom) and `apps/chat.js`
+ (message photos) — they used to carry twin verbatim copies.
- `store.js` — the local message store (#457): IndexedDB cache of RAW server
responses (ciphertext rows + wrapped keys, never plaintext) that boot and
thread-opens hydrate from before reconciling over the network. Its pure
diff --git a/web/static/apps/chat.js b/web/static/apps/chat.js
index 06e2b0f..6a2940a 100644
--- a/web/static/apps/chat.js
+++ b/web/static/apps/chat.js
@@ -24,6 +24,7 @@
// switch back per-device (⋮ menu → "Use original chat" — see chat.js).
import { defineApp, keyedList, runCommand, runAppMention, messageActionsFor, renderSurfaces, commandCatalog, buildLocalNotice, renderApp, deliverAnswer, parseMeCommand, defaultCommandFor, appChatReadOnly, setCommandPanelHost, notifyCommandPanel, motionOK } from "../apps.js";
import { replySnippet, formatBytes, emojiOnlyCount, firstLink, youtubeId, typingSummary } from "../format.js";
+import { openLightbox } from "../lightbox.js";
import { parseMessage, renderTree } from "../markdown.js";
import { createComposer, adoptComposer, classicComposerPref, formatAsYouTypePref } from "../composer.js";
@@ -2081,416 +2082,6 @@ function wireGifToggle(img, url) {
}
}
-function openLightbox(url, source) {
- const reduceMotion = matchMedia("(prefers-reduced-motion: reduce)").matches;
- const back = document.createElement("div");
- back.className = "lightbox";
- // The dark backing is a separate layer so we can dim it by animating *opacity*
- // (composited on the GPU, no repaint) instead of recomputing its color per move.
- const backdrop = document.createElement("div");
- backdrop.className = "lightbox-backdrop";
- backdrop.style.opacity = "0"; // faded in by the open animation
- const img = document.createElement("img");
- img.src = url;
- img.draggable = false;
- img.style.visibility = "hidden"; // stays hidden until laid out, so the fly-in has no flash
- back.appendChild(backdrop);
- back.appendChild(img);
-
- let tapTimer = 0; // pending single-tap-to-close, cancelled by a 2nd tap or a drag/pinch
- let closing = false;
- const close = () => {
- clearTimeout(tapTimer);
- back.remove();
- document.removeEventListener("keydown", onKey);
- };
- const onKey = (e) => {
- if (e.key === "Escape") animateClose();
- };
- // Tapping empty space (not the photo) closes — unless a gesture just ran, whose
- // synthesized click we swallow.
- back.addEventListener("click", (e) => {
- if (e.target === img) return;
- if (suppressBackdropClick) {
- suppressBackdropClick = false;
- return;
- }
- animateClose();
- });
- document.addEventListener("keydown", onKey);
- document.body.appendChild(back);
-
- // The photo's rounding is a fixed CSS radius, but the FLIP scales it — so a naive
- // fly-out lands on a corner radius that doesn't match the thumbnail underneath it
- // (a message's rounded corner, or a circular avatar) and snaps at the end. We
- // animate border-radius across the transition so the corners line up at both
- // ends: the source's radius at the thumbnail, the CSS radius at rest. Since the
- // transform scales the element, the CSS radius that *renders* as `r` at scale `s`
- // is `r / s`.
- const restRadius = parseFloat(getComputedStyle(img).borderTopLeftRadius) || 8;
- const sourceRadius = (rect) => {
- if (!source) return restRadius;
- const raw = getComputedStyle(source).borderTopLeftRadius;
- const r = raw.endsWith("%") ? (parseFloat(raw) / 100) * Math.min(rect.width, rect.height) : parseFloat(raw);
- return r || 0;
- };
-
- // --- gesture state -------------------------------------------------------
- // One transform drives everything: translate3d + rotate + scale (GPU-composited).
- // A single finger at 1× flick-dismisses; two fingers pinch-zoom-and-rotate about
- // their midpoint; one finger while zoomed pans; double-tap toggles zoom.
- const MIN_SCALE = 1;
- const MAX_SCALE = 5;
- const TAP_MS = 280; // double-tap window / lone-tap dismiss delay
- let scale = 1;
- let tx = 0;
- let ty = 0;
- let rot = 0; // rotation °: free during a pinch (springs upright), or a 1× drag tumble
- let mode = ""; // "drag" (1× dismiss) | "pan" (zoomed) | "pinch"
- let pinched = false; // did this gesture involve a pinch? if so it settles, never dismisses
- let suppressBackdropClick = false;
- let raf = 0;
-
- // The image's at-rest centre in screen coords — the anchor for all zoom math and
- // the fly-in/out target. Measured whenever we sit at the identity transform.
- let baseCX = 0;
- let baseCY = 0;
- let haveBase = false;
- const setBase = () => {
- const r = img.getBoundingClientRect();
- baseCX = r.left + r.width / 2;
- baseCY = r.top + r.height / 2;
- haveBase = true;
- };
- const ensureBase = () => {
- if (scale === 1 && tx === 0 && ty === 0) setBase();
- };
-
- const pointers = new Map(); // pointerId -> {x, y}
- // single-finger drag/pan tracking
- let sx = 0;
- let sy = 0;
- let panStartTx = 0;
- let panStartTy = 0;
- let lastX = 0;
- let lastY = 0;
- let lastT = 0;
- let vx = 0;
- let vy = 0;
- let moved = false;
- // pinch anchors (captured when the second finger lands)
- let pinchStartDist = 0;
- let pinchStartScale = 1;
- let pinchStartAngle = 0;
- let pinchStartRot = 0;
- let pinchVX = 0;
- let pinchVY = 0;
- // double-tap tracking
- let lastTapT = 0;
- let lastTapX = 0;
- let lastTapY = 0;
-
- const render = () => {
- raf = 0;
- let t = `translate3d(${tx}px, ${ty}px, 0)`;
- if (rot) t += ` rotate(${rot}deg)`;
- if (scale !== 1) t += ` scale(${scale})`;
- img.style.transform = t;
- // The backdrop only dims while dragging a 1× image toward dismissal.
- backdrop.style.opacity =
- mode === "drag" && !pinched ? String(Math.max(0, 1 - Math.hypot(tx, ty) / 700)) : "1";
- };
- const schedule = () => {
- if (!raf) raf = requestAnimationFrame(render);
- };
-
- // Fly the photo up from the thumbnail it was opened from, then ease to rest
- // (a FLIP / shared-element transition). Runs once the image has a size.
- let openStarted = false;
- const runOpenAnim = () => {
- if (openStarted) return;
- openStarted = true;
- img.style.visibility = "";
- setBase();
- const S = source && source.getBoundingClientRect();
- if (!haveBase || !img.clientWidth || !S || !S.width || reduceMotion) {
- backdrop.style.transition = reduceMotion ? "" : "opacity 0.2s";
- backdrop.style.opacity = "1"; // no thumbnail to fly from — just appear
- return;
- }
- const s = Math.max(S.width / img.clientWidth, S.height / img.clientHeight);
- const fromX = S.left + S.width / 2 - baseCX;
- const fromY = S.top + S.height / 2 - baseCY;
- img.style.transition = "none";
- img.style.transform = `translate3d(${fromX}px, ${fromY}px, 0) scale(${s})`;
- img.style.borderRadius = sourceRadius(S) / s + "px"; // match the thumbnail's rounding to start
- void img.offsetWidth; // reflow so the changes below animate from here
- img.style.transition = "transform 0.3s cubic-bezier(0.2,0.8,0.2,1), border-radius 0.3s ease";
- img.style.transform = "translate3d(0,0,0)";
- img.style.borderRadius = restRadius + "px";
- backdrop.style.transition = "opacity 0.3s";
- backdrop.style.opacity = "1";
- };
- if (img.complete && img.naturalWidth) runOpenAnim();
- else {
- img.addEventListener("load", runOpenAnim, { once: true });
- img.addEventListener("error", runOpenAnim, { once: true });
- }
-
- // Reverse of the fly-in: shrink the photo back onto its thumbnail, then remove.
- // Used by taps / Escape / backdrop closes; a fling instead hurls it off-screen.
- const animateClose = () => {
- if (closing) return;
- closing = true;
- clearTimeout(tapTimer);
- if (raf) {
- cancelAnimationFrame(raf);
- raf = 0;
- }
- const S = source && source.getBoundingClientRect();
- if (!haveBase || !S || !S.width || reduceMotion) return close();
- const s = Math.max(S.width / img.clientWidth, S.height / img.clientHeight);
- const toX = S.left + S.width / 2 - baseCX;
- const toY = S.top + S.height / 2 - baseCY;
- img.style.transition = "transform 0.28s cubic-bezier(0.4,0,0.2,1), border-radius 0.28s ease";
- img.style.transform = `translate3d(${toX}px, ${toY}px, 0) scale(${s})`;
- img.style.borderRadius = sourceRadius(S) / s + "px"; // shrink to the thumbnail's rounding, no snap
- backdrop.style.transition = "opacity 0.28s";
- backdrop.style.opacity = "0";
- setTimeout(close, 300);
- };
-
- // Keep the transformed image within reach of the viewport when zoomed.
- const clampPan = () => {
- const maxX = Math.max(0, (img.clientWidth * scale - window.innerWidth) / 2 + 20);
- const maxY = Math.max(0, (img.clientHeight * scale - window.innerHeight) / 2 + 20);
- tx = Math.max(-maxX, Math.min(maxX, tx));
- ty = Math.max(-maxY, Math.min(maxY, ty));
- };
-
- // Animate to a target scale anchored at a screen point (double-tap). Zooming
- // out always recentres.
- const zoomTo = (target, cx, cy) => {
- if (raf) {
- cancelAnimationFrame(raf);
- raf = 0;
- }
- if (target <= MIN_SCALE) {
- scale = 1;
- tx = 0;
- ty = 0;
- } else {
- const vX = (cx - baseCX - tx) / scale;
- const vY = (cy - baseCY - ty) / scale;
- scale = Math.min(MAX_SCALE, target);
- tx = cx - baseCX - scale * vX;
- ty = cy - baseCY - scale * vY;
- clampPan();
- }
- rot = 0;
- img.style.transition = "transform 0.25s cubic-bezier(0.2,0.8,0.2,1)";
- img.style.transform = scale === 1 ? "translate3d(0,0,0)" : `translate3d(${tx}px, ${ty}px, 0) scale(${scale})`;
- backdrop.style.transition = "opacity 0.25s";
- backdrop.style.opacity = "1";
- };
-
- // Ease back to the identity transform (1×, centred, upright).
- const springBack = () => {
- scale = 1;
- tx = 0;
- ty = 0;
- rot = 0;
- img.style.transition = "transform 0.28s cubic-bezier(0.2,0.9,0.2,1)";
- img.style.transform = "translate3d(0,0,0)";
- backdrop.style.transition = "opacity 0.28s";
- backdrop.style.opacity = "1";
- };
-
- // Settle a still-zoomed image: keep the zoom, clamp into view, straighten any
- // rotation the pinch left behind.
- const settleZoom = () => {
- clampPan();
- rot = 0;
- img.style.transition = "transform 0.28s cubic-bezier(0.2,0.9,0.2,1)";
- img.style.transform = `translate3d(${tx}px, ${ty}px, 0) scale(${scale})`;
- backdrop.style.transition = "opacity 0.28s";
- backdrop.style.opacity = "1";
- };
-
- img.addEventListener("pointerdown", (e) => {
- img.setPointerCapture?.(e.pointerId);
- pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
- clearTimeout(tapTimer); // a new touch means this isn't a tap-and-wait dismiss
- if (raf) {
- cancelAnimationFrame(raf);
- raf = 0;
- }
- img.style.transition = "none";
- backdrop.style.transition = "none";
- ensureBase();
- if (pointers.size >= 2) {
- // Second finger down: start a pinch, anchoring the current midpoint + angle.
- const [a, b] = [...pointers.values()];
- pinchStartDist = Math.hypot(a.x - b.x, a.y - b.y) || 1;
- pinchStartScale = scale;
- pinchStartAngle = Math.atan2(b.y - a.y, b.x - a.x);
- pinchStartRot = rot;
- const midX = (a.x + b.x) / 2;
- const midY = (a.y + b.y) / 2;
- pinchVX = (midX - baseCX - tx) / scale;
- pinchVY = (midY - baseCY - ty) / scale;
- mode = "pinch";
- moved = true;
- pinched = true;
- suppressBackdropClick = true;
- } else {
- // First finger of a fresh gesture: pan if zoomed, else a candidate dismiss drag.
- pinched = false;
- sx = e.clientX;
- sy = e.clientY;
- panStartTx = tx;
- panStartTy = ty;
- lastX = e.clientX;
- lastY = e.clientY;
- lastT = e.timeStamp || performance.now();
- vx = vy = 0;
- moved = false;
- rot = 0;
- mode = scale > 1 ? "pan" : "drag";
- }
- });
-
- img.addEventListener("pointermove", (e) => {
- const p = pointers.get(e.pointerId);
- if (!p) return;
- p.x = e.clientX;
- p.y = e.clientY;
- if (mode === "pinch") {
- const pts = [...pointers.values()];
- if (pts.length < 2) return;
- const dist = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y) || 1;
- const midX = (pts[0].x + pts[1].x) / 2;
- const midY = (pts[0].y + pts[1].y) / 2;
- scale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, (pinchStartScale * dist) / pinchStartDist));
- const ang = Math.atan2(pts[1].y - pts[0].y, pts[1].x - pts[0].x);
- rot = pinchStartRot + (ang - pinchStartAngle) * (180 / Math.PI); // free twist
- tx = midX - baseCX - scale * pinchVX;
- ty = midY - baseCY - scale * pinchVY;
- clampPan();
- schedule();
- return;
- }
- if (mode === "pan") {
- tx = panStartTx + (e.clientX - sx);
- ty = panStartTy + (e.clientY - sy);
- clampPan();
- schedule();
- return;
- }
- // mode === "drag": 1× dismiss drag with velocity sampling (px/ms).
- tx = e.clientX - sx;
- ty = e.clientY - sy;
- if (Math.abs(tx) > 6 || Math.abs(ty) > 6) moved = true;
- const now = e.timeStamp || performance.now();
- const dt = now - lastT;
- if (dt > 0) {
- vx = (e.clientX - lastX) / dt;
- vy = (e.clientY - lastY) / dt;
- }
- lastX = e.clientX;
- lastY = e.clientY;
- lastT = now;
- rot = tx / 40;
- schedule();
- });
-
- const armSuppress = () => {
- suppressBackdropClick = true;
- setTimeout(() => (suppressBackdropClick = false), 350);
- };
-
- const flingOrSettle = (e) => {
- // A tap on the photo. A second tap within the window is a double-tap (toggle
- // zoom); otherwise a lone tap dismisses at 1× — but only after the window
- // elapses, so it can't fire before a double-tap is ruled out.
- if (!moved) {
- const now = e.timeStamp || performance.now();
- if (now - lastTapT < TAP_MS && Math.hypot(e.clientX - lastTapX, e.clientY - lastTapY) < 30) {
- lastTapT = 0;
- zoomTo(scale > 1 ? MIN_SCALE : 2.5, e.clientX, e.clientY);
- armSuppress();
- return;
- }
- lastTapT = now;
- lastTapX = e.clientX;
- lastTapY = e.clientY;
- if (scale === 1) tapTimer = setTimeout(animateClose, TAP_MS); // lone tap → dismiss into place
- return;
- }
- // Anything that involved a pinch, or ended zoomed, settles — it never dismisses,
- // so pinching back down to 1× can't fling the photo away by accident.
- if (pinched || mode === "pan" || scale > 1) {
- if (scale <= 1.001) springBack();
- else settleZoom();
- armSuppress();
- return;
- }
- // Clean 1× drag: spring back, or fling off with momentum.
- const speed = Math.hypot(vx, vy); // px/ms
- if (speed < 0.55 && Math.hypot(tx, ty) < 130) {
- springBack();
- return;
- }
- // Fling: continue in the release direction (fall back to the drag direction if it
- // was a slow long drag), far enough to clear the screen, backdrop fading out.
- let dx = vx;
- let dy = vy;
- if (speed < 0.05) {
- dx = tx;
- dy = ty;
- }
- const norm = Math.hypot(dx, dy) || 1;
- const reach = Math.max(window.innerWidth, window.innerHeight) * 1.25;
- const fx = tx + (dx / norm) * reach;
- const fy = ty + (dy / norm) * reach;
- const spin = Math.max(-65, Math.min(65, fx / 20)); // tumbles more as it leaves
- closing = true;
- img.style.transition = "transform 0.5s cubic-bezier(0.25,0.8,0.35,1)";
- img.style.transform = `translate3d(${fx}px, ${fy}px, 0) rotate(${spin}deg)`;
- backdrop.style.transition = "opacity 0.45s";
- backdrop.style.opacity = "0";
- setTimeout(close, 480);
- };
-
- const onUp = (e) => {
- if (!pointers.has(e.pointerId)) return;
- pointers.delete(e.pointerId);
- img.releasePointerCapture?.(e.pointerId);
- if (raf) {
- cancelAnimationFrame(raf);
- raf = 0;
- }
- if (pointers.size === 1) {
- // Dropped from a pinch to one finger — hand off to pan/drag without a jump.
- const [rp] = [...pointers.values()];
- sx = rp.x;
- sy = rp.y;
- panStartTx = tx;
- panStartTy = ty;
- lastX = rp.x;
- lastY = rp.y;
- lastT = e.timeStamp || performance.now();
- vx = vy = 0;
- moved = true; // came from a pinch; not a tap
- mode = scale > 1 ? "pan" : "drag";
- return;
- }
- if (pointers.size >= 1) return;
- flingOrSettle(e);
- };
- img.addEventListener("pointerup", onUp);
- img.addEventListener("pointercancel", onUp);
-}
// The original click-to-play YouTube card (.yt-card): thumbnail from the video
// id, swapped for the privacy-friendly nocookie embed on tap.
diff --git a/web/static/lightbox.js b/web/static/lightbox.js
new file mode 100644
index 0000000..fa8bdd9
--- /dev/null
+++ b/web/static/lightbox.js
@@ -0,0 +1,416 @@
+// The shared photo lightbox: full-screen viewer with the FLIP fly-in/out,
+// pinch-zoom-and-rotate, pan, flick-to-dismiss, and double-tap zoom. One copy,
+// two callers — chat.js (message photos, GIF stills) and shell.js (the header
+// avatar's full photo). Self-contained: everything it needs rides in the call
+// (the image URL + the source element whose corner radius the fly-out must
+// land on). Extracted from the twin verbatim copies both files carried.
+export function openLightbox(url, source) {
+ const reduceMotion = matchMedia("(prefers-reduced-motion: reduce)").matches;
+ const back = document.createElement("div");
+ back.className = "lightbox";
+ // The dark backing is a separate layer so we can dim it by animating *opacity*
+ // (composited on the GPU, no repaint) instead of recomputing its color per move.
+ const backdrop = document.createElement("div");
+ backdrop.className = "lightbox-backdrop";
+ backdrop.style.opacity = "0"; // faded in by the open animation
+ const img = document.createElement("img");
+ img.src = url;
+ img.draggable = false;
+ img.style.visibility = "hidden"; // stays hidden until laid out, so the fly-in has no flash
+ back.appendChild(backdrop);
+ back.appendChild(img);
+
+ let tapTimer = 0; // pending single-tap-to-close, cancelled by a 2nd tap or a drag/pinch
+ let closing = false;
+ const close = () => {
+ clearTimeout(tapTimer);
+ back.remove();
+ document.removeEventListener("keydown", onKey);
+ };
+ const onKey = (e) => {
+ if (e.key === "Escape") animateClose();
+ };
+ // Tapping empty space (not the photo) closes — unless a gesture just ran, whose
+ // synthesized click we swallow.
+ back.addEventListener("click", (e) => {
+ if (e.target === img) return;
+ if (suppressBackdropClick) {
+ suppressBackdropClick = false;
+ return;
+ }
+ animateClose();
+ });
+ document.addEventListener("keydown", onKey);
+ document.body.appendChild(back);
+
+ // The photo's rounding is a fixed CSS radius, but the FLIP scales it — so a naive
+ // fly-out lands on a corner radius that doesn't match the thumbnail underneath it
+ // (a message's rounded corner, or a circular avatar) and snaps at the end. We
+ // animate border-radius across the transition so the corners line up at both
+ // ends: the source's radius at the thumbnail, the CSS radius at rest. Since the
+ // transform scales the element, the CSS radius that *renders* as `r` at scale `s`
+ // is `r / s`.
+ const restRadius = parseFloat(getComputedStyle(img).borderTopLeftRadius) || 8;
+ const sourceRadius = (rect) => {
+ if (!source) return restRadius;
+ const raw = getComputedStyle(source).borderTopLeftRadius;
+ const r = raw.endsWith("%") ? (parseFloat(raw) / 100) * Math.min(rect.width, rect.height) : parseFloat(raw);
+ return r || 0;
+ };
+
+ // --- gesture state -------------------------------------------------------
+ // One transform drives everything: translate3d + rotate + scale (GPU-composited).
+ // A single finger at 1× flick-dismisses; two fingers pinch-zoom-and-rotate about
+ // their midpoint; one finger while zoomed pans; double-tap toggles zoom.
+ const MIN_SCALE = 1;
+ const MAX_SCALE = 5;
+ const TAP_MS = 280; // double-tap window / lone-tap dismiss delay
+ let scale = 1;
+ let tx = 0;
+ let ty = 0;
+ let rot = 0; // rotation °: free during a pinch (springs upright), or a 1× drag tumble
+ let mode = ""; // "drag" (1× dismiss) | "pan" (zoomed) | "pinch"
+ let pinched = false; // did this gesture involve a pinch? if so it settles, never dismisses
+ let suppressBackdropClick = false;
+ let raf = 0;
+
+ // The image's at-rest centre in screen coords — the anchor for all zoom math and
+ // the fly-in/out target. Measured whenever we sit at the identity transform.
+ let baseCX = 0;
+ let baseCY = 0;
+ let haveBase = false;
+ const setBase = () => {
+ const r = img.getBoundingClientRect();
+ baseCX = r.left + r.width / 2;
+ baseCY = r.top + r.height / 2;
+ haveBase = true;
+ };
+ const ensureBase = () => {
+ if (scale === 1 && tx === 0 && ty === 0) setBase();
+ };
+
+ const pointers = new Map(); // pointerId -> {x, y}
+ // single-finger drag/pan tracking
+ let sx = 0;
+ let sy = 0;
+ let panStartTx = 0;
+ let panStartTy = 0;
+ let lastX = 0;
+ let lastY = 0;
+ let lastT = 0;
+ let vx = 0;
+ let vy = 0;
+ let moved = false;
+ // pinch anchors (captured when the second finger lands)
+ let pinchStartDist = 0;
+ let pinchStartScale = 1;
+ let pinchStartAngle = 0;
+ let pinchStartRot = 0;
+ let pinchVX = 0;
+ let pinchVY = 0;
+ // double-tap tracking
+ let lastTapT = 0;
+ let lastTapX = 0;
+ let lastTapY = 0;
+
+ const render = () => {
+ raf = 0;
+ let t = `translate3d(${tx}px, ${ty}px, 0)`;
+ if (rot) t += ` rotate(${rot}deg)`;
+ if (scale !== 1) t += ` scale(${scale})`;
+ img.style.transform = t;
+ // The backdrop only dims while dragging a 1× image toward dismissal.
+ backdrop.style.opacity =
+ mode === "drag" && !pinched ? String(Math.max(0, 1 - Math.hypot(tx, ty) / 700)) : "1";
+ };
+ const schedule = () => {
+ if (!raf) raf = requestAnimationFrame(render);
+ };
+
+ // Fly the photo up from the thumbnail it was opened from, then ease to rest
+ // (a FLIP / shared-element transition). Runs once the image has a size.
+ let openStarted = false;
+ const runOpenAnim = () => {
+ if (openStarted) return;
+ openStarted = true;
+ img.style.visibility = "";
+ setBase();
+ const S = source && source.getBoundingClientRect();
+ if (!haveBase || !img.clientWidth || !S || !S.width || reduceMotion) {
+ backdrop.style.transition = reduceMotion ? "" : "opacity 0.2s";
+ backdrop.style.opacity = "1"; // no thumbnail to fly from — just appear
+ return;
+ }
+ const s = Math.max(S.width / img.clientWidth, S.height / img.clientHeight);
+ const fromX = S.left + S.width / 2 - baseCX;
+ const fromY = S.top + S.height / 2 - baseCY;
+ img.style.transition = "none";
+ img.style.transform = `translate3d(${fromX}px, ${fromY}px, 0) scale(${s})`;
+ img.style.borderRadius = sourceRadius(S) / s + "px"; // match the thumbnail's rounding to start
+ void img.offsetWidth; // reflow so the changes below animate from here
+ img.style.transition = "transform 0.3s cubic-bezier(0.2,0.8,0.2,1), border-radius 0.3s ease";
+ img.style.transform = "translate3d(0,0,0)";
+ img.style.borderRadius = restRadius + "px";
+ backdrop.style.transition = "opacity 0.3s";
+ backdrop.style.opacity = "1";
+ };
+ if (img.complete && img.naturalWidth) runOpenAnim();
+ else {
+ img.addEventListener("load", runOpenAnim, { once: true });
+ img.addEventListener("error", runOpenAnim, { once: true });
+ }
+
+ // Reverse of the fly-in: shrink the photo back onto its thumbnail, then remove.
+ // Used by taps / Escape / backdrop closes; a fling instead hurls it off-screen.
+ const animateClose = () => {
+ if (closing) return;
+ closing = true;
+ clearTimeout(tapTimer);
+ if (raf) {
+ cancelAnimationFrame(raf);
+ raf = 0;
+ }
+ const S = source && source.getBoundingClientRect();
+ if (!haveBase || !S || !S.width || reduceMotion) return close();
+ const s = Math.max(S.width / img.clientWidth, S.height / img.clientHeight);
+ const toX = S.left + S.width / 2 - baseCX;
+ const toY = S.top + S.height / 2 - baseCY;
+ img.style.transition = "transform 0.28s cubic-bezier(0.4,0,0.2,1), border-radius 0.28s ease";
+ img.style.transform = `translate3d(${toX}px, ${toY}px, 0) scale(${s})`;
+ img.style.borderRadius = sourceRadius(S) / s + "px"; // shrink to the thumbnail's rounding, no snap
+ backdrop.style.transition = "opacity 0.28s";
+ backdrop.style.opacity = "0";
+ setTimeout(close, 300);
+ };
+
+ // Keep the transformed image within reach of the viewport when zoomed.
+ const clampPan = () => {
+ const maxX = Math.max(0, (img.clientWidth * scale - window.innerWidth) / 2 + 20);
+ const maxY = Math.max(0, (img.clientHeight * scale - window.innerHeight) / 2 + 20);
+ tx = Math.max(-maxX, Math.min(maxX, tx));
+ ty = Math.max(-maxY, Math.min(maxY, ty));
+ };
+
+ // Animate to a target scale anchored at a screen point (double-tap). Zooming
+ // out always recentres.
+ const zoomTo = (target, cx, cy) => {
+ if (raf) {
+ cancelAnimationFrame(raf);
+ raf = 0;
+ }
+ if (target <= MIN_SCALE) {
+ scale = 1;
+ tx = 0;
+ ty = 0;
+ } else {
+ const vX = (cx - baseCX - tx) / scale;
+ const vY = (cy - baseCY - ty) / scale;
+ scale = Math.min(MAX_SCALE, target);
+ tx = cx - baseCX - scale * vX;
+ ty = cy - baseCY - scale * vY;
+ clampPan();
+ }
+ rot = 0;
+ img.style.transition = "transform 0.25s cubic-bezier(0.2,0.8,0.2,1)";
+ img.style.transform = scale === 1 ? "translate3d(0,0,0)" : `translate3d(${tx}px, ${ty}px, 0) scale(${scale})`;
+ backdrop.style.transition = "opacity 0.25s";
+ backdrop.style.opacity = "1";
+ };
+
+ // Ease back to the identity transform (1×, centred, upright).
+ const springBack = () => {
+ scale = 1;
+ tx = 0;
+ ty = 0;
+ rot = 0;
+ img.style.transition = "transform 0.28s cubic-bezier(0.2,0.9,0.2,1)";
+ img.style.transform = "translate3d(0,0,0)";
+ backdrop.style.transition = "opacity 0.28s";
+ backdrop.style.opacity = "1";
+ };
+
+ // Settle a still-zoomed image: keep the zoom, clamp into view, straighten any
+ // rotation the pinch left behind.
+ const settleZoom = () => {
+ clampPan();
+ rot = 0;
+ img.style.transition = "transform 0.28s cubic-bezier(0.2,0.9,0.2,1)";
+ img.style.transform = `translate3d(${tx}px, ${ty}px, 0) scale(${scale})`;
+ backdrop.style.transition = "opacity 0.28s";
+ backdrop.style.opacity = "1";
+ };
+
+ img.addEventListener("pointerdown", (e) => {
+ img.setPointerCapture?.(e.pointerId);
+ pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
+ clearTimeout(tapTimer); // a new touch means this isn't a tap-and-wait dismiss
+ if (raf) {
+ cancelAnimationFrame(raf);
+ raf = 0;
+ }
+ img.style.transition = "none";
+ backdrop.style.transition = "none";
+ ensureBase();
+ if (pointers.size >= 2) {
+ // Second finger down: start a pinch, anchoring the current midpoint + angle.
+ const [a, b] = [...pointers.values()];
+ pinchStartDist = Math.hypot(a.x - b.x, a.y - b.y) || 1;
+ pinchStartScale = scale;
+ pinchStartAngle = Math.atan2(b.y - a.y, b.x - a.x);
+ pinchStartRot = rot;
+ const midX = (a.x + b.x) / 2;
+ const midY = (a.y + b.y) / 2;
+ pinchVX = (midX - baseCX - tx) / scale;
+ pinchVY = (midY - baseCY - ty) / scale;
+ mode = "pinch";
+ moved = true;
+ pinched = true;
+ suppressBackdropClick = true;
+ } else {
+ // First finger of a fresh gesture: pan if zoomed, else a candidate dismiss drag.
+ pinched = false;
+ sx = e.clientX;
+ sy = e.clientY;
+ panStartTx = tx;
+ panStartTy = ty;
+ lastX = e.clientX;
+ lastY = e.clientY;
+ lastT = e.timeStamp || performance.now();
+ vx = vy = 0;
+ moved = false;
+ rot = 0;
+ mode = scale > 1 ? "pan" : "drag";
+ }
+ });
+
+ img.addEventListener("pointermove", (e) => {
+ const p = pointers.get(e.pointerId);
+ if (!p) return;
+ p.x = e.clientX;
+ p.y = e.clientY;
+ if (mode === "pinch") {
+ const pts = [...pointers.values()];
+ if (pts.length < 2) return;
+ const dist = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y) || 1;
+ const midX = (pts[0].x + pts[1].x) / 2;
+ const midY = (pts[0].y + pts[1].y) / 2;
+ scale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, (pinchStartScale * dist) / pinchStartDist));
+ const ang = Math.atan2(pts[1].y - pts[0].y, pts[1].x - pts[0].x);
+ rot = pinchStartRot + (ang - pinchStartAngle) * (180 / Math.PI); // free twist
+ tx = midX - baseCX - scale * pinchVX;
+ ty = midY - baseCY - scale * pinchVY;
+ clampPan();
+ schedule();
+ return;
+ }
+ if (mode === "pan") {
+ tx = panStartTx + (e.clientX - sx);
+ ty = panStartTy + (e.clientY - sy);
+ clampPan();
+ schedule();
+ return;
+ }
+ // mode === "drag": 1× dismiss drag with velocity sampling (px/ms).
+ tx = e.clientX - sx;
+ ty = e.clientY - sy;
+ if (Math.abs(tx) > 6 || Math.abs(ty) > 6) moved = true;
+ const now = e.timeStamp || performance.now();
+ const dt = now - lastT;
+ if (dt > 0) {
+ vx = (e.clientX - lastX) / dt;
+ vy = (e.clientY - lastY) / dt;
+ }
+ lastX = e.clientX;
+ lastY = e.clientY;
+ lastT = now;
+ rot = tx / 40;
+ schedule();
+ });
+
+ const armSuppress = () => {
+ suppressBackdropClick = true;
+ setTimeout(() => (suppressBackdropClick = false), 350);
+ };
+
+ const flingOrSettle = (e) => {
+ // A tap on the photo. A second tap within the window is a double-tap (toggle
+ // zoom); otherwise a lone tap dismisses at 1× — but only after the window
+ // elapses, so it can't fire before a double-tap is ruled out.
+ if (!moved) {
+ const now = e.timeStamp || performance.now();
+ if (now - lastTapT < TAP_MS && Math.hypot(e.clientX - lastTapX, e.clientY - lastTapY) < 30) {
+ lastTapT = 0;
+ zoomTo(scale > 1 ? MIN_SCALE : 2.5, e.clientX, e.clientY);
+ armSuppress();
+ return;
+ }
+ lastTapT = now;
+ lastTapX = e.clientX;
+ lastTapY = e.clientY;
+ if (scale === 1) tapTimer = setTimeout(animateClose, TAP_MS); // lone tap → dismiss into place
+ return;
+ }
+ // Anything that involved a pinch, or ended zoomed, settles — it never dismisses,
+ // so pinching back down to 1× can't fling the photo away by accident.
+ if (pinched || mode === "pan" || scale > 1) {
+ if (scale <= 1.001) springBack();
+ else settleZoom();
+ armSuppress();
+ return;
+ }
+ // Clean 1× drag: spring back, or fling off with momentum.
+ const speed = Math.hypot(vx, vy); // px/ms
+ if (speed < 0.55 && Math.hypot(tx, ty) < 130) {
+ springBack();
+ return;
+ }
+ // Fling: continue in the release direction (fall back to the drag direction if it
+ // was a slow long drag), far enough to clear the screen, backdrop fading out.
+ let dx = vx;
+ let dy = vy;
+ if (speed < 0.05) {
+ dx = tx;
+ dy = ty;
+ }
+ const norm = Math.hypot(dx, dy) || 1;
+ const reach = Math.max(window.innerWidth, window.innerHeight) * 1.25;
+ const fx = tx + (dx / norm) * reach;
+ const fy = ty + (dy / norm) * reach;
+ const spin = Math.max(-65, Math.min(65, fx / 20)); // tumbles more as it leaves
+ closing = true;
+ img.style.transition = "transform 0.5s cubic-bezier(0.25,0.8,0.35,1)";
+ img.style.transform = `translate3d(${fx}px, ${fy}px, 0) rotate(${spin}deg)`;
+ backdrop.style.transition = "opacity 0.45s";
+ backdrop.style.opacity = "0";
+ setTimeout(close, 480);
+ };
+
+ const onUp = (e) => {
+ if (!pointers.has(e.pointerId)) return;
+ pointers.delete(e.pointerId);
+ img.releasePointerCapture?.(e.pointerId);
+ if (raf) {
+ cancelAnimationFrame(raf);
+ raf = 0;
+ }
+ if (pointers.size === 1) {
+ // Dropped from a pinch to one finger — hand off to pan/drag without a jump.
+ const [rp] = [...pointers.values()];
+ sx = rp.x;
+ sy = rp.y;
+ panStartTx = tx;
+ panStartTy = ty;
+ lastX = rp.x;
+ lastY = rp.y;
+ lastT = e.timeStamp || performance.now();
+ vx = vy = 0;
+ moved = true; // came from a pinch; not a tap
+ mode = scale > 1 ? "pan" : "drag";
+ return;
+ }
+ if (pointers.size >= 1) return;
+ flingOrSettle(e);
+ };
+ img.addEventListener("pointerup", onUp);
+ img.addEventListener("pointercancel", onUp);
+}
diff --git a/web/static/shell.js b/web/static/shell.js
index 2313063..7df5f3b 100644
--- a/web/static/shell.js
+++ b/web/static/shell.js
@@ -137,6 +137,7 @@ import { SearchIndex, snippetFor } from "/static/search.js"; // message search o
import { loadMemberApps, setMemberSeeder, setSurfaceRefresh, setMemberAdmin, recentLocalDeploy, pushTheme } from "/static/sandbox.js"; // member-built apps, run sandboxed (docs/member-apps.md)
import { THEME_META, THEME_ORDER, currentTheme, currentAppearance, setTheme, setAppearance, currentTextSize, setTextSize, onThemeApplied } from "/static/theme.js"; // applies the saved theme/text-size on import
+import { openLightbox } from "/static/lightbox.js";
import { parseInviteFragment, buildInviteFragment } from "/static/invite.js";
import { qrCanvas } from "/static/qrcode.js";
import { initBackupBanner, canRestore, launchRestore, checkRestoreReturn, launchEnrol, isEnrolled, deferBackupNudges } from "/static/backup.js";
@@ -7504,416 +7505,6 @@ async function loadBlob(id, threadId = activeId, type, onProgress) {
}
}
-function openLightbox(url, source) {
- const reduceMotion = matchMedia("(prefers-reduced-motion: reduce)").matches;
- const back = document.createElement("div");
- back.className = "lightbox";
- // The dark backing is a separate layer so we can dim it by animating *opacity*
- // (composited on the GPU, no repaint) instead of recomputing its color per move.
- const backdrop = document.createElement("div");
- backdrop.className = "lightbox-backdrop";
- backdrop.style.opacity = "0"; // faded in by the open animation
- const img = document.createElement("img");
- img.src = url;
- img.draggable = false;
- img.style.visibility = "hidden"; // stays hidden until laid out, so the fly-in has no flash
- back.appendChild(backdrop);
- back.appendChild(img);
-
- let tapTimer = 0; // pending single-tap-to-close, cancelled by a 2nd tap or a drag/pinch
- let closing = false;
- const close = () => {
- clearTimeout(tapTimer);
- back.remove();
- document.removeEventListener("keydown", onKey);
- };
- const onKey = (e) => {
- if (e.key === "Escape") animateClose();
- };
- // Tapping empty space (not the photo) closes — unless a gesture just ran, whose
- // synthesized click we swallow.
- back.addEventListener("click", (e) => {
- if (e.target === img) return;
- if (suppressBackdropClick) {
- suppressBackdropClick = false;
- return;
- }
- animateClose();
- });
- document.addEventListener("keydown", onKey);
- document.body.appendChild(back);
-
- // The photo's rounding is a fixed CSS radius, but the FLIP scales it — so a naive
- // fly-out lands on a corner radius that doesn't match the thumbnail underneath it
- // (a message's rounded corner, or a circular avatar) and snaps at the end. We
- // animate border-radius across the transition so the corners line up at both
- // ends: the source's radius at the thumbnail, the CSS radius at rest. Since the
- // transform scales the element, the CSS radius that *renders* as `r` at scale `s`
- // is `r / s`.
- const restRadius = parseFloat(getComputedStyle(img).borderTopLeftRadius) || 8;
- const sourceRadius = (rect) => {
- if (!source) return restRadius;
- const raw = getComputedStyle(source).borderTopLeftRadius;
- const r = raw.endsWith("%") ? (parseFloat(raw) / 100) * Math.min(rect.width, rect.height) : parseFloat(raw);
- return r || 0;
- };
-
- // --- gesture state -------------------------------------------------------
- // One transform drives everything: translate3d + rotate + scale (GPU-composited).
- // A single finger at 1× flick-dismisses; two fingers pinch-zoom-and-rotate about
- // their midpoint; one finger while zoomed pans; double-tap toggles zoom.
- const MIN_SCALE = 1;
- const MAX_SCALE = 5;
- const TAP_MS = 280; // double-tap window / lone-tap dismiss delay
- let scale = 1;
- let tx = 0;
- let ty = 0;
- let rot = 0; // rotation °: free during a pinch (springs upright), or a 1× drag tumble
- let mode = ""; // "drag" (1× dismiss) | "pan" (zoomed) | "pinch"
- let pinched = false; // did this gesture involve a pinch? if so it settles, never dismisses
- let suppressBackdropClick = false;
- let raf = 0;
-
- // The image's at-rest centre in screen coords — the anchor for all zoom math and
- // the fly-in/out target. Measured whenever we sit at the identity transform.
- let baseCX = 0;
- let baseCY = 0;
- let haveBase = false;
- const setBase = () => {
- const r = img.getBoundingClientRect();
- baseCX = r.left + r.width / 2;
- baseCY = r.top + r.height / 2;
- haveBase = true;
- };
- const ensureBase = () => {
- if (scale === 1 && tx === 0 && ty === 0) setBase();
- };
-
- const pointers = new Map(); // pointerId -> {x, y}
- // single-finger drag/pan tracking
- let sx = 0;
- let sy = 0;
- let panStartTx = 0;
- let panStartTy = 0;
- let lastX = 0;
- let lastY = 0;
- let lastT = 0;
- let vx = 0;
- let vy = 0;
- let moved = false;
- // pinch anchors (captured when the second finger lands)
- let pinchStartDist = 0;
- let pinchStartScale = 1;
- let pinchStartAngle = 0;
- let pinchStartRot = 0;
- let pinchVX = 0;
- let pinchVY = 0;
- // double-tap tracking
- let lastTapT = 0;
- let lastTapX = 0;
- let lastTapY = 0;
-
- const render = () => {
- raf = 0;
- let t = `translate3d(${tx}px, ${ty}px, 0)`;
- if (rot) t += ` rotate(${rot}deg)`;
- if (scale !== 1) t += ` scale(${scale})`;
- img.style.transform = t;
- // The backdrop only dims while dragging a 1× image toward dismissal.
- backdrop.style.opacity =
- mode === "drag" && !pinched ? String(Math.max(0, 1 - Math.hypot(tx, ty) / 700)) : "1";
- };
- const schedule = () => {
- if (!raf) raf = requestAnimationFrame(render);
- };
-
- // Fly the photo up from the thumbnail it was opened from, then ease to rest
- // (a FLIP / shared-element transition). Runs once the image has a size.
- let openStarted = false;
- const runOpenAnim = () => {
- if (openStarted) return;
- openStarted = true;
- img.style.visibility = "";
- setBase();
- const S = source && source.getBoundingClientRect();
- if (!haveBase || !img.clientWidth || !S || !S.width || reduceMotion) {
- backdrop.style.transition = reduceMotion ? "" : "opacity 0.2s";
- backdrop.style.opacity = "1"; // no thumbnail to fly from — just appear
- return;
- }
- const s = Math.max(S.width / img.clientWidth, S.height / img.clientHeight);
- const fromX = S.left + S.width / 2 - baseCX;
- const fromY = S.top + S.height / 2 - baseCY;
- img.style.transition = "none";
- img.style.transform = `translate3d(${fromX}px, ${fromY}px, 0) scale(${s})`;
- img.style.borderRadius = sourceRadius(S) / s + "px"; // match the thumbnail's rounding to start
- void img.offsetWidth; // reflow so the changes below animate from here
- img.style.transition = "transform 0.3s cubic-bezier(0.2,0.8,0.2,1), border-radius 0.3s ease";
- img.style.transform = "translate3d(0,0,0)";
- img.style.borderRadius = restRadius + "px";
- backdrop.style.transition = "opacity 0.3s";
- backdrop.style.opacity = "1";
- };
- if (img.complete && img.naturalWidth) runOpenAnim();
- else {
- img.addEventListener("load", runOpenAnim, { once: true });
- img.addEventListener("error", runOpenAnim, { once: true });
- }
-
- // Reverse of the fly-in: shrink the photo back onto its thumbnail, then remove.
- // Used by taps / Escape / backdrop closes; a fling instead hurls it off-screen.
- const animateClose = () => {
- if (closing) return;
- closing = true;
- clearTimeout(tapTimer);
- if (raf) {
- cancelAnimationFrame(raf);
- raf = 0;
- }
- const S = source && source.getBoundingClientRect();
- if (!haveBase || !S || !S.width || reduceMotion) return close();
- const s = Math.max(S.width / img.clientWidth, S.height / img.clientHeight);
- const toX = S.left + S.width / 2 - baseCX;
- const toY = S.top + S.height / 2 - baseCY;
- img.style.transition = "transform 0.28s cubic-bezier(0.4,0,0.2,1), border-radius 0.28s ease";
- img.style.transform = `translate3d(${toX}px, ${toY}px, 0) scale(${s})`;
- img.style.borderRadius = sourceRadius(S) / s + "px"; // shrink to the thumbnail's rounding, no snap
- backdrop.style.transition = "opacity 0.28s";
- backdrop.style.opacity = "0";
- setTimeout(close, 300);
- };
-
- // Keep the transformed image within reach of the viewport when zoomed.
- const clampPan = () => {
- const maxX = Math.max(0, (img.clientWidth * scale - window.innerWidth) / 2 + 20);
- const maxY = Math.max(0, (img.clientHeight * scale - window.innerHeight) / 2 + 20);
- tx = Math.max(-maxX, Math.min(maxX, tx));
- ty = Math.max(-maxY, Math.min(maxY, ty));
- };
-
- // Animate to a target scale anchored at a screen point (double-tap). Zooming
- // out always recentres.
- const zoomTo = (target, cx, cy) => {
- if (raf) {
- cancelAnimationFrame(raf);
- raf = 0;
- }
- if (target <= MIN_SCALE) {
- scale = 1;
- tx = 0;
- ty = 0;
- } else {
- const vX = (cx - baseCX - tx) / scale;
- const vY = (cy - baseCY - ty) / scale;
- scale = Math.min(MAX_SCALE, target);
- tx = cx - baseCX - scale * vX;
- ty = cy - baseCY - scale * vY;
- clampPan();
- }
- rot = 0;
- img.style.transition = "transform 0.25s cubic-bezier(0.2,0.8,0.2,1)";
- img.style.transform = scale === 1 ? "translate3d(0,0,0)" : `translate3d(${tx}px, ${ty}px, 0) scale(${scale})`;
- backdrop.style.transition = "opacity 0.25s";
- backdrop.style.opacity = "1";
- };
-
- // Ease back to the identity transform (1×, centred, upright).
- const springBack = () => {
- scale = 1;
- tx = 0;
- ty = 0;
- rot = 0;
- img.style.transition = "transform 0.28s cubic-bezier(0.2,0.9,0.2,1)";
- img.style.transform = "translate3d(0,0,0)";
- backdrop.style.transition = "opacity 0.28s";
- backdrop.style.opacity = "1";
- };
-
- // Settle a still-zoomed image: keep the zoom, clamp into view, straighten any
- // rotation the pinch left behind.
- const settleZoom = () => {
- clampPan();
- rot = 0;
- img.style.transition = "transform 0.28s cubic-bezier(0.2,0.9,0.2,1)";
- img.style.transform = `translate3d(${tx}px, ${ty}px, 0) scale(${scale})`;
- backdrop.style.transition = "opacity 0.28s";
- backdrop.style.opacity = "1";
- };
-
- img.addEventListener("pointerdown", (e) => {
- img.setPointerCapture?.(e.pointerId);
- pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
- clearTimeout(tapTimer); // a new touch means this isn't a tap-and-wait dismiss
- if (raf) {
- cancelAnimationFrame(raf);
- raf = 0;
- }
- img.style.transition = "none";
- backdrop.style.transition = "none";
- ensureBase();
- if (pointers.size >= 2) {
- // Second finger down: start a pinch, anchoring the current midpoint + angle.
- const [a, b] = [...pointers.values()];
- pinchStartDist = Math.hypot(a.x - b.x, a.y - b.y) || 1;
- pinchStartScale = scale;
- pinchStartAngle = Math.atan2(b.y - a.y, b.x - a.x);
- pinchStartRot = rot;
- const midX = (a.x + b.x) / 2;
- const midY = (a.y + b.y) / 2;
- pinchVX = (midX - baseCX - tx) / scale;
- pinchVY = (midY - baseCY - ty) / scale;
- mode = "pinch";
- moved = true;
- pinched = true;
- suppressBackdropClick = true;
- } else {
- // First finger of a fresh gesture: pan if zoomed, else a candidate dismiss drag.
- pinched = false;
- sx = e.clientX;
- sy = e.clientY;
- panStartTx = tx;
- panStartTy = ty;
- lastX = e.clientX;
- lastY = e.clientY;
- lastT = e.timeStamp || performance.now();
- vx = vy = 0;
- moved = false;
- rot = 0;
- mode = scale > 1 ? "pan" : "drag";
- }
- });
-
- img.addEventListener("pointermove", (e) => {
- const p = pointers.get(e.pointerId);
- if (!p) return;
- p.x = e.clientX;
- p.y = e.clientY;
- if (mode === "pinch") {
- const pts = [...pointers.values()];
- if (pts.length < 2) return;
- const dist = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y) || 1;
- const midX = (pts[0].x + pts[1].x) / 2;
- const midY = (pts[0].y + pts[1].y) / 2;
- scale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, (pinchStartScale * dist) / pinchStartDist));
- const ang = Math.atan2(pts[1].y - pts[0].y, pts[1].x - pts[0].x);
- rot = pinchStartRot + (ang - pinchStartAngle) * (180 / Math.PI); // free twist
- tx = midX - baseCX - scale * pinchVX;
- ty = midY - baseCY - scale * pinchVY;
- clampPan();
- schedule();
- return;
- }
- if (mode === "pan") {
- tx = panStartTx + (e.clientX - sx);
- ty = panStartTy + (e.clientY - sy);
- clampPan();
- schedule();
- return;
- }
- // mode === "drag": 1× dismiss drag with velocity sampling (px/ms).
- tx = e.clientX - sx;
- ty = e.clientY - sy;
- if (Math.abs(tx) > 6 || Math.abs(ty) > 6) moved = true;
- const now = e.timeStamp || performance.now();
- const dt = now - lastT;
- if (dt > 0) {
- vx = (e.clientX - lastX) / dt;
- vy = (e.clientY - lastY) / dt;
- }
- lastX = e.clientX;
- lastY = e.clientY;
- lastT = now;
- rot = tx / 40;
- schedule();
- });
-
- const armSuppress = () => {
- suppressBackdropClick = true;
- setTimeout(() => (suppressBackdropClick = false), 350);
- };
-
- const flingOrSettle = (e) => {
- // A tap on the photo. A second tap within the window is a double-tap (toggle
- // zoom); otherwise a lone tap dismisses at 1× — but only after the window
- // elapses, so it can't fire before a double-tap is ruled out.
- if (!moved) {
- const now = e.timeStamp || performance.now();
- if (now - lastTapT < TAP_MS && Math.hypot(e.clientX - lastTapX, e.clientY - lastTapY) < 30) {
- lastTapT = 0;
- zoomTo(scale > 1 ? MIN_SCALE : 2.5, e.clientX, e.clientY);
- armSuppress();
- return;
- }
- lastTapT = now;
- lastTapX = e.clientX;
- lastTapY = e.clientY;
- if (scale === 1) tapTimer = setTimeout(animateClose, TAP_MS); // lone tap → dismiss into place
- return;
- }
- // Anything that involved a pinch, or ended zoomed, settles — it never dismisses,
- // so pinching back down to 1× can't fling the photo away by accident.
- if (pinched || mode === "pan" || scale > 1) {
- if (scale <= 1.001) springBack();
- else settleZoom();
- armSuppress();
- return;
- }
- // Clean 1× drag: spring back, or fling off with momentum.
- const speed = Math.hypot(vx, vy); // px/ms
- if (speed < 0.55 && Math.hypot(tx, ty) < 130) {
- springBack();
- return;
- }
- // Fling: continue in the release direction (fall back to the drag direction if it
- // was a slow long drag), far enough to clear the screen, backdrop fading out.
- let dx = vx;
- let dy = vy;
- if (speed < 0.05) {
- dx = tx;
- dy = ty;
- }
- const norm = Math.hypot(dx, dy) || 1;
- const reach = Math.max(window.innerWidth, window.innerHeight) * 1.25;
- const fx = tx + (dx / norm) * reach;
- const fy = ty + (dy / norm) * reach;
- const spin = Math.max(-65, Math.min(65, fx / 20)); // tumbles more as it leaves
- closing = true;
- img.style.transition = "transform 0.5s cubic-bezier(0.25,0.8,0.35,1)";
- img.style.transform = `translate3d(${fx}px, ${fy}px, 0) rotate(${spin}deg)`;
- backdrop.style.transition = "opacity 0.45s";
- backdrop.style.opacity = "0";
- setTimeout(close, 480);
- };
-
- const onUp = (e) => {
- if (!pointers.has(e.pointerId)) return;
- pointers.delete(e.pointerId);
- img.releasePointerCapture?.(e.pointerId);
- if (raf) {
- cancelAnimationFrame(raf);
- raf = 0;
- }
- if (pointers.size === 1) {
- // Dropped from a pinch to one finger — hand off to pan/drag without a jump.
- const [rp] = [...pointers.values()];
- sx = rp.x;
- sy = rp.y;
- panStartTx = tx;
- panStartTy = ty;
- lastX = rp.x;
- lastY = rp.y;
- lastT = e.timeStamp || performance.now();
- vx = vy = 0;
- moved = true; // came from a pinch; not a tap
- mode = scale > 1 ? "pan" : "drag";
- return;
- }
- if (pointers.size >= 1) return;
- flingOrSettle(e);
- };
- img.addEventListener("pointerup", onUp);
- img.addEventListener("pointercancel", onUp);
-}
// ---- websocket ----------------------------------------------------------