summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
Diffstat (limited to 'Flow/modules')
-rw-r--r--Flow/modules/contributions/base.js31
-rw-r--r--Flow/modules/editor/editors/ext.flow.editors.AbstractEditor.js41
-rw-r--r--Flow/modules/editor/editors/ext.flow.editors.none.js165
-rw-r--r--Flow/modules/editor/editors/visualeditor/ext.flow.editors.visualeditor.js208
-rw-r--r--Flow/modules/editor/editors/visualeditor/mw.flow.ve.CommandRegistry.js22
-rw-r--r--Flow/modules/editor/editors/visualeditor/mw.flow.ve.SequenceRegistry.js12
-rw-r--r--Flow/modules/editor/editors/visualeditor/mw.flow.ve.Target.js65
-rw-r--r--Flow/modules/editor/editors/visualeditor/mw.flow.ve.Target.less49
-rw-r--r--Flow/modules/editor/editors/visualeditor/ui/actions/mw.flow.ve.ui.SwitchEditorAction.js55
-rw-r--r--Flow/modules/editor/editors/visualeditor/ui/contextitem/mw.flow.ve.ui.MentionContextItem.js58
-rw-r--r--Flow/modules/editor/editors/visualeditor/ui/images/icons/flow-mention.svg83
-rw-r--r--Flow/modules/editor/editors/visualeditor/ui/images/icons/flow-switch-editor.svg69
-rw-r--r--Flow/modules/editor/editors/visualeditor/ui/inspectors/mw.flow.ve.ui.MentionInspector.js372
-rw-r--r--Flow/modules/editor/editors/visualeditor/ui/mw.flow.ve.ui.Icons.less9
-rw-r--r--Flow/modules/editor/editors/visualeditor/ui/tools/mw.flow.ve.ui.MentionInspectorTool.js40
-rw-r--r--Flow/modules/editor/editors/visualeditor/ui/tools/mw.flow.ve.ui.SwitchEditorTool.js29
-rw-r--r--Flow/modules/editor/editors/visualeditor/ui/widgets/mw.flow.ve.ui.MentionTargetInputWidget.js170
-rw-r--r--Flow/modules/editor/ext.flow.editor.js272
-rw-r--r--Flow/modules/editor/ext.flow.parsoid.js46
-rw-r--r--Flow/modules/engine/components/board/base/flow-board-api-events.js920
-rw-r--r--Flow/modules/engine/components/board/base/flow-board-interactive-events.js213
-rw-r--r--Flow/modules/engine/components/board/base/flow-board-load-events.js42
-rw-r--r--Flow/modules/engine/components/board/base/flow-board-misc.js127
-rw-r--r--Flow/modules/engine/components/board/base/flow-boardandhistory-base.js190
-rw-r--r--Flow/modules/engine/components/board/features/flow-board-loadmore.js664
-rw-r--r--Flow/modules/engine/components/board/features/flow-board-navigation.js282
-rw-r--r--Flow/modules/engine/components/board/features/flow-board-preview.js237
-rw-r--r--Flow/modules/engine/components/board/features/flow-board-switcheditor.js52
-rw-r--r--Flow/modules/engine/components/board/features/flow-board-toc.js354
-rw-r--r--Flow/modules/engine/components/board/features/flow-board-visualeditor.js37
-rw-r--r--Flow/modules/engine/components/board/flow-board.js196
-rw-r--r--Flow/modules/engine/components/board/flow-boardhistory.js59
-rw-r--r--Flow/modules/engine/components/common/flow-component-engines.js39
-rw-r--r--Flow/modules/engine/components/common/flow-component-events.js917
-rw-r--r--Flow/modules/engine/components/common/flow-component-menus.js142
-rw-r--r--Flow/modules/engine/components/flow-component.js251
-rw-r--r--Flow/modules/engine/components/flow-registry.js165
-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
-rw-r--r--Flow/modules/flow-initialize.js14
-rw-r--r--Flow/modules/handlebars.js25
-rw-r--r--Flow/modules/messagePoster/ext.flow.messagePoster.js57
-rw-r--r--Flow/modules/notification/icon/Talk-ltr.pngbin0 -> 298 bytes
-rw-r--r--Flow/modules/notification/icon/Talk-rtl.pngbin0 -> 448 bytes
-rw-r--r--Flow/modules/styles/board/content-preview.less24
-rw-r--r--Flow/modules/styles/board/editor-switcher.less54
-rw-r--r--Flow/modules/styles/board/form-actions.less54
-rw-r--r--Flow/modules/styles/board/header.less27
-rw-r--r--Flow/modules/styles/board/menu.less154
-rw-r--r--Flow/modules/styles/board/moderated.less35
-rw-r--r--Flow/modules/styles/board/navigation.less139
-rw-r--r--Flow/modules/styles/board/replycount.less30
-rw-r--r--Flow/modules/styles/board/terms-of-use.less22
-rw-r--r--Flow/modules/styles/board/timestamps.less59
-rw-r--r--Flow/modules/styles/board/topic/meta.less9
-rw-r--r--Flow/modules/styles/board/topic/post.less190
-rw-r--r--Flow/modules/styles/board/topic/summary.less18
-rw-r--r--Flow/modules/styles/board/topic/titlebar.less71
-rw-r--r--Flow/modules/styles/board/topic/watchlist.less69
-rw-r--r--Flow/modules/styles/common.less124
-rw-r--r--Flow/modules/styles/errors.less11
-rw-r--r--Flow/modules/styles/flow.less/flow.colors.less4
-rw-r--r--Flow/modules/styles/flow.less/flow.helpers.less16
-rw-r--r--Flow/modules/styles/flow.less/flow.variables.less5
-rw-r--r--Flow/modules/styles/history/history-line.less3
-rw-r--r--Flow/modules/styles/js.less104
-rw-r--r--Flow/modules/styles/mediawiki.ui/forms.less243
-rw-r--r--Flow/modules/styles/mediawiki.ui/modal.less84
-rw-r--r--Flow/modules/styles/mediawiki.ui/text.less10
-rw-r--r--Flow/modules/styles/mediawiki.ui/tooltips.less212
-rw-r--r--Flow/modules/styles/minerva/common.less4
-rw-r--r--Flow/modules/vendor/Storer.js1355
-rw-r--r--Flow/modules/vendor/handlebars.js3079
-rw-r--r--Flow/modules/wikiglyph/WikiFont-Glyphs.eotbin0 -> 15946 bytes
-rw-r--r--Flow/modules/wikiglyph/WikiFont-Glyphs.svg291
-rw-r--r--Flow/modules/wikiglyph/WikiFont-Glyphs.ttfbin0 -> 15628 bytes
-rw-r--r--Flow/modules/wikiglyph/WikiFont-Glyphs.woffbin0 -> 11168 bytes
-rw-r--r--Flow/modules/wikiglyph/flow-override.less7
-rw-r--r--Flow/modules/wikiglyph/wikiglyphs.css359
85 files changed, 15709 insertions, 0 deletions
diff --git a/Flow/modules/contributions/base.js b/Flow/modules/contributions/base.js
new file mode 100644
index 00000000..b68fed53
--- /dev/null
+++ b/Flow/modules/contributions/base.js
@@ -0,0 +1,31 @@
+/*!
+ * This file provides a shim to load Flow when clicking an interactive
+ * Flow link.
+ */
+(function( $, mw ) {
+ function clickedFlowLink( event ) {
+ var $container = $( event.delegateTarget ),
+ onComplete = function() {
+ $( event.target ).click();
+ };
+
+ event.preventDefault();
+
+ $container.
+ addClass( 'flow-component' ).
+ data( 'flow-component', 'boardHistory' );
+
+ // if successfull, flow will now handle clicking the target
+ // If that failed still run the onComplete, it will not trigger
+ // our handler and be a normal click this time.
+ mw.loader.using(
+ [ 'ext.flow', 'ext.flow.mediawiki.ui.modal', 'mediawiki.ui.input' ],
+ onComplete,
+ onComplete
+ );
+ }
+
+ $( document ).ready( function() {
+ $( '#bodyContent' ).one( 'click', '.flow-click-interactive', clickedFlowLink );
+ } );
+} )( jQuery, mediaWiki );
diff --git a/Flow/modules/editor/editors/ext.flow.editors.AbstractEditor.js b/Flow/modules/editor/editors/ext.flow.editors.AbstractEditor.js
new file mode 100644
index 00000000..838bff00
--- /dev/null
+++ b/Flow/modules/editor/editors/ext.flow.editors.AbstractEditor.js
@@ -0,0 +1,41 @@
+( function ( mw, OO ) {
+ 'use strict';
+
+ mw.flow = mw.flow || {};
+
+ /**
+ * Abstract editor class for Flow content
+ * Sets certain defaults, but most have to be implemented in subclasses
+ *
+ * @class
+ * @abstract
+ *
+ * @constructor
+ */
+ mw.flow.editors = {
+ AbstractEditor: function () {}
+ };
+
+ OO.initClass( mw.flow.editors.AbstractEditor );
+
+ // Static methods
+
+ /**
+ * Returns whether this editor uses a preview mode
+ *
+ * @return {boolean}
+ */
+ mw.flow.editors.AbstractEditor.static.usesPreview = function () {
+ return true;
+ };
+
+ /**
+ * Determines if this editor is supported for the current user and
+ * environment (browser, etc.)
+ *
+ * @return {boolean}
+ */
+ mw.flow.editors.AbstractEditor.static.isSupported = function () {
+ return true;
+ };
+}( mediaWiki, OO ) );
diff --git a/Flow/modules/editor/editors/ext.flow.editors.none.js b/Flow/modules/editor/editors/ext.flow.editors.none.js
new file mode 100644
index 00000000..c8699438
--- /dev/null
+++ b/Flow/modules/editor/editors/ext.flow.editors.none.js
@@ -0,0 +1,165 @@
+( function ( $, mw ) {
+ 'use strict';
+
+ /**
+ * Editor class that uses a simple wikitext textarea
+ *
+ * @class
+ * @constructor
+ *
+ * @param {jQuery} $node
+ * @param {string} [content='']
+ */
+ mw.flow.editors.none = function ( $node, content ) {
+ this.$node = $node;
+ this.$node.val( content || '' );
+
+ this.$node.css( 'overflow', 'hidden' );
+ this.$node.css( 'resize', 'none' );
+
+ // auto-expansion shouldn't shrink too much; set default height as min
+ this.$node.css( 'min-height', this.$node.outerHeight() );
+
+ // initialize at height of existing content & update on every keyup
+ this.$node.keyup( this.autoExpand );
+ this.autoExpand.call( this.$node.get( 0 ) );
+
+ // only attach switcher if VE is actually supported
+ // code to figure out if that VE is supported is in that module
+ mw.loader.using( 'ext.flow.editors.visualeditor', $.proxy( this.attachControls, this ) );
+ };
+
+ OO.inheritClass( mw.flow.editors.none, mw.flow.editors.AbstractEditor );
+
+ // Static properties
+ /**
+ * Type of content to use (html or wikitext)
+ *
+ * @var string
+ */
+ mw.flow.editors.none.static.format = 'wikitext';
+
+ /**
+ * Name of this editor
+ *
+ * @var string
+ */
+ mw.flow.editors.none.static.name = 'none';
+
+ mw.flow.editors.none.prototype.destroy = function () {
+ // remove the help+switcher information
+ this.$node.siblings( '.flow-switcher-controls' ).remove();
+ // unset min-height that was set for auto-expansion
+ this.$node.css( 'min-height', '' );
+ // unset height that was set by auto-expansion
+ this.$node.css( 'height', '' );
+ // clear content
+ this.$node.val( '' );
+ };
+
+ /**
+ * @return {string}
+ */
+ mw.flow.editors.none.prototype.getRawContent = function () {
+ return this.$node.val();
+ };
+
+ /**
+ * Checks whether the field is empty
+ *
+ * @return {boolean} True if and only if it's empty
+ */
+ mw.flow.editors.none.prototype.isEmpty = function () {
+ return this.getRawContent() === '';
+ };
+
+ /**
+ * Auto-expand/shrink as content changes.
+ */
+ mw.flow.editors.none.prototype.autoExpand = function() {
+ var scrollHeight, $form, formBottom, windowBottom, maxHeightIncrease,
+ $this = $( this ),
+ height = $this.height(),
+ padding = $this.outerHeight() - $this.height() + 5;
+
+ /*
+ * Collapse to 0 height to get accurate scrollHeight for the content,
+ * then restore height.
+ * Without collapsing, scrollHeight would be the highest of:
+ * * the content height
+ * * the height the textarea already has
+ * Since we're looking to also shrink the textarea when content shrinks,
+ * we want to ignore that last case (hence the collapsing)
+ */
+ $this.height( 0 );
+ scrollHeight = this.scrollHeight;
+ $this.height( height );
+
+ /*
+ * Only animate height change if there actually is a change; we don't
+ * want every keystroke firing a 50ms animation.
+ */
+ if ( scrollHeight === $this.data( 'flow-prev-scroll-height' ) ) {
+ // no change
+ return;
+ }
+ $this.data( 'flow-prev-scroll-height', scrollHeight );
+
+ $form = $this.closest( 'form' );
+ formBottom = $form.offset().top + $form.outerHeight( true );
+ windowBottom = $( window ).scrollTop() + $( window ).height();
+ // additional padding of 20px so the targeted form has breathing room
+ maxHeightIncrease = windowBottom - formBottom - 20;
+
+ if ( scrollHeight - height - padding >= maxHeightIncrease ) {
+ // If we can't expand ensure overflow-y is set to auto
+ $this.css( 'overflow-y', 'auto' );
+ } else if ( scrollHeight !== $this.height() ) {
+ $this.css( {
+ height: scrollHeight,
+ 'overflow-y': 'hidden'
+ } );
+ }
+ };
+
+ mw.flow.editors.none.prototype.attachControls = function() {
+ var $preview, $controls, templateArgs,
+ board = mw.flow.getPrototypeMethod( 'board', 'getInstanceByElement' )( this.$node );
+
+ if ( mw.flow.editors.visualeditor.static.isSupported() ) {
+ $preview = $( '<a>' ).attr( {
+ href: '#',
+ 'data-flow-interactive-handler': 'switchEditor',
+ 'data-flow-target': '< form textarea'
+ } ).text( mw.message( 'flow-wikitext-editor-help-preview-the-result' ).text() );
+
+ templateArgs = {
+ enable_switcher: true,
+ help_text: mw.message( 'flow-wikitext-editor-help-and-preview' ).params( [
+ mw.message( 'flow-wikitext-editor-help-uses-wikitext' ).parse(),
+ $preview[0].outerHTML
+ ] ).parse()
+ };
+ } else {
+ // render just a basic help text
+ templateArgs = {
+ enable_switcher: false,
+ help_text: mw.message( 'flow-wikitext-editor-help' ).params( [
+ mw.message( 'flow-wikitext-editor-help-uses-wikitext' ).parse()
+ ] ).parse()
+ };
+ }
+
+ $controls = $( mw.flow.TemplateEngine.processTemplateGetFragment(
+ 'flow_editor_switcher.partial',
+ templateArgs
+ ) ).children();
+
+ // insert help information + editor switcher, and make it interactive
+ board.emitWithReturn( 'makeContentInteractive', $controls.insertAfter( this.$node ) );
+ };
+
+ mw.flow.editors.none.prototype.focus = function() {
+ return this.$node.focus();
+ };
+} ( jQuery, mediaWiki ) );
diff --git a/Flow/modules/editor/editors/visualeditor/ext.flow.editors.visualeditor.js b/Flow/modules/editor/editors/visualeditor/ext.flow.editors.visualeditor.js
new file mode 100644
index 00000000..91eb1a13
--- /dev/null
+++ b/Flow/modules/editor/editors/visualeditor/ext.flow.editors.visualeditor.js
@@ -0,0 +1,208 @@
+( function ( $, mw, OO, ve ) {
+ 'use strict';
+
+ /**
+ * @param {jQuery} $node Node to replace with a VisualEditor
+ * @param {string} [content='']
+ */
+ mw.flow.editors.visualeditor = function ( $node, content ) {
+ // node the editor is associated with.
+ this.$node = $node;
+
+ // Replace the node with a spinner
+ $node.hide();
+ $node.injectSpinner( {
+ 'size' : 'large',
+ 'type' : 'block',
+ 'id' : 'flow-editor-loading'
+ } );
+
+ // load dependencies & init editor
+ mw.loader.using( 'ext.flow.visualEditor', $.proxy( this.init, this, content || '' ) );
+ };
+
+ OO.inheritClass( mw.flow.editors.visualeditor, mw.flow.editors.AbstractEditor );
+
+ /**
+ * List of callbacks to execute when VE is fully loaded
+ */
+ mw.flow.editors.visualeditor.prototype.initCallbacks = [];
+
+ /**
+ * Callback function, executed after all VE dependencies have been loaded.
+ *
+ * @param {string} [content='']
+ */
+ mw.flow.editors.visualeditor.prototype.init = function ( content ) {
+ var $veNode, htmlDoc, dmDoc, target,
+ $focusedElement = $( ':focus' ),
+ flowEditor = this;
+
+ // ve.createDocumentFromHtml documents support for an empty string
+ // to create an empty document, but does not mention other falsy values.
+ content = content || '';
+
+ // add i18n messages to VE
+ ve.init.platform.addMessages( mw.messages.values );
+
+ $.removeSpinner( 'flow-editor-loading' );
+
+ target = this.target = new mw.flow.ve.Target();
+
+ htmlDoc = ve.createDocumentFromHtml( content ); // HTMLDocument
+
+ // Based on ve.init.mw.Target.prototype.setupSurface
+ dmDoc = this.dmDoc = ve.dm.converter.getModelFromDom(
+ htmlDoc,
+ null,
+ mw.config.get( 'wgVisualEditor' ).pageLanguageCode,
+ mw.config.get( 'wgVisualEditor' ).pageLanguageDir
+ );
+
+ setTimeout( function () {
+ var surface = target.addSurface( dmDoc ),
+ surfaceView = surface.getView(),
+ $documentNode = surfaceView.getDocument().getDocumentNode().$element;
+
+ $( target.$element ).insertAfter( flowEditor.$node );
+
+ $documentNode.addClass(
+ // Add appropriately mw-content-ltr or mw-content-rtl class
+ 'mw-content-' + mw.config.get( 'wgVisualEditor' ).pageLanguageDir
+ );
+
+ setTimeout( function () {
+ // focus VE instance if textarea had focus
+ if ( !$focusedElement.length || flowEditor.$node.is( $focusedElement ) ) {
+ surface.getView().focus();
+ } else {
+ $focusedElement.focus();
+ }
+
+ $veNode = surface.$element.find( '.ve-ce-documentNode' );
+
+ $veNode.addClass( 'mw-ui-input' );
+
+ // simulate a keyup event on the original node, so the validation code will
+ // pick up changes in the new node
+ $veNode.keyup( $.proxy( function () {
+ this.$node.keyup();
+ }, flowEditor ) );
+
+ $.each( flowEditor.initCallbacks, $.proxy( function( k, callback ) {
+ callback.apply( this );
+ }, flowEditor ) );
+
+ } );
+ } );
+ };
+
+ mw.flow.editors.visualeditor.prototype.destroy = function () {
+ if ( this.target ) {
+ this.target.destroy();
+ }
+
+ // re-display original node
+ this.$node.show();
+ };
+
+ /**
+ * Gets HTML of Flow field
+ *
+ * @return {string}
+ */
+ mw.flow.editors.visualeditor.prototype.getRawContent = function () {
+ var doc, html;
+
+ // If we haven't fully loaded yet, just return nothing.
+ if ( !this.target ) {
+ return '';
+ }
+
+ // get document from ve
+ doc = ve.dm.converter.getDomFromModel( this.dmDoc );
+
+ // document content will include html, head & body nodes; get only content inside body node
+ html = ve.properInnerHtml( $( doc.documentElement ).find( 'body' )[0] );
+ return html;
+ };
+
+ /**
+ * Checks if the document is empty
+ *
+ * @return {boolean} True if and only if it's empty
+ */
+ mw.flow.editors.visualeditor.prototype.isEmpty = function () {
+ if ( !this.dmDoc ) {
+ return true;
+ }
+
+ // Per Roan
+ return this.dmDoc.data.countNonInternalElements() <= 2;
+ };
+
+ mw.flow.editors.visualeditor.prototype.focus = function() {
+ if ( !this.target ) {
+ this.initCallbacks.push( function() {
+ this.focus();
+ } );
+ return;
+ }
+
+ this.target.surface.getView().focus();
+ };
+
+ mw.flow.editors.visualeditor.prototype.moveCursorToEnd = function () {
+ if ( !this.target ) {
+ this.initCallbacks.push( function() {
+ this.moveCursorToEnd();
+ } );
+ return;
+ }
+
+ var data = this.target.surface.getModel().getDocument().data,
+ cursorPos = data.getNearestContentOffset( data.getLength(), -1 );
+
+ this.target.surface.getModel().setSelection( new ve.Range( cursorPos ) );
+ };
+
+ // Static fields
+
+ /**
+ * Type of content to use (html or wikitext)
+ *
+ * @var {string}
+ */
+ mw.flow.editors.visualeditor.static.format = 'html';
+
+ /**
+ * Name of this editor
+ *
+ * @var string
+ */
+ mw.flow.editors.visualeditor.static.name = 'visualeditor';
+
+ // Static methods
+
+ mw.flow.editors.visualeditor.static.isSupported = function () {
+ return !!(
+ // ES5 support, from es5-skip.js
+ ( function () {
+ // This test is based on 'use strict',
+ // which is inherited from the top-level function.
+ return !this && !!Function.prototype.bind;
+ }() ) &&
+
+ // Since VE commit e2fab2f1ebf2a28f18b8ead08c478c4fc95cd64e, SVG is required
+ document.createElementNS &&
+ document.createElementNS( 'http://www.w3.org/2000/svg', 'svg' ).createSVGRect &&
+
+ // ve needs to be turned on as a valid editor
+ mw.config.get( 'wgFlowEditorList' ).indexOf( 'visualeditor' ) !== -1
+ );
+ };
+
+ mw.flow.editors.visualeditor.static.usesPreview = function () {
+ return false;
+ };
+} ( jQuery, mediaWiki, OO, ve ) );
diff --git a/Flow/modules/editor/editors/visualeditor/mw.flow.ve.CommandRegistry.js b/Flow/modules/editor/editors/visualeditor/mw.flow.ve.CommandRegistry.js
new file mode 100644
index 00000000..e9c90794
--- /dev/null
+++ b/Flow/modules/editor/editors/visualeditor/mw.flow.ve.CommandRegistry.js
@@ -0,0 +1,22 @@
+( function ( ve ) {
+ 'use strict';
+
+ ve.ui.commandRegistry.register(
+ new ve.ui.Command(
+ 'flowMention',
+ 'window',
+ 'open',
+ { args: ['flowMention'] },
+ { supportedSelections: ['linear'] }
+ )
+ );
+
+ ve.ui.commandRegistry.register(
+ new ve.ui.Command(
+ 'flowSwitchEditor',
+ 'flowSwitchEditor',
+ 'switch', // method to call on action
+ { args: [] } // arguments to pass to action
+ )
+ );
+} ( ve ) );
diff --git a/Flow/modules/editor/editors/visualeditor/mw.flow.ve.SequenceRegistry.js b/Flow/modules/editor/editors/visualeditor/mw.flow.ve.SequenceRegistry.js
new file mode 100644
index 00000000..8a2f1c02
--- /dev/null
+++ b/Flow/modules/editor/editors/visualeditor/mw.flow.ve.SequenceRegistry.js
@@ -0,0 +1,12 @@
+( function ( ve ) {
+ 'use strict';
+
+ ve.ui.sequenceRegistry.register(
+ new ve.ui.Sequence(
+ 'flowAtCharMention',
+ 'flowMention',
+ '@',
+ 1
+ )
+ );
+} ( ve ) );
diff --git a/Flow/modules/editor/editors/visualeditor/mw.flow.ve.Target.js b/Flow/modules/editor/editors/visualeditor/mw.flow.ve.Target.js
new file mode 100644
index 00000000..2925164f
--- /dev/null
+++ b/Flow/modules/editor/editors/visualeditor/mw.flow.ve.Target.js
@@ -0,0 +1,65 @@
+( function ( mw, OO, ve ) {
+ 'use strict';
+
+ mw.flow.ve = {
+ ui: {}
+ };
+
+ /**
+ * Flow-specific target, inheriting from the stand-alone target
+ *
+ * @class
+ * @extends ve.init.sa.Target
+ */
+ mw.flow.ve.Target = function FlowVeTarget() {
+ mw.flow.ve.Target.parent.call(
+ this,
+ 'desktop',
+ { floatable: false }
+ );
+ };
+
+ OO.inheritClass( mw.flow.ve.Target, ve.init.sa.Target );
+
+ // Static
+
+ mw.flow.ve.Target.static.toolbarGroups = [
+ {
+ type: 'list',
+ icon: 'text-style',
+ title: OO.ui.deferMsg( 'visualeditor-toolbar-style-tooltip' ),
+ include: [ 'bold', 'italic' ],
+ forceExpand: [ 'bold', 'italic' ]
+ },
+
+ { include: [ 'link' ] },
+
+ { include: [ 'flowMention' ] }
+ ];
+
+ if ( mw.flow.editors.none.static.isSupported() ) {
+ mw.flow.ve.Target.static.actionGroups = [
+ { include: [ 'flowSwitchEditor' ] }
+ ];
+ }
+
+ // Methods
+
+ mw.flow.ve.Target.prototype.attachToolbar = function() {
+ this.getToolbar().$element.insertAfter( this.getToolbar().getSurface().$element );
+ };
+
+ // This is a workaround.
+ //
+ // We need to make sure the MW platform wins (we need it for e.g. linkCache), because our
+ // dependencies do not agree.
+ //
+ // ext.visualEditor.data depends on ext.visualEditor.mediawiki, which provides
+ // ve.init.mw.Platform.js. However, we also use ext.visualEditor.standalone, which
+ // provides ve.init.sa.Platform. Both of these self-initialize ve.init.platform.
+ ve.init.platform = new ve.init.mw.Platform();
+
+ OO.ui.getUserLanguages = ve.init.platform.getUserLanguages.bind( ve.init.platform );
+
+ OO.ui.msg = ve.init.platform.getMessage.bind( ve.init.platform );
+} ( mediaWiki, OO, ve ) );
diff --git a/Flow/modules/editor/editors/visualeditor/mw.flow.ve.Target.less b/Flow/modules/editor/editors/visualeditor/mw.flow.ve.Target.less
new file mode 100644
index 00000000..6076edf8
--- /dev/null
+++ b/Flow/modules/editor/editors/visualeditor/mw.flow.ve.Target.less
@@ -0,0 +1,49 @@
+@import 'mediawiki.mixins';
+
+// Override another * rule in common.less back to browser default
+// box-sizing is not inherited.
+.flow-component {
+ .ve-init-target {
+ .box-sizing(content-box);
+ border: 1px solid #CCC;
+
+ * {
+ .box-sizing(content-box);
+ }
+
+ // Core, VE and OOjs UI do use other models in some cases, so we
+ // have to re-override them again as needed.
+ .oo-ui-textInputWidget input,
+ .mw-ui-input {
+ .box-sizing(border-box);
+ }
+ }
+
+ .ve-ce-documentNode {
+ // this border comes from .mw-ui-input on the same div, but i dont think
+ // we want to remove this for all inputs just the documentNode
+ border: none;
+ // This creates a space for the toolbar, a matching negative margin-top
+ // shifts the toolbar into this location.
+ // @todo where did this 40 come from, could it be calculated?
+ padding-bottom: 40px;
+ }
+
+ .oo-ui-toolbar {
+ // The -40 matches the padding-bottom on .ve-ce-documentNode to put the toolbar inside
+ // the editing area. The 2px of positive margin gives room for the blue border of the
+ // documentNode (via .mw-ui-input)
+ margin: -40px 2px 0 2px;
+ }
+
+ // Due to this being floated, it needs a matching top margin to still display inside the bar
+ .oo-ui-toolbar-actions {
+ margin-top: 38px;
+ }
+
+ .oo-ui-toolbar-bar {
+ // The default border is only appropriate in the default ve, with
+ // the toolbar above the editing surface.
+ border: none;
+ }
+}
diff --git a/Flow/modules/editor/editors/visualeditor/ui/actions/mw.flow.ve.ui.SwitchEditorAction.js b/Flow/modules/editor/editors/visualeditor/ui/actions/mw.flow.ve.ui.SwitchEditorAction.js
new file mode 100644
index 00000000..5d2c396e
--- /dev/null
+++ b/Flow/modules/editor/editors/visualeditor/ui/actions/mw.flow.ve.ui.SwitchEditorAction.js
@@ -0,0 +1,55 @@
+( function ( mw, OO, ve ) {
+
+/**
+ * Action to switch from VisualEditor to the Wikitext editing interface
+ * within Flow.
+ *
+ * @class
+ * @extends ve.ui.Action
+ *
+ * @constructor
+ * @param {ve.ui.Surface} surface Surface to act on
+ */
+mw.flow.ve.ui.SwitchEditorAction = function MwFlowVeUiSwitchEditorAction( surface ) {
+ // Parent constructor
+ ve.ui.Action.call( this, surface );
+};
+
+/* Inheritance */
+
+OO.inheritClass( mw.flow.ve.ui.SwitchEditorAction, ve.ui.Action );
+
+/* Static Properties */
+
+/**
+ * Name of this action
+ *
+ * @static
+ * @property
+ */
+mw.flow.ve.ui.SwitchEditorAction.static.name = 'flowSwitchEditor';
+
+/**
+ * List of allowed methods for the action.
+ *
+ * @static
+ * @property
+ */
+mw.flow.ve.ui.SwitchEditorAction.static.methods = [ 'switch' ];
+
+/* Methods */
+
+/**
+ * Switch to wikitext editing.
+ *
+ * @method
+ */
+mw.flow.ve.ui.SwitchEditorAction.prototype.switch = function () {
+ var $node = this.surface.$element.closest( 'form' ).find( 'textarea' );
+
+ mw.flow.editor.switchEditor( $node, 'none' );
+};
+
+ve.ui.actionFactory.register( mw.flow.ve.ui.SwitchEditorAction );
+
+}( mediaWiki, OO, ve ) );
diff --git a/Flow/modules/editor/editors/visualeditor/ui/contextitem/mw.flow.ve.ui.MentionContextItem.js b/Flow/modules/editor/editors/visualeditor/ui/contextitem/mw.flow.ve.ui.MentionContextItem.js
new file mode 100644
index 00000000..b3f79071
--- /dev/null
+++ b/Flow/modules/editor/editors/visualeditor/ui/contextitem/mw.flow.ve.ui.MentionContextItem.js
@@ -0,0 +1,58 @@
+( function ( mw, OO, ve ) {
+ 'use strict';
+
+ /**
+ * Context item for user mentions
+ *
+ * @class
+ * @extends ve.ui.ContextItem
+ *
+ * @param {ve.ui.Context} context Context item is in
+ * @param {ve.dm.Model} model Model item is related to
+ * @param {Object} config Configuration options
+ */
+ mw.flow.ve.ui.MentionContextItem = function FlowVeMentionContextItem( context, model, config ) {
+ mw.flow.ve.ui.MentionContextItem.parent.call( this, context, model, config );
+
+ this.$element.addClass( 'flow-ve-ui-mentionContextItem' );
+ };
+
+ OO.inheritClass( mw.flow.ve.ui.MentionContextItem, ve.ui.MWTransclusionContextItem );
+
+ // Static
+ mw.flow.ve.ui.MentionContextItem.static.name = 'flowMention';
+
+ mw.flow.ve.ui.MentionContextItem.static.icon = 'flow-mention';
+
+ mw.flow.ve.ui.MentionContextItem.static.label = OO.ui.deferMsg( 'flow-ve-mention-context-item-label' );
+
+ mw.flow.ve.ui.MentionContextItem.static.commandName = 'flowMention';
+
+ // Make sure the inspector uses an arrow, rather than trying to fit in the template.
+ // Wouldn't fit anyway, though, most likely.
+
+ mw.flow.ve.ui.MentionContextItem.static.embeddable = false;
+ /**
+ * @static
+ * @localdoc Sharing implementation with mw.flow.ve.ui.MentionInspectorTool
+ */
+ mw.flow.ve.ui.MentionContextItem.static.isCompatibleWith =
+ mw.flow.ve.ui.MentionInspectorTool.static.isCompatibleWith;
+
+
+ // Instance Methods
+
+ /**
+ * Returns a short description emphasizing the relevant data (currently just the user name)
+ *
+ * @return string User name
+ */
+ mw.flow.ve.ui.MentionContextItem.prototype.getDescription = function () {
+ var key = mw.flow.ve.ui.MentionInspector.static.templateParameterKey;
+
+ // Is there a more intuitive way to do this?
+ return this.model.element.attributes.mw.parts[0].template.params[key].wt;
+ };
+
+ ve.ui.contextItemFactory.register( mw.flow.ve.ui.MentionContextItem );
+} ( mediaWiki, OO, ve ) );
diff --git a/Flow/modules/editor/editors/visualeditor/ui/images/icons/flow-mention.svg b/Flow/modules/editor/editors/visualeditor/ui/images/icons/flow-mention.svg
new file mode 100644
index 00000000..b5a70dbb
--- /dev/null
+++ b/Flow/modules/editor/editors/visualeditor/ui/images/icons/flow-mention.svg
@@ -0,0 +1,83 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ version="1.1"
+ width="24"
+ height="24"
+ id="svg2">
+ <defs
+ id="defs4" />
+ <metadata
+ id="metadata7">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ <dc:title></dc:title>
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <g
+ transform="translate(0,-1028.3622)"
+ id="layer1">
+ <g
+ transform="translate(0,-4)"
+ id="g3048">
+ <g
+ transform="translate(-0.263648,0)"
+ id="g3061">
+ <g
+ transform="translate(-13.472704,1038.1992)"
+ id="g3">
+ <polygon
+ points="20,11 20,7 24,7 24,5 20,5 20,1 18,1 18,5 14,5 14,7 18,7 18,11 "
+ id="polygon5" />
+ </g>
+ <g
+ transform="translate(7.5,1035.8622)"
+ id="g5-1">
+ <g
+ id="g7"
+ style="fill:#000000;fill-opacity:1">
+ <g
+ id="g9"
+ style="fill:#000000;fill-opacity:1">
+ <g
+ id="g11"
+ style="fill:#000000;fill-opacity:1">
+ <path
+ d="M 9,9 C 6.7,9 4.8,7.1 4.8,4.7 4.8,2.3 6.7,0.5 9,0.5 c 2.3,0 4.2,1.9 4.2,4.2 C 13.2,7 11.4,9 9,9 z"
+ id="path13"
+ style="fill:#000000;fill-opacity:1" />
+ </g>
+ </g>
+ </g>
+ <g
+ id="g15"
+ style="fill:#000000;fill-opacity:1">
+ <g
+ id="g17"
+ style="fill:#000000;fill-opacity:1">
+ <g
+ id="g19"
+ style="fill:#000000;fill-opacity:1">
+ <path
+ d="m 16.5,16.5 -15,0 0,-0.6 c 0,-1.1 0.2,-2 0.5,-2.8 0.3,-0.8 0.8,-1.4 1.4,-2 C 4,10.6 4.8,10.2 5.7,9.9 L 6,9.8 6.4,10 c 0.8,0.5 1.7,0.8 2.7,0.8 0.9,0 1.9,-0.3 2.7,-0.8 L 12,9.8 12.3,9.9 c 0.9,0.3 1.6,0.7 2.3,1.2 0.6,0.5 1.1,1.2 1.4,2 0.3,0.8 0.5,1.7 0.5,2.8 z"
+ id="path21"
+ style="fill:#000000;fill-opacity:1" />
+ </g>
+ </g>
+ </g>
+ </g>
+ </g>
+ </g>
+ </g>
+</svg>
diff --git a/Flow/modules/editor/editors/visualeditor/ui/images/icons/flow-switch-editor.svg b/Flow/modules/editor/editors/visualeditor/ui/images/icons/flow-switch-editor.svg
new file mode 100644
index 00000000..80561ac8
--- /dev/null
+++ b/Flow/modules/editor/editors/visualeditor/ui/images/icons/flow-switch-editor.svg
@@ -0,0 +1,69 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ version="1.1"
+ width="24"
+ height="24"
+ id="svg2"
+ inkscape:version="0.48.4 r9939"
+ sodipodi:docname="flow-switch-editor.svg">
+ <sodipodi:namedview
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1"
+ objecttolerance="10"
+ gridtolerance="10"
+ guidetolerance="10"
+ inkscape:pageopacity="0"
+ inkscape:pageshadow="2"
+ inkscape:window-width="640"
+ inkscape:window-height="480"
+ id="namedview3262"
+ showgrid="false"
+ inkscape:zoom="9.8333333"
+ inkscape:cx="12"
+ inkscape:cy="7.9322034"
+ inkscape:window-x="0"
+ inkscape:window-y="24"
+ inkscape:window-maximized="0"
+ inkscape:current-layer="svg2" />
+ <defs
+ id="defs4" />
+ <metadata
+ id="metadata7">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ <dc:title />
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <g
+ transform="matrix(3.1730191,0,0,6.5378108,-0.51387626,-7.0405915)"
+ style="font-size:4px;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;text-align:start;line-height:125%;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#000000;fill-opacity:1;stroke:none;font-family:Sans;-inkscape-font-specification:Sans"
+ id="flowRoot3264">
+ <path
+ d="m 2.8339844,2.2369966 -2.01562502,0.7167969 2.01562502,0.7128906 0,0.3554688 -2.50390627,-0.9082031 0,-0.3242188 2.50390627,-0.9082031 0,0.3554687"
+ style=""
+ id="path3277" />
+ <path
+ d="m 4.2734375,1.289731 0.3320312,0 -1.0156249,3.2871094 -0.3320313,0 1.015625,-3.2871094"
+ style=""
+ id="path3279" />
+ <path
+ d="m 5.0332031,2.2369966 0,-0.3554687 2.5039063,0.9082031 0,0.3242188 -2.5039063,0.9082031 0,-0.3554688 L 7.0449219,2.9537935 5.0332031,2.2369966"
+ style=""
+ id="path3281" />
+ </g>
+</svg>
diff --git a/Flow/modules/editor/editors/visualeditor/ui/inspectors/mw.flow.ve.ui.MentionInspector.js b/Flow/modules/editor/editors/visualeditor/ui/inspectors/mw.flow.ve.ui.MentionInspector.js
new file mode 100644
index 00000000..e5d74c7f
--- /dev/null
+++ b/Flow/modules/editor/editors/visualeditor/ui/inspectors/mw.flow.ve.ui.MentionInspector.js
@@ -0,0 +1,372 @@
+( function ( $, mw, OO, ve ) {
+ 'use strict';
+
+ // Based partly on ve.ui.MWTemplateDialog
+ /**
+ * Inspector for editing Flow mentions. This is a friendly
+ * UI for a transclusion (e.g. {{ping}}, template varies by wiki).
+ *
+ * @class
+ * @extends ve.ui.NodeInspector
+ *
+ * @constructor
+ * @param {Object} [config] Configuration options
+ */
+ mw.flow.ve.ui.MentionInspector = function FlowVeMentionInspector( config ) {
+ mw.flow.ve.ui.MentionInspector.parent.call( this, config );
+
+ // this.selectedNode is the ve.dm.MWTransclusionNode, which we inherit
+ // from ve.ui.NodeInspector.
+ //
+ // The templateModel (used locally some places) is a sub-part of the transclusion
+ // model.
+ this.transclusionModel = null;
+ this.loaded = false;
+ this.altered = false;
+
+ this.targetInput = null;
+ this.errorWidget = null;
+ this.errorFieldsetLayout = null;
+ };
+
+ OO.inheritClass( mw.flow.ve.ui.MentionInspector, ve.ui.NodeInspector );
+
+ // Static
+
+ mw.flow.ve.ui.MentionInspector.static.name = 'flowMention';
+ mw.flow.ve.ui.MentionInspector.static.icon = 'flow-mention';
+ mw.flow.ve.ui.MentionInspector.static.title = OO.ui.deferMsg( 'flow-ve-mention-inspector-title' );
+ mw.flow.ve.ui.MentionInspector.static.modelClasses = [ ve.dm.MWTransclusionNode ];
+
+ mw.flow.ve.ui.MentionInspector.static.template = mw.config.get( 'wgFlowMentionTemplate' );
+ mw.flow.ve.ui.MentionInspector.static.templateParameterKey = '1'; // 1-indexed positional parameter
+
+ // Buttons
+ mw.flow.ve.ui.MentionInspector.static.actions = [
+ {
+ action: 'remove',
+ label: OO.ui.deferMsg( 'flow-ve-mention-inspector-remove-label' ),
+ flags: ['destructive'],
+ modes: 'edit'
+ }
+ ].concat( mw.flow.ve.ui.MentionInspector.parent.static.actions );
+
+ // Instance Methods
+
+ /**
+ * Handle changes to the input widget
+ */
+ mw.flow.ve.ui.MentionInspector.prototype.onTargetInputChange = function () {
+ var templateModel, parameterModel, key, value, inspector;
+
+ this.hideErrors();
+
+ key = mw.flow.ve.ui.MentionInspector.static.templateParameterKey;
+ value = this.targetInput.getValue();
+ inspector = this;
+
+ this.pushPending();
+ this.targetInput.isValid().done( function ( isValid ) {
+ if ( isValid ) {
+ // After the updates are done, we'll get onTransclusionModelChange
+ templateModel = inspector.transclusionModel.getParts()[0];
+ if ( templateModel.hasParameter( key ) ) {
+ parameterModel = templateModel.getParameter( key );
+ parameterModel.setValue( value );
+ } else {
+ parameterModel = new ve.dm.MWParameterModel(
+ templateModel,
+ key,
+ value
+ );
+ templateModel.addParameter( parameterModel );
+ }
+ } else {
+ // Disable save button
+ inspector.setApplicableStatus();
+ }
+ } ).always( function () {
+ inspector.popPending();
+ } );
+ };
+
+ /**
+ * Handle the transclusion becoming ready
+ */
+ mw.flow.ve.ui.MentionInspector.prototype.onTransclusionReady = function () {
+ var templateModel, key;
+
+ key = mw.flow.ve.ui.MentionInspector.static.templateParameterKey;
+
+ this.loaded = true;
+ this.$element.addClass( 'flow-ve-ui-mentionInspector-ready' );
+ this.popPending();
+
+ templateModel = this.transclusionModel.getParts()[0];
+ if ( templateModel.hasParameter( key ) ) {
+ this.targetInput.setValue( templateModel.getParameter( key ).getValue() );
+ }
+ };
+
+ /**
+ * Handles the transclusion model changing. This should only happen when we change
+ * the parameter, then get a callback.
+ */
+ mw.flow.ve.ui.MentionInspector.prototype.onTransclusionModelChange = function () {
+ if ( this.loaded ) {
+ this.altered = true;
+ this.setApplicableStatus();
+ }
+ };
+
+ /**
+ * Sets the abiliities based on the current status
+ *
+ * If it's empty or invalid, it can not be inserted or updated.
+ */
+ mw.flow.ve.ui.MentionInspector.prototype.setApplicableStatus = function () {
+ var parts = this.transclusionModel.getParts(),
+ templateModel = parts[0],
+ key = mw.flow.ve.ui.MentionInspector.static.templateParameterKey,
+ inspector = this;
+
+ // The template should always be there; the question is whether the first/only
+ // positional parameter is.
+ //
+ // If they edit an existing mention, and make it invalid, they should be able
+ // to cancel, but not save.
+ if ( templateModel.hasParameter( key ) ) {
+ this.pushPending();
+ this.targetInput.isValid().done( function ( isValid ) {
+ inspector.actions.setAbilities( { done: isValid } );
+ } ).always( function () {
+ inspector.popPending();
+ } );
+ } else {
+ inspector.actions.setAbilities( { done: false } );
+ }
+ };
+
+ /**
+ * Initialize UI of inspector
+ */
+ mw.flow.ve.ui.MentionInspector.prototype.initialize = function () {
+ var flowBoard, overlay, indicatorWidget;
+
+ mw.flow.ve.ui.MentionInspector.parent.prototype.initialize.call( this );
+
+ // I would much prefer to use dependency injection to get the list of topic posters
+ // into the inspector, but I haven't been able to figure out how to pass it through
+ // yet.
+
+ flowBoard = mw.flow.getPrototypeMethod( 'board', 'getInstanceByElement' )(
+ this.$element
+ );
+
+ // Properties
+ overlay = this.manager.getOverlay();
+ this.targetInput = new mw.flow.ve.ui.MentionTargetInputWidget( {
+ $: this.$,
+ $overlay: overlay ? overlay.$element : this.$frame,
+ topicPosters: flowBoard.getTopicPosters( this.$element )
+ } );
+ indicatorWidget = new OO.ui.IndicatorWidget( {
+ indicator: 'alert'
+ } );
+ this.errorWidget = new OO.ui.FieldLayout( indicatorWidget, {
+ align: 'inline'
+ } );
+ this.errorFieldsetLayout = new OO.ui.FieldsetLayout( {
+ items: [
+ this.errorWidget
+ ]
+ } );
+
+ // Initialization
+ this.$content.addClass( 'flow-ve-ui-mentionInspector-content' );
+ this.errorFieldsetLayout.toggle( false );
+ this.form.addItems( [
+ this.errorFieldsetLayout
+ ] );
+ this.form.$element.append( this.targetInput.$element );
+ };
+
+ /**
+ * @inheritdoc
+ */
+ mw.flow.ve.ui.MentionInspector.prototype.getActionProcess = function ( action ) {
+ var surfaceModel = this.getFragment().getSurface(), dfd, inspector;
+
+ if ( action === 'done' ) {
+ dfd = $.Deferred();
+ inspector = this;
+
+ this.targetInput.isValid().done( function ( isValid ) {
+ var transclusionModelPlain;
+
+ if ( isValid ) {
+ transclusionModelPlain = inspector.transclusionModel.getPlainObject();
+
+ // Should be either null or the right template
+ if ( inspector.selectedNode instanceof ve.dm.MWTransclusionNode ) {
+ inspector.transclusionModel.updateTransclusionNode( surfaceModel, inspector.selectedNode );
+ } else if ( transclusionModelPlain !== null ) {
+ inspector.fragment = inspector.getFragment().collapseToEnd();
+ inspector.transclusionModel.insertTransclusionNode( inspector.getFragment() );
+ surfaceModel.setSelection( surfaceModel.getSelection().collapseToEnd() );
+ }
+
+ inspector.close( { action: action } );
+ dfd.resolve();
+ } else {
+ dfd.reject( new OO.ui.Error( OO.ui.msg( 'flow-ve-mention-inspector-invalid-user', inspector.targetInput.getValue() ) ) );
+ }
+ } );
+
+ return new OO.ui.Process( dfd.promise() );
+ } else if ( action === 'remove' ) {
+ return new OO.ui.Process( function () {
+ var doc, nodeRange;
+
+ doc = surfaceModel.getDocument();
+ nodeRange = this.selectedNode.getOuterRange();
+
+ surfaceModel.change(
+ ve.dm.Transaction.newFromRemoval( doc, nodeRange )
+ );
+
+ this.close( { action: action } );
+ }, this );
+ }
+
+ return mw.flow.ve.ui.MentionInspector.parent.prototype.getActionProcess.call( this, action );
+ };
+
+ // Technically, these are private. However, it's necessary to override them (and not call
+ // the parent), since otherwise this UI (which was probably designed for dialogs) does not fit the inspector.
+ // Only handles on error at a time for now.
+ //
+ // It would be nice to implement a general solution for this that covers all inspectors (or
+ // maybe a mixin for inline errors next to form elements).
+ /**
+ * @inherit
+ */
+ mw.flow.ve.ui.MentionInspector.prototype.showErrors = function ( errors ) {
+ var errorText;
+
+ if ( errors instanceof OO.ui.Error ) {
+ errors = [errors];
+ }
+
+ errorText = errors[0].getMessageText();
+ this.errorWidget.setLabel( errorText );
+ this.errorFieldsetLayout.toggle( true );
+ this.setSize( 'large' );
+ };
+
+ /**
+ * @inherit
+ */
+ mw.flow.ve.ui.MentionInspector.prototype.hideErrors = function () {
+ this.errorFieldsetLayout.toggle( false );
+ this.errorWidget.setLabel( '' );
+ this.setSize( 'medium' );
+ };
+
+ /**
+ * Pre-populate the username based on the node
+ *
+ * @param {Object} [data] Inspector initial data
+ */
+ mw.flow.ve.ui.MentionInspector.prototype.getSetupProcess = function ( data ) {
+ return mw.flow.ve.ui.MentionInspector.parent.prototype.getSetupProcess.call( this, data )
+ .next( function () {
+ var templateModel, promise;
+
+ this.loaded = false;
+ this.altered = false;
+ // MWTransclusionModel has some unnecessary behavior for our use
+ // case, mainly templatedata lookups.
+ this.transclusionModel = new ve.dm.MWTransclusionModel();
+
+ // Events
+ this.transclusionModel.connect( this, {
+ change: 'onTransclusionModelChange'
+ } );
+
+ this.targetInput.connect( this, {
+ change: 'onTargetInputChange'
+ } );
+
+ // Initialization
+ if ( !this.selectedNode ) {
+ this.actions.setMode( 'insert' );
+ templateModel = ve.dm.MWTemplateModel.newFromName(
+ this.transclusionModel,
+ mw.flow.ve.ui.MentionInspector.static.template
+ );
+ promise = this.transclusionModel.addPart( templateModel );
+ } else {
+ this.actions.setMode( 'edit' );
+
+ // Load existing ping
+ promise = this.transclusionModel
+ .load( ve.copy( this.selectedNode.getAttribute( 'mw' ) ) );
+ }
+
+ // Don't allow saving until we're sure it's valid.
+ this.actions.setAbilities( { done: false } );
+ this.pushPending();
+ promise.always( this.onTransclusionReady.bind( this ) );
+ }, this );
+ };
+
+ /**
+ * @inheritdoc
+ */
+ mw.flow.ve.ui.MentionInspector.prototype.getReadyProcess = function ( data ) {
+ return mw.flow.ve.ui.MentionInspector.parent.prototype.getReadyProcess.call( this, data )
+ .next( function () {
+ this.targetInput.focus();
+ }, this );
+ };
+
+ /**
+ * @inheritdoc
+ */
+ mw.flow.ve.ui.MentionInspector.prototype.getTeardownProcess = function ( data ) {
+ data = data || {};
+ return mw.flow.ve.ui.MentionInspector.parent.prototype.getTeardownProcess.call( this, data )
+ .first( function () {
+ // Cleanup
+ this.$element.removeClass( 'flow-ve-ui-mentionInspector-ready' );
+ this.transclusionModel.disconnect( this );
+ this.transclusionModel.abortRequests();
+ this.transclusionModel = null;
+
+ this.targetInput.disconnect( this );
+
+ this.targetInput.setValue( '' );
+ }, this );
+ };
+
+ /**
+ * Gets the transclusion node representing this mention
+ *
+ * @param {Object} [data] Inspector opening data
+ * @return {ve.dm.Node} Selected node
+ */
+ mw.flow.ve.ui.MentionInspector.prototype.getSelectedNode = function () {
+ // Checks the model class
+ var node = mw.flow.ve.ui.MentionInspector.parent.prototype.getSelectedNode.call( this );
+ if ( node !== null ) {
+ if ( node.isSingleTemplate( mw.flow.ve.ui.MentionInspector.static.template ) ) {
+ return node;
+ }
+ }
+
+ return null;
+ };
+
+ ve.ui.windowFactory.register( mw.flow.ve.ui.MentionInspector );
+} ( jQuery, mediaWiki, OO, ve ) );
diff --git a/Flow/modules/editor/editors/visualeditor/ui/mw.flow.ve.ui.Icons.less b/Flow/modules/editor/editors/visualeditor/ui/mw.flow.ve.ui.Icons.less
new file mode 100644
index 00000000..4e38f7cb
--- /dev/null
+++ b/Flow/modules/editor/editors/visualeditor/ui/mw.flow.ve.ui.Icons.less
@@ -0,0 +1,9 @@
+@import 'mediawiki.mixins';
+
+.oo-ui-icon-flow-mention {
+ .background-image('images/icons/flow-mention.svg');
+}
+
+.oo-ui-icon-flow-switch-editor {
+ .background-image('images/icons/flow-switch-editor.svg');
+}
diff --git a/Flow/modules/editor/editors/visualeditor/ui/tools/mw.flow.ve.ui.MentionInspectorTool.js b/Flow/modules/editor/editors/visualeditor/ui/tools/mw.flow.ve.ui.MentionInspectorTool.js
new file mode 100644
index 00000000..641f9fbf
--- /dev/null
+++ b/Flow/modules/editor/editors/visualeditor/ui/tools/mw.flow.ve.ui.MentionInspectorTool.js
@@ -0,0 +1,40 @@
+( function ( mw, OO, ve ) {
+ 'use strict';
+
+ /**
+ * Tool for user mentions
+ *
+ * @class
+ * @extends ve.ui.InspectorTool
+ *
+ * @constructor
+ * @param {OO.ui.ToolGroup} toolGroup
+ * @param {Object} [config] Configuration options
+ */
+
+ mw.flow.ve.ui.MentionInspectorTool = function FlowVeMentionInspectorTool( toolGroup, config ) {
+ mw.flow.ve.ui.MentionInspectorTool.parent.call( this, toolGroup, config );
+ };
+
+ OO.inheritClass( mw.flow.ve.ui.MentionInspectorTool, ve.ui.InspectorTool );
+
+ // Static
+ mw.flow.ve.ui.MentionInspectorTool.static.commandName = 'flowMention';
+ mw.flow.ve.ui.MentionInspectorTool.static.name = 'flowMention';
+ mw.flow.ve.ui.MentionInspectorTool.static.icon = 'flow-mention';
+ mw.flow.ve.ui.MentionInspectorTool.static.title = OO.ui.deferMsg( 'flow-ve-mention-tool-title' );
+
+ mw.flow.ve.ui.MentionInspectorTool.static.template = mw.flow.ve.ui.MentionInspector.static.template;
+
+ /**
+ * Checks whether the model represents a user mention
+ *
+ * @return boolean
+ */
+ mw.flow.ve.ui.MentionInspectorTool.static.isCompatibleWith = function ( model ) {
+ return model instanceof ve.dm.MWTransclusionNode &&
+ model.isSingleTemplate( mw.flow.ve.ui.MentionInspectorTool.static.template );
+ };
+
+ ve.ui.toolFactory.register( mw.flow.ve.ui.MentionInspectorTool );
+} ( mediaWiki, OO, ve ) );
diff --git a/Flow/modules/editor/editors/visualeditor/ui/tools/mw.flow.ve.ui.SwitchEditorTool.js b/Flow/modules/editor/editors/visualeditor/ui/tools/mw.flow.ve.ui.SwitchEditorTool.js
new file mode 100644
index 00000000..865f8883
--- /dev/null
+++ b/Flow/modules/editor/editors/visualeditor/ui/tools/mw.flow.ve.ui.SwitchEditorTool.js
@@ -0,0 +1,29 @@
+( function ( mw, OO, ve ) {
+ 'use strict';
+
+ /**
+ * Tool for switching editors
+ *
+ * @class
+ * @extends ve.ui.Tool
+ *
+ * @constructor
+ * @param {OO.ui.ToolGroup} toolGroup
+ * @param {Object} [config] Configuration options
+ */
+
+ mw.flow.ve.ui.SwitchEditorTool = function FlowVeSwitchEditorTool( toolGroup, config ) {
+ mw.flow.ve.ui.SwitchEditorTool.parent.call( this, toolGroup, config );
+ };
+
+ OO.inheritClass( mw.flow.ve.ui.SwitchEditorTool, ve.ui.Tool );
+
+ // Static
+ mw.flow.ve.ui.SwitchEditorTool.static.commandName = 'flowSwitchEditor';
+ mw.flow.ve.ui.SwitchEditorTool.static.name = 'flowSwitchEditor';
+ mw.flow.ve.ui.SwitchEditorTool.static.icon = 'flow-switch-editor';
+ mw.flow.ve.ui.SwitchEditorTool.static.title = OO.ui.deferMsg( 'flow-ve-switch-editor-tool-title' );
+
+
+ ve.ui.toolFactory.register( mw.flow.ve.ui.SwitchEditorTool );
+} ( mediaWiki, OO, ve ) );
diff --git a/Flow/modules/editor/editors/visualeditor/ui/widgets/mw.flow.ve.ui.MentionTargetInputWidget.js b/Flow/modules/editor/editors/visualeditor/ui/widgets/mw.flow.ve.ui.MentionTargetInputWidget.js
new file mode 100644
index 00000000..eb9870e5
--- /dev/null
+++ b/Flow/modules/editor/editors/visualeditor/ui/widgets/mw.flow.ve.ui.MentionTargetInputWidget.js
@@ -0,0 +1,170 @@
+( function ( $, mw, OO, ve ) {
+ 'use strict';
+
+ /**
+ * Creates an input widget with auto-completion for users to be mentioned
+ *
+ * @class
+ * @extends oo.ui.TextInputWidget
+ * @mixins OO.ui.LookupElement
+ *
+ * @constructor
+ * @param {Object} [config] Configuration options
+ * @param {Array} [config.topicPosters] Array of usernames representing posters to this thread,
+ * without duplicates.
+ */
+ mw.flow.ve.ui.MentionTargetInputWidget = function FlowVeUiMentionTargetInputWidget( config ) {
+ mw.flow.ve.ui.MentionTargetInputWidget.parent.call( this, config );
+
+ // Mixin constructor
+ config.allowSuggestionsWhenEmpty = true;
+ OO.ui.LookupElement.call( this, config );
+
+ // Properties
+ // Exclude anonymous users, since they do not receive pings.
+ this.loggedInTopicPosters = $.grep( config.topicPosters || [], function ( poster ) {
+ return !mw.util.isIPAddress( poster, false );
+ } );
+ this.username = null;
+ // Username to validity promise (promise resolves with true/false for existent/non-existent
+ this.isUsernameValidCache = {};
+
+ this.$element.addClass( 'flow-ve-ui-mentionTargetInputWidget' );
+ this.lookupMenu.$element.addClass( 'flow-ve-ui-mentionTargetInputWidget-menu' );
+ };
+
+ OO.inheritClass( mw.flow.ve.ui.MentionTargetInputWidget, OO.ui.TextInputWidget );
+
+ OO.mixinClass( mw.flow.ve.ui.MentionTargetInputWidget, OO.ui.LookupElement );
+
+ /**
+ * @inheritdoc
+ */
+ mw.flow.ve.ui.MentionTargetInputWidget.prototype.isValid = function () {
+ var api = new mw.Api(),
+ dfd = $.Deferred(),
+ promise = dfd.promise(),
+ username = this.getValue(),
+ widget = this,
+ isValid;
+
+ if ( $.trim( username ) === '' ) {
+ dfd.resolve( false );
+ return promise;
+ }
+
+ username = username[0].toUpperCase() + username.slice( 1 );
+ if ( this.isUsernameValidCache[username] !== undefined ) {
+ return this.isUsernameValidCache[username];
+ }
+
+ // Note that we delete this below if it turns out to get an error.
+ this.isUsernameValidCache[username] = promise;
+
+ api.get( {
+ action: 'query',
+ list: 'users',
+ ususers: username
+ } ).done( function ( resp ) {
+ if (
+ resp &&
+ resp.query &&
+ resp.query.users &&
+ resp.query.users.length > 0
+ ) {
+ // This is the normal path for either existent or non-existent users.
+ isValid = resp.query.users[0].missing === undefined;
+ dfd.resolve( isValid );
+ } else {
+ // This means part of the response is missing, which again shouldn't
+ // happen (it could for empty string user, but we're not supposed to
+ // send the request at all then). See explanation under fail.
+ dfd.resolve( true );
+ delete widget.isUsernameValidCache[username];
+ }
+ } ).fail( function () {
+ // This should only happen on error cases. Even if the user doesn't exist,
+ // we should still enter done. Since this is an unforseen error, return true
+ // so we don't block submission, and evict cache.
+ dfd.resolve( true );
+ delete widget.isUsernameValidCache[username];
+ } );
+
+ return promise;
+ };
+
+ /**
+ * Gets a promise representing the auto-complete.
+ * Right now, the auto-complete is based on the users who have already posted to the topic.
+ *
+ * It does a case-insensitive search for a string (anywhere in the poster's username)
+ * matching what the user has typed in so far.
+ *
+ * E.g. if one of the posters is "Mary Jane Smith", that will be a suggestion if the user has
+ * entered e.g. "Mary", "jane", or 'Smi'.
+ *
+ * @method
+ * @returns {jQuery.Promise}
+ */
+ mw.flow.ve.ui.MentionTargetInputWidget.prototype.getLookupRequest = function () {
+ var abortObject = { abort: $.noop }, dfd = $.Deferred(),
+ lowerValue = this.value.toLowerCase(), matches;
+
+ matches = $.grep( this.loggedInTopicPosters, function ( poster ) {
+ return poster.toLowerCase().indexOf( lowerValue ) >= 0;
+ } );
+
+ dfd.resolve( matches );
+ return dfd.promise( abortObject );
+ };
+
+ /**
+ * @inheritdoc
+ */
+ mw.flow.ve.ui.MentionTargetInputWidget.prototype.getLookupCacheDataFromResponse = function ( data ) {
+ return data;
+ };
+
+ /**
+ * Converts the raw data to UI objects
+ *
+ * @param Array list of users
+ * @return {OO.ui.MenuOptionWidget[]} Menu items
+ */
+ mw.flow.ve.ui.MentionTargetInputWidget.prototype.getLookupMenuOptionsFromData = function ( users ) {
+ var items = [], user, i;
+
+ for ( i = 0; i < users.length; i++ ) {
+ user = users[i];
+
+ items.push( new OO.ui.MenuOptionWidget( {
+ $: this.lookupMenu.$,
+ data: user,
+ label: user
+ } ) );
+ }
+
+ return items;
+ };
+
+ // Based on ve.ui.MWLinkTargetInputWidget.prototype.initializeLookupMenuSelection
+ /**
+ * @inheritdoc
+ */
+ mw.flow.ve.ui.MentionTargetInputWidget.prototype.initializeLookupMenuSelection = function () {
+ var item;
+ if ( this.username ) {
+ this.lookupMenu.selectItem( this.lookupMenu.getItemFromData( this.username ) );
+ }
+
+ item = this.lookupMenu.getSelectedItem();
+ if ( !item ) {
+ OO.ui.LookupElement.prototype.initializeLookupMenuSelection.call( this );
+ }
+
+ item = this.lookupMenu.getSelectedItem();
+ if ( item ) {
+ this.username = item.getData();
+ }
+ };
+} ( jQuery, mediaWiki, OO, ve ) );
diff --git a/Flow/modules/editor/ext.flow.editor.js b/Flow/modules/editor/ext.flow.editor.js
new file mode 100644
index 00000000..95af71d7
--- /dev/null
+++ b/Flow/modules/editor/ext.flow.editor.js
@@ -0,0 +1,272 @@
+( function ( $, mw ) {
+ 'use strict';
+
+ // This is more of an EditorFacade/EditorDispatcher or something, and should be renamed.
+ // It's not the base class, nor is it an actual editor.
+ mw.flow.editor = {
+ /**
+ * Specific editor to be used.
+ *
+ * @property {object}
+ */
+ editor: null,
+
+ /**
+ * Array of target instances.
+ *
+ * The first entry is null to make sure that the reference saved to a data-
+ * attribute is never index 0; a 0-value will make :data(flow-editor)
+ * selector not find a result.
+ *
+ * @property {object}
+ */
+ editors: [null],
+
+ init: function () {
+ var editorList = mw.config.get( 'wgFlowEditorList' ),
+ index = editorList.indexOf( mw.user.options.get( 'flow-editor' ) );
+
+ // determine editor instance to use, depending on availability
+ mw.flow.editor.loadEditor( index );
+ },
+
+ loadEditor: function ( editorIndex ) {
+ var editorList = mw.config.get( 'wgFlowEditorList' ),
+ editor;
+
+ if ( !editorIndex || editorIndex < 0 || editorIndex >= editorList.length ) {
+ editorIndex = 0;
+ }
+
+ if ( editorList[editorIndex] ) {
+ editor = editorList[editorIndex];
+ } else {
+ editor = 'none';
+ }
+
+ mw.loader.using( 'ext.flow.editors.' + editor, function () {
+ // Some editors only work under certain circumstances
+ if ( !mw.flow.editors[editor].static.isSupported() ) {
+ mw.flow.editor.loadEditor( editorIndex + 1 );
+ } else {
+ mw.flow.editor.editor = mw.flow.editors[editor];
+ }
+ } );
+ },
+
+ /**
+ * @param {jQuery} $node
+ * @param {string} [content] Existing content to load, in any format
+ * @param {string} [contentFormat] The format that content is in, or null (defaults to wikitext)
+ * @return {jQuery.Promise} Will resolve once editor instance is loaded
+ */
+ load: function ( $node, content, contentFormat ) {
+ /**
+ * When calling load(), init() may not yet have completed loading the
+ * dependencies. To make sure it doesn't break, this will in interval,
+ * check for it and only start loading once initialization is complete.
+ */
+ var load = function ( $node, content, contentFormat ) {
+ if ( mw.flow.editor.editor === null ) {
+ return;
+ } else {
+ clearTimeout( interval );
+ }
+
+ if ( contentFormat === undefined ) {
+ contentFormat = 'wikitext';
+ }
+
+ // quit early if editor is already loaded
+ if ( mw.flow.editor.getEditor( $node ) ) {
+ deferred.resolve();
+ return;
+ }
+
+ mw.flow.parsoid.convert( contentFormat, mw.flow.editor.getFormat( $node ), content )
+ .done( function( content ) {
+ mw.flow.editor.create( $node, content );
+ deferred.resolve();
+ })
+ .fail( function() {
+ deferred.reject();
+ });
+ },
+ deferred = $.Deferred(),
+ interval = setInterval( $.proxy( load, this, $node, content, contentFormat ), 10 );
+
+ return deferred.promise();
+ },
+
+ /**
+ * @param {jQuery} $node
+ */
+ destroy: function ( $node ) {
+ var editor = mw.flow.editor.getEditor( $node );
+
+ if ( editor ) {
+ editor.destroy();
+
+ // destroy reference
+ mw.flow.editor.editors[$.inArray( editor, mw.flow.editor.editors )] = null;
+ $node
+ .removeData( 'flow-editor' )
+ .show()
+ .closest( '.flow-editor' ).removeClass( 'flow-editor-' + editor.constructor.static.name );
+ }
+ },
+
+ /**
+ * Get the editor's text format.
+ *
+ * @param {jQuery} $node
+ * @return {string}
+ */
+ getFormat: function ( $node ) {
+ var editor;
+
+ if ( $node ) {
+ editor = mw.flow.editor.getEditor( $node );
+ }
+
+ if ( editor ) {
+ return editor.constructor.static.format;
+ } else {
+ return mw.flow.editor.editor.static.format;
+ }
+ },
+
+ /**
+ * Get the raw, unconverted, content, in the current editor's format.
+ *
+ * @param {jQuery} $node
+ * @return {string}
+ */
+ getRawContent: function ( $node ) {
+ var editor = mw.flow.editor.getEditor( $node );
+ return editor.getRawContent() || '';
+ },
+
+ /**
+ * Initialize an editor object with given content & tie it to the given node.
+ *
+ * @param {jQuery} $node
+ * @param {string} content
+ * @return {object}
+ */
+ create: function ( $node, content ) {
+ $node.data( 'flow-editor', mw.flow.editor.editors.length )
+ .closest( '.flow-editor' ).addClass( 'flow-editor-' + mw.flow.editor.editor.static.name );
+
+ mw.flow.editor.editors.push( new mw.flow.editor.editor( $node, content ) );
+ return mw.flow.editor.getEditor( $node );
+ },
+
+ /**
+ * Returns editor object associated with a given node.
+ *
+ * @param {jQuery} $node
+ * @return {object}
+ */
+ getEditor: function ( $node ) {
+ return mw.flow.editor.editors[$node.data( 'flow-editor' )];
+ },
+
+ /**
+ * Returns true if the given $node has an associated editor instance.
+ *
+ * @param {jQuery} $node
+ * @return {bool}
+ */
+ exists: function ( $node ) {
+ return mw.flow.editor.editors.hasOwnProperty( $node.data( 'flow-editor' ) );
+ },
+
+ /**
+ * Changes the default editor to desiredEditor and converts $node to that
+ * type of editor.
+ *
+ * @todo Should support $node containing multiple editing nodes, such
+ * as selecting all active editors in the page and switching all of
+ * them to the desiredEditor. Currently you will need to $node.each()
+ * and call switchEditor for each iteration.
+ *
+ * @param {jQuery} $node
+ * @param {string} desiredEditor
+ * @return {jQuery.Promise} Will resolve once editor instance is loaded
+ */
+ switchEditor: function ( $node, desiredEditor ) {
+ var content, format,
+ editorList = mw.config.get( 'wgFlowEditorList' ),
+ editor = mw.flow.editor.getEditor( $node ),
+ deferred = $.Deferred(),
+ performSwitch = function () {
+ if ( mw.flow.editors[desiredEditor].static.isSupported() ) {
+ content = editor.getRawContent();
+ format = editor.constructor.static.format;
+
+ mw.flow.editor.editor = mw.flow.editors[desiredEditor];
+
+ mw.flow.editor.destroy( $node );
+ mw.flow.editor.load( $node, content, format );
+
+ deferred.resolve();
+ } else {
+ deferred.reject( 'editor-not-supported' );
+ }
+ };
+
+ if ( !editor ) {
+ // $node is not an editor
+ deferred.reject( 'not-an-editor' );
+ } else if ( editorList.indexOf( desiredEditor ) === -1 ) {
+ // desiredEditor does not exist
+ deferred.reject( 'unknown-editor-type' );
+ } else {
+ mw.loader.using( 'ext.flow.editors.' + desiredEditor ).then(
+ performSwitch,
+ function() {
+ deferred.reject( 'fail-loading-editor' );
+ }
+ );
+ }
+
+ deferred.then(
+ function() {
+ if ( !mw.user.isAnon() ) {
+ // update the user preferences; no preferences for anons
+ new mw.Api().saveOption( 'flow-editor', desiredEditor );
+ // ensure we also see that preference in the current page
+ mw.user.options.set( 'flow-editor', desiredEditor );
+ }
+ },
+ function( rejectionCode ) {
+ mw.flow.debug( '[switchEditor] Could not switch to ' + desiredEditor + ' : ' + rejectionCode );
+ }
+ );
+
+ return deferred.promise();
+ },
+
+ focus: function( $node ) {
+ var editor = mw.flow.editor.getEditor( $node );
+
+ if ( editor && editor.focus ) {
+ editor.focus();
+ } else {
+ $node.focus();
+ }
+ },
+
+ moveCursorToEnd : function( $node ) {
+ var editor = mw.flow.editor.getEditor( $node );
+
+ if ( editor && editor.moveCursorToEnd ) {
+ return editor.moveCursorToEnd();
+ } else {
+ $node.selectRange( $node.val().length );
+ }
+ }
+ };
+ $( mw.flow.editor.init );
+} ( jQuery, mediaWiki ) );
diff --git a/Flow/modules/editor/ext.flow.parsoid.js b/Flow/modules/editor/ext.flow.parsoid.js
new file mode 100644
index 00000000..837c442c
--- /dev/null
+++ b/Flow/modules/editor/ext.flow.parsoid.js
@@ -0,0 +1,46 @@
+( function ( $, mw ) {
+ 'use strict';
+
+ mw.flow = mw.flow || {}; // create mw.flow globally
+ mw.flow.parsoid = {
+ /**
+ * @param {string} from Input format: html|wikitext
+ * @param {string} to Desired output format: html|wikitext
+ * @param {string} content Content to convert
+ * @param {string} [title] Page title
+ * @return {jQuery.Promise} Will resolve with converted content as data
+ */
+ convert: function ( from, to, content, title ) {
+ var deferred = $.Deferred(),
+ api = new mw.Api();
+
+ if ( content === '' ) {
+ return deferred.resolve( content );
+ }
+
+ if ( from === to ) {
+ return deferred.resolve( content );
+ }
+
+ if ( !title ) {
+ title = mw.config.get( 'wgPageName' );
+ }
+
+ api.post( {
+ action: 'flow-parsoid-utils',
+ from: from,
+ to: to,
+ content: content,
+ title: title
+ } )
+ .done( function ( data ) {
+ deferred.resolve( data['flow-parsoid-utils'].content );
+ } )
+ .fail( function () {
+ deferred.reject();
+ } );
+
+ return deferred.promise();
+ }
+ };
+} ( jQuery, mediaWiki ) );
diff --git a/Flow/modules/engine/components/board/base/flow-board-api-events.js b/Flow/modules/engine/components/board/base/flow-board-api-events.js
new file mode 100644
index 00000000..015cc5c9
--- /dev/null
+++ b/Flow/modules/engine/components/board/base/flow-board-api-events.js
@@ -0,0 +1,920 @@
+/*!
+ * @todo break this down into mixins for each callback section (eg. post actions, read topics)
+ */
+
+( function ( $, mw ) {
+ /**
+ * Binds API events to FlowBoardComponent
+ * @param {jQuery} $container
+ * @extends FlowComponent
+ * @constructor
+ */
+ function FlowBoardComponentApiEventsMixin( $container ) {
+ // Bind event callbacks
+ this.bindNodeHandlers( FlowBoardComponentApiEventsMixin.UI.events );
+ }
+ OO.initClass( FlowBoardComponentApiEventsMixin );
+
+ /** Event handlers are stored here, but are registered in the constructor */
+ FlowBoardComponentApiEventsMixin.UI = {
+ events: {
+ globalApiPreHandlers: {},
+ apiPreHandlers: {},
+ apiHandlers: {}
+ }
+ };
+
+ //
+ // pre-api callback handlers, to do things before the API call
+ //
+
+ /**
+ * Textareas are turned into editor objects, so we can't rely on
+ * textareas to properly return the real content we're looking for (the
+ * real editor can be anything, depending on the type of editor)
+ *
+ * @param {Event} event
+ * @return {Object}
+ */
+ FlowBoardComponentApiEventsMixin.UI.events.globalApiPreHandlers.prepareEditor = function ( event ) {
+ var $textareas = $( this ).closest( 'form' ).find( 'textarea' ),
+ override = {};
+
+ $textareas.each( function() {
+ var $editor = $( this );
+
+ // Doublecheck that this textarea is actually an editor instance
+ // (the editor may have added a textarea itself...)
+ if ( mw.flow.editor && mw.flow.editor.exists( $editor ) ) {
+ override[$editor.attr( 'name' )] = mw.flow.editor.getRawContent( $editor );
+ override.flow_format = mw.flow.editor.getFormat( $editor );
+ }
+
+ // @todo: we have to make sure we get rid of all unwanted data
+ // in the form (whatever "editor instance" may have added)
+ // because we'll $form.serializeArray() to get the content.
+ // This is currently not an issue since we only have "none"
+ // editor type, which just uses the existing textarea. Someday,
+ // however, we may have VE (or wikieditor or ...) which could
+ // add its own nodes, which may be picked up by serializeArray()
+ } );
+
+ return override;
+ };
+
+ /**
+ * When presented with an error conflict, the conflicting content can
+ * subsequently be re-submitted (to overwrite the conflicting content)
+ * This will prepare the data-to-be-submitted so that the override is
+ * submitted against the most current revision ID.
+ * @param {Event} event
+ * @return {Object}
+ */
+ FlowBoardComponentApiEventsMixin.UI.events.globalApiPreHandlers.prepareEditConflict = function ( event ) {
+ var $form = $( this ).closest( 'form' ),
+ prevRevisionId = $form.data( 'flow-prev-revision' );
+
+ if ( !prevRevisionId ) {
+ return {};
+ }
+
+ // Get rid of the temp-saved new revision ID
+ $form.removeData( 'flow-prev-revision' );
+
+ /*
+ * This is prev_revision in "generic" form. Each Flow API has its
+ * own unique prefix, which (in FlowApi.prototype.getQueryMap) will
+ * be properly applied for the respective API call; e.g.
+ * epprev_revision (for edit post)
+ */
+ return {
+ flow_prev_revision: prevRevisionId
+ };
+ };
+
+ /**
+ * Before activating header, sends an overrideObject to the API to modify the request params.
+ * @param {Event} event
+ * @return {Object}
+ */
+ FlowBoardComponentApiEventsMixin.UI.events.apiPreHandlers.activateEditHeader = function () {
+ return {
+ submodule: 'view-header', // href submodule is edit-header
+ vhcontentFormat: 'wikitext' // href does not have this param
+ };
+ };
+
+ /**
+ * Before activating topic, sends an overrideObject to the API to modify the request params.
+ *
+ * @param {Event} event
+ * @return {Object}
+ */
+ FlowBoardComponentApiEventsMixin.UI.events.apiPreHandlers.activateEditTitle = function ( event ) {
+ // Use view-post API for topic as well; we only want this on
+ // particular (title) post revision, not the full topic
+ return {
+ submodule: "view-post",
+ vppostId: $( this ).closest( '.flow-topic' ).data( 'flow-id' ),
+ vpcontentFormat: "wikitext"
+ };
+ };
+
+ /**
+ * Before activating post, sends an overrideObject to the API to modify the request params.
+ * @param {Event} event
+ * @return {Object}
+ */
+ FlowBoardComponentApiEventsMixin.UI.events.apiPreHandlers.activateEditPost = function ( event ) {
+ return {
+ submodule: 'view-post',
+ vppostId: $( this ).closest( '.flow-post' ).data( 'flow-id' ),
+ vpcontentFormat: 'wikitext'
+ };
+ };
+
+ /**
+ * Adjusts query params to use global watch action, and specifies it should use a watch token.
+ * @param {Event} event
+ * @returns {Function}
+ */
+ FlowBoardComponentApiEventsMixin.UI.events.apiPreHandlers.watchItem = function ( event ) {
+ return function ( queryMap ) {
+ var params = {
+ action: 'watch',
+ titles: queryMap.page,
+ _internal: {
+ tokenType: 'watch'
+ }
+ };
+ if ( queryMap.submodule === 'unwatch' ) {
+ params.unwatch = 1;
+ }
+ return params;
+ };
+ };
+
+ /**
+ * Before activating summarize topic, sends an overrideObject to the
+ * API to modify the request params.
+ * @param {Event} event
+ * @param {Object} info
+ * @return {Object}
+ */
+ FlowBoardComponentApiEventsMixin.UI.events.apiPreHandlers.activateSummarizeTopic = function ( event, info ) {
+ if ( info.$target.find( 'form' ).length ) {
+ // Form already open; cancel the old form
+ var flowBoard = mw.flow.getPrototypeMethod( 'board', 'getInstanceByElement' )( $( this ) );
+ flowBoard.emitWithReturn( 'cancelForm', info.$target );
+ return false;
+ }
+
+ return {
+ // href submodule is edit-topic-summary
+ submodule: 'view-topic-summary',
+ // href does not have this param
+ vtscontentFormat: 'wikitext'
+ };
+ };
+
+ /**
+ * Before activating lock/unlock edit form, sends an overrideObject
+ * to the API to modify the request params.
+ * @param {Event} event
+ * @return {Object}
+ */
+ FlowBoardComponentApiEventsMixin.UI.events.apiPreHandlers.activateLockTopic = function ( event ) {
+ return {
+ // href submodule is lock-topic
+ submodule: 'view-post',
+ // href does not have this param
+ vpcontentFormat: 'wikitext',
+ // request just the data for this topic
+ vppostId: $( this ).data( 'flow-id' )
+ };
+ };
+
+ //
+ // api callback handlers
+ //
+
+ /**
+ * On complete board reprocessing through view-topiclist (eg. change topic sort order), re-render any given blocks.
+ * @param {Object} info
+ * @param {string} info.status "done" or "fail"
+ * @param {jQuery} info.$target
+ * @param {Object} data
+ * @param {jqXHR} jqxhr
+ * @returns {$.Promise}
+ */
+ FlowBoardComponentApiEventsMixin.UI.events.apiHandlers.board = function ( info, data, jqxhr ) {
+ var $rendered,
+ flowBoard = info.component,
+ dfd = $.Deferred();
+
+ if ( info.status !== 'done' ) {
+ // Error will be displayed by default, nothing else to wrap up
+ return dfd.reject().promise();
+ }
+
+ $rendered = $(
+ flowBoard.constructor.static.TemplateEngine.processTemplateGetFragment(
+ 'flow_block_loop',
+ { blocks: data.flow[ 'view-topiclist' ].result }
+ )
+ ).children();
+
+ // Run this on a short timeout so that the other board handler in FlowBoardComponentLoadMoreFeatureMixin can run
+ // TODO: Using a timeout doesn't seem like the right way to do this.
+ setTimeout( function () {
+ // Reinitialize the whole board with these nodes
+ flowBoard.reinitializeContainer( $rendered );
+ dfd.resolve();
+ }, 50 );
+
+ return dfd.promise();
+ };
+
+ /**
+ * @returns {$.Promise}
+
+ return $.Deferred().resolve().promise();
+ * Renders the editable board header with the given API response.
+ * @param {Object} info
+ * @param {string} info.status "done" or "fail"
+ * @param {jQuery} info.$target
+ * @param {Object} data
+ * @param {jqXHR} jqxhr
+ * @returns {$.Promise}
+ */
+ FlowBoardComponentApiEventsMixin.UI.events.apiHandlers.activateEditHeader = function ( info, data, jqxhr ) {
+ var $rendered,
+ flowBoard = mw.flow.getPrototypeMethod( 'board', 'getInstanceByElement' )( $( this ) ),
+ $oldBoardNodes;
+
+ if ( info.status !== 'done' ) {
+ // Error will be displayed by default & edit conflict handled, nothing else to wrap up
+ return $.Deferred().reject().promise();
+ }
+
+ // Change "header" to "header_edit" so that it loads up flow_block_header_edit
+ data.flow[ 'view-header' ].result.header.type = 'header_edit';
+
+ $rendered = $(
+ flowBoard.constructor.static.TemplateEngine.processTemplateGetFragment(
+ 'flow_block_loop',
+ { blocks: data.flow[ 'view-header' ].result }
+ )
+ ).children();
+
+ // Set the cancel callback on this form so that it returns the old content back if needed
+ flowBoard.emitWithReturn( 'addFormCancelCallback', $rendered.find( 'form' ), function () {
+ flowBoard.reinitializeContainer( $oldBoardNodes );
+ } );
+
+ // Reinitialize the whole board with these nodes, and hold onto the replaced header
+ $oldBoardNodes = flowBoard.reinitializeContainer( $rendered );
+
+ return $.Deferred().resolve().promise();
+ };
+
+ /**
+ * After submit of the board header edit form, process the new header data.
+ * @param {Object} info (status:done|fail, $target: jQuery)
+ * @param {Object} data
+ * @param {jqXHR} jqxhr
+ * @returns {$.Deferred}
+ */
+ FlowBoardComponentApiEventsMixin.UI.events.apiHandlers.submitHeader = function ( info, data, jqxhr ) {
+ var $rendered,
+ flowBoard = mw.flow.getPrototypeMethod( 'board', 'getInstanceByElement' )( $( this ) );
+
+ if ( info.status !== 'done' ) {
+ // Error will be displayed by default & edit conflict handled, nothing else to wrap up
+ return $.Deferred().reject();
+ }
+
+ $rendered = $(
+ flowBoard.constructor.static.TemplateEngine.processTemplateGetFragment(
+ 'flow_block_loop',
+ { blocks: data.flow[ 'edit-header' ].result }
+ )
+ ).children();
+
+ // Reinitialize the whole board with these nodes
+ flowBoard.reinitializeContainer( $rendered );
+
+ return $.Deferred().resolve();
+ };
+
+ /**
+ * Renders the editable lock/unlock text area with the given API response.
+ * Allows a user to lock or unlock an entire topic.
+ * @param {Object} info
+ * @param {Object} data
+ * @param {jqXHR} jqxhr
+ * @returns {$.Promise}
+ */
+ FlowBoardComponentApiEventsMixin.UI.events.apiHandlers.activateLockTopic = function ( info, data ) {
+ var result, revision, postId, revisionId,
+ $target = info.$target,
+ $old = $target,
+ flowBoard = mw.flow.getPrototypeMethod( 'board', 'getInstanceByElement' )( $( this ) );
+
+ $( this ).closest( '.flow-menu' ).removeClass( 'focus' );
+
+ if ( info.status !== 'done' ) {
+ // Error will be displayed by default & edit conflict handled, nothing else to wrap up
+ return $.Deferred().reject().promise();
+ }
+
+ // FIXME: API should take care of this for me.
+ result = data.flow[ 'view-post' ].result.topic;
+ postId = result.roots[0];
+ revisionId = result.posts[postId];
+ revision = result.revisions[revisionId];
+
+ // Enable the editable summary
+ $target = $( flowBoard.constructor.static.TemplateEngine.processTemplateGetFragment(
+ 'flow_topic_titlebar_lock.partial', revision
+ ) ).children();
+
+ // Ensure that on a cancel the form gets destroyed.
+ flowBoard.emitWithReturn( 'addFormCancelCallback', $target.find( 'form' ), function () {
+ // xxx: Can this use replaceWith()? If so, use it because it saves the browser
+ // from having to reflow the document view twice (once with both elements on the
+ // page and then again after its removed, which causes bugs like losing your
+ // scroll offset on long pages).
+ $target.before( $old ).remove();
+ } );
+
+ // Replace the old one
+ $old.before( $target ).detach();
+
+ flowBoard.emitWithReturn( 'makeContentInteractive', $target );
+
+ // Focus on first form field
+ $target.find( 'input, textarea' ).filter( ':visible:first' ).focus();
+
+ return $.Deferred().resolve().promise();
+ };
+
+ /**
+ * After submit of the lock/unlock topic form, process the new summary data and re-render
+ * the title bar.
+ * @param {String} status
+ * @param {Object} data
+ * @param {jqXHR} jqxhr
+ * @returns {$.Promise}
+ */
+ FlowBoardComponentApiEventsMixin.UI.events.apiHandlers.lockTopic = function ( info, data ) {
+ var $replacement,
+ $target = info.$target,
+ $this = $( this ),
+ $deferred = $.Deferred(),
+ flowBoard = mw.flow.getPrototypeMethod( 'board', 'getInstanceByElement' )( $this ),
+ flowId = $this.closest( '.flow-topic' ).data( 'flow-id' );
+
+ if ( info.status !== 'done' ) {
+ // Error will be displayed by default & edit conflict handled, nothing else to wrap up
+ return $deferred.reject().promise();
+ }
+
+ // We couldn't make lock-topic to return topic data after a successful
+ // post submission because lock-topic is used for no-js support as well.
+ // If we make it return topic data, that means it has to return wikitext format
+ // for edit form in no-js mode. This is a performance problem for wikitext
+ // conversion since topic data returns all children data as well. So we need to
+ // make lock-topic return a single post for topic then fire
+ // another request to topic data in html format
+ //
+ // @todo the html could json encode the parameters including topics, the js
+ // could then import that and continuously update it with new revisions from
+ // api calls. Rendering a topic would then just be pointing the template at
+ // the right part of that data instead of requesting it.
+ flowBoard.Api.apiCall( {
+ action: 'flow',
+ submodule: 'view-topic',
+ workflow: flowId,
+ // Flow topic title, in Topic:<topicId> format (2600 is topic namespace id)
+ page: mw.Title.newFromText( flowId, 2600 ).getPrefixedDb()
+ // @todo fixme
+ // - mw.Title.newFromText can return null. If you're not going to check its return
+ // value, use 'new mw.Title' instead so that you get an exception for 'invalid title'
+ // instead of an exception for 'property of null'.
+ // - The second parameter to mw.Title is 'defaultNamespace' not 'namespace'.
+ // E.g. mw.Title.newFromText( 'User:Example', 6 ) -> 'User:Example', not 'File:
+ // If you need to prefix/enforce a namespace, use the canonical prefix instead.
+ } ).done( function( result ) {
+ // Update view of the full topic
+ $replacement = $( flowBoard.constructor.static.TemplateEngine.processTemplateGetFragment(
+ 'flow_topiclist_loop.partial',
+ result.flow['view-topic'].result.topic
+ ) ).children();
+
+ $target.replaceWith( $replacement );
+ flowBoard.emitWithReturn( 'makeContentInteractive', $replacement );
+
+ $deferred.resolve();
+ } ).fail( function( code, result ) {
+ /*
+ * At this point, the lock/unlock actually worked, but failed
+ * fetching the new data to be displayed.
+ */
+ flowBoard.emitWithReturn( 'removeError', $target );
+ var errorMsg = flowBoard.constructor.static.getApiErrorMessage( code, result );
+ errorMsg = mw.msg( 'flow-error-fetch-after-open-lock', errorMsg );
+ flowBoard.emitWithReturn( 'showError', $target, errorMsg );
+
+ $deferred.reject();
+ } );
+
+ return $deferred.promise();
+ };
+
+ /**
+ * @param {Object} info
+ * @param {string} info.status "done" or "fail"
+ * @param {jQuery} info.$target
+ * @param {Object} data
+ * @param {jqXHR} jqxhr
+ * @returns {$.Promise}
+ */
+ FlowBoardComponentApiEventsMixin.UI.events.apiHandlers.submitTopicTitle = function( info, data, jqxhr ) {
+ var
+ topicData,
+ rootId,
+ revisionId,
+ $this = $( this ),
+ $topic = info.$target,
+ $oldTopicTitleBar, $newTopicTitleBar,
+ flowBoard = mw.flow.getPrototypeMethod( 'board', 'getInstanceByElement' )( $this );
+
+ if ( info.status !== 'done' ) {
+ // Error will be displayed by default & edit conflict handled, nothing else to wrap up
+ return $.Deferred().reject().promise();
+ }
+
+ $oldTopicTitleBar = $topic.find( '.flow-topic-titlebar' );
+ topicData = data.flow['edit-title'].result.topic;
+ rootId = topicData.roots[0];
+ revisionId = topicData.posts[rootId][0];
+ $newTopicTitleBar = $( flowBoard.constructor.static.TemplateEngine.processTemplateGetFragment(
+ 'flow_topic_titlebar.partial',
+ topicData.revisions[revisionId]
+ ) ).children();
+
+ $oldTopicTitleBar
+ .replaceWith( $newTopicTitleBar );
+
+ flowBoard.emitWithReturn( 'makeContentInteractive', $newTopicTitleBar );
+
+ $newTopicTitleBar.conditionalScrollIntoView();
+
+ return $.Deferred().resolve().promise();
+ };
+
+ /**
+ * After submit of the topic title edit form, process the response.
+ *
+ * @param {Object} info
+ * @param {string} info.status "done" or "fail"
+ * @param {jQuery} info.$target
+ * @param {Object} data
+ * @param {jqXHR} jqxhr
+ * @returns {$.Promise}
+ */
+ FlowBoardComponentApiEventsMixin.UI.events.apiHandlers.submitEditPost = function( info, data, jqxhr ) {
+ var result;
+
+ if ( info.status !== 'done' ) {
+ // Error will be displayed by default & edit conflict handled, nothing else to wrap up
+ return $.Deferred().reject().promise();
+ }
+
+ result = data.flow['edit-post'].result.topic;
+ // clear out submitted data, otherwise it would re-trigger an edit
+ // form in the refreshed topic
+ result.submitted = {};
+
+ _flowBoardComponentRefreshTopic( info.$target, result );
+
+ return $.Deferred().resolve().promise();
+ };
+
+ /**
+ * After submitting a new topic, process the response.
+ * @param {Object} info
+ * @param {string} info.status "done" or "fail"
+ * @param {jQuery} info.$target
+ * @param {Object} data
+ * @param {jqXHR} jqxhr
+ * @returns {$.Promise}
+ */
+ FlowBoardComponentApiEventsMixin.UI.events.apiHandlers.newTopic = function ( info, data, jqxhr ) {
+ var result, fragment,
+ schemaName = $( this ).data( 'flow-eventlog-schema' ),
+ funnelId = $( this ).data( 'flow-eventlog-funnel-id' ),
+ flowBoard = mw.flow.getPrototypeMethod( 'board', 'getInstanceByElement' )( $( this ) );
+
+ if ( info.status !== 'done' ) {
+ // Error will be displayed by default, nothing else to wrap up
+ return $.Deferred().reject().promise();
+ }
+
+ flowBoard.logEvent( schemaName, { action: 'save-success', funnelId: funnelId } );
+
+ result = data.flow['new-topic'].result.topiclist;
+
+ // render only the new topic
+ result.roots = [result.roots[0]];
+ fragment = mw.flow.TemplateEngine.processTemplateGetFragment( 'flow_topiclist_loop.partial', result );
+
+ flowBoard.emitWithReturn( 'cancelForm', $( this ).closest( 'form' ) );
+
+ // Everything must be reset before re-initializing
+ // @todo un-hardcode
+ flowBoard.reinitializeContainer(
+ flowBoard.$container.find( '.flow-topics' ).prepend( fragment )
+ );
+
+ // remove focus - title input field may still have focus
+ // (submitted via enter key), which it needs to lose:
+ // the form will only re-activate if re-focused
+ document.activeElement.blur();
+
+ return $.Deferred().resolve().promise();
+ };
+
+ /**
+ * @param {Object} info (status:done|fail, $target: jQuery)
+ * @param {Object} data
+ * @param {jqXHR} jqxhr
+ * @returns {$.Promise}
+ */
+ FlowBoardComponentApiEventsMixin.UI.events.apiHandlers.submitReply = function ( info, data, jqxhr ) {
+ var $form = $( this ).closest( 'form' ),
+ flowBoard = mw.flow.getPrototypeMethod( 'board', 'getInstanceByElement' )( $form ),
+ schemaName = $( this ).data( 'flow-eventlog-schema' ),
+ funnelId = $( this ).data( 'flow-eventlog-funnel-id' );
+
+ if ( info.status !== 'done' ) {
+ // Error will be displayed by default, nothing else to wrap up
+ return $.Deferred().reject().promise();
+ }
+
+ flowBoard.logEvent( schemaName, { action: 'save-success', funnelId: funnelId } );
+
+ // Execute cancel callback to destroy form
+ flowBoard.emitWithReturn( 'cancelForm', $form );
+
+ // Target should be flow-topic
+ _flowBoardComponentRefreshTopic( info.$target, data.flow.reply.result.topic );
+
+ return $.Deferred().resolve().promise();
+ };
+
+ /**
+ * @param {Object} info
+ * @param {string} info.status "done" or "fail"
+ * @param {jQuery} info.$target
+ * @param {Object} data
+ * @param {jqXHR} jqxhr
+ * @returns {$.Promise}
+ */
+ FlowBoardComponentApiEventsMixin.UI.events.apiHandlers.watchItem = function ( info, data, jqxhr ) {
+ var watchUrl, unwatchUrl,
+ watchType, watchLinkTemplate, $newLink,
+ $target = $( this ),
+ $tooltipTarget = $target.closest( '.flow-watch-link' ),
+ flowBoard = mw.flow.getPrototypeMethod( 'board', 'getInstanceByElement' )( $tooltipTarget ),
+ isWatched = false,
+ url = $( this ).prop( 'href' ),
+ links = {};
+
+ if ( info.status !== 'done' ) {
+ // Error will be displayed by default, nothing else to wrap up
+ return $.Deferred().reject().promise();
+ }
+
+ if ( $tooltipTarget.is( '.flow-topic-watchlist' ) ) {
+ watchType = 'topic';
+ watchLinkTemplate = 'flow_topic_titlebar_watch.partial';
+ }
+
+ if ( data.watch[0].watched !== undefined ) {
+ unwatchUrl = url.replace( 'watch', 'unwatch' );
+ watchUrl = url;
+ isWatched = true;
+ } else {
+ watchUrl = url.replace( 'unwatch', 'watch' );
+ unwatchUrl = url;
+ }
+ links['unwatch-' + watchType] = { url : unwatchUrl };
+ links['watch-' + watchType] = { url : watchUrl };
+
+ // Render new icon
+ // This will hide any tooltips if present
+ $newLink = $(
+ flowBoard.constructor.static.TemplateEngine.processTemplateGetFragment(
+ watchLinkTemplate,
+ {
+ isWatched: isWatched,
+ links: links,
+ watchable: true
+ }
+ )
+ ).children();
+ $tooltipTarget.replaceWith( $newLink );
+
+ if ( data.watch[0].watched !== undefined ) {
+ // Successful watch: show tooltip
+ flowBoard.emitWithReturn( 'showSubscribedTooltip', $newLink.find( '.wikiglyph' ), watchType );
+ }
+
+ return $.Deferred().resolve().promise();
+ };
+
+ /**
+ * Activate the editable summarize topic form with given api request
+ * @param {Object} info (status:done|fail, $target: jQuery)
+ * @param {Object} data
+ * @param {jqXHR} jqxhr
+ * @returns {$.Promise}
+ */
+ FlowBoardComponentApiEventsMixin.UI.events.apiHandlers.activateSummarizeTopic = function ( info, data, jqxhr ) {
+ var $target = info.$target,
+ $old = $target,
+ flowBoard = mw.flow.getPrototypeMethod( 'board', 'getInstanceByElement' )( $( this ) );
+
+ if ( info.status !== 'done' ) {
+ // Error will be displayed by default, nothing else to wrap up
+ return $.Deferred().reject().promise();
+ }
+
+ // Create the new topic_summary_edit template
+ $target = $( flowBoard.constructor.static.TemplateEngine.processTemplateGetFragment(
+ 'flow_block_topicsummary_edit',
+ data.flow[ 'view-topic-summary' ].result.topicsummary
+ ) ).children();
+
+ // On cancel, put the old topicsummary back
+ flowBoard.emitWithReturn( 'addFormCancelCallback', $target.find( 'form' ), function() {
+ $target.before( $old ).remove();
+ } );
+
+ // Replace the old one
+ $old.before( $target ).detach();
+
+ flowBoard.emitWithReturn( 'makeContentInteractive', $target );
+
+ // Focus on first form field
+ $target.find( 'input, textarea' ).filter( ':visible:first' ).focus();
+
+ return $.Deferred().resolve().promise();
+ };
+
+ /**
+ * After submit of the summarize topic edit form, process the new topic summary data.
+ * @param {Object} info
+ * @param {string} info.status "done" or "fail"
+ * @param {jQuery} info.$target
+ * @param {Object} data
+ * @param {jqXHR} jqxhr
+ * @returns {$.Promise}
+ */
+ FlowBoardComponentApiEventsMixin.UI.events.apiHandlers.summarizeTopic = function ( info, data, jqxhr ) {
+ if ( info.status !== 'done' ) {
+ // Error will be displayed by default, nothing else to wrap up
+ return $.Deferred().reject().promise();
+ }
+
+ _flowBoardComponentRefreshTopic(
+ info.$target,
+ data.flow['edit-topic-summary'].result.topic,
+ '.flow-topic-titlebar'
+ );
+
+ return $.Deferred().resolve().promise();
+ };
+
+ /**
+ * Shows the form for editing a topic title, it's not already showing.
+ *
+ * @param {Object} info (status:done|fail, $target: jQuery)
+ * @param {Object} data
+ * @param {jqXHR} jqxhr
+ * @returns {$.Promise}
+ */
+ FlowBoardComponentApiEventsMixin.UI.events.apiHandlers.activateEditTitle = function ( info, data, jqxhr ) {
+ var flowBoard, $form, cancelCallback,
+ $link = $( this ),
+ activeClass = 'flow-topic-title-activate-edit',
+ rootBlock = data.flow['view-post'].result.topic,
+ revision = rootBlock.revisions[rootBlock.posts[rootBlock.roots[0]]];
+
+ if ( info.status !== 'done' ) {
+ // Error will be displayed by default, nothing else to wrap up
+ return $.Deferred().reject().promise();
+ }
+
+ $form = info.$target.find( 'form' );
+
+ if ( $form.length === 0 ) {
+ // Add class to identify title is being edited (so we can hide the
+ // current title in CSS)
+ info.$target.addClass( activeClass );
+
+ cancelCallback = function() {
+ $form.remove();
+ info.$target.removeClass( activeClass );
+ };
+
+ flowBoard = mw.flow.getPrototypeMethod( 'board', 'getInstanceByElement' )( $link );
+ $form = $( flowBoard.constructor.static.TemplateEngine.processTemplateGetFragment(
+ 'flow_edit_topic_title.partial',
+ {
+ 'actions' : {
+ 'edit' : {
+ 'url' : $link.attr( 'href' )
+ }
+ },
+ 'content': {
+ 'content' : revision.content.content
+ },
+ 'revisionId' : revision.revisionId
+ }
+ ) ).children();
+
+ flowBoard.emitWithReturn( 'addFormCancelCallback', $form, cancelCallback );
+ $form
+ .data( 'flow-initial-state', 'hidden' )
+ .prependTo( info.$target );
+ }
+
+ $form.find( '.mw-ui-input' ).focus();
+
+ return $.Deferred().resolve().promise();
+ };
+
+ /**
+ * Renders the editable post with the given API response.
+ * @param {Object} info
+ * @param {string} info.status "done" or "fail"
+ * @param {jQuery} info.$target
+ * @param {Object} data
+ * @param {jqXHR} jqxhr
+ * @returns {$.Promise}
+ */
+ FlowBoardComponentApiEventsMixin.UI.events.apiHandlers.activateEditPost = function ( info, data, jqxhr ) {
+ var $rendered, rootBlock,
+ flowBoard = mw.flow.getPrototypeMethod( 'board', 'getInstanceByElement' )( $( this ) ),
+ $post = info.$target;
+
+ if ( info.status !== 'done' ) {
+ // Error will be displayed by default, nothing else to wrap up
+ return $.Deferred().reject().promise();
+ }
+
+ // The API returns with the entire topic, but we only want to render the edit form
+ // for a singular post
+ rootBlock = data.flow['view-post'].result.topic;
+ $rendered = $(
+ flowBoard.constructor.static.TemplateEngine.processTemplateGetFragment(
+ 'flow_edit_post_ajax.partial',
+ {
+ revision: rootBlock.revisions[rootBlock.posts[rootBlock.roots[0]]],
+ rootBlock: rootBlock
+ }
+ )
+ ).children();
+
+ // Set the cancel callback on this form so that it returns to the post
+ flowBoard.emitWithReturn( 'addFormCancelCallback',
+ $rendered.find( 'form' ).addBack( 'form' ),
+ function () {
+ $rendered.replaceWith( $post );
+ }
+ );
+
+ $post.replaceWith( $rendered );
+ $rendered.find( 'textarea' ).conditionalScrollIntoView().focus();
+
+ return $.Deferred().resolve().promise();
+ };
+
+ /**
+ * Callback from the topic moderation dialog.
+ */
+ FlowBoardComponentApiEventsMixin.UI.events.apiHandlers.moderateTopic = _genModerateHandler(
+ 'moderate-topic',
+ function ( $target, revision, apiResult ) {
+ var $replacement,
+ flowBoard = mw.flow.getPrototypeMethod( 'board', 'getInstanceByElement' )( $( this ) );
+ if ( revision.isModerated && !flowBoard.constructor.static.inTopicNamespace( $target ) ) {
+ $replacement = $( $.parseHTML( mw.flow.TemplateEngine.processTemplate(
+ 'flow_moderate_topic_confirmation.partial',
+ revision
+ ) ) );
+
+ $target.closest( '.flow-topic' ).replaceWith( $replacement );
+ flowBoard.emitWithReturn( 'makeContentInteractive', $replacement );
+ } else {
+ _flowBoardComponentRefreshTopic( $target, apiResult );
+ }
+ }
+ );
+
+ /**
+ * Callback from the post moderation dialog.
+ */
+ FlowBoardComponentApiEventsMixin.UI.events.apiHandlers.moderatePost = _genModerateHandler(
+ 'moderate-post',
+ function ( $target, revision, apiResult ) {
+ var $replacement,
+ flowBoard = mw.flow.getPrototypeMethod( 'board', 'getInstanceByElement' )( $( this ) );
+
+ if ( revision.isModerated ) {
+ $replacement = $( $.parseHTML( flowBoard.constructor.static.TemplateEngine.processTemplate(
+ 'flow_moderate_post_confirmation.partial',
+ revision
+ ) ) );
+ $target.closest( '.flow-post-main' ).replaceWith( $replacement );
+ flowBoard.emitWithReturn( 'makeContentInteractive', $replacement );
+ } else {
+ _flowBoardComponentRefreshTopic( $target, apiResult );
+ }
+ }
+ );
+
+ //
+ // Private functions
+ //
+
+ /**
+ * Generate a moderation handler callback
+ *
+ * @param {string} Action to expect in api response
+ * @param {Function} Method to call on api success
+ */
+ function _genModerateHandler( action, successCallback ) {
+ /**
+ * After submit of a moderation form, process the response.
+ *
+ * @param {Object} info (status:done|fail, $target: jQuery)
+ * @param {Object} data
+ * @param {jqXHR} jqxhr
+ * @returns {$.Promise}
+ */
+ return function ( info, data, jqxhr ) {
+ if ( info.status !== 'done' ) {
+ // Error will be displayed by default, nothing else to wrap up
+ return $.Deferred().reject().promise();
+ }
+
+ var result = data.flow[action].result.topic,
+ $this = $( this ),
+ $form = $this.closest( 'form' ),
+ id = result.submitted.postId || result.postId || result.roots[0],
+ flowBoard = mw.flow.getPrototypeMethod( 'board', 'getInstanceByElement' )( $this );
+
+ successCallback.call(
+ this,
+ $form.data( 'flow-dialog-owner' ) || $form,
+ result.revisions[result.posts[id]],
+ result
+ );
+
+ flowBoard.emitWithReturn( 'cancelForm', $form );
+
+ return $.Deferred().resolve().promise();
+ };
+ }
+
+ /**
+ * Refreshes the titlebar of a topic given an API response.
+ * @param {jQuery} $targetElement An element in the topic.
+ * @param {Object} apiResult Plain object containing the API response to build from.
+ * @param {String} [selector] Select specific element to replace
+ */
+ function _flowBoardComponentRefreshTopic( $targetElement, apiResult, selector ) {
+ var $target = $targetElement.closest( '.flow-topic' ),
+ flowBoard = mw.flow.getPrototypeMethod( 'board', 'getInstanceByElement' )( $targetElement ),
+ $newContent = $( flowBoard.constructor.static.TemplateEngine.processTemplateGetFragment(
+ 'flow_topiclist_loop.partial',
+ apiResult
+ ) ).children();
+
+ if ( selector ) {
+ $newContent = $newContent.find( selector );
+ $target = $target.find( selector );
+ }
+
+ $target.replaceWith( $newContent );
+ // Run loadHandlers
+ flowBoard.emitWithReturn( 'makeContentInteractive', $newContent );
+ }
+
+ // Mixin to FlowBoardComponent
+ mw.flow.mixinComponent( 'board', FlowBoardComponentApiEventsMixin );
+}( jQuery, mediaWiki ) );
diff --git a/Flow/modules/engine/components/board/base/flow-board-interactive-events.js b/Flow/modules/engine/components/board/base/flow-board-interactive-events.js
new file mode 100644
index 00000000..6e10caae
--- /dev/null
+++ b/Flow/modules/engine/components/board/base/flow-board-interactive-events.js
@@ -0,0 +1,213 @@
+/*!
+ * Implements element interactive handler callbacks for FlowBoardComponent
+ */
+
+( function ( $, mw ) {
+ /**
+ * Binds element interactive (click) handlers for FlowBoardComponent
+ * @param {jQuery} $container
+ * @extends FlowComponent
+ * @constructor
+ */
+ function FlowBoardComponentInteractiveEventsMixin( $container ) {
+ this.bindNodeHandlers( FlowBoardComponentInteractiveEventsMixin.UI.events );
+ }
+ OO.initClass( FlowBoardComponentInteractiveEventsMixin );
+
+ FlowBoardComponentInteractiveEventsMixin.UI = {
+ events: {
+ interactiveHandlers: {}
+ }
+ };
+
+ //
+ // interactive handlers
+ //
+
+ /**
+ * Toggles collapse state
+ *
+ * @param {Event} event
+ */
+ FlowBoardComponentInteractiveEventsMixin.UI.events.interactiveHandlers.collapserCollapsibleToggle = function ( event ) {
+ var $target = $( this ).closest( '.flow-element-collapsible' ),
+ $deferred = $.Deferred();
+
+ if ( $target.is( '.flow-element-collapsed' ) ) {
+ $target.removeClass( 'flow-element-collapsed' ).addClass( 'flow-element-expanded' );
+ } else {
+ $target.addClass( 'flow-element-collapsed' ).removeClass( 'flow-element-expanded' );
+ }
+
+ return $deferred.resolve().promise();
+ };
+
+ /**
+ * @param {Event} event
+ * @returns {$.Promise}
+ */
+ FlowBoardComponentInteractiveEventsMixin.UI.events.interactiveHandlers.activateReplyTopic = function ( event ) {
+ var $topic = $( this ).closest( '.flow-topic' ),
+ topicId = $topic.data( 'flow-id' ),
+ component;
+
+ // The reply form is used in multiple places. This will check if it was
+ // triggered from inside the topic reply form.
+ if ( $( this ).closest( '#flow-reply-' + topicId ).length === 0 ) {
+ // Not in topic reply form
+ return $.Deferred().reject();
+ }
+
+ // Only if the textarea is compressed, is it being activated. Otherwise,
+ // it has already expanded and this focus is now just re-focussing the
+ // already active form
+ if ( !$( this ).hasClass( 'flow-input-compressed' ) ) {
+ // Form already activated
+ return $.Deferred().reject();
+ }
+
+ component = mw.flow.getPrototypeMethod( 'component', 'getInstanceByElement' )( $( this ) );
+ component.logEvent(
+ 'FlowReplies',
+ // log data
+ {
+ entrypoint: 'reply-bottom',
+ action: 'initiate'
+ },
+ // nodes to forward funnel to
+ $( this ).findWithParent(
+ '< .flow-reply-form [data-role="cancel"],' +
+ '< .flow-reply-form [data-role="action"][name="preview"],' +
+ '< .flow-reply-form [data-role="submit"]'
+ )
+ );
+
+ return $.Deferred().resolve();
+ };
+
+ /**
+ * @param {Event} event
+ */
+ FlowBoardComponentInteractiveEventsMixin.UI.events.interactiveHandlers.activateNewTopic = function ( event ) {
+ var $form = $( this ).closest( '.flow-newtopic-form' ),
+ component;
+
+ // Only if the textarea is compressed, is it being activated. Otherwise,
+ // it has already expanded and this focus is now just re-focussing the
+ // already active form
+ if ( $form.find( '.flow-input-compressed' ).length === 0 ) {
+ // Form already activated
+ return $.Deferred().reject();
+ }
+
+ component = mw.flow.getPrototypeMethod( 'component', 'getInstanceByElement' )( $( this ) );
+ component.logEvent(
+ 'FlowReplies',
+ // log data
+ {
+ entrypoint: 'new-topic',
+ action: 'initiate'
+ },
+ // nodes to forward funnel to
+ $( this ).findWithParent(
+ '< .flow-newtopic-form [data-role="cancel"],' +
+ '< .flow-newtopic-form [data-role="action"][name="preview"],' +
+ '< .flow-newtopic-form [data-role="submit"]'
+ )
+ );
+
+ return $.Deferred().resolve();
+ };
+
+ /**
+ * @param {Event} event
+ */
+ FlowBoardComponentInteractiveEventsMixin.UI.events.interactiveHandlers.activateReplyPost = function ( event ) {
+ event.preventDefault();
+
+ var $form,
+ $this = $( this ),
+ topicId = $this.closest( '.flow-topic' ).data( 'flow-id' ),
+ flowBoard = mw.flow.getPrototypeMethod( 'board', 'getInstanceByElement' )( $this ),
+ $post = $this.closest( '.flow-post' ),
+ href = $this.attr( 'href' ),
+ uri = new mw.Uri( href ),
+ postId = uri.query.topic_postId,
+ $targetPost = $( '#flow-post-' + postId ),
+ topicTitle = $post.closest( '.flow-topic' ).find( '.flow-topic-title' ).text(),
+ replyToContent = $post.find( '.flow-post-content' ).filter( ':first' ).text() || topicTitle,
+ author = $.trim( $post.find( '.flow-author' ).filter( ':first' ).find( '.mw-userlink' ).text() ),
+ $deferred = $.Deferred();
+
+ if ( $targetPost.length === 0 ) {
+ $targetPost = $( '#flow-topic-' + postId );
+ }
+
+ // forward all top level replys to the topic reply box
+ if ( $targetPost.is( '.flow-topic' ) ) {
+ $targetPost.find( '#flow-post-' + postId + '-form-content' ).trigger( 'focus' );
+ return $deferred.resolve().promise();
+ }
+
+ // Check if reply form has already been opened
+ if ( $post.data( 'flow-replying' ) ) {
+ return $deferred.reject().promise();
+ }
+ $post.data( 'flow-replying', true );
+
+ $form = $( flowBoard.constructor.static.TemplateEngine.processTemplateGetFragment(
+ 'flow_reply_form.partial',
+ // arguments can be empty: we just want an empty reply form
+ {
+ actions: {
+ reply: {
+ url: href,
+ text: mw.msg( 'flow-reply-link', author )
+ }
+ },
+ postId: postId,
+ author: {
+ name: author
+ },
+ // text for flow-reply-topic-title-placeholder placeholder
+ properties: {
+ 'topic-of-post': $.trim( replyToContent ).substr( 0, 200 )
+ },
+ // Topic:UUID
+ articleTitle: mw.config.get( 'wgFormattedNamespaces' )[2600] + ':' + topicId[0].toUpperCase() + topicId.slice(1)
+ }
+ ) ).children();
+
+ // Set the cancel callback on this form so that it gets rid of the form.
+ // We have to make sure the data attribute is added to the form; the
+ // addBack is failsafe for when form is actually the root node in $form
+ // already (there may or may not be parent containers)
+ flowBoard.emitWithReturn( 'addFormCancelCallback', $form.find( 'form' ).addBack( 'form' ), function () {
+ $post.removeData( 'flow-replying' );
+ $form.remove();
+ } );
+
+ // Add reply form below the post being replied to (WRT max depth)
+ $targetPost.children( '.flow-replies' ).append( $form );
+ $form.conditionalScrollIntoView();
+
+ // focus the input
+ $form.find('textarea').focus();
+
+ return $deferred.resolve().promise();
+ };
+
+ // @todo remove these data-flow handler forwarder callbacks when data-mwui handlers are implemented
+ $( [ 'close', 'prevOrClose', 'nextOrSubmit', 'prev', 'next' ] ).each( function ( i, fn ) {
+ // Assigns each handler with the prefix 'modal', eg. 'close' becomes 'modalClose'
+ FlowBoardComponentInteractiveEventsMixin.UI.events.interactiveHandlers[ 'modal' + fn.charAt(0).toUpperCase() + fn.substr( 1 ) ] = function ( event ) {
+ event.preventDefault();
+
+ // eg. call mw.Modal.close( this );
+ mw.Modal[ fn ]( this );
+ };
+ } );
+
+ // Mixin to FlowBoardComponent
+ mw.flow.mixinComponent( 'board', FlowBoardComponentInteractiveEventsMixin );
+}( jQuery, mediaWiki ) );
diff --git a/Flow/modules/engine/components/board/base/flow-board-load-events.js b/Flow/modules/engine/components/board/base/flow-board-load-events.js
new file mode 100644
index 00000000..9fe19d78
--- /dev/null
+++ b/Flow/modules/engine/components/board/base/flow-board-load-events.js
@@ -0,0 +1,42 @@
+/*!
+ * Implements element on-load callbacks for FlowBoardComponent
+ */
+
+( function ( $, mw ) {
+ /**
+ * Binds element load handlers for FlowBoardComponent
+ * @param {jQuery} $container
+ * @extends FlowComponent
+ * @constructor
+ */
+ function FlowBoardComponentLoadEventsMixin( $container ) {
+ this.bindNodeHandlers( FlowBoardComponentLoadEventsMixin.UI.events );
+ }
+ OO.initClass( FlowBoardComponentLoadEventsMixin );
+
+ FlowBoardComponentLoadEventsMixin.UI = {
+ events: {
+ loadHandlers: {}
+ }
+ };
+
+ //
+ // On element-load handlers
+ //
+
+ /**
+ * Replaces $time with a new flow-timestamp element generated by TemplateEngine
+ * @param {jQuery} $time
+ */
+ FlowBoardComponentLoadEventsMixin.UI.events.loadHandlers.timestamp = function ( $time ) {
+ $time.replaceWith(
+ mw.flow.TemplateEngine.callHelper(
+ 'timestamp',
+ parseInt( $time.attr( 'datetime' ), 10) * 1000
+ )
+ );
+ };
+
+ // Mixin to FlowBoardComponent
+ mw.flow.mixinComponent( 'board', FlowBoardComponentLoadEventsMixin );
+}( jQuery, mediaWiki ) );
diff --git a/Flow/modules/engine/components/board/base/flow-board-misc.js b/Flow/modules/engine/components/board/base/flow-board-misc.js
new file mode 100644
index 00000000..3eb7ebcb
--- /dev/null
+++ b/Flow/modules/engine/components/board/base/flow-board-misc.js
@@ -0,0 +1,127 @@
+/*!
+ * Contains miscellaneous functionality needed for FlowBoardComponents.
+ * @todo Find a better place for this code.
+ */
+
+( function ( $, mw ) {
+ /**
+ *
+ * @param {jQuery} $container
+ * @extends FlowComponent
+ * @constructor
+ */
+ function FlowBoardComponentMiscMixin( $container ) {
+ }
+ OO.initClass( FlowBoardComponentMiscMixin );
+
+ //
+ // Methods
+ //
+
+ /**
+ * Removes the preview and unhides the form fields.
+ * @param {jQuery} $cancelButton
+ * @return {bool} true if success
+ * @todo genericize into FlowComponent
+ */
+ function flowBoardComponentResetPreview( $cancelButton ) {
+ var $form = $cancelButton.closest( 'form' ),
+ $button = $form.find( '[name=preview]' ),
+ oldData = $button.data( 'flow-return-to-edit' );
+
+ if ( oldData ) {
+ // We're in preview mode. Revert it back.
+ $button.text( oldData.text );
+
+ // Show the inputs again
+ $form.find( '.flow-preview-target-hidden' ).removeClass( 'flow-preview-target-hidden' ).focus();
+
+ // Remove the preview
+ oldData.$nodes.remove();
+
+ // Remove this reset info
+ $button.removeData( 'flow-return-to-edit' );
+
+ return true;
+ }
+ return false;
+ }
+ FlowBoardComponentMiscMixin.prototype.resetPreview = flowBoardComponentResetPreview;
+
+ /**
+ * This will trigger an eventLog call to the given schema with the given
+ * parameters (along with other info about the user & page.)
+ * A unique funnel ID will be created for all new EventLog calls.
+ *
+ * There may be multiple subsequent calls in the same "funnel" (and share
+ * same info) that you want to track. It is possible to forward funnel data
+ * from one node to another once the first has been clicked. It'll then
+ * log new calls with the same data (schema & entrypoint) & funnel ID as the
+ * initial logged event.
+ *
+ * @param {string} schemaName
+ * @param {object} data Data to be logged
+ * @param {string} data.action Schema's action parameter. Always required!
+ * @param {string} [data.entrypoint] Schema's entrypoint parameter (can be
+ * omitted if already logged in funnel - will inherit)
+ * @param {string} [data.funnelId] Schema's funnelId parameter (can be
+ * omitted if starting new funnel - will be generated)
+ * @param {jQuery} [$forward] Nodes to forward funnel to
+ * @returns {object} Logged data
+ */
+ function logEvent( schemaName, data, $forward ) {
+ var // Get existing (forwarded) funnel id, or generate a new one if it does not yet exist
+ funnelId = data.funnelId || mw.flow.FlowEventLogRegistry.generateFunnelId(),
+ // Fetch existing EventLog object for this funnel (if any)
+ eventLog = mw.flow.FlowEventLogRegistry.funnels[funnelId];
+
+ // Optional argument, may not want/need to forward funnel to other nodes
+ $forward = $forward || $();
+
+ if ( !eventLog ) {
+ // Add some more data to log!
+ data = $.extend( data, {
+ isAnon: mw.user.isAnon(),
+ sessionId: mw.user.sessionId(),
+ funnelId: funnelId,
+ pageNs: mw.config.get( 'wgNamespaceNumber' ),
+ pageTitle: ( new mw.Title( mw.config.get( 'wgPageName' ) ) ).getMain()
+ } );
+
+ // A funnel with this id does not yet exist, create it!
+ eventLog = new mw.flow.EventLog( schemaName, data );
+
+ // Store this particular eventLog - we may want to log more things
+ // in this funnel
+ mw.flow.FlowEventLogRegistry.funnels[funnelId] = eventLog;
+ }
+
+ // Log this action
+ eventLog.logEvent( { action: data.action } );
+
+ // Forward the event
+ this.forwardEvent( $forward, schemaName, funnelId );
+
+ return data;
+ }
+ FlowBoardComponentMiscMixin.prototype.logEvent = logEvent;
+
+ /**
+ * Forward funnel data to other places.
+ *
+ * @param {jQuery} $forward Nodes to forward funnel to
+ * @param {string} schemaName
+ * @param {string} funnelId Schema's funnelId parameter
+ */
+ function forwardEvent( $forward, schemaName, funnelId ) {
+ // Not using data() - it somehow gets lost on some nodes
+ $forward.attr( {
+ 'data-flow-eventlog-schema': schemaName,
+ 'data-flow-eventlog-funnel-id': funnelId
+ } );
+ }
+ FlowBoardComponentMiscMixin.prototype.forwardEvent = forwardEvent;
+
+ // Mixin to FlowBoardComponent
+ mw.flow.mixinComponent( 'board', FlowBoardComponentMiscMixin );
+}( jQuery, mediaWiki ) );
diff --git a/Flow/modules/engine/components/board/base/flow-boardandhistory-base.js b/Flow/modules/engine/components/board/base/flow-boardandhistory-base.js
new file mode 100644
index 00000000..5d3c7085
--- /dev/null
+++ b/Flow/modules/engine/components/board/base/flow-boardandhistory-base.js
@@ -0,0 +1,190 @@
+/*!
+ * Contains the base class for both FlowBoardComponent and FlowBoardHistoryComponent.
+ * This is functionality that is used by both types of page, but not any other components.
+ */
+
+( function ( $, mw ) {
+ /**
+ *
+ * @param {jQuery} $container
+ * @constructor
+ */
+ function FlowBoardAndHistoryComponentBase( $container ) {
+ this.bindNodeHandlers( FlowBoardAndHistoryComponentBase.UI.events );
+ }
+ OO.initClass( FlowBoardAndHistoryComponentBase );
+
+ FlowBoardAndHistoryComponentBase.UI = {
+ events: {
+ apiPreHandlers: {},
+ apiHandlers: {},
+ interactiveHandlers: {}
+ }
+ };
+
+ // Register
+ mw.flow.registerComponent( 'boardAndHistoryBase', FlowBoardAndHistoryComponentBase );
+
+ //
+ // Methods
+ //
+
+ /**
+ * Sets up the board and base properties on this class.
+ * Returns either FALSE for failure, or jQuery object of old nodes that were replaced.
+ * @param {jQuery|boolean} $container
+ * @return {boolean|jQuery}
+ */
+ FlowBoardAndHistoryComponentBase.prototype.reinitializeContainer = function ( $container ) {
+ if ( $container === false ) {
+ return false;
+ }
+
+ // Progressively enhance the board and its forms
+ // @todo Needs a ~"liveUpdateComponents" method, since the functionality in makeContentInteractive needs to also run when we receive new content or update old content.
+ // @todo move form stuff
+ if ( $container.data( 'flow-component' ) !== 'board' ) {
+ // Don't do this for FlowBoardComponent, because that runs makeContentInteractive in its own reinit
+ this.emitWithReturn( 'makeContentInteractive', this );
+ }
+
+ // We don't replace anything with this method (we do with flowBoardComponentReinitializeContainer)
+ return $();
+ };
+
+ //
+ // Interactive handlers
+ //
+
+ /**
+ * @param {Event} event
+ * @returns {$.Promise}
+ */
+ FlowBoardAndHistoryComponentBase.UI.events.interactiveHandlers.moderationDialog = function ( event ) {
+ var $form,
+ $this = $( this ),
+ flowComponent = mw.flow.getPrototypeMethod( 'boardAndHistoryBase', 'getInstanceByElement' )( $this ),
+ // hide, delete, suppress
+ // @todo this could just be detected from the url
+ role = $this.data( 'role' ),
+ template = $this.data( 'flow-template' ),
+ params = {
+ editToken: mw.user.tokens.get( 'editToken' ), // might be unnecessary
+ submitted: {
+ moderationState: role
+ },
+ actions: {}
+ },
+ $deferred = $.Deferred(),
+ modal;
+
+ event.preventDefault();
+
+ params.actions[role] = { url: $this.attr( 'href' ), title: $this.attr( 'title' ) };
+
+ // Render the modal itself with mw-ui-modal
+ modal = mw.Modal( {
+ open: $( mw.flow.TemplateEngine.processTemplateGetFragment( template, params ) ).children(),
+ disableCloseOnOutsideClick: true
+ } );
+
+ // @todo remove this data-flow handler forwarder when data-mwui handlers are implemented
+ // Have the events begin bubbling up from $board
+ flowComponent.assignSpawnedNode( modal.getNode(), flowComponent.$board );
+
+ // Run loadHandlers
+ flowComponent.emitWithReturn( 'makeContentInteractive', modal.getContentNode() );
+
+ // Set flowDialogOwner for API callback @todo find a better way of doing this with mw.Modal
+ $form = modal.getContentNode().find( 'form' ).data( 'flow-dialog-owner', $this );
+ // Bind the cancel callback on the form
+ flowComponent.emitWithReturn( 'addFormCancelCallback', $form, function () {
+ mw.Modal.close( this );
+ } );
+
+ modal = null; // avoid permanent reference
+
+ return $deferred.resolve().promise();
+ };
+
+ /**
+ * Cancels and closes a form. If text has been entered, issues a warning first.
+ * @param {Event} event
+ * @returns {$.Promise}
+ */
+ FlowBoardAndHistoryComponentBase.UI.events.interactiveHandlers.cancelForm = function ( event ) {
+ var target = this,
+ $form = $( this ).closest( 'form' ),
+ flowComponent = mw.flow.getPrototypeMethod( 'boardAndHistoryBase', 'getInstanceByElement' )( $form ),
+ $fields = $form.find( 'textarea, :text' ),
+ changedFieldCount = 0,
+ $deferred = $.Deferred(),
+ callbacks = $form.data( 'flow-cancel-callback' ) || [],
+ schemaName = $( this ).data( 'flow-eventlog-schema' ),
+ funnelId = $( this ).data( 'flow-eventlog-funnel-id' );
+
+ event.preventDefault();
+
+ // Only log cancel attempt if it was user-initiated, not when the cancel
+ // was triggered by code (as part of a post-submit form destroy)
+ if ( event.which ) {
+ flowComponent.logEvent( schemaName, { action: 'cancel-attempt', funnelId: funnelId } );
+ }
+
+ // Check for non-empty fields of text
+ $fields.each( function () {
+ if ( $( this ).val() !== this.defaultValue ) {
+ changedFieldCount++;
+ return false;
+ }
+ } );
+
+ // Only log if user had already entered text (= confirmation was requested)
+ if ( changedFieldCount ) {
+ if ( confirm( flowComponent.constructor.static.TemplateEngine.l10n( 'flow-cancel-warning' ) ) ) {
+ flowComponent.logEvent( schemaName, { action: 'cancel-success', funnelId: funnelId } );
+ } else {
+ flowComponent.logEvent( schemaName, { action: 'cancel-abort', funnelId: funnelId } );
+
+ // User aborted cancel, quit this function & don't destruct the form!
+ return $deferred.reject().promise();
+ }
+ }
+
+ // Reset the form content
+ $form[0].reset();
+
+ // Trigger for flow-actions-disabler
+ $form.find( 'textarea, :text' ).trigger( 'keyup' );
+
+ // Hide the form
+ flowComponent.emitWithReturn( 'hideForm', $form );
+
+ // Get rid of existing error messages
+ flowComponent.emitWithReturn( 'removeError', $form );
+
+ // Trigger the cancel callback
+ $.each( callbacks, function ( idx, fn ) {
+ fn.call( target, event );
+ } );
+
+ return $deferred.resolve().promise();
+ };
+
+ //
+ // Static methods
+ //
+
+ /**
+ * Return true page is in topic namespace,
+ * and if $el is given, that if $el is also within .flow-post.
+ * @param {jQuery} [$el]
+ * @returns {boolean}
+ */
+ function flowBoardInTopicNamespace( $el ) {
+ return inTopicNamespace && ( !$el || $el.closest( '.flow-post' ).length === 0 );
+ }
+ FlowBoardAndHistoryComponentBase.static.inTopicNamespace = flowBoardInTopicNamespace;
+
+ var inTopicNamespace = mw.config.get( 'wgNamespaceNumber' ) === mw.config.get( 'wgNamespaceIds' ).topic;
+}( jQuery, mediaWiki ) );
diff --git a/Flow/modules/engine/components/board/features/flow-board-loadmore.js b/Flow/modules/engine/components/board/features/flow-board-loadmore.js
new file mode 100644
index 00000000..10daaf42
--- /dev/null
+++ b/Flow/modules/engine/components/board/features/flow-board-loadmore.js
@@ -0,0 +1,664 @@
+/*!
+ * Contains loadMore, jumpToTopic, and topic titles list functionality.
+ */
+
+( function ( $, mw, moment ) {
+ /**
+ * Bind UI events and infinite scroll handler for load more and titles list functionality.
+ * @param {jQuery} $container
+ * @this FlowBoardComponent
+ * @constructor
+ */
+ function FlowBoardComponentLoadMoreFeatureMixin( $container ) {
+ /** Stores a reference to each topic element currently on the page */
+ this.renderedTopics = {};
+ /** Stores a list of all topics titles by ID */
+ this.topicTitlesById = {};
+ /** Stores a list of all topic IDs in order */
+ this.orderedTopicIds = [];
+
+ this.bindNodeHandlers( FlowBoardComponentLoadMoreFeatureMixin.UI.events );
+ }
+ OO.initClass( FlowBoardComponentLoadMoreFeatureMixin );
+
+ FlowBoardComponentLoadMoreFeatureMixin.UI = {
+ events: {
+ apiPreHandlers: {},
+ apiHandlers: {},
+ loadHandlers: {}
+ }
+ };
+
+ //
+ // Prototype methods
+ //
+
+ /**
+ * Scrolls up or down to a specific topic, and loads any topics it needs to.
+ * 1. If topic is rendered, scrolls to it.
+ * 2. Otherwise, we load the topic itself
+ * 3b. When the user scrolls up, we begin loading the topics in between.
+ * @param {String} topicId
+ */
+ function flowBoardComponentLoadMoreFeatureJumpTo( topicId ) {
+ /** @type FlowBoardComponent*/
+ var flowBoard = this, apiParameters,
+ // Scrolls to the given topic, but disables infinite scroll loading while doing so
+ _scrollWithoutInfinite = function () {
+ var $renderedTopic = flowBoard.renderedTopics[ topicId ];
+
+ if ( $renderedTopic && $renderedTopic.length ) {
+ flowBoard.infiniteScrollDisabled = true;
+
+ // Get out of the way of the affixed navigation
+ // Not going the full $( '.flow-board-navigation' ).height()
+ // because then the load more button (above the new topic)
+ // would get in sight and any scroll would fire it
+ $( 'html, body' ).scrollTop( $renderedTopic.offset().top - 20 );
+
+ // Focus on given topic
+ $renderedTopic.click().focus();
+
+ /*
+ * Re-enable infinite scroll. Only doing that after a couple
+ * of milliseconds because we've just executed some
+ * scrolling (to the selected topic) and the very last
+ * scroll event may only just still be getting fired.
+ * To prevent an immediate scroll (above the new topic),
+ * let's only re-enable infinite scroll until we're sure
+ * that event has been fired.
+ */
+ setTimeout( function() {
+ delete flowBoard.infiniteScrollDisabled;
+ }, 1 );
+ } else {
+ flowBoard.debug( 'Rendered topic not found when attempting to scroll!' );
+ }
+ };
+
+ // 1. Topic is already on the page; just scroll to it
+ if ( flowBoard.renderedTopics[ topicId ] ) {
+ _scrollWithoutInfinite();
+ return;
+ }
+
+ // 2a. Topic is not rendered; do we know about this topic ID?
+ if ( flowBoard.topicTitlesById[ topicId ] === undefined ) {
+ // We don't. Abort!
+ return flowBoard.debug( 'Unknown topicId', arguments );
+ }
+
+ // 2b. Load that topic and jump to it
+ apiParameters = {
+ action: 'flow',
+ submodule: 'view-topiclist',
+ 'vtloffset-dir': 'fwd', // @todo support "middle" dir
+ 'vtlinclude-offset': true,
+ vtlsortby: this.topicIdSort
+ };
+
+ if ( this.topicIdSort === 'newest' ) {
+ apiParameters['vtloffset-id'] = topicId;
+ } else {
+ // TODO: It would seem to be safer to pass 'offset-id' for both (what happens
+ // if there are two posts at the same timestamp?). (Also, that would avoid needing
+ // the timestamp in the TOC-only API response). However,
+ // apparently, we must pass 'offset' for 'updated' order to get valid
+ // results (e.g. by passing offset-id for 'updated', it doesn't even include
+ // the item requested despite include-offset). However, the server
+ // does not throw an exception for 'offset-id' + 'sortby'='updated', which it
+ // should if this analysis is correct.
+
+ apiParameters.vtloffset = moment.utc( this.updateTimestampsByTopicId[ topicId ] ).format( 'YYYYMMDDHHmmss' );
+ }
+
+ flowBoard.Api.apiCall( apiParameters )
+ // TODO: Finish this error handling or remove the empty functions.
+ // Remove the load indicator
+ .always( function () {
+ // @todo support for multiple indicators on same target
+ //$target.removeClass( 'flow-api-inprogress' );
+ //$this.removeClass( 'flow-api-inprogress' );
+ } )
+ // On success, render the topic
+ .done( function( data ) {
+ _flowBoardComponentLoadMoreFeatureRenderTopics(
+ flowBoard,
+ data.flow[ 'view-topiclist' ].result.topiclist,
+ false,
+ null,
+ '',
+ '',
+ 'flow_topiclist_loop' // @todo clean up the way we pass these 3 params ^
+ );
+
+ _scrollWithoutInfinite();
+ } )
+ // On fail, render an error
+ .fail( function( code, data ) {
+ flowBoard.debug( true, 'Failed to load topics: ' + code );
+ // Failed fetching the new data to be displayed.
+ // @todo render the error at topic position and scroll to it
+ // @todo how do we render this?
+ // $target = ????
+ // flowBoard.emitWithReturn( 'removeError', $target );
+ // var errorMsg = flowBoard.constructor.static.getApiErrorMessage( code, result );
+ // errorMsg = mw.msg( '????', errorMsg );
+ // flowBoard.emitWithReturn( 'showError', $target, errorMsg );
+ } );
+ }
+ FlowBoardComponentLoadMoreFeatureMixin.prototype.jumpToTopic = flowBoardComponentLoadMoreFeatureJumpTo;
+
+ //
+ // API pre-handlers
+ //
+
+ /**
+ * On before board reloading (eg. change sort).
+ * This method only clears the storage in preparation for it to be reloaded.
+ * @param {Event} event
+ * @param {Object} info
+ * @param {jQuery} info.$target
+ * @param {FlowBoardComponent} info.component
+ */
+ function flowBoardComponentLoadMoreFeatureBoardApiPreHandler( event, info ) {
+ // Backup the topic data
+ info.component.renderedTopicsBackup = info.component.renderedTopics;
+ info.component.topicTitlesByIdBackup = info.component.topicTitlesById;
+ info.component.orderedTopicIdsBackup = info.component.orderedTopicIds;
+ // Reset the topic data
+ info.component.renderedTopics = {};
+ info.component.topicTitlesById = {};
+ info.component.orderedTopicIds = [];
+ }
+ FlowBoardComponentLoadMoreFeatureMixin.UI.events.apiPreHandlers.board = flowBoardComponentLoadMoreFeatureBoardApiPreHandler;
+
+ //
+ // API callback handlers
+ //
+
+ /**
+ * On failed board reloading (eg. change sort), restore old data.
+ * @param {Object} info
+ * @param {string} info.status "done" or "fail"
+ * @param {jQuery} info.$target
+ * @param {FlowBoardComponent} info.component
+ * @param {Object} data
+ * @param {jqXHR} jqxhr
+ * @returns {$.Promise}
+ */
+ function flowBoardComponentLoadMoreFeatureBoardApiCallback( info, data, jqxhr ) {
+ if ( info.status !== 'done' ) {
+ // Failed; restore the topic data
+ info.component.renderedTopics = info.component.renderedTopicsBackup;
+ info.component.topicTitlesById = info.component.topicTitlesByIdBackup;
+ info.component.orderedTopicIds = info.component.orderedTopicIdsBackup;
+ }
+
+ // Delete the backups
+ delete info.component.renderedTopicsBackup;
+ delete info.component.topicTitlesByIdBackup;
+ delete info.component.orderedTopicIdsBackup;
+ }
+ FlowBoardComponentLoadMoreFeatureMixin.UI.events.apiHandlers.board = flowBoardComponentLoadMoreFeatureBoardApiCallback;
+
+ /**
+ * Loads more content
+ * @param {Object} info
+ * @param {string} info.status "done" or "fail"
+ * @param {jQuery} info.$target
+ * @param {FlowBoardComponent} info.component
+ * @param {Object} data
+ * @param {jqXHR} jqxhr
+ */
+ function flowBoardComponentLoadMoreFeatureTopicsApiCallback( info, data, jqxhr ) {
+ if ( info.status !== 'done' ) {
+ // Error will be displayed by default, nothing else to wrap up
+ return $.Deferred().reject().promise();
+ }
+
+ var $this = $( this ),
+ $target = info.$target,
+ flowBoard = info.component,
+ scrollTarget = $this.data( 'flow-scroll-target' ),
+ $scrollContainer = $.findWithParent( $this, $this.data( 'flow-scroll-container' ) ),
+ topicsData = data.flow[ 'view-topiclist' ].result.topiclist,
+ readingTopicPosition;
+
+ if ( scrollTarget === 'window' && flowBoard.readingTopicId ) {
+ // Store the current position of the topic you are reading
+ readingTopicPosition = { id: flowBoard.readingTopicId };
+ // Where does the topic start?
+ readingTopicPosition.topicStart = flowBoard.renderedTopics[ readingTopicPosition.id ].offset().top;
+ // Where am I within the topic?
+ readingTopicPosition.topicPlace = $( window ).scrollTop() - readingTopicPosition.topicStart;
+ }
+
+ // Render topics
+ _flowBoardComponentLoadMoreFeatureRenderTopics(
+ flowBoard,
+ topicsData,
+ flowBoard.$container.find( flowBoard.$loadMoreNodes ).last()[ 0 ] === this, // if this is the last load more button
+ $target,
+ scrollTarget,
+ $this.data( 'flow-scroll-container' ),
+ $this.data( 'flow-template' )
+ );
+
+ // Remove the old load button (necessary if the above load_more template returns nothing)
+ $target.remove();
+
+ if ( scrollTarget === 'window' ) {
+ scrollTarget = $( window );
+
+ if ( readingTopicPosition ) {
+ readingTopicPosition.anuStart = flowBoard.renderedTopics[ readingTopicPosition.id ].offset().top;
+ if ( readingTopicPosition.anuStart > readingTopicPosition.topicStart ) {
+ // Looks like the topic we are reading got pushed down. Let's jump to where we were before
+ scrollTarget.scrollTop( readingTopicPosition.anuStart + readingTopicPosition.topicPlace );
+ }
+ }
+ } else {
+ scrollTarget = $.findWithParent( this, scrollTarget );
+ }
+
+ /*
+ * Fire infinite scroll check again - if no (or few) topics were
+ * added (e.g. because they're moderated), we should immediately
+ * fetch more instead of waiting for the user to scroll again (when
+ * there's no reason to scroll)
+ */
+ _flowBoardComponentLoadMoreFeatureInfiniteScrollCheck.call( flowBoard, $scrollContainer, scrollTarget );
+ return $.Deferred().resolve().promise();
+ }
+ FlowBoardComponentLoadMoreFeatureMixin.UI.events.apiHandlers.loadMoreTopics = flowBoardComponentLoadMoreFeatureTopicsApiCallback;
+
+ /**
+ * Loads up the topic titles list.
+ * Saves the topic titles to topicTitlesById and orderedTopicIds, and adds timestamps
+ * to updateTimestampsByTopicId.
+ *
+ * @param {Object} info
+ * @param {string} info.status "done" or "fail"
+ * @param {jQuery} info.$target
+ * @param {FlowBoardComponent} info.component
+ * @param {Object} data
+ * @param {jqXHR} jqxhr
+ */
+ function flowBoardComponentLoadMoreFeatureTopicListApiCallback( info, data, jqxhr ) {
+ if ( info.status !== 'done' ) {
+ // Error will be displayed by default, nothing else to wrap up
+ return;
+ }
+
+ var i = 0,
+ topicsData = data.flow[ 'view-topiclist' ].result.topiclist,
+ topicId, revisionId,
+ flowBoard = info.component;
+
+ // Iterate over every topic
+ for ( ; i < topicsData.roots.length; i++ ) {
+ // Get the topic ID
+ topicId = topicsData.roots[ i ];
+ // Get the revision ID
+ revisionId = topicsData.posts[ topicId ][0];
+
+ if ( $.inArray( topicId, flowBoard.orderedTopicIds ) === -1 ) {
+ // Append to the end, we will sort after the insert loop.
+ flowBoard.orderedTopicIds.push( topicId );
+ }
+
+ if ( flowBoard.topicTitlesById[ topicId ] === undefined ) {
+ // Store the title from the revision object
+ flowBoard.topicTitlesById[ topicId ] = topicsData.revisions[ revisionId ].content.content;
+ }
+
+ if ( flowBoard.updateTimestampsByTopicId[ topicId ] === undefined ) {
+ flowBoard.updateTimestampsByTopicId[ topicId ] = topicsData.revisions[ revisionId ].last_updated;
+ }
+ }
+
+ _flowBoardSortTopicIds( flowBoard );
+
+ // we need to re-trigger scroll.flow-load-more if there are not enough items in the
+ // toc for it to scroll and trigger on its own. Without this TOC never triggers
+ // the initial loadmore to expand from the number of topics on page to topics
+ // available from the api.
+ if ( this.$loadMoreNodes ) {
+ this.$loadMoreNodes
+ .filter( '[data-flow-api-handler=topicList]' )
+ .trigger( 'scroll.flow-load-more', { forceNavigationUpdate: true } );
+ }
+ }
+ FlowBoardComponentLoadMoreFeatureMixin.UI.events.apiHandlers.topicList = flowBoardComponentLoadMoreFeatureTopicListApiCallback;
+
+ //
+ // On element-load handlers
+ //
+
+ /**
+ * Stores the load more button for use with infinite scroll.
+ * @example <button data-flow-scroll-target="< ul"></button>
+ * @param {jQuery} $button
+ */
+ function flowBoardComponentLoadMoreFeatureElementLoadCallback( $button ) {
+ var scrollTargetSelector = $button.data( 'flow-scroll-target' ),
+ $target,
+ scrollContainerSelector = $button.data( 'flow-scroll-container' ),
+ $scrollContainer = $.findWithParent( $button, scrollContainerSelector ),
+ board = this;
+
+ if ( !this.$loadMoreNodes ) {
+ // Create a new $loadMoreNodes list
+ this.$loadMoreNodes = $();
+ } else {
+ // Remove any loadMore nodes that are no longer in the body
+ this.$loadMoreNodes = this.$loadMoreNodes.filter( function () {
+ var $this = $( this );
+
+ // @todo unbind scroll handlers
+ if ( !$this.closest( 'body' ).length ) {
+ // Get rid of this and its handlers
+ $this.remove();
+ // Delete from list
+ return false;
+ }
+
+ return true;
+ } );
+ }
+
+ // Store this new loadMore node
+ this.$loadMoreNodes = this.$loadMoreNodes.add( $button );
+
+ // Make sure we didn't already bind to this element's scroll previously
+ if ( $scrollContainer.data( 'scrollIsBound' ) ) {
+ return;
+ }
+ $scrollContainer.data( 'scrollIsBound', true );
+
+ // Bind the event for this
+ if ( scrollTargetSelector === 'window' ) {
+ this.on( 'windowScroll', function () {
+ _flowBoardComponentLoadMoreFeatureInfiniteScrollCheck.call( board, $scrollContainer, $( window ) );
+ } );
+ } else {
+ $target = $.findWithParent( $button, scrollTargetSelector );
+ $target.on( 'scroll.flow-load-more', $.throttle( 50, function ( evt ) {
+ _flowBoardComponentLoadMoreFeatureInfiniteScrollCheck.call( board, $scrollContainer, $target );
+ } ) );
+ }
+ }
+ FlowBoardComponentLoadMoreFeatureMixin.UI.events.loadHandlers.loadMore = flowBoardComponentLoadMoreFeatureElementLoadCallback;
+
+ /**
+ * Stores a list of all topics currently visible on the page.
+ * @param {jQuery} $topic
+ */
+ function flowBoardComponentLoadMoreFeatureElementLoadTopic( $topic ) {
+ var self = this,
+ currentTopicId = $topic.data( 'flow-id' );
+
+ // Store this topic by ID
+ this.renderedTopics[ currentTopicId ] = $topic;
+
+ // Remove any topics that are no longer on the page, just in case
+ $.each( this.renderedTopics, function ( topicId, $topic ) {
+ if ( !$topic.closest( self.$board ).length ) {
+ delete self.renderedTopics[ topicId ];
+ }
+ } );
+ }
+ FlowBoardComponentLoadMoreFeatureMixin.UI.events.loadHandlers.topic = flowBoardComponentLoadMoreFeatureElementLoadTopic;
+
+ /**
+ * Stores a list of all topics titles currently visible on the page.
+ * @param {jQuery} $topicTitle
+ */
+ function flowBoardComponentLoadMoreFeatureElementLoadTopicTitle( $topicTitle ) {
+ var currentTopicId = $topicTitle.closest( '[data-flow-id]' ).data( 'flowId' );
+
+ // If topic doesn't exist in topic titles list, add it (only happens at page load)
+ // @todo this puts the wrong order
+ if ( this.topicTitlesById[ currentTopicId ] === undefined ) {
+ this.topicTitlesById[ currentTopicId ] = $topicTitle.data( 'flow-topic-title' );
+
+ if ( $.inArray( currentTopicId, this.orderedTopicIds ) === -1 ) {
+ this.orderedTopicIds.push( currentTopicId );
+ _flowBoardSortTopicIds( this );
+ }
+ }
+ }
+ FlowBoardComponentLoadMoreFeatureMixin.UI.events.loadHandlers.topicTitle = flowBoardComponentLoadMoreFeatureElementLoadTopicTitle;
+
+ //
+ // Private functions
+ //
+
+
+ /**
+ * Re-sorts the orderedTopicIds after insert
+ *
+ * @param {Object} flowBoard
+ */
+ function _flowBoardSortTopicIds( flowBoard ) {
+ if ( flowBoard.topicIdSortCallback ) {
+ // Custom sorts
+ flowBoard.orderedTopicIds.sort( flowBoard.topicIdSortCallback );
+ } else {
+ // Default sort, takes advantage of topic ids monotonically increasing
+ // which allows for the newest sort to be the default utf-8 string sort
+ // in reverse.
+ // TODO: This can be optimized (to avoid two in-place operations that affect
+ // the whole array by doing a descending sort (with a custom comparator)
+ // rather than sorting then reversing.
+ flowBoard.orderedTopicIds.sort().reverse();
+ }
+ }
+
+ /**
+ * Called on scroll. Checks to see if a FlowBoard needs to have more content loaded.
+ * @param {jQuery} $searchContainer Container to find 'load more' buttons in
+ * @param {jQuery} $calculationContainer Container to do scroll calculations on (height, scrollTop, offset, etc.)
+ */
+ function _flowBoardComponentLoadMoreFeatureInfiniteScrollCheck( $searchContainer, $calculationContainer ) {
+ if ( this.infiniteScrollDisabled ) {
+ // This happens when the topic navigation is used to jump to a topic
+ // We should not infinite-load anything when we are scrolling to a topic
+ return;
+ }
+
+ var calculationContainerHeight = $calculationContainer.height(),
+ calculationContainerScroll = $calculationContainer.scrollTop(),
+ calculationContainerThreshold = ( $calculationContainer.offset() || { top: calculationContainerScroll } ).top;
+
+ // Find load more buttons within our search container, and they must be visible
+ $searchContainer.find( this.$loadMoreNodes ).filter( ':visible' ).each( function () {
+ var $this = $( this ),
+ nodeOffset = $this.offset().top,
+ nodeHeight = $this.outerHeight( true );
+
+ // First, is this element above or below us?
+ if ( nodeOffset <= calculationContainerThreshold ) {
+ // Top of element is above the viewport; don't use it.
+ return;
+ }
+
+ // @todo: this ignores that TOC also obscures the button: load more
+ // also shouldn't be triggered if it's still behind TOC!
+
+ // Is this element in the viewport?
+ if ( nodeOffset - nodeHeight <= calculationContainerThreshold + calculationContainerHeight ) {
+ // Element is almost in viewport, click it.
+ $( this ).trigger( 'click' );
+ }
+ } );
+ }
+
+ /**
+ * Renders and inserts a list of new topics.
+ * @param {FlowBoardComponent} flowBoard
+ * @param {Object} topicsData
+ * @param {boolean} [forceShowLoadMore]
+ * @param {jQuery} [$insertAt]
+ * @param {String} [scrollTarget]
+ * @param {String} [scrollContainer]
+ * @param {String} [scrollTemplate]
+ * @private
+ */
+ function _flowBoardComponentLoadMoreFeatureRenderTopics( flowBoard, topicsData, forceShowLoadMore, $insertAt, scrollTarget, scrollContainer, scrollTemplate ) {
+ if ( !topicsData.roots.length ) {
+ flowBoard.debug( 'No topics returned from API', arguments );
+ return;
+ }
+
+ /** @private
+ */
+ function _createRevPagination( $target ) {
+ if ( !topicsData.links.pagination.fwd && !topicsData.links.pagination.rev ) {
+ return;
+ }
+
+ if ( !topicsData.links.pagination.rev && topicsData.links.pagination.fwd ) {
+ // This is a fix for the fact that a "rev" is not available here (TODO: Why not?)
+ // We can create one by overriding dir=rev
+ topicsData.links.pagination.rev = $.extend( true, {}, topicsData.links.pagination.fwd, { title: 'rev' } );
+ topicsData.links.pagination.rev.url = topicsData.links.pagination.rev.url.replace( '_offset-dir=fwd', '_offset-dir=rev' );
+ }
+
+ $allRendered = $allRendered.add(
+ $( flowBoard.constructor.static.TemplateEngine.processTemplateGetFragment(
+ 'flow_load_more.partial',
+ {
+ loadMoreObject: topicsData.links.pagination.rev,
+ loadMoreApiHandler: 'loadMoreTopics',
+ loadMoreTarget: scrollTarget,
+ loadMoreContainer: scrollContainer,
+ loadMoreTemplate: scrollTemplate
+ }
+ ) ).children()
+ .insertBefore( $target.first() )
+ );
+ }
+
+ /** @private
+ */
+ function _createFwdPagination( $target ) {
+ if ( forceShowLoadMore || topicsData.links.pagination.fwd ) {
+ // Add the load more to the end of the stack
+ $allRendered = $allRendered.add(
+ $( flowBoard.constructor.static.TemplateEngine.processTemplateGetFragment(
+ 'flow_load_more.partial',
+ {
+ loadMoreObject: topicsData.links.pagination.fwd,
+ loadMoreApiHandler: 'loadMoreTopics',
+ loadMoreTarget: scrollTarget,
+ loadMoreContainer: scrollContainer,
+ loadMoreTemplate: scrollTemplate
+ }
+ ) ).children()
+ .insertAfter( $target.last() )
+ );
+ }
+ }
+
+ /**
+ * Renders topics by IDs from topicsData, and returns the elements.
+ * @param {Array} toRender List of topic IDs in topicsData
+ * @returns {jQuery}
+ * @private
+ */
+ function _render( toRender ) {
+ var rootsBackup = topicsData.roots,
+ $newTopics;
+
+ // Temporarily set roots to our subset to be rendered
+ topicsData.roots = toRender;
+
+ try {
+ $newTopics = $( flowBoard.constructor.static.TemplateEngine.processTemplateGetFragment(
+ scrollTemplate,
+ topicsData
+ ) ).children();
+ } catch( e ) {
+ flowBoard.debug( true, 'Failed to render new topic' );
+ $newTopics = $();
+ }
+
+ topicsData.roots = rootsBackup;
+
+ return $newTopics;
+ }
+
+ var i, j, $topic, topicId,
+ $allRendered = $( [] ),
+ toInsert = [];
+
+ for ( i = 0; i < topicsData.roots.length; i++ ) {
+ topicId = topicsData.roots[ i ];
+
+ if ( !flowBoard.renderedTopics[ topicId ] ) {
+ flowBoard.renderedTopics[ topicId ] = _render( [ topicId ] );
+ $allRendered.push( flowBoard.renderedTopics[ topicId ][0] );
+ toInsert.push( topicId );
+ if ( $.inArray( topicId, flowBoard.orderedTopicIds ) === -1 ) {
+ flowBoard.orderedTopicIds.push( topicId );
+ }
+ // @todo this is already done elsewhere, but it runs after insert
+ // to the DOM instead of before. Not sure how to fix ordering.
+ if ( !flowBoard.updateTimestampsByTopicId[ topicId ] ) {
+ flowBoard.updateTimestampsByTopicId[ topicId ] = topicsData.revisions[topicsData.posts[topicId][0]].last_updated;
+ }
+ }
+ }
+
+ if ( toInsert.length ) {
+ _flowBoardSortTopicIds( flowBoard );
+
+ // This uses the assumption that there will be at least one pre-existing
+ // topic above the topics to be inserted. This should hold true as the
+ // initial page load starts at the begining.
+ for ( i = 1; i < flowBoard.orderedTopicIds.length; i++ ) {
+ // topic is not to be inserted yet.
+ if ( $.inArray( flowBoard.orderedTopicIds[ i ], toInsert ) === -1 ) {
+ continue;
+ }
+
+ // find the most recent topic in the list that exists and insert after it.
+ for ( j = i - 1; j >= 0; j-- ) {
+ $topic = flowBoard.renderedTopics[ flowBoard.orderedTopicIds[ j ] ];
+ if ( $topic && $topic.length && $.contains( document.body, $topic[0] ) ) {
+ break;
+ }
+ }
+
+ // Put the new topic after the found topic above it
+ if ( j >= 0 ) {
+ $topic.after( flowBoard.renderedTopics[ flowBoard.orderedTopicIds[ i ] ] );
+ }
+ }
+
+ // This works because orderedTopicIds includes not only the topics on
+ // page but also the ones loaded by the toc. If these topics are due
+ // to a jump rather than forward auto-pagination the prior topic will
+ // not be rendered.
+ i = $.inArray( topicsData.roots[0], flowBoard.orderedTopicIds );
+ if ( i > 0 && flowBoard.renderedTopics[ flowBoard.orderedTopicIds[ i - 1 ] ] === undefined ) {
+ _createRevPagination( flowBoard.renderedTopics[ topicsData.roots[0] ] );
+ }
+ // Same for forward pagination, if we jumped and then scrolled backwards the
+ // topic after the last will already be rendered, and forward pagination
+ // will not be necessary.
+ i = $.inArray( topicsData.roots[ topicsData.roots.length - 1 ], flowBoard.orderedTopicIds );
+ if ( i === flowBoard.orderedTopicIds.length - 1 || flowBoard.renderedTopics[ flowBoard.orderedTopicIds[ i + 1 ] ] === undefined ) {
+ _createFwdPagination( flowBoard.renderedTopics[ topicsData.roots[ topicsData.roots.length - 1 ] ] );
+ }
+ }
+
+ // Run loadHandlers
+ flowBoard.emitWithReturn( 'makeContentInteractive', $allRendered );
+ }
+
+ // Mixin to FlowBoardComponent
+ mw.flow.mixinComponent( 'board', FlowBoardComponentLoadMoreFeatureMixin );
+}( jQuery, mediaWiki, moment ) );
diff --git a/Flow/modules/engine/components/board/features/flow-board-navigation.js b/Flow/modules/engine/components/board/features/flow-board-navigation.js
new file mode 100644
index 00000000..7b82230f
--- /dev/null
+++ b/Flow/modules/engine/components/board/features/flow-board-navigation.js
@@ -0,0 +1,282 @@
+/*!
+ * Contains board navigation header, which affixes to the viewport on scroll.
+ */
+
+( function ( $, mw ) {
+ /**
+ * Binds handlers for the board header itself.
+ * @param {jQuery} $container
+ * @this FlowComponent
+ * @constructor
+ */
+ function FlowBoardComponentBoardHeaderFeatureMixin( $container ) {
+ // Bind element handlers
+ this.bindNodeHandlers( FlowBoardComponentBoardHeaderFeatureMixin.UI.events );
+
+ /** {String} topic ID currently being read in viewport */
+ this.readingTopicId = null;
+
+ /** {Object} Map from topic id to its last update timestamp for sorting */
+ this.updateTimestampsByTopicId = {};
+ }
+ OO.initClass( FlowBoardComponentBoardHeaderFeatureMixin );
+
+ FlowBoardComponentBoardHeaderFeatureMixin.UI = {
+ events: {
+ apiPreHandlers: {},
+ apiHandlers: {},
+ interactiveHandlers: {},
+ loadHandlers: {}
+ }
+ };
+
+ //
+ // Prototype methods
+ //
+
+ //
+ // API pre-handlers
+ //
+
+ //
+ // On element-click handlers
+ //
+
+ //
+ // On element-load handlers
+ //
+
+ /**
+ * Bind the navigation header bar to the window.scroll event.
+ * @param {jQuery} $boardNavigation
+ */
+ function flowBoardLoadEventsBoardNavigation( $boardNavigation ) {
+ this
+ .off( 'windowScroll', _flowBoardAdjustTopicNavigationHeader )
+ .off( 'windowResize', _flowBoardAdjustTopicNavigationHeader )
+ .on( 'windowScroll', _flowBoardAdjustTopicNavigationHeader, [ $boardNavigation ] )
+ .on( 'windowResize', _flowBoardAdjustTopicNavigationHeader, [ $boardNavigation ] );
+
+ // remove any existing state about the affixed navigation, it has been replaced
+ // with a new $boardNavigation to clone from.
+ if ( this.$boardNavigationClone ) {
+ this.$boardNavigationClone.remove();
+ delete this.$boardNavigationClone;
+ }
+ // The topic navigation header becomes fixed to the window beyond its position
+ _flowBoardAdjustTopicNavigationHeader.call( this, $boardNavigation, {} );
+
+ // initialize the board topicId sorting callback. This expects to be rendered
+ // as a sibling of the topiclist component. The topiclist component includes
+ // information about how it is currently sorted, so we can maintain that in the
+ // TOC. This is typically either 'newest' or 'updated'.
+ this.topicIdSort = $boardNavigation.siblings('[data-flow-sortby]').data( 'flow-sortby' );
+ this.updateTopicIdSortCallback();
+
+ // This allows the toc to initialize eagerly before the user looks at it.
+ $boardNavigation.find( '[data-flow-api-handler=topicList]' )
+ .trigger( 'click', { skipMenuToggle: true, forceNavigationUpdate: true } );
+ }
+ FlowBoardComponentBoardHeaderFeatureMixin.UI.events.loadHandlers.boardNavigation = flowBoardLoadEventsBoardNavigation;
+
+ /**
+ * Stores the board navigation title.
+ * @param {jQuery} $boardNavigationTitle
+ */
+ function flowBoardLoadEventsBoardNavigationTitle( $boardNavigationTitle ) {
+ this.boardNavigationOriginalTitle = $boardNavigationTitle.text();
+ this.$boardNavigationTitle = $boardNavigationTitle;
+ }
+ FlowBoardComponentBoardHeaderFeatureMixin.UI.events.loadHandlers.boardNavigationTitle = flowBoardLoadEventsBoardNavigationTitle;
+
+ /**
+ * @param {jQuery} $topic
+ */
+ function flowBoardLoadEventsTopic( $topic ) {
+ var id = $topic.data( 'flow-id' ),
+ updated = $topic.data( 'flow-topic-timestamp-updated' );
+
+ this.updateTimestampsByTopicId[id] = updated;
+ }
+ FlowBoardComponentBoardHeaderFeatureMixin.UI.events.loadHandlers.topic = flowBoardLoadEventsTopic;
+
+ //
+ // Private functions
+ //
+
+ /**
+ * Initialize the topic id sort callback
+ */
+ function _flowBoardUpdateTopicIdSortCallback() {
+ if ( this.topicIdSort === 'newest' ) {
+ // the sort callback takes advantage of the default utf-8
+ // sort in this case
+ this.topicIdSortCallback = undefined;
+ } else if ( this.topicIdSort === 'updated' ) {
+ this.topicIdSortCallback = flowBoardTopicIdGenerateSortRecentlyActive( this );
+ } else {
+ throw new Error( 'this.topicIdSort has an invalid value' );
+ }
+ }
+ FlowBoardComponentBoardHeaderFeatureMixin.prototype.updateTopicIdSortCallback = _flowBoardUpdateTopicIdSortCallback;
+
+ /**
+ * Generates Array#sort callback for sorting a list of topic ids
+ * by the 'recently active' sort order. This is a numerical
+ * comparison of related timestamps held within the board object.
+ * Also note that this is a reverse sort from newest to oldest.
+ * @param {Object} board Object from which to source
+ * timestamps which map from topicId to its last updated timestamp
+ * @return {Function}
+ */
+ function flowBoardTopicIdGenerateSortRecentlyActive( board ) {
+ /**
+ * @param {String} a
+ * @param {String} b
+ * @return {integer} Per Array#sort callback rules
+ */
+ return function ( a, b ) {
+ var aTimestamp = board.updateTimestampsByTopicId[a],
+ bTimestamp = board.updateTimestampsByTopicId[b];
+
+ if ( aTimestamp === undefined && bTimestamp === undefined ) {
+ return 0;
+ } else if ( aTimestamp === undefined ) {
+ return 1;
+ } else if ( bTimestamp === undefined ) {
+ return -1;
+ } else {
+ return bTimestamp - aTimestamp;
+ }
+ };
+ }
+
+ // TODO: Let's look at decoupling the event handler part from the parts that actually do the
+ // work. (Already, event is not used.)
+ /**
+ * On window.scroll, we clone the nav header bar and fix the original to the window top.
+ * We clone so that we have one which always remains in the same place for calculation purposes,
+ * as it can vary depending on whether or not new content is rendered or the window is resized.
+ * @param {jQuery} $boardNavigation board navigation element
+ * @param {Event} event Event passed to windowScroll (unused)
+ * @param {Object} extraParameters
+ * @param {boolean} extraParameters.forceNavigationUpdate True to force a change to the
+ * active item and TOC scroll.
+ */
+ function _flowBoardAdjustTopicNavigationHeader( $boardNavigation, event, extraParameters ) {
+ var bottomScrollPosition, topicText, newReadingTopicId,
+ self = this,
+ boardNavigationPosition = ( this.$boardNavigationClone || $boardNavigation ).offset(),
+ windowVerticalScroll = $( window ).scrollTop();
+
+ extraParameters = extraParameters || {};
+
+ if ( windowVerticalScroll <= boardNavigationPosition.top ) {
+ // Board nav is still in view; don't affix it
+ if ( this.$boardNavigationClone ) {
+ // Un-affix this
+ $boardNavigation
+ .removeClass( 'flow-board-navigation-affixed' )
+ .css( 'left', '' );
+ // Remove the old clone if it exists
+ this.$boardNavigationClone.remove();
+ delete this.$boardNavigationClone;
+ }
+
+ if ( this.boardNavigationOriginalTitle && this.$boardNavigationTitle ) {
+ this.$boardNavigationTitle.text( this.boardNavigationOriginalTitle );
+ }
+
+ return;
+ }
+
+ if ( !this.$boardNavigationClone ) {
+ // Make a new clone
+ this.$boardNavigationClone = $boardNavigation.clone();
+
+ // Add new classes, and remove the main load handler so we don't trigger it again
+ this.$boardNavigationClone
+ .removeData( 'flow-load-handler' )
+ .removeClass( 'flow-load-interactive' )
+ // Also get rid of any menus, in case they were open
+ .find( '.flow-menu' )
+ .remove();
+
+ $boardNavigation
+ // Insert it
+ .before( this.$boardNavigationClone )
+ // Affix the original one
+ .addClass( 'flow-board-navigation-affixed' );
+
+ // After cloning a new navigation we must always update the sort
+ extraParameters.forceNavigationUpdate = true;
+ }
+
+ boardNavigationPosition = this.$boardNavigationClone.offset();
+
+ // The only thing that needs calculating is its left offset
+ if ( parseInt( $boardNavigation.css( 'left' ) ) !== boardNavigationPosition.left ) {
+ $boardNavigation.css( {
+ left: boardNavigationPosition.left
+ } );
+ }
+
+ // Find out what the bottom of the board nav is touching
+ // XXX: One of the IE 8 problems seems to be that $boardNavigation.outerHeight( true ) is about 10 times too big (35 on Firefox, 349 on IE 8).
+ // I think this also causes visual problems.
+ bottomScrollPosition = windowVerticalScroll + $boardNavigation.outerHeight( true );
+
+ $.each( this.orderedTopicIds || [], function ( idx, topicId, $topic ) {
+ $topic = self.renderedTopics[ topicId ];
+
+ if ( !$topic ) {
+ return;
+ }
+
+ var target = $topic.data( 'flow-toc-scroll-target' ),
+ $target = $.findWithParent( $topic, target );
+
+ if ( $target.offset().top - parseInt( $target.css( "padding-top" ) ) > bottomScrollPosition ) {
+ return false; // stop, this topic is too far
+ }
+
+ topicText = self.topicTitlesById[ topicId ];
+ newReadingTopicId = topicId;
+ } );
+
+ self.readingTopicId = newReadingTopicId;
+
+ if ( !this.$boardNavigationTitle ) {
+ return;
+ }
+
+ function calculateUpdatedTitleText( board, topicText ) {
+ // Find out if we need to change the title
+ if ( topicText !== undefined ) {
+ if ( board.$boardNavigationTitle.text() !== topicText ) {
+ // Change it
+ return topicText;
+ }
+ } else if ( board.$boardNavigationTitle.text() !== board.boardNavigationOriginalTitle ) {
+ return board.boardNavigationOriginalTitle;
+ }
+ }
+
+ // We still need to trigger movemnt when the topic title has not changed
+ // in instances where new data has been loaded.
+ topicText = calculateUpdatedTitleText( this, topicText );
+ if ( topicText !== undefined ) {
+ // We only reach this if the visible topic has changed
+ this.$boardNavigationTitle.text( topicText );
+ } else if ( extraParameters.forceNavigationUpdate !== true ) {
+ // If the visible topic has not changed and we are not forced
+ // to update(due to new items or other situations), exit early.
+ return;
+ }
+
+ this.scrollTocToActiveItem();
+ }
+
+ // Mixin to FlowComponent
+ mw.flow.mixinComponent( 'component', FlowBoardComponentBoardHeaderFeatureMixin );
+}( jQuery, mediaWiki ) );
diff --git a/Flow/modules/engine/components/board/features/flow-board-preview.js b/Flow/modules/engine/components/board/features/flow-board-preview.js
new file mode 100644
index 00000000..c706acf7
--- /dev/null
+++ b/Flow/modules/engine/components/board/features/flow-board-preview.js
@@ -0,0 +1,237 @@
+/*!
+ * @todo break this down into mixins for each callback section (eg. post actions, read topics)
+ */
+
+( function ( $, mw ) {
+ /**
+ * Binds API events to FlowBoardComponent
+ * @param {jQuery} $container
+ * @extends FlowComponent
+ * @constructor
+ */
+ function FlowBoardComponentPreviewMixin( $container ) {
+ // Bind event callbacks
+ this.bindNodeHandlers( FlowBoardComponentPreviewMixin.UI.events );
+ }
+ OO.initClass( FlowBoardComponentPreviewMixin );
+
+ /** Event handlers are stored here, but are registered in the constructor */
+ FlowBoardComponentPreviewMixin.UI = {
+ events: {
+ apiPreHandlers: {},
+ apiHandlers: {}
+ }
+ };
+
+ //
+ // pre-api callback handlers, to do things before the API call
+ //
+
+ /**
+ * First, resets the previous preview (if any).
+ * Then, using the form fields, finds the content element to be sent to Parsoid by looking
+ * for one ending in "content", or, failing that, with data-role=content.
+ * @param {Event} event The event being handled
+ * @return {Function} Callback to modify the API request
+ * @todo genericize into FlowComponent
+ */
+ FlowBoardComponentPreviewMixin.UI.events.apiPreHandlers.preview = function ( event ) {
+ var callback,
+ $this = $( this ),
+ $target = $this.findWithParent( $this.data( 'flow-api-target' ) ),
+ previewTitleGenerator = $target.data( 'flow-preview-title-generator' ),
+ previewTitle = $target.data( 'flow-preview-title' ),
+ flowBoard = mw.flow.getPrototypeMethod( 'board', 'getInstanceByElement' )( $this ),
+ schemaName = $this.data( 'flow-eventlog-schema' ),
+ funnelId = $this.data( 'flow-eventlog-funnel-id' ),
+ logAction = $this.data( 'flow-return-to-edit' ) ? 'keep-editing' : 'preview',
+ generators = {
+ newTopic: function() {
+ // Convert current timestamp to base-2
+ var namespace = mw.config.get( 'wgFormattedNamespaces' )[2600],
+ timestamp = mw.flow.baseConvert( Date.now(), 10, 2 );
+ // Pad base-2 out to 88 bits (@todo why 84?)
+ timestamp += [ 84 - timestamp.length ].join( '0' );
+ // convert base-2 to base-36
+ return namespace + ':' + mw.flow.baseConvert( timestamp, 2, 36 );
+ },
+ wgPageName: function() {
+ return mw.config.get( 'wgPageName' );
+ }
+ };
+
+ if ( !previewTitleGenerator || !generators.hasOwnProperty( previewTitleGenerator ) ) {
+ previewTitleGenerator = 'wgPageName';
+ }
+
+ flowBoard.logEvent( schemaName, { action: logAction, funnelId: funnelId } );
+
+ callback = function ( queryMap ) {
+ var content = null;
+
+ // XXX: Find the content parameter
+ $.each( queryMap, function( key, value ) {
+ var piece = key.slice( -7 );
+ if ( piece === 'content' || piece === 'summary' ) {
+ content = value;
+ return false;
+ }
+ } );
+
+ // If we fail to find a content param, look for a field that is the "content" role and use that
+ if ( content === null ) {
+ content = $this.closest( 'form' ).find( 'input, textarea' ).filter( '[data-role="content"]' ).val();
+ }
+
+ queryMap = {
+ 'action': 'flow-parsoid-utils',
+ 'from': 'wikitext',
+ 'to': 'html',
+ 'content': content
+ };
+
+ if ( previewTitle ) {
+ queryMap.title = previewTitle;
+ } else {
+ queryMap.title = generators[previewTitleGenerator]();
+ }
+
+ return queryMap;
+ };
+
+ // Reset the preview state if already in it
+ if ( flowBoard.resetPreview( $this ) ) {
+ // Special way of cancelling a request, other than returning false outright
+ callback._abort = true;
+ }
+
+ return callback;
+ };
+
+ /**
+ * Triggers a preview of the given content.
+ * @param {Object} info (status:done|fail, $target: jQuery)
+ * @param {Object} data
+ * @param {jqXHR} jqxhr
+ * @returns {$.Promise}
+ */
+ FlowBoardComponentPreviewMixin.UI.events.apiHandlers.preview = function( info, data, jqxhr ) {
+ var revision, creator,
+ $previewContainer,
+ templateParams,
+ $button = $( this ),
+ $form = $button.closest( 'form' ),
+ $cancelButton = $form.find('.mw-ui-button[data-role="cancel"]'),
+ flowBoard = mw.flow.getPrototypeMethod( 'board', 'getInstanceByElement' )( $form ),
+ $titleField = $form.find( 'input' ).filter( '[data-role=title]' ),
+ $target = info.$target,
+ username = $target.data( 'flow-creator' ) || mw.user.getName(),
+ id = Math.random(),
+ previewTemplate = $target.data( 'flow-preview-template' ),
+ contentNode = $target.data( 'flow-preview-node' ) || 'content';
+
+ if ( info.status !== 'done' ) {
+ // Error will be displayed by default, nothing else to wrap up
+ return $.Deferred().reject().promise();
+ }
+
+ creator = {
+ links: {
+ userpage: {
+ url: mw.util.getUrl( 'User:' + username ),
+ // FIXME: Assume, as we don't know at this point...
+ exists: true
+ },
+ talk: {
+ url: mw.util.getUrl( 'User talk:' + username ),
+ // FIXME: Assume, as we don't know at this point...
+ exists: true
+ },
+ contribs: {
+ url: mw.util.getUrl( 'Special:Contributions/' + username ),
+ exists: true,
+ title: username
+ }
+ },
+ name: username || flowBoard.constructor.static.TemplateEngine.l10n( 'flow-anonymous' )
+ };
+
+ revision = {
+ postId: id,
+ creator: creator,
+ replies: [ id ],
+ isPreview: true
+ };
+ templateParams = {};
+
+ // This is for most previews which expect a "revision" key
+ revision[contentNode] = {
+ content: data['flow-parsoid-utils'].content,
+ format: data['flow-parsoid-utils'].format
+ };
+ // This fixes summarize which expects a key "summary"
+ templateParams[contentNode] = revision[contentNode];
+
+ $.extend( templateParams, {
+ // This fixes titlebar which expects a key "content" for title
+ content: {
+ content: $titleField.val() || '',
+ format: 'content'
+ },
+ creator: creator,
+ posts: {},
+ // @todo don't do these. it's a catch-all for the templates which expect a revision key, and those that don't.
+ revision: revision,
+ reply_count: 1,
+ last_updated: +new Date(),
+ replies: [ id ],
+ revisions: {}
+ } );
+ templateParams.posts[id] = { 0: id };
+ templateParams.revisions[id] = revision;
+
+ // Render the preview warning
+ $previewContainer = $( flowBoard.constructor.static.TemplateEngine.processTemplateGetFragment(
+ 'flow_preview_warning.partial'
+ ) ).children();
+
+ // @todo Perhaps this should be done in each template, and not here?
+ $previewContainer.addClass( 'flow-preview' );
+
+ // Render this template with the preview data
+ $previewContainer = $previewContainer.add(
+ $( flowBoard.constructor.static.TemplateEngine.processTemplateGetFragment(
+ previewTemplate,
+ templateParams
+ ) ).children()
+ );
+
+ // Hide any input fields and anon warning
+ $form.find( 'input, textarea, .flow-anon-warning' )
+ .addClass( 'flow-preview-target-hidden' );
+
+ // Insert the new preview before the form
+ $target
+ .parent( 'form' )
+ .before( $previewContainer );
+
+ // Hide cancel button on preview screen
+ $cancelButton.hide();
+
+ // Assign the reset-preview information for later use
+ $button
+ .data( 'flow-return-to-edit', {
+ text: $button.text(),
+ $nodes: $previewContainer
+ } )
+ .text( flowBoard.constructor.static.TemplateEngine.l10n( 'flow-preview-return-edit-post' ) )
+ .one( 'click', function() {
+ $cancelButton.show();
+ } );
+
+ return $.Deferred().resolve().promise();
+ };
+
+ // Mixin to FlowBoardComponent
+ mw.flow.mixinComponent( 'component', FlowBoardComponentPreviewMixin );
+}( jQuery, mediaWiki ) );
diff --git a/Flow/modules/engine/components/board/features/flow-board-switcheditor.js b/Flow/modules/engine/components/board/features/flow-board-switcheditor.js
new file mode 100644
index 00000000..b08eb7bf
--- /dev/null
+++ b/Flow/modules/engine/components/board/features/flow-board-switcheditor.js
@@ -0,0 +1,52 @@
+/*!
+ * Handlers for the switching the editor from wikitext to visualeditor
+ */
+
+( function ( $, mw ) {
+ /**
+ * Binds handlers for switching from wikitext to visualeditor
+ *
+ * @param {jQuery} $container
+ * @this FlowComponent
+ * @constructor
+ */
+ function FlowBoardComponentSwitchEditorFeatureMixin( $container ) {
+ // Bind element handlers
+ this.bindNodeHandlers( FlowBoardComponentSwitchEditorFeatureMixin.UI.events );
+ }
+ OO.initClass( FlowBoardComponentSwitchEditorFeatureMixin );
+
+ FlowBoardComponentSwitchEditorFeatureMixin.UI = {
+ events: {
+ interactiveHandlers: {},
+ loadHandlers: {}
+ }
+ };
+
+ /**
+ * Toggle between possible editors.
+ *
+ * Currently the only options are visualeditor, and none. Visualeditor has its own
+ * code for switching, so this is only run by clicking the switch button from 'none'.
+ * If we add more editors later this will have to be revisited.
+ *
+ * @param {Event} event
+ * @returns {jQuery.Promise}
+ */
+ FlowBoardComponentSwitchEditorFeatureMixin.UI.events.interactiveHandlers.switchEditor = function ( event ) {
+ var $this = $( this ),
+ $target = $this.findWithParent( $this.data( 'flow-target' ) );
+
+ event.preventDefault();
+
+ if ( !$target.length ) {
+ mw.flow.debug( '[switchEditor] No target located' );
+ return $.Deferred().reject().promise();
+ }
+
+ return mw.flow.editor.switchEditor( $target, 'visualeditor' );
+ };
+
+ // Mixin to FlowComponent
+ mw.flow.mixinComponent( 'component', FlowBoardComponentSwitchEditorFeatureMixin );
+}( jQuery, mediaWiki ) );
diff --git a/Flow/modules/engine/components/board/features/flow-board-toc.js b/Flow/modules/engine/components/board/features/flow-board-toc.js
new file mode 100644
index 00000000..4d64730a
--- /dev/null
+++ b/Flow/modules/engine/components/board/features/flow-board-toc.js
@@ -0,0 +1,354 @@
+/*!
+ * Contains Table of Contents functionality.
+ */
+
+( function ( $, mw ) {
+ /**
+ * Binds handlers for TOC in board header.
+ * @param {jQuery} $container
+ * @this FlowComponent
+ * @constructor
+ */
+ function FlowBoardComponentTocFeatureMixin( $container ) {
+ /** Stores a list of topic IDs rendered in our TOC */
+ this.topicIdsInToc = {};
+ /** Used to define an offset to optimize fetching of TOC when we already have some items in it */
+ this.lastTopicIdInToc = null;
+
+ // Bind element handlers
+ this.bindNodeHandlers( FlowBoardComponentTocFeatureMixin.UI.events );
+ }
+ OO.initClass( FlowBoardComponentTocFeatureMixin );
+
+ FlowBoardComponentTocFeatureMixin.UI = {
+ events: {
+ apiPreHandlers: {},
+ apiHandlers: {},
+ interactiveHandlers: {},
+ loadHandlers: {}
+ }
+ };
+
+ //
+ // API pre-handlers
+ //
+
+ /**
+ * Empties out TOC menu when board is being refreshed.
+ * @param {Event} event
+ * @param {Object} info
+ * @param {jQuery} info.$target
+ * @param {FlowBoardComponent} info.component
+ */
+ function flowBoardComponentTocFeatureMixinBoardApiPreHandler( event, info ) {
+ info.component.topicIdsInTocBackup = info.component.topicIdsInToc;
+ info.component.lastTopicIdInTocBackup = info.component.lastTopicIdInToc;
+
+ info.component.topicIdsInToc = {};
+ info.component.lastTopicIdInToc = null;
+
+ if ( info.component.$tocMenu ) {
+ info.component.$tocMenuChildBackup = info.component.$tocMenu.children().detach();
+ info.component.$tocMenuBackup = info.component.$tocMenu;
+ info.component.$tocMenu = null;
+ }
+ }
+ FlowBoardComponentTocFeatureMixin.UI.events.apiPreHandlers.board = flowBoardComponentTocFeatureMixinBoardApiPreHandler;
+
+ /**
+ *
+ * @param {Event} event
+ * @param {Object} info
+ * @param {jQuery} info.$target
+ * @param {FlowBoardComponent} info.component
+ */
+ function flowBoardComponentTocFeatureMixinTopicListApiPreHandler( event, info, extraParameters ) {
+ var $this = $( this ),
+ isLoadMoreButton = $this.data( 'flow-load-handler' ) === 'loadMore',
+ overrides;
+
+ if ( !isLoadMoreButton && !( extraParameters || {} ).skipMenuToggle ) {
+ // Re-scroll the TOC (in case the scroll that tracks the page scroll failed
+ // due to insufficient elements making the desired scrollTop not work (T78572)).
+ info.component.scrollTocToActiveItem();
+
+ // Actually open/close the TOC menu on this node.
+ $this.trigger( 'click', { interactiveHandler: 'menuToggle' } );
+ }
+
+ if ( !isLoadMoreButton && info.component.doneInitialTocApiCall ) {
+ // Triggers load more if we didn't load enough content to fill the viewport
+ info.$target.trigger( 'scroll.flow-load-more', { forceNavigationUpdate: true } );
+ return false;
+ }
+
+ // Send some overrides to this API request
+ overrides = {
+ topiclist_sortby: info.component.$board.data( 'flow-sortby' ),
+ topiclist_limit: 50,
+ topiclist_toconly: true
+ };
+
+ // @todo verify that this works
+ //if ( info.component.lastTopicIdInToc ) {
+ // overrides.topiclist_offset = false;
+ // overrides['topiclist_offset-id'] = info.component.lastTopicIdInToc;
+ //}
+
+ if ( !overrides.topiclist_sortby ) {
+ delete overrides.topiclist_sortby;
+ }
+
+ return overrides;
+ }
+ FlowBoardComponentTocFeatureMixin.UI.events.apiPreHandlers.topicList = flowBoardComponentTocFeatureMixinTopicListApiPreHandler;
+
+ //
+ // API handlers
+ //
+
+ /**
+ * Restores TOC stuff if board reload fails.
+ * @param {Object} info
+ * @param {string} info.status "done" or "fail"
+ * @param {jQuery} info.$target
+ * @param {FlowBoardComponent} info.component
+ * @param {Object} data
+ * @param {jqXHR} jqxhr
+ */
+ function flowBoardComponentTocFeatureMixinBoardApiCallback( info, data, jqxhr ) {
+ if ( info.status !== 'done' ) {
+ // Failed; restore the topic data
+ info.component.topicIdsInToc = info.component.topicIdsInTocBackup;
+ info.component.lastTopicIdInToc = info.component.lastTopicIdInTocBackup;
+ if ( info.component.$tocMenuBackup ) {
+ info.component.$tocMenu = info.component.$tocMenuBackup;
+ }
+
+ if ( info.component.$tocMenu ) {
+ info.component.$tocMenu.append( info.component.$tocMenuChildBackup );
+ }
+ }
+
+ // Delete the backups
+ delete info.component.topicIdsInTocBackup;
+ delete info.component.lastTopicIdInTocBackup;
+ info.component.$tocMenuChildBackup.remove();
+ delete info.component.$tocMenuChildBackup;
+ delete info.component.$tocMenuBackup;
+
+ // Allow reopening
+ info.component.doneInitialTocApiCall = false;
+ }
+ FlowBoardComponentTocFeatureMixin.UI.events.apiHandlers.board = flowBoardComponentTocFeatureMixinBoardApiCallback;
+
+ /**
+ * The actual storage is in FlowBoardComponentLoadMoreFeatureMixin.UI.events.apiHandlers.topicList.
+ * @param {Object} info
+ * @param {string} info.status "done" or "fail"
+ * @param {jQuery} info.$target
+ * @param {Object} data
+ * @param {jqXHR} jqxhr
+ */
+ function flowBoardComponentTocFeatureMixinTopicListApiHandler( info, data, jqxhr ) {
+ if ( info.status !== 'done' ) {
+ // Error will be displayed by default, nothing else to wrap up
+ return;
+ }
+
+ var $kids, i,
+ $this = $( this ),
+ template = info.component.tocTemplate,
+ topicsData = data.flow['view-topiclist'].result.topiclist,
+ isLoadMoreButton = $this.data( 'flow-load-handler' ) === 'loadMore';
+
+ // Iterate over every topic
+ for ( i = 0; i < topicsData.roots.length; i++ ) {
+ // Do this until we find a topic that is not in our TOC
+ if ( info.component.topicIdsInToc[ topicsData.roots[ i ] ] ) {
+ // And then remove all the ones that are already in our TOC
+ topicsData.roots.splice( i, 1 );
+ i--;
+ } else {
+ // For any other subsequent IDs, just mark them as being in the TOC now
+ info.component.topicIdsInToc[ topicsData.roots[ i ] ] = true;
+ }
+ }
+
+ if ( topicsData.roots.length ) {
+ // Store the last topic ID for optimal offset use
+ info.component.lastTopicIdInToc = topicsData.roots[i];
+ } // render even if we have no roots, because another load-more button could appear
+
+ // Render the topic titles
+ $kids = $( mw.flow.TemplateEngine.processTemplateGetFragment(
+ template,
+ topicsData
+ ) ).children();
+
+ if ( isLoadMoreButton ) {
+ // Insert the new topic titles
+ info.$target.replaceWith( $kids );
+ } else {
+ // Prevent this API call from happening again
+ info.component.doneInitialTocApiCall = true;
+
+ // Insert the new topic titles
+ info.$target.append( $kids );
+ }
+
+ info.component.emitWithReturn( 'makeContentInteractive', $kids );
+
+ if ( isLoadMoreButton ) {
+ // Remove the old load button (necessary if the above load_more template returns nothing)
+ $this.remove();
+ }
+
+ // Triggers load more if we didn't load enough content to fill the viewport
+ $kids.trigger( 'scroll.flow-load-more', { forceNavigationUpdate: true } );
+
+ }
+ FlowBoardComponentTocFeatureMixin.UI.events.apiHandlers.topicList = flowBoardComponentTocFeatureMixinTopicListApiHandler;
+
+ //
+ // On element-click handlers
+ //
+
+ /**
+ *
+ * @param {Event} event
+ */
+ function flowBoardComponentTocFeatureMixinJumpToTopicCallback( event ) {
+ var $this = $( this ),
+ flowBoard = mw.flow.getPrototypeMethod( 'board', 'getInstanceByElement' )( $this );
+
+ event.preventDefault();
+
+ // Load and scroll to topic
+ flowBoard.jumpToTopic( $this.data( 'flow-id' ) );
+ }
+ FlowBoardComponentTocFeatureMixin.UI.events.interactiveHandlers.jumpToTopic = flowBoardComponentTocFeatureMixinJumpToTopicCallback;
+
+ //
+ // On element-load handlers
+ //
+
+ // This is a confusing name since this.$tocMenu is set to flow-board-toc-list, whereas you
+ // would expect flow-board-toc-menu.
+ /**
+ * Stores the TOC menu for later use.
+ * @param {jQuery} $tocMenu
+ */
+ function flowBoardComponentTocFeatureTocMenuLoadCallback( $tocMenu ) {
+ this.$tocMenu = $tocMenu;
+ this.tocTarget = $tocMenu.data( 'flow-toc-target' );
+ this.tocTemplate = $tocMenu.data( 'flow-template' );
+ }
+ FlowBoardComponentTocFeatureMixin.UI.events.loadHandlers.tocMenu = flowBoardComponentTocFeatureTocMenuLoadCallback;
+
+ /**
+ * Checks if this title is already in TOC, and if not, adds it to the end of the stack.
+ * @param {jQuery} $topicTitle
+ */
+ function flowBoardComponentTocFeatureElementLoadTopicTitle( $topicTitle ) {
+ var currentTopicId, topicData, $kids, $target;
+
+ if ( !this.$tocMenu ) {
+ // No TOC (expected if we're on Topic page)
+
+ return;
+ }
+
+ currentTopicId = $topicTitle.closest( '[data-flow-id]' ).data( 'flowId' );
+ topicData = {
+ posts: {},
+ revisions: {},
+ roots: [ currentTopicId ],
+ noLoadMore: true
+ };
+
+ if ( !this.topicIdsInToc[ currentTopicId ] ) {
+ // If we get in here, this must have been loaded by topics infinite scroll and NOT by jumpTo
+ this.topicIdsInToc[ currentTopicId ] = true;
+ this.lastTopicIdInToc = currentTopicId;
+
+ // Magic to set the revision data
+ topicData.posts[ currentTopicId ] = [ currentTopicId ];
+ topicData.revisions[ currentTopicId ] = {
+ content: {
+ content: $topicTitle.data( 'flow-topic-title' ),
+ format: 'plaintext'
+ }
+ };
+
+ // Render the topic title
+ $kids = $( mw.flow.TemplateEngine.processTemplateGetFragment(
+ this.tocTemplate,
+ topicData
+ ) ).children();
+
+ // Find out where/how to insert the title
+ $target = $.findWithParent( this.$tocMenu, this.tocTarget );
+ if ( !$target.length ) {
+ this.$tocMenu.append( $kids );
+ } else {
+ $target.after( $kids );
+ }
+
+ this.emitWithReturn( 'makeContentInteractive', $kids );
+ }
+ }
+ FlowBoardComponentTocFeatureMixin.UI.events.loadHandlers.topicTitle = flowBoardComponentTocFeatureElementLoadTopicTitle;
+
+ //
+ // Public functions
+ //
+
+ /**
+ * Scroll the TOC to the active item
+ */
+ function flowBoardComponentTocFeatureScrollTocToActiveItem() {
+ // Set TOC active item
+ var $tocContainer = this.$tocMenu,
+ requestedScrollTop, afterScrollTop, // For debugging
+ $scrollTarget = $tocContainer.find( 'a[data-flow-id]' )
+ .removeClass( 'active' )
+ .filter( '[data-flow-id=' + this.readingTopicId + ']' )
+ .addClass( 'active' )
+ .closest( 'li' )
+ .next();
+
+ if ( !$scrollTarget.length ) {
+ // we are at the last list item; use the current one instead
+ $scrollTarget = $scrollTarget.end();
+ }
+ // Scroll to the active item
+ if ( $scrollTarget.length ) {
+ requestedScrollTop = $scrollTarget.offset().top - $tocContainer.offset().top + $tocContainer.scrollTop();
+ $tocContainer.scrollTop( requestedScrollTop );
+ afterScrollTop = $tocContainer.scrollTop();
+ // the above may not trigger the scroll.flow-load-more event within the TOC if the $tocContainer
+ // does not have a scrollbar. If that happens you could have a TOC without a scrollbar
+ // that refuses to autoload anything else. Fire it again(wasteful) untill we find
+ // a better way.
+ // This does not seem to work for the initial load, that is handled in flow-boad-loadmore.js
+ // when it runs this same code. This seems to be required for subsequent loads after
+ // the initial call.
+ if ( this.$loadMoreNodes ) {
+ this.$loadMoreNodes
+ .filter( '[data-flow-api-handler=topicList]' )
+ .trigger( 'scroll.flow-load-more', { forceNavigationUpdate: true } );
+ }
+ }
+
+ }
+
+ FlowBoardComponentTocFeatureMixin.prototype.scrollTocToActiveItem = flowBoardComponentTocFeatureScrollTocToActiveItem;
+
+ //
+ // Private functions
+ //
+
+ // Mixin to FlowComponent
+ mw.flow.mixinComponent( 'component', FlowBoardComponentTocFeatureMixin );
+}( jQuery, mediaWiki ) );
diff --git a/Flow/modules/engine/components/board/features/flow-board-visualeditor.js b/Flow/modules/engine/components/board/features/flow-board-visualeditor.js
new file mode 100644
index 00000000..0fcb319a
--- /dev/null
+++ b/Flow/modules/engine/components/board/features/flow-board-visualeditor.js
@@ -0,0 +1,37 @@
+/*!
+ * Expose some functionality on the board object that is needed for VisualEditor.
+ */
+
+( function ( $, mw, OO ) {
+ /**
+ * FlowBoardComponentVisualEditorFeatureMixin
+ *
+ * @this FlowBoardComponent
+ * @constructor
+ *
+ */
+ function FlowBoardComponentVisualEditorFeatureMixin( $container ) {
+ }
+
+ // This is not really VE-specific, but I'm not sure where best to put it.
+ // Also, should we pre-compute this in a loadHandler?
+ /**
+ * Finds topic authors for the given node
+ *
+ * @return Array List of usernames
+ */
+ function flowVisualEditorGetTopicPosters( $node ) {
+ var $topic = $node.closest( '.flow-topic' ),
+ duplicatedArray;
+
+ // Could use a data attribute to avoid trim.
+ duplicatedArray = $.map( $topic.find( '.flow-author .mw-userlink' ).get(), function ( el ) {
+ return $.trim( $( el ).text() );
+ } );
+ return OO.unique( duplicatedArray );
+ }
+
+ FlowBoardComponentVisualEditorFeatureMixin.prototype.getTopicPosters = flowVisualEditorGetTopicPosters;
+
+ mw.flow.mixinComponent( 'board', FlowBoardComponentVisualEditorFeatureMixin );
+}( jQuery, mediaWiki, OO ) );
diff --git a/Flow/modules/engine/components/board/flow-board.js b/Flow/modules/engine/components/board/flow-board.js
new file mode 100644
index 00000000..6f3839ae
--- /dev/null
+++ b/Flow/modules/engine/components/board/flow-board.js
@@ -0,0 +1,196 @@
+/*!
+ * Contains the base constructor for FlowBoardComponent.
+ * @todo Clean up the remaining code that may not need to be here.
+ */
+
+( function ( $, mw ) {
+ /**
+ * Constructor class for instantiating a new Flow board.
+ * @example <div class="flow-component" data-flow-component="board" data-flow-id="rqx495tvz888x5ur">...</div>
+ * @param {jQuery} $container
+ * @extends FlowBoardAndHistoryComponentBase
+ * @mixins FlowComponentEventsMixin
+ * @mixins FlowComponentEnginesMixin
+ * @mixins FlowBoardComponentApiEventsMixin
+ * @mixins FlowBoardComponentInteractiveEventsMixin
+ * @mixins FlowBoardComponentLoadEventsMixin
+ * @mixins FlowBoardComponentMiscMixin
+ * @mixins FlowBoardComponentLoadMoreFeatureMixin
+ * @mixins FlowBoardComponentVisualEditorFeatureMixin
+ *
+ * @constructor
+ */
+ function FlowBoardComponent( $container ) {
+ var uri = new mw.Uri( location.href ),
+ uid = String( location.hash.match( /[0-9a-z]{16,19}$/i ) || '' );
+
+ // Default API submodule for FlowBoard URLs is to fetch a topiclist
+ this.Api.setDefaultSubmodule( 'view-topiclist' );
+
+ // Set up the board
+ if ( this.reinitializeContainer( $container ) === false ) {
+ // Failed to init for some reason
+ return false;
+ }
+
+ // Handle URL parameters
+ if ( uid ) {
+ if ( uri.query.fromnotif ) {
+ _flowHighlightPost( $container, uid, 'newer' );
+ } else {
+ _flowHighlightPost( $container, uid );
+ }
+ }
+
+ _overrideWatchlistNotification();
+ }
+ OO.initClass( FlowBoardComponent );
+
+ // Register
+ mw.flow.registerComponent( 'board', FlowBoardComponent, 'boardAndHistoryBase' );
+
+ //
+ // Methods
+ //
+
+ /**
+ * Sets up the board and base properties on this class.
+ * Returns either FALSE for failure, or jQuery object of old nodes that were replaced.
+ * @param {jQuery|boolean} $container
+ * @return {Boolean|jQuery}
+ */
+ function flowBoardComponentReinitializeContainer( $container ) {
+ if ( $container === false ) {
+ return false;
+ }
+
+ // Trigger this on FlowBoardAndHistoryComponentBase
+ // @todo use EventEmitter to do this?
+ var $retObj = FlowBoardComponent.parent.prototype.reinitializeContainer.call( this, $container ),
+ // Find any new (or previous) elements
+ $header = $container.find( '.flow-board-header' ).addBack().filter( '.flow-board-header:first' ),
+ $boardNavigation = $container.find( '.flow-board-navigation' ).addBack().filter( '.flow-board-navigation:first' ),
+ $board = $container.find( '.flow-board' ).addBack().filter( '.flow-board:first' );
+
+ if ( $retObj === false ) {
+ return false;
+ }
+
+ // Remove any of the old elements that are still in use
+ if ( $header.length ) {
+ if ( this.$header ) {
+ $retObj = $retObj.add( this.$header.replaceWith( $header ) );
+ this.$header.remove();
+ }
+
+ this.$header = $header;
+ }
+ if ( $boardNavigation.length ) {
+ if ( this.$boardNavigation ) {
+ $retObj = $retObj.add( this.$boardNavigation.replaceWith( $boardNavigation ) );
+ this.$boardNavigation.remove();
+ }
+
+ this.$boardNavigation = $boardNavigation;
+ }
+ if ( $board.length ) {
+ if ( this.$board ) {
+ $retObj = $retObj.add( this.$board.replaceWith( $board ) );
+ this.$board.remove();
+ }
+
+ this.$board = $board;
+ }
+
+ // Second, verify that this board in fact exists
+ if ( !this.$board || !this.$board.length ) {
+ // You need a board, dammit!
+ this.debug( 'Could not find .flow-board', arguments );
+ return false;
+ }
+
+ this.emitWithReturn( 'makeContentInteractive', this );
+
+ // Initialize editors, turning them from textareas into editor objects
+ if ( typeof this.editorTimer === 'undefined' ) {
+ /*
+ * When this method is first run, all page elements are initialized.
+ * We probably don't need editor immediately, so defer loading it
+ * to speed up the rest of the work that needs to be done.
+ */
+ this.editorTimer = setTimeout( $.proxy( function ( $container ) { this.emitWithReturn( 'initializeEditors', $container ); }, this, $container ), 20000 );
+ } else {
+ /*
+ * Subsequent calls here (e.g. when rendering the edit header form)
+ * should immediately initialize the editors!
+ */
+ clearTimeout( this.editorTimer );
+ this.emitWithReturn( 'initializeEditors', $container );
+ }
+
+ return $retObj;
+ }
+ FlowBoardComponent.prototype.reinitializeContainer = flowBoardComponentReinitializeContainer;
+
+ //
+ // Private functions
+ //
+
+ /**
+ * Helper receives
+ * @param {jQuery} $container
+ * @param {string} uid
+ * @param {string} option
+ * @return {jQuery}
+ */
+ function _flowHighlightPost( $container, uid, option ) {
+ var $target = $container.find( '#flow-post-' + uid );
+
+ // reset existing highlights
+ $container.find( '.flow-post-highlighted' ).removeClass( 'flow-post-highlighted' );
+
+ if ( option === 'newer' ) {
+ $target.addClass( 'flow-post-highlight-newer' );
+ if ( uid ) {
+ $container.find( '.flow-post' ).each( function( idx, el ) {
+ var $el = $( el ),
+ id = $el.data( 'flow-id' );
+ if ( id && id > uid ) {
+ $el.addClass( 'flow-post-highlight-newer' );
+ }
+ } );
+ }
+ } else {
+ $target.addClass( 'flow-post-highlighted' );
+ }
+
+ return $target;
+ }
+
+ /**
+ * We want the default behavior of watch/unwatch for page. However, we
+ * do want to show our own tooltip after this has happened.
+ * We'll override mw.notify, which is fired after successfully
+ * (un)watchlisting, to stop the notification from being displayed.
+ * If the action we just intercepted was after succesful watching, we'll
+ * want to show our own tooltip instead.
+ */
+ function _overrideWatchlistNotification() {
+ var _notify = mw.notify;
+ mw.notify = function( message, options ) {
+ // override message when we've just watched the board
+ if ( options.tag === 'watch-self' && $( '#ca-watch' ).length ) {
+ // Render a div telling the user that they have subscribed
+ message = $( mw.flow.TemplateEngine.processTemplateGetFragment(
+ 'flow_subscribed.partial',
+ {
+ type: 'board',
+ username: mw.user.getName()
+ }
+ ) ).children();
+ }
+
+ _notify.apply( this, arguments );
+ };
+ }
+}( jQuery, mediaWiki ) );
diff --git a/Flow/modules/engine/components/board/flow-boardhistory.js b/Flow/modules/engine/components/board/flow-boardhistory.js
new file mode 100644
index 00000000..b9f63d07
--- /dev/null
+++ b/Flow/modules/engine/components/board/flow-boardhistory.js
@@ -0,0 +1,59 @@
+/*!
+ *
+ */
+
+( function ( $, mw ) {
+ /**
+ *
+ * @example <div class="flow-component" data-flow-component="boardHistory" data-flow-id="rqx495tvz888x5ur">...</div>
+ * @param {jQuery} $container
+ * @extends FlowBoardAndHistoryComponentBase
+ * @constructor
+ */
+ function FlowBoardHistoryComponent( $container ) {
+ this.bindNodeHandlers( FlowBoardHistoryComponent.UI.events );
+ }
+ OO.initClass( FlowBoardHistoryComponent );
+
+ FlowBoardHistoryComponent.UI = {
+ events: {
+ apiHandlers: {}
+ }
+ };
+
+ mw.flow.registerComponent( 'boardHistory', FlowBoardHistoryComponent, 'boardAndHistoryBase' );
+
+ //
+ // API handlers
+ //
+
+ /**
+ * After submit of a moderation form, process the response.
+ *
+ * @param {Object} info
+ * @param {string} info.status "done" or "fail"
+ * @param {jQuery} info.$target
+ * @param {Object} data
+ * @param {jqXHR} jqxhr
+ * @returns {$.Promise}
+ */
+ function flowBoardHistoryModerationCallback( info, data, jqxhr ) {
+ if ( info.status !== 'done' ) {
+ // Error will be displayed by default, nothing else to wrap up
+ return $.Deferred().reject().promise();
+ }
+
+ var flowBoardHistory = mw.flow.getPrototypeMethod( 'boardHistory', 'getInstanceByElement' )( $( this ) );
+
+ // Clear the form so we can refresh without the confirmation dialog
+ flowBoardHistory.emitWithReturn( 'cancelForm', $( this ).closest( 'form' ) );
+
+ // @todo implement dynamic updating of the history page instead of this
+ location.reload();
+
+ return $.Deferred().resolve().promise();
+ }
+
+ FlowBoardHistoryComponent.UI.events.apiHandlers.moderateTopic = flowBoardHistoryModerationCallback;
+ FlowBoardHistoryComponent.UI.events.apiHandlers.moderatePost = flowBoardHistoryModerationCallback;
+}( jQuery, mediaWiki ) );
diff --git a/Flow/modules/engine/components/common/flow-component-engines.js b/Flow/modules/engine/components/common/flow-component-engines.js
new file mode 100644
index 00000000..3d8a9282
--- /dev/null
+++ b/Flow/modules/engine/components/common/flow-component-engines.js
@@ -0,0 +1,39 @@
+/*!
+ * Initializes StorageEngine (Storer), TemplateEngine (Handlebars), and API (FlowApi).
+ */
+
+( function ( $, mw, initStorer ) {
+ /**
+ * Initializes Storer, Handlebars, and FlowApi.
+ * @constructor
+ */
+ function FlowComponentEnginesMixin() {}
+ OO.initClass( FlowComponentEnginesMixin );
+
+ /**
+ * Contains Storer.js's (fallback) storage engines.
+ * @type {{ cookieStorage: Storer.cookieStorage, memoryStorage: Storer.memoryStorage, sessionStorage: Storer.sessionStorage, localStorage: Storer.localStorage }}
+ */
+ mw.flow.StorageEngine = FlowComponentEnginesMixin.static.StorageEngine = initStorer( { 'prefix': '_WMFLOW_' } );
+
+ /**
+ * Contains the Flow templating engine translation class (in case we change templating engines).
+ * @type {FlowHandlebars}
+ */
+ mw.flow.TemplateEngine = FlowComponentEnginesMixin.static.TemplateEngine = new mw.flow.FlowHandlebars( FlowComponentEnginesMixin.static.StorageEngine );
+
+ /**
+ * Flow API singleton
+ * @type {FlowApi}
+ */
+ mw.flow.Api = new mw.flow.FlowApi( FlowComponentEnginesMixin.static.StorageEngine );
+
+ /**
+ * EventLogging wrapper
+ * @type {FlowEventLog}
+ */
+ mw.flow.EventLog = mw.flow.FlowEventLog;
+
+ // Copy static and prototype from mixin to main class
+ mw.flow.mixinComponent( 'component', FlowComponentEnginesMixin );
+}( jQuery, mediaWiki, mediaWiki.flow.vendor.initStorer ) );
diff --git a/Flow/modules/engine/components/common/flow-component-events.js b/Flow/modules/engine/components/common/flow-component-events.js
new file mode 100644
index 00000000..f3b97701
--- /dev/null
+++ b/Flow/modules/engine/components/common/flow-component-events.js
@@ -0,0 +1,917 @@
+/*!
+ * Contains the code which registers and handles event callbacks.
+ * In addition, it contains some common callbacks (eg. apiRequest)
+ * @todo Find better places for a lot of the callbacks that have been placed here
+ */
+
+( function ( $, mw ) {
+ var _isGlobalBound;
+
+ /**
+ * This implements functionality for being able to capture the return value from a called event.
+ * In addition, this handles Flow event triggering and binding.
+ * @extends oo.EventEmitter
+ * @constructor
+ */
+ function FlowComponentEventsMixin( $container ) {
+ var self = this;
+
+ /**
+ * Stores event callbacks.
+ */
+ this.UI = {
+ events: {
+ globalApiPreHandlers: {},
+ apiPreHandlers: {},
+ apiHandlers: {},
+ interactiveHandlers: {},
+ loadHandlers: {}
+ }
+ };
+
+ // Init EventEmitter
+ OO.EventEmitter.call( this );
+
+ // Bind events to this instance
+ this.bindComponentHandlers( FlowComponentEventsMixin.eventHandlers );
+
+ // Bind element handlers
+ this.bindNodeHandlers( FlowComponentEventsMixin.UI.events );
+
+ // Container handlers
+ // @todo move some to FlowBoardComponent events, rename the others to FlowComponent
+ $container
+ .off( '.FlowBoardComponent' )
+ .on(
+ 'click.FlowBoardComponent keypress.FlowBoardComponent',
+ 'a, input, button, .flow-click-interactive',
+ this.getDispatchCallback( 'interactiveHandler' )
+ )
+ .on(
+ 'focusin.FlowBoardComponent',
+ 'a, input, button, .flow-click-interactive',
+ this.getDispatchCallback( 'interactiveHandlerFocus' )
+ )
+ .on(
+ 'focusin.FlowBoardComponent',
+ 'input.mw-ui-input, textarea',
+ this.getDispatchCallback( 'focusField' )
+ )
+ .on(
+ 'click.FlowBoardComponent keypress.FlowBoardComponent',
+ '[data-flow-eventlog-action]',
+ this.getDispatchCallback( 'eventLogHandler' )
+ );
+
+ if ( _isGlobalBound ) {
+ // Don't bind window.scroll again.
+ return;
+ }
+ _isGlobalBound = true;
+
+ // Handle scroll and resize events globally
+ $( window )
+ .on(
+ // Normal scroll events on elements do not bubble. However, if they
+ // are triggered, jQuery will do so. To avoid this affecting the
+ // global scroll handler, trigger scroll events on elements only with
+ // scroll.flow-something, where 'something' is not 'window-scroll'.
+ 'scroll.flow-window-scroll',
+ $.throttle( 50, function ( evt ) {
+ if ( evt.target !== window && evt.target !== document ) {
+ throw new Error( 'Target is "' + evt.target.nodeName + '", not window or document.' );
+ }
+
+ self.getDispatchCallback( 'windowScroll' ).apply( self, arguments );
+ } )
+ )
+ .on(
+ 'resize.flow',
+ $.throttle( 50, this.getDispatchCallback( 'windowResize' ) )
+ );
+ }
+ OO.mixinClass( FlowComponentEventsMixin, OO.EventEmitter );
+
+ FlowComponentEventsMixin.eventHandlers = {};
+ FlowComponentEventsMixin.UI = {
+ events: {
+ interactiveHandlers: {}
+ }
+ };
+
+ //
+ // Prototype methods
+ //
+
+ /**
+ * Same as OO.EventEmitter.emit, except that it returns an array of results.
+ * If something returns false (or an object with _abort:true), we stop processing the rest of the callbacks, if any.
+ * @param {String} event Name of the event to trigger
+ * @param {...*} [args] Arguments to pass to event callback
+ * @returns {Array}
+ */
+ function emitWithReturn( event, args ) {
+ var i, len, binding, bindings, method,
+ returns = [], retVal;
+
+ if ( event in this.bindings ) {
+ // Slicing ensures that we don't get tripped up by event handlers that add/remove bindings
+ bindings = this.bindings[event].slice();
+ args = Array.prototype.slice.call( arguments, 1 );
+ for ( i = 0, len = bindings.length; i < len; i++ ) {
+ binding = bindings[i];
+
+ if ( typeof binding.method === 'string' ) {
+ // Lookup method by name (late binding)
+ method = binding.context[ binding.method ];
+ } else {
+ method = binding.method;
+ }
+
+ // Call function
+ retVal = method.apply(
+ binding.context || this,
+ binding.args ? binding.args.concat( args ) : args
+ );
+
+ // Add this result to our list of return vals
+ returns.push( retVal );
+
+ if ( retVal === false || ( retVal && retVal._abort === true ) ) {
+ // Returned false; stop running callbacks
+ break;
+ }
+ }
+ return returns;
+ }
+ return [];
+ }
+ FlowComponentEventsMixin.prototype.emitWithReturn = emitWithReturn;
+
+ /**
+ *
+ * @param {Object} handlers
+ */
+ function bindFlowComponentHandlers( handlers ) {
+ var self = this;
+
+ // Bind class event handlers, triggered by .emit
+ $.each( handlers, function ( key, fn ) {
+ self.on( key, function () {
+ // Trigger callback with class instance context
+ try {
+ return fn.apply( self, arguments );
+ } catch ( e ) {
+ mw.flow.debug( 'Error in component handler:', key, e, arguments );
+ return false;
+ }
+ } );
+ } );
+ }
+ FlowComponentEventsMixin.prototype.bindComponentHandlers = bindFlowComponentHandlers;
+
+ /**
+ * handlers can have keys globalApiPreHandlers, apiPreHandlers, apiHandlers, interactiveHandlers, loadHandlers
+ * @param {Object} handlers
+ */
+ function bindFlowNodeHandlers( handlers ) {
+ var self = this;
+
+ // eg. { interactiveHandlers: { foo: Function } }
+ $.each( handlers, function ( type, callbacks ) {
+ // eg. { foo: Function }
+ $.each( callbacks, function ( name, fn ) {
+ // First time for this callback name, instantiate the callback list
+ if ( !self.UI.events[type][name] ) {
+ self.UI.events[type][name] = [];
+ }
+ if ( $.isArray( fn ) ) {
+ // eg. UI.events.interactiveHandlers.foo concat [Function, Function];
+ self.UI.events[type][name] = self.UI.events[type][name].concat( fn );
+ } else {
+ // eg. UI.events.interactiveHandlers.foo = [Function];
+ self.UI.events[type][name].push( fn );
+ }
+ } );
+ } );
+ }
+ FlowComponentEventsMixin.prototype.bindNodeHandlers = bindFlowNodeHandlers;
+
+ /**
+ * Returns a callback function which passes off arguments to the emitter.
+ * This only exists to clean up the FlowComponentEventsMixin constructor,
+ * by preventing it from having too many anonymous functions.
+ * @param {String} name
+ * @returns {Function}
+ * @private
+ */
+ function flowComponentGetDispatchCallback( name ) {
+ var context = this;
+
+ return function () {
+ var args = Array.prototype.slice.call( arguments, 0 );
+
+ // Add event name as first arg of emit
+ args.unshift( name );
+
+ return context.emitWithReturn.apply( context, args );
+ };
+ }
+ FlowComponentEventsMixin.prototype.getDispatchCallback = flowComponentGetDispatchCallback;
+
+ //
+ // Static methods
+ //
+
+ /**
+ * Utility to get error message for API result.
+ *
+ * @param string code
+ * @param Object result
+ * @returns string
+ */
+ function flowGetApiErrorMessage( code, result ) {
+ if ( result.error && result.error.info ) {
+ return result.error.info;
+ } else {
+ if ( code === 'http' ) {
+ // XXX: some network errors have English info in result.exception and result.textStatus.
+ return mw.msg( 'flow-error-http' );
+ } else {
+ return mw.msg( 'flow-error-external', code );
+ }
+ }
+ }
+ FlowComponentEventsMixin.static.getApiErrorMessage = flowGetApiErrorMessage;
+
+ //
+ // Interactive Handlers
+ //
+
+ /**
+ * Triggers an API request based on URL and form data, and triggers the callbacks based on flow-api-handler.
+ * @example <a data-flow-interactive-handler="apiRequest" data-flow-api-handler="loadMore" data-flow-api-target="< .flow-component div" href="...">...</a>
+ * @param {Event} event
+ * @returns {$.Promise}
+ */
+ function flowEventsMixinApiRequestInteractiveHandler( event ) {
+ var $deferred,
+ $handlerDeferred,
+ handlerPromises = [],
+ $target,
+ preHandlerReturn,
+ self = event.currentTarget || event.delegateTarget || event.target,
+ $this = $( self ),
+ flowComponent = mw.flow.getPrototypeMethod( 'component', 'getInstanceByElement' )( $this ),
+ dataParams = $this.data(),
+ handlerName = dataParams.flowApiHandler,
+ preHandlerReturns = [],
+ info = {
+ $target: null,
+ status: null,
+ component: flowComponent
+ },
+ args = Array.prototype.slice.call( arguments, 0 );
+
+ event.preventDefault();
+
+ // Find the target node
+ if ( dataParams.flowApiTarget ) {
+ // This fn supports finding parents
+ $target = $this.findWithParent( dataParams.flowApiTarget );
+ }
+ if ( !$target || !$target.length ) {
+ // Assign a target node if none
+ $target = $this;
+ }
+
+ info.$target = $target;
+ args.splice( 1, 0, info ); // insert info into args for prehandler
+
+ // Make sure an API call is not already in progress for this target
+ if ( $target.closest( '.flow-api-inprogress' ).length ) {
+ flowComponent.debug( false, 'apiRequest already in progress', arguments );
+ return $.Deferred().reject().promise();
+ }
+
+ // Mark the target node as "in progress" to disallow any further API calls until it finishes
+ $target.addClass( 'flow-api-inprogress' );
+ $this.addClass( 'flow-api-inprogress' );
+
+ // Let generic pre-handler take care of edit conflicts
+ $.each( flowComponent.UI.events.globalApiPreHandlers, function( key, callbackArray ) {
+ $.each( callbackArray, function ( i, callbackFn ) {
+ preHandlerReturns.push( callbackFn.apply( self, args ) );
+ } );
+ } );
+
+ // We'll return a deferred object that won't resolve before apiHandlers
+ // are resolved
+ $handlerDeferred = $.Deferred();
+
+ // Use the pre-callback to find out if we should process this
+ if ( flowComponent.UI.events.apiPreHandlers[ handlerName ] ) {
+ // apiPreHandlers can return FALSE to prevent processing,
+ // nothing at all to proceed,
+ // or OBJECT to add param overrides to the API
+ // or FUNCTION to modify API params
+ $.each( flowComponent.UI.events.apiPreHandlers[ handlerName ], function ( i, callbackFn ) {
+ preHandlerReturn = callbackFn.apply( self, args );
+ preHandlerReturns.push( preHandlerReturn );
+
+ if ( preHandlerReturn === false || ( preHandlerReturn && preHandlerReturn._abort === true ) ) {
+ // Callback returned false; break out of this loop
+ return false;
+ }
+ } );
+
+ if ( preHandlerReturn === false || ( preHandlerReturn && preHandlerReturn._abort === true ) ) {
+ // Last callback returned false
+ flowComponent.debug( false, 'apiPreHandler returned false', handlerName, args );
+
+ // Abort any old request in flight; this is normally done automatically by requestFromNode
+ flowComponent.Api.abortOldRequestFromNode( self, null, null, preHandlerReturns );
+
+ // @todo support for multiple indicators on same target
+ $target.removeClass( 'flow-api-inprogress' );
+ $this.removeClass( 'flow-api-inprogress' );
+
+ return $.Deferred().reject().promise();
+ }
+ }
+
+ // Make the request
+ $deferred = flowComponent.Api.requestFromNode( self, preHandlerReturns );
+ if ( !$deferred ) {
+ mw.flow.debug( '[FlowApi] [interactiveHandlers] apiRequest element is not anchor or form element' );
+ $deferred = $.Deferred();
+ $deferred.rejectWith( { error: { info: 'Not an anchor or form' } } );
+ }
+
+ // Remove the load indicator
+ $deferred.always( function () {
+ // @todo support for multiple indicators on same target
+ $target.removeClass( 'flow-api-inprogress' );
+ $this.removeClass( 'flow-api-inprogress' );
+ } );
+
+ // Remove existing errors from previous attempts
+ flowComponent.emitWithReturn( 'removeError', $this );
+
+ // We'll return a deferred object that won't resolve before apiHandlers
+ // are resolved
+ $handlerDeferred = $.Deferred();
+
+ // If this has a special api handler, bind it to the callback.
+ if ( flowComponent.UI.events.apiHandlers[ handlerName ] ) {
+ $deferred
+ .done( function () {
+ var args = Array.prototype.slice.call( arguments, 0 );
+ info.status = 'done';
+ args.unshift( info );
+ $.each( flowComponent.UI.events.apiHandlers[ handlerName ], function ( i, callbackFn ) {
+ handlerPromises.push( callbackFn.apply( self, args ) );
+ } );
+ } )
+ .fail( function ( code, result ) {
+ var errorMsg,
+ args = Array.prototype.slice.call( arguments, 0 ),
+ $form = $this.closest( 'form' );
+
+ info.status = 'fail';
+ args.unshift( info );
+
+ /*
+ * In the event of edit conflicts, store the previous
+ * revision id so we can re-submit an edit against the
+ * current id later.
+ */
+ if ( result.error && result.error.prev_revision ) {
+ $form.data( 'flow-prev-revision', result.error.prev_revision.revision_id );
+ }
+
+ /*
+ * Generic error handling: displays error message in the
+ * nearest error container.
+ *
+ * Errors returned by MW/Flow should always be in the
+ * same format. If the request failed without a specific
+ * error message, just fall back to some default error.
+ */
+ errorMsg = flowComponent.constructor.static.getApiErrorMessage( code, result );
+ flowComponent.emitWithReturn( 'showError', $this, errorMsg );
+
+ $.each( flowComponent.UI.events.apiHandlers[ handlerName ], function ( i, callbackFn ) {
+ handlerPromises.push( callbackFn.apply( self, args ) );
+ } );
+ } )
+ .always( function() {
+ // Resolve/reject the promised deferreds when all apiHandler
+ // deferreds have been resolved/rejected
+ $.when.apply( $, handlerPromises )
+ .done( $handlerDeferred.resolve )
+ .fail( $handlerDeferred.reject );
+ } );
+ }
+
+ // Return an aggregate promise that resolves when all are resolved, or
+ // rejects once one of them is rejected
+ return $handlerDeferred.promise();
+ }
+ FlowComponentEventsMixin.UI.events.interactiveHandlers.apiRequest = flowEventsMixinApiRequestInteractiveHandler;
+
+ //
+ // Event handler methods
+ //
+
+ /**
+ *
+ * @param {FlowComponent|jQuery} $container or entire FlowComponent
+ * @todo Perhaps use name="flow-load-handler" for performance in older browsers
+ */
+ function flowMakeContentInteractiveCallback( $container ) {
+ if ( !$container.jquery ) {
+ $container = $container.$container;
+ }
+
+ if ( !$container.length ) {
+ // Prevent erroring out with an empty node set
+ return;
+ }
+
+ // Get the FlowComponent
+ var component = mw.flow.getPrototypeMethod( 'component', 'getInstanceByElement' )( $container );
+
+ // Find all load-handlers and trigger them
+ $container.find( '.flow-load-interactive' ).add( $container.filter( '.flow-load-interactive' ) ).each( function () {
+ var $this = $( this ),
+ handlerName = $this.data( 'flow-load-handler' );
+
+ if ( $this.data( 'flow-load-handler-called' ) ) {
+ return;
+ }
+ $this.data( 'flow-load-handler-called', true );
+
+ // If this has a special load handler, run it.
+ component.emitWithReturn( 'loadHandler', handlerName, $this );
+ } );
+
+ // Find all the forms
+ // @todo move this into a flow-load-handler
+ $container.find( 'form' ).add( $container.filter( 'form' ) ).each( function () {
+ var $this = $( this );
+
+ // Trigger for flow-actions-disabler
+ $this.find( 'input, textarea' ).trigger( 'keyup' );
+
+ // Find this form's inputs
+ $this.find( 'textarea' ).filter( '[data-flow-expandable]' ).each( function () {
+ // Compress textarea if:
+ // the textarea isn't already focused
+ // and the textarea doesn't have text typed into it
+ if ( !$( this ).is( ':focus' ) && this.value === this.defaultValue ) {
+ component.emitWithReturn( 'compressTextarea', $( this ) );
+ }
+ } );
+
+ component.emitWithReturn( 'hideForm', $this );
+ } );
+ }
+ FlowComponentEventsMixin.eventHandlers.makeContentInteractive = flowMakeContentInteractiveCallback;
+
+ /**
+ * Triggers load handlers.
+ */
+ function flowLoadHandlerCallback( handlerName, args, context ) {
+ args = $.isArray( args ) ? args : ( args ? [args] : [] );
+ context = context || this;
+
+ if ( this.UI.events.loadHandlers[handlerName] ) {
+ $.each( this.UI.events.loadHandlers[handlerName], function ( i, fn ) {
+ fn.apply( context, args );
+ } );
+ }
+ }
+ FlowComponentEventsMixin.eventHandlers.loadHandler = flowLoadHandlerCallback;
+
+ /**
+ * Executes interactive handlers.
+ *
+ * @param {array} args
+ * @param {jQuery} $context
+ * @param {string} interactiveHandlerName
+ * @param {string} apiHandlerName
+ */
+ function flowExecuteInteractiveHandler( args, $context, interactiveHandlerName, apiHandlerName ) {
+ var promises = [];
+
+ // Call any matching interactive handlers
+ if ( this.UI.events.interactiveHandlers[interactiveHandlerName] ) {
+ $.each( this.UI.events.interactiveHandlers[interactiveHandlerName], function ( i, fn ) {
+ promises.push( fn.apply( $context[0], args ) );
+ } );
+ } else if ( this.UI.events.apiHandlers[apiHandlerName] ) {
+ // Call any matching API handlers
+ $.each( this.UI.events.interactiveHandlers.apiRequest, function ( i, fn ) {
+ promises.push( fn.apply( $context[0], args ) );
+ } );
+ } else if ( interactiveHandlerName ) {
+ this.debug( 'Failed to find interactiveHandler', interactiveHandlerName, arguments );
+ } else if ( apiHandlerName ) {
+ this.debug( 'Failed to find apiHandler', apiHandlerName, arguments );
+ }
+
+ // Add aggregate deferred object as data attribute, so we can hook into
+ // the element when the handlers have run
+ $context.data( 'flow-interactive-handler-promise', $.when.apply( $, promises ) );
+ }
+
+ /**
+ * Triggers both API and interactive handlers.
+ * To manually trigger a handler on an element, you can use extraParameters via $el.trigger.
+ * @param {Event} event
+ * @param {Object} [extraParameters]
+ * @param {String} [extraParameters.interactiveHandler]
+ * @param {String} [extraParameters.apiHandler]
+ */
+ function flowInteractiveHandlerCallback( event, extraParameters ) {
+ // Only trigger with enter key & no modifier keys, if keypress
+ if ( event.type === 'keypress' && ( event.charCode !== 13 || event.metaKey || event.shiftKey || event.ctrlKey || event.altKey )) {
+ return;
+ }
+
+ var args = Array.prototype.slice.call( arguments, 0 ),
+ $context = $( event.currentTarget || event.delegateTarget || event.target ),
+ // Have either of these been forced via trigger extraParameters?
+ interactiveHandlerName = ( extraParameters || {} ).interactiveHandler || $context.data( 'flow-interactive-handler' ),
+ apiHandlerName = ( extraParameters || {} ).apiHandler || $context.data( 'flow-api-handler' );
+
+ return flowExecuteInteractiveHandler.call( this, args, $context, interactiveHandlerName, apiHandlerName );
+ }
+ FlowComponentEventsMixin.eventHandlers.interactiveHandler = flowInteractiveHandlerCallback;
+ FlowComponentEventsMixin.eventHandlers.apiRequest = flowInteractiveHandlerCallback;
+
+ /**
+ * Triggers both API and interactive handlers, on focus.
+ */
+ function flowInteractiveHandlerFocusCallback( event ) {
+ var args = Array.prototype.slice.call( arguments, 0 ),
+ $context = $( event.currentTarget || event.delegateTarget || event.target ),
+ interactiveHandlerName = $context.data( 'flow-interactive-handler-focus' ),
+ apiHandlerName = $context.data( 'flow-api-handler-focus' );
+
+ return flowExecuteInteractiveHandler.call( this, args, $context, interactiveHandlerName, apiHandlerName );
+ }
+ FlowComponentEventsMixin.eventHandlers.interactiveHandlerFocus = flowInteractiveHandlerFocusCallback;
+
+ /**
+ * Callback function for when a [data-flow-eventlog-action] node is clicked.
+ * This will trigger a eventLog call to the given schema with the given
+ * parameters.
+ * A unique funnel ID will be created for all new EventLog calls.
+ *
+ * There may be multiple subsequent calls in the same "funnel" (and share
+ * same info) that you want to track. It is possible to forward funnel data
+ * from one attribute to another once the first has been clicked. It'll then
+ * log new calls with the same data (schema & entrypoint) & funnel ID as the
+ * initial logged event.
+ *
+ * Required parameters (as data-attributes) are:
+ * * data-flow-eventlog-schema: The schema name
+ * * data-flow-eventlog-entrypoint: The schema's entrypoint parameter
+ * * data-flow-eventlog-action: The schema's action parameter
+ *
+ * Additionally:
+ * * data-flow-eventlog-forward: Selectors to forward funnel data to
+ */
+ function flowEventLogCallback( event ) {
+ // Only trigger with enter key & no modifier keys, if keypress
+ if ( event.type === 'keypress' && ( event.charCode !== 13 || event.metaKey || event.shiftKey || event.ctrlKey || event.altKey )) {
+ return;
+ }
+
+ var $context = $( event.currentTarget ),
+ data = $context.data(),
+ component = mw.flow.getPrototypeMethod( 'component', 'getInstanceByElement' )( $context ),
+ $promise = data.flowInteractiveHandlerPromise || $.Deferred().resolve().promise(),
+ eventInstance = {},
+ key, value;
+
+ // Fetch loggable data: everything prefixed flowEventlog except
+ // flowEventLogForward and flowEventLogSchema
+ for ( key in data ) {
+ if ( key.indexOf( 'flowEventlog' ) === 0 ) {
+ // @todo Either the data or this config should have separate prefixes,
+ // it shouldn't be shared and then handled here.
+ if ( key === 'flowEventlogForward' || key === 'flowEventlogSchema' ) {
+ continue;
+ }
+
+ // Strips "flowEventlog" and lowercases first char after that
+ value = data[key];
+ key = key.substr( 12, 1 ).toLowerCase() + key.substr( 13 );
+
+ eventInstance[key] = value;
+ }
+ }
+
+ // Log the event
+ eventInstance = component.logEvent( data.flowEventlogSchema, eventInstance );
+
+ // Promise resolves once all interactiveHandlers/apiHandlers are done,
+ // so all nodes we want to forward to are bound to be there
+ $promise.always( function() {
+ // Now find all nodes to forward to
+ var $forward = data.flowEventlogForward ? $context.findWithParent( data.flowEventlogForward ) : $();
+
+ // Forward the funnel
+ eventInstance = component.forwardEvent( $forward, data.flowEventlogSchema, eventInstance.funnelId );
+ } );
+ }
+ FlowComponentEventsMixin.eventHandlers.eventLogHandler = flowEventLogCallback;
+
+ /**
+ * When the whole class has been instantiated fully (after every constructor has been called).
+ * @param {FlowComponent} component
+ */
+ function flowEventsMixinInstantiationComplete( component ) {
+ $( window ).trigger( 'scroll.flow-window-scroll' );
+ }
+ FlowComponentEventsMixin.eventHandlers.instantiationComplete = flowEventsMixinInstantiationComplete;
+
+
+ /**
+ * Compress and hide a flow form and/or its actions, depending on data-flow-initial-state.
+ * @param {jQuery} $form
+ * @todo Move this to a separate file
+ */
+ function flowEventsMixinHideForm( $form ) {
+ var initialState = $form.data( 'flow-initial-state' ),
+ component = mw.flow.getPrototypeMethod( 'component', 'getInstanceByElement' )( $form );
+
+ // Store state
+ $form.data( 'flow-state', 'hidden' );
+
+ // If any preview is visible cancel it
+ // Must be done before compressing text areas because
+ // the preview may have manipulated them.
+ if ( $form.parent().find( '.flow-preview-warning' ).length ) {
+ component.resetPreview(
+ $form.find( 'button[data-role="cancel"]' )
+ );
+ }
+
+ $form.find( 'textarea' ).each( function () {
+ var $editor = $( this );
+
+ // Kill editor instances
+ if ( mw.flow.editor && mw.flow.editor.exists( $editor ) ) {
+ mw.flow.editor.destroy( $editor );
+ }
+
+ // Drop the new input in place if:
+ // the textarea isn't already focused
+ // and the textarea doesn't have text typed into it
+ if ( !$editor.is( ':focus' ) && this.value === this.defaultValue ) {
+ component.emitWithReturn( 'compressTextarea', $editor );
+ }
+ } );
+
+ if ( initialState === 'collapsed' ) {
+ // Hide its actions
+ // @todo Use TemplateEngine to find and hide actions?
+ $form.find( '.flow-form-collapsible' ).hide();
+ $form.data( 'flow-form-collapse-state', 'collapsed' );
+ } else if ( initialState === 'hidden' ) {
+ // Hide the form itself
+ $form.hide();
+ }
+ }
+ FlowComponentEventsMixin.eventHandlers.hideForm = flowEventsMixinHideForm;
+
+ /**
+ * "Compresses" a textarea by adding a class to it, which CSS will pick up
+ * to force a smaller display size.
+ * @param {jQuery} $textarea
+ * @todo Move this to a separate file
+ */
+ function flowEventsMixinCompressTextarea( $textarea ) {
+ $textarea.addClass( 'flow-input-compressed' );
+ if ( mw.flow.editor && mw.flow.editor.exists( $textarea ) ) {
+ mw.flow.editor.destroy( $textarea );
+ }
+ }
+ FlowComponentEventsMixin.eventHandlers.compressTextarea = flowEventsMixinCompressTextarea;
+
+ /**
+ * If input is focused, expand it if compressed (into textarea).
+ * Otherwise, trigger the form to unhide.
+ * @param {Event} event
+ * @todo Move this to a separate file
+ */
+ function flowEventsMixinFocusField( event ) {
+ var $context = $( event.currentTarget || event.delegateTarget || event.target ),
+ component = mw.flow.getPrototypeMethod( 'component', 'getInstanceByElement' )( $context );
+
+ // Show the form (and swap it for textarea if needed)
+ component.emitWithReturn( 'showForm', $context.closest( 'form' ) );
+ }
+ FlowComponentEventsMixin.eventHandlers.focusField = flowEventsMixinFocusField;
+
+ /**
+ * Expand and make visible a flow form and/or its actions, depending on data-flow-initial-state.
+ * @param {jQuery} $form
+ */
+ function flowEventsMixinShowForm( $form ) {
+ var initialState = $form.data( 'flow-initial-state' ),
+ self = this;
+
+ if ( initialState === 'collapsed' ) {
+ // Show its actions
+ if ( $form.data( 'flow-form-collapse-state' ) === 'collapsed' ) {
+ $form.removeData( 'flow-form-collapse-state' );
+ $form.find( '.flow-form-collapsible' ).show();
+ }
+ } else if ( initialState === 'hidden' ) {
+ // Show the form itself
+ $form.show();
+ }
+
+ // Expand all textareas if needed
+ $form.find( '.flow-input-compressed' ).each( function () {
+ self.emitWithReturn( 'expandTextarea', $( this ) );
+ } );
+
+ // Initialize editors, turning them from textareas into editor objects
+ self.emitWithReturn( 'initializeEditors', $form );
+
+ // Store state
+ $form.data( 'flow-state', 'visible' );
+ }
+ FlowComponentEventsMixin.eventHandlers.showForm = flowEventsMixinShowForm;
+
+ /**
+ * Expand the textarea by removing the CSS class that will make it appear
+ * smaller.
+ * @param {jQuery} $textarea
+ */
+ function flowEventsMixinExpandTextarea( $textarea ) {
+ $textarea.removeClass( 'flow-input-compressed' );
+ }
+ FlowComponentEventsMixin.eventHandlers.expandTextarea = flowEventsMixinExpandTextarea;
+
+ /**
+ * Initialize all editors, turning them from textareas into editor objects.
+ *
+ * @param {jQuery} $container
+ */
+ function flowEventsMixinInitializeEditors( $container ) {
+ var flowComponent = this, $form;
+
+ mw.loader.using( 'ext.flow.editor', function() {
+ var $editors = $container.find( 'textarea:not(.flow-input-compressed)' );
+
+ $editors.each( function() {
+ var $editor = $( this );
+
+ $form = $editor.closest( 'form' );
+ // All editors already have their content in wikitext-format
+ // (mostly because we need to prefill them server-side so that
+ // JS-less users can interact)
+ mw.flow.editor.load( $editor, $editor.val(), 'wikitext' ).done( function () {
+ $form.toggleClass( 'flow-editor-supports-preview', mw.flow.editor.editor.static.usesPreview() );
+ } );
+
+ // Kill editor instance when the form it's in is cancelled
+ flowComponent.emitWithReturn( 'addFormCancelCallback', $form, function() {
+ if ( mw.flow.editor.exists( $editor ) ) {
+ mw.flow.editor.destroy( $editor );
+ }
+ } );
+ } );
+ } );
+ }
+ FlowComponentEventsMixin.eventHandlers.initializeEditors = flowEventsMixinInitializeEditors;
+
+ /**
+ * Adds a flow-cancel-callback to a given form, to be triggered on click of the "cancel" button.
+ * @param {jQuery} $form
+ * @param {Function} callback
+ */
+ function flowEventsMixinAddFormCancelCallback( $form, callback ) {
+ var fns = $form.data( 'flow-cancel-callback' ) || [];
+ fns.push( callback );
+ $form.data( 'flow-cancel-callback', fns );
+ }
+ FlowComponentEventsMixin.eventHandlers.addFormCancelCallback = flowEventsMixinAddFormCancelCallback;
+
+ /**
+ * @param {FlowBoardComponent|jQuery} $node or entire FlowBoard
+ */
+ function flowEventsMixinRemoveError( $node ) {
+ _flowFindUpward( $node, '.flow-error-container' ).filter( ':first' ).empty();
+ }
+ FlowComponentEventsMixin.eventHandlers.removeError = flowEventsMixinRemoveError;
+
+ /**
+ * @param {FlowBoardComponent|jQuery} $node or entire FlowBoard
+ * @param {String} msg The error that occurred. Currently hardcoded.
+ */
+ function flowEventsMixinShowError( $node, msg ) {
+ var fragment = mw.flow.TemplateEngine.processTemplate( 'flow_errors.partial', { errors: [ { message: msg } ] } );
+
+ if ( !$node.jquery ) {
+ $node = $node.$container;
+ }
+
+ _flowFindUpward( $node, '.flow-content-preview' ).hide();
+ _flowFindUpward( $node, '.flow-error-container' ).filter( ':first' ).replaceWith( fragment );
+ }
+ FlowComponentEventsMixin.eventHandlers.showError = flowEventsMixinShowError;
+
+ /**
+ * Shows a tooltip telling the user that they have subscribed
+ * to this topic|board
+ * @param {jQuery} $tooltipTarget Element to attach tooltip to.
+ * @param {string} type 'topic' or 'board'
+ * @param {string} dir Direction to point the pointer. 'left' or 'up'
+ */
+ function flowEventsMixinShowSubscribedTooltip( $tooltipTarget, type, dir ) {
+ dir = dir || 'left';
+
+ mw.tooltip.show(
+ $tooltipTarget,
+ // tooltipTarget will not always be part of a FlowBoardComponent
+ $( mw.flow.TemplateEngine.processTemplateGetFragment(
+ 'flow_tooltip_subscribed.partial',
+ {
+ unsubscribe: false,
+ type: type,
+ direction: dir,
+ username: mw.user.getName()
+ }
+ )
+ ).children(),
+ {
+ tooltipPointing: dir
+ }
+ );
+
+ // Hide after 5s
+ setTimeout( function () {
+ mw.tooltip.hide( $tooltipTarget );
+ }, 5000 );
+ }
+ FlowComponentEventsMixin.eventHandlers.showSubscribedTooltip = flowEventsMixinShowSubscribedTooltip;
+
+ /**
+ * If a form has a cancelForm handler, we clear the form and trigger it. This allows easy cleanup
+ * and triggering of form events after successful API calls.
+ * @param {Element|jQuery} formElement
+ */
+ function flowEventsMixinCancelForm( formElement ) {
+ var $form = $( formElement ),
+ $button = $form.find( 'button, input, a' ).filter( '[data-flow-interactive-handler="cancelForm"]' );
+
+ if ( $button.length ) {
+ // Clear contents to not trigger the "are you sure you want to
+ // discard your text" warning
+ $form.find( 'textarea, :text' ).each( function() {
+ $( this ).val( this.defaultValue );
+ } );
+
+ // Trigger a click on cancel to have it destroy the form the way it should
+ $button.trigger( 'click' );
+ }
+ }
+ FlowComponentEventsMixin.eventHandlers.cancelForm = flowEventsMixinCancelForm;
+
+ //
+ // Private functions
+ //
+
+ /**
+ * Given node & a selector, this will return the result closest to $node
+ * by first looking inside $node, then travelling up the DOM tree to
+ * locate the first result in a common ancestor.
+ *
+ * @param {jQuery} $node
+ * @param {String} selector
+ * @returns jQuery
+ */
+ function _flowFindUpward( $node, selector ) {
+ // first check if result can already be found inside $node
+ var $result = $node.find( selector );
+
+ // then keep looking up the tree until a result is found
+ while ( $result.length === 0 && $node.length !== 0 ) {
+ $node = $node.parent();
+ $result = $node.children( selector );
+ }
+
+ return $result;
+ }
+
+ // Copy static and prototype from mixin to main class
+ mw.flow.mixinComponent( 'component', FlowComponentEventsMixin );
+}( jQuery, mediaWiki ) );
diff --git a/Flow/modules/engine/components/common/flow-component-menus.js b/Flow/modules/engine/components/common/flow-component-menus.js
new file mode 100644
index 00000000..3239a851
--- /dev/null
+++ b/Flow/modules/engine/components/common/flow-component-menus.js
@@ -0,0 +1,142 @@
+/*!
+ * Contains flow-menu functionality.
+ */
+
+( function ( $, mw ) {
+ /**
+ * Binds handlers for flow-menu.
+ * @param {jQuery} $container
+ * @this FlowComponent
+ * @constructor
+ */
+ function FlowComponentMenusFeatureMixin( $container ) {
+ // Bind events to this instance
+ this.bindComponentHandlers( FlowComponentMenusFeatureMixin.eventHandlers );
+
+ // Bind element handlers
+ this.bindNodeHandlers( FlowComponentMenusFeatureMixin.UI.events );
+
+ // Bind special toggle menu handler
+ $container
+ .on(
+ 'click.FlowBoardComponent mousedown.FlowBoardComponent mouseup.FlowBoardComponent focusin.FlowBoardComponent focusout.FlowBoardComponent',
+ '.flow-menu',
+ this.getDispatchCallback( 'toggleHoverMenu' )
+ );
+ }
+ OO.initClass( FlowComponentMenusFeatureMixin );
+
+ FlowComponentMenusFeatureMixin.eventHandlers = {};
+ FlowComponentMenusFeatureMixin.UI = {
+ events: {
+ loadHandlers: {},
+ interactiveHandlers: {}
+ }
+ };
+
+ //
+ // Event handler methods
+ //
+
+ /**
+ * On click, focus, and blur of hover menu events, decides whether or not to hide or show the expanded menu
+ * @param {Event} event
+ */
+ function flowComponentMenusFeatureMixinToggleHoverMenuCallback( event ) {
+ var $this = $( event.target ),
+ $menu = $this.closest( '.flow-menu' );
+
+ if ( event.type === 'click' ) {
+ // If the caret was clicked, toggle focus
+ if ( $this.closest( '.flow-menu-js-drop' ).length ) {
+ $menu.toggleClass( 'focus' );
+
+ // This trick lets us wait for a blur event from A instead on body, to later hide the menu on outside click
+ if ( $menu.hasClass( 'focus' ) ) {
+ $menu.find( '.flow-menu-js-drop' ).find( 'a' ).focus();
+ }
+ } else if ( $this.is( 'a, button' ) ) {
+ // Remove the focus from the menu so it can hide after clicking on a link or button
+ setTimeout( function () {
+ if ( $this.is( ':focus' ) ) {
+ $this.blur();
+ }
+ }, 50 );
+ }
+
+ $menu.removeData( 'mousedown' );
+ } else if ( event.type === 'mousedown' ) {
+ // Fix for Chrome: it triggers blur when you click on the scrollbar! Let's prevent that.
+ $menu.data( 'mousedown', true );
+ } else if ( event.type === 'mouseup' ) {
+ // Chrome fix ^
+ $menu.removeData( 'mousedown' );
+ } else if ( event.type === 'focusin' ) {
+ // If we are focused on a menu item (eg. tabbed in), open the whole menu
+ $menu.addClass( 'focus' );
+ } else if ( event.type === 'focusout' && !$menu.find( 'a' ).filter( ':focus' ).length ) {
+ // If we lost focus, make sure no other element in this menu has focus, and then hide the menu
+ setTimeout( function () {
+ if ( !$menu.data( 'mousedown' ) && !$menu.find( 'a' ).filter( ':focus' ).length ) {
+ $menu.removeClass( 'focus' );
+ }
+ }, 250 );
+ }
+ }
+ FlowComponentMenusFeatureMixin.eventHandlers.toggleHoverMenu = flowComponentMenusFeatureMixinToggleHoverMenuCallback;
+
+ //
+ // On element-click handlers
+ //
+
+ /**
+ * Allows you to open a flow-menu from a secondary click handler elsewhere.
+ * Uses data-flow-menu-target="< foo .flow-menu"
+ * @param {Event} event
+ */
+ function flowComponentMenusFeatureElementMenuToggleCallback( event ) {
+ var $this = $( this ),
+ flowComponent = mw.flow.getPrototypeMethod( 'component', 'getInstanceByElement' )( $this ),
+ target = $this.data( 'flowMenuTarget' ),
+ $target = $.findWithParent( $this, target ),
+ $deferred = $.Deferred();
+
+
+ event.preventDefault();
+
+ if ( !$target || !$target.length ) {
+ flowComponent.debug( 'Could not find openFlowMenu target', arguments );
+ return $deferred.reject().promise();
+ }
+
+ $target.find( '.flow-menu-js-drop' ).trigger( 'click' );
+
+ return $deferred.resolve().promise();
+ }
+ FlowComponentMenusFeatureMixin.UI.events.interactiveHandlers.menuToggle = flowComponentMenusFeatureElementMenuToggleCallback;
+
+ //
+ // On element-load handlers
+ //
+
+ /**
+ * When a menu appears, check if it's already got the focus class. If so, re-focus it.
+ * @param {jQuery} $menu
+ */
+ function flowComponentMenusFeatureElementLoadCallback( $menu ) {
+ // For some reason, this menu is visible, but lacks physical focus
+ // This happens when you clone an activated flow-menu
+ if ( $menu.hasClass( 'focus' ) && !$menu.find( 'a' ).filter( ':focus' ).length ) {
+ // Give it focus again
+ $menu.find( '.flow-menu-js-drop' ).find( 'a' ).focus();
+ }
+ }
+ FlowComponentMenusFeatureMixin.UI.events.loadHandlers.menu = flowComponentMenusFeatureElementLoadCallback;
+
+ //
+ // Private functions
+ //
+
+ // Mixin to FlowComponent
+ mw.flow.mixinComponent( 'component', FlowComponentMenusFeatureMixin );
+}( jQuery, mediaWiki ) );
diff --git a/Flow/modules/engine/components/flow-component.js b/Flow/modules/engine/components/flow-component.js
new file mode 100644
index 00000000..ffba356f
--- /dev/null
+++ b/Flow/modules/engine/components/flow-component.js
@@ -0,0 +1,251 @@
+/*!
+ * Contains base FlowComponent class.
+ */
+
+( function ( $, mw ) {
+ var _totalInstanceCount = 0;
+
+ /**
+ * Inherited base class. Stores the instance in the class's instance registry.
+ * @param {jQuery} $container
+ * @mixins FlowComponentEventsMixin
+ * @mixins FlowComponentEnginesMixin
+ * @mixins FlowComponentMenusFeatureMixin
+ * @constructor
+ */
+ function FlowComponent( $container ) {
+ var parent = this.constructor.parent;
+
+ // Run progressive enhancements if any are needed by this container
+ mw.flow.TemplateEngine.processProgressiveEnhancement( $container );
+
+ // Store the container for later use
+ this.$container = $container;
+
+ // Get this component's ID
+ this.id = $container.data( 'flow-id' );
+ if ( !this.id ) {
+ // Generate an ID for this component
+ this.id = 'flow-generated-' + _totalInstanceCount;
+ $container.data( 'flow-id', this.id );
+ // @todo throw an exception here instead of generating an id?
+ } else if ( this.getInstanceByElement( $container ) ) {
+ // Check if this board was already instantiated, and return that instead
+ return this.getInstanceByElement( $container );
+ }
+
+ // Give this board its own API instance @todo do this with OOjs
+ this.Api = new mw.flow.FlowApi( FlowComponent.static.StorageEngine, this.id );
+
+ // Keep this in the registry to find it by other means
+ while ( parent ) {
+ parent._instanceRegistryById[this.id] = parent._instanceRegistry.push( this ) - 1;
+ parent = parent.parent; // and add it to every instance registry
+ }
+ _totalInstanceCount++;
+ }
+ OO.initClass( FlowComponent );
+
+ //
+ // PROTOTYPE METHODS
+ //
+
+ /**
+ * Takes any length of arguments, and passes it off to console.log.
+ * Only renders if window.flow_debug OR localStorage.flow_debug == true OR user is Admin or (WMF).
+ * @param {Boolean} [isError=true]
+ * @param {...*} args
+ */
+ mw.flow.debug = FlowComponent.prototype.debug = function ( isError, args ) {
+ if ( window.console ) {
+ args = Array.prototype.slice.call( arguments, 0 );
+
+ if ( typeof isError === 'boolean' ) {
+ args.shift();
+ } else {
+ isError = true;
+ }
+
+ args.unshift( '[FLOW] ' );
+
+ if ( isError && console.error ) {
+ // If console.error is supported, send that, because it gives a stack trace
+ return console.error.apply( console, args );
+ }
+
+ // Otherwise, use console.log
+ console.log.apply( console, args );
+ }
+ };
+
+ /**
+ * Converts a Flow UUID to a UNIX timestamp.
+ * @param {String} uuid
+ * @return {int} UNIX time
+ */
+ mw.flow.uuidToTime = FlowComponent.prototype.uuidToTime = function ( uuid ) {
+ var timestamp = parseInt( uuid, 36 ).toString( 2 ); // Parse from base-36, then serialize to base-2
+ timestamp = Array( 88 + 1 - timestamp.length ).join( '0' ) + timestamp; // left pad 0 to 88 chars
+ timestamp = parseInt( timestamp.substr( 0, 46 ), 2 ); // first 46 chars base-2 to base-10
+
+ return timestamp;
+ };
+
+ /**
+ * Returns all the registered instances of a given FlowComponent.
+ * @returns {FlowComponent[]}
+ */
+ FlowComponent.prototype.getInstances = function () {
+ // Use the correct context (instance vs prototype)
+ return ( this.constructor.parent || this )._instanceRegistry;
+ };
+
+ /**
+ * Goes up the DOM tree to find which FlowComponent $el belongs to, via .flow-component[flow-id].
+ * @param {jQuery} $el
+ * @returns {FlowComponent|bool}
+ */
+ FlowComponent.prototype.getInstanceByElement = function ( $el ) {
+ var $container = $el.closest( '.flow-component' ),
+ context = this.constructor.parent || this, // Use the correct context (instance vs prototype)
+ id;
+
+ // This element isn't _within_ any actual component; was it spawned _by_ a component?
+ if ( !$container.length ) {
+ // Find any parents of this element with the flowSpawnedBy data attribute
+ $container = $el.parents().addBack().filter( function () {
+ return $( this ).data( 'flowSpawnedBy' );
+ } ).last()
+ // Get the flowSpawnedBy node
+ .data( 'flowSpawnedBy' );
+ // and then return the closest flow-component of it
+ $container = $container ? $container.closest( '.flow-component' ) : $();
+ }
+
+ // Still no parent component. Crap out!
+ if ( !$container.length ) {
+ mw.flow.debug( 'Failed to getInstanceByElement: no $container.length', arguments );
+ return false;
+ }
+
+ id = $container.data( 'flow-id' );
+
+ return context._instanceRegistry[ context._instanceRegistryById[ id ] ] || false;
+ };
+
+ /**
+ * Sets the FlowComponent's $container element as the data-flow-spawned-by attribute on $el.
+ * Fires ALL events from within $el onto $eventTarget, albeit with the whole event intact.
+ * This allows us to listen for events from outside of FlowComponent's nodes, but still trigger them within.
+ * @param {jQuery} $el
+ * @param {jQuery} [$eventTarget]
+ */
+ FlowComponent.prototype.assignSpawnedNode = function ( $el, $eventTarget ) {
+ // Target defaults to .flow-component
+ $eventTarget = $eventTarget || this.$container;
+
+ // Assign flowSpawnedBy data attribute
+ $el.data( 'flowSpawnedBy', $eventTarget );
+
+ // Forward all events (except mouse movement) to $eventTarget
+ $el.on(
+ 'blur change click dblclick error focus focusin focusout keydown keypress keyup load mousedown mouseenter mouseleave mouseup resize scroll select submit',
+ '*',
+ { flowSpawnedBy: this.$container, flowSpawnedFrom: $el },
+ function ( event ) {
+ // Let's forward these events in an unusual way, similar to how jQuery propagates events...
+ // First, only take the very first, top-level event, as the rest of the propagation is handled elsewhere
+ if ( event.target === this ) {
+ // Get all the parent nodes of our target,
+ // but do not include any nodes we will already be bubbling up to (eg. body)
+ var $nodes = $eventTarget.parents().addBack().not( $( this ).parents().addBack() ),
+ i = $nodes.length;
+
+ // For every node between $eventTarget and window that was not filtered out above...
+ while ( i-- ) {
+ // Trigger a bubbling event on each one, with the correct context
+ _eventForwardDispatch.call( $nodes[i], event, $el[0] );
+ }
+ }
+ }
+ );
+ };
+
+ //
+ // PRIVATE FUNCTIONS
+ //
+
+ /**
+ * This method is mostly cloned from jQuery.event.dispatch, except that it has been modified to use container
+ * as its base for finding event handlers (via jQuery.event.handlers). This allows us to trigger events on said
+ * container (and its parents, bubbling up), as if the event originated from within it.
+ * jQuery itself doesn't allow for this, as the context (this & event.currentTarget) become the actual element you
+ * are triggering an event on, instead of the element which matched the selector.
+ *
+ * @example _eventForwardDispatch.call( Element, Event, Element );
+ *
+ * @param {jQuery.Event} event
+ * @param {Element} container
+ * @returns {*}
+ * @private
+ */
+ function _eventForwardDispatch( event, container ) {
+ // Make a writable jQuery.Event from the native event object
+ event = jQuery.event.fix( event );
+
+ var i, ret, handleObj, matched, j,
+ handlerQueue = [],
+ args = Array.prototype.slice.call( arguments, 0 ),
+ handlers = ( jQuery._data( this, 'events' ) || {} )[ event.type ] || [],
+ special = jQuery.event.special[ event.type ] || {};
+
+ // Use the fix-ed jQuery.Event rather than the (read-only) native event
+ args[0] = event;
+ event.delegateTarget = this;
+
+ // Call the preDispatch hook for the mapped type, and let it bail if desired
+ if ( special.preDispatch && special.preDispatch.call( this, event ) === false ) {
+ return;
+ }
+
+ // Determine handlers
+ // The important modification: we use container instead of this as the context
+ handlerQueue = jQuery.event.handlers.call( container, event, handlers );
+
+ // Run delegates first; they may want to stop propagation beneath us
+ i = 0;
+ while ( ( matched = handlerQueue[ i++ ] ) && !event.isPropagationStopped() ) {
+ event.currentTarget = matched.elem;
+
+ j = 0;
+ while ( ( handleObj = matched.handlers[ j++ ] ) && !event.isImmediatePropagationStopped() ) {
+ // Triggered event must either 1) have no namespace, or
+ // 2) have namespace(s) a subset or equal to those in the bound event (both can have no namespace).
+ if ( !event.namespace_re || event.namespace_re.test( handleObj.namespace ) ) {
+
+ event.handleObj = handleObj;
+ event.data = handleObj.data;
+
+ ret = ( ( jQuery.event.special[ handleObj.origType ] || {} ).handle || handleObj.handler )
+ .apply( matched.elem, args );
+
+ if ( ret !== undefined ) {
+ if ( ( event.result = ret ) === false ) {
+ event.preventDefault();
+ event.stopPropagation();
+ }
+ }
+ }
+ }
+ }
+
+ // Call the postDispatch hook for the mapped type
+ if ( special.postDispatch ) {
+ special.postDispatch.call( this, event );
+ }
+
+ return event.result;
+ }
+
+ mw.flow.registerComponent( 'component', FlowComponent );
+}( jQuery, mediaWiki ) );
diff --git a/Flow/modules/engine/components/flow-registry.js b/Flow/modules/engine/components/flow-registry.js
new file mode 100644
index 00000000..3b2a9498
--- /dev/null
+++ b/Flow/modules/engine/components/flow-registry.js
@@ -0,0 +1,165 @@
+/*!
+ * Creates and manages the component registry.
+ * We expand upon OOjs in several ways here:
+ * 1. Allow mixinClasses to have their constructor functions to be called (at initComponent).
+ * 2. Automatically call all parent constructors from inheritClasses (at initComponent).
+ * 3. Create a global instance registry of components on a page, and also create a registry for each component type.
+ * 4. Have the ability to fetch individual prototype methods from classes in the registry, as they are out of scope.
+ */
+
+( function ( $, mw ) {
+ mw.flow = mw.flow || {}; // create mw.flow globally
+
+ var _componentRegistry = new OO.Registry();
+
+ /**
+ * Instantiate one or more new FlowComponents.
+ * Uses data-flow-component to find the right class, and returns that new instance.
+ * Accepts one or more container elements in $container. If multiple, returns an array of FlowBoardComponents.
+ * @param {jQuery} $container
+ * @returns {FlowComponent|boolean|Array} The created FlowComponent instance, or an
+ * array of FlowComponent instances, or boolean false in case of an error.
+ */
+ function initFlowComponent( $container ) {
+ var a, i, componentName, componentBase,
+ /** @private
+ * Deep magic: This crazy little function becomes the "real" top-level constructor
+ * It recursively calls every parent so that we don't have to do it manually in a Component constructor
+ * @returns {FlowComponent}
+ */
+ _RecursiveConstructor = function () {
+ var constructors = [],
+ parent = this.constructor.parent,
+ i, j, parentReturn;
+
+ // Find each parent class
+ while ( parent ) {
+ constructors.push( parent );
+ parent = parent.parent;
+ }
+
+ // Call each parent in reverse (starting with the base class and moving up the chain)
+ for ( i = constructors.length; i--; ) {
+ // Call each mixin constructor
+ for ( j = 0; j < constructors[i].static.mixinConstructors.length; j++ ) {
+ constructors[i].static.mixinConstructors[j].apply( this, arguments );
+ }
+
+ // Call this class constructor
+ parentReturn = constructors[i].apply( this, arguments );
+
+ if ( parentReturn && parentReturn.constructor ) {
+ // If the parent returned an instantiated class (cached), return that instead
+ return parentReturn;
+ }
+ }
+
+ // Run any post-instantiation handlers
+ this.emitWithReturn( 'instantiationComplete', this );
+ };
+
+ if ( !$container || !$container.length ) {
+ // No containers found
+ mw.flow.debug( 'Will not instantiate: no $container.length', arguments );
+ return false;
+ } else if ( $container.length > 1 ) {
+ // Too many elements; instantiate them all
+ for ( a = [], i = $container.length; i--; ) {
+ a.push( initFlowComponent( $( $container[ i ] ) ) );
+ }
+ return a;
+ }
+
+ // Find out which component this is
+ componentName = $container.data( 'flow-component' );
+ // Get that component
+ componentBase = _componentRegistry.lookup( componentName );
+ if ( componentBase ) {
+ // Return the new instance of that FlowComponent, via our _RecursiveConstructor method
+ OO.inheritClass( _RecursiveConstructor, componentBase );
+ return new _RecursiveConstructor( $container );
+ }
+
+ // Don't know what kind of component this is.
+ mw.flow.debug( 'Unknown FlowComponent: ', componentName, arguments );
+ return false;
+ }
+ mw.flow.initComponent = initFlowComponent;
+
+ /**
+ * Registers a given FlowComponent into the component registry, and also has it inherit another class using the
+ * prototypeName argument (defaults to 'component', which returns FlowComponent).
+ * @param {String} name Name of component to register
+ * @param {Function} constructorClass Actual class to link to that name
+ * @param {String} [prototypeName='component'] A base class which this one will inherit
+ */
+ function registerFlowComponent( name, constructorClass, prototypeName ) {
+ if ( name !== 'component' ) {
+ // Inherit a base class; defaults to FlowComponent
+ OO.inheritClass( constructorClass, _componentRegistry.lookup( prototypeName || 'component' ) );
+ }
+
+ // Create the instance registry for this component
+ constructorClass._instanceRegistry = [];
+ constructorClass._instanceRegistryById = {};
+
+ // Assign the OOjs static name to this class
+ constructorClass.static.name = name;
+
+ // Allow mixins to use their constructor
+ constructorClass.static.mixinConstructors = [];
+
+ // Register the component class
+ _componentRegistry.register( name, constructorClass );
+ }
+ mw.flow.registerComponent = registerFlowComponent;
+
+ /**
+ * For when you want to call a specific function from a class's prototype.
+ * @example mw.flow.getPrototypeMethod( 'board', 'getInstanceByElement' )( $el );
+ * @param {String} className
+ * @param {String} methodName
+ * @param {*} [context]
+ * @return {Function}
+ */
+ function getFlowPrototypeMethod( className, methodName, context ) {
+ var registeredClass = _componentRegistry.lookup( className ),
+ method;
+
+ if ( !registeredClass ) {
+ mw.flow.debug( 'Failed to find FlowComponent.', arguments );
+ return $.noop;
+ }
+
+ method = registeredClass.prototype[methodName];
+ if ( !method ) {
+ mw.flow.debug( 'Failed to find FlowComponent method.', arguments );
+ return $.noop;
+ }
+
+ return $.proxy( method, context || registeredClass );
+ }
+ mw.flow.getPrototypeMethod = getFlowPrototypeMethod;
+
+ /**
+ * Mixes in the given mixinClass to be copied to an existing class, by name.
+ * @param {String} targetName Target component
+ * @param {Function} mixinClass Class with extension to add to target
+ */
+ function mixinFlowComponent( targetName, mixinClass ) {
+ var registeredClass = _componentRegistry.lookup( targetName );
+
+ if ( !registeredClass ) {
+ mw.flow.debug( 'Failed to find FlowComponent to extend.', arguments );
+ return;
+ }
+
+ OO.mixinClass( registeredClass, mixinClass );
+
+ // Allow mixins to use their constructors (in init)
+ if ( typeof mixinClass === 'function' ) {
+ registeredClass.static.mixinConstructors.push( mixinClass );
+ }
+ }
+ mw.flow.mixinComponent = mixinFlowComponent;
+}( jQuery, mediaWiki ) );
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 ) );
diff --git a/Flow/modules/flow-initialize.js b/Flow/modules/flow-initialize.js
new file mode 100644
index 00000000..279a1e3c
--- /dev/null
+++ b/Flow/modules/flow-initialize.js
@@ -0,0 +1,14 @@
+/*!
+ * Runs Flow code, using methods in FlowUI.
+ */
+
+( function ( $ ) {
+ // Pretend we got some data and run with it
+ /*
+ * Now do stuff
+ * @todo not like this
+ */
+ $( document ).ready( function () {
+ mw.flow.initComponent( $( '.flow-component' ) );
+ } );
+}( jQuery ) );
diff --git a/Flow/modules/handlebars.js b/Flow/modules/handlebars.js
new file mode 100644
index 00000000..a0acc7b7
--- /dev/null
+++ b/Flow/modules/handlebars.js
@@ -0,0 +1,25 @@
+// Register the Handlebars compiler with MediaWiki.
+( function() {
+ /*
+ * @class HandlebarsTemplateCompiler
+ * @singleton
+ */
+ var handlebars = {
+ /*
+ * Compiler source code into a template object
+ *
+ * @method
+ * @param {String} src the source of a template
+ * @return {HandleBars.Template} template object
+ */
+ compile: function( src ) {
+ return {
+ /* @param {*} data */
+ render: Handlebars.compile( src, { preventIndent: true } )
+ };
+ }
+ };
+
+ // register Handlebars with core.
+ mw.template.registerCompiler( 'handlebars', handlebars );
+}() );
diff --git a/Flow/modules/messagePoster/ext.flow.messagePoster.js b/Flow/modules/messagePoster/ext.flow.messagePoster.js
new file mode 100644
index 00000000..1937f1c4
--- /dev/null
+++ b/Flow/modules/messagePoster/ext.flow.messagePoster.js
@@ -0,0 +1,57 @@
+( function ( $, mw, OO ) {
+ mw.flow = mw.flow || {};
+
+ /**
+ * This is an implementation of MessagePoster for Flow boards
+ *
+ * The title can be a non-existent board, but it will only work if Flow is allowed in that
+ * namespace or the user has flow-create-board
+ *
+ * @class
+ * @constructor
+ *
+ * @extends mw.messagePoster.MessagePoster
+ *
+ * @param {mw.Title} title Title of Flow board
+ */
+ mw.flow.MessagePoster = function MwFlowMessagePoster( title ) {
+ // I considered using FlowApi, but most of that functionality is about mapping <form>
+ // or <a> tags to AJAX, which is not applicable. This allows us to keep
+ // mediawiki.messagePoster.flow-board light-weight.
+
+ this.api = new mw.Api();
+ this.title = title;
+ };
+
+ OO.inheritClass(
+ mw.flow.MessagePoster,
+ mw.messagePoster.MessagePoster
+ );
+
+ /**
+ * @inheritdoc
+ */
+ mw.flow.MessagePoster.prototype.post = function ( subject, body ) {
+ mw.flow.MessagePoster.parent.prototype.post.call( this, subject, body );
+
+ return this.api.postWithToken( 'edit', {
+ action: 'flow',
+ submodule: 'new-topic',
+ page: this.title.getPrefixedDb(),
+ nttopic: subject,
+ ntcontent: body,
+ ntformat: 'wikitext',
+ ntmetadataonly: 1
+ }, {
+ // IE 8 seems to have cached some POST requests without this
+ cache: false
+ } ).then(
+ null, // Preserve parameters from postWithToken promise
+ function ( code, details ) {
+ return $.Deferred().reject( 'api-fail', code, details );
+ }
+ ).promise();
+ };
+
+ mw.messagePoster.factory.register( 'flow-board', mw.flow.MessagePoster );
+} ( jQuery, mediaWiki, OO ) );
diff --git a/Flow/modules/notification/icon/Talk-ltr.png b/Flow/modules/notification/icon/Talk-ltr.png
new file mode 100644
index 00000000..124822f5
--- /dev/null
+++ b/Flow/modules/notification/icon/Talk-ltr.png
Binary files differ
diff --git a/Flow/modules/notification/icon/Talk-rtl.png b/Flow/modules/notification/icon/Talk-rtl.png
new file mode 100644
index 00000000..cdc652bf
--- /dev/null
+++ b/Flow/modules/notification/icon/Talk-rtl.png
Binary files differ
diff --git a/Flow/modules/styles/board/content-preview.less b/Flow/modules/styles/board/content-preview.less
new file mode 100644
index 00000000..30a518d3
--- /dev/null
+++ b/Flow/modules/styles/board/content-preview.less
@@ -0,0 +1,24 @@
+@import 'mediawiki.mixins';
+@import 'flow.colors';
+@import 'flow.helpers';
+@import 'flow.variables';
+
+// Preview
+.flow-content-preview {
+ word-wrap: break-word;
+ word-break: break-word;
+
+ background-color: #FDFFE7;
+ border: 1px solid #FCEB92;
+ padding: 5px;
+ margin-top: 5px;
+ margin-bottom: 15px;
+ white-space: normal;
+ overflow: auto;
+
+ display: none; // Hide initially
+
+ .flow-preview-sub-container {
+ margin-top: 5px;
+ }
+}
diff --git a/Flow/modules/styles/board/editor-switcher.less b/Flow/modules/styles/board/editor-switcher.less
new file mode 100644
index 00000000..d1cdd185
--- /dev/null
+++ b/Flow/modules/styles/board/editor-switcher.less
@@ -0,0 +1,54 @@
+@import 'mediawiki.mixins';
+@import 'mediawiki.ui/variables';
+@import 'flow.colors';
+
+// extra specificity is needed to override .mw-ui-button.mw-ui-*
+.mw-ui-button.flow-editor-color {
+ .flow-editor-none & {
+ color: white;
+ background-color: @colorConstructive;
+ }
+
+ .flow-editor-visualeditor & {
+ color: @colorText;
+ background-color: white;
+ }
+}
+
+.flow-editor {
+ // because we're attaching switcher controls below the textarea & we
+ // want them to look unified with the textarea, we'll have to take away
+ // it's border and re-apply on the parent node that contains both
+ &.flow-editor-none {
+ border: 1px solid @colorFieldBorder;
+
+ textarea {
+ border: 0;
+ }
+
+ .flow-switcher-controls {
+ background-color: white;
+ padding: .25em;
+ }
+ }
+
+ // would prefer textarea:not(.flow-input-compressed) above, but ie8 wont do it
+ // so here we re-apply the border from .mw-ui-input that was removed above.
+ textarea.flow-input-compressed {
+ border-bottom: 1px solid @colorFieldBorder;
+ }
+
+ // @todo this is basically the terms of use, come up with a shared
+ // name for all but the float
+ .flow-wikitext-editor-help {
+ float: left;
+ vertical-align: middle;
+ color: @colorTextLight;
+ font-size: .75em;
+ line-height: 1.4;
+ }
+
+ a.flow-editor-color {
+ float: right;
+ }
+}
diff --git a/Flow/modules/styles/board/form-actions.less b/Flow/modules/styles/board/form-actions.less
new file mode 100644
index 00000000..2901e96e
--- /dev/null
+++ b/Flow/modules/styles/board/form-actions.less
@@ -0,0 +1,54 @@
+@import 'mediawiki.mixins';
+@import 'flow.colors';
+@import 'flow.helpers';
+@import 'flow.variables';
+
+// Form button actions should be right-affixed
+.flow-form-actions {
+ position: relative;
+ margin-top: .25em;
+
+ button.mw-ui-button, a.mw-ui-button {
+ float: right;
+ margin-left: .25em;
+ }
+}
+
+textarea.mw-ui-input.flow-input-compressed {
+ height: 2.25em;
+ min-height: 2.25em;
+ resize: none;
+}
+
+.flow-anon-warning {
+ position: relative;
+}
+.flow-anon-warning-desktop {
+ display: none;
+ position: absolute;
+ right: -15em;
+ width: 15em;
+}
+
+.client-js {
+ // We determine in JS whether their editor has a preview mode.
+ // We assume all no-JS compatible editors do.
+ // The extra specificity is to override the normal .flow-js behavior
+
+ .flow-js.flow-form-action-preview {
+ display: none;
+ }
+
+ .flow-editor-supports-preview .flow-js.flow-form-action-preview {
+ display: block;
+ }
+}
+
+@media all and (min-width: @wgFlowDeviceWidthTablet) {
+ .flow-anon-warning-mobile {
+ display: none;
+ }
+ .flow-anon-warning-desktop {
+ display: block;
+ }
+}
diff --git a/Flow/modules/styles/board/header.less b/Flow/modules/styles/board/header.less
new file mode 100644
index 00000000..aa953623
--- /dev/null
+++ b/Flow/modules/styles/board/header.less
@@ -0,0 +1,27 @@
+@import 'mediawiki.mixins';
+@import 'flow.colors';
+@import 'flow.helpers';
+@import 'flow.variables';
+
+.flow-board-header {
+ word-break: break-word;
+}
+
+// Top board header
+.flow-board-header-nav {
+ position: relative;
+ text-align: right;
+}
+
+// Top board header edit icon
+.flow-board-header-icon {
+ position: absolute;
+ bottom: 0;
+ right: 0;
+ opacity: .33;
+
+ &:hover,
+ &:focus {
+ opacity: 1;
+ }
+}
diff --git a/Flow/modules/styles/board/menu.less b/Flow/modules/styles/board/menu.less
new file mode 100644
index 00000000..5aa8ce26
--- /dev/null
+++ b/Flow/modules/styles/board/menu.less
@@ -0,0 +1,154 @@
+@import 'mediawiki.mixins';
+@import 'flow.variables';
+@import 'flow.colors';
+@import 'flow.helpers';
+
+// @todo document flow-menu
+.flow-menu {
+ top: 0;
+ clear: both;
+ position: static;
+ right: 0;
+ bottom: 0;
+
+ ul {
+ font-size: 0.75em;
+ }
+ li {
+ display: inline;
+ text-align: left;
+
+ a {
+ font-weight: inherit;
+ }
+ }
+
+ a:focus {
+ outline: none;
+ }
+
+ // Hide the menu trigger completely in no-js mode
+ .flow-menu-js-drop {
+ display: none;
+ }
+}
+
+div.flow-menu-inverted {
+ right: auto;
+ left: 0;
+}
+
+// Use child selector to block IE6; it doesn't support :hover
+div > .flow-menu {
+ bottom: auto;
+ display: block;
+ border: none;
+
+ &.flow-menu-inverted {
+ right: auto;
+ left: 0;
+
+ .flow-menu-js-drop {
+ text-align: left;
+ }
+ }
+
+
+ // the toc needs to retain display:block for purposes
+ // of triggering autoload eagerly behind the scenes.
+ // flow-menu-scrollable and flow-menu-hoverable cannot
+ // be combined, as this hides the hoverable control.
+ &.flow-menu-scrollable {
+ visibility: hidden;
+ ul {
+ display: block;
+ }
+ }
+
+
+
+ &.flow-menu-hoverable:hover,
+ &.focus {
+ z-index: 2;
+
+ ul {
+ display: block;
+ }
+
+ &.flow-menu-scrollable {
+ visibility: visible;
+ }
+
+ .flow-menu-js-drop a {
+ outline: none;
+ border-color: transparent;
+ background: transparent;
+ background: rgba(0,0,0,0.05);
+
+ .caret {
+ border-top-color: #000;
+ }
+ }
+ }
+
+ ul {
+ // By default the menu control is shown and the menu
+ // itself is hidden
+ display: none;
+ font-size: 1em;
+ box-shadow: 0 1px 2px @colorGrayLight;
+ background: #fff;
+ border-radius: 2px;
+
+ > section:not(:first-of-type) > li:first-of-type,
+ li.flow-menu-section:not(:first-of-type) {
+ border-top: 1px solid @colorGrayLighter;
+ }
+
+ li {
+ display: block;
+ cursor: default;
+ }
+ }
+
+ // This is the menu opener handler; it contains an anchor which triggers the menu in touch devices, without JS
+ .flow-menu-js-drop {
+ display: block;
+ text-align: right;
+ text-indent: 0;
+ cursor: pointer;
+
+ a {
+ display: inline-block;
+ padding: 0 .5em;
+ border: 1px solid @colorGrayLight;
+ border-radius: 3px;
+ border-width: 0;
+ color: @colorTextLight;
+ }
+ }
+
+ // This is a hidden menu trigger; used when the menu is opened from a secondary handler via menuToggle
+ .flow-menu-js-drop-hidden {
+ position: absolute;
+ left: -999em;
+ height: 0;
+ }
+}
+
+// @todo move this
+div.flow-post > .flow-menu {
+ .flow-menu-js-drop {
+ a {
+ border-color: @colorGrayLightest;
+ border-width: 0;
+ }
+ }
+}
+
+@media all and (min-width: @wgFlowDeviceWidthTablet) {
+ // On desktop, the flow-menu is no longer inline
+ .flow-menu {
+ position: absolute;
+ }
+}
diff --git a/Flow/modules/styles/board/moderated.less b/Flow/modules/styles/board/moderated.less
new file mode 100644
index 00000000..1f120547
--- /dev/null
+++ b/Flow/modules/styles/board/moderated.less
@@ -0,0 +1,35 @@
+@import 'mediawiki.mixins';
+@import 'flow.colors';
+@import 'flow.helpers';
+
+// Visually mark moderated comments and posts
+// Locked and deleted are inverted (white bg)
+.flow-topic-moderatestate-lock,
+.flow-topic-moderatestate-delete {
+ color: @colorTextLight;
+
+ .flow-topic-titlebar {
+ background-color: @colorWhite;
+ border: solid 1px @colorGrayLight;
+ }
+}
+
+// Entire moderated post element
+.flow-post-moderated .flow-author a,
+.flow-moderated-post-content {
+ color: @colorTextLight;
+}
+
+.flow-post {
+ // Hide the message about the moderation action when the post is expanded
+ .flow-element-expanded .flow-moderated-post-content {
+ display: none;
+ }
+
+ .flow-element-collapsed {
+ .flow-post-content,
+ .flow-post-meta {
+ display: none;
+ }
+ }
+}
diff --git a/Flow/modules/styles/board/navigation.less b/Flow/modules/styles/board/navigation.less
new file mode 100644
index 00000000..ed290290
--- /dev/null
+++ b/Flow/modules/styles/board/navigation.less
@@ -0,0 +1,139 @@
+@import 'mediawiki.mixins';
+@import 'flow.colors';
+@import 'flow.helpers';
+@import 'flow.variables';
+
+// Top board navigation bar
+.flow-board-navigation {
+ left: 0;
+ position: static;
+ padding: 0;
+ white-space: nowrap;
+ min-width: 14em;
+ clear: both;
+
+ a:link, a:visited {
+ padding: 0.2em 0.3em;
+ }
+
+ a.flow-board-navigator-last {
+ float: right;
+ position: static;
+ }
+
+ .flow-board-navigation-inner {
+ overflow: hidden;
+ border-bottom: 1px solid @colorGrayLight;
+ white-space: nowrap;
+ }
+
+ .flow-board-navigator-filter {
+ display: inline-block;
+ position: relative;
+ }
+
+ a {
+ display: inline-block;
+
+ &:link, &:visited {
+ padding: 0.25em .75em;
+ color: @colorTextLight;
+ }
+ &:hover, &:focus, &.flow-board-navigator-link-highlight {
+ color: #000;
+ text-decoration: none;
+ }
+
+ &.flow-board-navigator-right {
+ float: right;
+ }
+ // The active menu item
+ &.flow-board-navigator-active {
+ font-weight: bold;
+ }
+ &.flow-board-navigator-first {
+ padding-left: 0;
+ }
+ }
+
+ // Added by JS when the window has been scrolled beyond the navigation, so it sticks to the viewport
+ &.flow-board-navigation-affixed {
+ position: fixed;
+ z-index: 2;
+ top: 0;
+ width: 100%;
+ background: @colorWhite;
+
+ .flow-board-toc-menu {
+ .flow-list {
+ // em version can probably be dropped when we drop IE 8
+ max-height: 30.6em;
+ max-height: 85vh;
+ }
+ }
+
+ .flow-board-navigation-inner {
+ & > a {
+ display: none; // hide everything but the current topic title
+ }
+
+ & > a.flow-board-navigator-active {
+ // Clip the topic title
+ display: block;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ }
+ }
+
+ }
+}
+
+// Filter & TOC menu below navigation bar
+.flow-board-header-menu {
+ position: relative;
+ float: right;
+
+ // Make TOC wide
+ .flow-board-toc-menu {
+ width: 100%;
+ position: absolute;
+
+ .flow-list {
+ overflow-y: auto;
+ // em version can probably be dropped when we drop IE 8
+ max-height: 18em;
+ max-height: 50vh;
+ }
+
+ a {
+ display: block;
+ overflow: hidden;
+ white-space: nowrap;
+ text-align: left;
+ text-overflow: ellipsis;
+ padding-left: 0;
+ padding-right: 0;
+
+ &.active {
+ font-weight: bold;
+ }
+ }
+
+ // This makes the list items align with the TOC bar text
+ .wikiglyph {
+ visibility: hidden;
+ font-weight: bold;
+ }
+ }
+}
+
+// MEDIA QUERIES
+@media all and (min-width: @wgFlowDeviceWidthTablet) {
+ html .flow-board-navigation {
+ left: 0;
+ font-size: 1.15em;
+ position: relative;
+ padding-top: .5em;
+ }
+}
diff --git a/Flow/modules/styles/board/replycount.less b/Flow/modules/styles/board/replycount.less
new file mode 100644
index 00000000..8b538532
--- /dev/null
+++ b/Flow/modules/styles/board/replycount.less
@@ -0,0 +1,30 @@
+@import 'mediawiki.mixins';
+@import 'flow.colors';
+@import 'flow.helpers';
+@import 'flow.variables';
+
+// Reply count (only visible in compact mode)
+.flow-reply-count {
+ display: none;
+ position: absolute;
+ top: 50%;
+ right: 1.5em;
+ margin-top: -.55em;
+ color: @colorGrayLighter;
+ font-size: 2em;
+ opacity: .5;
+
+ .flow-reply-count-number {
+ display: block;
+ position: absolute;
+ left: 0;
+ top: 0;
+ padding-left: .25em;
+ width: 100%;
+ color: @colorTextLight;
+ font-size: .5em;
+ line-height: 2.25;
+ font-weight: bold;
+ text-align: center;
+ }
+}
diff --git a/Flow/modules/styles/board/terms-of-use.less b/Flow/modules/styles/board/terms-of-use.less
new file mode 100644
index 00000000..7aa0ef6c
--- /dev/null
+++ b/Flow/modules/styles/board/terms-of-use.less
@@ -0,0 +1,22 @@
+@import 'mediawiki.mixins';
+@import 'flow.colors';
+@import 'flow.helpers';
+@import 'flow.variables';
+
+// @todo: Give more generic name
+// TOU should be tiny and grey
+.flow-terms-of-use {
+ display: block;
+ clear: both;
+ height: 3.6em;
+ vertical-align: middle;
+ color: @colorTextLight;
+ font-size: .75em;
+ line-height: 1.4;
+}
+
+@media all and (min-width: @wgFlowDeviceWidthTablet) {
+ .flow-terms-of-use {
+ clear: none;
+ }
+}
diff --git a/Flow/modules/styles/board/timestamps.less b/Flow/modules/styles/board/timestamps.less
new file mode 100644
index 00000000..3219df20
--- /dev/null
+++ b/Flow/modules/styles/board/timestamps.less
@@ -0,0 +1,59 @@
+@import 'mediawiki.mixins';
+@import 'flow.colors';
+@import 'flow.helpers';
+
+.flow-timestamp {
+ text-align: left;
+
+ span {
+ unicode-bidi: embed;
+ }
+
+ .flow-timestamp-user-formatted {
+ display: none;
+ }
+ .flow-timestamp-ago {
+ display: inline;
+ }
+
+ &:hover {
+ .flow-timestamp-user-formatted {
+ display: inline;
+ }
+ .flow-timestamp-ago {
+ display: none;
+ }
+ }
+}
+
+.flow-timestamp-ago,
+.flow-timestamp-user-formatted {
+ display: block;
+ position: relative;
+}
+
+.flow-timestamp-ago {
+ margin-top: -1em;
+}
+
+a.flow-timestamp-anchor {
+ &, &:visited {
+ color: inherit;
+ }
+}
+
+// Colors are from mediawiki.skinning/elements.css
+// Could use a.flow-timestamp-anchor:not(:hover) to set color to gray
+// only when *not* hovering (and avoid the copied colors), but we can't
+// use :not for now due to old IE.
+a.flow-timestamp-anchor:hover {
+ color: #0645ad;
+
+ &:visited {
+ color: #0b0080;
+ }
+
+ &:active {
+ color: #faa700;
+ }
+}
diff --git a/Flow/modules/styles/board/topic/meta.less b/Flow/modules/styles/board/topic/meta.less
new file mode 100644
index 00000000..d3cd82b4
--- /dev/null
+++ b/Flow/modules/styles/board/topic/meta.less
@@ -0,0 +1,9 @@
+@import 'mediawiki.mixins';
+@import 'flow.colors';
+@import 'flow.helpers';
+@import 'flow.variables';
+
+// Topic metadata
+.flow-topic-meta {
+ color: @colorTextLight;
+}
diff --git a/Flow/modules/styles/board/topic/post.less b/Flow/modules/styles/board/topic/post.less
new file mode 100644
index 00000000..5080b3b6
--- /dev/null
+++ b/Flow/modules/styles/board/topic/post.less
@@ -0,0 +1,190 @@
+@import 'mediawiki.mixins';
+@import 'flow.colors';
+@import 'flow.helpers';
+@import 'flow.variables';
+
+@highlightedIndent: 0.2em;
+
+// Helpers
+.minimalPostHighlight( @negativeMargin ) {
+ margin-left: 0 - @negativeMargin - @topicIndent;
+ padding-left: @topicIndent + @negativeMargin - @highlightedIndent;
+}
+
+// Comments
+form.flow-post {
+ margin-left: @topicIndent - (@textareaPadding * 2);
+}
+
+.flow-post {
+ position: relative;
+ margin: .5em 0 0 .75em;
+ padding: 0 .5em 0 0;
+ color: @colorText;
+ word-wrap: break-word;
+
+ // Nested comments (replies & reply forms)
+ .flow-replies {
+ margin-left: @topicIndent;
+ padding-left: 0.5em;
+ border-left: 1px dotted @colorGrayLighter;
+
+ // Remove tangent preview nesting (no IE6 support, but acceptable degradation)
+ &.flow-preview {
+ margin-left: 0;
+ padding-left: 0;
+ border-left-width: 0;
+
+ .flow-post-main {
+ padding-left: 0;
+ border-left-width: 0;
+ }
+ }
+ }
+
+ &.flow-post-max-depth .flow-replies {
+ margin-left: 0;
+ padding-left: 0;
+ border-left-width: 0;
+
+ .flow-post-max-depth {
+ margin-left: 0;
+ }
+ }
+
+ .flow-post-main {
+ margin-left: 0.1em;
+ }
+
+ // Highlights a post (no IE6 support, but acceptable degradation)
+ &.flow-post-highlighted {
+ > .flow-post-main {
+ @highlightedIndent: @topicIndent - 0.75em;
+ padding-left: @highlightedIndent;
+ border-left: solid @highlightedIndent @colorHighlight;
+ }
+ }
+
+ // Highlights all posts newer than a specific post
+ &.flow-post-highlight-newer {
+ .flow-post-content {
+ border-left: solid @highlightedIndent @colorHighlightNewer;
+ }
+ }
+
+ &.flow-post-highlight-newer {
+ .flow-post-content {
+ .minimalPostHighlight( 0.7em );
+ }
+ }
+
+ .flow-post {
+ &.flow-post-highlight-newer {
+ > .flow-post-main .flow-post-content {
+ .minimalPostHighlight( -0.1em );
+ }
+ }
+
+ .flow-post {
+ &.flow-post-highlight-newer {
+ > .flow-post-main .flow-post-content {
+ .minimalPostHighlight( -0.1em );
+ }
+ }
+ }
+ }
+
+ // Content of comments
+ .flow-post-content {
+ // protect from content breaking out of its box
+ word-break: break-word;
+ overflow: auto;
+ max-height: 2000px;
+ }
+
+ // Author link in post
+ .flow-author {
+ font-size: .875em;
+ line-height: 1.2;
+ display: inline-block;
+ color: @colorText;
+ word-wrap: break-word;
+
+ .mw-userlink {
+ font-weight: bold;
+ }
+
+ .mw-usertoollinks {
+ opacity: 0;
+ .transition( opacity .25s linear );
+ }
+ &:hover .mw-usertoollinks {
+ opacity: 1;
+ }
+ }
+}
+
+// Comment metadata
+.flow-post-meta {
+ // @todo needs overflow: hidden but crops button border at bottom
+ color: @colorGrayDark;
+ font-size: .875em;
+ text-align: right;
+}
+
+.flow-post-meta-actions {
+ float: left;
+ a {
+ &::after {
+ content: "\2022";
+ padding: 0 8px;
+ text-decoration: none;
+ display: inline-block;
+ color: @colorGrayDark;
+ }
+ &:last-child {
+ &::after {
+ content: "";
+ }
+ }
+ }
+}
+
+// MEDIA QUERIES
+@media all and (min-width: @wgFlowDeviceWidthTablet) {
+ .flow-post {
+ /* left margin provided by highlighting zone */
+ margin: 1em 0 0 @topicIndent;
+ padding: 0;
+
+ .flow-author {
+ line-height: inherit;
+ font-size: inherit;
+ }
+
+ &.flow-post-highlight-newer > .flow-post-main .flow-post-content {
+ .minimalPostHighlight( 1.3em );
+ }
+ .flow-post.flow-post-highlight-newer > .flow-post-main .flow-post-content {
+ .minimalPostHighlight( 0.7em );
+ }
+ .flow-post .flow-post.flow-post-highlight-newer > .flow-post-main .flow-post-content {
+ .minimalPostHighlight( 0.7em );
+ }
+ }
+}
+
+// What to do? vector changes this width on us from screen-hd.less with:
+//
+// @media screen and (min-width: 982px)
+// div#content {
+// margin-left: 11em;
+// padding: 1.25em 1.5em 1.5em 1.5em;
+// }
+//
+// The standard padding for narrower screens is 1em all around.
+@media all and (min-width: 982px) {
+ .flow-post.flow-post-highlight-newer > .flow-post-main .flow-post-content {
+ .minimalPostHighlight( 1.8em );
+ }
+}
diff --git a/Flow/modules/styles/board/topic/summary.less b/Flow/modules/styles/board/topic/summary.less
new file mode 100644
index 00000000..87ea6765
--- /dev/null
+++ b/Flow/modules/styles/board/topic/summary.less
@@ -0,0 +1,18 @@
+@import 'mediawiki.mixins';
+@import 'flow.colors';
+@import 'flow.helpers';
+@import 'flow.variables';
+
+.flow-topic-summary {
+ border-top: 1px dotted @colorGrayLight;
+ margin-top: .33em;
+
+ // Needs increased specificity to override `div#content p`
+ div#content & > p {
+ font-style: italic;
+ &:last-of-type {
+ // Remove margin-bottom from last p in summary, to remove excess bottom whitespace
+ margin-bottom: 0;
+ }
+ }
+}
diff --git a/Flow/modules/styles/board/topic/titlebar.less b/Flow/modules/styles/board/topic/titlebar.less
new file mode 100644
index 00000000..018a41c5
--- /dev/null
+++ b/Flow/modules/styles/board/topic/titlebar.less
@@ -0,0 +1,71 @@
+@import 'mediawiki.mixins';
+@import 'flow.colors';
+@import 'flow.helpers';
+@import 'flow.variables';
+
+// Show that the topic titlebar is clickable
+.flow-topic-titlebar {
+ position: relative;
+ padding: .5em .75em;
+ background: @colorGrayLightest;
+ border-radius: 3px;
+ outline: none;
+
+ // use child selector to block ie6
+ .flow-menu {
+ top: 1.5em;
+ }
+}
+
+// needs extra specificity to override `div#content h2` from vector
+div#content .flow-topic-title {
+ padding: 0;
+ border-bottom: none;
+ margin: 0 2.5em .15em 0;
+ font-family: sans-serif;
+ font-weight: bold;
+ word-break: break-word;
+ word-wrap: break-word;
+ overflow: visible;
+}
+
+.flow-moderated-topic-title {
+ margin-bottom: .33em;
+ color: @colorTextLight;
+ font-weight: bold;
+}
+
+// Override default Vector heading styles
+div#content h2.flow-topic-title {
+ font-size: 1.75em;
+}
+
+// Notification about subscribing to a topic
+.flow-notification-tooltip-topicsub {
+ width: 15em;
+}
+.flow-notification-tooltip-icon {
+ font-size: 2.5em;
+ text-align: center;
+}
+.flow-notification-tooltip-title {
+ font-size: 1em;
+ font-weight: bold;
+}
+
+.flow-undo {
+ float: right;
+}
+
+.flow-topic-title-activate-edit {
+ .flow-topic-title {
+ display: none;
+ }
+}
+
+// MEDIA QUERIES
+@media all and (min-width: @wgFlowDeviceWidthTablet) {
+ .flow-topic-titlebar {
+ padding: 1em (@topicIndent + 1) 1em @topicIndent;
+ }
+}
diff --git a/Flow/modules/styles/board/topic/watchlist.less b/Flow/modules/styles/board/topic/watchlist.less
new file mode 100644
index 00000000..95081b0c
--- /dev/null
+++ b/Flow/modules/styles/board/topic/watchlist.less
@@ -0,0 +1,69 @@
+@import 'mediawiki.mixins';
+@import 'flow.colors';
+@import 'flow.helpers';
+@import 'flow.variables';
+
+.flow-watch-link {
+ position: absolute;
+ top: 0;
+ right: 0;
+
+ &.flow-board-watch-link {
+ font-size: 1.5em;
+
+ // 1em for the size of the watch star,
+ // 0.25em for the margin-bottom on the h1
+ // 0.5em for the #contentSub
+ top: -1.75em;
+
+ // Override default right value, this div is getting a specific width
+ // (in line with other flow elements) and the icon will be floated to
+ // the right.
+ right: auto;
+ a {
+ float: right;
+ }
+ }
+
+ a {
+ display: inline-block;
+ *display: inline;
+ zoom: 1;
+ padding: .25em .5em;
+
+ &.mw-ui-quiet {
+ // Quiet mode shows the outline star
+ .wikiglyph-star {
+ display: none;
+ }
+ .wikiglyph-unstar {
+ display: inline-block;
+ *display: inline;
+ zoom: 1;
+ }
+ }
+
+ // Regular mode shows the full star
+ .wikiglyph-unstar {
+ display: none;
+ }
+ }
+}
+
+.flow-topic-watchlist {
+ a {
+ font-size: 1.8em;
+ }
+}
+
+@media all and (min-width: @wgFlowDeviceWidthTablet) {
+ .flow-topic-watchlist {
+ a {
+ font-size: inherit;
+ }
+ }
+}
+
+.content {
+ position: relative;
+}
diff --git a/Flow/modules/styles/common.less b/Flow/modules/styles/common.less
new file mode 100644
index 00000000..b7022afa
--- /dev/null
+++ b/Flow/modules/styles/common.less
@@ -0,0 +1,124 @@
+@import 'mediawiki.mixins';
+@import 'flow.colors';
+@import 'flow.helpers';
+@import 'flow.variables';
+
+.flow-component {
+
+ .flow-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ }
+
+ // Box-sizing: border-box is default in the Flow world
+ * {
+ .box-sizing(border-box);
+ }
+}
+
+// Keep a fixed spacing between each individual flow section
+.flow-newtopic-form,
+.flow-topics-bar {
+ padding-top: 1.5em;
+}
+
+// Top board header
+.flow-board-header,
+// board navigation
+.flow-board-navigation,
+// The sort navigation
+.flow-board-header-menu,
+// The whole board content wrapper
+.flow-board {
+ font-size: .875em;
+ width: 100%;
+ max-width: 850px;
+}
+
+// Individual topic containers
+.flow-topic {
+ padding: 1.6em 0 1.4em;
+}
+
+// Revision view
+.flow-revision-content {
+ background: none repeat scroll 0 0 #EDEDED;
+ color: #777777;
+ margin-top: 20px;
+ padding: 10px;
+}
+
+// "No more" link
+.flow-no-more,
+// "Undo" moderation link
+.flow-undo {
+ font-size: .875em;
+ color: #777777;
+}
+
+.flow-topic-meta {
+ font-size: .875em;
+}
+
+// Decorate as a pipelist, ex: (foo | bar | baz)
+.flow-pipelist {
+ span ~ span:before {
+ content: ' | ';
+ }
+}
+
+.flow-ui-clear {
+ clear: both;
+ line-height: 0;
+}
+
+@media all and (min-width: @wgFlowDeviceWidthTablet) {
+ .flow-topic-meta {
+ font-size: 1em;
+ }
+
+ // Top board header
+ .flow-board-header,
+ // The sort navigation
+ .flow-board-header-menu,
+ // board navigation
+ .flow-board-navigation,
+ // The whole board content wrapper
+ .flow-board {
+ // Set a fixed font-size from which everything else can use a relative amount
+ font-size: 1em;
+ line-height: 1.4;
+ }
+}
+
+.flow-history-moderation-menu {
+ display: inline;
+ margin: 0;
+ padding: 0;
+ list-style-type: none;
+
+ li {
+ display: inline;
+ }
+
+ > section {
+ display: inline;
+
+ &:not(:first-of-type) > li {
+ &:first-of-type {
+ border-top: 0px;
+
+ &:before {
+ content: '(';
+ }
+ }
+ & + li:before {
+ content: ' | ';
+ }
+ &:last-of-type:after {
+ content: ')';
+ }
+ }
+ }
+}
diff --git a/Flow/modules/styles/errors.less b/Flow/modules/styles/errors.less
new file mode 100644
index 00000000..7419e618
--- /dev/null
+++ b/Flow/modules/styles/errors.less
@@ -0,0 +1,11 @@
+// Error messages
+.flow-errors.errorbox {
+ display: block; // overwrites core .errorbox's display: inline-block
+ margin: 1em 0 0;
+
+ .mw-warning-with-logexcerpt {
+ border: none;
+ margin: 0;
+ padding: 0;
+ }
+}
diff --git a/Flow/modules/styles/flow.less/flow.colors.less b/Flow/modules/styles/flow.less/flow.colors.less
new file mode 100644
index 00000000..d324391b
--- /dev/null
+++ b/Flow/modules/styles/flow.less/flow.colors.less
@@ -0,0 +1,4 @@
+@import 'mediawiki.ui/variables';
+
+@colorHighlight: #00AF89;
+@colorHighlightNewer: #0645AD;
diff --git a/Flow/modules/styles/flow.less/flow.helpers.less b/Flow/modules/styles/flow.less/flow.helpers.less
new file mode 100644
index 00000000..dbd12629
--- /dev/null
+++ b/Flow/modules/styles/flow.less/flow.helpers.less
@@ -0,0 +1,16 @@
+// Cross-browser Helpers
+
+// @todo: Revisit this. See https://gerrit.wikimedia.org/r/72212
+.box-shadow( @value ) {
+ -webkit-box-shadow: @value; // Android 2.3+, iOS 4.0.2-4.2, Safari 3-4
+ box-shadow: @value; // Chrome 6+, Firefox 4+, IE 9+, iOS 5+, Opera 10.50+
+}
+// @todo: Consolidate with mobile and move this mixin to core. Both MobileFrontend and Flow are using it.
+.transition ( @value ) {
+ -webkit-backface-visibility: hidden; // fixes Chrome 1px movement bug
+ -webkit-transition: @value;
+ -moz-transition: @value;
+ -ms-transition: @value;
+ -o-transition: @value;
+ transition: @value;
+}
diff --git a/Flow/modules/styles/flow.less/flow.variables.less b/Flow/modules/styles/flow.less/flow.variables.less
new file mode 100644
index 00000000..55ffe267
--- /dev/null
+++ b/Flow/modules/styles/flow.less/flow.variables.less
@@ -0,0 +1,5 @@
+@topicIndent: 1.5em;
+@textareaPadding: .3em;
+
+// @todo: Use same variable as MobileFrontend
+@wgFlowDeviceWidthTablet: 768px;
diff --git a/Flow/modules/styles/history/history-line.less b/Flow/modules/styles/history/history-line.less
new file mode 100644
index 00000000..3f6bb8df
--- /dev/null
+++ b/Flow/modules/styles/history/history-line.less
@@ -0,0 +1,3 @@
+.flow-history-moderation-action {
+ text-transform: lowercase;
+}
diff --git a/Flow/modules/styles/js.less b/Flow/modules/styles/js.less
new file mode 100644
index 00000000..9da41676
--- /dev/null
+++ b/Flow/modules/styles/js.less
@@ -0,0 +1,104 @@
+@import 'mediawiki.mixins';
+@import 'flow.colors';
+@import 'flow.helpers';
+
+// @todo: Find better home for this css
+.client-js {
+ /*
+ Fallback elements
+
+ Fallback elements are invisible when JavaScript is enabled. They only exist when JavaScript does not run.
+
+ Markup:
+ <div class="flow-ui-fallback-element"></div>
+
+ Styleguide X.
+ */
+ .flow-ui-fallback-element {
+ visibility: hidden;
+ height: 0;
+ }
+
+ // A preview version of a given block
+ .flow-preview {
+ cursor: help;
+ margin-top: .5em;
+ color: @colorRegressive;
+ }
+
+ div#content div#bodyContent .flow-preview-target-hidden {
+ display: none;
+ }
+
+ // With JS, hide .flow-nojs & display .flow-js elements
+ .flow-nojs {
+ display: none;
+ }
+ .flow-js {
+ display: block;
+ }
+}
+
+.client-nojs {
+ // Without JS, hide .flow-js & display .flow-nojs elements
+ .flow-nojs {
+ display: block;
+ }
+ .flow-js {
+ display: none;
+ }
+}
+
+// When the load more wrapper is being processed, show the spinning loading icon
+.flow-load-more.flow-api-inprogress {
+ .flow-topics > &,
+ .flow-board-header-menu .flow-list & {
+ .flow-loading;
+ }
+}
+
+// Basic API interaction indicator
+div#content div#bodyContent .flow-api-inprogress {
+ opacity: 0.5;
+ cursor: wait;
+}
+
+// Spinning loading icon
+.flow-loading {
+ overflow: visible;
+
+ display: block;
+ width: 100%;
+ text-align: center;
+
+ &:before {
+ display: inline-block;
+ content: "\e018";
+ font-family: 'WikiFont-Glyphs';
+ font-size: 3em;
+ line-height: 1em;
+ -webkit-font-smoothing: antialiased;
+
+ -webkit-animation: spin infinite 2s linear;
+ -moz-animation: spin infinite 2s linear;
+ -ms-animation: spin infinite 2s linear;
+ -o-animation: spin infinite 2s linear;
+ animation: spin infinite 2s linear;
+ }
+}
+
+@-webkit-keyframes spin {
+ 0% { -webkit-transform: rotate(0deg); opacity: .5; }
+ 50% { opacity: .75; }
+ 100% { -webkit-transform: rotate(360deg); opacity: .5; }
+}
+@-moz-keyframes spin {
+ 0% { -moz-transform: rotate(0deg); opacity: .5; }
+ 50% { opacity: .75; }
+ 100% { -moz-transform: rotate(360deg); opacity: .5; }
+}
+@keyframes spin {
+ 0% { transform:rotate(0deg); opacity: .5; }
+ 50% { opacity: .75; }
+ 100% { transform:rotate(360deg); opacity: .5; }
+}
diff --git a/Flow/modules/styles/mediawiki.ui/forms.less b/Flow/modules/styles/mediawiki.ui/forms.less
new file mode 100644
index 00000000..cb371b8d
--- /dev/null
+++ b/Flow/modules/styles/mediawiki.ui/forms.less
@@ -0,0 +1,243 @@
+@import 'mediawiki.mixins';
+@import 'flow.colors';
+@import 'flow.helpers';
+@import 'flow.variables';
+
+// Form elements [Draft]
+//
+// Styleguide 4.
+
+.flow-ui-input-replacement-anchor {
+ display: block;
+ margin: 1em 0 0 .9em;
+
+ // FIXME: It's a shame we have to duplicate css in mw-ui-input. Need saner way going forward. mw-ui-textarea ?
+ &.mw-ui-input-large {
+ margin: 0;
+ font-size: 1.75em;
+ font-style: italic;
+ line-height: 1.25;
+ color: @colorTextLight;
+ }
+}
+
+.client-nojs {
+ .flow-ui-form {
+ // Hide destructive actions in no-JavaScript mode.
+ .flow-ui-destructive {
+ display: none;
+ }
+ }
+}
+
+// Make all text fields 100% wide by default
+.mw-ui-fieldtype-text,
+.mw-ui-fieldtag-textarea {
+ width: 100%;
+}
+
+// Wrapper element for stylized form elements
+.mw-ui-field {
+ position: relative;
+ display: inline-block;
+ white-space: nowrap;
+ min-height: 0;
+
+ .mw-ui-input {
+ margin: 0;
+ }
+}
+
+.mw-ui-field-icon {
+ display: none;
+}
+
+.mw-ui-uls-icon:before {
+ //.glyphicon-globe
+ content: "\e135";
+ opacity: 0.66;
+}
+
+
+/*
+== MediaWiki UI Text Field Validation ==
+
+=== Guidelines ===
+
+
+=== Notes ===
+Does not support IE7 nor IE8.
+ */
+/*.flow-ui-validated */.mw-ui-field:not(.ie8WillIgnoreThis) {
+ .mw-ui-field-icon {
+ white-space: nowrap;
+ position: absolute;
+ top: 0;
+ right: 0;
+ width: 2em;
+ height: 100%;
+ text-align: center;
+ pointer-events: none;
+ }
+
+ .mw-ui-field-icon:before {
+ display: inline-block;
+ position: absolute;
+ top: 50%;
+ left: 0;
+ margin-top: -.6em;
+ width: 2em;
+ color: @colorGrayLight;
+ font-size: 1em;
+ line-height: 1;
+ text-align: center;
+ pointer-events: none;
+ //.glyphicon
+ font-family: 'Glyphicons Halflings';
+ -webkit-font-smoothing: antialiased;
+ }
+
+ .mw-ui-validation-icon {
+ border-radius: 0 2px 2px 0;
+ border: 1px solid @colorGrayDark;
+ border-width: 1px 1px 1px 0;
+ }
+ .mw-ui-validation-icon:before {
+ color: #fff;
+ }
+
+ .mw-ui-input {
+ // Hide the ULS icon because these elements have HTML5 controls
+ &[type='date'], &[type='number'], &[type='search'], &[type='time'] {
+ ~ .mw-ui-uls-icon {
+ visibility: hidden;
+ }
+ }
+
+ &:valid {
+ &[required], &[min], &[max], &[pattern],
+ &[type='color'], &[type='date'], &[type='email'], &[type='number'],
+ &[type='url'], &[type='range'], &[type='time'] {
+ border-right-width: 2em;
+
+ ~ .mw-ui-validation-icon {
+ display: block;
+ }
+ }
+ ~ .mw-ui-validation-icon {
+ background: #00B08A;
+ background: rgba(0, 176, 138, .85);
+ }
+ ~ .mw-ui-validation-icon:before {
+ //.glyphicon-ok
+ content: "\e013";
+ }
+
+ // Support up to two icons side by side
+ + .mw-ui-validation-icon + .mw-ui-uls-icon {
+ right: 2em;
+ }
+ &[type='date'] {
+ + .mw-ui-validation-icon + .mw-ui-uls-icon {
+ right: 4em;
+ }
+ }
+ &[type='number'], &[type='time'] {
+ + .mw-ui-validation-icon + .mw-ui-uls-icon {
+ right: 3em;
+ }
+ }
+ }
+
+ &:invalid {
+ &[required], &[min], &[max], &[pattern],
+ &[type='color'], &[type='date'], &[type='email'], &[type='number'],
+ &[type='url'], &[type='range'], &[type='time'] {
+ border-right-width: 2em;
+
+ ~ .mw-ui-validation-icon {
+ display: block;
+ }
+ }
+ ~ .mw-ui-validation-icon {
+ background: #D31300;
+ background: rgba(211, 19, 0, .85);
+ }
+ ~ .mw-ui-validation-icon:before {
+ //.glyphicon-remove
+ content: "\e014";
+ }
+
+ // Support up to two icons side by side
+ + .mw-ui-validation-icon + .mw-ui-uls-icon {
+ right: 2em;
+ }
+ }
+
+ &:focus {
+ ~ .mw-ui-uls-icon {
+ display: block;
+ }
+ }
+ }
+}
+
+
+/*
+== MediaWiki UI Radio and Checkbox ==
+
+=== Guidelines ===
+
+
+=== Notes ===
+Does not support IE7 nor IE8.
+ */
+.mw-ui-fieldtag-input:not(.ie8WillIgnoreThis) {
+ cursor: pointer;
+
+ .mw-ui-radio:before,
+ .mw-ui-checkbox:before {
+ display: inline-block;
+ vertical-align: text-bottom;
+ font-family: 'Glyphicons Halflings';
+ -webkit-font-smoothing: antialiased;
+ color: @colorTextLight;
+ font-size: inherit;
+ line-height: inherit;
+ }
+
+ input[type='radio'],
+ input[type='checkbox'] {
+ display: none;
+ }
+
+ input[type='radio'] {
+ + .mw-ui-radio:before {
+ //.glyphicon-dashboard
+ content: "\e141";
+ }
+ &:checked + .mw-ui-radio:before {
+ //.glyphicon-record
+ text-shadow: none;
+ content: "\e165";
+ }
+ }
+ input[type='checkbox'] {
+ + .mw-ui-checkbox:before {
+ //.glyphicon-unchecked
+ content: "\e157";
+ }
+ &:checked + .mw-ui-checkbox:before {
+ //.glyphicon-check
+ content: "\e067";
+ }
+ }
+}
+
+input.mw-ui-input-large {
+ padding-left: .75em;
+}
+
+textarea.mw-ui-input-large {
+ padding-left: @topicIndent;
+}
diff --git a/Flow/modules/styles/mediawiki.ui/modal.less b/Flow/modules/styles/mediawiki.ui/modal.less
new file mode 100644
index 00000000..463010ca
--- /dev/null
+++ b/Flow/modules/styles/mediawiki.ui/modal.less
@@ -0,0 +1,84 @@
+@import 'mediawiki.mixins';
+@import 'flow.colors';
+@import 'flow.helpers';
+
+.flow-ui-modal {
+ .box-sizing(border-box);
+ // make content centered
+ display: block; // nonflex
+ display: flex; // flexbox
+ align-items: center; // flexbox
+ justify-content: center; // flexbox
+ text-align: center;
+ // affix
+ position: fixed;
+ z-index: 100;
+ top: 0;
+ left: 0;
+ // background styling
+ width: 100%;
+ height: 100%;
+ background: fade( @colorWhite, 75% );
+
+ // fix content centering for nonflex
+ &:before {
+ .box-sizing(border-box);
+ content: '';
+ display: inline-block;
+ height: 100%;
+ vertical-align: middle;
+ }
+}
+
+.flow-ui-modal-layout {
+ .box-sizing(border-box);
+ // center in viewport
+ vertical-align: middle;
+ display: inline-block; // nonflex center fix
+ min-width: 320px;
+ // scroll content if too big
+ overflow: auto;
+ max-width: 97%;
+ max-height: 97%;
+ // box styling
+ position: relative;
+ background: @colorWhite;
+ box-shadow: 0 4px 0 0 @colorGrayLighter, 0 0 0 1px @colorGrayLighter;
+ border-radius: 3px;
+ color: @colorText;
+ text-align: left;
+}
+
+.flow-ui-modal-heading {
+ .box-sizing(border-box);
+ margin: .3em .3em 0;
+ padding: 0 .3em;
+ font-weight: bold;
+ color: @colorText;
+ line-height: 2.2;
+ border-bottom: 1px solid @colorGrayLight;
+}
+
+.flow-ui-modal-heading-prev {
+ float: left;
+ display: inline-block;
+ padding: 0 .3em;
+ margin: 0 .6em 0 -.3em;
+ height: 100%;
+ border-right: 1px solid @colorGrayLight;
+ color: @colorTextLight;
+}
+.flow-ui-modal-heading-next {
+ float: right;
+ display: inline-block;
+ padding: 0 .3em;
+ margin: 0 -.3em 0 .6em;
+ height: 100%;
+ border-left: 1px solid @colorGrayLight;
+ color: @colorTextLight;
+}
+
+.flow-ui-modal-content {
+ overflow: hidden;
+ margin: 1.3em;
+}
diff --git a/Flow/modules/styles/mediawiki.ui/text.less b/Flow/modules/styles/mediawiki.ui/text.less
new file mode 100644
index 00000000..84a32281
--- /dev/null
+++ b/Flow/modules/styles/mediawiki.ui/text.less
@@ -0,0 +1,10 @@
+@import 'mediawiki.mixins';
+@import 'flow.colors';
+@import 'flow.helpers';
+
+.flow-ui-text-truncated {
+ white-space: nowrap;
+ overflow: hidden;
+ -webkit-text-overflow: ellipsis;
+ text-overflow: ellipsis;
+}
diff --git a/Flow/modules/styles/mediawiki.ui/tooltips.less b/Flow/modules/styles/mediawiki.ui/tooltips.less
new file mode 100644
index 00000000..fb85a3d8
--- /dev/null
+++ b/Flow/modules/styles/mediawiki.ui/tooltips.less
@@ -0,0 +1,212 @@
+@import 'mediawiki.mixins';
+@import 'flow.colors';
+@import 'flow.helpers';
+
+/*
+Tooltips
+
+<h3>Guidelines</h3>
+
+Requires the following markup at minimum: <span class="mw-ui-tooltip">CONTENT<span class="mw-ui-tooltip-triangle"></span></span>
+An additional class should be added relating the triangle to the direction of the content: mw-ui-tooltip-DIRECTION, where direction is one of up, down, left, or right.
+Adding to this could be an extra class: mw-ui-tooltip-inverted, which moves the tooltip and triangle to the opposing side. This used when the tooltip would go off the right of the viewport, and instead aligns to the right of the viewport.
+Finally, a context class can be given to assign it a color (eg. mw-ui-progressive).
+
+This is intended to be used with JavaScript, but does not have to be. With JS, you can directly bind the element to given X-Y coords for an element.
+
+Styleguide 4.0.
+ */
+.flow-ui-tooltip {
+ position: relative;
+ top: 1px;
+ display: inline-block;
+ padding: .5em;
+ background: @colorWhite;
+ *background: @colorOffWhite; // ie6
+ color: @colorText;
+ word-wrap: break-word;
+ border-radius: 3px;
+ .box-shadow( ~"0 2px 0 0 @{colorGrayLight}, 0 0 1px 0 @{colorGrayLight}" );
+ opacity: .9;
+
+ a {
+ // FIXME: Due to the lack of a fix for bug 66746 this link is treated as an external link.
+ // Yes Shahyar !important is bad
+ // but the alternative css hacks that would be needed here are even more horrible.
+ color: #fff !important;
+ font-weight: bold;
+ }
+
+ font-size: .875em; // not inherited from div#bodyContent, as we insert at body
+
+ #bodyContent & {
+ font-size: 1em;
+ }
+
+ .flow-ui-tooltip-triangle {
+ position: absolute;
+ overflow: hidden;
+ pointer-events: none;
+
+ // Fix offset-by-1px bug
+ z-index: 1;
+ -webkit-backface-visibility: hidden;
+ backface-visibility: hidden;
+
+ &:after {
+ content: "";
+ position: absolute;
+ z-index: 1;
+ width: 1em;
+ height: 1em;
+ background: @colorWhite;
+ *background: @colorOffWhite; //ie6
+ transform: rotate(45deg);
+ -webkit-transform: rotate(45deg);
+ }
+ }
+
+ // mw-ui-tooltip helpers to cleanly set triangle location
+ // The first four are because less.php doesn't support "@{var}: n" syntax
+ .flow-ui-tooltip-triangle-location-horizontal( top ) { top: -1em; }
+ .flow-ui-tooltip-triangle-location-horizontal( bottom ) { bottom: -1em; }
+ .flow-ui-tooltip-triangle-location-vertical( left ) { left: -1em; }
+ .flow-ui-tooltip-triangle-location-vertical( right ) { right: -1em; }
+ // up-down
+ .flow-ui-tooltip-triangle-location( horizontal, @location ) {
+ width: 2em;
+ height: 1em;
+ left: 50%;
+ .flow-ui-tooltip-triangle-location-horizontal( @location );
+ margin-left: -1em;
+ }
+ // left-right
+ .flow-ui-tooltip-triangle-location( vertical, @location ) {
+ width: 1em;
+ height: 2em;
+ .flow-ui-tooltip-triangle-location-vertical( @location );
+ top: 50%;
+ margin-top: -1em;
+ }
+
+ // triangle on top
+ &.flow-ui-tooltip-up {
+ margin-top: .75em;
+
+ .flow-ui-tooltip-triangle {
+ .flow-ui-tooltip-triangle-location( horizontal, top );
+
+ &:after {
+ top: .5em;
+ left: .5em;
+ .box-shadow( ~"0 0 1px 0 @{colorGrayLight}" );
+ }
+ }
+ }
+
+ // triangle on bottom
+ &.flow-ui-tooltip-down {
+ margin-bottom: .75em;
+
+ .flow-ui-tooltip-triangle {
+ .flow-ui-tooltip-triangle-location( horizontal, bottom );
+
+ &:after {
+ top: -.5em;
+ left: .5em;
+ .box-shadow( ~"0 -1.5px 0 1.5px @{colorGrayLight}, 0 0 1px 0 @{colorGrayLight}" );
+ }
+ }
+ }
+
+ // triangle at left
+ &.flow-ui-tooltip-left {
+ margin-left: .75em;
+
+ .flow-ui-tooltip-triangle {
+ .flow-ui-tooltip-triangle-location( vertical, left );
+
+ &:after {
+ margin-top: -1px;
+ top: .5em;
+ right: -.5em;
+ .box-shadow( ~"1.5px 0 0 1.5px @{colorGrayLight}, 0 0 1px 0 @{colorGrayLight}" );
+ }
+ }
+ }
+
+ // triangle at right
+ &.flow-ui-tooltip-right {
+ margin-left: -.75em;
+
+ .flow-ui-tooltip-triangle {
+ .flow-ui-tooltip-triangle-location( vertical, right );
+
+ &:after {
+ margin-top: -1px;
+ top: .5em;
+ left: -.5em;
+ .box-shadow( ~"0 1.5px 0 1.5px @{colorGrayLight}, 0 0 1px 0 @{colorGrayLight}" );
+ }
+ }
+ }
+
+ .flow-ui-tooltip-color( @backgroundColor ) {
+ @backgroundColorDarkened: darken( @backgroundColor, @colorDarkenPercentage );
+
+ background: @backgroundColor;
+ .box-shadow( ~"0 2px 0 0 @{backgroundColorDarkened}" );
+ color: @colorWhite;
+
+ .flow-ui-tooltip-triangle:after {
+ background: @backgroundColor;
+ }
+ &.flow-ui-tooltip-down .flow-ui-tooltip-triangle:after {
+ .box-shadow( ~"0 -2px 0 2px @{backgroundColorDarkened}" );
+ }
+ &.flow-ui-tooltip-left .flow-ui-tooltip-triangle:after {
+ .box-shadow( ~"2px 0 0 2px @{backgroundColorDarkened}" );
+ }
+ &.flow-ui-tooltip-right .flow-ui-tooltip-triangle:after {
+ .box-shadow( ~"0 2px 0 2px @{backgroundColorDarkened}" );
+ }
+ }
+
+ // Content for tooltips generated by JS
+ .flow-ui-tooltip-content {
+ display: block;
+ max-width: 360px;
+ }
+
+ // Don't apply these classes on IE6
+ &[class] {
+ &.mw-ui-progressive {
+ .flow-ui-tooltip-color( @colorProgressive );
+ }
+ &.mw-ui-constructive {
+ .flow-ui-tooltip-color( @colorConstructive );
+ }
+ &.mw-ui-destructive {
+ .flow-ui-tooltip-color( @colorDestructive );
+ }
+ &.flow-ui-tooltip-small {
+ font-size: .75em;
+
+ .flow-ui-tooltip-content {
+ max-width: 240px;
+ }
+ }
+ &.flow-ui-tooltip-large {
+ max-width: 100%;
+
+ .flow-ui-tooltip-content {
+ max-width: 100%;
+ }
+ }
+ }
+}
+
+// Block-level tooltip
+.flow-ui-tooltip-block {
+ width: 100%;
+}
diff --git a/Flow/modules/styles/minerva/common.less b/Flow/modules/styles/minerva/common.less
new file mode 100644
index 00000000..782cd0dc
--- /dev/null
+++ b/Flow/modules/styles/minerva/common.less
@@ -0,0 +1,4 @@
+// On mobile enable the talk page button when Flow is enabled
+.mw-mobile-mode.stable #ca-talk {
+ display: block;
+}
diff --git a/Flow/modules/vendor/Storer.js b/Flow/modules/vendor/Storer.js
new file mode 100644
index 00000000..2708b223
--- /dev/null
+++ b/Flow/modules/vendor/Storer.js
@@ -0,0 +1,1355 @@
+/*!
+ * Storer.js is a fallback-reliant, HTML5 Storage-based storage system.<br/>
+ * <br/>
+ * All of its storage subsystems implement getItem, setItem, removeItem, clear, key, and length, as the HTML5 Web
+ * Storage specification is written, with some enhancements on them, and slight deviations on memory/cookieStorage.<br/>
+ * <br/>
+ * It piggybacks on the real HTML5 storage when available, and creates the additional functionality of being able to
+ * prepend a 'prefix' to all key names automatically (see initStorer params). This is useful for projects where you
+ * would like to use Storage without worrying about name collisions.<br/>
+ * <br/>
+ * It _always_ returns every type of storage, and falls back to others, as listed below. In the worst-case scenario,
+ * all the storage subsystems are instances of memoryStorage, which means no persistance is available, but that no code
+ * will break while performing actions on the current page.<br/>
+ * <br/>
+ * The fallbacks are as follows:<br/>
+ * localStorage = localStorage || userData || cookieStorage || memoryStorage<br/>
+ * sessionStorage = sessionStorage || window.name || memoryStorage<br/>
+ * cookieStorage = cookieStorage || memoryStorage<br/>
+ * memoryStorage = memoryStorage<br/>
+ * <br/>
+ * cookieStorage also supports an additional 'global' Boolean argument on all of its methods, allowing you to escape
+ * out of the 'prefix' defined, so that you may use it to fetch general cookies as well.<br/>
+ * <br/>
+ * initStorer is called, takes a callback function, which will return the storage subsystems.<br/>
+ * This is necessary because the Internet Explorer fallback for localStorage is userData, which needs to be able to
+ * insert an element into the document before proceeding. On any modern or non-IE browser, the callback function is
+ * triggered synchronously and immediately.<br/>
+ * <br/>
+ * Note: for IE6-7 compatibility, initStorer requires a function called domReady, or uses jQuery(document).ready if available.<br/>
+ * <br/>
+ * Here is a cat. =^.^= His name is Frisbee.
+ * <br/>
+ *
+ * @copyright Viafoura, Inc. <viafoura.com>
+ * @author Shahyar G <github.com/shahyar>, originally for <github.com/viafoura>
+ * @license CC-BY 3.0 <creativecommons.org/licenses/by/3.0>: Keep @copyright, @author intact.
+ *
+ * @example
+ * initStorer(function (Storer) {
+ * cookieStorage = Storer.cookieStorage;
+ * memoryStorage = Storer.memoryStorage;
+ * sessionStorage = Storer.sessionStorage;
+ * localStorage = Storer.localStorage;
+ * }, { 'prefix': '_MyStorage_' });
+ */
+
+/**
+ * This will return an object with each of the storage types.
+ * The callback will fire when all of the necessary types have been created, although it's really only necessary
+ * for Internet Explorer's userData storage, which requires domReady to begin.
+ *
+ * @author Shahyar G <github.com/shahyar>, originally for Viafoura, Inc. <viafoura.com>
+ * @param {Function} [callback]
+ * @param {Object} [params]
+ * {String} [prefix=''] automatic key prefix for sessionStorage and localStorage
+ * {String} [default_domain=''] default domain for cookies
+ * {String} [default_path=''] default path for cookies
+ * {Boolean} [no_cookie_fallback=false] If true, do not use cookies as fallback for localStorage
+ * @return {Object} {cookieStorage, localStorage, memoryStorage, sessionStorage}
+ * @version 0.1.3
+ */
+function initStorer(callback, params) {
+ "use strict";
+
+ var _TESTID = '__SG__',
+ top = window,
+ PREFIX = (params = Object.prototype.toString.call(callback) === "[object Object]" ? callback : (params || {})).prefix || '',
+ NO_COOKIE_FALLBACK = params.no_cookie_fallback || false,
+ _callbackNow = true,
+ cookieStorage, localStorage, memoryStorage, sessionStorage;
+
+ if (params === callback) {
+ // Allow passing params without callback
+ callback = null;
+ }
+
+ // get top within cross-domain limit if we're in an iframe
+ try { while (top !== top.top) { top = top.top; } } catch (e) {}
+
+ /**
+ * Returns result.value if result has ._end key, or returns result entirely otherwise.
+ * Returns null when: result is null or undefined, or end && end > current timestamp.
+ * @param {String|Number|Date|null|undefined} end
+ * @param {*} result
+ * @param {Function} remove_callback
+ * @param {String} remove_callback_key
+ * @returns {*}
+ * @private
+ */
+ function _checkEnd(end, result, remove_callback, remove_callback_key) {
+ if (result === null || result === undefined || (end && parseInt(+new Date() / 1000, 10) > parseInt(end, 10))) {
+ // Remove this key from the data set
+ remove_callback(remove_callback_key);
+ // Return nothing
+ return null;
+ }
+ // Return the actual data
+ return result._end !== undefined ? result.value : result;
+ }
+
+ /**
+ * Parses str into JSON object, but also handles backwards compatibility with 0.0.4 when data was not automatically
+ * JSONified. If data._end exists, also runs _checkEnd. When not a valid JSON object, returns str back.
+ * @param {String|*} str
+ * @param {Function} [remove_callback]
+ * @param {String} [callback_key]
+ * @returns {*}
+ * @private
+ */
+ function _getJSON(str, remove_callback, callback_key) {
+ try {
+ var obj = str && JSON.parse(str);
+ if (obj) {
+ // Backwards compatibility for 0.0.4, when _end did not exist
+ if (obj._end !== undefined) {
+ // Check for expiry
+ return _checkEnd(obj._end, obj.value, remove_callback, callback_key);
+ }
+ return obj;
+ }
+ } catch (e) {}
+
+ // Non-JSON data (0.0.4)
+ return str;
+ }
+
+ /**
+ * Puts data and end (standardized to seconds) in an object, and returns it for use.
+ * If end is valid and end > now, data = null, and remove_callback is called,
+ * otherwise, set_callback is called.
+ * @param {Object|*} data
+ * @param {String|Number|Date} [end]
+ * @param {Function} [set_callback]
+ * @param {Function} [remove_callback]
+ * @param {String} [callback_key]
+ * @param {Boolean} [json]
+ * @returns {*}
+ * @private
+ */
+ function _storeEnd(data, end, set_callback, remove_callback, callback_key, json) {
+ var now = parseInt(+new Date() / 1000, 10);
+
+ switch (typeof end) {
+ case "number":
+ // Max-age, although we allow end=0 to mimic 0 for cookies
+ end = end && parseInt(now + end, 10);
+ break;
+ case "string":
+ // timestamp or Date string
+ end = end.length > 4 && "" + parseInt(end, 10) === end ? parseInt(end, 10) : parseInt(+new Date(end) / 1000, 10);
+ break;
+ case "object":
+ if (end.toGMTString) {
+ // Date object
+ end = parseInt(+end / 1000, 10);
+ }
+ break;
+ default:
+ end = null;
+ }
+
+ data = { value: end && now > end ? null : data, _end: end || null };
+
+ if (data.value === null || data.value === undefined) {
+ // Automatically expire this item
+ remove_callback && remove_callback(callback_key);
+ } else if (json) {
+ // Set the data with JSON
+ set_callback && set_callback(callback_key, JSON.stringify(data._end ? data : data.value));
+ } else {
+ // Set the data
+ set_callback && set_callback(callback_key, data._end ? data : data.value);
+ }
+
+ return data;
+ }
+
+ /**
+ * Clears expired data from each storage subsystem.
+ * @private
+ */
+ function _clearExpired() {
+ var i, j, key;
+ // Iterate over every storage subsystem
+ for (i in _returnable) {
+ // Ignore memoryStorage, as it doesn't have anything to expire
+ if (_returnable.hasOwnProperty(i) && i.charAt(0) !== '_' && _returnable[i].STORE_TYPE !== 'memoryStorage') {
+ j = 0;
+ // Iterate over every key in this subsystem
+ while ((key = _returnable[i].key(j++))) {
+ // getItem automatically handles removing expired items
+ _returnable[i].getItem(key);
+ }
+ }
+ }
+ }
+
+ /**
+ * A hack for Safari's inability to extend a class with Storage.
+ * @param {String} name
+ * @param {Storage} StoreRef
+ * @return {Object}
+ */
+ function _createReferencedStorage(name, StoreRef) {
+ var store = {
+ STORE_TYPE: 'ref' + name,
+ key: function (key) {
+ return StoreRef.key(key);
+ },
+ getItem: function (key) {
+ return StoreRef.getItem(key);
+ },
+ setItem: function (key, value, end) {
+ return StoreRef.setItem(key, value, end);
+ },
+ removeItem: function (key) {
+ return StoreRef.removeItem(key);
+ },
+ clear: function () {
+ return StoreRef.clear();
+ }
+ };
+ Object.defineProperty(store, "length", { get: function () { return StoreRef.length; } });
+ return store;
+ }
+
+ /**
+ * A hack for IE8's inability to extend a class with Storage. We use a DOM property getter to apply length.
+ * @param {String} name
+ * @param {Storage} StoreRef
+ * @return {Object}
+ */
+ function _createDOMStorage(name, StoreRef) {
+ var store = document.createElement('div');
+ store.STORE_TYPE = 'DOM' + name;
+ store.key = function (key) {
+ try {
+ return StoreRef.key(key);
+ } catch (e) { return null; } // IE8 throws an exception on nonexistent keys
+ };
+ store.getItem = function (key) {
+ return StoreRef.getItem(key);
+ };
+ store.setItem = function (key, value, end) {
+ return StoreRef.setItem(key, value, end);
+ };
+ store.removeItem = function (key) {
+ return StoreRef.removeItem(key);
+ };
+ store.clear = function () {
+ return StoreRef.clear();
+ };
+ Object.defineProperty(store, "length", { get: function () { return StoreRef.length; } });
+ return store;
+ }
+
+ /**
+ * Amends getItem and setItem to support expiry times for HTML5 Storage.
+ * @param {Object|Storage} StoreRef
+ * @return {Object}
+ * @private
+ */
+ function _adjustHTML5Storage(StoreRef) {
+ var _getItem = StoreRef.getItem,
+ _setItem = StoreRef.setItem,
+ _removeItem = StoreRef._removeItem || StoreRef.removeItem,
+ _removeItemCallback = function (key) {
+ _removeItem(key);
+ };
+
+ StoreRef.getItem = function (key) {
+ return _getJSON(_getItem(key), _removeItemCallback, key);
+ };
+ StoreRef.setItem = function (key, data, end) {
+ return _storeEnd(
+ data,
+ end,
+ function (key, value) {
+ _setItem(key, value);
+ },
+ _removeItemCallback,
+ key,
+ true
+ );
+ };
+
+ return StoreRef;
+ }
+
+ /**
+ *
+ * @param {Object|Storage} StoreRef
+ * @returns {*}
+ * @private
+ */
+ function _assignPrefix(StoreRef) {
+ // Use the rest of the object natively without a prefix
+ // memoryStorage doesn't need prefixes
+ if (!PREFIX || StoreRef.STORE_TYPE === 'memoryStorage') {
+ return StoreRef;
+ }
+
+ // Rewire functions to use a prefix and avoid collisions
+ // @todo Rewire length for prefixes as well
+ StoreRef._getItem = StoreRef.getItem;
+ StoreRef._setItem = StoreRef.setItem;
+ StoreRef._removeItem = StoreRef.removeItem;
+ StoreRef._key = StoreRef.key;
+
+ /** Variable # of items in Storage.
+ * @const int length
+ * @memberof sessionStorage
+ * @memberof localStorage */
+
+ /**
+ * Returns an item from the current type of Storage.
+ * @param {String} key
+ * @returns {*}
+ * @memberof sessionStorage
+ * @memberof localStorage
+ */
+ StoreRef.getItem = function (key) {
+ return StoreRef._getItem(PREFIX + key);
+ };
+
+ /**
+ * Sets an item in the current type of Storage.
+ * end is expiry: Number = seconds from now, String = date string for Date(), or Date object.
+ * @param {String} key
+ * @param {*} data
+ * @param {int|String|Date} [end]
+ * @memberof sessionStorage
+ * @memberof localStorage
+ */
+ StoreRef.setItem = function (key, data, end) {
+ return StoreRef._setItem(PREFIX + key, data, end);
+ };
+
+ /**
+ * Removes key from the current Storage instance, if it has been set.
+ * @param {String} key
+ * @memberof sessionStorage
+ * @memberof localStorage
+ */
+ StoreRef.removeItem = function (key) {
+ return StoreRef._removeItem(PREFIX + key);
+ };
+
+ StoreRef._key = StoreRef.key;
+ /**
+ * Gets the key (if any) at index, from the current Storage instance.
+ * @param {int} index
+ * @returns {String|null}
+ * @memberof sessionStorage
+ * @memberof localStorage
+ */
+ StoreRef.key = function (index) {
+ if ((index = StoreRef._key(index)) !== undefined && index !== null) {
+ // Chop off the index
+ return index.indexOf(PREFIX) === 0 ? index.substr(PREFIX.length) : index;
+ }
+ return null;
+ };
+
+ if (StoreRef.STORE_TYPE !== 'cookieStorage') {
+ // cookieStorage has its own clear which supports prefixes
+ /**
+ * Removes all the current keys from this Storage instance.
+ * @memberof sessionStorage
+ * @memberof localStorage
+ */
+ StoreRef.clear = function () {
+ for (var i = StoreRef.length, key; i--;) {
+ if ((key = StoreRef._key(i)).indexOf(PREFIX) === 0) {
+ StoreRef._removeItem(key);
+ }
+ }
+ };
+ } else {
+ // cookieStorage is the only one which implements hasItem
+ if (StoreRef.hasItem) {
+ StoreRef._hasItem = StoreRef.hasItem;
+ StoreRef.hasItem = function (key) {
+ return StoreRef._hasItem(PREFIX + key);
+ };
+ }
+ }
+
+ return StoreRef;
+ }
+
+ /**
+ * Returns memoryStorage on failure
+ * @param {String} [cookie_prefix] An additional prefix, useful for isolating fallbacks for local/sessionStorage.
+ * @return {cookieStorage|memoryStorage}
+ */
+ function _createCookieStorage(cookie_prefix) {
+ cookie_prefix = (cookie_prefix || '');
+ var _cookiergx = new RegExp("(?:^|;)\\s*" + cookie_prefix + PREFIX + "[^=;]+\\s*(?:=[^;]*)?", "g"),
+ _nameclean = new RegExp("^;?\\s*" + cookie_prefix + PREFIX),
+ _cookiergxGlobal = new RegExp("(?:^|;)\\s*[^=;]+\\s*(?:=[^;]*)?", "g"),
+ _namecleanGlobal = new RegExp("^;?\\s*"),
+ _expire = (new Date(1979)).toGMTString(),
+ /**
+ * @namespace cookieStorage
+ * @memberof Storer
+ * @public
+ * @global
+ */
+ _cookieStorage = {
+ /** @const String STORE_TYPE
+ * @default "cookieStorage"
+ * @memberof cookieStorage */
+ STORE_TYPE: 'cookieStorage',
+ /** Default domain to use in cookieStorage.setItem (set by initStorer)
+ * @const String DEFAULT_DOMAIN
+ * @memberof cookieStorage */
+ DEFAULT_DOMAIN: escape(params.default_domain || ''),
+ /** Default path to use in cookieStorage.setItem (set by initStorer)
+ * @const String DEFAULT_PATH
+ * @memberof cookieStorage */
+ DEFAULT_PATH: escape(params.default_path || ''),
+
+ /** Variable # of items in storage
+ * @const int length
+ * @memberof cookieStorage */
+ length: 0,
+
+ /**
+ * Returns the cookie key at idx.
+ * @param {int} idx
+ * @param {Boolean} [global=false] Omits prefix.
+ * @return {*}
+ * @memberof cookieStorage
+ */
+ key: function (idx, global) {
+ var cookies = _cookieStorage.getAll(false, global);
+ return cookies[idx] ? cookies[idx].key : undefined;
+ },
+
+ /**
+ * Clears all cookies for this prefix.
+ * @param {Boolean} [global=false] true omits the prefix, and erases all cookies
+ * @memberof cookieStorage
+ */
+ clear: function (global) {
+ var cookies = _cookieStorage.getAll(false, global),
+ i = cookies.length;
+
+ while (i--) {
+ // Don't use static _removeItemFn reference, because cookieStorage.clear is not handled by _assignPrefix
+ _cookieStorage.removeItem(cookies[i].key);
+ }
+ },
+
+ /**
+ * Returns an Array of Objects of key-value pairs, or an Object with properties-values plus length (as_object).
+ * @param {Boolean} [as_object=false] true returns a single object of key-value pairs
+ * @param {Boolean} [global=false] true gets all cookies, omitting the default prefix
+ * @return {Object[]|Object}
+ * @memberof cookieStorage
+ */
+ getAll: function (as_object, global) {
+ var cleaner = global ? _namecleanGlobal : _nameclean,
+ matches = document.cookie.match(global ? _cookiergxGlobal : _cookiergx) || [],
+ i = matches.length, _cache;
+
+ if (as_object === true) { // object of properties/values
+ for (_cache = {length: i}; i--;) {
+ _cache[unescape((matches[i] = matches[i].split('='))[0].replace(cleaner, ''))] = matches[i][1];
+ }
+ } else { // array of key/value objects
+ for (_cache = []; i--;) {
+ _cache.push({ key: unescape((matches[i] = matches[i].split('='))[0].replace(cleaner, '')), value: matches[i][1] });
+ }
+ }
+
+ return _cache;
+ },
+
+ /**
+ * Get a cookie by name.
+ * @param {String} key
+ * @param {Boolean} [global=false] true omits the prefix, and searches for a match "globally"
+ * @return {String}
+ * @memberof cookieStorage
+ */
+ getItem: function (key, global) {
+ if (!key || !_hasItemFn(key, global)) {
+ return null;
+ }
+
+ return ((global = document.cookie.match(new RegExp('(?:^|;) *' + escape((global ? '' : cookie_prefix) + key) + '=([^;]*)(?:;|$)'))), global && global[0] ? unescape(global[1]) : null);
+ },
+
+ /**
+ * cookieStorage.setItem(key, value, end, path, domain, is_secure);
+ * @param {String} key name of the cookie
+ * @param {String} value value of the cookie;
+ * @param {Number|String|Date} [end] max-age in seconds (e.g., 31536e3 for a year) or the
+ * expires date in GMTString format or in Date Object format; if not specified it will expire at the end of session;
+ * @param {String} [path] e.g., "/", "/mydir"; if not specified, defaults to the current path of the current document location;
+ * @param {String} [domain] e.g., "example.com", ".example.com" (includes all subdomains) or "subdomain.example.com"; if not
+ * specified, defaults to the host portion of the current document location;
+ * @param {Boolean} [is_secure=false] cookie will be transmitted only over secure protocol as https;
+ * @param {Boolean} [global=false] true omits prefix, defines the cookie "globally"
+ * @return {Boolean}
+ * @memberof cookieStorage
+ **/
+ setItem: function (key, value, end, path, domain, is_secure, global) {
+ if (!key || key === 'expires' || key === 'max-age' || key === 'path' || key === 'domain' || key === 'secure') {
+ return false;
+ }
+
+ var sExpires = "",
+ store_end = _storeEnd(value, end);
+ if (store_end._end !== null) {
+ sExpires = "; expires=" + (new Date(store_end._end * 1000)).toGMTString();
+ }
+
+ if (store_end.value !== null && value !== undefined && value !== null) {
+ domain = (domain = typeof domain === 'string' ? escape(domain) : _cookieStorage.DEFAULT_DOMAIN) ? '; domain=' + domain : '';
+ path = (path = typeof path === 'string' ? escape(path) : _cookieStorage.DEFAULT_PATH) ? '; path=' + path : '';
+ document.cookie = escape((global ? '' : cookie_prefix) + key) + '=' + escape(value) + sExpires + domain + path + (is_secure ? '; secure' : '');
+
+ _updateLength();
+ return true;
+ }
+
+ return _removeItemFn(key, domain, path, is_secure, global);
+ },
+
+ /**
+ * Get a cookie by name
+ * @param {String} key
+ * @param {String} [path]
+ * @param {String} [domain]
+ * @param {Boolean} [is_secure]
+ * @param {Boolean} [global=false] Omits prefix.
+ * @memberof cookieStorage
+ */
+ removeItem: function (key, domain, path, is_secure, global) {
+ if (!key || !_hasItemFn(key, global)) {
+ return;
+ }
+
+ domain = (domain = typeof domain === 'string' ? escape(domain) : _cookieStorage.DEFAULT_DOMAIN) ? '; domain=' + domain : '';
+ path = (path = typeof path === 'string' ? escape(path) : _cookieStorage.DEFAULT_PATH) ? '; path=' + path : '';
+ document.cookie = escape((global ? '' : cookie_prefix) + key) + '=; expires=' + _expire + domain + path + (is_secure ? '; secure' : '');
+
+ _updateLength();
+ },
+
+ /**
+ * Returns true if a cookie with that name was found, false otherwise
+ * @param {String} key
+ * @param {Boolean} [global=false] Omits prefix.
+ * @param {Boolean}
+ * @memberof cookieStorage
+ */
+ hasItem: function (key, global) {
+ return (new RegExp('(?:^|;) *' + escape((global ? '' : cookie_prefix) + key) + '=')).test(document.cookie);
+ }
+ },
+ // Keep backups of these functions, as they may be overriden by _assignPrefix
+ _removeItemFn = _cookieStorage.removeItem,
+ _hasItemFn = _cookieStorage.hasItem;
+
+ /**
+ * Updates cookieStorage.length on update
+ * @private
+ */
+ function _updateLength() {
+ _cookieStorage.length = _cookieStorage.getAll().length;
+ }
+
+ _cookieStorage.setItem(_TESTID, 4);
+ if (_cookieStorage.getItem(_TESTID) == 4) {
+ _cookieStorage.removeItem(_TESTID);
+ return _assignPrefix(_cookieStorage);
+ }
+ return _createMemoryStorage();
+ }
+
+ /**
+ * Returns a memoryStorage object. This is a constructor to be reused as a fallback on sessionStorage & localStorage
+ * @return {memoryStorage}
+ */
+ function _createMemoryStorage() {
+ var _data = {}, // key : data
+ _keys = [], // _keys key : _ikey key
+ _ikey = {}; // _ikey key : _keys key
+ /**
+ * @namespace memoryStorage
+ */
+ var _memoryStorage = {
+ /** @const String STORE_TYPE
+ * @default "memoryStorage"
+ * @memberof memoryStorage */
+ STORE_TYPE: 'memoryStorage',
+
+ /** Variable # of items in storage
+ * @const int length
+ * @memberof memoryStorage */
+ length: 0,
+
+ /**
+ * Get key name by id
+ * @param {int} i
+ * @return {String|null}
+ * @memberof memoryStorage
+ */
+ key: function (i) {
+ return _keys[i];
+ },
+
+ /**
+ * Get an item
+ * @param {String} key
+ * @return {*}
+ * @memberof memoryStorage
+ */
+ getItem: function (key) {
+ return _checkEnd(_data[key] && _data[key]._end, _data[key], _memoryStorage.removeItem, key);
+ },
+
+ /**
+ * Set an item
+ * @param {String} key
+ * @param {String} data
+ * @param {String|Number|Date} [end]
+ * @memberof memoryStorage
+ */
+ setItem: function (key, data, end) {
+ if (data !== null && data !== undefined) {
+ _ikey[key] === undefined && (_ikey[key] = (_memoryStorage.length = _keys.push(key)) - 1);
+ return (_data[key] = _storeEnd(data, end)).value;
+ }
+ return _memoryStorage.removeItem(key);
+ },
+
+ /**
+ * Removes an item
+ * @param {String} key
+ * @return {Boolean}
+ * @memberof memoryStorage
+ */
+ removeItem: function (key) {
+ var was = _data[key] !== undefined;
+ if (_ikey[key] !== undefined) {
+ // re-reference all the keys because we've removed an item in between
+ for (var i = _keys.length; --i > _ikey[key];) {
+ _ikey[_keys[i]]--;
+ }
+ _keys.splice(_ikey[key], 1);
+ delete _ikey[key];
+ }
+ delete _data[key];
+ _memoryStorage.length = _keys.length;
+ return was;
+ },
+
+ /**
+ * Clears memoryStorage
+ * @memberof memoryStorage
+ */
+ clear: function () {
+ for (var i in _data) {
+ if (_data.hasOwnProperty(i)) {
+ delete _data[i];
+ }
+ }
+ _memoryStorage.length = _keys.length = 0;
+ _ikey = {};
+ }
+ };
+ return _memoryStorage;
+ }
+
+ /**
+ * Returns a nameStorage object. This constructor is designed to be a fallback for sessionStorage in IE7 and under.
+ * It uses window.name and RC4 encryption on a per-domain basis. Inspired by LSS by Andrea Giammarchi.
+ * @param {DOMWindow} [win=top]
+ * @return {nameStorage}
+ */
+ function _createNameStorage(win) {
+ if (!win) {
+ win = top;
+ }
+
+ /** RC4 Stream Cipher
+ * http://www.wisdom.weizmann.ac.il/~itsik/RC4/rc4.html
+ * -----------------------------------------------
+ * @description A quick stream cipher to encode & decode any string, using a random key of up to 256 bytes.
+ *
+ * @author Ported to JavaScript by Andrea Giammarchi
+ * @license MIT-style license
+ * @blog http://webreflection.blogspot.com/
+ * @version 1.2.1
+ */
+ var RC4 = (function (String, fromCharCode, random) {
+ return {
+ /** RC4.decode(key:String, data:String):String
+ * @description given a data string encoded with the same key
+ * generates original data string.
+ * @param {String} key key precedently used to encode data
+ * @param {String} data data encoded using same key
+ * @return {String} decoded data
+ * @private
+ */
+ decode: function (key, data) {
+ return this.encode(key, data);
+ },
+
+ /** RC4.encode(key:String, data:String):String
+ * @description encode a data string using provided key
+ * @param {String} key key to use for this encoding
+ * @param {String} data data to encode
+ * @return {String} encoded data. Will require same key to be decoded
+ * @private
+ */
+ encode: function (key, data) {
+ for (var length = key.length, len = data.length, decode = [], a = [],
+ i = 0, j = 0, k = 0, l = 0, $;
+ i < 256;
+ i++
+ ) {
+ a[i] = i;
+ }
+ for (i = 0; i < 256; i++) {
+ j = (j + ($ = a[i]) + key.charCodeAt(i % length)) % 256;
+ a[i] = a[j];
+ a[j] = $;
+ }
+ for (j = 0; k < len; k++) {
+ i = k % 256;
+ j = (j + ($ = a[i])) % 256;
+ length = a[i] = a[j];
+ a[j] = $;
+ decode[l++] = data.charCodeAt(k) ^ a[(length + $) % 256];
+ }
+ return fromCharCode.apply(String, decode);
+ },
+
+ /** RC4.key(length:Number):String
+ * @description generate a random key with arbitrary length
+ * @param {Number} length The length of the generated key
+ * @return {String} a randomly generated key
+ * @private
+ */
+ key: function (length) {
+ for (var i = 0, key = []; i < length; i++) {
+ key[i] = 1 + ((random() * 255) << 0);
+ }
+ return fromCharCode.apply(String, key);
+ }
+ };
+ // I like to freeze stuff in interpretation time
+ // it makes things a bit safer when obtrusive libraries
+ // are around
+ }(String, String.fromCharCode, Math.random)),
+ // Opera will store on every set, because it has no onbeforeunload
+ is_opera = Object.prototype.toString.call(window.opera) === "[object Opera]",
+ // Key used for this domain
+ KEY;
+ // Try to fetch an old key
+ try {
+ KEY = decodeURI(cookieStorage.getItem('.sessionStorageKey'));
+ } catch (e) {}
+ // Generate an encryption key if we don't have a valid one.
+ if (!KEY || KEY.length !== STRENGTH) {
+ KEY = RC4.key(STRENGTH);
+ cookieStorage.setItem('.sessionStorageKey', encodeURI(KEY));
+ }
+
+ // Domain used for prefixing keys
+ var DOMAIN = win.document.domain,
+ // Encrypted domain
+ EDOMAIN = RC4.encode(KEY, DOMAIN),
+ // Start of Header
+ SOH = '#' + String.fromCharCode(1) + 'STOR/' + EDOMAIN,
+ // End of Transmission
+ EOT = EDOMAIN + '/STOR' + String.fromCharCode(4) + '#',
+ // Start of Text
+ STX = String.fromCharCode(2) + ';',
+ // End of Transmission Block
+ ETB = String.fromCharCode(23) + ';',
+ // End of Text
+ ETX = ';' + String.fromCharCode(3),
+ // Key strength in bytes (32 = 256-bit)
+ STRENGTH = 32,
+ // Lengths
+ SOHl = SOH.length,
+ EOTl = EOT.length,
+ STXl = STX.length,
+ ETBl = ETB.length,
+ ETXl = ETX.length,
+ // Data storage by key name
+ _dataObject = {},
+ // Key storage by index
+ _dataArray = [],
+
+ /**
+ * Cannot be accessed directly, and in fact appears as Storer.sessionStorage when in use.
+ * You can, however, know that it is in use when sessionStorage.STORE_TYPE === 'name'.
+ * @namespace nameStorage
+ */
+ _nameStorage = {
+ /** @const String STORE_TYPE
+ * @default "name"
+ * @memberof nameStorage */
+ STORE_TYPE: 'name',
+
+ /** Number of items in storage */
+ length: 0,
+
+ /**
+ * Get an item key by its index
+ * @param {int} index
+ * @return {String|null} key
+ */
+ key: function (index) {
+ return _dataArray[index];
+ },
+
+ /**
+ * Get an item by its key
+ * @param {String} key
+ * @return {String|null} data
+ */
+ getItem: function (key) {
+ return _checkEnd(_dataObject[key] && _dataObject[key]._end, _dataObject[key], _removeItemFn, key);
+ },
+
+ /**
+ * Set an item by key
+ * @param {String} key
+ * @param {String} data
+ * @param {String|Number|Date} [end]
+ */
+ setItem: function (key, data, end) {
+ var store_end = _storeEnd(data, end)._end;
+
+ if (store_end.value === null) {
+ return _removeItemFn(key);
+ }
+
+ if (_dataObject[key]) {
+ // Update an existing key's value
+ _dataObject[key].value = data;
+ _dataObject[key]._end = store_end._end;
+ } else {
+ // Store this item by its key
+ _dataObject[key] = {
+ value: data,
+ // For new items, increment the length property
+ index: (_nameStorage.length = _dataArray.push(key)) - 1,
+ _end: store_end._end
+ };
+ }
+
+ if (!store_end._end) {
+ // Save some space
+ delete _dataObject[key]._end;
+ }
+
+ is_opera && _write();
+
+ return data;
+ },
+
+ /**
+ * Remove an item by key
+ * @param {String} key
+ */
+ removeItem: function (key) {
+ if (_dataObject[key]) {
+ // Validity check on _dataArray just in case, to prevent corruption
+ if (_dataArray[_dataObject[key].index] === key) {
+ // Remove the stored index
+ _dataArray.splice(_dataObject[key].index, 1);
+
+ // Update length property
+ _nameStorage.length = _dataArray.length;
+
+ // Update all other indices to point to their new locations
+ for (var i = _dataObject[key].index, len = _dataArray.length; i < len; i++) {
+ _dataObject[_dataArray[i]].index--;
+ }
+ }
+
+ // Delete the stored data
+ delete _dataObject[key];
+
+ is_opera && _write();
+
+ return true;
+ }
+
+ return false;
+ },
+
+ /**
+ * Completely empies storage
+ */
+ clear: function () {
+ _dataArray.length = 0;
+ _dataObject = {};
+ is_opera && _write();
+ }
+ },
+ // Keep backups of these functions, as they may be overriden by _assignPrefix
+ _removeItemFn = _nameStorage.removeItem;
+
+ // Format: FULLCONTENT: [SOH]CONTENTPIECECONTENTPIECE...[EOT]
+ // Format: CONTENTPIECE: [STX]keylength[:]contentlength[ETB]key[ETB]content[ETX]
+ /*
+ win.name = SOH
+ + STX + 5 + ':' + 5 + ETB + 'hello' + ETB + 'world' + ETX
+ + STX + 2 + ':' + 10 + ETB + 'my' + ETB + 'abcd?fhi~k' + ETX
+ + EOT;
+ */
+
+ /**
+ * Writes _dataObject's keys and values to window.name.
+ */
+ function _write() {
+ var str = win.name,
+ start = str.indexOf(SOH),
+ end = str.indexOf(EOT),
+ i = _dataArray.length;
+
+ // Remove any previous storage on window.name
+ if (start > -1 && end > start) {
+ win.name = str.slice(0, start) + str.slice(end + EOTl);
+ }
+
+ for (str = ''; i--;) {
+ if (_dataObject[_dataArray[i]] && _dataObject[_dataArray[i]].value) {
+ // STX --------KEY LENGTH--------- : ----------------CONTENT LENGTH---------------- ETB -----KEY----- ETB -------------CONTENT------------ ETX
+ str += STX + ('' + _dataArray[i]).length + ':' + ('' + _dataObject[_dataArray[i]].value).length + ETB + _dataArray[i] + ETB + _dataObject[_dataArray[i]].value + ETX;
+ }
+ }
+
+ // Encrypt the contents and write it to window.name
+ win.name += SOH + encodeURI(RC4.encode(KEY, str)) + EOT;
+ }
+
+ /**
+ * This function processes window.name, tries to find matching keys, and stores them.
+ */
+ function _initialize() {
+ var str = win.name,
+ start = str.indexOf(SOH),
+ end = str.indexOf(EOT),
+ last_index = 0,
+ item_key = '',
+ item_klen = 0,
+ item_clen = 0;
+
+ if (start > -1 && end > start) {
+ // Remove it from the window to append it later. This helps with invalid data.
+ // eg. ABC;def;HIJ -> ABCHIJ
+ win.name = str.slice(0, start) + str.slice(end + EOTl);
+
+ // Use the rest of the string for storage parsing
+ str = RC4.decode(KEY, decodeURI(str.slice(start + SOHl, end)));
+
+ // Find the start of an item
+ while ((start = str.indexOf(STX, last_index)) !== -1) {
+ last_index = start + STXl; // move index to start of item, past STX
+
+ // Find out how long this item is, and its key name
+ if ((end = str.indexOf(ETB, last_index)) !== -1) {
+ // [1] content length, [0] key length
+ item_klen = str.slice(last_index, end).split(':');
+ item_clen = parseInt(item_klen[1], 10);
+ item_klen = parseInt(item_klen[0], 10);
+
+ last_index = end + ETBl; // move index to start of item key, past length-ETB
+
+ // Validate: Make sure ETB is immediately after the key
+ if ((end = str.indexOf(ETB, last_index)) === last_index + item_klen) {
+ // Parse out this item's key
+ item_key = str.substr(last_index, item_klen);
+
+ last_index = end + ETBl; // move index to start of item content, past key-ETB
+
+ // Validate: Make sure ETX is immediately after the content
+ if ((end = str.indexOf(ETX, last_index)) === last_index + item_clen) {
+ // Store this item
+ _nameStorage.setItem(item_key, str.substr(last_index, item_clen));
+ }
+ }
+ }
+ }
+ }
+
+ // _write data onbeforeunload
+ if (win.addEventListener) {
+ win.addEventListener('beforeunload', _write, true);
+ } else if (win.attachEvent) {
+ win.attachEvent('onbeforeunload', _write);
+ }
+ }
+
+ try {
+ _initialize();
+ } catch (e) {
+ }
+
+ return _nameStorage;
+ }
+ if (callback) {
+ // Create a callback wrapper to empty expired data preemptively
+ callback = (function (callback) {
+ return function () {
+ callback(_returnable);
+ setTimeout(_clearExpired, 100); // delay expiration
+ };
+ }(callback));
+ }
+
+ // Return this stuff
+ var _returnable = {
+ 'cookieStorage': null,
+ 'localStorage': null,
+ 'memoryStorage': null,
+ 'sessionStorage': null,
+ '_createCookieStorage': _createCookieStorage,
+ '_createMemoryStorage': _createMemoryStorage
+ };
+
+ /**
+ * @instanceof cookieStorage
+ */
+ _returnable.cookieStorage = cookieStorage = _createCookieStorage();
+
+ /**
+ * @instanceof memoryStorage
+ */
+ _returnable.memoryStorage = memoryStorage = _createMemoryStorage();
+
+ /**
+ * @namespace sessionStorage
+ * @mixes localStorage
+ */
+ _returnable.sessionStorage = sessionStorage = (function () {
+ // Grab sessionStorage from top window
+ var _sessionStorage = top.sessionStorage;
+
+ // Try to use original sessionStorage
+ if (_sessionStorage) {
+ try {
+ // Test to make sure it works and isn't full
+ _sessionStorage.setItem(_TESTID, 1);
+ _sessionStorage.removeItem(_TESTID);
+
+ // Now clone sessionStorage so that we may extend it with our own methods
+ var _tmp = function () {
+ };
+ _tmp.prototype = _sessionStorage;
+ // jshint -W055
+ _tmp = new _tmp();
+ try {
+ if (_tmp.getItem) {
+ _tmp.setItem(_TESTID, 2);
+ _tmp.removeItem(_TESTID);
+ }
+ } catch (e) {
+ // Firefox 14+ throws a security exception when wrapping a native class
+ _tmp = null;
+ }
+
+ if (_tmp && !_tmp.getItem) {
+ // Internet Explorer 8 does not inherit the prototype here. We can hack around it using a DOM object
+ _sessionStorage = _adjustHTML5Storage(_createDOMStorage('sessionStorage', _sessionStorage));
+ } else if (!_tmp || Object.prototype.toString.apply(Storage.prototype) === '[object StoragePrototype]') {
+ // Safari throws a type error when extending with Storage
+ _sessionStorage = _adjustHTML5Storage(_createReferencedStorage('sessionStorage', _sessionStorage));
+ } else {
+ _sessionStorage = _adjustHTML5Storage(_tmp);
+ }
+ } catch (e) {
+ _sessionStorage = null;
+ }
+ }
+
+ // Build one
+ if (!_sessionStorage) {
+ try {
+ // instantiate nameStorage
+ _sessionStorage = _createNameStorage();
+
+ // Test it
+ _sessionStorage.setItem(_TESTID, 2);
+ if (_sessionStorage.getItem(_TESTID) == 2) {
+ _sessionStorage.removeItem(_TESTID);
+ } else {
+ _sessionStorage = null;
+ }
+ } catch (e) {
+ _sessionStorage = null;
+ }
+ // Last ditch effort: use memory storage
+ if (!_sessionStorage) {
+ _sessionStorage = _createMemoryStorage();
+ }
+ }
+
+ // cookieStorage already calls _assignPrefix
+ return _sessionStorage.STORE_TYPE === 'cookieStorage' ? _sessionStorage : _assignPrefix(_sessionStorage);
+ }());
+
+ /**
+ * @namespace localStorage
+ */
+ _returnable.localStorage = localStorage = (function () {
+ var _localStorage;
+
+ if (top.localStorage || top.globalStorage) {
+ try {
+ _localStorage = top.localStorage || top.globalStorage[location.hostname];
+ _localStorage.setItem(_TESTID, 1);
+ _localStorage.removeItem(_TESTID);
+
+ // Now clone sessionStorage so that we may extend it with our own methods
+ var _tmp = function () {};
+ _tmp.prototype = _localStorage;
+ // jshint -W055
+ _tmp = new _tmp();
+ try {
+ if (_tmp.getItem) {
+ _tmp.setItem(_TESTID, 2);
+ _tmp.removeItem(_TESTID);
+ }
+ } catch (e) {
+ // Firefox 14+ throws a security exception when wrapping a native class
+ _tmp = null;
+ }
+
+ if (_tmp && !_tmp.getItem) {
+ // Internet Explorer 8 does not inherit the prototype here. We can hack around it using a DOM object
+ _localStorage = _adjustHTML5Storage(_createDOMStorage('localStorage', _localStorage));
+ } else if (!_tmp || Object.prototype.toString.apply(Storage.prototype) === '[object StoragePrototype]') {
+ // Safari throws a type error when extending with Storage
+ _localStorage = _adjustHTML5Storage(_createReferencedStorage('localStorage', _localStorage));
+ } else {
+ // Spec
+ _localStorage = _adjustHTML5Storage(_tmp);
+ }
+ } catch (e) {
+ _localStorage = null;
+ }
+ }
+
+ // Did not work, try alternatives...
+ // Try userData first
+ if (!_localStorage) {
+ _localStorage = (function () {
+ /**
+ * @param {String} str
+ * @return {String}
+ */
+
+ var _esc = function (str) {
+ return 'PS' + str.replace(_e, '__').replace(_s, '_s');
+ },
+ _e = /_/g,
+ _s = / /g,
+ _PREFIX = _esc(PREFIX + 'uData'),
+ _NAME = _esc('Storer');
+
+ if (window.ActiveXObject) {
+ // Try userData
+ try {
+ // Data cache
+ var _data = {}, // key : data
+ _keys = [], // _keys key : _ikey key
+ _ikey = {}, // _ikey key : _keys key
+ /**
+ * Cannot be accessed directly, and in fact appears as Storer.localStorage when in use.
+ * You can, however, know that it is in use when localStorage.STORE_TYPE === 'userData'.
+ * @namespace userDataStorage */
+ userData = {
+ /** @const String STORE_TYPE
+ * @default "userData"
+ * @memberof userDataStorage */
+ STORE_TYPE: 'userData',
+
+ /** # of items */
+ length: 0,
+
+ /**
+ * Returns key of i
+ * @param {int} i
+ * @return {String}
+ */
+ key: function (i) {
+ return _keys[i];
+ },
+
+ /**
+ * Gets data of key
+ * @param {String} key
+ * @return {*}
+ */
+ getItem: function (key) {
+ var esckey = _esc(key);
+ return _checkEnd(el.getAttribute('_end_' + esckey), el.getAttribute(esckey), _removeItemFn, key);
+ },
+
+ /**
+ * Sets key to data
+ * @param {String} key
+ * @param {String} data
+ * @param {String|Number|Date} [end]
+ */
+ setItem: function (key, data, end) {
+ if (data !== null && data !== undefined) {
+ var esckey = _esc(key),
+ store_end = _storeEnd(data, end);
+ if (store_end.value !== null) {
+ el.setAttribute(esckey, data);
+ if (!store_end._end) {
+ // Save some space
+ el.removeAttribute('_end_' + esckey);
+ } else {
+ el.setAttribute('_end_' + esckey, "" + store_end._end);
+ }
+ _ikey[key] === undefined && (_ikey[key] = (userData.length = _keys.push(key)) - 1);
+ el.save(_PREFIX + _NAME);
+ return (_data[key] = store_end.value);
+ }
+ }
+ return _removeItemFn(key);
+ },
+
+ /**
+ * Removes item at key
+ * @param {String} key
+ */
+ removeItem: function (key) {
+ var esckey = _esc(key);
+ el.removeAttribute(esckey);
+ el.removeAttribute('_end_' + esckey);
+ if (_ikey[key] !== undefined) {
+ // re-reference all the keys because we've removed an item in between
+ for (var i = _keys.length; --i > _ikey[key];) {
+ _ikey[_keys[i]]--;
+ }
+ _keys.splice(_ikey[key], 1);
+ delete _ikey[key];
+ }
+ el.save(_PREFIX + _NAME);
+ userData.length = _keys.length;
+ },
+
+ /**
+ * Clears all data
+ */
+ clear: function () {
+ for (var doc = el.xmlDocument,
+ attributes = doc.firstChild.attributes,
+ attr,
+ i = attributes.length;
+ 0 <= --i;) {
+ attr = attributes[i];
+ delete _data[attr.nodeName]; // remove from cache
+ el.removeAttribute(attr.nodeName); // use the standard DOM properties to remove the item
+ userData.length--;
+ }
+ el.save(_PREFIX + _NAME);
+ userData.length = _keys.length = 0;
+ _data = {};
+ _ikey = {};
+ }
+ },
+ // Keep backups of these functions, as they may be overriden by _assignPrefix
+ _removeItemFn = userData.removeItem,
+ _hasItemFn = userData.hasItem;
+
+ // Init userData element
+ var el = document.createElement('input');
+ el.style.display = 'none';
+ el.addBehavior('#default#userData');
+
+ var fn = (typeof domReady === 'function' ? domReady : (typeof jQuery !== 'undefined' ? jQuery(document).ready : false));
+ _callbackNow = !fn;
+
+ fn && fn(function () {
+ try {
+ var bod = document.body || document.getElementsByTagName('head')[0];
+ bod.appendChild(el);
+ el.load(_PREFIX + _NAME);
+
+ // Test
+ userData.setItem(_TESTID, 3);
+ if (userData.getItem(_TESTID) == 3) {
+ userData.removeItem(_TESTID);
+
+ // Good. Parse.
+ var attr,
+ // the reference to the XMLDocument
+ doc = el.xmlDocument,
+ // the root element will always be the firstChild of the XMLDocument
+ attributes = doc.firstChild.attributes,
+ i = -1,
+ len = attributes.length;
+ while (++i < len) {
+ attr = attributes[i];
+ if (attr.nodeValue !== undefined && attr.nodeValue !== null) {
+ _ikey[attr.nodeName] = _keys.push(attr.nodeName) - 1;
+ _data[attr.nodeName] = attr.nodeValue; // use the standard DOM properties to retrieve the key and value
+ }
+ }
+
+ _returnable.localStorage = localStorage = userData;
+ callback && callback(_returnable);
+ } else {
+ userData = null;
+ }
+ } catch (e) {
+ userData = null;
+ }
+
+ if (!userData) {
+ _returnable.localStorage = localStorage = _localStorage = NO_COOKIE_FALLBACK ? _createMemoryStorage() : _createCookieStorage('localStorage');
+ callback && callback(_returnable);
+ }
+ });
+
+ return userData;
+ } catch (e) {}
+ }
+ }());
+ }
+ if (!_localStorage) {
+ // Try cookie or memory
+ _localStorage = NO_COOKIE_FALLBACK ? _createMemoryStorage() : _createCookieStorage('localStorage');
+ }
+
+ // cookieStorage already calls _assignPrefix
+ return _localStorage.STORE_TYPE === 'cookieStorage' ? _localStorage : _assignPrefix(_localStorage);
+ }());
+
+ _callbackNow && callback && callback(_returnable);
+
+ return _returnable;
+}
+
+window.mediaWiki = window.mediaWiki || {};
+mediaWiki.flow = mediaWiki.flow || {};
+mediaWiki.flow.vendor = mediaWiki.flow.vendor || {};
+mediaWiki.flow.vendor.initStorer = initStorer; \ No newline at end of file
diff --git a/Flow/modules/vendor/handlebars.js b/Flow/modules/vendor/handlebars.js
new file mode 100644
index 00000000..f826bbfd
--- /dev/null
+++ b/Flow/modules/vendor/handlebars.js
@@ -0,0 +1,3079 @@
+/*!
+
+ handlebars v2.0.0
+
+Copyright (C) 2011-2014 by Yehuda Katz
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+
+@license
+*/
+/* exported Handlebars */
+(function (root, factory) {
+ if (typeof define === 'function' && define.amd) {
+ define([], factory);
+ } else if (typeof exports === 'object') {
+ module.exports = factory();
+ } else {
+ root.Handlebars = root.Handlebars || factory();
+ }
+}(this, function () {
+// handlebars/safe-string.js
+var __module4__ = (function() {
+ "use strict";
+ var __exports__;
+ // Build out our basic SafeString type
+ function SafeString(string) {
+ this.string = string;
+ }
+
+ SafeString.prototype.toString = function() {
+ return "" + this.string;
+ };
+
+ __exports__ = SafeString;
+ return __exports__;
+})();
+
+// handlebars/utils.js
+var __module3__ = (function(__dependency1__) {
+ "use strict";
+ var __exports__ = {};
+ /*jshint -W004 */
+ var SafeString = __dependency1__;
+
+ var escape = {
+ "&": "&amp;",
+ "<": "&lt;",
+ ">": "&gt;",
+ '"': "&quot;",
+ "'": "&#x27;",
+ "`": "&#x60;"
+ };
+
+ var badChars = /[&<>"'`]/g;
+ var possible = /[&<>"'`]/;
+
+ function escapeChar(chr) {
+ return escape[chr];
+ }
+
+ function extend(obj /* , ...source */) {
+ for (var i = 1; i < arguments.length; i++) {
+ for (var key in arguments[i]) {
+ if (Object.prototype.hasOwnProperty.call(arguments[i], key)) {
+ obj[key] = arguments[i][key];
+ }
+ }
+ }
+
+ return obj;
+ }
+
+ __exports__.extend = extend;var toString = Object.prototype.toString;
+ __exports__.toString = toString;
+ // Sourced from lodash
+ // https://github.com/bestiejs/lodash/blob/master/LICENSE.txt
+ var isFunction = function(value) {
+ return typeof value === 'function';
+ };
+ // fallback for older versions of Chrome and Safari
+ /* istanbul ignore next */
+ if (isFunction(/x/)) {
+ isFunction = function(value) {
+ return typeof value === 'function' && toString.call(value) === '[object Function]';
+ };
+ }
+ var isFunction;
+ __exports__.isFunction = isFunction;
+ /* istanbul ignore next */
+ var isArray = Array.isArray || function(value) {
+ return (value && typeof value === 'object') ? toString.call(value) === '[object Array]' : false;
+ };
+ __exports__.isArray = isArray;
+
+ function escapeExpression(string) {
+ // don't escape SafeStrings, since they're already safe
+ if (string instanceof SafeString) {
+ return string.toString();
+ } else if (string == null) {
+ return "";
+ } else if (!string) {
+ return string + '';
+ }
+
+ // Force a string conversion as this will be done by the append regardless and
+ // the regex test will do this transparently behind the scenes, causing issues if
+ // an object's to string has escaped characters in it.
+ string = "" + string;
+
+ if(!possible.test(string)) { return string; }
+ return string.replace(badChars, escapeChar);
+ }
+
+ __exports__.escapeExpression = escapeExpression;function isEmpty(value) {
+ if (!value && value !== 0) {
+ return true;
+ } else if (isArray(value) && value.length === 0) {
+ return true;
+ } else {
+ return false;
+ }
+ }
+
+ __exports__.isEmpty = isEmpty;function appendContextPath(contextPath, id) {
+ return (contextPath ? contextPath + '.' : '') + id;
+ }
+
+ __exports__.appendContextPath = appendContextPath;
+ return __exports__;
+})(__module4__);
+
+// handlebars/exception.js
+var __module5__ = (function() {
+ "use strict";
+ var __exports__;
+
+ var errorProps = ['description', 'fileName', 'lineNumber', 'message', 'name', 'number', 'stack'];
+
+ function Exception(message, node) {
+ var line;
+ if (node && node.firstLine) {
+ line = node.firstLine;
+
+ message += ' - ' + line + ':' + node.firstColumn;
+ }
+
+ var tmp = Error.prototype.constructor.call(this, message);
+
+ // Unfortunately errors are not enumerable in Chrome (at least), so `for prop in tmp` doesn't work.
+ for (var idx = 0; idx < errorProps.length; idx++) {
+ this[errorProps[idx]] = tmp[errorProps[idx]];
+ }
+
+ if (line) {
+ this.lineNumber = line;
+ this.column = node.firstColumn;
+ }
+ }
+
+ Exception.prototype = new Error();
+
+ __exports__ = Exception;
+ return __exports__;
+})();
+
+// handlebars/base.js
+var __module2__ = (function(__dependency1__, __dependency2__) {
+ "use strict";
+ var __exports__ = {};
+ var Utils = __dependency1__;
+ var Exception = __dependency2__;
+
+ var VERSION = "2.0.0";
+ __exports__.VERSION = VERSION;var COMPILER_REVISION = 6;
+ __exports__.COMPILER_REVISION = COMPILER_REVISION;
+ var REVISION_CHANGES = {
+ 1: '<= 1.0.rc.2', // 1.0.rc.2 is actually rev2 but doesn't report it
+ 2: '== 1.0.0-rc.3',
+ 3: '== 1.0.0-rc.4',
+ 4: '== 1.x.x',
+ 5: '== 2.0.0-alpha.x',
+ 6: '>= 2.0.0-beta.1'
+ };
+ __exports__.REVISION_CHANGES = REVISION_CHANGES;
+ var isArray = Utils.isArray,
+ isFunction = Utils.isFunction,
+ toString = Utils.toString,
+ objectType = '[object Object]';
+
+ function HandlebarsEnvironment(helpers, partials) {
+ this.helpers = helpers || {};
+ this.partials = partials || {};
+
+ registerDefaultHelpers(this);
+ }
+
+ __exports__.HandlebarsEnvironment = HandlebarsEnvironment;HandlebarsEnvironment.prototype = {
+ constructor: HandlebarsEnvironment,
+
+ logger: logger,
+ log: log,
+
+ registerHelper: function(name, fn) {
+ if (toString.call(name) === objectType) {
+ if (fn) { throw new Exception('Arg not supported with multiple helpers'); }
+ Utils.extend(this.helpers, name);
+ } else {
+ this.helpers[name] = fn;
+ }
+ },
+ unregisterHelper: function(name) {
+ delete this.helpers[name];
+ },
+
+ registerPartial: function(name, partial) {
+ if (toString.call(name) === objectType) {
+ Utils.extend(this.partials, name);
+ } else {
+ this.partials[name] = partial;
+ }
+ },
+ unregisterPartial: function(name) {
+ delete this.partials[name];
+ }
+ };
+
+ function registerDefaultHelpers(instance) {
+ instance.registerHelper('helperMissing', function(/* [args, ]options */) {
+ if(arguments.length === 1) {
+ // A missing field in a {{foo}} constuct.
+ return undefined;
+ } else {
+ // Someone is actually trying to call something, blow up.
+ throw new Exception("Missing helper: '" + arguments[arguments.length-1].name + "'");
+ }
+ });
+
+ instance.registerHelper('blockHelperMissing', function(context, options) {
+ var inverse = options.inverse,
+ fn = options.fn;
+
+ if(context === true) {
+ return fn(this);
+ } else if(context === false || context == null) {
+ return inverse(this);
+ } else if (isArray(context)) {
+ if(context.length > 0) {
+ if (options.ids) {
+ options.ids = [options.name];
+ }
+
+ return instance.helpers.each(context, options);
+ } else {
+ return inverse(this);
+ }
+ } else {
+ if (options.data && options.ids) {
+ var data = createFrame(options.data);
+ data.contextPath = Utils.appendContextPath(options.data.contextPath, options.name);
+ options = {data: data};
+ }
+
+ return fn(context, options);
+ }
+ });
+
+ instance.registerHelper('each', function(context, options) {
+ if (!options) {
+ throw new Exception('Must pass iterator to #each');
+ }
+
+ var fn = options.fn, inverse = options.inverse;
+ var i = 0, ret = "", data;
+
+ var contextPath;
+ if (options.data && options.ids) {
+ contextPath = Utils.appendContextPath(options.data.contextPath, options.ids[0]) + '.';
+ }
+
+ if (isFunction(context)) { context = context.call(this); }
+
+ if (options.data) {
+ data = createFrame(options.data);
+ }
+
+ if(context && typeof context === 'object') {
+ if (isArray(context)) {
+ for(var j = context.length; i<j; i++) {
+ if (data) {
+ data.index = i;
+ data.first = (i === 0);
+ data.last = (i === (context.length-1));
+
+ if (contextPath) {
+ data.contextPath = contextPath + i;
+ }
+ }
+ ret = ret + fn(context[i], { data: data });
+ }
+ } else {
+ for(var key in context) {
+ if(context.hasOwnProperty(key)) {
+ if(data) {
+ data.key = key;
+ data.index = i;
+ data.first = (i === 0);
+
+ if (contextPath) {
+ data.contextPath = contextPath + key;
+ }
+ }
+ ret = ret + fn(context[key], {data: data});
+ i++;
+ }
+ }
+ }
+ }
+
+ if(i === 0){
+ ret = inverse(this);
+ }
+
+ return ret;
+ });
+
+ instance.registerHelper('if', function(conditional, options) {
+ if (isFunction(conditional)) { conditional = conditional.call(this); }
+
+ // Default behavior is to render the positive path if the value is truthy and not empty.
+ // The `includeZero` option may be set to treat the condtional as purely not empty based on the
+ // behavior of isEmpty. Effectively this determines if 0 is handled by the positive path or negative.
+ if ((!options.hash.includeZero && !conditional) || Utils.isEmpty(conditional)) {
+ return options.inverse(this);
+ } else {
+ return options.fn(this);
+ }
+ });
+
+ instance.registerHelper('unless', function(conditional, options) {
+ return instance.helpers['if'].call(this, conditional, {fn: options.inverse, inverse: options.fn, hash: options.hash});
+ });
+
+ instance.registerHelper('with', function(context, options) {
+ if (isFunction(context)) { context = context.call(this); }
+
+ var fn = options.fn;
+
+ if (!Utils.isEmpty(context)) {
+ if (options.data && options.ids) {
+ var data = createFrame(options.data);
+ data.contextPath = Utils.appendContextPath(options.data.contextPath, options.ids[0]);
+ options = {data:data};
+ }
+
+ return fn(context, options);
+ } else {
+ return options.inverse(this);
+ }
+ });
+
+ instance.registerHelper('log', function(message, options) {
+ var level = options.data && options.data.level != null ? parseInt(options.data.level, 10) : 1;
+ instance.log(level, message);
+ });
+
+ instance.registerHelper('lookup', function(obj, field) {
+ return obj && obj[field];
+ });
+ }
+
+ var logger = {
+ methodMap: { 0: 'debug', 1: 'info', 2: 'warn', 3: 'error' },
+
+ // State enum
+ DEBUG: 0,
+ INFO: 1,
+ WARN: 2,
+ ERROR: 3,
+ level: 3,
+
+ // can be overridden in the host environment
+ log: function(level, message) {
+ if (logger.level <= level) {
+ var method = logger.methodMap[level];
+ if (typeof console !== 'undefined' && console[method]) {
+ console[method].call(console, message);
+ }
+ }
+ }
+ };
+ __exports__.logger = logger;
+ var log = logger.log;
+ __exports__.log = log;
+ var createFrame = function(object) {
+ var frame = Utils.extend({}, object);
+ frame._parent = object;
+ return frame;
+ };
+ __exports__.createFrame = createFrame;
+ return __exports__;
+})(__module3__, __module5__);
+
+// handlebars/runtime.js
+var __module6__ = (function(__dependency1__, __dependency2__, __dependency3__) {
+ "use strict";
+ var __exports__ = {};
+ var Utils = __dependency1__;
+ var Exception = __dependency2__;
+ var COMPILER_REVISION = __dependency3__.COMPILER_REVISION;
+ var REVISION_CHANGES = __dependency3__.REVISION_CHANGES;
+ var createFrame = __dependency3__.createFrame;
+
+ function checkRevision(compilerInfo) {
+ var compilerRevision = compilerInfo && compilerInfo[0] || 1,
+ currentRevision = COMPILER_REVISION;
+
+ if (compilerRevision !== currentRevision) {
+ if (compilerRevision < currentRevision) {
+ var runtimeVersions = REVISION_CHANGES[currentRevision],
+ compilerVersions = REVISION_CHANGES[compilerRevision];
+ throw new Exception("Template was precompiled with an older version of Handlebars than the current runtime. "+
+ "Please update your precompiler to a newer version ("+runtimeVersions+") or downgrade your runtime to an older version ("+compilerVersions+").");
+ } else {
+ // Use the embedded version info since the runtime doesn't know about this revision yet
+ throw new Exception("Template was precompiled with a newer version of Handlebars than the current runtime. "+
+ "Please update your runtime to a newer version ("+compilerInfo[1]+").");
+ }
+ }
+ }
+
+ __exports__.checkRevision = checkRevision;// TODO: Remove this line and break up compilePartial
+
+ function template(templateSpec, env) {
+ /* istanbul ignore next */
+ if (!env) {
+ throw new Exception("No environment passed to template");
+ }
+ if (!templateSpec || !templateSpec.main) {
+ throw new Exception('Unknown template object: ' + typeof templateSpec);
+ }
+
+ // Note: Using env.VM references rather than local var references throughout this section to allow
+ // for external users to override these as psuedo-supported APIs.
+ env.VM.checkRevision(templateSpec.compiler);
+
+ var invokePartialWrapper = function(partial, indent, name, context, hash, helpers, partials, data, depths) {
+ if (hash) {
+ context = Utils.extend({}, context, hash);
+ }
+
+ var result = env.VM.invokePartial.call(this, partial, name, context, helpers, partials, data, depths);
+
+ if (result == null && env.compile) {
+ var options = { helpers: helpers, partials: partials, data: data, depths: depths };
+ partials[name] = env.compile(partial, { data: data !== undefined, compat: templateSpec.compat }, env);
+ result = partials[name](context, options);
+ }
+ if (result != null) {
+ if (indent) {
+ var lines = result.split('\n');
+ for (var i = 0, l = lines.length; i < l; i++) {
+ if (!lines[i] && i + 1 === l) {
+ break;
+ }
+
+ lines[i] = indent + lines[i];
+ }
+ result = lines.join('\n');
+ }
+ return result;
+ } else {
+ throw new Exception("The partial " + name + " could not be compiled when running in runtime-only mode");
+ }
+ };
+
+ // Just add water
+ var container = {
+ lookup: function(depths, name) {
+ var len = depths.length;
+ for (var i = 0; i < len; i++) {
+ if (depths[i] && depths[i][name] != null) {
+ return depths[i][name];
+ }
+ }
+ },
+ lambda: function(current, context) {
+ return typeof current === 'function' ? current.call(context) : current;
+ },
+
+ escapeExpression: Utils.escapeExpression,
+ invokePartial: invokePartialWrapper,
+
+ fn: function(i) {
+ return templateSpec[i];
+ },
+
+ programs: [],
+ program: function(i, data, depths) {
+ var programWrapper = this.programs[i],
+ fn = this.fn(i);
+ if (data || depths) {
+ programWrapper = program(this, i, fn, data, depths);
+ } else if (!programWrapper) {
+ programWrapper = this.programs[i] = program(this, i, fn);
+ }
+ return programWrapper;
+ },
+
+ data: function(data, depth) {
+ while (data && depth--) {
+ data = data._parent;
+ }
+ return data;
+ },
+ merge: function(param, common) {
+ var ret = param || common;
+
+ if (param && common && (param !== common)) {
+ ret = Utils.extend({}, common, param);
+ }
+
+ return ret;
+ },
+
+ noop: env.VM.noop,
+ compilerInfo: templateSpec.compiler
+ };
+
+ var ret = function(context, options) {
+ options = options || {};
+ var data = options.data;
+
+ ret._setup(options);
+ if (!options.partial && templateSpec.useData) {
+ data = initData(context, data);
+ }
+ var depths;
+ if (templateSpec.useDepths) {
+ depths = options.depths ? [context].concat(options.depths) : [context];
+ }
+
+ return templateSpec.main.call(container, context, container.helpers, container.partials, data, depths);
+ };
+ ret.isTop = true;
+
+ ret._setup = function(options) {
+ if (!options.partial) {
+ container.helpers = container.merge(options.helpers, env.helpers);
+
+ if (templateSpec.usePartial) {
+ container.partials = container.merge(options.partials, env.partials);
+ }
+ } else {
+ container.helpers = options.helpers;
+ container.partials = options.partials;
+ }
+ };
+
+ ret._child = function(i, data, depths) {
+ if (templateSpec.useDepths && !depths) {
+ throw new Exception('must pass parent depths');
+ }
+
+ return program(container, i, templateSpec[i], data, depths);
+ };
+ return ret;
+ }
+
+ __exports__.template = template;function program(container, i, fn, data, depths) {
+ var prog = function(context, options) {
+ options = options || {};
+
+ return fn.call(container, context, container.helpers, container.partials, options.data || data, depths && [context].concat(depths));
+ };
+ prog.program = i;
+ prog.depth = depths ? depths.length : 0;
+ return prog;
+ }
+
+ __exports__.program = program;function invokePartial(partial, name, context, helpers, partials, data, depths) {
+ var options = { partial: true, helpers: helpers, partials: partials, data: data, depths: depths };
+
+ if(partial === undefined) {
+ throw new Exception("The partial " + name + " could not be found");
+ } else if(partial instanceof Function) {
+ return partial(context, options);
+ }
+ }
+
+ __exports__.invokePartial = invokePartial;function noop() { return ""; }
+
+ __exports__.noop = noop;function initData(context, data) {
+ if (!data || !('root' in data)) {
+ data = data ? createFrame(data) : {};
+ data.root = context;
+ }
+ return data;
+ }
+ return __exports__;
+})(__module3__, __module5__, __module2__);
+
+// handlebars.runtime.js
+var __module1__ = (function(__dependency1__, __dependency2__, __dependency3__, __dependency4__, __dependency5__) {
+ "use strict";
+ var __exports__;
+ /*globals Handlebars: true */
+ var base = __dependency1__;
+
+ // Each of these augment the Handlebars object. No need to setup here.
+ // (This is done to easily share code between commonjs and browse envs)
+ var SafeString = __dependency2__;
+ var Exception = __dependency3__;
+ var Utils = __dependency4__;
+ var runtime = __dependency5__;
+
+ // For compatibility and usage outside of module systems, make the Handlebars object a namespace
+ var create = function() {
+ var hb = new base.HandlebarsEnvironment();
+
+ Utils.extend(hb, base);
+ hb.SafeString = SafeString;
+ hb.Exception = Exception;
+ hb.Utils = Utils;
+ hb.escapeExpression = Utils.escapeExpression;
+
+ hb.VM = runtime;
+ hb.template = function(spec) {
+ return runtime.template(spec, hb);
+ };
+
+ return hb;
+ };
+
+ var Handlebars = create();
+ Handlebars.create = create;
+
+ Handlebars['default'] = Handlebars;
+
+ __exports__ = Handlebars;
+ return __exports__;
+})(__module2__, __module4__, __module5__, __module3__, __module6__);
+
+// handlebars/compiler/ast.js
+var __module7__ = (function(__dependency1__) {
+ "use strict";
+ var __exports__;
+ var Exception = __dependency1__;
+
+ function LocationInfo(locInfo) {
+ locInfo = locInfo || {};
+ this.firstLine = locInfo.first_line;
+ this.firstColumn = locInfo.first_column;
+ this.lastColumn = locInfo.last_column;
+ this.lastLine = locInfo.last_line;
+ }
+
+ var AST = {
+ ProgramNode: function(statements, strip, locInfo) {
+ LocationInfo.call(this, locInfo);
+ this.type = "program";
+ this.statements = statements;
+ this.strip = strip;
+ },
+
+ MustacheNode: function(rawParams, hash, open, strip, locInfo) {
+ LocationInfo.call(this, locInfo);
+ this.type = "mustache";
+ this.strip = strip;
+
+ // Open may be a string parsed from the parser or a passed boolean flag
+ if (open != null && open.charAt) {
+ // Must use charAt to support IE pre-10
+ var escapeFlag = open.charAt(3) || open.charAt(2);
+ this.escaped = escapeFlag !== '{' && escapeFlag !== '&';
+ } else {
+ this.escaped = !!open;
+ }
+
+ if (rawParams instanceof AST.SexprNode) {
+ this.sexpr = rawParams;
+ } else {
+ // Support old AST API
+ this.sexpr = new AST.SexprNode(rawParams, hash);
+ }
+
+ // Support old AST API that stored this info in MustacheNode
+ this.id = this.sexpr.id;
+ this.params = this.sexpr.params;
+ this.hash = this.sexpr.hash;
+ this.eligibleHelper = this.sexpr.eligibleHelper;
+ this.isHelper = this.sexpr.isHelper;
+ },
+
+ SexprNode: function(rawParams, hash, locInfo) {
+ LocationInfo.call(this, locInfo);
+
+ this.type = "sexpr";
+ this.hash = hash;
+
+ var id = this.id = rawParams[0];
+ var params = this.params = rawParams.slice(1);
+
+ // a mustache is definitely a helper if:
+ // * it is an eligible helper, and
+ // * it has at least one parameter or hash segment
+ this.isHelper = !!(params.length || hash);
+
+ // a mustache is an eligible helper if:
+ // * its id is simple (a single part, not `this` or `..`)
+ this.eligibleHelper = this.isHelper || id.isSimple;
+
+ // if a mustache is an eligible helper but not a definite
+ // helper, it is ambiguous, and will be resolved in a later
+ // pass or at runtime.
+ },
+
+ PartialNode: function(partialName, context, hash, strip, locInfo) {
+ LocationInfo.call(this, locInfo);
+ this.type = "partial";
+ this.partialName = partialName;
+ this.context = context;
+ this.hash = hash;
+ this.strip = strip;
+
+ this.strip.inlineStandalone = true;
+ },
+
+ BlockNode: function(mustache, program, inverse, strip, locInfo) {
+ LocationInfo.call(this, locInfo);
+
+ this.type = 'block';
+ this.mustache = mustache;
+ this.program = program;
+ this.inverse = inverse;
+ this.strip = strip;
+
+ if (inverse && !program) {
+ this.isInverse = true;
+ }
+ },
+
+ RawBlockNode: function(mustache, content, close, locInfo) {
+ LocationInfo.call(this, locInfo);
+
+ if (mustache.sexpr.id.original !== close) {
+ throw new Exception(mustache.sexpr.id.original + " doesn't match " + close, this);
+ }
+
+ content = new AST.ContentNode(content, locInfo);
+
+ this.type = 'block';
+ this.mustache = mustache;
+ this.program = new AST.ProgramNode([content], {}, locInfo);
+ },
+
+ ContentNode: function(string, locInfo) {
+ LocationInfo.call(this, locInfo);
+ this.type = "content";
+ this.original = this.string = string;
+ },
+
+ HashNode: function(pairs, locInfo) {
+ LocationInfo.call(this, locInfo);
+ this.type = "hash";
+ this.pairs = pairs;
+ },
+
+ IdNode: function(parts, locInfo) {
+ LocationInfo.call(this, locInfo);
+ this.type = "ID";
+
+ var original = "",
+ dig = [],
+ depth = 0,
+ depthString = '';
+
+ for(var i=0,l=parts.length; i<l; i++) {
+ var part = parts[i].part;
+ original += (parts[i].separator || '') + part;
+
+ if (part === ".." || part === "." || part === "this") {
+ if (dig.length > 0) {
+ throw new Exception("Invalid path: " + original, this);
+ } else if (part === "..") {
+ depth++;
+ depthString += '../';
+ } else {
+ this.isScoped = true;
+ }
+ } else {
+ dig.push(part);
+ }
+ }
+
+ this.original = original;
+ this.parts = dig;
+ this.string = dig.join('.');
+ this.depth = depth;
+ this.idName = depthString + this.string;
+
+ // an ID is simple if it only has one part, and that part is not
+ // `..` or `this`.
+ this.isSimple = parts.length === 1 && !this.isScoped && depth === 0;
+
+ this.stringModeValue = this.string;
+ },
+
+ PartialNameNode: function(name, locInfo) {
+ LocationInfo.call(this, locInfo);
+ this.type = "PARTIAL_NAME";
+ this.name = name.original;
+ },
+
+ DataNode: function(id, locInfo) {
+ LocationInfo.call(this, locInfo);
+ this.type = "DATA";
+ this.id = id;
+ this.stringModeValue = id.stringModeValue;
+ this.idName = '@' + id.stringModeValue;
+ },
+
+ StringNode: function(string, locInfo) {
+ LocationInfo.call(this, locInfo);
+ this.type = "STRING";
+ this.original =
+ this.string =
+ this.stringModeValue = string;
+ },
+
+ NumberNode: function(number, locInfo) {
+ LocationInfo.call(this, locInfo);
+ this.type = "NUMBER";
+ this.original =
+ this.number = number;
+ this.stringModeValue = Number(number);
+ },
+
+ BooleanNode: function(bool, locInfo) {
+ LocationInfo.call(this, locInfo);
+ this.type = "BOOLEAN";
+ this.bool = bool;
+ this.stringModeValue = bool === "true";
+ },
+
+ CommentNode: function(comment, locInfo) {
+ LocationInfo.call(this, locInfo);
+ this.type = "comment";
+ this.comment = comment;
+
+ this.strip = {
+ inlineStandalone: true
+ };
+ }
+ };
+
+
+ // Must be exported as an object rather than the root of the module as the jison lexer
+ // most modify the object to operate properly.
+ __exports__ = AST;
+ return __exports__;
+})(__module5__);
+
+// handlebars/compiler/parser.js
+var __module9__ = (function() {
+ "use strict";
+ var __exports__;
+ /* jshint ignore:start */
+ /* istanbul ignore next */
+ /* Jison generated parser */
+ var handlebars = (function(){
+ var parser = {trace: function trace() { },
+ yy: {},
+ symbols_: {"error":2,"root":3,"program":4,"EOF":5,"program_repetition0":6,"statement":7,"mustache":8,"block":9,"rawBlock":10,"partial":11,"CONTENT":12,"COMMENT":13,"openRawBlock":14,"END_RAW_BLOCK":15,"OPEN_RAW_BLOCK":16,"sexpr":17,"CLOSE_RAW_BLOCK":18,"openBlock":19,"block_option0":20,"closeBlock":21,"openInverse":22,"block_option1":23,"OPEN_BLOCK":24,"CLOSE":25,"OPEN_INVERSE":26,"inverseAndProgram":27,"INVERSE":28,"OPEN_ENDBLOCK":29,"path":30,"OPEN":31,"OPEN_UNESCAPED":32,"CLOSE_UNESCAPED":33,"OPEN_PARTIAL":34,"partialName":35,"param":36,"partial_option0":37,"partial_option1":38,"sexpr_repetition0":39,"sexpr_option0":40,"dataName":41,"STRING":42,"NUMBER":43,"BOOLEAN":44,"OPEN_SEXPR":45,"CLOSE_SEXPR":46,"hash":47,"hash_repetition_plus0":48,"hashSegment":49,"ID":50,"EQUALS":51,"DATA":52,"pathSegments":53,"SEP":54,"$accept":0,"$end":1},
+ terminals_: {2:"error",5:"EOF",12:"CONTENT",13:"COMMENT",15:"END_RAW_BLOCK",16:"OPEN_RAW_BLOCK",18:"CLOSE_RAW_BLOCK",24:"OPEN_BLOCK",25:"CLOSE",26:"OPEN_INVERSE",28:"INVERSE",29:"OPEN_ENDBLOCK",31:"OPEN",32:"OPEN_UNESCAPED",33:"CLOSE_UNESCAPED",34:"OPEN_PARTIAL",42:"STRING",43:"NUMBER",44:"BOOLEAN",45:"OPEN_SEXPR",46:"CLOSE_SEXPR",50:"ID",51:"EQUALS",52:"DATA",54:"SEP"},
+ productions_: [0,[3,2],[4,1],[7,1],[7,1],[7,1],[7,1],[7,1],[7,1],[10,3],[14,3],[9,4],[9,4],[19,3],[22,3],[27,2],[21,3],[8,3],[8,3],[11,5],[11,4],[17,3],[17,1],[36,1],[36,1],[36,1],[36,1],[36,1],[36,3],[47,1],[49,3],[35,1],[35,1],[35,1],[41,2],[30,1],[53,3],[53,1],[6,0],[6,2],[20,0],[20,1],[23,0],[23,1],[37,0],[37,1],[38,0],[38,1],[39,0],[39,2],[40,0],[40,1],[48,1],[48,2]],
+ performAction: function anonymous(yytext,yyleng,yylineno,yy,yystate,$$,_$) {
+
+ var $0 = $$.length - 1;
+ switch (yystate) {
+ case 1: yy.prepareProgram($$[$0-1].statements, true); return $$[$0-1];
+ break;
+ case 2:this.$ = new yy.ProgramNode(yy.prepareProgram($$[$0]), {}, this._$);
+ break;
+ case 3:this.$ = $$[$0];
+ break;
+ case 4:this.$ = $$[$0];
+ break;
+ case 5:this.$ = $$[$0];
+ break;
+ case 6:this.$ = $$[$0];
+ break;
+ case 7:this.$ = new yy.ContentNode($$[$0], this._$);
+ break;
+ case 8:this.$ = new yy.CommentNode($$[$0], this._$);
+ break;
+ case 9:this.$ = new yy.RawBlockNode($$[$0-2], $$[$0-1], $$[$0], this._$);
+ break;
+ case 10:this.$ = new yy.MustacheNode($$[$0-1], null, '', '', this._$);
+ break;
+ case 11:this.$ = yy.prepareBlock($$[$0-3], $$[$0-2], $$[$0-1], $$[$0], false, this._$);
+ break;
+ case 12:this.$ = yy.prepareBlock($$[$0-3], $$[$0-2], $$[$0-1], $$[$0], true, this._$);
+ break;
+ case 13:this.$ = new yy.MustacheNode($$[$0-1], null, $$[$0-2], yy.stripFlags($$[$0-2], $$[$0]), this._$);
+ break;
+ case 14:this.$ = new yy.MustacheNode($$[$0-1], null, $$[$0-2], yy.stripFlags($$[$0-2], $$[$0]), this._$);
+ break;
+ case 15:this.$ = { strip: yy.stripFlags($$[$0-1], $$[$0-1]), program: $$[$0] };
+ break;
+ case 16:this.$ = {path: $$[$0-1], strip: yy.stripFlags($$[$0-2], $$[$0])};
+ break;
+ case 17:this.$ = new yy.MustacheNode($$[$0-1], null, $$[$0-2], yy.stripFlags($$[$0-2], $$[$0]), this._$);
+ break;
+ case 18:this.$ = new yy.MustacheNode($$[$0-1], null, $$[$0-2], yy.stripFlags($$[$0-2], $$[$0]), this._$);
+ break;
+ case 19:this.$ = new yy.PartialNode($$[$0-3], $$[$0-2], $$[$0-1], yy.stripFlags($$[$0-4], $$[$0]), this._$);
+ break;
+ case 20:this.$ = new yy.PartialNode($$[$0-2], undefined, $$[$0-1], yy.stripFlags($$[$0-3], $$[$0]), this._$);
+ break;
+ case 21:this.$ = new yy.SexprNode([$$[$0-2]].concat($$[$0-1]), $$[$0], this._$);
+ break;
+ case 22:this.$ = new yy.SexprNode([$$[$0]], null, this._$);
+ break;
+ case 23:this.$ = $$[$0];
+ break;
+ case 24:this.$ = new yy.StringNode($$[$0], this._$);
+ break;
+ case 25:this.$ = new yy.NumberNode($$[$0], this._$);
+ break;
+ case 26:this.$ = new yy.BooleanNode($$[$0], this._$);
+ break;
+ case 27:this.$ = $$[$0];
+ break;
+ case 28:$$[$0-1].isHelper = true; this.$ = $$[$0-1];
+ break;
+ case 29:this.$ = new yy.HashNode($$[$0], this._$);
+ break;
+ case 30:this.$ = [$$[$0-2], $$[$0]];
+ break;
+ case 31:this.$ = new yy.PartialNameNode($$[$0], this._$);
+ break;
+ case 32:this.$ = new yy.PartialNameNode(new yy.StringNode($$[$0], this._$), this._$);
+ break;
+ case 33:this.$ = new yy.PartialNameNode(new yy.NumberNode($$[$0], this._$));
+ break;
+ case 34:this.$ = new yy.DataNode($$[$0], this._$);
+ break;
+ case 35:this.$ = new yy.IdNode($$[$0], this._$);
+ break;
+ case 36: $$[$0-2].push({part: $$[$0], separator: $$[$0-1]}); this.$ = $$[$0-2];
+ break;
+ case 37:this.$ = [{part: $$[$0]}];
+ break;
+ case 38:this.$ = [];
+ break;
+ case 39:$$[$0-1].push($$[$0]);
+ break;
+ case 48:this.$ = [];
+ break;
+ case 49:$$[$0-1].push($$[$0]);
+ break;
+ case 52:this.$ = [$$[$0]];
+ break;
+ case 53:$$[$0-1].push($$[$0]);
+ break;
+ }
+ },
+ table: [{3:1,4:2,5:[2,38],6:3,12:[2,38],13:[2,38],16:[2,38],24:[2,38],26:[2,38],31:[2,38],32:[2,38],34:[2,38]},{1:[3]},{5:[1,4]},{5:[2,2],7:5,8:6,9:7,10:8,11:9,12:[1,10],13:[1,11],14:16,16:[1,20],19:14,22:15,24:[1,18],26:[1,19],28:[2,2],29:[2,2],31:[1,12],32:[1,13],34:[1,17]},{1:[2,1]},{5:[2,39],12:[2,39],13:[2,39],16:[2,39],24:[2,39],26:[2,39],28:[2,39],29:[2,39],31:[2,39],32:[2,39],34:[2,39]},{5:[2,3],12:[2,3],13:[2,3],16:[2,3],24:[2,3],26:[2,3],28:[2,3],29:[2,3],31:[2,3],32:[2,3],34:[2,3]},{5:[2,4],12:[2,4],13:[2,4],16:[2,4],24:[2,4],26:[2,4],28:[2,4],29:[2,4],31:[2,4],32:[2,4],34:[2,4]},{5:[2,5],12:[2,5],13:[2,5],16:[2,5],24:[2,5],26:[2,5],28:[2,5],29:[2,5],31:[2,5],32:[2,5],34:[2,5]},{5:[2,6],12:[2,6],13:[2,6],16:[2,6],24:[2,6],26:[2,6],28:[2,6],29:[2,6],31:[2,6],32:[2,6],34:[2,6]},{5:[2,7],12:[2,7],13:[2,7],16:[2,7],24:[2,7],26:[2,7],28:[2,7],29:[2,7],31:[2,7],32:[2,7],34:[2,7]},{5:[2,8],12:[2,8],13:[2,8],16:[2,8],24:[2,8],26:[2,8],28:[2,8],29:[2,8],31:[2,8],32:[2,8],34:[2,8]},{17:21,30:22,41:23,50:[1,26],52:[1,25],53:24},{17:27,30:22,41:23,50:[1,26],52:[1,25],53:24},{4:28,6:3,12:[2,38],13:[2,38],16:[2,38],24:[2,38],26:[2,38],28:[2,38],29:[2,38],31:[2,38],32:[2,38],34:[2,38]},{4:29,6:3,12:[2,38],13:[2,38],16:[2,38],24:[2,38],26:[2,38],28:[2,38],29:[2,38],31:[2,38],32:[2,38],34:[2,38]},{12:[1,30]},{30:32,35:31,42:[1,33],43:[1,34],50:[1,26],53:24},{17:35,30:22,41:23,50:[1,26],52:[1,25],53:24},{17:36,30:22,41:23,50:[1,26],52:[1,25],53:24},{17:37,30:22,41:23,50:[1,26],52:[1,25],53:24},{25:[1,38]},{18:[2,48],25:[2,48],33:[2,48],39:39,42:[2,48],43:[2,48],44:[2,48],45:[2,48],46:[2,48],50:[2,48],52:[2,48]},{18:[2,22],25:[2,22],33:[2,22],46:[2,22]},{18:[2,35],25:[2,35],33:[2,35],42:[2,35],43:[2,35],44:[2,35],45:[2,35],46:[2,35],50:[2,35],52:[2,35],54:[1,40]},{30:41,50:[1,26],53:24},{18:[2,37],25:[2,37],33:[2,37],42:[2,37],43:[2,37],44:[2,37],45:[2,37],46:[2,37],50:[2,37],52:[2,37],54:[2,37]},{33:[1,42]},{20:43,27:44,28:[1,45],29:[2,40]},{23:46,27:47,28:[1,45],29:[2,42]},{15:[1,48]},{25:[2,46],30:51,36:49,38:50,41:55,42:[1,52],43:[1,53],44:[1,54],45:[1,56],47:57,48:58,49:60,50:[1,59],52:[1,25],53:24},{25:[2,31],42:[2,31],43:[2,31],44:[2,31],45:[2,31],50:[2,31],52:[2,31]},{25:[2,32],42:[2,32],43:[2,32],44:[2,32],45:[2,32],50:[2,32],52:[2,32]},{25:[2,33],42:[2,33],43:[2,33],44:[2,33],45:[2,33],50:[2,33],52:[2,33]},{25:[1,61]},{25:[1,62]},{18:[1,63]},{5:[2,17],12:[2,17],13:[2,17],16:[2,17],24:[2,17],26:[2,17],28:[2,17],29:[2,17],31:[2,17],32:[2,17],34:[2,17]},{18:[2,50],25:[2,50],30:51,33:[2,50],36:65,40:64,41:55,42:[1,52],43:[1,53],44:[1,54],45:[1,56],46:[2,50],47:66,48:58,49:60,50:[1,59],52:[1,25],53:24},{50:[1,67]},{18:[2,34],25:[2,34],33:[2,34],42:[2,34],43:[2,34],44:[2,34],45:[2,34],46:[2,34],50:[2,34],52:[2,34]},{5:[2,18],12:[2,18],13:[2,18],16:[2,18],24:[2,18],26:[2,18],28:[2,18],29:[2,18],31:[2,18],32:[2,18],34:[2,18]},{21:68,29:[1,69]},{29:[2,41]},{4:70,6:3,12:[2,38],13:[2,38],16:[2,38],24:[2,38],26:[2,38],29:[2,38],31:[2,38],32:[2,38],34:[2,38]},{21:71,29:[1,69]},{29:[2,43]},{5:[2,9],12:[2,9],13:[2,9],16:[2,9],24:[2,9],26:[2,9],28:[2,9],29:[2,9],31:[2,9],32:[2,9],34:[2,9]},{25:[2,44],37:72,47:73,48:58,49:60,50:[1,74]},{25:[1,75]},{18:[2,23],25:[2,23],33:[2,23],42:[2,23],43:[2,23],44:[2,23],45:[2,23],46:[2,23],50:[2,23],52:[2,23]},{18:[2,24],25:[2,24],33:[2,24],42:[2,24],43:[2,24],44:[2,24],45:[2,24],46:[2,24],50:[2,24],52:[2,24]},{18:[2,25],25:[2,25],33:[2,25],42:[2,25],43:[2,25],44:[2,25],45:[2,25],46:[2,25],50:[2,25],52:[2,25]},{18:[2,26],25:[2,26],33:[2,26],42:[2,26],43:[2,26],44:[2,26],45:[2,26],46:[2,26],50:[2,26],52:[2,26]},{18:[2,27],25:[2,27],33:[2,27],42:[2,27],43:[2,27],44:[2,27],45:[2,27],46:[2,27],50:[2,27],52:[2,27]},{17:76,30:22,41:23,50:[1,26],52:[1,25],53:24},{25:[2,47]},{18:[2,29],25:[2,29],33:[2,29],46:[2,29],49:77,50:[1,74]},{18:[2,37],25:[2,37],33:[2,37],42:[2,37],43:[2,37],44:[2,37],45:[2,37],46:[2,37],50:[2,37],51:[1,78],52:[2,37],54:[2,37]},{18:[2,52],25:[2,52],33:[2,52],46:[2,52],50:[2,52]},{12:[2,13],13:[2,13],16:[2,13],24:[2,13],26:[2,13],28:[2,13],29:[2,13],31:[2,13],32:[2,13],34:[2,13]},{12:[2,14],13:[2,14],16:[2,14],24:[2,14],26:[2,14],28:[2,14],29:[2,14],31:[2,14],32:[2,14],34:[2,14]},{12:[2,10]},{18:[2,21],25:[2,21],33:[2,21],46:[2,21]},{18:[2,49],25:[2,49],33:[2,49],42:[2,49],43:[2,49],44:[2,49],45:[2,49],46:[2,49],50:[2,49],52:[2,49]},{18:[2,51],25:[2,51],33:[2,51],46:[2,51]},{18:[2,36],25:[2,36],33:[2,36],42:[2,36],43:[2,36],44:[2,36],45:[2,36],46:[2,36],50:[2,36],52:[2,36],54:[2,36]},{5:[2,11],12:[2,11],13:[2,11],16:[2,11],24:[2,11],26:[2,11],28:[2,11],29:[2,11],31:[2,11],32:[2,11],34:[2,11]},{30:79,50:[1,26],53:24},{29:[2,15]},{5:[2,12],12:[2,12],13:[2,12],16:[2,12],24:[2,12],26:[2,12],28:[2,12],29:[2,12],31:[2,12],32:[2,12],34:[2,12]},{25:[1,80]},{25:[2,45]},{51:[1,78]},{5:[2,20],12:[2,20],13:[2,20],16:[2,20],24:[2,20],26:[2,20],28:[2,20],29:[2,20],31:[2,20],32:[2,20],34:[2,20]},{46:[1,81]},{18:[2,53],25:[2,53],33:[2,53],46:[2,53],50:[2,53]},{30:51,36:82,41:55,42:[1,52],43:[1,53],44:[1,54],45:[1,56],50:[1,26],52:[1,25],53:24},{25:[1,83]},{5:[2,19],12:[2,19],13:[2,19],16:[2,19],24:[2,19],26:[2,19],28:[2,19],29:[2,19],31:[2,19],32:[2,19],34:[2,19]},{18:[2,28],25:[2,28],33:[2,28],42:[2,28],43:[2,28],44:[2,28],45:[2,28],46:[2,28],50:[2,28],52:[2,28]},{18:[2,30],25:[2,30],33:[2,30],46:[2,30],50:[2,30]},{5:[2,16],12:[2,16],13:[2,16],16:[2,16],24:[2,16],26:[2,16],28:[2,16],29:[2,16],31:[2,16],32:[2,16],34:[2,16]}],
+ defaultActions: {4:[2,1],44:[2,41],47:[2,43],57:[2,47],63:[2,10],70:[2,15],73:[2,45]},
+ parseError: function parseError(str, hash) {
+ throw new Error(str);
+ },
+ parse: function parse(input) {
+ var self = this, stack = [0], vstack = [null], lstack = [], table = this.table, yytext = "", yylineno = 0, yyleng = 0, recovering = 0, TERROR = 2, EOF = 1;
+ this.lexer.setInput(input);
+ this.lexer.yy = this.yy;
+ this.yy.lexer = this.lexer;
+ this.yy.parser = this;
+ if (typeof this.lexer.yylloc == "undefined")
+ this.lexer.yylloc = {};
+ var yyloc = this.lexer.yylloc;
+ lstack.push(yyloc);
+ var ranges = this.lexer.options && this.lexer.options.ranges;
+ if (typeof this.yy.parseError === "function")
+ this.parseError = this.yy.parseError;
+ function popStack(n) {
+ stack.length = stack.length - 2 * n;
+ vstack.length = vstack.length - n;
+ lstack.length = lstack.length - n;
+ }
+ function lex() {
+ var token;
+ token = self.lexer.lex() || 1;
+ if (typeof token !== "number") {
+ token = self.symbols_[token] || token;
+ }
+ return token;
+ }
+ var symbol, preErrorSymbol, state, action, a, r, yyval = {}, p, len, newState, expected;
+ while (true) {
+ state = stack[stack.length - 1];
+ if (this.defaultActions[state]) {
+ action = this.defaultActions[state];
+ } else {
+ if (symbol === null || typeof symbol == "undefined") {
+ symbol = lex();
+ }
+ action = table[state] && table[state][symbol];
+ }
+ if (typeof action === "undefined" || !action.length || !action[0]) {
+ var errStr = "";
+ if (!recovering) {
+ expected = [];
+ for (p in table[state])
+ if (this.terminals_[p] && p > 2) {
+ expected.push("'" + this.terminals_[p] + "'");
+ }
+ if (this.lexer.showPosition) {
+ errStr = "Parse error on line " + (yylineno + 1) + ":\n" + this.lexer.showPosition() + "\nExpecting " + expected.join(", ") + ", got '" + (this.terminals_[symbol] || symbol) + "'";
+ } else {
+ errStr = "Parse error on line " + (yylineno + 1) + ": Unexpected " + (symbol == 1?"end of input":"'" + (this.terminals_[symbol] || symbol) + "'");
+ }
+ this.parseError(errStr, {text: this.lexer.match, token: this.terminals_[symbol] || symbol, line: this.lexer.yylineno, loc: yyloc, expected: expected});
+ }
+ }
+ if (action[0] instanceof Array && action.length > 1) {
+ throw new Error("Parse Error: multiple actions possible at state: " + state + ", token: " + symbol);
+ }
+ switch (action[0]) {
+ case 1:
+ stack.push(symbol);
+ vstack.push(this.lexer.yytext);
+ lstack.push(this.lexer.yylloc);
+ stack.push(action[1]);
+ symbol = null;
+ if (!preErrorSymbol) {
+ yyleng = this.lexer.yyleng;
+ yytext = this.lexer.yytext;
+ yylineno = this.lexer.yylineno;
+ yyloc = this.lexer.yylloc;
+ if (recovering > 0)
+ recovering--;
+ } else {
+ symbol = preErrorSymbol;
+ preErrorSymbol = null;
+ }
+ break;
+ case 2:
+ len = this.productions_[action[1]][1];
+ yyval.$ = vstack[vstack.length - len];
+ yyval._$ = {first_line: lstack[lstack.length - (len || 1)].first_line, last_line: lstack[lstack.length - 1].last_line, first_column: lstack[lstack.length - (len || 1)].first_column, last_column: lstack[lstack.length - 1].last_column};
+ if (ranges) {
+ yyval._$.range = [lstack[lstack.length - (len || 1)].range[0], lstack[lstack.length - 1].range[1]];
+ }
+ r = this.performAction.call(yyval, yytext, yyleng, yylineno, this.yy, action[1], vstack, lstack);
+ if (typeof r !== "undefined") {
+ return r;
+ }
+ if (len) {
+ stack = stack.slice(0, -1 * len * 2);
+ vstack = vstack.slice(0, -1 * len);
+ lstack = lstack.slice(0, -1 * len);
+ }
+ stack.push(this.productions_[action[1]][0]);
+ vstack.push(yyval.$);
+ lstack.push(yyval._$);
+ newState = table[stack[stack.length - 2]][stack[stack.length - 1]];
+ stack.push(newState);
+ break;
+ case 3:
+ return true;
+ }
+ }
+ return true;
+ }
+ };
+ /* Jison generated lexer */
+ var lexer = (function(){
+ var lexer = ({EOF:1,
+ parseError:function parseError(str, hash) {
+ if (this.yy.parser) {
+ this.yy.parser.parseError(str, hash);
+ } else {
+ throw new Error(str);
+ }
+ },
+ setInput:function (input) {
+ this._input = input;
+ this._more = this._less = this.done = false;
+ this.yylineno = this.yyleng = 0;
+ this.yytext = this.matched = this.match = '';
+ this.conditionStack = ['INITIAL'];
+ this.yylloc = {first_line:1,first_column:0,last_line:1,last_column:0};
+ if (this.options.ranges) this.yylloc.range = [0,0];
+ this.offset = 0;
+ return this;
+ },
+ input:function () {
+ var ch = this._input[0];
+ this.yytext += ch;
+ this.yyleng++;
+ this.offset++;
+ this.match += ch;
+ this.matched += ch;
+ var lines = ch.match(/(?:\r\n?|\n).*/g);
+ if (lines) {
+ this.yylineno++;
+ this.yylloc.last_line++;
+ } else {
+ this.yylloc.last_column++;
+ }
+ if (this.options.ranges) this.yylloc.range[1]++;
+
+ this._input = this._input.slice(1);
+ return ch;
+ },
+ unput:function (ch) {
+ var len = ch.length;
+ var lines = ch.split(/(?:\r\n?|\n)/g);
+
+ this._input = ch + this._input;
+ this.yytext = this.yytext.substr(0, this.yytext.length-len-1);
+ //this.yyleng -= len;
+ this.offset -= len;
+ var oldLines = this.match.split(/(?:\r\n?|\n)/g);
+ this.match = this.match.substr(0, this.match.length-1);
+ this.matched = this.matched.substr(0, this.matched.length-1);
+
+ if (lines.length-1) this.yylineno -= lines.length-1;
+ var r = this.yylloc.range;
+
+ this.yylloc = {first_line: this.yylloc.first_line,
+ last_line: this.yylineno+1,
+ first_column: this.yylloc.first_column,
+ last_column: lines ?
+ (lines.length === oldLines.length ? this.yylloc.first_column : 0) + oldLines[oldLines.length - lines.length].length - lines[0].length:
+ this.yylloc.first_column - len
+ };
+
+ if (this.options.ranges) {
+ this.yylloc.range = [r[0], r[0] + this.yyleng - len];
+ }
+ return this;
+ },
+ more:function () {
+ this._more = true;
+ return this;
+ },
+ less:function (n) {
+ this.unput(this.match.slice(n));
+ },
+ pastInput:function () {
+ var past = this.matched.substr(0, this.matched.length - this.match.length);
+ return (past.length > 20 ? '...':'') + past.substr(-20).replace(/\n/g, "");
+ },
+ upcomingInput:function () {
+ var next = this.match;
+ if (next.length < 20) {
+ next += this._input.substr(0, 20-next.length);
+ }
+ return (next.substr(0,20)+(next.length > 20 ? '...':'')).replace(/\n/g, "");
+ },
+ showPosition:function () {
+ var pre = this.pastInput();
+ var c = new Array(pre.length + 1).join("-");
+ return pre + this.upcomingInput() + "\n" + c+"^";
+ },
+ next:function () {
+ if (this.done) {
+ return this.EOF;
+ }
+ if (!this._input) this.done = true;
+
+ var token,
+ match,
+ tempMatch,
+ index,
+ col,
+ lines;
+ if (!this._more) {
+ this.yytext = '';
+ this.match = '';
+ }
+ var rules = this._currentRules();
+ for (var i=0;i < rules.length; i++) {
+ tempMatch = this._input.match(this.rules[rules[i]]);
+ if (tempMatch && (!match || tempMatch[0].length > match[0].length)) {
+ match = tempMatch;
+ index = i;
+ if (!this.options.flex) break;
+ }
+ }
+ if (match) {
+ lines = match[0].match(/(?:\r\n?|\n).*/g);
+ if (lines) this.yylineno += lines.length;
+ this.yylloc = {first_line: this.yylloc.last_line,
+ last_line: this.yylineno+1,
+ first_column: this.yylloc.last_column,
+ last_column: lines ? lines[lines.length-1].length-lines[lines.length-1].match(/\r?\n?/)[0].length : this.yylloc.last_column + match[0].length};
+ this.yytext += match[0];
+ this.match += match[0];
+ this.matches = match;
+ this.yyleng = this.yytext.length;
+ if (this.options.ranges) {
+ this.yylloc.range = [this.offset, this.offset += this.yyleng];
+ }
+ this._more = false;
+ this._input = this._input.slice(match[0].length);
+ this.matched += match[0];
+ token = this.performAction.call(this, this.yy, this, rules[index],this.conditionStack[this.conditionStack.length-1]);
+ if (this.done && this._input) this.done = false;
+ if (token) return token;
+ else return;
+ }
+ if (this._input === "") {
+ return this.EOF;
+ } else {
+ return this.parseError('Lexical error on line '+(this.yylineno+1)+'. Unrecognized text.\n'+this.showPosition(),
+ {text: "", token: null, line: this.yylineno});
+ }
+ },
+ lex:function lex() {
+ var r = this.next();
+ if (typeof r !== 'undefined') {
+ return r;
+ } else {
+ return this.lex();
+ }
+ },
+ begin:function begin(condition) {
+ this.conditionStack.push(condition);
+ },
+ popState:function popState() {
+ return this.conditionStack.pop();
+ },
+ _currentRules:function _currentRules() {
+ return this.conditions[this.conditionStack[this.conditionStack.length-1]].rules;
+ },
+ topState:function () {
+ return this.conditionStack[this.conditionStack.length-2];
+ },
+ pushState:function begin(condition) {
+ this.begin(condition);
+ }});
+ lexer.options = {};
+ lexer.performAction = function anonymous(yy,yy_,$avoiding_name_collisions,YY_START) {
+
+
+ function strip(start, end) {
+ return yy_.yytext = yy_.yytext.substr(start, yy_.yyleng-end);
+ }
+
+
+ var YYSTATE=YY_START
+ switch($avoiding_name_collisions) {
+ case 0:
+ if(yy_.yytext.slice(-2) === "\\\\") {
+ strip(0,1);
+ this.begin("mu");
+ } else if(yy_.yytext.slice(-1) === "\\") {
+ strip(0,1);
+ this.begin("emu");
+ } else {
+ this.begin("mu");
+ }
+ if(yy_.yytext) return 12;
+
+ break;
+ case 1:return 12;
+ break;
+ case 2:
+ this.popState();
+ return 12;
+
+ break;
+ case 3:
+ yy_.yytext = yy_.yytext.substr(5, yy_.yyleng-9);
+ this.popState();
+ return 15;
+
+ break;
+ case 4: return 12;
+ break;
+ case 5:strip(0,4); this.popState(); return 13;
+ break;
+ case 6:return 45;
+ break;
+ case 7:return 46;
+ break;
+ case 8: return 16;
+ break;
+ case 9:
+ this.popState();
+ this.begin('raw');
+ return 18;
+
+ break;
+ case 10:return 34;
+ break;
+ case 11:return 24;
+ break;
+ case 12:return 29;
+ break;
+ case 13:this.popState(); return 28;
+ break;
+ case 14:this.popState(); return 28;
+ break;
+ case 15:return 26;
+ break;
+ case 16:return 26;
+ break;
+ case 17:return 32;
+ break;
+ case 18:return 31;
+ break;
+ case 19:this.popState(); this.begin('com');
+ break;
+ case 20:strip(3,5); this.popState(); return 13;
+ break;
+ case 21:return 31;
+ break;
+ case 22:return 51;
+ break;
+ case 23:return 50;
+ break;
+ case 24:return 50;
+ break;
+ case 25:return 54;
+ break;
+ case 26:// ignore whitespace
+ break;
+ case 27:this.popState(); return 33;
+ break;
+ case 28:this.popState(); return 25;
+ break;
+ case 29:yy_.yytext = strip(1,2).replace(/\\"/g,'"'); return 42;
+ break;
+ case 30:yy_.yytext = strip(1,2).replace(/\\'/g,"'"); return 42;
+ break;
+ case 31:return 52;
+ break;
+ case 32:return 44;
+ break;
+ case 33:return 44;
+ break;
+ case 34:return 43;
+ break;
+ case 35:return 50;
+ break;
+ case 36:yy_.yytext = strip(1,2); return 50;
+ break;
+ case 37:return 'INVALID';
+ break;
+ case 38:return 5;
+ break;
+ }
+ };
+ lexer.rules = [/^(?:[^\x00]*?(?=(\{\{)))/,/^(?:[^\x00]+)/,/^(?:[^\x00]{2,}?(?=(\{\{|\\\{\{|\\\\\{\{|$)))/,/^(?:\{\{\{\{\/[^\s!"#%-,\.\/;->@\[-\^`\{-~]+(?=[=}\s\/.])\}\}\}\})/,/^(?:[^\x00]*?(?=(\{\{\{\{\/)))/,/^(?:[\s\S]*?--\}\})/,/^(?:\()/,/^(?:\))/,/^(?:\{\{\{\{)/,/^(?:\}\}\}\})/,/^(?:\{\{(~)?>)/,/^(?:\{\{(~)?#)/,/^(?:\{\{(~)?\/)/,/^(?:\{\{(~)?\^\s*(~)?\}\})/,/^(?:\{\{(~)?\s*else\s*(~)?\}\})/,/^(?:\{\{(~)?\^)/,/^(?:\{\{(~)?\s*else\b)/,/^(?:\{\{(~)?\{)/,/^(?:\{\{(~)?&)/,/^(?:\{\{!--)/,/^(?:\{\{![\s\S]*?\}\})/,/^(?:\{\{(~)?)/,/^(?:=)/,/^(?:\.\.)/,/^(?:\.(?=([=~}\s\/.)])))/,/^(?:[\/.])/,/^(?:\s+)/,/^(?:\}(~)?\}\})/,/^(?:(~)?\}\})/,/^(?:"(\\["]|[^"])*")/,/^(?:'(\\[']|[^'])*')/,/^(?:@)/,/^(?:true(?=([~}\s)])))/,/^(?:false(?=([~}\s)])))/,/^(?:-?[0-9]+(?:\.[0-9]+)?(?=([~}\s)])))/,/^(?:([^\s!"#%-,\.\/;->@\[-\^`\{-~]+(?=([=~}\s\/.)]))))/,/^(?:\[[^\]]*\])/,/^(?:.)/,/^(?:$)/];
+ lexer.conditions = {"mu":{"rules":[6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38],"inclusive":false},"emu":{"rules":[2],"inclusive":false},"com":{"rules":[5],"inclusive":false},"raw":{"rules":[3,4],"inclusive":false},"INITIAL":{"rules":[0,1,38],"inclusive":true}};
+ return lexer;})()
+ parser.lexer = lexer;
+ function Parser () { this.yy = {}; }Parser.prototype = parser;parser.Parser = Parser;
+ return new Parser;
+ })();__exports__ = handlebars;
+ /* jshint ignore:end */
+ return __exports__;
+})();
+
+// handlebars/compiler/helpers.js
+var __module10__ = (function(__dependency1__) {
+ "use strict";
+ var __exports__ = {};
+ var Exception = __dependency1__;
+
+ function stripFlags(open, close) {
+ return {
+ left: open.charAt(2) === '~',
+ right: close.charAt(close.length-3) === '~'
+ };
+ }
+
+ __exports__.stripFlags = stripFlags;
+ function prepareBlock(mustache, program, inverseAndProgram, close, inverted, locInfo) {
+ /*jshint -W040 */
+ if (mustache.sexpr.id.original !== close.path.original) {
+ throw new Exception(mustache.sexpr.id.original + ' doesn\'t match ' + close.path.original, mustache);
+ }
+
+ var inverse = inverseAndProgram && inverseAndProgram.program;
+
+ var strip = {
+ left: mustache.strip.left,
+ right: close.strip.right,
+
+ // Determine the standalone candiacy. Basically flag our content as being possibly standalone
+ // so our parent can determine if we actually are standalone
+ openStandalone: isNextWhitespace(program.statements),
+ closeStandalone: isPrevWhitespace((inverse || program).statements)
+ };
+
+ if (mustache.strip.right) {
+ omitRight(program.statements, null, true);
+ }
+
+ if (inverse) {
+ var inverseStrip = inverseAndProgram.strip;
+
+ if (inverseStrip.left) {
+ omitLeft(program.statements, null, true);
+ }
+ if (inverseStrip.right) {
+ omitRight(inverse.statements, null, true);
+ }
+ if (close.strip.left) {
+ omitLeft(inverse.statements, null, true);
+ }
+
+ // Find standalone else statments
+ if (isPrevWhitespace(program.statements)
+ && isNextWhitespace(inverse.statements)) {
+
+ omitLeft(program.statements);
+ omitRight(inverse.statements);
+ }
+ } else {
+ if (close.strip.left) {
+ omitLeft(program.statements, null, true);
+ }
+ }
+
+ if (inverted) {
+ return new this.BlockNode(mustache, inverse, program, strip, locInfo);
+ } else {
+ return new this.BlockNode(mustache, program, inverse, strip, locInfo);
+ }
+ }
+
+ __exports__.prepareBlock = prepareBlock;
+ function prepareProgram(statements, isRoot) {
+ for (var i = 0, l = statements.length; i < l; i++) {
+ var current = statements[i],
+ strip = current.strip;
+
+ if (!strip) {
+ continue;
+ }
+
+ var _isPrevWhitespace = isPrevWhitespace(statements, i, isRoot, current.type === 'partial'),
+ _isNextWhitespace = isNextWhitespace(statements, i, isRoot),
+
+ openStandalone = strip.openStandalone && _isPrevWhitespace,
+ closeStandalone = strip.closeStandalone && _isNextWhitespace,
+ inlineStandalone = strip.inlineStandalone && _isPrevWhitespace && _isNextWhitespace;
+
+ if (strip.right) {
+ omitRight(statements, i, true);
+ }
+ if (strip.left) {
+ omitLeft(statements, i, true);
+ }
+
+ if (inlineStandalone) {
+ omitRight(statements, i);
+
+ if (omitLeft(statements, i)) {
+ // If we are on a standalone node, save the indent info for partials
+ if (current.type === 'partial') {
+ current.indent = (/([ \t]+$)/).exec(statements[i-1].original) ? RegExp.$1 : '';
+ }
+ }
+ }
+ if (openStandalone) {
+ omitRight((current.program || current.inverse).statements);
+
+ // Strip out the previous content node if it's whitespace only
+ omitLeft(statements, i);
+ }
+ if (closeStandalone) {
+ // Always strip the next node
+ omitRight(statements, i);
+
+ omitLeft((current.inverse || current.program).statements);
+ }
+ }
+
+ return statements;
+ }
+
+ __exports__.prepareProgram = prepareProgram;function isPrevWhitespace(statements, i, isRoot) {
+ if (i === undefined) {
+ i = statements.length;
+ }
+
+ // Nodes that end with newlines are considered whitespace (but are special
+ // cased for strip operations)
+ var prev = statements[i-1],
+ sibling = statements[i-2];
+ if (!prev) {
+ return isRoot;
+ }
+
+ if (prev.type === 'content') {
+ return (sibling || !isRoot ? (/\r?\n\s*?$/) : (/(^|\r?\n)\s*?$/)).test(prev.original);
+ }
+ }
+ function isNextWhitespace(statements, i, isRoot) {
+ if (i === undefined) {
+ i = -1;
+ }
+
+ var next = statements[i+1],
+ sibling = statements[i+2];
+ if (!next) {
+ return isRoot;
+ }
+
+ if (next.type === 'content') {
+ return (sibling || !isRoot ? (/^\s*?\r?\n/) : (/^\s*?(\r?\n|$)/)).test(next.original);
+ }
+ }
+
+ // Marks the node to the right of the position as omitted.
+ // I.e. {{foo}}' ' will mark the ' ' node as omitted.
+ //
+ // If i is undefined, then the first child will be marked as such.
+ //
+ // If mulitple is truthy then all whitespace will be stripped out until non-whitespace
+ // content is met.
+ function omitRight(statements, i, multiple) {
+ var current = statements[i == null ? 0 : i + 1];
+ if (!current || current.type !== 'content' || (!multiple && current.rightStripped)) {
+ return;
+ }
+
+ var original = current.string;
+ current.string = current.string.replace(multiple ? (/^\s+/) : (/^[ \t]*\r?\n?/), '');
+ current.rightStripped = current.string !== original;
+ }
+
+ // Marks the node to the left of the position as omitted.
+ // I.e. ' '{{foo}} will mark the ' ' node as omitted.
+ //
+ // If i is undefined then the last child will be marked as such.
+ //
+ // If mulitple is truthy then all whitespace will be stripped out until non-whitespace
+ // content is met.
+ function omitLeft(statements, i, multiple) {
+ var current = statements[i == null ? statements.length - 1 : i - 1];
+ if (!current || current.type !== 'content' || (!multiple && current.leftStripped)) {
+ return;
+ }
+
+ // We omit the last node if it's whitespace only and not preceeded by a non-content node.
+ var original = current.string;
+ current.string = current.string.replace(multiple ? (/\s+$/) : (/[ \t]+$/), '');
+ current.leftStripped = current.string !== original;
+ return current.leftStripped;
+ }
+ return __exports__;
+})(__module5__);
+
+// handlebars/compiler/base.js
+var __module8__ = (function(__dependency1__, __dependency2__, __dependency3__, __dependency4__) {
+ "use strict";
+ var __exports__ = {};
+ var parser = __dependency1__;
+ var AST = __dependency2__;
+ var Helpers = __dependency3__;
+ var extend = __dependency4__.extend;
+
+ __exports__.parser = parser;
+
+ var yy = {};
+ extend(yy, Helpers, AST);
+
+ function parse(input) {
+ // Just return if an already-compile AST was passed in.
+ if (input.constructor === AST.ProgramNode) { return input; }
+
+ parser.yy = yy;
+
+ return parser.parse(input);
+ }
+
+ __exports__.parse = parse;
+ return __exports__;
+})(__module9__, __module7__, __module10__, __module3__);
+
+// handlebars/compiler/compiler.js
+var __module11__ = (function(__dependency1__, __dependency2__) {
+ "use strict";
+ var __exports__ = {};
+ var Exception = __dependency1__;
+ var isArray = __dependency2__.isArray;
+
+ var slice = [].slice;
+
+ function Compiler() {}
+
+ __exports__.Compiler = Compiler;// the foundHelper register will disambiguate helper lookup from finding a
+ // function in a context. This is necessary for mustache compatibility, which
+ // requires that context functions in blocks are evaluated by blockHelperMissing,
+ // and then proceed as if the resulting value was provided to blockHelperMissing.
+
+ Compiler.prototype = {
+ compiler: Compiler,
+
+ equals: function(other) {
+ var len = this.opcodes.length;
+ if (other.opcodes.length !== len) {
+ return false;
+ }
+
+ for (var i = 0; i < len; i++) {
+ var opcode = this.opcodes[i],
+ otherOpcode = other.opcodes[i];
+ if (opcode.opcode !== otherOpcode.opcode || !argEquals(opcode.args, otherOpcode.args)) {
+ return false;
+ }
+ }
+
+ // We know that length is the same between the two arrays because they are directly tied
+ // to the opcode behavior above.
+ len = this.children.length;
+ for (i = 0; i < len; i++) {
+ if (!this.children[i].equals(other.children[i])) {
+ return false;
+ }
+ }
+
+ return true;
+ },
+
+ guid: 0,
+
+ compile: function(program, options) {
+ this.opcodes = [];
+ this.children = [];
+ this.depths = {list: []};
+ this.options = options;
+ this.stringParams = options.stringParams;
+ this.trackIds = options.trackIds;
+
+ // These changes will propagate to the other compiler components
+ var knownHelpers = this.options.knownHelpers;
+ this.options.knownHelpers = {
+ 'helperMissing': true,
+ 'blockHelperMissing': true,
+ 'each': true,
+ 'if': true,
+ 'unless': true,
+ 'with': true,
+ 'log': true,
+ 'lookup': true
+ };
+ if (knownHelpers) {
+ for (var name in knownHelpers) {
+ this.options.knownHelpers[name] = knownHelpers[name];
+ }
+ }
+
+ return this.accept(program);
+ },
+
+ accept: function(node) {
+ return this[node.type](node);
+ },
+
+ program: function(program) {
+ var statements = program.statements;
+
+ for(var i=0, l=statements.length; i<l; i++) {
+ this.accept(statements[i]);
+ }
+ this.isSimple = l === 1;
+
+ this.depths.list = this.depths.list.sort(function(a, b) {
+ return a - b;
+ });
+
+ return this;
+ },
+
+ compileProgram: function(program) {
+ var result = new this.compiler().compile(program, this.options);
+ var guid = this.guid++, depth;
+
+ this.usePartial = this.usePartial || result.usePartial;
+
+ this.children[guid] = result;
+
+ for(var i=0, l=result.depths.list.length; i<l; i++) {
+ depth = result.depths.list[i];
+
+ if(depth < 2) { continue; }
+ else { this.addDepth(depth - 1); }
+ }
+
+ return guid;
+ },
+
+ block: function(block) {
+ var mustache = block.mustache,
+ program = block.program,
+ inverse = block.inverse;
+
+ if (program) {
+ program = this.compileProgram(program);
+ }
+
+ if (inverse) {
+ inverse = this.compileProgram(inverse);
+ }
+
+ var sexpr = mustache.sexpr;
+ var type = this.classifySexpr(sexpr);
+
+ if (type === "helper") {
+ this.helperSexpr(sexpr, program, inverse);
+ } else if (type === "simple") {
+ this.simpleSexpr(sexpr);
+
+ // now that the simple mustache is resolved, we need to
+ // evaluate it by executing `blockHelperMissing`
+ this.opcode('pushProgram', program);
+ this.opcode('pushProgram', inverse);
+ this.opcode('emptyHash');
+ this.opcode('blockValue', sexpr.id.original);
+ } else {
+ this.ambiguousSexpr(sexpr, program, inverse);
+
+ // now that the simple mustache is resolved, we need to
+ // evaluate it by executing `blockHelperMissing`
+ this.opcode('pushProgram', program);
+ this.opcode('pushProgram', inverse);
+ this.opcode('emptyHash');
+ this.opcode('ambiguousBlockValue');
+ }
+
+ this.opcode('append');
+ },
+
+ hash: function(hash) {
+ var pairs = hash.pairs, i, l;
+
+ this.opcode('pushHash');
+
+ for(i=0, l=pairs.length; i<l; i++) {
+ this.pushParam(pairs[i][1]);
+ }
+ while(i--) {
+ this.opcode('assignToHash', pairs[i][0]);
+ }
+ this.opcode('popHash');
+ },
+
+ partial: function(partial) {
+ var partialName = partial.partialName;
+ this.usePartial = true;
+
+ if (partial.hash) {
+ this.accept(partial.hash);
+ } else {
+ this.opcode('push', 'undefined');
+ }
+
+ if (partial.context) {
+ this.accept(partial.context);
+ } else {
+ this.opcode('getContext', 0);
+ this.opcode('pushContext');
+ }
+
+ this.opcode('invokePartial', partialName.name, partial.indent || '');
+ this.opcode('append');
+ },
+
+ content: function(content) {
+ if (content.string) {
+ this.opcode('appendContent', content.string);
+ }
+ },
+
+ mustache: function(mustache) {
+ this.sexpr(mustache.sexpr);
+
+ if(mustache.escaped && !this.options.noEscape) {
+ this.opcode('appendEscaped');
+ } else {
+ this.opcode('append');
+ }
+ },
+
+ ambiguousSexpr: function(sexpr, program, inverse) {
+ var id = sexpr.id,
+ name = id.parts[0],
+ isBlock = program != null || inverse != null;
+
+ this.opcode('getContext', id.depth);
+
+ this.opcode('pushProgram', program);
+ this.opcode('pushProgram', inverse);
+
+ this.ID(id);
+
+ this.opcode('invokeAmbiguous', name, isBlock);
+ },
+
+ simpleSexpr: function(sexpr) {
+ var id = sexpr.id;
+
+ if (id.type === 'DATA') {
+ this.DATA(id);
+ } else if (id.parts.length) {
+ this.ID(id);
+ } else {
+ // Simplified ID for `this`
+ this.addDepth(id.depth);
+ this.opcode('getContext', id.depth);
+ this.opcode('pushContext');
+ }
+
+ this.opcode('resolvePossibleLambda');
+ },
+
+ helperSexpr: function(sexpr, program, inverse) {
+ var params = this.setupFullMustacheParams(sexpr, program, inverse),
+ id = sexpr.id,
+ name = id.parts[0];
+
+ if (this.options.knownHelpers[name]) {
+ this.opcode('invokeKnownHelper', params.length, name);
+ } else if (this.options.knownHelpersOnly) {
+ throw new Exception("You specified knownHelpersOnly, but used the unknown helper " + name, sexpr);
+ } else {
+ id.falsy = true;
+
+ this.ID(id);
+ this.opcode('invokeHelper', params.length, id.original, id.isSimple);
+ }
+ },
+
+ sexpr: function(sexpr) {
+ var type = this.classifySexpr(sexpr);
+
+ if (type === "simple") {
+ this.simpleSexpr(sexpr);
+ } else if (type === "helper") {
+ this.helperSexpr(sexpr);
+ } else {
+ this.ambiguousSexpr(sexpr);
+ }
+ },
+
+ ID: function(id) {
+ this.addDepth(id.depth);
+ this.opcode('getContext', id.depth);
+
+ var name = id.parts[0];
+ if (!name) {
+ // Context reference, i.e. `{{foo .}}` or `{{foo ..}}`
+ this.opcode('pushContext');
+ } else {
+ this.opcode('lookupOnContext', id.parts, id.falsy, id.isScoped);
+ }
+ },
+
+ DATA: function(data) {
+ this.options.data = true;
+ this.opcode('lookupData', data.id.depth, data.id.parts);
+ },
+
+ STRING: function(string) {
+ this.opcode('pushString', string.string);
+ },
+
+ NUMBER: function(number) {
+ this.opcode('pushLiteral', number.number);
+ },
+
+ BOOLEAN: function(bool) {
+ this.opcode('pushLiteral', bool.bool);
+ },
+
+ comment: function() {},
+
+ // HELPERS
+ opcode: function(name) {
+ this.opcodes.push({ opcode: name, args: slice.call(arguments, 1) });
+ },
+
+ addDepth: function(depth) {
+ if(depth === 0) { return; }
+
+ if(!this.depths[depth]) {
+ this.depths[depth] = true;
+ this.depths.list.push(depth);
+ }
+ },
+
+ classifySexpr: function(sexpr) {
+ var isHelper = sexpr.isHelper;
+ var isEligible = sexpr.eligibleHelper;
+ var options = this.options;
+
+ // if ambiguous, we can possibly resolve the ambiguity now
+ // An eligible helper is one that does not have a complex path, i.e. `this.foo`, `../foo` etc.
+ if (isEligible && !isHelper) {
+ var name = sexpr.id.parts[0];
+
+ if (options.knownHelpers[name]) {
+ isHelper = true;
+ } else if (options.knownHelpersOnly) {
+ isEligible = false;
+ }
+ }
+
+ if (isHelper) { return "helper"; }
+ else if (isEligible) { return "ambiguous"; }
+ else { return "simple"; }
+ },
+
+ pushParams: function(params) {
+ for(var i=0, l=params.length; i<l; i++) {
+ this.pushParam(params[i]);
+ }
+ },
+
+ pushParam: function(val) {
+ if (this.stringParams) {
+ if(val.depth) {
+ this.addDepth(val.depth);
+ }
+ this.opcode('getContext', val.depth || 0);
+ this.opcode('pushStringParam', val.stringModeValue, val.type);
+
+ if (val.type === 'sexpr') {
+ // Subexpressions get evaluated and passed in
+ // in string params mode.
+ this.sexpr(val);
+ }
+ } else {
+ if (this.trackIds) {
+ this.opcode('pushId', val.type, val.idName || val.stringModeValue);
+ }
+ this.accept(val);
+ }
+ },
+
+ setupFullMustacheParams: function(sexpr, program, inverse) {
+ var params = sexpr.params;
+ this.pushParams(params);
+
+ this.opcode('pushProgram', program);
+ this.opcode('pushProgram', inverse);
+
+ if (sexpr.hash) {
+ this.hash(sexpr.hash);
+ } else {
+ this.opcode('emptyHash');
+ }
+
+ return params;
+ }
+ };
+
+ function precompile(input, options, env) {
+ if (input == null || (typeof input !== 'string' && input.constructor !== env.AST.ProgramNode)) {
+ throw new Exception("You must pass a string or Handlebars AST to Handlebars.precompile. You passed " + input);
+ }
+
+ options = options || {};
+ if (!('data' in options)) {
+ options.data = true;
+ }
+ if (options.compat) {
+ options.useDepths = true;
+ }
+
+ var ast = env.parse(input);
+ var environment = new env.Compiler().compile(ast, options);
+ return new env.JavaScriptCompiler().compile(environment, options);
+ }
+
+ __exports__.precompile = precompile;function compile(input, options, env) {
+ if (input == null || (typeof input !== 'string' && input.constructor !== env.AST.ProgramNode)) {
+ throw new Exception("You must pass a string or Handlebars AST to Handlebars.compile. You passed " + input);
+ }
+
+ options = options || {};
+
+ if (!('data' in options)) {
+ options.data = true;
+ }
+ if (options.compat) {
+ options.useDepths = true;
+ }
+
+ var compiled;
+
+ function compileInput() {
+ var ast = env.parse(input);
+ var environment = new env.Compiler().compile(ast, options);
+ var templateSpec = new env.JavaScriptCompiler().compile(environment, options, undefined, true);
+ return env.template(templateSpec);
+ }
+
+ // Template is only compiled on first use and cached after that point.
+ var ret = function(context, options) {
+ if (!compiled) {
+ compiled = compileInput();
+ }
+ return compiled.call(this, context, options);
+ };
+ ret._setup = function(options) {
+ if (!compiled) {
+ compiled = compileInput();
+ }
+ return compiled._setup(options);
+ };
+ ret._child = function(i, data, depths) {
+ if (!compiled) {
+ compiled = compileInput();
+ }
+ return compiled._child(i, data, depths);
+ };
+ return ret;
+ }
+
+ __exports__.compile = compile;function argEquals(a, b) {
+ if (a === b) {
+ return true;
+ }
+
+ if (isArray(a) && isArray(b) && a.length === b.length) {
+ for (var i = 0; i < a.length; i++) {
+ if (!argEquals(a[i], b[i])) {
+ return false;
+ }
+ }
+ return true;
+ }
+ }
+ return __exports__;
+})(__module5__, __module3__);
+
+// handlebars/compiler/javascript-compiler.js
+var __module12__ = (function(__dependency1__, __dependency2__) {
+ "use strict";
+ var __exports__;
+ var COMPILER_REVISION = __dependency1__.COMPILER_REVISION;
+ var REVISION_CHANGES = __dependency1__.REVISION_CHANGES;
+ var Exception = __dependency2__;
+
+ function Literal(value) {
+ this.value = value;
+ }
+
+ function JavaScriptCompiler() {}
+
+ JavaScriptCompiler.prototype = {
+ // PUBLIC API: You can override these methods in a subclass to provide
+ // alternative compiled forms for name lookup and buffering semantics
+ nameLookup: function(parent, name /* , type*/) {
+ if (JavaScriptCompiler.isValidJavaScriptVariableName(name)) {
+ return parent + "." + name;
+ } else {
+ return parent + "['" + name + "']";
+ }
+ },
+ depthedLookup: function(name) {
+ this.aliases.lookup = 'this.lookup';
+
+ return 'lookup(depths, "' + name + '")';
+ },
+
+ compilerInfo: function() {
+ var revision = COMPILER_REVISION,
+ versions = REVISION_CHANGES[revision];
+ return [revision, versions];
+ },
+
+ appendToBuffer: function(string) {
+ if (this.environment.isSimple) {
+ return "return " + string + ";";
+ } else {
+ return {
+ appendToBuffer: true,
+ content: string,
+ toString: function() { return "buffer += " + string + ";"; }
+ };
+ }
+ },
+
+ initializeBuffer: function() {
+ return this.quotedString("");
+ },
+
+ namespace: "Handlebars",
+ // END PUBLIC API
+
+ compile: function(environment, options, context, asObject) {
+ this.environment = environment;
+ this.options = options;
+ this.stringParams = this.options.stringParams;
+ this.trackIds = this.options.trackIds;
+ this.precompile = !asObject;
+
+ this.name = this.environment.name;
+ this.isChild = !!context;
+ this.context = context || {
+ programs: [],
+ environments: []
+ };
+
+ this.preamble();
+
+ this.stackSlot = 0;
+ this.stackVars = [];
+ this.aliases = {};
+ this.registers = { list: [] };
+ this.hashes = [];
+ this.compileStack = [];
+ this.inlineStack = [];
+
+ this.compileChildren(environment, options);
+
+ this.useDepths = this.useDepths || environment.depths.list.length || this.options.compat;
+
+ var opcodes = environment.opcodes,
+ opcode,
+ i,
+ l;
+
+ for (i = 0, l = opcodes.length; i < l; i++) {
+ opcode = opcodes[i];
+
+ this[opcode.opcode].apply(this, opcode.args);
+ }
+
+ // Flush any trailing content that might be pending.
+ this.pushSource('');
+
+ /* istanbul ignore next */
+ if (this.stackSlot || this.inlineStack.length || this.compileStack.length) {
+ throw new Exception('Compile completed with content left on stack');
+ }
+
+ var fn = this.createFunctionContext(asObject);
+ if (!this.isChild) {
+ var ret = {
+ compiler: this.compilerInfo(),
+ main: fn
+ };
+ var programs = this.context.programs;
+ for (i = 0, l = programs.length; i < l; i++) {
+ if (programs[i]) {
+ ret[i] = programs[i];
+ }
+ }
+
+ if (this.environment.usePartial) {
+ ret.usePartial = true;
+ }
+ if (this.options.data) {
+ ret.useData = true;
+ }
+ if (this.useDepths) {
+ ret.useDepths = true;
+ }
+ if (this.options.compat) {
+ ret.compat = true;
+ }
+
+ if (!asObject) {
+ ret.compiler = JSON.stringify(ret.compiler);
+ ret = this.objectLiteral(ret);
+ }
+
+ return ret;
+ } else {
+ return fn;
+ }
+ },
+
+ preamble: function() {
+ // track the last context pushed into place to allow skipping the
+ // getContext opcode when it would be a noop
+ this.lastContext = 0;
+ this.source = [];
+ },
+
+ createFunctionContext: function(asObject) {
+ var varDeclarations = '';
+
+ var locals = this.stackVars.concat(this.registers.list);
+ if(locals.length > 0) {
+ varDeclarations += ", " + locals.join(", ");
+ }
+
+ // Generate minimizer alias mappings
+ for (var alias in this.aliases) {
+ if (this.aliases.hasOwnProperty(alias)) {
+ varDeclarations += ', ' + alias + '=' + this.aliases[alias];
+ }
+ }
+
+ var params = ["depth0", "helpers", "partials", "data"];
+
+ if (this.useDepths) {
+ params.push('depths');
+ }
+
+ // Perform a second pass over the output to merge content when possible
+ var source = this.mergeSource(varDeclarations);
+
+ if (asObject) {
+ params.push(source);
+
+ return Function.apply(this, params);
+ } else {
+ return 'function(' + params.join(',') + ') {\n ' + source + '}';
+ }
+ },
+ mergeSource: function(varDeclarations) {
+ var source = '',
+ buffer,
+ appendOnly = !this.forceBuffer,
+ appendFirst;
+
+ for (var i = 0, len = this.source.length; i < len; i++) {
+ var line = this.source[i];
+ if (line.appendToBuffer) {
+ if (buffer) {
+ buffer = buffer + '\n + ' + line.content;
+ } else {
+ buffer = line.content;
+ }
+ } else {
+ if (buffer) {
+ if (!source) {
+ appendFirst = true;
+ source = buffer + ';\n ';
+ } else {
+ source += 'buffer += ' + buffer + ';\n ';
+ }
+ buffer = undefined;
+ }
+ source += line + '\n ';
+
+ if (!this.environment.isSimple) {
+ appendOnly = false;
+ }
+ }
+ }
+
+ if (appendOnly) {
+ if (buffer || !source) {
+ source += 'return ' + (buffer || '""') + ';\n';
+ }
+ } else {
+ varDeclarations += ", buffer = " + (appendFirst ? '' : this.initializeBuffer());
+ if (buffer) {
+ source += 'return buffer + ' + buffer + ';\n';
+ } else {
+ source += 'return buffer;\n';
+ }
+ }
+
+ if (varDeclarations) {
+ source = 'var ' + varDeclarations.substring(2) + (appendFirst ? '' : ';\n ') + source;
+ }
+
+ return source;
+ },
+
+ // [blockValue]
+ //
+ // On stack, before: hash, inverse, program, value
+ // On stack, after: return value of blockHelperMissing
+ //
+ // The purpose of this opcode is to take a block of the form
+ // `{{#this.foo}}...{{/this.foo}}`, resolve the value of `foo`, and
+ // replace it on the stack with the result of properly
+ // invoking blockHelperMissing.
+ blockValue: function(name) {
+ this.aliases.blockHelperMissing = 'helpers.blockHelperMissing';
+
+ var params = [this.contextName(0)];
+ this.setupParams(name, 0, params);
+
+ var blockName = this.popStack();
+ params.splice(1, 0, blockName);
+
+ this.push('blockHelperMissing.call(' + params.join(', ') + ')');
+ },
+
+ // [ambiguousBlockValue]
+ //
+ // On stack, before: hash, inverse, program, value
+ // Compiler value, before: lastHelper=value of last found helper, if any
+ // On stack, after, if no lastHelper: same as [blockValue]
+ // On stack, after, if lastHelper: value
+ ambiguousBlockValue: function() {
+ this.aliases.blockHelperMissing = 'helpers.blockHelperMissing';
+
+ // We're being a bit cheeky and reusing the options value from the prior exec
+ var params = [this.contextName(0)];
+ this.setupParams('', 0, params, true);
+
+ this.flushInline();
+
+ var current = this.topStack();
+ params.splice(1, 0, current);
+
+ this.pushSource("if (!" + this.lastHelper + ") { " + current + " = blockHelperMissing.call(" + params.join(", ") + "); }");
+ },
+
+ // [appendContent]
+ //
+ // On stack, before: ...
+ // On stack, after: ...
+ //
+ // Appends the string value of `content` to the current buffer
+ appendContent: function(content) {
+ if (this.pendingContent) {
+ content = this.pendingContent + content;
+ }
+
+ this.pendingContent = content;
+ },
+
+ // [append]
+ //
+ // On stack, before: value, ...
+ // On stack, after: ...
+ //
+ // Coerces `value` to a String and appends it to the current buffer.
+ //
+ // If `value` is truthy, or 0, it is coerced into a string and appended
+ // Otherwise, the empty string is appended
+ append: function() {
+ // Force anything that is inlined onto the stack so we don't have duplication
+ // when we examine local
+ this.flushInline();
+ var local = this.popStack();
+ this.pushSource('if (' + local + ' != null) { ' + this.appendToBuffer(local) + ' }');
+ if (this.environment.isSimple) {
+ this.pushSource("else { " + this.appendToBuffer("''") + " }");
+ }
+ },
+
+ // [appendEscaped]
+ //
+ // On stack, before: value, ...
+ // On stack, after: ...
+ //
+ // Escape `value` and append it to the buffer
+ appendEscaped: function() {
+ this.aliases.escapeExpression = 'this.escapeExpression';
+
+ this.pushSource(this.appendToBuffer("escapeExpression(" + this.popStack() + ")"));
+ },
+
+ // [getContext]
+ //
+ // On stack, before: ...
+ // On stack, after: ...
+ // Compiler value, after: lastContext=depth
+ //
+ // Set the value of the `lastContext` compiler value to the depth
+ getContext: function(depth) {
+ this.lastContext = depth;
+ },
+
+ // [pushContext]
+ //
+ // On stack, before: ...
+ // On stack, after: currentContext, ...
+ //
+ // Pushes the value of the current context onto the stack.
+ pushContext: function() {
+ this.pushStackLiteral(this.contextName(this.lastContext));
+ },
+
+ // [lookupOnContext]
+ //
+ // On stack, before: ...
+ // On stack, after: currentContext[name], ...
+ //
+ // Looks up the value of `name` on the current context and pushes
+ // it onto the stack.
+ lookupOnContext: function(parts, falsy, scoped) {
+ /*jshint -W083 */
+ var i = 0,
+ len = parts.length;
+
+ if (!scoped && this.options.compat && !this.lastContext) {
+ // The depthed query is expected to handle the undefined logic for the root level that
+ // is implemented below, so we evaluate that directly in compat mode
+ this.push(this.depthedLookup(parts[i++]));
+ } else {
+ this.pushContext();
+ }
+
+ for (; i < len; i++) {
+ this.replaceStack(function(current) {
+ var lookup = this.nameLookup(current, parts[i], 'context');
+ // We want to ensure that zero and false are handled properly if the context (falsy flag)
+ // needs to have the special handling for these values.
+ if (!falsy) {
+ return ' != null ? ' + lookup + ' : ' + current;
+ } else {
+ // Otherwise we can use generic falsy handling
+ return ' && ' + lookup;
+ }
+ });
+ }
+ },
+
+ // [lookupData]
+ //
+ // On stack, before: ...
+ // On stack, after: data, ...
+ //
+ // Push the data lookup operator
+ lookupData: function(depth, parts) {
+ /*jshint -W083 */
+ if (!depth) {
+ this.pushStackLiteral('data');
+ } else {
+ this.pushStackLiteral('this.data(data, ' + depth + ')');
+ }
+
+ var len = parts.length;
+ for (var i = 0; i < len; i++) {
+ this.replaceStack(function(current) {
+ return ' && ' + this.nameLookup(current, parts[i], 'data');
+ });
+ }
+ },
+
+ // [resolvePossibleLambda]
+ //
+ // On stack, before: value, ...
+ // On stack, after: resolved value, ...
+ //
+ // If the `value` is a lambda, replace it on the stack by
+ // the return value of the lambda
+ resolvePossibleLambda: function() {
+ this.aliases.lambda = 'this.lambda';
+
+ this.push('lambda(' + this.popStack() + ', ' + this.contextName(0) + ')');
+ },
+
+ // [pushStringParam]
+ //
+ // On stack, before: ...
+ // On stack, after: string, currentContext, ...
+ //
+ // This opcode is designed for use in string mode, which
+ // provides the string value of a parameter along with its
+ // depth rather than resolving it immediately.
+ pushStringParam: function(string, type) {
+ this.pushContext();
+ this.pushString(type);
+
+ // If it's a subexpression, the string result
+ // will be pushed after this opcode.
+ if (type !== 'sexpr') {
+ if (typeof string === 'string') {
+ this.pushString(string);
+ } else {
+ this.pushStackLiteral(string);
+ }
+ }
+ },
+
+ emptyHash: function() {
+ this.pushStackLiteral('{}');
+
+ if (this.trackIds) {
+ this.push('{}'); // hashIds
+ }
+ if (this.stringParams) {
+ this.push('{}'); // hashContexts
+ this.push('{}'); // hashTypes
+ }
+ },
+ pushHash: function() {
+ if (this.hash) {
+ this.hashes.push(this.hash);
+ }
+ this.hash = {values: [], types: [], contexts: [], ids: []};
+ },
+ popHash: function() {
+ var hash = this.hash;
+ this.hash = this.hashes.pop();
+
+ if (this.trackIds) {
+ this.push('{' + hash.ids.join(',') + '}');
+ }
+ if (this.stringParams) {
+ this.push('{' + hash.contexts.join(',') + '}');
+ this.push('{' + hash.types.join(',') + '}');
+ }
+
+ this.push('{\n ' + hash.values.join(',\n ') + '\n }');
+ },
+
+ // [pushString]
+ //
+ // On stack, before: ...
+ // On stack, after: quotedString(string), ...
+ //
+ // Push a quoted version of `string` onto the stack
+ pushString: function(string) {
+ this.pushStackLiteral(this.quotedString(string));
+ },
+
+ // [push]
+ //
+ // On stack, before: ...
+ // On stack, after: expr, ...
+ //
+ // Push an expression onto the stack
+ push: function(expr) {
+ this.inlineStack.push(expr);
+ return expr;
+ },
+
+ // [pushLiteral]
+ //
+ // On stack, before: ...
+ // On stack, after: value, ...
+ //
+ // Pushes a value onto the stack. This operation prevents
+ // the compiler from creating a temporary variable to hold
+ // it.
+ pushLiteral: function(value) {
+ this.pushStackLiteral(value);
+ },
+
+ // [pushProgram]
+ //
+ // On stack, before: ...
+ // On stack, after: program(guid), ...
+ //
+ // Push a program expression onto the stack. This takes
+ // a compile-time guid and converts it into a runtime-accessible
+ // expression.
+ pushProgram: function(guid) {
+ if (guid != null) {
+ this.pushStackLiteral(this.programExpression(guid));
+ } else {
+ this.pushStackLiteral(null);
+ }
+ },
+
+ // [invokeHelper]
+ //
+ // On stack, before: hash, inverse, program, params..., ...
+ // On stack, after: result of helper invocation
+ //
+ // Pops off the helper's parameters, invokes the helper,
+ // and pushes the helper's return value onto the stack.
+ //
+ // If the helper is not found, `helperMissing` is called.
+ invokeHelper: function(paramSize, name, isSimple) {
+ this.aliases.helperMissing = 'helpers.helperMissing';
+
+ var nonHelper = this.popStack();
+ var helper = this.setupHelper(paramSize, name);
+
+ var lookup = (isSimple ? helper.name + ' || ' : '') + nonHelper + ' || helperMissing';
+ this.push('((' + lookup + ').call(' + helper.callParams + '))');
+ },
+
+ // [invokeKnownHelper]
+ //
+ // On stack, before: hash, inverse, program, params..., ...
+ // On stack, after: result of helper invocation
+ //
+ // This operation is used when the helper is known to exist,
+ // so a `helperMissing` fallback is not required.
+ invokeKnownHelper: function(paramSize, name) {
+ var helper = this.setupHelper(paramSize, name);
+ this.push(helper.name + ".call(" + helper.callParams + ")");
+ },
+
+ // [invokeAmbiguous]
+ //
+ // On stack, before: hash, inverse, program, params..., ...
+ // On stack, after: result of disambiguation
+ //
+ // This operation is used when an expression like `{{foo}}`
+ // is provided, but we don't know at compile-time whether it
+ // is a helper or a path.
+ //
+ // This operation emits more code than the other options,
+ // and can be avoided by passing the `knownHelpers` and
+ // `knownHelpersOnly` flags at compile-time.
+ invokeAmbiguous: function(name, helperCall) {
+ this.aliases.functionType = '"function"';
+ this.aliases.helperMissing = 'helpers.helperMissing';
+ this.useRegister('helper');
+
+ var nonHelper = this.popStack();
+
+ this.emptyHash();
+ var helper = this.setupHelper(0, name, helperCall);
+
+ var helperName = this.lastHelper = this.nameLookup('helpers', name, 'helper');
+
+ this.push(
+ '((helper = (helper = ' + helperName + ' || ' + nonHelper + ') != null ? helper : helperMissing'
+ + (helper.paramsInit ? '),(' + helper.paramsInit : '') + '),'
+ + '(typeof helper === functionType ? helper.call(' + helper.callParams + ') : helper))');
+ },
+
+ // [invokePartial]
+ //
+ // On stack, before: context, ...
+ // On stack after: result of partial invocation
+ //
+ // This operation pops off a context, invokes a partial with that context,
+ // and pushes the result of the invocation back.
+ invokePartial: function(name, indent) {
+ var params = [this.nameLookup('partials', name, 'partial'), "'" + indent + "'", "'" + name + "'", this.popStack(), this.popStack(), "helpers", "partials"];
+
+ if (this.options.data) {
+ params.push("data");
+ } else if (this.options.compat) {
+ params.push('undefined');
+ }
+ if (this.options.compat) {
+ params.push('depths');
+ }
+
+ this.push("this.invokePartial(" + params.join(", ") + ")");
+ },
+
+ // [assignToHash]
+ //
+ // On stack, before: value, ..., hash, ...
+ // On stack, after: ..., hash, ...
+ //
+ // Pops a value off the stack and assigns it to the current hash
+ assignToHash: function(key) {
+ var value = this.popStack(),
+ context,
+ type,
+ id;
+
+ if (this.trackIds) {
+ id = this.popStack();
+ }
+ if (this.stringParams) {
+ type = this.popStack();
+ context = this.popStack();
+ }
+
+ var hash = this.hash;
+ if (context) {
+ hash.contexts.push("'" + key + "': " + context);
+ }
+ if (type) {
+ hash.types.push("'" + key + "': " + type);
+ }
+ if (id) {
+ hash.ids.push("'" + key + "': " + id);
+ }
+ hash.values.push("'" + key + "': (" + value + ")");
+ },
+
+ pushId: function(type, name) {
+ if (type === 'ID' || type === 'DATA') {
+ this.pushString(name);
+ } else if (type === 'sexpr') {
+ this.pushStackLiteral('true');
+ } else {
+ this.pushStackLiteral('null');
+ }
+ },
+
+ // HELPERS
+
+ compiler: JavaScriptCompiler,
+
+ compileChildren: function(environment, options) {
+ var children = environment.children, child, compiler;
+
+ for(var i=0, l=children.length; i<l; i++) {
+ child = children[i];
+ compiler = new this.compiler();
+
+ var index = this.matchExistingProgram(child);
+
+ if (index == null) {
+ this.context.programs.push(''); // Placeholder to prevent name conflicts for nested children
+ index = this.context.programs.length;
+ child.index = index;
+ child.name = 'program' + index;
+ this.context.programs[index] = compiler.compile(child, options, this.context, !this.precompile);
+ this.context.environments[index] = child;
+
+ this.useDepths = this.useDepths || compiler.useDepths;
+ } else {
+ child.index = index;
+ child.name = 'program' + index;
+ }
+ }
+ },
+ matchExistingProgram: function(child) {
+ for (var i = 0, len = this.context.environments.length; i < len; i++) {
+ var environment = this.context.environments[i];
+ if (environment && environment.equals(child)) {
+ return i;
+ }
+ }
+ },
+
+ programExpression: function(guid) {
+ var child = this.environment.children[guid],
+ depths = child.depths.list,
+ useDepths = this.useDepths,
+ depth;
+
+ var programParams = [child.index, 'data'];
+
+ if (useDepths) {
+ programParams.push('depths');
+ }
+
+ return 'this.program(' + programParams.join(', ') + ')';
+ },
+
+ useRegister: function(name) {
+ if(!this.registers[name]) {
+ this.registers[name] = true;
+ this.registers.list.push(name);
+ }
+ },
+
+ pushStackLiteral: function(item) {
+ return this.push(new Literal(item));
+ },
+
+ pushSource: function(source) {
+ if (this.pendingContent) {
+ this.source.push(this.appendToBuffer(this.quotedString(this.pendingContent)));
+ this.pendingContent = undefined;
+ }
+
+ if (source) {
+ this.source.push(source);
+ }
+ },
+
+ pushStack: function(item) {
+ this.flushInline();
+
+ var stack = this.incrStack();
+ this.pushSource(stack + " = " + item + ";");
+ this.compileStack.push(stack);
+ return stack;
+ },
+
+ replaceStack: function(callback) {
+ var prefix = '',
+ inline = this.isInline(),
+ stack,
+ createdStack,
+ usedLiteral;
+
+ /* istanbul ignore next */
+ if (!this.isInline()) {
+ throw new Exception('replaceStack on non-inline');
+ }
+
+ // We want to merge the inline statement into the replacement statement via ','
+ var top = this.popStack(true);
+
+ if (top instanceof Literal) {
+ // Literals do not need to be inlined
+ prefix = stack = top.value;
+ usedLiteral = true;
+ } else {
+ // Get or create the current stack name for use by the inline
+ createdStack = !this.stackSlot;
+ var name = !createdStack ? this.topStackName() : this.incrStack();
+
+ prefix = '(' + this.push(name) + ' = ' + top + ')';
+ stack = this.topStack();
+ }
+
+ var item = callback.call(this, stack);
+
+ if (!usedLiteral) {
+ this.popStack();
+ }
+ if (createdStack) {
+ this.stackSlot--;
+ }
+ this.push('(' + prefix + item + ')');
+ },
+
+ incrStack: function() {
+ this.stackSlot++;
+ if(this.stackSlot > this.stackVars.length) { this.stackVars.push("stack" + this.stackSlot); }
+ return this.topStackName();
+ },
+ topStackName: function() {
+ return "stack" + this.stackSlot;
+ },
+ flushInline: function() {
+ var inlineStack = this.inlineStack;
+ if (inlineStack.length) {
+ this.inlineStack = [];
+ for (var i = 0, len = inlineStack.length; i < len; i++) {
+ var entry = inlineStack[i];
+ if (entry instanceof Literal) {
+ this.compileStack.push(entry);
+ } else {
+ this.pushStack(entry);
+ }
+ }
+ }
+ },
+ isInline: function() {
+ return this.inlineStack.length;
+ },
+
+ popStack: function(wrapped) {
+ var inline = this.isInline(),
+ item = (inline ? this.inlineStack : this.compileStack).pop();
+
+ if (!wrapped && (item instanceof Literal)) {
+ return item.value;
+ } else {
+ if (!inline) {
+ /* istanbul ignore next */
+ if (!this.stackSlot) {
+ throw new Exception('Invalid stack pop');
+ }
+ this.stackSlot--;
+ }
+ return item;
+ }
+ },
+
+ topStack: function() {
+ var stack = (this.isInline() ? this.inlineStack : this.compileStack),
+ item = stack[stack.length - 1];
+
+ if (item instanceof Literal) {
+ return item.value;
+ } else {
+ return item;
+ }
+ },
+
+ contextName: function(context) {
+ if (this.useDepths && context) {
+ return 'depths[' + context + ']';
+ } else {
+ return 'depth' + context;
+ }
+ },
+
+ quotedString: function(str) {
+ return '"' + str
+ .replace(/\\/g, '\\\\')
+ .replace(/"/g, '\\"')
+ .replace(/\n/g, '\\n')
+ .replace(/\r/g, '\\r')
+ .replace(/\u2028/g, '\\u2028') // Per Ecma-262 7.3 + 7.8.4
+ .replace(/\u2029/g, '\\u2029') + '"';
+ },
+
+ objectLiteral: function(obj) {
+ var pairs = [];
+
+ for (var key in obj) {
+ if (obj.hasOwnProperty(key)) {
+ pairs.push(this.quotedString(key) + ':' + obj[key]);
+ }
+ }
+
+ return '{' + pairs.join(',') + '}';
+ },
+
+ setupHelper: function(paramSize, name, blockHelper) {
+ var params = [],
+ paramsInit = this.setupParams(name, paramSize, params, blockHelper);
+ var foundHelper = this.nameLookup('helpers', name, 'helper');
+
+ return {
+ params: params,
+ paramsInit: paramsInit,
+ name: foundHelper,
+ callParams: [this.contextName(0)].concat(params).join(", ")
+ };
+ },
+
+ setupOptions: function(helper, paramSize, params) {
+ var options = {}, contexts = [], types = [], ids = [], param, inverse, program;
+
+ options.name = this.quotedString(helper);
+ options.hash = this.popStack();
+
+ if (this.trackIds) {
+ options.hashIds = this.popStack();
+ }
+ if (this.stringParams) {
+ options.hashTypes = this.popStack();
+ options.hashContexts = this.popStack();
+ }
+
+ inverse = this.popStack();
+ program = this.popStack();
+
+ // Avoid setting fn and inverse if neither are set. This allows
+ // helpers to do a check for `if (options.fn)`
+ if (program || inverse) {
+ if (!program) {
+ program = 'this.noop';
+ }
+
+ if (!inverse) {
+ inverse = 'this.noop';
+ }
+
+ options.fn = program;
+ options.inverse = inverse;
+ }
+
+ // The parameters go on to the stack in order (making sure that they are evaluated in order)
+ // so we need to pop them off the stack in reverse order
+ var i = paramSize;
+ while (i--) {
+ param = this.popStack();
+ params[i] = param;
+
+ if (this.trackIds) {
+ ids[i] = this.popStack();
+ }
+ if (this.stringParams) {
+ types[i] = this.popStack();
+ contexts[i] = this.popStack();
+ }
+ }
+
+ if (this.trackIds) {
+ options.ids = "[" + ids.join(",") + "]";
+ }
+ if (this.stringParams) {
+ options.types = "[" + types.join(",") + "]";
+ options.contexts = "[" + contexts.join(",") + "]";
+ }
+
+ if (this.options.data) {
+ options.data = "data";
+ }
+
+ return options;
+ },
+
+ // the params and contexts arguments are passed in arrays
+ // to fill in
+ setupParams: function(helperName, paramSize, params, useRegister) {
+ var options = this.objectLiteral(this.setupOptions(helperName, paramSize, params));
+
+ if (useRegister) {
+ this.useRegister('options');
+ params.push('options');
+ return 'options=' + options;
+ } else {
+ params.push(options);
+ return '';
+ }
+ }
+ };
+
+ var reservedWords = (
+ "break else new var" +
+ " case finally return void" +
+ " catch for switch while" +
+ " continue function this with" +
+ " default if throw" +
+ " delete in try" +
+ " do instanceof typeof" +
+ " abstract enum int short" +
+ " boolean export interface static" +
+ " byte extends long super" +
+ " char final native synchronized" +
+ " class float package throws" +
+ " const goto private transient" +
+ " debugger implements protected volatile" +
+ " double import public let yield"
+ ).split(" ");
+
+ var compilerWords = JavaScriptCompiler.RESERVED_WORDS = {};
+
+ for(var i=0, l=reservedWords.length; i<l; i++) {
+ compilerWords[reservedWords[i]] = true;
+ }
+
+ JavaScriptCompiler.isValidJavaScriptVariableName = function(name) {
+ return !JavaScriptCompiler.RESERVED_WORDS[name] && /^[a-zA-Z_$][0-9a-zA-Z_$]*$/.test(name);
+ };
+
+ __exports__ = JavaScriptCompiler;
+ return __exports__;
+})(__module2__, __module5__);
+
+// handlebars.js
+var __module0__ = (function(__dependency1__, __dependency2__, __dependency3__, __dependency4__, __dependency5__) {
+ "use strict";
+ var __exports__;
+ /*globals Handlebars: true */
+ var Handlebars = __dependency1__;
+
+ // Compiler imports
+ var AST = __dependency2__;
+ var Parser = __dependency3__.parser;
+ var parse = __dependency3__.parse;
+ var Compiler = __dependency4__.Compiler;
+ var compile = __dependency4__.compile;
+ var precompile = __dependency4__.precompile;
+ var JavaScriptCompiler = __dependency5__;
+
+ var _create = Handlebars.create;
+ var create = function() {
+ var hb = _create();
+
+ hb.compile = function(input, options) {
+ return compile(input, options, hb);
+ };
+ hb.precompile = function (input, options) {
+ return precompile(input, options, hb);
+ };
+
+ hb.AST = AST;
+ hb.Compiler = Compiler;
+ hb.JavaScriptCompiler = JavaScriptCompiler;
+ hb.Parser = Parser;
+ hb.parse = parse;
+
+ return hb;
+ };
+
+ Handlebars = create();
+ Handlebars.create = create;
+
+ Handlebars['default'] = Handlebars;
+
+ __exports__ = Handlebars;
+ return __exports__;
+})(__module1__, __module7__, __module8__, __module11__, __module12__);
+
+ return __module0__;
+}));
diff --git a/Flow/modules/wikiglyph/WikiFont-Glyphs.eot b/Flow/modules/wikiglyph/WikiFont-Glyphs.eot
new file mode 100644
index 00000000..c8e54244
--- /dev/null
+++ b/Flow/modules/wikiglyph/WikiFont-Glyphs.eot
Binary files differ
diff --git a/Flow/modules/wikiglyph/WikiFont-Glyphs.svg b/Flow/modules/wikiglyph/WikiFont-Glyphs.svg
new file mode 100644
index 00000000..41fd4acb
--- /dev/null
+++ b/Flow/modules/wikiglyph/WikiFont-Glyphs.svg
@@ -0,0 +1,291 @@
+<?xml version="1.0" standalone="no"?>
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" >
+<svg xmlns="http://www.w3.org/2000/svg">
+<metadata>
+Created by FontPrep 20130207 at Tue Sep 9 22:46:02 2014
+ By May Tee-Galloway
+Modified BSD License
+</metadata>
+<defs>
+<font id="1b21e434c004bb0ef5da181d3ec74d04" horiz-adv-x='1024' >
+ <font-face
+ font-family="WikiFont-Glyphs"
+ font-weight="400"
+ font-stretch="normal"
+ units-per-em="2048"
+ panose-1="0 0 5 0 0 0 0 0 0 0"
+ ascent="1638"
+ descent="-410"
+ bbox="10.8621 -308 2049 1792"
+ underline-thickness="50"
+ underline-position="-25"
+ unicode-range="U+0020-E903"
+ />
+<missing-glyph horiz-adv-x="600"
+ />
+ <glyph glyph-name=".notdef" horiz-adv-x="600"
+ />
+ <glyph glyph-name=".null" horiz-adv-x="0"
+ />
+ <glyph glyph-name="nonmarkingreturn" horiz-adv-x="682"
+ />
+ <glyph glyph-name="space" unicode=" " horiz-adv-x="600"
+ />
+ <glyph glyph-name="uniE000" unicode="&#xe000;"
+d="M877 1073q-88 -1 -164 -55t-110 -132q-35 -78 -23 -170t76 -163q96 -95 224 -99t226 89q56 55 78 128t6 142t-54 128t-104 95t-155 37zM888 1305q224 0 383 -160q137 -137 157 -328t-85 -349l362 -362l-61 -61q-43 -43 -103 -43t-102 43l-260 258q-159 -103 -351 -81
+t-327 156q-103 104 -140 244t0 280t142 244q161 159 385 159z" />
+ <glyph glyph-name="uniE001" unicode="&#xe001;"
+d="M790 755h802q60 -1 102 -43t42 -102v-84h-946v-309l-477 405l477 441v-308z" />
+ <glyph glyph-name="uniE002" unicode="&#xe002;"
+d="M1585 1143q60 0 102 -42l58 -60l-959 -959l-497 497l161 161l336 -335l696 696q43 42 103 42z" />
+ <glyph glyph-name="uniE003" unicode="&#xe003;"
+d="M1513 1032q0 -59 -42 -102l-291 -293l393 -393l-162 -162l-393 393l-395 -393l-60 60q-43 42 -43 102t43 102l292 292l-379 379l162 162l379 -379l394 394l60 -60q42 -43 42 -102z" />
+ <glyph glyph-name="uniE004" unicode="&#xe004;"
+d="M1024 568l299 -298l122 122l-298 299l298 299l-122 122l-299 -298l-287 288l-124 -124l288 -287l-298 -299l122 -122zM1024 1386q142 0 270 -55t222 -149t148 -222q55 -128 55 -269t-55 -269q-54 -128 -148 -222t-222 -149t-270 -55t-270 55t-222 149t-148 222
+q-55 128 -55 269t55 269q54 128 148 222t222 149t270 55z" />
+ <glyph glyph-name="uniE005" unicode="&#xe005;"
+d="M1142 803l-118 239l-118 -239l-265 -39l192 -186l-46 -264l237 124l237 -124l-46 264l191 186zM792 960l232 471l232 -471l520 -75l-376 -367l89 -518l-465 244l-465 -243l89 517l-376 367z" />
+ <glyph glyph-name="uniE006" unicode="&#xe006;" horiz-adv-x="2053"
+d="M794 965l233 474l232 -474l522 -76l-377 -368l90 -521l-467 245l-467 -243l89 519l-377 368z" />
+ <glyph glyph-name="uniE007" unicode="&#xe007;"
+d="M1094 624h438v-150h-438v150zM1094 387h550v-150h-550v150zM1094 150h340v-150h-340v150zM1006 711v-429l-409 -213l79 453l-331 320l457 66l204 412q204 -412 214 -414l448 -64l-136 -131h-526z" />
+ <glyph glyph-name="uniE008" unicode="&#xe008;"
+d="M1544 1202q0 -42 -26 -81l-118 -117l-73 69l191 192q26 -21 26 -63zM1503 751h272q0 -42 -30 -73t-71 -31h-171v104zM974 1174v272q42 0 73 -30t31 -71v-171h-104zM1518 178q-42 0 -71 29l-120 119l71 71l191 -190q-29 -29 -71 -29zM530 1223q41 0 70 -29l120 -120
+l-71 -72l-189 192q29 29 70 29zM973 219h104v-272q-42 0 -73 30t-31 71v171zM373 751h171v-104h-272q0 42 30 73t71 31zM530 276l120 120l71 -71l-190 -191q-29 29 -29 71t28 71zM1026 1062q151 0 257 -106t106 -258t-106 -258t-256 -106t-258 108t-108 257q0 151 107 257
+t258 106z" />
+ <glyph glyph-name="uniE009" unicode="&#xe009;"
+d="M1138 0h-86q-59 0 -101 42t-42 101v325l-619 648h1469l-621 -650v-466z" />
+ <glyph glyph-name="uniE010" unicode="&#xe010;"
+d="M1024 867q73 0 125 -52t52 -125q0 -74 -52 -126q-51 -52 -125 -52t-126 52t-52 126t52 125q52 52 126 52zM1024 363q134 0 230 96q96 97 96 231t-96 230t-230 96t-230 -96t-96 -230t96 -231q96 -96 230 -96zM1012 1212q92 0 186 -30t178 -72q83 -44 173 -104
+q139 -92 247 -187t143 -129l-30 -29q-66 -64 -171 -148q-179 -144 -367 -245t-347 -101q-285 1 -575 207q-138 98 -228 192t-113 124l24 29q49 62 143 147q93 84 175 140t168 102q88 46 195 75t199 29z" />
+ <glyph glyph-name="uniE011" unicode="&#xe011;"
+d="M1112 137h-172v253q-104 7 -207 35l-87 -230l-160 61l85 226q-101 46 -182 104l-153 -181l-131 110l153 182q-96 94 -167 213l90 52q45 28 97 19t87 -50q121 -149 294 -231t367 -82t368 82q172 82 293 231q35 41 87 50t98 -19l83 -54q-71 -118 -162 -209l165 -196
+l-131 -110l-164 194q-85 -61 -179 -104l89 -234l-161 -61l-90 237q-105 -28 -210 -35v-253z" />
+ <glyph glyph-name="uniE012" unicode="&#xe012;"
+d="M731 990v302h-66q-36 0 -61 -26t-25 -60v-216h152zM616 1397h721q48 0 82 -34t34 -82v-1326l-360 240l-349 -240v929h-271v370q0 59 42 101t101 42z" />
+ <glyph glyph-name="uniE013" unicode="&#xe013;"
+d="M1392 1039v222h-672q-23 0 -40 -17t-17 -40v-165h729zM1387 100v548h-738v-548h738zM703 1361h784v-322h189q60 0 102 -42t42 -101v-640h-333v-256h-939v256h-221q-41 0 -70 29t-29 70v684h332v179q0 59 42 101t101 42z" />
+ <glyph glyph-name="uniE014" unicode="&#xe014;"
+d="M732 1082l256 255l266 -266q4 -4 10 -2q7 2 10 40q6 82 54 122t107 41t111 -47l16 -16q62 -63 62 -125t-36 -104t-82 -52t-64 -10t-20 -6t2 -11l266 -266l-258 -258q-4 -5 -2 -11t8 -6q8 -1 36 -4t43 -6q50 -11 86 -54t37 -102t-47 -110l-16 -16q-64 -64 -125 -64
+t-109 42q-48 41 -54 123q-4 61 -21 44l-292 -292l-279 279q-12 12 5 17t52 8t49 6q50 9 86 53t37 104t-47 108l-16 17q-21 24 -61 44q-40 19 -82 19t-85 -36t-54 -87q-3 -13 -6 -48t-8 -52t-17 -6l-276 276l289 289q4 4 2 10q-1 6 -5 6t-33 4q-29 3 -43 6q-50 11 -86 54
+t-37 102t47 110l16 16q64 64 124 64t108 -40q46 -39 56 -134q4 -40 20 -28z" />
+ <glyph glyph-name="uniE015" unicode="&#xe015;" horiz-adv-x="1835"
+d="M986 1159h-168v-562l382 -220l12 21q30 51 14 108t-66 87l-174 102v464zM918 1380q187 0 346 -92q160 -92 252 -252q92 -159 92 -346t-92 -346q-92 -160 -252 -252q-159 -92 -346 -92q-186 0 -346 92t-252 252q-92 159 -93 346q1 187 93 346q92 160 252 252t346 92z" />
+ <glyph glyph-name="uniE016" unicode="&#xe016;"
+d="M1316 1044q-48 0 -82 -34t-33 -80q-1 -48 33 -82t82 -33q48 -1 80 33q34 34 34 82t-34 80q-32 34 -80 34zM1316 462q-48 0 -82 -34t-33 -80q-1 -48 33 -82t82 -33q48 -1 80 33q34 34 34 82t-34 80q-32 34 -80 34zM734 1044q-48 0 -82 -34t-33 -80q-1 -48 33 -82t82 -33
+q48 -1 80 33q34 34 34 82t-34 80q-32 34 -80 34zM1024 753q-48 1 -80 -33q-34 -34 -34 -82t34 -80q34 -34 80 -34q48 0 82 34t33 80q1 48 -33 82t-82 33zM748 462q-48 0 -82 -34t-33 -80q-1 -48 33 -82t82 -33q48 -1 80 33q34 34 34 82t-34 80q-32 34 -80 34zM386 1277h1134
+q59 0 101 -42t42 -101v-1134h-1134q-59 0 -101 42t-42 101v1134z" />
+ <glyph glyph-name="uniE017" unicode="&#xe017;"
+d="M1131 1137v-293h292v139l300 -277l-300 -255v140h-293v-292h139l-277 -299l-254 299h139v292h-292v-139l-301 254l300 278v-139h293v292h-140l255 300l278 -300h-139z" />
+ <glyph glyph-name="uniE018" unicode="&#xe018;" horiz-adv-x="2055"
+d="M1028 954q-108 0 -186 -77t-78 -187t77 -186q77 -77 187 -77q110 1 186 77t77 186q0 110 -77 187t-186 77zM902 1380h253l25 -153q53 -14 118 -49l128 92l178 -182l-91 -128q37 -66 50 -117l155 -27v-252l-155 -25q-13 -50 -50 -119l91 -128l-178 -178l-128 91
+q-57 -31 -118 -49l-25 -156h-255l-25 154q-62 17 -119 48l-128 -89l-178 178l90 128q-32 59 -49 119l-154 26v254l154 24q16 59 49 119l-90 128l179 178l129 -91q63 35 117 49z" />
+ <glyph glyph-name="uniE019" unicode="&#xe019;"
+d="M1583 790q69 0 118 -49t49 -118t-49 -117t-118 -49q-69 1 -118 49t-49 117t49 118t118 49zM1190 623q0 -69 -48 -117t-118 -49q-69 1 -118 49t-49 117t49 118t118 49q70 0 118 -49t48 -118zM631 623q0 -69 -49 -117t-118 -49q-69 1 -118 49t-49 117t49 118t118 49
+t118 -49t49 -118z" />
+ <glyph glyph-name="uniE020" unicode="&#xe020;"
+d="M259 1197h1393q58 -1 98 -41t40 -97v-64l-762 -388l-769 390v200zM1790 0h-1393q-57 0 -97 40t-41 98v667l771 -387l760 387v-805z" />
+ <glyph glyph-name="uniE021" unicode="&#xe021;"
+d="M1225 1325q99 86 231 82t224 -87q92 -82 110 -200q29 -182 -125 -320q-5 -4 -92 -82q-87 -77 -749 -668q-96 -84 -226 -86t-226 81q-162 157 -104 359q21 72 54 110t56 58q22 20 52 46t44 39l459 408q151 134 286 52q63 -38 87 -103q24 -64 7 -132q-18 -68 -71 -115
+l-449 -399q-97 109 -16 180q200 181 374 335q33 28 12 63q-20 35 -44 34q-25 0 -57 -28t-278 -248t-260 -235t-42 -43q-37 -39 -55 -96q-18 -58 21 -112q38 -54 93 -72t101 -4q79 25 723 616l214 196q37 40 41 98t-24 102t-62 62q-36 18 -74 16t-90 -7q-52 -6 -89 35
+q-2 3 -34 30q-31 28 -22 35z" />
+ <glyph glyph-name="uniE022" unicode="&#xe022;"
+d="M1418 204v347h204v-551h-1075q-53 0 -89 36t-36 89v426h204v-257q0 -38 26 -64t64 -26h702zM1119 846v-509h-75q-52 1 -88 37t-37 88v384h-268l384 416l352 -416h-268z" />
+ <glyph glyph-name="uniE023" unicode="&#xe023;"
+d="M1049 1400h87v-536h306l-403 -477l-440 478h306v391q1 60 43 102t101 42zM334 634h235v-296q0 -44 30 -74t73 -30h807v399h235v-633h-1236q-60 0 -102 42t-42 101v491z" />
+ <glyph glyph-name="uniE024" unicode="&#xe024;"
+d="M433 1171h197l177 -180h-194v-706q1 -43 31 -73t73 -31h705v194l180 -184v-190l-1025 -1q-60 0 -102 42t-42 101v1028zM837 508q0 60 43 101l317 317l-248 248h651v-650l-241 240l-419 -419l-60 61q-43 42 -43 102z" />
+ <glyph glyph-name="uniE025" unicode="&#xe025;"
+d="M1022 -108q-70 0 -129 43q-58 43 -79 108h412q-22 -67 -78 -109t-126 -42zM466 848q0 150 75 278t203 204t276 78q112 -2 214 -48t176 -120t120 -177q44 -103 44 -215v-471l147 -235h-1402l147 235v471z" />
+ <glyph glyph-name="uniE026" unicode="&#xe026;"
+d="M917 62l403 -85q-34 -61 -98 -91t-134 -16t-116 69q-47 54 -55 123zM1545 268l95 -261l-1372 289l193 200l97 460q30 146 130 256t240 159q140 48 287 20q145 -33 255 -134t156 -242t16 -287z" />
+ <glyph glyph-name="uniE027" unicode="&#xe027;"
+d="M1279 1080l-439 -414l411 -439q35 -37 33 -89t-39 -89l-52 -49l-635 676l675 633l50 -52q36 -38 35 -90t-39 -87z" />
+ <glyph glyph-name="uniE028" unicode="&#xe028;"
+d="M1539 1009q52 0 89 -37l51 -51l-656 -656l-654 655l51 52q36 38 88 38t89 -37l427 -426l426 425q37 37 89 37z" />
+ <glyph glyph-name="uniE029" unicode="&#xe029;"
+d="M1024 1164q-99 0 -169 -70t-70 -169t70 -169t169 -70t169 70t70 169t-70 169t-169 70zM1024 394q28 0 88 -54t75 -62v-370l-164 162l-163 -161v369q16 8 76 62t88 54zM1521 925q0 -30 -63 -61q-63 -30 -71 -52t8 -58t29 -71q13 -34 2 -50q-11 -17 -50 -15t-72 8t-57 -8
+q-17 -14 -31 -86t-38 -80q-22 -8 -77 43q-55 50 -75 50t-51 -26q-30 -26 -58 -49t-49 -17q-22 5 -36 79t-31 86t-93 4q-76 -9 -86 11q-11 16 2 50q13 35 29 71q17 36 11 52t-28 32t-46 26q-24 12 -44 28t-19 33q0 30 63 61q63 30 72 54q8 22 -23 90t-19 86t52 16
+q39 -2 72 -8t57 8q17 12 31 86t38 80q24 7 78 -43t76 -50t76 50t78 43q24 -6 38 -80t31 -86t93 -4q76 9 86 -11q14 -19 -17 -87t-25 -84q6 -18 28 -34t46 -26q63 -30 63 -61z" />
+ <glyph glyph-name="uniE030" unicode="&#xe030;"
+d="M1240 1042q-60 0 -101 -42q-42 -42 -42 -102t42 -101q41 -42 101 -42t102 42q42 41 42 101t-42 102t-102 42zM1237 1365q126 0 233 -62t169 -169t62 -233q0 -192 -136 -328t-328 -136q-98 0 -188 39v-148h-164v-164h-164v-164h-375l23 224l458 458q-55 104 -55 224
+t62 228q62 107 170 169t233 62z" />
+ <glyph glyph-name="uniE031" unicode="&#xe031;"
+d="M143 1457h1495l-4 -172h-1342l1 -977h-150v1149zM1294 284l-332 347l-53 -22l-350 -457l1186 1l-271 238zM410 1149h1495v-1149h-1495v1149z" />
+ <glyph glyph-name="uniE032" unicode="&#xe032;"
+d="M1351 165l438 166v1096l-438 -167v-1095zM258 164l438 167v1096l-438 -167v-1096zM1911 1533v-1286l-622 -237l-533 213l-554 -211q-24 -8 -45 6t-21 40v1286l621 237l533 -213l555 211q24 9 45 -5t21 -41z" />
+ <glyph glyph-name="uniE033" unicode="&#xe033;"
+d="M1351 1204q143 0 257 -100q54 -48 88 -123q33 -76 33 -168q0 -228 -200 -401q-78 -68 -288 -240t-217 -172l-503 414q-93 77 -147 179q-54 103 -55 197t21 159q39 119 141 187q101 68 217 68q191 0 326 -163q134 163 327 163z" />
+ <glyph glyph-name="uniE040" unicode="&#xe040;"
+d="M1726 429h-1224q-60 0 -102 42t-42 101v29h1368v-172zM1726 774h-1368v172h1368v-172zM358 1119v172h1224q60 0 102 -42t42 -101v-29h-1368z" />
+ <glyph glyph-name="uniE041" unicode="&#xe041;"
+d="M1503 524h-1145v243h1145v-243zM1058 0h-556q-60 0 -102 42t-42 101v100h700v-243zM358 1048v243h1224q60 0 102 -42t42 -102v-99h-1368z" />
+ <glyph glyph-name="uniE042" unicode="&#xe042;"
+d="M340 877h1144v-173h-1144v173zM340 604h698v-172h-698v172zM340 244h1369v-244h-1225q-60 0 -102 42t-42 102v100zM340 1293h1225q60 0 102 -42t42 -102v-100h-1369v244z" />
+ <glyph glyph-name="uniE043" unicode="&#xe043;"
+d="M1710 701h-1368v172h1368v-172zM340 604h698v-172h-698v172z" />
+ <glyph glyph-name="article" unicode="&#xe100;"
+d="M1127 958h279v353h-279v-353zM640 706h766v100h-766v-100zM640 453h766v98h-766v-98zM640 201h766v101h-766v-101zM974 1058h-334v-100h334v100zM975 1311h-335v-100h335v100zM442 1508h1032q61 0 97 -33t36 -94v-1381h-1035q-60 0 -95 35t-35 94v1379z" />
+ <glyph glyph-name="articleCheck" unicode="&#xe101;"
+d="M1367 954v350h-279v-350h279zM937 1205v100h-332v-100h332zM937 954v99h-332v-99h332zM1369 702v100h-764v-100h764zM406 1501h1017q59 0 101 -42t42 -101v-812l-250 -250l-155 154h208v100h-764v-100h330l-82 -82l-77 -66h-171v-100h402l-96 96l129 129l268 -267
+l449 447q33 35 81 35t82 -35l47 -47l-401 -401v-159h-159l-99 -99l-99 99h-659q-60 0 -102 42t-42 101v1358z" />
+ <glyph glyph-name="articleSearch" unicode="&#xe102;"
+d="M1369 950v350h-279v-350h279zM939 1201v100h-332v-100h332zM939 950v99h-332v-99h332zM1381 698q152 0 252 -101t114 -229t-57 -235l-1 -1l245 -246l-41 -41q-29 -29 -70 -29t-69 29l-176 176q-86 -58 -199 -58q-152 0 -260 108q-107 108 -107 260t108 260
+q109 107 261 107zM1380 542q-85 0 -149 -62q-5 -3 -8 -8q-56 -63 -54 -147q1 -85 63 -145t148 -60t148 62t62 150t-62 148q-63 62 -148 62zM607 546v-100h320q-19 -76 -13 -148h-307v-100h324q33 -114 117 -198l5 -4h-501q-60 0 -102 42t-42 101v1358h1017q59 0 101 -42
+t42 -101v-596q-89 40 -188 40h-773v-100h484q-82 -64 -126 -152h-358z" />
+ <glyph glyph-name="uniE300" unicode="&#xe300;"
+d="M1115 906l-91 254l-90 -254l-244 116l116 -244l-254 -90l254 -91l-116 -244l244 116l90 -254l91 254l243 -116l-115 244l254 91l-254 90l115 244zM1048 1375q164 0 321 -92q158 -93 250 -251t92 -344t-92 -346q-92 -158 -250 -250q-157 -92 -321 -92t-291 54t-219 147
+q-92 91 -146 219t-55 268q1 140 55 267t146 219t219 146t291 55z" />
+ <glyph glyph-name="uniE301" unicode="&#xe301;"
+d="M1026 1103q-52 0 -89 -37t-37 -89t37 -89t89 -37t89 37t37 89t-37 89t-89 37zM1092 90q50 -12 74 -42t25 -64q2 -34 -28 -74t-70 -84q-40 -42 -52 -75q-11 33 -51 75t-70 84q-30 40 -28 74t26 62q24 30 72 42q-33 -18 -35 -48q-1 -30 39 -58t47 -51q7 21 30 37t41 32
+t15 44q-2 28 -35 46zM1027 1441q98 -47 166 -124q123 -139 144 -347t-31 -442l137 -348l-222 72q-27 -69 -49 -113h-296q-23 46 -48 111l-223 -68l135 365q-108 513 124 773q67 75 163 121z" />
+ <glyph glyph-name="uniE500" unicode="&#xe500;"
+d="M572 1081q-54 0 -53 19v99q0 19 28 33t67 14l189 17l69 78h296l72 -80l195 -15q39 0 67 -14t27 -33v-99q-1 -19 -53 -19h-904zM1324 -5h-599q-27 -1 -47 19t-20 47l-115 883h963l-114 -883q0 -26 -20 -46t-48 -20z" />
+ <glyph glyph-name="uniE501" unicode="&#xe501;"
+d="M1218 0h-490q-46 0 -60 46q-8 27 -11 51q-2 25 -3 30l821 820h34l-116 -882q-8 -49 -51 -59q-42 -10 -80 -8q-39 2 -44 2zM524 1101v99q0 18 28 32t67 14l188 16l68 78h298l70 -78l165 -12l217 218l105 -103l-1361 -1403l-125 125l367 367l-65 491h557l137 137h-621
+l-43 -1q-52 0 -52 20z" />
+ <glyph glyph-name="uniE502" unicode="&#xe502;"
+d="M1469 569v244h-892v-244h892zM1024 1380q187 0 346 -92q160 -92 252 -252q92 -159 92 -346t-92 -346q-92 -160 -252 -252q-159 -92 -346 -92t-346 92q-160 92 -252 252q-92 159 -92 346t92 346q92 160 252 252q159 92 346 92z" />
+ <glyph glyph-name="uniE503" unicode="&#xe503;"
+d="M1469 566v245h-86l234 233q103 -172 95 -378t-115 -361q-108 -155 -266 -233q-159 -78 -335 -72t-327 96l470 470h330zM576 810v-244h179l244 244h-423zM1433 1242l275 275l121 -121l-1435 -1437l-120 124l192 195q-143 192 -133 438t168 427q115 131 279 191t338 35
+q174 -24 315 -127z" />
+ <glyph glyph-name="uniE504" unicode="&#xe504;"
+d="M633 52q-1 -31 -29 -43t-56 -9l-28 3v1340q142 13 366 -59q88 -28 170 -68t158 -49q188 -22 314 104v-714q-46 -77 -150 -113q-139 -49 -320 55q-268 176 -425 124v-571z" />
+ <glyph glyph-name="uniE505" unicode="&#xe505;"
+d="M1106 554l564 567v-569q-46 -77 -150 -112q-140 -47 -318 57q-47 31 -96 57zM778 50q0 -30 -28 -42t-56 -10l-28 3v115l112 111v-177zM1331 1166l311 309l119 -119l-1388 -1388l-119 120l412 415v837q190 11 442 -97q146 -62 223 -77z" />
+ <glyph glyph-name="uniE506" unicode="&#xe506;"
+d="M1345 685l-502 291v-581zM1025 1380q187 0 346 -92t251 -252q92 -159 93 -346q-1 -187 -93 -346q-92 -160 -251 -252t-346 -92t-347 92t-252 252q-92 159 -92 346t92 346q92 160 252 252t347 92z" />
+ <glyph glyph-name="uniE507" unicode="&#xe507;"
+d="M1281 432v517h-516v-517h516zM1024 1380q187 0 346 -92q160 -92 252 -252q92 -159 92 -346t-92 -346q-92 -160 -252 -252q-159 -92 -346 -92t-346 92q-160 92 -252 252q-92 159 -92 346t92 346q92 160 252 252q159 92 346 92z" />
+ <glyph glyph-name="uniE508" unicode="&#xe508;"
+d="M1196 950q0 200 -172 200t-173 -199v-177h345v176zM1408 953v-179h175v-774h-975q-59 0 -101 42t-42 102v630h175v178q0 117 49 215t137 155t198 57q164 1 274 -123t110 -303z" />
+ <glyph glyph-name="uniE509" unicode="&#xe509;"
+d="M1408 1014h-212q0 94 -43 146t-131 52q-171 0 -171 -199v-239h732v-774h-975q-59 0 -101 42t-42 102v630h175v240q0 180 110 304t274 124t274 -124t110 -304z" />
+ <glyph glyph-name="uniE600" unicode="&#xe600;"
+d="M1024 1376q94 0 161 -50q131 -96 131 -280q0 -183 -86 -302t-206 -119t-206 119t-86 302q0 184 130 280q68 50 162 50zM1657 717v-717h-1122q-59 0 -101 42t-42 102v573h322q41 -85 130 -139t175 -54q85 0 177 52t139 141h322z" />
+ <glyph glyph-name="uniE601" unicode="&#xe601;"
+d="M1215 902q-36 0 -61 -25t-25 -61t25 -60q25 -26 61 -26t61 25t25 61t-25 61t-61 25zM833 902q-36 0 -61 -26t-25 -62t25 -60t61 -24t61 25t25 61t-25 60q-25 26 -61 26zM702 550q-22 0 -38 -16q-15 -16 -15 -38t16 -38q85 -86 219 -118t268 -4t226 107q20 17 26 41
+t-11 42t-39 20t-38 -13q-68 -65 -178 -89t-220 0t-178 91q-16 15 -38 15zM554 1282h1111l-1 -1120q0 -22 -12 -54q-38 -107 -157 -107l-1110 -1l-2 1119q0 57 42 110t129 53z" />
+ <glyph glyph-name="uniE602" unicode="&#xe602;"
+d="M1299 742q75 0 129 44t53 106q-65 -64 -181 -64t-183 64q-1 -62 53 -106t129 -44zM770 742q76 0 128 44t52 106q-66 -64 -180 -64t-182 64q0 -62 54 -106t128 -44zM789 500q-17 12 -39 7t-34 -23t-8 -40t24 -34q128 -81 292 -83t295 75q18 10 24 32t-3 41q-10 19 -32 25
+t-41 -6q-102 -62 -240 -60t-238 66zM554 1282h1111v-1119q0 -57 -42 -110t-129 -53h-1111v1119q0 57 42 110t129 53z" />
+ <glyph glyph-name="uniE700" unicode="&#xe700;"
+d="M1600 457l-181 540l-183 -540h364zM508 1413h182v-240h481l-59 -180h-422q53 -206 187 -354q30 -33 84 -87l-62 -180q-80 80 -95 96q-14 17 -50 55t-50 58t-48 64q-33 44 -59 113q-83 -266 -275 -422q-76 -62 -177 -117l-23 69q-16 48 1 94t60 73q78 62 125 108
+q46 46 82 105t56 111t55 154l7 60h-425v180h425v240zM1437 1173q47 0 84 -24t52 -67l391 -1161h-182l-122 358h-486l-121 -358h-182l425 1252h141z" />
+ <glyph glyph-name="uniE701" unicode="&#xe701;"
+d="M1666 676v150h-150v-150h150zM1441 676v150h-150v-150h150zM1666 414v150h-150v-150h150zM1216 676v150h-150v-150h150zM1441 414v150h-150v-150h150zM1666 152v150h-150v-150h150zM991 826h-150v-148l150 -2v150zM1216 414v150h-150v-150h150zM766 678v148h-150v-148
+h150zM991 414v150h-150v-150h150zM1441 152v150h-825v-150h825zM541 677v149h-149v-149h149zM766 414v150h-150v-150h150zM542 414v150h-150v-150h150zM542 152v150h-149v-150h149zM240 977h1433q59 -1 101 -43t43 -101v-833h-1433q-60 0 -102 42t-42 102v833z" />
+ <glyph glyph-name="uniE800" unicode="&#xe800;"
+d="M366 361q26 -18 78 -70l1004 908q-27 48 -81 66zM258 402l1113 998q125 -21 221 -124t103 -230l-1114 -1002l-412 -44z" />
+ <glyph glyph-name="uniE801" unicode="&#xe801;"
+d="M1161 1502q115 0 233 -50t166 -139q-353 -9 -564 -267l113 -171l-573 22l42 579l149 -168q218 194 434 194zM358 359q38 -25 77 -69l546 492l319 -10l-174 259q94 95 222 147t269 60q66 -137 71 -191l-1115 -1004l-412 -45l89 402l210 189t228 205l147 -6z" />
+ <glyph glyph-name="uniE802" unicode="&#xe802;"
+d="M358 359q38 -25 77 -69l546 492l319 -10l-174 259q94 95 222 147t269 60q66 -137 71 -191l-1115 -1004l-412 -45l89 402l210 189t228 205l147 -6z" />
+ <glyph glyph-name="uniE803" unicode="&#xe803;"
+d="M1161 1502q115 0 233 -50t166 -139q-353 -9 -564 -267l113 -171l-573 22l42 579l149 -168q218 194 434 194z" />
+ <glyph glyph-name="uniE804" unicode="&#xe804;"
+d="M1468 1302q-88 0 -88 -101l1 -89h175l-1 89q1 101 -87 101zM1468 1419q83 0 139 -63t56 -154v-90h88v-392h-566v392h89v91q0 91 56 153t138 63zM1088 622h138l-644 -580l-413 -45l88 403l831 743v-133l-722 -652q26 -18 78 -70l644 582v-248z" />
+ <glyph glyph-name="uniE805" unicode="&#xe805;"
+d="M1088 622h138l-644 -580l-413 -45l88 403l831 743v-133l-722 -652q26 -18 78 -70l644 582v-248z" />
+ <glyph glyph-name="uniE806" unicode="&#xe806;"
+d="M1468 1302q-88 0 -88 -101l1 -89h175l-1 89q1 101 -87 101zM1468 1419q83 0 139 -63t56 -154v-90h88v-392h-566v392h89v91q0 91 56 153t138 63z" />
+ <glyph glyph-name="uniE810" unicode="&#xe810;"
+d="M294 1277h1581v-1116q-2 -17 -4 -24q-2 -8 -5 -21q-3 -14 -9 -26q-7 -12 -15 -26q-41 -64 -130 -64h-1673l255 283v994z" />
+ <glyph glyph-name="uniE811" unicode="&#xe811;"
+d="M679 938h1066v-730l186 -208h-1134q-82 0 -109 74q-9 25 -9 44v820zM230 1337h1068v-305h-714v-633h-541l187 209v729z" />
+ <glyph glyph-name="uniE812" unicode="&#xe812;"
+d="M1171 727v267h-172v-267h-269v-165h269v-279h172v279h270v165h-270zM294 1277h1581v-1116q-2 -17 -4 -24q-2 -8 -5 -21q-3 -14 -9 -26q-7 -12 -15 -26q-41 -64 -130 -64h-1673l255 283v994z" />
+ <glyph glyph-name="uniE813" unicode="&#xe813;"
+d="M1289 1034q-38 0 -65 -27t-27 -65t27 -64t65 -27q38 1 65 27t27 64t-27 65t-65 27zM908 1034q-38 0 -64 -27t-27 -65q1 -38 27 -64t64 -27q38 1 65 27t27 64t-27 65t-65 27zM1109 625q-112 1 -236 27t-192 52l-68 26q0 -132 65 -244t177 -177q111 -65 227 -65t206 38
+t155 104q65 65 103 155t39 189q-236 -105 -476 -105zM294 1278h1581v-1117q-19 -152 -148 -161h-1688l255 285v993z" />
+ <glyph glyph-name="uniE820" unicode="&#xe820;"
+d="M678 858q161 134 339 100q122 -22 204 -111q27 -29 25 -71t-33 -70q-62 -56 -71 -62q-17 70 -75 111q-57 41 -129 39t-125 -52l-238 -221q-66 -65 -65 -151t59 -144q101 -103 247 -14q38 23 65 49q28 27 49 29t78 -15t132 -8q-16 -15 -64 -61t-70 -66q-86 -76 -148 -104
+q-167 -76 -321 4q-128 67 -175 209q-48 142 12 277q30 69 88 122q20 18 92 92t124 118zM1272 1320q47 12 110 7q63 -4 130 -39t110 -88q91 -112 85 -263q-5 -151 -119 -257q-26 -23 -94 -93q-140 -143 -240 -191q-100 -49 -222 -26t-204 112q-26 30 -24 71t32 69
+q63 57 72 62q17 -70 75 -111q57 -41 129 -38t124 52l238 220q66 66 66 151q0 86 -49 134q-85 85 -170 61t-161 -94q-33 -31 -83 -17q-51 14 -79 20t-88 -1q16 14 64 61q48 46 70 66t68 55t80 50t80 27z" />
+ <glyph glyph-name="uniE830" unicode="&#xe830;"
+d="M1760 1116q0 -45 -26 -82t-68 -53q-104 -39 -177 -125t-101 -195h372v-514q0 -61 -44 -105t-106 -45h-513v355q1 126 15 226t57 210t122 202t189 152t280 87v-113zM924 1116q0 -44 -27 -82t-70 -53q-104 -39 -176 -125t-100 -195h372v-514q0 -62 -44 -106t-105 -44h-514
+v355q2 126 16 226t56 210q43 110 122 201q79 92 189 152t281 88v-113z" />
+ <glyph glyph-name="uniE831" unicode="&#xe831;"
+d="M1522 1515h166v-269h279v-166h-279v-280h-166v280h-269v166h269v269zM1420 980v-122q-73 -82 -103 -196h372v-517q1 -62 -43 -106t-106 -44h-514v342q2 202 42 359t146 284h206zM851 1116q0 -45 -26 -82t-68 -53q-105 -41 -177 -125t-100 -194h371v-514q0 -62 -44 -108
+t-105 -45h-513v359q3 306 99 496q95 190 283 292q109 60 280 88v-114z" />
+ <glyph glyph-name="uniE840" unicode="&#xe840;"
+d="M1441 347v655h-872v-655h872zM660 438l218 291l200 -200l109 91l163 -182h-690zM160 1294h1727v-1294h-1727v1294z" />
+ <glyph glyph-name="uniE841" unicode="&#xe841;"
+d="M1854 1184q0 101 -87 101t-87 -101v-89h174v89zM1767 1401q63 1 113 -39q88 -70 82 -238v-29h87v-390h-564v390h84q-1 113 17 170t68 97t113 39zM1591 167v1l-301 264l-200 -118l-368 385l-60 -24l-389 -508h1318zM108 -2v1276h1379q-15 -37 -16 -77h-88v-593h386v-606
+h-1661z" />
+ <glyph glyph-name="uniE842" unicode="&#xe842;"
+d="M1591 167v1l-301 264l-200 -118l-368 385l-60 -24l-389 -508h1318zM108 -2v1276h1379q-15 -37 -16 -77h-88v-593h386v-606h-1661z" />
+ <glyph glyph-name="uniE843" unicode="&#xe843;"
+d="M1854 1184q0 101 -87 101t-87 -101v-89h174v89zM1767 1401q63 1 113 -39q88 -70 82 -238v-29h87v-390h-564v390h84q-1 113 17 170t68 97t113 39z" />
+ <glyph glyph-name="uniE844" unicode="&#xe844;"
+d="M1600 1546h171v-268h271v-164h-271v-279h-171v279h-269v164h269v268zM1085 315l-368 386l-59 -25l-389 -508l1317 1l-301 265zM104 0v1276h1121v-267h269v-269h270v-740h-1660z" />
+ <glyph glyph-name="uniE845" unicode="&#xe845;"
+d="M1085 315l-368 386l-59 -25l-389 -508l1317 1l-301 265zM104 0v1276h1121v-267h269v-269h270v-740h-1660z" />
+ <glyph glyph-name="uniE846" unicode="&#xe846;"
+d="M1600 1546h171v-268h271v-164h-271v-279h-171v279h-269v164h269v268z" />
+ <glyph glyph-name="uniE847" unicode="&#xe847;"
+d="M1336 1087v487h-650v-487h650zM753 1154l163 217l149 -149l81 68l122 -136h-515zM381 1792h1286v-2048h-1286v2048z" />
+ <glyph glyph-name="uniE848" unicode="&#xe848;"
+d="M1418 890h-460l-107 108h-271v-567l82 -82h756v541zM1887 1294v-1294h-1727v1294h1727z" />
+ <glyph glyph-name="uniE849" unicode="&#xe849;"
+d="M694 1571v-422l61 -61h563v403h-342l-81 80h-201zM381 1792h1286v-2048h-1286v2048z" />
+ <glyph glyph-name="uniE850" unicode="&#xe850;"
+d="M1507 1176v248h159v-248h193q25 0 42 -17t17 -42v-94h-252v-259h-160v259h-248v153h249zM131 193v983h1032v-155h-880v-724q-1 -49 35 -85t86 -36h1102v463h156v-639h-1338q-80 0 -136 57t-57 136z" />
+ <glyph glyph-name="uniE851" unicode="&#xe851;"
+d="M1023 1237q-143 -1 -243 -101t-100 -242t100 -243t244 -101q142 0 242 101t101 243q0 142 -101 242t-243 101zM1187 1507q163 -43 285 -165t164 -284q42 -164 0 -326q-44 -162 -166 -284l-448 -448l-447 445q-122 121 -165 285t1 326q44 163 166 285q123 121 285 165
+t325 1z" />
+ <glyph glyph-name="uniE852" unicode="&#xe852;"
+d="M1557 1689h127v-198h153q19 1 33 -13t14 -34v-75h-200v-206h-128v206h-197v122h198v198zM1026 1237q-142 -1 -242 -101t-101 -242q1 -142 101 -243t242 -101t243 101t101 243t-101 242t-243 101zM1269 1280h198v-206h167q49 -160 7 -330t-168 -296l-448 -448l-447 445
+q-122 121 -165 285t1 326q44 163 174 293t320 167t361 -36v-200z" />
+ <glyph glyph-name="uniE853" unicode="&#xe853;"
+d="M1026 1237q-142 -1 -242 -101t-101 -242q1 -142 101 -243t242 -101t243 101t101 243t-101 242t-243 101zM1269 1280h198v-206h167q49 -160 7 -330t-168 -296l-448 -448l-447 445q-122 121 -165 285t1 326q44 163 174 293t320 167t361 -36v-200z" />
+ <glyph glyph-name="uniE854" unicode="&#xe854;"
+d="M1557 1689h127v-198h153q19 1 33 -13t14 -34v-75h-200v-206h-128v206h-197v122h198v198z" />
+ <glyph glyph-name="uniE870" unicode="&#xe870;"
+d="M639 706h766v100h-766v-100zM639 453h766v98h-766v-98zM639 201h766v101h-766v-101zM974 1058h-335v-100h335v100zM974 1311h-335v-100h335v100zM1406 1508h67q61 0 97 -33t36 -94v-1381h-1035q-60 0 -95 35t-35 94v1379h687v-547l137 173l141 -173v547z" />
+ <glyph glyph-name="uniE871" unicode="&#xe871;"
+d="M1667 319v918h-358q-59 -1 -101 -43t-42 -101v-779q23 5 42 5h459zM1208 1438h661v-1321h-661q-73 -1 -126 -51t-58 -122q-4 72 -58 122t-127 51h-660v1321h660q73 0 127 -50t58 -122q5 72 58 122t126 50z" />
+ <glyph glyph-name="uniE872" unicode="&#xe872;"
+d="M1404 1205v100h-494v-100h494zM1252 952v100h-342v-100h342zM443 1506h151v-1506h-151v1506zM707 1506h899v-1329q0 -65 -61 -121t-129 -56h-709v1506z" />
+ <glyph glyph-name="uniE873" unicode="&#xe873;"
+d="M1002 1137q-32 1 -54 -21t-21 -54t23 -54t53 -22t53 22t22 53t-22 53t-54 23zM750 1137q-32 1 -54 -21t-21 -54t23 -54t53 -22t53 22t22 53t-22 53t-54 23zM497 1137q-31 1 -53 -21t-22 -54t22 -54t54 -22t54 22t21 53t-23 53t-53 23zM1628 148v736h-1206v-646
+q0 -38 26 -64t64 -26h1116zM272 1239h1505v-1239h-1361q-60 0 -102 42t-42 102v1095z" />
+ <glyph glyph-name="uniE874" unicode="&#xe874;"
+d="M1611 858v175h-902v-175h902zM1611 203v504h-309v-504h309zM1151 607v100h-441v-100h441zM1151 405v100h-441v-100h441zM1151 202v100h-441v-100h441zM307 311q0 -132 100 -132q49 -1 75 35t26 97v921h1158q60 0 102 -42t42 -102v-1088h-1403q-127 0 -189 74t-62 233v510
+h248v-152h-97v-354z" />
+ <glyph glyph-name="uniE875" unicode="&#xe875;"
+d="M1718 1237q-36 25 -82 25t-81 -27q-199 -149 -268 -379q-38 -123 -27 -213q176 51 322 163q147 113 191 285q9 41 -6 81t-49 65zM26 151h1995v-150h-1995v150zM293 715l171 171l102 -102l-171 -172l172 -172l-102 -102l-173 172l-165 -166l-101 102l164 166l-165 166
+l102 102zM2023 362v-56l-147 -1q7 83 -7 92q-54 35 -232 -29q-38 -14 -51 -18q-108 -35 -230 -7t-189 125q-163 -29 -447 -20v150q263 -6 393 14q-21 136 26 292t145 284q98 130 222 194q151 80 306 -36q84 -62 110 -178q12 -54 -8 -134t-79 -168q-58 -89 -137 -156
+q-155 -132 -361 -199q104 -61 235 -7q65 26 134 40h1q131 22 199 -1q87 -30 106 -100q11 -42 11 -81z" />
+ <glyph glyph-name="uniE876" unicode="&#xe876;"
+d="M202 1310h-80v45q93 3 93 60h55v-339h-68v234zM340 749q0 -43 -18 -67t-55 -51l-27 -19q-40 -28 -51 -45h151v-60h-236q0 35 15 69t65 70t68 54q18 20 19 44q-1 24 -13 38q-12 15 -34 15q-50 0 -50 -65h-66q3 123 112 123q54 0 88 -31q32 -32 32 -75zM114 176
+q7 106 106 106q47 0 77 -24q29 -26 29 -61t-14 -51t-26 -20q8 0 29 -20t21 -60t-28 -72t-93 -31q-102 0 -107 109h64q0 -57 49 -57q18 0 33 13q14 13 14 38q0 50 -73 50v46q33 0 49 8t16 37q0 42 -43 42t-43 -53h-60zM1944 226v-226h-1435v226h1435zM1944 794v-226h-1435
+v226h1435zM1827 1359q49 -1 83 -35t34 -83v-108h-1435v226h1318z" />
+ <glyph glyph-name="uniE877" unicode="&#xe877;"
+d="M520 787h1414v-223h-1414v223zM243 1369q54 0 92 -38q38 -37 38 -91t-38 -92t-92 -38t-92 38t-38 92t38 91q38 38 92 38zM520 223h1414v-223h-1414v223zM243 805q54 0 92 -38q38 -37 38 -91t-38 -92t-92 -38t-92 38t-38 92t38 91q38 38 92 38zM243 241q54 0 92 -38
+q38 -37 38 -91t-38 -92t-92 -38t-92 38t-38 92t38 91q38 38 92 38zM520 1351h1298q48 0 82 -34t34 -82v-107h-1414v223z" />
+ <glyph glyph-name="uniE878" unicode="&#xe878;"
+d="M286 664l313 290v-555zM805 585h583v-185h-583v185zM805 954h957v-187h-957v187zM284 186h1480v-186h-1382q-42 0 -70 28t-28 69v89zM284 1353h1381q41 -1 69 -29t28 -68v-90h-1478v187z" />
+ <glyph glyph-name="uniE879" unicode="&#xe879;"
+d="M804 954h958v-186h-958v186zM804 586h584v-186h-584v186zM284 399v555l314 -265zM283 186h1481v-186h-1383q-40 0 -69 28t-29 69v89zM283 1354h1381q40 0 69 -28t29 -69v-90h-1479v187z" />
+ <glyph glyph-name="uniE900" unicode="&#xe900;"
+d="M1653 945l105 57l78 -145l142 77l58 -108l-144 -77l76 -141l-106 -58l-76 142l-143 -74l-58 106l144 77zM1279 867l63 155q121 -52 209 -77l-47 -147l-4 -14zM965 1004l66 156q149 -62 211 -94l-69 -156zM590 1358q64 52 148 45q84 -8 136 -72q54 -63 46 -147t-72 -137
+t-148 -45t-136 72t-45 148t71 136zM413 1119q-100 0 -170 -50l-96 141q116 76 266 79v-170zM44 1062l156 -69q-33 -84 -5 -157l-159 -60q-54 148 8 286zM398 596l-115 -125q-131 126 -167 168l125 113q102 -107 157 -156zM467 520q93 76 211 66t194 -104q76 -92 66 -210
+q-11 -118 -103 -194q-93 -75 -211 -66q-118 10 -194 102t-66 210q10 120 103 196z" />
+ <glyph glyph-name="uniE901" unicode="&#xe901;"
+d="M1155 351q-55 -55 -135 -57t-138 56t-57 139q1 80 57 135q56 54 136 56t138 -56t56 -138t-57 -135zM1573 491q0 -165 -81 -297t-219 -202q-138 -72 -298 -59q-203 16 -347 161t-159 338q-14 193 83 346q55 87 91 121q39 -35 70 -67t31 -35l38 -38l37 37l36 -140l-141 36
+l37 37l-33 33q-22 -24 -59 -94q-37 -71 -43 -156h45v53l126 -74l-126 -74v52h-46q4 -143 100 -253l36 37l-37 37l141 36l-36 -141l-37 37l-37 -37q107 -99 253 -108v53h-52l74 125l74 -125h-53v-53q146 7 255 107l-38 38l-37 -37l-36 141l141 -36l-37 -37l37 -38
+q97 109 101 254h-49v-52l-125 74l125 74v-53h48q-11 143 -107 247l-30 -30l37 -37l-141 -36l36 140l37 -37l30 30q-57 49 -124 74l-22 10q-21 10 -50 23t-41 20q-86 50 -128 177l-10 52q0 2 -58 -16t-58 -17q25 58 153 271l44 75l183 -346q-7 3 -19 7l-38 13q-58 20 -56 13
+q13 -54 47 -85q26 -28 115 -62q155 -62 251 -202t96 -305z" />
+ <glyph glyph-name="uniE902" unicode="&#xe902;"
+d="M1966 1184q0 -22 -9 -31q-95 -10 -150 -107q-13 -23 -32 -64l-434 -940q-13 -22 -21 -32t-22 -10q-30 0 -43 42l-241 516l-266 -518q-18 -39 -51 -39t-49 41l-402 943q-45 106 -71 135t-95 33q-9 9 -10 31t8 30q49 -2 195 -2t202 2q9 -8 9 -30t-9 -31q-85 -9 -99 -39
+t24 -109l337 -770h8l208 412l-159 337q-48 102 -74 130t-82 38q-9 8 -10 30t8 31q40 -1 166 -1t166 1q9 -9 9 -31t-9 -30q-56 -9 -58 -39t38 -109l94 -181l91 176q42 79 44 105q3 26 -11 34t-44 13q-6 9 -5 31t8 31q37 -1 141 -1t139 1q9 -9 9 -31t-9 -30q-65 -10 -103 -50
+t-79 -119l-124 -261l236 -484h8l349 758q37 75 22 113t-101 44q-9 9 -9 31t9 30q52 -2 176 -2t168 2q9 -8 9 -30z" />
+ <glyph glyph-name="uniE903" unicode="&#xe903;"
+d="M1132 599q0 -106 86 -106q46 0 69 52l75 -38q-56 -94 -148 -94t-140 50t-49 136q-1 87 49 137t127 50q111 0 159 -87l-80 -42q-21 48 -67 48q-81 0 -81 -106zM1021 1091q-131 -1 -245 -67t-180 -180q-68 -114 -68 -244q0 -131 68 -244q68 -114 181 -180q114 -66 241 -66
+q128 0 240 61t178 161t74 225q18 231 -133 382q-152 152 -356 152zM786 599q0 -106 86 -106q46 0 69 52l74 -38q-52 -94 -158 -94q-81 0 -130 49t-49 136q0 88 50 138t126 50q110 0 160 -87l-81 -42q-21 48 -66 48q-81 0 -81 -106zM1020 1200q121 0 232 -48t192 -127
+q80 -79 128 -191t48 -254t-81 -280t-219 -219t-300 -81q-161 0 -299 82q-137 82 -219 220t-82 278t48 251t129 192t191 129t232 48z" />
+ <glyph glyph-name="newGlyph"
+d="M1378 -5h-765v-101h765v101zM1378 243h-765v-97h765v97zM1378 499h-765v-100h765v100zM947 751h-334v-100h334v100zM1380 1004h-280v-353h280v353zM947 1004h-334v-100l334 -1v101zM415 1200h1031q62 0 98 -33t36 -94v-1381h-1035q-59 0 -95 35t-35 94v1379z" />
+ </font>
+</defs></svg>
diff --git a/Flow/modules/wikiglyph/WikiFont-Glyphs.ttf b/Flow/modules/wikiglyph/WikiFont-Glyphs.ttf
new file mode 100644
index 00000000..3d767428
--- /dev/null
+++ b/Flow/modules/wikiglyph/WikiFont-Glyphs.ttf
Binary files differ
diff --git a/Flow/modules/wikiglyph/WikiFont-Glyphs.woff b/Flow/modules/wikiglyph/WikiFont-Glyphs.woff
new file mode 100644
index 00000000..7186d17e
--- /dev/null
+++ b/Flow/modules/wikiglyph/WikiFont-Glyphs.woff
Binary files differ
diff --git a/Flow/modules/wikiglyph/flow-override.less b/Flow/modules/wikiglyph/flow-override.less
new file mode 100644
index 00000000..9a1da7b8
--- /dev/null
+++ b/Flow/modules/wikiglyph/flow-override.less
@@ -0,0 +1,7 @@
+
+// Flow overrides for the distributed wikiglyph css
+.mediawiki .wikiglyph {
+ height: .7em;
+ font-size: 1.6em;
+ line-height: .7em;
+}
diff --git a/Flow/modules/wikiglyph/wikiglyphs.css b/Flow/modules/wikiglyph/wikiglyphs.css
new file mode 100644
index 00000000..6ccde1e2
--- /dev/null
+++ b/Flow/modules/wikiglyph/wikiglyphs.css
@@ -0,0 +1,359 @@
+@font-face {
+ font-family: 'WikiFont-Glyphs';
+ src: url('WikiFont-Glyphs.eot'); /* IE9 Compat Modes */
+ src: url('WikiFont-Glyphs.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */
+ url('WikiFont-Glyphs.woff') format('woff'), /* Modern Browsers */
+ url('WikiFont-Glyphs.ttf') format('truetype'), /* Safari, Android, iOS */
+ url('WikiFont-Glyphs.svg#8088f7bbbdba5c9832b27edb3dfcdf09') format('svg'); /* Legacy iOS */
+}
+
+
+.wikiglyph {
+ display: inline-block;
+ height: 1.10em;
+ font-family: 'WikiFont-Glyphs';
+ -webkit-font-smoothing: antialiased;
+ font-size: inherit;
+ font-style: normal;
+ font-weight: normal;
+ line-height: 1em;
+ overflow: visible;
+ vertical-align: text-bottom;
+}
+
+.wikiglyph[dir='rtl'] {
+ filter: progid:DXImageTransform.Microsoft.BasicImage(rotation=0, mirror=1);
+ -webkit-transform: scale(-1, 1);
+ -moz-transform: scale(-1, 1);
+ -ms-transform: scale(-1, 1);
+ -o-transform: scale(-1, 1);
+ transform: scale(-1, 1);
+}
+/* UI ELEMENTS e000-023
+*/
+
+.wikiglyph-magnifying-glass:before {
+ content: "\e000";
+}
+.wikiglyph-arrow-left:before {
+ content: "\e001";
+}
+.wikiglyph-tick:before {
+ content: "\e002";
+}
+.wikiglyph-x:before {
+ content: "\e003";
+}
+.wikiglyph-x-circle:before {
+ content: "\e004";
+}
+.wikiglyph-unstar:before {
+ content: "\e005";
+}
+.wikiglyph-star:before {
+ content: "\e006";
+}
+.wikiglyph-star-list:before {
+ content: "\e007";
+}
+.wikiglyph-sun:before {
+ content: "\e008";
+}
+.wikiglyph-funnel:before {
+ content: "\e009";
+}
+.wikiglyph-eye:before {
+ content: "\e010";
+}
+.wikiglyph-eye-lid:before {
+ content: "\e011";
+}
+.wikiglyph-bookmark:before {
+ content: "\e012";
+}
+.wikiglyph-printer:before {
+ content: "\e013";
+}
+.wikiglyph-puzzle:before {
+ content: "\e014";
+}
+.wikiglyph-clock:before {
+ content: "\e015";
+}
+.wikiglyph-dice:before {
+ content: "\e016";
+}
+.wikiglyph-move:before {
+ content: "\e017";
+}
+.wikiglyph-gear:before {
+ content: "\e018";
+}
+.wikiglyph-ellipsis:before {
+ content: "\e019";
+}
+.wikiglyph-envelope:before {
+ content: "\e020";
+}
+.wikiglyph-pin:before {
+ content: "\e021";
+}
+.wikiglyph-share:before {
+ content: "\e022";
+}
+.wikiglyph-download:before {
+ content: "\e023";
+}
+.wikiglyph-bell:before {
+ content: "\e025";
+}
+.wikiglyph-bell-ring:before {
+ content: "\e026";
+}
+.wikiglyph-caret-left:before {
+ content: "\e027";
+}
+.wikiglyph-caret-down:before {
+ content: "\e028";
+}
+.wikiglyph-ribbon:before {
+ content: "\e029";
+}
+.wikiglyph-key:before {
+ content: "\e030";
+}
+.wikiglyph-gallery:before {
+ content: "\e031";
+}
+.wikiglyph-map:before {
+ content: "\e032";
+}
+.wikiglyph-heart:before {
+ content: "\e033";
+}
+
+
+/* EXPERIMENTS e300-301
+*/
+.wikiglyph-star-circle:before {
+ content: "\e300";
+}
+.wikiglyph-rocket:before {
+ content: "\e301";
+}
+
+
+/* STRIPES e040-043
+*/
+.wikiglyph-stripe-compact:before {
+ content: "\e040";
+}
+.wikiglyph-stripe-toc:before {
+ content: "\e041";
+}
+.wikiglyph-stripe-expanded:before {
+ content: "\e042";
+}
+
+
+/* UI MODERATION ELEMENTS e500-508
+*/
+
+.wikiglyph-article:before {
+ content: "\e100";
+}
+.wikiglyph-article-check:before {
+ content: "\e101";
+}
+.wikiglyph-article-search:before {
+ content: "\e102";
+}
+.wikiglyph-trash:before {
+ content: "\e500";
+}
+.wikiglyph-trash-slash:before {
+ content: "\e501";
+}
+.wikiglyph-block:before {
+ content: "\e502";
+}
+.wikiglyph-block-slash:before {
+ content: "\e503";
+}
+.wikiglyph-flag:before {
+ content: "\e504";
+}
+.wikiglyph-flag-slash:before {
+ content: "\e505";
+}
+.wikiglyph-play:before {
+ content: "\e506";
+}
+.wikiglyph-stop:before {
+ content: "\e507";
+}
+.wikiglyph-lock:before {
+ content: "\e508";
+}
+.wikiglyph-unlock:before {
+ content: "\e509";
+}
+
+/* USER e600-602
+*/
+.wikiglyph-user-bust:before {
+ content: "\e600";
+}
+.wikiglyph-user-smile:before {
+ content: "\e601";
+}
+.wikiglyph-user-sleep:before {
+ content: "\e602";
+}
+
+
+/* TRANSLATION e700
+*/
+.wikiglyph-translate:before {
+ content: "\e700";
+}
+.wikiglyph-keyboard:before {
+ content: "\e701";
+}
+
+
+/* CONTRIBUTION e800-845
+*/
+.wikiglyph-pencil:before {
+ content: "\e800";
+}
+.wikiglyph-pencil-revert-full:before {
+ content: "\e801";
+}
+.wikiglyph-pencil-revert-pt1:before {
+ content: "\e802";
+}
+.wikiglyph-pencil-revert-pt2:before {
+ content: "\e803";
+}
+.wikiglyph-pencil-lock-full:before {
+ content: "\e804";
+}
+.wikiglyph-pencil-lock-pt1:before {
+ content: "\e805";
+}
+.wikiglyph-pencil-lock-pt2:before {
+ content: "\e806";
+}
+.wikiglyph-speech-bubble:before {
+ content: "\e810";
+}
+.wikiglyph-speech-bubbles:before {
+ content: "\e811";
+}
+.wikiglyph-speech-bubble-add:before {
+ content: "\e812";
+}
+.wikiglyph-speech-bubble-smile:before {
+ content: "\e813";
+}
+.wikiglyph-link:before {
+ content: "\e820";
+}
+.wikiglyph-quotes:before {
+ content: "\e830";
+}
+.wikiglyph-quotes-add:before {
+ content: "\e831";
+}
+.wikiglyph-image:before {
+ content: "\e840";
+}
+.wikiglyph-image-lock-full:before {
+ content: "\e841";
+}
+.wikiglyph-image-lock-pt1:before {
+ content: "\e842";
+}
+.wikiglyph-image-lock-pt2:before {
+ content: "\e843";
+}
+.wikiglyph-image-add-full:before {
+ content: "\e844";
+}
+.wikiglyph-image-add-pt1:before {
+ content: "\e845";
+}
+.wikiglyph-image-add-pt2:before {
+ content: "\e846";
+}
+.wikiglyph-image-main-placeholder:before {
+ content: "\e847";
+}
+.wikiglyph-folder:before {
+ content: "\e848";
+}
+.wikiglyph-folder-main-placeholder:before {
+ content: "\e849";
+}
+.wikiglyph-template-add:before {
+ content: "\e850";
+}
+.wikiglyph-pin:before {
+ content: "\e851";
+}
+.wikiglyph-pin-add:before {
+ content: "\e852";
+}
+.wikiglyph-pin-add-pt1:before {
+ content: "\e853";
+}
+.wikiglyph-pin-add-pt2:before {
+ content: "\e854";
+}
+.wikiglyph-cite:before {
+ content: "\e870";
+}
+.wikiglyph-book:before {
+ content: "\e871";
+}
+.wikiglyph-journal:before {
+ content: "\e872";
+}
+.wikiglyph-web:before {
+ content: "\e873";
+}
+.wikiglyph-news:before {
+ content: "\e874";
+}
+.wikiglyph-signature:before {
+ content: "\e875";
+}
+.wikiglyph-list-sorted:before {
+ content: "\e876";
+}
+.wikiglyph-list-unsorted:before {
+ content: "\e877";
+}
+.wikiglyph-indent-left:before {
+ content: "\e878";
+}
+.wikiglyph-indent-right:before {
+ content: "\e879";
+}
+
+/* WIKI-X e900+
+*/
+.wikiglyph-wikitrail:before {
+ content: "\e900";
+}
+.wikiglyph-ccmark:before {
+ content: "\e903";
+}
+.wikiglyph-cmark:before {
+ content: "\e901";
+}
+.wikiglyph-wmark:before {
+ content: "\e902";
+}
+