171 lines (153 loc) · 6.25 KB
  1import { $, $$, storage } from "./dom.js";
  2
  3const STORAGE_KEY = "toc-state";
  4
  5/**
  6 * Table of contents. The same <details> is a sticky sidebar at >= 1400px
  7 * (collapse state persisted, clamped above the footer) and, below that, a
  8 * bottom-right circle that opens a "Contents" sheet. Scroll-spy highlights the
  9 * active section.
 10 */
 11export const initToc = (signal) => {
 12  const toc = $("[data-post-toc]");
 13  if (!toc) return;
 14
 15  const floating = window.matchMedia("(max-width: 1399.98px)");
 16
 17  // Collapse state: persist only for the wide sidebar. The floating sheet
 18  // always starts closed so it never covers content on load.
 19  if (floating.matches) {
 20    toc.open = false;
 21  } else if (storage) {
 22    const stored = storage.getItem(STORAGE_KEY);
 23    if (stored !== null) {
 24      toc.classList.add("no-transition");
 25      toc.open = stored === "true";
 26      requestAnimationFrame(() => toc.classList.remove("no-transition"));
 27    }
 28  }
 29  if (storage) {
 30    toc.addEventListener("toggle", () => {
 31      if (!floating.matches) storage.setItem(STORAGE_KEY, toc.open ? "true" : "false");
 32    }, { signal });
 33  }
 34
 35  // The floating sheet overlays the circle; it closes via its button, Escape,
 36  // backdrop/outside click, or after picking a section.
 37  $("[data-toc-close]", toc)?.addEventListener("click", () => { toc.open = false; }, { signal });
 38  document.addEventListener("keydown", (e) => {
 39    if (e.key === "Escape" && toc.open && floating.matches) toc.open = false;
 40  }, { signal });
 41  document.addEventListener("click", (e) => {
 42    if (!toc.open || !floating.matches) return;
 43    // e.target === toc means the click hit the dim ::before backdrop.
 44    if (e.target === toc || !e.target.closest(".post-toc")) toc.open = false;
 45  }, { signal });
 46
 47  const articleMeta = $(".article-meta");
 48  const footer = $(".footer");
 49  const desktop = window.matchMedia("(min-width: 1400px)");
 50
 51  // Align the ToC top with the article meta, down to a minimum offset.
 52  const setTocTop = () => {
 53    if (!articleMeta || getComputedStyle(toc).position === "absolute") return;
 54    const metaTop = articleMeta.getBoundingClientRect().top;
 55    const minTop = parseFloat(getComputedStyle(toc).getPropertyValue("--toc-top-min")) || 80;
 56    toc.style.setProperty("--toc-top", `${metaTop <= minTop ? minTop : metaTop}px`);
 57  };
 58
 59  const clearClamp = () => {
 60    toc.style.position = toc.style.top = toc.style.left = "";
 61  };
 62
 63  // Read the ToC's natural fixed `top` by momentarily clearing inline overrides.
 64  const readFixedTop = () => {
 65    const { position, top, left } = toc.style;
 66    clearClamp();
 67    const fixedTop = parseFloat(getComputedStyle(toc).top) || 0;
 68    Object.assign(toc.style, { position, top, left });
 69    return fixedTop;
 70  };
 71
 72  // On desktop, switch the fixed ToC to absolute before it overlaps the footer.
 73  const updateClamp = () => {
 74    if (!desktop.matches) return clearClamp();
 75    const footerTop = footer.getBoundingClientRect().top + window.scrollY;
 76    const tocHeight = toc.offsetHeight;
 77    const gap = 24;
 78    if (window.scrollY + readFixedTop() + tocHeight >= footerTop - gap) {
 79      toc.style.position = "absolute";
 80      toc.style.top = `${Math.max(footerTop - tocHeight - gap, 0)}px`;
 81      toc.style.left = `${toc.getBoundingClientRect().left + window.scrollX}px`;
 82    } else {
 83      clearClamp();
 84    }
 85  };
 86
 87  // Keep the floating circle above the footer instead of overlapping it.
 88  // While the sheet is open the circle is covered, so leave it untouched —
 89  // otherwise resetting its bottom makes it visibly jump under the animation.
 90  const fabClamp = () => {
 91    if (toc.open) return;
 92    if (!footer || !floating.matches) {
 93      toc.style.bottom = "";
 94      return;
 95    }
 96    const lift = window.innerHeight - footer.getBoundingClientRect().top + 16;
 97    toc.style.bottom = lift > 20 ? `${lift}px` : "";
 98  };
 99
100  if (footer) {
101    updateClamp();
102    fabClamp();
103    window.addEventListener("scroll", () => { updateClamp(); fabClamp(); }, { passive: true, signal });
104    window.addEventListener("resize", () => { updateClamp(); fabClamp(); }, { signal });
105    desktop.addEventListener("change", updateClamp, { signal });
106    toc.addEventListener("toggle", fabClamp, { signal });
107  }
108
109  if (articleMeta) {
110    setTocTop();
111    window.addEventListener("scroll", setTocTop, { passive: true, signal });
112    window.addEventListener("resize", setTocTop, { signal });
113    $(".article-cover__img")?.addEventListener("load", setTocTop, { signal });
114  }
115  toc.style.visibility = "visible";
116
117  // --- Scroll-spy ---
118  const headings = $$(".article-content h2[id], .article-content h3[id], .article-content h4[id]");
119  const links = $$("a[href^='#']", toc);
120  if (!headings.length || !links.length) return;
121
122  let activeId = null;
123  const setActive = (id) => {
124    if (id === activeId) return;
125    activeId = id;
126    links.forEach((a) => a.classList.toggle("is-active", a.getAttribute("href") === `#${id}`));
127  };
128
129  // While a click-driven smooth scroll is in flight, hold the active link at
130  // the clicked target instead of stepping through every section it scrolls
131  // past on the way there.
132  let lockedId = null;
133  let lockTimer = null;
134  links.forEach((a) =>
135    a.addEventListener("click", () => {
136      if (floating.matches) toc.open = false;
137      const id = a.getAttribute("href").slice(1);
138      lockedId = id;
139      clearTimeout(lockTimer);
140      lockTimer = setTimeout(() => { lockedId = null; }, 1000);
141      setActive(id);
142    }, { signal })
143  );
144
145  const observer = new IntersectionObserver(
146    (entries) => {
147      for (const entry of entries) {
148        if (entry.isIntersecting) {
149          if (lockedId && entry.target.id !== lockedId) continue;
150          if (lockedId === entry.target.id) {
151            lockedId = null;
152            clearTimeout(lockTimer);
153          }
154          setActive(entry.target.id);
155          return;
156        }
157      }
158    },
159    { rootMargin: "0px 0px -80% 0px", threshold: 0 }
160  );
161  headings.forEach((h) => observer.observe(h));
162  signal.addEventListener("abort", () => observer.disconnect());
163
164  // Near the bottom, force-select the last heading (which may never fully
165  // satisfy the observer's rootMargin).
166  window.addEventListener("scroll", () => {
167    if (window.innerHeight + window.scrollY >= document.body.offsetHeight - 10) {
168      setActive(headings[headings.length - 1].id);
169    }
170  }, { passive: true, signal });
171};