Switching between VE & wikitext (plain)

Bug: T234403
Change-Id: I84a639ab8e31ec9f683a63b0ecf184f426df09dd
This commit is contained in:
Ed Sanders 2020-04-27 17:23:27 +01:00
parent d18496f025
commit 56d8721fd7
8 changed files with 347 additions and 34 deletions

View file

@ -91,6 +91,8 @@
"discussiontools-replywidget-anon-warning",
"discussiontools-replywidget-cancel",
"discussiontools-replywidget-feedback",
"discussiontools-replywidget-mode-source",
"discussiontools-replywidget-mode-visual",
"discussiontools-replywidget-placeholder-reply",
"discussiontools-replywidget-preview",
"discussiontools-replywidget-reply",
@ -98,6 +100,7 @@
"discussiontools-replywidget-transcluded"
],
"dependencies": [
"oojs-ui-widgets",
"ext.discussionTools.init",
"mediawiki.widgets.AbandonEditDialog"
],
@ -112,7 +115,6 @@
],
"dependencies": [
"ext.discussionTools.ReplyWidget",
"oojs-ui-core",
"mediawiki.editfont.styles",
"mediawiki.user",
"mediawiki.jqueryMsg"

View file

@ -171,7 +171,18 @@ CommentController.prototype.setup = function () {
$( commentController.newListItem ).text( mw.msg( 'discussiontools-replywidget-loading' ) );
}
commentController.replyWidgetPromise.then( this.setupReplyWidget.bind( this ) );
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( commentController.comment );
}
$( commentController.newListItem ).empty().append( replyWidget.$element );
commentController.setupReplyWidget( replyWidget, true );
logger( { action: 'ready' } );
logger( { action: 'loaded' } );
} );
};
CommentController.prototype.getReplyWidgetClass = function ( visual ) {
@ -191,27 +202,24 @@ CommentController.prototype.createReplyWidget = function ( parsoidData, visual )
var commentController = this;
return this.getReplyWidgetClass( visual ).then( function ( ReplyWidget ) {
commentController.replyWidget = new ReplyWidget( commentController, parsoidData, {
return new ReplyWidget( commentController, parsoidData, {
input: {
authors: parser.getAuthors( commentController.thread )
}
} );
commentController.replyWidget.connect( commentController, { teardown: 'teardown' } );
} );
};
CommentController.prototype.setupReplyWidget = function () {
if ( !this.newListItem ) {
// On subsequent loads, there's no list item yet, so create one now
this.newListItem = modifier.addListItem( this.comment );
}
$( this.newListItem ).empty().append( this.replyWidget.$element );
this.replyWidget.setup();
this.replyWidget.scrollElementIntoView( { padding: scrollPadding } );
this.replyWidget.focus();
CommentController.prototype.setupReplyWidget = function ( replyWidget, scrollIntoView ) {
replyWidget.connect( this, { teardown: 'teardown' } );
logger( { action: 'ready' } );
logger( { action: 'loaded' } );
replyWidget.setup();
if ( scrollIntoView ) {
replyWidget.scrollElementIntoView( { padding: scrollPadding } );
}
replyWidget.focus();
this.replyWidget = replyWidget;
};
CommentController.prototype.teardown = function () {
@ -363,4 +371,82 @@ CommentController.prototype.save = function ( parsoidData ) {
} );
};
CommentController.prototype.switchToWikitext = function () {
var wikitextPromise,
oldWidget = this.replyWidget,
pageData = oldWidget.parsoidData.pageData,
target = oldWidget.replyBodyWidget.target,
commentController = this;
wikitextPromise = target.getWikitextFragment(
target.getSurface().getModel().getDocument(),
{
page: pageData.pageName,
baserevid: pageData.oldId,
etag: pageData.etag
}
);
this.replyWidgetPromise = this.createReplyWidget( oldWidget.parsoidData, false );
return $.when( wikitextPromise, this.replyWidgetPromise ).then( function ( wikitext, replyWidget ) {
// Swap out the DOM nodes
oldWidget.$element.replaceWith( replyWidget.$element );
// Teardown the old widget
oldWidget.disconnect( commentController );
oldWidget.teardown();
replyWidget.setValue( controller.sanitizeWikitextLinebreaks( wikitext ) );
commentController.setupReplyWidget( replyWidget );
} );
};
CommentController.prototype.switchToVisual = function () {
var parsePromise,
oldWidget = this.replyWidget,
wikitext = oldWidget.getValue(),
pageData = oldWidget.parsoidData.pageData,
commentController = this;
wikitext = controller.sanitizeWikitextLinebreaks( wikitext ).trim();
if ( wikitext ) {
wikitext = wikitext.split( '\n' ).map( function ( line ) {
return ':' + line;
} ).join( '\n' );
// Based on ve.init.mw.Target#parseWikitextFragment
parsePromise = ( new mw.Api() ).post( {
action: 'visualeditor',
paction: 'parsefragment',
page: pageData.pageName,
wikitext: wikitext
} ).then( function ( response ) {
return response && response.visualeditor.content;
} );
} else {
parsePromise = $.Deferred().resolve( '' ).promise();
}
this.replyWidgetPromise = this.createReplyWidget( oldWidget.parsoidData, true );
return $.when( parsePromise, this.replyWidgetPromise ).then( function ( html, replyWidget ) {
var doc;
// Swap out the DOM nodes
oldWidget.$element.replaceWith( replyWidget.$element );
// Teardown the old widget
oldWidget.disconnect( commentController );
oldWidget.teardown();
if ( html ) {
doc = replyWidget.replyBodyWidget.target.parseDocument( html );
// Unindent list
modifier.unwrapList( doc.body.children[ 0 ] );
}
replyWidget.setValue( doc );
commentController.setupReplyWidget( replyWidget );
} );
};
module.exports = CommentController;

View file

@ -47,6 +47,25 @@ function ReplyWidget( commentController, parsoidData, config ) {
framed: false
} );
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
} );
this.modeTabSelect.connect( this, {
choose: 'onModeTabSelectChoose'
} );
this.$preview = $( '<div>' ).addClass( 'dt-ui-replyWidget-preview' ).attr( 'data-label', mw.msg( 'discussiontools-replywidget-preview' ) );
this.$actionsWrapper = $( '<div>' ).addClass( 'dt-ui-replyWidget-actionsWrapper' );
this.$actions = $( '<div>' ).addClass( 'dt-ui-replyWidget-actions' ).append(
@ -87,6 +106,7 @@ function ReplyWidget( commentController, parsoidData, config ) {
// Initialization
this.$element.addClass( 'dt-ui-replyWidget' ).append(
this.modeTabSelect.$element,
this.replyBodyWidget.$element,
this.$preview,
this.$actionsWrapper
@ -124,9 +144,6 @@ function ReplyWidget( commentController, parsoidData, config ) {
} );
this.initAutoSave();
// Init preview and button state
this.onInputChange();
}
/* Inheritance */
@ -137,10 +154,27 @@ OO.inheritClass( ReplyWidget, OO.ui.Widget );
ReplyWidget.prototype.createReplyBodyWidget = null;
/**
* Focus the widget
*
* @method
* @chainable
* @return {ReplyWidget}
*/
ReplyWidget.prototype.focus = null;
ReplyWidget.prototype.getValue = null;
/**
* Set the reply widget's value
*
* @method
* @chainable
* @param {Mixed} value Value
* @return {ReplyWidget}
*/
ReplyWidget.prototype.setValue = null;
ReplyWidget.prototype.isEmpty = null;
ReplyWidget.prototype.getMode = null;
@ -164,10 +198,48 @@ ReplyWidget.prototype.setPending = function ( pending ) {
}
};
ReplyWidget.prototype.setup = function () {
this.bindBeforeUnloadHandler();
ReplyWidget.prototype.onModeTabSelectChoose = function ( option ) {
var promise,
widget = this;
this.setPending( true );
this.modeTabSelect.setDisabled( true );
switch ( option.getData() ) {
case 'source':
promise = this.commentController.switchToWikitext();
break;
case 'visual':
promise = this.commentController.switchToVisual();
break;
}
promise.then( null, function () {
// Switch failed, restore previous tab selection
widget.modeTabSelect.selectItemByData( option.getData() === 'source' ? 'visual' : 'source' );
} ).always( function () {
widget.setPending( false );
widget.modeTabSelect.setDisabled( false );
} );
};
/**
* Setup the widget
*
* @chainable
* @return {ReplyWidget}
*/
ReplyWidget.prototype.setup = function () {
this.bindBeforeUnloadHandler();
this.modeTabSelect.selectItemByData( this.getMode() );
// Init preview and button state
this.onInputChange();
return this;
};
/**
* 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;
@ -196,13 +268,21 @@ ReplyWidget.prototype.tryTeardown = function () {
promise.then( function () {
widget.teardown();
} );
return this;
};
/**
* Teardown the widget
*
* @chainable
* @return {ReplyWidget}
*/
ReplyWidget.prototype.teardown = function () {
this.unbindBeforeUnloadHandler();
this.clear();
this.$preview.empty();
this.emit( 'teardown' );
return this;
};
ReplyWidget.prototype.onKeyDown = function ( e ) {

View file

@ -1,5 +1,7 @@
.dt-ui-replyWidget {
margin-bottom: 1em;
position: relative;
clear: both;
> .oo-ui-textInputWidget {
max-width: none;
@ -20,6 +22,17 @@
}
}
&-modeTabs {
box-shadow: none;
height: 3em;
}
&-ve .dt-ui-replyWidget-modeTabs {
position: absolute;
z-index: 2;
top: 0;
}
&-actionsWrapper {
display: flex;
margin-top: 0.5em;

View file

@ -14,6 +14,8 @@ function ReplyWidgetPlain() {
// Events
this.replyBodyWidget.connect( this, { change: this.onInputChangeThrottled } );
this.$element.addClass( 'dt-ui-replyWidget-plain' );
}
/* Inheritance */
@ -38,13 +40,15 @@ ReplyWidgetPlain.prototype.createReplyBodyWidget = function ( config ) {
ReplyWidgetPlain.prototype.focus = function () {
this.replyBodyWidget.focus();
return this;
};
ReplyWidgetPlain.prototype.clear = function () {
// Parent method
ReplyWidgetPlain.super.prototype.clear.apply( this, arguments );
this.replyBodyWidget.setValue( '' );
this.setValue( '' );
};
ReplyWidgetPlain.prototype.isEmpty = function () {
@ -79,6 +83,15 @@ ReplyWidgetPlain.prototype.setup = function () {
ReplyWidgetPlain.super.prototype.setup.call( this );
this.replyBodyWidget.once( 'change', this.onFirstTransaction.bind( this ) );
return this;
};
ReplyWidgetPlain.prototype.teardown = function () {
this.replyBodyWidget.off( 'change' );
// Parent method
return ReplyWidgetPlain.super.prototype.teardown.call( this );
};
ReplyWidgetPlain.prototype.onKeyDown = function ( e ) {
@ -108,4 +121,9 @@ ReplyWidgetPlain.prototype.getValue = function () {
return this.replyBodyWidget.getValue();
};
ReplyWidgetPlain.prototype.setValue = function ( value ) {
this.replyBodyWidget.setValue( value );
return this;
};
module.exports = ReplyWidgetPlain;

View file

@ -17,7 +17,17 @@ function ReplyWidgetVisual() {
ReplyWidgetVisual.super.apply( this, arguments );
// TODO: Use user preference
this.defaultMode = 'source';
this.defaultMode = 'visual';
this.initialValue = null;
// Events
this.replyBodyWidget.connect( this, {
change: 'onInputChangeThrottled',
submit: 'onReplyClick'
} );
// TODO: Rename this widget to VE, as it isn't just visual mode
this.$element.addClass( 'dt-ui-replyWidget-ve' );
}
/* Inheritance */
@ -40,7 +50,16 @@ ReplyWidgetVisual.prototype.getValue = function () {
}
};
// TODO: Implement getMode to get current mode from surface
ReplyWidgetVisual.prototype.setValue = function ( value ) {
var target = this.replyBodyWidget.target;
if ( target && target.getSurface() ) {
target.setDocument( value );
} else {
// #setup hasn't been called yet, just save the value for when it is
this.initialValue = value;
}
return this;
};
ReplyWidgetVisual.prototype.clear = function () {
// Parent method
@ -65,20 +84,22 @@ ReplyWidgetVisual.prototype.initAutoSave = function () {
};
ReplyWidgetVisual.prototype.setup = function () {
var surface;
this.replyBodyWidget.setDocument( this.initialValue || '<p></p>' );
this.initialValue = null;
// Parent method
ReplyWidgetVisual.super.prototype.setup.call( this );
this.replyBodyWidget.setDocument( '<p></p>' );
this.replyBodyWidget.once( 'change', this.onFirstTransaction.bind( this ) );
surface = this.replyBodyWidget.target.getSurface();
return this;
};
// Events
surface.getModel().getDocument()
.connect( this, { transact: this.onInputChangeThrottled } )
.once( 'transact', this.onFirstTransaction.bind( this ) );
surface.connect( this, { submit: 'onReplyClick' } );
ReplyWidgetVisual.prototype.teardown = function () {
this.replyBodyWidget.off( 'change' );
// Parent method
return ReplyWidgetVisual.super.prototype.teardown.call( this );
};
ReplyWidgetVisual.prototype.focus = function () {
@ -87,17 +108,18 @@ ReplyWidgetVisual.prototype.focus = function () {
targetWidget.getSurface().getModel().selectLastContentOffset();
targetWidget.focus();
} );
return this;
};
ReplyWidgetVisual.prototype.setPending = function ( pending ) {
ReplyWidgetVisual.super.prototype.setPending.call( this, pending );
if ( pending ) {
// TODO
// this.replyBodyWidget.pushPending();
this.replyBodyWidget.pushPending();
this.replyBodyWidget.setReadOnly( true );
} else {
// this.replyBodyWidget.popPending();
this.replyBodyWidget.popPending();
this.replyBodyWidget.setReadOnly( false );
}
};

View file

@ -194,6 +194,47 @@ function removeListItem( node ) {
}
}
/**
* Unwrap a top level list, converting list item text to paragraphs
*
* Assumes that the list is the only child of it's parent.
*
* @param {HTMLElement} list List element (dl/ol/ul)
*/
function unwrapList( list ) {
var p,
doc = list.ownerDocument,
container = list.parentNode;
container.removeChild( list );
while ( list.firstChild ) {
if ( list.firstChild.nodeType === Node.ELEMENT_NODE ) {
// Move <dd> contents to <p>
p = doc.createElement( 'p' );
while ( list.firstChild.firstChild ) {
// If contents is a block element, place outside the paragraph
// and start a new paragraph after
if ( ve.isBlockElement( list.firstChild.firstChild ) ) {
if ( p.firstChild ) {
container.appendChild( p );
}
container.appendChild( list.firstChild.firstChild );
p = doc.createElement( 'p' );
} else {
p.appendChild( list.firstChild.firstChild );
}
}
if ( p.firstChild ) {
container.appendChild( p );
}
list.removeChild( list.firstChild );
} else {
// Text node / comment node, probably empty
container.appendChild( list.firstChild );
}
}
}
/**
* Add another list item after the given one.
*
@ -221,5 +262,6 @@ module.exports = {
addListItem: addListItem,
removeListItem: removeListItem,
addSiblingListItem: addSiblingListItem,
unwrapList: unwrapList,
createWikitextNode: createWikitextNode
};

View file

@ -207,3 +207,53 @@ QUnit.test( '#addReplyLink', function ( assert ) {
);
}
} );
QUnit.test( '#unwrapList', function ( assert ) {
var cases;
cases = [
{
name: 'empty',
html: '<dl><dd></dd></dl>',
expected: ''
},
{
name: 'single item',
html: '<dl><dd>Foo</dd></dl>',
expected: '<p>Foo</p>'
},
{
name: 'single block item',
html: '<dl><dd><pre>Foo</pre></dd></dl>',
expected: '<pre>Foo</pre>'
},
{
name: 'mixed inline and block',
html: '<dl><dd>Foo <pre>Bar</pre> Baz</dd></dl>',
expected: '<p>Foo </p><pre>Bar</pre><p> Baz</p>'
},
{
name: 'multiple items',
html: '<dl><dd>Foo</dd><dd>Bar</dd></dl>',
expected: '<p>Foo</p><p>Bar</p>'
},
{
name: 'nested list',
html: '<dl><dd>Foo<dl><dd>Bar</dd></dl></dd></dl>',
expected: '<p>Foo</p><dl><dd>Bar</dd></dl>'
}
];
cases.forEach( function ( caseItem ) {
var container = document.createElement( 'div' );
container.innerHTML = caseItem.html;
modifier.unwrapList( container.firstChild );
assert.strictEqual(
container.innerHTML.trim(),
caseItem.expected,
caseItem.name
);
} );
} );