diff options
Diffstat (limited to 'Flow/modules/engine/misc')
-rw-r--r-- | Flow/modules/engine/misc/flow-api.js | 411 | ||||
-rw-r--r-- | Flow/modules/engine/misc/flow-baseconvert.js | 70 | ||||
-rw-r--r-- | Flow/modules/engine/misc/flow-eventlog.js | 45 | ||||
-rw-r--r-- | Flow/modules/engine/misc/flow-handlebars.js | 581 | ||||
-rw-r--r-- | Flow/modules/engine/misc/jquery.conditionalScroll.js | 51 | ||||
-rw-r--r-- | Flow/modules/engine/misc/jquery.findWithParent.js | 45 | ||||
-rw-r--r-- | Flow/modules/engine/misc/mw-ui.enhance.js | 451 | ||||
-rw-r--r-- | Flow/modules/engine/misc/mw-ui.modal.js | 410 |
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 ) ); |