mediawiki-extensions-Discus.../modules/dt.ui.ReplyWidget.js
Bartosz Dziewoński ffafc1a752 Another attempt to fix page re-initialization after updating
* Run the 'wikipage.content' hook before our initialization.
  This way collapsible elements and other changes to the page layout
  are processed before we measure where the new reply is and try to
  highlight and scroll it into view. (T252903)

* Remove the code dealing with 'mw-parser-output' and 'dt-init-done'.
  This was needed to avoid initializing twice on the same element,
  which can't happen now. (T254807)

This is much closer to the original approach Ed proposed in
I05a3c766668999f05cfe06473652429025595196 before I changed it:
https://gerrit.wikimedia.org/r/c/mediawiki/extensions/DiscussionTools/+/550693/7..8

Bug: T252903
Bug: T254807
Change-Id: Ibc3fcbd3c92c8eceda19b68cee9e69f6e92456f5
2020-06-24 22:56:40 +02:00

638 lines
18 KiB
JavaScript

var controller = require( 'ext.discussionTools.init' ).controller,
utils = require( 'ext.discussionTools.init' ).utils,
logger = require( 'ext.discussionTools.init' ).logger;
/**
* @external CommentController
*/
/**
* DiscussionTools ReplyWidget class
*
* @class mw.dt.ReplyWidget
* @extends OO.ui.Widget
* @constructor
* @param {CommentController} commentController Comment controller
* @param {Object} parsoidData Result from controller#getParsoidCommentData
* @param {Object} [config] Configuration options
* @param {Object} [config.input] Configuration options for the comment input widget
* @param {boolean} [config.switchable] Widget can switch modes (visual/source)
*/
function ReplyWidget( commentController, parsoidData, config ) {
var returnTo, contextNode, inputConfig,
widget = this,
pageData = parsoidData.pageData,
comment = parsoidData.comment;
config = config || {};
// Parent constructor
ReplyWidget.super.call( this, config );
this.pending = false;
this.commentController = commentController;
this.parsoidData = parsoidData;
contextNode = utils.closestElement( comment.range.endContainer, [ 'dl', 'ul', 'ol' ] );
this.context = contextNode ? contextNode.nodeName.toLowerCase() : 'dl';
// TODO: Should storagePrefix include pageName?
this.storagePrefix = 'reply/' + comment.id;
this.storage = mw.storage.session;
// eslint-disable-next-line no-jquery/no-global-selector
this.contentDir = $( '#mw-content-text' ).css( 'direction' );
inputConfig = $.extend(
{ placeholder: mw.msg( 'discussiontools-replywidget-placeholder-reply', comment.author ) },
config.input
);
this.replyBodyWidget = this.createReplyBodyWidget( inputConfig );
this.replyButton = new OO.ui.ButtonWidget( {
flags: [ 'primary', 'progressive' ],
label: mw.msg( 'discussiontools-replywidget-reply' )
} );
this.cancelButton = new OO.ui.ButtonWidget( {
flags: [ 'destructive' ],
label: mw.msg( 'discussiontools-replywidget-cancel' ),
framed: false
} );
this.$preview = $( '<div>' )
.addClass( 'dt-ui-replyWidget-preview' )
.attr( 'data-label', mw.msg( 'discussiontools-replywidget-preview' ) )
// Set preview direction to content direction
.attr( 'dir', this.contentDir );
this.$actionsWrapper = $( '<div>' ).addClass( 'dt-ui-replyWidget-actionsWrapper' );
this.$actions = $( '<div>' ).addClass( 'dt-ui-replyWidget-actions' ).append(
this.cancelButton.$element,
this.replyButton.$element
);
this.$footer = $( '<div>' ).addClass( 'dt-ui-replyWidget-footer' );
if ( pageData.pageName !== mw.config.get( 'wgRelevantPageName' ) ) {
this.$footer.append( $( '<p>' ).append(
mw.message( 'discussiontools-replywidget-transcluded', pageData.pageName ).parseDom()
) );
}
this.$footer.append(
$( '<p>' ).append(
mw.message( 'discussiontools-replywidget-terms-click', mw.msg( 'discussiontools-replywidget-reply' ) ).parseDom()
),
$( '<p>' ).append(
$( '<a>' )
.attr( {
href: mw.msg( 'discussiontools-replywidget-feedback-link' ),
target: '_blank',
rel: 'noopener'
} )
.text( mw.msg( 'discussiontools-replywidget-feedback' ) )
)
);
this.$actionsWrapper.append( this.$footer, this.$actions );
// Events
this.replyButton.connect( this, { click: 'onReplyClick' } );
this.cancelButton.connect( this, { click: 'tryTeardown' } );
this.$element.on( 'keydown', this.onKeyDown.bind( this ) );
this.beforeUnloadHandler = this.onBeforeUnload.bind( this );
this.unloadHandler = this.onUnload.bind( this );
this.api = new mw.Api( { parameters: { formatversion: 2 } } );
this.onInputChangeThrottled = OO.ui.throttle( this.onInputChange.bind( this ), 1000 );
// Initialization
this.$element.addClass( 'dt-ui-replyWidget' ).append(
this.replyBodyWidget.$element,
this.$preview,
this.$actionsWrapper
);
// Set direction to interface direction
this.$element.attr( 'dir', $( document.body ).css( 'direction' ) );
if ( config.switchable ) {
this.modeTabSelect = new OO.ui.TabSelectWidget( {
classes: [ 'dt-ui-replyWidget-modeTabs' ],
items: [
new OO.ui.TabOptionWidget( {
label: mw.msg( 'discussiontools-replywidget-mode-visual' ),
data: 'visual'
} ),
new OO.ui.TabOptionWidget( {
label: mw.msg( 'discussiontools-replywidget-mode-source' ),
data: 'source'
} )
],
framed: false
} );
// Initialize to avoid flicker when switching mode
this.modeTabSelect.selectItemByData( this.getMode() );
this.modeTabSelect.connect( this, {
choose: 'onModeTabSelectChoose'
} );
this.$element.prepend( this.modeTabSelect.$element );
}
if ( mw.user.isAnon() ) {
returnTo = {
returntoquery: encodeURIComponent( window.location.search ),
returnto: mw.config.get( 'wgPageName' )
};
this.anonWarning = new OO.ui.MessageWidget( {
classes: [ 'dt-ui-replyWidget-anonWarning' ],
type: 'warning',
label: mw.message( 'discussiontools-replywidget-anon-warning' )
.params( [
mw.util.getUrl( 'Special:Userlogin', returnTo ),
mw.util.getUrl( 'Special:Userlogin/signup', returnTo )
] )
.parseDom()
} );
this.anonWarning.$element.append( this.$actions );
this.$element.append( this.anonWarning.$element, this.$footer );
this.$actionsWrapper.detach();
}
this.checkboxesPromise = controller.getCheckboxesPromise( this.parsoidData.pageData );
this.checkboxesPromise.then( function ( checkboxes ) {
var name;
function trackCheckbox( name ) {
mw.track( 'dt.schemaVisualEditorFeatureUse', {
feature: 'dtReply',
action: 'checkbox-' + name
} );
}
if ( checkboxes.checkboxFields ) {
widget.$checkboxes = $( '<div>' ).addClass( 'dt-ui-replyWidget-checkboxes' );
checkboxes.checkboxFields.forEach( function ( field ) {
widget.$checkboxes.append( field.$element );
} );
widget.$actions.prepend( widget.$checkboxes );
// bind logging:
for ( name in checkboxes.checkboxesByName ) {
checkboxes.checkboxesByName[ name ].$element.off( '.dtReply' ).on( 'click.dtReply', trackCheckbox.bind( this, name ) );
}
}
} );
}
/* Inheritance */
OO.inheritClass( ReplyWidget, OO.ui.Widget );
/* Methods */
ReplyWidget.prototype.createReplyBodyWidget = null;
/**
* Focus the widget
*
* @method
* @chainable
* @return {ReplyWidget}
*/
ReplyWidget.prototype.focus = null;
ReplyWidget.prototype.getValue = null;
ReplyWidget.prototype.isEmpty = null;
ReplyWidget.prototype.getMode = null;
ReplyWidget.prototype.clear = function () {
if ( this.errorMessage ) {
this.errorMessage.$element.remove();
}
};
ReplyWidget.prototype.setPending = function ( pending ) {
this.pending = pending;
if ( pending ) {
this.replyButton.setDisabled( true );
this.cancelButton.setDisabled( true );
} else {
this.replyButton.setDisabled( false );
this.cancelButton.setDisabled( false );
this.updateButtons();
}
};
ReplyWidget.prototype.saveEditMode = function ( mode ) {
this.api.saveOption( 'discussiontools-editmode', mode ).then( function () {
mw.user.options.set( 'discussiontools-editmode', mode );
} );
};
ReplyWidget.prototype.onModeTabSelectChoose = function ( option ) {
var promise,
mode = option.getData(),
widget = this;
if ( mode === this.getMode() ) {
return;
}
this.setPending( true );
this.modeTabSelect.setDisabled( true );
switch ( mode ) {
case 'source':
promise = this.commentController.switchToWikitext();
break;
case 'visual':
promise = this.commentController.switchToVisual();
break;
}
// TODO: We rely on #setup to call #saveEditMode, so when we have 2017WTE
// we will need to save the new preference here as switching will not
// reload the editor.
promise.then( null, function () {
// Switch failed, restore previous tab selection
widget.modeTabSelect.selectItemByData( mode === 'source' ? 'visual' : 'source' );
} ).always( function () {
widget.setPending( false );
widget.modeTabSelect.setDisabled( false );
} );
mw.track( 'dt.schemaVisualEditorFeatureUse', {
feature: 'editor-switch',
// TODO: Log as `source-nwe-desktop` when enable2017Wikitext is set
action: ( mode === 'visual' ? 'visual' : 'source' ) + '-desktop'
} );
};
/**
* Setup the widget
*
* @param {Mixed} initialValue Initial value
* @chainable
* @return {ReplyWidget}
*/
ReplyWidget.prototype.setup = function () {
this.bindBeforeUnloadHandler();
if ( this.modeTabSelect ) {
this.modeTabSelect.selectItemByData( this.getMode() );
this.saveEditMode( this.getMode() );
}
return this;
};
ReplyWidget.prototype.afterSetup = function () {
// Init preview and button state
this.onInputChange();
// Autosave
this.storage.set( this.storagePrefix + '/mode', this.getMode() );
};
/**
* Try to teardown the widget, prompting the user if unsaved changes will be lost.
*
* @chainable
* @return {ReplyWidget}
*/
ReplyWidget.prototype.tryTeardown = function () {
var promise,
widget = this;
if ( !this.isEmpty() ) {
// TODO: Override messages in dialog to be more ReplyWidget specific
promise = OO.ui.getWindowManager().openWindow( 'abandonedit' )
.closed.then( function ( data ) {
if ( !( data && data.action === 'discard' ) ) {
return $.Deferred().reject().promise();
}
logger( {
action: 'abort',
mechanism: 'cancel',
type: 'abandon'
} );
} );
} else {
promise = $.Deferred().resolve().promise();
logger( {
action: 'abort',
mechanism: 'cancel',
type: 'nochange'
} );
}
promise.then( function () {
widget.teardown( true );
} );
return this;
};
/**
* Teardown the widget
*
* @param {boolean} [abandoned] Widget was torn down after a reply was abandoned
* @chainable
* @return {ReplyWidget}
*/
ReplyWidget.prototype.teardown = function ( abandoned ) {
this.unbindBeforeUnloadHandler();
this.clear();
this.storage.remove( this.storagePrefix + '/mode' );
this.storage.remove( this.storagePrefix + '/saveable' );
this.$preview.empty();
this.emit( 'teardown', abandoned );
return this;
};
ReplyWidget.prototype.onKeyDown = function ( e ) {
if ( e.which === OO.ui.Keys.ESCAPE ) {
this.tryTeardown();
return false;
}
};
ReplyWidget.prototype.onInputChange = function () {
this.updateButtons();
this.storage.set( this.storagePrefix + '/saveable', this.isEmpty() ? '' : '1' );
this.preparePreview( this.getValue() );
};
/**
* Update the interface with the preview of the given wikitext.
*
* @param {string} wikitext
* @return {jQuery.Promise} Promise resolved when we're done
*/
ReplyWidget.prototype.preparePreview = function ( wikitext ) {
var parsePromise,
widget = this,
indent = {
dl: ':',
ul: '*',
ol: '#'
}[ this.context ];
if ( this.getMode() !== 'source' ) {
return $.Deferred().resolve().promise();
}
if ( this.previewWikitext === wikitext ) {
return $.Deferred().resolve().promise();
}
this.previewWikitext = wikitext;
if ( this.previewRequest ) {
this.previewRequest.abort();
this.previewRequest = null;
}
if ( !wikitext.trim() ) {
parsePromise = $.Deferred().resolve( null ).promise();
} else {
wikitext = controller.sanitizeWikitextLinebreaks(
controller.autoSignWikitext( wikitext )
);
// Drop opacity of signature in preview to make message body preview clearer.
wikitext = wikitext.slice( 0, -4 ) + '<span style="opacity: 0.6;">~~~~</span>';
wikitext = indent + wikitext.replace( /\n/g, '\n' + indent );
this.previewRequest = parsePromise = this.api.post( {
action: 'parse',
text: wikitext,
pst: true,
prop: [ 'text', 'modules', 'jsconfigvars' ],
title: mw.config.get( 'wgPageName' )
} );
}
// TODO: Add list context
return parsePromise.then( function ( response ) {
widget.$preview.html( response ? response.parse.text : '' );
if ( response ) {
mw.config.set( response.parse.jsconfigvars );
mw.loader.load( response.parse.modulestyles );
mw.loader.load( response.parse.modules );
}
} );
};
ReplyWidget.prototype.updateButtons = function () {
this.replyButton.setDisabled( this.isEmpty() );
};
ReplyWidget.prototype.onFirstTransaction = function () {
logger( { action: 'firstChange' } );
};
/**
* Bind the beforeunload handler, if needed and if not already bound.
*
* @private
*/
ReplyWidget.prototype.bindBeforeUnloadHandler = function () {
$( window ).on( 'beforeunload', this.beforeUnloadHandler );
$( window ).on( 'unload', this.unloadHandler );
};
/**
* Unbind the beforeunload handler if it is bound.
*
* @private
*/
ReplyWidget.prototype.unbindBeforeUnloadHandler = function () {
$( window ).off( 'beforeunload', this.beforeUnloadHandler );
$( window ).off( 'unload', this.unloadHandler );
};
/**
* Respond to beforeunload event.
*
* @private
* @param {jQuery.Event} e Event
* @return {string|undefined}
*/
ReplyWidget.prototype.onBeforeUnload = function ( e ) {
if ( !this.isEmpty() ) {
e.preventDefault();
return '';
}
};
/**
* Respond to unload event.
*
* @private
* @param {jQuery.Event} e Event
*/
ReplyWidget.prototype.onUnload = function () {
logger( {
action: 'abort',
type: this.isEmpty() ? 'nochange' : 'abandon',
mechanism: 'navigate'
} );
};
ReplyWidget.prototype.onReplyClick = function () {
var widget = this,
pageData = this.parsoidData.pageData,
comment = this.parsoidData.comment;
if ( this.pending || this.isEmpty() ) {
return;
}
if ( this.errorMessage ) {
this.errorMessage.$element.remove();
}
this.setPending( true );
logger( { action: 'saveIntent' } );
// TODO: When editing a transcluded page, VE API returning the page HTML is a waste, since we won't use it
// We must get a new copy of the document every time, otherwise any unsaved replies will pile up
// TODO: Move most of this logic to the CommentController
controller.getParsoidCommentData(
pageData.pageName,
pageData.oldId,
comment.id
).then( function ( parsoidData ) {
logger( { action: 'saveAttempt' } );
return widget.commentController.save( parsoidData );
} ).then( function ( data ) {
var
pageUpdated = $.Deferred(),
// eslint-disable-next-line no-jquery/no-global-selector
$container = $( '#mw-content-text' );
widget.teardown();
// TODO: Tell controller to teardown all other open widgets
// Update page state
if ( pageData.pageName === mw.config.get( 'wgRelevantPageName' ) ) {
// We can use the result from the VisualEditor API
$container.html( data.content );
mw.config.set( {
wgCurRevisionId: data.newrevid,
wgRevisionId: data.newrevid
} );
mw.config.set( data.jsconfigvars );
// Note: VE API merges 'modules' and 'modulestyles'
mw.loader.load( data.modules );
// TODO update categories, displaytitle, lastmodified
// (see ve.init.mw.DesktopArticleTarget.prototype.replacePageContent)
pageUpdated.resolve();
} else {
// We saved to another page, we must purge and then fetch the current page
widget.api.post( {
action: 'purge',
titles: mw.config.get( 'wgRelevantPageName' )
} ).then( function () {
return widget.api.get( {
action: 'parse',
prop: [ 'text', 'modules', 'jsconfigvars' ],
page: mw.config.get( 'wgRelevantPageName' )
} );
} ).then( function ( parseResp ) {
$container.html( parseResp.parse.text );
mw.config.set( parseResp.parse.jsconfigvars );
mw.loader.load( parseResp.parse.modulestyles );
mw.loader.load( parseResp.parse.modules );
// TODO update categories, displaytitle, lastmodified
// We may not be able to use prop=displaytitle without making changes in the action=parse API,
// VE API has some confusing code that changes the HTML escaping on it before returning???
pageUpdated.resolve();
} ).catch( function () {
// We saved the reply, but couldn't purge or fetch the updated page. Seems difficult to
// explain this problem. Redirect to the page where the user can at least see their reply…
window.location = mw.util.getUrl( pageData.pageName );
} );
}
pageUpdated.then( function () {
// Re-initialize and highlight the new reply.
mw.dt.initState.repliedTo = comment.id;
// We need our init code to run after everyone else's handlers for this hook,
// so that all changes to the page layout have been completed (e.g. collapsible elements),
// and we can measure things and display the highlight in the right place.
mw.hook( 'wikipage.content' ).remove( mw.dt.init );
mw.hook( 'wikipage.content' ).fire( $container );
// The hooks have "memory" so calling add() after fire() actually fires the handler,
// and calling add() before fire() would actually fire it twice.
mw.hook( 'wikipage.content' ).add( mw.dt.init );
logger( {
action: 'saveSuccess',
// eslint-disable-next-line camelcase
revision_id: data.newrevid
} );
} );
}, function ( code, data ) {
var typeMap = {
// Compare to ve.init.mw.ArticleTargetEvents.js in VisualEditor.
editconflict: 'editConflict',
wasdeleted: 'editPageDeleted',
abusefilter: 'extensionAbuseFilter',
'abusefilter-disallowed': 'extensionAbuseFilter',
captcha: 'extensionCaptcha',
spamprotectiontext: 'extensionSpamBlacklist',
titleblacklist: 'extensionTitleBlacklist',
'titleblacklist-forbidden-edit': 'extensionTitleBlacklist',
badtoken: 'userBadToken',
newuser: 'userNewUser',
spamblacklist: 'extensionSpamBlacklist',
empty: 'responseEmpty',
unknown: 'responseUnknown',
pagedeleted: 'editPageDeleted'
};
if ( widget.captchaMessage ) {
widget.captchaMessage.$element.detach();
}
widget.captchaInput = undefined;
if ( OO.getProp( data, 'visualeditoredit', 'edit', 'captcha' ) ) {
code = 'captcha';
widget.captchaInput = new mw.libs.confirmEdit.CaptchaInputWidget(
OO.getProp( data, 'visualeditoredit', 'edit', 'captcha' )
);
// Save when pressing 'Enter' in captcha field as it is single line.
widget.captchaInput.on( 'enter', function () {
widget.onReplyClick();
} );
widget.captchaMessage = new OO.ui.MessageWidget( {
type: 'notice',
label: widget.captchaInput.$element
} );
widget.captchaMessage.$element.insertAfter( widget.$preview );
widget.captchaInput.focus();
widget.captchaInput.scrollElementIntoView();
} else {
widget.errorMessage = new OO.ui.MessageWidget( {
type: 'error',
label: code instanceof Error ? code.toString() : widget.api.getErrorMessage( data )
} );
widget.errorMessage.$element.insertBefore( widget.replyBodyWidget.$element );
}
logger( {
action: 'saveFailure',
message: code,
type: typeMap[ code ] || 'responseUnknown'
} );
} ).always( function () {
widget.setPending( false );
} );
};
/* Window registration */
OO.ui.getWindowManager().addWindows( [ new mw.widgets.AbandonEditDialog() ] );
module.exports = ReplyWidget;