diff --git a/extension.json b/extension.json index 3c06b5d7..2f9b4adc 100644 --- a/extension.json +++ b/extension.json @@ -13,7 +13,7 @@ "license-name": "GPL-2.0-or-later", "type": "editor", "requires": { - "MediaWiki": ">= 1.37" + "MediaWiki": ">= 1.38.0" }, "MessagesDirs": { "WikiEditor": [ @@ -300,6 +300,26 @@ "ext.wikiEditor.styles": { "group": "ext.wikiEditor", "styles": "ext.wikiEditor.toolbar.styles.less" + }, + "ext.wikiEditor.realtimepreview": { + "dependencies": [ + "ext.wikiEditor", + "mediawiki.page.preview" + ], + "messages": [ + "wikieditor-realtimepreview-preview" + ], + "packageFiles": [ + "realtimepreview/init.js", + "realtimepreview/RealtimePreview.js", + "realtimepreview/ResizingDragBar.js", + "realtimepreview/TwoPaneLayout.js" + ], + "styles": [ + "realtimepreview/RealtimePreview.less", + "realtimepreview/ResizingDragBar.less", + "realtimepreview/TwoPaneLayout.less" + ] } }, "ResourceFileModulePaths": { @@ -389,5 +409,11 @@ "AutoloadNamespaces": { "MediaWiki\\Extension\\WikiEditor\\": "includes/" }, + "config": { + "WikiEditorRealtimePreview": { + "description": "Whether to enable the Realtime Preview feature.", + "value": false + } + }, "manifest_version": 2 } diff --git a/i18n/en.json b/i18n/en.json index 543897ce..9f2d696e 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -197,5 +197,6 @@ "wikieditor-toolbar-help-content-indent-syntax": "Normal text
:Indented text
::Indented text", "wikieditor-toolbar-help-content-indent-result": "Normal text
Indented text
Indented text
", "tag-wikieditor": "-", - "tag-wikieditor-description": "Edit made using [[mw:Special:MyLanguage/Extension:WikiEditor|WikiEditor]] (2010 wikitext editor)" + "tag-wikieditor-description": "Edit made using [[mw:Special:MyLanguage/Extension:WikiEditor|WikiEditor]] (2010 wikitext editor)", + "wikieditor-realtimepreview-preview": "Preview" } diff --git a/i18n/qqq.json b/i18n/qqq.json index 1946dedc..7146a9ee 100644 --- a/i18n/qqq.json +++ b/i18n/qqq.json @@ -228,5 +228,6 @@ "wikieditor-toolbar-help-content-indent-syntax": "{{RawHtml|phab=T294760}}\n\nSyntax example used in the help section \"discussion\" of the toolbar", "wikieditor-toolbar-help-content-indent-result": "{{RawHtml|phab=T294760}}\n\nHTML example used in the help section \"discussion\" of the toolbar", "tag-wikieditor": "{{ignored}}Short description of the wikieditor tag.\n\nShown on lists of changes (history, recentchanges, etc.) for each edit made using WikiEditor.\n\nSee also:\n* {{msg-mw|Tag-wikieditor-description}}", - "tag-wikieditor-description": "Long description of the wikieditor tag ({{msg-mw|Tag-wikieditor}}).\n\nShown on [[Special:Tags]].\n\nSee also:\n* {{msg-mw|Tag-wikieditor}}" + "tag-wikieditor-description": "Long description of the wikieditor tag ({{msg-mw|Tag-wikieditor}}).\n\nShown on [[Special:Tags]].\n\nSee also:\n* {{msg-mw|Tag-wikieditor}}", + "wikieditor-realtimepreview-preview": "Label for the toolbar button to enable/disable real-time preview." } diff --git a/includes/Hooks.php b/includes/Hooks.php index 4e512cbe..af9bde44 100644 --- a/includes/Hooks.php +++ b/includes/Hooks.php @@ -222,6 +222,10 @@ class Hooks implements if ( $this->userOptionsLookup->getBoolOption( $user, 'usebetatoolbar' ) ) { $outputPage->addModuleStyles( 'ext.wikiEditor.styles' ); $outputPage->addModules( 'ext.wikiEditor' ); + // Optionally enable Realtime Preview. + if ( $this->config->get( 'WikiEditorRealtimePreview' ) ) { + $outputPage->addModules( 'ext.wikiEditor.realtimepreview' ); + } } // Don't run this if the request was posted - we don't want to log 'init' when the diff --git a/modules/realtimepreview/RealtimePreview.js b/modules/realtimepreview/RealtimePreview.js new file mode 100644 index 00000000..a98f7b03 --- /dev/null +++ b/modules/realtimepreview/RealtimePreview.js @@ -0,0 +1,134 @@ +var ResizingDragBar = require( './ResizingDragBar.js' ); +var TwoPaneLayout = require( './TwoPaneLayout.js' ); + +/** + * @class + */ +function RealtimePreview() { + this.enabled = false; + this.twoPaneLayout = new TwoPaneLayout(); + this.pagePreview = require( 'mediawiki.page.preview' ); + // @todo This shouldn't be required, but the preview element is added in PHP + // and can have attributes with values that aren't easily accessible from here, + // and we need to duplicate here what Live Preview does in core. + var $previewContent = $( '#wikiPreview' ).clone().html(); + this.$previewNode = $( '
' ) + .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; + } + } + } + } ); +} );