/*!
* VisualEditor client (browser) specific utilities that interact with a rendered DOM.
*
* @copyright See AUTHORS.txt
*/
/**
* @method
* @see OO.ui.Element#scrollIntoView
*/
ve.scrollIntoView = OO.ui.Element.static.scrollIntoView.bind( OO.ui.Element.static );
/**
* Select the contents of an element
*
* @param {HTMLElement} element
*/
ve.selectElement = function ( element ) {
const win = OO.ui.Element.static.getWindow( element ),
nativeRange = win.document.createRange(),
nativeSelection = win.getSelection();
nativeRange.setStart( element, 0 );
nativeRange.setEnd( element, element.childNodes.length );
nativeSelection.removeAllRanges();
nativeSelection.addRange( nativeRange );
};
/**
* Feature detect if the browser supports extending selections
*
* Should work everywhere except IE
*
* @private
* @property {boolean}
*/
ve.supportsSelectionExtend = !!window.getSelection().extend;
/**
* Translate rect by some fixed vector and return a new offset object
*
* @param {Object} rect Offset object containing all or any of top, left, bottom, right, width & height
* @param {number} x Horizontal translation
* @param {number} y Vertical translation
* @return {Object} Translated rect
*/
ve.translateRect = function ( rect, x, y ) {
const translatedRect = {};
if ( rect.top !== undefined ) {
translatedRect.top = rect.top + y;
}
if ( rect.bottom !== undefined ) {
translatedRect.bottom = rect.bottom + y;
}
if ( rect.left !== undefined ) {
translatedRect.left = rect.left + x;
}
if ( rect.right !== undefined ) {
translatedRect.right = rect.right + x;
}
if ( rect.width !== undefined ) {
translatedRect.width = rect.width;
}
if ( rect.height !== undefined ) {
translatedRect.height = rect.height;
}
return translatedRect;
};
/**
* Get the start and end rectangles (in a text flow sense) from a list of rectangles
*
* The start rectangle is the top-most, and the end rectangle is the bottom-most.
*
* @param {Object[]|null} rects Full list of rectangles
* @return {Object.<string,Object>|null} Object containing two rectangles: start and end, or null if there are no rectangles
*/
ve.getStartAndEndRects = function ( rects ) {
if ( !rects || !rects.length ) {
return null;
}
let startRect, endRect;
for ( let i = 0, l = rects.length; i < l; i++ ) {
if ( !startRect || rects[ i ].top < startRect.top ) {
// Use ve.extendObject as ve.copy copies non-plain objects by reference
startRect = ve.extendObject( {}, rects[ i ] );
} else if ( rects[ i ].top === startRect.top ) {
// Merge rects with the same top coordinate
startRect.left = Math.min( startRect.left, rects[ i ].left );
startRect.right = Math.max( startRect.right, rects[ i ].right );
startRect.width = startRect.right - startRect.left;
}
if ( !endRect || rects[ i ].bottom > endRect.bottom ) {
// Use ve.extendObject as ve.copy copies non-plain objects by reference
endRect = ve.extendObject( {}, rects[ i ] );
} else if ( rects[ i ].bottom === endRect.bottom ) {
// Merge rects with the same bottom coordinate
endRect.left = Math.min( endRect.left, rects[ i ].left );
endRect.right = Math.max( endRect.right, rects[ i ].right );
endRect.width = startRect.right - startRect.left;
}
}
return {
start: startRect,
end: endRect
};
};
/**
* Minimize a set of rectangles by discarding ones which are contained by others
*
* @param {Object[]} rects Full list of rectangles
* @param {number} [allowedErrorOffset=3] Allowed error offset, the pixel error amount
* used in coordinate comparisons.
* @return {Object[]} Minimized list of rectangles
*/
ve.minimizeRects = function ( rects, allowedErrorOffset ) {
if ( allowedErrorOffset === undefined ) {
allowedErrorOffset = 3;
}
// Check if rect1 contains rect2
function contains( rect1, rect2 ) {
return rect2.left >= rect1.left - allowedErrorOffset &&
rect2.top >= rect1.top - allowedErrorOffset &&
rect2.right <= rect1.right + allowedErrorOffset &&
rect2.bottom <= rect1.bottom + allowedErrorOffset;
}
function merge( rect1, rect2 ) {
const rect = {
top: Math.min( rect1.top, rect2.top ),
left: Math.min( rect1.left, rect2.left ),
bottom: Math.max( rect1.bottom, rect2.bottom ),
right: Math.max( rect1.right, rect2.right )
};
rect.width = rect.right - rect.left;
rect.height = rect.bottom - rect.top;
return rect;
}
function isApprox( a, b ) {
return Math.abs( a - b ) < allowedErrorOffset;
}
const minimalRects = [];
rects.forEach( ( rect ) => {
let keep = true;
for ( let i = 0, il = minimalRects.length; i < il; i++ ) {
// This rect is contained by an existing rect, discard
if ( contains( minimalRects[ i ], rect ) ) {
keep = false;
break;
}
// An existing rect is contained by this rect, discard the existing rect
if ( contains( rect, minimalRects[ i ] ) ) {
minimalRects.splice( i, 1 );
i--;
il--;
break;
}
// Rect is horizontally adjacent to an existing rect, merge
if (
isApprox( rect.top, minimalRects[ i ].top ) && isApprox( rect.bottom, minimalRects[ i ].bottom ) && (
isApprox( rect.left, minimalRects[ i ].right ) || isApprox( rect.right, minimalRects[ i ].left )
)
) {
keep = false;
minimalRects[ i ] = merge( minimalRects[ i ], rect );
break;
}
// Rect is vertically adjacent to an existing rect, merge
if (
isApprox( rect.left, minimalRects[ i ].left ) && isApprox( rect.right, minimalRects[ i ].right ) && (
isApprox( rect.top, minimalRects[ i ].bottom ) || isApprox( rect.bottom, minimalRects[ i ].top )
)
) {
keep = false;
minimalRects[ i ] = merge( minimalRects[ i ], rect );
break;
}
// TODO: Consider case where a rect bridges two existing minimalRects, and so requires two
// merges in one step. As rects are usually returned in order, this is unlikely to happen.
}
if ( keep ) {
minimalRects.push( rect );
}
} );
return minimalRects;
};
/**
* Get the client platform string from the browser.
*
* FIXME T126036: This is a wrapper for calling getSystemPlatform() on the current
* platform except that if the platform hasn't been constructed yet, it falls back
* to using the base class implementation in {ve.init.Platform}. A proper solution
* would be not to need this information before the platform is constructed.
*
* @see ve.init.Platform#getSystemPlatform
* @return {string} Client platform string
*/
ve.getSystemPlatform = function () {
return ( ve.init.platform && ve.init.platform.constructor || ve.init.Platform ).static.getSystemPlatform();
};
/**
* Check whether a jQuery event represents a plain left click, without any modifiers
*
* @param {jQuery.Event} e
* @return {boolean} Whether it was an unmodified left click
*/
ve.isUnmodifiedLeftClick = function ( e ) {
return e && e.which && e.which === OO.ui.MouseButtons.LEFT && !( e.shiftKey || e.altKey || e.ctrlKey || e.metaKey );
};
/**
* Are multiple formats for clipboardData items supported?
*
* If you want to use unknown formats, an additional check for whether we're
* on MS Edge needs to be made, as that only supports standard plain text / HTML.
*
* @param {jQuery.Event} e A jQuery event object for a copy/paste event
* @param {boolean} [customTypes] Check whether non-standard formats are supported
* @return {boolean} Whether multiple clipboardData item formats are supported
*/
ve.isClipboardDataFormatsSupported = function ( e, customTypes ) {
const cacheKey = customTypes ? 'cachedCustom' : 'cached';
if ( ve.isClipboardDataFormatsSupported[ cacheKey ] === undefined ) {
const profile = $.client.profile();
const clipboardData = e.originalEvent.clipboardData || e.originalEvent.dataTransfer;
ve.isClipboardDataFormatsSupported[ cacheKey ] = !!(
clipboardData &&
( !customTypes || profile.name !== 'edge' ) && (
// Support: Chrome
clipboardData.items ||
// Support: Firefox >= 48
// (but not Firefox Android, which has name='android' and doesn't support this feature)
( profile.name === 'firefox' && profile.versionNumber >= 48 )
)
);
}
return ve.isClipboardDataFormatsSupported[ cacheKey ];
};
/**
* Workaround for catastrophic Firefox bug (T209646)
*
* Support: Firefox <= ~70
* anchorNode and focusNode return unusable 'Restricted' object
* when focus is in a number input:
* https://bugzilla.mozilla.org/show_bug.cgi?id=1495482
* This task was resolved around late 2019
*
* @param {Selection} selection Native selection
*/
ve.fixSelectionNodes = function ( selection ) {
const profile = $.client.profile();
if ( profile.layout !== 'gecko' ) {
return;
}
function fixNodeProperty( prop ) {
Object.defineProperty( selection, prop, {
get: function () {
const node = Object.getOwnPropertyDescriptor( Selection.prototype, prop ).get.call( this );
try {
// Try to read a property out of node if it not null
// Throws an exception in FF
// eslint-disable-next-line no-unused-expressions
node && node.prop;
} catch ( e ) {
// When an input is focused, the selection becomes the input itself,
// so the anchor/focusNode is the input's parent.
// Fall back to null if that doesn't exist.
ve.log( 'Worked around Firefox bug with getSelection().anchorNode/focusNode. See https://phabricator.wikimedia.org/T209646.' );
return ( document.activeElement && document.activeElement.parentNode ) || null;
}
return node;
}
} );
}
if ( !Object.getOwnPropertyDescriptor( selection, 'anchorNode' ) ) {
fixNodeProperty( 'anchorNode' );
fixNodeProperty( 'focusNode' );
}
};
/**
* Safely decode HTML entities
*
* @param {string} html Text with HTML entities
* @return {string} Text with HTML entities decoded
*/
ve.safeDecodeEntities = function ( html ) {
// Decode HTML entities, safely (no elements permitted inside textarea)
const textarea = document.createElement( 'textarea' );
textarea.innerHTML = html;
return textarea.textContent;
};