mirror of
https://gerrit.wikimedia.org/r/mediawiki/extensions/DiscussionTools
synced 2024-11-30 19:16:39 +00:00
9b317c6f22
When using ReplyWidgetVisual, this would just crashes in #getValue. When using ReplyWidgetPlain, if the timing is right, this might update our auto-save after the value was cleared in the teardown code, breaking the way we restore the reply after showing new comments. Bug: T316074 Change-Id: I23c5f17a6ff9a6ec9c73a176e4cc60e3ad96e7f1
1113 lines
34 KiB
JavaScript
1113 lines
34 KiB
JavaScript
var controller = require( 'ext.discussionTools.init' ).controller,
|
|
utils = require( 'ext.discussionTools.init' ).utils,
|
|
logger = require( 'ext.discussionTools.init' ).logger,
|
|
ModeTabSelectWidget = require( './ModeTabSelectWidget.js' ),
|
|
ModeTabOptionWidget = require( './ModeTabOptionWidget.js' ),
|
|
licenseMessages = require( './licenseMessages.json' ),
|
|
featuresEnabled = mw.config.get( 'wgDiscussionToolsFeaturesEnabled' ) || {},
|
|
enable2017Wikitext = featuresEnabled.sourcemodetoolbar;
|
|
|
|
require( './AbandonCommentDialog.js' );
|
|
require( './AbandonTopicDialog.js' );
|
|
|
|
/**
|
|
* DiscussionTools ReplyWidget class
|
|
*
|
|
* @class mw.dt.ReplyWidget
|
|
* @extends OO.ui.Widget
|
|
* @constructor
|
|
* @param {CommentController} commentController Comment controller
|
|
* @param {CommentDetails} commentDetails
|
|
* @param {Object} [config] Configuration options
|
|
* @param {Object} [config.input] Configuration options for the comment input widget
|
|
*/
|
|
function ReplyWidget( commentController, commentDetails, config ) {
|
|
var widget = this;
|
|
|
|
config = config || {};
|
|
|
|
// Parent constructor
|
|
ReplyWidget.super.call( this, config );
|
|
|
|
this.pending = false;
|
|
this.isTornDown = false;
|
|
this.commentController = commentController;
|
|
var threadItem = commentController.getThreadItem();
|
|
this.commentDetails = commentDetails;
|
|
this.isNewTopic = !!threadItem.isNewTopic;
|
|
this.pageName = commentDetails.pageName;
|
|
this.oldId = commentDetails.oldId;
|
|
// pageExists can only be false for the new comment tool, so we
|
|
// don't need to worry about transcluded replies.
|
|
this.pageExists = mw.config.get( 'wgRelevantArticleId', 0 ) !== 0;
|
|
var contextNode = utils.closestElement( threadItem.range.endContainer, [ 'dl', 'ul', 'ol' ] );
|
|
this.context = contextNode ? contextNode.tagName.toLowerCase() : 'dl';
|
|
// TODO: Should storagePrefix include pageName?
|
|
this.storagePrefix = 'reply/' + threadItem.id;
|
|
this.storage = controller.storage;
|
|
// eslint-disable-next-line no-jquery/no-global-selector
|
|
this.contentDir = $( '#mw-content-text' ).css( 'direction' );
|
|
this.hideNewCommentsWarning = false;
|
|
// Floating position for scroll back buttons: 'top', 'bottom', or null
|
|
this.floating = null;
|
|
// Copied from ve.init.Platform.static.isIos
|
|
this.isIos = /ipad|iphone|ipod/i.test( navigator.userAgent );
|
|
|
|
this.$window = $( this.getElementWindow() );
|
|
this.onWindowScrollThrottled = OO.ui.throttle( this.onWindowScroll.bind( this ), 100 );
|
|
this.onViewportChangeThrottled = OO.ui.throttle( this.onViewportChange.bind( this ), 100 );
|
|
|
|
var inputConfig = $.extend(
|
|
{
|
|
placeholder: this.isNewTopic ?
|
|
mw.msg( 'discussiontools-replywidget-placeholder-newtopic' ) :
|
|
mw.msg( 'discussiontools-replywidget-placeholder-reply', threadItem.author ),
|
|
authors: this.isNewTopic ?
|
|
// No suggestions in new topic tool yet (T277357)
|
|
[] :
|
|
// threadItem is a CommentItem when replying
|
|
threadItem.getHeading().getAuthorsBelow()
|
|
},
|
|
config.input
|
|
);
|
|
this.replyBodyWidget = this.createReplyBodyWidget( inputConfig );
|
|
this.replyButtonLabel = this.isNewTopic ?
|
|
mw.msg( 'discussiontools-replywidget-newtopic' ) :
|
|
mw.msg( 'discussiontools-replywidget-reply' );
|
|
this.replyButton = new OO.ui.ButtonWidget( {
|
|
flags: [ 'primary', 'progressive' ],
|
|
label: this.replyButtonLabel,
|
|
title: this.replyButtonLabel + ' ' +
|
|
// TODO: Use VE keyboard shortcut generating code
|
|
( $.client.profile().platform === 'mac' ?
|
|
'⌘⏎' :
|
|
mw.msg( 'visualeditor-key-ctrl' ) + '+' + mw.msg( 'visualeditor-key-enter' )
|
|
)
|
|
} );
|
|
this.cancelButton = new OO.ui.ButtonWidget( {
|
|
flags: [ 'destructive' ],
|
|
label: mw.msg( 'discussiontools-replywidget-cancel' ),
|
|
framed: false,
|
|
title: mw.msg( 'discussiontools-replywidget-cancel' ) + ' ' +
|
|
// TODO: Use VE keyboard shortcut generating code
|
|
( $.client.profile().platform === 'mac' ?
|
|
'⎋' :
|
|
mw.msg( 'visualeditor-key-escape' )
|
|
)
|
|
} );
|
|
|
|
this.$headerWrapper = $( '<div>' ).addClass( 'ext-discussiontools-ui-replyWidget-headerWrapper' );
|
|
|
|
if ( !OO.ui.isMobile() ) {
|
|
this.modeTabSelect = new ModeTabSelectWidget( {
|
|
classes: [ 'ext-discussiontools-ui-replyWidget-modeTabs' ],
|
|
items: [
|
|
new ModeTabOptionWidget( {
|
|
label: mw.msg( 'discussiontools-replywidget-mode-visual' ),
|
|
data: 'visual'
|
|
} ),
|
|
new ModeTabOptionWidget( {
|
|
label: mw.msg( 'discussiontools-replywidget-mode-source' ),
|
|
data: 'source'
|
|
} )
|
|
],
|
|
framed: false
|
|
} );
|
|
this.modeTabSelect.$element.attr( 'aria-label', mw.msg( 'visualeditor-mweditmode-tooltip' ) );
|
|
// Make the option for the current mode disabled, to make it un-interactable
|
|
// (we override the styles to make it look as if it was selected)
|
|
this.modeTabSelect.findItemFromData( this.getMode() ).setDisabled( true );
|
|
this.modeTabSelect.connect( this, {
|
|
choose: 'onModeTabSelectChoose'
|
|
} );
|
|
this.$headerWrapper.append(
|
|
// Visual mode toolbar attached here by CommentTarget#attachToolbar
|
|
this.modeTabSelect.$element
|
|
);
|
|
}
|
|
|
|
this.$bodyWrapper = $( '<div>' ).addClass( 'ext-discussiontools-ui-replyWidget-bodyWrapper' ).append(
|
|
this.replyBodyWidget.$element
|
|
);
|
|
|
|
this.$preview = $( '<div>' )
|
|
.addClass( 'ext-discussiontools-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( 'ext-discussiontools-ui-replyWidget-actionsWrapper' );
|
|
this.$actions = $( '<div>' ).addClass( 'ext-discussiontools-ui-replyWidget-actions' ).append(
|
|
this.cancelButton.$element,
|
|
this.replyButton.$element
|
|
);
|
|
|
|
this.editSummaryInput = new OO.ui.TextInputWidget( {
|
|
classes: [ 'ext-discussiontools-ui-replyWidget-editSummary' ]
|
|
} );
|
|
mw.widgets.visibleCodePointLimit( this.editSummaryInput, mw.config.get( 'wgCommentCodePointLimit' ) );
|
|
|
|
this.editSummaryField = new OO.ui.FieldLayout(
|
|
this.editSummaryInput,
|
|
{
|
|
align: 'top',
|
|
classes: [ 'ext-discussiontools-ui-replyWidget-editSummaryField' ],
|
|
label: mw.msg( 'discussiontools-replywidget-summary' )
|
|
}
|
|
);
|
|
|
|
this.advancedToggle = new OO.ui.ButtonWidget( {
|
|
label: mw.msg( 'discussiontools-replywidget-advanced' ),
|
|
indicator: 'down',
|
|
framed: false,
|
|
flags: [ 'progressive' ],
|
|
classes: [ 'ext-discussiontools-ui-replyWidget-advancedToggle' ]
|
|
} );
|
|
this.advanced = new OO.ui.MessageWidget( {
|
|
type: 'message',
|
|
$content: this.editSummaryField.$element,
|
|
classes: [ 'ext-discussiontools-ui-replyWidget-advanced' ]
|
|
} ).toggle( false ).setIcon( '' );
|
|
|
|
this.$footer = $( '<div>' ).addClass( 'ext-discussiontools-ui-replyWidget-footer' );
|
|
if ( this.pageName !== mw.config.get( 'wgRelevantPageName' ) ) {
|
|
this.$footer.append( $( '<p>' ).append(
|
|
mw.message( 'discussiontools-replywidget-transcluded', this.pageName ).parseDom()
|
|
) );
|
|
}
|
|
var $footerLinks = $( '<ul>' ).addClass( 'ext-discussiontools-ui-replyWidget-footer-links' );
|
|
if ( !mw.user.isAnon() ) {
|
|
$footerLinks.append(
|
|
$( '<li>' ).append(
|
|
$( '<a>' )
|
|
.attr( {
|
|
href: mw.util.getUrl( 'Special:Preferences#mw-prefsection-editing-discussion' ),
|
|
target: '_blank',
|
|
rel: 'noopener'
|
|
} )
|
|
.text( mw.msg( 'discussiontools-replywidget-preferences' ) )
|
|
)
|
|
);
|
|
}
|
|
$footerLinks.append(
|
|
$( '<li>' ).append(
|
|
$( '<a>' )
|
|
.attr( {
|
|
href: this.isNewTopic ?
|
|
mw.msg( 'discussiontools-replywidget-feedback-link-newtopic' ) :
|
|
mw.msg( 'discussiontools-replywidget-feedback-link' ),
|
|
target: '_blank',
|
|
rel: 'noopener'
|
|
} )
|
|
.text( mw.msg( 'discussiontools-replywidget-feedback' ) )
|
|
)
|
|
);
|
|
this.$footer.append(
|
|
$( '<p>' ).addClass( 'plainlinks' ).html(
|
|
this.isNewTopic ? licenseMessages.newtopic : licenseMessages.reply
|
|
),
|
|
$footerLinks
|
|
);
|
|
this.$actionsWrapper.append( this.$footer, this.$actions );
|
|
|
|
this.viewportScrollContainer = OO.ui.Element.static.getClosestScrollableContainer( document.body );
|
|
this.scrollBackTopButton = new OO.ui.ButtonWidget( {
|
|
classes: [ 'ext-discussiontools-ui-replyWidget-scrollback-top' ],
|
|
icon: 'collapse',
|
|
label: mw.msg(
|
|
this.isNewTopic ?
|
|
'discussiontools-replywidget-return-to-newtopic' :
|
|
'discussiontools-replywidget-return-to-reply'
|
|
),
|
|
flags: [ 'progressive' ]
|
|
} );
|
|
this.scrollBackBottomButton = new OO.ui.ButtonWidget( {
|
|
classes: [ 'ext-discussiontools-ui-replyWidget-scrollback-bottom' ],
|
|
icon: 'expand',
|
|
label: mw.msg(
|
|
this.isNewTopic ?
|
|
'discussiontools-replywidget-return-to-newtopic' :
|
|
'discussiontools-replywidget-return-to-reply'
|
|
),
|
|
flags: [ 'progressive' ]
|
|
} );
|
|
|
|
// Events
|
|
this.replyButton.connect( this, { click: 'onReplyClick' } );
|
|
this.cancelButton.connect( this, { click: 'tryTeardown' } );
|
|
this.$element.on( 'keydown', this.onKeyDown.bind( this, true ) );
|
|
this.beforeUnloadHandler = this.onBeforeUnload.bind( this );
|
|
this.unloadHandler = this.onUnload.bind( this );
|
|
this.onWatchToggleHandler = this.onWatchToggle.bind( this );
|
|
this.advancedToggle.connect( this, { click: 'onAdvancedToggleClick' } );
|
|
this.editSummaryInput.connect( this, { change: 'onEditSummaryChange' } );
|
|
this.editSummaryInput.$input.on( 'keydown', this.onKeyDown.bind( this, false ) );
|
|
if ( this.isNewTopic ) {
|
|
this.commentController.sectionTitle.$input.on( 'keydown', this.onKeyDown.bind( this, false ) );
|
|
}
|
|
this.scrollBackTopButton.connect( this, { click: 'onScrollBackButtonClick' } );
|
|
this.scrollBackBottomButton.connect( this, { click: 'onScrollBackButtonClick' } );
|
|
|
|
this.onInputChangeThrottled = OO.ui.throttle( this.onInputChange.bind( this ), 1000 );
|
|
|
|
// Initialization
|
|
this.$element.addClass( 'ext-discussiontools-ui-replyWidget' ).append(
|
|
this.$headerWrapper,
|
|
this.$bodyWrapper,
|
|
this.$preview,
|
|
this.advancedToggle.$element,
|
|
this.advanced.$element,
|
|
this.$actionsWrapper,
|
|
this.scrollBackTopButton.$element,
|
|
this.scrollBackBottomButton.$element
|
|
);
|
|
// Set direction to interface direction
|
|
this.$element.attr( 'dir', $( document.body ).css( 'direction' ) );
|
|
if ( this.isNewTopic ) {
|
|
this.$element.addClass( 'ext-discussiontools-ui-replyWidget-newTopic' );
|
|
}
|
|
|
|
if ( mw.user.isAnon() ) {
|
|
var returnTo = {
|
|
returntoquery: window.location.search.slice( 1 ),
|
|
returnto: mw.config.get( 'wgPageName' )
|
|
};
|
|
this.anonWarning = new OO.ui.MessageWidget( {
|
|
classes: [ 'ext-discussiontools-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.anonWarning.$label.addClass( 'plainlinks' );
|
|
this.$element.append( this.anonWarning.$element, this.$footer );
|
|
this.$actionsWrapper.detach();
|
|
}
|
|
|
|
this.checkboxesPromise = controller.getCheckboxesPromise( this.pageName, this.oldId );
|
|
this.checkboxesPromise.then( function ( checkboxes ) {
|
|
function trackCheckbox( n ) {
|
|
mw.track( 'dt.schemaVisualEditorFeatureUse', {
|
|
feature: 'dtReply',
|
|
action: 'checkbox-' + n
|
|
} );
|
|
}
|
|
if ( checkboxes.checkboxFields ) {
|
|
widget.$checkboxes = $( '<div>' ).addClass( 'ext-discussiontools-ui-replyWidget-checkboxes' );
|
|
checkboxes.checkboxFields.forEach( function ( field ) {
|
|
widget.$checkboxes.append( field.$element );
|
|
} );
|
|
widget.editSummaryField.$body.append( widget.$checkboxes );
|
|
|
|
// bind logging:
|
|
for ( var 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 */
|
|
|
|
/**
|
|
* Create the widget for the reply body
|
|
*
|
|
* The body widget should implement #setReadOnly, #pushPending and #popPending
|
|
*
|
|
* @method
|
|
* @return {OO.ui.Widget}
|
|
*/
|
|
ReplyWidget.prototype.createReplyBodyWidget = null;
|
|
|
|
/**
|
|
* Focus the widget
|
|
*
|
|
* @method
|
|
* @chainable
|
|
* @return {ReplyWidget}
|
|
*/
|
|
ReplyWidget.prototype.focus = null;
|
|
|
|
/**
|
|
* Get value of reply body, HTML or wikitext
|
|
*
|
|
* @method
|
|
* @return {string}
|
|
*/
|
|
ReplyWidget.prototype.getValue = null;
|
|
|
|
/**
|
|
* Check if the reply widget is empty
|
|
*
|
|
* @method
|
|
* @return {boolean}
|
|
*/
|
|
ReplyWidget.prototype.isEmpty = null;
|
|
|
|
/**
|
|
* Get the current input mode of the reply widget, 'source' or 'visual'
|
|
*
|
|
* @method
|
|
* @return {string}
|
|
*/
|
|
ReplyWidget.prototype.getMode = null;
|
|
|
|
/**
|
|
* Restore the widget to its original state
|
|
*
|
|
* Clear any widget values, reset UI states, and clear
|
|
* any (optional) auto-save values.
|
|
*
|
|
* @param {boolean} [preserveStorage] Preserve auto-save storage
|
|
*/
|
|
ReplyWidget.prototype.clear = function ( preserveStorage ) {
|
|
if ( this.saveErrorMessage ) {
|
|
this.saveErrorMessage.$element.remove();
|
|
this.saveErrorMessage = null;
|
|
}
|
|
if ( this.previewRequest ) {
|
|
this.previewRequest.abort();
|
|
this.previewRequest = null;
|
|
}
|
|
this.$preview.empty();
|
|
this.previewWikitext = null;
|
|
this.previewTitle = null;
|
|
this.toggleAdvanced( false );
|
|
|
|
if ( !preserveStorage ) {
|
|
this.clearStorage();
|
|
} else {
|
|
this.storage.set( this.storagePrefix + '/saveable', '1' );
|
|
}
|
|
|
|
this.emit( 'clear' );
|
|
};
|
|
|
|
/**
|
|
* Remove any storage that the widget is using
|
|
*/
|
|
ReplyWidget.prototype.clearStorage = function () {
|
|
this.storage.remove( this.storagePrefix + '/mode' );
|
|
this.storage.remove( this.storagePrefix + '/saveable' );
|
|
this.storage.remove( this.storagePrefix + '/summary' );
|
|
this.storage.remove( this.storagePrefix + '/showAdvanced' );
|
|
this.storage.remove( this.storagePrefix + '/formToken' );
|
|
|
|
this.emit( 'clearStorage' );
|
|
};
|
|
|
|
/**
|
|
* Handle window scroll events
|
|
*/
|
|
ReplyWidget.prototype.onWindowScroll = function () {
|
|
var rect = this.$element[ 0 ].getBoundingClientRect();
|
|
var viewportHeight = window.visualViewport ? visualViewport.height : this.viewportScrollContainer.clientHeight;
|
|
var floating = rect.bottom < 0 ? 'top' :
|
|
( rect.top > viewportHeight ? 'bottom' : null );
|
|
|
|
if ( floating !== this.floating ) {
|
|
this.floating = floating;
|
|
// Always remove classes as we have switched directly from top to bottom with a fast scroll
|
|
this.$element
|
|
.removeClass( 'ext-discussiontools-ui-replyWidget-floating-top ext-discussiontools-ui-replyWidget-floating-bottom' );
|
|
|
|
if ( this.floating ) {
|
|
// The following classes are used here:
|
|
// * ext-discussiontools-ui-replyWidget-floating-top
|
|
// * ext-discussiontools-ui-replyWidget-floating-bottom
|
|
this.$element.addClass( 'ext-discussiontools-ui-replyWidget-floating-' + this.floating );
|
|
}
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Handle events which change the visualViewport (scroll/resize)
|
|
*/
|
|
ReplyWidget.prototype.onViewportChange = function () {
|
|
if ( this.floating ) {
|
|
var isKeyboardOpen = visualViewport.height < this.viewportScrollContainer.clientHeight;
|
|
this.scrollBackBottomButton.toggle( !isKeyboardOpen );
|
|
this.scrollBackTopButton.toggle( !isKeyboardOpen );
|
|
}
|
|
};
|
|
|
|
ReplyWidget.prototype.setPending = function ( pending ) {
|
|
this.pending = pending;
|
|
if ( pending ) {
|
|
this.replyButton.setDisabled( true );
|
|
this.cancelButton.setDisabled( true );
|
|
this.replyBodyWidget.setReadOnly( true );
|
|
this.replyBodyWidget.pushPending();
|
|
} else {
|
|
this.replyButton.setDisabled( false );
|
|
this.cancelButton.setDisabled( false );
|
|
this.replyBodyWidget.setReadOnly( false );
|
|
this.replyBodyWidget.popPending();
|
|
this.updateButtons();
|
|
}
|
|
};
|
|
|
|
ReplyWidget.prototype.saveEditMode = function ( mode ) {
|
|
controller.getApi().saveOption( 'discussiontools-editmode', mode ).then( function () {
|
|
mw.user.options.set( 'discussiontools-editmode', mode );
|
|
} );
|
|
};
|
|
|
|
ReplyWidget.prototype.onAdvancedToggleClick = function () {
|
|
var showAdvanced = !this.showAdvanced;
|
|
mw.track( 'dt.schemaVisualEditorFeatureUse', {
|
|
feature: 'dtReply',
|
|
action: 'advanced-' + ( showAdvanced ? 'show' : 'hide' )
|
|
} );
|
|
controller.getApi().saveOption( 'discussiontools-showadvanced', +showAdvanced ).then( function () {
|
|
mw.user.options.set( 'discussiontools-showadvanced', +showAdvanced );
|
|
} );
|
|
this.toggleAdvanced( showAdvanced );
|
|
|
|
if ( showAdvanced ) {
|
|
var summary = this.editSummaryInput.getValue();
|
|
|
|
// If the current summary has not been edited yet, select the text following the autocomment to
|
|
// make it easier to change. Otherwise, move cursor to end.
|
|
var selectFromIndex = summary.length;
|
|
if ( this.isNewTopic ) {
|
|
var titleText = this.commentController.sectionTitle.getValue();
|
|
if ( summary === this.commentController.generateSummary( titleText ) ) {
|
|
selectFromIndex = titleText.length + '/* '.length + ' */ '.length;
|
|
}
|
|
} else {
|
|
// Same as summary.endsWith( defaultReplyTrail )
|
|
var defaultReplyTrail = '*/ ' + mw.msg( 'discussiontools-defaultsummary-reply' );
|
|
var endCommentIndex = summary.indexOf( defaultReplyTrail );
|
|
if ( endCommentIndex + defaultReplyTrail.length === summary.length ) {
|
|
selectFromIndex = endCommentIndex + 3;
|
|
}
|
|
}
|
|
|
|
this.editSummaryInput.selectRange( selectFromIndex, summary.length );
|
|
this.editSummaryInput.focus();
|
|
} else {
|
|
this.focus();
|
|
}
|
|
};
|
|
|
|
ReplyWidget.prototype.toggleAdvanced = function ( showAdvanced ) {
|
|
this.showAdvanced = showAdvanced === undefined ? !this.showAdvanced : showAdvanced;
|
|
this.advanced.toggle( !!this.showAdvanced );
|
|
this.advancedToggle.setIndicator( this.showAdvanced ? 'up' : 'down' );
|
|
|
|
this.storeEditSummary();
|
|
this.storage.set( this.storagePrefix + '/showAdvanced', this.showAdvanced ? '1' : '' );
|
|
};
|
|
|
|
ReplyWidget.prototype.onEditSummaryChange = function () {
|
|
this.storeEditSummary();
|
|
};
|
|
|
|
ReplyWidget.prototype.storeEditSummary = function () {
|
|
this.storage.set( this.storagePrefix + '/summary', this.getEditSummary() );
|
|
};
|
|
|
|
ReplyWidget.prototype.getEditSummary = function () {
|
|
return this.editSummaryInput.getValue();
|
|
};
|
|
|
|
ReplyWidget.prototype.onModeTabSelectChoose = function ( option ) {
|
|
var mode = option.getData(),
|
|
widget = this;
|
|
|
|
if ( mode === this.getMode() ) {
|
|
return;
|
|
}
|
|
|
|
this.modeTabSelect.setDisabled( true );
|
|
this.switch( mode ).then(
|
|
null,
|
|
function () {
|
|
// Switch failed, clear the tab selection
|
|
widget.modeTabSelect.selectItem( null );
|
|
}
|
|
).always( function () {
|
|
widget.modeTabSelect.setDisabled( false );
|
|
} );
|
|
};
|
|
|
|
ReplyWidget.prototype.switch = function ( mode ) {
|
|
var widget = this;
|
|
|
|
if ( mode === this.getMode() ) {
|
|
return $.Deferred().reject().promise();
|
|
}
|
|
|
|
var promise;
|
|
this.setPending( 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.
|
|
return promise.then( function () {
|
|
// Switch succeeded
|
|
mw.track( 'dt.schemaVisualEditorFeatureUse', {
|
|
feature: 'editor-switch',
|
|
action: (
|
|
mode === 'visual' ?
|
|
'visual' :
|
|
( enable2017Wikitext ? 'source-nwe' : 'source' )
|
|
) + '-desktop'
|
|
} );
|
|
} ).always( function () {
|
|
widget.setPending( false );
|
|
} );
|
|
};
|
|
|
|
/**
|
|
* Setup the widget
|
|
*
|
|
* @param {Object} [data] Initial data
|
|
* @param {Mixed} [data.value] Initial value
|
|
* @param {string} [data.showAdvanced] Whether the "Advanced" menu is initially visible
|
|
* @param {string} [data.editSummary] Initial edit summary
|
|
* @chainable
|
|
* @return {ReplyWidget}
|
|
*/
|
|
ReplyWidget.prototype.setup = function ( data ) {
|
|
data = data || {};
|
|
|
|
this.bindBeforeUnloadHandler();
|
|
if ( this.modeTabSelect ) {
|
|
// Make the option for the current mode disabled, to make it un-interactable
|
|
// (we override the styles to make it look as if it was selected)
|
|
this.modeTabSelect.findItemFromData( this.getMode() ).setDisabled( true );
|
|
}
|
|
this.saveEditMode( this.getMode() );
|
|
|
|
var summary = this.storage.get( this.storagePrefix + '/summary' ) || data.editSummary;
|
|
|
|
if ( !summary ) {
|
|
if ( this.isNewTopic ) {
|
|
// Edit summary is filled in when the user inputs the topic title,
|
|
// in NewTopicController#onSectionTitleChange
|
|
summary = '';
|
|
} else {
|
|
var title = this.commentController.getThreadItem().getHeading().getLinkableTitle();
|
|
summary = ( title ? '/* ' + title + ' */ ' : '' ) +
|
|
mw.msg( 'discussiontools-defaultsummary-reply' );
|
|
}
|
|
}
|
|
|
|
this.toggleAdvanced(
|
|
!!this.storage.get( this.storagePrefix + '/showAdvanced' ) ||
|
|
!!+mw.user.options.get( 'discussiontools-showadvanced' ) ||
|
|
!!data.showAdvanced
|
|
);
|
|
|
|
this.editSummaryInput.setValue( summary );
|
|
|
|
if ( this.isNewTopic ) {
|
|
this.commentController.sectionTitle.connect( this, { change: 'onInputChangeThrottled' } );
|
|
} else {
|
|
// De-indent replies on mobile
|
|
if ( OO.ui.isMobile() ) {
|
|
this.$element.css( 'margin-left', -this.$element.position().left );
|
|
}
|
|
}
|
|
|
|
mw.hook( 'wikipage.watchlistChange' ).add( this.onWatchToggleHandler );
|
|
|
|
// TODO: Use ve.addPassiveEventListener
|
|
this.$window.on( 'scroll', this.onWindowScrollThrottled );
|
|
if ( this.isIos && window.visualViewport ) {
|
|
$( visualViewport ).on( 'scroll resize', this.onViewportChangeThrottled );
|
|
}
|
|
|
|
return this;
|
|
};
|
|
|
|
/**
|
|
* Perform additional actions once the widget has been setup and is ready for input
|
|
*/
|
|
ReplyWidget.prototype.afterSetup = function () {
|
|
// Init preview and button state
|
|
this.onInputChange();
|
|
// Autosave
|
|
this.storage.set( this.storagePrefix + '/mode', this.getMode() );
|
|
};
|
|
|
|
/**
|
|
* Get a random token that is unique to this reply instance
|
|
*
|
|
* @return {string} Form token
|
|
*/
|
|
ReplyWidget.prototype.getFormToken = function () {
|
|
var formToken = this.storage.get( this.storagePrefix + '/formToken' );
|
|
if ( !formToken ) {
|
|
// See ApiBase::PARAM_MAX_CHARS in ApiDiscussionToolsEdit.php
|
|
var maxLength = 16;
|
|
formToken = Math.random().toString( 36 ).slice( 2, maxLength + 2 );
|
|
this.storage.set( this.storagePrefix + '/formToken', formToken );
|
|
}
|
|
return formToken;
|
|
};
|
|
|
|
/**
|
|
* Try to teardown the widget, prompting the user if unsaved changes will be lost.
|
|
*
|
|
* @chainable
|
|
* @return {jQuery.Promise} Resolves if widget was torn down, rejects if it wasn't
|
|
*/
|
|
ReplyWidget.prototype.tryTeardown = function () {
|
|
var promise,
|
|
widget = this;
|
|
|
|
if ( !this.isEmpty() || ( this.isNewTopic && this.commentController.sectionTitle.getValue() ) ) {
|
|
promise = OO.ui.getWindowManager().openWindow( this.isNewTopic ? 'abandontopic' : 'abandoncomment' )
|
|
.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 = promise.then( function () {
|
|
widget.teardown( 'abandoned' );
|
|
} );
|
|
return promise;
|
|
};
|
|
|
|
/**
|
|
* Teardown the widget
|
|
*
|
|
* @param {string} [mode] Teardown mode:
|
|
* - 'default': The reply was saved and can be discarded
|
|
* - 'abandoned': The reply was abandoned and discarded
|
|
* - 'refresh': The page is being refreshed, preserve auto-save
|
|
* @chainable
|
|
* @return {ReplyWidget}
|
|
*/
|
|
ReplyWidget.prototype.teardown = function ( mode ) {
|
|
// Call the change handler to save the current value in auto-save
|
|
this.onInputChange();
|
|
|
|
if ( this.isNewTopic ) {
|
|
this.commentController.sectionTitle.disconnect( this );
|
|
}
|
|
// Make sure that the selector is blurred before it gets removed from the document, otherwise
|
|
// event handlers for arrow keys are not removed, and it keeps trying to switch modes (T274423)
|
|
if ( this.modeTabSelect ) {
|
|
this.modeTabSelect.blur();
|
|
}
|
|
this.unbindBeforeUnloadHandler();
|
|
this.$window.off( 'scroll', this.onWindowScrollThrottled );
|
|
if ( this.isIos && window.visualViewport ) {
|
|
$( visualViewport ).off( 'scroll resize', this.onViewportChangeThrottled );
|
|
}
|
|
mw.hook( 'wikipage.watchlistChange' ).remove( this.onWatchToggleHandler );
|
|
|
|
this.isTornDown = true;
|
|
this.clear( mode === 'refresh' );
|
|
this.emit( 'teardown', mode );
|
|
return this;
|
|
};
|
|
|
|
/**
|
|
* Handle changes to the watch state of the page
|
|
*
|
|
* @param {boolean} isWatched
|
|
* @param {string} expiry
|
|
* @param {string} expirySelected
|
|
*/
|
|
ReplyWidget.prototype.onWatchToggle = function ( isWatched ) {
|
|
var widget = this;
|
|
if ( this.pageName === mw.config.get( 'wgRelevantPageName' ) ) {
|
|
this.checkboxesPromise.then( function ( checkboxes ) {
|
|
if ( checkboxes.checkboxesByName.wpWatchthis ) {
|
|
checkboxes.checkboxesByName.wpWatchthis.setSelected(
|
|
!!mw.user.options.get( 'watchdefault' ) ||
|
|
( !!mw.user.options.get( 'watchcreations' ) && !widget.pageExists ) ||
|
|
isWatched
|
|
);
|
|
}
|
|
} );
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Handle key down events anywhere in the reply widget
|
|
*
|
|
* @param {boolean} isMultiline The current input is multiline
|
|
* @param {jQuery.Event} e Key down event
|
|
* @return {boolean} Return false to prevent default event
|
|
*/
|
|
ReplyWidget.prototype.onKeyDown = function ( isMultiline, e ) {
|
|
if ( e.which === OO.ui.Keys.ESCAPE ) {
|
|
this.tryTeardown();
|
|
return false;
|
|
}
|
|
|
|
// VE surfaces already handle CTRL+Enter, but this will catch
|
|
// the plain surface, and the edit summary input.
|
|
if ( e.which === OO.ui.Keys.ENTER && ( !isMultiline || e.ctrlKey || e.metaKey ) ) {
|
|
this.onReplyClick();
|
|
return false;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Handle input change events anywhere in the reply widget
|
|
*/
|
|
ReplyWidget.prototype.onInputChange = function () {
|
|
if ( this.isTornDown ) {
|
|
// Ignore calls after teardown, which would clear the auto-save or crash
|
|
return;
|
|
}
|
|
|
|
this.updateButtons();
|
|
this.storage.set( this.storagePrefix + '/saveable', this.isEmpty() ? '' : '1' );
|
|
this.preparePreview();
|
|
};
|
|
|
|
/**
|
|
* Update the interface with the preview of the given wikitext.
|
|
*
|
|
* @param {string} [wikitext] Wikitext to preview, defaults to current value
|
|
* @return {jQuery.Promise} Promise resolved when we're done
|
|
*/
|
|
ReplyWidget.prototype.preparePreview = function ( wikitext ) {
|
|
var widget = this;
|
|
|
|
if ( this.getMode() !== 'source' ) {
|
|
return $.Deferred().resolve().promise();
|
|
}
|
|
|
|
wikitext = wikitext !== undefined ? wikitext : this.getValue();
|
|
wikitext = utils.htmlTrim( wikitext );
|
|
var title = this.isNewTopic && this.commentController.sectionTitle.getValue();
|
|
|
|
if ( this.previewWikitext === wikitext && this.previewTitle === title ) {
|
|
return $.Deferred().resolve().promise();
|
|
}
|
|
this.previewWikitext = wikitext;
|
|
this.previewTitle = title;
|
|
|
|
if ( this.previewRequest ) {
|
|
this.previewRequest.abort();
|
|
this.previewRequest = null;
|
|
}
|
|
|
|
var parsePromise;
|
|
if ( !wikitext ) {
|
|
parsePromise = $.Deferred().resolve( null ).promise();
|
|
} else {
|
|
this.previewRequest = parsePromise = controller.getApi().post( {
|
|
action: 'discussiontoolspreview',
|
|
type: this.isNewTopic ? 'topic' : 'reply',
|
|
page: this.pageName,
|
|
wikitext: wikitext,
|
|
sectiontitle: title,
|
|
useskin: mw.config.get( 'skin' ),
|
|
mobileformat: OO.ui.isMobile()
|
|
} );
|
|
}
|
|
|
|
return parsePromise.then( function ( response ) {
|
|
widget.$preview.html( response ? response.discussiontoolspreview.parse.text : '' );
|
|
|
|
if ( response ) {
|
|
mw.config.set( response.discussiontoolspreview.parse.jsconfigvars );
|
|
mw.loader.load( response.discussiontoolspreview.parse.modulestyles );
|
|
mw.loader.load( response.discussiontoolspreview.parse.modules );
|
|
}
|
|
|
|
mw.hook( 'wikipage.content' ).fire( widget.$preview );
|
|
} );
|
|
};
|
|
|
|
/**
|
|
* Update buttons when widget state has changed
|
|
*/
|
|
ReplyWidget.prototype.updateButtons = function () {
|
|
this.replyButton.setDisabled( this.isEmpty() );
|
|
};
|
|
|
|
/**
|
|
* Handle the first change in the reply widget
|
|
*
|
|
* Currently only the first change in the body, used for logging.
|
|
*/
|
|
ReplyWidget.prototype.onFirstChange = 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.updateParentRemovedError = function ( parentRemoved ) {
|
|
if ( !parentRemoved ) {
|
|
if ( this.parentRemovedErrorMessage ) {
|
|
this.parentRemovedErrorMessage.$element.remove();
|
|
this.parentRemovedErrorMessage = null;
|
|
}
|
|
return;
|
|
}
|
|
if ( !this.parentRemovedErrorMessage ) {
|
|
this.parentRemovedErrorMessage = this.createErrorMessage( mw.msg( 'discussiontools-error-comment-disappeared' ) );
|
|
}
|
|
// Don't show the reload prompt if there is nothing to reply to
|
|
this.updateNewCommentsWarning( [] );
|
|
};
|
|
|
|
/**
|
|
* Update "new comments" warning based on list of new comments found
|
|
*
|
|
* @param {Object[]} comments Array of JSON-serialized CommentItem's
|
|
*/
|
|
ReplyWidget.prototype.updateNewCommentsWarning = function ( comments ) {
|
|
var widget = this;
|
|
if ( !comments.length ) {
|
|
if ( this.newCommentsWarning ) {
|
|
this.newCommentsWarning.toggle( false );
|
|
}
|
|
return;
|
|
}
|
|
// Don't show the reload prompt if there is nothing to reply to
|
|
if ( this.parentRemovedErrorMessage ) {
|
|
return;
|
|
}
|
|
if ( !this.newCommentsWarning ) {
|
|
this.newCommentsShow = new OO.ui.ButtonWidget( {
|
|
flags: [ 'progressive' ]
|
|
} );
|
|
this.newCommentsClose = new OO.ui.ButtonWidget( {
|
|
icon: 'close',
|
|
label: mw.msg( 'ooui-popup-widget-close-button-aria-label' ),
|
|
invisibleLabel: true
|
|
} );
|
|
this.newCommentsWarning = new OO.ui.ButtonGroupWidget( {
|
|
classes: [ 'ext-discussiontools-ui-replyWidget-newComments' ],
|
|
items: [
|
|
this.newCommentsShow,
|
|
this.newCommentsClose
|
|
]
|
|
} );
|
|
this.newCommentsShow.connect( this, { click: [ 'emit', 'reloadPage' ] } );
|
|
this.newCommentsClose.connect( this, { click: 'onNewCommentsCloseClick' } );
|
|
this.$bodyWrapper.append( this.newCommentsWarning.$element );
|
|
}
|
|
|
|
this.newCommentsShow.setLabel(
|
|
mw.msg( 'discussiontools-replywidget-newcomments-button', mw.language.convertNumber( comments.length ) )
|
|
);
|
|
if ( !this.hideNewCommentsWarning ) {
|
|
this.newCommentsWarning.toggle( true );
|
|
setTimeout( function () {
|
|
widget.newCommentsWarning.$element.addClass( 'ext-discussiontools-ui-replyWidget-newComments-open' );
|
|
} );
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Handle click events on the new comments close button
|
|
*/
|
|
ReplyWidget.prototype.onNewCommentsCloseClick = function () {
|
|
this.newCommentsWarning.toggle( false );
|
|
// Hide the warning for the rest of the lifetime of the widget
|
|
this.hideNewCommentsWarning = true;
|
|
this.focus();
|
|
};
|
|
|
|
/**
|
|
* Create an error message widget and attach it to the DOM
|
|
*
|
|
* @param {string} message Message string
|
|
* @return {OO.ui.MessageWidget} Message widget
|
|
*/
|
|
ReplyWidget.prototype.createErrorMessage = function ( message ) {
|
|
var errorMessage = new OO.ui.MessageWidget( {
|
|
type: 'error',
|
|
label: message,
|
|
classes: [ 'ext-discussiontools-ui-replyWidget-error' ]
|
|
} );
|
|
errorMessage.$element.insertBefore( this.replyBodyWidget.$element );
|
|
errorMessage.scrollElementIntoView();
|
|
return errorMessage;
|
|
};
|
|
|
|
/**
|
|
* Handle clicks on the reply button
|
|
*/
|
|
ReplyWidget.prototype.onReplyClick = function () {
|
|
var widget = this;
|
|
|
|
if ( this.pending || this.isEmpty() ) {
|
|
return;
|
|
}
|
|
|
|
if ( this.saveErrorMessage ) {
|
|
this.saveErrorMessage.$element.remove();
|
|
this.saveErrorMessage = null;
|
|
}
|
|
|
|
this.saveInitiated = mw.now();
|
|
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
|
|
var pageName = this.pageName;
|
|
|
|
logger( { action: 'saveAttempt' } );
|
|
widget.commentController.save( pageName ).fail( function ( code, data ) {
|
|
// Compare to ve.init.mw.ArticleTargetEvents.js in VisualEditor.
|
|
var typeMap = {
|
|
badtoken: 'userBadToken',
|
|
assertanonfailed: 'userNewUser',
|
|
assertuserfailed: 'userNewUser',
|
|
assertnameduserfailed: 'userNewUser',
|
|
'abusefilter-disallowed': 'extensionAbuseFilter',
|
|
'abusefilter-warning': 'extensionAbuseFilter',
|
|
captcha: 'extensionCaptcha',
|
|
spamblacklist: 'extensionSpamBlacklist',
|
|
'titleblacklist-forbidden': 'extensionTitleBlacklist',
|
|
pagedeleted: 'editPageDeleted',
|
|
editconflict: 'editConflict'
|
|
};
|
|
|
|
if ( widget.captchaMessage ) {
|
|
widget.captchaMessage.$element.detach();
|
|
}
|
|
widget.captchaInput = undefined;
|
|
|
|
if ( OO.getProp( data, 'discussiontoolsedit', 'edit', 'captcha' ) ) {
|
|
code = 'captcha';
|
|
|
|
widget.captchaInput = new mw.libs.confirmEdit.CaptchaInputWidget(
|
|
OO.getProp( data, 'discussiontoolsedit', '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,
|
|
classes: [ 'ext-discussiontools-ui-replyWidget-captcha' ]
|
|
} );
|
|
widget.captchaMessage.$element.insertAfter( widget.$preview );
|
|
|
|
widget.captchaInput.focus();
|
|
widget.captchaInput.scrollElementIntoView();
|
|
|
|
} else {
|
|
if ( !(
|
|
// Don't duplicate the parentRemovedErrorMessage
|
|
code === 'discussiontools-commentname-notfound' && widget.parentRemovedErrorMessage
|
|
) ) {
|
|
widget.saveErrorMessage = widget.createErrorMessage(
|
|
code instanceof Error ? code.toString() : controller.getApi().getErrorMessage( data )
|
|
);
|
|
}
|
|
}
|
|
|
|
if ( code instanceof Error ) {
|
|
code = 'exception';
|
|
}
|
|
// Log more precise error codes, mw.Api just gives us 'http' in all of these cases
|
|
if ( data ) {
|
|
if ( data.textStatus === 'timeout' || data.textStatus === 'abort' || data.textStatus === 'parsererror' ) {
|
|
code = data.textStatus;
|
|
} else if ( data.xhr ) {
|
|
code = 'http-' + ( data.xhr.status || 0 );
|
|
}
|
|
}
|
|
|
|
logger( {
|
|
action: 'saveFailure',
|
|
timing: mw.now() - widget.saveInitiated,
|
|
message: code,
|
|
type: typeMap[ code ] || 'responseUnknown'
|
|
} );
|
|
} ).always( function () {
|
|
widget.setPending( false );
|
|
} );
|
|
};
|
|
|
|
/**
|
|
* Handle click events on one of the scroll back buttons
|
|
*/
|
|
ReplyWidget.prototype.onScrollBackButtonClick = function () {
|
|
this.commentController.showAndFocus();
|
|
};
|
|
|
|
module.exports = ReplyWidget;
|