mediawiki-skins-Citizen/resources/skins.citizen.scripts/overflowElements.js

348 lines
11 KiB
JavaScript
Raw Normal View History

const config = require( './config.json' );
/**
* Class representing an OverflowElement.
*
* @class
*/
class OverflowElement {
constructor( element, isPointerDevice ) {
this.element = element;
this.isPointerDevice = isPointerDevice;
this.elementWidth = 0;
this.contentScrollLeft = 0;
this.contentWidth = 0;
this.onScroll = mw.util.throttle( this.onScroll.bind( this ), 250 );
this.updateState = this.updateState.bind( this );
}
/**
* Toggles classes on the wrapper element based on the provided conditions.
*
* @param {Array} classes - An array of conditions and class names to toggle.
* Each element in the array should be a tuple where the first element is a boolean condition
* and the second element is the class name to toggle.
*
* @return {void}
*/
toggleClasses( classes ) {
classes.forEach( ( [ condition, className ] ) => {
const hasClass = this.wrapper.classList.contains( className );
if ( condition && !hasClass ) {
this.wrapper.classList.add( className );
} else if ( !condition && hasClass ) {
this.wrapper.classList.remove( className );
}
} );
}
/**
* Checks if the state of the overflow element has changed by comparing the current element width, content scroll left,
* and content width with the cached values. Returns true if any of the values have changed, otherwise returns false.
*
* @return {boolean} - True if the state has changed, false otherwise.
*/
hasStateChanged() {
return (
this.element.scrollWidth !== this.elementWidth ||
Math.round( this.content.scrollLeft ) !== this.contentScrollLeft ||
this.content.offsetWidth !== this.contentWidth
);
}
/**
* Checks if the element has overflowed horizontally by comparing the element width with the content width.
*
* @return {boolean} - True if the element has overflowed, false otherwise.
*/
hasOverflowed() {
return this.elementWidth > this.contentWidth;
}
/**
* Updates the state of the overflow element by calculating the element width, content scroll left, and content width.
* If the width values are invalid, logs an error and returns.
* Compares the current state with the previous state and updates the cache if there is a change.
* Toggles classes on the content element based on the overflow state (left or right).
*
* @return {void}
*/
updateState() {
const elementWidth = this.element.scrollWidth;
const contentScrollLeft = Math.round( this.content.scrollLeft );
const contentWidth = this.content.offsetWidth;
if ( !this.hasStateChanged() ) {
return;
}
this.elementWidth = elementWidth;
this.contentScrollLeft = contentScrollLeft;
this.contentWidth = contentWidth;
let isLeftOverflowing, isRightOverflowing;
if ( !this.hasOverflowed() ) {
isLeftOverflowing = false;
isRightOverflowing = false;
} else {
isLeftOverflowing = this.contentScrollLeft > 0;
isRightOverflowing =
this.contentScrollLeft + this.contentWidth < this.elementWidth;
}
window.requestAnimationFrame( () => {
const updateClasses = [
[ isLeftOverflowing, 'citizen-overflow--left' ],
[ isRightOverflowing, 'citizen-overflow--right' ]
];
this.toggleClasses( updateClasses );
} );
}
/**
* Filters and adds inherited classes to the wrapper element.
*
* @return {void}
*/
handleInheritedClasses() {
const inheritedClasses = config.wgCitizenOverflowInheritedClasses;
const filteredClasses = inheritedClasses.filter( ( cls ) => this.element.classList.contains( cls )
);
filteredClasses.forEach( ( cls ) => {
if ( !this.wrapper.classList.contains( cls ) ) {
this.wrapper.classList.add( cls );
}
if ( this.element.classList.contains( cls ) ) {
this.element.classList.remove( cls );
}
} );
}
/**
* Wraps the element in a div container with the class 'citizen-overflow-wrapper'.
* Checks if the element or its parent node is null or undefined, and logs an error if so.
* Verifies the existence of the necessary configuration classes for wrapping and logs an error if missing.
* Creates a new div wrapper element, adds the class 'citizen-overflow-wrapper', and appends it to the parent node before the element.
* Moves the element inside the wrapper.
* Handles inherited classes such as 'floatleft' and 'floatright' by adding them to the wrapper and removing them from the element.
* Logs any errors that occur during the wrapping process.
*
* @return {void}
*/
wrap() {
if ( !this.element || !this.element.parentNode ) {
mw.log.error(
'[Citizen] Element or element.parentNode is null or undefined. Please check if the element or element.parentNode is null or undefined.'
);
return;
}
try {
const fragment = document.createDocumentFragment();
const wrapper = document.createElement( 'div' );
wrapper.className = 'citizen-overflow-wrapper';
fragment.appendChild( wrapper );
this.wrapper = wrapper;
this.handleInheritedClasses();
if ( this.isPointerDevice ) {
const createButton = ( type ) => {
const button = document.createElement( 'button' );
button.className = `citizen-overflow-navButton citizen-overflow-navButton-${ type } citizen-ui-icon mw-ui-icon-wikimedia-collapse`;
button.setAttribute( 'aria-hidden', 'true' );
button.setAttribute( 'tabindex', '-1' );
return button;
};
const nav = document.createElement( 'div' );
nav.className = 'citizen-overflow-nav';
nav.appendChild( createButton( 'left' ) );
nav.appendChild( createButton( 'right' ) );
wrapper.appendChild( nav );
this.nav = nav;
}
const content = document.createElement( 'div' );
content.className = 'citizen-overflow-content';
wrapper.appendChild( content );
const parentNode = this.element.parentNode;
parentNode.insertBefore( fragment, this.element );
content.appendChild( this.element );
this.content = content;
} catch ( error ) {
mw.log.error(
`[Citizen] Error occurred while wrapping element: ${ error.message }`
);
}
}
/**
* Scrolls the content element by the specified offset.
*
* @param {number} offset - The amount by which to scroll the content element.
* @return {void}
*/
scrollContent( offset ) {
const delta = this.content.scrollWidth - this.content.offsetWidth;
const scrollLeft = Math.floor( this.content.scrollLeft ) + offset;
window.requestAnimationFrame( () => {
this.content.scrollLeft = Math.min( Math.max( scrollLeft, 0 ), delta );
} );
}
/**
* Handles the click event on the navigation buttons.
* Scrolls the content element left or right based on the button clicked.
*
* @param {Event} event - The click event object.
* @return {void}
*/
onClick( event ) {
const target = event.target;
if ( !target.classList.contains( 'citizen-overflow-navButton' ) ) {
return;
}
// Prevent triggering the form submit action (e.g. realtime preview in WikiEditor)
event.preventDefault();
const offset = this.wrapper.offsetWidth / 2;
if ( target.classList.contains( 'citizen-overflow-navButton-left' ) ) {
this.scrollContent( -offset );
} else if (
target.classList.contains( 'citizen-overflow-navButton-right' )
) {
this.scrollContent( offset );
}
}
/**
* Handles the scroll event by requesting an animation frame to update the state of the overflow element.
*
* @return {void}
*/
onScroll() {
this.updateState();
}
/**
* Resumes the functionality of the overflow element by updating its state, adding a scroll event listener, and observing element resize.
* Calls the 'updateState' method to update the state of the overflow element.
* Adds a scroll event listener to the content element to handle scroll events by calling the 'onScroll' method.
* Observes the element for resize changes using the 'resizeObserver'.
*
* @return {void}
*/
resume() {
this.updateState();
this.content.addEventListener( 'scroll', this.onScroll );
this.resizeObserver.observe( this.element );
if ( this.isPointerDevice ) {
this.nav.addEventListener( 'click', this.onClick.bind( this ) );
}
}
/**
* Pauses the functionality of the overflow element by removing the scroll event listener and stopping observation of element resize.
* Removes the scroll event listener from the content element that triggers the 'onScroll' method.
* Stops observing resize changes of the element using the 'resizeObserver'.
*
* @return {void}
*/
pause() {
this.content.removeEventListener( 'scroll', this.onScroll );
this.resizeObserver.unobserve( this.element );
if ( this.isPointerDevice ) {
this.nav.removeEventListener( 'click', this.onClick );
}
}
/**
* Sets up an IntersectionObserver to handle intersection changes for the overflow element.
* When the element intersects with the viewport, resumes the functionality by calling the 'resume' method.
* When the element is not intersecting with the viewport, pauses the functionality by calling the 'pause' method.
* Observes the intersection changes for the element using the IntersectionObserver.
*
* @return {void}
*/
setupIntersectionObserver() {
// eslint-disable-next-line compat/compat
this.intersectionObserver = new IntersectionObserver( ( entries ) => {
entries.forEach( ( entry ) => {
if ( entry.isIntersecting ) {
this.resume();
} else {
this.pause();
}
} );
} );
this.intersectionObserver.observe( this.element );
}
/**
* Sets up a ResizeObserver to monitor changes in the size of the element and triggers the 'updateState' method accordingly.
*
* @return {void}
*/
setupResizeObserver() {
// eslint-disable-next-line compat/compat
this.resizeObserver = new ResizeObserver( this.updateState );
}
/**
* Initializes the OverflowElement by wrapping the element, setting up a ResizeObserver to monitor size changes,
* setting up an IntersectionObserver to handle intersection changes, and resuming the functionality of the overflow element.
*
* @return {void}
*/
init() {
this.wrap();
this.setupResizeObserver();
this.setupIntersectionObserver();
this.resume();
}
}
/**
* Initializes the process of wrapping overflow elements within the given body content.
*
* @param {HTMLElement} bodyContent - The body content element containing elements to be wrapped.
* @return {void}
*/
function init( bodyContent ) {
const nowrapClasses = config.wgCitizenOverflowNowrapClasses;
if ( !nowrapClasses || !Array.isArray( nowrapClasses ) ) {
mw.log.error(
'[Citizen] Invalid or missing $wgCitizenOverflowNowrapClasses. Cannot proceed with wrapping element.'
);
return;
}
const overflowElements = bodyContent.querySelectorAll(
'.citizen-overflow, .wikitable:not( .wikitable .wikitable )'
);
if ( !overflowElements.length ) {
return;
}
const isPointerDevice = window.matchMedia( '(hover: hover) and (pointer: fine)' ).matches;
overflowElements.forEach( ( el ) => {
if ( nowrapClasses.some( ( cls ) => el.classList.contains( cls ) ) ) {
return;
}
const overflowElement = new OverflowElement( el, isPointerDevice );
overflowElement.init();
} );
}
module.exports = {
init: init
};