8
0
Просмотр исходного кода

Merge branch 'master' into t/25

Piotrek Koszuliński 6 лет назад
Родитель
Сommit
8ad1c8614a

+ 31 - 44
packages/ckeditor5-mention/docs/_snippets/features/mention-customization.js

@@ -10,7 +10,7 @@ import { CS_CONFIG } from '@ckeditor/ckeditor5-cloud-services/tests/_utils/cloud
 ClassicEditor
 	.create( document.querySelector( '#snippet-mention-customization' ), {
 		cloudServices: CS_CONFIG,
-		extraPlugins: [ CustomMention ],
+		extraPlugins: [ MentionCustomization ],
 		toolbar: {
 			items: [
 				'heading', '|', 'bold', 'italic', '|', 'undo', 'redo'
@@ -35,8 +35,9 @@ ClassicEditor
 		console.error( err.stack );
 	} );
 
-function CustomMention( editor ) {
-	// The upcast converter will convert <a class="mention"> elements to the model 'mention' attribute.
+function MentionCustomization( editor ) {
+	// The upcast converter will convert <a class="mention" href="" data-user-id="">
+	// elements to the model 'mention' attribute.
 	editor.conversion.for( 'upcast' ).elementToAttribute( {
 		view: {
 			name: 'a',
@@ -50,16 +51,13 @@ function CustomMention( editor ) {
 		model: {
 			key: 'mention',
 			value: viewItem => {
-				// Optionally: do not convert partial mentions.
-				if ( !isFullMention( viewItem ) ) {
-					return;
-				}
-
-				// The mention feature expects that mention attribute value in the model is a plain object:
+				// The mention feature expects that the mention attribute value
+				// in the model is a plain object:
 				const mentionValue = {
-					// The name attribute is required by mention editing.
+					// The name attribute is required.
 					name: viewItem.getAttribute( 'data-mention' ),
-					// Add any other properties as required.
+
+					// Add any other properties that you need.
 					link: viewItem.getAttribute( 'href' ),
 					id: viewItem.getAttribute( 'data-user-id' )
 				};
@@ -70,30 +68,12 @@ function CustomMention( editor ) {
 		converterPriority: 'high'
 	} );
 
-	function isFullMention( viewElement ) {
-		const textNode = viewElement.getChild( 0 );
-		const dataMention = viewElement.getAttribute( 'data-mention' );
-
-		// Do not parse empty mentions.
-		if ( !textNode || !textNode.is( 'text' ) ) {
-			return false;
-		}
-
-		const mentionString = textNode.data;
-
-		// Assume that mention is set as marker + mention name.
-		const name = mentionString.slice( 1 );
-
-		// Do not upcast partial mentions - might come from copy-paste of partially selected mention.
-		return name == dataMention;
-	}
-
-	// Don't forget to define a downcast converter as well:
+	// Do not forget to define a downcast converter as well:
 	editor.conversion.for( 'downcast' ).attributeToElement( {
 		model: 'mention',
 		view: ( modelAttributeValue, viewWriter ) => {
+			// Do not convert empty attributes (lack of value means no mention).
 			if ( !modelAttributeValue ) {
-				// Do not convert empty attributes.
 				return;
 			}
 
@@ -116,8 +96,10 @@ const items = [
 	{ id: '5', name: 'Ted Mosby', username: 'tdog', link: 'https://www.imdb.com/title/tt0460649/characters/nm1102140' }
 ];
 
-function getFeedItems( feedText ) {
-	// As an example of asynchronous action return a promise that resolves after a 100ms timeout.
+function getFeedItems( queryText ) {
+	// As an example of an asynchronous action, let's return a promise
+	// that resolves after a 100ms timeout.
+	// This can be a server request, or any sort of delayed action.
 	return new Promise( resolve => {
 		setTimeout( () => {
 			resolve( items.filter( isItemMatching ) );
@@ -127,24 +109,29 @@ function getFeedItems( feedText ) {
 	// Filtering function - it uses `name` and `username` properties of an item to find a match.
 	function isItemMatching( item ) {
 		// Make search case-insensitive.
-		const searchString = feedText.toLowerCase();
+		const searchString = queryText.toLowerCase();
 
 		// Include an item in the search results if name or username includes the current user input.
-		return textIncludesSearchSting( item.name, searchString ) || textIncludesSearchSting( item.username, searchString );
-	}
-
-	function textIncludesSearchSting( text, searchString ) {
-		return text.toLowerCase().includes( searchString );
+		return (
+			item.name.toLowerCase().includes( searchString ) ||
+			item.username.toLowerCase().includes( searchString )
+		);
 	}
 }
 
 function customItemRenderer( item ) {
-	const span = document.createElement( 'span' );
+	const itemElement = document.createElement( 'span' );
+
+	itemElement.classList.add( 'custom-item' );
+	itemElement.id = `mention-list-item-id-${ item.id }`;
+	itemElement.textContent = `${ item.name } `;
+
+	const usernameElement = document.createElement( 'span' );
 
-	span.classList.add( 'custom-item' );
-	span.id = `mention-list-item-id-${ item.id }`;
+	usernameElement.classList.add( 'custom-item-username' );
+	usernameElement.textContent = `@${ item.username }`;
 
-	span.innerHTML = `${ item.name } <span class="custom-item-username">@${ item.username }</span>`;
+	itemElement.appendChild( usernameElement );
 
-	return span;
+	return itemElement;
 }

+ 197 - 84
packages/ckeditor5-mention/docs/features/mention.md

@@ -6,22 +6,27 @@ category: features
 
 # Mention
 
-The {@link module:mention/mention~Mention} feature brings support for smart completion based on user input. When user types a pre-configured marker, such as `@` or `#`, they get an autocomplete suggestions in a balloon panel displayed next to the caret. The selected suggestion is then inserted into the content.
+The {@link module:mention/mention~Mention} feature brings support for smart autocompletion based on user input. When a user types a pre-configured marker, such as `@` or `#`, they get autocomplete suggestions in a panel displayed next to the caret. The selected suggestion is then inserted into the content.
 
 ## Demo
 
-You can type `'@'` character to invoke mention auto-complete UI. The below demo is configured as static list of names.
+You can type `'@'` character to invoke mention autocomplete UI. The below demo is configured as a static list of names.
 
 {@snippet features/mention}
 
 ## Configuration
 
-The minimal configuration of a mention requires defining a {@link module:mention/mention~MentionFeed `feed`} and a {@link module:mention/mention~MentionFeed `marker`} (if not using the default `@` character). You can define also `minimumCharacters` after which the auto-complete panel will be shown. 
+The minimal configuration of the mention feature requires defining a {@link module:mention/mention~MentionFeed `feed`} and a {@link module:mention/mention~MentionFeed `marker`}. You can also define `minimumCharacters` after which the autocomplete panel will be shown.
+
+The code snippet below was used to configure the demo above. It defines the list of names that will be autocompleted after the user types the `'@'` character.
 
 ```js
 ClassicEditor
 	.create( document.querySelector( '#editor' ), {
+		// This feature is not available in any of the builds.
+		// See the "Installation" section.
 		plugins: [ Mention, ... ],
+
 		mention: {
 			feeds: [
 				{
@@ -36,29 +41,29 @@ ClassicEditor
 	.catch( ... );
 ```
 
-Additionally you can configure:
-- How the item is rendered in the auto-complete panel.
-- How the item is converted during the conversion.
+Additionally, you can configure:
+
+* How the item is rendered in the autocomplete panel (via setting {@link module:mention/mention~MentionFeed `itemRenderer`}). See ["Customizing the autocomplete list"](#customizing-the-autocomplete-list).
+* How the item is converted during the conversion. See ["Customizing the output"](#customizing-the-output).
+* Multiple feeds &mdash; in the demo above we used only one feed, which is triggered by the `'@'` character. You can define multiple feeds but they must use different markers. For example, you can use `'@'` for people and `#` for tags.
 
 ### Providing the feed
 
 The {@link module:mention/mention~MentionFeed `feed`} can be provided as:
 
-- static array - good for scenarios with relatively small set of auto-complete items.
-- a callback - which provides more control over the returned list of items.
+* a static array &mdash; good for scenarios with a relatively small set of autocomplete items.
+* a callback &mdash; which provides more control over the returned list of items.
 
-If using a callback you can return a `Promise` that resolves with list of {@link module:mention/mention~MentionFeedItem mention feed items}. Those can be simple stings used as mention text or plain objects with at least one `name` property. The other parameters can be used either when {@link features/mention#customizing-the-auto-complete-list customizing the auto-complete list} {@link features/mention#customizing-the-output customizing the output}.
+When using a callback you can return a `Promise` that resolves with the list of {@link module:mention/mention~MentionFeedItem matching feed items}. Those can be simple strings or plain objects with at least the `name` property. The other properties of this object can later be used e.g. when [customizing the autocomplete list](#customizing-the-autocomplete-list) or [customizing the output](#customizing-the-output).
 
 <info-box>
-When using external resources to obtain the feed it is recommended to add some caching mechanism so subsequent calls for the same suggestoin would load faster.
-</info-box>
+	When using external resources to obtain the feed it is recommended to add some caching mechanism so subsequent calls for the same suggestion would load faster.
 
-The callback receives a matched text which should be used to filter item suggestions. It should return a `Promise` and resolve it with an array of items that match to the feed text.
-
-<info-box>
-Consider adding the `minimumCharacters` option to the feed config so the editor will call the feed callback after a minimum characters typed instead of action on marker alone. 
+	You can also consider adding the `minimumCharacters` option to the feed config so the editor will call the feed callback after minimum characters typed instead of action on marker alone.
 </info-box>
 
+The callback receives the query text which should be used to filter item suggestions. It should return a `Promise` and resolve it with an array of items that match to the feed text.
+
 ```js
 const items = [
 	{ id: '1', name: 'Barney Stinson', username: 'swarley', link: 'https://www.imdb.com/title/tt0460649/characters/nm0000439' },
@@ -68,14 +73,16 @@ const items = [
 	{ id: '5', name: 'Ted Mosby', username: 'tdog', link: 'https://www.imdb.com/title/tt0460649/characters/nm1102140' }
 ];
 
-function getFeedItems( feedText ) {
-	// As an example of asynchronous action return a promise that resolves after a 100ms timeout.
+function getFeedItems( queryText ) {
+	// As an example of an asynchronous action, let's return a promise
+	// that resolves after a 100ms timeout.
+	// This can be a server request or any sort of delayed action.
 	return new Promise( resolve => {
 		setTimeout( () => {
 			const itemsToDisplay = items
-				// Filter out the full list of all items to only those matching feedText.
+				// Filter out the full list of all items to only those matching queryText.
 				.filter( isItemMatching )
-				// Return at most 10 items - notably for generic queries when the list may contain hundreds of elements.
+				// Return 10 items max - needed for generic queries when the list may contain hundreds of elements.
 				.slice( 0, 10 );
 
 			resolve( itemsToDisplay );
@@ -85,29 +92,24 @@ function getFeedItems( feedText ) {
 	// Filtering function - it uses `name` and `username` properties of an item to find a match.
 	function isItemMatching( item ) {
 		// Make search case-insensitive.
-		const searchString = feedText.toLowerCase();
+		const searchString = queryText.toLowerCase();
 
 		// Include an item in the search results if name or username includes the current user input.
-		return textIncludesSearchSting( item.name, searchString ) || textIncludesSearchSting( item.username, searchString );
-	}
-
-	function textIncludesSearchSting( text, searchString ) {
-		return text.toLowerCase().includes( searchString );
+		return (
+			item.name.toLowerCase().includes( searchString ) ||
+			item.username.toLowerCase().includes( searchString )
+		);
 	}
 }
 ```
 
-The full working demo with all customization possible is {@link features/mention#fully-customized-mention-feed  at the end of this section}.
+A full, working demo with all possible customizations and its source code is available {@link features/mention#fully-customized-mention-feed at the end of this section}.
 
-<info-box>
-The mention feature does not limit items displayed in the mention suggestion list when using the callback. You should limit the output by yourself. 
-</info-box>
+### Customizing the autocomplete list
 
-### Customizing the auto-complete list
+The items displayed in the autocomplete list can be customized by defining the {@link module:mention/mention~MentionFeed `itemRenderer`} callback.
 
-The items displayed in auto-complete list can be customized by defining the {@link module:mention/mention~MentionFeed `itemRenderer`} callback.
-
-This callback takes a plain object feed item (at least with `name` parameter - even when feed items are defined as strings). The item renderer function must return a new DOM element.
+This callback takes a feed item (it contains at least the `name` property). The item renderer function must return a new DOM element.
 
 ```js
 ClassicEditor
@@ -115,7 +117,7 @@ ClassicEditor
 		plugins: [ Mention, ... ],
 		mention: {
 			feeds: [
-				{ 
+				{
 					feed: [ ... ],
 					// Define the custom item renderer:
 					itemRenderer: customItemRenderer
@@ -127,25 +129,30 @@ ClassicEditor
 	.catch( ... );
 
 function customItemRenderer( item ) {
-	const span = document.createElement( 'span' );
+	const itemElement = document.createElement( 'span' );
+
+	itemElement.classList.add( 'custom-item' );
+	itemElement.id = `mention-list-item-id-${ item.id }`;
+	itemElement.textContent = `${ item.name } `;
 
-	span.classList.add( 'custom-item' );
-	span.id = `mention-list-item-id-${ item.id }`;
+	const usernameElement = document.createElement( 'span' );
 
-	// Add child nodes to the main span or just set innerHTML.
-	span.innerHTML = `${ item.name } <span class="custom-item-username">@${ item.username }</span>`;
+	usernameElement.classList.add( 'custom-item-username' );
+	usernameElement.textContent = `@${ item.username }`;
 
-	return span;
+	itemElement.appendChild( usernameElement );
+
+	return itemElement;
 }
 ```
 
-The full working demo with all customization possible is {@link features/mention#fully-customized-mention-feed  at the end of this section}.
+A full, working demo with all possible customizations and its source code is available {@link features/mention#fully-customized-mention-feed at the end of this section}.
 
 ### Customizing the output
 
-In order to have full control over the markup generated by the editor you can overwrite the conversion process. To do that you must specify both {@link module:engine/conversion/upcastdispatcher~UpcastDispatcher upcast} and {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher downcast} converters.
+In order to change the markup generated by the editor for mentions, you can overwrite the default converter of the mention feature. To do that, you must specify both {@link module:engine/conversion/upcastdispatcher~UpcastDispatcher upcast} and {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher downcast} converters.
 
-Below is an example of a plugin that overrides the default output:
+The example below defined a plugin which overrides the default output:
 
 ```html
 <span data-mention="Ted" class="mention">@Ted</span>
@@ -157,21 +164,22 @@ To a link:
 <a class="mention" data-mention="Ted Mosby" data-user-id="5" href="https://www.imdb.com/title/tt0460649/characters/nm1102140">@Ted Mosby</a>
 ```
 
-The below converters must have priority 'high' priority as the link attribute converter has 'normal' priority. The mention item in the model must be stored as a plain object with `name` attribute.
+The converters must be defined with a `'high'` priority to be executed before the link feature's converter and before the default converter of the mention feature. A mention is stored in the model as a text attribute which stores an object (see {@link module:mention/mention~MentionFeedItem}).
 
 ```js
 ClassicEditor
 	.create( document.querySelector( '#editor' ), {
-		plugins: [ Mention, CustomMention, ... ],    // Add custom mention plugin function.
+		plugins: [ Mention, MentionCustomization, ... ], // Add custom mention plugin function.
 		mention: {
-			// configuration...
+			// Configuration...
 		}
 	} )
 	.then( ... )
 	.catch( ... );
 
-function CustomMention( editor ) {
-	// The upcast converter will convert <a class="mention"> elements to the model 'mention' attribute.
+function MentionCustomization( editor ) {
+	// The upcast converter will convert view <a class="mention" href="" data-user-id="">
+	// elements to the model 'mention' text attribute.
 	editor.conversion.for( 'upcast' ).elementToAttribute( {
 		view: {
 			name: 'a',
@@ -185,16 +193,13 @@ function CustomMention( editor ) {
 		model: {
 			key: 'mention',
 			value: viewItem => {
-				// Optionally: do not convert partial mentions.
-				if ( !isFullMention( viewItem ) ) {
-					return;
-				}
-
-				// The mention feature expects that mention attribute value in the model is a plain object:
+				// The mention feature expects that the mention attribute value
+				// in the model is a plain object:
 				const mentionValue = {
-					// The name attribute is required by mention editing.
+					// The name attribute is required.
 					name: viewItem.getAttribute( 'data-mention' ),
-					// Add any other properties as required.
+
+					// Add any other properties that you need.
 					link: viewItem.getAttribute( 'href' ),
 					id: viewItem.getAttribute( 'data-user-id' )
 				};
@@ -205,30 +210,102 @@ function CustomMention( editor ) {
 		converterPriority: 'high'
 	} );
 
-	function isFullMention( viewElement ) {
-		const textNode = viewElement.getChild( 0 );
-		const dataMention = viewElement.getAttribute( 'data-mention' );
+	// Downcast the model 'mention' text attribute to a view <a> element.
+	editor.conversion.for( 'downcast' ).attributeToElement( {
+		model: 'mention',
+		view: ( modelAttributeValue, viewWriter ) => {
+			// Do not convert empty attributes (lack of value means no mention).
+			if ( !modelAttributeValue ) {
+				return;
+			}
+
+			return viewWriter.createAttributeElement( 'a', {
+				class: 'mention',
+				'data-mention': modelAttributeValue.name,
+				'data-user-id': modelAttributeValue.id,
+				'href': modelAttributeValue.link
+			} );
+		},
+		converterPriority: 'high'
+	} );
+}
+```
+
+The full working demo with all customization possible is {@link features/mention#fully-customized-mention-feed  at the end of this section}.
+
+### Fully customized mention feed
+
+Below is an example of a customized mention feature that:
+
+* Uses a feed of items with additional properties (`id`, `username`, `link`).
+* Renders custom item views in the autocomplete panel.
+* Converts mention to an `<a>` element instead of `<span>`.
 
-		// Do not parse empty mentions.
-		if ( !textNode || !textNode.is( 'text' ) ) {
-			return false;
+{@snippet features/mention-customization}
+
+#### Source code
+
+```js
+ClassicEditor
+	.create( document.querySelector( '#snippet-mention-customization' ), {
+		plugins: [ Mention, MentionCustomization, ... ],
+		mention: {
+			feeds: [
+				{
+					marker: '@',
+					feed: getFeedItems,
+					itemRenderer: customItemRenderer,
+					minimumCharacters: 1
+				}
+			]
 		}
+	} )
+	.then( editor => {
+		window.editor = editor;
+	} )
+	.catch( err => {
+		console.error( err.stack );
+	} );
 
-		const mentionString = textNode.data;
+function MentionCustomization( editor ) {
+	// The upcast converter will convert <a class="mention" href="" data-user-id="">
+	// elements to the model 'mention' attribute.
+	editor.conversion.for( 'upcast' ).elementToAttribute( {
+		view: {
+			name: 'a',
+			key: 'data-mention',
+			classes: 'mention',
+			attributes: {
+				href: true,
+				'data-user-id': true
+			}
+		},
+		model: {
+			key: 'mention',
+			value: viewItem => {
+				// The mention feature expects that the mention attribute value
+				// in the model is a plain object:
+				const mentionValue = {
+					// The name attribute is required.
+					name: viewItem.getAttribute( 'data-mention' ),
 
-		// Assume that mention is set as marker + mention name.
-		const name = mentionString.slice( 1 );
+					// Add any other properties that you need.
+					link: viewItem.getAttribute( 'href' ),
+					id: viewItem.getAttribute( 'data-user-id' )
+				};
 
-		// Do not upcast partial mentions - might come from copy-paste of partially selected mention.
-		return name == dataMention;
-	}
+				return mentionValue;
+			}
+		},
+		converterPriority: 'high'
+	} );
 
-	// Don't forget to define a downcast converter as well:
+	// Do not forget to define a downcast converter as well:
 	editor.conversion.for( 'downcast' ).attributeToElement( {
 		model: 'mention',
 		view: ( modelAttributeValue, viewWriter ) => {
+			// Do not convert empty attributes (lack of value means no mention).
 			if ( !modelAttributeValue ) {
-				// Do not convert empty attributes.
 				return;
 			}
 
@@ -242,19 +319,55 @@ function CustomMention( editor ) {
 		converterPriority: 'high'
 	} );
 }
-```
 
-The full working demo with all customization possible is {@link features/mention#fully-customized-mention-feed  at the end of this section}.
+const items = [
+	{ id: '1', name: 'Barney Stinson', username: 'swarley', link: 'https://www.imdb.com/title/tt0460649/characters/nm0000439' },
+	{ id: '2', name: 'Lily Aldrin', username: 'lilypad', link: 'https://www.imdb.com/title/tt0460649/characters/nm0004989' },
+	{ id: '3', name: 'Marshall Eriksen', username: 'marshmallow', link: 'https://www.imdb.com/title/tt0460649/characters/nm0781981' },
+	{ id: '4', name: 'Robin Scherbatsky', username: 'rsparkles', link: 'https://www.imdb.com/title/tt0460649/characters/nm1130627' },
+	{ id: '5', name: 'Ted Mosby', username: 'tdog', link: 'https://www.imdb.com/title/tt0460649/characters/nm1102140' }
+];
 
-### Fully customized mention feed
+function getFeedItems( queryText ) {
+	// As an example of an asynchronous action, let's return a promise
+	// that resolves after a 100ms timeout.
+	// This can be a server request or any sort of delayed action.
+	return new Promise( resolve => {
+		setTimeout( () => {
+			resolve( items.filter( isItemMatching ) );
+		}, 100 );
+	} );
 
-Below is an example of a customized mention feature that:
+	// Filtering function - it uses `name` and `username` properties of an item to find a match.
+	function isItemMatching( item ) {
+		// Make search case-insensitive.
+		const searchString = queryText.toLowerCase();
+
+		// Include an item in the search results if name or username includes the current user input.
+		return (
+			item.name.toLowerCase().includes( searchString ) ||
+			item.username.toLowerCase().includes( searchString )
+		);
+	}
+}
+
+function customItemRenderer( item ) {
+	const itemElement = document.createElement( 'span' );
 
-- Returns a feed of items with extended properties.
-- Renders custom DOM view in auto-complete suggestion in panel view.
-- Converts mention to an `<a>` element instead of `<span>`.
+	itemElement.classList.add( 'custom-item' );
+	itemElement.id = `mention-list-item-id-${ item.id }`;
+	itemElement.textContent = `${ item.name } `;
 
-{@snippet features/mention-customization}
+	const usernameElement = document.createElement( 'span' );
+
+	usernameElement.classList.add( 'custom-item-username' );
+	usernameElement.textContent = `@${ item.username }`;
+
+	itemElement.appendChild( usernameElement );
+
+	return itemElement;
+}
+```
 
 ### Colors and styles
 
@@ -276,17 +389,13 @@ The mention feature is using the power of [CSS variables](https://developer.mozi
 
 ## Installation
 
-<info-box info>
-	This feature is enabled by default in all builds. The installation instructions are for developers interested in building their own, custom editor.
-</info-box>
-
 To add this feature to your editor, install the [`@ckeditor/ckeditor5-mention`](https://www.npmjs.com/package/@ckeditor/ckeditor5-mention) package:
 
 ```bash
 npm install --save @ckeditor/ckeditor5-mention
 ```
 
-Then add `Mention` to your plugin list and {@link module:mention/mention~MentionConfig configure} the feature (if needed):
+Then add `Mention` to your plugin list and {@link module:mention/mention~MentionConfig configure} the feature:
 
 ```js
 import Mention from '@ckeditor/ckeditor5-mention/src/mention';
@@ -295,13 +404,17 @@ ClassicEditor
 	.create( document.querySelector( '#editor' ), {
 		plugins: [ Mention, ... ],
 		mention: {
-			// configuration...
+			// Configuration...
 		}
 	} )
 	.then( ... )
 	.catch( ... );
 ```
 
+<info-box info>
+	Read more about {@link builds/guides/integration/installing-plugins installing plugins}.
+</info-box>
+
 ## Common API
 
 The {@link module:mention/mention~Mention} plugin registers:

+ 115 - 30
packages/ckeditor5-mention/src/mention.js

@@ -47,36 +47,24 @@ export default class Mention extends Plugin {
  */
 
 /**
- * The mention feed descriptor. Used in {@link module:mention/mention~MentionConfig `config.mention`}.
- *
- * See {@link module:mention/mention~MentionConfig} to learn more.
+ * The configuration of the mention feature.
  *
- *		const mentionFeed = {
- *			marker: '@',
- *			feed: [ 'Alice', 'Bob', ... ]
- *		}
+ * Read more about {@glink features/mention#configuration configuring the mention feature}.
  *
- * @typedef {Object} module:mention/mention~MentionFeed
- * @property {String} [marker=''] The character which triggers auto-completion for mention.
- * @property {Array.<module:mention/mention~MentionFeedItem>|Function} feed The auto complete feed items. Provide an array for
- * static configuration or a function that returns a promise for asynchronous feeds.
- * @property {Number} [minimumCharacters=0] Specifies after how many characters show the autocomplete panel.
- * @property {Function} [itemRenderer] Function that renders {@link module:mention/mention~MentionFeedItem}
- * to the autocomplete list to a DOM element.
- */
-
-/**
- * The mention feed item. In configuration might be defined as string or a plain object. The strings will be used as `name` property
- * when converting to an object in the model.
+ *		ClassicEditor
+ *			.create( editorElement, {
+ *				mention: ... // Media embed feature options.
+ *			} )
+ *			.then( ... )
+ *			.catch( ... );
  *
- * *Note* When defining feed item as a plain object you must provide the at least the `name` property.
+ * See {@link module:core/editor/editorconfig~EditorConfig all editor options}.
  *
- * @typedef {Object|String} module:mention/mention~MentionFeedItem
- * @property {String} name Name of the mention.
+ * @interface MentionConfig
  */
 
 /**
- * The list fo mention feeds supported by the editor.
+ * The list of mention feeds supported by the editor.
  *
  *		ClassicEditor
  *			.create( editorElement, {
@@ -94,24 +82,121 @@ export default class Mention extends Plugin {
  *			.then( ... )
  *			.catch( ... );
  *
- * You can provide as many mention feeds but they must have different `marker` defined.
+ * You can provide as many mention feeds but they must use different `marker`s.
+ * For example, you can use `'@'` to autocomplete people and `'#'` to autocomplete tags.
  *
  * @member {Array.<module:mention/mention~MentionFeed>} module:mention/mention~MentionConfig#feeds
  */
 
 /**
- * The configuration of the mention features.
+ * The mention feed descriptor. Used in {@link module:mention/mention~MentionConfig `config.mention`}.
  *
- * Read more about {@glink features/mention#configuration configuring the mention feature}.
+ * See {@link module:mention/mention~MentionConfig} to learn more.
+ *
+ *		// Static configuration.
+ *		const mentionFeedPeople = {
+ *			marker: '@',
+ *			feed: [ 'Alice', 'Bob', ... ],
+ *			minimumCharacters: 2
+ *		};
+ *
+ *		// Simple, synchronous callback.
+ *		const mentionFeedTags = {
+ *			marker: '#',
+ *			feed: searchString => {
+ *				return tags
+ *					// Filter the tags list.
+ *					.filter( tag => {
+ *						return tag.toLowerCase() == queryText.toLowerCase();
+ *					} )
+ *					// Return 10 items max - needed for generic queries when the list may contain hundreds of elements.
+ *					.slice( 0, 10 );
+ *			}
+ * 		};
+ *
+ *		const tags = [ 'wysiwyg', 'rte', 'rich-text-edior', 'collaboration', 'real-time', ... ];
+ *
+ *		// Asynchronous callback.
+ *		const mentionFeedPlaceholders = {
+ *			marker: '$',
+ *			feed: searchString => {
+ *				return getMatchingPlaceholders( searchString );
+ *			}
+ * 		};
+ *
+ *		function getMatchingPlaceholders( searchString ) {
+ *			return new Promise( resolve => {
+ *				doSomeXHRQuery( result => {
+ *					// console.log( result );
+ *					// -> [ '$name', '$surname', '$postal', ... ]
+ *
+ *					resolve( result );
+ * 				} );
+ *			} );
+ *		}
+ *
+ * @typedef {Object} module:mention/mention~MentionFeed
+ * @property {String} [marker='@'] The character which triggers autocompletion for mention.
+ * @property {Array.<module:mention/mention~MentionFeedItem>|Function} feed The autocomplete items. Provide an array for
+ * a static configuration (the mention feature will show matching items automatically) or a function which returns an array of
+ * matching items (directly, or via a promise).
+ * @property {Number} [minimumCharacters=0] Specifies after how many characters the autocomplete panel should be shown.
+ * @property {Function} [itemRenderer] Function that renders a {@link module:mention/mention~MentionFeedItem}
+ * to the autocomplete panel.
+ */
+
+/**
+ * The mention feed item. It may be defined as a string or a plain object.
+ *
+ * When defining feed item as a plain object, the `name` property is obligatory. The additional properties
+ * can be used when customizing the mention feature bahaviour
+ * (see {@glink features/mention#customizing-the-autocomplete-list "Customizing the autocomplete list"}
+ * and {@glink features/mention#customizing-the-output "Customizing the output"} sections).
  *
  *		ClassicEditor
  *			.create( editorElement, {
- * 				mention: ... // Media embed feature options.
+ *				plugins: [ Mention, ... ],
+ *				mention: {
+ *					feeds: [
+ *						// Feed items as objects.
+ *						{
+ *							marker: '@',
+ *							feed: [
+ *								{
+ *									name: 'Barney',
+ *									fullName: 'Barney Bloom'
+ *								},
+ *								{
+ *									name: 'Lily',
+ *									fullName: 'Lily Smith'
+ *								},
+ *								{
+ *									name: 'Marshall',
+ *									fullName: 'Marshall McDonald'
+ *								},
+ *								{
+ *									name: 'Robin',
+ *									fullName: 'Robin Hood'
+ *								},
+ *								{
+ *									name: 'Ted',
+ *									fullName: 'Ted Cruze'
+ *								},
+ *								// ...
+ *							]
+ *						},
+ *
+ *						// Feed items as plain strings.
+ *						{
+ *							marker: '#',
+ *							feed: [ 'wysiwyg', 'rte', 'rich-text-edior', 'collaboration', 'real-time', ... ]
+ *						},
+ * 					]
+ *				}
  *			} )
  *			.then( ... )
  *			.catch( ... );
  *
- * See {@link module:core/editor/editorconfig~EditorConfig all editor options}.
- *
- * @interface MentionConfig
+ * @typedef {Object|String} module:mention/mention~MentionFeedItem
+ * @property {String} name Name of the mention.
  */

+ 83 - 25
packages/ckeditor5-mention/src/mentionui.js

@@ -21,7 +21,7 @@ import MentionsView from './ui/mentionsview';
 import DomWrapperView from './ui/domwrapperview';
 import MentionListItemView from './ui/mentionlistitemview';
 
-const VERTICAL_SPACING = 5;
+const VERTICAL_SPACING = 3;
 
 /**
  * The mention UI feature.
@@ -91,17 +91,17 @@ export default class MentionUI extends Plugin {
 				}
 
 				if ( data.keyCode == keyCodes.esc ) {
-					this._hidePanel();
+					this._hidePanelAndRemoveMarker();
 				}
 			}
-		}, { priority: 'highest' } ); // priority highest required for enter overriding.
+		}, { priority: 'highest' } ); // Required to override enter.
 
 		// Close the #panelView upon clicking outside of the plugin UI.
 		clickOutsideHandler( {
 			emitter: this.panelView,
 			contextElements: [ this.panelView.element ],
 			activator: () => this.panelView.isVisible,
-			callback: () => this._hidePanel()
+			callback: () => this._hidePanelAndRemoveMarker()
 		} );
 
 		const feeds = this.editor.config.get( 'mention.feeds' );
@@ -205,7 +205,7 @@ export default class MentionUI extends Plugin {
 			const start = end.getShiftedBy( -matchedTextLength );
 			const range = model.createRange( start, end );
 
-			this._hidePanel();
+			this._hidePanelAndRemoveMarker();
 
 			editor.execute( 'mention', {
 				mention: item,
@@ -273,6 +273,26 @@ export default class MentionUI extends Plugin {
 
 			const { feedText, marker } = matched;
 
+			const matchedTextLength = marker.length + feedText.length;
+
+			// create marker range
+			const start = selection.focus.getShiftedBy( -matchedTextLength );
+			const end = selection.focus.getShiftedBy( -feedText.length );
+
+			const markerRange = editor.model.createRange( start, end );
+
+			let mentionMarker;
+
+			if ( editor.model.markers.has( 'mention' ) ) {
+				mentionMarker = editor.model.markers.get( 'mention' );
+			} else {
+				mentionMarker = editor.model.change( writer => writer.addMarker( 'mention', {
+					range: markerRange,
+					usingOperation: false,
+					affectsData: false
+				} ) );
+			}
+
 			this._getFeed( marker, feedText )
 				.then( feed => {
 					this._items.clear();
@@ -284,15 +304,15 @@ export default class MentionUI extends Plugin {
 					}
 
 					if ( this._items.length ) {
-						this._showPanel();
+						this._showPanel( mentionMarker );
 					} else {
-						this._hidePanel();
+						this._hidePanelAndRemoveMarker();
 					}
 				} );
 		} );
 
 		watcher.on( 'unmatched', () => {
-			this._hidePanel();
+			this._hidePanelAndRemoveMarker();
 		} );
 
 		return watcher;
@@ -316,19 +336,25 @@ export default class MentionUI extends Plugin {
 	 *
 	 * @private
 	 */
-	_showPanel() {
-		this.panelView.pin( this._getBalloonPanelPositionData() );
+	_showPanel( markerMarker ) {
+		this.panelView.pin( this._getBalloonPanelPositionData( markerMarker, this.panelView.position ) );
 		this.panelView.show();
 		this._mentionsView.selectFirst();
 	}
 
 	/**
-	 * Hides the {@link #panelView}.
+	 * Hides the {@link #panelView} and remove 'mention' marker from markers collection.
 	 *
 	 * @private
 	 */
-	_hidePanel() {
+	_hidePanelAndRemoveMarker() {
+		if ( this.editor.model.markers.has( 'mention' ) ) {
+			this.editor.model.change( writer => writer.removeMarker( 'mention' ) );
+		}
+
 		this.panelView.unpin();
+		// Make last matched position on panel view undefined so the #_getBalloonPanelPositionData() will return all positions on next call.
+		this.panelView.position = undefined;
 		this.panelView.hide();
 	}
 
@@ -364,22 +390,39 @@ export default class MentionUI extends Plugin {
 	}
 
 	/**
+	 * Creates position options object used to position the balloon panel.
+	 *
+	 * @param {module:engine/model/markercollection~Marker} mentionMarker
+	 * @param {String|undefined} positionName Name of last matched position name.
 	 * @returns {module:utils/dom/position~Options}
 	 * @private
 	 */
-	_getBalloonPanelPositionData() {
-		const view = this.editor.editing.view;
-		const domConverter = view.domConverter;
-		const viewSelection = view.document.selection;
+	_getBalloonPanelPositionData( mentionMarker, positionName ) {
+		const editing = this.editor.editing;
+		const domConverter = editing.view.domConverter;
+		const mapper = editing.mapper;
 
 		return {
 			target: () => {
-				const range = viewSelection.getLastRange();
-				const rangeRects = Rect.getDomRangeRects( domConverter.viewRangeToDom( range ) );
+				const viewRange = mapper.toViewRange( mentionMarker.getRange() );
+
+				const rangeRects = Rect.getDomRangeRects( domConverter.viewRangeToDom( viewRange ) );
 
 				return rangeRects.pop();
 			},
-			positions: getBalloonPanelPositions()
+			limiter: () => {
+				const view = this.editor.editing.view;
+				const viewDocument = view.document;
+				const editableElement = viewDocument.selection.editableElement;
+
+				if ( editableElement ) {
+					return view.domConverter.mapViewToDom( editableElement.root );
+				}
+
+				return null;
+			},
+			positions: getBalloonPanelPositions( positionName ),
+			fitInViewport: true
 		};
 	}
 }
@@ -387,10 +430,10 @@ export default class MentionUI extends Plugin {
 // Returns balloon positions data callbacks.
 //
 // @returns {Array.<module:utils/dom/position~Position>}
-function getBalloonPanelPositions() {
-	return [
+function getBalloonPanelPositions( positionName ) {
+	const positions = {
 		// Positions panel to the south of caret rect.
-		targetRect => {
+		'caret_se': targetRect => {
 			return {
 				top: targetRect.bottom + VERTICAL_SPACING,
 				left: targetRect.right,
@@ -399,7 +442,7 @@ function getBalloonPanelPositions() {
 		},
 
 		// Positions panel to the north of caret rect.
-		( targetRect, balloonRect ) => {
+		'caret_ne': ( targetRect, balloonRect ) => {
 			return {
 				top: targetRect.top - balloonRect.height - VERTICAL_SPACING,
 				left: targetRect.right,
@@ -408,7 +451,7 @@ function getBalloonPanelPositions() {
 		},
 
 		// Positions panel to the south of caret rect.
-		( targetRect, balloonRect ) => {
+		'caret_sw': ( targetRect, balloonRect ) => {
 			return {
 				top: targetRect.bottom + VERTICAL_SPACING,
 				left: targetRect.right - balloonRect.width,
@@ -417,13 +460,28 @@ function getBalloonPanelPositions() {
 		},
 
 		// Positions panel to the north of caret rect.
-		( targetRect, balloonRect ) => {
+		'caret_nw': ( targetRect, balloonRect ) => {
 			return {
 				top: targetRect.top - balloonRect.height - VERTICAL_SPACING,
 				left: targetRect.right - balloonRect.width,
 				name: 'caret_nw'
 			};
 		}
+	};
+
+	// Return only last position if it was matched to prevent panel from jumping after first match.
+	if ( positions.hasOwnProperty( positionName ) ) {
+		return [
+			positions[ positionName ]
+		];
+	}
+
+	// As default return all positions callbacks.
+	return [
+		positions.caret_se,
+		positions.caret_ne,
+		positions.caret_sw,
+		positions.caret_nw
 	];
 }
 

+ 45 - 17
packages/ckeditor5-mention/src/textwatcher.js

@@ -48,6 +48,15 @@ export default class TextWatcher {
 	_startListening() {
 		const editor = this.editor;
 
+		editor.model.document.selection.on( 'change', ( evt, { directChange } ) => {
+			// The indirect changes (ie on typing) are handled in document's change event.
+			if ( !directChange ) {
+				return;
+			}
+
+			this._evaluateTextBeforeSelection();
+		} );
+
 		editor.model.document.on( 'change', ( evt, batch ) => {
 			if ( batch.type == 'transparent' ) {
 				return;
@@ -58,29 +67,49 @@ export default class TextWatcher {
 
 			// Typing is represented by only a single change.
 			const isTypingChange = changes.length == 1 && entry.name == '$text' && entry.length == 1;
-			// Selection is represented by empty changes.
-			const isSelectionChange = changes.length == 0;
 
-			if ( !isTypingChange && !isSelectionChange ) {
+			if ( !isTypingChange ) {
 				return;
 			}
 
-			const text = this._getText();
-
-			const textHasMatch = this.testCallback( text );
+			this._evaluateTextBeforeSelection();
+		} );
+	}
 
-			if ( !textHasMatch && this.hasMatch ) {
-				this.fire( 'unmatched' );
-			}
+	/**
+	 * Checks the editor content for matched text.
+	 *
+	 * @fires matched
+	 * @fires unmatched
+	 *
+	 * @private
+	 */
+	_evaluateTextBeforeSelection() {
+		const text = this._getText();
+
+		const textHasMatch = this.testCallback( text );
+
+		if ( !textHasMatch && this.hasMatch ) {
+			/**
+			 * Fired whenever text doesn't match anymore. Fired only when text matcher was matched.
+			 *
+			 * @event unmatched
+			 */
+			this.fire( 'unmatched' );
+		}
 
-			this.hasMatch = textHasMatch;
+		this.hasMatch = textHasMatch;
 
-			if ( textHasMatch ) {
-				const matched = this.textMatcher( text );
+		if ( textHasMatch ) {
+			const matched = this.textMatcher( text );
 
-				this.fire( 'matched', { text, matched } );
-			}
-		} );
+			/**
+			 * Fired whenever text matcher was matched.
+			 *
+			 * @event matched
+			 */
+			this.fire( 'matched', { text, matched } );
+		}
 	}
 
 	/**
@@ -105,8 +134,7 @@ export default class TextWatcher {
 }
 
 // Returns whole text from parent element by adding all data from text nodes together.
-// @todo copied from autoformat...
-
+//
 // @private
 // @param {module:engine/model/element~Element} element
 // @returns {String}

+ 130 - 9
packages/ckeditor5-mention/tests/mentionui.js

@@ -108,9 +108,13 @@ describe( 'MentionUI', () => {
 		} );
 
 		it( 'should properly calculate position data', () => {
+			const editableElement = editingView.document.selection.editableElement;
+
 			setData( model, '<paragraph>foo []</paragraph>' );
 			stubSelectionRects( [ caretRect ] );
 
+			expect( editor.model.markers.has( 'mention' ) ).to.be.false;
+
 			model.change( writer => {
 				writer.insertText( '@', doc.selection.getFirstPosition() );
 			} );
@@ -118,11 +122,31 @@ describe( 'MentionUI', () => {
 			return waitForDebounce()
 				.then( () => {
 					const pinArgument = pinSpy.firstCall.args[ 0 ];
-					const { target, positions } = pinArgument;
+					const { target, positions, limiter, fitInViewport } = pinArgument;
 
-					expect( target() ).to.deep.equal( caretRect );
+					expect( fitInViewport ).to.be.true;
 					expect( positions ).to.have.length( 4 );
 
+					// Mention UI should set limiter to the editable area.
+					expect( limiter() ).to.equal( editingView.domConverter.mapViewToDom( editableElement ) );
+
+					expect( editor.model.markers.has( 'mention' ) ).to.be.true;
+					const mentionMarker = editor.model.markers.get( 'mention' );
+					const focus = doc.selection.focus;
+					const expectedRange = editor.model.createRange( focus.getShiftedBy( -1 ), focus );
+
+					// It should create a model marker for matcher marker character ('@').
+					expect( expectedRange.isEqual( mentionMarker.getRange() ) ).to.be.true;
+
+					const toViewRangeSpy = sinon.spy( editor.editing.mapper, 'toViewRange' );
+
+					expect( target() ).to.deep.equal( caretRect );
+
+					sinon.assert.calledOnce( toViewRangeSpy );
+					const range = toViewRangeSpy.firstCall.args[ 0 ];
+
+					expect( mentionMarker.getRange().isEqual( range ), 'Should position to mention marker.' );
+
 					const caretSouthEast = positions[ 0 ];
 					const caretNorthEast = positions[ 1 ];
 					const caretSouthWest = positions[ 2 ];
@@ -131,30 +155,30 @@ describe( 'MentionUI', () => {
 					expect( caretSouthEast( caretRect, balloonRect ) ).to.deep.equal( {
 						left: 501,
 						name: 'caret_se',
-						top: 123
+						top: 121
 					} );
 
 					expect( caretNorthEast( caretRect, balloonRect ) ).to.deep.equal( {
 						left: 501,
 						name: 'caret_ne',
-						top: -55
+						top: -53
 					} );
 
 					expect( caretSouthWest( caretRect, balloonRect ) ).to.deep.equal( {
 						left: 301,
 						name: 'caret_sw',
-						top: 123
+						top: 121
 					} );
 
 					expect( caretNorthWest( caretRect, balloonRect ) ).to.deep.equal( {
 						left: 301,
 						name: 'caret_nw',
-						top: -55
+						top: -53
 					} );
 				} );
 		} );
 
-		it( 'should re-calculate position on typing', () => {
+		it( 'should re-calculate position on typing and stay on selected position', () => {
 			setData( model, '<paragraph>foo []</paragraph>' );
 			stubSelectionRects( [ caretRect ] );
 
@@ -162,10 +186,19 @@ describe( 'MentionUI', () => {
 				writer.insertText( '@', doc.selection.getFirstPosition() );
 			} );
 
+			let positionAfterFirstShow;
+
 			return waitForDebounce()
 				.then( () => {
 					sinon.assert.calledOnce( pinSpy );
 
+					const pinArgument = pinSpy.firstCall.args[ 0 ];
+					const { positions } = pinArgument;
+
+					expect( positions ).to.have.length( 4 );
+
+					positionAfterFirstShow = panelView.position;
+
 					model.change( writer => {
 						writer.insertText( 't', doc.selection.getFirstPosition() );
 					} );
@@ -173,6 +206,34 @@ describe( 'MentionUI', () => {
 				.then( waitForDebounce )
 				.then( () => {
 					sinon.assert.calledTwice( pinSpy );
+
+					const pinArgument = pinSpy.secondCall.args[ 0 ];
+					const { positions } = pinArgument;
+
+					expect( positions, 'should reuse first matched position' ).to.have.length( 1 );
+					expect( positions[ 0 ].name ).to.equal( positionAfterFirstShow );
+				} );
+		} );
+
+		it( 'does not fail if selection has no #editableElement', () => {
+			setData( model, '<paragraph>foo []</paragraph>' );
+			stubSelectionRects( [ caretRect ] );
+
+			expect( editor.model.markers.has( 'mention' ) ).to.be.false;
+
+			model.change( writer => {
+				writer.insertText( '@', doc.selection.getFirstPosition() );
+			} );
+
+			return waitForDebounce()
+				.then( () => {
+					const pinArgument = pinSpy.firstCall.args[ 0 ];
+					const { limiter } = pinArgument;
+
+					sinon.stub( editingView.document.selection, 'editableElement' ).value( null );
+
+					// Should not break;
+					expect( limiter() ).to.be.null;
 				} );
 		} );
 	} );
@@ -195,6 +256,7 @@ describe( 'MentionUI', () => {
 				.then( waitForDebounce )
 				.then( () => {
 					expect( panelView.isVisible ).to.be.false;
+					expect( editor.model.markers.has( 'mention' ) ).to.be.false;
 				} )
 				.then( waitForDebounce )
 				.then( () => {
@@ -205,6 +267,7 @@ describe( 'MentionUI', () => {
 				.then( waitForDebounce )
 				.then( () => {
 					expect( panelView.isVisible ).to.be.true;
+					expect( editor.model.markers.has( 'mention' ) ).to.be.true;
 					expect( listView.items ).to.have.length( 1 );
 
 					model.change( writer => {
@@ -214,6 +277,7 @@ describe( 'MentionUI', () => {
 				.then( waitForDebounce )
 				.then( () => {
 					expect( panelView.isVisible ).to.be.true;
+					expect( editor.model.markers.has( 'mention' ) ).to.be.true;
 					expect( listView.items ).to.have.length( 1 );
 				} );
 		} );
@@ -295,6 +359,8 @@ describe( 'MentionUI', () => {
 			it( 'should show panel for matched marker', () => {
 				setData( model, '<paragraph>foo []</paragraph>' );
 
+				expect( editor.model.markers.has( 'mention' ) ).to.be.false;
+
 				model.change( writer => {
 					writer.insertText( '@', doc.selection.getFirstPosition() );
 				} );
@@ -302,6 +368,7 @@ describe( 'MentionUI', () => {
 				return waitForDebounce()
 					.then( () => {
 						expect( panelView.isVisible ).to.be.true;
+						expect( editor.model.markers.has( 'mention' ) ).to.be.true;
 						expect( listView.items ).to.have.length( 5 );
 					} );
 			} );
@@ -316,6 +383,7 @@ describe( 'MentionUI', () => {
 				return waitForDebounce()
 					.then( () => {
 						expect( panelView.isVisible ).to.be.true;
+						expect( editor.model.markers.has( 'mention' ) ).to.be.true;
 						expect( listView.items ).to.have.length( 5 );
 					} );
 			} );
@@ -330,6 +398,7 @@ describe( 'MentionUI', () => {
 				return waitForDebounce()
 					.then( () => {
 						expect( panelView.isVisible ).to.be.false;
+						expect( editor.model.markers.has( 'mention' ) ).to.be.false;
 					} );
 			} );
 
@@ -346,6 +415,7 @@ describe( 'MentionUI', () => {
 				return waitForDebounce()
 					.then( () => {
 						expect( panelView.isVisible ).to.be.false;
+						expect( editor.model.markers.has( 'mention' ) ).to.be.false;
 					} );
 			} );
 
@@ -362,6 +432,7 @@ describe( 'MentionUI', () => {
 				return waitForDebounce()
 					.then( () => {
 						expect( panelView.isVisible ).to.be.false;
+						expect( editor.model.markers.has( 'mention' ) ).to.be.false;
 					} );
 			} );
 
@@ -375,6 +446,7 @@ describe( 'MentionUI', () => {
 				return waitForDebounce()
 					.then( () => {
 						expect( panelView.isVisible ).to.be.true;
+						expect( editor.model.markers.has( 'mention' ) ).to.be.true;
 
 						model.change( () => {
 							model.modifySelection( doc.selection, { direction: 'backward', unit: 'character' } );
@@ -383,6 +455,7 @@ describe( 'MentionUI', () => {
 					.then( waitForDebounce )
 					.then( () => {
 						expect( panelView.isVisible ).to.be.false;
+						expect( editor.model.markers.has( 'mention' ) ).to.be.false;
 					} );
 			} );
 
@@ -420,6 +493,7 @@ describe( 'MentionUI', () => {
 				return waitForDebounce()
 					.then( () => {
 						expect( panelView.isVisible ).to.be.true;
+						expect( editor.model.markers.has( 'mention' ) ).to.be.true;
 						expect( listView.items ).to.have.length( 1 );
 					} );
 			} );
@@ -456,6 +530,7 @@ describe( 'MentionUI', () => {
 					.then( waitForDebounce )
 					.then( () => {
 						expect( panelView.isVisible ).to.be.false;
+						expect( editor.model.markers.has( 'mention' ) ).to.be.false;
 						expect( listView.items ).to.have.length( 0 );
 					} );
 			} );
@@ -512,6 +587,7 @@ describe( 'MentionUI', () => {
 				return waitForDebounce()
 					.then( () => {
 						expect( panelView.isVisible ).to.be.true;
+						expect( editor.model.markers.has( 'mention' ) ).to.be.true;
 						expect( listView.items ).to.have.length( 4 );
 					} );
 			} );
@@ -530,6 +606,7 @@ describe( 'MentionUI', () => {
 				return waitForDebounce()
 					.then( () => {
 						expect( panelView.isVisible ).to.be.true;
+						expect( editor.model.markers.has( 'mention' ) ).to.be.true;
 						expect( listView.items ).to.have.length( 1 );
 					} );
 			} );
@@ -551,6 +628,7 @@ describe( 'MentionUI', () => {
 					.then( waitForDebounce )
 					.then( () => {
 						expect( panelView.isVisible ).to.be.false;
+						expect( editor.model.markers.has( 'mention' ) ).to.be.false;
 						expect( listView.items ).to.have.length( 0 );
 					} );
 			} );
@@ -591,6 +669,7 @@ describe( 'MentionUI', () => {
 				.then( waitForDebounce )
 				.then( () => {
 					expect( panelView.isVisible ).to.be.true;
+					expect( editor.model.markers.has( 'mention' ) ).to.be.true;
 
 					fireKeyDownEvent( {
 						keyCode: keyCodes.esc,
@@ -599,6 +678,7 @@ describe( 'MentionUI', () => {
 					} );
 
 					expect( panelView.isVisible ).to.be.false;
+					expect( editor.model.markers.has( 'mention' ) ).to.be.false;
 				} );
 		} );
 
@@ -614,14 +694,41 @@ describe( 'MentionUI', () => {
 				.then( waitForDebounce )
 				.then( () => {
 					expect( panelView.isVisible ).to.be.true;
+					expect( editor.model.markers.has( 'mention' ) ).to.be.true;
 
 					document.body.dispatchEvent( new Event( 'mousedown', { bubbles: true } ) );
 
 					expect( panelView.isVisible ).to.be.false;
+					expect( editor.model.markers.has( 'mention' ) ).to.be.false;
+				} );
+		} );
+
+		it( 'should hide the panel on selection change', () => {
+			return createClassicTestEditor( staticConfig )
+				.then( () => {
+					setData( model, '<paragraph>foo []</paragraph>' );
+
+					model.change( writer => {
+						writer.insertText( '@', doc.selection.getFirstPosition() );
+					} );
+				} )
+				.then( waitForDebounce )
+				.then( () => {
+					expect( panelView.isVisible ).to.be.true;
+					expect( editor.model.markers.has( 'mention' ) ).to.be.true;
+
+					model.change( writer => {
+						// Place position at the beginning of a paragraph.
+						writer.setSelection( doc.getRoot().getChild( 0 ), 0 );
+					} );
+
+					expect( panelView.isVisible ).to.be.false;
+					expect( panelView.position ).to.be.undefined;
+					expect( editor.model.markers.has( 'mention' ) ).to.be.false;
 				} );
 		} );
 
-		it( 'should hide the panel when click outside', () => {
+		it( 'should hide the panel on selection change triggered by mouse click', () => {
 			return createClassicTestEditor( staticConfig )
 				.then( () => {
 					setData( model, '<paragraph>foo []</paragraph>' );
@@ -633,13 +740,22 @@ describe( 'MentionUI', () => {
 				.then( waitForDebounce )
 				.then( () => {
 					expect( panelView.isVisible ).to.be.true;
+					expect( editor.model.markers.has( 'mention' ) ).to.be.true;
 
+					// This happens when user clicks outside the panel view and selection is changed.
+					// Two panel closing mechanisms are run:
+					// - clickOutsideHandler
+					// - unmatched text in text watcher
+					// which may fail when trying to remove mention marker twice.
+					document.body.dispatchEvent( new Event( 'mousedown', { bubbles: true } ) );
 					model.change( writer => {
-						// Place position at the begging of a paragraph.
+						// Place position at the beginning of a paragraph.
 						writer.setSelection( doc.getRoot().getChild( 0 ), 0 );
 					} );
 
 					expect( panelView.isVisible ).to.be.false;
+					expect( panelView.position ).to.be.undefined;
+					expect( editor.model.markers.has( 'mention' ) ).to.be.false;
 				} );
 		} );
 
@@ -768,6 +884,7 @@ describe( 'MentionUI', () => {
 				return waitForDebounce()
 					.then( () => {
 						expect( panelView.isVisible ).to.be.true;
+						expect( editor.model.markers.has( 'mention' ) ).to.be.true;
 						expect( listView.items ).to.have.length( 5 );
 					} );
 			} );
@@ -942,6 +1059,7 @@ describe( 'MentionUI', () => {
 				return waitForDebounce()
 					.then( () => {
 						expect( panelView.isVisible ).to.be.true;
+						expect( editor.model.markers.has( 'mention' ) ).to.be.true;
 
 						fireKeyDownEvent( {
 							keyCode: keyCodes.esc,
@@ -950,6 +1068,7 @@ describe( 'MentionUI', () => {
 						} );
 
 						expect( panelView.isVisible ).to.be.false;
+						expect( editor.model.markers.has( 'mention' ) ).to.be.false;
 
 						fireKeyDownEvent( {
 							keyCode,
@@ -960,6 +1079,7 @@ describe( 'MentionUI', () => {
 						sinon.assert.notCalled( spy );
 
 						expect( panelView.isVisible ).to.be.false;
+						expect( editor.model.markers.has( 'mention' ) ).to.be.false;
 					} );
 			} );
 		}
@@ -1007,6 +1127,7 @@ describe( 'MentionUI', () => {
 					listView.items.get( 0 ).children.get( 0 ).fire( 'execute' );
 
 					expect( panelView.isVisible ).to.be.false;
+					expect( editor.model.markers.has( 'mention' ) ).to.be.false;
 				} );
 		} );