wiki.techinc.nl/resources/lib/oojs/oojs.js
Timo Tijhof 34875dec1f ResourceLoader: Upgrade OOjs from 6.0.0 to 7.0.1
https://gerrit.wikimedia.org/g/oojs/core/+/v7.0.1/History.md

* [BREAKING CHANGE] Remove support for ES5 browsers such as IE11 (Timo Tijhof)
* Factory: Add support to register and create objects from ES6 classes (Timo Tijhof) T284935
* Factory: Add support to register by Class.key (Timo Tijhof) T96640
* core: Make OO.unique() faster by using ES6 Set methods (Bartosz Dziewoński)

Change-Id: I2697531e2963d784a2cb9390a65cc9e959b55a05
2023-05-11 00:52:46 +00:00

1695 lines
48 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*!
* OOjs v7.0.1
* https://www.mediawiki.org/wiki/OOjs
*
* Copyright 2011-2023 OOjs Team and other contributors.
* Released under the MIT license
* https://oojs.mit-license.org
*/
( function ( global ) {
'use strict';
/* exported slice, toString */
/**
* Namespace for all classes, static methods and static properties.
*
* @namespace OO
*/
var
// eslint-disable-next-line no-redeclare
OO = {},
// Optimisation: Local reference to methods from a global prototype
hasOwn = OO.hasOwnProperty,
slice = Array.prototype.slice,
// eslint-disable-next-line no-redeclare
toString = OO.toString;
/* Class Methods */
/**
* Utility to initialize a class for OO inheritance.
*
* Currently this just initializes an empty static object.
*
* @memberof OO
* @method initClass
* @param {Function} fn
*/
OO.initClass = function ( fn ) {
fn.static = fn.static || {};
};
/**
* Inherit from prototype to another using Object#create.
*
* Beware: This redefines the prototype, call before setting your prototypes.
*
* Beware: This redefines the prototype, can only be called once on a function.
* If called multiple times on the same function, the previous prototype is lost.
* This is how prototypal inheritance works, it can only be one straight chain
* (just like classical inheritance in PHP for example). If you need to work with
* multiple constructors consider storing an instance of the other constructor in a
* property instead, or perhaps use a mixin (see OO.mixinClass).
*
* function Thing() {}
* Thing.prototype.exists = function () {};
*
* function Person() {
* Person.super.apply( this, arguments );
* }
* OO.inheritClass( Person, Thing );
* Person.static.defaultEyeCount = 2;
* Person.prototype.walk = function () {};
*
* function Jumper() {
* Jumper.super.apply( this, arguments );
* }
* OO.inheritClass( Jumper, Person );
* Jumper.prototype.jump = function () {};
*
* Jumper.static.defaultEyeCount === 2;
* var x = new Jumper();
* x.jump();
* x.walk();
* x instanceof Thing && x instanceof Person && x instanceof Jumper;
*
* @memberof OO
* @method inheritClass
* @param {Function} targetFn
* @param {Function} originFn
* @throws {Error} If target already inherits from origin
*/
OO.inheritClass = function ( targetFn, originFn ) {
if ( !originFn ) {
throw new Error( 'inheritClass: Origin is not a function (actually ' + originFn + ')' );
}
if ( targetFn.prototype instanceof originFn ) {
throw new Error( 'inheritClass: Target already inherits from origin' );
}
var targetConstructor = targetFn.prototype.constructor;
// [DEPRECATED] Provide .parent as alias for code supporting older browsers which
// allows people to comply with their style guide.
targetFn.super = targetFn.parent = originFn;
targetFn.prototype = Object.create( originFn.prototype, {
// Restore constructor property of targetFn
constructor: {
value: targetConstructor,
enumerable: false,
writable: true,
configurable: true
}
} );
// Extend static properties - always initialize both sides
OO.initClass( originFn );
targetFn.static = Object.create( originFn.static );
};
/**
* Copy over *own* prototype properties of a mixin.
*
* The 'constructor' (whether implicit or explicit) is not copied over.
*
* This does not create inheritance to the origin. If you need inheritance,
* use OO.inheritClass instead.
*
* Beware: This can redefine a prototype property, call before setting your prototypes.
*
* Beware: Don't call before OO.inheritClass.
*
* function Foo() {}
* function Context() {}
*
* // Avoid repeating this code
* function ContextLazyLoad() {}
* ContextLazyLoad.prototype.getContext = function () {
* if ( !this.context ) {
* this.context = new Context();
* }
* return this.context;
* };
*
* function FooBar() {}
* OO.inheritClass( FooBar, Foo );
* OO.mixinClass( FooBar, ContextLazyLoad );
*
* @memberof OO
* @method mixinClass
* @param {Function} targetFn
* @param {Function} originFn
*/
OO.mixinClass = function ( targetFn, originFn ) {
if ( !originFn ) {
throw new Error( 'mixinClass: Origin is not a function (actually ' + originFn + ')' );
}
var key;
// Copy prototype properties
for ( key in originFn.prototype ) {
if ( key !== 'constructor' && hasOwn.call( originFn.prototype, key ) ) {
targetFn.prototype[ key ] = originFn.prototype[ key ];
}
}
// Copy static properties - always initialize both sides
OO.initClass( targetFn );
if ( originFn.static ) {
for ( key in originFn.static ) {
if ( hasOwn.call( originFn.static, key ) ) {
targetFn.static[ key ] = originFn.static[ key ];
}
}
} else {
OO.initClass( originFn );
}
};
/**
* Test whether one class is a subclass of another, without instantiating it.
*
* Every class is considered a subclass of Object and of itself.
*
* @memberof OO
* @method isSubClass
* @param {Function} testFn The class to be tested
* @param {Function} baseFn The base class
* @return {boolean} Whether testFn is a subclass of baseFn (or equal to it)
*/
OO.isSubclass = function ( testFn, baseFn ) {
return testFn === baseFn || testFn.prototype instanceof baseFn;
};
/* Object Methods */
/**
* Get a deeply nested property of an object using variadic arguments, protecting against
* undefined property errors.
*
* `quux = OO.getProp( obj, 'foo', 'bar', 'baz' );` is equivalent to `quux = obj.foo.bar.baz;`
* except that the former protects against JS errors if one of the intermediate properties
* is undefined. Instead of throwing an error, this function will return undefined in
* that case.
*
* @memberof OO
* @method getProp
* @param {Object} obj
* @param {...any} [keys]
* @return {Object|undefined} obj[arguments[1]][arguments[2]].... or undefined
*/
OO.getProp = function ( obj ) {
var retval = obj;
for ( var i = 1; i < arguments.length; i++ ) {
if ( retval === undefined || retval === null ) {
// Trying to access a property of undefined or null causes an error
return undefined;
}
retval = retval[ arguments[ i ] ];
}
return retval;
};
/**
* Set a deeply nested property of an object using variadic arguments, protecting against
* undefined property errors.
*
* `OO.setProp( obj, 'foo', 'bar', 'baz' );` is equivalent to `obj.foo.bar = baz;` except that
* the former protects against JS errors if one of the intermediate properties is
* undefined. Instead of throwing an error, undefined intermediate properties will be
* initialized to an empty object. If an intermediate property is not an object, or if obj itself
* is not an object, this function will silently abort.
*
* @memberof OO
* @method setProp
* @param {Object} obj
* @param {...any} [keys]
* @param {any} [value]
*/
OO.setProp = function ( obj ) {
if ( Object( obj ) !== obj || arguments.length < 2 ) {
return;
}
var prop = obj;
for ( var i = 1; i < arguments.length - 2; i++ ) {
if ( prop[ arguments[ i ] ] === undefined ) {
prop[ arguments[ i ] ] = {};
}
if ( Object( prop[ arguments[ i ] ] ) !== prop[ arguments[ i ] ] ) {
return;
}
prop = prop[ arguments[ i ] ];
}
prop[ arguments[ arguments.length - 2 ] ] = arguments[ arguments.length - 1 ];
};
/**
* Delete a deeply nested property of an object using variadic arguments, protecting against
* undefined property errors, and deleting resulting empty objects.
*
* @memberof OO
* @method deleteProp
* @param {Object} obj
* @param {...any} [keys]
*/
OO.deleteProp = function ( obj ) {
if ( Object( obj ) !== obj || arguments.length < 2 ) {
return;
}
var prop = obj;
var props = [ prop ];
var i = 1;
for ( ; i < arguments.length - 1; i++ ) {
if (
prop[ arguments[ i ] ] === undefined ||
Object( prop[ arguments[ i ] ] ) !== prop[ arguments[ i ] ]
) {
return;
}
prop = prop[ arguments[ i ] ];
props.push( prop );
}
delete prop[ arguments[ i ] ];
// Walk back through props removing any plain empty objects
while (
props.length > 1 &&
( prop = props.pop() ) &&
OO.isPlainObject( prop ) && !Object.keys( prop ).length
) {
delete props[ props.length - 1 ][ arguments[ props.length ] ];
}
};
/**
* Create a new object that is an instance of the same
* constructor as the input, inherits from the same object
* and contains the same own properties.
*
* This makes a shallow non-recursive copy of own properties.
* To create a recursive copy of plain objects, use #copy.
*
* var foo = new Person( mom, dad );
* foo.setAge( 21 );
* var foo2 = OO.cloneObject( foo );
* foo.setAge( 22 );
*
* // Then
* foo2 !== foo; // true
* foo2 instanceof Person; // true
* foo2.getAge(); // 21
* foo.getAge(); // 22
*
* @memberof OO
* @method cloneObject
* @param {Object} origin
* @return {Object} Clone of origin
*/
OO.cloneObject = function ( origin ) {
var r = Object.create( origin.constructor.prototype );
for ( var key in origin ) {
if ( hasOwn.call( origin, key ) ) {
r[ key ] = origin[ key ];
}
}
return r;
};
/**
* Get an array of all property values in an object.
*
* @memberof OO
* @method getObjectValues
* @param {Object} obj Object to get values from
* @return {Array} List of object values
*/
OO.getObjectValues = function ( obj ) {
if ( obj !== Object( obj ) ) {
throw new TypeError( 'Called on non-object' );
}
var values = [];
for ( var key in obj ) {
if ( hasOwn.call( obj, key ) ) {
values[ values.length ] = obj[ key ];
}
}
return values;
};
/**
* Use binary search to locate an element in a sorted array.
*
* searchFunc is given an element from the array. `searchFunc(elem)` must return a number
* above 0 if the element we're searching for is to the right of (has a higher index than) elem,
* below 0 if it is to the left of elem, or zero if it's equal to elem.
*
* To search for a specific value with a comparator function (a `function cmp(a,b)` that returns
* above 0 if `a > b`, below 0 if `a < b`, and 0 if `a == b`), you can use
* `searchFunc = cmp.bind( null, value )`.
*
* @memberof OO
* @method binarySearch
* @param {Array} arr Array to search in
* @param {Function} searchFunc Search function
* @param {boolean} [forInsertion] If not found, return index where val could be inserted
* @return {number|null} Index where val was found, or null if not found
*/
OO.binarySearch = function ( arr, searchFunc, forInsertion ) {
var left = 0;
var right = arr.length;
while ( left < right ) {
// Equivalent to Math.floor( ( left + right ) / 2 ) but much faster
// eslint-disable-next-line no-bitwise
var mid = ( left + right ) >> 1;
var cmpResult = searchFunc( arr[ mid ] );
if ( cmpResult < 0 ) {
right = mid;
} else if ( cmpResult > 0 ) {
left = mid + 1;
} else {
return mid;
}
}
return forInsertion ? right : null;
};
/**
* Recursively compare properties between two objects.
*
* A false result may be caused by property inequality or by properties in one object missing from
* the other. An asymmetrical test may also be performed, which checks only that properties in the
* first object are present in the second object, but not the inverse.
*
* If either a or b is null or undefined it will be treated as an empty object.
*
* @memberof OO
* @method compare
* @param {Object|undefined|null} a First object to compare
* @param {Object|undefined|null} b Second object to compare
* @param {boolean} [asymmetrical] Whether to check only that a's values are equal to b's
* (i.e. a is a subset of b)
* @return {boolean} If the objects contain the same values as each other
*/
OO.compare = function ( a, b, asymmetrical ) {
if ( a === b ) {
return true;
}
a = a || {};
b = b || {};
if ( typeof a.nodeType === 'number' && typeof a.isEqualNode === 'function' ) {
return a.isEqualNode( b );
}
for ( var k in a ) {
if ( !hasOwn.call( a, k ) || a[ k ] === undefined || a[ k ] === b[ k ] ) {
// Ignore undefined values, because there is no conceptual difference between
// a key that is absent and a key that is present but whose value is undefined.
continue;
}
var aValue = a[ k ];
var bValue = b[ k ];
var aType = typeof aValue;
var bType = typeof bValue;
if ( aType !== bType ||
(
( aType === 'string' || aType === 'number' || aType === 'boolean' ) &&
aValue !== bValue
) ||
( aValue === Object( aValue ) && !OO.compare( aValue, bValue, true ) ) ) {
return false;
}
}
// If the check is not asymmetrical, recursing with the arguments swapped will verify our result
return asymmetrical ? true : OO.compare( b, a, true );
};
/**
* Create a plain deep copy of any kind of object.
*
* Copies are deep, and will either be an object or an array depending on `source`.
*
* @memberof OO
* @method copy
* @param {Object} source Object to copy
* @param {Function} [leafCallback] Applied to leaf values after they are cloned but before they are
* added to the clone
* @param {Function} [nodeCallback] Applied to all values before they are cloned. If the
* nodeCallback returns a value other than undefined, the returned value is used instead of
* attempting to clone.
* @return {Object} Copy of source object
*/
OO.copy = function ( source, leafCallback, nodeCallback ) {
var destination;
if ( nodeCallback ) {
// Extensibility: check before attempting to clone source.
destination = nodeCallback( source );
if ( destination !== undefined ) {
return destination;
}
}
if ( Array.isArray( source ) ) {
// Array (fall through)
destination = new Array( source.length );
} else if ( source && typeof source.clone === 'function' ) {
// Duck type object with custom clone method
return leafCallback ? leafCallback( source.clone() ) : source.clone();
} else if ( source && typeof source.cloneNode === 'function' ) {
// DOM Node
return leafCallback ?
leafCallback( source.cloneNode( true ) ) :
source.cloneNode( true );
} else if ( OO.isPlainObject( source ) ) {
// Plain objects (fall through)
destination = {};
} else {
// Non-plain objects (incl. functions) and primitive values
return leafCallback ? leafCallback( source ) : source;
}
// source is an array or a plain object
for ( var key in source ) {
destination[ key ] = OO.copy( source[ key ], leafCallback, nodeCallback );
}
// This is an internal node, so we don't apply the leafCallback.
return destination;
};
/**
* Generate a hash of an object based on its name and data.
*
* Performance optimization: <http://jsperf.com/ve-gethash-201208#/toJson_fnReplacerIfAoForElse>
*
* To avoid two objects with the same values generating different hashes, we utilize the replacer
* argument of JSON.stringify and sort the object by key as it's being serialized. This may or may
* not be the fastest way to do this; we should investigate this further.
*
* Objects and arrays are hashed recursively. When hashing an object that has a .getHash()
* function, we call that function and use its return value rather than hashing the object
* ourselves. This allows classes to define custom hashing.
*
* @memberof OO
* @method getHash
* @param {Object} val Object to generate hash for
* @return {string} Hash of object
*/
OO.getHash = function ( val ) {
return JSON.stringify( val, OO.getHash.keySortReplacer );
};
/**
* Sort objects by key (helper function for OO.getHash).
*
* This is a callback passed into JSON.stringify.
*
* @memberof OO
* @method getHash_keySortReplacer
* @param {string} key Property name of value being replaced
* @param {any} val Property value to replace
* @return {any} Replacement value
*/
OO.getHash.keySortReplacer = function ( key, val ) {
if ( val && typeof val.getHashObject === 'function' ) {
// This object has its own custom hash function, use it
val = val.getHashObject();
}
if ( !Array.isArray( val ) && Object( val ) === val ) {
// Only normalize objects when the key-order is ambiguous
// (e.g. any object not an array).
var normalized = {};
var keys = Object.keys( val ).sort();
for ( var i = 0, len = keys.length; i < len; i++ ) {
normalized[ keys[ i ] ] = val[ keys[ i ] ];
}
return normalized;
} else {
// Primitive values and arrays get stable hashes
// by default. Lets those be stringified as-is.
return val;
}
};
/**
* Get the unique values of an array, removing duplicates.
*
* @memberof OO
* @method unique
* @param {Array} arr Array
* @return {Array} Unique values in array
*/
OO.unique = function ( arr ) {
return Array.from( new Set( arr ) );
};
/**
* Compute the union (duplicate-free merge) of a set of arrays.
*
* @memberof OO
* @method simpleArrayUnion
* @param {Array} a First array
* @param {...Array} rest Arrays to union
* @return {Array} Union of the arrays
*/
OO.simpleArrayUnion = function ( a, ...rest ) {
var set = new Set( a );
for ( var i = 0; i < rest.length; i++ ) {
var arr = rest[ i ];
for ( var j = 0; j < arr.length; j++ ) {
set.add( arr[ j ] );
}
}
return Array.from( set );
};
/**
* Combine arrays (intersection or difference).
*
* An intersection checks the item exists in 'b' while difference checks it doesn't.
*
* @private
* @param {Array} a First array
* @param {Array} b Second array
* @param {boolean} includeB Whether to items in 'b'
* @return {Array} Combination (intersection or difference) of arrays
*/
function simpleArrayCombine( a, b, includeB ) {
var set = new Set( b );
var result = [];
for ( var j = 0; j < a.length; j++ ) {
var isInB = set.has( a[ j ] );
if ( isInB === includeB ) {
result.push( a[ j ] );
}
}
return result;
}
/**
* Compute the intersection of two arrays (items in both arrays).
*
* @memberof OO
* @method simpleArrayIntersection
* @param {Array} a First array
* @param {Array} b Second array
* @return {Array} Intersection of arrays
*/
OO.simpleArrayIntersection = function ( a, b ) {
return simpleArrayCombine( a, b, true );
};
/**
* Compute the difference of two arrays (items in 'a' but not 'b').
*
* @memberof OO
* @method simpleArrayDifference
* @param {Array} a First array
* @param {Array} b Second array
* @return {Array} Intersection of arrays
*/
OO.simpleArrayDifference = function ( a, b ) {
return simpleArrayCombine( a, b, false );
};
/* eslint-disable-next-line no-redeclare */
/* global hasOwn, toString */
/**
* Assert whether a value is a plain object or not.
*
* @memberof OO
* @param {any} obj
* @return {boolean}
*/
OO.isPlainObject = function ( obj ) {
// Optimise for common case where internal [[Class]] property is not "Object"
if ( !obj || toString.call( obj ) !== '[object Object]' ) {
return false;
}
var proto = Object.getPrototypeOf( obj );
// Objects without prototype (e.g., `Object.create( null )`) are considered plain
if ( !proto ) {
return true;
}
// The 'isPrototypeOf' method is set on Object.prototype.
return hasOwn.call( proto, 'isPrototypeOf' );
};
/* global hasOwn, slice */
( function () {
/**
* @class
*/
OO.EventEmitter = function OoEventEmitter() {
// Properties
/**
* Storage of bound event handlers by event name.
*
* @private
* @property {Object} bindings
*/
this.bindings = {};
};
OO.initClass( OO.EventEmitter );
/* Private helper functions */
/**
* Validate a function or method call in a context
*
* For a method name, check that it names a function in the context object
*
* @private
* @param {Function|string} method Function or method name
* @param {any} context The context of the call
* @throws {Error} A method name is given but there is no context
* @throws {Error} In the context object, no property exists with the given name
* @throws {Error} In the context object, the named property is not a function
*/
function validateMethod( method, context ) {
// Validate method and context
if ( typeof method === 'string' ) {
// Validate method
if ( context === undefined || context === null ) {
throw new Error( 'Method name "' + method + '" has no context.' );
}
if ( typeof context[ method ] !== 'function' ) {
// Technically the property could be replaced by a function before
// call time. But this probably signals a typo.
throw new Error( 'Property "' + method + '" is not a function' );
}
} else if ( typeof method !== 'function' ) {
throw new Error( 'Invalid callback. Function or method name expected.' );
}
}
/**
* @private
* @param {OO.EventEmitter} eventEmitter Event emitter
* @param {string} event Event name
* @param {Object} binding
*/
function addBinding( eventEmitter, event, binding ) {
var bindings;
// Auto-initialize bindings list
if ( hasOwn.call( eventEmitter.bindings, event ) ) {
bindings = eventEmitter.bindings[ event ];
} else {
bindings = eventEmitter.bindings[ event ] = [];
}
// Add binding
bindings.push( binding );
}
/* Methods */
/**
* Add a listener to events of a specific event.
*
* The listener can be a function or the string name of a method; if the latter, then the
* name lookup happens at the time the listener is called.
*
* @param {string} event Type of event to listen to
* @param {Function|string} method Function or method name to call when event occurs
* @param {Array} [args] Arguments to pass to listener, will be prepended to emitted arguments
* @param {Object} [context=null] Context object for function or method call
* @return {OO.EventEmitter}
* @throws {Error} Listener argument is not a function or a valid method name
*/
OO.EventEmitter.prototype.on = function ( event, method, args, context ) {
validateMethod( method, context );
// Ensure consistent object shape (optimisation)
addBinding( this, event, {
method: method,
args: args,
context: ( arguments.length < 4 ) ? null : context,
once: false
} );
return this;
};
/**
* Add a one-time listener to a specific event.
*
* @param {string} event Type of event to listen to
* @param {Function} listener Listener to call when event occurs
* @return {OO.EventEmitter}
*/
OO.EventEmitter.prototype.once = function ( event, listener ) {
validateMethod( listener );
// Ensure consistent object shape (optimisation)
addBinding( this, event, {
method: listener,
args: undefined,
context: null,
once: true
} );
return this;
};
/**
* Remove a specific listener from a specific event.
*
* @param {string} event Type of event to remove listener from
* @param {Function|string} [method] Listener to remove. Must be in the same form as was passed
* to "on". Omit to remove all listeners.
* @param {Object} [context=null] Context object function or method call
* @return {OO.EventEmitter}
* @throws {Error} Listener argument is not a function or a valid method name
*/
OO.EventEmitter.prototype.off = function ( event, method, context ) {
if ( arguments.length === 1 ) {
// Remove all bindings for event
delete this.bindings[ event ];
return this;
}
validateMethod( method, context );
if ( !hasOwn.call( this.bindings, event ) || !this.bindings[ event ].length ) {
// No matching bindings
return this;
}
// Default to null context
if ( arguments.length < 3 ) {
context = null;
}
// Remove matching handlers
var bindings = this.bindings[ event ];
var i = bindings.length;
while ( i-- ) {
if ( bindings[ i ].method === method && bindings[ i ].context === context ) {
bindings.splice( i, 1 );
}
}
// Cleanup if now empty
if ( bindings.length === 0 ) {
delete this.bindings[ event ];
}
return this;
};
/**
* Emit an event.
*
* All listeners for the event will be called synchronously, in an
* unspecified order. If any listeners throw an exception, this won't
* disrupt the calls to the remaining listeners; however, the exception
* won't be thrown until the next tick.
*
* Listeners should avoid mutating the emitting object, as this is
* something of an anti-pattern which can easily result in
* hard-to-understand code with hidden side-effects and dependencies.
*
* @param {string} event Type of event
* @param {...any} [args] Arguments passed to the event handler
* @return {boolean} Whether the event was handled by at least one listener
*/
OO.EventEmitter.prototype.emit = function ( event ) {
if ( !hasOwn.call( this.bindings, event ) ) {
return false;
}
// Slicing ensures that we don't get tripped up by event
// handlers that add/remove bindings
var bindings = this.bindings[ event ].slice();
var args = slice.call( arguments, 1 );
for ( var i = 0; i < bindings.length; i++ ) {
var binding = bindings[ i ];
var method;
if ( typeof binding.method === 'string' ) {
// Lookup method by name (late binding)
method = binding.context[ binding.method ];
} else {
method = binding.method;
}
if ( binding.once ) {
// Unbind before calling, to avoid any nested triggers.
this.off( event, method );
}
try {
method.apply(
binding.context,
binding.args ? binding.args.concat( args ) : args
);
} catch ( e ) {
// If one listener has an unhandled error, don't have it
// take down the emitter. But rethrow asynchronously so
// debuggers can break with a full async stack trace.
setTimeout( ( function ( error ) {
throw error;
} ).bind( null, e ) );
}
}
return true;
};
/**
* Emit an event, propagating the first exception some listener throws
*
* All listeners for the event will be called synchronously, in an
* unspecified order. If any listener throws an exception, this won't
* disrupt the calls to the remaining listeners. The first exception
* thrown will be propagated back to the caller; any others won't be
* thrown until the next tick.
*
* Listeners should avoid mutating the emitting object, as this is
* something of an anti-pattern which can easily result in
* hard-to-understand code with hidden side-effects and dependencies.
*
* @param {string} event Type of event
* @param {...any} [args] Arguments passed to the event handler
* @return {boolean} Whether the event was handled by at least one listener
*/
OO.EventEmitter.prototype.emitThrow = function ( event ) {
// We tolerate code duplication with #emit, because the
// alternative is an extra level of indirection which will
// appear in very many stack traces.
if ( !hasOwn.call( this.bindings, event ) ) {
return false;
}
var firstError;
// Slicing ensures that we don't get tripped up by event
// handlers that add/remove bindings
var bindings = this.bindings[ event ].slice();
var args = slice.call( arguments, 1 );
for ( var i = 0; i < bindings.length; i++ ) {
var binding = bindings[ i ];
var method;
if ( typeof binding.method === 'string' ) {
// Lookup method by name (late binding)
method = binding.context[ binding.method ];
} else {
method = binding.method;
}
if ( binding.once ) {
// Unbind before calling, to avoid any nested triggers.
this.off( event, method );
}
try {
method.apply(
binding.context,
binding.args ? binding.args.concat( args ) : args
);
} catch ( e ) {
if ( firstError === undefined ) {
firstError = e;
} else {
// If one listener has an unhandled error, don't have it
// take down the emitter. But rethrow asynchronously so
// debuggers can break with a full async stack trace.
setTimeout( ( function ( error ) {
throw error;
} ).bind( null, e ) );
}
}
}
if ( firstError !== undefined ) {
throw firstError;
}
return true;
};
/**
* Connect event handlers to an object.
*
* @param {Object} context Object to call methods on when events occur
* @param {Object.<string,string>|Object.<string,Function>|Object.<string,Array>} methods
* List of event bindings keyed by event name containing either method names, functions or
* arrays containing method name or function followed by a list of arguments to be passed to
* callback before emitted arguments.
* @return {OO.EventEmitter}
*/
OO.EventEmitter.prototype.connect = function ( context, methods ) {
for ( var event in methods ) {
var method = methods[ event ];
var args;
// Allow providing additional args
if ( Array.isArray( method ) ) {
args = method.slice( 1 );
method = method[ 0 ];
} else {
args = [];
}
// Add binding
this.on( event, method, args, context );
}
return this;
};
/**
* Disconnect event handlers from an object.
*
* @param {Object} context Object to disconnect methods from
* @param {Object.<string,string>|Object.<string,Function>|Object.<string,Array>} [methods]
* List of event bindings keyed by event name. Values can be either method names, functions or
* arrays containing a method name.
* NOTE: To allow matching call sites with connect(), array values are allowed to contain the
* parameters as well, but only the method name is used to find bindings. It is discouraged to
* have multiple bindings for the same event to the same listener, but if used (and only the
* parameters vary), disconnecting one variation of (event name, event listener, parameters)
* will disconnect other variations as well.
* @return {OO.EventEmitter}
*/
OO.EventEmitter.prototype.disconnect = function ( context, methods ) {
var event;
if ( methods ) {
// Remove specific connections to the context
for ( event in methods ) {
var method = methods[ event ];
if ( Array.isArray( method ) ) {
method = method[ 0 ];
}
this.off( event, method, context );
}
} else {
// Remove all connections to the context
for ( event in this.bindings ) {
var bindings = this.bindings[ event ];
var i = bindings.length;
while ( i-- ) {
// bindings[i] may have been removed by the previous step's
// this.off so check it still exists
if ( bindings[ i ] && bindings[ i ].context === context ) {
this.off( event, bindings[ i ].method, context );
}
}
}
}
return this;
};
}() );
( function () {
/**
* Contain and manage a list of @{link OO.EventEmitter} items.
*
* Aggregates and manages their events collectively.
*
* This mixin must be used in a class that also mixes in @{link OO.EventEmitter}.
*
* @abstract
* @class
*/
OO.EmitterList = function OoEmitterList() {
this.items = [];
this.aggregateItemEvents = {};
};
OO.initClass( OO.EmitterList );
/* Events */
/**
* Item has been added.
*
* @event OO.EmitterList#add
* @param {OO.EventEmitter} item Added item
* @param {number} index Index items were added at
*/
/**
* Item has been moved to a new index.
*
* @event OO.EmitterList#move
* @param {OO.EventEmitter} item Moved item
* @param {number} index Index item was moved to
* @param {number} oldIndex The original index the item was in
*/
/**
* Item has been removed.
*
* @event OO.EmitterList#remove
* @param {OO.EventEmitter} item Removed item
* @param {number} index Index the item was removed from
*/
/**
* The list has been cleared of items.
*
* @event OO.EmitterList#clear
*/
/* Methods */
/**
* Normalize requested index to fit into the bounds of the given array.
*
* @private
* @static
* @param {Array} arr Given array
* @param {number|undefined} index Requested index
* @return {number} Normalized index
*/
function normalizeArrayIndex( arr, index ) {
return ( index === undefined || index < 0 || index >= arr.length ) ?
arr.length :
index;
}
/**
* Get all items.
*
* @return {OO.EventEmitter[]} Items in the list
*/
OO.EmitterList.prototype.getItems = function () {
return this.items.slice( 0 );
};
/**
* Get the index of a specific item.
*
* @param {OO.EventEmitter} item Requested item
* @return {number} Index of the item
*/
OO.EmitterList.prototype.getItemIndex = function ( item ) {
return this.items.indexOf( item );
};
/**
* Get number of items.
*
* @return {number} Number of items in the list
*/
OO.EmitterList.prototype.getItemCount = function () {
return this.items.length;
};
/**
* Check if a list contains no items.
*
* @return {boolean} Group is empty
*/
OO.EmitterList.prototype.isEmpty = function () {
return !this.items.length;
};
/**
* Aggregate the events emitted by the group.
*
* When events are aggregated, the group will listen to all contained items for the event,
* and then emit the event under a new name. The new event will contain an additional leading
* parameter containing the item that emitted the original event. Other arguments emitted from
* the original event are passed through.
*
* @param {Object.<string,string|null>} events An object keyed by the name of the event that
* should be aggregated (e.g., click) and the value of the new name to use
* (e.g., groupClick). A `null` value will remove aggregated events.
* @throws {Error} If aggregation already exists
*/
OO.EmitterList.prototype.aggregate = function ( events ) {
var i, item;
for ( var itemEvent in events ) {
var groupEvent = events[ itemEvent ];
// Remove existing aggregated event
if ( Object.prototype.hasOwnProperty.call( this.aggregateItemEvents, itemEvent ) ) {
// Don't allow duplicate aggregations
if ( groupEvent ) {
throw new Error( 'Duplicate item event aggregation for ' + itemEvent );
}
// Remove event aggregation from existing items
for ( i = 0; i < this.items.length; i++ ) {
item = this.items[ i ];
if ( item.connect && item.disconnect ) {
var remove = {};
remove[ itemEvent ] = [ 'emit', this.aggregateItemEvents[ itemEvent ], item ];
item.disconnect( this, remove );
}
}
// Prevent future items from aggregating event
delete this.aggregateItemEvents[ itemEvent ];
}
// Add new aggregate event
if ( groupEvent ) {
// Make future items aggregate event
this.aggregateItemEvents[ itemEvent ] = groupEvent;
// Add event aggregation to existing items
for ( i = 0; i < this.items.length; i++ ) {
item = this.items[ i ];
if ( item.connect && item.disconnect ) {
var add = {};
add[ itemEvent ] = [ 'emit', groupEvent, item ];
item.connect( this, add );
}
}
}
}
};
/**
* Add items to the list.
*
* @param {OO.EventEmitter|OO.EventEmitter[]} items Item to add or
* an array of items to add
* @param {number} [index] Index to add items at. If no index is
* given, or if the index that is given is invalid, the item
* will be added at the end of the list.
* @return {OO.EmitterList}
* @fires OO.EmitterList#add
* @fires OO.EmitterList#move
*/
OO.EmitterList.prototype.addItems = function ( items, index ) {
if ( !Array.isArray( items ) ) {
items = [ items ];
}
if ( items.length === 0 ) {
return this;
}
index = normalizeArrayIndex( this.items, index );
for ( var i = 0; i < items.length; i++ ) {
var oldIndex = this.items.indexOf( items[ i ] );
if ( oldIndex !== -1 ) {
// Move item to new index
index = this.moveItem( items[ i ], index );
this.emit( 'move', items[ i ], index, oldIndex );
} else {
// insert item at index
index = this.insertItem( items[ i ], index );
this.emit( 'add', items[ i ], index );
}
index++;
}
return this;
};
/**
* Move an item from its current position to a new index.
*
* The item is expected to exist in the list. If it doesn't,
* the method will throw an exception.
*
* @private
* @param {OO.EventEmitter} item Items to add
* @param {number} newIndex Index to move the item to
* @return {number} The index the item was moved to
* @throws {Error} If item is not in the list
*/
OO.EmitterList.prototype.moveItem = function ( item, newIndex ) {
var existingIndex = this.items.indexOf( item );
if ( existingIndex === -1 ) {
throw new Error( 'Item cannot be moved, because it is not in the list.' );
}
newIndex = normalizeArrayIndex( this.items, newIndex );
// Remove the item from the current index
this.items.splice( existingIndex, 1 );
// If necessary, adjust new index after removal
if ( existingIndex < newIndex ) {
newIndex--;
}
// Move the item to the new index
this.items.splice( newIndex, 0, item );
return newIndex;
};
/**
* Utility method to insert an item into the list, and
* connect it to aggregate events.
*
* Don't call this directly unless you know what you're doing.
* Use #addItems instead.
*
* This method can be extended in child classes to produce
* different behavior when an item is inserted. For example,
* inserted items may also be attached to the DOM or may
* interact with some other nodes in certain ways. Extending
* this method is allowed, but if overridden, the aggregation
* of events must be preserved, or behavior of emitted events
* will be broken.
*
* If you are extending this method, please make sure the
* parent method is called.
*
* @protected
* @param {OO.EventEmitter|Object} item Item to add
* @param {number} index Index to add items at
* @return {number} The index the item was added at
*/
OO.EmitterList.prototype.insertItem = function ( item, index ) {
// Throw an error if null or item is not an object.
if ( item === null || typeof item !== 'object' ) {
throw new Error( 'Expected object, but item is ' + typeof item );
}
// Add the item to event aggregation
if ( item.connect && item.disconnect ) {
var events = {};
for ( var event in this.aggregateItemEvents ) {
events[ event ] = [ 'emit', this.aggregateItemEvents[ event ], item ];
}
item.connect( this, events );
}
index = normalizeArrayIndex( this.items, index );
// Insert into items array
this.items.splice( index, 0, item );
return index;
};
/**
* Remove items.
*
* @param {OO.EventEmitter|OO.EventEmitter[]} items Items to remove
* @return {OO.EmitterList}
* @fires OO.EmitterList#remove
*/
OO.EmitterList.prototype.removeItems = function ( items ) {
if ( !Array.isArray( items ) ) {
items = [ items ];
}
if ( items.length === 0 ) {
return this;
}
// Remove specific items
for ( var i = 0; i < items.length; i++ ) {
var item = items[ i ];
var index = this.items.indexOf( item );
if ( index !== -1 ) {
if ( item.connect && item.disconnect ) {
// Disconnect all listeners from the item
item.disconnect( this );
}
this.items.splice( index, 1 );
this.emit( 'remove', item, index );
}
}
return this;
};
/**
* Clear all items.
*
* @return {OO.EmitterList}
* @fires OO.EmitterList#clear
*/
OO.EmitterList.prototype.clearItems = function () {
var cleared = this.items.splice( 0, this.items.length );
// Disconnect all items
for ( var i = 0; i < cleared.length; i++ ) {
var item = cleared[ i ];
if ( item.connect && item.disconnect ) {
item.disconnect( this );
}
}
this.emit( 'clear' );
return this;
};
}() );
/**
* Manage a sorted list of OO.EmitterList objects.
*
* The sort order is based on a callback that compares two items. The return value of
* callback( a, b ) must be less than zero if a < b, greater than zero if a > b, and zero
* if a is equal to b. The callback should only return zero if the two objects are
* considered equal.
*
* When an item changes in a way that could affect their sorting behavior, it must
* emit the {@link OO.SortedEmitterList#event:itemSortChange itemSortChange} event.
* This will cause it to be re-sorted automatically.
*
* This mixin must be used in a class that also mixes in {@link OO.EventEmitter}.
*
* @abstract
* @class
* @mixes OO.EmitterList
* @param {Function} sortingCallback Callback that compares two items.
*/
OO.SortedEmitterList = function OoSortedEmitterList( sortingCallback ) {
// Mixin constructors
OO.EmitterList.call( this );
this.sortingCallback = sortingCallback;
// Listen to sortChange event and make sure
// we re-sort the changed item when that happens
this.aggregate( {
sortChange: 'itemSortChange'
} );
this.connect( this, {
itemSortChange: 'onItemSortChange'
} );
};
OO.mixinClass( OO.SortedEmitterList, OO.EmitterList );
/* Events */
/**
* An item has changed properties that affect its sort positioning
* inside the list.
*
* @private
* @event OO.SortedEmitterList#itemSortChange
*/
/* Methods */
/**
* Handle a case where an item changed a property that relates
* to its sorted order.
*
* @param {OO.EventEmitter} item Item in the list
*/
OO.SortedEmitterList.prototype.onItemSortChange = function ( item ) {
// Remove the item
this.removeItems( item );
// Re-add the item so it is in the correct place
this.addItems( item );
};
/**
* Change the sorting callback for this sorted list.
*
* The callback receives two items. The return value of callback(a, b) must be less than zero
* if a < b, greater than zero if a > b, and zero if a is equal to b.
*
* @param {Function} sortingCallback Sorting callback
*/
OO.SortedEmitterList.prototype.setSortingCallback = function ( sortingCallback ) {
var items = this.getItems();
this.sortingCallback = sortingCallback;
// Empty the list
this.clearItems();
// Re-add the items in the new order
this.addItems( items );
};
/**
* Add items to the sorted list.
*
* @param {OO.EventEmitter|OO.EventEmitter[]} items Item to add or
* an array of items to add
* @return {OO.SortedEmitterList}
*/
OO.SortedEmitterList.prototype.addItems = function ( items ) {
if ( !Array.isArray( items ) ) {
items = [ items ];
}
if ( items.length === 0 ) {
return this;
}
for ( var i = 0; i < items.length; i++ ) {
// Find insertion index
var insertionIndex = this.findInsertionIndex( items[ i ] );
// Check if the item exists using the sorting callback
// and remove it first if it exists
if (
// First make sure the insertion index is not at the end
// of the list (which means it does not point to any actual
// items)
insertionIndex <= this.items.length &&
// Make sure there actually is an item in this index
this.items[ insertionIndex ] &&
// The callback returns 0 if the items are equal
this.sortingCallback( this.items[ insertionIndex ], items[ i ] ) === 0
) {
// Remove the existing item
this.removeItems( this.items[ insertionIndex ] );
}
// Insert item at the insertion index
var index = this.insertItem( items[ i ], insertionIndex );
this.emit( 'add', items[ i ], index );
}
return this;
};
/**
* Find the index a given item should be inserted at. If the item is already
* in the list, this will return the index where the item currently is.
*
* @param {OO.EventEmitter} item Items to insert
* @return {number} The index the item should be inserted at
*/
OO.SortedEmitterList.prototype.findInsertionIndex = function ( item ) {
var list = this;
return OO.binarySearch(
this.items,
// Fake a this.sortingCallback.bind( null, item ) call here
// otherwise this doesn't pass tests in phantomJS
function ( otherItem ) {
return list.sortingCallback( item, otherItem );
},
true
);
};
/* global hasOwn */
/**
* A map interface for associating arbitrary data with a symbolic name. Used in
* place of a plain object to provide additional {@link OO.Registry#register registration}
* or {@link OO.Registry#lookup lookup} functionality.
*
* See <https://www.mediawiki.org/wiki/OOjs/Registries_and_factories>.
*
* @class
* @mixes OO.EventEmitter
*/
OO.Registry = function OoRegistry() {
// Mixin constructors
OO.EventEmitter.call( this );
// Properties
this.registry = {};
};
/* Inheritance */
OO.mixinClass( OO.Registry, OO.EventEmitter );
/* Events */
/**
* @event OO.Registry#register
* @param {string} name
* @param {any} data
*/
/**
* @event OO.Registry#unregister
* @param {string} name
* @param {any} data Data removed from registry
*/
/* Methods */
/**
* Associate one or more symbolic names with some data.
*
* Any existing entry with the same name will be overridden.
*
* @param {string|string[]} name Symbolic name or list of symbolic names
* @param {any} data Data to associate with symbolic name
* @fires OO.Registry#register
* @throws {Error} Name argument must be a string or array
*/
OO.Registry.prototype.register = function ( name, data ) {
if ( typeof name === 'string' ) {
this.registry[ name ] = data;
this.emit( 'register', name, data );
} else if ( Array.isArray( name ) ) {
for ( var i = 0, len = name.length; i < len; i++ ) {
this.register( name[ i ], data );
}
} else {
throw new Error( 'Name must be a string or array, cannot be a ' + typeof name );
}
};
/**
* Remove one or more symbolic names from the registry.
*
* @param {string|string[]} name Symbolic name or list of symbolic names
* @fires OO.Registry#unregister
* @throws {Error} Name argument must be a string or array
*/
OO.Registry.prototype.unregister = function ( name ) {
if ( typeof name === 'string' ) {
var data = this.lookup( name );
if ( data !== undefined ) {
delete this.registry[ name ];
this.emit( 'unregister', name, data );
}
} else if ( Array.isArray( name ) ) {
for ( var i = 0, len = name.length; i < len; i++ ) {
this.unregister( name[ i ] );
}
} else {
throw new Error( 'Name must be a string or array, cannot be a ' + typeof name );
}
};
/**
* Get data for a given symbolic name.
*
* @param {string} name Symbolic name
* @return {any|undefined} Data associated with symbolic name
*/
OO.Registry.prototype.lookup = function ( name ) {
if ( hasOwn.call( this.registry, name ) ) {
return this.registry[ name ];
}
};
/**
* @class
* @extends OO.Registry
*/
OO.Factory = function OoFactory() {
// Parent constructor
OO.Factory.super.call( this );
};
/* Inheritance */
OO.inheritClass( OO.Factory, OO.Registry );
/* Methods */
/**
* Register a class with the factory.
*
* function MyClass() {};
* OO.initClass( MyClass );
* MyClass.key = 'hello';
*
* // Register class with the factory
* factory.register( MyClass );
*
* // Instantiate a class based on its registered key (also known as a "symbolic name")
* factory.create( 'hello' );
*
* @param {Function} constructor Class to use when creating an object
* @param {string} [key] The key for #create().
* This parameter is usually omitted in favour of letting the class declare
* its own key, through `MyClass.key`.
* For backwards-compatiblity with OOjs 6.0 (2021) and older, it can also be declared
* via `MyClass.static.name`.
* @throws {Error} If a parameter is invalid
*/
OO.Factory.prototype.register = function ( constructor, key ) {
if ( typeof constructor !== 'function' ) {
throw new Error( 'constructor must be a function, got ' + typeof constructor );
}
if ( arguments.length <= 1 ) {
key = constructor.key || ( constructor.static && constructor.static.name );
}
if ( typeof key !== 'string' || key === '' ) {
throw new Error( 'key must be a non-empty string' );
}
// Parent method
OO.Factory.super.prototype.register.call( this, key, constructor );
};
/**
* Unregister a class from the factory.
*
* @param {string|Function} key Constructor function or key to unregister
* @throws {Error} If a parameter is invalid
*/
OO.Factory.prototype.unregister = function ( key ) {
if ( typeof key === 'function' ) {
key = key.key || ( key.static && key.static.name );
}
if ( typeof key !== 'string' || key === '' ) {
throw new Error( 'key must be a non-empty string' );
}
// Parent method
OO.Factory.super.prototype.unregister.call( this, key );
};
/**
* Create an object based on a key.
*
* The key is used to look up the class to use, with any subsequent arguments passed to the
* constructor function.
*
* @param {string} key Class key
* @param {...any} [args] Arguments to pass to the constructor
* @return {Object} The new object
* @throws {Error} Unknown key
*/
OO.Factory.prototype.create = function ( key, ...args ) {
var constructor = this.lookup( key );
if ( !constructor ) {
throw new Error( 'No class registered by that key: ' + key );
}
return new constructor( ...args );
};
/* eslint-env node */
/* istanbul ignore next */
if ( typeof module !== 'undefined' && module.exports ) {
module.exports = OO;
} else {
global.OO = OO;
}
}( this ) );