Vector-Local/resources/skins.vector.es6/tableOfContents.js

98 lines
2.4 KiB
JavaScript
Raw Normal View History

Add sectionObserver and tableOfContents component JS to respond to intersection changes This commits sets up the Table of Contents to bold the active section when the section is scrolled. Unfortunately, because our content does not have actual sections but instead has a flat list of headings and paragraphs, we can't use IntersectionObserver in the conventional way as it is optimized to find intersections of elements that are *within* the viewport and the callback will not reliably fire during certain scenarios (e.g. with fast scrolling or when the headings are not currently within the viewport). Furthermore, iterating through a list of elements and calling `getBoundingClientRect()` can be expensive and can also cause significant forced synchronous layouts that block the main thread. The best compromise in terms of performance and function that I've found is to use a combination of a throttled scroll event listener and IntersectionObserver's ability to asyncronously find the boundingClientRect of all elements off the main thread when `.observe` is called which is the approach this patch takes. Although this is an unorthodox way to use IntersectionObserver, performance profiles recorded while holding the "down" arrow and scrolling for 10 seconds with a 6x CPU throttle are comparable between master and this patch: master: https://phabricator.wikimedia.org/F34930737 this patch: https://phabricator.wikimedia.org/F34930738 Bug: T297614 Change-Id: I4077d86a1786cc1f4a7d85b20b7cf402960940e7
2022-01-21 20:15:34 +00:00
const ACTIVE_SECTION_CLASS = 'sidebar-toc-list-item-active';
const PARENT_SECTION_CLASS = 'sidebar-toc-level-1';
const LINK_CLASS = 'sidebar-toc-link';
const LIST_ITEM_CLASS = 'sidebar-toc-list-item';
/**
* Sets an `ACTIVE_SECTION_CLASS` on the element with an id that matches `id`.
* If the element is not a top level heading (e.g. element with the
* `PARENT_SECTION_CLASS`), the top level heading will also have the
* `ACTIVE_SECTION_CLASS`;
*
* @callback ActivateSection
* @param {string} id The id of the element to be activated in the Table of Contents.
*/
/**
* Called when a list item is clicked.
*
* @callback OnSectionClick
* @param {string} id The id of the clicked list item.
*/
/**
* @typedef {Object} TableOfContents
* @property {ActivateSection} activateSection
*/
/**
* Initializes the sidebar's Table of Contents.
*
* @param {Object} props
* @param {HTMLElement} props.container
* @param {OnSectionClick} [props.onSectionClick]
* @return {TableOfContents}
*/
module.exports = function tableOfContents( props ) {
props = Object.assign( {
onSectionClick: () => {}
}, props );
let /** @type {HTMLElement | undefined} */ activeParentSection;
let /** @type {HTMLElement | undefined} */ activeChildSection;
/**
* @param {string} id
*/
function activateSection( id ) {
const tocSection = document.getElementById( id );
if ( !tocSection ) {
return;
}
const parentSection = /** @type {HTMLElement} */ ( tocSection.closest( `.${PARENT_SECTION_CLASS}` ) );
if ( activeChildSection ) {
activeChildSection.classList.remove( ACTIVE_SECTION_CLASS );
}
if ( activeParentSection ) {
activeParentSection.classList.remove( ACTIVE_SECTION_CLASS );
}
tocSection.classList.add( ACTIVE_SECTION_CLASS );
if ( parentSection ) {
parentSection.classList.add( ACTIVE_SECTION_CLASS );
}
activeChildSection = tocSection;
activeParentSection = parentSection || undefined;
}
function bindClickListener() {
props.container.addEventListener( 'click', function ( e ) {
if (
!( e.target instanceof HTMLElement && e.target.classList.contains( LINK_CLASS ) )
) {
return;
}
const tocSection =
/** @type {HTMLElement | null} */ ( e.target.closest( `.${LIST_ITEM_CLASS}` ) );
if ( tocSection && tocSection.id ) {
activateSection( tocSection.id );
// @ts-ignore
props.onSectionClick( tocSection.id );
}
} );
}
bindClickListener();
return {
activateSection
};
};