mirror of
https://gerrit.wikimedia.org/r/mediawiki/extensions/VisualEditor
synced 2024-11-15 02:23:58 +00:00
143b086c74
Objectives: * Scroll when needed to show highlighted (with keyboard) or selected (by any means) options in select widgets * Allow clipping and automatic scrolling for certain elements when they are otherwise going to be rendered partially out of view Changes: *.php * Add links to new file ve.ui.Widget.css, ve.ui.Dialog.css * Removed unneeded x-axis overflow rules ve.ui.ClippableElement.js, ve.ui.Element.css * New mixin, adds visible area clipping support to an element ve.ui.PopupToolGroup.js, ve.ui.MenuWidget.js * Mixin clippable element ve.ui.OptionWidget.js, ve.ui.OutlineItemWidget.js * Add scroll-into-view configuration for option widgets ve.ui.SearchWidget.js * Scroll items into view when highlighting with keyboard ve.Element.js * Add getBorders, getDimensions, getClosestScrollableContainer and scrollIntoView static methods * Add getClosestScrollableElementContainer and scrollElementIntoView methods Bug: 53610 Change-Id: Ie21faa973a68f517c7cfce8bd879b5317f536365
382 lines
9.6 KiB
JavaScript
382 lines
9.6 KiB
JavaScript
/*!
|
|
* VisualEditor UserInterface Element class.
|
|
*
|
|
* @copyright 2011-2013 VisualEditor Team and others; see AUTHORS.txt
|
|
* @license The MIT License (MIT); see LICENSE.txt
|
|
*/
|
|
|
|
/**
|
|
* Creates an ve.Element object.
|
|
*
|
|
* @class
|
|
* @abstract
|
|
*
|
|
* @constructor
|
|
* @param {Object} [config] Config options
|
|
* @cfg {Function} [$$] jQuery for the frame the widget is in
|
|
*/
|
|
ve.Element = function VeElement( config ) {
|
|
// Initialize config
|
|
config = config || {};
|
|
// Properties
|
|
this.$$ = config.$$ || ve.Element.get$$( document );
|
|
this.$ = this.$$( this.$$.context.createElement( this.getTagName() ) );
|
|
};
|
|
|
|
/* Static Properties */
|
|
|
|
/**
|
|
* @static
|
|
* @property
|
|
* @inheritable
|
|
*/
|
|
ve.Element.static = {};
|
|
|
|
/**
|
|
* HTML tag name.
|
|
*
|
|
* This may be ignored if getTagName is overridden.
|
|
*
|
|
* @static
|
|
* @property {string}
|
|
* @inheritable
|
|
*/
|
|
ve.Element.static.tagName = 'div';
|
|
|
|
/* Static Methods */
|
|
|
|
/**
|
|
* Gets a jQuery function within a specific document.
|
|
*
|
|
* @static
|
|
* @param {jQuery|HTMLElement|HTMLDocument|Window} context Context to bind the function to
|
|
* @param {ve.ui.Frame} [frame] Frame of the document context
|
|
* @returns {Function} Bound jQuery function
|
|
*/
|
|
ve.Element.get$$ = function ( context, frame ) {
|
|
function wrapper( selector ) {
|
|
return $( selector, wrapper.context );
|
|
}
|
|
|
|
wrapper.context = this.getDocument( context );
|
|
|
|
if ( frame ) {
|
|
wrapper.frame = frame;
|
|
}
|
|
|
|
return wrapper;
|
|
};
|
|
|
|
/**
|
|
* Get the document of an element.
|
|
*
|
|
* @static
|
|
* @param {jQuery|HTMLElement|HTMLDocument|Window} context Context to bind the function to
|
|
* @returns {HTMLDocument} Document object
|
|
* @throws {Error} If context is invalid
|
|
*/
|
|
ve.Element.getDocument = function ( context ) {
|
|
var doc =
|
|
// jQuery - selections created "offscreen" won't have a context, so .context isn't reliable
|
|
( context[0] && context[0].ownerDocument ) ||
|
|
// Empty jQuery selections might have a context
|
|
context.context ||
|
|
// HTMLElement
|
|
context.ownerDocument ||
|
|
// Window
|
|
context.document ||
|
|
// HTMLDocument
|
|
( context.nodeType === 9 && context );
|
|
|
|
if ( doc ) {
|
|
return doc;
|
|
}
|
|
|
|
throw new Error( 'Invalid context' );
|
|
};
|
|
|
|
/**
|
|
* Get the window of an element or document.
|
|
*
|
|
* @static
|
|
* @param {jQuery|HTMLElement|HTMLDocument|Window} context Context to bind the function to
|
|
* @returns {Window} Window object
|
|
*/
|
|
ve.Element.getWindow = function ( context ) {
|
|
var doc = this.getDocument( context );
|
|
return doc.parentWindow || doc.defaultView;
|
|
};
|
|
|
|
/**
|
|
* Get the offset between two frames.
|
|
*
|
|
* TODO: Make this function not use recursion.
|
|
*
|
|
* @static
|
|
* @param {Window} from Window of the child frame
|
|
* @param {Window} [to=window] Window of the parent frame
|
|
* @param {Object} [offset] Offset to start with, used internally
|
|
* @returns {Object} Offset object, containing left and top properties
|
|
*/
|
|
ve.Element.getFrameOffset = function ( from, to, offset ) {
|
|
var i, len, frames, frame, rect;
|
|
|
|
if ( !to ) {
|
|
to = window;
|
|
}
|
|
if ( !offset ) {
|
|
offset = { 'top': 0, 'left': 0 };
|
|
}
|
|
if ( from.parent === from ) {
|
|
return offset;
|
|
}
|
|
|
|
// Get iframe element
|
|
frames = from.parent.document.getElementsByTagName( 'iframe' );
|
|
for ( i = 0, len = frames.length; i < len; i++ ) {
|
|
if ( frames[i].contentWindow === from ) {
|
|
frame = frames[i];
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Recursively accumulate offset values
|
|
if ( frame ) {
|
|
rect = frame.getBoundingClientRect();
|
|
offset.left += rect.left;
|
|
offset.top += rect.top;
|
|
if ( from !== to ) {
|
|
this.getFrameOffset( from.parent, offset );
|
|
}
|
|
}
|
|
return offset;
|
|
};
|
|
|
|
/**
|
|
* Get the offset between two elements.
|
|
*
|
|
* @static
|
|
* @param {jQuery} $from
|
|
* @param {jQuery} $to
|
|
* @returns {Object} Translated position coordinates, containing top and left properties
|
|
*/
|
|
ve.Element.getRelativePosition = function ( $from, $to ) {
|
|
var from = $from.offset(),
|
|
to = $to.offset();
|
|
return { 'top': from.top - to.top, 'left': from.left - to.left };
|
|
};
|
|
|
|
/**
|
|
* Get element border sizes.
|
|
*
|
|
* @param {HTMLElement} el Element to measure
|
|
* @return {Object} Dimensions object with `top`, `left`, `bottom` and `right` properties
|
|
*/
|
|
ve.Element.getBorders = function ( el ) {
|
|
var doc = el.ownerDocument,
|
|
win = doc.parentWindow || doc.defaultView,
|
|
style = win && win.getComputedStyle ?
|
|
win.getComputedStyle( el, null ) : el.currentStyle,
|
|
loc = win && win.getComputedStyle ? true : false,
|
|
$el = $( el ),
|
|
top = parseFloat( loc ? style.borderTopWidth : $el.css( 'borderTopWidth' ) ) || 0,
|
|
left = parseFloat( loc ? style.borderLeftWidth : $el.css( 'borderLeftWidth' ) ) || 0,
|
|
bottom = parseFloat( loc ? style.borderBottomWidth : $el.css( 'borderBottomWidth' ) ) || 0,
|
|
right = parseFloat( loc ? style.borderRightWidth : $el.css( 'borderRightWidth' ) ) || 0;
|
|
|
|
return {
|
|
'top': Math.round( top ),
|
|
'left': Math.round( left ),
|
|
'bottom': Math.round( bottom ),
|
|
'right': Math.round( right )
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Get dimensions of an element or window.
|
|
*
|
|
* @param {HTMLElement|Window} el Element to measure
|
|
* @return {Object} Dimensions object with `borders`, `scroll`, `scrollbar` and `rect` properties
|
|
*/
|
|
ve.Element.getDimensions = function ( el ) {
|
|
var $el, $win,
|
|
doc = el.ownerDocument || el.document,
|
|
win = doc.parentWindow || doc.defaultView;
|
|
|
|
if ( win === el || el === doc.documentElement ) {
|
|
$win = $( win );
|
|
return {
|
|
'borders': { 'top': 0, 'left': 0, 'bottom': 0, 'right': 0 },
|
|
'scroll': {
|
|
'top': $win.scrollTop(),
|
|
'left': $win.scrollLeft()
|
|
},
|
|
'scrollbar': { 'right': 0, 'bottom': 0 },
|
|
'rect': {
|
|
'top': 0,
|
|
'left': 0,
|
|
'bottom': $win.innerHeight(),
|
|
'right': $win.innerWidth()
|
|
}
|
|
};
|
|
} else {
|
|
$el = $( el );
|
|
return {
|
|
'borders': this.getBorders( el ),
|
|
'scroll': {
|
|
'top': $el.scrollTop(),
|
|
'left': $el.scrollLeft()
|
|
},
|
|
'scrollbar': {
|
|
'right': $el.innerWidth() - el.clientWidth,
|
|
'bottom': $el.innerHeight() - el.clientHeight
|
|
},
|
|
'rect': el.getBoundingClientRect()
|
|
};
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Get closest scrollable container.
|
|
*
|
|
* Traverses up until either a scrollable element or the root is reached, in which case the window
|
|
* will be returned.
|
|
*
|
|
* @static
|
|
* @param {HTMLElement} el Element to find scrollable container for
|
|
* @param {string} [dimension] Dimension of scrolling to look for; `x`, `y` or omit for either
|
|
* @return {HTMLElement|Window} Closest scrollable container
|
|
*/
|
|
ve.Element.getClosestScrollableContainer = function ( el, dimension ) {
|
|
var i, val,
|
|
props = [ 'overflow' ],
|
|
$parent = $( el ).parent();
|
|
|
|
if ( dimension === 'x' || dimension === 'y' ) {
|
|
props.push( 'overflow-' + dimension );
|
|
}
|
|
|
|
while ( $parent ) {
|
|
if ( $parent[0] === el.ownerDocument.documentElement ) {
|
|
break;
|
|
}
|
|
i = props.length;
|
|
while ( i-- ) {
|
|
val = $parent.css( props[i] );
|
|
if ( val === 'auto' || val === 'scroll' ) {
|
|
return $parent[0];
|
|
}
|
|
}
|
|
$parent = $parent.parent();
|
|
}
|
|
return this.getWindow( el );
|
|
};
|
|
|
|
/**
|
|
* Scroll element into view
|
|
*
|
|
* @static
|
|
* @param {HTMLElement} el Element to scroll into view
|
|
* @param {Object} [config] Configuration config
|
|
* @param {string} [config.duration] jQuery animation duration value
|
|
* @param {string} [config.direction] Scroll in only one direction, e.g. 'x' or 'y', omit
|
|
* to scroll in both directions
|
|
* @param {Function} [config.complete] Function to call when scrolling completes
|
|
*/
|
|
ve.Element.scrollIntoView = function ( el, config ) {
|
|
// Configuration initialization
|
|
config = config || {};
|
|
|
|
var anim = {},
|
|
callback = typeof config.complete === 'function' && config.complete,
|
|
sc = this.getClosestScrollableContainer( el, config.direction ),
|
|
$sc = $( sc ),
|
|
eld = this.getDimensions( el ),
|
|
scd = this.getDimensions( sc ),
|
|
rel = {
|
|
'top': eld.rect.top - ( scd.rect.top + scd.borders.top ),
|
|
'bottom': scd.rect.bottom - scd.borders.bottom - scd.scrollbar.bottom - eld.rect.bottom,
|
|
'left': eld.rect.left - ( scd.rect.left + scd.borders.left ),
|
|
'right': scd.rect.right - scd.borders.right - scd.scrollbar.right - eld.rect.right
|
|
};
|
|
|
|
if ( !config.direction || config.direction === 'y' ) {
|
|
if ( rel.top < 0 ) {
|
|
anim.scrollTop = scd.scroll.top + rel.top;
|
|
} else if ( rel.top > 0 && rel.bottom < 0 ) {
|
|
anim.scrollTop = scd.scroll.top + Math.min( rel.top, -rel.bottom );
|
|
}
|
|
}
|
|
if ( !config.direction || config.direction === 'x' ) {
|
|
if ( rel.left < 0 ) {
|
|
anim.scrollLeft = scd.scroll.left + rel.left;
|
|
} else if ( rel.left > 0 && rel.right < 0 ) {
|
|
anim.scrollLeft = scd.scroll.left + Math.min( rel.left, -rel.right );
|
|
}
|
|
}
|
|
if ( !ve.isEmptyObject( anim ) ) {
|
|
$sc.stop( true ).animate( anim, config.duration || 'fast' );
|
|
if ( callback ) {
|
|
$sc.queue( function ( next ) {
|
|
callback();
|
|
next();
|
|
} );
|
|
}
|
|
} else {
|
|
if ( callback ) {
|
|
callback();
|
|
}
|
|
}
|
|
};
|
|
|
|
/* Methods */
|
|
|
|
/**
|
|
* Get the HTML tag name.
|
|
*
|
|
* Override this method to base the result on instance information.
|
|
*
|
|
* @returns {string} HTML tag name
|
|
*/
|
|
ve.Element.prototype.getTagName = function () {
|
|
return this.constructor.static.tagName;
|
|
};
|
|
|
|
/**
|
|
* Get the DOM document.
|
|
*
|
|
* @returns {HTMLDocument} Document object
|
|
*/
|
|
ve.Element.prototype.getElementDocument = function () {
|
|
return ve.Element.getDocument( this.$ );
|
|
};
|
|
|
|
/**
|
|
* Get the DOM window.
|
|
*
|
|
* @returns {Window} Window object
|
|
*/
|
|
ve.Element.prototype.getElementWindow = function () {
|
|
return ve.Element.getWindow( this.$ );
|
|
};
|
|
|
|
/**
|
|
* Get closest scrollable container.
|
|
*
|
|
* @method
|
|
* @see #static-method-getClosestScrollableContainer
|
|
*/
|
|
ve.Element.prototype.getClosestScrollableElementContainer = function () {
|
|
return ve.Element.getClosestScrollableContainer( this.$[0] );
|
|
};
|
|
|
|
/**
|
|
* Scroll element into view
|
|
*
|
|
* @method
|
|
* @see #static-method-scrollIntoView
|
|
*/
|
|
ve.Element.prototype.scrollElementIntoView = function ( config ) {
|
|
return ve.Element.scrollIntoView( this.$[0], config );
|
|
};
|