Spaces:
Sleeping
Sleeping
| // 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<Element>): 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<Element>) { | |
| 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 }; |