mirror of
https://github.com/CaiJimmy/hugo-theme-stack.git
synced 2024-11-23 10:21:46 +01:00
feat(article): Scrollspy for the table of contents (#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
This commit is contained in:
parent
9e08854681
commit
2b40a32d47
6 changed files with 209 additions and 5 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -1,3 +1,4 @@
|
||||||
public
|
public
|
||||||
resources
|
resources
|
||||||
assets/jsconfig.json
|
assets/jsconfig.json
|
||||||
|
.hugo_build.lock
|
|
@ -1,7 +1,6 @@
|
||||||
html {
|
html {
|
||||||
font-size: 62.5%;
|
font-size: 62.5%;
|
||||||
overflow-y: scroll;
|
overflow-y: scroll;
|
||||||
scroll-behavior: smooth;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
|
|
|
@ -123,7 +123,6 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.article-page.has-toc {
|
.article-page.has-toc {
|
||||||
scroll-behavior: smooth;
|
|
||||||
|
|
||||||
.left-sidebar {
|
.left-sidebar {
|
||||||
display: none;
|
display: none;
|
||||||
|
@ -194,6 +193,10 @@
|
||||||
color: var(--card-text-color-main);
|
color: var(--card-text-color-main);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background-color: var(--card-separator-color);
|
||||||
|
}
|
||||||
|
|
||||||
#TableOfContents {
|
#TableOfContents {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
max-height: 75vh;
|
max-height: 75vh;
|
||||||
|
@ -208,7 +211,7 @@
|
||||||
list-style-type: none;
|
list-style-type: none;
|
||||||
counter-reset: item;
|
counter-reset: item;
|
||||||
|
|
||||||
li:before {
|
li a::before {
|
||||||
counter-increment: item;
|
counter-increment: item;
|
||||||
content: counters(item, ".") ". ";
|
content: counters(item, ".") ". ";
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
|
@ -221,7 +224,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
li {
|
li {
|
||||||
margin: 15px 20px;
|
margin: 15px 0 15px 20px;
|
||||||
padding: 5px;
|
padding: 5px;
|
||||||
|
|
||||||
& > ol,
|
& > ol,
|
||||||
|
@ -235,6 +238,38 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
li.active-class > a {
|
||||||
|
border-left: var(--heading-border-size) solid var(--accent-color);
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul li.active-class > a {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
@function repeat($str, $n) {
|
||||||
|
$result: "";
|
||||||
|
@for $_ from 0 to $n {
|
||||||
|
$result: $result + $str;
|
||||||
|
}
|
||||||
|
@return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Support up to 6 levels of indentation for lists in ToCs
|
||||||
|
@for $i from 0 to 5 {
|
||||||
|
& > ul #{repeat("> li > ul", $i)} > li.active-class > a {
|
||||||
|
$n: 25 + $i * 35;
|
||||||
|
margin-left: calc(-#{$n}px - 1em);
|
||||||
|
padding-left: calc(#{$n}px + 1em - var(--heading-border-size));
|
||||||
|
}
|
||||||
|
|
||||||
|
& > ol #{repeat("> li > ol", $i)} > li.active-class > a {
|
||||||
|
$n: 9 + $i * 35;
|
||||||
|
margin-left: calc(-#{$n}px - 1em);
|
||||||
|
padding-left: calc(#{$n}px + 1em - var(--heading-border-size));
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -10,6 +10,8 @@ import { getColor } from 'ts/color';
|
||||||
import menu from 'ts/menu';
|
import menu from 'ts/menu';
|
||||||
import createElement from 'ts/createElement';
|
import createElement from 'ts/createElement';
|
||||||
import StackColorScheme from 'ts/colorScheme';
|
import StackColorScheme from 'ts/colorScheme';
|
||||||
|
import { setupScrollspy } from 'ts/scrollspy';
|
||||||
|
import { setupSmoothAnchors } from "ts/smoothAnchors";
|
||||||
|
|
||||||
let Stack = {
|
let Stack = {
|
||||||
init: () => {
|
init: () => {
|
||||||
|
@ -21,6 +23,8 @@ let Stack = {
|
||||||
const articleContent = document.querySelector('.article-content') as HTMLElement;
|
const articleContent = document.querySelector('.article-content') as HTMLElement;
|
||||||
if (articleContent) {
|
if (articleContent) {
|
||||||
new StackGallery(articleContent);
|
new StackGallery(articleContent);
|
||||||
|
setupSmoothAnchors();
|
||||||
|
setupScrollspy();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
131
assets/ts/scrollspy.ts
Normal file
131
assets/ts/scrollspy.ts
Normal file
|
@ -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 };
|
34
assets/ts/smoothAnchors.ts
Normal file
34
assets/ts/smoothAnchors.ts
Normal file
|
@ -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 };
|
Loading…
Reference in a new issue