mirror of
https://gerrit.wikimedia.org/r/mediawiki/extensions/WikiEditor
synced 2024-11-24 00:06:49 +00:00
e979df2036
The #wikiPreview element can contain other child elements such as the already-removed one of .previewnote but also (on category pages) category lists, which we don't want. This changes to only clone the specific content element. We could just create this in JS, but don't at this point have access to the lang direction. Bug: T315558 Change-Id: Ifcd02e9cf005e00c8dd69df162893fb673117231
383 lines
13 KiB
JavaScript
383 lines
13 KiB
JavaScript
var ResizingDragBar = require( './ResizingDragBar.js' );
|
|
var TwoPaneLayout = require( './TwoPaneLayout.js' );
|
|
var ErrorLayout = require( './ErrorLayout.js' );
|
|
var ManualWidget = require( './ManualWidget.js' );
|
|
var OnboardingPopup = require( './OnboardingPopup.js' );
|
|
|
|
/**
|
|
* @class
|
|
*/
|
|
function RealtimePreview() {
|
|
this.configData = mw.loader.moduleRegistry[ 'ext.wikiEditor' ].script.files[ 'data.json' ];
|
|
// Preference name, must match what's in extension.json and Hooks.php.
|
|
this.prefName = 'wikieditor-realtimepreview';
|
|
this.userPref = this.getUserPref();
|
|
this.enabled = this.userPref;
|
|
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 (such as `dir`) that aren't easily accessible from here,
|
|
// and we need to duplicate here what Live Preview does in core.
|
|
var $previewContent = $( '#wikiPreview .mw-content-ltr, #wikiPreview .mw-content-rtl' ).first().clone();
|
|
this.$previewNode = $( '<div>' )
|
|
.addClass( 'ext-WikiEditor-realtimepreview-preview' )
|
|
.append( $previewContent );
|
|
|
|
// Loading bar.
|
|
this.$loadingBar = $( '<div>' ).addClass( 'ext-WikiEditor-realtimepreview-loadingbar' ).append( '<div>' );
|
|
this.$loadingBar.hide();
|
|
|
|
// Error layout.
|
|
this.errorLayout = new ErrorLayout();
|
|
this.errorLayout.getReloadButton().connect( this, {
|
|
click: function () {
|
|
this.doRealtimePreview( true );
|
|
mw.hook( 'ext.WikiEditor.realtimepreview.reloadError' ).fire( this );
|
|
}.bind( this )
|
|
} );
|
|
|
|
// Manual reload button (visible on hover).
|
|
this.reloadButton = new OO.ui.ButtonWidget( {
|
|
classes: [ 'ext-WikiEditor-reloadButton' ],
|
|
icon: 'reload',
|
|
label: mw.msg( 'wikieditor-realtimepreview-reload' ),
|
|
accessKey: mw.msg( 'accesskey-wikieditor-realtimepreview' ),
|
|
title: mw.msg( 'wikieditor-realtimepreview-reload-title' )
|
|
} );
|
|
this.reloadButton.connect( this, {
|
|
click: function () {
|
|
// Only refresh the preview if we're enabled.
|
|
if ( this.enabled ) {
|
|
this.doRealtimePreview( true );
|
|
}
|
|
// Let other things happen after refreshing.
|
|
mw.hook( 'ext.WikiEditor.realtimepreview.reloadHover' ).fire( this );
|
|
}.bind( this )
|
|
} );
|
|
|
|
this.onboardingPopup = new OnboardingPopup();
|
|
|
|
// Manual mode widget.
|
|
this.manualWidget = new ManualWidget( this, this.reloadButton );
|
|
// Set up a property for reducedMotion — useful for customising the UI message.
|
|
this.reducedMotion = window.matchMedia( '(prefers-reduced-motion: reduce)' ).matches;
|
|
// If the user has "prefers-reduced-motion" set, force us into manual mode.
|
|
this.inManualMode = this.reducedMotion;
|
|
|
|
this.twoPaneLayout.getPane2().append( this.manualWidget.$element, this.reloadButton.$element, this.$loadingBar, this.$previewNode, this.errorLayout.$element );
|
|
this.eventNames = 'change.realtimepreview input.realtimepreview cut.realtimepreview paste.realtimepreview';
|
|
// Used to ensure we wait for a response before making new requests.
|
|
this.isPreviewing = false;
|
|
this.previewPending = false;
|
|
this.lastWikitext = null;
|
|
// Used to average response times and automatically disable realtime preview if it's very slow.
|
|
this.responseTimes = [];
|
|
}
|
|
|
|
/**
|
|
* @public
|
|
* @param {Object} context The WikiEditor context.
|
|
* @return {jQuery}
|
|
*/
|
|
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' );
|
|
context.$textarea.addClass( 'ext-WikiEditor-realtimepreview-textbox' );
|
|
|
|
// 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,
|
|
// T305953; So we can find usage of this class later: .tool
|
|
classes: [ 'tool', 'ext-WikiEditor-realtimepreview-button' ]
|
|
} );
|
|
this.button.connect( this, { change: [ this.toggle, true ] } );
|
|
if ( !this.isScreenWideEnough() ) {
|
|
this.button.toggle( false );
|
|
this.onboardingPopup.toggle( false );
|
|
}
|
|
|
|
// Hide or show the preview and toolbar button when the window is resized.
|
|
$( window ).on( 'resize', this.enableFeatureWhenScreenIsWideEnough.bind( this ) );
|
|
|
|
// Add the onboarding popup.
|
|
this.button.connect( this.onboardingPopup, { change: this.onboardingPopup.onPreviewButtonClick } );
|
|
|
|
return $( '<div>' ).append( this.button.$element, this.onboardingPopup.$element );
|
|
};
|
|
|
|
/**
|
|
* Get the user preference for Realtime Preview.
|
|
*
|
|
* @public
|
|
* @return {boolean}
|
|
*/
|
|
RealtimePreview.prototype.getUserPref = function () {
|
|
return ( typeof this.userPref !== 'undefined' ) ? this.userPref : mw.user.options.get( this.prefName ) > 0;
|
|
};
|
|
|
|
/**
|
|
* Enable or disable Realtime Preview.
|
|
*
|
|
* @param {boolean} [enable=true] Whether to enable or disable.
|
|
* @param {boolean} [saveUserPref=true] Whether to save the user preference.
|
|
* @public
|
|
*/
|
|
RealtimePreview.prototype.setEnabled = function ( enable, saveUserPref ) {
|
|
// Set this.enabled to the opposite of what we want, and then toggle it to the desired state.
|
|
this.enabled = ( typeof enable === 'boolean' ) ? !enable : false;
|
|
this.toggle( saveUserPref );
|
|
};
|
|
|
|
/**
|
|
* Save the user preference for Realtime Preview.
|
|
*
|
|
* @private
|
|
*/
|
|
RealtimePreview.prototype.saveUserPref = function () {
|
|
this.userPref = this.enabled ? 1 : 0;
|
|
( new mw.Api() ).saveOption( this.prefName, this.userPref );
|
|
};
|
|
|
|
/**
|
|
* Toggle the two-pane preview display.
|
|
*
|
|
* @param {boolean} [saveUserPref=true] Whether to save the user preference.
|
|
* @private
|
|
*/
|
|
RealtimePreview.prototype.toggle = function ( saveUserPref ) {
|
|
var $uiText = this.context.$ui.find( '.wikiEditor-ui-text' );
|
|
var $textarea = this.context.$textarea;
|
|
|
|
// Save the current cursor selection and focused element.
|
|
var cursorPos = $textarea.textSelection( 'getCaretPosition', { startAndEnd: true } );
|
|
var focusedElement = document.activeElement;
|
|
|
|
// 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%' );
|
|
|
|
// Load the preview when enabling,
|
|
this.doRealtimePreview();
|
|
// and also on keyup, change, paste etc.
|
|
$textarea
|
|
.off( this.eventNames )
|
|
.on( this.eventNames, this.getEventHandler() );
|
|
|
|
// Hide or show the manual-reload message bar.
|
|
this.manualWidget.toggle( this.inManualMode );
|
|
|
|
// Let other things happen after enabling.
|
|
mw.hook( 'ext.WikiEditor.realtimepreview.enable' ).fire( this );
|
|
}
|
|
|
|
// Restore current selection.
|
|
$textarea.textSelection( 'setSelection', { start: cursorPos[ 0 ], end: cursorPos[ 1 ] } );
|
|
$textarea.textSelection( 'scrollToCaretPosition' );
|
|
// Focus on whatever had focus before, in case it wasn't the textarea.
|
|
focusedElement.focus();
|
|
|
|
// Record the toggle state and update the button.
|
|
this.enabled = !this.enabled;
|
|
this.button.$element.toggleClass( 'tool-active', this.enabled ); // T305953
|
|
this.button.setFlags( { progressive: this.enabled } );
|
|
|
|
if ( typeof saveUserPref === 'undefined' || ( typeof saveUserPref === 'boolean' && saveUserPref ) ) {
|
|
this.saveUserPref();
|
|
}
|
|
};
|
|
|
|
/**
|
|
* @public
|
|
* @return {Function}
|
|
*/
|
|
RealtimePreview.prototype.getEventHandler = function () {
|
|
return mw.util.debounce(
|
|
function () {
|
|
// Only do preview if we're not in manual mode (as set in this.checkResponseTimes()).
|
|
if ( !this.inManualMode ) {
|
|
this.doRealtimePreview();
|
|
}
|
|
}.bind( this ),
|
|
this.configData.realtimeDebounce
|
|
);
|
|
};
|
|
|
|
/**
|
|
* Check if screen meets minimum width requirement for Realtime Preview.
|
|
*
|
|
* @public
|
|
* @return {boolean}
|
|
*/
|
|
RealtimePreview.prototype.isScreenWideEnough = function () {
|
|
return this.context.$ui.width() > 600;
|
|
};
|
|
|
|
/**
|
|
* Display feature (buttons and functionality) only when screen is wide enough
|
|
*
|
|
* @private
|
|
*/
|
|
RealtimePreview.prototype.enableFeatureWhenScreenIsWideEnough = function () {
|
|
var previewButtonIsVisible = this.button.isVisible();
|
|
var isScreenWideEnough = this.isScreenWideEnough();
|
|
if ( !isScreenWideEnough && previewButtonIsVisible ) {
|
|
this.button.toggle( false );
|
|
this.onboardingPopup.toggle( false );
|
|
this.reloadButton.setDisabled( true );
|
|
if ( this.enabled ) {
|
|
this.setEnabled( false, false );
|
|
}
|
|
} else if ( isScreenWideEnough && !previewButtonIsVisible ) {
|
|
this.button.toggle( true );
|
|
this.onboardingPopup.toggle( true );
|
|
this.reloadButton.setDisabled( false );
|
|
// if user preference and realtime disable
|
|
if ( !this.enabled && this.getUserPref() ) {
|
|
this.setEnabled( true, false );
|
|
}
|
|
}
|
|
};
|
|
|
|
/**
|
|
* @private
|
|
* @param {jQuery} $msg
|
|
*/
|
|
RealtimePreview.prototype.showError = function ( $msg ) {
|
|
this.$previewNode.hide();
|
|
this.reloadButton.toggle( false );
|
|
this.manualWidget.toggle( false );
|
|
// There is no need for a default message because mw.Api.getErrorMessage() will
|
|
// always provide something (even for no network connection, server-side fatal errors, etc.).
|
|
this.errorLayout.setMessage( $msg );
|
|
this.errorLayout.toggle( true );
|
|
};
|
|
|
|
/**
|
|
* @private
|
|
* @param {number} time
|
|
*/
|
|
RealtimePreview.prototype.checkResponseTimes = function ( time ) {
|
|
// Don't track response times if we're already in manual mode or an error is shown.
|
|
if ( this.inManualMode || this.errorLayout.isVisible() ) {
|
|
return;
|
|
}
|
|
|
|
this.responseTimes.push( Date.now() - time );
|
|
if ( this.responseTimes.length < 3 ) {
|
|
return;
|
|
}
|
|
|
|
var totalResponseTime = this.responseTimes.reduce( function ( a, b ) {
|
|
return a + b;
|
|
}, 0 );
|
|
|
|
if ( ( totalResponseTime / this.responseTimes.length ) > this.configData.realtimeDisableDuration ) {
|
|
this.inManualMode = true;
|
|
this.manualWidget.toggle( true );
|
|
mw.hook( 'ext.WikiEditor.realtimepreview.stop' ).fire( this );
|
|
}
|
|
|
|
this.responseTimes.shift();
|
|
};
|
|
|
|
/**
|
|
* @private
|
|
* @param {boolean} forceUpdate For the preview to update, even if the wikitext is unchanged,
|
|
* e.g. when the user presses the 'reload' button.
|
|
*/
|
|
RealtimePreview.prototype.doRealtimePreview = function ( forceUpdate ) {
|
|
// Wait for a response before making any new requests.
|
|
if ( this.isPreviewing ) {
|
|
// Queue up one final preview once this one finishes.
|
|
this.previewPending = true;
|
|
return;
|
|
}
|
|
|
|
var $textareaNode = $( '#wpTextbox1' );
|
|
var wikitext = $textareaNode.textSelection( 'getContents' );
|
|
if ( !forceUpdate && wikitext === this.lastWikitext ) {
|
|
// Wikitext unchanged, no update necessary
|
|
return;
|
|
}
|
|
this.lastWikitext = wikitext;
|
|
|
|
this.isPreviewing = true;
|
|
this.$loadingBar.show();
|
|
this.$previewNode.show();
|
|
this.reloadButton.setDisabled( true );
|
|
this.manualWidget.setDisabled( true );
|
|
this.errorLayout.toggle( false );
|
|
var loadingSelectors = this.pagePreview.getLoadingSelectors();
|
|
loadingSelectors.push( '.ext-WikiEditor-realtimepreview-preview' );
|
|
loadingSelectors.push( '.ext-WikiEditor-ManualWidget' );
|
|
loadingSelectors.push( '.ext-WikiEditor-realtimepreview-ErrorLayout' );
|
|
var time = Date.now();
|
|
|
|
this.pagePreview.doPreview( {
|
|
$textareaNode: $textareaNode,
|
|
$previewNode: this.$previewNode,
|
|
$spinnerNode: false,
|
|
loadingSelectors: loadingSelectors,
|
|
// Don't hide the diff view, if visible.
|
|
$diffNode: null
|
|
} ).done( function () {
|
|
this.errorLayout.toggle( false );
|
|
}.bind( this ) ).fail( function ( code, result ) {
|
|
this.showError( ( new mw.Api() ).getErrorMessage( result ) );
|
|
mw.log.error( 'WikiEditor realtime preview error', result );
|
|
}.bind( this ) ).always( function () {
|
|
this.$loadingBar.hide();
|
|
this.reloadButton.setDisabled( false );
|
|
if ( !this.errorLayout.isVisible() ) {
|
|
// Only re-show the reload button if no error message is currently showing.
|
|
this.reloadButton.toggle( true );
|
|
}
|
|
// Show the manual mode if applicable (but not if an error is displayed).
|
|
this.manualWidget.toggle( this.inManualMode && !this.errorLayout.isVisible() );
|
|
this.manualWidget.setDisabled( false );
|
|
this.isPreviewing = false;
|
|
this.checkResponseTimes( time );
|
|
|
|
if ( this.previewPending ) {
|
|
this.previewPending = false;
|
|
this.doRealtimePreview();
|
|
}
|
|
mw.hook( 'ext.WikiEditor.realtimepreview.loaded' ).fire( this );
|
|
}.bind( this ) );
|
|
};
|
|
|
|
module.exports = RealtimePreview;
|