/*!
* VisualEditor UserInterface Context class.
*
* @copyright See AUTHORS.txt
*/
/**
* UserInterface context.
*
* @class
* @abstract
* @extends OO.ui.Element
* @mixes OO.EventEmitter
* @mixes OO.ui.mixin.GroupElement
*
* @constructor
* @param {ve.ui.Surface} surface
* @param {Object} [config] Configuration options
*/
ve.ui.Context = function VeUiContext( surface, config ) {
// Parent constructor
ve.ui.Context.super.call( this, config );
// Mixin constructors
OO.EventEmitter.call( this );
OO.ui.mixin.GroupElement.call( this, config );
// Properties
this.surface = surface;
this.visible = false;
this.choosing = false;
this.$focusTrapBefore = $( '<div>' ).prop( 'tabIndex', 0 );
this.$focusTrapAfter = $( '<div>' ).prop( 'tabIndex', 0 );
this.$focusTrapBefore.add( this.$focusTrapAfter ).on( 'focus', () => {
surface.getView().activate();
} );
// Initialization
// Hide element using a class, not this.toggle, as child implementations
// of toggle may require the instance to be fully constructed before running.
this.$group.addClass( 've-ui-context-menu' );
this.$element
.addClass( 've-ui-context ve-ui-context-hidden' )
.append( this.$focusTrapBefore, this.$group, this.$focusTrapAfter );
};
/* Inheritance */
OO.inheritClass( ve.ui.Context, OO.ui.Element );
OO.mixinClass( ve.ui.Context, OO.EventEmitter );
OO.mixinClass( ve.ui.Context, OO.ui.mixin.GroupElement );
/* Events */
/**
* @event ve.ui.Context#resize
*/
/* Static Properties */
/**
* Context is for mobile devices.
*
* @static
* @inheritable
* @property {boolean}
*/
ve.ui.Context.static.isMobile = false;
/* Methods */
/**
* Check if context is for mobile devices
*
* @return {boolean} Context is for mobile devices
*/
ve.ui.Context.prototype.isMobile = function () {
return this.constructor.static.isMobile;
};
/**
* Check if context is visible.
*
* @return {boolean} Context is visible
*/
ve.ui.Context.prototype.isVisible = function () {
return this.visible;
};
/**
* Get related item sources.
*
* Result is cached, and cleared when the model or selection changes.
*
* @abstract
* @return {Object[]} List of objects containing `type`, `name`, and `model` or `data` properties,
* `type` is either `item`, `tool` or `persistent`
* `name` is the symbolic name of the item or tool
* `model` is the model the item or tool is compatible with (for `item` or `tool`)
* `data` is additional data, for `persistent` context items
*/
ve.ui.Context.prototype.getRelatedSources = null;
/**
* Get the surface the context is being used with.
*
* @return {ve.ui.Surface}
*/
ve.ui.Context.prototype.getSurface = function () {
return this.surface;
};
/**
* Hide the context while it has valid items in the menu
*
* This could be triggered by clicking the close button on
* mobile or by pressing escape.
*/
ve.ui.Context.prototype.hide = function () {
const surfaceModel = this.surface.getModel();
this.toggleMenu( false );
this.toggle( false );
// Desktop: Ensure the next cursor movement re-evaluates the context,
// e.g. if moving within a link, the context is re-shown.
surfaceModel.once( 'select', () => {
surfaceModel.emitContextChange();
} );
// Mobile: Clear last-known contexedAnnotations so that clicking the annotation
// again just brings up this context item. (T232172)
this.getSurface().getView().contexedAnnotations = [];
};
/**
* Toggle the menu.
*
* @param {boolean} [show] Show the menu, omit to toggle
* @return {ve.ui.Context}
* @chainable
*/
ve.ui.Context.prototype.toggleMenu = function ( show ) {
show = show === undefined ? !this.choosing : !!show;
if ( show !== this.choosing ) {
this.choosing = show;
this.$element.toggleClass( 've-ui-context-choosing', show );
if ( show ) {
this.setupMenuItems();
} else {
this.teardownMenuItems();
}
}
return this;
};
/**
* Setup menu items.
*
* @protected
* @param {ve.ui.ContextItem[]} [previousItems] if a context is being refreshed, this will
* be the previously-open items for comparison
* @return {ve.ui.Context}
* @chainable
*/
ve.ui.Context.prototype.setupMenuItems = function ( previousItems ) {
const sources = this.getRelatedSources(),
items = [];
let i, len;
for ( i = 0, len = sources.length; i < len; i++ ) {
const source = sources[ i ];
if ( source.type === 'item' ) {
items.push( ve.ui.contextItemFactory.create(
sources[ i ].name, this, sources[ i ].model
) );
} else if ( source.type === 'tool' ) {
items.push( new ve.ui.ToolContextItem(
this, sources[ i ].model, ve.ui.toolFactory.lookup( sources[ i ].name )
) );
} else if ( source.type === 'persistent' ) {
items.push( ve.ui.contextItemFactory.create(
sources[ i ].name, this, sources[ i ].data
) );
}
}
items.sort( ( a, b ) => a.constructor.static.sortOrder - b.constructor.static.sortOrder );
this.addItems( items );
for ( i = 0, len = items.length; i < len; i++ ) {
const item = items[ i ];
const isRefreshing = previousItems && previousItems.some(
// We treat it as refreshing if they're exactly equal, or if either is null.
// Null here probably means we're dealing with a persistent fragment that's
// between text-selections currently.
( oldItem ) => oldItem.equals( item ) ||
oldItem.getFragment().isNull() ||
item.getFragment().isNull()
);
item.connect( this, { command: 'onContextItemCommand' } );
item.setup( isRefreshing );
}
return this;
};
/**
* Teardown menu items.
*
* @protected
* @return {ve.ui.Context}
* @chainable
*/
ve.ui.Context.prototype.teardownMenuItems = function () {
for ( let i = 0, len = this.items.length; i < len; i++ ) {
this.items[ i ].teardown();
}
this.clearItems();
return this;
};
/**
* Handle command events from context items
*/
ve.ui.Context.prototype.onContextItemCommand = function () {};
/**
* Toggle the visibility of the context.
*
* @param {boolean} [show] Show the context, omit to toggle
* @return {jQuery.Promise} Promise resolved when context is finished showing/hiding
* @fires ve.ui.Context#resize
*/
ve.ui.Context.prototype.toggle = function ( show ) {
show = show === undefined ? !this.visible : !!show;
if ( show !== this.visible ) {
this.visible = show;
this.$element.toggleClass( 've-ui-context-hidden', !this.visible );
}
this.emit( 'resize' );
return ve.createDeferred().resolve().promise();
};
/**
* Update the size and position of the context.
*
* @return {ve.ui.Context}
* @chainable
* @fires ve.ui.Context#resize
*/
ve.ui.Context.prototype.updateDimensions = function () {
// Override in subclass if context is positioned relative to content
this.emit( 'resize' );
return this;
};
/**
* Destroy the context, removing all DOM elements.
*
* @return {ve.ui.Context}
* @chainable
*/
ve.ui.Context.prototype.destroy = function () {
// Disconnect events
this.surface.getModel().disconnect( this );
this.$element.remove();
return this;
};
/**
* Get an object describing the amount of padding the context adds to the surface.
*
* For example the mobile context, which is fixed to the bottom of the viewport,
* will add bottom padding, whereas the floating desktop context will add none.
*
* @return {ve.ui.Surface.Padding|null} Padding object, or null
*/
ve.ui.Context.prototype.getSurfacePadding = function () {
return null;
};