Skip to content

Commit

Permalink
Merge pull request #11017 from ckeditor/ck/9741-handle-space-in-menti…
Browse files Browse the repository at this point in the history
…on-plugin

Fix (mention): The mention plugin now allows searching mentions that include space characters. Closes #9741.
  • Loading branch information
niegowski authored Dec 21, 2021
2 parents 84b676e + a8a571e commit d95bc68
Show file tree
Hide file tree
Showing 5 changed files with 163 additions and 41 deletions.
109 changes: 85 additions & 24 deletions packages/ckeditor5-mention/src/mentionui.js
Original file line number Diff line number Diff line change
Expand Up @@ -160,16 +160,14 @@ export default class MentionUI extends Plugin {
throw new CKEditorError( 'mentionconfig-incorrect-marker', null, { marker } );
}

const minimumCharacters = mentionDescription.minimumCharacters || 0;
const feedCallback = typeof feed == 'function' ? feed.bind( this.editor ) : createFeedCallback( feed );
const watcher = this._setupTextWatcherForFeed( marker, minimumCharacters );
const itemRenderer = mentionDescription.itemRenderer;

const definition = { watcher, marker, feedCallback, itemRenderer };
const definition = { marker, feedCallback, itemRenderer };

this._mentionsConfigurations.set( marker, definition );
}

