Jelajahi Sumber

Merge pull request #2 from ckeditor/t/ckeditor5/998

Feature: The mentions feature. Closes ckeditor/ckeditor5#998.
Piotrek Koszuliński 6 tahun lalu
induk
melakukan
6ebe53f72a
34 mengubah file dengan 3719 tambahan dan 1 penghapusan
  1. 0 0
      packages/ckeditor5-mention/docs/_snippets/features/build-mention-source.html
  2. 13 0
      packages/ckeditor5-mention/docs/_snippets/features/build-mention-source.js
  3. 14 0
      packages/ckeditor5-mention/docs/_snippets/features/custom-mention-colors-variables.html
  4. 31 0
      packages/ckeditor5-mention/docs/_snippets/features/custom-mention-colors-variables.js
  5. 22 0
      packages/ckeditor5-mention/docs/_snippets/features/mention-customization.html
  6. 153 0
      packages/ckeditor5-mention/docs/_snippets/features/mention-customization.js
  7. 3 0
      packages/ckeditor5-mention/docs/_snippets/features/mention.html
  8. 31 0
      packages/ckeditor5-mention/docs/_snippets/features/mention.js
  9. 34 0
      packages/ckeditor5-mention/docs/api/mention.md
  10. 323 0
      packages/ckeditor5-mention/docs/features/mention.md
  11. 6 1
      packages/ckeditor5-mention/package.json
  12. 115 0
      packages/ckeditor5-mention/src/mention.js
  13. 86 0
      packages/ckeditor5-mention/src/mentioncommand.js
  14. 180 0
      packages/ckeditor5-mention/src/mentionediting.js
  15. 493 0
      packages/ckeditor5-mention/src/mentionui.js
  16. 118 0
      packages/ckeditor5-mention/src/textwatcher.js
  17. 76 0
      packages/ckeditor5-mention/src/ui/domwrapperview.js
  18. 24 0
      packages/ckeditor5-mention/src/ui/mentionlistitemview.js
  19. 84 0
      packages/ckeditor5-mention/src/ui/mentionsview.js
  20. 22 0
      packages/ckeditor5-mention/tests/manual/mention-custom-renderer.html
  21. 67 0
      packages/ckeditor5-mention/tests/manual/mention-custom-renderer.js
  22. 37 0
      packages/ckeditor5-mention/tests/manual/mention-custom-renderer.md
  23. 24 0
      packages/ckeditor5-mention/tests/manual/mention-custom-view.html
  24. 107 0
      packages/ckeditor5-mention/tests/manual/mention-custom-view.js
  25. 33 0
      packages/ckeditor5-mention/tests/manual/mention-custom-view.md
  26. 4 0
      packages/ckeditor5-mention/tests/manual/mention.html
  27. 41 0
      packages/ckeditor5-mention/tests/manual/mention.js
  28. 34 0
      packages/ckeditor5-mention/tests/manual/mention.md
  29. 89 0
      packages/ckeditor5-mention/tests/mention-integration.js
  30. 50 0
      packages/ckeditor5-mention/tests/mention.js
  31. 126 0
      packages/ckeditor5-mention/tests/mentioncommand.js
  32. 281 0
      packages/ckeditor5-mention/tests/mentionediting.js
  33. 994 0
      packages/ckeditor5-mention/tests/mentionui.js
  34. 4 0
      packages/ckeditor5-mention/theme/mentionediting.css

+ 0 - 0
packages/ckeditor5-mention/docs/_snippets/features/build-mention-source.html


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

@@ -0,0 +1,13 @@
+/**
+ * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* globals window */
+
+import ClassicEditor from '@ckeditor/ckeditor5-build-classic/src/ckeditor';
+import Mention from '@ckeditor/ckeditor5-mention/src/mention';
+
+ClassicEditor.builtinPlugins.push( Mention );
+
+window.ClassicEditor = ClassicEditor;

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

@@ -0,0 +1,14 @@
+<style>
+	/* We are not using here :root because of many instances of editor */
+	#snippet-mention-custom-colors + .ck-editor {
+		/* 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%);
+	}
+</style>
+
+<div id="snippet-mention-custom-colors">
+	<p>Hello <span class="mention" data-mention="Ted">@Ted</span>.</p>
+</div>

+ 31 - 0
packages/ckeditor5-mention/docs/_snippets/features/custom-mention-colors-variables.js

@@ -0,0 +1,31 @@
+/**
+ * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* globals ClassicEditor, console, window, document */
+
+import { CS_CONFIG } from '@ckeditor/ckeditor5-cloud-services/tests/_utils/cloud-services-config';
+
+ClassicEditor
+	.create( document.querySelector( '#snippet-mention-custom-colors' ), {
+		cloudServices: CS_CONFIG,
+		toolbar: {
+			items: [
+				'heading', '|', 'bold', 'italic', '|', 'undo', 'redo'
+			],
+			viewportTopOffset: window.getViewportTopOffsetConfig()
+		},
+		mention: [
+			{
+				marker: '@',
+				feed: [ 'Barney', 'Lily', 'Marshall', 'Robin', 'Ted' ]
+			}
+		]
+	} )
+	.then( editor => {
+		window.editor = editor;
+	} )
+	.catch( err => {
+		console.error( err.stack );
+	} );

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

@@ -0,0 +1,22 @@
+<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>
+</div>
+
+<style>
+	.custom-item {
+		display: block;
+		padding: 1em;
+	}
+
+	.custom-item.ck-on {
+		color: white;
+	}
+
+	.custom-item .custom-item-username {
+		color: #666;
+	}
+
+	.custom-item.ck-on .custom-item-username{
+		color: #ddd;
+	}
+</style>

+ 153 - 0
packages/ckeditor5-mention/docs/_snippets/features/mention-customization.js

@@ -0,0 +1,153 @@
+/**
+ * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* 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 ],
+		toolbar: {
+			items: [
+				'heading', '|', 'bold', 'italic', '|', 'undo', 'redo'
+			],
+			viewportTopOffset: window.getViewportTopOffsetConfig(),
+		},
+		mention: [
+			{
+				marker: '@',
+				feed: getFeedItems,
+				itemRenderer: customItemRenderer,
+				minimumCharacters: 1
+			}
+		]
+	} )
+	.then( editor => {
+		window.editor = editor;
+	} )
+	.catch( err => {
+		console.error( err.stack );
+	} );
+
+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
+	} );
+}
+
+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( () => {
+			resolve( items.filter( isItemMatching ) );
+		}, 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 );
+	}
+}
+
+function customItemRenderer( item ) {
+	const span = document.createElement( 'span' );
+
+	span.classList.add( 'custom-item' );
+	span.id = `mention-list-item-id-${ item.id }`;
+
+	span.innerHTML = `${ item.name } <span class="custom-item-username">@${ item.username }</span>`;
+
+	return span;
+}

+ 3 - 0
packages/ckeditor5-mention/docs/_snippets/features/mention.html

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

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

@@ -0,0 +1,31 @@
+/**
+ * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* globals ClassicEditor, console, window, document */
+
+import { CS_CONFIG } from '@ckeditor/ckeditor5-cloud-services/tests/_utils/cloud-services-config';
+
+ClassicEditor
+	.create( document.querySelector( '#snippet-mention' ), {
+		cloudServices: CS_CONFIG,
+		toolbar: {
+			items: [
+				'heading', '|', 'bold', 'italic', '|', 'undo', 'redo'
+			],
+			viewportTopOffset: window.getViewportTopOffsetConfig()
+		},
+		mention: [
+			{
+				marker: '@',
+				feed: [ 'Barney', 'Lily', 'Marshall', 'Robin', 'Ted' ]
+			}
+		]
+	} )
+	.then( editor => {
+		window.editor = editor;
+	} )
+	.catch( err => {
+		console.error( err.stack );
+	} );

+ 34 - 0
packages/ckeditor5-mention/docs/api/mention.md

