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