mediawiki-extensions-WikiEd.../modules/realtimepreview/RealtimePreview.js
Sam Wilson f1bcbe75c3 Realtime Preview: also trigger on form.reset()
If the form is reset, make sure that the preview is re-run.
There isn't actually any reset button in the normal edit
form, but Edit Recovery uses reset() to remove any
recovered data, and if RTP is open when that's done the preview
needs to be updated.

Bug: T351821
Depends-On: I1ec757d5ddd9f0db66496c6aaef70747d93a5c83
Change-Id: I30481edae4c071e8586ac1dc7d587bd550965967
2023-12-08 16:00:04 +00:00

389 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 localStorage = require( 'mediawiki.storage' ).local;
/**
* @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' )
.attr( 'tabindex', '1' ) // T317108
.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 )
} );
// 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.enabled = false;
this.button.toggle( false );
}
// Hide or show the preview and toolbar button when the window is resized.
$( window ).on( 'resize', this.enableFeatureWhenScreenIsWideEnough.bind( this ) );
// Remove the old onboarding-status storage that was discontinued in March 2023.
localStorage.remove( 'WikiEditor-RealtimePreview-onboarding-dismissed' );
return $( '<div>' ).append( this.button.$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;
var $form = $textarea.parents( 'form' );
// 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 );
$form.off( 'reset.realtimepreview' );
// 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() );
$form.off( 'reset.realtimepreview' )
.on( 'reset.realtimepreview', 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.reloadButton.setDisabled( true );
if ( this.enabled ) {
this.setEnabled( false, false );
}
} else if ( isScreenWideEnough && !previewButtonIsVisible ) {
this.button.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()
// config.$previewNode below is a clone of #wikiPreview with a different selector!
// config.$diffNode defaults to #wikiDiff but is disabled below and never updated.
.filter( function ( selector ) {
return selector.indexOf( '#wiki' ) !== 0;
} );
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;