| 1 | // The shared photo lightbox: full-screen viewer with the FLIP fly-in/out, |
| 2 | // pinch-zoom-and-rotate, pan, flick-to-dismiss, and double-tap zoom. One copy, |
| 3 | // two callers — chat.js (message photos, GIF stills) and shell.js (the header |
| 4 | // avatar's full photo). Self-contained: everything it needs rides in the call |
| 5 | // (the image URL + the source element whose corner radius the fly-out must |
| 6 | // land on). Extracted from the twin verbatim copies both files carried. |
| 7 | export function openLightbox(url, source) { |
| 8 | const reduceMotion = matchMedia("(prefers-reduced-motion: reduce)").matches; |
| 9 | const back = document.createElement("div"); |
| 10 | back.className = "lightbox"; |
| 11 | // The dark backing is a separate layer so we can dim it by animating *opacity* |
| 12 | // (composited on the GPU, no repaint) instead of recomputing its color per move. |
| 13 | const backdrop = document.createElement("div"); |
| 14 | backdrop.className = "lightbox-backdrop"; |
| 15 | backdrop.style.opacity = "0"; // faded in by the open animation |
| 16 | const img = document.createElement("img"); |
| 17 | img.src = url; |
| 18 | img.draggable = false; |
| 19 | img.style.visibility = "hidden"; // stays hidden until laid out, so the fly-in has no flash |
| 20 | back.appendChild(backdrop); |
| 21 | back.appendChild(img); |
| 22 | |
| 23 | let tapTimer = 0; // pending single-tap-to-close, cancelled by a 2nd tap or a drag/pinch |
| 24 | let closing = false; |
| 25 | const close = () => { |
| 26 | clearTimeout(tapTimer); |
| 27 | back.remove(); |
| 28 | document.removeEventListener("keydown", onKey); |
| 29 | }; |
| 30 | const onKey = (e) => { |
| 31 | if (e.key === "Escape") animateClose(); |
| 32 | }; |
| 33 | // Tapping empty space (not the photo) closes — unless a gesture just ran, whose |
| 34 | // synthesized click we swallow. |
| 35 | back.addEventListener("click", (e) => { |
| 36 | if (e.target === img) return; |
| 37 | if (suppressBackdropClick) { |
| 38 | suppressBackdropClick = false; |
| 39 | return; |
| 40 | } |
| 41 | animateClose(); |
| 42 | }); |
| 43 | document.addEventListener("keydown", onKey); |
| 44 | document.body.appendChild(back); |
| 45 | |
| 46 | // The photo's rounding is a fixed CSS radius, but the FLIP scales it — so a naive |
| 47 | // fly-out lands on a corner radius that doesn't match the thumbnail underneath it |
| 48 | // (a message's rounded corner, or a circular avatar) and snaps at the end. We |
| 49 | // animate border-radius across the transition so the corners line up at both |
| 50 | // ends: the source's radius at the thumbnail, the CSS radius at rest. Since the |
| 51 | // transform scales the element, the CSS radius that *renders* as `r` at scale `s` |
| 52 | // is `r / s`. |
| 53 | const restRadius = parseFloat(getComputedStyle(img).borderTopLeftRadius) || 8; |
| 54 | const sourceRadius = (rect) => { |
| 55 | if (!source) return restRadius; |
| 56 | const raw = getComputedStyle(source).borderTopLeftRadius; |
| 57 | const r = raw.endsWith("%") ? (parseFloat(raw) / 100) * Math.min(rect.width, rect.height) : parseFloat(raw); |
| 58 | return r || 0; |
| 59 | }; |
| 60 | |
| 61 | // --- gesture state ------------------------------------------------------- |
| 62 | // One transform drives everything: translate3d + rotate + scale (GPU-composited). |
| 63 | // A single finger at 1× flick-dismisses; two fingers pinch-zoom-and-rotate about |
| 64 | // their midpoint; one finger while zoomed pans; double-tap toggles zoom. |
| 65 | const MIN_SCALE = 1; |
| 66 | const MAX_SCALE = 5; |
| 67 | const TAP_MS = 280; // double-tap window / lone-tap dismiss delay |
| 68 | let scale = 1; |
| 69 | let tx = 0; |
| 70 | let ty = 0; |
| 71 | let rot = 0; // rotation °: free during a pinch (springs upright), or a 1× drag tumble |
| 72 | let mode = ""; // "drag" (1× dismiss) | "pan" (zoomed) | "pinch" |
| 73 | let pinched = false; // did this gesture involve a pinch? if so it settles, never dismisses |
| 74 | let suppressBackdropClick = false; |
| 75 | let raf = 0; |
| 76 | |
| 77 | // The image's at-rest centre in screen coords — the anchor for all zoom math and |
| 78 | // the fly-in/out target. Measured whenever we sit at the identity transform. |
| 79 | let baseCX = 0; |
| 80 | let baseCY = 0; |
| 81 | let haveBase = false; |
| 82 | const setBase = () => { |
| 83 | const r = img.getBoundingClientRect(); |
| 84 | baseCX = r.left + r.width / 2; |
| 85 | baseCY = r.top + r.height / 2; |
| 86 | haveBase = true; |
| 87 | }; |
| 88 | const ensureBase = () => { |
| 89 | if (scale === 1 && tx === 0 && ty === 0) setBase(); |
| 90 | }; |
| 91 | |
| 92 | const pointers = new Map(); // pointerId -> {x, y} |
| 93 | // single-finger drag/pan tracking |
| 94 | let sx = 0; |
| 95 | let sy = 0; |
| 96 | let panStartTx = 0; |
| 97 | let panStartTy = 0; |
| 98 | let lastX = 0; |
| 99 | let lastY = 0; |
| 100 | let lastT = 0; |
| 101 | let vx = 0; |
| 102 | let vy = 0; |
| 103 | let moved = false; |
| 104 | // pinch anchors (captured when the second finger lands) |
| 105 | let pinchStartDist = 0; |
| 106 | let pinchStartScale = 1; |
| 107 | let pinchStartAngle = 0; |
| 108 | let pinchStartRot = 0; |
| 109 | let pinchVX = 0; |
| 110 | let pinchVY = 0; |
| 111 | // double-tap tracking |
| 112 | let lastTapT = 0; |
| 113 | let lastTapX = 0; |
| 114 | let lastTapY = 0; |
| 115 | |
| 116 | const render = () => { |
| 117 | raf = 0; |
| 118 | let t = `translate3d(${tx}px, ${ty}px, 0)`; |
| 119 | if (rot) t += ` rotate(${rot}deg)`; |
| 120 | if (scale !== 1) t += ` scale(${scale})`; |
| 121 | img.style.transform = t; |
| 122 | // The backdrop only dims while dragging a 1× image toward dismissal. |
| 123 | backdrop.style.opacity = |
| 124 | mode === "drag" && !pinched ? String(Math.max(0, 1 - Math.hypot(tx, ty) / 700)) : "1"; |
| 125 | }; |
| 126 | const schedule = () => { |
| 127 | if (!raf) raf = requestAnimationFrame(render); |
| 128 | }; |
| 129 | |
| 130 | // Fly the photo up from the thumbnail it was opened from, then ease to rest |
| 131 | // (a FLIP / shared-element transition). Runs once the image has a size. |
| 132 | let openStarted = false; |
| 133 | const runOpenAnim = () => { |
| 134 | if (openStarted) return; |
| 135 | openStarted = true; |
| 136 | img.style.visibility = ""; |
| 137 | setBase(); |
| 138 | const S = source && source.getBoundingClientRect(); |
| 139 | if (!haveBase || !img.clientWidth || !S || !S.width || reduceMotion) { |
| 140 | backdrop.style.transition = reduceMotion ? "" : "opacity 0.2s"; |
| 141 | backdrop.style.opacity = "1"; // no thumbnail to fly from — just appear |
| 142 | return; |
| 143 | } |
| 144 | const s = Math.max(S.width / img.clientWidth, S.height / img.clientHeight); |
| 145 | const fromX = S.left + S.width / 2 - baseCX; |
| 146 | const fromY = S.top + S.height / 2 - baseCY; |
| 147 | img.style.transition = "none"; |
| 148 | img.style.transform = `translate3d(${fromX}px, ${fromY}px, 0) scale(${s})`; |
| 149 | img.style.borderRadius = sourceRadius(S) / s + "px"; // match the thumbnail's rounding to start |
| 150 | void img.offsetWidth; // reflow so the changes below animate from here |
| 151 | img.style.transition = "transform 0.3s cubic-bezier(0.2,0.8,0.2,1), border-radius 0.3s ease"; |
| 152 | img.style.transform = "translate3d(0,0,0)"; |
| 153 | img.style.borderRadius = restRadius + "px"; |
| 154 | backdrop.style.transition = "opacity 0.3s"; |
| 155 | backdrop.style.opacity = "1"; |
| 156 | }; |
| 157 | if (img.complete && img.naturalWidth) runOpenAnim(); |
| 158 | else { |
| 159 | img.addEventListener("load", runOpenAnim, { once: true }); |
| 160 | img.addEventListener("error", runOpenAnim, { once: true }); |
| 161 | } |
| 162 | |
| 163 | // Reverse of the fly-in: shrink the photo back onto its thumbnail, then remove. |
| 164 | // Used by taps / Escape / backdrop closes; a fling instead hurls it off-screen. |
| 165 | const animateClose = () => { |
| 166 | if (closing) return; |
| 167 | closing = true; |
| 168 | clearTimeout(tapTimer); |
| 169 | if (raf) { |
| 170 | cancelAnimationFrame(raf); |
| 171 | raf = 0; |
| 172 | } |
| 173 | const S = source && source.getBoundingClientRect(); |
| 174 | if (!haveBase || !S || !S.width || reduceMotion) return close(); |
| 175 | const s = Math.max(S.width / img.clientWidth, S.height / img.clientHeight); |
| 176 | const toX = S.left + S.width / 2 - baseCX; |
| 177 | const toY = S.top + S.height / 2 - baseCY; |
| 178 | img.style.transition = "transform 0.28s cubic-bezier(0.4,0,0.2,1), border-radius 0.28s ease"; |
| 179 | img.style.transform = `translate3d(${toX}px, ${toY}px, 0) scale(${s})`; |
| 180 | img.style.borderRadius = sourceRadius(S) / s + "px"; // shrink to the thumbnail's rounding, no snap |
| 181 | backdrop.style.transition = "opacity 0.28s"; |
| 182 | backdrop.style.opacity = "0"; |
| 183 | setTimeout(close, 300); |
| 184 | }; |
| 185 | |
| 186 | // Keep the transformed image within reach of the viewport when zoomed. |
| 187 | const clampPan = () => { |
| 188 | const maxX = Math.max(0, (img.clientWidth * scale - window.innerWidth) / 2 + 20); |
| 189 | const maxY = Math.max(0, (img.clientHeight * scale - window.innerHeight) / 2 + 20); |
| 190 | tx = Math.max(-maxX, Math.min(maxX, tx)); |
| 191 | ty = Math.max(-maxY, Math.min(maxY, ty)); |
| 192 | }; |
| 193 | |
| 194 | // Animate to a target scale anchored at a screen point (double-tap). Zooming |
| 195 | // out always recentres. |
| 196 | const zoomTo = (target, cx, cy) => { |
| 197 | if (raf) { |
| 198 | cancelAnimationFrame(raf); |
| 199 | raf = 0; |
| 200 | } |
| 201 | if (target <= MIN_SCALE) { |
| 202 | scale = 1; |
| 203 | tx = 0; |
| 204 | ty = 0; |
| 205 | } else { |
| 206 | const vX = (cx - baseCX - tx) / scale; |
| 207 | const vY = (cy - baseCY - ty) / scale; |
| 208 | scale = Math.min(MAX_SCALE, target); |
| 209 | tx = cx - baseCX - scale * vX; |
| 210 | ty = cy - baseCY - scale * vY; |
| 211 | clampPan(); |
| 212 | } |
| 213 | rot = 0; |
| 214 | img.style.transition = "transform 0.25s cubic-bezier(0.2,0.8,0.2,1)"; |
| 215 | img.style.transform = scale === 1 ? "translate3d(0,0,0)" : `translate3d(${tx}px, ${ty}px, 0) scale(${scale})`; |
| 216 | backdrop.style.transition = "opacity 0.25s"; |
| 217 | backdrop.style.opacity = "1"; |
| 218 | }; |
| 219 | |
| 220 | // Ease back to the identity transform (1×, centred, upright). |
| 221 | const springBack = () => { |
| 222 | scale = 1; |
| 223 | tx = 0; |
| 224 | ty = 0; |
| 225 | rot = 0; |
| 226 | img.style.transition = "transform 0.28s cubic-bezier(0.2,0.9,0.2,1)"; |
| 227 | img.style.transform = "translate3d(0,0,0)"; |
| 228 | backdrop.style.transition = "opacity 0.28s"; |
| 229 | backdrop.style.opacity = "1"; |
| 230 | }; |
| 231 | |
| 232 | // Settle a still-zoomed image: keep the zoom, clamp into view, straighten any |
| 233 | // rotation the pinch left behind. |
| 234 | const settleZoom = () => { |
| 235 | clampPan(); |
| 236 | rot = 0; |
| 237 | img.style.transition = "transform 0.28s cubic-bezier(0.2,0.9,0.2,1)"; |
| 238 | img.style.transform = `translate3d(${tx}px, ${ty}px, 0) scale(${scale})`; |
| 239 | backdrop.style.transition = "opacity 0.28s"; |
| 240 | backdrop.style.opacity = "1"; |
| 241 | }; |
| 242 | |
| 243 | img.addEventListener("pointerdown", (e) => { |
| 244 | img.setPointerCapture?.(e.pointerId); |
| 245 | pointers.set(e.pointerId, { x: e.clientX, y: e.clientY }); |
| 246 | clearTimeout(tapTimer); // a new touch means this isn't a tap-and-wait dismiss |
| 247 | if (raf) { |
| 248 | cancelAnimationFrame(raf); |
| 249 | raf = 0; |
| 250 | } |
| 251 | img.style.transition = "none"; |
| 252 | backdrop.style.transition = "none"; |
| 253 | ensureBase(); |
| 254 | if (pointers.size >= 2) { |
| 255 | // Second finger down: start a pinch, anchoring the current midpoint + angle. |
| 256 | const [a, b] = [...pointers.values()]; |
| 257 | pinchStartDist = Math.hypot(a.x - b.x, a.y - b.y) || 1; |
| 258 | pinchStartScale = scale; |
| 259 | pinchStartAngle = Math.atan2(b.y - a.y, b.x - a.x); |
| 260 | pinchStartRot = rot; |
| 261 | const midX = (a.x + b.x) / 2; |
| 262 | const midY = (a.y + b.y) / 2; |
| 263 | pinchVX = (midX - baseCX - tx) / scale; |
| 264 | pinchVY = (midY - baseCY - ty) / scale; |
| 265 | mode = "pinch"; |
| 266 | moved = true; |
| 267 | pinched = true; |
| 268 | suppressBackdropClick = true; |
| 269 | } else { |
| 270 | // First finger of a fresh gesture: pan if zoomed, else a candidate dismiss drag. |
| 271 | pinched = false; |
| 272 | sx = e.clientX; |
| 273 | sy = e.clientY; |
| 274 | panStartTx = tx; |
| 275 | panStartTy = ty; |
| 276 | lastX = e.clientX; |
| 277 | lastY = e.clientY; |
| 278 | lastT = e.timeStamp || performance.now(); |
| 279 | vx = vy = 0; |
| 280 | moved = false; |
| 281 | rot = 0; |
| 282 | mode = scale > 1 ? "pan" : "drag"; |
| 283 | } |
| 284 | }); |
| 285 | |
| 286 | img.addEventListener("pointermove", (e) => { |
| 287 | const p = pointers.get(e.pointerId); |
| 288 | if (!p) return; |
| 289 | p.x = e.clientX; |
| 290 | p.y = e.clientY; |
| 291 | if (mode === "pinch") { |
| 292 | const pts = [...pointers.values()]; |
| 293 | if (pts.length < 2) return; |
| 294 | const dist = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y) || 1; |
| 295 | const midX = (pts[0].x + pts[1].x) / 2; |
| 296 | const midY = (pts[0].y + pts[1].y) / 2; |
| 297 | scale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, (pinchStartScale * dist) / pinchStartDist)); |
| 298 | const ang = Math.atan2(pts[1].y - pts[0].y, pts[1].x - pts[0].x); |
| 299 | rot = pinchStartRot + (ang - pinchStartAngle) * (180 / Math.PI); // free twist |
| 300 | tx = midX - baseCX - scale * pinchVX; |
| 301 | ty = midY - baseCY - scale * pinchVY; |
| 302 | clampPan(); |
| 303 | schedule(); |
| 304 | return; |
| 305 | } |
| 306 | if (mode === "pan") { |
| 307 | tx = panStartTx + (e.clientX - sx); |
| 308 | ty = panStartTy + (e.clientY - sy); |
| 309 | clampPan(); |
| 310 | schedule(); |
| 311 | return; |
| 312 | } |
| 313 | // mode === "drag": 1× dismiss drag with velocity sampling (px/ms). |
| 314 | tx = e.clientX - sx; |
| 315 | ty = e.clientY - sy; |
| 316 | if (Math.abs(tx) > 6 || Math.abs(ty) > 6) moved = true; |
| 317 | const now = e.timeStamp || performance.now(); |
| 318 | const dt = now - lastT; |
| 319 | if (dt > 0) { |
| 320 | vx = (e.clientX - lastX) / dt; |
| 321 | vy = (e.clientY - lastY) / dt; |
| 322 | } |
| 323 | lastX = e.clientX; |
| 324 | lastY = e.clientY; |
| 325 | lastT = now; |
| 326 | rot = tx / 40; |
| 327 | schedule(); |
| 328 | }); |
| 329 | |
| 330 | const armSuppress = () => { |
| 331 | suppressBackdropClick = true; |
| 332 | setTimeout(() => (suppressBackdropClick = false), 350); |
| 333 | }; |
| 334 | |
| 335 | const flingOrSettle = (e) => { |
| 336 | // A tap on the photo. A second tap within the window is a double-tap (toggle |
| 337 | // zoom); otherwise a lone tap dismisses at 1× — but only after the window |
| 338 | // elapses, so it can't fire before a double-tap is ruled out. |
| 339 | if (!moved) { |
| 340 | const now = e.timeStamp || performance.now(); |
| 341 | if (now - lastTapT < TAP_MS && Math.hypot(e.clientX - lastTapX, e.clientY - lastTapY) < 30) { |
| 342 | lastTapT = 0; |
| 343 | zoomTo(scale > 1 ? MIN_SCALE : 2.5, e.clientX, e.clientY); |
| 344 | armSuppress(); |
| 345 | return; |
| 346 | } |
| 347 | lastTapT = now; |
| 348 | lastTapX = e.clientX; |
| 349 | lastTapY = e.clientY; |
| 350 | if (scale === 1) tapTimer = setTimeout(animateClose, TAP_MS); // lone tap → dismiss into place |
| 351 | return; |
| 352 | } |
| 353 | // Anything that involved a pinch, or ended zoomed, settles — it never dismisses, |
| 354 | // so pinching back down to 1× can't fling the photo away by accident. |
| 355 | if (pinched || mode === "pan" || scale > 1) { |
| 356 | if (scale <= 1.001) springBack(); |
| 357 | else settleZoom(); |
| 358 | armSuppress(); |
| 359 | return; |
| 360 | } |
| 361 | // Clean 1× drag: spring back, or fling off with momentum. |
| 362 | const speed = Math.hypot(vx, vy); // px/ms |
| 363 | if (speed < 0.55 && Math.hypot(tx, ty) < 130) { |
| 364 | springBack(); |
| 365 | return; |
| 366 | } |
| 367 | // Fling: continue in the release direction (fall back to the drag direction if it |
| 368 | // was a slow long drag), far enough to clear the screen, backdrop fading out. |
| 369 | let dx = vx; |
| 370 | let dy = vy; |
| 371 | if (speed < 0.05) { |
| 372 | dx = tx; |
| 373 | dy = ty; |
| 374 | } |
| 375 | const norm = Math.hypot(dx, dy) || 1; |
| 376 | const reach = Math.max(window.innerWidth, window.innerHeight) * 1.25; |
| 377 | const fx = tx + (dx / norm) * reach; |
| 378 | const fy = ty + (dy / norm) * reach; |
| 379 | const spin = Math.max(-65, Math.min(65, fx / 20)); // tumbles more as it leaves |
| 380 | closing = true; |
| 381 | img.style.transition = "transform 0.5s cubic-bezier(0.25,0.8,0.35,1)"; |
| 382 | img.style.transform = `translate3d(${fx}px, ${fy}px, 0) rotate(${spin}deg)`; |
| 383 | backdrop.style.transition = "opacity 0.45s"; |
| 384 | backdrop.style.opacity = "0"; |
| 385 | setTimeout(close, 480); |
| 386 | }; |
| 387 | |
| 388 | const onUp = (e) => { |
| 389 | if (!pointers.has(e.pointerId)) return; |
| 390 | pointers.delete(e.pointerId); |
| 391 | img.releasePointerCapture?.(e.pointerId); |
| 392 | if (raf) { |
| 393 | cancelAnimationFrame(raf); |
| 394 | raf = 0; |
| 395 | } |
| 396 | if (pointers.size === 1) { |
| 397 | // Dropped from a pinch to one finger — hand off to pan/drag without a jump. |
| 398 | const [rp] = [...pointers.values()]; |
| 399 | sx = rp.x; |
| 400 | sy = rp.y; |
| 401 | panStartTx = tx; |
| 402 | panStartTy = ty; |
| 403 | lastX = rp.x; |
| 404 | lastY = rp.y; |
| 405 | lastT = e.timeStamp || performance.now(); |
| 406 | vx = vy = 0; |
| 407 | moved = true; // came from a pinch; not a tap |
| 408 | mode = scale > 1 ? "pan" : "drag"; |
| 409 | return; |
| 410 | } |
| 411 | if (pointers.size >= 1) return; |
| 412 | flingOrSettle(e); |
| 413 | }; |
| 414 | img.addEventListener("pointerup", onUp); |
| 415 | img.addEventListener("pointercancel", onUp); |
| 416 | } |
| 417 | |