mirror of
https://gerrit.wikimedia.org/r/mediawiki/extensions/DiscussionTools
synced 2024-11-28 18:21:50 +00:00
a2c662d3b2
Problems with the current setup: * Each CommentController must have exactly one link. For T277371 we want multiple, and for T282205 we might want zero. * CommentController objects must be constructed immediately. They are implemented to make this pretty fast, but it's still unnecessary work to do on page load. * Only one link may be activated at a time, and activating one affects the styling of others, so CommentController has to use global state to check if it can set up and to update them. Instead introduce ReplyLinksController, which knows about all reply links and which one is active, and emits events that allow CommentControllers to be constructed on demand. Change-Id: Iabdeded2e71e598ae78703a6ff9410d0cfba397c
523 lines
16 KiB
JavaScript
523 lines
16 KiB
JavaScript
/**
|
|
* @external CommentItem
|
|
* @external CommentDetails
|
|
* @external ThreadItem
|
|
*/
|
|
|
|
var
|
|
controller = require( './controller.js' ),
|
|
modifier = require( './modifier.js' ),
|
|
logger = require( './logger.js' ),
|
|
scrollPadding = { top: 10, bottom: 10 },
|
|
defaultEditMode = mw.user.options.get( 'discussiontools-editmode' ) || mw.config.get( 'wgDiscussionToolsFallbackEditMode' ),
|
|
defaultVisual = defaultEditMode === 'visual',
|
|
featuresEnabled = mw.config.get( 'wgDiscussionToolsFeaturesEnabled' ) || {},
|
|
enable2017Wikitext = featuresEnabled.sourcemodetoolbar,
|
|
conf = mw.config.get( 'wgVisualEditorConfig' ),
|
|
visualModules = [ 'ext.discussionTools.ReplyWidgetVisual' ]
|
|
.concat( conf.pluginModules.filter( mw.loader.getState ) ),
|
|
plainModules = [ 'ext.discussionTools.ReplyWidgetPlain' ];
|
|
|
|
// Start loading reply widget code
|
|
if ( defaultVisual || enable2017Wikitext ) {
|
|
mw.loader.using( visualModules );
|
|
} else {
|
|
mw.loader.using( plainModules );
|
|
}
|
|
|
|
function CommentController( $pageContainer, comment, parser ) {
|
|
// Mixin constructors
|
|
OO.EventEmitter.call( this );
|
|
|
|
this.$pageContainer = $pageContainer;
|
|
this.comment = comment;
|
|
this.parser = parser;
|
|
this.newListItem = null;
|
|
this.replyWidgetPromise = null;
|
|
}
|
|
|
|
OO.initClass( CommentController );
|
|
OO.mixinClass( CommentController, OO.EventEmitter );
|
|
|
|
/* CommentController private utilities */
|
|
|
|
/**
|
|
* Get the latest revision ID of the page.
|
|
*
|
|
* @param {string} pageName
|
|
* @return {jQuery.Promise}
|
|
*/
|
|
function getLatestRevId( pageName ) {
|
|
return controller.getApi().get( {
|
|
action: 'query',
|
|
prop: 'revisions',
|
|
rvprop: 'ids',
|
|
rvlimit: 1,
|
|
titles: pageName
|
|
} ).then( function ( resp ) {
|
|
return resp.query.pages[ 0 ].revisions[ 0 ].revid;
|
|
} );
|
|
}
|
|
|
|
/**
|
|
* Like #checkCommentOnPage, but assumes the comment was found on the current page,
|
|
* and then follows transclusions to determine the source page where it is written.
|
|
*
|
|
* @param {CommentItem} comment Comment
|
|
* @return {jQuery.Promise} Promise which resolves with a CommentDetails object, or rejects with an error
|
|
*/
|
|
CommentController.prototype.getTranscludedFromSource = function ( comment ) {
|
|
var pageName = mw.config.get( 'wgRelevantPageName' ),
|
|
oldId = mw.config.get( 'wgCurRevisionId' );
|
|
|
|
function followTransclusion( recursionLimit, code, data ) {
|
|
var errorData;
|
|
if ( recursionLimit > 0 && code === 'comment-is-transcluded' ) {
|
|
errorData = data.errors[ 0 ].data;
|
|
if ( errorData.follow && typeof errorData.transcludedFrom === 'string' ) {
|
|
return getLatestRevId( errorData.transcludedFrom ).then( function ( latestRevId ) {
|
|
// Fetch the transcluded page, until we cross the recursion limit
|
|
return controller.checkCommentOnPage( errorData.transcludedFrom, latestRevId, comment )
|
|
.catch( followTransclusion.bind( null, recursionLimit - 1 ) );
|
|
} );
|
|
}
|
|
}
|
|
return $.Deferred().reject( code, data );
|
|
}
|
|
|
|
// Arbitrary limit of 10 steps, which should be more than anyone could ever need
|
|
// (there are reasonable use cases for at least 2)
|
|
var promise = controller.checkCommentOnPage( pageName, oldId, comment )
|
|
.catch( followTransclusion.bind( null, 10 ) );
|
|
|
|
return promise;
|
|
};
|
|
|
|
/* Static properties */
|
|
|
|
CommentController.static.initType = 'page';
|
|
|
|
/* Methods */
|
|
|
|
/**
|
|
* Create and setup the reply widget
|
|
*
|
|
* @param {string} [mode] Optionally force a mode, 'visual' or 'source'
|
|
* @param {boolean} [hideErrors]
|
|
*/
|
|
CommentController.prototype.setup = function ( mode, hideErrors ) {
|
|
var comment = this.comment,
|
|
commentController = this;
|
|
|
|
if ( mode === undefined ) {
|
|
mode = mw.user.options.get( 'discussiontools-editmode' ) ||
|
|
( defaultVisual ? 'visual' : 'source' );
|
|
}
|
|
|
|
logger( {
|
|
action: 'init',
|
|
type: this.constructor.static.initType || 'page',
|
|
mechanism: 'click',
|
|
// eslint-disable-next-line camelcase
|
|
editor_interface: mode === 'visual' ? 'visualeditor' :
|
|
( enable2017Wikitext ? 'wikitext-2017' : 'wikitext' )
|
|
} );
|
|
|
|
if ( !this.replyWidgetPromise ) {
|
|
this.replyWidgetPromise = this.getTranscludedFromSource( comment ).then( function ( commentDetails ) {
|
|
return commentController.createReplyWidget( comment, commentDetails, { mode: mode } );
|
|
}, function ( code, data ) {
|
|
commentController.teardown();
|
|
|
|
if ( !hideErrors ) {
|
|
OO.ui.alert(
|
|
code instanceof Error ? code.toString() : controller.getApi().getErrorMessage( data ),
|
|
{ size: 'medium' }
|
|
);
|
|
}
|
|
|
|
logger( {
|
|
action: 'abort',
|
|
type: 'preinit'
|
|
} );
|
|
|
|
commentController.replyWidgetPromise = null;
|
|
|
|
return $.Deferred().reject();
|
|
} );
|
|
|
|
// On first load, add a placeholder list item
|
|
commentController.newListItem = modifier.addListItem( comment );
|
|
$( commentController.newListItem ).text( mw.msg( 'discussiontools-replywidget-loading' ) );
|
|
}
|
|
|
|
commentController.replyWidgetPromise.then( function ( replyWidget ) {
|
|
if ( !commentController.newListItem ) {
|
|
// On subsequent loads, there's no list item yet, so create one now
|
|
commentController.newListItem = modifier.addListItem( comment );
|
|
}
|
|
$( commentController.newListItem ).empty().append( replyWidget.$element );
|
|
|
|
commentController.setupReplyWidget( replyWidget );
|
|
|
|
commentController.showAndFocus();
|
|
|
|
logger( { action: 'ready' } );
|
|
logger( { action: 'loaded' } );
|
|
} );
|
|
};
|
|
|
|
CommentController.prototype.getReplyWidgetClass = function ( visual ) {
|
|
// If 2017WTE mode is enabled, always use ReplyWidgetVisual.
|
|
visual = visual || enable2017Wikitext;
|
|
|
|
return mw.loader.using( visual ? visualModules : plainModules ).then( function () {
|
|
return require( visual ? 'ext.discussionTools.ReplyWidgetVisual' : 'ext.discussionTools.ReplyWidgetPlain' );
|
|
} );
|
|
};
|
|
|
|
/**
|
|
* @param {CommentItem} comment
|
|
* @param {CommentDetails} commentDetails
|
|
* @param {Object} config
|
|
* @return {jQuery.Promise} Promise resolved with a ReplyWidget
|
|
*/
|
|
CommentController.prototype.createReplyWidget = function ( comment, commentDetails, config ) {
|
|
var commentController = this;
|
|
return this.getReplyWidgetClass( config.mode === 'visual' ).then( function ( ReplyWidget ) {
|
|
return new ReplyWidget( commentController, comment, commentDetails, config );
|
|
} );
|
|
};
|
|
|
|
CommentController.prototype.setupReplyWidget = function ( replyWidget, data ) {
|
|
replyWidget.connect( this, { teardown: 'teardown' } );
|
|
|
|
replyWidget.setup( data );
|
|
|
|
this.replyWidget = replyWidget;
|
|
};
|
|
|
|
/**
|
|
* Focus the first input field inside the controller.
|
|
*/
|
|
CommentController.prototype.focus = function () {
|
|
this.replyWidget.focus();
|
|
};
|
|
|
|
CommentController.prototype.showAndFocus = function () {
|
|
var commentController = this;
|
|
this.focus();
|
|
// Calling can trigger a scroll-into-view withing VE, so wait for this to
|
|
// happen so it doesn't cancel our scrolling of the whole widget into view
|
|
setTimeout( function () {
|
|
commentController.replyWidget.scrollElementIntoView( { padding: scrollPadding } );
|
|
} );
|
|
};
|
|
|
|
CommentController.prototype.teardown = function ( abandoned ) {
|
|
modifier.removeAddedListItem( this.newListItem );
|
|
this.newListItem = null;
|
|
this.emit( 'teardown', abandoned );
|
|
};
|
|
|
|
/**
|
|
* Get the parameters of the API query that can be used to post this comment.
|
|
*
|
|
* @param {ThreadItem} comment Parent comment
|
|
* @param {string} pageName Title of the page to post on
|
|
* @param {Object} checkboxes Value of the promise returned by controller#getCheckboxesPromise
|
|
* @return {Object}
|
|
*/
|
|
CommentController.prototype.getApiQuery = function ( comment, pageName, checkboxes ) {
|
|
var replyWidget = this.replyWidget;
|
|
var sameNameComments = this.parser.findCommentsByName( comment.name );
|
|
|
|
var mode = replyWidget.getMode();
|
|
var tags = [
|
|
'discussiontools',
|
|
'discussiontools-reply',
|
|
'discussiontools-' + mode
|
|
];
|
|
|
|
if ( mode === 'source' && enable2017Wikitext ) {
|
|
tags.push( 'discussiontools-source-enhanced' );
|
|
}
|
|
|
|
var data = {
|
|
action: 'discussiontoolsedit',
|
|
paction: 'addcomment',
|
|
page: pageName,
|
|
commentname: comment.name,
|
|
// Only specify this if necessary to disambiguate, to avoid errors if the parent changes
|
|
commentid: sameNameComments.length > 1 ? comment.id : undefined,
|
|
summary: replyWidget.getEditSummary(),
|
|
assert: mw.user.isAnon() ? 'anon' : 'user',
|
|
assertuser: mw.user.getName() || undefined,
|
|
uselang: mw.config.get( 'wgUserLanguage' ),
|
|
// HACK: Always display reply links afterwards, ignoring preferences etc., in case this was
|
|
// a page view with reply links forced with ?dtenable=1 or otherwise
|
|
dtenable: '1',
|
|
dttags: tags.join( ',' )
|
|
};
|
|
|
|
if ( replyWidget.getMode() === 'source' ) {
|
|
data.wikitext = replyWidget.getValue();
|
|
} else {
|
|
data.html = replyWidget.getValue();
|
|
}
|
|
|
|
var captchaInput = replyWidget.captchaInput;
|
|
if ( captchaInput ) {
|
|
data.captchaid = captchaInput.getCaptchaId();
|
|
data.captchaword = captchaInput.getCaptchaWord();
|
|
}
|
|
|
|
if ( checkboxes.checkboxesByName.wpWatchthis ) {
|
|
data.watchlist = checkboxes.checkboxesByName.wpWatchthis.isSelected() ?
|
|
'watch' :
|
|
'unwatch';
|
|
}
|
|
|
|
return data;
|
|
};
|
|
|
|
CommentController.prototype.save = function ( comment, pageName ) {
|
|
var replyWidget = this.replyWidget,
|
|
commentController = this;
|
|
|
|
return this.replyWidget.checkboxesPromise.then( function ( checkboxes ) {
|
|
var data = commentController.getApiQuery( comment, pageName, checkboxes );
|
|
|
|
// No timeout. Huge talk pages can take a long time to save, and falsely reporting an error
|
|
// could result in duplicate messages if the user retries. (T249071)
|
|
var defaults = OO.copy( controller.getApi().defaults );
|
|
defaults.timeout = 0;
|
|
var noTimeoutApi = new mw.Api( defaults );
|
|
|
|
return mw.libs.ve.targetSaver.postContent(
|
|
data, { api: noTimeoutApi }
|
|
).catch( function ( code, responseData ) {
|
|
// Better user-facing error messages
|
|
if ( code === 'editconflict' ) {
|
|
return $.Deferred().reject( code, { errors: [ {
|
|
code: code,
|
|
html: mw.message( 'discussiontools-error-comment-conflict' ).parse()
|
|
} ] } ).promise();
|
|
}
|
|
if (
|
|
code === 'discussiontools-commentid-notfound' ||
|
|
code === 'discussiontools-commentname-ambiguous' ||
|
|
code === 'discussiontools-commentname-notfound'
|
|
) {
|
|
return $.Deferred().reject( code, { errors: [ {
|
|
code: code,
|
|
html: mw.message( 'discussiontools-error-comment-disappeared' ).parse()
|
|
} ] } ).promise();
|
|
}
|
|
return $.Deferred().reject( code, responseData ).promise();
|
|
} ).then( function ( responseData ) {
|
|
controller.update( responseData, comment, pageName, replyWidget );
|
|
} );
|
|
} );
|
|
};
|
|
|
|
CommentController.prototype.switchToWikitext = function () {
|
|
var oldWidget = this.replyWidget,
|
|
target = oldWidget.replyBodyWidget.target,
|
|
oldShowAdvanced = oldWidget.showAdvanced,
|
|
oldEditSummary = oldWidget.getEditSummary(),
|
|
previewDeferred = $.Deferred(),
|
|
commentController = this;
|
|
|
|
// TODO: We may need to pass oldid/etag when editing is supported
|
|
var wikitextPromise = target.getWikitextFragment( target.getSurface().getModel().getDocument() );
|
|
this.replyWidgetPromise = this.createReplyWidget(
|
|
oldWidget.comment,
|
|
oldWidget.commentDetails,
|
|
{ mode: 'source' }
|
|
);
|
|
|
|
return $.when( wikitextPromise, this.replyWidgetPromise ).then( function ( wikitext, replyWidget ) {
|
|
// To prevent the "Reply" / "Cancel" buttons from shifting when the preview loads,
|
|
// wait for the preview (but no longer than 500 ms) before swithing the editors.
|
|
replyWidget.preparePreview( wikitext ).then( previewDeferred.resolve );
|
|
setTimeout( previewDeferred.resolve, 500 );
|
|
|
|
return previewDeferred.then( function () {
|
|
// Teardown the old widget
|
|
oldWidget.disconnect( commentController );
|
|
oldWidget.teardown();
|
|
|
|
// Swap out the DOM nodes
|
|
oldWidget.$element.replaceWith( replyWidget.$element );
|
|
|
|
commentController.setupReplyWidget( replyWidget, {
|
|
value: wikitext,
|
|
showAdvanced: oldShowAdvanced,
|
|
editSummary: oldEditSummary
|
|
} );
|
|
|
|
// Focus the editor
|
|
replyWidget.focus();
|
|
} );
|
|
} );
|
|
};
|
|
|
|
/**
|
|
* Remove empty lines and add indent characters to convert the paragraphs in given wikitext to
|
|
* definition list items, as customary in discussions.
|
|
*
|
|
* @param {string} wikitext
|
|
* @param {string} indent Indent character, ':' or '*'
|
|
* @return {string}
|
|
*/
|
|
CommentController.prototype.doIndentReplacements = function ( wikitext, indent ) {
|
|
wikitext = modifier.sanitizeWikitextLinebreaks( wikitext );
|
|
|
|
wikitext = wikitext.split( '\n' ).map( function ( line ) {
|
|
return indent + line;
|
|
} ).join( '\n' );
|
|
|
|
return wikitext;
|
|
};
|
|
|
|
/**
|
|
* Turn definition list items, customary in discussions, back into normal paragraphs, suitable for
|
|
* the editing interface.
|
|
*
|
|
* @param {Node} rootNode Node potentially containing definition lists (modified in-place)
|
|
*/
|
|
CommentController.prototype.undoIndentReplacements = function ( rootNode ) {
|
|
var children = Array.prototype.slice.call( rootNode.childNodes );
|
|
// There may be multiple lists when some lines are template generated
|
|
children.forEach( function ( child ) {
|
|
if ( child.nodeType === Node.ELEMENT_NODE ) {
|
|
// Unwrap list
|
|
modifier.unwrapList( child );
|
|
}
|
|
} );
|
|
};
|
|
|
|
/**
|
|
* Get the list of selectors that match nodes that can't be inserted in the comment. (We disallow
|
|
* things that generate wikitext syntax that may conflict with list item syntax.)
|
|
*
|
|
* @return {Object} Map of type used for error messages (string) to CSS selector (string)
|
|
*/
|
|
CommentController.prototype.getUnsupportedNodeSelectors = function () {
|
|
return {
|
|
// Tables are almost always multi-line
|
|
table: 'table',
|
|
// Headings are converted to plain text before we can detect them:
|
|
// `:==h2==` -> `<p>==h2==</p>`
|
|
// heading: 'h1, h2, h3, h4, h5, h6',
|
|
// Templates can be multiline
|
|
template: '[typeof*="mw:Transclusion"]',
|
|
// Extensions (includes references) can be multiline, could be supported later (T251633)
|
|
extension: '[typeof*="mw:Extension"]'
|
|
// Images are probably fine unless a multi-line caption was used (rare)
|
|
// image: 'figure, figure-inline'
|
|
};
|
|
};
|
|
|
|
CommentController.prototype.switchToVisual = function () {
|
|
var oldWidget = this.replyWidget,
|
|
oldShowAdvanced = oldWidget.showAdvanced,
|
|
oldEditSummary = oldWidget.getEditSummary(),
|
|
wikitext = oldWidget.getValue(),
|
|
commentController = this;
|
|
|
|
// Replace wikitext signatures with a special marker recognized by DtDmMWSignatureNode
|
|
// to render them as signature nodes in visual mode.
|
|
wikitext = wikitext.replace(
|
|
// Replace ~~~~ (four tildes), but not ~~~~~ (five tildes)
|
|
/([^~]|^)~~~~([^~]|$)/g,
|
|
'$1<span data-dtsignatureforswitching="1"></span>$2'
|
|
);
|
|
|
|
var parsePromise;
|
|
if ( wikitext ) {
|
|
wikitext = this.doIndentReplacements( wikitext, ':' );
|
|
|
|
// Based on ve.init.mw.Target#parseWikitextFragment
|
|
parsePromise = controller.getApi().post( {
|
|
action: 'visualeditor',
|
|
paction: 'parsefragment',
|
|
page: oldWidget.pageName,
|
|
wikitext: wikitext,
|
|
pst: true
|
|
} ).then( function ( response ) {
|
|
return response && response.visualeditor.content;
|
|
} );
|
|
} else {
|
|
parsePromise = $.Deferred().resolve( '' ).promise();
|
|
}
|
|
this.replyWidgetPromise = this.createReplyWidget(
|
|
oldWidget.comment,
|
|
oldWidget.commentDetails,
|
|
{ mode: 'visual' }
|
|
);
|
|
|
|
return $.when( parsePromise, this.replyWidgetPromise ).then( function ( html, replyWidget ) {
|
|
var unsupportedSelectors = commentController.getUnsupportedNodeSelectors();
|
|
|
|
var doc;
|
|
if ( html ) {
|
|
doc = replyWidget.replyBodyWidget.target.parseDocument( html );
|
|
// Remove RESTBase IDs (T253584)
|
|
mw.libs.ve.stripRestbaseIds( doc );
|
|
// Check for tables, headings, images, templates
|
|
for ( var type in unsupportedSelectors ) {
|
|
if ( doc.querySelector( unsupportedSelectors[ type ] ) ) {
|
|
var $msg = $( '<div>' ).html(
|
|
mw.message(
|
|
'discussiontools-error-noswitchtove',
|
|
// The following messages are used here:
|
|
// * discussiontools-error-noswitchtove-extension
|
|
// * discussiontools-error-noswitchtove-table
|
|
// * discussiontools-error-noswitchtove-template
|
|
mw.msg( 'discussiontools-error-noswitchtove-' + type )
|
|
).parse()
|
|
);
|
|
$msg.find( 'a' ).attr( {
|
|
target: '_blank',
|
|
rel: 'noopener'
|
|
} );
|
|
OO.ui.alert(
|
|
$msg.contents(),
|
|
{
|
|
title: mw.msg( 'discussiontools-error-noswitchtove-title' ),
|
|
size: 'medium'
|
|
}
|
|
);
|
|
mw.track( 'dt.schemaVisualEditorFeatureUse', {
|
|
feature: 'editor-switch',
|
|
action: 'dialog-prevent-show'
|
|
} );
|
|
|
|
return $.Deferred().reject().promise();
|
|
}
|
|
}
|
|
commentController.undoIndentReplacements( doc.body );
|
|
}
|
|
|
|
// Teardown the old widget
|
|
oldWidget.disconnect( commentController );
|
|
oldWidget.teardown();
|
|
|
|
// Swap out the DOM nodes
|
|
oldWidget.$element.replaceWith( replyWidget.$element );
|
|
|
|
commentController.setupReplyWidget( replyWidget, {
|
|
value: doc,
|
|
showAdvanced: oldShowAdvanced,
|
|
editSummary: oldEditSummary
|
|
} );
|
|
|
|
// Focus the editor
|
|
replyWidget.focus();
|
|
} );
|
|
};
|
|
|
|
module.exports = CommentController;
|