From dfad51cdec1d3acfd1c0c25ba5e19f3f3a8080ec Mon Sep 17 00:00:00 2001 From: Zoroark Date: Sat, 27 Nov 2021 18:11:03 +0100 Subject: [PATCH] Scrollspy actually works now --- assets/ts/main.ts | 4 ++- assets/ts/scrollspy.ts | 67 +++++++++++++++++++++++++------------- assets/ts/smoothAnchors.ts | 30 +++++++++++++++++ 3 files changed, 77 insertions(+), 24 deletions(-) create mode 100644 assets/ts/smoothAnchors.ts diff --git a/assets/ts/main.ts b/assets/ts/main.ts index 0fd6d4e..2e2d30f 100644 --- a/assets/ts/main.ts +++ b/assets/ts/main.ts @@ -11,6 +11,7 @@ import menu from 'ts/menu'; import createElement from 'ts/createElement'; import StackColorScheme from 'ts/colorScheme'; import { setupScrollspy } from 'ts/scrollspy'; +import { setupSmoothAnchors } from "ts/smoothAnchors"; let Stack = { init: () => { @@ -22,7 +23,8 @@ let Stack = { const articleContent = document.querySelector('.article-content') as HTMLElement; if (articleContent) { new StackGallery(articleContent); - setupScrollspy(".article-content h1[id], .article-content h2[id], .article-content h3[id], .article-content h4[id], .article-content h5[id], .article-content h6[id]", "#TableOfContents", "#TableOfContents li", "active-class") + setupSmoothAnchors(); + setupScrollspy(); } /** diff --git a/assets/ts/scrollspy.ts b/assets/ts/scrollspy.ts index 6b34248..7b6d244 100644 --- a/assets/ts/scrollspy.ts +++ b/assets/ts/scrollspy.ts @@ -1,3 +1,5 @@ +// Implements a scroll spy system for the ToC, displaying the current section with an indicator and scrolling to it when needed. + // While solutions for debouncing like the ones in https://gomakethings.com/debouncing-your-javascript-events/ could work, // we do need an actual debouncing of scroll events in order to only capture the "end" of the scroll. function debounced(func: Function) { @@ -17,14 +19,39 @@ function debounced(func: Function) { } } -function setupScrollspy(headersQuery: string, tocQuery: string, navigationQuery: string, activeClass: string) { +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" }); +} + +function findLinkForSectionId(sectionId: string, navigation: NodeListOf): HTMLElement | undefined { + for (let i = 0; i < navigation.length; i++) { + let link = navigation[i].querySelector("a"); + if (link.getAttribute("href") === "#" + sectionId) { + return navigation[i] as HTMLElement; + } + } + + return undefined; +} + +function setupScrollspy() { let headers = document.querySelectorAll(headersQuery); if (!headers) { console.warn("No header matched query", headers); return; } - let scrollableNavigation = document.querySelector(tocQuery); + let scrollableNavigation = document.querySelector(tocQuery) as HTMLElement | undefined; if (!scrollableNavigation) { console.warn("No toc matched query", tocQuery); return; @@ -41,36 +68,37 @@ function setupScrollspy(headersQuery: string, tocQuery: string, navigationQuery: headers.forEach((header: HTMLElement) => { sectionsOffsets.push({ id: header.id, offset: header.offsetTop }) }); sectionsOffsets.sort((a, b) => a.offset - b.offset); - let activeSectionLink: Element; + // 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)); - function hoverHandler(isHovered: boolean) { - tocHovered = isHovered; - } + let activeSectionLink: Element; function scrollHandler(e: Event) { let scrollPosition = document.documentElement.scrollTop || document.body.scrollTop; - let newActiveSection: HTMLElement; + 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); } }); - let newActiveSectionLink: HTMLElement; + // 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) { - for (let i = 0; i < navigation.length; i++) { - let link = navigation[i].querySelector("a"); - if (link.getAttribute("href") === "#" + newActiveSection.id) { - newActiveSectionLink = navigation[i] as HTMLElement; - break; - } - } + newActiveSectionLink = findLinkForSectionId(newActiveSection.id, navigation); } if (newActiveSection && !newActiveSectionLink) { + // The active section does not have a link in the ToC, so we can't scroll to it. console.warn("No link found for section", newActiveSection); } else if (newActiveSectionLink !== activeSectionLink) { if (activeSectionLink) @@ -79,12 +107,7 @@ function setupScrollspy(headersQuery: string, tocQuery: string, navigationQuery: 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) - let textHeight = newActiveSectionLink.querySelector("a").offsetHeight; - let scrollTop = newActiveSectionLink.offsetTop - scrollableNavigation.offsetHeight / 2 + textHeight / 2 - scrollableNavigation.offsetTop; - if (scrollTop < 0) { - scrollTop = 0; - } - scrollableNavigation.scrollTo({ top: scrollTop, behavior: "auto" }); + scrollToTocElement(newActiveSectionLink, scrollableNavigation); } } activeSectionLink = newActiveSectionLink; @@ -92,8 +115,6 @@ function setupScrollspy(headersQuery: string, tocQuery: string, navigationQuery: } window.addEventListener("scroll", debounced(scrollHandler)); - scrollableNavigation.addEventListener("mouseenter", debounced(() => hoverHandler(true))); - scrollableNavigation.addEventListener("mouseleave", debounced(() => hoverHandler(false))); } export { setupScrollspy }; \ No newline at end of file diff --git a/assets/ts/smoothAnchors.ts b/assets/ts/smoothAnchors.ts new file mode 100644 index 0000000..d59e2d0 --- /dev/null +++ b/assets/ts/smoothAnchors.ts @@ -0,0 +1,30 @@ +// Implements smooth scrolling when clicking on an anchor link. +// This is required instead of using modern CSS because Chromium does not currently support scrolling +// one element with scrollTo while another element is scrolled because of a click on a link. This would thus not work with the ToC scrollspy. + +const anchorLinksQuery = "a[href]"; + +function setupSmoothAnchors() { + document.querySelectorAll(anchorLinksQuery).forEach(aElement => { + let href = aElement.getAttribute("href"); + if (!href.startsWith("#")) { + return; + } + aElement.addEventListener("click", clickEvent => { + clickEvent.preventDefault(); + + let targetId = aElement.getAttribute("href").substring(1); + let target = document.querySelector(`#${targetId.replace(":", "\\:")}`) as HTMLElement; + + //let tocLink = findLinkForSectionId(targetId, navigation); + //if (tocLink) { + // scrollToTocElement(tocLink, scrollableNavigation); + //} + + window.history.pushState({}, "", aElement.getAttribute("href")); + scrollTo({ top: target.offsetTop, behavior: "smooth" }); + }); + }); +} + +export { setupSmoothAnchors }; \ No newline at end of file