this._setupTextWatcher( feeds );
this.listenTo( editor, 'change:isReadOnly', () => {
this._hideUIAndRemoveMarker();
} );
Expand Down Expand Up @@ -373,16 +371,21 @@ export default class MentionUI extends Plugin {
* Registers a text watcher for the marker.
*
* @private
* @param {String} marker
* @param {Number} minimumCharacters
* @param {Array.<Object>} feeds Feeds of mention plugin configured in editor
* @returns {module:typing/textwatcher~TextWatcher}
*/
_setupTextWatcherForFeed( marker, minimumCharacters ) {
_setupTextWatcher( feeds ) {
const editor = this.editor;

const watcher = new TextWatcher( editor.model, createTestCallback( marker, minimumCharacters ) );
const feedsWithPattern = feeds.map( feed => ( {
...feed,
pattern: createRegExp( feed.marker, feed.minimumCharacters || 0 )
} ) );

const watcher = new TextWatcher( editor.model, createTestCallback( feedsWithPattern ) );

watcher.on( 'matched', ( evt, data ) => {
const markerDefinition = getLastValidMarkerInText( feedsWithPattern, data.text );
const selection = editor.model.document.selection;
const focus = selection.focus;

Expand All @@ -392,8 +395,8 @@ export default class MentionUI extends Plugin {
return;
}

const feedText = requestFeedText( marker, data.text );
const matchedTextLength = marker.length + feedText.length;
const feedText = requestFeedText( markerDefinition, data.text );
const matchedTextLength = markerDefinition.marker.length + feedText.length;

// Create a marker range.
const start = focus.getShiftedBy( -matchedTextLength );
Expand All @@ -414,7 +417,7 @@ export default class MentionUI extends Plugin {
} );
}

this._requestFeedDebounced( marker, feedText );
this._requestFeedDebounced( markerDefinition.marker, feedText );
} );

watcher.on( 'unmatched', () => {
Expand Down Expand Up @@ -655,6 +658,43 @@ function getBalloonPanelPositions( preferredPosition ) {
];
}

// Returns a marker definition of the last valid occuring marker in given string.
// If there is no valid marker in string it returns undefined.
//
// Example of returned object:
//
// {
// marker: '@',
// position: 4,
// minimumCharacters: 0
// }
//
// @param {Array.<Object>} feedsWithPattern Registered feeds in editor for mention plugin with created RegExp for matching marker.
// @param {String} text String to find marker in
// @returns {Object} Matched marker's definition
function getLastValidMarkerInText( feedsWithPattern, text ) {
let lastValidMarker;

for ( const feed of feedsWithPattern ) {
const currentMarkerLastIndex = text.lastIndexOf( feed.marker );

if ( currentMarkerLastIndex > 0 && !text.substring( currentMarkerLastIndex - 1 ).match( feed.pattern ) ) {
continue;
}

if ( !lastValidMarker || currentMarkerLastIndex >= lastValidMarker.position ) {
lastValidMarker = {
marker: feed.marker,
position: currentMarkerLastIndex,
minimumCharacters: feed.minimumCharacters,
pattern: feed.pattern
};
}
}

return lastValidMarker;
}

// Creates a RegExp pattern for the marker.
//
// Function has to be exported to achieve 100% code coverage.
Expand All @@ -666,39 +706,60 @@ export function createRegExp( marker, minimumCharacters ) {
const numberOfCharacters = minimumCharacters == 0 ? '*' : `{${ minimumCharacters },}`;

const openAfterCharacters = env.features.isRegExpUnicodePropertySupported ? '\\p{Ps}\\p{Pi}"\'' : '\\(\\[{"\'';
const mentionCharacters = '\\S';
const mentionCharacters = '.';

// The pattern consists of 3 groups:
// - 0 (non-capturing): Opening sequence - start of the line, space or an opening punctuation character like "(" or "\"",
// - 1: The marker character,
// - 2: Mention input (taking the minimal length into consideration to trigger the UI),
//
// The pattern matches up to the caret (end of string switch - $).
// (0: opening sequence )(1: marker )(2: typed mention )$
const pattern = `(?:^|[ ${ openAfterCharacters }])([${ marker }])([${ mentionCharacters }]${ numberOfCharacters })$`;

// (0: opening sequence )(1: marker )(2: typed mention )$
const pattern = `(?:^|[ ${ openAfterCharacters }])([${ marker }])(${ mentionCharacters }${ numberOfCharacters })$`;
return new RegExp( pattern, 'u' );
}

// Creates a test callback for the marker to be used in the text watcher instance.
//
// @param {String} marker
// @param {Number} minimumCharacters
// @param {Array.<Object>} feedsWithPattern Feeds of mention plugin configured in editor with RegExp to match marker in text
// @returns {Function}
function createTestCallback( marker, minimumCharacters ) {
const regExp = createRegExp( marker, minimumCharacters );
function createTestCallback( feedsWithPattern ) {
const textMatcher = text => {
const markerDefinition = getLastValidMarkerInText( feedsWithPattern, text );

return text => regExp.test( text );
if ( !markerDefinition ) {
return false;
}

let splitStringFrom = 0;

if ( markerDefinition.position !== 0 ) {
splitStringFrom = markerDefinition.position - 1;
}

const textToTest = text.substring( splitStringFrom );

return markerDefinition.pattern.test( textToTest );
};

return textMatcher;
}

// Creates a text matcher from the marker.
//
// @param {String} marker
// @param {Object} markerDefinition
// @param {String} text
// @returns {Function}
function requestFeedText( marker, text ) {
const regExp = createRegExp( marker, 0 );
function requestFeedText( markerDefinition, text ) {
let splitStringFrom = 0;

if ( markerDefinition.position !== 0 ) {
splitStringFrom = markerDefinition.position - 1;
}

const match = text.match( regExp );
const regExp = createRegExp( markerDefinition.marker, 0 );
const textToMatch = text.substring( splitStringFrom );
const match = textToMatch.match( regExp );

return match[ 2 ];
}
Expand Down
4 changes: 2 additions & 2 deletions packages/ckeditor5-mention/tests/manual/mention.html
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<div id="editor">
<p>Hello <span class="mention" data-mention="@Ted">@Ted</span>.</p>
<p>Hello <span class="mention" data-mention="@Ted">@Ted</span><span class="mention" data-mention="@Ted">@Ted</span>.</p>
<p>Hello <span class="mention" data-mention="@Ted Mosby">@Ted Mosby</span>.</p>
<p>Hello <span class="mention" data-mention="@Ted Mosby">@Ted Mosby</span><span class="mention" data-mention="@Ted Mosby">@Ted Mosby</span>.</p>

<figure class="image">
<img src="sample.jpg" />
Expand Down
2 changes: 1 addition & 1 deletion packages/ckeditor5-mention/tests/manual/mention.js
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ ClassicEditor
feeds: [
{
marker: '@',
feed: [ '@Barney', '@Lily', '@Marshall', '@Robin', '@Ted' ]
feed: [ '@Barney Stinson', '@Lily Aldrin', '@Marshall Eriksen', '@Robin Sherbatsky', '@Ted Mosby' ]
},
{
marker: '#',
Expand Down
10 changes: 5 additions & 5 deletions packages/ckeditor5-mention/tests/manual/mention.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,11 @@ The feeds:

1. Static list with `@` marker:

- Barney
- Lily
- Marshall
- Robin
- Ted
- Barney Stinson
- Lily Aldrin
- Marshall Eriksen
- Robin Sherbatsky
- Ted Mosby

2. Static list of 20 items (`#` marker)

Expand Down
79 changes: 70 additions & 9 deletions packages/ckeditor5-mention/tests/mentionui.js
Original file line number Diff line number Diff line change
Expand Up @@ -525,14 +525,14 @@ describe( 'MentionUI', () => {
env.features.isRegExpUnicodePropertySupported = false;
createRegExp( '@', 2 );
sinon.assert.calledOnce( regExpStub );
sinon.assert.calledWithExactly( regExpStub, '(?:^|[ \\(\\[{"\'])([@])([\\S]{2,})$', 'u' );
sinon.assert.calledWithExactly( regExpStub, '(?:^|[ \\(\\[{"\'])([@])(.{2,})$', 'u' );
} );

it( 'returns a ES2018 RegExp for browsers supporting Unicode punctuation groups', () => {
env.features.isRegExpUnicodePropertySupported = true;
createRegExp( '@', 2 );
sinon.assert.calledOnce( regExpStub );
sinon.assert.calledWithExactly( regExpStub, '(?:^|[ \\p{Ps}\\p{Pi}"\'])([@])([\\S]{2,})$', 'u' );
sinon.assert.calledWithExactly( regExpStub, '(?:^|[ \\p{Ps}\\p{Pi}"\'])([@])(.{2,})$', 'u' );
} );
} );

Expand Down Expand Up @@ -1942,7 +1942,7 @@ describe( 'MentionUI', () => {
feeds: [
{
marker: '@',
feed: [ '@a1', '@a2', '@a3' ]
feed: [ '@a1', '@a2', '@a3', '@a4 xyz', '@a5 x y z', '@a6 x$z' ]
},
{
marker: '$',
Expand All @@ -1967,7 +1967,7 @@ describe( 'MentionUI', () => {
.then( () => {
expect( panelView.isVisible ).to.be.true;
expect( editor.model.markers.has( 'mention' ) ).to.be.true;
expect( mentionsView.items ).to.have.length( 3 );
expect( mentionsView.items ).to.have.length( 6 );

mentionsView.items.get( 0 ).children.get( 0 ).fire( 'execute' );
} )
Expand Down Expand Up @@ -2002,7 +2002,7 @@ describe( 'MentionUI', () => {
expect( panelView.isVisible ).to.be.true;
expect( editor.model.markers.has( 'mention' ) ).to.be.true;

expect( mentionsView.items ).to.have.length( 3 );
expect( mentionsView.items ).to.have.length( 6 );
} );
} );

Expand All @@ -2017,7 +2017,7 @@ describe( 'MentionUI', () => {
.then( () => {
expect( panelView.isVisible ).to.be.true;
expect( editor.model.markers.has( 'mention' ) ).to.be.true;
expect( mentionsView.items ).to.have.length( 3 );
expect( mentionsView.items ).to.have.length( 6 );

mentionsView.items.get( 0 ).children.get( 0 ).fire( 'execute' );
} )
Expand All @@ -2042,6 +2042,66 @@ describe( 'MentionUI', () => {
expect( editor.model.markers.has( 'mention' ) ).to.be.true;
} );
} );

it( 'should match a feed', () => {
setData( model, '<paragraph>foo []</paragraph>' );

model.change( writer => {
writer.insertText( '@a3', doc.selection.getFirstPosition() );
} );

return waitForDebounce()
.then( () => {
expect( panelView.isVisible ).to.be.true;
expect( editor.model.markers.has( 'mention' ) ).to.be.true;
expect( mentionsView.items ).to.have.length( 1 );
} );
} );

it( 'should match a feed with space', () => {
setData( model, '<paragraph>foo []</paragraph>' );

model.change( writer => {
writer.insertText( '@a4 xyz', doc.selection.getFirstPosition() );
} );

return waitForDebounce()
.then( () => {
expect( panelView.isVisible ).to.be.true;
expect( editor.model.markers.has( 'mention' ) ).to.be.true;
expect( mentionsView.items ).to.have.length( 1 );
} );
} );

it( 'should match a feed with multiple spaces', () => {
setData( model, '<paragraph>foo []</paragraph>' );

model.change( writer => {
writer.insertText( '@a5 x y z', doc.selection.getFirstPosition() );
} );

return waitForDebounce()
.then( () => {
expect( panelView.isVisible ).to.be.true;
expect( editor.model.markers.has( 'mention' ) ).to.be.true;
expect( mentionsView.items ).to.have.length( 1 );
} );
} );

it( 'should match a feed with spaces and other mention character', () => {
setData( model, '<paragraph>foo []</paragraph>' );

model.change( writer => {
writer.insertText( '@a6 x$z', doc.selection.getFirstPosition() );
} );

return waitForDebounce()
.then( () => {
expect( panelView.isVisible ).to.be.true;
expect( editor.model.markers.has( 'mention' ) ).to.be.true;
expect( mentionsView.items ).to.have.length( 1 );
} );
} );
} );

function testExecuteKey( name, keyCode, feedItems ) {
Expand Down Expand Up @@ -2308,9 +2368,10 @@ describe( 'MentionUI', () => {
return waitForDebounce()
.then( () => {
mentionsView.items.get( 0 ).children.get( 0 ).fire( 'execute' );

expect( panelView.isVisible ).to.be.false;
expect( editor.model.markers.has( 'mention' ) ).to.be.false;
return waitForDebounce().then( () => {
expect( panelView.isVisible ).to.be.false;
expect( editor.model.markers.has( 'mention' ) ).to.be.false;
} );
} );
} );

Expand Down

0 comments on commit d95bc68

Please sign in to comment.