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

Merge branch 'master' into t/7

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

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

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

Разница между файлами не показана из-за своего большого размера
+ 2 - 2
packages/ckeditor5-mention/.travis.yml


+ 8 - 6
packages/ckeditor5-mention/docs/_snippets/features/custom-mention-colors-variables.js

@@ -16,12 +16,14 @@ ClassicEditor
 			],
 			viewportTopOffset: window.getViewportTopOffsetConfig()
 		},
-		mention: [
-			{
-				marker: '@',
-				feed: [ 'Barney', 'Lily', 'Marshall', 'Robin', 'Ted' ]
-			}
-		]
+		mention: {
+			feeds: [
+				{
+					marker: '@',
+					feed: [ 'Barney', 'Lily', 'Marshall', 'Robin', 'Ted' ]
+				}
+			]
+		}
 	} )
 	.then( editor => {
 		window.editor = editor;

+ 43 - 59
packages/ckeditor5-mention/docs/_snippets/features/mention-customization.js

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

+ 8 - 6
packages/ckeditor5-mention/docs/_snippets/features/mention.js

@@ -16,12 +16,14 @@ ClassicEditor
 			],
 			viewportTopOffset: window.getViewportTopOffsetConfig()
 		},
-		mention: [
-			{
-				marker: '@',
-				feed: [ 'Barney', 'Lily', 'Marshall', 'Robin', 'Ted' ]
-			}
-		]
+		mention: {
+			feeds: [
+				{
+					marker: '@',
+					feed: [ 'Barney', 'Lily', 'Marshall', 'Robin', 'Ted' ]
+				}
+			]
+		}
 	} )
 	.then( editor => {
 		window.editor = editor;

+ 199 - 91
packages/ckeditor5-mention/docs/features/mention.md

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

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

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

+ 86 - 19
packages/ckeditor5-mention/src/mentionediting.js

@@ -12,8 +12,6 @@ import uid from '@ckeditor/ckeditor5-utils/src/uid';
 
 import MentionCommand from './mentioncommand';
 
-import '../theme/mentionediting.css';
-
 /**
  * The mention editing feature.
  *
@@ -59,7 +57,8 @@ export default class MentionEditing extends Plugin {
 			view: createViewMentionElement
 		} );
 
-		doc.registerPostFixer( writer => removePartialMentionPostFixer( writer, doc ) );
+		doc.registerPostFixer( writer => removePartialMentionPostFixer( writer, doc, model.schema ) );
+		doc.registerPostFixer( writer => extendAttributeOnMentionPostFixer( writer, doc ) );
 		doc.registerPostFixer( writer => selectionMentionAttributePostFixer( writer, doc ) );
 
 		editor.commands.add( 'mention', new MentionCommand( editor ) );
@@ -142,34 +141,69 @@ function selectionMentionAttributePostFixer( writer, doc ) {
 // @param {module:engine/model/writer~Writer} writer
 // @param {module:engine/model/document~Document} doc
 // @returns {Boolean} Returns true if selection was fixed.
-function removePartialMentionPostFixer( writer, doc ) {
+function removePartialMentionPostFixer( writer, doc, schema ) {
 	const changes = doc.differ.getChanges();
 
 	let wasChanged = false;
 
 	for ( const change of changes ) {
-		// Check if user edited part of a mention.
-		if ( change.type == 'insert' || change.type == 'remove' ) {
-			const textNode = change.position.textNode;
+		// Check text node on current position;
+		const position = change.position;
+
+		if ( change.name == '$text' ) {
+			const nodeAfterInsertedTextNode = position.textNode && position.textNode.nextSibling;
+
+			// Check textNode where the change occurred.
+			wasChanged = checkAndFix( position.textNode, writer ) || wasChanged;
+
+			// Occurs on paste occurs inside a text node with mention.
+			wasChanged = checkAndFix( nodeAfterInsertedTextNode, writer ) || wasChanged;
+			wasChanged = checkAndFix( position.nodeBefore, writer ) || wasChanged;
+			wasChanged = checkAndFix( position.nodeAfter, writer ) || wasChanged;
+		}
 
-			if ( change.name == '$text' && textNode && textNode.hasAttribute( 'mention' ) ) {
-				writer.removeAttribute( 'mention', textNode );
-				wasChanged = true;
+		// Check text nodes in inserted elements (might occur when splitting paragraph or pasting content inside text with mention).
+		if ( change.name != '$text' && change.type == 'insert' ) {
+			const insertedNode = position.nodeAfter;
+
+			for ( const item of writer.createRangeIn( insertedNode ).getItems() ) {
+				wasChanged = checkAndFix( item, writer ) || wasChanged;
 			}
 		}
 
-		// Additional check for deleting last character of a text node.
-		if ( change.type == 'remove' ) {
-			const nodeBefore = change.position.nodeBefore;
+		// Inserted inline elements might break mention.
+		if ( change.type == 'insert' && schema.isInline( change.name ) ) {
+			const nodeAfterInserted = position.nodeAfter && position.nodeAfter.nextSibling;
+
+			wasChanged = checkAndFix( position.nodeBefore, writer ) || wasChanged;
+			wasChanged = checkAndFix( nodeAfterInserted, writer ) || wasChanged;
+		}
+	}
+
+	return wasChanged;
+}
+
+// This post-fixer will extend attribute applied on part of a mention so a whole text node of a mention will have added attribute.
+//
+// @param {module:engine/model/writer~Writer} writer
+// @param {module:engine/model/document~Document} doc
+// @returns {Boolean} Returns true if selection was fixed.
+function extendAttributeOnMentionPostFixer( writer, doc ) {
+	const changes = doc.differ.getChanges();
+
+	let wasChanged = false;
 
-			if ( nodeBefore && nodeBefore.hasAttribute( 'mention' ) ) {
-				const text = nodeBefore.data;
-				const mention = nodeBefore.getAttribute( 'mention' );
+	for ( const change of changes ) {
+		if ( change.type === 'attribute' && change.attributeKey != 'mention' ) {
+			// Check node at the left side of a range...
+			const nodeBefore = change.range.start.nodeBefore;
+			// ... and on right side of range.
+			const nodeAfter = change.range.end.nodeAfter;
 
-				const expectedText = mention._marker + mention.name;
+			for ( const node of [ nodeBefore, nodeAfter ] ) {
+				if ( isBrokenMentionNode( node ) && node.getAttribute( change.attributeKey ) != change.attributeNewValue ) {
+					writer.setAttribute( change.attributeKey, change.attributeNewValue, node );
 
-				if ( text != expectedText ) {
-					writer.removeAttribute( 'mention', nodeBefore );
 					wasChanged = true;
 				}
 			}
@@ -178,3 +212,36 @@ function removePartialMentionPostFixer( writer, doc ) {
 
 	return wasChanged;
 }
+
+// Checks if node has correct mention attribute if present.
+// Returns true if node is text and has a mention attribute which text does not match expected mention text.
+//
+// @param {module:engine/model/node~Node} node a node to check
+// @returns {Boolean}
+function isBrokenMentionNode( node ) {
+	if ( !node || !( node.is( 'text' ) || node.is( 'textProxy' ) ) || !node.hasAttribute( 'mention' ) ) {
+		return false;
+	}
+
+	const text = node.data;
+	const mention = node.getAttribute( 'mention' );
+
+	const expectedText = mention._marker + mention.name;
+
+	return text != expectedText;
+}
+
+// Fixes mention on text node it needs a fix.
+//
+// @param {module:engine/model/text~Text} textNode
+// @param {module:engine/model/writer~Writer} writer
+// @returns {Boolean}
+function checkAndFix( textNode, writer ) {
+	if ( isBrokenMentionNode( textNode ) ) {
+		writer.removeAttribute( 'mention', textNode );
+
+		return true;
+	}
+
+	return false;
+}

+ 6 - 4
packages/ckeditor5-mention/src/mentionui.js

@@ -94,7 +94,7 @@ export default class MentionUI extends Plugin {
 					this._hidePanelAndRemoveMarker();
 				}
 			}
-		}, { priority: 'highest' } ); // priority highest required for enter overriding.
+		}, { priority: 'highest' } ); // Required to override enter.
 
 		// Close the #panelView upon clicking outside of the plugin UI.
 		clickOutsideHandler( {
@@ -527,9 +527,11 @@ function createTextMatcher( marker ) {
 // Default feed callback
 function createFeedCallback( feedItems ) {
 	return feedText => {
-		const filteredItems = feedItems.filter( item => {
-			return item.toLowerCase().includes( feedText.toLowerCase() );
-		} );
+		const filteredItems = feedItems
+		// Make default mention feed case-insensitive.
+			.filter( item => item.toLowerCase().includes( feedText.toLowerCase() ) )
+			// Do not return more than 10 items.
+			.slice( 0, 10 );
 
 		return Promise.resolve( filteredItems );
 	};

+ 46 - 1
packages/ckeditor5-mention/src/ui/mentionsview.js

@@ -9,6 +9,9 @@
 
 import View from '@ckeditor/ckeditor5-ui/src/view';
 import ListView from '@ckeditor/ckeditor5-ui/src/list/listview';
+import Rect from '@ckeditor/ckeditor5-utils/src/dom/rect';
+
+import '../../theme/mentionui.css';
 
 /**
  * The mention ui view.
@@ -16,6 +19,9 @@ import ListView from '@ckeditor/ckeditor5-ui/src/list/listview';
  * @extends module:ui/view~View
  */
 export default class MentionsView extends View {
+	/**
+	 * @inheritDoc
+	 */
 	constructor( locale ) {
 		super( locale );
 
@@ -27,7 +33,7 @@ export default class MentionsView extends View {
 			attributes: {
 				class: [
 					'ck',
-					'ck-mention'
+					'ck-mentions'
 				],
 
 				tabindex: '-1'
@@ -39,10 +45,18 @@ export default class MentionsView extends View {
 		} );
 	}
 
+	/**
+	 * {@link #select Selects} the first item.
+	 */
 	selectFirst() {
 		this.select( 0 );
 	}
 
+	/**
+	 * Selects next item to the currently {@link #select selected}.
+	 *
+	 * If the last item is already selected, it will select the first item.
+	 */
 	selectNext() {
 		const item = this.selected;
 
@@ -51,6 +65,11 @@ export default class MentionsView extends View {
 		this.select( index + 1 );
 	}
 
+	/**
+	 * Selects previous item to the currently {@link #select selected}.
+	 *
+	 * If the first item is already selected, it will select the last item.
+	 */
 	selectPrevious() {
 		const item = this.selected;
 
@@ -59,6 +78,15 @@ export default class MentionsView extends View {
 		this.select( index - 1 );
 	}
 
+	/**
+	 * Marks item at a given index as selected.
+	 *
+	 * Handles selection cycling when passed index is out of bounds:
+	 * - if the index is lower than 0, it will select the last item,
+	 * - if the index is higher than the last item index, it will select the first item.
+	 *
+	 * @param {Number} index Index of an item to be marked as selected.
+	 */
 	select( index ) {
 		let indexToGet = 0;
 
@@ -71,6 +99,11 @@ export default class MentionsView extends View {
 		const item = this.listView.items.get( indexToGet );
 		item.highlight();
 
+		// Scroll the mentions view to the selected element.
+		if ( !this._isItemVisibleInScrolledArea( item ) ) {
+			this.element.scrollTop = item.element.offsetTop;
+		}
+
 		if ( this.selected ) {
 			this.selected.removeHighlight();
 		}
@@ -78,7 +111,19 @@ export default class MentionsView extends View {
 		this.selected = item;
 	}
 
+	/**
+	 * Triggers the `execute` event on the {@link #select selected} item.
+	 */
 	executeSelected() {
 		this.selected.fire( 'execute' );
 	}
+
+	// Checks if an item is visible in the scrollable area.
+	//
+	// The item is considered visible when:
+	// - its top boundary is inside the scrollable rect
+	// - its bottom boundary is inside the scrollable rect (the whole item must be visible)
+	_isItemVisibleInScrolledArea( item ) {
+		return new Rect( this.element ).contains( new Rect( item.element ) );
+	}
 }

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

@@ -22,13 +22,10 @@ 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;
@@ -53,7 +50,7 @@ class CustomMentionAttributeView extends Plugin {
 					return mentionValue;
 				}
 			},
-			converterPriority: HIGHER_THEN_HIGHEST
+			converterPriority: 'high'
 		} );
 
 		editor.conversion.for( 'downcast' ).attributeToElement( {
@@ -69,7 +66,7 @@ class CustomMentionAttributeView extends Plugin {
 					'href': modelAttributeValue.link
 				} );
 			},
-			converterPriority: HIGHER_THEN_HIGHEST
+			converterPriority: 'high'
 		} );
 	}
 }

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

