// Implements a scroll spy system for the ToC, displaying the current section with an indicator and scrolling to it when needed. // Inspired from https://gomakethings.com/debouncing-your-javascript-events/ function debounced(func: Function) { let timeout; return () => { if (timeout) { window.cancelAnimationFrame(timeout); } timeout = window.requestAnimationFrame(() => func()); } } const headersQuery = ".article-content h1[id], .article-content h2[id], .article-content h3[id], .article-content h4[id], .article-content h5[id], .article-content h6[id]"; const tocQuery = "#TableOfContents"; const navigationQuery = "#TableOfContents li"; const activeClass = "active-class"; function scrollToTocElement(tocElement: HTMLElement, scrollableNavigation: HTMLElement) { let textHeight = tocElement.querySelector("a").offsetHeight; let scrollTop = tocElement.offsetTop - scrollableNavigation.offsetHeight / 2 + textHeight / 2 - scrollableNavigation.offsetTop; if (scrollTop < 0) { scrollTop = 0; } scrollableNavigation.scrollTo({ top: scrollTop, behavior: "smooth" }); } type IdToElementMap = { [key: string]: HTMLElement }; function buildIdToNavigationElementMap(navigation: NodeListOf): IdToElementMap { const sectionLinkRef: IdToElementMap = {}; navigation.forEach((navigationElement: HTMLElement) => { const link = navigationElement.querySelector("a"); const href = link.getAttribute("href"); if (href.startsWith("#")) { sectionLinkRef[href.slice(1)] = navigationElement; } }); return sectionLinkRef; } function computeOffsets(headers: NodeListOf) { let sectionsOffsets = []; headers.forEach((header: HTMLElement) => { sectionsOffsets.push({ id: header.id, offset: header.offsetTop }) }); sectionsOffsets.sort((a, b) => a.offset - b.offset); return sectionsOffsets; } function setupScrollspy() { let headers = document.querySelectorAll(headersQuery); if (!headers) { console.warn("No header matched query", headers); return; } let scrollableNavigation = document.querySelector(tocQuery) as HTMLElement | undefined; if (!scrollableNavigation) { console.warn("No toc matched query", tocQuery); return; } let navigation = document.querySelectorAll(navigationQuery); if (!navigation) { console.warn("No navigation matched query", navigationQuery); return; } let sectionsOffsets = computeOffsets(headers); // We need to avoid scrolling when the user is actively interacting with the ToC. Otherwise, if the user clicks on a link in the ToC, // we would scroll their view, which is not optimal usability-wise. let tocHovered: boolean = false; scrollableNavigation.addEventListener("mouseenter", debounced(() => tocHovered = true)); scrollableNavigation.addEventListener("mouseleave", debounced(() => tocHovered = false)); let activeSectionLink: Element; let idToNavigationElement: IdToElementMap = buildIdToNavigationElementMap(navigation); function scrollHandler() { let scrollPosition = document.documentElement.scrollTop || document.body.scrollTop; let newActiveSection: HTMLElement | undefined; // Find the section that is currently active. // It is possible for no section to be active, so newActiveSection may be undefined. sectionsOffsets.forEach((section) => { if (scrollPosition >= section.offset - 20) { newActiveSection = document.getElementById(section.id); } }); // Find the link for the active section. Once again, there are a few edge cases: // - No active section = no link => undefined // - No active section but the link does not exist in toc (e.g. because it is outside of the applicable ToC levels) => undefined let newActiveSectionLink: HTMLElement | undefined if (newActiveSection) { newActiveSectionLink = idToNavigationElement[newActiveSection.id]; } if (newActiveSection && !newActiveSectionLink) { // The active section does not have a link in the ToC, so we can't scroll to it. console.debug("No link found for section", newActiveSection); } else if (newActiveSectionLink !== activeSectionLink) { if (activeSectionLink) activeSectionLink.classList.remove(activeClass); if (newActiveSectionLink) { newActiveSectionLink.classList.add(activeClass); if (!tocHovered) { // Scroll so that newActiveSectionLink is in the middle of scrollableNavigation, except when it's from a manual click (hence the tocHovered check) scrollToTocElement(newActiveSectionLink, scrollableNavigation); } } activeSectionLink = newActiveSectionLink; } } window.addEventListener("scroll", debounced(scrollHandler)); // Resizing may cause the offset values to change: recompute them. function resizeHandler() { sectionsOffsets = computeOffsets(headers); scrollHandler(); } window.addEventListener("resize", debounced(resizeHandler)); } export { setupScrollspy };