1import { $, $$ } from "./dom";
2
3export const initPhotoPreview = (signal: AbortSignal): void => {
4 const dialog = $<HTMLDialogElement>("#photo-preview");
5 if (!dialog) return;
6 const img = $<HTMLImageElement>("img", dialog);
7 if (!img) return;
8 const hint = $<HTMLElement>(".photo-preview__hint", dialog);
9
10 const items: { el: HTMLElement; src: string }[] = [
11 ...$$<HTMLAnchorElement>(".photo-grid__item a").map((el) => ({ el, src: el.href })),
12 ...$$<HTMLImageElement>(".article-content img").map((el) => ({ el, src: el.dataset.full || el.src })),
13 ];
14 if (!items.length) return;
15
16 const sources = items.map((it) => it.src);
17 const multiple = sources.length > 1;
18 let current = 0;
19
20 if (hint) hint.hidden = !multiple;
21
22 const show = (index: number) => {
23 current = (index + sources.length) % sources.length;
24 const src = sources[current];
25 if (img.getAttribute("src") !== src) {
26 img.removeAttribute("src");
27 img.style.visibility = "hidden";
28 img.onload = () => { img.style.visibility = "visible"; };
29 img.src = src;
30 }
31 };
32
33 const openAt = (index: number) => {
34 show(index);
35 dialog.showModal();
36 history.pushState(null, "", "#preview-open");
37 };
38 const step = (delta: number) => { if (multiple) show(current + delta); };
39
40 items.forEach((it, i) => {
41 if (it.el.tagName === "A") {
42 it.el.addEventListener("click", (e) => { e.preventDefault(); openAt(i); }, { signal });
43 } else {
44 it.el.style.cursor = "zoom-in";
45 it.el.addEventListener("click", () => openAt(i), { signal });
46 }
47 });
48
49 $("[data-preview-close]", dialog)?.addEventListener("click", () => dialog.close(), { signal });
50
51 // When dialog closes by any means other than Back, pop the history state we pushed.
52 dialog.addEventListener("close", () => {
53 if (location.hash === "#preview-open") history.back();
54 }, { signal });
55
56 // Back button / mobile swipe-back: close the dialog instead of navigating away.
57 window.addEventListener("popstate", () => {
58 if (dialog.open) dialog.close();
59 }, { signal });
60
61 document.addEventListener("keydown", (e) => {
62 if (!dialog.open) return;
63 if (e.key === "Enter") { dialog.close(); return; }
64 if (!multiple) return;
65 if (e.key === "ArrowLeft") step(-1);
66 else if (e.key === "ArrowRight") step(1);
67 }, { signal });
68
69 // Click image: navigate left/right half; close if single.
70 img.addEventListener("click", (e) => {
71 if (!multiple) { dialog.close(); return; }
72 step(e.offsetX < img.offsetWidth / 2 ? -1 : 1);
73 }, { signal });
74
75 // Backdrop click closes.
76 dialog.addEventListener("click", (e) => {
77 if (e.target === dialog) dialog.close();
78 }, { signal });
79
80 // Scroll wheel navigation (throttled to one step per 300ms).
81 let wheelLocked = false;
82 dialog.addEventListener("wheel", (e) => {
83 if (!multiple || wheelLocked) return;
84 wheelLocked = true;
85 step(e.deltaY > 0 ? 1 : -1);
86 setTimeout(() => { wheelLocked = false; }, 300);
87 }, { passive: true, signal });
88
89 // Swipe navigation. Ignore multi-touch (pinch-to-zoom) so zooming doesn't step.
90 let touchX = 0;
91 let pinching = false;
92 dialog.addEventListener("touchstart", (e) => {
93 pinching = e.touches.length > 1;
94 touchX = e.touches[0].clientX;
95 }, { passive: true, signal });
96 dialog.addEventListener("touchend", (e) => {
97 if (pinching || e.touches.length > 0) return;
98 const dx = e.changedTouches[0].clientX - touchX;
99 if (Math.abs(dx) > 50) step(dx < 0 ? 1 : -1);
100 }, { signal });
101};