Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Handle spaces in feeds for mention plugin #11017

Merged
merged 6 commits into from
Dec 21, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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