docsify
Version:
A magical documentation generator.
887 lines (750 loc) • 25.5 kB
JavaScript
import { isMobile, mobileBreakpoint } from '../util/env.js';
import { noop } from '../util/core.js';
import * as dom from '../util/dom.js';
import { stripUrlExceptId } from '../router/util.js';
/** @typedef {import('../Docsify.js').Constructor} Constructor */
/**
* @template {!Constructor} T
* @param {T} Base - The class to extend
*/
export function Events(Base) {
return class Events extends Base {
#intersectionObserver = new IntersectionObserver(() => {});
#isScrolling = false;
#cancelAnchorScroll = noop;
#title = dom.$.title;
// Initialization
// =========================================================================
/**
* Initialize Docsify events
* One-time setup of listeners, observers, and tasks.
* @void
*/
initEvent() {
const { topMargin } = this.config;
// Apply topMargin to scrolled content
if (topMargin) {
const value =
typeof topMargin === 'number' ? `${topMargin}px` : topMargin;
document.documentElement.style.setProperty(
'--scroll-padding-top',
value,
);
}
this.#initCover();
this.#initSkipToContent();
this.#initSidebar();
this.#initSidebarToggle();
this.#initKeyBindings();
}
// Sub-Initializations
// =========================================================================
/**
* Initialize cover observer
* Toggles sticky behavior when cover is not in view
* @void
*/
#initCover() {
const coverElm = dom.find('section.cover');
if (!coverElm) {
dom.body.classList.add('sticky');
return;
}
const observer = new IntersectionObserver(
entries => {
const isIntersecting = entries[0].isIntersecting;
const op = isIntersecting ? 'remove' : 'add';
dom.body.classList[op]('sticky');
},
{ threshold: 0.01 },
);
observer.observe(coverElm);
}
/**
* Initialize heading observer
* Toggles sidebar active item based on top viewport edge intersection
* @void
*/
#initHeadings() {
const headingElms = dom.findAll('#main :where(h1, h2, h3, h4, h5, h6)');
const headingsInView = new Set();
let isInitialLoad = true;
// Mark sidebar active item on heading intersection
this.#intersectionObserver?.disconnect();
this.#intersectionObserver = new IntersectionObserver(
entries => {
if (isInitialLoad) {
isInitialLoad = false;
return;
}
if (this.#isScrolling) {
return;
}
for (const entry of entries) {
const op = entry.isIntersecting ? 'add' : 'delete';
headingsInView[op](entry.target);
}
let activeHeading;
if (headingsInView.size === 1) {
// Get first and only item in set.
// May be undefined if no headings are in view.
activeHeading = headingsInView.values().next().value;
} else if (headingsInView.size > 1) {
// Find the closest heading to the top of the viewport
// Reduce over the Set of headings currently in view (headingsInView) to determine the closest heading.
activeHeading = Array.from(headingsInView).reduce(
(closest, current) => {
return !closest ||
closest.compareDocumentPosition(current) &
Node.DOCUMENT_POSITION_FOLLOWING
? current
: closest;
},
null,
);
}
if (activeHeading) {
const id = activeHeading.getAttribute('id');
const href = this.router.toURL(this.router.getCurrentPath(), {
id,
});
const newSidebarActiveElm = this.#markSidebarActiveElm(href);
newSidebarActiveElm?.scrollIntoView({
behavior: 'instant',
block: 'nearest',
inline: 'nearest',
});
}
},
{
rootMargin: '0% 0% -50% 0%', // Top half of viewport
},
);
headingElms.forEach(elm => {
this.#intersectionObserver.observe(elm);
});
}
/**
* Initialize keyboard bindings
* @void
*/
#initKeyBindings() {
const { keyBindings } = this.config;
const modifierKeys = ['alt', 'ctrl', 'meta', 'shift'];
if (keyBindings && keyBindings.constructor === Object) {
// Prepare key binding configurations
Object.values(keyBindings || []).forEach(bindingConfig => {
const { bindings } = bindingConfig;
if (!bindings) {
return;
}
// Convert bindings to arrays
// Ex: 'alt+t' => ['alt+t']
bindingConfig.bindings = Array.isArray(bindings)
? bindings
: [bindings];
// Convert key sequences to sorted arrays (modifiers first)
// Ex: ['alt+t', 't+ctrl'] => [['alt', 't'], ['ctrl', 't']]
bindingConfig.bindings = bindingConfig.bindings.map(
(/** @type {string | string[]} */ keys) => {
/** @type {string[][]} */
const sortedKeys = [[], []]; // Modifier keys, non-modifier keys
if (typeof keys === 'string') {
keys = keys.split('+');
}
keys.forEach(key => {
const isModifierKey = modifierKeys.includes(key);
const targetArray = sortedKeys[isModifierKey ? 0 : 1];
const newKeyValue = key.trim().toLowerCase();
targetArray.push(newKeyValue);
});
sortedKeys.forEach(arr => arr.sort());
return sortedKeys.flat();
},
);
});
// Handle keyboard events
dom.on('keydown', (/** @type {KeyboardEvent} */ e) => {
const isTextEntry = /** @type {HTMLElement} */ (
document.activeElement
).matches('input, select, textarea');
if (isTextEntry) {
return;
}
const bindingConfigs = Object.values(keyBindings || []);
const matchingConfigs = bindingConfigs.filter(
(/** @type {{ bindings: string[][] }} */ { bindings }) =>
bindings &&
// bindings: [['alt', 't'], ['ctrl', 't']]
bindings.some((/** @type {string[]} */ keys) =>
// keys: ['alt', 't']
keys.every(
// k: 'alt'
k =>
(modifierKeys.includes(k) &&
e[/** @type {keyof KeyboardEvent} */ (k + 'Key')]) ||
e.key === k || // Ex: " ", "a"
e.code.toLowerCase() === k || // "space"
e.code.toLowerCase() === `key${k}`, // "keya"
),
),
);
matchingConfigs.forEach(({ callback }) => {
e.preventDefault();
callback(e);
});
});
}
}
/**
* Initialize sidebar event listeners
*
* @void
*/
#initSidebar() {
const sidebarElm = document.querySelector('.sidebar');
if (!sidebarElm) {
return;
}
// Auto-toggle on resolution change
window
?.matchMedia?.(`(max-width: ${mobileBreakpoint})`)
.addEventListener('change', evt => {
this.#toggleSidebar(!evt.matches);
});
// Collapse toggle
dom.on(sidebarElm, 'click', (/** @type {MouseEvent} */ { target }) => {
const linkElm = /** @type {HTMLElement} */ (target).closest('a');
const linkParent = /** @type {HTMLLIElement} */ (
linkElm?.closest('li')
);
const hasSubSidebar = linkParent?.querySelector('.app-sub-sidebar');
if (hasSubSidebar) {
linkParent.classList.toggle('collapse');
}
});
}
/**
* Initialize sidebar show/hide toggle behavior
*
* @void
*/
#initSidebarToggle() {
const contentElm = dom.find('main > .content');
const toggleElm = dom.find('button.sidebar-toggle');
if (!toggleElm) {
return;
}
/** @type {HTMLElement | null} */
let lastContentFocusElm;
// Store last focused content element (restored via #toggleSidebar)
dom.on(contentElm, 'focusin', (/** @type {FocusEvent} */ e) => {
const focusAttr = 'data-restore-focus';
lastContentFocusElm?.removeAttribute(focusAttr);
lastContentFocusElm = /** @type {HTMLElement} */ (e.target);
lastContentFocusElm.setAttribute(focusAttr, '');
});
// Toggle sidebar
dom.on(toggleElm, 'click', (/** @type {MouseEvent} */ e) => {
e.stopPropagation();
this.#toggleSidebar();
});
}
/**
* Initialize skip to content behavior
*
* @void
*/
#initSkipToContent() {
const skipElm = document.querySelector('#skip-to-content');
if (!skipElm) {
return;
}
skipElm.addEventListener('click', evt => {
const focusElm = this.#focusContent();
evt.preventDefault();
focusElm?.scrollIntoView({
behavior: 'smooth',
});
});
}
// Callbacks
// =========================================================================
/**
* Handle rendering UI element updates and new content
* @void
*/
onRender() {
const { name, pageTitleFormatter } = this.config;
const currentPath = this.router.toURL(this.router.getCurrentPath());
const currentSection = dom
.find(`.sidebar a[href='${currentPath}']`)
?.getAttribute('title');
// If a pageTitleFormatter is provided, let the user format the name
// (no automatic HTML stripping). Otherwise, default to stripping
// HTML tags from the configured name.
const plainName =
typeof pageTitleFormatter === 'function' && typeof name === 'string'
? pageTitleFormatter(name)
: name
? name.replace(/<[^>]+>/g, '').trim()
: name;
const currentTitle = plainName
? currentSection
? `${currentSection} - ${plainName}`
: plainName
: currentSection;
// Update page title
dom.$.title = currentTitle || this.#title;
this.#markAppNavActiveElm();
this.#markSidebarCurrentPage();
this.#initHeadings();
}
/**
* Handle navigation events
*
* @param {undefined|"history"|"navigate"} source Type of navigation where
* undefined is initial load, "history" is forward/back, and "navigate" is
* user click/tap
* @void
*/
onNavigate(source) {
const { auto2top, topMargin } = this.config;
const { path, query } = this.route;
const activeSidebarElm = this.#markSidebarActiveElm();
// Note: Scroll position set by browser on forward/back (i.e. "history")
if (source !== 'history') {
// Anchor link
if (query.id) {
const headingElm = dom.find(
`.markdown-section :where(h1, h2, h3, h4, h5, h6)[id="${query.id}"]`,
);
if (headingElm) {
this.#scrollToHeading(headingElm);
}
}
// User click/tap
else if (source === 'navigate') {
// Scroll to top
if (auto2top) {
/** @type {Element} */ (document.scrollingElement).scrollTop =
topMargin ?? 0;
}
}
}
const isNavigate = source === 'navigate';
const hasId = 'id' in query;
const noSubSidebar = !activeSidebarElm?.querySelector('.app-sub-sidebar');
// Clicked anchor link
const shouldCloseSidebar =
path === '/' || (isNavigate && (hasId || noSubSidebar));
if (shouldCloseSidebar && isMobile()) {
this.#toggleSidebar(false);
}
// Clicked anchor link or page load with anchor ID
if (hasId || isNavigate) {
this.#focusContent();
}
}
// Functions
// =========================================================================
/**
* Set focus on the main content area: current route ID, first heading, or
* the main content container
*
* @see https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/focus
* @param {Object} options HTMLElement focus() method options
* @returns HTMLElement|undefined
* @void
*/
#focusContent(options = {}) {
const settings = {
preventScroll: true,
...options,
};
const { query } = this.route;
const focusEl = /** @type {HTMLElement|null} */ (
query.id
? // Heading ID
dom.find(`#${query.id}`)
: // First heading
dom.find('#main :where(h1, h2, h3, h4, h5, h6)') ||
// Content container
dom.find('#main')
);
// Move focus to content area
if (focusEl) {
if (!focusEl.hasAttribute('tabindex')) {
focusEl.setAttribute('tabindex', '-1');
focusEl.setAttribute('data-added-tabindex', 'true');
}
if (focusEl.hasAttribute('data-added-tabindex')) {
focusEl.scrollIntoView({ behavior: 'smooth' });
}
focusEl.focus(settings);
}
return focusEl;
}
/**
* Marks the active app nav item
*/
#markAppNavActiveElm() {
const href = decodeURIComponent(this.router.toURL(this.route.path));
['.app-nav', '.app-nav-merged'].forEach(selector => {
const navElm = dom.find(selector);
if (!navElm) {
return;
}
const newActive = /** @type {HTMLAnchorElement[]} */ (
dom.findAll(navElm, 'a')
)
.sort((a, b) => b.href.length - a.href.length)
.find(
a =>
href.includes(/** @type {string} */ (a.getAttribute('href'))) ||
href.includes(
decodeURI(/** @type {string} */ (a.getAttribute('href'))),
),
)
?.closest('li');
const oldActive = dom.find(navElm, 'li.active');
if (newActive && newActive !== oldActive) {
oldActive?.classList.remove('active');
newActive.classList.add('active');
}
});
}
/**
* Marks the active sidebar item
*
* @param {string} [href] Matching element HREF value. If unspecified,
* defaults to the current path (with query params)
* @returns Element|undefined
*/
#markSidebarActiveElm(href) {
href ??= this.router.toURL(this.router.getCurrentPath());
const sidebar = dom.find('.sidebar');
if (!sidebar) {
return;
}
href = stripUrlExceptId(href);
const oldActive = dom.find(sidebar, 'li.active');
const newActive = dom
.find(
sidebar,
`a[href="${href}"], a[href="${decodeURIComponent(/** @type {string} */ (href))}"]`,
)
?.closest('li');
if (newActive && newActive !== oldActive) {
oldActive?.classList.remove('active');
newActive.classList.add('active');
}
return newActive;
}
/**
* Marks the current page in the sidebar
*
* @param {string} [href] Matching sidebar element HREF value. If
* unspecified, defaults to the current path (without query params)
* @returns Element|undefined
*/
#markSidebarCurrentPage(href) {
href ??= this.router.toURL(this.route.path);
const sidebar = dom.find('.sidebar');
if (!sidebar) {
return;
}
const path = href?.split('?')[0];
const oldPage = dom.find(sidebar, 'li[aria-current]');
const newPage = dom
.find(
sidebar,
`a[href="${path}"], a[href="${decodeURIComponent(/** @type {string} */ (path))}"]`,
)
?.closest('li');
if (newPage && newPage !== oldPage) {
oldPage?.removeAttribute('aria-current');
newPage.setAttribute('aria-current', 'page');
}
return newPage;
}
/**
* @param {boolean} [force]
*/
#toggleSidebar(force) {
const sidebarElm = /** @type {HTMLElement|null} */ (dom.find('.sidebar'));
if (!sidebarElm) {
return;
}
const ariaElms = dom.findAll('[aria-controls="__sidebar"]');
const inertElms = dom.findAll(
'body > *:not(main, script), main > .content',
);
const isShow = sidebarElm.classList.toggle('show', force);
// Set aria-expanded attribute
ariaElms.forEach(toggleElm => {
const expanded = force ?? sidebarElm.classList.contains('show');
toggleElm.setAttribute('aria-expanded', String(expanded));
toggleElm.setAttribute(
'aria-label',
expanded ? 'Hide primary navigation' : 'Show primary navigation',
);
});
// Add inert attributes (focus trap)
if (isShow && isMobile()) {
inertElms.forEach(elm => elm.setAttribute('inert', ''));
}
// Remove inert attributes
else {
inertElms.forEach(elm => elm.removeAttribute('inert'));
}
if (isShow) {
sidebarElm.focus();
}
// Restore focus
else {
const restoreElm = /** @type {HTMLElement|null} */ (
document.querySelector('main > .content [data-restore-focus]')
);
if (restoreElm) {
restoreElm.focus({
preventScroll: true,
});
}
}
}
/**
* Scroll an anchor target into view and keep it aligned while late-loading
* content above the target changes the page height.
*
* @param {Element} headingElm Heading element to scroll to
* @void
*/
#scrollToHeading(headingElm) {
this.#cancelAnchorScroll();
const contentElm = dom.find('.markdown-section');
const userEvents = ['keydown', 'mousedown', 'touchstart', 'wheel'];
/** @type {{ wait?: ReturnType<typeof setTimeout> }} */
const timers = {};
/** @type {number} */
let animationFrame = 0;
/** @type {number} */
let correctionFrame = 0;
let cancelled = false;
let cancel = noop;
let hasScrolled = false;
let scrollScheduled = false;
let remainingImages = 0;
/** @type {() => void} */
let cleanup = () => {};
/** @type {{ image: HTMLImageElement, eventName: "load" | "error", listener: () => void }[]} */
const imageListeners = [];
/** @type {{ image: HTMLImageElement, previousHeight: number }[]} */
const pendingImageCorrections = [];
const removeUserListeners = () => {
userEvents.forEach(eventName => {
window.removeEventListener(eventName, cancel);
});
};
const removeImageListeners = () => {
imageListeners.forEach(({ image, eventName, listener }) => {
image.removeEventListener(eventName, listener);
});
imageListeners.length = 0;
};
const scrollToHeading = () => {
if (cancelled) {
return;
}
if (!document.contains(headingElm)) {
cancel();
return;
}
hasScrolled = true;
this.#watchNextScroll();
headingElm.scrollIntoView({
behavior: 'smooth',
block: 'start',
});
if (remainingImages === 0) {
cleanup();
}
};
const scheduleScroll = () => {
if (hasScrolled || scrollScheduled) {
return;
}
scrollScheduled = true;
clearTimeout(timers.wait);
animationFrame = requestAnimationFrame(scrollToHeading);
};
/**
* Keep the heading visually anchored when late images above it resize
* after the fallback scroll has already started.
*
* @param {HTMLImageElement} image Image that changed height
* @param {number} previousHeight Height before the image settled
* @void
*/
const scheduleCorrection = (image, previousHeight) => {
if (cancelled || !hasScrolled) {
return;
}
pendingImageCorrections.push({ image, previousHeight });
if (correctionFrame) {
return;
}
correctionFrame = requestAnimationFrame(() => {
correctionFrame = 0;
if (cancelled) {
return;
}
if (!document.contains(headingElm)) {
cleanup();
return;
}
let heightChange = 0;
for (const { image, previousHeight } of pendingImageCorrections) {
const isBeforeHeading =
image.compareDocumentPosition(headingElm) &
Node.DOCUMENT_POSITION_FOLLOWING;
const currentHeight = image.getBoundingClientRect().height;
if (isBeforeHeading) {
heightChange += currentHeight - previousHeight;
}
}
pendingImageCorrections.length = 0;
if (Math.abs(heightChange) < 1) {
if (remainingImages === 0) {
cleanup();
}
return;
}
const scrollingElm = document.scrollingElement;
if (!scrollingElm) {
cleanup();
return;
}
const scrollPaddingTop =
parseFloat(getComputedStyle(scrollingElm).scrollPaddingTop) || 0;
const headingTop = headingElm.getBoundingClientRect().top;
const scrollAdjustment = headingTop - scrollPaddingTop;
if (Math.abs(scrollAdjustment) < 1) {
if (remainingImages === 0) {
cleanup();
}
return;
}
this.#watchNextScroll();
scrollingElm.scrollTop += scrollAdjustment;
if (remainingImages === 0) {
cleanup();
}
});
};
cleanup = () => {
if (cancelled) {
return;
}
cancelled = true;
cancelAnimationFrame(animationFrame);
cancelAnimationFrame(correctionFrame);
clearTimeout(timers.wait);
removeImageListeners();
removeUserListeners();
this.#cancelAnchorScroll = noop;
};
cancel = cleanup;
const waitForImages = () => {
const images = /** @type {HTMLImageElement[]} */ (
contentElm ? Array.from(contentElm.querySelectorAll('img')) : []
).filter(image => {
return (
!image.complete &&
image.compareDocumentPosition(headingElm) &
Node.DOCUMENT_POSITION_FOLLOWING
);
});
if (!images.length) {
scheduleScroll();
return;
}
remainingImages = images.length;
const onImageSettled = (image, previousHeight) => {
remainingImages -= 1;
if (hasScrolled) {
scheduleCorrection(image, previousHeight);
} else if (remainingImages === 0) {
scheduleScroll();
}
if (remainingImages === 0 && hasScrolled && !correctionFrame) {
cleanup();
}
};
images.forEach(image => {
let settled = false;
const previousHeight = image.getBoundingClientRect().height;
const listener = () => {
if (settled) {
return;
}
settled = true;
onImageSettled(image, previousHeight);
};
image.addEventListener('load', listener, { once: true });
image.addEventListener('error', listener, { once: true });
imageListeners.push(
{ image, eventName: 'load', listener },
{ image, eventName: 'error', listener },
);
});
timers.wait = setTimeout(scheduleScroll, 300);
};
userEvents.forEach(eventName => {
window.addEventListener(eventName, cancel, {
once: true,
passive: true,
});
});
waitForImages();
this.#cancelAnchorScroll = cancel;
}
/**
* Monitor next scroll start/end and set #isScrolling to true/false
* accordingly. Listeners are removed after the start/end events are fired.
* @void
*/
#watchNextScroll() {
// Scroll start
document.addEventListener(
'scroll',
() => {
this.#isScrolling = true;
// Scroll end
if ('onscrollend' in window) {
document.addEventListener(
'scrollend',
() => (this.#isScrolling = false),
{ once: true },
);
}
// Browsers w/o native scrollend event support (Safari)
else {
/** @type {any} */
let scrollTimer;
const callback = () => {
clearTimeout(scrollTimer);
scrollTimer = setTimeout(() => {
document.removeEventListener('scroll', callback);
this.#isScrolling = false;
}, 100);
};
document.addEventListener('scroll', callback, false);
callback();
}
},
{ once: true },
);
}
};
}