forked from CaiJimmy/hugo-theme-stack
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(article): Scrollspy for the table of contents (CaiJimmy#425)
* Add first try at scrollspy (broken right now) * Scrollspy actually works now * Fix VS Code errors by setting JS version * Recompute offsets when window size changes * Improve list compatibility for toc active selection Support up to 6 levels of indentation, properly support <ol> * Remove debug string * Add more docs in smoothAnchors * Use a map to match ids to navigation elements
- Loading branch information
Showing
6 changed files
with
209 additions
and
5 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,3 +1,4 @@ | ||
public | ||
resources | ||
assets/jsconfig.json | ||
assets/jsconfig.json | ||
.hugo_build.lock |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,7 +1,6 @@ | ||
html { | ||
font-size: 62.5%; | ||
overflow-y: scroll; | ||
scroll-behavior: smooth; | ||
} | ||
|
||
* { | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,131 @@ | ||
// 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 }; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,34 @@ | ||
// 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 and e.g. footnotes. | ||
|
||
// Here are additional links about this issue: | ||
// - https://stackoverflow.com/questions/49318497/google-chrome-simultaneously-smooth-scrollintoview-with-more-elements-doesn | ||
// - https://stackoverflow.com/questions/57214373/scrollintoview-using-smooth-function-on-multiple-elements-in-chrome | ||
// - https://bugs.chromium.org/p/chromium/issues/detail?id=833617 | ||
// - https://bugs.chromium.org/p/chromium/issues/detail?id=1043933 | ||
// - https://bugs.chromium.org/p/chromium/issues/detail?id=1121151 | ||
|
||
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); | ||
// The replace done on ':' is here for footnotes, as this character would otherwise interfere when used as a CSS selector. | ||
let target = document.querySelector(`#${targetId.replace(":", "\\:")}`) as HTMLElement; | ||
|
||
window.history.pushState({}, "", aElement.getAttribute("href")); | ||
scrollTo({ top: target.offsetTop, behavior: "smooth" }); | ||
}); | ||
}); | ||
} | ||
|
||
export { setupSmoothAnchors }; |