@@ -30,6 +30,13 @@ ClassicEditor
 		mention: {
 			feeds: [
 				{ feed: [ 'Barney', 'Lily', 'Marshall', 'Robin', 'Ted' ] },
+				{
+					marker: '#',
+					feed: [
+						'a01', 'a02', 'a03', 'a04', 'a05', 'a06', 'a07', 'a08', 'a09', 'a10',
+						'a11', 'a12', 'a13', 'a14', 'a15', 'a16', 'a17', 'a18', 'a19', 'a20'
+					]
+				}
 			]
 		}
 	} )

+ 13 - 7
packages/ckeditor5-mention/tests/manual/mention.md

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

+ 135 - 13
packages/ckeditor5-mention/tests/mention-integration.js

@@ -5,15 +5,18 @@
 
 /* global document */
 
-import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
-import { getData as getViewData } from '@ckeditor/ckeditor5-engine/src/dev-utils/view';
+import BlockQuote from '@ckeditor/ckeditor5-block-quote/src/blockquote';
+import Clipboard from '@ckeditor/ckeditor5-clipboard/src/clipboard';
 import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
 import UndoEditing from '@ckeditor/ckeditor5-undo/src/undoediting';
 import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor';
 
+import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
+import { parse as parseView, getData as getViewData } from '@ckeditor/ckeditor5-engine/src/dev-utils/view';
+
 import MentionEditing from '../src/mentionediting';
 
