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

Merge branch 'master' into t/ckeditor5/1655

Kamil Piechaczek 6 лет назад
Родитель
Сommit
f68ad1d477
39 измененных файлов с 2415 добавлено и 857 удалено
  1. 1 1
      packages/ckeditor5-mention/.eslintrc.js
  2. 9 0
      packages/ckeditor5-mention/.github/PULL_REQUEST_TEMPLATE.md
  3. 1 5
      packages/ckeditor5-mention/.travis.yml
  4. 6 0
      packages/ckeditor5-mention/CHANGELOG.md
  5. 1 1
      packages/ckeditor5-mention/docs/_snippets/features/build-mention-source.js
  6. 1 1
      packages/ckeditor5-mention/docs/_snippets/features/custom-mention-colors-variables.html
  7. 9 7
      packages/ckeditor5-mention/docs/_snippets/features/custom-mention-colors-variables.js
  8. 1 1
      packages/ckeditor5-mention/docs/_snippets/features/mention-customization.html
  9. 60 73
      packages/ckeditor5-mention/docs/_snippets/features/mention-customization.js
  10. 1 1
      packages/ckeditor5-mention/docs/_snippets/features/mention.html
  11. 9 7
      packages/ckeditor5-mention/docs/_snippets/features/mention.js
  12. 2 2
      packages/ckeditor5-mention/docs/api/mention.md
  13. 0 323
      packages/ckeditor5-mention/docs/features/mention.md
  14. 455 0
      packages/ckeditor5-mention/docs/features/mentions.md
  15. 12 16
      packages/ckeditor5-mention/package.json
  16. 150 33
      packages/ckeditor5-mention/src/mention.js
  17. 72 12
      packages/ckeditor5-mention/src/mentioncommand.js
  18. 117 45
      packages/ckeditor5-mention/src/mentionediting.js
  19. 129 38
      packages/ckeditor5-mention/src/mentionui.js
  20. 57 33
      packages/ckeditor5-mention/src/textwatcher.js
  21. 1 1
      packages/ckeditor5-mention/src/ui/domwrapperview.js
  22. 1 1
      packages/ckeditor5-mention/src/ui/mentionlistitemview.js
  23. 56 23
      packages/ckeditor5-mention/src/ui/mentionsview.js
  24. 35 28
      packages/ckeditor5-mention/tests/manual/mention-custom-renderer.js
  25. 17 8
      packages/ckeditor5-mention/tests/manual/mention-custom-renderer.md
  26. 2 2
      packages/ckeditor5-mention/tests/manual/mention-custom-view.html
  27. 29 37
      packages/ckeditor5-mention/tests/manual/mention-custom-view.js
  28. 5 5
      packages/ckeditor5-mention/tests/manual/mention-custom-view.md
  29. 7 2
      packages/ckeditor5-mention/tests/manual/mention.html
  30. 26 16
      packages/ckeditor5-mention/tests/manual/mention.js
  31. 15 6
      packages/ckeditor5-mention/tests/manual/mention.md
  32. BIN
      packages/ckeditor5-mention/tests/manual/sample.jpg
  33. 144 22
      packages/ckeditor5-mention/tests/mention-integration.js
  34. 44 1
      packages/ckeditor5-mention/tests/mention.js
  35. 65 18
      packages/ckeditor5-mention/tests/mentioncommand.js
  36. 288 36
      packages/ckeditor5-mention/tests/mentionediting.js
  37. 559 51
      packages/ckeditor5-mention/tests/mentionui.js
  38. 1 1
      packages/ckeditor5-mention/theme/mention.css
  39. 27 0
      packages/ckeditor5-mention/theme/mentionui.css

+ 1 - 1
packages/ckeditor5-mention/.eslintrc.js

@@ -1,6 +1,6 @@
 /**
  * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
- * For licensing, see LICENSE.md.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
  */
 
 /* eslint-env node */

+ 9 - 0
packages/ckeditor5-mention/.github/PULL_REQUEST_TEMPLATE.md

@@ -0,0 +1,9 @@
+### Suggested merge commit message ([convention](https://github.com/ckeditor/ckeditor5-design/wiki/Git-commit-message-convention))
+
+Type: Message. Closes #000.
+
+---
+
+### Additional information
+
+*For example – encountered issues, assumptions you had to make, other affected tickets, etc.*

+ 1 - 5
packages/ckeditor5-mention/.travis.yml

@@ -1,12 +1,8 @@
 sudo: required
 dist: trusty
 addons:
+  chrome: stable
   firefox: latest
-  apt:
-    sources:
-    - google-chrome
-    packages:
-    - google-chrome-stable
 language: node_js
 node_js:
 - '8'

+ 6 - 0
packages/ckeditor5-mention/CHANGELOG.md

@@ -0,0 +1,6 @@
+Changelog
+=========
+
+## [10.0.0](https://github.com/ckeditor/ckeditor5-mention/tree/v10.0.0) (2019-04-10)
+
+The initial release.

+ 1 - 1
packages/ckeditor5-mention/docs/_snippets/features/build-mention-source.js

@@ -1,6 +1,6 @@
 /**
  * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
- * For licensing, see LICENSE.md.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
  */
 
 /* globals window */

+ 1 - 1
packages/ckeditor5-mention/docs/_snippets/features/custom-mention-colors-variables.html

@@ -10,5 +10,5 @@
 </style>
 
 <div id="snippet-mention-custom-colors">
-	<p>Hello <span class="mention" data-mention="Ted">@Ted</span>.</p>
+	<p>Hello <span class="mention" data-mention="@Ted">@Ted</span>.</p>
 </div>

+ 9 - 7
packages/ckeditor5-mention/docs/_snippets/features/custom-mention-colors-variables.js

@@ -1,6 +1,6 @@
 /**
  * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
- * For licensing, see LICENSE.md.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
  */
 
 /* globals ClassicEditor, console, window, document */
@@ -16,12 +16,14 @@ ClassicEditor
 			],
 			viewportTopOffset: window.getViewportTopOffsetConfig()
 		},
-		mention: [
-			{
-				marker: '@',
-				feed: [ 'Barney', 'Lily', 'Marshall', 'Robin', 'Ted' ]
-			}
-		]
+		mention: {
+			feeds: [
+				{
+					marker: '@',
+					feed: [ '@Barney', '@Lily', '@Marshall', '@Robin', '@Ted' ]
+				}
+			]
+		}
 	} )
 	.then( editor => {
 		window.editor = editor;

+ 1 - 1
packages/ckeditor5-mention/docs/_snippets/features/mention-customization.html

@@ -1,5 +1,5 @@
 <div id="snippet-mention-customization">
-	<p>Hello <a class="mention" data-mention="Ted Mosby" data-user-id="5" href="https://www.imdb.com/title/tt0460649/characters/nm1102140">@Ted Mosby</a>!</p>
+	<p>Hello <a class="mention" data-mention="@tdog" data-user-id="5" href="https://www.imdb.com/title/tt0460649/characters/nm1102140">@tdog</a>!</p>
 </div>
 
 <style>

+ 60 - 73
packages/ckeditor5-mention/docs/_snippets/features/mention-customization.js

@@ -1,35 +1,31 @@
 /**
  * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
- * For licensing, see LICENSE.md.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
  */
 
 /* globals ClassicEditor, console, window, document, setTimeout */
 
 import { CS_CONFIG } from '@ckeditor/ckeditor5-cloud-services/tests/_utils/cloud-services-config';
 
-import priorities from '@ckeditor/ckeditor5-utils/src/priorities';
-
-// The link plugin using highest priority in conversion pipeline.
-const HIGHER_THEN_HIGHEST = priorities.highest + 50;
-
 ClassicEditor
 	.create( document.querySelector( '#snippet-mention-customization' ), {
 		cloudServices: CS_CONFIG,
-		extraPlugins: [ CustomMention ],
+		extraPlugins: [ MentionCustomization ],
 		toolbar: {
 			items: [
 				'heading', '|', 'bold', 'italic', '|', 'undo', 'redo'
 			],
 			viewportTopOffset: window.getViewportTopOffsetConfig(),
 		},
-		mention: [
-			{
-				marker: '@',
-				feed: getFeedItems,
-				itemRenderer: customItemRenderer,
-				minimumCharacters: 1
-			}
-		]
+		mention: {
+			feeds: [
+				{
+					marker: '@',
+					feed: getFeedItems,
+					itemRenderer: customItemRenderer
+				}
+			]
+		}
 	} )
 	.then( editor => {
 		window.editor = editor;
@@ -38,8 +34,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 view <a class="mention" href="" data-user-id="">
+	// elements to the model 'mention' text attribute.
 	editor.conversion.for( 'upcast' ).elementToAttribute( {
 		view: {
 			name: 'a',
@@ -53,101 +50,91 @@ 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:
-				const mentionValue = {
-					// The name attribute is required by mention editing.
-					name: viewItem.getAttribute( 'data-mention' ),
-					// Add any other properties as required.
+				// The mention feature expects that the mention attribute value
+				// in the model is a plain object with a set of additional attributes.
+				// In order to create a proper object use the toMentionAttribute() helper method:
+				const mentionAttribute = editor.plugins.get( 'Mention' ).toMentionAttribute( viewItem, {
+					// Add any other properties that you need.
 					link: viewItem.getAttribute( 'href' ),
-					id: viewItem.getAttribute( 'data-user-id' )
-				};
+					userId: viewItem.getAttribute( 'data-user-id' )
+				} );
 
-				return mentionValue;
+				return mentionAttribute;
 			}
 		},
-		converterPriority: HIGHER_THEN_HIGHEST
+		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:
+	// 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 ) {
-				// Do not convert empty attributes.
 				return;
 			}
 
 			return viewWriter.createAttributeElement( 'a', {
 				class: 'mention',
-				'data-mention': modelAttributeValue.name,
-				'data-user-id': modelAttributeValue.id,
+				'data-mention': modelAttributeValue.id,
+				'data-user-id': modelAttributeValue.userId,
 				'href': modelAttributeValue.link
 			} );
 		},
-		converterPriority: HIGHER_THEN_HIGHEST
+		converterPriority: 'high'
 	} );
 }
 
 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' }
+	{ id: '@swarley', userId: '1', name: 'Barney Stinson', link: 'https://www.imdb.com/title/tt0460649/characters/nm0000439' },
+	{ id: '@lilypad', userId: '2', name: 'Lily Aldrin', link: 'https://www.imdb.com/title/tt0460649/characters/nm0004989' },
+	{ id: '@marshmallow', userId: '3', name: 'Marshall Eriksen', link: 'https://www.imdb.com/title/tt0460649/characters/nm0781981' },
+	{ id: '@rsparkles', userId: '4', name: 'Robin Scherbatsky', link: 'https://www.imdb.com/title/tt0460649/characters/nm1130627' },
+	{ id: '@tdog', userId: '5', name: 'Ted Mosby', 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 ) );
+			const itemsToDisplay = items
+				// Filter out the full list of all items to only those matching queryText.
+				.filter( isItemMatching )
+				// Return 10 items max - needed for generic queries when the list may contain hundreds of elements.
+				.slice( 0, 10 );
+
+			resolve( itemsToDisplay );
 		}, 100 );
 	} );
 
 	// 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.id.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.userId }`;
+	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.id;
 
-	span.innerHTML = `${ item.name } <span class="custom-item-username">@${ item.username }</span>`;
+	itemElement.appendChild( usernameElement );
 
-	return span;
+	return itemElement;
 }

+ 1 - 1
packages/ckeditor5-mention/docs/_snippets/features/mention.html

@@ -1,3 +1,3 @@
 <div id="snippet-mention">
-	<p>Hello <span class="mention" data-mention="Ted">@Ted</span>.</p>
+	<p>Hello <span class="mention" data-mention="@Ted">@Ted</span>.</p>
 </div>

+ 9 - 7
packages/ckeditor5-mention/docs/_snippets/features/mention.js

@@ -1,6 +1,6 @@
 /**
  * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
- * For licensing, see LICENSE.md.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
  */
 
 /* globals ClassicEditor, console, window, document */
@@ -16,12 +16,14 @@ ClassicEditor
 			],
 			viewportTopOffset: window.getViewportTopOffsetConfig()
 		},