@@ -0,0 +1,34 @@
+---
+category: api-reference
+---
+
+# Mention feature for CKEditor 5
+
+[![npm version](https://badge.fury.io/js/%40ckeditor%2Fckeditor5-mention.svg)](https://www.npmjs.com/package/@ckeditor/ckeditor5-mention)
+
+This package implements the mention feature for CKEditor 5. This features provides smart completion functionality for custom text matches based on user input.
+
+## Demo
+
+Check out the {@link features/mention#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.
+
+## Installation
+
+```bash
+npm install --save @ckeditor/ckeditor5-mention
+```
+
+## Contribute
+
+The source code of this package is available on GitHub in https://github.com/ckeditor/ckeditor5-mention.
+
+## External links
+
+* [`@ckeditor/ckeditor5-mention` on npm](https://www.npmjs.com/package/@ckeditor/ckeditor5-mention)
+* [`ckeditor/ckeditor5-mention` on GitHub](https://github.com/ckeditor/ckeditor5-mention)
+* [Issue tracker](https://github.com/ckeditor/ckeditor5-mention/issues)
+* [Changelog](https://github.com/ckeditor/ckeditor5-mention/blob/master/CHANGELOG.md)

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

@@ -0,0 +1,323 @@
+---
+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.

+ 6 - 1
packages/ckeditor5-mention/package.json

@@ -10,7 +10,9 @@
     "ckeditor5-plugin"
     "ckeditor5-plugin"
   ],
   ],
   "dependencies": {
   "dependencies": {
-    "@ckeditor/ckeditor5-core": "^12.0.0"
+    "@ckeditor/ckeditor5-core": "^12.0.0",
+    "@ckeditor/ckeditor5-ui": "^12.0.0",
+    "@ckeditor/ckeditor5-utils": "^12.0.0"
   },
   },
   "devDependencies": {
   "devDependencies": {
     "@ckeditor/ckeditor5-basic-styles": "^11.0.0",
     "@ckeditor/ckeditor5-basic-styles": "^11.0.0",
@@ -20,8 +22,11 @@
     "@ckeditor/ckeditor5-enter": "^11.0.0",
     "@ckeditor/ckeditor5-enter": "^11.0.0",
     "@ckeditor/ckeditor5-heading": "^11.0.0",
     "@ckeditor/ckeditor5-heading": "^11.0.0",
     "@ckeditor/ckeditor5-link": "^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-typing": "^12.0.0",
     "@ckeditor/ckeditor5-undo": "^11.0.0",
     "@ckeditor/ckeditor5-undo": "^11.0.0",
+    "@ckeditor/ckeditor5-widget": "^11.0.0",
     "eslint": "^5.5.0",
     "eslint": "^5.5.0",
     "eslint-config-ckeditor5": "^1.0.11",
     "eslint-config-ckeditor5": "^1.0.11",
     "husky": "^1.3.1",
     "husky": "^1.3.1",

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

@@ -0,0 +1,115 @@
+/**
+ * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module mention/mention
+ */
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+
+import MentionEditing from './mentionediting';
+import MentionUI from './mentionui';
+
+/**
+ * The mention plugin.
+ *
+ * For a detailed overview, check the {@glink features/mention Mention feature documentation}.
+ *
+ * @extends module:core/plugin~Plugin
+ */
+export default class Mention extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	static get pluginName() {
+		return 'Mention';
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	static get requires() {
+		return [ MentionEditing, MentionUI ];
+	}
+}
+
+/**
+ * The configuration of the {@link module:mention/mention~Mention} feature.
+ *
+ * Read more in {@link module:mention/mention~MentionConfig}.
+ *
+ * @member {module:mention/mention~MentionConfig} module:core/editor/editorconfig~EditorConfig#mention
+ * @type {Array.<module/mention~MentionFeed>}
+ */
+
+/**
+ * The mention feed descriptor. Used in {@link module:mention/mention~MentionConfig `config.mention`}.
+ *
+ * See {@link module:mention/mention~MentionConfig} to learn more.
+ *
+ *		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.
+ *
+ * *Note* When defining feed item as a plain object you must provide the at least the `name` property.
+ *
+ * @typedef {Object|String} module:mention/mention~MentionFeedItem
+ * @property {String} name Name of the mention.
+ */
+
+/**
+ * The list fo mention feeds supported by the editor.
+ *
+ *		ClassicEditor
+ *			.create( editorElement, {
+ *				plugins: [ Mention, ... ],
+ *				mention: {
+ *					feeds: [
+ *						{
+ *							marker: '@',
+ *							feed: [ 'Barney', 'Lily', 'Marshall', 'Robin', 'Ted' ]
+ *						},
+ *						...
+ * 					]
+ *				}
+ *			} )
+ *			.then( ... )
+ *			.catch( ... );
+ *
+ * You can provide as many mention feeds but they must have different `marker` defined.
+ *
+ * @member {Array.<module:mention/mention~MentionFeed>} module:mention/mention~MentionConfig#feeds
+ */
+
+/**
+ * The configuration of the mention features.
+ *
+ * Read more about {@glink features/mention#configuration configuring the mention feature}.
+ *
+ *		ClassicEditor
+ *			.create( editorElement, {
+ * 				mention: ... // Media embed feature options.
+ *			} )
+ *			.then( ... )
+ *			.catch( ... );
+ *
+ * See {@link module:core/editor/editorconfig~EditorConfig all editor options}.
+ *
+ * @interface MentionConfig
+ */

+ 86 - 0
packages/ckeditor5-mention/src/mentioncommand.js

@@ -0,0 +1,86 @@
+/**
+ * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module mention/mentioncommand
+ */
+
+import Command from '@ckeditor/ckeditor5-core/src/command';
+import toMap from '@ckeditor/ckeditor5-utils/src/tomap';
+import uid from '@ckeditor/ckeditor5-utils/src/uid';
+
+/**
+ * The mention command.
+ *
+ * The command is registered by the {@link module:mention/mentionediting~MentionEditing} as `'mention'`.
+ *
+ * To insert a mention on a range, execute the command and specify a mention object and a range to replace:
+ *
+ *		const focus = editor.model.document.selection.focus;
+ *
+ *		editor.execute( 'mention', {
+ *			mention: {
+ *				name: 'Foo',
+ *				id: '1234',
+ *				title: 'Big Foo'
+ *			},
+ *			marker: '#',
+ *			range: model.createRange( focus, focus.getShiftedBy( -1 ) )
+ *		} );
+ *
+ * @extends module:core/command~Command
+ */
+export default class MentionCommand extends Command {
+	/**
+	 * @inheritDoc
+	 */
+	refresh() {
+		const model = this.editor.model;
+		const doc = model.document;
+
+		this.isEnabled = model.schema.checkAttributeInSelection( doc.selection, 'mention' );
+	}
+
+	/**
+	 * Executes the 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.range] Range to replace. Note that replace range might be shorter then inserted text with mention attribute.
+	 * @fires execute
+	 */
+	execute( options ) {
+		const model = this.editor.model;
+		const document = model.document;
+		const selection = document.selection;
+
+		const marker = options.marker || '@';
+
+		const mention = typeof options.mention == 'string' ? { name: options.mention } : options.mention;
+
+		// Set internal attributes on mention object.
+		mention._id = uid();
+		mention._marker = marker;
+
+		const range = options.range || selection.getFirstRange();
+
+		model.change( writer => {
+			const currentAttributes = toMap( selection.getAttributes() );
+			const attributesWithMention = new Map( currentAttributes.entries() );
+			attributesWithMention.set( 'mention', mention );
+
+			const mentionText = `${ marker }${ mention.name }`;
+
+			// Replace range with a text with mention.
+			writer.remove( range );
+			writer.insertText( mentionText, attributesWithMention, range.start );
+
+			// Insert space after a mention.
+			writer.insertText( ' ', currentAttributes, model.document.selection.focus );
+		} );
+	}
+}

+ 180 - 0
packages/ckeditor5-mention/src/mentionediting.js

@@ -0,0 +1,180 @@
+/**
+ * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module mention/mentionediting
+ */
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+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">`.
+ *
+ * @extends module:core/plugin~Plugin
+ */
+export default class MentionEditing extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	static get pluginName() {
+		return 'MentionEditing';
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	init() {
+		const editor = this.editor;
+		const model = editor.model;
+		const doc = model.document;
+
+		// Allow mention attribute on all text nodes.
+		model.schema.extend( '$text', { allowAttributes: 'mention' } );
+
+		editor.conversion.for( 'upcast' ).elementToAttribute( {
+			view: {
+				name: 'span',
+				key: 'data-mention',
+				classes: 'mention'
+			},
+			model: {
+				key: 'mention',
+				value: parseMentionViewItemAttributes
+			}
+		} );
+
+		editor.conversion.for( 'downcast' ).attributeToElement( {
+			model: 'mention',
+			view: createViewMentionElement
+		} );
+
+		doc.registerPostFixer( writer => removePartialMentionPostFixer( 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;
+	}
+
+	const mentionString = textNode.data;
+
+	// Assume that mention is set as marker + mention name.
+	const marker = mentionString.slice( 0, 1 );
+	const name = mentionString.slice( 1 );
+
+	// Do not upcast partial mentions - might come from copy-paste of partially selected mention.
+	if ( name != dataMention ) {
+		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() };
+}
+
+// Creates mention element from mention data.
+//
+// @param {Object} mention
+// @param {module:engine/view/downcastwriter~DowncastWriter} viewWriter
+// @returns {module:engine/view/attributeelement~AttributeElement}
+function createViewMentionElement( mention, viewWriter ) {
+	if ( !mention ) {
+		return;
+	}
+
+	const attributes = {
+		class: 'mention',
+		'data-mention': mention.name
+	};
+
+	const options = {
+		id: mention._id
+	};
+
+	return viewWriter.createAttributeElement( 'span', attributes, options );
+}
+
+// Model post-fixer that disallows typing with selection when selection is placed after the text node with mention attribute.
+//
+// @param {module:engine/model/writer~Writer} writer
+// @param {module:engine/model/document~Document} doc
+// @returns {Boolean} Returns true if selection was fixed.
+function selectionMentionAttributePostFixer( writer, doc ) {
+	const selection = doc.selection;
+	const focus = selection.focus;
+
+	if ( selection.isCollapsed && selection.hasAttribute( 'mention' ) && isNodeBeforeAText( focus ) ) {
+		writer.removeSelectionAttribute( 'mention' );
+
+		return true;
+	}
+
+	function isNodeBeforeAText( position ) {
+		return position.nodeBefore && position.nodeBefore.is( 'text' );
+	}
+}
+
+// Model post-fixer that removes mention attribute from modified text node.
+//
+// @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 ) {
+	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;
+
+			if ( change.name == '$text' && textNode && textNode.hasAttribute( 'mention' ) ) {
+				writer.removeAttribute( 'mention', textNode );
+				wasChanged = true;
+			}
+		}
+
+		// Additional check for deleting last character of a text node.
+		if ( change.type == 'remove' ) {
+			const nodeBefore = change.position.nodeBefore;
+
+			if ( nodeBefore && nodeBefore.hasAttribute( 'mention' ) ) {
+				const text = nodeBefore.data;
+				const mention = nodeBefore.getAttribute( 'mention' );
+
+				const expectedText = mention._marker + mention.name;
+
+				if ( text != expectedText ) {
+					writer.removeAttribute( 'mention', nodeBefore );
+					wasChanged = true;
+				}
+			}
+		}
+	}
+
+	return wasChanged;
+}

+ 493 - 0
packages/ckeditor5-mention/src/mentionui.js

@@ -0,0 +1,493 @@
+/**
+ * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module mention/mentionui
+ */
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
+import Collection from '@ckeditor/ckeditor5-utils/src/collection';
+import BalloonPanelView from '@ckeditor/ckeditor5-ui/src/panel/balloon/balloonpanelview';
+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 TextWatcher from './textwatcher';
+
+import MentionsView from './ui/mentionsview';
+import DomWrapperView from './ui/domwrapperview';
+import MentionListItemView from './ui/mentionlistitemview';
+
+const VERTICAL_SPACING = 5;
+
+/**
+ * The mention UI feature.
+ *
+ * @extends module:core/plugin~Plugin
+ */
+export default class MentionUI extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	static get pluginName() {
+		return 'MentionUI';
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	constructor( editor ) {
+		super( editor );
+
+		/**
+		 * The balloon panel view, containing the mention view.
+		 *
+		 * @type {module:ui/panel/balloon/balloonpanelview~BalloonPanelView}
+		 */
+		this.panelView = this._creatPanelView();
+
+		/**
+		 * The mentions view.
+		 *
+		 * @type {module:mention/ui/mentionsview~MentionsView}
+		 * @private
+		 */
+		this._mentionsView = this._createMentionView();
+
+		/**
+		 * Stores mentions feeds configurations.
+		 *
+		 * @type {Map<String, Object>}
+		 * @private
+		 */
+		this._mentionsConfigurations = new Map();
+
+		editor.config.define( 'mention', { feeds: [] } );
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	init() {
+		// Key listener that handles navigation in mention view.
+		this.editor.editing.view.document.on( 'keydown', ( evt, data ) => {
+			if ( isHandledKey( data.keyCode ) && this.panelView.isVisible ) {
+				data.preventDefault();
+				evt.stop(); // Required for enter overriding.
+
+				if ( data.keyCode == keyCodes.arrowdown ) {
+					this._mentionsView.selectNext();
+				}
+
+				if ( data.keyCode == keyCodes.arrowup ) {
+					this._mentionsView.selectPrevious();
+				}
+
+				if ( data.keyCode == keyCodes.enter || data.keyCode == keyCodes.tab || data.keyCode == keyCodes.space ) {
+					this._mentionsView.executeSelected();
+				}
+
+				if ( data.keyCode == keyCodes.esc ) {
+					this._hidePanel();
+				}
+			}
+		}, { priority: 'highest' } ); // priority highest required for enter overriding.
+
+		// 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()
+		} );
+
+		const feeds = this.editor.config.get( 'mention.feeds' );
+
+		for ( const mentionDescription of feeds ) {
+			const feed = mentionDescription.feed;
+
+			const marker = mentionDescription.marker || '@';
+			const minimumCharacters = mentionDescription.minimumCharacters || 0;
+			const feedCallback = typeof feed == 'function' ? feed : createFeedCallback( feed );
+			const watcher = this._setupTextWatcherForFeed( marker, minimumCharacters );
+			const itemRenderer = mentionDescription.itemRenderer;
+
+			const definition = { watcher, marker, feedCallback, itemRenderer };
+
+			this._mentionsConfigurations.set( marker, definition );
+		}
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	destroy() {
+		super.destroy();
+
+		// Destroy created UI components as they are not automatically destroyed (see ckeditor5#1341).
+		this.panelView.destroy();
+	}
+
+	/**
+	 * Creates the {@link #panelView}.
+	 *
+	 * @private
+	 * @returns {module:ui/panel/balloon/balloonpanelview~BalloonPanelView}
+	 */
+	_creatPanelView() {
+		const panelView = new BalloonPanelView( this.editor.locale );
+
+		panelView.withArrow = false;
+		panelView.render();
+
+		this.editor.ui.view.body.add( panelView );
+
+		return panelView;
+	}
+
+	/**
+	 * Creates the {@link #_mentionsView}.
+	 *
+	 * @private
+	 * @returns {module:mention/ui/mentionsview~MentionsView}
+	 */
+	_createMentionView() {
+		const locale = this.editor.locale;
+
+		const mentionsView = new MentionsView( locale );
+
+		this._items = new Collection();
+
+		this.panelView.content.add( mentionsView );
+
+		mentionsView.listView.items.bindTo( this._items ).using( data => {
+			const { item, marker } = data;
+
+			const listItemView = new MentionListItemView( locale );
+
+			const view = this._renderItem( item, marker );
+			view.delegate( 'execute' ).to( listItemView );
+
+			listItemView.children.add( view );
+			listItemView.item = item;
+			listItemView.marker = marker;
+
+			listItemView.on( 'execute', () => {
+				mentionsView.fire( 'execute', {
+					item,
+					marker
+				} );
+			} );
+
+			return listItemView;
+		} );
+
+		mentionsView.on( 'execute', ( evt, data ) => {
+			const editor = this.editor;
+			const model = editor.model;
+
+			const item = data.item;
+			const marker = data.marker;
+
+			const watcher = this._getWatcher( marker );
+
+			const text = watcher.last;
+
+			const textMatcher = createTextMatcher( marker );
+			const matched = textMatcher( text );
+			const matchedTextLength = matched.marker.length + matched.feedText.length;
+
+			// Create a range on matched text.
+			const end = model.createPositionAt( model.document.selection.focus );
+			const start = end.getShiftedBy( -matchedTextLength );
+			const range = model.createRange( start, end );
+
+			editor.execute( 'mention', {
+				mention: item,
+				marker,
+				range
+			} );
+
+			this._hidePanel();
+		} );
+
+		return mentionsView;
+	}
+
+	/**
+	 * Returns item renderer for marker.
+	 *
+	 * @private
+	 * @param {String} marker
+	 * @returns {Function|null}
+	 */
+	_getItemRenderer( marker ) {
+		const { itemRenderer } = this._mentionsConfigurations.get( marker );
+
+		return itemRenderer;
+	}
+
+	/**
+	 * Returns a promise that resolves with autocomplete items for given text.
+	 *
+	 * @param {String} marker
+	 * @param {String} feedText
+	 * @return {Promise<module:mention/mention~MentionFeedItem>}
+	 * @private
+	 */
+	_getFeed( marker, feedText ) {
+		const { feedCallback } = this._mentionsConfigurations.get( marker );
+
+		return Promise.resolve().then( () => feedCallback( feedText ) );
+	}
+
+	/**
+	 * Registers a text watcher for marker.
+	 *
+	 * @private
+	 * @param {String} marker
+	 * @param {Number} minimumCharacters
+	 * @returns {module:mention/textwatcher~TextWatcher}
+	 */
+	_setupTextWatcherForFeed( marker, minimumCharacters ) {
+		const editor = this.editor;
+
+		const watcher = new TextWatcher( editor, createTestCallback( marker, minimumCharacters ), createTextMatcher( marker ) );
+
+		watcher.on( 'matched', ( evt, data ) => {
+			const matched = data.matched;
+
+			const selection = editor.model.document.selection;
+
+			const hasMention = selection.hasAttribute( 'mention' );
+			const nodeBefore = selection.focus.nodeBefore;
+
+			if ( hasMention || nodeBefore && nodeBefore.is( 'text' ) && nodeBefore.hasAttribute( 'mention' ) ) {
+				return;
+			}
+
+			const { feedText, marker } = matched;
+
+			this._getFeed( marker, feedText )
+				.then( feed => {
+					this._items.clear();
+
+					for ( const name of feed ) {
+						const item = typeof name != 'object' ? { name } : name;
+
+						this._items.add( { item, marker } );
+					}
+
+					if ( this._items.length ) {
+						this._showPanel();
+					} else {
+						this._hidePanel();
+					}
+				} );
+		} );
+
+		watcher.on( 'unmatched', () => {
+			this._hidePanel();
+		} );
+
+		return watcher;
+	}
+
+	/**
+	 * Returns registered text watcher for marker.
+	 *
+	 * @private
+	 * @param {String} marker
+	 * @returns {module:mention/textwatcher~TextWatcher}
+	 */
+	_getWatcher( marker ) {
+		const { watcher } = this._mentionsConfigurations.get( marker );
+
+		return watcher;
+	}
+
+	/**
+	 * Shows the {@link #panelView}. If panel is already visible it will reposition it.
+	 *
+	 * @private
+	 */
+	_showPanel() {
+		this.panelView.pin( this._getBalloonPanelPositionData() );
+		this.panelView.show();
+		this._mentionsView.selectFirst();
+	}
+
+	/**
+	 * Hides the {@link #panelView}.
+	 *
+	 * @private
+	 */
+	_hidePanel() {
+		this.panelView.unpin();
+		this.panelView.hide();
+	}
+
+	/**
+	 * Renders a single item in the autocomplete list.
+	 *
+	 * @private
+	 * @param {module:mention/mention~MentionFeedItem} item
+	 * @param {String} marker
+	 * @returns {module:ui/button/buttonview~ButtonView|module:mention/ui/domwrapperview~DomWrapperView}
+	 */
+	_renderItem( item, marker ) {
+		const editor = this.editor;
+
+		let view;
+
+		const renderer = this._getItemRenderer( marker );
+
+		if ( renderer ) {
+			const domNode = renderer( item );
+
+			view = new DomWrapperView( editor.locale, domNode );
+		} else {
+			const buttonView = new ButtonView( editor.locale );
+
+			buttonView.label = item.name;
+			buttonView.withText = true;
+
+			view = buttonView;
+		}
+
+		return view;
+	}
+
+	/**
+	 * @returns {module:utils/dom/position~Options}
+	 * @private
+	 */
+	_getBalloonPanelPositionData() {
+		const view = this.editor.editing.view;
+		const domConverter = view.domConverter;
+		const viewSelection = view.document.selection;
+
+		return {
+			target: () => {
+				const range = viewSelection.getLastRange();
+				const rangeRects = Rect.getDomRangeRects( domConverter.viewRangeToDom( range ) );
+
+				return rangeRects.pop();
+			},
+			positions: getBalloonPanelPositions()
+		};
+	}
+}
+
+// Returns balloon positions data callbacks.
+//
+// @returns {Array.<module:utils/dom/position~Position>}
+function getBalloonPanelPositions() {
+	return [
+		// Positions panel to the south of caret rect.
+		targetRect => {
+			return {
+				top: targetRect.bottom + VERTICAL_SPACING,
+				left: targetRect.right,
+				name: 'caret_se'
+			};
+		},
+
+		// Positions panel to the north of caret rect.
+		( targetRect, balloonRect ) => {
+			return {
+				top: targetRect.top - balloonRect.height - VERTICAL_SPACING,
+				left: targetRect.right,
+				name: 'caret_ne'
+			};
+		},
+
+		// Positions panel to the south of caret rect.
+		( targetRect, balloonRect ) => {
+			return {
+				top: targetRect.bottom + VERTICAL_SPACING,
+				left: targetRect.right - balloonRect.width,
+				name: 'caret_sw'
+			};
+		},
+
+		// Positions panel to the north of caret rect.
+		( targetRect, balloonRect ) => {
+			return {
+				top: targetRect.top - balloonRect.height - VERTICAL_SPACING,
+				left: targetRect.right - balloonRect.width,
+				name: 'caret_nw'
+			};
+		}
+	];
+}
+
+// Creates a regex pattern for marker.
+//
+// @param {String} marker
+// @param {Number} minimumCharacters
+// @returns {String}
+function createPattern( marker, minimumCharacters ) {
+	const numberOfCharacters = minimumCharacters == 0 ? '*' : `{${ minimumCharacters },}`;
+
+	return `(^| )(${ marker })([_a-zA-Z0-9À-ž]${ numberOfCharacters }?)$`;
+}
+
+// Creates a test callback for marker to be used in text watcher instance.
+//
+// @param {String} marker
+// @param {Number} minimumCharacters
+// @returns {Function}
+function createTestCallback( marker, minimumCharacters ) {
+	const regExp = new RegExp( createPattern( marker, minimumCharacters ) );
+
+	return text => regExp.test( text );
+}
+
+// Creates a text watcher matcher for marker.
+//
+// @param {String} marker
+// @returns {Function}
+function createTextMatcher( marker ) {
+	const regExp = new RegExp( createPattern( marker, 0 ) );
+
+	return text => {
+		const match = text.match( regExp );
+
+		const marker = match[ 2 ];
+		const feedText = match[ 3 ];
+
+		return { marker, feedText };
+	};
+}
+
+// Default feed callback
+function createFeedCallback( feedItems ) {
+	return feedText => {
+		const filteredItems = feedItems.filter( item => {
+			return item.toLowerCase().includes( feedText.toLowerCase() );
+		} );
+
+		return Promise.resolve( filteredItems );
+	};
+}
+
+// Checks if given key code is handled by the mention ui.
+//
+// @param {Number}
+// @returns {Boolean}
+function isHandledKey( keyCode ) {
+	const handledKeyCodes = [
+		keyCodes.arrowup,
+		keyCodes.arrowdown,
+		keyCodes.enter,
+		keyCodes.tab,
+		keyCodes.space,
+		keyCodes.esc
+	];
+
+	return handledKeyCodes.includes( keyCode );
+}

+ 118 - 0
packages/ckeditor5-mention/src/textwatcher.js

@@ -0,0 +1,118 @@
+/**
+ * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module mention/textwatcher
+ */
+
+import mix from '@ckeditor/ckeditor5-utils/src/mix';
+import EmitterMixin from '@ckeditor/ckeditor5-utils/src/emittermixin';
+
+/**
+ * Text watcher feature.
+ * @private
+ */
+export default class TextWatcher {
+	/**
+	 * Creates a text watcher instance.
+	 * @param {module:core/editor/editor~Editor} editor
+	 * @param {Function} testCallback Function used to match the text.
+	 * @param {Function} textMatcherCallback Function used to process matched text.
+	 */
+	constructor( editor, testCallback, textMatcherCallback ) {
+		this.editor = editor;
+		this.testCallback = testCallback;
+		this.textMatcher = textMatcherCallback;
+
+		this.hasMatch = false;
+
+		this._startListening();
+	}
+
+	/**
+	 * Last matched text.
+	 *
+	 * @property {String}
+	 */
+	get last() {
+		return this._getText();
+	}
+
+	/**
+	 * Starts listening the editor for typing & selection events.
+	 *
+	 * @private
+	 */
+	_startListening() {
+		const editor = this.editor;
+
+		editor.model.document.on( 'change', ( evt, batch ) => {
+			if ( batch.type == 'transparent' ) {
+				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;
+
+			if ( !isTypingChange && !isSelectionChange ) {
+				return;
+			}
+
+			const text = this._getText();
+
+			const textHasMatch = this.testCallback( text );
+
+			if ( !textHasMatch && this.hasMatch ) {
+				this.fire( 'unmatched' );
+			}
+
+			this.hasMatch = textHasMatch;
+
+			if ( textHasMatch ) {
+				const matched = this.textMatcher( text );
+
+				this.fire( 'matched', { text, matched } );
+			}
+		} );
+	}
+
+	/**
+	 * Returns the text before the caret from the current selection block.
+	 *
+	 * @returns {String|undefined} Text from block or undefined if selection is not collapsed.
+	 * @private
+	 */
+	_getText() {
+		const editor = this.editor;
+		const selection = editor.model.document.selection;
+
+		// Do nothing if selection is not collapsed.
+		if ( !selection.isCollapsed ) {
+			return;
+		}
+
+		const block = selection.focus.parent;
+
+		return getText( 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, '' );
+}
+
+mix( TextWatcher, EmitterMixin );
+

+ 76 - 0
packages/ckeditor5-mention/src/ui/domwrapperview.js

@@ -0,0 +1,76 @@
+/**
+ * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module mention/ui/domwrapperview
+ */
+
+import View from '@ckeditor/ckeditor5-ui/src/view';
+
+/**
+ * This class wraps DOM element as a CKEditor5 UI View.
+ *
+ * It allows to render any DOM element and use it in mentions list.
+ */
+export default class DomWrapperView extends View {
+	/**
+	 * Creates an instance of {@link module:mention/ui/domwrapperview~DomWrapperView} class.
+	 *
+	 * Also see {@link #render}.
+	 *
+	 * @param {module:utils/locale~Locale} [locale] The localization services instance.
+	 * @param {Element} domElement
+	 */
+	constructor( locale, domElement ) {
+		super( locale );
+
+		// Disable template rendering on this view.
+		this.template = false;
+
+		/**
+		 * The DOM element for which wrapper was created.
+		 *
+		 * @type {Element}
+		 */
+		this.domElement = domElement;
+
+		// Render dom wrapper as a button.
+		this.domElement.classList.add( 'ck-button' );
+
+		/**
+		 * Controls whether the dom wrapper view is "on". This is in line with {@link module:ui/button/button~Button#isOn} property.
+		 *
+		 * @observable
+		 * @default true
+		 * @member {Boolean} #isOn
+		 */
+		this.set( 'isOn', false );
+
+		// Handle isOn state as in buttons.
+		this.on( 'change:isOn', ( evt, name, isOn ) => {
+			if ( isOn ) {
+				this.domElement.classList.add( 'ck-on' );
+				this.domElement.classList.remove( 'ck-off' );
+			} else {
+				this.domElement.classList.add( 'ck-off' );
+				this.domElement.classList.remove( 'ck-on' );
+			}
+		} );
+
+		// Pass click event as execute event.
+		this.listenTo( this.domElement, 'click', () => {
+			this.fire( 'execute' );
+		} );
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	render() {
+		super.render();
+
+		this.element = this.domElement;
+	}
+}

+ 24 - 0
packages/ckeditor5-mention/src/ui/mentionlistitemview.js

@@ -0,0 +1,24 @@
+/**
+ * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module mention/ui/mentionlistitemview
+ */
+
+import ListItemView from '@ckeditor/ckeditor5-ui/src/list/listitemview';
+
+export default class MentionListItemView extends ListItemView {
+	highlight() {
+		const child = this.children.first;
+
+		child.isOn = true;
+	}
+
+	removeHighlight() {
+		const child = this.children.first;
+
+		child.isOn = false;
+	}
+}

+ 84 - 0
packages/ckeditor5-mention/src/ui/mentionsview.js

@@ -0,0 +1,84 @@
+/**
+ * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module mention/ui/mentionsview
+ */
+
+import View from '@ckeditor/ckeditor5-ui/src/view';
+import ListView from '@ckeditor/ckeditor5-ui/src/list/listview';
+
+/**
+ * The mention ui view.
+ *
+ * @extends module:ui/view~View
+ */
+export default class MentionsView extends View {
+	constructor( locale ) {
+		super( locale );
+
+		this.listView = new ListView( locale );
+
+		this.setTemplate( {
+			tag: 'div',
+
+			attributes: {
+				class: [
+					'ck',
+					'ck-mention'
+				],
+
+				tabindex: '-1'
+			},
+
+			children: [
+				this.listView
+			]
+		} );
+	}
+
+	selectFirst() {
+		this.select( 0 );
+	}
+
+	selectNext() {
+		const item = this.selected;
+
+		const index = this.listView.items.getIndex( item );
+
+		this.select( index + 1 );
+	}
+
+	selectPrevious() {
+		const item = this.selected;
+
+		const index = this.listView.items.getIndex( item );
+
+		this.select( index - 1 );
+	}
+
+	select( index ) {
+		let indexToGet = 0;
+
+		if ( index > 0 && index < this.listView.items.length ) {
+			indexToGet = index;
+		} else if ( index < 0 ) {
+			indexToGet = this.listView.items.length - 1;
+		}
+
+		const item = this.listView.items.get( indexToGet );
+		item.highlight();
+
+		if ( this.selected ) {
+			this.selected.removeHighlight();
+		}
+
+		this.selected = item;
+	}
+
+	executeSelected() {
+		this.selected.fire( 'execute' );
+	}
+}

+ 22 - 0
packages/ckeditor5-mention/tests/manual/mention-custom-renderer.html

@@ -0,0 +1,22 @@
+<div id="editor">
+	<p>Have you met... </p>
+</div>
+
+<style>
+	.custom-item {
+		display: block;
+		padding: 1em;
+	}
+
+	.custom-item.ck-on {
+		color: white;
+	}
+
+	.custom-item .custom-item-username {
+		color: #666;
+	}
+
+	.custom-item.ck-on .custom-item-username{
+		color: #ddd;
+	}
+</style>

+ 67 - 0
packages/ckeditor5-mention/tests/manual/mention-custom-renderer.js

@@ -0,0 +1,67 @@
+/**
+ * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* global console, window */
+
+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';
+
+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' ],
+		mention: {
+			feeds: [
+				{
+					feed: getFeed,
+					itemRenderer: item => {
+						const span = global.document.createElementNS( 'http://www.w3.org/1999/xhtml', 'span' );
+
+						span.classList.add( 'custom-item' );
+						span.id = `mention-list-item-id-${ item.id }`;
+
+						span.innerHTML = `${ item.name } <span class="custom-item-username">@${ item.username }</span>`;
+
+						return span;
+					}
+				}
+			]
+		}
+	} )
+	.then( editor => {
+		window.editor = editor;
+	} )
+	.catch( err => {
+		console.error( err.stack );
+	} );
+
+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' }
+	].filter( item => {
+		const searchString = feedText.toLowerCase();
+
+		return item.name.toLowerCase().includes( searchString ) || item.username.toLowerCase().includes( searchString );
+	} ) );
+}

+ 37 - 0
packages/ckeditor5-mention/tests/manual/mention-custom-renderer.md

@@ -0,0 +1,37 @@
+## Mention
+
+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 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' }`
+
+The item is rendered as `<span>` instead of default button.
+
+### Interaction
+
+You can interact with mention panel with keyboard:
+
+- Move arrows up/down to select an item.
+- Use <kbd>enter</kbd> or <kbd>tab</kbd> to insert a mention into the documentation. 
+- The <kbd>esc</kbd> should close the panel.
+
+Mention panel should be closed on:
+- Click outside the panel view.
+- Changing selection - like placing it in other part of text.
+
+### Editing behavior:
+
+The mention should be removed from the text when:
+
+- typing inside a mention
+- removing characters from a mention
+- breaking the mention (<kbd>enter</kbd>)
+- pasting part of a mention

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

@@ -0,0 +1,24 @@
+<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>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>
+	.custom-item {
+		display: block;
+		padding: 1em;
+	}
+
+	.custom-item.ck-on {
+		color: white;
+	}
+
+	.custom-item .custom-item-username {
+		color: #666;
+	}
+
+	.custom-item.ck-on .custom-item-username{
+		color: #ddd;
+	}
+</style>

+ 107 - 0
packages/ckeditor5-mention/tests/manual/mention-custom-view.js

@@ -0,0 +1,107 @@
+/**
+ * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* global console, window */
+
+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 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;
+
+class CustomMentionAttributeView extends Plugin {
+	init() {
+		const editor = this.editor;
+
+		editor.conversion.for( 'upcast' ).elementToAttribute( {
+			view: {
+				name: 'a',
+				key: 'data-mention',
+				classes: 'mention',
+				attributes: {
+					href: true
+				}
+			},
+			model: {
+				key: 'mention',
+				value: viewItem => {
+					const mentionValue = {
+						name: viewItem.getAttribute( 'data-mention' ),
+						link: viewItem.getAttribute( 'href' )
+					};
+
+					return mentionValue;
+				}
+			},
+			converterPriority: HIGHER_THEN_HIGHEST
+		} );
+
+		editor.conversion.for( 'downcast' ).attributeToElement( {
+			model: 'mention',
+			view: ( modelAttributeValue, viewWriter ) => {
+				if ( !modelAttributeValue ) {
+					return;
+				}
+
+				return viewWriter.createAttributeElement( 'a', {
+					class: 'mention',
+					'data-mention': modelAttributeValue.name,
+					'href': modelAttributeValue.link
+				} );
+			},
+			converterPriority: HIGHER_THEN_HIGHEST
+		} );
+	}
+}
+
+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' ],
+		mention: {
+			feeds: [
+				{ feed: getFeed }
+			]
+		}
+	} )
+	.then( editor => {
+		window.editor = editor;
+	} )
+	.catch( err => {
+		console.error( err.stack );
+	} );
+
+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' }
+	].filter( item => {
+		const searchString = feedText.toLowerCase();
+
+		return item.name.toLowerCase().includes( searchString );
+	} );
+}

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

