Add toggle button and two-pane layout for realtime preview

* Toolbar button to toggle a two-pane layout on and off.
* Resizable bar for the width of the two panes.
* Resizable bar for the edit box height, even when realtime preview is off.
* Only enabled when $wgWikiEditorRealtimePreview = true.
* Fires JS hooks when enabled, resized, and disabled.

Bug: T293347
Change-Id: Id09d44519249c0b7f5c33d48d524b7c92a5a9106
This commit is contained in:
Sam Wilson 2021-11-18 18:50:17 +08:00
parent 27020ae196
commit 411be83bc3
11 changed files with 460 additions and 3 deletions

View file

@ -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
}

View file

@ -197,5 +197,6 @@
"wikieditor-toolbar-help-content-indent-syntax": "Normal text<br />:Indented text<br />::Indented text",
"wikieditor-toolbar-help-content-indent-result": "Normal text<dl><dd>Indented text<dl><dd>Indented text</dd></dl></dd></dl>",
"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"
}

View file

@ -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."
}

View file

@ -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

View file

@ -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 = $( '<div>' )
.addClass( 'ext-WikiEditor-realtimepreview-preview' )
.append( $previewContent );
this.$errorNode = $( '<div>' )
.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;

View file

@ -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;
}

View file

@ -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( $( '<span>' ) );
// 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;

View file

@ -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;
}

View file

@ -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 = $( '<div>' ).addClass( 'ext-WikiEditor-twopanes-pane1' );
var middleDragBar = new ResizingDragBar( { isEW: true } );
this.$pane2 = $( '<div>' ).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;

View file

@ -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 );
}
}
}

View file

@ -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;
}
}
}
} );
} );