' )
+ .addClass( 'ext-WikiEditor-realtimepreview-preview' )
+ .append( $previewContent );
+ this.$errorNode = $( '
' )
+ .addClass( 'error' );
+ this.twoPaneLayout.getPane2().append( this.$previewNode, this.$errorNode );
+ this.eventNames = 'change.realtimepreview input.realtimepreview cut.realtimepreview paste.realtimepreview';
+}
+
+/**
+ * @public
+ * @param {Object} context The WikiEditor context.
+ * @return {OO.ui.ToggleButtonWidget}
+ */
+RealtimePreview.prototype.getToolbarButton = function ( context ) {
+ this.context = context;
+ var $uiText = context.$ui.find( '.wikiEditor-ui-text' );
+
+ // Fix the height of the textarea, before adding a resizing bar below it.
+ var height = context.$textarea.height();
+ $uiText.css( 'height', height + 'px' );
+ context.$textarea.removeAttr( 'rows cols' );
+
+ // Add the resizing bar.
+ var bottomDragBar = new ResizingDragBar( { isEW: false } );
+ $uiText.after( bottomDragBar.$element );
+
+ // Create and configure the toolbar button.
+ this.button = new OO.ui.ToggleButtonWidget( {
+ label: mw.msg( 'wikieditor-realtimepreview-preview' ),
+ icon: 'article',
+ value: this.enabled,
+ framed: false
+ } );
+ this.button.connect( this, { change: this.toggle } );
+ return this.button;
+};
+
+/**
+ * Toggle the two-pane preview display.
+ *
+ * @private
+ * @param {Object} context The WikiEditor context object.
+ */
+RealtimePreview.prototype.toggle = function () {
+ var $uiText = this.context.$ui.find( '.wikiEditor-ui-text' );
+ var $textarea = this.context.$textarea;
+
+ // Remove or add the layout to the DOM.
+ if ( this.enabled ) {
+ // Move height from the TwoPaneLayout to the text UI div.
+ $uiText.css( 'height', this.twoPaneLayout.$element.height() + 'px' );
+
+ // Put the text div back to being after the layout, and then hide the layout.
+ this.twoPaneLayout.$element.after( $uiText );
+ this.twoPaneLayout.$element.hide();
+
+ // Remove the keyup handler.
+ $textarea.off( this.eventNames );
+
+ // Let other things happen after disabling.
+ mw.hook( 'ext.WikiEditor.realtimepreview.disable' ).fire( this );
+
+ } else {
+ // Add the layout before the text div of the UI and then move the text div into it.
+ $uiText.before( this.twoPaneLayout.$element );
+ this.twoPaneLayout.setPane1( $uiText );
+ this.twoPaneLayout.$element.show();
+
+ // Move explicit height from text-ui (which may have been set via manual resizing), to panes.
+ this.twoPaneLayout.$element.css( 'height', $uiText.height() + 'px' );
+ $uiText.css( 'height', '100%' );
+
+ // Enable realtime previewing.
+ this.addPreviewListener( $textarea );
+
+ // Let other things happen after enabling.
+ mw.hook( 'ext.WikiEditor.realtimepreview.enable' ).fire( this );
+ }
+
+ // Record the toggle state and update the button.
+ this.enabled = !this.enabled;
+ this.button.setFlags( { progressive: this.enabled } );
+};
+
+/**
+ * @public
+ * @param {jQuery} $editor The element to listen to changes on.
+ */
+RealtimePreview.prototype.addPreviewListener = function ( $editor ) {
+ // Get preview when enabling.
+ this.doRealtimePreview();
+ // Also get preview on keyup, change, paste etc.
+ $editor
+ .off( this.eventNames )
+ .on( this.eventNames, mw.util.debounce( 2000, this.doRealtimePreview.bind( this ) ) );
+};
+
+/**
+ * @private
+ */
+RealtimePreview.prototype.doRealtimePreview = function () {
+ this.twoPaneLayout.getPane2().addClass( 'ext-WikiEditor-twopanes-loading' );
+ var loadingSelectors = this.pagePreview.getLoadingSelectors();
+ loadingSelectors.push( '.ext-WikiEditor-realtimepreview-preview' );
+ this.$errorNode.empty();
+ this.pagePreview.doPreview( {
+ $previewNode: this.$previewNode,
+ $spinnerNode: false,
+ loadingSelectors: loadingSelectors
+ } ).fail( function ( code, result ) {
+ var $errorMsg = ( new mw.Api() ).getErrorMessage( result );
+ this.$previewNode.hide();
+ this.$errorNode.append( $errorMsg );
+ }.bind( this ) ).always( function () {
+ this.twoPaneLayout.getPane2().removeClass( 'ext-WikiEditor-twopanes-loading' );
+ }.bind( this ) );
+};
+
+module.exports = RealtimePreview;
diff --git a/modules/realtimepreview/RealtimePreview.less b/modules/realtimepreview/RealtimePreview.less
new file mode 100644
index 00000000..de942e75
--- /dev/null
+++ b/modules/realtimepreview/RealtimePreview.less
@@ -0,0 +1,19 @@
+@import 'mediawiki.ui/variables.less';
+
+/* stylelint-disable selector-max-id */
+#wpTextbox1,
+.mw-editform #wpTextbox1 {
+ // stylelint-disable-next-line plugin/no-unsupported-browser-features
+ resize: none;
+ height: 100%;
+ min-height: auto;
+ max-height: none;
+}
+
+.wikiEditor-ui .wikiEditor-ui-view {
+ border-bottom: 0;
+}
+
+.ext-WikiEditor-ResizingDragBar-ns {
+ border-top: 1px solid @colorGray12;
+}
diff --git a/modules/realtimepreview/ResizingDragBar.js b/modules/realtimepreview/ResizingDragBar.js
new file mode 100644
index 00000000..3fc5be08
--- /dev/null
+++ b/modules/realtimepreview/ResizingDragBar.js
@@ -0,0 +1,84 @@
+/**
+ * @class
+ * @constructor
+ * @extends OO.ui.Element
+ * @param {Object} [config] Configuration options
+ * @param {boolean} [config.isEW] Orientation of the drag bar, East-West (true) or North-South (false).
+ */
+function ResizingDragBar( config ) {
+ config = $.extend( {}, {
+ isEW: true,
+ classes: [ 'ext-WikiEditor-ResizingDragBar' ]
+ }, config );
+ ResizingDragBar.super.call( this, config );
+
+ var classNameDir = 'ext-WikiEditor-ResizingDragBar-' + ( config.isEW ? 'ew' : 'ns' );
+ // Possible class names:
+ // * ext-WikiEditor-ResizingDragBar-ew
+ // * ext-WikiEditor-ResizingDragBar-ns
+ this.$element.addClass( classNameDir );
+
+ var resizingDragBar = this;
+ this.$element.on( 'mousedown', function ( eventMousedown ) {
+ if ( eventMousedown.button !== ResizingDragBar.static.MAIN_MOUSE_BUTTON ) {
+ // If not the main mouse (e.g. left) button, ignore.
+ return;
+ }
+ // Prevent selecting (or anything else) when dragging over other parts of the page.
+ $( document ).on( 'selectstart.' + classNameDir, false );
+ // Set up parameter names.
+ var xOrY = config.isEW ? 'pageX' : 'pageY';
+ var widthOrHeight = config.isEW ? 'width' : 'height';
+ var lastOffset = eventMousedown[ xOrY ];
+ // Handle the actual dragging.
+ $( document ).on( 'mousemove.' + classNameDir, function ( eventMousemove ) {
+ // Initial width or height of the pane.
+ var startSize = resizingDragBar.getResizedPane()[ widthOrHeight ]();
+ // Current position of the mouse (relative to page, not viewport).
+ var newOffset = eventMousemove[ xOrY ];
+ // Distance the mouse has moved.
+ var change = lastOffset - newOffset;
+ // Set the new size of the pane, and tell others about it.
+ var newSize = Math.max( startSize - change, ResizingDragBar.static.MIN_PANE_SIZE );
+ resizingDragBar.getResizedPane().css( widthOrHeight, newSize );
+ // Save the new starting point of the mouse, from which to calculate the next move.
+ lastOffset = newOffset;
+ // Let other scripts do things after the resize.
+ mw.hook( 'ext.WikiEditor.realtimepreview.resize' ).fire( resizingDragBar );
+ } );
+ } );
+ // Add a UI affordance within the handle area (CSS gives it its appearance).
+ this.$element.append( $( '
' ) );
+ // Remove the resize event handler when the mouse is released.
+ $( document ).on( 'mouseup', function () {
+ $( document ).off( 'mousemove.' + classNameDir );
+ $( document ).off( 'selectstart.' + classNameDir, false );
+ } );
+}
+
+OO.inheritClass( ResizingDragBar, OO.ui.Element );
+
+/**
+ * @static
+ * @property {number} See https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/button
+ */
+ResizingDragBar.static.MAIN_MOUSE_BUTTON = 0;
+
+/**
+ * @static
+ * @property {number} The minimum pane size, in pixels.
+ * Should be slightly more than the affordance length.
+ */
+ResizingDragBar.static.MIN_PANE_SIZE = 100;
+
+/**
+ * Get the pane that is resized by this bar (always the immediate prior sibling).
+ *
+ * @public
+ * @return {jQuery}
+ */
+ResizingDragBar.prototype.getResizedPane = function () {
+ return this.$element.prev();
+};
+
+module.exports = ResizingDragBar;
diff --git a/modules/realtimepreview/ResizingDragBar.less b/modules/realtimepreview/ResizingDragBar.less
new file mode 100644
index 00000000..ff814db6
--- /dev/null
+++ b/modules/realtimepreview/ResizingDragBar.less
@@ -0,0 +1,40 @@
+@import 'mediawiki.ui/variables.less';
+
+// The dimensions of the UI affordance (the little line in the draggable area).
+@affordance-width: 4px;
+@affordance-length: 110px;
+
+.ext-WikiEditor-ResizingDragBar {
+ background-color: @colorGray14;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.ext-WikiEditor-ResizingDragBar-ns {
+ cursor: ns-resize;
+}
+
+.ext-WikiEditor-ResizingDragBar-ew {
+ cursor: ew-resize;
+}
+
+.ext-WikiEditor-ResizingDragBar span {
+ width: @affordance-length;
+ height: @affordance-width;
+ background-color: @colorGray12;
+ border-radius: 2px;
+ display: block;
+ // Don't change without also changing the calculated width of pane1 above.
+ margin: 1px;
+}
+
+.ext-WikiEditor-ResizingDragBar:hover span {
+ background-color: @colorGray5;
+}
+
+.ext-WikiEditor-ResizingDragBar-ew span {
+ height: @affordance-length;
+ width: @affordance-width;
+ border-width: 0 1px;
+}
diff --git a/modules/realtimepreview/TwoPaneLayout.js b/modules/realtimepreview/TwoPaneLayout.js
new file mode 100644
index 00000000..3f63bb10
--- /dev/null
+++ b/modules/realtimepreview/TwoPaneLayout.js
@@ -0,0 +1,79 @@
+var ResizingDragBar = require( './ResizingDragBar.js' );
+
+/**
+ * This is a layout with two resizable panes.
+ *
+ * @class
+ * @constructor
+ * @extends OO.ui.Layout
+ * @param {Object} [config] Configuration options
+ */
+function TwoPaneLayout( config ) {
+ // Configuration initialization
+ config = config || {};
+ TwoPaneLayout.super.call( this, config );
+
+ this.$pane1 = $( '' ).addClass( 'ext-WikiEditor-twopanes-pane1' );
+ var middleDragBar = new ResizingDragBar( { isEW: true } );
+ this.$pane2 = $( '
' ).addClass( 'ext-WikiEditor-twopanes-pane2' );
+
+ this.$element.addClass( 'ext-WikiEditor-twopanes-TwoPaneLayout' );
+ this.$element.append( this.$pane1, middleDragBar.$element, this.$pane2 );
+}
+
+OO.inheritClass( TwoPaneLayout, OO.ui.Layout );
+
+/**
+ * Set pane 1 content.
+ *
+ * @public
+ * @param {jQuery|string|Function|OO.ui.HtmlSnippet} content
+ */
+TwoPaneLayout.prototype.setPane1 = function ( content ) {
+ this.setContent( this.$pane1, content );
+};
+
+/**
+ * @public
+ * @return {jQuery}
+ */
+TwoPaneLayout.prototype.getPane1 = function () {
+ return this.$pane1;
+};
+
+/**
+ * Set pane 2 content.
+ *
+ * @public
+ * @param {jQuery|string|Function|OO.ui.HtmlSnippet} content
+ */
+TwoPaneLayout.prototype.setPane2 = function ( content ) {
+ this.setContent( this.$pane2, content );
+};
+
+/**
+ * @public
+ * @return {jQuery}
+ */
+TwoPaneLayout.prototype.getPane2 = function () {
+ return this.$pane2;
+};
+
+/**
+ * @private
+ * @param {jQuery} $container The container to set the content in.
+ * @param {jQuery|string|Function|OO.ui.HtmlSnippet} content The content to set.
+ */
+TwoPaneLayout.prototype.setContent = function ( $container, content ) {
+ if ( typeof content === 'string' ) {
+ $container.text( content );
+ } else if ( content instanceof OO.ui.HtmlSnippet ) {
+ $container.html( content.toString() );
+ } else if ( content instanceof $ ) {
+ $container.empty().append( content );
+ } else {
+ $container.empty();
+ }
+};
+
+module.exports = TwoPaneLayout;
diff --git a/modules/realtimepreview/TwoPaneLayout.less b/modules/realtimepreview/TwoPaneLayout.less
new file mode 100644
index 00000000..7c293960
--- /dev/null
+++ b/modules/realtimepreview/TwoPaneLayout.less
@@ -0,0 +1,53 @@
+@import 'mediawiki.ui/variables.less';
+
+.ext-WikiEditor-twopanes-TwoPaneLayout {
+ // stylelint-disable-next-line plugin/no-unsupported-browser-features
+ display: flex;
+
+ .ext-WikiEditor-twopanes-pane1 {
+ border: 1px solid @colorGray12;
+ border-width: 0 1px 0 0;
+ // Offset by half the difference in padding and the UI affordance.
+ // stylelint-disable-next-line plugin/no-unsupported-browser-features
+ width: ~'calc( 50% - 9px )';
+ }
+
+ .ext-WikiEditor-twopanes-pane2 {
+ position: relative;
+ border: 1px solid @colorGray12;
+ border-width: 0 0 0 1px;
+ flex: 1 1 0;
+ overflow: auto;
+ padding: 0 6px;
+ }
+
+ @loadingbar-width: 20%;
+
+ // stylelint-disable-next-line selector-pseudo-element-colon-notation
+ .ext-WikiEditor-twopanes-pane2.ext-WikiEditor-twopanes-loading::before {
+ position: absolute;
+ top: 0;
+ z-index: 5;
+ display: block;
+ opacity: 1;
+ content: ' ';
+ background-color: @color-primary;
+ width: @loadingbar-width;
+ height: 4px;
+ // Hide the loading bar to start with; it'll be shown if the loading state persists for more than 1s.
+ visibility: hidden;
+ animation: loadingbar 1.5s 1s infinite linear alternate;
+ }
+
+ @keyframes loadingbar {
+ 0% {
+ visibility: visible;
+ left: 0;
+ }
+
+ 100% {
+ // stylelint-disable-next-line plugin/no-unsupported-browser-features
+ left: calc( 100% - @loadingbar-width );
+ }
+ }
+}
diff --git a/modules/realtimepreview/init.js b/modules/realtimepreview/init.js
new file mode 100644
index 00000000..ecaf959e
--- /dev/null
+++ b/modules/realtimepreview/init.js
@@ -0,0 +1,16 @@
+mw.hook( 'wikiEditor.toolbarReady' ).add( function ( $textarea ) {
+ var RealtimePreview = require( './RealtimePreview.js' );
+ var realtimePreview = new RealtimePreview();
+ $textarea.wikiEditor( 'addToToolbar', {
+ section: 'secondary',
+ group: 'default',
+ tools: {
+ realtimepreview: {
+ type: 'element',
+ element: function ( context ) {
+ return realtimePreview.getToolbarButton( context ).$element;
+ }
+ }
+ }
+ } );
+} );