@@ -0,0 +1,33 @@
+## Mention
+
+This sample overrides default mention conversion to `<span>`. The mention is converted to a link (`<a>`).
+
+### 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' }`
+
+### Interaction
+
+You can interact with mention panel with keyboard:
+
+- Move arrows up/down to select an item.
+- Use <kbd>enter</kbd> or <kbd>tab</kbd> to insert a mention into the documentation. 
+- The <kbd>esc</kbd> should close the panel.
+
+Mention panel should be closed on:
+- Click outside the panel view.
+- Changing selection - like placing it in other part of text.
+
+### Editing behavior:
+
+The mention should be removed from the text when:
+
+- typing inside a mention
+- removing characters from a mention
+- breaking the mention (<kbd>enter</kbd>)
+- pasting part of a mention

+ 4 - 0
packages/ckeditor5-mention/tests/manual/mention.html

@@ -0,0 +1,4 @@
+<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>
+</div>

+ 41 - 0
packages/ckeditor5-mention/tests/manual/mention.js

@@ -0,0 +1,41 @@
+/**
+ * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* global console, window */
+
+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';
+
+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' ],
+		mention: {
+			feeds: [
+				{ feed: [ 'Barney', 'Lily', 'Marshall', 'Robin', 'Ted' ] },
+			]
+		}
+	} )
+	.then( editor => {
+		window.editor = editor;
+	} )
+	.catch( err => {
+		console.error( err.stack );
+	} );

