117 lines
4.4 KiB
JavaScript
117 lines
4.4 KiB
JavaScript
/* toc.js — Sticky TOC with IntersectionObserver scroll tracking,
|
|
horizontal progress indicator, and expand/collapse.
|
|
Loaded with defer.
|
|
*/
|
|
(function () {
|
|
document.addEventListener('DOMContentLoaded', function () {
|
|
const toc = document.getElementById('toc');
|
|
if (!toc) return;
|
|
|
|
const links = Array.from(toc.querySelectorAll('a[data-target]'));
|
|
if (!links.length) return;
|
|
|
|
// Map: heading ID → TOC anchor element.
|
|
const linkMap = new Map(links.map(a => [a.dataset.target, a]));
|
|
|
|
// All headings in the body that have a matching TOC entry.
|
|
const headings = Array.from(
|
|
document.querySelectorAll('#markdownBody :is(h1,h2,h3,h4,h5,h6)[id]')
|
|
).filter(h => linkMap.has(h.id));
|
|
|
|
if (!headings.length) return;
|
|
|
|
const label = toc.querySelector('.toc-active-label');
|
|
const toggleBtn = toc.querySelector('.toc-toggle');
|
|
|
|
const pageTitleEl = document.querySelector('#markdownBody .page-title');
|
|
const pageTitle = pageTitleEl ? pageTitleEl.textContent.trim() : 'Contents';
|
|
|
|
function activateTitle() {
|
|
links.forEach(a => a.classList.remove('is-active'));
|
|
if (label) label.textContent = pageTitle;
|
|
}
|
|
|
|
function activate(id) {
|
|
links.forEach(a => a.classList.toggle('is-active', a.dataset.target === id));
|
|
if (label) {
|
|
const activeLink = linkMap.get(id);
|
|
label.textContent = activeLink ? activeLink.textContent : pageTitle;
|
|
}
|
|
}
|
|
|
|
// Collapse / expand
|
|
function setExpanded(open) {
|
|
if (!toggleBtn) return;
|
|
toc.classList.toggle('is-collapsed', !open);
|
|
toggleBtn.setAttribute('aria-expanded', String(open));
|
|
}
|
|
|
|
setExpanded(true);
|
|
|
|
if (toggleBtn) {
|
|
toggleBtn.addEventListener('click', function () {
|
|
setExpanded(toc.classList.contains('is-collapsed'));
|
|
});
|
|
}
|
|
|
|
// Auto-collapse once the first section becomes active via scrolling.
|
|
let autoCollapsed = false;
|
|
function collapseOnce() {
|
|
if (autoCollapsed) return;
|
|
autoCollapsed = true;
|
|
setExpanded(false);
|
|
}
|
|
|
|
// Progress indicator — drives the horizontal bar under .toc-header.
|
|
function updateProgress() {
|
|
const scrollable = document.documentElement.scrollHeight - window.innerHeight;
|
|
const progress = scrollable > 0 ? Math.min(1, window.scrollY / scrollable) : 0;
|
|
toc.style.setProperty('--toc-progress', progress);
|
|
}
|
|
window.addEventListener('scroll', updateProgress, { passive: true });
|
|
updateProgress();
|
|
|
|
// The set of headings currently intersecting the trigger band.
|
|
const visible = new Set();
|
|
|
|
const observer = new IntersectionObserver(function (entries) {
|
|
entries.forEach(function (e) {
|
|
if (e.isIntersecting) visible.add(e.target);
|
|
else visible.delete(e.target);
|
|
});
|
|
|
|
if (visible.size > 0) {
|
|
// Activate the topmost visible heading in document order.
|
|
const top = headings.find(h => visible.has(h));
|
|
if (top) { activate(top.id); collapseOnce(); }
|
|
} else {
|
|
// Nothing in the trigger band: activate the last heading
|
|
// whose top edge is above the sticky nav bar.
|
|
const navHeight = (document.querySelector('header') || {}).offsetHeight || 0;
|
|
let candidate = null;
|
|
for (const h of headings) {
|
|
if (h.getBoundingClientRect().top < navHeight + 16) candidate = h;
|
|
else break;
|
|
}
|
|
if (candidate) { activate(candidate.id); collapseOnce(); }
|
|
else activateTitle();
|
|
}
|
|
}, {
|
|
// Trigger band: a strip from 10% to 15% down from the top of the
|
|
// viewport. Headings become active when they enter this band.
|
|
rootMargin: '-10% 0px -85% 0px',
|
|
threshold: 0,
|
|
});
|
|
|
|
headings.forEach(h => observer.observe(h));
|
|
|
|
// Set initial active state based on URL hash, else first heading.
|
|
const hash = window.location.hash.slice(1);
|
|
if (hash && linkMap.has(hash)) {
|
|
activate(hash);
|
|
} else {
|
|
activateTitle();
|
|
}
|
|
});
|
|
})();
|