eleven / messenger Public

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

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.
7export 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