Merge "Add sectionObserver and tableOfContents component JS to respond to intersection changes"

This commit is contained in:
jenkins-bot 2022-01-27 17:13:08 +00:00 committed by Gerrit Code Review
commit 24e7430734
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

@ -264,6 +264,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"