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};