eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

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).
11export function stepIndex(i, delta, len) {
12 return Math.max(0, Math.min(len - 1, i + delta));
13}
14
15export 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
22export 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