mirror of
https://github.com/StarCitizenTools/mediawiki-skins-Citizen.git
synced 2024-11-14 10:04:56 +00:00
refactor(core): rewrite ToC scrollspy based on Vector
This commit is contained in:
parent
f38422d97a
commit
1be7f5faaf
192
resources/skins.citizen.scripts/sectionObserver.js
Normal file
192
resources/skins.citizen.scripts/sectionObserver.js
Normal 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
|
||||
};
|
|
@ -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,
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Reference in a new issue