mirror of
https://github.com/StarCitizenTools/mediawiki-skins-Citizen.git
synced 2024-11-25 06:47:16 +00:00
7fb35f90f5
When the overflow button is within a `<form>` element, it can sometimes trigger the form action (e.g. In the realtime preview of WikiEditor).
353 lines
11 KiB
JavaScript
353 lines
11 KiB
JavaScript
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';
|
|
this.handleInheritedClasses();
|
|
fragment.appendChild( wrapper );
|
|
|
|
if ( this.isPointerDevice ) {
|
|
const elementStyles = window.getComputedStyle( this.element );
|
|
const topMargin = parseInt( elementStyles.marginTop );
|
|
const bottomMargin = parseInt( elementStyles.marginBottom );
|
|
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.style.marginTop = `${ topMargin }px`;
|
|
nav.style.marginBottom = `${ bottomMargin }px`;
|
|
|
|
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.wrapper = wrapper;
|
|
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
|
|
};
|