summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
Diffstat (limited to 'Flow/modules/engine/misc')
-rw-r--r--Flow/modules/engine/misc/flow-api.js411
-rw-r--r--Flow/modules/engine/misc/flow-baseconvert.js70
-rw-r--r--Flow/modules/engine/misc/flow-eventlog.js45
-rw-r--r--Flow/modules/engine/misc/flow-handlebars.js581
-rw-r--r--Flow/modules/engine/misc/jquery.conditionalScroll.js51
-rw-r--r--Flow/modules/engine/misc/jquery.findWithParent.js45
-rw-r--r--Flow/modules/engine/misc/mw-ui.enhance.js451
-rw-r--r--Flow/modules/engine/misc/mw-ui.modal.js410
8 files changed, 2064 insertions, 0 deletions
diff --git a/Flow/modules/engine/misc/flow-api.js b/Flow/modules/engine/misc/flow-api.js
new file mode 100644
index 00000000..229a2556
--- /dev/null
+++ b/Flow/modules/engine/misc/flow-api.js
@@ -0,0 +1,411 @@
+( function ( mw, $ ) {
+ mw.flow = mw.flow || {}; // create mw.flow globally
+
+ var apiTransformMap = {
+ // Map of API submodule name, block name, and prefix name
+ 'moderate-post': [ 'topic_', 'mp' ],
+ 'new-topic': [ 'topiclist_', 'nt' ],
+ 'edit-header': [ 'header_', 'eh' ],
+ 'edit-post': [ 'topic_', 'ep' ],
+ 'reply': [ 'topic_', 'rep' ],
+ 'moderate-topic': [ 'topic_', 'mt' ],
+ 'edit-title': [ 'topic_', 'et' ],
+ 'lock-topic': [ 'topic_', 'cot' ],
+ 'view-topiclist': [ 'topiclist_', 'vtl' ],
+ 'view-post': [ 'topic', 'vp' ],
+ 'view-topic': [ 'topic', 'vt' ],
+ 'view-header': [ 'header_', 'vh' ],
+ 'view-topic-summary': [ 'topicsummary_', 'vts' ],
+ 'edit-topic-summary': [ 'topicsummary_', 'ets' ]
+ };
+
+ /**
+ * Handles Flow API calls. Each FlowComponent has its own instance of FlowApi as component.Api,
+ * so that it can store a workflowId and pageName permanently for simplicity.
+ * @param {String} [workflowId]
+ * @param {String} [pageName]
+ * @returns {FlowApi}
+ * @constructor
+ */
+ function FlowApi( storageEngine, workflowId, pageName ) {
+ this.StorageEngine = storageEngine;
+ this.workflowId = workflowId;
+ this.pageName = pageName;
+
+ /**
+ * Makes the actual API call and returns
+ * @param {Object|String} [params] May be a JSON object string
+ * @param {String} [pageName]
+ * @returns {$.Deferred}
+ */
+ function flowApiCall( params, method ) {
+ var mwApi, tokenType,
+ $deferred = $.Deferred();
+
+ // IE8 caches POST under some conditions, prevent that here.
+ // IE8 is most likely the only browser we support that doesn't
+ // have addEventListener, and anything else that gets caught
+ // up isn't that bad off.
+ if ( !document.addEventListener ) {
+ mwApi = new mw.Api( { ajax: { cache: false } } );
+ } else {
+ mwApi = new mw.Api();
+ }
+
+ params = params || {};
+ // Server is using page instead of title
+ // @todo this should not be necessary
+ params.page = params.page || this.pageName || mw.config.get( 'wgPageName' );
+ method = method ? method.toUpperCase() : 'GET';
+
+ if ( !params.action ) {
+ mw.flow.debug( '[FlowApi] apiCall error: missing action string', arguments );
+ return $deferred.rejectWith({ error: 'Invalid action' });
+ }
+ if ( !params.page ) {
+ mw.flow.debug( '[FlowApi] apiCall error: missing page string', [ mw.config.get( 'wgPageName' ) ], arguments );
+ return $deferred.rejectWith({ error: 'Invalid title' });
+ }
+
+ if ( method === 'POST' ) {
+ if ( params._internal && params._internal.tokenType ) {
+ tokenType = params._internal.tokenType;
+ } else {
+ tokenType = 'edit';
+ }
+
+ delete params._internal;
+
+ return mwApi.postWithToken( tokenType, params );
+ } else if ( method !== 'GET' ) {
+ return $deferred.rejectWith({ error: 'Unknown submission method: ' + method });
+ } else {
+ return mwApi.get( params );
+ }
+ }
+
+ this.apiCall = flowApiCall;
+ }
+
+ /** @type {Storer} */
+ FlowApi.prototype.StorageEngine = null;
+ /** @type {String} */
+ FlowApi.prototype.pageName = null;
+ /** @type {String} */
+ FlowApi.prototype.workflowId = null;
+ /** @type {String} */
+ FlowApi.prototype.defaultSubmodule = null;
+
+ /**
+ * Sets the fixed pageName for this API instance.
+ * @param {String} pageName
+ */
+ function flowApiSetPageName( pageName ) {
+ this.pageName = pageName;
+ }
+
+ FlowApi.prototype.setPageName = flowApiSetPageName;
+
+ /**
+ * Sets the fixed workflowId for this API instance.
+ * @param {String} workflowId
+ */
+ function flowApiSetWorkflowId( workflowId ) {
+ this.workflowId = workflowId;
+ }
+
+ FlowApi.prototype.setWorkflowId = flowApiSetWorkflowId;
+
+ /**
+ * Transforms URL request parameters into API params
+ * @todo fix it server-side so we don't need this client-side
+ * @param {Object} queryMap
+ * @returns {Object}
+ */
+ function flowApiTransformMap( queryMap ) {
+ var key,
+ map = apiTransformMap[queryMap.submodule];
+ if ( !map ) {
+ return queryMap;
+ }
+ for ( key in queryMap ) {
+ if ( queryMap.hasOwnProperty( key ) ) {
+ if ( key.indexOf( map[0] ) === 0 ) {
+ queryMap[ key.replace( map[0], map[1] ) ] = queryMap[ key ];
+ delete queryMap[ key ];
+ }
+ if ( key.indexOf( 'flow_' ) === 0 ) {
+ queryMap[ key.replace( 'flow_', map[1] ) ] = queryMap[ key ];
+ delete queryMap[ key ];
+ }
+ }
+ }
+
+ return queryMap;
+ }
+
+ /**
+ * Sets the fixed defaultSubmodule for this API instance.
+ * @param {String} defaultSubmodule
+ */
+ function flowApiSetDefaultSubmodule( defaultSubmodule ) {
+ this.defaultSubmodule = defaultSubmodule;
+ }
+
+ FlowApi.prototype.setDefaultSubmodule = flowApiSetDefaultSubmodule;
+
+ /**
+ * Overrides (values of) queryMap with a provided override, which can come
+ * in the form of an object (which the queryMap will be extended with) or as
+ * a function (whose return value will replace queryMap)
+ *
+ * @param {Object} [queryMap]
+ * @param {Function|Object} [override]
+ * @returns {Object}
+ */
+ function flowOverrideQueryMap( queryMap, override ) {
+ if ( override ) {
+ switch ( typeof override ) {
+ // If given an override object, extend our queryMap with it
+ case 'object':
+ $.extend( queryMap, override );
+ break;
+ // If given an override function, call it and make it return the new queryMap
+ case 'function':
+ queryMap = override( queryMap );
+ break;
+ }
+ }
+
+ return queryMap;
+ }
+
+ /**
+ * With a url (a://b.c/d?e=f&g#h) will return an object of key-value pairs ({e:'f', g:''}).
+ * @param {String|Element} url
+ * @param {Object} [queryMap]
+ * @param {Array<(Function|Object)>} [overrides]
+ * @returns {Object}
+ */
+ function flowApiGetQueryMap( url, queryMap, overrides ) {
+ var uri,
+ queryKey,
+ queryValue,
+ i = 0,
+ $node, $form, formData;
+
+ queryMap = queryMap || {};
+ overrides = overrides || [];
+
+ // If URL is an Element...
+ if ( typeof url !== 'string' ) {
+ $node = $( url );
+
+ // Get the data-flow-api-action override from the node itself
+ queryMap.submodule = $node.data( 'flow-api-action' );
+
+ if ( $node.is( 'form, input, button, textarea, select, option' ) ) {
+ // We are processing a form
+ $form = $node.closest( 'form' );
+ formData = $form.serializeArray();
+
+ // Get the data-flow-api-action override from the form
+ queryMap.submodule = queryMap.submodule || $form.data( 'flow-api-action' );
+
+ // Build the queryMap manually from a serialized form
+ for ( i = 0; i < formData.length; i++ ) {
+ // skip wpEditToken, its handle independently
+ if ( formData[ i ].name !== 'wpEditToken' ) {
+ queryMap[ formData[ i ].name ] = formData[ i ].value;
+ }
+ }
+
+ // Add the given button to the queryMap as well
+ if ( $node.is( 'button, input' ) && $node.prop( 'name' ) ) {
+ queryMap[ $node.prop( 'name' ) ] = $node.val();
+ }
+
+ // Now process the form action as the URL
+ url = $form.attr( 'action' );
+ } else if ( $node.is( 'a' ) ) {
+ // It's an anchor, process the href as the URL
+ url = $node.prop( 'href' );
+ } else {
+ // Somebody set up us the bomb
+ url = '';
+ }
+ }
+
+ // Parse the URL query params
+ uri = new mw.Uri( url );
+
+ for ( queryKey in uri.query ) {
+ queryValue = uri.query[queryKey];
+ if ( queryKey === 'action' ) {
+ // Submodule is the action
+ queryKey = 'submodule';
+ }
+ if ( queryKey === 'title' ) {
+ // Server is using page
+ queryKey = 'page';
+ }
+
+ // Only add this to the query map if it didn't already exist, eg. in a form input
+ if ( !queryMap[ queryKey ] ) {
+ queryMap[ queryKey ] = queryValue;
+ }
+ }
+
+ // Use the default submodule if no action in URL
+ queryMap.submodule = queryMap.submodule || this.defaultSubmodule;
+ // Default action is flow
+ queryMap.action = queryMap.action || 'flow';
+
+ // Override the automatically generated queryMap
+ for ( i = 0; i < overrides.length; i++ ) {
+ queryMap = flowOverrideQueryMap( queryMap, overrides[i] );
+ }
+
+ // Use the API map to transform this data if necessary, eg.
+ queryMap = flowApiTransformMap( queryMap );
+
+ return queryMap;
+ }
+
+ FlowApi.prototype.getQueryMap = flowApiGetQueryMap;
+
+ /**
+ * Using a given form, parses its action, serializes the data, and sends it as GET or POST depending on form method.
+ * With button, its name=value is serialized in. If button is an Event, it will attempt to find the clicked button.
+ * Additional params can be set with data-flow-api-params on both the clicked button or the form.
+ * @param {Event|Element} [button]
+ * @param {Array<(Function|Object)>} [overrides]
+ * @return {$.Deferred}
+ */
+ function flowApiRequestFromForm( button, overrides ) {
+ var $deferred = $.Deferred(),
+ $button = $( button ),
+ method = $button.closest( 'form' ).attr( 'method' ) || 'GET',
+ queryMap;
+
+ // Parse the form action to get the rest of the queryMap
+ if ( !( queryMap = this.getQueryMap( button, null, overrides ) ) ) {
+ return $deferred.rejectWith( { error: 'Invalid form action' } );
+ }
+
+ if ( !( queryMap.action ) ) {
+ return $deferred.rejectWith( { error: 'Unknown action for form' } );
+ }
+
+ // Cancel any old form request, and also trigger a new one
+ return this.abortOldRequestFromNode( $button, queryMap, method );
+ }
+
+ FlowApi.prototype.requestFromForm = flowApiRequestFromForm;
+
+ /**
+ * Using a given anchor, parses its URL and sends it as a GET (default) or POST depending on data-flow-api-method.
+ * Additional params can be set with data-flow-api-params.
+ * @param {Element} anchor
+ * @param {Array<(Function|Object)>} [overrides]
+ * @return {$.Deferred}
+ */
+ function flowApiRequestFromAnchor( anchor, overrides ) {
+ var $anchor = $( anchor ),
+ $deferred = $.Deferred(),
+ queryMap,
+ method = $anchor.data( 'flow-api-method' ) || 'GET';
+
+ // Build the query map from this anchor's HREF
+ if ( !( queryMap = this.getQueryMap( anchor.href, null, overrides ) ) ) {
+ mw.flow.debug( '[FlowApi] requestFromAnchor error: invalid href', arguments );
+ return $deferred.rejectWith( { error: 'Invalid href' } );
+ }
+
+ // Abort any old requests, and have it issue a new one via GET or POST
+ return this.abortOldRequestFromNode( $anchor, queryMap, method );
+ }
+
+ FlowApi.prototype.requestFromAnchor = flowApiRequestFromAnchor;
+
+ /**
+ * Automatically calls requestFromAnchor or requestFromForm depending on the type of node given.
+ * @param {Element} node
+ * @param {Array<(Function|Object)>} [overrides]
+ * @return {$.Deferred|bool}
+ */
+ function flowApiRequestFromNode( node, overrides ) {
+ var $node = $( node );
+
+ if ( $node.is( 'a' ) ) {
+ return this.requestFromAnchor.apply( this, arguments );
+ } else if ( $node.is( 'form, input, button, textarea, select, option' ) ) {
+ return this.requestFromForm.apply( this, arguments );
+ } else {
+ return false;
+ }
+ }
+
+ FlowApi.prototype.requestFromNode = flowApiRequestFromNode;
+
+ /**
+ * Handles aborting an old in-flight API request.
+ * If startNewMethod is given, this method also STARTS a new API call and stores it for later abortion if needed.
+ * @param {jQuery|Element} $node
+ * @param {Object} [queryMap]
+ * @param {String} [startNewMethod] If given: starts, stores, and returns a new API call
+ * @param {Array<(Function|Object)>} [overrides]
+ * @return {undefined|$.Deferred}
+ */
+ function flowApiAbortOldRequestFromNode( $node, queryMap, startNewMethod, overrides ) {
+ $node = $( $node );
+
+ if ( !queryMap ) {
+ // Get the queryMap automatically if one wasn't given
+ if ( !( queryMap = this.getQueryMap( $node, null, overrides ) ) ) {
+ mw.flow.debug( '[FlowApi] abortOldRequestFromNode failed to find a queryMap', arguments );
+ return;
+ }
+ }
+
+ // If this anchor already has a request in flight, abort it
+ var str = 'flow-api-query-temp-' + queryMap.action + '-' + queryMap.submodule,
+ prevApiCall = $node.data( str ),
+ newApiCall;
+
+ // If a previous API call was found, let's abort it
+ if ( prevApiCall ) {
+ $node.removeData( str );
+
+ if ( prevApiCall.abort ) {
+ prevApiCall.abort();
+ }
+
+ mw.flow.debug( '[FlowApi] apiCall abort request in flight: ' + str, arguments );
+ }
+
+ // If a method was given, we want to also issue a new API request now
+ if ( startNewMethod ) {
+ // Make a new request with this info
+ newApiCall = this.apiCall( queryMap, startNewMethod );
+
+ // Store this request on the node if it needs to be aborted
+ $node.data(
+ 'flow-api-query-temp-' + queryMap.action + '-' + queryMap.submodule,
+ newApiCall
+ );
+
+ // Remove the request on success
+ newApiCall.always( function () {
+ $node.removeData( 'flow-api-query-temp-' + queryMap.action + '-' + queryMap.submodule );
+ } );
+
+ return newApiCall;
+ }
+ }
+
+ FlowApi.prototype.abortOldRequestFromNode = flowApiAbortOldRequestFromNode;
+
+ // Export
+ mw.flow.FlowApi = FlowApi;
+}( mw, jQuery ) );
diff --git a/Flow/modules/engine/misc/flow-baseconvert.js b/Flow/modules/engine/misc/flow-baseconvert.js
new file mode 100644
index 00000000..ae6e755f
--- /dev/null
+++ b/Flow/modules/engine/misc/flow-baseconvert.js
@@ -0,0 +1,70 @@
+( function ( mw, $ ) {
+ mw.flow = mw.flow || {}; // create mw.flow globally
+
+ // Direct translation of wfBaseConvert. Can't be done with parseInt and
+ // Integer.toString because javascript uses doubles for math, giving only
+ // 53 bits of precision.
+ mw.flow.baseConvert = function ( input, sourceBase, destBase ) {
+ var regex = new RegExp( "^[" + '0123456789abcdefghijklmnopqrstuvwxyz'.substr( 0, sourceBase ) + "]+$" ),
+ baseChars = {
+ '10': 'a', '11': 'b', '12': 'c', '13': 'd', '14': 'e', '15': 'f',
+ '16': 'g', '17': 'h', '18': 'i', '19': 'j', '20': 'k', '21': 'l',
+ '22': 'm', '23': 'n', '24': 'o', '25': 'p', '26': 'q', '27': 'r',
+ '28': 's', '29': 't', '30': 'u', '31': 'v', '32': 'w', '33': 'x',
+ '34': 'y', '35': 'z',
+
+ '0': 0, '1': 1, '2': 2, '3': 3, '4': 4, '5': 5,
+ '6': 6, '7': 7, '8': 8, '9': 9, 'a': 10, 'b': 11,
+ 'c': 12, 'd': 13, 'e': 14, 'f': 15, 'g': 16, 'h': 17,
+ 'i': 18, 'j': 19, 'k': 20, 'l': 21, 'm': 22, 'n': 23,
+ 'o': 24, 'p': 25, 'q': 26, 'r': 27, 's': 28, 't': 29,
+ 'u': 30, 'v': 31, 'w': 32, 'x': 33, 'y': 34, 'z': 35
+ },
+ inDigits = [],
+ result = [],
+ i, work, workDigits;
+
+ input = String( input );
+ if ( sourceBase < 2 ||
+ sourceBase > 36 ||
+ destBase < 2 ||
+ destBase > 36 ||
+ sourceBase !== parseInt( sourceBase, 10 ) ||
+ destBase !== parseInt( destBase, 10 ) ||
+ !regex.test( input )
+ ) {
+ return false;
+ }
+
+ for ( i in input ) {
+ inDigits.push( baseChars[input[i]] );
+ }
+
+ // Iterate over the input, modulo-ing out an output digit
+ // at a time until input is gone.
+ while( inDigits.length ) {
+ work = 0;
+ workDigits = [];
+
+ // Long division...
+ for ( i in inDigits ) {
+ work *= sourceBase;
+ work += inDigits[i];
+
+ if ( workDigits.length || work >= destBase ) {
+ workDigits.push( parseInt( work / destBase, 10 ) );
+ }
+ work %= destBase;
+ }
+
+ // All that division leaves us with a remainder,
+ // which is conveniently our next output digit
+ result.push( baseChars[work] );
+
+ // And we continue
+ inDigits = workDigits;
+ }
+
+ return result.reverse().join("");
+ };
+}( mw, jQuery ) );
diff --git a/Flow/modules/engine/misc/flow-eventlog.js b/Flow/modules/engine/misc/flow-eventlog.js
new file mode 100644
index 00000000..aafa7515
--- /dev/null
+++ b/Flow/modules/engine/misc/flow-eventlog.js
@@ -0,0 +1,45 @@
+( function ( mw, $ ) {
+ /**
+ * @param {String} schemaName Canonical schema name.
+ * @param {Object} [eventInstance] Shared event instance data.
+ * @returns {FlowEventLog}
+ * @constructor
+ */
+ function FlowEventLog( schemaName, eventInstance ) {
+ this.schemaName = schemaName;
+ this.eventInstance = eventInstance || {};
+
+ /**
+ * @param {object} eventInstance Additional event instance data for this
+ * particular event.
+ * @returns {$.Deferred}
+ */
+ function logEvent( eventInstance ) {
+ // Ensure eventLog & this schema exist, or return a stub deferred
+ if ( !mw.eventLog || !mw.eventLog.schemas[this.schemaName] ) {
+ return $.Deferred().promise();
+ }
+
+ return mw.eventLog.logEvent(
+ this.schemaName,
+ $.extend( this.eventInstance, eventInstance )
+ );
+ }
+ this.logEvent = logEvent;
+ }
+
+ var FlowEventLogRegistry = {
+ funnels: {},
+
+ /**
+ * Generates a unique id.
+ *
+ * @returns {string}
+ */
+ generateFunnelId: mw.user.generateRandomSessionId
+ };
+
+ // Export
+ mw.flow.FlowEventLog = FlowEventLog;
+ mw.flow.FlowEventLogRegistry = FlowEventLogRegistry;
+}( mw, jQuery ) );
diff --git a/Flow/modules/engine/misc/flow-handlebars.js b/Flow/modules/engine/misc/flow-handlebars.js
new file mode 100644
index 00000000..b3f21925
--- /dev/null
+++ b/Flow/modules/engine/misc/flow-handlebars.js
@@ -0,0 +1,581 @@
+/*!
+ * Implements a Handlebars layer for FlowBoard.TemplateEngine
+ */
+
+( function ( mw, $, moment, Handlebars ) {
+ mw.flow = mw.flow || {}; // create mw.flow globally
+
+ var _tplcache = {},
+ _timestamp = {
+ list: [],
+ currentIndex: 0
+ };
+
+
+ /**
+ * Instantiates a FlowHandlebars instance for TemplateEngine.
+ * @param {Object} FlowStorageEngine
+ * @returns {FlowHandlebars}
+ * @constructor
+ */
+ function FlowHandlebars( FlowStorageEngine ) {
+ return this;
+ }
+
+ mw.flow.FlowHandlebars = FlowHandlebars;
+
+ /**
+ * Returns a given template function. If template is missing, the template function is noop with mw.flow.debug.
+ * @param {String|Function} templateName
+ * @returns {Function}
+ */
+ FlowHandlebars.prototype.getTemplate = function ( templateName ) {
+ // If a template is already being passed, use it
+ if ( typeof templateName === 'function' ) {
+ return templateName;
+ }
+
+ if ( _tplcache[ templateName ] ) {
+ // Return cached compiled template
+ return _tplcache[ templateName ];
+ }
+
+ _tplcache[ templateName ] = mw.template.get( 'ext.flow.templating', 'handlebars/' + templateName + '.handlebars' );
+ if ( _tplcache[ templateName ] ) {
+ // Try to get this template
+ _tplcache[ templateName ] = _tplcache[ templateName ].render;
+ }
+
+ return _tplcache[ templateName ] || function () { mw.flow.debug( '[Handlebars] Missing template', arguments ); };
+ };
+
+ /**
+ * Processes a given template and returns the HTML generated by it.
+ * @param {String} templateName
+ * @param {*} [args]
+ * @returns {String}
+ */
+ FlowHandlebars.prototype.processTemplate = function ( templateName, args ) {
+ return FlowHandlebars.prototype.getTemplate( templateName )( args );
+ };
+
+ /**
+ * Runs processTemplate inside, but returns a DocumentFragment instead of an HTML string.
+ * This should be used for runtime parsing of a template, as it triggers processProgressiveEnhancement on the
+ * fragment, which allows progressiveEnhancement blocks to be instantiated.
+ * @param {String} templateName
+ * @param {*} [args]
+ * @returns {DocumentFragment}
+ */
+ FlowHandlebars.prototype.processTemplateGetFragment = function ( templateName, args ) {
+ var fragment = document.createDocumentFragment(),
+ div = document.createElement( 'div' );
+
+ div.innerHTML = FlowHandlebars.prototype.processTemplate( templateName, args );
+
+ FlowHandlebars.prototype.processProgressiveEnhancement( div );
+
+ while ( div.firstChild ) {
+ fragment.appendChild( div.firstChild );
+ }
+
+ div = null;
+
+ return fragment;
+ };
+
+ /**
+ * A method to call helper functions from outside templates. This removes Handlebars.SafeString wrappers.
+ * @param {String} helperName
+ * @param {...*} [args]
+ * @return mixed
+ */
+ FlowHandlebars.prototype.callHelper = function ( helperName, args ) {
+ var result = this[ helperName ].apply( this, Array.prototype.slice.call( arguments, 1 ) );
+ if ( result && result.string ) {
+ return result.string;
+ }
+ return result;
+ };
+
+ /**
+ * Finds scripts of x-handlebars-template-progressive-enhancement type, compiles its innerHTML as a Handlebars
+ * template, and then replaces the whole script tag with it. This is used to "progressively enhance" a page with
+ * elements that are only necessary with JavaScript. On a non-JS page, these elements are never rendered at all.
+ * @param {Element|jQuery} target
+ * @todo Lacks args, lacks functionality, full support. (see also FlowHandlebars.prototype.progressiveEnhancement)
+ */
+ FlowHandlebars.prototype.processProgressiveEnhancement = function ( target ) {
+ $( target ).find( 'script' ).addBack( 'script' ).filter( '[type="text/x-handlebars-template-progressive-enhancement"]' ).each( function () {
+ var $this = $( this ),
+ data = $this.data(),
+ target = $.trim( data.target ),
+ $target = $this,
+ content, $prevTarg, $nextTarg;
+
+ // Find new target, if not the script tag itself
+ if ( target ) {
+ $target = $this.findWithParent( target );
+
+ if ( !$target.length ) {
+ mw.flow.debug( '[processProgressiveEnhancement] Failed to find target', target, arguments );
+ return;
+ }
+ }
+
+ // Replace the nested flowprogressivescript tag with a real script tag for recursive progressiveEnhancement
+ content = this.innerHTML.replace( /<\/flowprogressivescript>/g, '</script>' );
+
+ // Inject the content
+ switch ( data.type ) {
+ case 'content':
+ // Insert
+ $target.empty().append( content );
+ // Get all new nodes
+ $target = $target.children();
+ break;
+
+ case 'insert':
+ // Store sibling before adding new content
+ $prevTarg = $target.prev();
+ // Insert
+ $target.before( content );
+ // Get all new nodes
+ $target = $target.prevUntil( $prevTarg );
+ break;
+
+ case 'replace':
+ /* falls through */
+ default:
+ // Store siblings before adding new content
+ $prevTarg = $target.prev();
+ $nextTarg = $target.next();
+ // Insert
+ $target.replaceWith( content );
+ // Get all new nodes
+ $target = $prevTarg.nextUntil( $nextTarg );
+ }
+
+ // $target now contains all the new elements inserted; let's recursively do progressiveEnhancement if needed
+ FlowHandlebars.prototype.processProgressiveEnhancement( $target );
+
+ // Remove script tag
+ $this.remove();
+ } );
+ };
+
+ /**
+ * Parameters could be Message::rawParam (in PHP) object, which will
+ * translate into a { raw: "string" } object in JS.
+ * @todo: this does not exactly match the behavior in PHP yet (no parse,
+ * no escape), but at least it won't print an [Object object] param.
+ *
+ * @param {Array} parameters
+ * @return {Array}
+ */
+ function flowNormalizeL10nParameters( parameters ) {
+ return $.map( parameters, function ( arg ) {
+ return arg ? ( arg.raw || arg.plaintext || arg ) : '';
+ } );
+ }
+
+ /**
+ * Calls flowMessages to get localized message strings.
+ * @todo use mw.message
+ * @example {{l10n "reply_count" 12}}
+ * @param {String} str
+ * @param {...*} [args]
+ * @param {Object} [options]
+ * @returns {String}
+ */
+ FlowHandlebars.prototype.l10n = function ( str /*, args..., options */ ) {
+ // chop off str and options leaving just args
+ var args = flowNormalizeL10nParameters( Array.prototype.slice.call( arguments, 1, -1 ) );
+
+ return mw.message( str ).params( args ).text();
+ };
+
+ /**
+ * HTML-safe version of l10n.
+ * @returns {String|Handlebars.SafeString}
+ */
+ FlowHandlebars.prototype.l10nParse = function ( str /*, args..., options */ ) {
+ var args = flowNormalizeL10nParameters( Array.prototype.slice.call( arguments, 1, -1 ) );
+
+ return FlowHandlebars.prototype.html(
+ mw.message( str ).params( args ).parse()
+ );
+ };
+
+ /**
+ * Parses the timestamp out of a base-36 UUID, and calls timestamp with it.
+ * @example {{uuidTimestamp id "flow-message-x-"}}
+ * @param {String} uuid id
+ * @param {bool} [timeAgoOnly]
+ * @returns {String}
+ */
+ FlowHandlebars.prototype.uuidTimestamp = function ( uuid, timeAgoOnly ) {
+ var timestamp = mw.flow.uuidToTime( uuid );
+
+ return FlowHandlebars.prototype.timestamp( timestamp, timeAgoOnly );
+ };
+
+ /**
+ * Generates markup for an "nnn sssss ago" and date/time string.
+ * @example {{timestamp start_time}}
+ * @param {int} timestamp milliseconds
+ * @returns {String}
+ */
+ FlowHandlebars.prototype.timestamp = function ( timestamp ) {
+ if ( isNaN( timestamp ) ) {
+ mw.flow.debug( '[timestamp] Invalid arguments', arguments );
+ return;
+ }
+
+ var guid,
+ formatter = moment( timestamp );
+
+ // Generate a GUID for this element to find it later
+ guid = ( Math.random() + 1 ).toString( 36 ).substring( 2 );
+
+ // Store this in the timestamps auto-updater array
+ _timestamp.list.push( { guid: guid, timestamp: timestamp, failcount: 0 } );
+
+ // Render the timestamp template
+ return FlowHandlebars.prototype.html(
+ FlowHandlebars.prototype.processTemplate(
+ 'timestamp',
+ {
+ time_iso: timestamp,
+ time_ago: formatter.fromNow(),
+ time_readable: formatter.format( 'LLL' ),
+ guid: guid
+ }
+ )
+ );
+ };
+
+ /**
+ * Updates one flow-timestamp node at a time every 100ms, until finishing, and then sleeps 5s.
+ * Nodes do not get updated again until they have changed.
+ * @todo Perhaps only update elements within the viewport?
+ * @todo Maybe updating elements every few seconds is distracting? Think about this.
+ */
+ function timestampAutoUpdate() {
+ var arrayItem, $ago, failed, secondsAgo, text, formatter,
+ currentTime = +new Date() / 1000;
+
+ // Only update elements that need updating (eg. only update minutes every 60s)
+ do {
+ arrayItem = _timestamp.list[ _timestamp.list._currentIndex ];
+
+ if ( !arrayItem || !arrayItem.nextUpdate || currentTime >= arrayItem.nextUpdate ) {
+ break;
+ }
+
+ // Find the next array item
+ _timestamp.list._currentIndex++;
+ } while ( arrayItem );
+
+ if ( !arrayItem ) {
+ // Finished array; reset loop
+ _timestamp.list._currentIndex = 0;
+
+ // Run again in 5s
+ setTimeout( timestampAutoUpdate, 5000 );
+ return;
+ }
+
+ $ago = $( document.getElementById( arrayItem.guid ) );
+ failed = true;
+ secondsAgo = currentTime - ( arrayItem.timestamp / 1000 );
+
+ if ( $ago && $ago.length ) {
+ formatter = moment( arrayItem.timestamp );
+ text = formatter.fromNow();
+
+ // Returned a valid "n ago" string?
+ if ( text ) {
+ // Reset the failcount
+ failed = arrayItem.failcount = 0;
+
+ // Set the next update time
+ arrayItem.nextUpdate = currentTime + ( secondsAgo > 604800 ? 604800 - currentTime % 604800 : ( secondsAgo > 86400 ? 86400 - currentTime % 86400 : ( secondsAgo > 3600 ? 3600 - currentTime % 3600 : ( secondsAgo > 60 ? 60 - currentTime % 60 : 1 ) ) ) );
+
+ // Only touch the DOM if the text has actually changed
+ if ( $ago.text() !== text ) {
+ $ago.text( text );
+ }
+ }
+ }
+
+ if ( failed && ++arrayItem.failcount > 9 ) {
+ // Remove this array item if we failed this 10 times in a row
+ _timestamp.list.splice( _timestamp.list._currentIndex, 1 );
+ } else {
+ // Go to next item
+ _timestamp.list._currentIndex++;
+ }
+
+ // Run every 100ms until we update all nodes
+ setTimeout( timestampAutoUpdate, 100 );
+ }
+
+ $( document ).ready( timestampAutoUpdate );
+
+ /**
+ * Do not escape HTML string. Used as a Handlebars helper.
+ * @example {{html "<div/>"}}
+ * @param {String} string
+ * @returns {String|Handlebars.SafeString}
+ */
+ FlowHandlebars.prototype.html = function ( string ) {
+ return new Handlebars.SafeString( string );
+ };
+
+ /**
+ *
+ * @example {{block this}}
+ * @param {Object} context
+ * @param {Object} options
+ * @returns {String}
+ */
+ FlowHandlebars.prototype.workflowBlock = function ( context, options ) {
+ return FlowHandlebars.prototype.html( FlowHandlebars.prototype.processTemplate(
+ 'flow_block_' + context.type + ( context['block-action-template'] || '' ),
+ context
+ ) );
+ };
+
+ /**
+ * @example {{post ../../../../rootBlock this}}
+ * @param {Object} context
+ * @param {Object} revision
+ * @param {Object} options
+ * @returns {String}
+ */
+ FlowHandlebars.prototype.postBlock = function ( context, revision, options ) {
+ return FlowHandlebars.prototype.html( FlowHandlebars.prototype.processTemplate(
+ 'flow_post',
+ {
+ revision: revision,
+ rootBlock: context
+ }
+ ) );
+ };
+
+ /**
+ * @example {{#each topics}}{{#eachPost this}}{{content}}{{/eachPost}}{{/each}}
+ * @param {String} context
+ * @param {String} postId
+ * @param {Object} options
+ * @returns {String}
+ * @todo support multiple postIds in an array
+ */
+ FlowHandlebars.prototype.eachPost = function ( context, postId, options ) {
+ var revId = ( context.posts && context.posts[postId] && context.posts[postId][0] ),
+ revision = ( context.revisions && context.revisions[revId] ) || { content: null };
+
+ if ( revision.content === null ) {
+ mw.flow.debug( '[eachPost] Failed to find revision object', arguments );
+ }
+
+ return options.fn ? options.fn( revision ) : revision;
+ };
+
+ /**
+ * The progressiveEnhancement helper essentially does one of replace things:
+ * 1. type="replace": (target="selector") Replaces target entirely with rendered template.
+ * 2. type="content": (target="selector") Replaces target's content with rendered template.
+ * 3. type="insert": Inserts rendered template at the helper's location.
+ *
+ * This template is used to simplify server-side and client-side rendering. Client-side renders a
+ * progressiveEnhancement helper instantly, in the post-process stage. The server-side renders only a script tag
+ * with a template inside. This script tag is found ondomready, and then the post-processing occurs at that time.
+ *
+ * Option keys:
+ * * type=String (replace, content, insert)
+ * * target=String (jQuery selector; needed for replace and content -- defaults to self)
+ * * id=String
+ * @example {{#progressiveEnhancement type="content"}}{{> ok}}{{/progressiveEnhancement}}
+ * @param {Object} options
+ * @return {String}
+ * @todo Implement support for full functionality, perhaps revisit the implementation.
+ */
+ FlowHandlebars.prototype.progressiveEnhancement = function ( options ) {
+ var hash = options.hash,
+ // Replace nested script tag with placeholder tag for
+ // recursive progresiveEnhancement
+ inner = options.fn( this ).replace( /<\/script>/g, '</flowprogressivescript>' );
+
+ if ( !hash.type ) {
+ hash.type = 'insert';
+ }
+
+ return FlowHandlebars.prototype.html(
+ '<scr' + 'ipt' +
+ ' type="text/x-handlebars-template-progressive-enhancement"' +
+ ' data-type="' + hash.type + '"' +
+ ( hash.target ? ' data-target="' + hash.target + '"' : '' ) +
+ ( hash.id ? ' id="' + hash.id + '"' : '' ) +
+ '>' +
+ inner +
+ '</scr' + 'ipt>'
+ );
+ };
+
+ /**
+ * Runs a callback when user is anonymous
+ * @param array $options which must contain fn and inverse key mapping to functions.
+ *
+ * @return mixed result of callback
+ */
+ FlowHandlebars.prototype.ifAnonymous = function( options ) {
+ if ( mw.user.isAnon() ) {
+ return options.fn( this );
+ }
+ return options.inverse( this );
+ };
+
+ /**
+ * Adds returnto parameter pointing to given Title to an existing URL
+ * @param string $title
+ *
+ * @return string modified url
+ */
+ FlowHandlebars.prototype.linkWithReturnTo = function( title ) {
+ return mw.util.getUrl( title, {
+ returntoquery: encodeURIComponent( window.location.search ),
+ returnto: mw.config.get( 'wgPageName' )
+ } );
+ };
+
+ /**
+ * Accepts the contentType and content properties returned from the api
+ * for individual revisions and ensures that content is included in the
+ * final html page in an XSS safe maner.
+ *
+ * It is expected that all content with contentType of html has been
+ * processed by parsoid and is safe for direct output into the document.
+ *
+ * Usage:
+ * {{escapeContent revision.contentType revision.content}}
+ *
+ * @param {string} contentType
+ * @param {string} content
+ * @return {string}
+ */
+ FlowHandlebars.prototype.escapeContent = function ( contentType, content ) {
+ if ( contentType === 'html' ) {
+ return FlowHandlebars.prototype.html( content );
+ }
+ return content;
+ };
+
+ /**
+ * Renders a tooltip node.
+ * @example {{#tooltip positionClass="up" contextClass="progressive" extraClass="flow-my-tooltip"}}what{{/tooltip}}
+ * @param {Object} options
+ * @returns {String}
+ */
+ FlowHandlebars.prototype.tooltip = function ( options ) {
+ var params = options.hash;
+
+ return FlowHandlebars.prototype.html( FlowHandlebars.prototype.processTemplate(
+ 'flow_tooltip',
+ {
+ positionClass: params.positionClass ? 'flow-ui-tooltip-' + params.positionClass : null,
+ contextClass: params.contextClass ? 'mw-ui-' + params.contextClass : null,
+ extraClass: params.extraClass,
+ blockClass: params.isBlock ? 'flow-ui-tooltip-block' : null,
+ content: options.fn( this )
+ }
+ ) );
+ };
+
+ /**
+ * Return url for putting post into the specified moderation state. If the user
+ * cannot put the post into the specified state a blank string is returned.
+ *
+ * @param {Object}
+ * @param {string}
+ * @return {string}
+ */
+ FlowHandlebars.prototype.moderationAction = function ( actions, moderationState ) {
+ return actions[moderationState] ? actions[moderationState].url : '';
+ };
+
+ /**
+ * Concatenate all unnamed handlebars arguments
+ *
+ * @return {string}
+ */
+ FlowHandlebars.prototype.concat = function () {
+ // handlebars puts an options argument at the end of
+ // user supplied parameters, pop that off
+ return Array.prototype.slice.call( arguments, 0, -1 ).join( '' );
+ };
+
+ /**
+ * Renders block if condition is true
+ *
+ * @param {string} value
+ * @param {string} operator supported values: 'or'
+ * @param {string} value
+ */
+ FlowHandlebars.prototype.ifCond = function ( value, operator, value2, options ) {
+ if ( operator === 'or' ) {
+ return value || value2 ? options.fn( this ) : options.inverse( this );
+ }
+ if ( operator === '===' ) {
+ return value === value2 ? options.fn( this ) : options.inverse( this );
+ }
+ if ( operator === '!==' ) {
+ return value !== value2 ? options.fn( this ) : options.inverse( this );
+ }
+ return '';
+ };
+
+ /**
+ * Outputs debugging information
+ *
+ * For development use only
+ */
+ FlowHandlebars.prototype.debug = function () {
+ mw.flow.debug( '[Handlebars] debug', arguments );
+ };
+
+ // Load partials
+ $.each( mw.templates.values, function( moduleName ) {
+ $.each( this, function( name ) {
+ // remove extension
+ var partialMatch, partialName;
+
+ partialMatch = name.match( /handlebars\/(.*)\.partial\.handlebars$/ );
+ if ( partialMatch ) {
+ partialName = partialMatch[1];
+ Handlebars.partials[ partialName ] = mw.template.get( moduleName, name ).render;
+ }
+ } );
+ } );
+
+ // Register helpers
+ Handlebars.registerHelper( 'l10n', FlowHandlebars.prototype.l10n );
+ Handlebars.registerHelper( 'l10nParse', FlowHandlebars.prototype.l10nParse );
+ Handlebars.registerHelper( 'uuidTimestamp', FlowHandlebars.prototype.uuidTimestamp );
+ Handlebars.registerHelper( 'timestamp', FlowHandlebars.prototype.timestamp );
+ Handlebars.registerHelper( 'html', FlowHandlebars.prototype.html );
+ Handlebars.registerHelper( 'block', FlowHandlebars.prototype.workflowBlock );
+ Handlebars.registerHelper( 'post', FlowHandlebars.prototype.postBlock );
+ Handlebars.registerHelper( 'eachPost', FlowHandlebars.prototype.eachPost );
+ Handlebars.registerHelper( 'progressiveEnhancement', FlowHandlebars.prototype.progressiveEnhancement );
+ Handlebars.registerHelper( 'ifAnonymous', FlowHandlebars.prototype.ifAnonymous );
+ Handlebars.registerHelper( 'linkWithReturnTo', FlowHandlebars.prototype.linkWithReturnTo );
+ Handlebars.registerHelper( 'escapeContent', FlowHandlebars.prototype.escapeContent );
+ Handlebars.registerHelper( 'tooltip', FlowHandlebars.prototype.tooltip );
+ Handlebars.registerHelper( 'moderationAction', FlowHandlebars.prototype.moderationAction );
+ Handlebars.registerHelper( 'concat', FlowHandlebars.prototype.concat );
+ Handlebars.registerHelper( 'ifCond', FlowHandlebars.prototype.ifCond );
+ Handlebars.registerHelper( 'debug', FlowHandlebars.prototype.debug );
+
+}( mediaWiki, jQuery, moment, Handlebars ) );
diff --git a/Flow/modules/engine/misc/jquery.conditionalScroll.js b/Flow/modules/engine/misc/jquery.conditionalScroll.js
new file mode 100644
index 00000000..27d4791f
--- /dev/null
+++ b/Flow/modules/engine/misc/jquery.conditionalScroll.js
@@ -0,0 +1,51 @@
+( function ( $ ) {
+ /**
+ * Scrolls the viewport to fit $el into view only if necessary. Scenarios:
+ * 1. If el starts above viewport, scrolls to put top of el at top of viewport.
+ * 2. If el ends below viewport and fits into viewport, scrolls to put bottom of el at bottom of viewport.
+ * 3. If el ends below viewport but is taller than the viewport, scrolls to put top of el at top of viewport.
+ * @param {string|int} [speed='fast']
+ */
+ $.fn.conditionalScrollIntoView = function ( speed ) {
+ speed = speed !== undefined ? speed : 'fast';
+
+ // We queue this to happen on the element, because we need to wait for it to finish performing its own
+ // animations (eg. it might be doing a slideDown), even though THIS actual animation occurs on body.
+ this.queue( function () {
+ var $this = $( this ),
+ viewportY = $( window ).scrollTop(),
+ viewportHeight = $( window ).height(),
+ elOffset = $this.offset(),
+ elHeight = $this.outerHeight(),
+ scrollTo = -1;
+
+ if ( elOffset.top < viewportY ) {
+ // Element starts above viewport; put el top at top
+ scrollTo = elOffset.top;
+ } else if ( elOffset.top + elHeight > viewportY + viewportHeight ) {
+ // Element ends below viewport
+ if ( elHeight > viewportHeight ) {
+ // Too tall to fit into viewport; put el top at top
+ scrollTo = elOffset.top;
+ } else {
+ // Fits into viewport; put el bottom at bottom
+ scrollTo = elOffset.top + elHeight - viewportHeight;
+ }
+ } // else: element is already in viewport.
+
+ if ( scrollTo > -1 ) {
+ // Scroll the viewport to display this element
+ $( 'html, body' ).animate( { scrollTop: scrollTo }, speed, function () {
+ // Fire off the next fx queue on the main element when we finish scrolling the window
+ $this.dequeue();
+ } );
+ } else {
+ // If we don't have to scroll, continue to the next fx queue item immediately
+ $this.dequeue();
+ }
+ } );
+
+ // Do nothing
+ return this;
+ };
+}( jQuery ) );
diff --git a/Flow/modules/engine/misc/jquery.findWithParent.js b/Flow/modules/engine/misc/jquery.findWithParent.js
new file mode 100644
index 00000000..c2c60a5f
--- /dev/null
+++ b/Flow/modules/engine/misc/jquery.findWithParent.js
@@ -0,0 +1,45 @@
+( function ( $ ) {
+ /**
+ * Gives support to find parent elements using .closest with less-than selector syntax.
+ * @example $.findWithParent( $div, "< html div < body" ); // find closest parent of $div "html", find child "div" of it, find closest parent "body" of that, return "body"
+ * @example $( '#foo' ).findWithParent( '.bar < .baz' ); // find child ".bar" of "#foo", return closest parent ".baz" from there
+ * @param {jQuery|Element|String} $context
+ * @param {String} selector
+ * @returns {jQuery}
+ */
+ function jQueryFindWithParent( $context, selector ) {
+ var matches;
+
+ $context = $( $context );
+ selector = $.trim( selector );
+
+ while ( selector && ( matches = selector.match(/(.*?(?:^|[>\s+~]))(<\s*[^>\s+~]+)(.*?)$/) ) ) {
+ if ( $.trim( matches[ 1 ] ) ) {
+ $context = $context.find( matches[ 1 ] );
+ }
+ if ( $.trim( matches[ 2 ] ) ) {
+ $context = $context.closest( matches[ 2 ].substr( 1 ) );
+ }
+ selector = $.trim( matches[ 3 ] );
+ }
+
+ if ( selector ) {
+ $context = $context.find( selector );
+ }
+
+ return $context;
+ }
+
+ $.findWithParent = jQueryFindWithParent;
+ $.fn.findWithParent = function ( selector ) {
+ var selectors = selector.split( ',' ),
+ $elements = $(),
+ self = this;
+
+ $.each( selectors, function( i, selector ) {
+ $elements = $elements.add( jQueryFindWithParent( self, selector ) );
+ } );
+
+ return $elements;
+ };
+}( jQuery ) );
diff --git a/Flow/modules/engine/misc/mw-ui.enhance.js b/Flow/modules/engine/misc/mw-ui.enhance.js
new file mode 100644
index 00000000..3da4e70d
--- /dev/null
+++ b/Flow/modules/engine/misc/mw-ui.enhance.js
@@ -0,0 +1,451 @@
+/*!
+ * Enhances mediawiki-ui style elements with JavaScript.
+ */
+
+( function ( mw, $ ) {
+ /*
+ * Reduce eye-wandering due to adjacent colorful buttons
+ * This will make unhovered and unfocused sibling buttons become faded and blurred
+ * Usage: Buttons must be in a form, or in a parent with mw-ui-button-container, or they must be siblings
+ */
+ $( document ).ready( function () {
+ function onMwUiButtonFocus( event ) {
+ var $el, $form, $siblings;
+
+ if ( event.target.className.indexOf( 'mw-ui-button' ) === -1 ) {
+ // Not a button event
+ return;
+ }
+
+ $el = $( event.target );
+
+ if ( event.type !== 'keyup' || $el.is( ':focus' ) ) {
+ // Reset style
+ $el.removeClass( 'mw-ui-button-althover' );
+
+ $form = $el.closest( 'form, .mw-ui-button-container' );
+ if ( $form.length ) {
+ // If this button is in a form, apply this to all the form's buttons.
+ $siblings = $form.find( '.mw-ui-button' );
+ } else {
+ // Otherwise, try to find neighboring buttons
+ $siblings = $el.siblings( '.mw-ui-button' );
+ }
+
+ // Add fade/blur to unfocused sibling buttons
+ $siblings.not( $el ).filter( ':not(:focus)' )
+ .addClass( 'mw-ui-button-althover' );
+ }
+ }
+
+ function onMwUiButtonBlur( event ) {
+ if ( event.target.className.indexOf( 'mw-ui-button' ) === -1 ) {
+ // Not a button event
+ return;
+ }
+
+ var $el = $( event.target ),
+ $form, $siblings, $focused;
+
+ $form = $el.closest( 'form, .mw-ui-button-container' );
+ if ( $form.length ) {
+ // If this button is in a form, apply this to all the form's buttons.
+ $siblings = $form.find( '.mw-ui-button' );
+ } else {
+ // Otherwise, try to find neighboring buttons
+ $siblings = $el.siblings( '.mw-ui-button' );
+ }
+
+ // Add fade/blur to unfocused sibling buttons
+ $focused = $siblings.not( $el ).filter( ':focus' );
+
+ if ( event.type === 'mouseleave' && $el.is( ':focus' ) ) {
+ // If this button is still focused, but the mouse left it, keep siblings faded
+ return;
+ } else if ( $focused.length ) {
+ // A sibling has focus; have it trigger the restyling
+ $focused.trigger( 'mouseenter.mw-ui-enhance' );
+ } else {
+ // No other siblings are focused; removing button fading
+ $siblings.removeClass( 'mw-ui-button-althover' );
+ }
+ }
+
+ // Attach the mouseenter and mouseleave handlers on document
+ $( document )
+ .on( 'mouseenter.mw-ui-enhance', '.mw-ui-button', onMwUiButtonFocus )
+ .on( 'mouseleave.mw-ui-enhance', '.mw-ui-button', onMwUiButtonBlur );
+
+ // Attach these independently, because jQuery doesn't support useCapture mode (focus propagation)
+ if ( document.attachEvent ) {
+ document.attachEvent( 'focusin', onMwUiButtonFocus );
+ document.attachEvent( 'focusout', onMwUiButtonBlur );
+ } else {
+ document.body.addEventListener( 'focus', onMwUiButtonFocus, true );
+ document.body.addEventListener( 'blur', onMwUiButtonBlur, true );
+ }
+ } );
+
+ /**
+ * Disables action and submit buttons when a form has required fields
+ * @param {jQuery} $form jQuery object corresponding to a form element.
+ */
+ function enableFormWithRequiredFields( $form ) {
+ var
+ $fields = $form.find( 'input, textarea' ).filter( '[required]' ),
+ ready = true;
+
+ $fields.each( function () {
+ var $this = $( this );
+ if ( mw.flow.editor.exists( $this ) ) {
+ if ( mw.flow.editor.getEditor( $this ).isEmpty() ) {
+ ready = false;
+ }
+ } else if ( this.value === '' ) {
+ ready = false;
+ }
+ } );
+
+ // @todo scrap data-role? use submit types? or a single role=action?
+ $form.find( '.mw-ui-button' ).filter( '[data-role=action], [data-role=submit]' )
+ .prop( 'disabled', !ready );
+ }
+ /*
+ * Disable / enable preview and submit buttons without/with text in field.
+ * Usage: field needs required attribute
+ */
+ $( document ).ready( function () {
+ // We should probably not use this change detection method for VE
+ $( document ).on( 'keyup.flow-actions-disabler', '.mw-ui-input', function () {
+ enableFormWithRequiredFields( $( this ).closest( 'form' ) );
+ } );
+ } );
+
+
+ /*
+ * mw-ui-tooltip
+ * Renders tooltips on over, and also via mw.tooltip.
+ */
+ $( document ).ready( function () {
+ var _$tooltip = $(
+ '<span class="flow-ui-tooltip flow-ui-tooltip-left">' +
+ '<span class="flow-ui-tooltip-content"></span>' +
+ '<span class="flow-ui-tooltip-triangle"></span>' +
+ '<span class="flow-ui-tooltip-close"></span>' +
+ '</span>'
+ ),
+ $activeTooltips = $(),
+ _mwUiTooltipExpireTimer;
+
+ /**
+ * Renders a tooltip at target.
+ * Options (either given as param, or fetched from target as data-tooltip-x params):
+ * tooltipSize=String (small,large,block)
+ * tooltipContext=String (constructive,destructive,progressive,regressive)
+ * tooltipPointing=String (up,down,left,right)
+ * tooltipClosable=Boolean
+ * tooltipContentCallback=Function
+ *
+ * @param {jQuery|Element} target
+ * @param {jQuery|Element|String} [content] A jQuery set, an element, or a string of
+ * HTML. If omitted, first tries tooltipContentCallback, then target.title
+ * @param {Object} [options]
+ */
+ function mwUiTooltipShow( target, content, options ) {
+ var $target = $( target ),
+ // Find previous tooltip for this el
+ $tooltip = $target.data( '$tooltip' ),
+
+ // Get window size and scroll details
+ windowWidth = $( window ).width(),
+ windowHeight = $( window ).height(),
+ scrollX = Math.max( window.pageXOffset, document.documentElement.scrollLeft, document.body.scrollLeft ),
+ scrollY = Math.max( window.pageYOffset, document.documentElement.scrollTop, document.body.scrollTop ),
+
+ // Store target and tooltip details
+ tooltipWidth, tooltipHeight,
+ targetPosition,
+ locationOrder, tooltipLocation = {},
+ insertFn = 'append',
+
+ // Options, no longer by objet reference
+ optionsUnreferenced = {},
+
+ i = 0;
+
+ options = options || {};
+ // Do this so that we don't alter the data object by reference
+ optionsUnreferenced.tooltipSize = options.tooltipSize || $target.data( 'tooltipSize' );
+ optionsUnreferenced.tooltipContext = options.tooltipContext || $target.data( 'tooltipContext' );
+ optionsUnreferenced.tooltipPointing = options.tooltipPointing || $target.data( 'tooltipPointing' );
+ optionsUnreferenced.tooltipContentCallback = options.tooltipContentCallback || $target.data( 'tooltipContentCallback' );
+ // @todo closable
+ optionsUnreferenced.tooltipClosable = options.tooltipClosable || $target.data( 'tooltipClosable' );
+
+ // Support passing jQuery as argument
+ target = $target[0];
+
+ if ( !content ) {
+ if ( optionsUnreferenced.tooltipContentCallback ) {
+ // Use content callback to get the content for this element
+ content = optionsUnreferenced.tooltipContentCallback( target, optionsUnreferenced );
+
+ if ( !content ) {
+ return false;
+ }
+ } else {
+ // Check to see if we're simply using target.title as the content
+ if ( !target.title ) {
+ return false;
+ }
+
+ content = target.title;
+ $target.data( 'tooltipTitle', content ); // store title
+ target.title = ''; // and hide it so it doesn't appear
+ insertFn = 'text';
+
+ if ( !optionsUnreferenced.tooltipSize ) {
+ // Default size for title tooltip is small
+ optionsUnreferenced.tooltipSize = 'small';
+ }
+ }
+ }
+
+ // No previous tooltip
+ if ( !$tooltip ) {
+ // See if content itself is a tooltip
+ try {
+ if ( $.type( content ) === 'string' ) {
+ $tooltip = $( $.parseHTML( content ) );
+ } else {
+ $tooltip = $( content );
+ }
+ } catch ( e ) {}
+ if ( !$tooltip || !$tooltip.is( '.flow-ui-tooltip' ) && !$tooltip.find( '.flow-ui-tooltip' ).length ) {
+ // Content is not and does not contain a tooltip, so instead, put content inside a new tooltip wrapper
+ $tooltip = _$tooltip.clone();
+ }
+ }
+
+ // Try to inherit tooltipContext from the target's classes
+ if ( !optionsUnreferenced.tooltipContext ) {
+ if ( $target.hasClass( 'mw-ui-progressive' ) ) {
+ optionsUnreferenced.tooltipContext = 'progressive';
+ } else if ( $target.hasClass( 'mw-ui-constructive' ) ) {
+ optionsUnreferenced.tooltipContext = 'constructive';
+ } else if ( $target.hasClass( 'mw-ui-destructive' ) ) {
+ optionsUnreferenced.tooltipContext = 'destructive';
+ }
+ }
+
+ $tooltip
+ // Add the content to it
+ .find( '.flow-ui-tooltip-content' )
+ .empty()
+ [ insertFn ]( content )
+ .end()
+ // Move this off-page before rendering it, so that we can calculate its real dimensions
+ // @todo use .parent() loop to check for z-index and + that to this if needed
+ .css( { position: 'absolute', zIndex: 1000, top: 0, left: '-999em' } )
+ // Render
+ // @todo inject at #bodyContent to inherit (font-)styling
+ .appendTo( 'body' );
+
+ // Tooltip style context
+ if ( optionsUnreferenced.tooltipContext ) {
+ $tooltip.removeClass( 'mw-ui-progressive mw-ui-constructive mw-ui-destructive' );
+ $tooltip.addClass( 'mw-ui-' + optionsUnreferenced.tooltipContext );
+ }
+
+ // Tooltip size (small, large)
+ if ( optionsUnreferenced.tooltipSize ) {
+ $tooltip.removeClass( 'flow-ui-tooltip-sm flow-ui-tooltip-lg' );
+ $tooltip.addClass( 'flow-ui-tooltip-' + optionsUnreferenced.tooltipSize );
+ }
+
+ // Remove the old pointing direction
+ $tooltip.removeClass( 'flow-ui-tooltip-up flow-ui-tooltip-down flow-ui-tooltip-left flow-ui-tooltip-right' );
+
+ // tooltip width and height with the new content
+ tooltipWidth = $tooltip.outerWidth( true );
+ tooltipHeight = $tooltip.outerHeight( true );
+
+ // target positioning info
+ targetPosition = $target.offset();
+ targetPosition.width = $target.outerWidth( true );
+ targetPosition.height = $target.outerHeight( true );
+ targetPosition.leftEnd = targetPosition.left + targetPosition.width;
+ targetPosition.topEnd = targetPosition.top + targetPosition.height;
+ targetPosition.leftMiddle = targetPosition.left + targetPosition.width / 2;
+ targetPosition.topMiddle = targetPosition.top + targetPosition.height / 2;
+
+ // Use the preferred pointing direction first
+ switch ( optionsUnreferenced.tooltipPointing ) {
+ case 'left': locationOrder = [ 'left', 'right', 'left' ]; break;
+ case 'right': locationOrder = [ 'right', 'left', 'right' ]; break;
+ case 'down': locationOrder = [ 'down', 'up', 'down' ]; break;
+ default: locationOrder = [ 'up', 'down', 'up' ];
+ }
+
+ do {
+ // Position of the POINTER, not the tooltip itself
+ switch ( locationOrder[ i ] ) {
+ case 'left':
+ tooltipLocation.left = targetPosition.leftEnd;
+ tooltipLocation.top = targetPosition.topMiddle - tooltipHeight / 2;
+ break;
+ case 'right':
+ tooltipLocation.left = targetPosition.left - tooltipWidth;
+ tooltipLocation.top = targetPosition.topMiddle - tooltipHeight / 2;
+ break;
+ case 'down':
+ tooltipLocation.left = targetPosition.leftMiddle - tooltipWidth / 2;
+ tooltipLocation.top = targetPosition.top - tooltipHeight;
+ break;
+ case 'up':
+ tooltipLocation.left = targetPosition.leftMiddle - tooltipWidth / 2;
+ tooltipLocation.top = targetPosition.topEnd;
+ break;
+ }
+
+ // Verify tooltip will be mostly visible in viewport
+ if (
+ tooltipLocation.left > scrollX - 5 &&
+ tooltipLocation.top > scrollY - 5 &&
+ tooltipLocation.left + tooltipWidth < windowWidth + scrollX + 5 &&
+ tooltipLocation.top + tooltipHeight < windowHeight + scrollY + 5
+ ) {
+ break;
+ }
+ if ( i + 1 === locationOrder.length ) {
+ break;
+ }
+ } while ( ++i <= locationOrder.length );
+
+ // Add the pointing direction class from the loop
+ $tooltip.addClass( 'flow-ui-tooltip-' + locationOrder[ i ] );
+
+ // Apply the new location CSS
+ $tooltip.css( tooltipLocation );
+
+ // Store this tooltip onto target
+ $target.data( '$tooltip', $tooltip );
+ // Store this target onto tooltip
+ $tooltip.data( '$target', $target );
+ // Add this tooltip to our set of active tooltips
+ $activeTooltips = $activeTooltips.add( $tooltip );
+
+ // Start the expiry timer
+ _mwUiTooltipExpire();
+
+ return $tooltip;
+ }
+
+ /**
+ * Hides the tooltip associated with target instantly.
+ * @param {Element|jQuery} target
+ */
+ function mwUiTooltipHide( target ) {
+ var $target = $( target ),
+ $tooltip = $target.data( '$tooltip' ),
+ tooltipTitle = $target.data( 'tooltipTitle' );
+
+ // Remove tooltip from DOM
+ if ( $tooltip ) {
+ $target.removeData( '$tooltip' );
+ $activeTooltips = $activeTooltips.not( $tooltip );
+ $tooltip.remove();
+ }
+
+ // Restore old title; was used for tooltip
+ if ( tooltipTitle ) {
+ $target[0].title = tooltipTitle;
+ $target.removeData( 'tooltipTitle' );
+ }
+ }
+
+ /**
+ * Runs on a timer to expire tooltips. This is useful in scenarios where a tooltip's target
+ * node has disappeared (removed from page), and didn't trigger a mouseout event. We detect
+ * the target disappearing, and as such remove the tooltip node.
+ */
+ function _mwUiTooltipExpire() {
+ clearTimeout( _mwUiTooltipExpireTimer );
+
+ $activeTooltips.each( function () {
+ var $this = $( this ),
+ $target = $this.data( '$target' );
+
+ // Remove the tooltip if this tooltip has been removed,
+ // or if target is not visible (hidden or removed from DOM)
+ if ( !this.parentNode || !$target.is( ':visible' ) ) {
+ // Remove the tooltip from the DOM
+ $this.remove();
+ // Unset tooltip from target
+ $target.removeData( '$tooltip' );
+ // Remove the tooltip from our active tooltips list
+ $activeTooltips = $activeTooltips.not( $this );
+ }
+ } );
+
+ if ( $activeTooltips.length ) {
+ // Check again in 500ms if we still have active tooltips
+ _mwUiTooltipExpireTimer = setTimeout( _mwUiTooltipExpire, 500 );
+ }
+ }
+
+ /**
+ * MW UI Tooltip access through JS API.
+ */
+ mw.tooltip = {
+ show: mwUiTooltipShow,
+ hide: mwUiTooltipHide
+ };
+
+ /**
+ * Event handler for mouse entering on a .flow-ui-tooltip-target
+ * @param {Event} event
+ */
+ function onMwUiTooltipFocus( event ) {
+ mw.tooltip.show( this );
+ }
+
+ /**
+ * Event handler for mouse leaving a .flow-ui-tooltip-target
+ * @param {Event} event
+ */
+ function onMwUiTooltipBlur( event ) {
+ mw.tooltip.hide( this );
+ }
+
+ // Attach the mouseenter and mouseleave handlers on document
+ $( document )
+ .on( 'mouseenter.mw-ui-enhance focus.mw-ui-enhance', '.flow-ui-tooltip-target', onMwUiTooltipFocus )
+ .on( 'mouseleave.mw-ui-enhance blur.mw-ui-enhance click.mw-ui-enhance', '.flow-ui-tooltip-target', onMwUiTooltipBlur );
+ } );
+
+ /**
+ * Ask a user to confirm navigating away from a page when they have entered unsubmitted changes to a form.
+ */
+ var _oldOnBeforeUnload = window.onbeforeunload;
+ window.onbeforeunload = function () {
+ var uncommitted;
+
+ $( 'input, textarea' ).filter( '.mw-ui-input:visible' ).each( function () {
+ if ( $.trim( this.value ) && this.value !== this.defaultValue ) {
+ uncommitted = true;
+ return false;
+ }
+ } );
+
+ // Ask the user if they want to navigate away
+ if ( uncommitted ) {
+ return mw.msg( 'mw-ui-unsubmitted-confirm' );
+ }
+
+ // Run the old on beforeunload fn if it exists
+ if ( _oldOnBeforeUnload ) {
+ return _oldOnBeforeUnload();
+ }
+ };
+}( mw, jQuery ) );
diff --git a/Flow/modules/engine/misc/mw-ui.modal.js b/Flow/modules/engine/misc/mw-ui.modal.js
new file mode 100644
index 00000000..c549db03
--- /dev/null
+++ b/Flow/modules/engine/misc/mw-ui.modal.js
@@ -0,0 +1,410 @@
+/*!
+ * mw-ui-modal
+ * Implements mw.Modal functionality.
+ */
+
+( function ( mw, $ ) {
+ // Make it easier to remove this later on, should it be implemented in Core
+ if ( mw.Modal ) {
+ return;
+ }
+
+ /**
+ * Accepts an element or HTML string as contents. If none given,
+ * modal will start in hidden state.
+ * Settings keys:
+ * - open (same arguments as open method)
+ * - title String
+ * - disableCloseOnOutsideClick Boolean (if true, ESC and background clicks do not close it)
+ *
+ * @todo Implement multi-step
+ * @todo Implement data-mwui handlers
+ * @todo Implement OOJS & events
+ *
+ * @example modal = mw.Modal();
+ * @example modal = mw.Modal( { open: 'Contents!!', title: 'Title!!' } );
+ * @example modal = mw.Modal( 'special_modal' );
+ *
+ * @param {String} [name] Name of modal (may be omitted)
+ * @param {Object} [settings]
+ * @return MwUiModal
+ */
+ function MwUiModal( name, settings ) {
+ // allow calling this method with or without "new" keyword
+ if ( this.constructor !== MwUiModal ) {
+ return new MwUiModal( name, settings );
+ }
+
+ // Defaults and ordering
+ if ( !settings && typeof name === 'object' ) {
+ settings = name;
+ name = null;
+ }
+ settings = settings || {};
+
+ // Set name
+ this.name = name;
+
+ // Set title
+ this.setTitle( settings.title );
+
+ // Set disableCloseOnOutsideClick
+ this.disableCloseOnOutsideClick = !!settings.disableCloseOnOutsideClick;
+
+ // Auto-open
+ if ( settings.open ) {
+ this.open( settings.open );
+ }
+
+ return this;
+ }
+
+ /** Stores template
+ * @todo use data-mwui attributes instead of data-flow **/
+ MwUiModal.prototype.template = '' +
+ '<div class="flow-ui-modal">' +
+ '<div class="flow-ui-modal-layout">' +
+ '<div class="flow-ui-modal-heading">' +
+ '<a href="#" class="mw-ui-anchor mw-ui-quiet mw-ui-destructive flow-ui-modal-heading-prev" data-flow-interactive-handler="modalPrevOrClose"><span class="wikiglyph wikiglyph-x"></span></a>' +
+ '<a href="#" class="mw-ui-anchor mw-ui-quiet mw-ui-constructive flow-ui-modal-heading-next" data-flow-interactive-handler="modalNextOrSubmit"><span class="wikiglyph wikiglyph-tick"></span></a>' +
+ // title
+ '</div>' +
+
+ '<div class="flow-ui-modal-content">' +
+ // content
+ '</div>' +
+ '</div>' +
+ '</div>';
+
+ /** Stores modal wrapper selector **/
+ MwUiModal.prototype.wrapperSelector = '.flow-ui-modal';
+ /** Stores content wrapper selector **/
+ MwUiModal.prototype.contentSelector = '.flow-ui-modal-content';
+ /** Stores heading wrapper selector, which contains prev/next links **/
+ MwUiModal.prototype.headingSelector = '.flow-ui-modal-heading';
+ /** Stores prev link selector **/
+ MwUiModal.prototype.prevSelector = '.flow-ui-modal-heading-prev';
+ /** Stores next link selector **/
+ MwUiModal.prototype.nextSelector = '.flow-ui-modal-heading-next';
+
+ // Primary functions
+
+ /**
+ * Closes and destroys the given instance of mw.Modal.
+ *
+ * @return {Boolean} false on failure, true on success
+ */
+ MwUiModal.prototype.close = function () {
+ // Remove references
+ this._contents = this._title = null;
+
+ if ( this.$node ) {
+ // Remove whole thing from page
+ this.getNode().remove();
+
+ return true;
+ }
+
+ return false;
+ };
+
+ /**
+ * You can visually render the modal using this method. Opens up by displaying it on the page.
+ *
+ * - Multi-step modals with an Array. You can pass [ Element, Element ] to have two steps.
+ * - Multi-step modals with an Object to have named step keys. Pass this for three steps:
+ * { steps: [ 'first', 'second', 'foobar' ], first: Element, second: Element, foobar: Element }
+ *
+ * @todo Currently only supports String|jQuery|Element. Implement multi-step modals.
+ *
+ * @param {Array|Object|Element|jQuery|String} [contents]
+ * @return MwUiModal
+ */
+ MwUiModal.prototype.open = function ( contents ) {
+ var $node = this.getNode(),
+ $contentNode = this.getContentNode(),
+ $fields;
+
+ // Only update content if it's new
+ if ( contents && contents !== this._contents ) {
+ this._contents = contents;
+
+ $contentNode
+ // Remove children (this way we can unbind events)
+ .children()
+ .remove()
+ .end()
+ // Remove any plain text left over
+ .empty()
+ // Add the new content
+ .append( contents );
+ }
+
+ // Drop it into the page
+ $node.appendTo( 'body' );
+
+ // Hide the tick box @todo implement multi-step and event handling / form binding
+ $node.find( this.nextSelector ).hide();
+
+ // If something in here did not auto-focus, let's focus something
+ $fields = $node.find( 'textarea, input, select' ).filter( ':visible' );
+ if ( !$fields.filter( ':focus' ).length ) {
+ // Try to focus on an autofocus field
+ $fields = $fields.filter( '[autofocus]' );
+ if ( $fields.length ) {
+ $fields.trigger( 'focus' );
+ } else {
+ // Try to focus on ANY input
+ $fields = $fields.end().filter( ':first' );
+ if ( $fields.length ) {
+ $fields.trigger( 'focus' );
+ } else {
+ // Give focus to the wrapper itself
+ $node.focus();
+ }
+ }
+ }
+
+ return this;
+ };
+
+ /**
+ * Changes the title of the modal.
+ *
+ * @param {String|null} title
+ * @return MwUiModal
+ */
+ MwUiModal.prototype.setTitle = function ( title ) {
+ var $heading = this.getNode().find( this.headingSelector ),
+ $children;
+
+ title = title || '';
+
+ // Only update title if it's new
+ if ( title !== this._title ) {
+ this._title = title;
+
+ // Remove any element children temporarily, so we can set the title here
+ $children = $heading.children().detach();
+
+ $heading
+ // Set the new title
+ .text( title )
+ // Add the child nodes back
+ .prepend( $children );
+ }
+
+ // Show the heading if there's a title; hide otherwise
+ $heading[ title ? 'show' : 'hide' ]();
+
+ return this;
+ };
+
+ /**
+ * @todo Implement data-mwui handlers, currently using data-flow
+ */
+ MwUiModal.prototype.setInteractiveHandler = function () {
+ return false;
+ };
+
+ /**
+ * Returns modal name.
+ */
+ MwUiModal.prototype.getName = function () {
+ return this.name;
+ };
+
+ // Nodes
+
+ /**
+ * Returns the modal's wrapper Element, which contains the header node and content node.
+ * @returns {jQuery}
+ */
+ MwUiModal.prototype.getNode = function () {
+ var self = this,
+ $node = this.$node;
+
+ // Create our template instance
+ if ( !$node ) {
+ $node = this.$node = $( this.template );
+
+ // Store a self-reference
+ $node.data( 'MwUiModal', this );
+
+ // Bind close handlers
+ $node.on( 'click', function ( event ) {
+ // If we are clicking on the modal itself, it's the outside area, so close it;
+ // make sure we aren't clicking INSIDE the modal content!
+ if ( !self.disableCloseOnOutsideClick && this === $node[ 0 ] && event.target === $node[ 0 ] ) {
+ self.close();
+ }
+ } );
+ }
+
+ return $node;
+ };
+
+ /**
+ * Returns the wrapping Element on which you can bind bubbling events for your content.
+ * @returns {jQuery}
+ */
+ MwUiModal.prototype.getContentNode = function () {
+ return this.getNode().find( this.contentSelector );
+ };
+
+ // Step creation
+
+ /**
+ * Adds one or more steps, using the same arguments as modal.open.
+ * May overwrite steps if any exist with the same key in Object mode.
+ *
+ * @todo Implement multi-step.
+ *
+ * @param {Array|Object|Element|jQuery|String} contents
+ * @return MwUiModal
+ */
+ MwUiModal.prototype.addSteps = function ( contents ) {
+ return false;
+ };
+
+ /**
+ * Changes a given step. If String to does not exist in the list of steps, throws an exception;
+ * int to always succeeds. If the given step is the currently-active one, rerenders the modal contents.
+ * Theoretically, you could use setStep to keep changing step 1 to create a pseudo-multi-step modal.
+ *
+ * @todo Implement multi-step.
+ *
+ * @param {int|String} to
+ * @param {Element|jQuery|String} contents
+ * @return MwUiModal
+ */
+ MwUiModal.prototype.setStep = function ( to, contents ) {
+ return false;
+ };
+
+ /**
+ * Returns an Object with steps, and their contents.
+ *
+ * @todo Implement multi-step.
+ *
+ * @return Object
+ */
+ MwUiModal.prototype.getSteps = function ( to, contents ) {
+ return {};
+ };
+
+ // Step interaction
+
+ /**
+ * For a multi-step modal, goes to the previous step, otherwise, closes the modal.
+ *
+ * @return {MwUiModal|Boolean} false if none, MwUiModal on prev, true on close
+ */
+ MwUiModal.prototype.prevOrClose = function () {
+ if ( this.prev() === false ) {
+ return this.close();
+ }
+ };
+
+ /**
+ * For a multi-step modal, goes to the next step (if any), otherwise, submits the form.
+ *
+ * @return {MwUiModal|Boolean} false if no next step and no button to click, MwUiModal on success
+ */
+ MwUiModal.prototype.nextOrSubmit = function () {
+ var $button;
+
+ if ( this.next() === false && this.$node ) {
+ // Find an anchor or button with role=primary
+ $button = this.$node.find( this.contentSelector ).find( 'a, input, button' ).filter( ':visible' ).filter( '[type=submit], [data-role=submit]' );
+
+ if ( !$button.length ) {
+ return false;
+ }
+
+ $button.trigger( 'click' );
+ }
+ };
+
+ /**
+ * For a multi-step modal, goes to the previous step, if any are left.
+ *
+ * @todo Implement multi-step.
+ *
+ * @return {MwUiModal|Boolean} false if invalid step, MwUiModal on success
+ */
+ MwUiModal.prototype.prev = function () {
+ return false;
+ };
+
+ /**
+ * For a multi-step modal, goes to the next step, if any are left.
+ *
+ * @todo Implement multi-step.
+ *
+ * @return {MwUiModal|Boolean} false if invalid step, MwUiModal on success
+ */
+ MwUiModal.prototype.next = function () {
+ return false;
+ };
+
+ /**
+ * For a multi-step modal, goes to a specific step by number or name.
+ *
+ * @todo Implement multi-step.
+ *
+ * @param {int|String} to
+ * @return {MwUiModal|Boolean} false if invalid step, MwUiModal on success
+ */
+ MwUiModal.prototype.go = function ( to ) {
+ return false;
+ };
+
+ /**
+ * MW UI Modal access through JS API.
+ * @example mw.Modal( "<p>lorem</p>" );
+ */
+ mw.Modal = MwUiModal;
+
+ /**
+ * Returns an instance of mw.Modal if one is currently being displayed on the page.
+ * If node is given, tries to find which modal (if any) that node is within.
+ * Returns false if none found.
+ *
+ * @param {Element|jQuery} [node]
+ * @return {Boolean|MwUiModal}
+ */
+ mw.Modal.getModal = function ( node ) {
+ if ( node ) {
+ // Node was given; try to find a parent modal
+ return $( node ).closest( MwUiModal.prototype.wrapperSelector ).data( 'MwUiModal') || false;
+ }
+
+ // No node given; return the last-opened modal on the page
+ return $( 'body' ).children( MwUiModal.prototype.wrapperSelector ).filter( ':last' ).data( 'MwUiModal' ) || false;
+ };
+
+ // Transforms: automatically map these functions to call their mw.Modal methods globally, on any active instance
+ $.each( [ 'close', 'getName', 'prev', 'next', 'prevOrClose', 'nextOrSubmit', 'go' ], function ( i, fn ) {
+ mw.Modal[ fn ] = function () {
+ var args = Array.prototype.splice.call( arguments, 0, arguments.length - 1 ),
+ node = arguments[ arguments.length - 1 ],
+ modal;
+
+ // Find the node, if any was given
+ if ( !node || ( typeof node.is === 'function' && !node.is( '*' ) ) || node.nodeType !== 1 ) {
+ // The last argument to this function was not a node, assume none was intended to be given
+ node = null;
+ args = arguments;
+ }
+
+ // Try to find that modal
+ modal = mw.Modal.getModal( node );
+
+ // Call the intended function locally
+ if ( modal ) {
+ modal[ fn ].apply( modal, args );
+ }
+ };
+ } );
+}( mw, jQuery ) );