-		mention: [
-			{
-				marker: '@',
-				feed: [ 'Barney', 'Lily', 'Marshall', 'Robin', 'Ted' ]
-			}
-		]
+		mention: {
+			feeds: [
+				{
+					marker: '@',
+					feed: [ '@Barney', '@Lily', '@Marshall', '@Robin', '@Ted' ]
+				}
+			]
+		}
 	} )
 	.then( editor => {
 		window.editor = editor;

+ 2 - 2
packages/ckeditor5-mention/docs/api/mention.md

@@ -10,11 +10,11 @@ This package implements the mention feature for CKEditor 5. This features provid
 
 ## Demo
 
-Check out the {@link features/mention#demo demo in the Mention feature} guide.
+Check out the {@link features/mentions#demo demo in the Mention feature} guide.
 
 ## Documentation
 
-See the {@link features/mention Mention feature} guide and the {@link module:mention/mention~Mention} plugin documentation.
+See the {@link features/mentions Mention feature} guide and the {@link module:mention/mention~Mention} plugin documentation.
 
 ## Installation
 

+ 0 - 323
packages/ckeditor5-mention/docs/features/mention.md

@@ -1,323 +0,0 @@
----
-category: features
----
-
-{@snippet features/build-mention-source}
-
-# 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.
-
-## Demo
-
-You can type `'@'` character to invoke mention auto-complete UI. The below demo is configured as 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. 
-
-```js
-ClassicEditor
-	.create( document.querySelector( '#editor' ), {
-		plugins: [ Mention, ... ],
-		mention: {
-			feeds: [
-				{
-					marker: '@',
-					feed: [ 'Barney', 'Lily', 'Marshall', 'Robin', 'Ted' ],
-					minimumCharacters: 1
-				}
-			}
-		}
-	} )
-	.then( ... )
-	.catch( ... );
-```
-
-Additionally you can configure:
-- How the item is rendered in the auto-complete panel.
-- How the item is converted during the conversion.
-
-### 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.
-
-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}.
-
-<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>
-
-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. 
-</info-box>
-
-```js
-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' }
-];
-
-function getFeedItems( feedText ) {
-	// As an example of asynchronous action return a promise that resolves after a 100ms timeout.
-	return new Promise( resolve => {
-		setTimeout( () => {
-			const itemsToDisplay = items
-				// Filter out the full list of all items to only those matching feedText.
-				.filter( isItemMatching )
-				// Return at most 10 items - notably for generic queries when the list may contain hundreds of elements.
-				.slice( 0, 10 );
-
-			resolve( itemsToDisplay );
-		}, 100 );
-	} );
-
-	// 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();
-
-		// 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 );
-	}
-}
-```
-
-The full working demo with all customization possible is {@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 auto-complete list
-
-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.
-
-```js
-ClassicEditor
-	.create( document.querySelector( '#editor' ), {
-		plugins: [ Mention, ... ],
-		mention: {
-			feeds: [
-				{ 
-					feed: [ ... ],
-					// Define the custom item renderer:
-					itemRenderer: customItemRenderer
-				}
-			]
-		}
-	} )
-	.then( ... )
-	.catch( ... );
-
-function customItemRenderer( item ) {
-	const span = document.createElement( 'span' );
-
-	span.classList.add( 'custom-item' );
-	span.id = `mention-list-item-id-${ item.id }`;
-
-	// Add child nodes to the main span or just set innerHTML.
-	span.innerHTML = `${ item.name } <span class="custom-item-username">@${ item.username }</span>`;
-
-	return span;
-}
-```
-
-The full working demo with all customization possible is {@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.
-
-Below is an example of a plugin that overrides the default output:
-
-```html
-<span data-mention="Ted" class="mention">@Ted</span>
-```
-
-To a link:
-
-```html
-<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 higher then link attribute converter. The mention item in the model must be stored as a plain object with `name` attribute.
-
-```js
-import priorities from '@ckeditor/ckeditor5-utils/src/priorities';
-
-// The link plugin using highest priority in conversion pipeline.
-const HIGHER_THEN_HIGHEST = priorities.highest + 50;
-
-ClassicEditor
-	.create( document.querySelector( '#editor' ), {
-		plugins: [ Mention, CustomMention, ... ],    // Add custom mention plugin function.
-		mention: {
-			// configuration...
-		}
-	} )
-	.then( ... )
-	.catch( ... );
-
-function CustomMention( editor ) {
-	// The upcast converter will convert <a class="mention"> 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 => {
-				// 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:
-				const mentionValue = {
-					// The name attribute is required by mention editing.
-					name: viewItem.getAttribute( 'data-mention' ),
-					// Add any other properties as required.
-					link: viewItem.getAttribute( 'href' ),
-					id: viewItem.getAttribute( 'data-user-id' )
-				};
-
-				return mentionValue;
-			}
-		},
-		converterPriority: HIGHER_THEN_HIGHEST
-	} );
-
-	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:
-	editor.conversion.for( 'downcast' ).attributeToElement( {
-		model: 'mention',
-		view: ( modelAttributeValue, viewWriter ) => {
-			if ( !modelAttributeValue ) {
-				// Do not convert empty attributes.
-				return;
-			}
-
-			return viewWriter.createAttributeElement( 'a', {
-				class: 'mention',
-				'data-mention': modelAttributeValue.name,
-				'data-user-id': modelAttributeValue.id,
-				'href': modelAttributeValue.link
-			} );
-		},
-		converterPriority: HIGHER_THEN_HIGHEST
-	} );
-}
-```
-
-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:
-
-- 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>`.
-
-{@snippet features/mention-customization}
-
-### Colors and styles
-
-#### Using CSS variables
-
-The mention feature is using the power of [CSS variables](https://developer.mozilla.org/en-US/docs/Web/CSS/Using_CSS_variables) which are defined in the [theme lark stylesheet](https://github.com/ckeditor/ckeditor5-theme-lark/blob/master/theme/ckeditor5-mention/mentionediting.css). Thanks to that mention styles can be easily customized:
-
-```css
-:root {
-	/* Make mention background blue. */
-	--ck-color-mention-background: hsla(220, 100%, 54%, 0.4);
-
-    /* Make mention text dark grey. */
-	--ck-color-mention-text: hsl(0, 0%, 15%);
-}
-```
-
-{@snippet features/custom-mention-colors-variables}
-
-## 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):
-
-```js
-import Mention from '@ckeditor/ckeditor5-mention/src/mention';
-
-ClassicEditor
-	.create( document.querySelector( '#editor' ), {
-		plugins: [ Mention, ... ],
-		mention: {
-			// configuration...
-		}
-	} )
-	.then( ... )
-	.catch( ... );
-```
-
-## Common API
-
-The {@link module:mention/mention~Mention} plugin registers:
-* the `'mention'` command implemented by {@link module:mention/mentioncommand~MentionCommand}.
-
-	You can insert a mention element by executing the following code:
-
-	```js
-	editor.execute( 'mention', { marker: '@', mention: 'John' } );
-	```
-
-## Contribute
-
-The source code of the feature is available on GitHub in https://github.com/ckeditor/ckeditor5-mention.

+ 455 - 0
packages/ckeditor5-mention/docs/features/mentions.md

@@ -0,0 +1,455 @@
+---
+category: features
+menu-title: Mentions
+---
+
+{@snippet features/build-mention-source}
+
+# Mentions (autocompletion)
+
+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 the "@" character to invoke mention autocomplete UI. The demo below is configured to suggest a static list of names ("Barney", "Lily", "Marshall", "Robin", and "Ted").
+
+{@snippet features/mention}
+
+## Configuration
+
+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 show up.
+
+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: [
+				{
+					marker: '@',
+					feed: [ '@Barney', '@Lily', '@Marshall', '@Robin', '@Ted' ],
+					minimumCharacters: 1
+				}
+			}
+		}
+	} )
+	.then( ... )
+	.catch( ... );
+```
+
+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 {@link framework/guides/architecture/editing-engine#conversion 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:
+
+* 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.
+
+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 suggestion would load faster.
+
+	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
+ClassicEditor
+	.create( document.querySelector( '#editor' ), {
+		// This feature is not available in any of the builds.
+		// See the "Installation" section.
+		plugins: [ Mention, ... ],
+
+		mention: {
+			feeds: [
+				{
+					marker: '@',
+					feed: getFeedItems
+				}
+			}
+		}
+	} )
+	.then( ... )
+	.catch( ... );
+
+const items = [
+	{ id: '@swarley', userId: '1', name: 'Barney Stinson', link: 'https://www.imdb.com/title/tt0460649/characters/nm0000439' },
+	{ id: '@lilypad', userId: '2', name: 'Lily Aldrin', link: 'https://www.imdb.com/title/tt0460649/characters/nm0004989' },
+	{ id: '@marshmallow', userId: '3', name: 'Marshall Eriksen', link: 'https://www.imdb.com/title/tt0460649/characters/nm0781981' },
+	{ id: '@rsparkles', userId: '4', name: 'Robin Scherbatsky', link: 'https://www.imdb.com/title/tt0460649/characters/nm1130627' },
+	{ id: '@tdog', userId: '5', name: 'Ted Mosby', link: 'https://www.imdb.com/title/tt0460649/characters/nm1102140' }
+];
+
+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 queryText.
+				.filter( isItemMatching )
+				// Return 10 items max - needed for generic queries when the list may contain hundreds of elements.
+				.slice( 0, 10 );
+
+			resolve( itemsToDisplay );
+		}, 100 );
+	} );
+
+	// 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.id.toLowerCase().includes( searchString )
+		);
+	}
+}
+```
+
+A full, working demo with all possible customizations and its source code is available {@link features/mentions#fully-customized-mention-feed at the end of this section}.
+
+### Customizing the autocomplete list
+
+The items displayed in the autocomplete list can be customized by defining the {@link module:mention/mention~MentionFeed `itemRenderer`} callback.
+
+This callback takes a feed item (it contains at least the `name` property) and must return a new DOM element.
+
+```js
+ClassicEditor
+	.create( document.querySelector( '#editor' ), {
+		plugins: [ Mention, ... ],
+		mention: {
+			feeds: [
+				{
+					feed: [ ... ],
+					// Define the custom item renderer:
+					itemRenderer: customItemRenderer
+				}
+			]
+		}
+	} )
+	.then( ... )
+	.catch( ... );
+
+function customItemRenderer( item ) {
+	const itemElement = document.createElement( 'span' );
+
+	itemElement.classList.add( 'custom-item' );
+	itemElement.id = `mention-list-item-id-${ item.userId }`;
+	itemElement.textContent = `${ item.name } `;
+
+	const usernameElement = document.createElement( 'span' );
+
+	usernameElement.classList.add( 'custom-item-username' );
+	usernameElement.textContent = item.id;
+
+	itemElement.appendChild( usernameElement );
+
+	return itemElement;
+}
+```
+
+A full, working demo with all possible customizations and its source code is available {@link features/mentions#fully-customized-mention-feed at the end of this section}.
+
+### Customizing the output
+
+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.
+
+The example below defined a plugin which overrides the default output:
+
+```html
+<span data-mention="@Ted" class="mention">@Ted</span>
+```
+
+To a link:
+
+```html
+<a class="mention" data-mention="@Ted" data-user-id="5" href="https://www.imdb.com/title/tt0460649/characters/nm1102140">@tdog</a>
+```
+
+The converters must be defined with a `'high'` priority to be executed before the {@link features/link link} feature's converter and before the default converter of the mention feature. A mention is stored in the model as a {@link framework/guides/architecture/editing-engine#text-attributes text attribute} which stores an object (see {@link module:mention/mention~MentionFeedItem}).
+
+```js
+ClassicEditor
+	.create( document.querySelector( '#editor' ), {
+		plugins: [ Mention, MentionCustomization, ... ], // Add custom mention plugin function.
+		mention: {
+			// Configuration...
+		}
+	} )
+	.then( ... )
+	.catch( ... );
+
+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',
+			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 with a set of additional attributes.
+				// In order to create a proper object use the toMentionAttribute() helper method:
+				const mentionAttribute = editor.plugins.get( 'Mention' ).toMentionAttribute( viewItem, {
+					// Add any other properties that you need.
+					link: viewItem.getAttribute( 'href' ),
+					userId: viewItem.getAttribute( 'data-user-id' )
+				} );
+
+				return mentionAttribute;
+			}
+		},
+		converterPriority: 'high'
+	} );
+
+	// 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.id,
+				'data-user-id': modelAttributeValue.userId,
+				'href': modelAttributeValue.link
+			} );
+		},
+		converterPriority: 'high'
+	} );
+}
+```
+
+The full working demo with all customization possible is {@link features/mentions#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 a `<span>`.
+
+{@snippet features/mention-customization}
+
+#### Source code
+
+```js
+ClassicEditor
+	.create( document.querySelector( '#snippet-mention-customization' ), {
+		plugins: [ Mention, MentionCustomization, ... ],
+		mention: {
+			feeds: [
+				{
+					marker: '@',
+					feed: getFeedItems,
+					itemRenderer: customItemRenderer
+				}
+			]
+		}
+	} )
+	.then( editor => {
+		window.editor = editor;
+	} )
+	.catch( err => {
+		console.error( err.stack );
+	} );
+
+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 with set of additional attributes.
+				// In order to create proper object use `toMentionAttribute` helper method:
+				const mentionAttribute = editor.plugins.get( 'Mention' ).toMentionAttribute( viewItem, {
+					// Add any other properties that you need.
+					link: viewItem.getAttribute( 'href' ),
+					userId: viewItem.getAttribute( 'data-user-id' )
+				} );
+
+				return mentionAttribute;
+			}
+		},
+		converterPriority: 'high'
+	} );
+
+	// 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 ) {
+				return;
+			}
+
+			return viewWriter.createAttributeElement( 'a', {
+				class: 'mention',
+				'data-mention': modelAttributeValue.id,
+				'data-user-id': modelAttributeValue.userId,
+				'href': modelAttributeValue.link
+			} );
+		},
+		converterPriority: 'high'
+	} );
+}
+
+const items = [
+	{ id: '@swarley', userId: '1', name: 'Barney Stinson', link: 'https://www.imdb.com/title/tt0460649/characters/nm0000439' },
+	{ id: '@lilypad', userId: '2', name: 'Lily Aldrin', link: 'https://www.imdb.com/title/tt0460649/characters/nm0004989' },
+	{ id: '@marshmallow', userId: '3', name: 'Marshall Eriksen', link: 'https://www.imdb.com/title/tt0460649/characters/nm0781981' },
+	{ id: '@rsparkles', userId: '4', name: 'Robin Scherbatsky', link: 'https://www.imdb.com/title/tt0460649/characters/nm1130627' },
+	{ id: '@tdog', userId: '5', name: 'Ted Mosby', link: 'https://www.imdb.com/title/tt0460649/characters/nm1102140' }
+];
+
+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 queryText.
+				.filter( isItemMatching )
+				// Return 10 items max - needed for generic queries when the list may contain hundreds of elements.
+				.slice( 0, 10 );
+
+			resolve( itemsToDisplay );
+		}, 100 );
+	} );
+
+	// 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.id.toLowerCase().includes( searchString )
+		);
+	}
+}
+
+function customItemRenderer( item ) {
+	const itemElement = document.createElement( 'span' );
+
+	itemElement.classList.add( 'custom-item' );
+	itemElement.id = `mention-list-item-id-${ item.userId }`;
+	itemElement.textContent = `${ item.name } `;
+
+	const usernameElement = document.createElement( 'span' );
+
+	usernameElement.classList.add( 'custom-item-username' );
+	usernameElement.textContent = item.id;
+
+	itemElement.appendChild( usernameElement );
+
+	return itemElement;
+}
+```
+
+### Colors and styles
+
+#### Using CSS variables
+
+The mention feature is using the power of [CSS variables](https://developer.mozilla.org/en-US/docs/Web/CSS/Using_CSS_variables) which are defined in the [theme lark stylesheet](https://github.com/ckeditor/ckeditor5-theme-lark/blob/master/theme/ckeditor5-mention/mentionediting.css). Thanks to that mention styles can be {@link framework/guides/theme-customization easily customized}:
+
+```css
+:root {
+	/* Make mention background blue. */
+	--ck-color-mention-background: hsla(220, 100%, 54%, 0.4);
+
+	/* Make mention text dark grey. */
+	--ck-color-mention-text: hsl(0, 0%, 15%);
+}
+```
+
+{@snippet features/custom-mention-colors-variables}
+
+## Installation
+
+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:
+
+```js
+import Mention from '@ckeditor/ckeditor5-mention/src/mention';
+
+ClassicEditor
+	.create( document.querySelector( '#editor' ), {
+		plugins: [ Mention, ... ],
+		mention: {
+			// 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:
+* the `'mention'` command implemented by {@link module:mention/mentioncommand~MentionCommand}.
+
+	You can insert a mention element by executing the following code:
+
+	```js
+	editor.execute( 'mention', { marker: '@', mention: 'John' } );
+	```
+
+<info-box>
+	We recommend using the official {@link framework/guides/development-tools#ckeditor-5-inspector CKEditor 5 inspector} for development and debugging. It will give you tons of useful information about the state of the editor such as internal data structures, selection, commands, and many more.
+</info-box>
+
+## Contribute
+
+The source code of the feature is available on GitHub in https://github.com/ckeditor/ckeditor5-mention.

+ 12 - 16
packages/ckeditor5-mention/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@ckeditor/ckeditor5-mention",
-  "version": "0.0.1",
+  "version": "10.0.0",
   "description": "Mention feature for CKEditor 5.",
   "keywords": [
     "ckeditor",
@@ -10,23 +10,19 @@
     "ckeditor5-plugin"
   ],
   "dependencies": {
-    "@ckeditor/ckeditor5-core": "^12.0.0",
-    "@ckeditor/ckeditor5-ui": "^12.0.0",
-    "@ckeditor/ckeditor5-utils": "^12.0.0"
+    "@ckeditor/ckeditor5-core": "^12.1.0",
+    "@ckeditor/ckeditor5-ui": "^12.1.0",
+    "@ckeditor/ckeditor5-utils": "^12.1.0"
   },
   "devDependencies": {
-    "@ckeditor/ckeditor5-basic-styles": "^11.0.0",
-    "@ckeditor/ckeditor5-clipboard": "^11.0.0",
-    "@ckeditor/ckeditor5-editor-classic": "^12.0.0",
-    "@ckeditor/ckeditor5-engine": "^13.0.0",
-    "@ckeditor/ckeditor5-enter": "^11.0.0",
-    "@ckeditor/ckeditor5-heading": "^11.0.0",
-    "@ckeditor/ckeditor5-link": "^11.0.0",
-    "@ckeditor/ckeditor5-paragraph": "^11.0.0",
-    "@ckeditor/ckeditor5-table": "^12.0.0",
-    "@ckeditor/ckeditor5-typing": "^12.0.0",
-    "@ckeditor/ckeditor5-undo": "^11.0.0",
-    "@ckeditor/ckeditor5-widget": "^11.0.0",
+    "@ckeditor/ckeditor5-basic-styles": "^11.1.0",
+    "@ckeditor/ckeditor5-block-quote": "^11.0.1",
+    "@ckeditor/ckeditor5-clipboard": "^11.0.1",
+    "@ckeditor/ckeditor5-editor-classic": "^12.1.0",
+    "@ckeditor/ckeditor5-engine": "^13.1.0",
+    "@ckeditor/ckeditor5-font": "^11.1.0",
+    "@ckeditor/ckeditor5-paragraph": "^11.0.1",
+    "@ckeditor/ckeditor5-undo": "^11.0.1",
     "eslint": "^5.5.0",
     "eslint-config-ckeditor5": "^1.0.11",
     "husky": "^1.3.1",

+ 150 - 33
packages/ckeditor5-mention/src/mention.js

@@ -1,6 +1,6 @@
 /**
  * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
- * For licensing, see LICENSE.md.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
  */
 
 /**
@@ -9,17 +9,36 @@
 
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 
-import MentionEditing from './mentionediting';
+import MentionEditing, { _toMentionAttribute } from './mentionediting';
 import MentionUI from './mentionui';
 
+import '../theme/mention.css';
+
 /**
  * The mention plugin.
  *
- * For a detailed overview, check the {@glink features/mention Mention feature documentation}.
+ * For a detailed overview, check the {@glink features/mentions Mention feature documentation}.
  *
  * @extends module:core/plugin~Plugin
  */
 export default class Mention extends Plugin {
+	/**
+	 * Creates a mention attribute value from the provided view element and optional data.
+	 *
+	 *		editor.plugins.get( 'Mention' ).toMentionAttribute( viewElement, { userId: '1234' } );
+	 *
+	 *		// for a viewElement: <span data-mention="@joe">@John Doe</span>
+	 *		// it will return:
+	 *		// { id: '@joe', userId: '1234', _uid: '7a7bc7...', _text: '@John Doe' }
+	 *
+	 * @param {module:engine/view/element~Element} viewElement
+	 * @param {String|Object} [data] Additional data to be stored in the mention attribute.
+	 * @returns {module:mention/mention~MentionAttribute}
+	 */
+	toMentionAttribute( viewElement, data ) {
+		return _toMentionAttribute( viewElement, data );
+	}
+
 	/**
 	 * @inheritDoc
 	 */
@@ -45,36 +64,24 @@ export default class Mention extends Plugin {
  */
 
 /**
- * The mention feed descriptor. Used in {@link module:mention/mention~MentionConfig `config.mention`}.
+ * The configuration of the mention feature.
  *
- * See {@link module:mention/mention~MentionConfig} to learn more.
+ * Read more about {@glink features/mentions#configuration configuring the mention feature}.
  *
- *		const mentionFeed = {
- *			marker: '@',
- *			feed: [ 'Alice', 'Bob', ... ]
- *		}
- *
- * @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, {
@@ -83,7 +90,7 @@ export default class Mention extends Plugin {
  *					feeds: [
  *						{
  *							marker: '@',
- *							feed: [ 'Barney', 'Lily', 'Marshall', 'Robin', 'Ted' ]
+ *							feed: [ '@Barney', '@Lily', '@Marshall', '@Robin', '@Ted' ]
  *						},
  *						...
  * 					]
@@ -92,24 +99,134 @@ 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().includes( 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. It must be a single character.
+ * @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 a feed item as a plain object, the `id` property is obligatory. The additional properties
+ * can be used when customizing the mention feature bahavior
+ * (see {@glink features/mentions#customizing-the-autocomplete-list "Customizing the autocomplete list"}
+ * and {@glink features/mentions#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: [
+ *								{
+ *									id: '@Barney',
+ *									fullName: 'Barney Bloom'
+ *								},
+ *								{
+ *									id: '@Lily',
+ *									fullName: 'Lily Smith'
+ *								},
+ *								{
+ *									id: '@Marshall',
+ *									fullName: 'Marshall McDonald'
+ *								},
+ *								{
+ *									id: '@Robin',
+ *									fullName: 'Robin Hood'
+ *								},
+ *								{
+ *									id: '@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}.
+ * @typedef {Object|String} module:mention/mention~MentionFeedItem
+ * @property {String} id Unique id of the mention. It must start with the marker character.
+ * @property {String} [text] Text inserted into the editor when creating a mention.
+ */
+
+/**
+ * Represents mention in the model.
  *
- * @interface MentionConfig
+ * See {@link module:mention/mention~Mention#toMentionAttribute `Mention#toMentionAttribute()`}.
+ *
+ * @interface module:mention/mention~MentionAttribute
+ * @property {String} id Id of a mention – identifies the mention item in mention feed.
+ * @property {String} _uid Internal mention view item id. Should be passed as an `option.id` when using
+ * {@link module:engine/view/downcastwriter~DowncastWriter#createAttributeElement writer.createAttributeElement()}.
+ * @property {String} _text Helper property that holds text of inserted mention. Used for detecting broken mention in the editing area.
  */

+ 72 - 12
packages/ckeditor5-mention/src/mentioncommand.js

@@ -1,6 +1,6 @@
 /**
  * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
- * For licensing, see LICENSE.md.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
  */
 
 /**
@@ -9,7 +9,8 @@
 
 import Command from '@ckeditor/ckeditor5-core/src/command';
 import toMap from '@ckeditor/ckeditor5-utils/src/tomap';
-import uid from '@ckeditor/ckeditor5-utils/src/uid';
+import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
+import { _addMentionAttributes } from './mentionediting';
 
 /**
  * The mention command.
@@ -20,13 +21,28 @@ import uid from '@ckeditor/ckeditor5-utils/src/uid';
  *
  *		const focus = editor.model.document.selection.focus;
  *
+ *		// It will replace one character before selection focus with '#1234' text
+ *		// with mention attribute filled with passed attributes.
  *		editor.execute( 'mention', {
+ *			marker: '#',
  *			mention: {
+ *				id: '#1234',
  *				name: 'Foo',
- *				id: '1234',
  *				title: 'Big Foo'
  *			},
+ *			range: model.createRange( focus, focus.getShiftedBy( -1 ) )
+ *		} );
+ *
+ *		// It will replace one character before selection focus with 'Teh "Big Foo"' text
+ *		// with attribute filled with passed attributes.
+ *		editor.execute( 'mention', {
  *			marker: '#',
+ *			mention: {
+ *				id: '#1234',
+ *				name: 'Foo',
+ *				title: 'Big Foo'
+ *			},
+ *			text: 'The "Big Foo"',
  *			range: model.createRange( focus, focus.getShiftedBy( -1 ) )
  *		} );
  *
@@ -49,7 +65,9 @@ export default class MentionCommand extends Command {
 	 * @param {Object} [options] Options for the executed command.
 	 * @param {Object|String} options.mention Mention object to insert. If passed a string it will be used to create a plain object with
 	 * name attribute equal to passed string.
-	 * @param {String} [options.marker='@'] The mention marker to insert.
+	 * @param {String} options.marker The marker character (e.g. `'@'`).
+	 * @param {String} [options.text] The text of inserted mention. Defaults to full mention string composed from `marker` and
+	 * `mention` string or `mention.id` if object is passed.
 	 * @param {String} [options.range] Range to replace. Note that replace range might be shorter then inserted text with mention attribute.
 	 * @fires execute
 	 */
@@ -58,22 +76,64 @@ export default class MentionCommand extends Command {
 		const document = model.document;
 		const selection = document.selection;
 
-		const marker = options.marker || '@';
+		const mentionData = typeof options.mention == 'string' ? { id: options.mention } : options.mention;
+		const mentionID = mentionData.id;
+
+		const range = options.range || selection.getFirstRange();
 
-		const mention = typeof options.mention == 'string' ? { name: options.mention } : options.mention;
+		const mentionText = options.text || mentionID;
 
-		// Set internal attributes on mention object.
-		mention._id = uid();
-		mention._marker = marker;
+		const mention = _addMentionAttributes( { _text: mentionText, id: mentionID }, mentionData );
 
-		const range = options.range || selection.getFirstRange();
+		if ( options.marker.length != 1 ) {
+			/**
+			 * The marker must be a single character.
+			 *
+			 * Correct markers: `'@'`, `'#'`.
+			 *
+			 * Incorrect markers: `'$$'`, `'[@'`.
+			 *
+			 * See {@link module:mention/mention~MentionConfig}.
+			 *
+			 * @error mentioncommand-incorrect-marker
+			 */
+			throw new CKEditorError( 'mentioncommand-incorrect-marker: The marker must be a single character.' );
+		}
+
+		if ( mentionID.charAt( 0 ) != options.marker ) {
+			/**
+			 * The feed item id must start with the marker character.
+			 *
+			 * Correct mention feed setting:
+			 *
+			 *		mentions: [
+			 *			{
+			 *				marker: '@',
+			 *				feed: [ '@Ann', '@Barney', ... ]
+			 *			}
+			 *		]
+			 *
+			 * Incorrect mention feed setting:
+			 *
+			 *		mentions: [
+			 *			{
+			 *				marker: '@',
+			 *				feed: [ 'Ann', 'Barney', ... ]
+			 *			}
+			 *		]
+			 *
+			 * See {@link module:mention/mention~MentionConfig}.
+			 *
+			 * @error mentioncommand-incorrect-id
+			 */
+			throw new CKEditorError( 'mentioncommand-incorrect-id: The item id must start with the marker character.' );
+		}
 
 		model.change( writer => {
 			const currentAttributes = toMap( selection.getAttributes() );
 			const attributesWithMention = new Map( currentAttributes.entries() );
-			attributesWithMention.set( 'mention', mention );
 
-			const mentionText = `${ marker }${ mention.name }`;
+			attributesWithMention.set( 'mention', mention );
 
 			// Replace range with a text with mention.
 			writer.remove( range );

+ 117 - 45
packages/ckeditor5-mention/src/mentionediting.js

@@ -1,6 +1,6 @@
 /**
  * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
- * For licensing, see LICENSE.md.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
  */
 
 /**
@@ -12,14 +12,12 @@ import uid from '@ckeditor/ckeditor5-utils/src/uid';
 
 import MentionCommand from './mentioncommand';
 
-import '../theme/mentionediting.css';
-
 /**
  * The mention editing feature.
  *
  * It introduces the {@link module:mention/mentioncommand~MentionCommand command} and the `mention`
  * attribute in the {@link module:engine/model/model~Model model} which renders in the {@link module:engine/view/view view}
- * as a `<span class="mention" data-mention="name">`.
+ * as a `<span class="mention" data-mention="@mention">`.
  *
  * @extends module:core/plugin~Plugin
  */
@@ -50,7 +48,7 @@ export default class MentionEditing extends Plugin {
 			},
 			model: {
 				key: 'mention',
-				value: parseMentionViewItemAttributes
+				value: _toMentionAttribute
 			}
 		} );
 
@@ -59,40 +57,45 @@ export default class MentionEditing extends Plugin {
 			view: createViewMentionElement
 		} );
 
-		doc.registerPostFixer( writer => removePartialMentionPostFixer( writer, doc ) );
+		doc.registerPostFixer( writer => removePartialMentionPostFixer( writer, doc, model.schema ) );
+		doc.registerPostFixer( writer => extendAttributeOnMentionPostFixer( writer, doc ) );
 		doc.registerPostFixer( writer => selectionMentionAttributePostFixer( writer, doc ) );
 
 		editor.commands.add( 'mention', new MentionCommand( editor ) );
 	}
 }
 
-// Parses matched view element to mention attribute value.
-//
-// @param {module:engine/view/element} viewElement
-// @returns {Object} Mention attribute value
-function parseMentionViewItemAttributes( viewElement ) {
-	const dataMention = viewElement.getAttribute( 'data-mention' );
-
-	const textNode = viewElement.getChild( 0 );
-
-	// Do not parse empty mentions.
-	if ( !textNode || !textNode.is( 'text' ) ) {
-		return;
-	}
+export function _addMentionAttributes( baseMentionData, data ) {
+	return Object.assign( { _uid: uid() }, baseMentionData, data || {} );
+}
 
-	const mentionString = textNode.data;
+/**
+ * Creates mention attribute value from provided view element and optional data.
+ *
+ * This function is exposed as
+ * {@link module:mention/mention~Mention#toMentionAttribute `editor.plugins.get( 'Mention' ).toMentionAttribute()`}.
+ *
+ * @protected
+ * @param {module:engine/view/element~Element} viewElementOrMention
+ * @param {String|Object} [data] Mention data to be extended.
+ * @return {module:mention/mention~MentionAttribute}
+ */
+export function _toMentionAttribute( viewElementOrMention, data ) {
+	const dataMention = viewElementOrMention.getAttribute( 'data-mention' );
 
-	// Assume that mention is set as marker + mention name.
-	const marker = mentionString.slice( 0, 1 );
-	const name = mentionString.slice( 1 );
+	const textNode = viewElementOrMention.getChild( 0 );
 
-	// Do not upcast partial mentions - might come from copy-paste of partially selected mention.
-	if ( name != dataMention ) {
+	// Do not convert empty mentions.
+	if ( !textNode ) {
 		return;
 	}
 
-	// Set UID for mention to not merge mentions in the same block that are next to each other.
-	return { name: dataMention, _marker: marker, _id: uid() };
+	const baseMentionData = {
+		id: dataMention,
+		_text: textNode.data
+	};
+
+	return _addMentionAttributes( baseMentionData, data );
 }
 
 // Creates mention element from mention data.
@@ -107,11 +110,12 @@ function createViewMentionElement( mention, viewWriter ) {
 
 	const attributes = {
 		class: 'mention',
-		'data-mention': mention.name
+		'data-mention': mention.id
 	};
 
 	const options = {
-		id: mention._id
+		id: mention._uid,
+		priority: 20
 	};
 
 	return viewWriter.createAttributeElement( 'span', attributes, options );
@@ -142,34 +146,69 @@ function selectionMentionAttributePostFixer( writer, doc ) {
 // @param {module:engine/model/writer~Writer} writer
 // @param {module:engine/model/document~Document} doc
 // @returns {Boolean} Returns true if selection was fixed.
-function removePartialMentionPostFixer( writer, doc ) {
+function removePartialMentionPostFixer( writer, doc, schema ) {
 	const changes = doc.differ.getChanges();
 
 	let wasChanged = false;
 
 	for ( const change of changes ) {
-		// Check if user edited part of a mention.
-		if ( change.type == 'insert' || change.type == 'remove' ) {
-			const textNode = change.position.textNode;
+		// Check text node on current position;
+		const position = change.position;
+
+		if ( change.name == '$text' ) {
+			const nodeAfterInsertedTextNode = position.textNode && position.textNode.nextSibling;
+
+			// Check textNode where the change occurred.
+			wasChanged = checkAndFix( position.textNode, writer ) || wasChanged;
+
+			// Occurs on paste occurs inside a text node with mention.
+			wasChanged = checkAndFix( nodeAfterInsertedTextNode, writer ) || wasChanged;
+			wasChanged = checkAndFix( position.nodeBefore, writer ) || wasChanged;
+			wasChanged = checkAndFix( position.nodeAfter, writer ) || wasChanged;
+		}
+
+		// Check text nodes in inserted elements (might occur when splitting paragraph or pasting content inside text with mention).
+		if ( change.name != '$text' && change.type == 'insert' ) {
+			const insertedNode = position.nodeAfter;
 
-			if ( change.name == '$text' && textNode && textNode.hasAttribute( 'mention' ) ) {
-				writer.removeAttribute( 'mention', textNode );
-				wasChanged = true;
+			for ( const item of writer.createRangeIn( insertedNode ).getItems() ) {
+				wasChanged = checkAndFix( item, writer ) || wasChanged;
 			}
 		}
 
-		// Additional check for deleting last character of a text node.
-		if ( change.type == 'remove' ) {
-			const nodeBefore = change.position.nodeBefore;
+		// Inserted inline elements might break mention.
+		if ( change.type == 'insert' && schema.isInline( change.name ) ) {
+			const nodeAfterInserted = position.nodeAfter && position.nodeAfter.nextSibling;
+
+			wasChanged = checkAndFix( position.nodeBefore, writer ) || wasChanged;
+			wasChanged = checkAndFix( nodeAfterInserted, writer ) || wasChanged;
+		}
+	}
+
+	return wasChanged;
+}
+
+// This post-fixer will extend attribute applied on part of a mention so a whole text node of a mention will have added attribute.
+//
+// @param {module:engine/model/writer~Writer} writer
+// @param {module:engine/model/document~Document} doc
+// @returns {Boolean} Returns true if selection was fixed.
+function extendAttributeOnMentionPostFixer( writer, doc ) {
+	const changes = doc.differ.getChanges();
+
+	let wasChanged = false;
 
-			if ( nodeBefore && nodeBefore.hasAttribute( 'mention' ) ) {
-				const text = nodeBefore.data;
-				const mention = nodeBefore.getAttribute( 'mention' );
+	for ( const change of changes ) {
+		if ( change.type === 'attribute' && change.attributeKey != 'mention' ) {
+			// Check node at the left side of a range...
+			const nodeBefore = change.range.start.nodeBefore;
+			// ... and on right side of range.
+			const nodeAfter = change.range.end.nodeAfter;
 
-				const expectedText = mention._marker + mention.name;
+			for ( const node of [ nodeBefore, nodeAfter ] ) {
+				if ( isBrokenMentionNode( node ) && node.getAttribute( change.attributeKey ) != change.attributeNewValue ) {
+					writer.setAttribute( change.attributeKey, change.attributeNewValue, node );
 
-				if ( text != expectedText ) {
-					writer.removeAttribute( 'mention', nodeBefore );
 					wasChanged = true;
 				}
 			}
@@ -178,3 +217,36 @@ function removePartialMentionPostFixer( writer, doc ) {
 
 	return wasChanged;
 }
+
+// Checks if node has correct mention attribute if present.
+// Returns true if node is text and has a mention attribute which text does not match expected mention text.
+//
+// @param {module:engine/model/node~Node} node a node to check
+// @returns {Boolean}
+function isBrokenMentionNode( node ) {
+	if ( !node || !( node.is( 'text' ) || node.is( 'textProxy' ) ) || !node.hasAttribute( 'mention' ) ) {
+		return false;
+	}
+
+	const text = node.data;
+	const mention = node.getAttribute( 'mention' );
+
+	const expectedText = mention._text;
+
+	return text != expectedText;
+}
+
+// Fixes mention on text node it needs a fix.
+//
+// @param {module:engine/model/text~Text} textNode
+// @param {module:engine/model/writer~Writer} writer
+// @returns {Boolean}
+function checkAndFix( textNode, writer ) {
+	if ( isBrokenMentionNode( textNode ) ) {
+		writer.removeAttribute( 'mention', textNode );
+
+		return true;
+	}
+
+	return false;
+}

+ 129 - 38
packages/ckeditor5-mention/src/mentionui.js

@@ -1,6 +1,6 @@
 /**
  * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
- * For licensing, see LICENSE.md.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
  */
 
 /**
@@ -14,6 +14,7 @@ import BalloonPanelView from '@ckeditor/ckeditor5-ui/src/panel/balloon/balloonpa
 import clickOutsideHandler from '@ckeditor/ckeditor5-ui/src/bindings/clickoutsidehandler';
 import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard';
 import Rect from '@ckeditor/ckeditor5-utils/src/dom/rect';
+import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 
 import TextWatcher from './textwatcher';
 
@@ -21,7 +22,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 +92,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' );
@@ -109,7 +110,23 @@ export default class MentionUI extends Plugin {
 		for ( const mentionDescription of feeds ) {
 			const feed = mentionDescription.feed;
 
-			const marker = mentionDescription.marker || '@';
+			const marker = mentionDescription.marker;
+
+			if ( !marker || marker.length != 1 ) {
+				/**
+				 * The marker must be a single character.
+				 *
+				 * Correct markers: `'@'`, `'#'`.
+				 *
+				 * Incorrect markers: `'$$'`, `'[@'`.
+				 *
+				 * See {@link module:mention/mention~MentionConfig}.
+				 *
+				 * @error mentionconfig-incorrect-marker
+				 */
+				throw new CKEditorError( 'mentionconfig-incorrect-marker: The marker must be provided and be a single character.' );
+			}
+
 			const minimumCharacters = mentionDescription.minimumCharacters || 0;
 			const feedCallback = typeof feed == 'function' ? feed : createFeedCallback( feed );
 			const watcher = this._setupTextWatcherForFeed( marker, minimumCharacters );
@@ -163,7 +180,7 @@ export default class MentionUI extends Plugin {
 
 		this.panelView.content.add( mentionsView );
 
-		mentionsView.listView.items.bindTo( this._items ).using( data => {
+		mentionsView.items.bindTo( this._items ).using( data => {
 			const { item, marker } = data;
 
 			const listItemView = new MentionListItemView( locale );
@@ -205,10 +222,11 @@ 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,
+				text: item.text,
 				marker,
 				range
 			} );
@@ -273,26 +291,46 @@ 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();
 
-					for ( const name of feed ) {
-						const item = typeof name != 'object' ? { name } : name;
+					for ( const feedItem of feed ) {
+						const item = typeof feedItem != 'object' ? { id: feedItem, text: feedItem } : feedItem;
 
 						this._items.add( { item, marker } );
 					}
 
 					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 +354,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();
 	}
 
@@ -344,17 +388,24 @@ export default class MentionUI extends Plugin {
 		const editor = this.editor;
 
 		let view;
+		let label = item.id;
 
 		const renderer = this._getItemRenderer( marker );
 
 		if ( renderer ) {
-			const domNode = renderer( item );
+			const renderResult = renderer( item );
+
+			if ( typeof renderResult != 'string' ) {
+				view = new DomWrapperView( editor.locale, renderResult );
+			} else {
+				label = renderResult;
+			}
+		}
 
-			view = new DomWrapperView( editor.locale, domNode );
-		} else {
+		if ( !view ) {
 			const buttonView = new ButtonView( editor.locale );
 
-			buttonView.label = item.name;
+			buttonView.label = label;
 			buttonView.withText = true;
 
 			view = buttonView;
@@ -364,22 +415,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 +455,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 +467,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 +476,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 +485,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
 	];
 }
 
@@ -435,7 +518,7 @@ function getBalloonPanelPositions() {
 function createPattern( marker, minimumCharacters ) {
 	const numberOfCharacters = minimumCharacters == 0 ? '*' : `{${ minimumCharacters },}`;
 
-	return `(^| )(${ marker })([_a-zA-Z0-9À-ž]${ numberOfCharacters }?)$`;
+	return `(^| )(\\${ marker })([_a-zA-Z0-9À-ž]${ numberOfCharacters }?)$`;
 }
 
 // Creates a test callback for marker to be used in text watcher instance.
@@ -469,9 +552,17 @@ function createTextMatcher( marker ) {
 // Default feed callback
 function createFeedCallback( feedItems ) {
 	return feedText => {
-		const filteredItems = feedItems.filter( item => {
-			return item.toLowerCase().includes( feedText.toLowerCase() );
-		} );
+		const filteredItems = feedItems
+		// Make default mention feed case-insensitive.
+			.filter( item => {
+				// Item might be defined as object.
+				const itemId = typeof item == 'string' ? item : String( item.id );
+
+				// The default feed is case insensitive.
+				return itemId.toLowerCase().includes( feedText.toLowerCase() );
+			} )
+			// Do not return more than 10 items.
+			.slice( 0, 10 );
 
 		return Promise.resolve( filteredItems );
 	};

+ 57 - 33
packages/ckeditor5-mention/src/textwatcher.js

@@ -1,6 +1,6 @@
 /**
  * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
- * For licensing, see LICENSE.md.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
  */
 
 /**
@@ -12,6 +12,10 @@ import EmitterMixin from '@ckeditor/ckeditor5-utils/src/emittermixin';
 
 /**
  * Text watcher feature.
+ *
+ * Fires {@link module:mention/textwatcher~TextWatcher#event:matched matched} and
+ * {@link module:mention/textwatcher~TextWatcher#event:unmatched unmatched} events on typing or selection changes.
+ *
  * @private
  */
 export default class TextWatcher {
@@ -48,39 +52,58 @@ export default class TextWatcher {
 	_startListening() {
 		const editor = this.editor;
 
-		editor.model.document.on( 'change', ( evt, batch ) => {
-			if ( batch.type == 'transparent' ) {
+		editor.model.document.selection.on( 'change', ( evt, { directChange } ) => {
+			// The indirect changes (ie on typing) are handled in document's change event.
+			if ( !directChange ) {
 				return;
 			}
 
-			const changes = Array.from( editor.model.document.differ.getChanges() );
-			const entry = changes[ 0 ];
-
-			// 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;
+			this._evaluateTextBeforeSelection();
+		} );
 
-			if ( !isTypingChange && !isSelectionChange ) {
-				return;
+		editor.model.document.on( 'change:data', ( evt, batch ) => {
+			if ( batch.type == 'transparent' ) {
+				return false;
 			}
 
-			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 } );
+		}
 	}
 
 	/**
@@ -100,18 +123,19 @@ export default class TextWatcher {
 
 		const block = selection.focus.parent;
 
-		return getText( block ).slice( 0, selection.focus.offset );
+		return _getText( editor.model.createRangeIn( block ) ).slice( 0, selection.focus.offset );
 	}
 }
 
-// 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}
-function getText( element ) {
-	return Array.from( element.getChildren() ).reduce( ( a, b ) => a + b.data, '' );
+/**
+ * Returns whole text from given range by adding all data from text nodes together.
+ *
+ * @protected
+ * @param {module:engine/model/range~Range} range
+ * @returns {String}
+ */
+export function _getText( range ) {
+	return Array.from( range.getItems() ).reduce( ( a, b ) => a + b.data, '' );
 }
 
 mix( TextWatcher, EmitterMixin );

+ 1 - 1
packages/ckeditor5-mention/src/ui/domwrapperview.js

@@ -1,6 +1,6 @@
 /**
  * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
- * For licensing, see LICENSE.md.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
  */
 
 /**

+ 1 - 1
packages/ckeditor5-mention/src/ui/mentionlistitemview.js

@@ -1,6 +1,6 @@
 /**
  * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
- * For licensing, see LICENSE.md.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
  */
 
 /**

+ 56 - 23
packages/ckeditor5-mention/src/ui/mentionsview.js

@@ -1,76 +1,97 @@
 /**
  * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
- * For licensing, see LICENSE.md.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
  */
 
 /**
  * @module mention/ui/mentionsview
  */
 
-import View from '@ckeditor/ckeditor5-ui/src/view';
 import ListView from '@ckeditor/ckeditor5-ui/src/list/listview';
+import Rect from '@ckeditor/ckeditor5-utils/src/dom/rect';
+
+import '../../theme/mentionui.css';
 
 /**
  * The mention ui view.
  *
- * @extends module:ui/view~View
+ * @extends module:ui/list/listview~ListView
  */
-export default class MentionsView extends View {
+export default class MentionsView extends ListView {
+	/**
+	 * @inheritDoc
+	 */
 	constructor( locale ) {
 		super( locale );
 
-		this.listView = new ListView( locale );
-
-		this.setTemplate( {
-			tag: 'div',
-
+		this.extendTemplate( {
 			attributes: {
 				class: [
-					'ck',
-					'ck-mention'
+					'ck-mentions'
 				],
 
 				tabindex: '-1'
-			},
-
-			children: [
-				this.listView
-			]
+			}
 		} );
 	}
 
+	/**
+	 * {@link #select Selects} the first item.
+	 */
 	selectFirst() {
 		this.select( 0 );
 	}
 
+	/**
+	 * Selects next item to the currently {@link #select selected}.
+	 *
+	 * If the last item is already selected, it will select the first item.
+	 */
 	selectNext() {
 		const item = this.selected;
-
-		const index = this.listView.items.getIndex( item );
+		const index = this.items.getIndex( item );
 
 		this.select( index + 1 );
 	}
 
+	/**
+	 * Selects previous item to the currently {@link #select selected}.
+	 *
+	 * If the first item is already selected, it will select the last item.
+	 */
 	selectPrevious() {
 		const item = this.selected;
-
-		const index = this.listView.items.getIndex( item );
+		const index = this.items.getIndex( item );
 
 		this.select( index - 1 );
 	}
 
+	/**
+	 * Marks item at a given index as selected.
+	 *
+	 * Handles selection cycling when passed index is out of bounds:
+	 * - if the index is lower than 0, it will select the last item,
+	 * - if the index is higher than the last item index, it will select the first item.
+	 *
+	 * @param {Number} index Index of an item to be marked as selected.
+	 */
 	select( index ) {
 		let indexToGet = 0;
 
-		if ( index > 0 && index < this.listView.items.length ) {
+		if ( index > 0 && index < this.items.length ) {
 			indexToGet = index;
 		} else if ( index < 0 ) {
-			indexToGet = this.listView.items.length - 1;
+			indexToGet = this.items.length - 1;
 		}
 
-		const item = this.listView.items.get( indexToGet );
+		const item = this.items.get( indexToGet );
 		item.highlight();
 
+		// Scroll the mentions view to the selected element.
+		if ( !this._isItemVisibleInScrolledArea( item ) ) {
+			this.element.scrollTop = item.element.offsetTop;
+		}
+
 		if ( this.selected ) {
 			this.selected.removeHighlight();
 		}
@@ -78,7 +99,19 @@ export default class MentionsView extends View {
 		this.selected = item;
 	}
 
+	/**
+	 * Triggers the `execute` event on the {@link #select selected} item.
+	 */
 	executeSelected() {
 		this.selected.fire( 'execute' );
 	}
+
+	// Checks if an item is visible in the scrollable area.
+	//
+	// The item is considered visible when:
+	// - its top boundary is inside the scrollable rect
+	// - its bottom boundary is inside the scrollable rect (the whole item must be visible)
+	_isItemVisibleInScrolledArea( item ) {
+		return new Rect( this.element ).contains( new Rect( item.element ) );
+	}
 }

+ 35 - 28
packages/ckeditor5-mention/tests/manual/mention-custom-renderer.js

@@ -1,46 +1,53 @@
 /**
  * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
- * For licensing, see LICENSE.md.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
  */
 
-/* global console, window */
-
-import global from '@ckeditor/ckeditor5-utils/src/dom/global';
+/* global console, window, document */
 
 import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
-import Bold from '@ckeditor/ckeditor5-basic-styles/src/bold';
-import Enter from '@ckeditor/ckeditor5-enter/src/enter';
-import Heading from '@ckeditor/ckeditor5-heading/src/heading';
-import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
-import Typing from '@ckeditor/ckeditor5-typing/src/typing';
-import Undo from '@ckeditor/ckeditor5-undo/src/undo';
-import Widget from '@ckeditor/ckeditor5-widget/src/widget';
-import Clipboard from '@ckeditor/ckeditor5-clipboard/src/clipboard';
-import ShiftEnter from '@ckeditor/ckeditor5-enter/src/shiftenter';
-import Table from '@ckeditor/ckeditor5-table/src/table';
 import Mention from '../../src/mention';
-import Link from '@ckeditor/ckeditor5-link/src/link';
-import Italic from '@ckeditor/ckeditor5-basic-styles/src/italic';
 import Underline from '@ckeditor/ckeditor5-basic-styles/src/underline';
+import ArticlePluginSet from '@ckeditor/ckeditor5-core/tests/_utils/articlepluginset';
+import Font from '@ckeditor/ckeditor5-font/src/font';
 
 ClassicEditor
-	.create( global.document.querySelector( '#editor' ), {
-		plugins: [ Enter, Typing, Paragraph, Heading, Link, Bold, Italic, Underline, Undo, Clipboard, Widget, ShiftEnter, Table, Mention ],
-		toolbar: [ 'heading', '|', 'bold', 'italic', 'underline', 'link', '|', 'insertTable', '|', 'undo', 'redo' ],
+	.create( document.querySelector( '#editor' ), {
+		plugins: [ ArticlePluginSet, Underline, Font, Mention ],
+		toolbar: [
+			'heading',
+			'|', 'bulletedList', 'numberedList', 'blockQuote',
+			'|', 'bold', 'italic', 'underline', 'link',
+			'|', 'fontFamily', 'fontSize', 'fontColor', 'fontBackgroundColor',
+			'|', 'insertTable',
+			'|', 'undo', 'redo'
+		],
 		mention: {
 			feeds: [
 				{
+					marker: '@',
 					feed: getFeed,
 					itemRenderer: item => {
-						const span = global.document.createElementNS( 'http://www.w3.org/1999/xhtml', 'span' );
+						const span = document.createElement( 'span' );
 
 						span.classList.add( 'custom-item' );
-						span.id = `mention-list-item-id-${ item.id }`;
+						span.id = `mention-list-item-id-${ item.itemId }`;
 
-						span.innerHTML = `${ item.name } <span class="custom-item-username">@${ item.username }</span>`;
+						span.innerHTML = `${ item.name } <span class="custom-item-username">${ item.id }</span>`;
 
 						return span;
 					}
+				},
+				{
+					marker: '#',
+					feed: [
+						{ id: '#1002', text: 'Some bug in editor' },
+						{ id: '#1003', text: 'Introduce this feature' },
+						{ id: '#1004', text: 'Missing docs' },
+						{ id: '#1005', text: 'Another bug' },
+						{ id: '#1006', text: 'More bugs' }
+					],
+					itemRenderer: item => `Issue ${ item.id }: ${ item.text }`
 				}
 			]
 		}
@@ -54,14 +61,14 @@ ClassicEditor
 
 function getFeed( feedText ) {
 	return Promise.resolve( [
-		{ id: '1', name: 'Barney Stinson', username: 'swarley' },
-		{ id: '2', name: 'Lily Aldrin', username: 'lilypad' },
-		{ id: '3', name: 'Marshall Eriksen', username: 'marshmallow' },
-		{ id: '4', name: 'Robin Scherbatsky', username: 'rsparkles' },
-		{ id: '5', name: 'Ted Mosby', username: 'tdog' }
+		{ itemId: '1', name: 'Barney Stinson', id: '@swarley' },
+		{ itemId: '2', name: 'Lily Aldrin', id: '@lilypad' },
+		{ itemId: '3', name: 'Marshall Eriksen', id: '@marshmallow' },
+		{ itemId: '4', name: 'Robin Scherbatsky', id: '@rsparkles' },
+		{ itemId: '5', name: 'Ted Mosby', id: '@tdog' }
 	].filter( item => {
 		const searchString = feedText.toLowerCase();
 
-		return item.name.toLowerCase().includes( searchString ) || item.username.toLowerCase().includes( searchString );
+		return item.name.toLowerCase().includes( searchString ) || item.id.toLowerCase().includes( searchString );
 	} ) );
 }

+ 17 - 8
packages/ckeditor5-mention/tests/manual/mention-custom-renderer.md

@@ -4,16 +4,25 @@ The mention configuration with custom item renderer for autocomplete list.
 
 ### Configuration
 
-The list is returned in promise (no timeout) and is filtered for any match of `name` and `username` (custom feed):
+The feeds:
 
-The feed:
-- `{ id: '1', name: 'Barney Stinson', username: 'swarley' }`
-- `{ id: '2', name: 'Lily Aldrin', username: 'lilypad' }`
-- `{ id: '3', name: 'Marshall Eriksen', username: 'marshmallow' }`
-- `{ id: '4', name: 'Robin Scherbatsky', username: 'rsparkles' }`
-- `{ id: '5', name: 'Ted Mosby', username: 'tdog' }`
+1. The list is returned in promise (no timeout) and is filtered for any match of `name` and `username` (custom feed):
+    - `{ itemId: '1', name: 'Barney Stinson', id: '@swarley' }`
+    - `{ itemId: '2', name: 'Lily Aldrin', id: '@lilypad' }`
+    - `{ itemId: '3', name: 'Marshall Eriksen', id: '@marshmallow' }`
+    - `{ itemId: '4', name: 'Robin Scherbatsky', id: '@rsparkles' }`
+    - `{ itemId: '5', name: 'Ted Mosby', id: '@tdog' }`
 
-The item is rendered as `<span>` instead of default button.
+    The items are rendered as `<span>` instead of default button.
+
+2. Static list of issues with item renderer that returns a string:
+    - `{ id: '1002', text: 'Some bug in editor' }`
+    - `{ id: '1003', text: 'Introduce this feature' }`
+    - `{ id: '1004', text: 'Missing docs' }`
+    - `{ id: '1005', text: 'Another bug' }`
+    - `{ id: '1006', text: 'More bugs' }`
+
+    This feed will create mention with `text` (as it is defined for item) instead of `id`.
 
 ### Interaction
 

+ 2 - 2
packages/ckeditor5-mention/tests/manual/mention-custom-view.html

@@ -1,7 +1,7 @@
 <div id="editor">
-	<p>Have you met... <a class="mention" data-mention="Ted Mosby" href="https://www.imdb.com/title/tt0460649/characters/nm1102140">@Ted Mosby</a></p>
+	<p>Have you met... <a class="mention" data-mention="@Ted Mosby" href="https://www.imdb.com/title/tt0460649/characters/nm1102140">Ted Mosby</a></p>
 
-	<p>Same mention twice in data: <a class="mention" data-mention="Ted Mosby" href="https://www.imdb.com/title/tt0460649/characters/nm1102140">@Ted Mosby</a><a class="mention" data-mention="Ted Mosby" href="https://www.imdb.com/title/tt0460649/characters/nm1102140">@Ted Mosby</a></p>
+	<p>Same mention twice in data: <a class="mention" data-mention="@Ted Mosby" href="https://www.imdb.com/title/tt0460649/characters/nm1102140">Ted Mosby</a><a class="mention" data-mention="@Ted Mosby" href="https://www.imdb.com/title/tt0460649/characters/nm1102140">Ted Mosby</a></p>
 </div>
 
 <style>

+ 29 - 37
packages/ckeditor5-mention/tests/manual/mention-custom-view.js

@@ -1,6 +1,6 @@
 /**
  * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
- * For licensing, see LICENSE.md.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
  */
 
 /* global console, window */
@@ -8,26 +8,12 @@
 import global from '@ckeditor/ckeditor5-utils/src/dom/global';
 
 import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
-import Bold from '@ckeditor/ckeditor5-basic-styles/src/bold';
-import Enter from '@ckeditor/ckeditor5-enter/src/enter';
-import Heading from '@ckeditor/ckeditor5-heading/src/heading';
-import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
-import Typing from '@ckeditor/ckeditor5-typing/src/typing';
-import Undo from '@ckeditor/ckeditor5-undo/src/undo';
-import Widget from '@ckeditor/ckeditor5-widget/src/widget';
-import Clipboard from '@ckeditor/ckeditor5-clipboard/src/clipboard';
-import ShiftEnter from '@ckeditor/ckeditor5-enter/src/shiftenter';
-import Table from '@ckeditor/ckeditor5-table/src/table';
-import Link from '@ckeditor/ckeditor5-link/src/link';
-import Italic from '@ckeditor/ckeditor5-basic-styles/src/italic';
 import Underline from '@ckeditor/ckeditor5-basic-styles/src/underline';
+import ArticlePluginSet from '@ckeditor/ckeditor5-core/tests/_utils/articlepluginset';
+import Font from '@ckeditor/ckeditor5-font/src/font';
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
-import priorities from '@ckeditor/ckeditor5-utils/src/priorities';
 
-import MentionUI from '../../src/mentionui';
-import MentionEditing from '../../src/mentionediting';
-
-const HIGHER_THEN_HIGHEST = priorities.highest + 50;
+import Mention from '../../src/mention';
 
 class CustomMentionAttributeView extends Plugin {
 	init() {
@@ -45,15 +31,12 @@ class CustomMentionAttributeView extends Plugin {
 			model: {
 				key: 'mention',
 				value: viewItem => {
-					const mentionValue = {
-						name: viewItem.getAttribute( 'data-mention' ),
+					return editor.plugins.get( 'Mention' ).toMentionAttribute( viewItem, {
 						link: viewItem.getAttribute( 'href' )
-					};
-
-					return mentionValue;
+					} );
 				}
 			},
-			converterPriority: HIGHER_THEN_HIGHEST
+			converterPriority: 'high'
 		} );
 
 		editor.conversion.for( 'downcast' ).attributeToElement( {
@@ -65,23 +48,32 @@ class CustomMentionAttributeView extends Plugin {
 
 				return viewWriter.createAttributeElement( 'a', {
 					class: 'mention',
-					'data-mention': modelAttributeValue.name,
+					'data-mention': modelAttributeValue.id,
 					'href': modelAttributeValue.link
-				} );
+				}, { id: modelAttributeValue._uid } );
 			},
-			converterPriority: HIGHER_THEN_HIGHEST
+			converterPriority: 'high'
 		} );
 	}
 }
 
 ClassicEditor
 	.create( global.document.querySelector( '#editor' ), {
-		plugins: [ Enter, Typing, Paragraph, Link, Heading, Bold, Italic, Underline, Undo, Clipboard, Widget, ShiftEnter, Table,
-			MentionEditing, CustomMentionAttributeView, MentionUI ],
-		toolbar: [ 'heading', '|', 'bold', 'italic', 'underline', 'link', '|', 'insertTable', '|', 'undo', 'redo' ],
+		plugins: [ ArticlePluginSet, Underline, Font, Mention, CustomMentionAttributeView ],
+		toolbar: [
+			'heading',
+			'|', 'bulletedList', 'numberedList', 'blockQuote',
+			'|', 'bold', 'italic', 'underline', 'link',
+			'|', 'fontFamily', 'fontSize', 'fontColor', 'fontBackgroundColor',
+			'|', 'insertTable',
+			'|', 'undo', 'redo'
+		],
 		mention: {
 			feeds: [
-				{ feed: getFeed }
+				{
+					marker: '@',
+					feed: getFeed
+				}
 			]
 		}
 	} )
@@ -94,14 +86,14 @@ ClassicEditor
 
 function getFeed( feedText ) {
 	return [
-		{ id: '1', name: 'Barney Stinson', link: 'https://www.imdb.com/title/tt0460649/characters/nm0000439' },
-		{ id: '2', name: 'Lily Aldrin', link: 'https://www.imdb.com/title/tt0460649/characters/nm0004989' },
-		{ id: '3', name: 'Marshall Eriksen', link: 'https://www.imdb.com/title/tt0460649/characters/nm0781981' },
-		{ id: '4', name: 'Robin Scherbatsky', link: 'https://www.imdb.com/title/tt0460649/characters/nm1130627' },
-		{ id: '5', name: 'Ted Mosby', link: 'https://www.imdb.com/title/tt0460649/characters/nm1102140' }
+		{ id: '@Barney Stinson', text: 'Barney Stinson', link: 'https://www.imdb.com/title/tt0460649/characters/nm0000439' },
+		{ id: '@Lily Aldrin', text: 'Lily Aldrin', link: 'https://www.imdb.com/title/tt0460649/characters/nm0004989' },
+		{ id: '@Marshall Eriksen', text: 'Marshall Eriksen', link: 'https://www.imdb.com/title/tt0460649/characters/nm0781981' },
+		{ id: '@Robin Scherbatsky', text: 'Robin Scherbatsky', link: 'https://www.imdb.com/title/tt0460649/characters/nm1130627' },
+		{ id: '@Ted Mosby', text: 'Ted Mosby', link: 'https://www.imdb.com/title/tt0460649/characters/nm1102140' }
 	].filter( item => {
 		const searchString = feedText.toLowerCase();
 
-		return item.name.toLowerCase().includes( searchString );
+		return item.id.toLowerCase().includes( searchString );
 	} );
 }

+ 5 - 5
packages/ckeditor5-mention/tests/manual/mention-custom-view.md

@@ -5,11 +5,11 @@ This sample overrides default mention conversion to `<span>`. The mention is con
 ### Configuration
 
 The feed:
-- `{ name: 'Barney Stinson', link: 'https://www.imdb.com/title/tt0460649/characters/nm0000439' }`
-- `{ name: 'Lily Aldrin', link: 'https://www.imdb.com/title/tt0460649/characters/nm0004989?ref_=tt_cl_t5' }`
-- `{ name: 'Marshall Eriksen', link: 'https://www.imdb.com/title/tt0460649/characters/nm0781981' }`
-- `{ name: 'Robin Scherbatsky', link: 'https://www.imdb.com/title/tt0460649/characters/nm1130627' }`
-- `{ name: 'Ted Mosby', link: 'https://www.imdb.com/title/tt0460649/characters/nm1102140' }`
+- `{ id: '@Barney Stinson', text: 'Barney Stinson', link: 'https://www.imdb.com/title/tt0460649/characters/nm0000439' }`
+- `{ id: '@Lily Aldrin', text: 'Lily Aldrin', link: 'https://www.imdb.com/title/tt0460649/characters/nm0004989?ref_=tt_cl_t5' }`
+- `{ id: '@Marshall Eriksen', text: 'Marshall Eriksen', link: 'https://www.imdb.com/title/tt0460649/characters/nm0781981' }`
+- `{ id: '@Robin Scherbatsky', text: 'Robin Scherbatsky', link: 'https://www.imdb.com/title/tt0460649/characters/nm1130627' }`
+- `{ id: '@Ted Mosby', text: 'Ted Mosby', link: 'https://www.imdb.com/title/tt0460649/characters/nm1102140' }`
 
 ### Interaction
 

+ 7 - 2
packages/ckeditor5-mention/tests/manual/mention.html

@@ -1,4 +1,9 @@
 <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">@Ted</span>.</p>
+	<p>Hello <span class="mention" data-mention="@Ted">@Ted</span><span class="mention" data-mention="@Ted">@Ted</span>.</p>
+
+	<figure class="image">
+		<img src="sample.jpg" />
+		<figcaption>CKEditor logo - caption</figcaption>
+	</figure>
 </div>

+ 26 - 16
packages/ckeditor5-mention/tests/manual/mention.js

@@ -1,6 +1,6 @@
 /**
  * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
- * For licensing, see LICENSE.md.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
  */
 
 /* global console, window */
@@ -8,28 +8,38 @@
 import global from '@ckeditor/ckeditor5-utils/src/dom/global';
 
 import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
-import Bold from '@ckeditor/ckeditor5-basic-styles/src/bold';
-import Enter from '@ckeditor/ckeditor5-enter/src/enter';
-import Heading from '@ckeditor/ckeditor5-heading/src/heading';
-import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
-import Typing from '@ckeditor/ckeditor5-typing/src/typing';
-import Undo from '@ckeditor/ckeditor5-undo/src/undo';
-import Widget from '@ckeditor/ckeditor5-widget/src/widget';
-import Clipboard from '@ckeditor/ckeditor5-clipboard/src/clipboard';
-import ShiftEnter from '@ckeditor/ckeditor5-enter/src/shiftenter';
-import Table from '@ckeditor/ckeditor5-table/src/table';
 import Mention from '../../src/mention';
-import Link from '@ckeditor/ckeditor5-link/src/link';
-import Italic from '@ckeditor/ckeditor5-basic-styles/src/italic';
 import Underline from '@ckeditor/ckeditor5-basic-styles/src/underline';
+import ArticlePluginSet from '@ckeditor/ckeditor5-core/tests/_utils/articlepluginset';
+import Font from '@ckeditor/ckeditor5-font/src/font';
 
 ClassicEditor
 	.create( global.document.querySelector( '#editor' ), {
-		plugins: [ Enter, Typing, Paragraph, Heading, Link, Bold, Italic, Underline, Undo, Clipboard, Widget, ShiftEnter, Table, Mention ],
-		toolbar: [ 'heading', '|', 'bold', 'italic', 'underline', 'link', '|', 'insertTable', '|', 'undo', 'redo' ],
+		plugins: [ ArticlePluginSet, Underline, Font, Mention ],
+		toolbar: [
+			'heading',
+			'|', 'bulletedList', 'numberedList', 'blockQuote',
+			'|', 'bold', 'italic', 'underline', 'link',
+			'|', 'fontFamily', 'fontSize', 'fontColor', 'fontBackgroundColor',
+			'|', 'insertTable',
+			'|', 'undo', 'redo'
+		],
+		image: {
+			toolbar: [ 'imageStyle:full', 'imageStyle:side', '|', 'imageTextAlternative' ]
+		},
 		mention: {
 			feeds: [
-				{ feed: [ 'Barney', 'Lily', 'Marshall', 'Robin', 'Ted' ] },
+				{
+					marker: '@',
+					feed: [ '@Barney', '@Lily', '@Marshall', '@Robin', '@Ted' ]
+				},
+				{
+					marker: '#',
+					feed: [
+						'#a01', '#a02', '#a03', '#a04', '#a05', '#a06', '#a07', '#a08', '#a09', '#a10',
+						'#a11', '#a12', '#a13', '#a14', '#a15', '#a16', '#a17', '#a18', '#a19', '#a20'
+					]
+				}
 			]
 		}
 	} )

+ 15 - 6
packages/ckeditor5-mention/tests/manual/mention.md

@@ -4,13 +4,22 @@ The minimal mention configuration with a static list of autocomplete feed:
 
 ### Configuration
 
-The feed:
+The feeds:
 
-- Barney
-- Lily
-- Marshall
-- Robin
-- Ted
+1. Static list with `@` marker:
+
+    - Barney
+    - Lily
+    - Marshall
+    - Robin
+    - Ted
+
+2. Static list of 20 items (`#` marker)
+
+    - a01
+    - a02
+    - ...
+    - a20
 
 ### Interaction
 

BIN
packages/ckeditor5-mention/tests/manual/sample.jpg


+ 144 - 22
packages/ckeditor5-mention/tests/mention-integration.js

@@ -1,19 +1,22 @@
 /**
  * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
- * For licensing, see LICENSE.md.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
  */
 
 /* global document */
 
-import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
-import { getData as getViewData } from '@ckeditor/ckeditor5-engine/src/dev-utils/view';
+import BlockQuote from '@ckeditor/ckeditor5-block-quote/src/blockquote';
+import Clipboard from '@ckeditor/ckeditor5-clipboard/src/clipboard';
 import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
 import UndoEditing from '@ckeditor/ckeditor5-undo/src/undoediting';
 import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor';
 
+import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
+import { parse as parseView, getData as getViewData } from '@ckeditor/ckeditor5-engine/src/dev-utils/view';
+
 import MentionEditing from '../src/mentionediting';
 
-describe( 'MentionEditing - integration', () => {
+describe( 'Mention feature - integration', () => {
 	let div, editor, model, doc;
 
 	testUtils.createSinonSandbox();
@@ -21,13 +24,6 @@ describe( 'MentionEditing - integration', () => {
 	beforeEach( () => {
 		div = document.createElement( 'div' );
 		document.body.appendChild( div );
-
-		return ClassicTestEditor.create( div, { plugins: [ Paragraph, MentionEditing, UndoEditing ] } )
-			.then( newEditor => {
-				editor = newEditor;
-				model = editor.model;
-				doc = model.document;
-			} );
 	} );
 
 	afterEach( () => {
@@ -36,12 +32,24 @@ describe( 'MentionEditing - integration', () => {
 		return editor.destroy();
 	} );
 
-	describe( 'undo', () => {
+	describe( 'with undo', () => {
+		beforeEach( () => {
+			return ClassicTestEditor.create( div, { plugins: [ Paragraph, MentionEditing, UndoEditing ] } )
+				.then( newEditor => {
+					editor = newEditor;
+					model = editor.model;
+					doc = model.document;
+
+					model.schema.extend( '$text', { allowAttributes: [ 'bold' ] } );
+					editor.conversion.attributeToElement( { model: 'bold', view: 'strong' } );
+				} );
+		} );
+
 		// Failing test. See ckeditor/ckeditor5#1645.
 		it( 'should restore removed mention on adding a text inside mention', () => {
-			editor.setData( '<p>foo <span class="mention" data-mention="John">@John</span> bar</p>' );
+			editor.setData( '<p>foo <span class="mention" data-mention="@John">@John</span> bar</p>' );
 
-			expect( editor.getData() ).to.equal( '<p>foo <span class="mention" data-mention="John">@John</span> bar</p>' );
+			expect( editor.getData() ).to.equal( '<p>foo <span class="mention" data-mention="@John">@John</span> bar</p>' );
 
 			model.change( writer => {
 				const paragraph = doc.getRoot().getChild( 0 );
@@ -56,16 +64,16 @@ describe( 'MentionEditing - integration', () => {
 
 			editor.execute( 'undo' );
 
-			expect( editor.getData() ).to.equal( '<p>foo <span class="mention" data-mention="John">@John</span> bar</p>' );
-			expect( getViewData( editor.editing.view ) )
-				.to.equal( '<p>foo <span class="mention" data-mention="John">@John</span> bar</p>' );
+			expect( editor.getData() ).to.equal( '<p>foo <span class="mention" data-mention="@John">@John</span> bar</p>' );
+			expect( getViewData( editor.editing.view, { withoutSelection: true } ) )
+				.to.equal( '<p>foo <span class="mention" data-mention="@John">@John</span> bar</p>' );
 		} );
 
 		// Failing test. See ckeditor/ckeditor5#1645.
 		it( 'should restore removed mention on removing a text inside mention', () => {
-			editor.setData( '<p>foo <span class="mention" data-mention="John">@John</span> bar</p>' );
+			editor.setData( '<p>foo <span class="mention" data-mention="@John">@John</span> bar</p>' );
 
-			expect( editor.getData() ).to.equal( '<p>foo <span class="mention" data-mention="John">@John</span> bar</p>' );
+			expect( editor.getData() ).to.equal( '<p>foo <span class="mention" data-mention="@John">@John</span> bar</p>' );
 
 			model.change( writer => {
 				const paragraph = doc.getRoot().getChild( 0 );
@@ -81,9 +89,123 @@ describe( 'MentionEditing - integration', () => {
 
 			editor.execute( 'undo' );
 
-			expect( editor.getData() ).to.equal( '<p>foo <span class="mention" data-mention="John">@John</span> bar</p>' );
-			expect( getViewData( editor.editing.view ) )
-				.to.equal( '<p>foo <span class="mention" data-mention="John">@John</span> bar</p>' );
+			expect( editor.getData() ).to.equal( '<p>foo <span class="mention" data-mention="@John">@John</span> bar</p>' );
+			expect( getViewData( editor.editing.view, { withoutSelection: true } ) )
+				.to.equal( '<p>foo <span class="mention" data-mention="@John">@John</span> bar</p>' );
+		} );
+
+		it( 'should work with attribute post-fixer (beginning formatted)', () => {
+			testAttributePostFixer(
+				'<p>foo <span class="mention" data-mention="@John">@John</span> bar</p>',
+				'<p><strong>foo <span class="mention" data-mention="@John">@John</span></strong> bar</p>',
+				() => {
+					model.change( writer => {
+						const paragraph = doc.getRoot().getChild( 0 );
+						const start = writer.createPositionAt( paragraph, 0 );
+						const range = writer.createRange( start, start.getShiftedBy( 6 ) );
+
+						writer.setSelection( range );
+
+						writer.setAttribute( 'bold', true, range );
+					} );
+				} );
+		} );
+
+		it( 'should work with attribute post-fixer (end formatted)', () => {
+			testAttributePostFixer(
+				'<p>foo <span class="mention" data-mention="@John">@John</span> bar</p>',
+				'<p>foo <strong><span class="mention" data-mention="@John">@John</span> ba</strong>r</p>',
+				() => {
+					model.change( writer => {
+						const paragraph = doc.getRoot().getChild( 0 );
+						const start = writer.createPositionAt( paragraph, 6 );
+						const range = writer.createRange( start, start.getShiftedBy( 6 ) );
+
+						writer.setSelection( range );
+
+						writer.setAttribute( 'bold', true, range );
+					} );
+				} );
+		} );
+
+		it( 'should work with attribute post-fixer (middle formatted)', () => {
+			testAttributePostFixer(
+				'<p>foo <span class="mention" data-mention="@John">@John</span> bar</p>',
+				'<p>foo <strong><span class="mention" data-mention="@John">@John</span></strong> bar</p>',
+				() => {
+					model.change( writer => {
+						const paragraph = doc.getRoot().getChild( 0 );
+						const start = writer.createPositionAt( paragraph, 6 );
+						const range = writer.createRange( start, start.getShiftedBy( 1 ) );
+
+						writer.setSelection( range );
+
+						writer.setAttribute( 'bold', true, range );
+					} );
+				} );
+		} );
+
+		function testAttributePostFixer( initialData, expectedData, testCallback ) {
+			editor.setData( initialData );
+
+			expect( editor.getData() ).to.equal( initialData );
+
+			testCallback();
+
+			expect( editor.getData() )
+				.to.equal( expectedData );
+			expect( getViewData( editor.editing.view, { withoutSelection: true } ) )
+				.to.equal( expectedData );
+
+			editor.execute( 'undo' );
+
+			expect( editor.getData() ).to.equal( initialData );
+			expect( getViewData( editor.editing.view, { withoutSelection: true } ) )
+				.to.equal( initialData );
+
+			editor.execute( 'redo' );
+
+			expect( editor.getData() )
+				.to.equal( expectedData );
+			expect( getViewData( editor.editing.view, { withoutSelection: true } ) )
+				.to.equal( expectedData );
+		}
+	} );
+
+	describe( 'with clipboard', () => {
+		let clipboard;
+
+		beforeEach( () => {
+			return ClassicTestEditor
+				.create( div, { plugins: [ Clipboard, Paragraph, BlockQuote, MentionEditing, UndoEditing ] } )
+				.then( newEditor => {
+					editor = newEditor;
+					model = editor.model;
+					doc = model.document;
+
+					clipboard = editor.plugins.get( 'Clipboard' );
+				} );
+		} );
+
+		it( 'should not fix broken mention inside pasted content', () => {
+			editor.setData( '<p>foobar</p>' );
+
+			model.change( writer => {
+				writer.setSelection( doc.getRoot().getChild( 0 ), 3 );
+			} );
+
+			clipboard.fire( 'inputTransformation', {
+				content: parseView( '<blockquote><p>xxx<span class="mention" data-mention="@John">@Joh</span></p></blockquote>' )
+			} );
+
+			const expectedData = '<p>foo</p>' +
+				'<blockquote><p>xxx<span class="mention" data-mention="@John">@Joh</span></p></blockquote>' +
+				'<p>bar</p>';
+
+			expect( editor.getData() )
+				.to.equal( expectedData );
+			expect( getViewData( editor.editing.view, { withoutSelection: true } ) )
+				.to.equal( expectedData );
 		} );
 	} );
 } );

+ 44 - 1
packages/ckeditor5-mention/tests/mention.js

@@ -1,10 +1,12 @@
 /**
  * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
- * For licensing, see LICENSE.md.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
  */
 
 import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor';
 import global from '@ckeditor/ckeditor5-utils/src/dom/global';
+import Element from '@ckeditor/ckeditor5-engine/src/view/element';
+import Text from '@ckeditor/ckeditor5-engine/src/view/text';
 
 import Mention from '../src/mention';
 import MentionEditing from '../src/mentionediting';
@@ -47,4 +49,45 @@ describe( 'Mention', () => {
 	it( 'should load MentionUI plugin', () => {
 		expect( editor.plugins.get( MentionUI ) ).to.instanceOf( MentionUI );
 	} );
+
+	describe( 'toMentionAttribute()', () => {
+		it( 'should create mention attribute with default properties', () => {
+			const text = new Text( 'John Doe' );
+
+			const viewElement = new Element( 'span', {
+				'data-mention': '@John'
+			}, text );
+
+			const mentionAttribute = editor.plugins.get( 'Mention' ).toMentionAttribute( viewElement );
+
+			expect( mentionAttribute ).to.have.property( 'id', '@John' );
+			expect( mentionAttribute ).to.have.property( '_uid' );
+			expect( mentionAttribute ).to.have.property( '_text', 'John Doe' );
+		} );
+
+		it( 'should create mention attribute with provided attributes', () => {
+			const text = new Text( 'John Doe' );
+
+			const viewElement = new Element( 'span', {
+				'data-mention': '@John'
+			}, text );
+
+			const mentionAttribute = editor.plugins.get( 'Mention' ).toMentionAttribute( viewElement, { foo: 'bar' } );
+
+			expect( mentionAttribute ).to.have.property( 'id', '@John' );
+			expect( mentionAttribute ).to.have.property( 'foo', 'bar' );
+			expect( mentionAttribute ).to.have.property( '_uid' );
+			expect( mentionAttribute ).to.have.property( '_text', 'John Doe' );
+		} );
+
+		it( 'should return undefined if Element has no text node', () => {
+			const viewElement = new Element( 'span', {
+				'data-mention': '@John'
+			} );
+
+			const mentionAttribute = editor.plugins.get( 'Mention' ).toMentionAttribute( viewElement );
+
+			expect( mentionAttribute ).to.be.undefined;
+		} );
+	} );
 } );

+ 65 - 18
packages/ckeditor5-mention/tests/mentioncommand.js

@@ -1,12 +1,13 @@
 /**
  * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
- * For licensing, see LICENSE.md.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
  */
 
 import ModelTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/modeltesteditor';
 import { setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
 
 import MentionCommand from '../src/mentioncommand';
+import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 
 describe( 'MentionCommand', () => {
 	let editor, command, model, doc, selection;
@@ -54,26 +55,43 @@ describe( 'MentionCommand', () => {
 	} );
 
 	describe( 'execute()', () => {
-		it( 'inserts mention attribute for given range', () => {
+		it( 'inserts mention object if mention was passed as string', () => {
 			setData( model, '<paragraph>foo @Jo[]bar</paragraph>' );
 
 			command.execute( {
-				mention: { name: 'John' },
+				marker: '@',
+				mention: '@John',
 				range: model.createRange( selection.focus.getShiftedBy( -3 ), selection.focus )
 			} );
 
-			assertMention( doc.getRoot().getChild( 0 ).getChild( 1 ), '@', 'John' );
+			assertMention( doc.getRoot().getChild( 0 ).getChild( 1 ), '@John' );
 		} );
 
-		it( 'inserts mention object if mention was passed as string', () => {
+		it( 'inserts mention object with data if mention was passed as object', () => {
+			setData( model, '<paragraph>foo @Jo[]bar</paragraph>' );
+
+			command.execute( {
+				marker: '@',
+				mention: { id: '@John', userId: '123456' },
+				range: model.createRange( selection.focus.getShiftedBy( -3 ), selection.focus )
+			} );
+
+			const mentionNode = doc.getRoot().getChild( 0 ).getChild( 1 );
+			assertMention( mentionNode, '@John' );
+			expect( mentionNode.getAttribute( 'mention' ) ).to.have.property( 'userId', '123456' );
+		} );
+
+		it( 'inserts options.text as mention text', () => {
 			setData( model, '<paragraph>foo @Jo[]bar</paragraph>' );
 
 			command.execute( {
-				mention: 'John',
+				marker: '@',
+				mention: '@John',
+				text: '@John Doe',
 				range: model.createRange( selection.focus.getShiftedBy( -3 ), selection.focus )
 			} );
 
-			assertMention( doc.getRoot().getChild( 0 ).getChild( 1 ), '@', 'John' );
+			assertMention( doc.getRoot().getChild( 0 ).getChild( 1 ), '@John' );
 		} );
 
 		it( 'inserts mention attribute with passed marker for given range', () => {
@@ -83,22 +101,23 @@ describe( 'MentionCommand', () => {
 			const start = end.getShiftedBy( -3 );
 
 			command.execute( {
-				mention: { name: 'John' },
+				marker: '#',
+				mention: '#John',
 				range: model.createRange( start, end ),
-				marker: '#'
 			} );
 
-			assertMention( doc.getRoot().getChild( 0 ).getChild( 1 ), '#', 'John' );
+			assertMention( doc.getRoot().getChild( 0 ).getChild( 1 ), '#John' );
 		} );
 
 		it( 'inserts mention attribute at current selection if no range was passed', () => {
 			setData( model, '<paragraph>foo []bar</paragraph>' );
 
 			command.execute( {
-				mention: { name: 'John' }
+				marker: '@',
+				mention: '@John'
 			} );
 
-			assertMention( doc.getRoot().getChild( 0 ).getChild( 1 ), '@', 'John' );
+			assertMention( doc.getRoot().getChild( 0 ).getChild( 1 ), '@John' );
 		} );
 
 		it( 'should set also other styles in inserted text', () => {
@@ -107,20 +126,48 @@ describe( 'MentionCommand', () => {
 			setData( model, '<paragraph><$text bold="true">foo@John[]bar</$text></paragraph>' );
 
 			command.execute( {
-				mention: { name: 'John' },
+				marker: '@',
+				mention: '@John',
 				range: model.createRange( selection.focus.getShiftedBy( -5 ), selection.focus )
 			} );
 
 			const textNode = doc.getRoot().getChild( 0 ).getChild( 1 );
-			assertMention( textNode, '@', 'John' );
+			assertMention( textNode, '@John' );
 			expect( textNode.hasAttribute( 'bold' ) ).to.be.true;
 		} );
+
+		it( 'should throw if marker is not one character', () => {
+			setData( model, '<paragraph>foo @Jo[]bar</paragraph>' );
+
+			const testCases = [
+				{ marker: '##', mention: '##foo' },
+				{ marker: '', mention: '@foo' },
+			];
+
+			for ( const options of testCases ) {
+				expect( () => command.execute( options ) ).to.throw( CKEditorError, /mentioncommand-incorrect-marker/ );
+			}
+		} );
+
+		it( 'should throw if marker does not match mention id', () => {
+			setData( model, '<paragraph>foo @Jo[]bar</paragraph>' );
+
+			const testCases = [
+				{ marker: '@', mention: 'foo' },
+				{ marker: '@', mention: { id: 'foo' } },
+				{ marker: '@', mention: { id: '#foo' } }
+			];
+
+			for ( const options of testCases ) {
+				expect( () => command.execute( options ) ).to.throw( CKEditorError, /mentioncommand-incorrect-id/ );
+			}
+		} );
 	} );
 
-	function assertMention( textNode, marker, name ) {
+	function assertMention( textNode, id ) {
 		expect( textNode.hasAttribute( 'mention' ) ).to.be.true;
-		expect( textNode.getAttribute( 'mention' ) ).to.have.property( '_id' );
-		expect( textNode.getAttribute( 'mention' ) ).to.have.property( '_marker', marker );
-		expect( textNode.getAttribute( 'mention' ) ).to.have.property( 'name', name );
+		expect( textNode.getAttribute( 'mention' ) ).to.have.property( '_uid' );
+		expect( textNode.getAttribute( 'mention' ) ).to.have.property( '_text', textNode.data );
+		expect( textNode.getAttribute( 'mention' ) ).to.have.property( 'id', id );
 	}
 } );

+ 288 - 36
packages/ckeditor5-mention/tests/mentionediting.js

@@ -1,6 +1,6 @@
 /**
  * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
- * For licensing, see LICENSE.md.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
  */
 
 import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor';
@@ -67,18 +67,18 @@ describe( 'MentionEditing', () => {
 				} );
 		} );
 
-		it( 'should convert <span class="mention" data-mention="John"> to mention attribute', () => {
-			editor.setData( '<p>foo <span class="mention" data-mention="John">@John</span> bar</p>' );
+		it( 'should convert <span class="mention" data-mention="@John"> to mention attribute', () => {
+			editor.setData( '<p>foo <span class="mention" data-mention="@John">@John</span> bar</p>' );
 
 			const textNode = doc.getRoot().getChild( 0 ).getChild( 1 );
 
 			expect( textNode ).to.not.be.null;
 			expect( textNode.hasAttribute( 'mention' ) ).to.be.true;
-			expect( textNode.getAttribute( 'mention' ) ).to.have.property( '_id' );
-			expect( textNode.getAttribute( 'mention' ) ).to.have.property( '_marker', '@' );
-			expect( textNode.getAttribute( 'mention' ) ).to.have.property( 'name', 'John' );
+			expect( textNode.getAttribute( 'mention' ) ).to.have.property( 'id', '@John' );
+			expect( textNode.getAttribute( 'mention' ) ).to.have.property( '_text', '@John' );
+			expect( textNode.getAttribute( 'mention' ) ).to.have.property( '_uid' );
 
-			const expectedView = '<p>foo <span class="mention" data-mention="John">@John</span> bar</p>';
+			const expectedView = '<p>foo <span class="mention" data-mention="@John">@John</span> bar</p>';
 
 			expect( editor.getData() ).to.equal( expectedView );
 			expect( getViewData( editor.editing.view, { withoutSelection: true } ) ).to.equal( expectedView );
@@ -87,8 +87,8 @@ describe( 'MentionEditing', () => {
 		it( 'should convert consecutive mentions spans as two text nodes and two spans in the view', () => {
 			editor.setData(
 				'<p>' +
-					'<span class="mention" data-mention="John">@John</span>' +
-					'<span class="mention" data-mention="John">@John</span>' +
+					'<span class="mention" data-mention="@John">@John</span>' +
+					'<span class="mention" data-mention="@John">@John</span>' +
 				'</p>'
 			);
 
@@ -102,13 +102,13 @@ describe( 'MentionEditing', () => {
 			assertTextNode( paragraph.getChild( 0 ) );
 			assertTextNode( paragraph.getChild( 1 ) );
 
-			const firstMentionId = paragraph.getChild( 0 ).getAttribute( 'mention' )._id;
-			const secondMentionId = paragraph.getChild( 1 ).getAttribute( 'mention' )._id;
+			const firstMentionId = paragraph.getChild( 0 ).getAttribute( 'mention' )._uid;
+			const secondMentionId = paragraph.getChild( 1 ).getAttribute( 'mention' )._uid;
 
 			expect( firstMentionId ).to.not.equal( secondMentionId );
 
-			const expectedView = '<p><span class="mention" data-mention="John">@John</span>' +
-				'<span class="mention" data-mention="John">@John</span></p>';
+			const expectedView = '<p><span class="mention" data-mention="@John">@John</span>' +
+				'<span class="mention" data-mention="@John">@John</span></p>';
 
 			expect( editor.getData() ).to.equal( expectedView );
 			expect( getViewData( editor.editing.view, { withoutSelection: true } ) ).to.equal( expectedView );
@@ -116,25 +116,31 @@ describe( 'MentionEditing', () => {
 			function assertTextNode( textNode ) {
 				expect( textNode ).to.not.be.null;
 				expect( textNode.hasAttribute( 'mention' ) ).to.be.true;
-				expect( textNode.getAttribute( 'mention' ) ).to.have.property( '_id' );
-				expect( textNode.getAttribute( 'mention' ) ).to.have.property( '_marker', '@' );
-				expect( textNode.getAttribute( 'mention' ) ).to.have.property( 'name', 'John' );
+				expect( textNode.getAttribute( 'mention' ) ).to.have.property( 'id', '@John' );
+				expect( textNode.getAttribute( 'mention' ) ).to.have.property( '_text', '@John' );
+				expect( textNode.getAttribute( 'mention' ) ).to.have.property( '_uid' );
 			}
 		} );
 
 		it( 'should not convert partial mentions', () => {
-			editor.setData( '<p><span class="mention" data-mention="John">@Jo</span></p>' );
+			editor.setData( '<p><span class="mention" data-mention="@John">@Jo</span></p>' );
 
-			expect( getModelData( model, { withoutSelection: true } ) ).to.equal( '<paragraph>@Jo</paragraph>' );
+			const textNode = doc.getRoot().getChild( 0 ).getChild( 0 );
 
-			const expectedView = '<p>@Jo</p>';
+			expect( textNode ).to.not.be.null;
+			expect( textNode.hasAttribute( 'mention' ) ).to.be.true;
+			expect( textNode.getAttribute( 'mention' ) ).to.have.property( 'id', '@John' );
+			expect( textNode.getAttribute( 'mention' ) ).to.have.property( '_text', '@Jo' );
+			expect( textNode.getAttribute( 'mention' ) ).to.have.property( '_uid' );
+
+			const expectedView = '<p><span class="mention" data-mention="@John">@Jo</span></p>';
 
 			expect( editor.getData() ).to.equal( expectedView );
 			expect( getViewData( editor.editing.view, { withoutSelection: true } ) ).to.equal( expectedView );
 		} );
 
 		it( 'should not convert empty mentions', () => {
-			editor.setData( '<p>foo<span class="mention" data-mention="John"></span></p>' );
+			editor.setData( '<p>foo<span class="mention" data-mention="@John"></span></p>' );
 
 			expect( getModelData( model, { withoutSelection: true } ) ).to.equal( '<paragraph>foo</paragraph>' );
 
@@ -145,7 +151,7 @@ describe( 'MentionEditing', () => {
 		} );
 	} );
 
-	describe( 'selection post fixer', () => {
+	describe( 'selection post-fixer', () => {
 		beforeEach( () => {
 			return createTestEditor()
 				.then( newEditor => {
@@ -156,7 +162,7 @@ describe( 'MentionEditing', () => {
 		} );
 
 		it( 'should remove mention attribute from a selection if selection is on right side of a mention', () => {
-			editor.setData( '<p>foo <span class="mention" data-mention="John">@John</span>bar</p>' );
+			editor.setData( '<p>foo <span class="mention" data-mention="@John">@John</span>bar</p>' );
 
 			model.change( writer => {
 				const paragraph = doc.getRoot().getChild( 0 );
@@ -168,7 +174,7 @@ describe( 'MentionEditing', () => {
 		} );
 
 		it( 'should allow to type after a mention', () => {
-			editor.setData( '<p>foo <span class="mention" data-mention="John">@John</span>bar</p>' );
+			editor.setData( '<p>foo <span class="mention" data-mention="@John">@John</span>bar</p>' );
 
 			model.change( writer => {
 				const paragraph = doc.getRoot().getChild( 0 );
@@ -178,11 +184,11 @@ describe( 'MentionEditing', () => {
 				writer.insertText( ' ', paragraph, 9 );
 			} );
 
-			expect( editor.getData() ).to.equal( '<p>foo <span class="mention" data-mention="John">@John</span> bar</p>' );
+			expect( editor.getData() ).to.equal( '<p>foo <span class="mention" data-mention="@John">@John</span> bar</p>' );
 		} );
 	} );
 
-	describe( 'removing partial mention post fixer', () => {
+	describe( 'removing partial mention post-fixer', () => {
 		beforeEach( () => {
 			return createTestEditor()
 				.then( newEditor => {
@@ -192,16 +198,16 @@ describe( 'MentionEditing', () => {
 				} );
 		} );
 
-		it( 'should remove mention on adding a text inside mention', () => {
-			editor.setData( '<p>foo <span class="mention" data-mention="John">@John</span> bar</p>' );
+		it( 'should remove mention on adding a text inside mention (in the middle)', () => {
+			editor.setData( '<p>foo <span class="mention" data-mention="@John">@John</span> bar</p>' );
 
 			const textNode = doc.getRoot().getChild( 0 ).getChild( 1 );
 
 			expect( textNode ).to.not.be.null;
 			expect( textNode.hasAttribute( 'mention' ) ).to.be.true;
-			expect( textNode.getAttribute( 'mention' ) ).to.have.property( '_id' );
-			expect( textNode.getAttribute( 'mention' ) ).to.have.property( '_marker', '@' );
-			expect( textNode.getAttribute( 'mention' ) ).to.have.property( 'name', 'John' );
+			expect( textNode.getAttribute( 'mention' ) ).to.have.property( 'id', '@John' );
+			expect( textNode.getAttribute( 'mention' ) ).to.have.property( '_text', '@John' );
+			expect( textNode.getAttribute( 'mention' ) ).to.have.property( '_uid' );
 
 			model.change( writer => {
 				const paragraph = doc.getRoot().getChild( 0 );
@@ -217,8 +223,46 @@ describe( 'MentionEditing', () => {
 			expect( editor.getData() ).to.equal( '<p>foo @Jaohn bar</p>' );
 		} );
 
-		it( 'should remove mention on removing a text inside mention', () => {
-			editor.setData( '<p>foo <span class="mention" data-mention="John">@John</span> bar</p>' );
+		it( 'should remove mention on typing in mention node with selection attributes set', () => {
+			editor.setData( '<p>foo <span class="mention" data-mention="@John">@John</span> bar</p>' );
+
+			const textNode = doc.getRoot().getChild( 0 ).getChild( 1 );
+
+			expect( textNode ).to.not.be.null;
+			expect( textNode.hasAttribute( 'mention' ) ).to.be.true;
+
+			model.change( writer => {
+				const paragraph = doc.getRoot().getChild( 0 );
+
+				writer.setSelection( paragraph, 6 );
+				writer.setSelectionAttribute( 'bold', true );
+
+				writer.insertText( 'a', doc.selection.getAttributes(), writer.createPositionAt( paragraph, 6 ) );
+			} );
+
+			expect( getModelData( model, { withoutSelection: true } ) )
+				.to.equal( '<paragraph>foo @J<$text bold="true">a</$text>ohn bar</paragraph>' );
+		} );
+
+		it( 'should remove mention on removing a text at the beginning of a mention', () => {
+			editor.setData( '<p>foo <span class="mention" data-mention="@John">@John</span> bar</p>' );
+
+			const paragraph = doc.getRoot().getChild( 0 );
+
+			model.change( writer => {
+				writer.setSelection( paragraph, 4 );
+			} );
+
+			model.enqueueChange( () => {
+				model.modifySelection( doc.selection, { direction: 'forward', unit: 'codepoint' } );
+				model.deleteContent( doc.selection );
+			} );
+
+			expect( editor.getData() ).to.equal( '<p>foo John bar</p>' );
+		} );
+
+		it( 'should remove mention on removing a text in the middle a mention', () => {
+			editor.setData( '<p>foo <span class="mention" data-mention="@John">@John</span> bar</p>' );
 
 			const paragraph = doc.getRoot().getChild( 0 );
 
@@ -235,11 +279,10 @@ describe( 'MentionEditing', () => {
 		} );
 
 		it( 'should remove mention on removing a text at the and of a mention', () => {
-			editor.setData( '<p>foo <span class="mention" data-mention="John">@John</span> bar</p>' );
+			editor.setData( '<p>foo <span class="mention" data-mention="@John">@John</span> bar</p>' );
 
 			const paragraph = doc.getRoot().getChild( 0 );
 
-			// Set selection at the end of a John.
 			model.change( writer => {
 				writer.setSelection( paragraph, 9 );
 			} );
@@ -253,7 +296,7 @@ describe( 'MentionEditing', () => {
 		} );
 
 		it( 'should not remove mention on removing a text just after a mention', () => {
-			editor.setData( '<p>foo <span class="mention" data-mention="John">@John</span> bar</p>' );
+			editor.setData( '<p>foo <span class="mention" data-mention="@John">@John</span> bar</p>' );
 
 			const paragraph = doc.getRoot().getChild( 0 );
 
@@ -267,7 +310,216 @@ describe( 'MentionEditing', () => {
 				model.deleteContent( doc.selection );
 			} );
 
-			expect( editor.getData() ).to.equal( '<p>foo <span class="mention" data-mention="John">@John</span>bar</p>' );
+			expect( editor.getData() ).to.equal( '<p>foo <span class="mention" data-mention="@John">@John</span>bar</p>' );
+		} );
+
+		it( 'should remove mention on inserting text node inside a mention', () => {
+			editor.setData( '<p>foo <span class="mention" data-mention="@John">@John</span> bar</p>' );
+
+			const paragraph = doc.getRoot().getChild( 0 );
+
+			model.change( writer => {
+				writer.insertText( 'baz', paragraph, 7 );
+			} );
+
+			expect( editor.getData() ).to.equal( '<p>foo @Jobazhn bar</p>' );
+		} );
+
+		it( 'should remove mention on inserting inline element inside a mention', () => {
+			model.schema.register( 'inline', {
+				allowWhere: '$text',
+				isInline: true
+			} );
+			editor.conversion.elementToElement( { model: 'inline', view: 'br' } );
+
+			editor.setData( '<p>foo <span class="mention" data-mention="@John">@John</span> bar</p>' );
+
+			const paragraph = doc.getRoot().getChild( 0 );
+
+			model.change( writer => {
+				writer.insertElement( 'inline', paragraph, 7 );
+			} );
+
+			expect( editor.getData() ).to.equal( '<p>foo @Jo<br>hn bar</p>' );
+		} );
+
+		it( 'should remove mention when splitting paragraph with a mention', () => {
+			editor.setData( '<p>foo <span class="mention" data-mention="@John">@John</span> bar</p>' );
+
+			const paragraph = doc.getRoot().getChild( 0 );
+
+			model.change( writer => {
+				writer.split( writer.createPositionAt( paragraph, 7 ) );
+			} );
+
+			expect( editor.getData() ).to.equal( '<p>foo @Jo</p><p>hn bar</p>' );
+		} );
+
+		it( 'should remove mention when deep splitting elements', () => {
+			model.schema.register( 'blockQuote', {
+				allowWhere: '$block',
+				allowContentOf: '$root'
+			} );
+
+			editor.conversion.elementToElement( { model: 'blockQuote', view: 'blockquote' } );
+			editor.setData( '<blockquote><p>foo <span class="mention" data-mention="@John">@John</span> bar</p></blockquote>' );
+
+			model.change( writer => {
+				const paragraph = doc.getRoot().getChild( 0 ).getChild( 0 );
+
+				writer.split( writer.createPositionAt( paragraph, 7 ), doc.getRoot() );
+			} );
+
+			expect( editor.getData() ).to.equal( '<blockquote><p>foo @Jo</p></blockquote><blockquote><p>hn bar</p></blockquote>' );
+		} );
+	} );
+
+	describe( 'extend attribute on mention post-fixer', () => {
+		beforeEach( () => {
+			return createTestEditor()
+				.then( newEditor => {
+					editor = newEditor;
+					model = editor.model;
+					doc = model.document;
+				} );
+		} );
+
+		it( 'should set attribute on whole mention when formatting part of a mention (beginning formatted)', () => {
+			model.schema.extend( '$text', { allowAttributes: [ 'bold' ] } );
+			editor.conversion.attributeToElement( { model: 'bold', view: 'strong' } );
+
+			editor.setData( '<p>foo <span class="mention" data-mention="@John">@John</span> bar</p>' );
+
+			const paragraph = doc.getRoot().getChild( 0 );
+
+			model.change( writer => {
+				const start = writer.createPositionAt( paragraph, 0 );
+				const range = writer.createRange( start, start.getShiftedBy( 6 ) );
+
+				writer.setSelection( range );
+
+				writer.setAttribute( 'bold', true, range );
+			} );
+
+			expect( editor.getData() )
+				.to.equal( '<p><strong>foo <span class="mention" data-mention="@John">@John</span></strong> bar</p>' );
+		} );
+
+		it( 'should set attribute on whole mention when formatting part of a mention (end formatted)', () => {
+			model.schema.extend( '$text', { allowAttributes: [ 'bold' ] } );
+			editor.conversion.attributeToElement( { model: 'bold', view: 'strong' } );
+
+			editor.setData( '<p>foo <span class="mention" data-mention="@John">@John</span> bar</p>' );
+
+			const paragraph = doc.getRoot().getChild( 0 );
+
+			model.change( writer => {
+				const start = writer.createPositionAt( paragraph, 6 );
+				const range = writer.createRange( start, start.getShiftedBy( 6 ) );
+
+				writer.setSelection( range );
+
+				writer.setAttribute( 'bold', true, range );
+			} );
+
+			expect( editor.getData() )
+				.to.equal( '<p>foo <strong><span class="mention" data-mention="@John">@John</span> ba</strong>r</p>' );
+		} );
+
+		it( 'should set attribute on whole mention when formatting part of a mention (middle of mention formatted)', () => {
+			model.schema.extend( '$text', { allowAttributes: [ 'bold' ] } );
+			editor.conversion.attributeToElement( { model: 'bold', view: 'strong' } );
+
+			editor.setData( '<p>foo <span class="mention" data-mention="@John">@John</span> bar</p>' );
+
+			const paragraph = doc.getRoot().getChild( 0 );
+
+			model.change( writer => {
+				const start = writer.createPositionAt( paragraph, 6 );
+				const range = writer.createRange( start, start.getShiftedBy( 1 ) );
+
+				writer.setSelection( range );
+
+				writer.setAttribute( 'bold', true, range );
+			} );
+
+			expect( editor.getData() )
+				.to.equal( '<p>foo <strong><span class="mention" data-mention="@John">@John</span></strong> bar</p>' );
+		} );
+
+		it( 'should set attribute on whole mention when formatting part of two mentions', () => {
+			model.schema.extend( '$text', { allowAttributes: [ 'bold' ] } );
+			editor.conversion.attributeToElement( { model: 'bold', view: 'strong' } );
+
+			editor.setData(
+				'<p><span class="mention" data-mention="@John">@John</span><span class="mention" data-mention="@John">@John</span></p>'
+			);
+
+			const paragraph = doc.getRoot().getChild( 0 );
+
+			model.change( writer => {
+				const start = writer.createPositionAt( paragraph, 4 );
+				const range = writer.createRange( start, start.getShiftedBy( 4 ) );
+
+				writer.setSelection( range );
+
+				writer.setAttribute( 'bold', true, range );
+			} );
+
+			expect( editor.getData() ).to.equal(
+				'<p>' +
+					'<strong>' +
+						'<span class="mention" data-mention="@John">@John</span>' +
+						'<span class="mention" data-mention="@John">@John</span>' +
+					'</strong>' +
+				'</p>'
+			);
+		} );
+
+		it( 'should work with multiple ranges in change set', () => {
+			model.schema.extend( '$text', { allowAttributes: [ 'foo' ] } );
+			editor.conversion.attributeToElement( {
+				model: {
+					key: 'foo',
+					values: [ 'a', 'b' ]
+				},
+				view: {
+					a: {
+						name: 'span',
+						classes: 'mark-a'
+					},
+					b: {
+						name: 'span',
+						classes: 'mark-b'
+					}
+				},
+				converterPriority: 'high'
+			} );
+
+			editor.setData(
+				'<p>' +
+					'<span class="mark-a">foo <span class="mention" data-mention="@John">@John</span></span>' +
+					'<span class="mention" data-mention="@John">@John</span> bar' +
+				'</p>'
+			);
+
+			model.change( writer => {
+				const paragraph = doc.getRoot().getChild( 0 );
+				const start = writer.createPositionAt( paragraph, 7 );
+				const range = writer.createRange( start, start.getShiftedBy( 5 ) );
+
+				writer.setAttribute( 'foo', 'b', range );
+			} );
+
+			expect( editor.getData() ).to.equal(
+				'<p>' +
+					'<span class="mark-a">foo </span>' +
+					'<span class="mark-b">' +
+						'<span class="mention" data-mention="@John">@John</span>' +
+						'<span class="mention" data-mention="@John">@John</span>' +
+					'</span> bar' +
+				'</p>'
+			);
 		} );
 	} );
 

+ 559 - 51
packages/ckeditor5-mention/tests/mentionui.js

@@ -1,9 +1,9 @@
 /**
  * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
- * For licensing, see LICENSE.md.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
  */
 
-/* global window, document, setTimeout, Event */
+/* global document, setTimeout, Event */
 
 import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor';
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
@@ -15,17 +15,21 @@ import global from '@ckeditor/ckeditor5-utils/src/dom/global';
 import { setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
 import DomEventData from '@ckeditor/ckeditor5-engine/src/view/observer/domeventdata';
 import EventInfo from '@ckeditor/ckeditor5-utils/src/eventinfo';
+import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 
 import MentionUI from '../src/mentionui';
 import MentionEditing from '../src/mentionediting';
 import MentionsView from '../src/ui/mentionsview';
 
 describe( 'MentionUI', () => {
-	let editor, model, doc, editingView, mentionUI, editorElement, mentionsView, panelView, listView;
+	let editor, model, doc, editingView, mentionUI, editorElement, mentionsView, panelView;
 
 	const staticConfig = {
 		feeds: [
-			{ feed: [ 'Barney', 'Lily', 'Marshall', 'Robin', 'Ted' ] }
+			{
+				feed: [ '@Barney', '@Lily', '@Marshall', '@Robin', '@Ted' ],
+				marker: '@'
+			}
 		]
 	};
 
@@ -40,7 +44,9 @@ describe( 'MentionUI', () => {
 		sinon.restore();
 		editorElement.remove();
 
-		return editor.destroy();
+		if ( editor ) {
+			return editor.destroy();
+		}
 	} );
 
 	it( 'should create a plugin instance', () => {
@@ -50,6 +56,29 @@ describe( 'MentionUI', () => {
 		} );
 	} );
 
+	describe( 'init()', () => {
+		it( 'should throw if marker was not provided for feed', () => {
+			return createClassicTestEditor( { feeds: [ { feed: [ 'a' ] } ] } ).catch( error => {
+				expect( error ).to.be.instanceOf( CKEditorError );
+				expect( error.message ).to.match( /mentionconfig-incorrect-marker/ );
+			} );
+		} );
+
+		it( 'should throw if marker is empty string', () => {
+			return createClassicTestEditor( { feeds: [ { marker: '', feed: [ 'a' ] } ] } ).catch( error => {
+				expect( error ).to.be.instanceOf( CKEditorError );
+				expect( error.message ).to.match( /mentionconfig-incorrect-marker/ );
+			} );
+		} );
+
+		it( 'should throw if marker is longer then 1 character', () => {
+			return createClassicTestEditor( { feeds: [ { marker: '$$', feed: [ 'a' ] } ] } ).catch( error => {
+				expect( error ).to.be.instanceOf( CKEditorError );
+				expect( error.message ).to.match( /mentionconfig-incorrect-marker/ );
+			} );
+		} );
+	} );
+
 	describe( 'pluginName', () => {
 		it( 'should return plugin by its name', () => {
 			return createClassicTestEditor().then( () => {
@@ -108,9 +137,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 +151,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 +184,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 +215,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 +235,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;
+
+					testUtils.sinon.stub( editingView.document.selection, 'editableElement' ).value( null );
+
+					// Should not break;
+					expect( limiter() ).to.be.null;
 				} );
 		} );
 	} );
@@ -195,6 +285,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,7 +296,8 @@ describe( 'MentionUI', () => {
 				.then( waitForDebounce )
 				.then( () => {
 					expect( panelView.isVisible ).to.be.true;
-					expect( listView.items ).to.have.length( 1 );
+					expect( editor.model.markers.has( 'mention' ) ).to.be.true;
+					expect( mentionsView.items ).to.have.length( 1 );
 
 					model.change( writer => {
 						writer.insertText( 'r', doc.selection.getFirstPosition() );
@@ -214,8 +306,97 @@ describe( 'MentionUI', () => {
 				.then( waitForDebounce )
 				.then( () => {
 					expect( panelView.isVisible ).to.be.true;
-					expect( listView.items ).to.have.length( 1 );
+					expect( editor.model.markers.has( 'mention' ) ).to.be.true;
+					expect( mentionsView.items ).to.have.length( 1 );
+				} );
+		} );
+
+		describe( 'static list with large set of results', () => {
+			const bigList = {
+				marker: '@',
+				feed: [
+					'@a01', '@a02', '@a03', '@a04', '@a05', '@a06', '@a07', '@a08', '@a09', '@a10', '@a11', '@a12'
+				]
+			};
+
+			beforeEach( () => {
+				return createClassicTestEditor( { feeds: [ bigList ] } );
+			} );
+
+			it( 'should show panel with no more then 10 items for default static feed', () => {
+				setData( model, '<paragraph>foo []</paragraph>' );
+
+				model.change( writer => {
+					writer.insertText( '@', doc.selection.getFirstPosition() );
+				} );
+
+				return waitForDebounce()
+					.then( () => {
+						expect( panelView.isVisible ).to.be.true;
+						expect( mentionsView.items ).to.have.length( 10 );
+					} );
+			} );
+
+			it( 'should scroll mention panel to the selected item', () => {
+				setData( model, '<paragraph>foo []</paragraph>' );
+
+				model.change( writer => {
+					writer.insertText( '@', doc.selection.getFirstPosition() );
 				} );
+
+				return waitForDebounce()
+					.then( () => {
+						const arrowDownEvtData = {
+							keyCode: keyCodes.arrowdown,
+							preventDefault: sinon.spy(),
+							stopPropagation: sinon.spy()
+						};
+
+						const arrowUpEvtData = {
+							keyCode: keyCodes.arrowup,
+							preventDefault: sinon.spy(),
+							stopPropagation: sinon.spy()
+						};
+
+						// The scroll test highly depends on browser styles.
+						// Some CI test environments might not load theme which will result that tests will not render on CI as locally.
+						// To make this test repeatable across different environments it enforces mentions view size to 100px...
+						mentionsView.element.style = 'height:100px;padding:0px;margin:0px';
+
+						// ...and each list view item size to 25px...
+						Array.from( mentionsView.items ).forEach( item => {
+							item.children.get( 0 ).element.style = 'height:25px;padding:0px;margin:0px;';
+						} );
+
+						// ...so after those changes it is safe to assume that:
+						// - base offset is 0
+						// - only 4 items are visible at once
+						// - if scrolled to the last element scrollTop will be set to 150px. The 150px is the offset of the 7th item in the
+						//   list as last four (7, 8, 9 & 10) will be visible.
+						expect( panelView.isVisible ).to.be.true;
+
+						expectChildViewsIsOnState( [ true, false, false, false, false, false, false, false, false, false ] );
+						expect( mentionsView.element.scrollTop ).to.equal( 0 );
+
+						fireKeyDownEvent( arrowDownEvtData );
+
+						expectChildViewsIsOnState( [ false, true, false, false, false, false, false, false, false, false ] );
+						expect( mentionsView.element.scrollTop ).to.equal( 0 );
+
+						fireKeyDownEvent( arrowUpEvtData );
+						expectChildViewsIsOnState( [ true, false, false, false, false, false, false, false, false, false ] );
+						expect( mentionsView.element.scrollTop ).to.equal( 0 );
+
+						fireKeyDownEvent( arrowUpEvtData );
+						expectChildViewsIsOnState( [ false, false, false, false, false, false, false, false, false, true ] );
+						expect( mentionsView.element.scrollTop ).to.equal( 150 );
+
+						fireKeyDownEvent( arrowDownEvtData );
+
+						expectChildViewsIsOnState( [ true, false, false, false, false, false, false, false, false, false ] );
+						expect( mentionsView.element.scrollTop ).to.equal( 0 );
+					} );
+			} );
 		} );
 
 		describe( 'static list with default trigger', () => {
@@ -226,6 +407,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() );
 				} );
@@ -233,7 +416,8 @@ describe( 'MentionUI', () => {
 				return waitForDebounce()
 					.then( () => {
 						expect( panelView.isVisible ).to.be.true;
-						expect( listView.items ).to.have.length( 5 );
+						expect( editor.model.markers.has( 'mention' ) ).to.be.true;
+						expect( mentionsView.items ).to.have.length( 5 );
 					} );
 			} );
 
@@ -247,7 +431,8 @@ describe( 'MentionUI', () => {
 				return waitForDebounce()
 					.then( () => {
 						expect( panelView.isVisible ).to.be.true;
-						expect( listView.items ).to.have.length( 5 );
+						expect( editor.model.markers.has( 'mention' ) ).to.be.true;
+						expect( mentionsView.items ).to.have.length( 5 );
 					} );
 			} );
 
@@ -261,11 +446,15 @@ describe( 'MentionUI', () => {
 				return waitForDebounce()
 					.then( () => {
 						expect( panelView.isVisible ).to.be.false;
+						expect( editor.model.markers.has( 'mention' ) ).to.be.false;
 					} );
 			} );
 
 			it( 'should not show panel when selection is inside a mention', () => {
-				setData( model, '<paragraph>foo <$text mention="{\'name\':\'John\'}">@John</$text> bar</paragraph>' );
+				setData( model, '<paragraph>foo [@John] bar</paragraph>' );
+				model.change( writer => {
+					writer.setAttribute( 'mention', { id: '@John', _uid: 1234 }, doc.selection.getFirstRange() );
+				} );
 
 				model.change( writer => {
 					writer.setSelection( doc.getRoot().getChild( 0 ), 7 );
@@ -274,11 +463,15 @@ describe( 'MentionUI', () => {
 				return waitForDebounce()
 					.then( () => {
 						expect( panelView.isVisible ).to.be.false;
+						expect( editor.model.markers.has( 'mention' ) ).to.be.false;
 					} );
 			} );
 
 			it( 'should not show panel when selection is at the end of a mention', () => {
-				setData( model, '<paragraph>foo <$text mention="{\'name\':\'John\'}">@John</$text> bar</paragraph>' );
+				setData( model, '<paragraph>foo [@John] bar</paragraph>' );
+				model.change( writer => {
+					writer.setAttribute( 'mention', { id: '@John', _uid: 1234 }, doc.selection.getFirstRange() );
+				} );
 
 				model.change( writer => {
 					writer.setSelection( doc.getRoot().getChild( 0 ), 9 );
@@ -287,6 +480,7 @@ describe( 'MentionUI', () => {
 				return waitForDebounce()
 					.then( () => {
 						expect( panelView.isVisible ).to.be.false;
+						expect( editor.model.markers.has( 'mention' ) ).to.be.false;
 					} );
 			} );
 
@@ -300,12 +494,67 @@ 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' } );
+						} );
+					} )
+					.then( waitForDebounce )
+					.then( () => {
+						expect( panelView.isVisible ).to.be.false;
+						expect( editor.model.markers.has( 'mention' ) ).to.be.false;
+					} );
+			} );
+
+			it( 'should not show panel when selection is changing (non-collapsed)', () => {
+				setData( model, '<paragraph>foo []</paragraph>' );
+
+				model.change( writer => {
+					writer.insertText( '@', doc.selection.getFirstPosition() );
+				} );
+
+				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' } );
 						} );
 					} )
 					.then( waitForDebounce )
+					.then( () => {
+						expect( panelView.isVisible ).to.be.false;
+						expect( editor.model.markers.has( 'mention' ) ).to.be.false;
+					} )
+					.then( () => {
+						model.change( () => {
+							model.modifySelection( doc.selection, { direction: 'backward', unit: 'character' } );
+						} );
+					} )
+					.then( waitForDebounce )
+					.then( () => {
+						expect( panelView.isVisible ).to.be.false;
+						expect( editor.model.markers.has( 'mention' ) ).to.be.false;
+					} );
+			} );
+
+			it( 'should not show panel when selection is after existing mention', () => {
+				setData( model, '<paragraph>foo [@John] bar[]</paragraph>' );
+				model.change( writer => {
+					writer.setAttribute( 'mention', { id: '@John', _uid: 1234 }, doc.selection.getFirstRange() );
+				} );
+
+				return waitForDebounce()
+					.then( () => {
+						expect( panelView.isVisible ).to.be.false;
+
+						model.change( writer => {
+							writer.setSelection( doc.getRoot().getChild( 0 ), 8 );
+						} );
+					} )
+					.then( waitForDebounce )
 					.then( () => {
 						expect( panelView.isVisible ).to.be.false;
 					} );
@@ -325,7 +574,8 @@ describe( 'MentionUI', () => {
 				return waitForDebounce()
 					.then( () => {
 						expect( panelView.isVisible ).to.be.true;
-						expect( listView.items ).to.have.length( 1 );
+						expect( editor.model.markers.has( 'mention' ) ).to.be.true;
+						expect( mentionsView.items ).to.have.length( 1 );
 					} );
 			} );
 
@@ -338,7 +588,7 @@ describe( 'MentionUI', () => {
 
 				return waitForDebounce()
 					.then( () => {
-						const button = listView.items.get( 0 ).children.get( 0 );
+						const button = mentionsView.items.get( 0 ).children.get( 0 );
 
 						expect( button.isOn ).to.be.true;
 					} );
@@ -361,7 +611,8 @@ describe( 'MentionUI', () => {
 					.then( waitForDebounce )
 					.then( () => {
 						expect( panelView.isVisible ).to.be.false;
-						expect( listView.items ).to.have.length( 0 );
+						expect( editor.model.markers.has( 'mention' ) ).to.be.false;
+						expect( mentionsView.items ).to.have.length( 0 );
 					} );
 			} );
 
@@ -389,7 +640,7 @@ describe( 'MentionUI', () => {
 
 		describe( 'asynchronous list with custom trigger', () => {
 			beforeEach( () => {
-				const issuesNumbers = [ '100', '101', '102', '103' ];
+				const issuesNumbers = [ '#100', '#101', '#102', '#103' ];
 
 				return createClassicTestEditor( {
 					feeds: [
@@ -417,7 +668,8 @@ describe( 'MentionUI', () => {
 				return waitForDebounce()
 					.then( () => {
 						expect( panelView.isVisible ).to.be.true;
-						expect( listView.items ).to.have.length( 4 );
+						expect( editor.model.markers.has( 'mention' ) ).to.be.true;
+						expect( mentionsView.items ).to.have.length( 4 );
 					} );
 			} );
 
@@ -435,7 +687,8 @@ describe( 'MentionUI', () => {
 				return waitForDebounce()
 					.then( () => {
 						expect( panelView.isVisible ).to.be.true;
-						expect( listView.items ).to.have.length( 1 );
+						expect( editor.model.markers.has( 'mention' ) ).to.be.true;
+						expect( mentionsView.items ).to.have.length( 1 );
 					} );
 			} );
 
@@ -456,7 +709,8 @@ describe( 'MentionUI', () => {
 					.then( waitForDebounce )
 					.then( () => {
 						expect( panelView.isVisible ).to.be.false;
-						expect( listView.items ).to.have.length( 0 );
+						expect( editor.model.markers.has( 'mention' ) ).to.be.false;
+						expect( mentionsView.items ).to.have.length( 0 );
 					} );
 			} );
 
@@ -496,6 +750,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,
@@ -504,6 +759,7 @@ describe( 'MentionUI', () => {
 					} );
 
 					expect( panelView.isVisible ).to.be.false;
+					expect( editor.model.markers.has( 'mention' ) ).to.be.false;
 				} );
 		} );
 
@@ -519,14 +775,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>' );
@@ -538,18 +821,28 @@ 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;
 				} );
 		} );
 
 		describe( 'default list item', () => {
-			const feedItems = staticConfig.feeds[ 0 ].feed.map( name => ( { name } ) );
+			// Create map of expected feed items as objects as they will be stored internally.
+			const feedItems = staticConfig.feeds[ 0 ].feed.map( text => ( { text: `${ text }`, id: `${ text }` } ) );
 
 			beforeEach( () => {
 				return createClassicTestEditor( staticConfig );
@@ -634,13 +927,166 @@ describe( 'MentionUI', () => {
 			} );
 		} );
 
-		describe( 'custom list item', () => {
+		describe( 'default list item with custom feed', () => {
 			const issues = [
-				{ id: '1002', title: 'Some bug in editor.' },
-				{ id: '1003', title: 'Introduce this feature.' },
-				{ id: '1004', title: 'Missing docs.' },
-				{ id: '1005', title: 'Another bug.' },
-				{ id: '1006', title: 'More bugs' }
+				{ id: '@Ted' },
+				{ id: '@Barney' },
+				{ id: '@Robin' },
+				{ id: '@Lily' },
+				{ id: '@Marshal' }
+			];
+
+			beforeEach( () => {
+				return createClassicTestEditor( {
+					feeds: [
+						{
+							marker: '@',
+							feed: feedText => issues.filter( issue => issue.id.includes( feedText ) )
+						}
+					]
+				} );
+			} );
+
+			it( 'should show panel for matched marker', () => {
+				setData( model, '<paragraph>foo []</paragraph>' );
+
+				model.change( writer => {
+					writer.insertText( '@', 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( 5 );
+					} );
+			} );
+		} );
+
+		describe( 'custom list item (string)', () => {
+			const issues = [
+				{ id: '@1002', title: 'Some bug in editor.' },
+				{ id: '@1003', title: 'Introduce this feature.' },
+				{ id: '@1004', title: 'Missing docs.' },
+				{ id: '@1005', title: 'Another bug.' },
+				{ id: '@1006', title: 'More bugs' }
+			];
+
+			beforeEach( () => {
+				return createClassicTestEditor( {
+					feeds: [
+						{
+							marker: '@',
+							feed: issues,
+							itemRenderer: item => item.title
+						}
+					]
+				} );
+			} );
+
+			it( 'should show panel for matched marker', () => {
+				setData( model, '<paragraph>foo []</paragraph>' );
+
+				model.change( writer => {
+					writer.insertText( '@', 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( 5 );
+					} );
+			} );
+
+			describe( 'keys', () => {
+				describe( 'on arrows', () => {
+					it( 'should cycle down on arrow down', () => {
+						setData( model, '<paragraph>foo []</paragraph>' );
+
+						model.change( writer => {
+							writer.insertText( '@', doc.selection.getFirstPosition() );
+						} );
+
+						return waitForDebounce()
+							.then( () => {
+								expectChildViewsIsOnState( [ true, false, false, false, false ] );
+
+								const keyEvtData = {
+									keyCode: keyCodes.arrowdown,
+									preventDefault: sinon.spy(),
+									stopPropagation: sinon.spy()
+								};
+
+								fireKeyDownEvent( keyEvtData );
+								expectChildViewsIsOnState( [ false, true, false, false, false ] );
+
+								fireKeyDownEvent( keyEvtData );
+								expectChildViewsIsOnState( [ false, false, true, false, false ] );
+
+								fireKeyDownEvent( keyEvtData );
+								expectChildViewsIsOnState( [ false, false, false, true, false ] );
+
+								fireKeyDownEvent( keyEvtData );
+								expectChildViewsIsOnState( [ false, false, false, false, true ] );
+
+								fireKeyDownEvent( keyEvtData );
+								expectChildViewsIsOnState( [ true, false, false, false, false ] );
+							} );
+					} );
+
+					it( 'should cycle up on arrow up', () => {
+						setData( model, '<paragraph>foo []</paragraph>' );
+
+						model.change( writer => {
+							writer.insertText( '@', doc.selection.getFirstPosition() );
+						} );
+
+						return waitForDebounce()
+							.then( () => {
+								expectChildViewsIsOnState( [ true, false, false, false, false ] );
+
+								const keyEvtData = {
+									keyCode: keyCodes.arrowup,
+									preventDefault: sinon.spy(),
+									stopPropagation: sinon.spy()
+								};
+
+								fireKeyDownEvent( keyEvtData );
+								expectChildViewsIsOnState( [ false, false, false, false, true ] );
+
+								fireKeyDownEvent( keyEvtData );
+								expectChildViewsIsOnState( [ false, false, false, true, false ] );
+
+								fireKeyDownEvent( keyEvtData );
+								expectChildViewsIsOnState( [ false, false, true, false, false ] );
+
+								fireKeyDownEvent( keyEvtData );
+								expectChildViewsIsOnState( [ false, true, false, false, false ] );
+
+								fireKeyDownEvent( keyEvtData );
+								expectChildViewsIsOnState( [ true, false, false, false, false ] );
+							} );
+					} );
+				} );
+
+				describe( 'on "execute" keys', () => {
+					testExecuteKey( 'enter', keyCodes.enter, issues );
+
+					testExecuteKey( 'tab', keyCodes.tab, issues );
+
+					testExecuteKey( 'space', keyCodes.space, issues );
+				} );
+			} );
+		} );
+
+		describe( 'custom list item (DOM Element)', () => {
+			const issues = [
+				{ id: '@1002', title: 'Some bug in editor.' },
+				{ id: '@1003', title: 'Introduce this feature.' },
+				{ id: '@1004', title: 'Missing docs.' },
+				{ id: '@1005', title: 'Another bug.' },
+				{ id: '@1006', title: 'More bugs' }
 			];
 
 			beforeEach( () => {
@@ -654,7 +1100,7 @@ describe( 'MentionUI', () => {
 							itemRenderer: item => {
 								const span = global.document.createElementNS( 'http://www.w3.org/1999/xhtml', 'span' );
 
-								span.innerHTML = `<span id="issue-${ item.id }">@${ item.title }</span>`;
+								span.innerHTML = `<span id="issue-${ item.id.slice( 1 ) }">@${ item.title }</span>`;
 
 								return span;
 							}
@@ -673,7 +1119,8 @@ describe( 'MentionUI', () => {
 				return waitForDebounce()
 					.then( () => {
 						expect( panelView.isVisible ).to.be.true;
-						expect( listView.items ).to.have.length( 5 );
+						expect( editor.model.markers.has( 'mention' ) ).to.be.true;
+						expect( mentionsView.items ).to.have.length( 5 );
 					} );
 			} );
 
@@ -794,6 +1241,73 @@ describe( 'MentionUI', () => {
 			} );
 		} );
 
+		describe( 'multiple feeds configuration', () => {
+			beforeEach( () => {
+				return createClassicTestEditor( {
+					feeds: [
+						{
+							marker: '@',
+							feed: [ '@a1', '@a2', '@a3' ]
+						},
+						{
+							marker: '$',
+							feed: [ '$a1', '$a2', '$a3', '$a4', '$a5' ]
+						}
+					]
+				} );
+			} );
+
+			it( 'should show panel for matched marker', () => {
+				setData( model, '<paragraph>foo []</paragraph>' );
+
+				model.change( writer => {
+					writer.insertText( '@', 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( 3 );
+
+						mentionsView.items.get( 0 ).children.get( 0 ).fire( 'execute' );
+					} )
+					.then( waitForDebounce )
+					.then( () => {
+						expect( panelView.isVisible ).to.be.false;
+						expect( editor.model.markers.has( 'mention' ) ).to.be.false;
+
+						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;
+						expect( mentionsView.items ).to.have.length( 5 );
+
+						mentionsView.items.get( 0 ).children.get( 0 ).fire( 'execute' );
+					} )
+					.then( waitForDebounce )
+					.then( () => {
+						expect( panelView.isVisible ).to.be.false;
+						expect( editor.model.markers.has( 'mention' ) ).to.be.false;
+
+						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;
+
+						expect( mentionsView.items ).to.have.length( 3 );
+					} );
+			} );
+		} );
+
 		function testExecuteKey( name, keyCode, feedItems ) {
 			it( 'should execute selected button on ' + name, () => {
 				setData( model, '<paragraph>foo []</paragraph>' );
@@ -847,6 +1361,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,
@@ -855,6 +1370,7 @@ describe( 'MentionUI', () => {
 						} );
 
 						expect( panelView.isVisible ).to.be.false;
+						expect( editor.model.markers.has( 'mention' ) ).to.be.false;
 
 						fireKeyDownEvent( {
 							keyCode,
@@ -865,6 +1381,7 @@ describe( 'MentionUI', () => {
 						sinon.assert.notCalled( spy );
 
 						expect( panelView.isVisible ).to.be.false;
+						expect( editor.model.markers.has( 'mention' ) ).to.be.false;
 					} );
 			} );
 		}
@@ -885,13 +1402,13 @@ describe( 'MentionUI', () => {
 
 			return waitForDebounce()
 				.then( () => {
-					listView.items.get( 0 ).children.get( 0 ).fire( 'execute' );
+					mentionsView.items.get( 0 ).children.get( 0 ).fire( 'execute' );
 
 					sinon.assert.calledOnce( spy );
 
 					const commandOptions = spy.getCall( 0 ).args[ 0 ];
 
-					assertCommandOptions( commandOptions, '@', { name: 'Barney' } );
+					assertCommandOptions( commandOptions, '@', { id: '@Barney', text: '@Barney' } );
 
 					const start = model.createPositionAt( doc.getRoot().getChild( 0 ), 4 );
 					const expectedRange = model.createRange( start, start.getShiftedBy( 1 ) );
@@ -909,9 +1426,10 @@ describe( 'MentionUI', () => {
 
 			return waitForDebounce()
 				.then( () => {
-					listView.items.get( 0 ).children.get( 0 ).fire( 'execute' );
+					mentionsView.items.get( 0 ).children.get( 0 ).fire( 'execute' );
 
 					expect( panelView.isVisible ).to.be.false;
+					expect( editor.model.markers.has( 'mention' ) ).to.be.false;
 				} );
 		} );
 
@@ -926,7 +1444,7 @@ describe( 'MentionUI', () => {
 
 			return waitForDebounce()
 				.then( () => {
-					listView.items.get( 0 ).children.get( 0 ).fire( 'execute' );
+					mentionsView.items.get( 0 ).children.get( 0 ).fire( 'execute' );
 
 					sinon.assert.calledOnce( focusSpy );
 				} );
@@ -947,16 +1465,6 @@ describe( 'MentionUI', () => {
 				mentionUI = editor.plugins.get( MentionUI );
 				panelView = mentionUI.panelView;
 				mentionsView = mentionUI._mentionsView;
-				listView = mentionsView.listView;
-
-				editingView.attachDomRoot( editorElement );
-
-				// Focus the engine.
-				editingView.document.isFocused = true;
-				editingView.getDomRoot().focus();
-
-				// Remove all selection ranges from DOM before testing.
-				window.getSelection().removeAllRanges();
 			} );
 	}
 
@@ -992,7 +1500,7 @@ describe( 'MentionUI', () => {
 	}
 
 	function expectChildViewsIsOnState( expectedState ) {
-		const childViews = [ ...listView.items ].map( listView => listView.children.get( 0 ) );
+		const childViews = [ ...mentionsView.items ].map( item => item.children.get( 0 ) );
 
 		expect( childViews.map( child => child.isOn ) ).to.deep.equal( expectedState );
 	}

+ 1 - 1
packages/ckeditor5-mention/theme/mentionediting.css → packages/ckeditor5-mention/theme/mention.css

@@ -1,4 +1,4 @@
 /*
  * Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
- * For licensing, see LICENSE.md.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
  */

+ 27 - 0
packages/ckeditor5-mention/theme/mentionui.css

@@ -0,0 +1,27 @@
+/*
+ * Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+:root {
+	--ck-mention-list-max-height: 300px;
+}
+
+.ck.ck-mentions {
+	max-height: var(--ck-mention-list-max-height);
+
+	overflow-y: auto;
+
+	/* Prevent unnecessary horizontal scrollbar in Safari
+	https://github.com/ckeditor/ckeditor5-mention/issues/41 */
+	overflow-x: hidden;
+
+	overscroll-behavior: contain;
+
+	/* Prevent unnecessary vertical scrollbar in Safari
+	https://github.com/ckeditor/ckeditor5-mention/issues/41 */
+	& > .ck-list__item {
+		overflow: hidden;
+		flex-shrink: 0;
+	}
+}