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
This commit is contained in:
Nicholas Ray 2022-01-21 13:15:34 -07:00
parent 9fba9b6b9e
commit 3c433a5315
8 changed files with 334 additions and 4 deletions

View File

@ -5,7 +5,7 @@
},
{
"resourceModule": "skins.vector.styles",
"maxSize": "10.3 kB"
"maxSize": "10.4 kB"
},
{
"resourceModule": "skins.vector.legacy.js",

View File

@ -4,8 +4,8 @@
</div>
<ul id="table-of-contents">
{{#array-sections}}
<li class="sidebar-toc-level-{{toclevel}}">
<a href="#{{anchor}}">
<li id="toc-{{anchor}}" class="sidebar-toc-list-item sidebar-toc-level-{{toclevel}}">
<a class="sidebar-toc-link" href="#{{anchor}}">
<div class="sidebar-toc-text">
<span class="sidebar-toc-numb">{{number}}</span>{{{line}}}</div>
</a>

View File

@ -25,6 +25,7 @@
"EventTarget": "https://developer.mozilla.org/docs/Web/API/EventTarget",
"HTMLElement": "https://developer.mozilla.org/docs/Web/API/HTMLElement",
"IntersectionObserver": "https://developer.mozilla.org/docs/Web/API/IntersectionObserver",
"IntersectionObserverEntry": "https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserverEntry",
"Node": "https://developer.mozilla.org/docs/Web/API/Node",
"NodeList": "https://developer.mozilla.org/docs/Web/API/NodeList",
"HTMLInputElement": "https://developer.mozilla.org/docs/Web/API/HTMLInputElement",

View File

@ -3,7 +3,13 @@ const
searchToggle = require( './searchToggle.js' ),
stickyHeader = require( './stickyHeader.js' ),
scrollObserver = require( './scrollObserver.js' ),
AB = require( './AB.js' );
AB = require( './AB.js' ),
initSectionObserver = require( './sectionObserver.js' ),
initTableOfContents = require( './tableOfContents.js' ),
TOC_ID = 'mw-panel-toc',
BODY_CONTENT_ID = 'bodyContent',
HEADLINE_SELECTOR = '.mw-headline',
TOC_SECTION_ID_PREFIX = 'toc-';
/**
* @return {void}
@ -58,6 +64,53 @@ const main = () => {
} else if ( targetIntersection ) {
observer.observe( targetIntersection );
}
// Table of contents
const tocElement = document.getElementById( TOC_ID );
const bodyContent = document.getElementById( BODY_CONTENT_ID );
if ( !(
tocElement &&
bodyContent &&
window.IntersectionObserver &&
window.requestAnimationFrame )
) {
return;
}
// eslint-disable-next-line prefer-const
let /** @type {initSectionObserver.SectionObserver} */ sectionObserver;
const tableOfContents = initTableOfContents( {
container: tocElement,
onSectionClick: () => {
sectionObserver.pause();
// Ensure the browser has finished painting and has had enough time to
// scroll to the section before resuming section observer. One rAF should
// be sufficient in most browsers, but Firefox 96.0.2 seems to require two
// rAFs.
requestAnimationFrame( () => {
requestAnimationFrame( () => {
sectionObserver.resume();
} );
} );
}
} );
sectionObserver = initSectionObserver( {
container: bodyContent,
tagNames: [ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6' ],
topMargin: targetElement ? targetElement.getBoundingClientRect().height : 0,
/**
* @param {HTMLElement} section
*/
onIntersection: ( section ) => {
const headline = section.querySelector( HEADLINE_SELECTOR );
if ( headline ) {
tableOfContents.activateSection( TOC_SECTION_ID_PREFIX + headline.id );
}
}
} );
};
module.exports = {

View File

@ -0,0 +1,161 @@
/**
* Called when a new intersection is observed.
*
* @callback OnIntersection
* @param {HTMLElement} element The section that triggered the new intersection change.
*/
/**
* @typedef {Object} SectionObserver
* @property {Function} pause Pauses intersection observation until `resume` is called.
* @property {Function} resume Resumes intersection observation.
* @property {Function} unmount Cleans up event listeners and intersection
* observer. Should be called when the observer is permanently no longer needed.
*/
/**
* Observe intersection changes with the viewport for one or more tags within a
* container. 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 {Object} props
* @param {HTMLElement} props.container A container element that contains the `props.tagNames`.
* @param {string[]} props.tagNames The list of tag names that should be observed.
* @param {OnIntersection} props.onIntersection
* @param {number} [props.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.
* @param {number} [props.throttleMs] The number of milliseconds that the scroll
* handler should be throttled.
* @return {SectionObserver}
*/
module.exports = function sectionObserver( props ) {
props = Object.assign( {
topMargin: 0,
throttleMs: 200,
onIntersection: () => {}
}, props );
const tagGroups = props.tagNames.map( ( tagName ) => {
// `.getElementsByTagName` returns a live HTMLCollection object which will
// automatically update itself if tags are added or removed within the
// container (e.g. this might happen after a user adds or removes sections
// with VisualEditor ).
return props.container.getElementsByTagName( tagName );
} );
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.
tagGroups.forEach( ( tags ) => {
for ( let i = 0; i < tags.length; i++ ) {
observer.observe( tags[ i ] );
}
} );
}
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 );
}
bindScrollListener();
// Calculate intersection on page load.
calcIntersection();
return {
pause() {
unbindScrollListener();
// Assume current is no longer valid while paused.
current = undefined;
},
resume() {
bindScrollListener();
},
unmount() {
unbindScrollListener();
observer.disconnect();
}
};
};

View File

@ -0,0 +1,101 @@
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 ) {
// eslint-disable-next-line mediawiki/class-doc
activeChildSection.classList.remove( ACTIVE_SECTION_CLASS );
}
if ( activeParentSection ) {
// eslint-disable-next-line mediawiki/class-doc
activeParentSection.classList.remove( ACTIVE_SECTION_CLASS );
}
// eslint-disable-next-line mediawiki/class-doc
tocSection.classList.add( ACTIVE_SECTION_CLASS );
if ( parentSection ) {
// eslint-disable-next-line mediawiki/class-doc
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
};
};

View File

@ -26,6 +26,18 @@
border: 0;
}
.sidebar-toc-list-item-active {
> a {
font-weight: bold;
}
}
.sidebar-toc-link > * {
// Prevent click events on the link's contents so that we can use event
// delegation and have the target be the anchor element instead.
pointer-events: none;
}
.sidebar-toc-numb {
display: none;
}

View File

@ -262,6 +262,8 @@
"resources/skins.vector.es6/stickyHeader.js",
"resources/skins.vector.es6/scrollObserver.js",
"resources/skins.vector.es6/AB.js",
"resources/skins.vector.es6/tableOfContents.js",
"resources/skins.vector.es6/sectionObserver.js",
{
"name": "resources/skins.vector.es6/config.json",
"callback": "Vector\\Hooks::getVectorResourceLoaderConfig"