Support wikitext link editing

Depends-On: I3eb2d5ee0da52942db1de75ef9f8b0ae2632657e
Change-Id: I7ad61899a221a198a06e206614d907a331861dbb
This commit is contained in:
Ed Sanders 2019-11-11 20:15:16 +00:00 committed by Bartosz Dziewoński
parent 66ba383d3e
commit a007781e81
4 changed files with 248 additions and 14 deletions

View file

@ -2661,6 +2661,7 @@
"modules/ve-mw/tests/ui/datatransferhandlers/ve.ui.UrlStringTransferHandler.test.js",
"modules/ve-mw/tests/init/targets/ve.init.mw.DesktopArticleTarget.test.js",
"lib/ve/tests/ui/inspectors/ve.ui.FragmentInspector.test.js",
"modules/ve-mw/tests/ui/inspectors/ve.ui.FragmentInspector.test.js",
"lib/ve/tests/ce/ve.ce.TestRunner.js",
"lib/ve/tests/ce/ve.ce.imetests.test.js",
"lib/ve/tests/ce/imetests/backspace-chromium-ubuntu-none.js",

View file

@ -0,0 +1,151 @@
/*!
* VisualEditor UserInterface FragmentInspector tests.
*
* @copyright 2011-2019 VisualEditor Team and others; see http://ve.mit-license.org
*/
QUnit.module( 've.ui.FragmentInspector (MW)', ve.test.utils.mwEnvironment );
/* Tests */
QUnit.test( 'Wikitext link inspector', function ( assert ) {
var done = assert.async(),
surface = ve.init.target.createSurface(
ve.dm.converter.getModelFromDom(
ve.createDocumentFromHtml(
'<p>Foo [[bar]] [[Quux|baz]] x</p>' +
'<p>wh]]ee</p>'
)
),
{ mode: 'source' }
),
cases = [
{
msg: 'Collapsed selection expands to word',
name: 'wikitextLink',
range: new ve.Range( 2 ),
expectedRange: new ve.Range( 1, 8 ),
expectedData: function ( data ) {
data.splice(
1, 3,
'[', '[', 'F', 'o', 'o', ']', ']'
);
}
},
{
msg: 'Collapsed selection in word (noExpand)',
name: 'wikitextLink',
range: new ve.Range( 2 ),
setupData: { noExpand: true },
expectedRange: new ve.Range( 2 ),
expectedData: function () {}
},
{
msg: 'Cancel restores original data & selection',
name: 'wikitextLink',
range: new ve.Range( 2 ),
expectedRange: new ve.Range( 2 ),
expectedData: function () {},
actionData: {}
},
{
msg: 'Collapsed selection inside existing link',
name: 'wikitextLink',
range: new ve.Range( 5 ),
expectedRange: new ve.Range( 5, 12 ),
expectedData: function () {}
},
{
msg: 'Selection inside existing link',
name: 'wikitextLink',
range: new ve.Range( 19, 20 ),
expectedRange: new ve.Range( 13, 25 ),
expectedData: function () {}
},
{
msg: 'Selection spanning existing link',
name: 'wikitextLink',
range: new ve.Range( 3, 8 ),
expectedRange: new ve.Range( 3, 8 ),
expectedData: function () {}
},
{
msg: 'Selection with whitespace is trimmed',
name: 'wikitextLink',
range: new ve.Range( 1, 5 ),
expectedRange: new ve.Range( 1, 8 )
},
{
msg: 'Link insertion',
name: 'wikitextLink',
range: new ve.Range( 26 ),
input: function () {
this.annotationInput.getTextInputWidget().setValue( 'quux' );
},
expectedRange: new ve.Range( 34 ),
expectedData: function ( data ) {
data.splice.apply( data, [ 26, 0 ].concat( '[[quux]]'.split( '' ) ) );
}
},
{
msg: 'Link insertion with no input is no-op',
name: 'wikitextLink',
range: new ve.Range( 26 ),
expectedRange: new ve.Range( 26 ),
expectedData: function () {}
},
{
msg: 'Link modified',
name: 'wikitextLink',
range: new ve.Range( 5, 12 ),
input: function () {
this.annotationInput.getTextInputWidget().setValue( 'quux' );
},
expectedRange: new ve.Range( 5, 17 ),
expectedData: function ( data ) {
data.splice.apply( data, [ 7, 3 ].concat( 'Quux|bar'.split( '' ) ) );
}
},
{
msg: 'Link modified with initial selection including whitespace',
name: 'wikitextLink',
range: new ve.Range( 4, 13 ),
input: function () {
this.annotationInput.getTextInputWidget().setValue( 'quux' );
},
expectedRange: new ve.Range( 5, 17 ),
expectedData: function ( data ) {
data.splice.apply( data, [ 7, 3 ].concat( 'Quux|bar'.split( '' ) ) );
}
},
{
msg: 'Piped link modified',
name: 'wikitextLink',
range: new ve.Range( 16 ),
input: function () {
this.annotationInput.getTextInputWidget().setValue( 'whee' );
},
expectedRange: new ve.Range( 13, 25 ),
expectedData: function ( data ) {
data.splice.apply( data, [ 15, 4 ].concat( 'Whee'.split( '' ) ) );
}
},
{
msg: 'Link modified',
name: 'wikitextLink',
range: new ve.Range( 30, 36 ),
input: function () {
this.annotationInput.getTextInputWidget().setValue( 'foo' );
},
expectedRange: new ve.Range( 30, 61 ),
expectedData: function ( data ) {
data.splice.apply( data, [ 30, 6 ].concat( '[[Foo|wh<nowiki>]]</nowiki>ee]]'.split( '' ) ) );
}
}
// Skips clear annotation test, not implement yet
];
ve.test.utils.runFragmentInspectorTests( surface, assert, cases ).finally( function () {
done();
} );
} );

