| 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 | |
| 8 | // Pure decision helpers for collection mode, exported for Node tests. |
| 9 | // A horizontal gesture navigates; vertical keeps the flick-dismiss. Thresholds |
| 10 | // mirror the dismiss fling's own (speed 0.55 px/ms, reach 130px). |
| 11 | export function stepIndex(i, delta, len) { |
| 12 | return Math.max(0, Math.min(len - 1, i + delta)); |
| 13 | } |
| 14 | |
| 15 | export function dragIntent(tx, ty, vx, vy) { |
| 16 | const horizontal = Math.abs(tx) > Math.abs(ty) * 1.5; |
| 17 | if (horizontal && (Math.abs(tx) > 80 || Math.abs(vx) > 0.5)) return tx < 0 ? "nav-next" : "nav-prev"; |
| 18 | if (Math.hypot(vx, vy) >= 0.55 || Math.hypot(tx, ty) >= 130) return "dismiss"; |
| 19 | return "settle"; |
| 20 | } |
| 21 | |
| 22 | export function openLightbox(url, source, opts = {}) { |
| 23 | const reduceMotion = matchMedia("(prefers-reduced-motion: reduce)").matches; |
| 24 | const back = document.createElement("div"); |
| 25 | back.className = "lightbox"; |
| 26 | // The dark backing is a separate layer so we can dim it by animating *opacity* |
| 27 | // (composited on the GPU, no repaint) instead of recomputing its color per move. |
| 28 | const backdrop = document.createElement("div"); |
| 29 | backdrop.className = "lightbox-backdrop"; |
| 30 | backdrop.style.opacity = "0"; // faded in by the open animation |
| 31 | const img = document.createElement("img"); |
| 32 | img.src = url; |
| 33 | img.draggable = false; |
| 34 | img.style.visibility = "hidden"; // stays hidden until laid out, so the fly-in has no flash |
| 35 | back.appendChild(backdrop); |
| 36 | back.appendChild(img); |
| 37 | |
| 38 | // ---- collection controls: prev/next buttons, only when opts.collection ---- |
| 39 | // Built here (gated on the raw opts.collection, not the `coll` binding below, |
| 40 | // which doesn't exist yet) but wired to `navTo`/`paintNav` by closure — those |
| 41 | // are defined further down and only ever called after a user interaction, by |
| 42 | // which point the bindings are live (same forward-reference pattern `onKey` |
| 43 | // already uses to call `animateClose`). |
| 44 | let navPrev = null; |
| 45 | let navNext = null; |
| 46 | if (opts.collection) { |
| 47 | navPrev = document.createElement("button"); |
| 48 | navPrev.type = "button"; |
| 49 | navPrev.className = "lightbox-nav lightbox-nav-prev"; |
| 50 | navPrev.setAttribute("aria-label", "Previous photo"); |
| 51 | navPrev.textContent = "‹"; // ‹ |
| 52 | navPrev.onclick = () => navTo(-1); |
| 53 | back.appendChild(navPrev); |
| 54 | navNext = document.createElement("button"); |
| 55 | navNext.type = "button"; |
| 56 | navNext.className = "lightbox-nav lightbox-nav-next"; |
| 57 | navNext.setAttribute("aria-label", "Next photo"); |
| 58 | navNext.textContent = "›"; // › |
| 59 | navNext.onclick = () => navTo(1); |
| 60 | back.appendChild(navNext); |
| 61 | } |
| 62 | |
| 63 | let tapTimer = 0; // pending single-tap-to-close, cancelled by a 2nd tap or a drag/pinch |
| 64 | let closing = false; |
| 65 | const close = () => { |
| 66 | clearTimeout(tapTimer); |
| 67 | back.remove(); |
| 68 | document.removeEventListener("keydown", onKey); |
| 69 | }; |
| 70 | const onKey = (e) => { |
| 71 | if (e.key === "Escape") animateClose(); |
| 72 | else if (coll && e.key === "ArrowLeft") navTo(-1); |
| 73 | else if (coll && e.key === "ArrowRight") navTo(1); |
| 74 | }; |
| 75 | // Tapping empty space (not the photo) closes — unless a gesture just ran, whose |
| 76 | // synthesized click we swallow. |
| 77 | back.addEventListener("click", (e) => { |
| 78 | if (e.target === img) return; |
| 79 | if (suppressBackdropClick) { |
| 80 | suppressBackdropClick = false; |
| 81 | return; |
| 82 | } |
| 83 | animateClose(); |
| 84 | }); |
| 85 | document.addEventListener("keydown", onKey); |
| 86 | document.body.appendChild(back); |
| 87 | |
| 88 | // The photo's rounding is a fixed CSS radius, but the FLIP scales it — so a naive |
| 89 | // fly-out lands on a corner radius that doesn't match the thumbnail underneath it |
| 90 | // (a message's rounded corner, or a circular avatar) and snaps at the end. We |
| 91 | // animate border-radius across the transition so the corners line up at both |
| 92 | // ends: the source's radius at the thumbnail, the CSS radius at rest. Since the |
| 93 | // transform scales the element, the CSS radius that *renders* as `r` at scale `s` |
| 94 | // is `r / s`. |
| 95 | const restRadius = parseFloat(getComputedStyle(img).borderTopLeftRadius) || 8; |
| 96 | // Takes the element explicitly (rather than closing over `source`) so both the |
| 97 | // two-arg path and collection mode's per-item `liveSource()` can share it. |
| 98 | const sourceRadius = (rect, el) => { |
| 99 | if (!el) return restRadius; |
| 100 | const raw = getComputedStyle(el).borderTopLeftRadius; |
| 101 | const r = raw.endsWith("%") ? (parseFloat(raw) / 100) * Math.min(rect.width, rect.height) : parseFloat(raw); |
| 102 | return r || 0; |
| 103 | }; |
| 104 | |
| 105 | // --- gesture state ------------------------------------------------------- |
| 106 | // One transform drives everything: translate3d + rotate + scale (GPU-composited). |
| 107 | // A single finger at 1× flick-dismisses; two fingers pinch-zoom-and-rotate about |
| 108 | // their midpoint; one finger while zoomed pans; double-tap toggles zoom. |
| 109 | const MIN_SCALE = 1; |
| 110 | const MAX_SCALE = 5; |
| 111 | const TAP_MS = 280; // double-tap window / lone-tap dismiss delay |
| 112 | let scale = 1; |
| 113 | let tx = 0; |
| 114 | let ty = 0; |
| 115 | let rot = 0; // rotation °: free during a pinch (springs upright), or a 1× drag tumble |
| 116 | let mode = ""; // "drag" (1× dismiss) | "pan" (zoomed) | "pinch" |
| 117 | let pinched = false; // did this gesture involve a pinch? if so it settles, never dismisses |
| 118 | let suppressBackdropClick = false; |
| 119 | let raf = 0; |
| 120 | |
| 121 | // The image's at-rest centre in screen coords — the anchor for all zoom math and |
| 122 | // the fly-in/out target. Measured whenever we sit at the identity transform. |
| 123 | let baseCX = 0; |
| 124 | let baseCY = 0; |
| 125 | let haveBase = false; |
| 126 | const setBase = () => { |
| 127 | const r = img.getBoundingClientRect(); |
| 128 | baseCX = r.left + r.width / 2; |
| 129 | baseCY = r.top + r.height / 2; |
| 130 | haveBase = true; |
| 131 | }; |
| 132 | const ensureBase = () => { |
| 133 | if (scale === 1 && tx === 0 && ty === 0) setBase(); |
| 134 | }; |
| 135 | |
| 136 | const pointers = new Map(); // pointerId -> {x, y} |
| 137 | // single-finger drag/pan tracking |
| 138 | let sx = 0; |
| 139 | let sy = 0; |
| 140 | let panStartTx = 0; |
| 141 | let panStartTy = 0; |
| 142 | let lastX = 0; |
| 143 | let lastY = 0; |
| 144 | let lastT = 0; |
| 145 | let vx = 0; |
| 146 | let vy = 0; |
| 147 | let moved = false; |
| 148 | // pinch anchors (captured when the second finger lands) |
| 149 | let pinchStartDist = 0; |
| 150 | let pinchStartScale = 1; |
| 151 | let pinchStartAngle = 0; |
| 152 | let pinchStartRot = 0; |
| 153 | let pinchVX = 0; |
| 154 | let pinchVY = 0; |
| 155 | // double-tap tracking |
| 156 | let lastTapT = 0; |
| 157 | let lastTapX = 0; |
| 158 | let lastTapY = 0; |
| 159 | |
| 160 | // ---- collection mode (opts.collection): swipe/arrow between items ------- |
| 161 | const coll = opts.collection || null; |
| 162 | let collIndex = coll ? coll.index : 0; |
| 163 | const liveSource = () => { |
| 164 | // The fly-out targets the CURRENT item's tile when the grid still shows |
| 165 | // it; falls back to the original tap's element (or none — plain fade). |
| 166 | if (!coll) return source; |
| 167 | const el = coll.items[collIndex].source && coll.items[collIndex].source(); |
| 168 | return el && el.isConnected ? el : source; |
| 169 | }; |
| 170 | const paintNav = () => { |
| 171 | if (!coll) return; |
| 172 | navPrev.classList.toggle("hidden", collIndex === 0); |
| 173 | navNext.classList.toggle("hidden", collIndex === coll.items.length - 1); |
| 174 | }; |
| 175 | |
| 176 | const render = () => { |
| 177 | raf = 0; |
| 178 | let t = `translate3d(${tx}px, ${ty}px, 0)`; |
| 179 | if (rot) t += ` rotate(${rot}deg)`; |
| 180 | if (scale !== 1) t += ` scale(${scale})`; |
| 181 | img.style.transform = t; |
| 182 | // The backdrop only dims while dragging a 1× image toward dismissal. |
| 183 | backdrop.style.opacity = |
| 184 | mode === "drag" && !pinched ? String(Math.max(0, 1 - Math.hypot(tx, ty) / 700)) : "1"; |
| 185 | }; |
| 186 | const schedule = () => { |
| 187 | if (!raf) raf = requestAnimationFrame(render); |
| 188 | }; |
| 189 | |
| 190 | // Fly the photo up from the thumbnail it was opened from, then ease to rest |
| 191 | // (a FLIP / shared-element transition). Runs once the image has a size. |
| 192 | let openStarted = false; |
| 193 | const runOpenAnim = () => { |
| 194 | if (openStarted) return; |
| 195 | openStarted = true; |
| 196 | img.style.visibility = ""; |
| 197 | setBase(); |
| 198 | const src = liveSource(); |
| 199 | const S = src && src.getBoundingClientRect(); |
| 200 | if (!haveBase || !img.clientWidth || !S || !S.width || reduceMotion) { |
| 201 | backdrop.style.transition = reduceMotion ? "" : "opacity 0.2s"; |
| 202 | backdrop.style.opacity = "1"; // no thumbnail to fly from — just appear |
| 203 | return; |
| 204 | } |
| 205 | const s = Math.max(S.width / img.clientWidth, S.height / img.clientHeight); |
| 206 | const fromX = S.left + S.width / 2 - baseCX; |
| 207 | const fromY = S.top + S.height / 2 - baseCY; |
| 208 | img.style.transition = "none"; |
| 209 | img.style.transform = `translate3d(${fromX}px, ${fromY}px, 0) scale(${s})`; |
| 210 | img.style.borderRadius = sourceRadius(S, src) / s + "px"; // match the thumbnail's rounding to start |
| 211 | void img.offsetWidth; // reflow so the changes below animate from here |
| 212 | img.style.transition = "transform 0.3s cubic-bezier(0.2,0.8,0.2,1), border-radius 0.3s ease"; |
| 213 | img.style.transform = "translate3d(0,0,0)"; |
| 214 | img.style.borderRadius = restRadius + "px"; |
| 215 | backdrop.style.transition = "opacity 0.3s"; |
| 216 | backdrop.style.opacity = "1"; |
| 217 | }; |
| 218 | if (img.complete && img.naturalWidth) runOpenAnim(); |
| 219 | else { |
| 220 | img.addEventListener("load", runOpenAnim, { once: true }); |
| 221 | img.addEventListener("error", runOpenAnim, { once: true }); |
| 222 | } |
| 223 | if (coll) { |
| 224 | paintNav(); |
| 225 | // Preload both neighbours once up front so a swipe right after open usually |
| 226 | // lands on an already-decrypted URL instead of waiting on `.load()`. |
| 227 | const prev = stepIndex(collIndex, -1, coll.items.length); |
| 228 | if (prev !== collIndex) coll.items[prev].load(); |
| 229 | const next = stepIndex(collIndex, 1, coll.items.length); |
| 230 | if (next !== collIndex) coll.items[next].load(); |
| 231 | } |
| 232 | |
| 233 | // Reverse of the fly-in: shrink the photo back onto its thumbnail, then remove. |
| 234 | // Used by taps / Escape / backdrop closes; a fling instead hurls it off-screen. |
| 235 | const animateClose = () => { |
| 236 | if (closing) return; |
| 237 | closing = true; |
| 238 | clearTimeout(tapTimer); |
| 239 | if (raf) { |
| 240 | cancelAnimationFrame(raf); |
| 241 | raf = 0; |
| 242 | } |
| 243 | const src = liveSource(); |
| 244 | const S = src && src.getBoundingClientRect(); |
| 245 | if (!haveBase || !S || !S.width || reduceMotion) return close(); |
| 246 | const s = Math.max(S.width / img.clientWidth, S.height / img.clientHeight); |
| 247 | const toX = S.left + S.width / 2 - baseCX; |
| 248 | const toY = S.top + S.height / 2 - baseCY; |
| 249 | img.style.transition = "transform 0.28s cubic-bezier(0.4,0,0.2,1), border-radius 0.28s ease"; |
| 250 | img.style.transform = `translate3d(${toX}px, ${toY}px, 0) scale(${s})`; |
| 251 | img.style.borderRadius = sourceRadius(S, src) / s + "px"; // shrink to the thumbnail's rounding, no snap |
| 252 | backdrop.style.transition = "opacity 0.28s"; |
| 253 | backdrop.style.opacity = "0"; |
| 254 | setTimeout(close, 300); |
| 255 | }; |
| 256 | |
| 257 | // Keep the transformed image within reach of the viewport when zoomed. |
| 258 | const clampPan = () => { |
| 259 | const maxX = Math.max(0, (img.clientWidth * scale - window.innerWidth) / 2 + 20); |
| 260 | const maxY = Math.max(0, (img.clientHeight * scale - window.innerHeight) / 2 + 20); |
| 261 | tx = Math.max(-maxX, Math.min(maxX, tx)); |
| 262 | ty = Math.max(-maxY, Math.min(maxY, ty)); |
| 263 | }; |
| 264 | |
| 265 | // Animate to a target scale anchored at a screen point (double-tap). Zooming |
| 266 | // out always recentres. |
| 267 | const zoomTo = (target, cx, cy) => { |
| 268 | if (raf) { |
| 269 | cancelAnimationFrame(raf); |
| 270 | raf = 0; |
| 271 | } |
| 272 | if (target <= MIN_SCALE) { |
| 273 | scale = 1; |
| 274 | tx = 0; |
| 275 | ty = 0; |
| 276 | } else { |
| 277 | const vX = (cx - baseCX - tx) / scale; |
| 278 | const vY = (cy - baseCY - ty) / scale; |
| 279 | scale = Math.min(MAX_SCALE, target); |
| 280 | tx = cx - baseCX - scale * vX; |
| 281 | ty = cy - baseCY - scale * vY; |
| 282 | clampPan(); |
| 283 | } |
| 284 | rot = 0; |
| 285 | img.style.transition = "transform 0.25s cubic-bezier(0.2,0.8,0.2,1)"; |
| 286 | img.style.transform = scale === 1 ? "translate3d(0,0,0)" : `translate3d(${tx}px, ${ty}px, 0) scale(${scale})`; |
| 287 | backdrop.style.transition = "opacity 0.25s"; |
| 288 | backdrop.style.opacity = "1"; |
| 289 | }; |
| 290 | |
| 291 | // Ease back to the identity transform (1×, centred, upright). |
| 292 | const springBack = () => { |
| 293 | scale = 1; |
| 294 | tx = 0; |
| 295 | ty = 0; |
| 296 | rot = 0; |
| 297 | img.style.transition = "transform 0.28s cubic-bezier(0.2,0.9,0.2,1)"; |
| 298 | img.style.transform = "translate3d(0,0,0)"; |
| 299 | backdrop.style.transition = "opacity 0.28s"; |
| 300 | backdrop.style.opacity = "1"; |
| 301 | }; |
| 302 | |
| 303 | // Move to a neighbour: quick fade + src swap on the ONE img element (the |
| 304 | // FLIP fly-in stays an open/close-only effect), neighbours preloading on |
| 305 | // approach so a swipe usually lands on a ready decrypted URL. |
| 306 | // |
| 307 | // navBusy only blocks a second nav while the first is still awaiting |
| 308 | // .load() — it clears before the 150ms fade/swap phase, so a nav that |
| 309 | // starts in that window (arrow-key repeat, a fast double-swipe) can still |
| 310 | // race its own deferred swap against an earlier one, and a dismiss |
| 311 | // (Escape/backdrop/tap) can land mid-fly-out on a still-scheduled swap. |
| 312 | // A monotonic generation counter is the single guard for both: each |
| 313 | // accepted call claims the current generation, and the deferred callback |
| 314 | // only applies if no later nav (and no close) has superseded it since. |
| 315 | let navBusy = false; |
| 316 | let navGen = 0; |
| 317 | const navTo = async (delta) => { |
| 318 | if (!coll || navBusy || closing) return; |
| 319 | const next = stepIndex(collIndex, delta, coll.items.length); |
| 320 | if (next === collIndex) return springBack(); |
| 321 | navBusy = true; |
| 322 | const gen = ++navGen; |
| 323 | const url2 = await coll.items[next].load(); |
| 324 | navBusy = false; |
| 325 | if (!url2 || closing || gen !== navGen) return springBack(); |
| 326 | collIndex = next; |
| 327 | scale = 1; tx = 0; ty = 0; rot = 0; |
| 328 | img.style.transition = reduceMotion ? "none" : "opacity 0.15s"; |
| 329 | img.style.opacity = "0"; |
| 330 | setTimeout(() => { |
| 331 | if (closing || gen !== navGen) return; // superseded by a close or a newer nav |
| 332 | img.src = url2; |
| 333 | img.style.transform = "translate3d(0,0,0)"; |
| 334 | img.style.opacity = "1"; |
| 335 | setBase(); |
| 336 | paintNav(); |
| 337 | const n = stepIndex(collIndex, delta, coll.items.length); |
| 338 | if (n !== collIndex) coll.items[n].load(); // preload the next neighbour |
| 339 | }, reduceMotion ? 0 : 150); |
| 340 | }; |
| 341 | |
| 342 | // Settle a still-zoomed image: keep the zoom, clamp into view, straighten any |
| 343 | // rotation the pinch left behind. |
| 344 | const settleZoom = () => { |
| 345 | clampPan(); |
| 346 | rot = 0; |
| 347 | img.style.transition = "transform 0.28s cubic-bezier(0.2,0.9,0.2,1)"; |
| 348 | img.style.transform = `translate3d(${tx}px, ${ty}px, 0) scale(${scale})`; |
| 349 | backdrop.style.transition = "opacity 0.28s"; |
| 350 | backdrop.style.opacity = "1"; |
| 351 | }; |
| 352 | |
| 353 | img.addEventListener("pointerdown", (e) => { |
| 354 | img.setPointerCapture?.(e.pointerId); |
| 355 | pointers.set(e.pointerId, { x: e.clientX, y: e.clientY }); |
| 356 | clearTimeout(tapTimer); // a new touch means this isn't a tap-and-wait dismiss |
| 357 | if (raf) { |
| 358 | cancelAnimationFrame(raf); |
| 359 | raf = 0; |
| 360 | } |
| 361 | img.style.transition = "none"; |
| 362 | backdrop.style.transition = "none"; |
| 363 | ensureBase(); |
| 364 | if (pointers.size >= 2) { |
| 365 | // Second finger down: start a pinch, anchoring the current midpoint + angle. |
| 366 | const [a, b] = [...pointers.values()]; |
| 367 | pinchStartDist = Math.hypot(a.x - b.x, a.y - b.y) || 1; |
| 368 | pinchStartScale = scale; |
| 369 | pinchStartAngle = Math.atan2(b.y - a.y, b.x - a.x); |
| 370 | pinchStartRot = rot; |
| 371 | const midX = (a.x + b.x) / 2; |
| 372 | const midY = (a.y + b.y) / 2; |
| 373 | pinchVX = (midX - baseCX - tx) / scale; |
| 374 | pinchVY = (midY - baseCY - ty) / scale; |
| 375 | mode = "pinch"; |
| 376 | moved = true; |
| 377 | pinched = true; |
| 378 | suppressBackdropClick = true; |
| 379 | } else { |
| 380 | // First finger of a fresh gesture: pan if zoomed, else a candidate dismiss drag. |
| 381 | pinched = false; |
| 382 | sx = e.clientX; |
| 383 | sy = e.clientY; |
| 384 | panStartTx = tx; |
| 385 | panStartTy = ty; |
| 386 | lastX = e.clientX; |
| 387 | lastY = e.clientY; |
| 388 | lastT = e.timeStamp || performance.now(); |
| 389 | vx = vy = 0; |
| 390 | moved = false; |
| 391 | rot = 0; |
| 392 | mode = scale > 1 ? "pan" : "drag"; |
| 393 | } |
| 394 | }); |
| 395 | |
| 396 | img.addEventListener("pointermove", (e) => { |
| 397 | const p = pointers.get(e.pointerId); |
| 398 | if (!p) return; |
| 399 | p.x = e.clientX; |
| 400 | p.y = e.clientY; |
| 401 | if (mode === "pinch") { |
| 402 | const pts = [...pointers.values()]; |
| 403 | if (pts.length < 2) return; |
| 404 | const dist = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y) || 1; |
| 405 | const midX = (pts[0].x + pts[1].x) / 2; |
| 406 | const midY = (pts[0].y + pts[1].y) / 2; |
| 407 | scale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, (pinchStartScale * dist) / pinchStartDist)); |
| 408 | const ang = Math.atan2(pts[1].y - pts[0].y, pts[1].x - pts[0].x); |
| 409 | rot = pinchStartRot + (ang - pinchStartAngle) * (180 / Math.PI); // free twist |
| 410 | tx = midX - baseCX - scale * pinchVX; |
| 411 | ty = midY - baseCY - scale * pinchVY; |
| 412 | clampPan(); |
| 413 | schedule(); |
| 414 | return; |
| 415 | } |
| 416 | if (mode === "pan") { |
| 417 | tx = panStartTx + (e.clientX - sx); |
| 418 | ty = panStartTy + (e.clientY - sy); |
| 419 | clampPan(); |
| 420 | schedule(); |
| 421 | return; |
| 422 | } |
| 423 | // mode === "drag": 1× dismiss drag with velocity sampling (px/ms). |
| 424 | tx = e.clientX - sx; |
| 425 | ty = e.clientY - sy; |
| 426 | if (Math.abs(tx) > 6 || Math.abs(ty) > 6) moved = true; |
| 427 | const now = e.timeStamp || performance.now(); |
| 428 | const dt = now - lastT; |
| 429 | if (dt > 0) { |
| 430 | vx = (e.clientX - lastX) / dt; |
| 431 | vy = (e.clientY - lastY) / dt; |
| 432 | } |
| 433 | lastX = e.clientX; |
| 434 | lastY = e.clientY; |
| 435 | lastT = now; |
| 436 | rot = tx / 40; |
| 437 | schedule(); |
| 438 | }); |
| 439 | |
| 440 | const armSuppress = () => { |
| 441 | suppressBackdropClick = true; |
| 442 | setTimeout(() => (suppressBackdropClick = false), 350); |
| 443 | }; |
| 444 | |
| 445 | const flingOrSettle = (e) => { |
| 446 | // A tap on the photo. A second tap within the window is a double-tap (toggle |
| 447 | // zoom); otherwise a lone tap dismisses at 1× — but only after the window |
| 448 | // elapses, so it can't fire before a double-tap is ruled out. |
| 449 | if (!moved) { |
| 450 | const now = e.timeStamp || performance.now(); |
| 451 | if (now - lastTapT < TAP_MS && Math.hypot(e.clientX - lastTapX, e.clientY - lastTapY) < 30) { |
| 452 | lastTapT = 0; |
| 453 | zoomTo(scale > 1 ? MIN_SCALE : 2.5, e.clientX, e.clientY); |
| 454 | armSuppress(); |
| 455 | return; |
| 456 | } |
| 457 | lastTapT = now; |
| 458 | lastTapX = e.clientX; |
| 459 | lastTapY = e.clientY; |
| 460 | if (scale === 1) tapTimer = setTimeout(animateClose, TAP_MS); // lone tap → dismiss into place |
| 461 | return; |
| 462 | } |
| 463 | // Anything that involved a pinch, or ended zoomed, settles — it never dismisses, |
| 464 | // so pinching back down to 1× can't fling the photo away by accident. |
| 465 | if (pinched || mode === "pan" || scale > 1) { |
| 466 | if (scale <= 1.001) springBack(); |
| 467 | else settleZoom(); |
| 468 | armSuppress(); |
| 469 | return; |
| 470 | } |
| 471 | // In a collection, a strongly horizontal committed drag navigates instead of |
| 472 | // dismissing (mirrors the dismiss fling's own speed/reach thresholds — see |
| 473 | // dragIntent). Reaching here already means !pinched && scale === 1, but the |
| 474 | // guard is spelled out so this can't fire outside that state if the checks |
| 475 | // above it ever change. |
| 476 | const intent = coll && !pinched && scale === 1 ? dragIntent(tx, ty, vx, vy) : null; |
| 477 | if (intent === "nav-next" || intent === "nav-prev") { |
| 478 | armSuppress(); |
| 479 | navTo(intent === "nav-next" ? 1 : -1); |
| 480 | return; |
| 481 | } |
| 482 | // Clean 1× drag: spring back, or fling off with momentum. |
| 483 | const speed = Math.hypot(vx, vy); // px/ms |
| 484 | if (speed < 0.55 && Math.hypot(tx, ty) < 130) { |
| 485 | springBack(); |
| 486 | return; |
| 487 | } |
| 488 | // Fling: continue in the release direction (fall back to the drag direction if it |
| 489 | // was a slow long drag), far enough to clear the screen, backdrop fading out. |
| 490 | let dx = vx; |
| 491 | let dy = vy; |
| 492 | if (speed < 0.05) { |
| 493 | dx = tx; |
| 494 | dy = ty; |
| 495 | } |
| 496 | const norm = Math.hypot(dx, dy) || 1; |
| 497 | const reach = Math.max(window.innerWidth, window.innerHeight) * 1.25; |
| 498 | const fx = tx + (dx / norm) * reach; |
| 499 | const fy = ty + (dy / norm) * reach; |
| 500 | const spin = Math.max(-65, Math.min(65, fx / 20)); // tumbles more as it leaves |
| 501 | closing = true; |
| 502 | img.style.transition = "transform 0.5s cubic-bezier(0.25,0.8,0.35,1)"; |
| 503 | img.style.transform = `translate3d(${fx}px, ${fy}px, 0) rotate(${spin}deg)`; |
| 504 | backdrop.style.transition = "opacity 0.45s"; |
| 505 | backdrop.style.opacity = "0"; |
| 506 | setTimeout(close, 480); |
| 507 | }; |
| 508 | |
| 509 | const onUp = (e) => { |
| 510 | if (!pointers.has(e.pointerId)) return; |
| 511 | pointers.delete(e.pointerId); |
| 512 | img.releasePointerCapture?.(e.pointerId); |
| 513 | if (raf) { |
| 514 | cancelAnimationFrame(raf); |
| 515 | raf = 0; |
| 516 | } |
| 517 | if (pointers.size === 1) { |
| 518 | // Dropped from a pinch to one finger — hand off to pan/drag without a jump. |
| 519 | const [rp] = [...pointers.values()]; |
| 520 | sx = rp.x; |
| 521 | sy = rp.y; |
| 522 | panStartTx = tx; |
| 523 | panStartTy = ty; |
| 524 | lastX = rp.x; |
| 525 | lastY = rp.y; |
| 526 | lastT = e.timeStamp || performance.now(); |
| 527 | vx = vy = 0; |
| 528 | moved = true; // came from a pinch; not a tap |
| 529 | mode = scale > 1 ? "pan" : "drag"; |
| 530 | return; |
| 531 | } |
| 532 | if (pointers.size >= 1) return; |
| 533 | flingOrSettle(e); |
| 534 | }; |
| 535 | img.addEventListener("pointerup", onUp); |
| 536 | img.addEventListener("pointercancel", onUp); |
| 537 | } |
| 538 | |