mediawiki-skins-Citizen/resources/skins.citizen.scripts/sectionObserver.js
alistair3149 8640d4ef30
feat(toc): switch to ToC implementation based on Vector 2022
This will serve as the groundwork of merging the intersection observers
and collapsible toc in the future.
2024-09-10 18:28:12 -04:00

197 lines
5.8 KiB
JavaScript

// Adopted from Vector 2022
/** @module SectionObserver */
/**
* @callback OnIntersection
* @param {HTMLElement} element The section that triggered the new intersection change.
*/
/**
* @typedef {Object} SectionObserverProps
* @property {NodeList} elements A list of HTML elements to observe for
* intersection changes. This list can be updated through the `elements` setter.
* @property {OnIntersection} onIntersection Called when a new intersection is observed.
* @property {number} [topMargin] The number of pixels to shrink the top of
* the viewport's bounding box before calculating intersections. This is useful
* for sticky elements (e.g. sticky headers). Defaults to 0 pixels.
* @property {number} [throttleMs] The number of milliseconds that the scroll
* handler should be throttled.
*/
/**
* @callback initSectionObserver
* @param {SectionObserverProps} props
* @return {SectionObserver}
*/
/**
* Observe intersection changes with the viewport for one or more elements. This
* is intended to be used with the headings in the content so that the
* corresponding section(s) in the table of contents can be "activated" (e.g.
* bolded).
*
* When sectionObserver notices a new intersection change, the
* `props.onIntersection` callback will be fired with the corresponding section
* as a param.
*
* Because sectionObserver uses a scroll event listener (in combination with
* IntersectionObserver), the changes are throttled to a default maximum rate of
* 200ms so that the main thread is not excessively blocked.
* IntersectionObserver is used to asynchronously calculate the positions of the
* observed tags off the main thread and in a manner that does not cause
* expensive forced synchronous layouts.
*
* @param {SectionObserverProps} props
* @return {SectionObserver}
*/
module.exports = function sectionObserver( props ) {
props = Object.assign( {
topMargin: 0,
throttleMs: 200,
onIntersection: () => {}
}, props );
let /** @type {number | undefined} */ timeoutId;
let /** @type {HTMLElement | undefined} */ current;
// eslint-disable-next-line compat/compat
const observer = new IntersectionObserver( ( entries ) => {
let /** @type {IntersectionObserverEntry | undefined} */ closestNegativeEntry;
let /** @type {IntersectionObserverEntry | undefined} */ closestPositiveEntry;
const topMargin = /** @type {number} */ ( props.topMargin );
entries.forEach( ( entry ) => {
const top =
entry.boundingClientRect.top - topMargin;
if (
top > 0 &&
(
closestPositiveEntry === undefined ||
top < closestPositiveEntry.boundingClientRect.top - topMargin
)
) {
closestPositiveEntry = entry;
}
if (
top <= 0 &&
(
closestNegativeEntry === undefined ||
top > closestNegativeEntry.boundingClientRect.top - topMargin
)
) {
closestNegativeEntry = entry;
}
} );
const closestTag =
/** @type {HTMLElement} */ ( closestNegativeEntry ? closestNegativeEntry.target :
/** @type {IntersectionObserverEntry} */ ( closestPositiveEntry ).target
);
// If the intersection is new, fire the `onIntersection` callback.
if ( current !== closestTag ) {
props.onIntersection( closestTag );
}
current = closestTag;
// When finished finding the intersecting element, stop observing all
// observed elements. The scroll event handler will be responsible for
// throttling and reobserving the elements again. Because we don't have a
// wrapper element around our content headings and their children, we can't
// rely on IntersectionObserver (which is optimized to detect intersecting
// elements *within* the viewport) to reliably fire this callback without
// this manual step. Instead, we offload the work of calculating the
// position of each element in an efficient manner to IntersectionObserver,
// but do not use it to detect when a new element has entered the viewport.
observer.disconnect();
} );
/**
* Calculate the intersection of each observed element.
*/
function calcIntersection() {
// IntersectionObserver will asynchronously calculate the boundingClientRect
// of each observed element off the main thread after `observe` is called.
props.elements.forEach( ( element ) => {
if ( !element.parentNode ) {
mw.log.warn( 'Element being observed is not in DOM', element );
return;
}
observer.observe( /** @type {HTMLElement} */ ( element ) );
} );
}
function handleScroll() {
// Throttle the scroll event handler to fire at a rate limited by `props.throttleMs`.
if ( !timeoutId ) {
timeoutId = window.setTimeout( () => {
calcIntersection();
timeoutId = undefined;
}, props.throttleMs );
}
}
function bindScrollListener() {
window.addEventListener( 'scroll', handleScroll );
}
function unbindScrollListener() {
window.removeEventListener( 'scroll', handleScroll );
}
/**
* Pauses intersection observation until `resume` is called.
*/
function pause() {
unbindScrollListener();
clearTimeout( timeoutId );
timeoutId = undefined;
// Assume current is no longer valid while paused.
current = undefined;
}
/**
* Resumes intersection observation.
*/
function resume() {
bindScrollListener();
}
/**
* Cleans up event listeners and intersection observer. Should be called when
* the observer is permanently no longer needed.
*/
function unmount() {
unbindScrollListener();
observer.disconnect();
}
/**
* Set a list of HTML elements to observe for intersection changes.
*
* @param {NodeList} list
*/
function setElements( list ) {
props.elements = list;
}
bindScrollListener();
/**
* @typedef {Object} SectionObserver
* @property {calcIntersection} calcIntersection
* @property {pause} pause
* @property {resume} resume
* @property {unmount} unmount
* @property {setElements} setElements
*/
return {
calcIntersection,
pause,
resume,
unmount,
setElements
};
};