aboutsummaryrefslogtreecommitdiff
path: root/themes/hugo-theme-stack/assets/ts
diff options
context:
space:
mode:
authorAlexander Neonxp Kiryukhin <i@neonxp.ru>2024-11-03 20:08:36 +0300
committerAlexander Neonxp Kiryukhin <i@neonxp.ru>2024-11-03 20:08:36 +0300
commit59c7d4567380d1a9c80e96eb958fdbdd512ce006 (patch)
tree65410cfc10dbc7d060ec23be110662d9b7f6b0e9 /themes/hugo-theme-stack/assets/ts
новая жизнь блога
Diffstat (limited to 'themes/hugo-theme-stack/assets/ts')
-rw-r--r--themes/hugo-theme-stack/assets/ts/color.ts63
-rw-r--r--themes/hugo-theme-stack/assets/ts/colorScheme.ts92
-rw-r--r--themes/hugo-theme-stack/assets/ts/createElement.ts34
-rw-r--r--themes/hugo-theme-stack/assets/ts/gallery.ts186
-rw-r--r--themes/hugo-theme-stack/assets/ts/main.ts112
-rw-r--r--themes/hugo-theme-stack/assets/ts/menu.ts83
-rw-r--r--themes/hugo-theme-stack/assets/ts/scrollspy.ts131
-rw-r--r--themes/hugo-theme-stack/assets/ts/search.tsx333
-rw-r--r--themes/hugo-theme-stack/assets/ts/smoothAnchors.ts37
9 files changed, 1071 insertions, 0 deletions
diff --git a/themes/hugo-theme-stack/assets/ts/color.ts b/themes/hugo-theme-stack/assets/ts/color.ts
new file mode 100644
index 0000000..50581d1
--- /dev/null
+++ b/themes/hugo-theme-stack/assets/ts/color.ts
@@ -0,0 +1,63 @@
+interface colorScheme {
+ hash: string, /// Regenerate color scheme when the image hash is changed
+ DarkMuted: {
+ hex: string,
+ rgb: Number[],
+ bodyTextColor: string
+ },
+ Vibrant: {
+ hex: string,
+ rgb: Number[],
+ bodyTextColor: string
+ }
+}
+
+let colorsCache: { [key: string]: colorScheme } = {};
+
+if (localStorage.hasOwnProperty('StackColorsCache')) {
+ try {
+ colorsCache = JSON.parse(localStorage.getItem('StackColorsCache'));
+ }
+ catch (e) {
+ colorsCache = {};
+ }
+}
+
+async function getColor(key: string, hash: string, imageURL: string) {
+ if (!key) {
+ /**
+ * If no key is provided, do not cache the result
+ */
+ return await Vibrant.from(imageURL).getPalette();
+ }
+
+ if (!colorsCache.hasOwnProperty(key) || colorsCache[key].hash !== hash) {
+ /**
+ * If key is provided, but not found in cache, or the hash mismatches => Regenerate color scheme
+ */
+ const palette = await Vibrant.from(imageURL).getPalette();
+
+ colorsCache[key] = {
+ hash: hash,
+ Vibrant: {
+ hex: palette.Vibrant.hex,
+ rgb: palette.Vibrant.rgb,
+ bodyTextColor: palette.Vibrant.bodyTextColor
+ },
+ DarkMuted: {
+ hex: palette.DarkMuted.hex,
+ rgb: palette.DarkMuted.rgb,
+ bodyTextColor: palette.DarkMuted.bodyTextColor
+ }
+ }
+
+ /* Save the result in localStorage */
+ localStorage.setItem('StackColorsCache', JSON.stringify(colorsCache));
+ }
+
+ return colorsCache[key];
+}
+
+export {
+ getColor
+} \ No newline at end of file
diff --git a/themes/hugo-theme-stack/assets/ts/colorScheme.ts b/themes/hugo-theme-stack/assets/ts/colorScheme.ts
new file mode 100644
index 0000000..978e98e
--- /dev/null
+++ b/themes/hugo-theme-stack/assets/ts/colorScheme.ts
@@ -0,0 +1,92 @@
+type colorScheme = 'light' | 'dark' | 'auto';
+
+class StackColorScheme {
+ private localStorageKey = 'StackColorScheme';
+ private currentScheme: colorScheme;
+ private systemPreferScheme: colorScheme;
+
+ constructor(toggleEl: HTMLElement) {
+ this.bindMatchMedia();
+ this.currentScheme = this.getSavedScheme();
+ if (window.matchMedia('(prefers-color-scheme: dark)').matches === true)
+ this.systemPreferScheme = 'dark'
+ else
+ this.systemPreferScheme = 'light';
+
+ this.dispatchEvent(document.documentElement.dataset.scheme as colorScheme);
+
+ if (toggleEl)
+ this.bindClick(toggleEl);
+
+ if (document.body.style.transition == '')
+ document.body.style.setProperty('transition', 'background-color .3s ease');
+ }
+
+ private saveScheme() {
+ localStorage.setItem(this.localStorageKey, this.currentScheme);
+ }
+
+ private bindClick(toggleEl: HTMLElement) {
+ toggleEl.addEventListener('click', (e) => {
+ if (this.isDark()) {
+ /// Disable dark mode
+ this.currentScheme = 'light';
+ }
+ else {
+ this.currentScheme = 'dark';
+ }
+
+ this.setBodyClass();
+
+ if (this.currentScheme == this.systemPreferScheme) {
+ /// Set to auto
+ this.currentScheme = 'auto';
+ }
+
+ this.saveScheme();
+ })
+ }
+
+ private isDark() {
+ return (this.currentScheme == 'dark' || this.currentScheme == 'auto' && this.systemPreferScheme == 'dark');
+ }
+
+ private dispatchEvent(colorScheme: colorScheme) {
+ const event = new CustomEvent('onColorSchemeChange', {
+ detail: colorScheme
+ });
+ window.dispatchEvent(event);
+ }
+
+ private setBodyClass() {
+ if (this.isDark()) {
+ document.documentElement.dataset.scheme = 'dark';
+ }
+ else {
+ document.documentElement.dataset.scheme = 'light';
+ }
+
+ this.dispatchEvent(document.documentElement.dataset.scheme as colorScheme);
+ }
+
+ private getSavedScheme(): colorScheme {
+ const savedScheme = localStorage.getItem(this.localStorageKey);
+
+ if (savedScheme == 'light' || savedScheme == 'dark' || savedScheme == 'auto') return savedScheme;
+ else return 'auto';
+ }
+
+ private bindMatchMedia() {
+ window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
+ if (e.matches) {
+ this.systemPreferScheme = 'dark';
+ }
+ else {
+ this.systemPreferScheme = 'light';
+ }
+ this.setBodyClass();
+ });
+ }
+}
+
+export default StackColorScheme;
diff --git a/themes/hugo-theme-stack/assets/ts/createElement.ts b/themes/hugo-theme-stack/assets/ts/createElement.ts
new file mode 100644
index 0000000..3a1e85e
--- /dev/null
+++ b/themes/hugo-theme-stack/assets/ts/createElement.ts
@@ -0,0 +1,34 @@
+/**
+ * createElement
+ * Edited from:
+ * @link https://stackoverflow.com/a/42405694
+ */
+function createElement(tag, attrs, children) {
+ var element = document.createElement(tag);
+
+ for (let name in attrs) {
+ if (name && attrs.hasOwnProperty(name)) {
+ let value = attrs[name];
+
+ if (name == "dangerouslySetInnerHTML") {
+ element.innerHTML = value.__html;
+ }
+ else if (value === true) {
+ element.setAttribute(name, name);
+ } else if (value !== false && value != null) {
+ element.setAttribute(name, value.toString());
+ }
+ }
+ }
+ for (let i = 2; i < arguments.length; i++) {
+ let child = arguments[i];
+ if (child) {
+ element.appendChild(
+ child.nodeType == null ?
+ document.createTextNode(child.toString()) : child);
+ }
+ }
+ return element;
+}
+
+export default createElement; \ No newline at end of file
diff --git a/themes/hugo-theme-stack/assets/ts/gallery.ts b/themes/hugo-theme-stack/assets/ts/gallery.ts
new file mode 100644
index 0000000..9840f1e
--- /dev/null
+++ b/themes/hugo-theme-stack/assets/ts/gallery.ts
@@ -0,0 +1,186 @@
+declare global {
+ interface Window {
+ PhotoSwipe: any;
+ PhotoSwipeUI_Default: any
+ }
+}
+
+interface PhotoSwipeItem {
+ w: number;
+ h: number;
+ src: string;
+ msrc: string;
+ title?: string;
+ el: HTMLElement;
+}
+
+class StackGallery {
+ private galleryUID: number;
+ private items: PhotoSwipeItem[] = [];
+
+ constructor(container: HTMLElement, galleryUID = 1) {
+ if (window.PhotoSwipe == undefined || window.PhotoSwipeUI_Default == undefined) {
+ console.error("PhotoSwipe lib not loaded.");
+ return;
+ }
+
+ this.galleryUID = galleryUID;
+
+ StackGallery.createGallery(container);
+ this.loadItems(container);
+ this.bindClick();
+ }
+
+ private loadItems(container: HTMLElement) {
+ this.items = [];
+
+ const figures = container.querySelectorAll('figure.gallery-image');
+
+ for (const el of figures) {
+ const figcaption = el.querySelector('figcaption'),
+ img = el.querySelector('img');
+
+ let aux: PhotoSwipeItem = {
+ w: parseInt(img.getAttribute('width')),
+ h: parseInt(img.getAttribute('height')),
+ src: img.src,
+ msrc: img.getAttribute('data-thumb') || img.src,
+ el: el
+ }
+
+ if (figcaption) {
+ aux.title = figcaption.innerHTML;
+ }
+
+ this.items.push(aux);
+ }
+ }
+
+ public static createGallery(container: HTMLElement) {
+ /// The process of wrapping image with figure tag is done using JavaScript instead of only Hugo markdown render hook
+ /// because it can not detect whether image is being wrapped by a link or not
+ /// and it lead to a invalid HTML construction (<a><figure><img></figure></a>)
+
+ const images = container.querySelectorAll('img.gallery-image');
+ for (const img of Array.from(images)) {
+ /// Images are wrapped with figure tag if the paragraph has only images without texts
+ /// This is done to allow inline images within paragraphs
+ const paragraph = img.closest('p');
+
+ if (!paragraph || !container.contains(paragraph)) continue;
+
+ if (paragraph.textContent.trim() == '') {
+ /// Once we insert figcaption, this check no longer works
+ /// So we add a class to paragraph to mark it
+ paragraph.classList.add('no-text');
+ }
+
+ let isNewLineImage = paragraph.classList.contains('no-text');
+ if (!isNewLineImage) continue;
+
+ const hasLink = img.parentElement.tagName == 'A';
+
+ let el: HTMLElement = img;
+ /// Wrap image with figure tag, with flex-grow and flex-basis values extracted from img's data attributes
+ const figure = document.createElement('figure');
+ figure.style.setProperty('flex-grow', img.getAttribute('data-flex-grow') || '1');
+ figure.style.setProperty('flex-basis', img.getAttribute('data-flex-basis') || '0');
+ if (hasLink) {
+ /// Wrap <a> if it exists
+ el = img.parentElement;
+ }
+ el.parentElement.insertBefore(figure, el);
+ figure.appendChild(el);
+
+ /// Add figcaption if it exists
+ if (img.hasAttribute('alt')) {
+ const figcaption = document.createElement('figcaption');
+ figcaption.innerText = img.getAttribute('alt');
+ figure.appendChild(figcaption);
+ }
+
+ /// Wrap img tag with <a> tag if image was not wrapped by <a> tag
+ if (!hasLink) {
+ figure.className = 'gallery-image';
+
+ const a = document.createElement('a');
+ a.href = img.src;
+ a.setAttribute('target', '_blank');
+ img.parentNode.insertBefore(a, img);
+ a.appendChild(img);
+ }
+ }
+
+ const figuresEl = container.querySelectorAll('figure.gallery-image');
+
+ let currentGallery = [];
+
+ for (const figure of figuresEl) {
+ if (!currentGallery.length) {
+ /// First iteration
+ currentGallery = [figure];
+ }
+ else if (figure.previousElementSibling === currentGallery[currentGallery.length - 1]) {
+ /// Adjacent figures
+ currentGallery.push(figure);
+ }
+ else if (currentGallery.length) {
+ /// End gallery
+ StackGallery.wrap(currentGallery);
+ currentGallery = [figure];
+ }
+ }
+
+ if (currentGallery.length > 0) {
+ StackGallery.wrap(currentGallery);
+ }
+ }
+
+ /**
+ * Wrap adjacent figure tags with div.gallery
+ * @param figures
+ */
+ public static wrap(figures: HTMLElement[]) {
+ const galleryContainer = document.createElement('div');
+ galleryContainer.className = 'gallery';
+
+ const parentNode = figures[0].parentNode,
+ first = figures[0];
+
+ parentNode.insertBefore(galleryContainer, first)
+
+ for (const figure of figures) {
+ galleryContainer.appendChild(figure);
+ }
+ }
+
+ public open(index: number) {
+ const pswp = document.querySelector('.pswp') as HTMLDivElement;
+ const ps = new window.PhotoSwipe(pswp, window.PhotoSwipeUI_Default, this.items, {
+ index: index,
+ galleryUID: this.galleryUID,
+ getThumbBoundsFn: (index) => {
+ const thumbnail = this.items[index].el.getElementsByTagName('img')[0],
+ pageYScroll = window.pageYOffset || document.documentElement.scrollTop,
+ rect = thumbnail.getBoundingClientRect();
+
+ return { x: rect.left, y: rect.top + pageYScroll, w: rect.width };
+ }
+ });
+
+ ps.init();
+ }
+
+ private bindClick() {
+ for (const [index, item] of this.items.entries()) {
+ const a = item.el.querySelector('a');
+
+ a.addEventListener('click', (e) => {
+ e.preventDefault();
+ this.open(index);
+ })
+ }
+ }
+}
+
+export default StackGallery; \ No newline at end of file
diff --git a/themes/hugo-theme-stack/assets/ts/main.ts b/themes/hugo-theme-stack/assets/ts/main.ts
new file mode 100644
index 0000000..f3160ae
--- /dev/null
+++ b/themes/hugo-theme-stack/assets/ts/main.ts
@@ -0,0 +1,112 @@
+/*!
+* Hugo Theme Stack
+*
+* @author: Jimmy Cai
+* @website: https://jimmycai.com
+* @link: https://github.com/CaiJimmy/hugo-theme-stack
+*/
+import StackGallery from "ts/gallery";
+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: () => {
+ /**
+ * Bind menu event
+ */
+ menu();
+
+ const articleContent = document.querySelector('.article-content') as HTMLElement;
+ if (articleContent) {
+ new StackGallery(articleContent);
+ setupSmoothAnchors();
+ setupScrollspy();
+ }
+
+ /**
+ * Add linear gradient background to tile style article
+ */
+ const articleTile = document.querySelector('.article-list--tile');
+ if (articleTile) {
+ let observer = new IntersectionObserver(async (entries, observer) => {
+ entries.forEach(entry => {
+ if (!entry.isIntersecting) return;
+ observer.unobserve(entry.target);
+
+ const articles = entry.target.querySelectorAll('article.has-image');
+ articles.forEach(async articles => {
+ const image = articles.querySelector('img'),
+ imageURL = image.src,
+ key = image.getAttribute('data-key'),
+ hash = image.getAttribute('data-hash'),
+ articleDetails: HTMLDivElement = articles.querySelector('.article-details');
+
+ const colors = await getColor(key, hash, imageURL);
+
+ articleDetails.style.background = `
+ linear-gradient(0deg,
+ rgba(${colors.DarkMuted.rgb[0]}, ${colors.DarkMuted.rgb[1]}, ${colors.DarkMuted.rgb[2]}, 0.5) 0%,
+ rgba(${colors.Vibrant.rgb[0]}, ${colors.Vibrant.rgb[1]}, ${colors.Vibrant.rgb[2]}, 0.75) 100%)`;
+ })
+ })
+ });
+
+ observer.observe(articleTile)
+ }
+
+
+ /**
+ * Add copy button to code block
+ */
+ const highlights = document.querySelectorAll('.article-content div.highlight');
+ const copyText = `Copy`,
+ copiedText = `Copied!`;
+
+ highlights.forEach(highlight => {
+ const copyButton = document.createElement('button');
+ copyButton.innerHTML = copyText;
+ copyButton.classList.add('copyCodeButton');
+ highlight.appendChild(copyButton);
+
+ const codeBlock = highlight.querySelector('code[data-lang]');
+ if (!codeBlock) return;
+
+ copyButton.addEventListener('click', () => {
+ navigator.clipboard.writeText(codeBlock.textContent)
+ .then(() => {
+ copyButton.textContent = copiedText;
+
+ setTimeout(() => {
+ copyButton.textContent = copyText;
+ }, 1000);
+ })
+ .catch(err => {
+ alert(err)
+ console.log('Something went wrong', err);
+ });
+ });
+ });
+
+ new StackColorScheme(document.getElementById('dark-mode-toggle'));
+ }
+}
+
+window.addEventListener('load', () => {
+ setTimeout(function () {
+ Stack.init();
+ }, 0);
+})
+
+declare global {
+ interface Window {
+ createElement: any;
+ Stack: any
+ }
+}
+
+window.Stack = Stack;
+window.createElement = createElement; \ No newline at end of file
diff --git a/themes/hugo-theme-stack/assets/ts/menu.ts b/themes/hugo-theme-stack/assets/ts/menu.ts
new file mode 100644
index 0000000..34615ba
--- /dev/null
+++ b/themes/hugo-theme-stack/assets/ts/menu.ts
@@ -0,0 +1,83 @@
+/**
+ * Slide up/down
+ * Code from https://dev.to/bmsvieira/vanilla-js-slidedown-up-4dkn
+ * @param target
+ * @param duration
+ */
+let slideUp = (target: HTMLElement, duration = 500) => {
+ target.classList.add('transiting');
+ target.style.transitionProperty = 'height, margin, padding';
+ target.style.transitionDuration = duration + 'ms';
+ ///target.style.boxSizing = 'border-box';
+ target.style.height = target.offsetHeight + 'px';
+ target.offsetHeight;
+ target.style.overflow = 'hidden';
+ target.style.height = "0";
+ target.style.paddingTop = "0";
+ target.style.paddingBottom = "0";
+ target.style.marginTop = "0";
+ target.style.marginBottom = "0";
+ window.setTimeout(() => {
+ target.classList.remove('show')
+ target.style.removeProperty('height');
+ target.style.removeProperty('padding-top');
+ target.style.removeProperty('padding-bottom');
+ target.style.removeProperty('margin-top');
+ target.style.removeProperty('margin-bottom');
+ target.style.removeProperty('overflow');
+ target.style.removeProperty('transition-duration');
+ target.style.removeProperty('transition-property');
+ target.classList.remove('transiting');
+ }, duration);
+}
+
+let slideDown = (target: HTMLElement, duration = 500) => {
+ target.classList.add('transiting');
+ target.style.removeProperty('display');
+
+ target.classList.add('show');
+
+ let height = target.offsetHeight;
+ target.style.overflow = 'hidden';
+ target.style.height = "0";
+ target.style.paddingTop = "0";
+ target.style.paddingBottom = "0";
+ target.style.marginTop = "0";
+ target.style.marginBottom = "0";
+ target.offsetHeight;
+ ///target.style.boxSizing = 'border-box';
+ target.style.transitionProperty = "height, margin, padding";
+ target.style.transitionDuration = duration + 'ms';
+ target.style.height = height + 'px';
+ target.style.removeProperty('padding-top');
+ target.style.removeProperty('padding-bottom');
+ target.style.removeProperty('margin-top');
+ target.style.removeProperty('margin-bottom');
+ window.setTimeout(() => {
+ target.style.removeProperty('height');
+ target.style.removeProperty('overflow');
+ target.style.removeProperty('transition-duration');
+ target.style.removeProperty('transition-property');
+ target.classList.remove('transiting');
+ }, duration);
+}
+
+let slideToggle = (target, duration = 500) => {
+ if (window.getComputedStyle(target).display === 'none') {
+ return slideDown(target, duration);
+ } else {
+ return slideUp(target, duration);
+ }
+}
+
+export default function () {
+ const toggleMenu = document.getElementById('toggle-menu');
+ if (toggleMenu) {
+ toggleMenu.addEventListener('click', () => {
+ if (document.getElementById('main-menu').classList.contains('transiting')) return;
+ document.body.classList.toggle('show-menu');
+ slideToggle(document.getElementById('main-menu'), 300);
+ toggleMenu.classList.toggle('is-active');
+ });
+ }
+} \ No newline at end of file
diff --git a/themes/hugo-theme-stack/assets/ts/scrollspy.ts b/themes/hugo-theme-stack/assets/ts/scrollspy.ts
new file mode 100644
index 0000000..8a14085
--- /dev/null
+++ b/themes/hugo-theme-stack/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/themes/hugo-theme-stack/assets/ts/search.tsx b/themes/hugo-theme-stack/assets/ts/search.tsx
new file mode 100644
index 0000000..1c81dd1
--- /dev/null
+++ b/themes/hugo-theme-stack/assets/ts/search.tsx
@@ -0,0 +1,333 @@
+interface pageData {
+ title: string,
+ date: string,
+ permalink: string,
+ content: string,
+ image?: string,
+ preview: string,
+ matchCount: number
+}
+
+interface match {
+ start: number,
+ end: number
+}
+
+/**
+ * Escape HTML tags as HTML entities
+ * Edited from:
+ * @link https://stackoverflow.com/a/5499821
+ */
+const tagsToReplace = {
+ '&': '&amp;',
+ '<': '&lt;',
+ '>': '&gt;',
+ '"': '&quot;',
+ '…': '&hellip;'
+};
+
+function replaceTag(tag) {
+ return tagsToReplace[tag] || tag;
+}
+
+function replaceHTMLEnt(str) {
+ return str.replace(/[&<>"]/g, replaceTag);
+}
+
+function escapeRegExp(string) {
+ return string.replace(/[.*+\-?^${}()|[\]\\]/g, '\\$&');
+}
+
+class Search {
+ private data: pageData[];
+ private form: HTMLFormElement;
+ private input: HTMLInputElement;
+ private list: HTMLDivElement;
+ private resultTitle: HTMLHeadElement;
+ private resultTitleTemplate: string;
+
+ constructor({ form, input, list, resultTitle, resultTitleTemplate }) {
+ this.form = form;
+ this.input = input;
+ this.list = list;
+ this.resultTitle = resultTitle;
+ this.resultTitleTemplate = resultTitleTemplate;
+
+ /// Check if there's already value in the search input
+ if (this.input.value.trim() !== '') {
+ this.doSearch(this.input.value.split(' '));
+ }
+ else {
+ this.handleQueryString();
+ }
+
+ this.bindQueryStringChange();
+ this.bindSearchForm();
+ }
+
+ /**
+ * Processes search matches
+ * @param str original text
+ * @param matches array of matches
+ * @param ellipsis whether to add ellipsis to the end of each match
+ * @param charLimit max length of preview string
+ * @param offset how many characters before and after the match to include in preview
+ * @returns preview string
+ */
+ private static processMatches(str: string, matches: match[], ellipsis: boolean = true, charLimit = 140, offset = 20): string {
+ matches.sort((a, b) => {
+ return a.start - b.start;
+ });
+
+ let i = 0,
+ lastIndex = 0,
+ charCount = 0;
+
+ const resultArray: string[] = [];
+
+ while (i < matches.length) {
+ const item = matches[i];
+
+ /// item.start >= lastIndex (equal only for the first iteration)
+ /// because of the while loop that comes after, iterating over variable j
+
+ if (ellipsis && item.start - offset > lastIndex) {
+ resultArray.push(`${replaceHTMLEnt(str.substring(lastIndex, lastIndex + offset))} [...] `);
+ resultArray.push(`${replaceHTMLEnt(str.substring(item.start - offset, item.start))}`);
+ charCount += offset * 2;
+ }
+ else {
+ /// If the match is too close to the end of last match, don't add ellipsis
+ resultArray.push(replaceHTMLEnt(str.substring(lastIndex, item.start)));
+ charCount += item.start - lastIndex;
+ }
+
+ let j = i + 1,
+ end = item.end;
+
+ /// Include as many matches as possible
+ /// [item.start, end] is the range of the match
+ while (j < matches.length && matches[j].start <= end) {
+ end = Math.max(matches[j].end, end);
+ ++j;
+ }
+
+ resultArray.push(`<mark>${replaceHTMLEnt(str.substring(item.start, end))}</mark>`);
+ charCount += end - item.start;
+
+ i = j;
+ lastIndex = end;
+
+ if (ellipsis && charCount > charLimit) break;
+ }
+
+ /// Add the rest of the string
+ if (lastIndex < str.length) {
+ let end = str.length;
+ if (ellipsis) end = Math.min(end, lastIndex + offset);
+
+ resultArray.push(`${replaceHTMLEnt(str.substring(lastIndex, end))}`);
+
+ if (ellipsis && end != str.length) {
+ resultArray.push(` [...]`);
+ }
+ }
+
+ return resultArray.join('');
+ }
+
+ private async searchKeywords(keywords: string[]) {
+ const rawData = await this.getData();
+ const results: pageData[] = [];
+
+ const regex = new RegExp(keywords.filter((v, index, arr) => {
+ arr[index] = escapeRegExp(v);
+ return v.trim() !== '';
+ }).join('|'), 'gi');
+
+ for (const item of rawData) {
+ const titleMatches: match[] = [],
+ contentMatches: match[] = [];
+
+ let result = {
+ ...item,
+ preview: '',
+ matchCount: 0
+ }
+
+ const contentMatchAll = item.content.matchAll(regex);
+ for (const match of Array.from(contentMatchAll)) {
+ contentMatches.push({
+ start: match.index,
+ end: match.index + match[0].length
+ });
+ }
+
+ const titleMatchAll = item.title.matchAll(regex);
+ for (const match of Array.from(titleMatchAll)) {
+ titleMatches.push({
+ start: match.index,
+ end: match.index + match[0].length
+ });
+ }
+
+ if (titleMatches.length > 0) result.title = Search.processMatches(result.title, titleMatches, false);
+ if (contentMatches.length > 0) {
+ result.preview = Search.processMatches(result.content, contentMatches);
+ }
+ else {
+ /// If there are no matches in the content, use the first 140 characters as preview
+ result.preview = replaceHTMLEnt(result.content.substring(0, 140));
+ }
+
+ result.matchCount = titleMatches.length + contentMatches.length;
+ if (result.matchCount > 0) results.push(result);
+ }
+
+ /// Result with more matches appears first
+ return results.sort((a, b) => {
+ return b.matchCount - a.matchCount;
+ });
+ }
+
+ private async doSearch(keywords: string[]) {
+ const startTime = performance.now();
+
+ const results = await this.searchKeywords(keywords);
+ this.clear();
+
+ for (const item of results) {
+ this.list.append(Search.render(item));
+ }
+
+ const endTime = performance.now();
+
+ this.resultTitle.innerText = this.generateResultTitle(results.length, ((endTime - startTime) / 1000).toPrecision(1));
+ }
+
+ private generateResultTitle(resultLen, time) {
+ return this.resultTitleTemplate.replace("#PAGES_COUNT", resultLen).replace("#TIME_SECONDS", time);
+ }
+
+ public async getData() {
+ if (!this.data) {
+ /// Not fetched yet
+ const jsonURL = this.form.dataset.json;
+ this.data = await fetch(jsonURL).then(res => res.json());
+ const parser = new DOMParser();
+
+ for (const item of this.data) {
+ item.content = parser.parseFromString(item.content, 'text/html').body.innerText;
+ }
+ }
+
+ return this.data;
+ }
+
+ private bindSearchForm() {
+ let lastSearch = '';
+
+ const eventHandler = (e) => {
+ e.preventDefault();
+ const keywords = this.input.value.trim();
+
+ Search.updateQueryString(keywords, true);
+
+ if (keywords === '') {
+ lastSearch = '';
+ return this.clear();
+ }
+
+ if (lastSearch === keywords) return;
+ lastSearch = keywords;
+
+ this.doSearch(keywords.split(' '));
+ }
+
+ this.input.addEventListener('input', eventHandler);
+ this.input.addEventListener('compositionend', eventHandler);
+ }
+
+ private clear() {
+ this.list.innerHTML = '';
+ this.resultTitle.innerText = '';
+ }
+
+ private bindQueryStringChange() {
+ window.addEventListener('popstate', (e) => {
+ this.handleQueryString()
+ })
+ }
+
+ private handleQueryString() {
+ const pageURL = new URL(window.location.toString());
+ const keywords = pageURL.searchParams.get('keyword');
+ this.input.value = keywords;
+
+ if (keywords) {
+ this.doSearch(keywords.split(' '));
+ }
+ else {
+ this.clear()
+ }
+ }
+
+ private static updateQueryString(keywords: string, replaceState = false) {
+ const pageURL = new URL(window.location.toString());
+
+ if (keywords === '') {
+ pageURL.searchParams.delete('keyword')
+ }
+ else {
+ pageURL.searchParams.set('keyword', keywords);
+ }
+
+ if (replaceState) {
+ window.history.replaceState('', '', pageURL.toString());
+ }
+ else {
+ window.history.pushState('', '', pageURL.toString());
+ }
+ }
+
+ public static render(item: pageData) {
+ return <article>
+ <a href={item.permalink}>
+ <div class="article-details">
+ <h2 class="article-title" dangerouslySetInnerHTML={{ __html: item.title }}></h2>
+ <section class="article-preview" dangerouslySetInnerHTML={{ __html: item.preview }}></section>
+ </div>
+ {item.image &&
+ <div class="article-image">
+ <img src={item.image} loading="lazy" />
+ </div>
+ }
+ </a>
+ </article>;
+ }
+}
+
+declare global {
+ interface Window {
+ searchResultTitleTemplate: string;
+ }
+}
+
+window.addEventListener('load', () => {
+ setTimeout(function () {
+ const searchForm = document.querySelector('.search-form') as HTMLFormElement,
+ searchInput = searchForm.querySelector('input') as HTMLInputElement,
+ searchResultList = document.querySelector('.search-result--list') as HTMLDivElement,
+ searchResultTitle = document.querySelector('.search-result--title') as HTMLHeadingElement;
+
+ new Search({
+ form: searchForm,
+ input: searchInput,
+ list: searchResultList,
+ resultTitle: searchResultTitle,
+ resultTitleTemplate: window.searchResultTitleTemplate
+ });
+ }, 0);
+})
+
+export default Search; \ No newline at end of file
diff --git a/themes/hugo-theme-stack/assets/ts/smoothAnchors.ts b/themes/hugo-theme-stack/assets/ts/smoothAnchors.ts
new file mode 100644
index 0000000..16ab6a3
--- /dev/null
+++ b/themes/hugo-theme-stack/assets/ts/smoothAnchors.ts
@@ -0,0 +1,37 @@
+// 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();
+
+ const targetId = decodeURI(aElement.getAttribute("href").substring(1)),
+ target = document.getElementById(targetId) as HTMLElement,
+ offset = target.getBoundingClientRect().top - document.documentElement.getBoundingClientRect().top;
+
+ window.history.pushState({}, "", aElement.getAttribute("href"));
+ scrollTo({
+ top: offset,
+ behavior: "smooth"
+ });
+ });
+ });
+}
+
+export { setupSmoothAnchors }; \ No newline at end of file