/* 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(); } }); })();