refactor(core): rewrite ToC scrollspy based on Vector

This commit is contained in:
alistair3149 2022-05-17 15:10:14 -04:00
parent f38422d97a
commit 1be7f5faaf
4 changed files with 242 additions and 34 deletions

View file

@ -0,0 +1,192 @@
/**
* Based on Vector
* NOTE: It is kept as an ES6 module because we are dropping ES5 soon.
* But some parts are in ES5 because ResourceLoader is messing up ES6 in 1.35
*/
/** @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}
*/
function sectionObserver( props ) {
// eslint-disable-next-line compat/compat
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 ) => {
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
};
}
module.exports = {
init: sectionObserver
};

View file

@ -107,7 +107,7 @@ function main( window ) {
// TODO: There must be a cleaner way to do this
if ( tocContainer ) {
const toc = require( './tableOfContents.js' );
toc.init( tocContainer );
toc.init();
checkboxHack.bind(
window,

View file

@ -1,45 +1,60 @@
const ACTIVE_ITEM_CLASS = 'toc__item--active';
const ACTIVE_SECTION_CLASS = 'toc__item--active';
let /** @type {HTMLElement | undefined} */ activeSection;
/**
* @param {string} id
*/
function changeActiveSection( id ) {
const toc = document.getElementById( 'toc' );
const getLink = ( hash ) => {
const
prefix = 'a[href="#',
suffix = '"]';
let el = toc.querySelector( prefix + hash + suffix );
if ( el === null ) {
// Sometimes the href attribute is encoded
el = toc.querySelector( prefix + encodeURIComponent( hash ) + suffix );
}
return el;
};
const link = getLink( id );
if ( activeSection ) {
activeSection.classList.remove( ACTIVE_SECTION_CLASS );
activeSection = undefined;
}
activeSection = link.parentNode;
activeSection.classList.add( ACTIVE_SECTION_CLASS );
}
/**
* Toggle active HTML class to items in table of content based on user viewport.
* Based on Vector
*
* @param {Element} toc TOC element
* @return {void}
*/
function initToC( toc ) {
function initToC() {
const
headlines = document.querySelectorAll( '.mw-headline' ),
marginTop = '-' + window.getComputedStyle( document.documentElement ).getPropertyValue( 'scroll-padding-top' );
header = document.querySelector( '.citizen-header' ),
bodyContent = document.getElementById( 'bodyContent' );
for ( let i = 0; i < headlines.length; i++ ) {
/* eslint-disable compat/compat */
const observer = new IntersectionObserver( ( entry ) => {
/* eslint-enable compat/compat */
if ( entry[ 0 ].isIntersecting ) {
const
headlineId = headlines[ i ].id,
// Get the decoded ID from the span before
decodedId = ( headlines[ i ].previousSibling !== null ) ?
headlines[ i ].previousSibling.id : '',
links = toc.querySelector( "a[href='#" + headlineId + "']" ) ||
toc.querySelector( "a[href='#" + decodedId + "']" ),
targetLink = links.parentNode,
activeLink = toc.querySelector( '.' + ACTIVE_ITEM_CLASS );
const initSectionObserver = require( './sectionObserver.js' ).init;
if ( activeLink !== null ) {
activeLink.classList.remove( ACTIVE_ITEM_CLASS );
}
if ( targetLink !== null ) {
targetLink.classList.add( ACTIVE_ITEM_CLASS );
}
}
}, {
// Will break in viewport with short height
// But calculating bottom margin on the fly is too costly
rootMargin: marginTop + ' 0px -85% 0px'
} );
observer.observe( headlines[ i ] );
}
const sectionObserver = initSectionObserver( {
elements: bodyContent.querySelectorAll( '.mw-headline' ),
topMargin: header.getBoundingClientRect().height,
onIntersection: ( section ) => { changeActiveSection( section.id ); }
} );
// TODO: Pause section observer on ToC link click
sectionObserver.resume();
}
module.exports = {

View file

@ -189,6 +189,7 @@
},
"resources/skins.citizen.scripts/checkboxHack.js",
"resources/skins.citizen.scripts/scrollObserver.js",
"resources/skins.citizen.scripts/sectionObserver.js",
"resources/skins.citizen.scripts/search.js",
"resources/skins.citizen.scripts/tableOfContents.js",
"resources/skins.citizen.scripts/theme.js"