+ 34 - 0
packages/ckeditor5-mention/tests/manual/mention.md

@@ -0,0 +1,34 @@
+## Mention
+
+The minimal mention configuration with a static list of autocomplete feed:
+
+### Configuration
+
+The feed:
+
+- Barney
+- Lily
+- Marshall
+- Robin
+- Ted
+
+### Interaction
+
+You can interact with mention panel with keyboard:
+
+- Move arrows up/down to select an item.
+- Use <kbd>enter</kbd> or <kbd>tab</kbd> to insert a mention into the documentation. 
+- The <kbd>esc</kbd> should close the panel.
+
+Mention panel should be closed on:
+- Click outside the panel view.
+- Changing selection - like placing it in other part of text.
+
+### Editing behavior:
+
+The mention should be removed from the text when:
+
+- typing inside a mention
+- removing characters from a mention
+- breaking the mention (<kbd>enter</kbd>)
+- pasting part of a mention

+ 89 - 0
packages/ckeditor5-mention/tests/mention-integration.js

@@ -0,0 +1,89 @@
+/**
+ * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* global document */
+
+import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
+import { getData as getViewData } from '@ckeditor/ckeditor5-engine/src/dev-utils/view';
+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 MentionEditing from '../src/mentionediting';
+
+describe( 'MentionEditing - integration', () => {
+	let div, editor, model, doc;
+
+	testUtils.createSinonSandbox();
+
+	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( () => {
+		div.remove();
+
+		return editor.destroy();
+	} );
+
+	describe( 'undo', () => {
+		// 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>' );
+
+			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 );
+
+				writer.setSelection( paragraph, 6 );
+
+				writer.insertText( 'a', doc.selection.getAttributes(), writer.createPositionAt( paragraph, 6 ) );
+			} );
+
+			expect( editor.getData() ).to.equal( '<p>foo @Jaohn bar</p>' );
+			expect( getViewData( editor.editing.view, { withoutSelection: true } ) ).to.equal( '<p>foo @Jaohn bar</p>' );
+
+			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>' );
+		} );
+
+		// 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>' );
+
+			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 );
+
+				writer.setSelection( paragraph, 7 );
+
+				model.modifySelection( doc.selection, { direction: 'backward', unit: 'codepoint' } );
+				model.deleteContent( doc.selection );
+			} );
+
+			expect( editor.getData() ).to.equal( '<p>foo @Jhn bar</p>' );
+			expect( getViewData( editor.editing.view, { withoutSelection: true } ) ).to.equal( '<p>foo @Jhn bar</p>' );
+
+			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>' );
+		} );
+	} );
+} );

