// 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); }