-describe( 'MentionEditing - integration', () => {
+describe( 'Mention feature - integration', () => {
 	let div, editor, model, doc;
 
 	testUtils.createSinonSandbox();
@@ -21,13 +24,6 @@ describe( 'MentionEditing - integration', () => {
 	beforeEach( () => {
 		div = document.createElement( 'div' );
 		document.body.appendChild( div );
-
-		return ClassicTestEditor.create( div, { plugins: [ Paragraph, MentionEditing, UndoEditing ] } )
-			.then( newEditor => {
-				editor = newEditor;
-				model = editor.model;
-				doc = model.document;
-			} );
 	} );
 
 	afterEach( () => {
@@ -36,7 +32,19 @@ describe( 'MentionEditing - integration', () => {
 		return editor.destroy();
 	} );
 
-	describe( 'undo', () => {
+	describe( 'with undo', () => {
+		beforeEach( () => {
+			return ClassicTestEditor.create( div, { plugins: [ Paragraph, MentionEditing, UndoEditing ] } )
+				.then( newEditor => {
+					editor = newEditor;
+					model = editor.model;
+					doc = model.document;
+
+					model.schema.extend( '$text', { allowAttributes: [ 'bold' ] } );
+					editor.conversion.attributeToElement( { model: 'bold', view: 'strong' } );
+				} );
+		} );
+
 		// Failing test. See ckeditor/ckeditor5#1645.
 		it( 'should restore removed mention on adding a text inside mention', () => {
 			editor.setData( '<p>foo <span class="mention" data-mention="John">@John</span> bar</p>' );
@@ -57,7 +65,7 @@ describe( 'MentionEditing - integration', () => {
 			editor.execute( 'undo' );
 
 			expect( editor.getData() ).to.equal( '<p>foo <span class="mention" data-mention="John">@John</span> bar</p>' );
-			expect( getViewData( editor.editing.view ) )
+			expect( getViewData( editor.editing.view, { withoutSelection: true } ) )
 				.to.equal( '<p>foo <span class="mention" data-mention="John">@John</span> bar</p>' );
 		} );
 
@@ -82,8 +90,122 @@ describe( 'MentionEditing - integration', () => {
 			editor.execute( 'undo' );
 
 			expect( editor.getData() ).to.equal( '<p>foo <span class="mention" data-mention="John">@John</span> bar</p>' );
-			expect( getViewData( editor.editing.view ) )
+			expect( getViewData( editor.editing.view, { withoutSelection: true } ) )
 				.to.equal( '<p>foo <span class="mention" data-mention="John">@John</span> bar</p>' );
 		} );
+
+		it( 'should work with attribute post-fixer (beginning formatted)', () => {
+			testAttributePostFixer(
+				'<p>foo <span class="mention" data-mention="John">@John</span> bar</p>',
+				'<p><strong>foo </strong><span class="mention" data-mention="John"><strong>@John</strong></span> bar</p>',
+				() => {
+					model.change( writer => {
+						const paragraph = doc.getRoot().getChild( 0 );
+						const start = writer.createPositionAt( paragraph, 0 );
+						const range = writer.createRange( start, start.getShiftedBy( 6 ) );
+
+						writer.setSelection( range );
+
+						writer.setAttribute( 'bold', true, range );
+					} );
+				} );
+		} );
+
+		it( 'should work with attribute post-fixer (end formatted)', () => {
+			testAttributePostFixer(
+				'<p>foo <span class="mention" data-mention="John">@John</span> bar</p>',
+				'<p>foo <span class="mention" data-mention="John"><strong>@John</strong></span><strong> ba</strong>r</p>',
+				() => {
+					model.change( writer => {
+						const paragraph = doc.getRoot().getChild( 0 );
+						const start = writer.createPositionAt( paragraph, 6 );
+						const range = writer.createRange( start, start.getShiftedBy( 6 ) );
+
+						writer.setSelection( range );
+
+						writer.setAttribute( 'bold', true, range );
+					} );
+				} );
+		} );
+
+		it( 'should work with attribute post-fixer (middle formatted)', () => {
+			testAttributePostFixer(
+				'<p>foo <span class="mention" data-mention="John">@John</span> bar</p>',
+				'<p>foo <span class="mention" data-mention="John"><strong>@John</strong></span> bar</p>',
+				() => {
+					model.change( writer => {
+						const paragraph = doc.getRoot().getChild( 0 );
+						const start = writer.createPositionAt( paragraph, 6 );
+						const range = writer.createRange( start, start.getShiftedBy( 1 ) );
+
+						writer.setSelection( range );
+
+						writer.setAttribute( 'bold', true, range );
+					} );
+				} );
+		} );
+
+		function testAttributePostFixer( initialData, expectedData, testCallback ) {
+			editor.setData( initialData );
+
+			expect( editor.getData() ).to.equal( initialData );
+
+			testCallback();
+
+			expect( editor.getData() )
+				.to.equal( expectedData );
+			expect( getViewData( editor.editing.view, { withoutSelection: true } ) )
+				.to.equal( expectedData );
+
+			editor.execute( 'undo' );
+
+			expect( editor.getData() ).to.equal( initialData );
+			expect( getViewData( editor.editing.view, { withoutSelection: true } ) )
+				.to.equal( initialData );
+
+			editor.execute( 'redo' );
+
+			expect( editor.getData() )
+				.to.equal( expectedData );
+			expect( getViewData( editor.editing.view, { withoutSelection: true } ) )
+				.to.equal( expectedData );
+		}
+	} );
+
+	describe( 'with clipboard', () => {
+		let clipboard;
+
+		beforeEach( () => {
+			return ClassicTestEditor
+				.create( div, { plugins: [ Clipboard, Paragraph, BlockQuote, MentionEditing, UndoEditing ] } )
+				.then( newEditor => {
+					editor = newEditor;
+					model = editor.model;
+					doc = model.document;
+
+					clipboard = editor.plugins.get( 'Clipboard' );
+				} );
+		} );
+
+		it( 'should fix broken mention inside pasted content', () => {
+			editor.setData( '<p>foobar</p>' );
+
+			model.change( writer => {
+				writer.setSelection( doc.getRoot().getChild( 0 ), 3 );
+			} );
+
+			clipboard.fire( 'inputTransformation', {
+				content: parseView( '<blockquote><p>xxx<span class="mention" data-mention="John">@Joh</span></p></blockquote>' )
+			} );
+
+			const expectedData = '<p>foo</p>' +
+				'<blockquote><p>xxx@Joh</p></blockquote>' +
+				'<p>bar</p>';
+
+			expect( editor.getData() )
+				.to.equal( expectedData );
+			expect( getViewData( editor.editing.view, { withoutSelection: true } ) )
+				.to.equal( expectedData );
+		} );
 	} );
 } );

+ 249 - 5
packages/ckeditor5-mention/tests/mentionediting.js

@@ -145,7 +145,7 @@ describe( 'MentionEditing', () => {
 		} );
 	} );
 
-	describe( 'selection post fixer', () => {
+	describe( 'selection post-fixer', () => {
 		beforeEach( () => {
 			return createTestEditor()
 				.then( newEditor => {
@@ -182,7 +182,7 @@ describe( 'MentionEditing', () => {
 		} );
 	} );
 
-	describe( 'removing partial mention post fixer', () => {
+	describe( 'removing partial mention post-fixer', () => {
 		beforeEach( () => {
 			return createTestEditor()
 				.then( newEditor => {
@@ -192,7 +192,7 @@ describe( 'MentionEditing', () => {
 				} );
 		} );
 
-		it( 'should remove mention on adding a text inside mention', () => {
+		it( 'should remove mention on adding a text inside mention (in the middle)', () => {
 			editor.setData( '<p>foo <span class="mention" data-mention="John">@John</span> bar</p>' );
 
 			const textNode = doc.getRoot().getChild( 0 ).getChild( 1 );
@@ -217,7 +217,45 @@ describe( 'MentionEditing', () => {
 			expect( editor.getData() ).to.equal( '<p>foo @Jaohn bar</p>' );
 		} );
 
-		it( 'should remove mention on removing a text inside mention', () => {
+		it( 'should remove mention on typing in mention node with selection attributes set', () => {
+			editor.setData( '<p>foo <span class="mention" data-mention="John">@John</span> bar</p>' );
+
+			const textNode = doc.getRoot().getChild( 0 ).getChild( 1 );
+
+			expect( textNode ).to.not.be.null;
+			expect( textNode.hasAttribute( 'mention' ) ).to.be.true;
+
+			model.change( writer => {
+				const paragraph = doc.getRoot().getChild( 0 );
+
+				writer.setSelection( paragraph, 6 );
+				writer.setSelectionAttribute( 'bold', true );
+
+				writer.insertText( 'a', doc.selection.getAttributes(), writer.createPositionAt( paragraph, 6 ) );
+			} );
+
+			expect( getModelData( model, { withoutSelection: true } ) )
+				.to.equal( '<paragraph>foo @J<$text bold="true">a</$text>ohn bar</paragraph>' );
+		} );
+
+		it( 'should remove mention on removing a text at the beginning of a mention', () => {
+			editor.setData( '<p>foo <span class="mention" data-mention="John">@John</span> bar</p>' );
+
+			const paragraph = doc.getRoot().getChild( 0 );
+
+			model.change( writer => {
+				writer.setSelection( paragraph, 4 );
+			} );
+
+			model.enqueueChange( () => {
+				model.modifySelection( doc.selection, { direction: 'forward', unit: 'codepoint' } );
+				model.deleteContent( doc.selection );
+			} );
+
+			expect( editor.getData() ).to.equal( '<p>foo John bar</p>' );
+		} );
+
+		it( 'should remove mention on removing a text in the middle a mention', () => {
 			editor.setData( '<p>foo <span class="mention" data-mention="John">@John</span> bar</p>' );
 
 			const paragraph = doc.getRoot().getChild( 0 );
@@ -239,7 +277,6 @@ describe( 'MentionEditing', () => {
 
 			const paragraph = doc.getRoot().getChild( 0 );
 
-			// Set selection at the end of a John.
 			model.change( writer => {
 				writer.setSelection( paragraph, 9 );
 			} );
@@ -269,6 +306,213 @@ describe( 'MentionEditing', () => {
 
 			expect( editor.getData() ).to.equal( '<p>foo <span class="mention" data-mention="John">@John</span>bar</p>' );
 		} );
+
+		it( 'should remove mention on inserting text node inside a mention', () => {
+			editor.setData( '<p>foo <span class="mention" data-mention="John">@John</span> bar</p>' );
+
+			const paragraph = doc.getRoot().getChild( 0 );
+
+			model.change( writer => {
+				writer.insertText( 'baz', paragraph, 7 );
+			} );
+
+			expect( editor.getData() ).to.equal( '<p>foo @Jobazhn bar</p>' );
+		} );
+
+		it( 'should remove mention on inserting inline element inside a mention', () => {
+			model.schema.register( 'inline', {
+				allowWhere: '$text',
+				isInline: true
+			} );
+			editor.conversion.elementToElement( { model: 'inline', view: 'br' } );
+
+			editor.setData( '<p>foo <span class="mention" data-mention="John">@John</span> bar</p>' );
+
+			const paragraph = doc.getRoot().getChild( 0 );
+
+			model.change( writer => {
+				writer.insertElement( 'inline', paragraph, 7 );
+			} );
+
+			expect( editor.getData() ).to.equal( '<p>foo @Jo<br>hn bar</p>' );
+		} );
+
+		it( 'should remove mention when splitting paragraph with a mention', () => {
+			editor.setData( '<p>foo <span class="mention" data-mention="John">@John</span> bar</p>' );
+
+			const paragraph = doc.getRoot().getChild( 0 );
+
+			model.change( writer => {
+				writer.split( writer.createPositionAt( paragraph, 7 ) );
+			} );
+
+			expect( editor.getData() ).to.equal( '<p>foo @Jo</p><p>hn bar</p>' );
+		} );
+
+		it( 'should remove mention when deep splitting elements', () => {
+			model.schema.register( 'blockQuote', {
+				allowWhere: '$block',
+				allowContentOf: '$root'
+			} );
+
+			editor.conversion.elementToElement( { model: 'blockQuote', view: 'blockquote' } );
+			editor.setData( '<blockquote><p>foo <span class="mention" data-mention="John">@John</span> bar</p></blockquote>' );
+
+			model.change( writer => {
+				const paragraph = doc.getRoot().getChild( 0 ).getChild( 0 );
+
+				writer.split( writer.createPositionAt( paragraph, 7 ), doc.getRoot() );
+			} );
+
+			expect( editor.getData() ).to.equal( '<blockquote><p>foo @Jo</p></blockquote><blockquote><p>hn bar</p></blockquote>' );
+		} );
+	} );
+
+	describe( 'extend attribute on mention post-fixer', () => {
+		beforeEach( () => {
+			return createTestEditor()
+				.then( newEditor => {
+					editor = newEditor;
+					model = editor.model;
+					doc = model.document;
+				} );
+		} );
+
+		it( 'should set attribute on whole mention when formatting part of a mention (beginning formatted)', () => {
+			model.schema.extend( '$text', { allowAttributes: [ 'bold' ] } );
+			editor.conversion.attributeToElement( { model: 'bold', view: 'strong' } );
+
+			editor.setData( '<p>foo <span class="mention" data-mention="John">@John</span> bar</p>' );
+
+			const paragraph = doc.getRoot().getChild( 0 );
+
+			model.change( writer => {
+				const start = writer.createPositionAt( paragraph, 0 );
+				const range = writer.createRange( start, start.getShiftedBy( 6 ) );
+
+				writer.setSelection( range );
+
+				writer.setAttribute( 'bold', true, range );
+			} );
+
+			expect( editor.getData() )
+				.to.equal( '<p><strong>foo </strong><span class="mention" data-mention="John"><strong>@John</strong></span> bar</p>' );
+		} );
+
+		it( 'should set attribute on whole mention when formatting part of a mention (end formatted)', () => {
+			model.schema.extend( '$text', { allowAttributes: [ 'bold' ] } );
+			editor.conversion.attributeToElement( { model: 'bold', view: 'strong' } );
+
+			editor.setData( '<p>foo <span class="mention" data-mention="John">@John</span> bar</p>' );
+
+			const paragraph = doc.getRoot().getChild( 0 );
+
+			model.change( writer => {
+				const start = writer.createPositionAt( paragraph, 6 );
+				const range = writer.createRange( start, start.getShiftedBy( 6 ) );
+
+				writer.setSelection( range );
+
+				writer.setAttribute( 'bold', true, range );
+			} );
+
+			expect( editor.getData() )
+				.to.equal( '<p>foo <span class="mention" data-mention="John"><strong>@John</strong></span><strong> ba</strong>r</p>' );
+		} );
+
+		it( 'should set attribute on whole mention when formatting part of a mention (middle of mention formatted)', () => {
+			model.schema.extend( '$text', { allowAttributes: [ 'bold' ] } );
+			editor.conversion.attributeToElement( { model: 'bold', view: 'strong' } );
+
+			editor.setData( '<p>foo <span class="mention" data-mention="John">@John</span> bar</p>' );
+
+			const paragraph = doc.getRoot().getChild( 0 );
+
+			model.change( writer => {
+				const start = writer.createPositionAt( paragraph, 6 );
+				const range = writer.createRange( start, start.getShiftedBy( 1 ) );
+
+				writer.setSelection( range );
+
+				writer.setAttribute( 'bold', true, range );
+			} );
+
+			expect( editor.getData() )
+				.to.equal( '<p>foo <span class="mention" data-mention="John"><strong>@John</strong></span> bar</p>' );
+		} );
+
+		it( 'should set attribute on whole mention when formatting part of two mentions', () => {
+			model.schema.extend( '$text', { allowAttributes: [ 'bold' ] } );
+			editor.conversion.attributeToElement( { model: 'bold', view: 'strong' } );
+
+			editor.setData(
+				'<p><span class="mention" data-mention="John">@John</span><span class="mention" data-mention="John">@John</span></p>'
+			);
+
+			const paragraph = doc.getRoot().getChild( 0 );
+
+			model.change( writer => {
+				const start = writer.createPositionAt( paragraph, 4 );
+				const range = writer.createRange( start, start.getShiftedBy( 4 ) );
+
+				writer.setSelection( range );
+
+				writer.setAttribute( 'bold', true, range );
+			} );
+
+			expect( editor.getData() ).to.equal(
+				'<p>' +
+					'<span class="mention" data-mention="John"><strong>@John</strong></span>' +
+					'<span class="mention" data-mention="John"><strong>@John</strong></span>' +
+				'</p>'
+			);
+		} );
+
+		it( 'should work with multiple ranges in change set', () => {
+			model.schema.extend( '$text', { allowAttributes: [ 'foo' ] } );
+			editor.conversion.attributeToElement( {
+				model: {
+					key: 'foo',
+					values: [ 'a', 'b' ]
+				},
+				view: {
+					a: {
+						name: 'span',
+						classes: 'mark-a'
+					},
+					b: {
+						name: 'span',
+						classes: 'mark-b'
+					}
+				},
+				converterPriority: 'high'
+			} );
+
+			editor.setData(
+				'<p>' +
+					'<span class="mark-a">foo <span class="mention" data-mention="John">@John</span></span>' +
+					'<span class="mention" data-mention="John">@John</span> bar' +
+				'</p>'
+			);
+
+			model.change( writer => {
+				const paragraph = doc.getRoot().getChild( 0 );
+				const start = writer.createPositionAt( paragraph, 7 );
+				const range = writer.createRange( start, start.getShiftedBy( 5 ) );
+
+				writer.setAttribute( 'foo', 'b', range );
+			} );
+
+			expect( editor.getData() ).to.equal(
+				'<p>' +
+					'<span class="mark-a">foo </span>' +
+					'<span class="mark-b">' +
+						'<span class="mention" data-mention="John">@John</span>' +
+						'<span class="mention" data-mention="John">@John</span>' +
+					'</span> bar' +
+				'</p>'
+			);
+		} );
 	} );
 
 	function createTestEditor( mentionConfig ) {

+ 97 - 2
packages/ckeditor5-mention/tests/mentionui.js

@@ -282,6 +282,75 @@ describe( 'MentionUI', () => {
 				} );
 		} );
 
+		describe( 'static list with large set of results', () => {
+			const bigList = {
+				marker: '@',
+				feed: [
+					'a01', 'a02', 'a03', 'a04', 'a05', 'a06', 'a07', 'a08', 'a09', 'a10', 'a11', 'a12'
+				]
+			};
+
+			beforeEach( () => {
+				return createClassicTestEditor( { feeds: [ bigList ] } );
+			} );
+
+			it( 'should show panel with no more then 10 items for default static feed', () => {
+				setData( model, '<paragraph>foo []</paragraph>' );
+
+				model.change( writer => {
+					writer.insertText( '@', doc.selection.getFirstPosition() );
+				} );
+
+				return waitForDebounce()
+					.then( () => {
+						expect( panelView.isVisible ).to.be.true;
+						expect( listView.items ).to.have.length( 10 );
+					} );
+			} );
+
+			it( 'should scroll mention panel to the selected item', () => {
+				setData( model, '<paragraph>foo []</paragraph>' );
+
+				model.change( writer => {
+					writer.insertText( '@', doc.selection.getFirstPosition() );
+				} );
+
+				return waitForDebounce()
+					.then( () => {
+						expect( panelView.isVisible ).to.be.true;
+
+						expectChildViewsIsOnState( [ true, false, false, false, false, false, false, false, false, false ] );
+
+						const arrowDownEvtData = {
+							keyCode: keyCodes.arrowdown,
+							preventDefault: sinon.spy(),
+							stopPropagation: sinon.spy()
+						};
+
+						const arrowUpEvtData = {
+							keyCode: keyCodes.arrowup,
+							preventDefault: sinon.spy(),
+							stopPropagation: sinon.spy()
+						};
+
+						fireKeyDownEvent( arrowDownEvtData );
+						expect( mentionsView.element.scrollTop ).to.equal( 0 );
+
+						expectChildViewsIsOnState( [ false, true, false, false, false, false, false, false, false, false ] );
+
+						fireKeyDownEvent( arrowUpEvtData );
+						fireKeyDownEvent( arrowUpEvtData );
+
+						expectChildViewsIsOnState( [ false, false, false, false, false, false, false, false, false, true ] );
+						expect( mentionsView.element.scrollTop ).to.be.not.equal( 0 );
+
+						fireKeyDownEvent( arrowDownEvtData );
+						expectChildViewsIsOnState( [ true, false, false, false, false, false, false, false, false, false ] );
+						expect( mentionsView.element.scrollTop ).to.equal( 0 );
+					} );
+			} );
+		} );
+
 		describe( 'static list with default trigger', () => {
 			beforeEach( () => {
 				return createClassicTestEditor( staticConfig );
@@ -334,7 +403,10 @@ describe( 'MentionUI', () => {
 			} );
 
 			it( 'should not show panel when selection is inside a mention', () => {
-				setData( model, '<paragraph>foo <$text mention="{\'name\':\'John\'}">@John</$text> bar</paragraph>' );
+				setData( model, '<paragraph>foo [@John] bar</paragraph>' );
+				model.change( writer => {
+					writer.setAttribute( 'mention', { name: 'John', _marker: '@', _id: 1234 }, doc.selection.getFirstRange() );
+				} );
 
 				model.change( writer => {
 					writer.setSelection( doc.getRoot().getChild( 0 ), 7 );
@@ -348,7 +420,10 @@ describe( 'MentionUI', () => {
 			} );
 
 			it( 'should not show panel when selection is at the end of a mention', () => {
-				setData( model, '<paragraph>foo <$text mention="{\'name\':\'John\'}">@John</$text> bar</paragraph>' );
+				setData( model, '<paragraph>foo [@John] bar</paragraph>' );
+				model.change( writer => {
+					writer.setAttribute( 'mention', { name: 'John', _marker: '@', _id: 1234 }, doc.selection.getFirstRange() );
+				} );
 
 				model.change( writer => {
 					writer.setSelection( doc.getRoot().getChild( 0 ), 9 );
@@ -384,6 +459,26 @@ describe( 'MentionUI', () => {
 					} );
 			} );
 
+			it( 'should not show panel when selection is after existing mention', () => {
+				setData( model, '<paragraph>foo [@John] bar[]</paragraph>' );
+				model.change( writer => {
+					writer.setAttribute( 'mention', { name: 'John', _marker: '@', _id: 1234 }, doc.selection.getFirstRange() );
+				} );
+
+				return waitForDebounce()
+					.then( () => {
+						expect( panelView.isVisible ).to.be.false;
+
+						model.change( writer => {
+							writer.setSelection( doc.getRoot().getChild( 0 ), 8 );
+						} );
+					} )
+					.then( waitForDebounce )
+					.then( () => {
+						expect( panelView.isVisible ).to.be.false;
+					} );
+			} );
+
 			it( 'should show filtered results for matched text', () => {
 				setData( model, '<paragraph>foo []</paragraph>' );
 

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


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

@@ -0,0 +1,16 @@
+/*
+ * Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+:root {
+	--ck-mention-list-max-height: 300px;
+}
+
+.ck.ck-mentions {
+	max-height: var(--ck-mention-list-max-height);
+
+	overflow-y: auto;
+
+	overscroll-behavior: contain;
+}