2024-09-10 22:28:12 +00:00
|
|
|
// Adopted from Vector 2022
|
|
|
|
const
|
|
|
|
scrollObserver = require( './scrollObserver.js' ),
|
2024-11-06 23:51:08 +00:00
|
|
|
resizeObserver = require( './resizeObserver.js' ),
|
2024-09-10 22:28:12 +00:00
|
|
|
initSectionObserver = require( './sectionObserver.js' ),
|
2024-11-07 00:31:49 +00:00
|
|
|
stickyHeader = require( './stickyHeader.js' ),
|
2024-09-10 22:28:12 +00:00
|
|
|
initTableOfContents = require( './tableOfContents.js' ),
|
|
|
|
deferUntilFrame = require( './deferUntilFrame.js' ),
|
|
|
|
TOC_ID = 'citizen-toc',
|
|
|
|
BODY_CONTENT_ID = 'bodyContent',
|
|
|
|
// Support two variants of heading markup: (see T13555, T358452)
|
|
|
|
// (old) <h2> <span class="mw-headline" id="...">...</span> ... </h2>
|
|
|
|
// (new) <div class="mw-heading"> <h2 id="...">...</h2> ... </div>
|
|
|
|
// [more information: https://www.mediawiki.org/wiki/Heading_HTML_changes]
|
|
|
|
HEADING_TAGS = [ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6' ],
|
|
|
|
HEADING_SELECTOR = [ '.mw-heading', ...HEADING_TAGS.map( ( tag ) => `${ tag }:not([id])` ) ]
|
|
|
|
.map( ( sel ) => `.mw-parser-output ${ sel }` ).join( ', ' ),
|
|
|
|
HEADLINE_SELECTOR = [ '.mw-headline', ...HEADING_TAGS.map( ( tag ) => `${ tag }[id]` ) ]
|
|
|
|
.map( ( sel ) => `.mw-parser-output ${ sel }` ).join( ', ' ),
|
2024-11-07 00:31:49 +00:00
|
|
|
TOC_SECTION_ID_PREFIX = 'toc-',
|
|
|
|
SCROLL_DOWN_CLASS = 'citizen-scroll--down',
|
2024-11-08 02:09:25 +00:00
|
|
|
SCROLL_UP_CLASS = 'citizen-scroll--up',
|
|
|
|
PAGE_TITLE_INTERSECTION_CLASS = 'citizen-below-page-title';
|
2024-09-10 22:28:12 +00:00
|
|
|
|
|
|
|
/**
|
|
|
|
* @callback OnIntersection
|
|
|
|
* @param {HTMLElement} element The section that triggered the new intersection change.
|
|
|
|
*/
|
|
|
|
|
|
|
|
/**
|
|
|
|
* @ignore
|
|
|
|
* @param {Function} changeActiveSection
|
|
|
|
* @return {OnIntersection}
|
|
|
|
*/
|
|
|
|
const getHeadingIntersectionHandler = ( changeActiveSection ) =>
|
|
|
|
/**
|
|
|
|
* @param {HTMLElement} section
|
|
|
|
*/
|
|
|
|
// eslint-disable-next-line implicit-arrow-linebreak
|
|
|
|
( section ) => {
|
|
|
|
const headline = section.classList.contains( 'mw-body-content' ) ?
|
|
|
|
section :
|
|
|
|
section.querySelector( HEADLINE_SELECTOR );
|
|
|
|
if ( headline ) {
|
|
|
|
changeActiveSection( `${ TOC_SECTION_ID_PREFIX }${ headline.id }` );
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Return the computed value of the `scroll-margin-top` CSS property of the document element
|
|
|
|
* which is also used for the scroll intersection threshold (T317661).
|
|
|
|
*
|
|
|
|
* @return {number} Value of scroll-margin-top OR 75 if falsy.
|
|
|
|
* 75 derived from @scroll-padding-top LESS variable
|
|
|
|
* https://gerrit.wikimedia.org/r/c/mediawiki/skins/Vector/+/894696/3/resources/common/variables.less ?
|
|
|
|
*/
|
|
|
|
function getDocumentScrollPaddingTop() {
|
|
|
|
const defaultScrollPaddingTop = 75;
|
|
|
|
const documentStyles = getComputedStyle( document.documentElement );
|
|
|
|
const scrollPaddingTopString = documentStyles.getPropertyValue( 'scroll-padding-top' );
|
|
|
|
return ( parseInt( scrollPaddingTopString, 10 ) || defaultScrollPaddingTop );
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* @param {HTMLElement|null} tocElement
|
|
|
|
* @param {HTMLElement|null} bodyContent
|
|
|
|
* @param {initSectionObserver} initSectionObserverFn
|
|
|
|
* @return {tableOfContents|null}
|
|
|
|
*/
|
|
|
|
const setupTableOfContents = ( tocElement, bodyContent, initSectionObserverFn ) => {
|
|
|
|
if ( !(
|
|
|
|
tocElement &&
|
|
|
|
bodyContent
|
|
|
|
) ) {
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
|
|
|
|
const handleTocSectionChange = () => {
|
|
|
|
// eslint-disable-next-line no-use-before-define
|
|
|
|
sectionObserver.pause();
|
|
|
|
|
|
|
|
// T297614: We want the link that the user has clicked inside the TOC or the
|
|
|
|
// section that corresponds to the hashchange event to be "active" (e.g.
|
|
|
|
// bolded) regardless of whether the browser's scroll position corresponds
|
|
|
|
// to that section. Therefore, we need to temporarily ignore section
|
|
|
|
// observer until the browser has finished scrolling to the section (if
|
|
|
|
// needed).
|
|
|
|
//
|
|
|
|
// However, because the scroll event happens asynchronously after the user
|
|
|
|
// clicks on a link and may not even happen at all (e.g. the user has
|
|
|
|
// scrolled all the way to the bottom and clicks a section that is already
|
|
|
|
// in the viewport), determining when we should resume section observer is a
|
|
|
|
// bit tricky.
|
|
|
|
//
|
|
|
|
// Because a scroll event may not even be triggered after clicking the link,
|
|
|
|
// we instead allow the browser to perform a maximum number of repaints
|
|
|
|
// before resuming sectionObserver. Per T297614#7687656, Firefox 97.0 wasn't
|
|
|
|
// consistently activating the table of contents section that the user
|
|
|
|
// clicked even after waiting 2 frames. After further investigation, it
|
|
|
|
// sometimes waits up to 3 frames before painting the new scroll position so
|
|
|
|
// we have that as the limit.
|
|
|
|
deferUntilFrame( () => {
|
|
|
|
// eslint-disable-next-line no-use-before-define
|
|
|
|
sectionObserver.resume();
|
|
|
|
}, 3 );
|
|
|
|
};
|
|
|
|
|
|
|
|
const tableOfContents = initTableOfContents( {
|
|
|
|
container: tocElement,
|
|
|
|
onHeadingClick: handleTocSectionChange,
|
|
|
|
onHashChange: handleTocSectionChange
|
|
|
|
} );
|
|
|
|
const elements = () => bodyContent.querySelectorAll( `${ HEADING_SELECTOR }, .mw-body-content` );
|
|
|
|
|
|
|
|
const sectionObserver = initSectionObserverFn( {
|
|
|
|
elements: elements(),
|
|
|
|
topMargin: getDocumentScrollPaddingTop(),
|
|
|
|
onIntersection: getHeadingIntersectionHandler( tableOfContents.changeActiveSection )
|
|
|
|
} );
|
|
|
|
const updateElements = () => {
|
|
|
|
sectionObserver.resume();
|
|
|
|
sectionObserver.setElements( elements() );
|
|
|
|
};
|
2024-09-23 21:53:00 +00:00
|
|
|
|
2024-09-10 22:28:12 +00:00
|
|
|
mw.hook( 've.activationStart' ).add( () => {
|
|
|
|
sectionObserver.pause();
|
|
|
|
} );
|
|
|
|
mw.hook( 'wikipage.tableOfContents' ).add( ( sections ) => {
|
|
|
|
tableOfContents.reloadTableOfContents( sections ).then( () => {
|
|
|
|
updateElements();
|
|
|
|
} );
|
|
|
|
} );
|
2024-09-23 21:53:00 +00:00
|
|
|
mw.hook( 've.deactivationComplete' ).add( () => {
|
|
|
|
updateElements();
|
|
|
|
} );
|
2024-09-10 22:28:12 +00:00
|
|
|
|
|
|
|
const setInitialActiveSection = () => {
|
|
|
|
const hash = location.hash.slice( 1 );
|
|
|
|
// If hash fragment is blank, determine the active section with section
|
|
|
|
// observer.
|
|
|
|
if ( hash === '' ) {
|
|
|
|
sectionObserver.calcIntersection();
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
// T325086: If hash fragment is present and corresponds to a toc section,
|
|
|
|
// expand the section.
|
|
|
|
const hashSection = /** @type {HTMLElement|null} */ ( mw.util.getTargetFromFragment( `${ TOC_SECTION_ID_PREFIX }${ hash }` ) );
|
|
|
|
if ( hashSection ) {
|
|
|
|
tableOfContents.expandSection( hashSection.id );
|
|
|
|
}
|
|
|
|
|
|
|
|
// T325086: If hash fragment corresponds to a section AND the user is at
|
|
|
|
// bottom of page, activate the section. Otherwise, use section observer to
|
|
|
|
// calculate the active section.
|
|
|
|
//
|
|
|
|
// Note that even if a hash fragment is present, it's possible for the
|
|
|
|
// browser to scroll to a position that is different from the position of
|
|
|
|
// the section that corresponds to the hash fragment. This can happen when
|
|
|
|
// the browser remembers a prior scroll position after refreshing the page,
|
|
|
|
// for example.
|
|
|
|
if (
|
|
|
|
hashSection &&
|
|
|
|
Math.round( window.innerHeight + window.scrollY ) >= document.body.scrollHeight
|
|
|
|
) {
|
|
|
|
tableOfContents.changeActiveSection( hashSection.id );
|
|
|
|
} else {
|
|
|
|
// Fallback to section observer's calculation for the active section.
|
|
|
|
sectionObserver.calcIntersection();
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
|
|
|
setInitialActiveSection();
|
|
|
|
|
|
|
|
return tableOfContents;
|
|
|
|
};
|
|
|
|
|
|
|
|
/**
|
|
|
|
* @return {void}
|
|
|
|
*/
|
|
|
|
const main = () => {
|
|
|
|
const tocElement = document.getElementById( TOC_ID );
|
|
|
|
const bodyContent = document.getElementById( BODY_CONTENT_ID );
|
|
|
|
|
2024-11-08 00:12:26 +00:00
|
|
|
/* eslint-disable no-unused-vars */
|
2024-11-08 02:12:50 +00:00
|
|
|
const tableOfContents = setupTableOfContents( tocElement, bodyContent, initSectionObserver );
|
2024-09-10 22:28:12 +00:00
|
|
|
|
2024-11-08 00:12:26 +00:00
|
|
|
const
|
|
|
|
stickyHeaderElement = document.getElementById( stickyHeader.STICKY_HEADER_ID ),
|
|
|
|
stickyIntersection = document.getElementById( 'citizen-page-header-sticky-sentinel' ),
|
|
|
|
stickyPlaceholder = document.getElementById( stickyHeader.STICKY_HEADER_PLACEHOLDER_ID );
|
|
|
|
|
|
|
|
// eslint-disable-next-line es-x/no-optional-chaining
|
|
|
|
const shouldStickyHeader = getComputedStyle( stickyIntersection )?.getPropertyValue( 'display' ) !== 'none';
|
|
|
|
const isStickyHeaderAllowed = !!stickyHeaderElement &&
|
|
|
|
!!stickyPlaceholder &&
|
|
|
|
!!stickyIntersection &&
|
|
|
|
shouldStickyHeader;
|
2024-11-07 00:31:49 +00:00
|
|
|
|
|
|
|
const scrollDirectionObserver = scrollObserver.initDirectionObserver(
|
|
|
|
() => {
|
|
|
|
document.body.classList.remove( SCROLL_UP_CLASS );
|
|
|
|
document.body.classList.add( SCROLL_DOWN_CLASS );
|
|
|
|
},
|
|
|
|
() => {
|
|
|
|
document.body.classList.remove( SCROLL_DOWN_CLASS );
|
|
|
|
document.body.classList.add( SCROLL_UP_CLASS );
|
|
|
|
},
|
2024-11-08 09:58:11 +00:00
|
|
|
10
|
2024-11-07 00:31:49 +00:00
|
|
|
);
|
|
|
|
|
2024-11-08 00:12:26 +00:00
|
|
|
const resumeStickyHeader = () => {
|
2024-11-09 23:22:52 +00:00
|
|
|
if (
|
|
|
|
isStickyHeaderAllowed &&
|
|
|
|
!document.body.classList.contains( stickyHeader.STICKY_HEADER_VISIBLE_CLASS ) &&
|
|
|
|
document.body.classList.contains( PAGE_TITLE_INTERSECTION_CLASS )
|
|
|
|
) {
|
2024-11-08 00:12:26 +00:00
|
|
|
stickyHeader.show( stickyHeaderElement, stickyPlaceholder );
|
2024-11-08 01:33:56 +00:00
|
|
|
if ( document.documentElement.classList.contains( 'citizen-feature-autohide-navigation-clientpref-1' ) ) {
|
|
|
|
scrollDirectionObserver.resume();
|
|
|
|
}
|
2024-11-08 00:12:26 +00:00
|
|
|
}
|
|
|
|
};
|
2024-11-07 00:31:49 +00:00
|
|
|
|
2024-11-08 00:12:26 +00:00
|
|
|
const pauseStickyHeader = () => {
|
2024-11-09 23:22:52 +00:00
|
|
|
if ( document.body.classList.contains( stickyHeader.STICKY_HEADER_VISIBLE_CLASS ) ) {
|
2024-11-08 00:12:26 +00:00
|
|
|
stickyHeader.hide( stickyHeaderElement, stickyPlaceholder );
|
|
|
|
scrollDirectionObserver.pause();
|
2024-11-06 23:51:08 +00:00
|
|
|
}
|
2024-11-08 00:12:26 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
const pageHeaderObserver = scrollObserver.initScrollObserver(
|
2024-11-08 02:09:25 +00:00
|
|
|
() => {
|
|
|
|
document.body.classList.add( PAGE_TITLE_INTERSECTION_CLASS );
|
2024-11-09 23:22:52 +00:00
|
|
|
resumeStickyHeader();
|
2024-11-08 02:09:25 +00:00
|
|
|
},
|
|
|
|
() => {
|
|
|
|
document.body.classList.remove( PAGE_TITLE_INTERSECTION_CLASS );
|
2024-11-09 23:22:52 +00:00
|
|
|
pauseStickyHeader();
|
2024-11-08 02:09:25 +00:00
|
|
|
}
|
2024-11-06 23:51:08 +00:00
|
|
|
);
|
|
|
|
|
2024-11-07 00:31:49 +00:00
|
|
|
pageHeaderObserver.observe( stickyIntersection );
|
|
|
|
|
2024-11-09 02:24:56 +00:00
|
|
|
// Initialize var
|
|
|
|
let bodyWidth = 0;
|
2024-11-06 23:51:08 +00:00
|
|
|
const bodyObserver = resizeObserver.initResizeObserver(
|
2024-11-08 01:33:56 +00:00
|
|
|
// onResize
|
|
|
|
() => {},
|
|
|
|
// onResizeStart
|
2024-11-09 02:24:56 +00:00
|
|
|
( entry ) => {
|
|
|
|
// eslint-disable-next-line es-x/no-optional-chaining
|
|
|
|
bodyWidth = entry.borderBoxSize?.[ 0 ].inlineSize;
|
2024-11-07 00:31:49 +00:00
|
|
|
// Disable all CSS animation during resize
|
2024-11-06 23:51:08 +00:00
|
|
|
if ( document.documentElement.classList.contains( 'citizen-animations-ready' ) ) {
|
|
|
|
document.documentElement.classList.remove( 'citizen-animations-ready' );
|
2024-09-10 22:28:12 +00:00
|
|
|
}
|
2024-11-06 23:51:08 +00:00
|
|
|
},
|
2024-11-08 01:33:56 +00:00
|
|
|
// onResizeEnd
|
2024-11-09 02:24:56 +00:00
|
|
|
( entry ) => {
|
|
|
|
// eslint-disable-next-line es-x/no-optional-chaining
|
|
|
|
const newBodyWidth = entry.borderBoxSize?.[ 0 ].inlineSize;
|
|
|
|
const shouldRecalcStickyHeader =
|
|
|
|
document.body.classList.contains( PAGE_TITLE_INTERSECTION_CLASS ) &&
|
|
|
|
typeof newBodyWidth === 'number' &&
|
|
|
|
bodyWidth !== newBodyWidth;
|
|
|
|
|
2024-11-07 00:31:49 +00:00
|
|
|
// Enable CSS animation after resize is finished
|
2024-11-06 23:51:08 +00:00
|
|
|
document.documentElement.classList.add( 'citizen-animations-ready' );
|
2024-11-08 00:12:26 +00:00
|
|
|
// Recalculate sticky header height at the end of the resize
|
2024-11-09 02:24:56 +00:00
|
|
|
if ( shouldRecalcStickyHeader ) {
|
2024-11-08 02:09:25 +00:00
|
|
|
resumeStickyHeader();
|
|
|
|
}
|
2024-09-10 22:28:12 +00:00
|
|
|
}
|
|
|
|
);
|
2024-11-06 23:51:08 +00:00
|
|
|
bodyObserver.observe( document.body );
|
2024-11-09 23:22:52 +00:00
|
|
|
|
|
|
|
mw.hook( 've.activationStart' ).add( () => {
|
|
|
|
pauseStickyHeader();
|
|
|
|
} );
|
|
|
|
|
|
|
|
mw.hook( 've.deactivationComplete' ).add( () => {
|
|
|
|
resumeStickyHeader();
|
|
|
|
} );
|
2024-09-10 22:28:12 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
module.exports = {
|
|
|
|
main
|
|
|
|
};
|