123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131 |
- // 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 };
|