131 lines
No EOL
5.2 KiB
TypeScript
131 lines
No EOL
5.2 KiB
TypeScript
// 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 }; |