172 lines (154 loc) · 6.40 KB
  1import { $, $$, asElement, storage } from "./dom";
  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: AbortSignal): void => {
 12  const toc = $<HTMLDetailsElement>("[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 || !asElement(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 (!footer || !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: string | null = null;
123  const setActive = (id: string) => {
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: string | null = null;
133  let lockTimer: number | undefined;
134  links.forEach((a) =>
135    a.addEventListener("click", () => {
136      if (floating.matches) toc.open = false;
137      const id = a.getAttribute("href")?.slice(1);
138      if (!id) return;
139      lockedId = id;
140      clearTimeout(lockTimer);
141      lockTimer = setTimeout(() => { lockedId = null; }, 1000);
142      setActive(id);
143    }, { signal })
144  );
145
146  const observer = new IntersectionObserver(
147    (entries) => {
148      for (const entry of entries) {
149        if (entry.isIntersecting) {
150          if (lockedId && entry.target.id !== lockedId) continue;
151          if (lockedId === entry.target.id) {
152            lockedId = null;
153            clearTimeout(lockTimer);
154          }
155          setActive(entry.target.id);
156          return;
157        }
158      }
159    },
160    { rootMargin: "0px 0px -80% 0px", threshold: 0 }
161  );
162  headings.forEach((h) => observer.observe(h));
163  signal.addEventListener("abort", () => observer.disconnect());
164
165  // Near the bottom, force-select the last heading (which may never fully
166  // satisfy the observer's rootMargin).
167  window.addEventListener("scroll", () => {
168    if (window.innerHeight + window.scrollY >= document.body.offsetHeight - 10) {
169      setActive(headings[headings.length - 1].id);
170    }
171  }, { passive: true, signal });
172};