101 lines (86 loc) · 3.43 KB
  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};