Merge "Add sectionObserver and tableOfContents component JS to respond to intersection changes"
This commit is contained in:
commit
24e7430734
|
@ -5,7 +5,7 @@
|
|||
},
|
||||
{
|
||||
"resourceModule": "skins.vector.styles",
|
||||
"maxSize": "10.3 kB"
|
||||
"maxSize": "10.4 kB"
|
||||
},
|
||||
{
|
||||
"resourceModule": "skins.vector.legacy.js",
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
};
|
||||
};
|
|
@ -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
|
||||
};
|
||||
};
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Reference in New Issue