diff --git a/extension.json b/extension.json
index 5afe21bf1c..f211717d03 100644
--- a/extension.json
+++ b/extension.json
@@ -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",
diff --git a/modules/ve-mw/tests/ui/inspectors/ve.ui.FragmentInspector.test.js b/modules/ve-mw/tests/ui/inspectors/ve.ui.FragmentInspector.test.js
new file mode 100644
index 0000000000..81e6a73eef
--- /dev/null
+++ b/modules/ve-mw/tests/ui/inspectors/ve.ui.FragmentInspector.test.js
@@ -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(
+ '
Foo [[bar]] [[Quux|baz]] x
' +
+ 'wh]]ee
'
+ )
+ ),
+ { 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]]ee]]'.split( '' ) ) );
+ }
+ }
+ // Skips clear annotation test, not implement yet
+ ];
+
+ ve.test.utils.runFragmentInspectorTests( surface, assert, cases ).finally( function () {
+ done();
+ } );
+} );
diff --git a/modules/ve-mw/tests/ve.test.utils.js b/modules/ve-mw/tests/ve.test.utils.js
index 5b96494c02..8015d47d3d 100644
--- a/modules/ve-mw/tests/ve.test.utils.js
+++ b/modules/ve-mw/tests/ve.test.utils.js
@@ -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.
diff --git a/modules/ve-mw/ui/inspectors/ve.ui.MWWikitextLinkAnnotationInspector.js b/modules/ve-mw/ui/inspectors/ve.ui.MWWikitextLinkAnnotationInspector.js
index a6812b5574..5412060086 100644
--- a/modules/ve-mw/ui/inspectors/ve.ui.MWWikitextLinkAnnotationInspector.js
+++ b/modules/ve-mw/ui/inspectors/ve.ui.MWWikitextLinkAnnotationInspector.js
@@ -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( /(\]{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 ) {