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 ) {