89 lines
3.1 KiB
JavaScript
89 lines
3.1 KiB
JavaScript
/* Event listener for scrolling and changing the active label on the TOC */
|
||
document.addEventListener("DOMContentLoaded", () => {
|
||
const toc = document.querySelector("#text-table-of-contents");
|
||
if (!toc) { console.warn("No #text-table-of-contents found"); return; }
|
||
|
||
const links = toc.querySelectorAll('a[href^="#"]'); // '^=' is a starts with operator.
|
||
// <a href="#introduction">Intro</a> matches
|
||
if (!links.length) { console.warn("No ToC links found"); return; }
|
||
|
||
// Map: id -> link
|
||
const linkById = new Map();
|
||
links.forEach(a => {
|
||
const id = decodeURIComponent(a.getAttribute("href").slice(1));
|
||
const el = document.getElementById(id);
|
||
if (el) linkById.set(id, a);
|
||
});
|
||
if (!linkById.size) { console.warn("No matching headings with IDs"); return; }
|
||
|
||
// Headings to observe (h2–h4 usually)
|
||
const headings = [...document.querySelectorAll("h2[id], h3[id], h4[id]")]
|
||
.filter(h => linkById.has(h.id));
|
||
|
||
// Helper to mark active
|
||
const setActive = (id) => {
|
||
links.forEach(a => {
|
||
const active = a.getAttribute("href") === `#${id}`;
|
||
a.classList.toggle("is-active", active);
|
||
if (active) a.setAttribute("aria-current", "true");
|
||
else a.removeAttribute("aria-current");
|
||
});
|
||
};
|
||
|
||
// Calculate sticky header offset in px
|
||
const headerOffsetPx = 6.5 * parseFloat(getComputedStyle(document.documentElement).fontSize);
|
||
|
||
// Track visible headings (id -> distance from top)
|
||
const visible = new Map();
|
||
|
||
const observer = new IntersectionObserver((entries) => {
|
||
entries.forEach(entry => {
|
||
const id = entry.target.id;
|
||
if (entry.isIntersecting) {
|
||
// How far from the top (after header offset)
|
||
const dist = entry.target.getBoundingClientRect().top - headerOffsetPx;
|
||
visible.set(id, dist);
|
||
} else {
|
||
visible.delete(id);
|
||
}
|
||
});
|
||
|
||
if (visible.size) {
|
||
// Choose the heading closest to the top (>= -headerOffset)
|
||
const topMost = [...visible.entries()]
|
||
.sort((a,b) => Math.abs(a[1]) - Math.abs(b[1]))[0][0];
|
||
setActive(topMost);
|
||
// console.log("Active:", topMost, visible);
|
||
}
|
||
}, {
|
||
root: null, // track relative to viewport
|
||
rootMargin: `-${headerOffsetPx}px 0px -70% 0px`,
|
||
threshold: [0, 0.01, 0.1] // fire as soon as it enters
|
||
});
|
||
|
||
headings.forEach(h => observer.observe(h));
|
||
|
||
// Initial highlight (in case load mid‑page)
|
||
let bestId = null, bestDist = Infinity;
|
||
headings.forEach(h => {
|
||
const top = h.getBoundingClientRect().top - headerOffsetPx;
|
||
const dist = top < 0 ? Math.abs(top) : top + 1e6;
|
||
if (dist < bestDist) { bestDist = dist; bestId = h.id; }
|
||
});
|
||
if (bestId) setActive(bestId);
|
||
|
||
// smooth-scroll ToC clicks
|
||
toc.addEventListener("click", (e) => {
|
||
const a = e.target.closest('a[href^="#"]');
|
||
if (!a) return;
|
||
const id = decodeURIComponent(a.hash.slice(1));
|
||
const el = document.getElementById(id);
|
||
if (!el) return;
|
||
e.preventDefault();
|
||
el.scrollIntoView({ behavior: "smooth", block: "start" });
|
||
el.setAttribute("tabindex", "-1");
|
||
el.focus({ preventScroll: true });
|
||
history.pushState(null, "", `#${id}`);
|
||
});
|
||
});
|