mediawiki-skins-Vector/resources/skins.vector.es6/sectionObserver.js
Jon Robson 450a395f95 SectionObserver should be updated at certain points in MediaWiki lifecycle
* When wikipage.tableOfContents is executed, the table of contents has been
replaced so we should update the sections associated with the section
observer as the old sections no longer exist in theDOM.
* If VisualEditor repaints the page, we should update the elements associated
with the table of contents as the body-content element no longer exists in the page.
* When VisuaLEditor is active we should disable the intersection observer as
there is no table of contents to update.

Minor changes:
* Light refactor of large function in main.js
* reloadTableOfContents returns a promise and now fires a new hook
wikipage.tableOfContents.vector to signal when it is finished which
has also been requested by community members. This allows the code in main.js
to update elements at relevant point in time
* Add FIXME note (doing this would have been considerable work)
* I've added a call to mw.log.warn which would have saved me considerable
debugging time for this issue to diagnose the issue.

Bug: T316037
Bug: T316025
Change-Id: Ib42d532d4e900c01061e1c5e39c03b17f0619c46
2023-02-23 01:39:46 +00:00

187 lines
5.6 KiB
JavaScript

/** @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.
*/
/**
* 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 {boolean} */ inThrottle = false;
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();
} );
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 ) {
// @ts-ignore
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 ( !inThrottle ) {
inThrottle = true;
setTimeout( () => {
calcIntersection();
inThrottle = false;
}, props.throttleMs );
}
}
function bindScrollListener() {
window.addEventListener( 'scroll', handleScroll );
}
function unbindScrollListener() {
window.removeEventListener( 'scroll', handleScroll );
}
/**
* Pauses intersection observation until `resume` is called.
*/
function pause() {
unbindScrollListener();
// 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();
// Calculate intersection on page load.
calcIntersection();
/**
* @typedef {Object} SectionObserver
* @property {pause} pause
* @property {resume} resume
* @property {unmount} unmount
* @property {setElements} setElements
*/
return {
pause,
resume,
unmount,
setElements
};
};