+ 50 - 0
packages/ckeditor5-mention/tests/mention.js

@@ -0,0 +1,50 @@
+/**
+ * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor';
+import global from '@ckeditor/ckeditor5-utils/src/dom/global';
+
+import Mention from '../src/mention';
+import MentionEditing from '../src/mentionediting';
+import MentionUI from '../src/mentionui';
+
+describe( 'Mention', () => {
+	let editorElement, editor;
+
+	beforeEach( () => {
+		editorElement = global.document.createElement( 'div' );
+		global.document.body.appendChild( editorElement );
+
+		return ClassicTestEditor
+			.create( editorElement, {
+				plugins: [ Mention ]
+			} )
+			.then( newEditor => {
+				editor = newEditor;
+			} );
+	} );
+
+	afterEach( () => {
+		editorElement.remove();
+
+		return editor.destroy();
+	} );
+
+	it( 'should be loaded', () => {
+		expect( editor.plugins.get( Mention ) ).to.instanceOf( Mention );
+	} );
+
+	it( 'has proper name', () => {
+		expect( Mention.pluginName ).to.equal( 'Mention' );
+	} );
+
+	it( 'should load MentionEditing plugin', () => {
+		expect( editor.plugins.get( MentionEditing ) ).to.instanceOf( MentionEditing );
+	} );
+
+	it( 'should load MentionUI plugin', () => {
+		expect( editor.plugins.get( MentionUI ) ).to.instanceOf( MentionUI );
+	} );
+} );

+ 126 - 0
packages/ckeditor5-mention/tests/mentioncommand.js

@@ -0,0 +1,126 @@
+/**
+ * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import ModelTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/modeltesteditor';
+import { setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+
+import MentionCommand from '../src/mentioncommand';
+
+describe( 'MentionCommand', () => {
+	let editor, command, model, doc, selection;
+
+	beforeEach( () => {
+		return ModelTestEditor
+			.create()
+			.then( newEditor => {
+				editor = newEditor;
+				model = editor.model;
+				doc = model.document;
+				selection = doc.selection;
+
+				model.schema.register( 'paragraph', { inheritAllFrom: '$block' } );
+				model.schema.register( 'x', { inheritAllFrom: '$block' } );
+				model.schema.extend( '$text', { allowAttributes: [ 'mention' ] } );
+
+				command = new MentionCommand( editor );
+			} );
+	} );
+
+	afterEach( () => {
+		command.destroy();
+
+		return editor.destroy();
+	} );
+
+	describe( 'isEnabled', () => {
+		it( 'should return true if characters with the attribute can be placed at caret position', () => {
+			setData( model, '<paragraph>f[]oo</paragraph>' );
+			expect( command.isEnabled ).to.be.true;
+		} );
+
+		it( 'should return false if characters with the attribute cannot be placed at caret position', () => {
+			model.schema.addAttributeCheck( ( ctx, attributeName ) => {
+				// Allow 'bold' on p>$text.
+				if ( ctx.endsWith( 'x $text' ) && attributeName == 'mention' ) {
+					return false;
+				}
+			} );
+
+			setData( model, '<x>fo[]o</x>' );
+			expect( command.isEnabled ).to.be.false;
+		} );
+	} );
+
+	describe( 'execute()', () => {
+		it( 'inserts mention attribute for given range', () => {
+			setData( model, '<paragraph>foo @Jo[]bar</paragraph>' );
+
+			command.execute( {
+				mention: { name: 'John' },
+				range: model.createRange( selection.focus.getShiftedBy( -3 ), selection.focus )
+			} );
+
+			assertMention( doc.getRoot().getChild( 0 ).getChild( 1 ), '@', 'John' );
+		} );
+
+		it( 'inserts mention object if mention was passed as string', () => {
+			setData( model, '<paragraph>foo @Jo[]bar</paragraph>' );
+
+			command.execute( {
+				mention: 'John',
+				range: model.createRange( selection.focus.getShiftedBy( -3 ), selection.focus )
+			} );
+
+			assertMention( doc.getRoot().getChild( 0 ).getChild( 1 ), '@', 'John' );
+		} );
+
+		it( 'inserts mention attribute with passed marker for given range', () => {
+			setData( model, '<paragraph>foo @Jo[]bar</paragraph>' );
+
+			const end = model.createPositionAt( selection.focus );
+			const start = end.getShiftedBy( -3 );
+
+			command.execute( {
+				mention: { name: 'John' },
+				range: model.createRange( start, end ),
+				marker: '#'
+			} );
+
+			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' }
+			} );
+
+			assertMention( doc.getRoot().getChild( 0 ).getChild( 1 ), '@', 'John' );
+		} );
+
+		it( 'should set also other styles in inserted text', () => {
+			model.schema.extend( '$text', { allowAttributes: [ 'bold' ] } );
+
+			setData( model, '<paragraph><$text bold="true">foo@John[]bar</$text></paragraph>' );
+
+			command.execute( {
+				mention: { name: 'John' },
+				range: model.createRange( selection.focus.getShiftedBy( -5 ), selection.focus )
+			} );
+
+			const textNode = doc.getRoot().getChild( 0 ).getChild( 1 );
+			assertMention( textNode, '@', 'John' );
+			expect( textNode.hasAttribute( 'bold' ) ).to.be.true;
+		} );
+	} );
+
+	function assertMention( textNode, marker, name ) {
+		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 );
+	}
+} );

+ 281 - 0
packages/ckeditor5-mention/tests/mentionediting.js

@@ -0,0 +1,281 @@
+/**
+ * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor';
+import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
+import { getData as getModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+import { getData as getViewData } from '@ckeditor/ckeditor5-engine/src/dev-utils/view';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+
+import MentionEditing from '../src/mentionediting';
+import MentionCommand from '../src/mentioncommand';
+
+describe( 'MentionEditing', () => {
+	let editor, model, doc;
+
+	testUtils.createSinonSandbox();
+
+	afterEach( () => {
+		if ( editor ) {
+			return editor.destroy();
+		}
+	} );
+
+	it( 'should be named', () => {
+		expect( MentionEditing.pluginName ).to.equal( 'MentionEditing' );
+	} );
+
+	it( 'should be loaded', () => {
+		return createTestEditor()
+			.then( newEditor => {
+				expect( newEditor.plugins.get( MentionEditing ) ).to.be.instanceOf( MentionEditing );
+			} );
+	} );
+
+	it( 'should set proper schema rules', () => {
+		return createTestEditor()
+			.then( newEditor => {
+				model = newEditor.model;
+
+				expect( model.schema.checkAttribute( [ '$root', '$text' ], 'mention' ) ).to.be.true;
+
+				expect( model.schema.checkAttribute( [ '$block', '$text' ], 'mention' ) ).to.be.true;
+				expect( model.schema.checkAttribute( [ '$clipboardHolder', '$text' ], 'mention' ) ).to.be.true;
+
+				expect( model.schema.checkAttribute( [ '$block' ], 'mention' ) ).to.be.false;
+			} );
+	} );
+
+	it( 'should register mention command', () => {
+		return createTestEditor()
+			.then( newEditor => {
+				const command = newEditor.commands.get( 'mention' );
+
+				expect( command ).to.be.instanceof( MentionCommand );
+			} );
+	} );
+
+	describe( 'conversion', () => {
+		beforeEach( () => {
+			return createTestEditor()
+				.then( newEditor => {
+					editor = newEditor;
+					model = editor.model;
+					doc = model.document;
+				} );
+		} );
+
+		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' );
+
+			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 );
+		} );
+
+		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>' +
+				'</p>'
+			);
+
+			// getModelData() merges text blocks with "same" attributes:
+			// So expected: <$text mention="{"name":"John"}">@John</$text><$text mention="{"name":"John"}">@John</$text>'
+			// Is returned as: <$text mention="{"name":"John"}">@John@John</$text>'
+			const paragraph = doc.getRoot().getChild( 0 );
+
+			expect( paragraph.childCount ).to.equal( 2 );
+
+			assertTextNode( paragraph.getChild( 0 ) );
+			assertTextNode( paragraph.getChild( 1 ) );
+
+			const firstMentionId = paragraph.getChild( 0 ).getAttribute( 'mention' )._id;
+			const secondMentionId = paragraph.getChild( 1 ).getAttribute( 'mention' )._id;
+
+			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>';
+
+			expect( editor.getData() ).to.equal( expectedView );
+			expect( getViewData( editor.editing.view, { withoutSelection: true } ) ).to.equal( expectedView );
+
+			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' );
+			}
+		} );
+
+		it( 'should not convert partial mentions', () => {
+			editor.setData( '<p><span class="mention" data-mention="John">@Jo</span></p>' );
+
+			expect( getModelData( model, { withoutSelection: true } ) ).to.equal( '<paragraph>@Jo</paragraph>' );
+
+			const expectedView = '<p>@Jo</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>' );
+
+			expect( getModelData( model, { withoutSelection: true } ) ).to.equal( '<paragraph>foo</paragraph>' );
+
+			const expectedView = '<p>foo</p>';
+
+			expect( editor.getData() ).to.equal( expectedView );
+			expect( getViewData( editor.editing.view, { withoutSelection: true } ) ).to.equal( expectedView );
+		} );
+	} );
+
+	describe( 'selection post fixer', () => {
+		beforeEach( () => {
+			return createTestEditor()
+				.then( newEditor => {
+					editor = newEditor;
+					model = editor.model;
+					doc = model.document;
+				} );
+		} );
+
+		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>' );
+
+			model.change( writer => {
+				const paragraph = doc.getRoot().getChild( 0 );
+
+				writer.setSelection( paragraph, 9 );
+			} );
+
+			expect( Array.from( doc.selection.getAttributes() ) ).to.deep.equal( [] );
+		} );
+
+		it( 'should allow to type after a mention', () => {
+			editor.setData( '<p>foo <span class="mention" data-mention="John">@John</span>bar</p>' );
+
+			model.change( writer => {
+				const paragraph = doc.getRoot().getChild( 0 );
+
+				writer.setSelection( paragraph, 9 );
+
+				writer.insertText( ' ', paragraph, 9 );
+			} );
+
+			expect( editor.getData() ).to.equal( '<p>foo <span class="mention" data-mention="John">@John</span> bar</p>' );
+		} );
+	} );
+
+	describe( 'removing partial mention post fixer', () => {
+		beforeEach( () => {
+			return createTestEditor()
+				.then( newEditor => {
+					editor = newEditor;
+					model = editor.model;
+					doc = model.document;
+				} );
+		} );
+
+		it( 'should remove mention on adding a text inside mention', () => {
+			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' );
+
+			model.change( writer => {
+				const paragraph = doc.getRoot().getChild( 0 );
+
+				writer.setSelection( paragraph, 6 );
+
+				writer.insertText( 'a', doc.selection.getAttributes(), writer.createPositionAt( paragraph, 6 ) );
+			} );
+
+			expect( getModelData( model, { withoutSelection: true } ) )
+				.to.equal( '<paragraph>foo @Jaohn bar</paragraph>' );
+
+			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>' );
+
+			const paragraph = doc.getRoot().getChild( 0 );
+
+			model.change( writer => {
+				writer.setSelection( paragraph, 6 );
+			} );
+
+			model.enqueueChange( () => {
+				model.modifySelection( doc.selection, { direction: 'backward', unit: 'codepoint' } );
+				model.deleteContent( doc.selection );
+			} );
+
+			expect( editor.getData() ).to.equal( '<p>foo @ohn bar</p>' );
+		} );
+
+		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>' );
+
+			const paragraph = doc.getRoot().getChild( 0 );
+
+			// Set selection at the end of a John.
+			model.change( writer => {
+				writer.setSelection( paragraph, 9 );
+			} );
+
+			model.enqueueChange( () => {
+				model.modifySelection( doc.selection, { direction: 'backward', unit: 'codepoint' } );
+				model.deleteContent( doc.selection );
+			} );
+
+			expect( editor.getData() ).to.equal( '<p>foo @Joh bar</p>' );
+		} );
+
+		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>' );
+
+			const paragraph = doc.getRoot().getChild( 0 );
+
+			// Set selection before bar.
+			model.change( writer => {
+				writer.setSelection( paragraph, 10 );
+			} );
+
+			model.enqueueChange( () => {
+				model.modifySelection( doc.selection, { direction: 'backward', unit: 'codepoint' } );
+				model.deleteContent( doc.selection );
+			} );
+
+			expect( editor.getData() ).to.equal( '<p>foo <span class="mention" data-mention="John">@John</span>bar</p>' );
+		} );
+	} );
+
+	function createTestEditor( mentionConfig ) {
+		return VirtualTestEditor
+			.create( {
+				plugins: [ Paragraph, MentionEditing ],
+				mention: mentionConfig
+			} );
+	}
+} );

+ 994 - 0
packages/ckeditor5-mention/tests/mentionui.js

@@ -0,0 +1,994 @@
+/**
+ * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* global window, document, setTimeout, Event */
+
+import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor';
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import BalloonPanelView from '@ckeditor/ckeditor5-ui/src/panel/balloon/balloonpanelview';
+import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard';
+import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
+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 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;
+
+	const staticConfig = {
+		feeds: [
+			{ feed: [ 'Barney', 'Lily', 'Marshall', 'Robin', 'Ted' ] }
+		]
+	};
+
+	testUtils.createSinonSandbox();
+
+	beforeEach( () => {
+		editorElement = document.createElement( 'div' );
+		document.body.appendChild( editorElement );
+	} );
+
+	afterEach( () => {
+		sinon.restore();
+		editorElement.remove();
+
+		return editor.destroy();
+	} );
+
+	it( 'should create a plugin instance', () => {
+		return createClassicTestEditor().then( () => {
+			expect( mentionUI ).to.instanceOf( Plugin );
+			expect( mentionUI ).to.instanceOf( MentionUI );
+		} );
+	} );
+
+	describe( 'pluginName', () => {
+		it( 'should return plugin by its name', () => {
+			return createClassicTestEditor().then( () => {
+				expect( editor.plugins.get( 'MentionUI' ) ).to.equal( mentionUI );
+			} );
+		} );
+	} );
+
+	describe( 'child views', () => {
+		beforeEach( () => createClassicTestEditor() );
+
+		describe( 'panelView', () => {
+			it( 'should create a view instance', () => {
+				expect( panelView ).to.instanceof( BalloonPanelView );
+			} );
+
+			it( 'should be added to the ui.view.body collection', () => {
+				expect( Array.from( editor.ui.view.body ) ).to.include( panelView );
+			} );
+
+			it( 'should have disabled arrow', () => {
+				expect( panelView.withArrow ).to.be.false;
+			} );
+
+			it( 'should have added MentionView as a child', () => {
+				expect( panelView.content.get( 0 ) ).to.be.instanceof( MentionsView );
+			} );
+		} );
+	} );
+
+	describe( 'position', () => {
+		let pinSpy;
+
+		const caretRect = {
+			bottom: 118,
+			height: 18,
+			left: 500,
+			right: 501,
+			top: 100,
+			width: 1
+		};
+
+		const balloonRect = {
+			bottom: 150,
+			height: 150,
+			left: 0,
+			right: 200,
+			top: 0,
+			width: 200
+		};
+
+		beforeEach( () => {
+			return createClassicTestEditor( staticConfig ).then( () => {
+				pinSpy = sinon.spy( panelView, 'pin' );
+			} );
+		} );
+
+		it( 'should properly calculate position data', () => {
+			setData( model, '<paragraph>foo []</paragraph>' );
+			stubSelectionRects( [ caretRect ] );
+
+			model.change( writer => {
+				writer.insertText( '@', doc.selection.getFirstPosition() );
+			} );
+
+			return waitForDebounce()
+				.then( () => {
+					const pinArgument = pinSpy.firstCall.args[ 0 ];
+					const { target, positions } = pinArgument;
+
+					expect( target() ).to.deep.equal( caretRect );
+					expect( positions ).to.have.length( 4 );
+
+					const caretSouthEast = positions[ 0 ];
+					const caretNorthEast = positions[ 1 ];
+					const caretSouthWest = positions[ 2 ];
+					const caretNorthWest = positions[ 3 ];
+
+					expect( caretSouthEast( caretRect, balloonRect ) ).to.deep.equal( {
+						left: 501,
+						name: 'caret_se',
+						top: 123
+					} );
+
+					expect( caretNorthEast( caretRect, balloonRect ) ).to.deep.equal( {
+						left: 501,
+						name: 'caret_ne',
+						top: -55
+					} );
+
+					expect( caretSouthWest( caretRect, balloonRect ) ).to.deep.equal( {
+						left: 301,
+						name: 'caret_sw',
+						top: 123
+					} );
+
+					expect( caretNorthWest( caretRect, balloonRect ) ).to.deep.equal( {
+						left: 301,
+						name: 'caret_nw',
+						top: -55
+					} );
+				} );
+		} );
+
+		it( 'should re-calculate position on typing', () => {
+			setData( model, '<paragraph>foo []</paragraph>' );
+			stubSelectionRects( [ caretRect ] );
+
+			model.change( writer => {
+				writer.insertText( '@', doc.selection.getFirstPosition() );
+			} );
+
+			return waitForDebounce()
+				.then( () => {
+					sinon.assert.calledOnce( pinSpy );
+
+					model.change( writer => {
+						writer.insertText( 't', doc.selection.getFirstPosition() );
+					} );
+				} )
+				.then( waitForDebounce )
+				.then( () => {
+					sinon.assert.calledTwice( pinSpy );
+				} );
+		} );
+	} );
+
+	describe( 'typing integration', () => {
+		it( 'should show panel for matched marker after typing minimum characters', () => {
+			return createClassicTestEditor( { feeds: [ Object.assign( { minimumCharacters: 2 }, staticConfig.feeds[ 0 ] ) ] } )
+				.then( () => {
+					setData( model, '<paragraph>foo []</paragraph>' );
+
+					model.change( writer => {
+						writer.insertText( '@', doc.selection.getFirstPosition() );
+					} );
+				} )
+				.then( () => {
+					model.change( writer => {
+						writer.insertText( 'B', doc.selection.getFirstPosition() );
+					} );
+				} )
+				.then( waitForDebounce )
+				.then( () => {
+					expect( panelView.isVisible ).to.be.false;
+				} )
+				.then( waitForDebounce )
+				.then( () => {
+					model.change( writer => {
+						writer.insertText( 'a', doc.selection.getFirstPosition() );
+					} );
+				} )
+				.then( waitForDebounce )
+				.then( () => {
+					expect( panelView.isVisible ).to.be.true;
+					expect( listView.items ).to.have.length( 1 );
+
+					model.change( writer => {
+						writer.insertText( 'r', doc.selection.getFirstPosition() );
+					} );
+				} )
+				.then( waitForDebounce )
+				.then( () => {
+					expect( panelView.isVisible ).to.be.true;
+					expect( listView.items ).to.have.length( 1 );
+				} );
+		} );
+
+		describe( 'static list with default trigger', () => {
+			beforeEach( () => {
+				return createClassicTestEditor( staticConfig );
+			} );
+
+			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( listView.items ).to.have.length( 5 );
+					} );
+			} );
+
+			it( 'should show panel for matched marker at the beginning of paragraph', () => {
+				setData( model, '<paragraph>[] foo</paragraph>' );
+
+				model.change( writer => {
+					writer.insertText( '@', doc.selection.getFirstPosition() );
+				} );
+
+				return waitForDebounce()
+					.then( () => {
+						expect( panelView.isVisible ).to.be.true;
+						expect( listView.items ).to.have.length( 5 );
+					} );
+			} );
+
+			it( 'should not show panel for marker in the middle of other word', () => {
+				setData( model, '<paragraph>foo[]</paragraph>' );
+
+				model.change( writer => {
+					writer.insertText( '@', doc.selection.getFirstPosition() );
+				} );
+
+				return waitForDebounce()
+					.then( () => {
+						expect( panelView.isVisible ).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>' );
+
+				model.change( writer => {
+					writer.setSelection( doc.getRoot().getChild( 0 ), 7 );
+				} );
+
+				return waitForDebounce()
+					.then( () => {
+						expect( panelView.isVisible ).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>' );
+
+				model.change( writer => {
+					writer.setSelection( doc.getRoot().getChild( 0 ), 9 );
+				} );
+
+				return waitForDebounce()
+					.then( () => {
+						expect( panelView.isVisible ).to.be.false;
+					} );
+			} );
+
+			it( 'should not show panel when selection is not collapsed', () => {
+				setData( model, '<paragraph>foo []</paragraph>' );
+
+				model.change( writer => {
+					writer.insertText( '@', doc.selection.getFirstPosition() );
+				} );
+
+				return waitForDebounce()
+					.then( () => {
+						expect( panelView.isVisible ).to.be.true;
+
+						model.change( () => {
+							model.modifySelection( doc.selection, { direction: 'backward', unit: 'character' } );
+						} );
+					} )
+					.then( waitForDebounce )
+					.then( () => {
+						expect( panelView.isVisible ).to.be.false;
+					} );
+			} );
+
+			it( 'should show filtered results for matched text', () => {
+				setData( model, '<paragraph>foo []</paragraph>' );
+
+				model.change( writer => {
+					writer.insertText( '@', doc.selection.getFirstPosition() );
+				} );
+
+				model.change( writer => {
+					writer.insertText( 'T', doc.selection.getFirstPosition() );
+				} );
+
+				return waitForDebounce()
+					.then( () => {
+						expect( panelView.isVisible ).to.be.true;
+						expect( listView.items ).to.have.length( 1 );
+					} );
+			} );
+
+			it( 'should focus the first item in panel', () => {
+				setData( model, '<paragraph>foo []</paragraph>' );
+
+				model.change( writer => {
+					writer.insertText( '@', doc.selection.getFirstPosition() );
+				} );
+
+				return waitForDebounce()
+					.then( () => {
+						const button = listView.items.get( 0 ).children.get( 0 );
+
+						expect( button.isOn ).to.be.true;
+					} );
+			} );
+
+			it( 'should hide panel if no matched items', () => {
+				setData( model, '<paragraph>foo []</paragraph>' );
+
+				model.change( writer => {
+					writer.insertText( '@', doc.selection.getFirstPosition() );
+				} );
+
+				return waitForDebounce()
+					.then( () => expect( panelView.isVisible ).to.be.true )
+					.then( () => {
+						model.change( writer => {
+							writer.insertText( 'x', doc.selection.getFirstPosition() );
+						} );
+					} )
+					.then( waitForDebounce )
+					.then( () => {
+						expect( panelView.isVisible ).to.be.false;
+						expect( listView.items ).to.have.length( 0 );
+					} );
+			} );
+
+			it( 'should hide panel when text was unmatched', () => {
+				setData( model, '<paragraph>foo []</paragraph>' );
+
+				model.change( writer => {
+					writer.insertText( '@', doc.selection.getFirstPosition() );
+				} );
+
+				return waitForDebounce()
+					.then( () => expect( panelView.isVisible ).to.be.true )
+					.then( () => {
+						model.change( writer => {
+							const end = doc.selection.getFirstPosition();
+							const start = end.getShiftedBy( -1 );
+
+							writer.remove( writer.createRange( start, end ) );
+						} );
+					} )
+					.then( waitForDebounce )
+					.then( () => expect( panelView.isVisible ).to.be.false );
+			} );
+		} );
+
+		describe( 'asynchronous list with custom trigger', () => {
+			beforeEach( () => {
+				const issuesNumbers = [ '100', '101', '102', '103' ];
+
+				return createClassicTestEditor( {
+					feeds: [
+						{
+							marker: '#',
+							feed: feedText => {
+								return new Promise( resolve => {
+									setTimeout( () => {
+										resolve( issuesNumbers.filter( number => number.includes( feedText ) ) );
+									}, 20 );
+								} );
+							}
+						}
+					]
+				} );
+			} );
+
+			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( listView.items ).to.have.length( 4 );
+					} );
+			} );
+
+			it( 'should show filtered results for matched text', () => {
+				setData( model, '<paragraph>foo []</paragraph>' );
+
+				model.change( writer => {
+					writer.insertText( '#', doc.selection.getFirstPosition() );
+				} );
+
+				model.change( writer => {
+					writer.insertText( '2', doc.selection.getFirstPosition() );
+				} );
+
+				return waitForDebounce()
+					.then( () => {
+						expect( panelView.isVisible ).to.be.true;
+						expect( listView.items ).to.have.length( 1 );
+					} );
+			} );
+
+			it( 'should hide panel if no matched items', () => {
+				setData( model, '<paragraph>foo []</paragraph>' );
+
+				model.change( writer => {
+					writer.insertText( '#', doc.selection.getFirstPosition() );
+				} );
+
+				return waitForDebounce()
+					.then( () => expect( panelView.isVisible ).to.be.true )
+					.then( () => {
+						model.change( writer => {
+							writer.insertText( 'x', doc.selection.getFirstPosition() );
+						} );
+					} )
+					.then( waitForDebounce )
+					.then( () => {
+						expect( panelView.isVisible ).to.be.false;
+						expect( listView.items ).to.have.length( 0 );
+					} );
+			} );
+
+			it( 'should hide panel when text was unmatched', () => {
+				setData( model, '<paragraph>foo []</paragraph>' );
+
+				model.change( writer => {
+					writer.insertText( '#', doc.selection.getFirstPosition() );
+				} );
+
+				return waitForDebounce()
+					.then( () => expect( panelView.isVisible ).to.be.true )
+					.then( () => {
+						model.change( writer => {
+							const end = doc.selection.getFirstPosition();
+							const start = end.getShiftedBy( -1 );
+
+							writer.remove( writer.createRange( start, end ) );
+						} );
+					} )
+					.then( waitForDebounce )
+					.then( () => expect( panelView.isVisible ).to.be.false );
+			} );
+		} );
+	} );
+
+	describe( 'panel behavior', () => {
+		it( 'should close the opened panel on esc', () => {
+			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;
+
+					fireKeyDownEvent( {
+						keyCode: keyCodes.esc,
+						preventDefault: sinon.spy(),
+						stopPropagation: sinon.spy()
+					} );
+
+					expect( panelView.isVisible ).to.be.false;
+				} );
+		} );
+
+		it( 'should close the opened panel when click outside the panel', () => {
+			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;
+
+					document.body.dispatchEvent( new Event( 'mousedown', { bubbles: true } ) );
+
+					expect( panelView.isVisible ).to.be.false;
+				} );
+		} );
+
+		it( 'should hide the panel when click outside', () => {
+			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;
+
+					model.change( writer => {
+						// Place position at the begging of a paragraph.
+						writer.setSelection( doc.getRoot().getChild( 0 ), 0 );
+					} );
+
+					expect( panelView.isVisible ).to.be.false;
+				} );
+		} );
+
+		describe( 'default list item', () => {
+			const feedItems = staticConfig.feeds[ 0 ].feed.map( name => ( { name } ) );
+
+			beforeEach( () => {
+				return createClassicTestEditor( staticConfig );
+			} );
+
+			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, feedItems );
+
+				testExecuteKey( 'tab', keyCodes.tab, feedItems );
+
+				testExecuteKey( 'space', keyCodes.space, feedItems );
+			} );
+		} );
+
+		describe( 'custom list item', () => {
+			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: feedText => {
+								return Promise.resolve( issues.filter( issue => issue.id.includes( feedText ) ) );
+							},
+							itemRenderer: item => {
+								const span = global.document.createElementNS( 'http://www.w3.org/1999/xhtml', 'span' );
+
+								span.innerHTML = `<span id="issue-${ item.id }">@${ item.title }</span>`;
+
+								return span;
+							}
+						}
+					]
+				} );
+			} );
+
+			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( listView.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( 'mouse', () => {
+					it( 'should execute selected button on mouse click', () => {
+						setData( model, '<paragraph>foo []</paragraph>' );
+
+						model.change( writer => {
+							writer.insertText( '@', doc.selection.getFirstPosition() );
+						} );
+
+						const command = editor.commands.get( 'mention' );
+						const spy = testUtils.sinon.spy( command, 'execute' );
+
+						return waitForDebounce()
+							.then( () => {
+								expectChildViewsIsOnState( [ true, false, false, false, false ] );
+
+								const element = panelView.element.querySelector( '#issue-1004' );
+								element.dispatchEvent( new Event( 'click', { bubbles: true } ) );
+
+								sinon.assert.calledOnce( spy );
+
+								const commandOptions = spy.getCall( 0 ).args[ 0 ];
+
+								const item = issues[ 2 ];
+
+								expect( commandOptions ).to.have.property( 'mention' ).that.deep.equal( item );
+								expect( commandOptions ).to.have.property( 'marker', '@' );
+								expect( commandOptions ).to.have.property( 'range' );
+
+								const start = model.createPositionAt( doc.getRoot().getChild( 0 ), 4 );
+								const expectedRange = model.createRange( start, start.getShiftedBy( 1 ) );
+
+								expect( commandOptions.range.isEqual( expectedRange ) ).to.be.true;
+							} );
+					} );
+				} );
+			} );
+		} );
+
+		function testExecuteKey( name, keyCode, feedItems ) {
+			it( 'should execute selected button on ' + name, () => {
+				setData( model, '<paragraph>foo []</paragraph>' );
+
+				model.change( writer => {
+					writer.insertText( '@', doc.selection.getFirstPosition() );
+				} );
+
+				const command = editor.commands.get( 'mention' );
+				const spy = testUtils.sinon.spy( command, 'execute' );
+
+				return waitForDebounce()
+					.then( () => {
+						expectChildViewsIsOnState( [ true, false, false, false, false ] );
+
+						fireKeyDownEvent( {
+							keyCode: keyCodes.arrowup,
+							preventDefault: sinon.spy(),
+							stopPropagation: sinon.spy()
+						} );
+
+						expectChildViewsIsOnState( [ false, false, false, false, true ] );
+
+						fireKeyDownEvent( {
+							keyCode,
+							preventDefault: sinon.spy(),
+							stopPropagation: sinon.spy()
+						} );
+
+						sinon.assert.calledOnce( spy );
+
+						assertCommandOptions( spy.getCall( 0 ).args[ 0 ], '@', feedItems[ 4 ] );
+
+						const start = model.createPositionAt( doc.getRoot().getChild( 0 ), 4 );
+						const expectedRange = model.createRange( start, start.getShiftedBy( 1 ) );
+
+						expect( spy.getCall( 0 ).args[ 0 ].range.isEqual( expectedRange ) ).to.be.true;
+					} );
+			} );
+
+			it( 'should do nothing if panel is not visible on ' + name, () => {
+				setData( model, '<paragraph>foo []</paragraph>' );
+
+				model.change( writer => {
+					writer.insertText( '@', doc.selection.getFirstPosition() );
+				} );
+
+				const command = editor.commands.get( 'mention' );
+				const spy = testUtils.sinon.spy( command, 'execute' );
+
+				return waitForDebounce()
+					.then( () => {
+						expect( panelView.isVisible ).to.be.true;
+
+						fireKeyDownEvent( {
+							keyCode: keyCodes.esc,
+							preventDefault: sinon.spy(),
+							stopPropagation: sinon.spy()
+						} );
+
+						expect( panelView.isVisible ).to.be.false;
+
+						fireKeyDownEvent( {
+							keyCode,
+							preventDefault: sinon.spy(),
+							stopPropagation: sinon.spy()
+						} );
+
+						sinon.assert.notCalled( spy );
+
+						expect( panelView.isVisible ).to.be.false;
+					} );
+			} );
+		}
+	} );
+
+	describe( 'execute', () => {
+		beforeEach( () => createClassicTestEditor( staticConfig ) );
+
+		it( 'should call the mention command with proper options', () => {
+			setData( model, '<paragraph>foo []</paragraph>' );
+
+			model.change( writer => {
+				writer.insertText( '@', doc.selection.getFirstPosition() );
+			} );
+
+			const command = editor.commands.get( 'mention' );
+			const spy = testUtils.sinon.spy( command, 'execute' );
+
+			return waitForDebounce()
+				.then( () => {
+					listView.items.get( 0 ).children.get( 0 ).fire( 'execute' );
+
+					sinon.assert.calledOnce( spy );
+
+					const commandOptions = spy.getCall( 0 ).args[ 0 ];
+
+					assertCommandOptions( commandOptions, '@', { name: 'Barney' } );
+
+					const start = model.createPositionAt( doc.getRoot().getChild( 0 ), 4 );
+					const expectedRange = model.createRange( start, start.getShiftedBy( 1 ) );
+
+					expect( commandOptions.range.isEqual( expectedRange ) ).to.be.true;
+				} );
+		} );
+
+		it( 'should hide panel on execute', () => {
+			setData( model, '<paragraph>foo []</paragraph>' );
+
+			model.change( writer => {
+				writer.insertText( '@', doc.selection.getFirstPosition() );
+			} );
+
+			return waitForDebounce()
+				.then( () => {
+					listView.items.get( 0 ).children.get( 0 ).fire( 'execute' );
+
+					expect( panelView.isVisible ).to.be.false;
+				} );
+		} );
+	} );
+
+	function createClassicTestEditor( mentionConfig ) {
+		return ClassicTestEditor
+			.create( editorElement, {
+				plugins: [ Paragraph, MentionEditing, MentionUI ],
+				mention: mentionConfig
+			} )
+			.then( newEditor => {
+				editor = newEditor;
+				model = editor.model;
+				doc = model.document;
+				editingView = editor.editing.view;
+				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();
+			} );
+	}
+
+	function waitForDebounce() {
+		return new Promise( resolve => {
+			setTimeout( () => {
+				resolve();
+			}, 50 );
+		} );
+	}
+
+	function fireKeyDownEvent( options ) {
+		const eventInfo = new EventInfo( editingView.document, 'keydown' );
+		const eventData = new DomEventData( editingView.document, {
+			target: document.body
+		}, options );
+
+		editingView.document.fire( eventInfo, eventData );
+	}
+
+	function stubSelectionRects( rects ) {
+		const originalViewRangeToDom = editingView.domConverter.viewRangeToDom;
+
+		// Mock selection rect.
+		sinon.stub( editingView.domConverter, 'viewRangeToDom' ).callsFake( ( ...args ) => {
+			const domRange = originalViewRangeToDom.apply( editingView.domConverter, args );
+
+			sinon.stub( domRange, 'getClientRects' )
+				.returns( rects );
+
+			return domRange;
+		} );
+	}
+
+	function expectChildViewsIsOnState( expectedState ) {
+		const childViews = [ ...listView.items ].map( listView => listView.children.get( 0 ) );
+
+		expect( childViews.map( child => child.isOn ) ).to.deep.equal( expectedState );
+	}
+
+	function assertCommandOptions( commandOptions, marker, item ) {
+		expect( commandOptions ).to.have.property( 'marker', marker );
+		expect( commandOptions ).to.have.property( 'range' );
+		expect( commandOptions ).to.have.property( 'mention' );
+
+		const mentionForCommand = commandOptions.mention;
+
+		for ( const key of Object.keys( item ) ) {
+			expect( mentionForCommand[ key ] ).to.equal( item[ key ] );
+		}
+	}
+} );

+ 4 - 0
packages/ckeditor5-mention/theme/mentionediting.css

@@ -0,0 +1,4 @@
+/*
+ * Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */