fix: inline stack theme
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
import * as params from '@params';
|
||||
|
||||
export function setupCodeCopy() {
|
||||
/**
|
||||
* Add copy button to code block
|
||||
*/
|
||||
const highlights = document.querySelectorAll('.article-content div.highlight');
|
||||
const copyText = params.codeblock.copy,
|
||||
copiedText = params.codeblock.copied;
|
||||
|
||||
if (!navigator.clipboard) {
|
||||
/// Clipboard API is only supported in secure contexts (HTTPS)
|
||||
console.warn('Clipboard API not supported, copy button will not work.');
|
||||
return;
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -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;
|
||||
@@ -0,0 +1,141 @@
|
||||
/*
|
||||
* Consent-gated comments bootstrap.
|
||||
*
|
||||
* This module controls whether third-party comments are rendered based on
|
||||
* functional cookie consent, and injects template scripts in sequence to
|
||||
* avoid eager or duplicate script execution.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Consent event payload used by the comments gate.
|
||||
* It mirrors cookie consent state shape but keeps optional fields for safety.
|
||||
*/
|
||||
interface CommentsConsentState {
|
||||
necessary?: boolean;
|
||||
analytics?: boolean;
|
||||
functional?: boolean;
|
||||
timestamp?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve functional consent with layered fallback:
|
||||
* 1) explicit event detail, 2) CookieConsent public API, 3) dataset mirror.
|
||||
*/
|
||||
const hasFunctionalConsent = (consentDetail?: CommentsConsentState | null): boolean => {
|
||||
const cookieConsent = (window as Window & {
|
||||
cookieConsent?: {
|
||||
hasConsent?: (category: 'necessary' | 'analytics' | 'functional') => boolean;
|
||||
};
|
||||
}).cookieConsent;
|
||||
|
||||
return (
|
||||
consentDetail?.functional ??
|
||||
cookieConsent?.hasConsent?.('functional') ??
|
||||
document.documentElement.dataset.consentFunctional === 'true'
|
||||
);
|
||||
};
|
||||
|
||||
interface DeferredScript {
|
||||
placeholder: Comment;
|
||||
script: HTMLScriptElement;
|
||||
}
|
||||
|
||||
const prepareDeferredScripts = (fragment: DocumentFragment): DeferredScript[] => {
|
||||
return Array.from(fragment.querySelectorAll('script')).map(script => {
|
||||
const placeholder = document.createComment('comments-script-placeholder');
|
||||
script.replaceWith(placeholder);
|
||||
return { placeholder, script };
|
||||
});
|
||||
};
|
||||
|
||||
const activateDeferredScripts = async (scripts: DeferredScript[]): Promise<void> => {
|
||||
// Inject scripts sequentially so third-party embeds can rely on execution order.
|
||||
for (const { placeholder, script } of scripts) {
|
||||
if (!placeholder.parentNode) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const newScript = document.createElement('script');
|
||||
Array.from(script.attributes).forEach(attr => {
|
||||
newScript.setAttribute(attr.name, attr.value);
|
||||
});
|
||||
|
||||
if (script.textContent) {
|
||||
newScript.text = script.textContent;
|
||||
}
|
||||
|
||||
let done: Promise<void> = Promise.resolve();
|
||||
if (newScript.src) {
|
||||
done = new Promise<void>(resolve => {
|
||||
newScript.onload = newScript.onerror = () => resolve();
|
||||
});
|
||||
}
|
||||
|
||||
placeholder.replaceWith(newScript);
|
||||
await done;
|
||||
}
|
||||
};
|
||||
|
||||
const initCommentsConsent = (): void => {
|
||||
// Main entry: gate comments rendering by functional cookie consent.
|
||||
const placeholder = document.getElementById('comments-consent-placeholder') as HTMLElement | null;
|
||||
const container = document.getElementById('comments-container') as HTMLElement | null;
|
||||
const template = document.getElementById('comments-template') as HTMLTemplateElement | null;
|
||||
|
||||
if (!placeholder || !container || !template) {
|
||||
return;
|
||||
}
|
||||
|
||||
let commentsLoaded = false;
|
||||
let commentsLoading = false;
|
||||
|
||||
const showComments = async (): Promise<void> => {
|
||||
placeholder.style.display = 'none';
|
||||
container.style.display = 'block';
|
||||
|
||||
if (commentsLoaded || commentsLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevent duplicate template hydration while scripts are still loading.
|
||||
commentsLoading = true;
|
||||
|
||||
try {
|
||||
const clone = template.content.cloneNode(true) as DocumentFragment;
|
||||
const scripts = prepareDeferredScripts(clone);
|
||||
|
||||
container.appendChild(clone);
|
||||
await activateDeferredScripts(scripts);
|
||||
commentsLoaded = true;
|
||||
} finally {
|
||||
commentsLoading = false;
|
||||
}
|
||||
};
|
||||
|
||||
const hideComments = (): void => {
|
||||
placeholder.style.display = 'block';
|
||||
container.style.display = 'none';
|
||||
};
|
||||
|
||||
window.addEventListener('onCookieConsentChange', (event: Event) => {
|
||||
// Cross-module contract: this event is dispatched by cookies.ts.
|
||||
const customEvent = event as CustomEvent<CommentsConsentState | null>;
|
||||
if (hasFunctionalConsent(customEvent.detail)) {
|
||||
showComments();
|
||||
} else {
|
||||
hideComments();
|
||||
}
|
||||
});
|
||||
|
||||
if (hasFunctionalConsent()) {
|
||||
showComments();
|
||||
} else {
|
||||
hideComments();
|
||||
}
|
||||
};
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initCommentsConsent, { once: true });
|
||||
} else {
|
||||
initCommentsConsent();
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
interface ConsentState {
|
||||
necessary: boolean;
|
||||
analytics: boolean;
|
||||
functional: boolean;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
class CookieConsent {
|
||||
private static COOKIE_NAME = 'cookie_consent';
|
||||
private static COOKIE_DAYS = 365;
|
||||
|
||||
private state: ConsentState | null = null;
|
||||
private banner: HTMLElement | null = null;
|
||||
private settingsPanel: HTMLElement | null = null;
|
||||
|
||||
constructor() {
|
||||
this.banner = document.getElementById('cookie-consent-banner');
|
||||
this.settingsPanel = document.getElementById('cookie-settings-panel');
|
||||
|
||||
this.state = this.loadState();
|
||||
|
||||
if (!this.state && this.banner) {
|
||||
this.showBanner();
|
||||
}
|
||||
|
||||
this.bindEvents();
|
||||
this.dispatchConsentEvent();
|
||||
}
|
||||
|
||||
private loadState(): ConsentState | null {
|
||||
const cookie = document.cookie
|
||||
.split('; ')
|
||||
.find(row => row.startsWith(CookieConsent.COOKIE_NAME + '='));
|
||||
|
||||
if (!cookie) return null;
|
||||
|
||||
try {
|
||||
return JSON.parse(decodeURIComponent(cookie.split('=')[1]));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private saveState(): void {
|
||||
if (!this.state) return;
|
||||
|
||||
const expires = new Date();
|
||||
expires.setDate(expires.getDate() + CookieConsent.COOKIE_DAYS);
|
||||
|
||||
document.cookie = `${CookieConsent.COOKIE_NAME}=${encodeURIComponent(JSON.stringify(this.state))}; expires=${expires.toUTCString()}; path=/; SameSite=Lax`;
|
||||
}
|
||||
|
||||
private showBanner(): void {
|
||||
if (this.banner) {
|
||||
this.banner.removeAttribute('aria-hidden');
|
||||
}
|
||||
}
|
||||
|
||||
private hideBanner(): void {
|
||||
if (this.banner) {
|
||||
// Blur any focused element inside the banner before hiding
|
||||
const activeElement = document.activeElement as HTMLElement;
|
||||
if (activeElement && this.banner.contains(activeElement)) {
|
||||
activeElement.blur();
|
||||
}
|
||||
this.banner.setAttribute('aria-hidden', 'true');
|
||||
}
|
||||
this.hideSettings();
|
||||
}
|
||||
|
||||
private showSettings(): void {
|
||||
if (this.settingsPanel) {
|
||||
this.settingsPanel.removeAttribute('aria-hidden');
|
||||
|
||||
// Restore checkbox states from current state or defaults
|
||||
const checkboxes = this.settingsPanel.querySelectorAll('input[data-cookie-category]');
|
||||
checkboxes.forEach((cb) => {
|
||||
const input = cb as HTMLInputElement;
|
||||
const category = input.dataset.cookieCategory as keyof ConsentState;
|
||||
if (category && this.state && typeof this.state[category] === 'boolean') {
|
||||
input.checked = this.state[category] as boolean;
|
||||
} else {
|
||||
input.checked = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private hideSettings(): void {
|
||||
if (this.settingsPanel) {
|
||||
// Blur any focused element inside the settings panel before hiding
|
||||
const activeElement = document.activeElement as HTMLElement;
|
||||
if (activeElement && this.settingsPanel.contains(activeElement)) {
|
||||
activeElement.blur();
|
||||
}
|
||||
this.settingsPanel.setAttribute('aria-hidden', 'true');
|
||||
}
|
||||
}
|
||||
|
||||
private bindEvents(): void {
|
||||
document.addEventListener('click', (e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const action = target.dataset.cookieAction;
|
||||
|
||||
if (!action) return;
|
||||
|
||||
switch (action) {
|
||||
case 'accept':
|
||||
this.acceptAll();
|
||||
break;
|
||||
case 'deny':
|
||||
this.denyAll();
|
||||
break;
|
||||
case 'settings':
|
||||
this.showSettings();
|
||||
break;
|
||||
case 'save':
|
||||
this.saveSettings();
|
||||
break;
|
||||
case 'cancel':
|
||||
this.hideSettings();
|
||||
break;
|
||||
case 'reopen':
|
||||
this.showBanner();
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private acceptAll(): void {
|
||||
this.state = {
|
||||
necessary: true,
|
||||
analytics: true,
|
||||
functional: true,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
this.saveState();
|
||||
this.hideBanner();
|
||||
this.dispatchConsentEvent();
|
||||
}
|
||||
|
||||
private denyAll(): void {
|
||||
this.state = {
|
||||
necessary: true,
|
||||
analytics: false,
|
||||
functional: false,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
this.saveState();
|
||||
this.hideBanner();
|
||||
this.dispatchConsentEvent();
|
||||
}
|
||||
|
||||
private saveSettings(): void {
|
||||
const checkboxes = document.querySelectorAll('input[data-cookie-category]');
|
||||
|
||||
this.state = {
|
||||
necessary: true,
|
||||
analytics: false,
|
||||
functional: false,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
checkboxes.forEach((cb) => {
|
||||
const input = cb as HTMLInputElement;
|
||||
const category = input.dataset.cookieCategory as keyof ConsentState;
|
||||
if (category && category in this.state!) {
|
||||
(this.state as any)[category] = input.checked;
|
||||
}
|
||||
});
|
||||
|
||||
this.saveState();
|
||||
this.hideBanner();
|
||||
this.dispatchConsentEvent();
|
||||
}
|
||||
|
||||
private dispatchConsentEvent(): void {
|
||||
// Cross-module event consumed by consent-gated features (for example commentsConsent.ts).
|
||||
const event = new CustomEvent('onCookieConsentChange', {
|
||||
detail: this.state
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
|
||||
// Set data attributes on document for CSS-based control
|
||||
if (this.state) {
|
||||
document.documentElement.dataset.consentAnalytics = String(this.state.analytics);
|
||||
document.documentElement.dataset.consentFunctional = String(this.state.functional);
|
||||
}
|
||||
}
|
||||
|
||||
// Public API
|
||||
public hasConsent(category: keyof Omit<ConsentState, 'timestamp'>): boolean {
|
||||
if (!this.state) return false;
|
||||
return this.state[category] ?? false;
|
||||
}
|
||||
|
||||
public getState(): ConsentState | null {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
public reopenBanner(): void {
|
||||
this.showBanner();
|
||||
}
|
||||
}
|
||||
|
||||
// Export for module usage
|
||||
export default CookieConsent;
|
||||
|
||||
// Initialize when DOM is ready and expose globally
|
||||
declare global {
|
||||
interface Window {
|
||||
cookieConsent: CookieConsent;
|
||||
}
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
window.cookieConsent = new CookieConsent();
|
||||
});
|
||||
} else {
|
||||
window.cookieConsent = new CookieConsent();
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,110 @@
|
||||
const 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);
|
||||
}
|
||||
}
|
||||
|
||||
const unescapeHtml = (html: string) => {
|
||||
/// Replace special HTML entities with their corresponding characters
|
||||
return html.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
export default (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') as NodeListOf<HTMLImageElement>;
|
||||
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.classList.add('gallery-image');
|
||||
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.classList.add('image-link');
|
||||
el.setAttribute('data-pswp-width', img.getAttribute('width')!);
|
||||
el.setAttribute('data-pswp-height', img.getAttribute('height')!);
|
||||
} else {
|
||||
const a = document.createElement('a');
|
||||
a.href = img.src;
|
||||
a.setAttribute('class', 'image-link');
|
||||
a.setAttribute('target', '_blank');
|
||||
a.setAttribute('data-pswp-width', img.getAttribute('width')!);
|
||||
a.setAttribute('data-pswp-height', img.getAttribute('height')!);
|
||||
img.parentNode!.insertBefore(a, img);
|
||||
a.appendChild(img);
|
||||
el = a;
|
||||
}
|
||||
|
||||
el.parentElement!.insertBefore(figure, el);
|
||||
figure.appendChild(el);
|
||||
|
||||
/// Add figcaption if it exists
|
||||
let caption = img.getAttribute('alt');
|
||||
if (img.getAttribute('data-title-escaped')) {
|
||||
caption = unescapeHtml(img.getAttribute('data-title-escaped')!);
|
||||
}
|
||||
if (caption) {
|
||||
const figcaption = document.createElement('figcaption');
|
||||
figcaption.innerHTML = caption;
|
||||
figure.appendChild(figcaption);
|
||||
}
|
||||
}
|
||||
|
||||
const figuresEl = container.querySelectorAll('figure.gallery-image') as NodeListOf<HTMLElement>;
|
||||
|
||||
let currentGallery: HTMLElement[] = [];
|
||||
|
||||
for (const figure of Array.from(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
|
||||
wrap(currentGallery);
|
||||
currentGallery = [figure];
|
||||
}
|
||||
}
|
||||
|
||||
if (currentGallery.length > 0) {
|
||||
wrap(currentGallery);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
/*!
|
||||
* Hugo Theme Stack
|
||||
*
|
||||
* @author: Jimmy Cai
|
||||
* @website: https://jimmycai.com
|
||||
* @link: https://github.com/CaiJimmy/hugo-theme-stack
|
||||
*/
|
||||
import menu from './menu';
|
||||
import createElement from './createElement';
|
||||
import StackColorScheme from './colorScheme';
|
||||
import { setupScrollspy } from './scrollspy';
|
||||
import { setupSmoothAnchors } from './smoothAnchors';
|
||||
import { setupPaginationJump } from './pagination';
|
||||
import { setupCodeCopy } from './code-copy';
|
||||
|
||||
let Stack = {
|
||||
init: () => {
|
||||
/**
|
||||
* Bind menu event
|
||||
*/
|
||||
menu();
|
||||
|
||||
const articleContent = document.querySelector('.article-content') as HTMLElement;
|
||||
if (articleContent) {
|
||||
setupSmoothAnchors();
|
||||
setupScrollspy();
|
||||
setupCodeCopy();
|
||||
}
|
||||
|
||||
setupPaginationJump();
|
||||
|
||||
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;
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
declare const mermaid: {
|
||||
initialize(config: Record<string, any>): void;
|
||||
run(options: { nodes: HTMLElement[] }): Promise<void>;
|
||||
};
|
||||
|
||||
interface MermaidConfig {
|
||||
transparentBackground?: boolean;
|
||||
lightTheme?: string;
|
||||
darkTheme?: string;
|
||||
lightThemeVariables?: Record<string, any>;
|
||||
darkThemeVariables?: Record<string, any>;
|
||||
securityLevel?: string;
|
||||
look?: string;
|
||||
htmlLabels?: boolean;
|
||||
maxTextSize?: number;
|
||||
maxEdges?: number;
|
||||
fontSize?: number;
|
||||
fontFamily?: string;
|
||||
curve?: string;
|
||||
logLevel?: number;
|
||||
}
|
||||
|
||||
type Scheme = 'light' | 'dark';
|
||||
|
||||
const PANZOOM_CDN = 'https://cdn.jsdelivr.net/npm/panzoom@9.4.3/+esm';
|
||||
|
||||
function getScheme(): Scheme {
|
||||
return document.documentElement.dataset.scheme === 'dark' ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
function buildThemeConfig(cfg: MermaidConfig, scheme: Scheme) {
|
||||
const isLight = scheme === 'light';
|
||||
const theme = isLight ? (cfg.lightTheme ?? 'default') : (cfg.darkTheme ?? 'dark');
|
||||
const vars = isLight ? (cfg.lightThemeVariables ?? {}) : (cfg.darkThemeVariables ?? {});
|
||||
return {
|
||||
theme,
|
||||
themeVariables: { ...vars, ...(cfg.transparentBackground ? { background: 'transparent' } : {}) },
|
||||
};
|
||||
}
|
||||
|
||||
function buildBaseConfig(cfg: MermaidConfig): Record<string, any> {
|
||||
const base: Record<string, any> = {
|
||||
startOnLoad: false,
|
||||
securityLevel: cfg.securityLevel ?? 'strict',
|
||||
look: cfg.look ?? 'classic',
|
||||
flowchart: { htmlLabels: cfg.htmlLabels ?? true, useMaxWidth: true },
|
||||
gantt: { useWidth: 800 },
|
||||
};
|
||||
const optional: (keyof MermaidConfig)[] = ['maxTextSize', 'maxEdges', 'fontSize', 'fontFamily', 'curve', 'logLevel'];
|
||||
for (const key of optional) {
|
||||
if (cfg[key] != null) base[key] = cfg[key];
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
function initWithTheme(
|
||||
scheme: Scheme,
|
||||
themes: Record<Scheme, ReturnType<typeof buildThemeConfig>>,
|
||||
baseConfig: Record<string, any>,
|
||||
) {
|
||||
const { theme, themeVariables } = themes[scheme];
|
||||
mermaid.initialize({
|
||||
...baseConfig,
|
||||
theme,
|
||||
...(Object.keys(themeVariables).length && { themeVariables }),
|
||||
});
|
||||
}
|
||||
|
||||
async function renderOffscreen(sources: string[]): Promise<string[]> {
|
||||
const container = document.createElement('div');
|
||||
container.className = 'mermaid-offscreen';
|
||||
document.body.appendChild(container);
|
||||
const nodes = sources.map(src => {
|
||||
const n = document.createElement('pre');
|
||||
n.innerHTML = src;
|
||||
container.appendChild(n);
|
||||
return n;
|
||||
});
|
||||
await mermaid.run({ nodes });
|
||||
const results = nodes.map(n => n.innerHTML);
|
||||
container.remove();
|
||||
return results;
|
||||
}
|
||||
|
||||
function setupWrappers(elements: NodeListOf<HTMLElement>) {
|
||||
elements.forEach((el, idx) => {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'mermaid-wrapper';
|
||||
el.parentNode!.insertBefore(wrapper, el);
|
||||
wrapper.appendChild(el);
|
||||
wrapper.insertAdjacentHTML(
|
||||
'beforeend',
|
||||
`<div class="mermaid-toolbar"><button data-idx="${idx}" title="Open fullscreen with pan/zoom">⛶ Expand</button></div>`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function setupModal(elements: NodeListOf<HTMLElement>) {
|
||||
const modal = document.getElementById('mermaid-modal')!;
|
||||
const modalBody = document.getElementById('mermaid-modal-body')!;
|
||||
const modalContent = document.getElementById('mermaid-modal-content')!;
|
||||
let pzInstance: any = null;
|
||||
let panzoom: any = null;
|
||||
|
||||
const loadPanzoom = async () => {
|
||||
if (!panzoom) {
|
||||
const url = PANZOOM_CDN;
|
||||
panzoom = (await import(url)).default;
|
||||
}
|
||||
return panzoom;
|
||||
};
|
||||
|
||||
const fitToScreen = () => {
|
||||
const wrapper = modalContent.querySelector('.mermaid-panzoom-container') as HTMLElement | null;
|
||||
if (!pzInstance || !wrapper) return;
|
||||
const w = +(wrapper.dataset.nativeWidth ?? 0);
|
||||
const h = +(wrapper.dataset.nativeHeight ?? 0);
|
||||
const rect = modalContent.getBoundingClientRect();
|
||||
const scale = Math.min((rect.width - 60) / w, (rect.height - 60) / h);
|
||||
pzInstance.zoomAbs(0, 0, scale);
|
||||
pzInstance.moveTo((rect.width - w * scale) / 2, (rect.height - h * scale) / 2);
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
modal.classList.remove('active');
|
||||
document.body.style.overflow = '';
|
||||
pzInstance?.dispose();
|
||||
pzInstance = null;
|
||||
modalContent.innerHTML = '';
|
||||
};
|
||||
|
||||
const openModal = async (idx: number) => {
|
||||
const svg = elements[idx].querySelector('svg');
|
||||
if (!svg) return;
|
||||
|
||||
const svgClone = svg.cloneNode(true) as SVGElement;
|
||||
const viewBox = svg.getAttribute('viewBox');
|
||||
const [w, h] = viewBox
|
||||
? viewBox.split(/[\s,]+/).slice(2).map(Number)
|
||||
: [svg.getBoundingClientRect().width || 800, svg.getBoundingClientRect().height || 600];
|
||||
svgClone.setAttribute('width', String(w));
|
||||
svgClone.setAttribute('height', String(h));
|
||||
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'mermaid-panzoom-container';
|
||||
wrapper.dataset.nativeWidth = String(w);
|
||||
wrapper.dataset.nativeHeight = String(h);
|
||||
wrapper.appendChild(svgClone);
|
||||
|
||||
modalContent.innerHTML = '';
|
||||
modalContent.appendChild(wrapper);
|
||||
modal.classList.add('active');
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
const pz = await loadPanzoom();
|
||||
setTimeout(() => {
|
||||
pzInstance = pz(wrapper, { maxZoom: 10, minZoom: 0.05, bounds: false });
|
||||
fitToScreen();
|
||||
wrapper.classList.add('ready');
|
||||
}, 50);
|
||||
};
|
||||
|
||||
// Event delegation
|
||||
document.addEventListener('click', (e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const toolbarBtn = target.closest('.mermaid-toolbar button') as HTMLElement | null;
|
||||
if (toolbarBtn) return openModal(+(toolbarBtn.dataset.idx!));
|
||||
|
||||
const zoomBtn = target.closest('.mermaid-modal-controls button') as HTMLElement | null;
|
||||
if (zoomBtn && pzInstance) {
|
||||
const z = zoomBtn.dataset.zoom;
|
||||
const rect = modalBody.getBoundingClientRect();
|
||||
if (z === 'fit') fitToScreen();
|
||||
else if (z === '0') { pzInstance.moveTo(0, 0); pzInstance.zoomAbs(0, 0, 1); }
|
||||
else pzInstance.smoothZoom(rect.width / 2, rect.height / 2, z === '1' ? 1.5 : 0.67);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('mermaid-modal-close')!.addEventListener('click', closeModal);
|
||||
modalBody.addEventListener('click', (e) => { if (e.target === modalBody) closeModal(); });
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && modal.classList.contains('active')) closeModal();
|
||||
});
|
||||
}
|
||||
|
||||
export async function initMermaidPage(config: MermaidConfig) {
|
||||
const elements = document.querySelectorAll('.mermaid') as NodeListOf<HTMLElement>;
|
||||
if (!elements.length) return;
|
||||
|
||||
const sources = Array.from(elements).map(el => el.innerHTML);
|
||||
const perDiagramTransparent = sources.map(src => /%%\s*transparent\s*%%/i.test(src));
|
||||
const cache: Record<Scheme, string[]> = { light: [], dark: [] };
|
||||
|
||||
const themes = {
|
||||
light: buildThemeConfig(config, 'light'),
|
||||
dark: buildThemeConfig(config, 'dark'),
|
||||
};
|
||||
const baseConfig = buildBaseConfig(config);
|
||||
|
||||
const applyTransparency = (el: HTMLElement, i: number) => {
|
||||
if (perDiagramTransparent[i]) el.querySelector('svg')?.style.setProperty('background', 'transparent');
|
||||
};
|
||||
|
||||
setupWrappers(elements);
|
||||
setupModal(elements);
|
||||
|
||||
// Initial render
|
||||
const scheme = getScheme();
|
||||
initWithTheme(scheme, themes, baseConfig);
|
||||
await mermaid.run({ nodes: Array.from(elements) });
|
||||
elements.forEach((el, i) => {
|
||||
el.style.visibility = '';
|
||||
cache[scheme][i] = el.innerHTML;
|
||||
applyTransparency(el, i);
|
||||
});
|
||||
|
||||
// Pre-render alternate theme during idle time
|
||||
const alt: Scheme = scheme === 'dark' ? 'light' : 'dark';
|
||||
const idle = window.requestIdleCallback ?? ((fn: IdleRequestCallback) => setTimeout(fn, 1000));
|
||||
idle(() => {
|
||||
if (cache[alt].length) return;
|
||||
initWithTheme(alt, themes, baseConfig);
|
||||
renderOffscreen(sources).then(results => { cache[alt] = results; });
|
||||
});
|
||||
|
||||
// Swap cached diagrams on theme change
|
||||
window.addEventListener('onColorSchemeChange', async () => {
|
||||
const newScheme = getScheme();
|
||||
if (!cache[newScheme].length) {
|
||||
initWithTheme(newScheme, themes, baseConfig);
|
||||
cache[newScheme] = await renderOffscreen(sources);
|
||||
}
|
||||
elements.forEach((el, i) => { el.innerHTML = cache[newScheme][i]; applyTransparency(el, i); });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
export function setupPaginationJump() {
|
||||
const triggers = document.querySelectorAll<HTMLButtonElement>('.pagination-jump-trigger');
|
||||
const dialog = document.getElementById('pagination-jump-dialog') as HTMLDialogElement;
|
||||
|
||||
if (!dialog || triggers.length === 0) return;
|
||||
|
||||
const nav = document.querySelector('.pagination') as HTMLElement;
|
||||
const input = document.getElementById('pagination-jump-input') as HTMLInputElement;
|
||||
const form = dialog.querySelector('.pagination-jump-form') as HTMLFormElement;
|
||||
const supportsDialog = typeof dialog.showModal === 'function' && typeof dialog.close === 'function';
|
||||
let lastFocusedElement: HTMLElement | null = null;
|
||||
|
||||
if (!supportsDialog || !nav || !input || !form) return;
|
||||
|
||||
const closeDialog = (): void => {
|
||||
if (dialog.classList.contains('closing')) return;
|
||||
dialog.classList.add('closing');
|
||||
dialog.addEventListener(
|
||||
'animationend',
|
||||
() => {
|
||||
dialog.classList.remove('closing');
|
||||
dialog.close();
|
||||
if (lastFocusedElement?.isConnected) {
|
||||
lastFocusedElement.focus();
|
||||
}
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
};
|
||||
|
||||
// Open dialog when triggers are clicked
|
||||
triggers.forEach((trigger) => {
|
||||
trigger.addEventListener('click', () => {
|
||||
const activeElement = document.activeElement;
|
||||
lastFocusedElement = activeElement instanceof HTMLElement ? activeElement : trigger;
|
||||
dialog.showModal();
|
||||
input.value = '';
|
||||
input.focus();
|
||||
});
|
||||
});
|
||||
|
||||
// Handle ESC key closing the dialog
|
||||
dialog.addEventListener('cancel', (e) => {
|
||||
e.preventDefault();
|
||||
closeDialog();
|
||||
});
|
||||
|
||||
// Close dialog when clicking backdrop
|
||||
dialog.addEventListener('click', (e) => {
|
||||
const rect = dialog.getBoundingClientRect();
|
||||
const isInDialog =
|
||||
rect.top <= e.clientY &&
|
||||
e.clientY <= rect.top + rect.height &&
|
||||
rect.left <= e.clientX &&
|
||||
e.clientX <= rect.left + rect.width;
|
||||
if (!isInDialog) {
|
||||
closeDialog();
|
||||
}
|
||||
});
|
||||
|
||||
// Allow Enter key to trigger validation and submit
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (form.reportValidity()) {
|
||||
form.dispatchEvent(new Event('submit', { cancelable: true, bubbles: true }));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Handle form submission
|
||||
form.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!form.checkValidity()) {
|
||||
form.reportValidity();
|
||||
return;
|
||||
}
|
||||
|
||||
const targetPage = parseInt(input.value);
|
||||
if (isNaN(targetPage) || targetPage < 1) return;
|
||||
|
||||
const totalPages = parseInt(nav.dataset.total || '0');
|
||||
if (targetPage > totalPages) return;
|
||||
|
||||
const firstUrl = nav.dataset.firstUrl || '';
|
||||
const formatUrl = nav.dataset.formatUrl || '';
|
||||
|
||||
let targetUrl = '';
|
||||
if (targetPage === 1) {
|
||||
targetUrl = firstUrl;
|
||||
} else {
|
||||
// formatUrl is the URL for page 2. E.g., /tags/page/2/ or /page/2/
|
||||
// Replace the '2' before the trailing slash or .html with the target page number
|
||||
targetUrl = formatUrl.replace(/2([^\d]*)$/, `${targetPage}$1`);
|
||||
}
|
||||
|
||||
if (targetUrl) {
|
||||
window.location.href = targetUrl;
|
||||
}
|
||||
|
||||
closeDialog();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
// 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");
|
||||
if (link) {
|
||||
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();
|
||||
}
|
||||
|
||||
// Use ResizeObserver to detect changes in the size of .article-content
|
||||
const articleContent = document.querySelector(".article-content");
|
||||
if (articleContent) {
|
||||
const resizeObserver = new ResizeObserver(debounced(resizeHandler));
|
||||
resizeObserver.observe(articleContent);
|
||||
}
|
||||
|
||||
window.addEventListener("resize", debounced(resizeHandler));
|
||||
}
|
||||
|
||||
export { setupScrollspy };
|
||||
@@ -0,0 +1,348 @@
|
||||
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 = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
'…': '…'
|
||||
};
|
||||
|
||||
function replaceTag(tag: string) {
|
||||
return (tagsToReplace as Record<string, string>)[tag] || tag;
|
||||
}
|
||||
|
||||
function replaceHTMLEnt(str: string) {
|
||||
return str.replace(/[&<>"]/g, replaceTag);
|
||||
}
|
||||
|
||||
function escapeRegExp(string: 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;
|
||||
private container: HTMLDivElement;
|
||||
|
||||
constructor({ form, input, list, resultTitle, resultTitleTemplate }: {
|
||||
form: HTMLFormElement,
|
||||
input: HTMLInputElement,
|
||||
list: HTMLDivElement,
|
||||
resultTitle: HTMLHeadingElement,
|
||||
resultTitleTemplate: string
|
||||
}) {
|
||||
this.form = form;
|
||||
this.input = input;
|
||||
this.list = list;
|
||||
this.resultTitle = resultTitle;
|
||||
this.resultTitleTemplate = resultTitleTemplate;
|
||||
this.container = list.parentElement as HTMLDivElement;
|
||||
|
||||
/// 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));
|
||||
|
||||
this.container?.classList.remove('hidden');
|
||||
}
|
||||
|
||||
private generateResultTitle(resultLen: number, time: string) {
|
||||
return this.resultTitleTemplate.replace("#PAGES_COUNT", resultLen.toString()).replace("#TIME_SECONDS", time);
|
||||
}
|
||||
|
||||
public async getData() {
|
||||
if (!this.data) {
|
||||
/// Not fetched yet
|
||||
const jsonURL = this.form.dataset.json as string;
|
||||
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: Event) => {
|
||||
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 = '';
|
||||
this.container.classList.add('hidden');
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
if (!searchForm || !searchInput || !searchResultList || !searchResultTitle) return;
|
||||
|
||||
new Search({
|
||||
form: searchForm,
|
||||
input: searchInput,
|
||||
list: searchResultList,
|
||||
resultTitle: searchResultTitle,
|
||||
resultTitleTemplate: window.searchResultTitleTemplate
|
||||
});
|
||||
}, 0);
|
||||
})
|
||||
|
||||
export default Search;
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user