Welcome to mirror list, hosted at ThFree Co, Russian Federation.

github.com/CaiJimmy/hugo-theme-stack.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorZoroark <utybodev@gmail.com>2022-01-22 12:35:08 +0300
committerGitHub <noreply@github.com>2022-01-22 12:35:08 +0300
commit2b40a32d4785ab3c3d12a09ea8a136b1522deabe (patch)
treedc49eede72ca356854d74807ebef25734e1bd7f7
parent9e0885468167bf1ad831436df328542b6e7f57f7 (diff)
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
-rw-r--r--.gitignore3
-rw-r--r--assets/scss/partials/base.scss1
-rw-r--r--assets/scss/partials/layout/article.scss41
-rw-r--r--assets/ts/main.ts4
-rw-r--r--assets/ts/scrollspy.ts131
-rw-r--r--assets/ts/smoothAnchors.ts34
6 files changed, 209 insertions, 5 deletions
diff --git a/.gitignore b/.gitignore
index 9ebefdf..9ff142d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,4 @@
public
resources
-assets/jsconfig.json \ No newline at end of file
+assets/jsconfig.json
+.hugo_build.lock \ No newline at end of file
diff --git a/assets/scss/partials/base.scss b/assets/scss/partials/base.scss
index f02dcbd..408629e 100644
--- a/assets/scss/partials/base.scss
+++ b/assets/scss/partials/base.scss
@@ -1,7 +1,6 @@
html {
font-size: 62.5%;
overflow-y: scroll;
- scroll-behavior: smooth;
}
* {
diff --git a/assets/scss/partials/layout/article.scss b/assets/scss/partials/layout/article.scss
index b421cfc..78ed81c 100644
--- a/assets/scss/partials/layout/article.scss
+++ b/assets/scss/partials/layout/article.scss
@@ -123,7 +123,6 @@
}
.article-page.has-toc {
- scroll-behavior: smooth;
.left-sidebar {
display: none;
@@ -194,6 +193,10 @@
color: var(--card-text-color-main);
overflow: hidden;
+ ::-webkit-scrollbar-thumb {
+ background-color: var(--card-separator-color);
+ }
+
#TableOfContents {
overflow-x: auto;
max-height: 75vh;
@@ -208,7 +211,7 @@
list-style-type: none;
counter-reset: item;
- li:before {
+ li a::before {
counter-increment: item;
content: counters(item, ".") ". ";
font-weight: bold;
@@ -221,7 +224,7 @@
}
li {
- margin: 15px 20px;
+ margin: 15px 0 15px 20px;
padding: 5px;
& > 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;
+ }
+ }
}
}
diff --git a/assets/ts/main.ts b/assets/ts/main.ts
index 625bbbc..20de18c 100644
--- a/assets/ts/main.ts
+++ b/assets/ts/main.ts
@@ -10,6 +10,8 @@ import { getColor } from 'ts/color';
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: () => {
@@ -21,6 +23,8 @@ let Stack = {
const articleContent = document.querySelector('.article-content') as HTMLElement;
if (articleContent) {
new StackGallery(articleContent);
+ setupSmoothAnchors();
+ setupScrollspy();
}
/**
diff --git a/assets/ts/scrollspy.ts b/assets/ts/scrollspy.ts
new file mode 100644
index 0000000..8a14085
--- /dev/null
+++ b/assets/ts/scrollspy.ts
@@ -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 }; \ No newline at end of file
diff --git a/assets/ts/smoothAnchors.ts b/assets/ts/smoothAnchors.ts
new file mode 100644
index 0000000..0718bf5
--- /dev/null
+++ b/assets/ts/smoothAnchors.ts
@@ -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 }; \ No newline at end of file