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>
4 files changed,
+421
−820
CLAUDE.md+3 −0web/static/apps/chat.js+1 −410web/static/lightbox.js+416 −0web/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 ---------------------------------------------------------- |