View file

@ -20,6 +20,9 @@
// Ensure a mock server is used (e.g. as in ve.ui.MWWikitextStringTransferHandler)
return new mw.Api().post();
};
MWDummyTarget.prototype.getContentApi = function () {
return new mw.Api();
};
MWDummyTarget.prototype.createSurface = ve.init.mw.Target.prototype.createSurface;
MWDummyTarget.prototype.getSurfaceConfig = ve.init.mw.Target.prototype.getSurfaceConfig;
// Copy import rules from mw target, for paste tests.

View file

@ -31,27 +31,76 @@ ve.ui.MWWikitextLinkAnnotationInspector.static.modelClasses = [];
ve.ui.MWWikitextLinkAnnotationInspector.static.handlesSource = true;
// TODO: Support [[linktrail]]s & [[pipe trick|]]
ve.ui.MWWikitextLinkAnnotationInspector.static.internalLinkParser = ( function () {
var openLink = '\\[\\[',
closeLink = '\\]\\]',
noCloseLink = '(?:(?!' + closeLink + ').)*',
noCloseLinkOrPipe = '(?:(?!' + closeLink + ')[^|])*';
return new RegExp(
openLink +
'(' + noCloseLinkOrPipe + ')' +
'(?:\\|(' + noCloseLink + '))?' +
closeLink,
'g'
);
}() );
/* Methods */
/**
* @inheritdoc
*/
ve.ui.MWWikitextLinkAnnotationInspector.prototype.getSetupProcess = function ( data ) {
// Annotation inspector stages the annotation, so call its parent
// Call grand-parent
return ve.ui.FragmentInspector.prototype.getSetupProcess.call( this, data )
return ve.ui.AnnotationInspector.super.prototype.getSetupProcess.call( this, data )
.next( function () {
var fragment = this.getFragment();
var text, matches, matchTitle, range, contextFragment, contextRange, linkMatches, linkRange, title,
inspectorTitle,
internalLinkParser = this.constructor.static.internalLinkParser,
fragment = this.getFragment();
// Only supports linear selections
if ( !( this.previousSelection instanceof ve.dm.LinearSelection ) ) {
return ve.createDeferred().reject().promise();
}
// Initialize range
if ( this.previousSelection instanceof ve.dm.LinearSelection ) {
if (
fragment.getSelection().isCollapsed() &&
fragment.getDocument().data.isContentOffset( fragment.getSelection().getRange().start )
) {
// Expand to nearest word
if ( !data.noExpand ) {
fragment = fragment.expandLinearSelection( 'word' );
if ( !data.noExpand ) {
if ( !fragment.getSelection().isCollapsed() ) {
// Trim whitespace
fragment = fragment.trimLinearSelection();
}
// Expand to existing link, if present
// Find all links in the paragraph and see which one contains
// the current selection.
contextFragment = fragment.expandLinearSelection( 'siblings' );
contextRange = contextFragment.getSelection().getCoveringRange();
range = fragment.getSelection().getCoveringRange();
text = contextFragment.getText();
internalLinkParser.lastIndex = 0;
while ( ( matches = internalLinkParser.exec( text ) ) !== null ) {
matchTitle = mw.Title.newFromText( matches[ 1 ] );
if ( !matchTitle ) {
continue;
}
linkRange = new ve.Range(
contextRange.start + matches.index,
contextRange.start + matches.index + matches[ 0 ].length
);
if ( linkRange.containsRange( range ) ) {
linkMatches = matches;
fragment = fragment.getSurface().getLinearFragment( linkRange );
break;
}
}
}
if ( !linkMatches ) {
if ( !data.noExpand && fragment.getSelection().isCollapsed() ) {
// expand to nearest word
fragment = fragment.expandLinearSelection( 'word' );
} else {
// Trim whitespace
fragment = fragment.trimLinearSelection();
@ -60,17 +109,45 @@ ve.ui.MWWikitextLinkAnnotationInspector.prototype.getSetupProcess = function ( d
// Update selection
fragment.select();
this.initialSelection = fragment.getSelection();
this.initialSelection = fragment.getSelection();
this.fragment = fragment;
this.initialLabelText = this.fragment.getText();
if ( linkMatches ) {
// Group 1 is the link target, group 2 is the label after | if present
title = mw.Title.newFromText( linkMatches[ 1 ] );
this.initialLabelText = linkMatches[ 2 ] || linkMatches[ 1 ];
// HACK: Remove escaping probably added by this tool.
// We should really do a full parse from wikitext to HTML if
// we see any syntax
this.initialLabelText = this.initialLabelText.replace( /<nowiki>(\]{2,})<\/nowiki>/g, '$1' );
} else {
title = mw.Title.newFromText( this.initialLabelText );
}
if ( title ) {
this.initialAnnotation = this.newInternalLinkAnnotationFromTitle( title );
}
// eslint-disable-next-line mediawiki/msg-doc
inspectorTitle = ve.msg(
this.isReadOnly() ?
'visualeditor-linkinspector-title' : (
!linkMatches ?
'visualeditor-linkinspector-title-add' :
'visualeditor-linkinspector-title-edit'
)
);
this.title.setLabel( inspectorTitle ).setTitle( inspectorTitle );
this.annotationInput.setReadOnly( this.isReadOnly() );
this.actions.setMode( this.getMode() );
this.initialAnnotation = this.getAnnotationFromFragment( fragment );
this.linkTypeIndex.setTabPanel(
this.initialAnnotation instanceof ve.dm.MWExternalLinkAnnotation ? 'external' : 'internal'
);
this.annotationInput.setAnnotation( this.initialAnnotation );
this.updateActions();
}, this );
};
@ -93,8 +170,10 @@ ve.ui.MWWikitextLinkAnnotationInspector.prototype.getTeardownProcess = function
insert = this.initialSelection.isCollapsed() && insertion.length;
if ( insert ) {
fragment.insertContent( insertion );
labelText = insertion;
} else {
labelText = this.initialLabelText;
}
labelText = fragment.getText();
// Build internal links locally
if ( annotation instanceof ve.dm.MWInternalLinkAnnotation ) {