/**
* jQuery extensions. See {@link external:jQuery.fn jQuery.fn}.
*
* @module jqueryExtensions
*/
import cd from './cd';
import controller from './controller';
import { defined, isMetadataNode, sleep } from './utils-general';
import { createSvg } from './utils-window';
/**
* Merge many jQuery objects into one. Works like {@link https://api.jquery.com/add/ .add()}, but
* accepts many parameters and is faster. Unlike `.add()`, only accepts jQuery objects though and
* doesn't reorder elements based on their relative position in the DOM.
*
* @param {Array.<external:jQuery|undefined>} arrayOfJquery jQuery objects. Undefined values will be
* omitted.
* @returns {external:JQuery} jQuery
* @name cdMerge
* @memberof external:jQuery
*/
$.cdMerge = function (...arrayOfJquery) {
return $($.map(arrayOfJquery.filter(defined), ($object) => $object.get()));
};
/**
* jQuery. See {@link external:jQuery.fn jQuery.fn} for extensions.
*
* @external jQuery
* @type {object}
* @see https://jquery.com/
* @global
*/
/**
* jQuery extensions.
*
* @namespace fn
* @memberof external:jQuery
*/
export default {
/**
* Remove non-element nodes and metadata elements (`'STYLE'`, `'LINK'`) from a jQuery collection.
*
* @returns {external:jQuery}
* @memberof external:jQuery.fn
*/
cdRemoveNonElementNodes: function () {
return this.filter((i, el) => el.tagName && !isMetadataNode(el));
},
/**
* Scroll to the element.
*
* @param {'top'|'center'|'bottom'} [alignment='top'] Where should the element be positioned
* relative to the viewport.
* @param {boolean} [smooth=true] Whether to use a smooth animation.
* @param {Function} [callback] Callback to run after the animation has completed.
* @returns {external:jQuery}
* @memberof external:jQuery.fn
*/
cdScrollTo(alignment = 'top', smooth = true, callback) {
const defaultScrollPaddingTop = 7;
let $elements = this.cdRemoveNonElementNodes();
// Filter out elements like .mw-empty-elt
const findFirstVisibleElementOffset = (direction) => {
const elements = $elements.get();
if (direction === 'backward') {
elements.reverse();
}
for (const el of elements) {
const offset = $(el).offset();
if (!(offset.top === 0 && offset.left === 0)) {
return offset;
}
}
return null;
}
let offsetFirst = findFirstVisibleElementOffset();
let offsetLast = findFirstVisibleElementOffset('backward');
if (!offsetFirst || !offsetLast) {
const $firstVisibleAncestor = $elements.first().closest(':visible');
if ($firstVisibleAncestor.length && !$firstVisibleAncestor.is(controller.$root)) {
$elements = $firstVisibleAncestor;
offsetFirst = findFirstVisibleElementOffset();
offsetLast = findFirstVisibleElementOffset('backward');
mw.notify(cd.s('error-elementhidden-container'), {
tag: 'cd-elementhidden-container',
});
} else {
mw.notify(cd.s('error-elementhidden'), {
type: 'error',
tag: 'cd-elementhidden',
});
return this;
}
}
const offsetBottom = offsetLast.top + $elements.last().outerHeight();
let top;
if (alignment === 'center') {
top = Math.min(
offsetFirst.top,
offsetFirst.top + ((offsetBottom - offsetFirst.top) * 0.5) - $(window).height() * 0.5
);
} else if (alignment === 'bottom') {
top = offsetBottom - $(window).height() + defaultScrollPaddingTop;
} else {
top = offsetFirst.top - (cd.g.bodyScrollPaddingTop || defaultScrollPaddingTop);
}
controller.toggleAutoScrolling(true);
controller.scrollToY(top, smooth, callback);
return this;
},
/**
* Check if the element is in the viewport. Elements hidden with `display: none` are checked as if
* they were visible. Elements inside other hidden elements return `false`.
*
* This method is not supposed to be used on element collections that are partially visible,
* partially hidden, as it can't remember their state.
*
* @param {boolean} partially Return `true` even if only a part of the element is in the viewport.
* @returns {?boolean}
* @memberof external:jQuery.fn
*/
cdIsInViewport(partially = false) {
const $elements = this.cdRemoveNonElementNodes();
// Workaround for hidden elements (use cases like checking if the add section form is in the
// viewport).
const wasHidden = $elements.get().every((el) => el.style.display === 'none');
if (wasHidden) {
$elements.show();
}
const elementTop = $elements.first().offset().top;
const elementBottom = $elements.last().offset().top + $elements.last().height();
// The element is hidden.
if (elementTop === 0 && elementBottom === 0) {
return false;
}
if (wasHidden) {
$elements.hide();
}
const scrollTop = $(window).scrollTop();
const viewportTop = scrollTop + cd.g.bodyScrollPaddingTop;
const viewportBottom = scrollTop + $(window).height();
return partially ?
elementBottom > viewportTop && elementTop < viewportBottom :
elementTop >= viewportTop && elementBottom <= viewportBottom;
},
/**
* Scroll to the element if it is not in the viewport.
*
* @param {'top'|'center'|'bottom'} [alignment='top'] Where should the element be positioned
* relative to the viewport.
* @param {boolean} [smooth=true] Whether to use a smooth animation.
* @param {Function} [callback] Callback to run after the animation has completed.
* @returns {external:jQuery}
* @memberof external:jQuery.fn
*/
cdScrollIntoView(alignment = 'top', smooth = true, callback) {
if (this.cdIsInViewport()) {
callback?.();
} else {
if (callback) {
// Add sleep() for a more smooth animation in case there is .focus() in the callback.
sleep().then(() => {
this.cdScrollTo(alignment, smooth, callback);
});
} else {
this.cdScrollTo(alignment, smooth, callback);
}
}
return this;
},
/**
* Get the element text as it is rendered in the browser, i.e. line breaks, paragraphs etc. are
* taken into account. **This function is expensive.**
*
* @returns {string}
* @memberof external:jQuery.fn
*/
cdGetText() {
let text;
const dummyElement = document.createElement('div');
[...this[0].childNodes].forEach((node) => {
dummyElement.appendChild(node.cloneNode(true));
});
document.body.appendChild(dummyElement);
text = dummyElement.innerText;
dummyElement.remove();
return text;
},
/**
* Add a close button to the element.
*
* @returns {external:jQuery}
* @memberof external:jQuery.fn
*/
cdAddCloseButton() {
if (this.find('.cd-closeButton').length) {
return this;
}
const $closeButton = $('<a>')
.attr('title', cd.s('cf-block-close'))
.append(
createSvg(20, 20).html(
`<path d="M4.34 2.93l12.73 12.73-1.41 1.41L2.93 4.35z" /><path d="M17.07 4.34L4.34 17.07l-1.41-1.41L15.66 2.93z" />
`)
)
.addClass('cd-closeButton cd-icon')
.on('click', () => {
this.empty();
});
this.prepend($closeButton);
return this;
},
/**
* Remove the close button from the element.
*
* @returns {external:jQuery}
* @memberof external:jQuery.fn
*/
cdRemoveCloseButton() {
this.find('.cd-closeButton').remove();
return this;
},
};