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

Merge pull request #33 from ckeditor/t/10

Other: Change mention attribute properties names. Closes #10. Closes #30.
Piotrek Koszuliński 6 лет назад
Родитель
Сommit
0edd4c338b
26 измененных файлов с 722 добавлено и 332 удалено
  1. 1 1
      packages/ckeditor5-mention/docs/_snippets/features/custom-mention-colors-variables.html
  2. 1 1
      packages/ckeditor5-mention/docs/_snippets/features/custom-mention-colors-variables.js
  3. 1 1
      packages/ckeditor5-mention/docs/_snippets/features/mention-customization.html
  4. 28 25
      packages/ckeditor5-mention/docs/_snippets/features/mention-customization.js
  5. 1 1
      packages/ckeditor5-mention/docs/_snippets/features/mention.html
  6. 1 1
      packages/ckeditor5-mention/docs/_snippets/features/mention.js
  7. 61 42
      packages/ckeditor5-mention/docs/features/mention.md
  8. 2 6
      packages/ckeditor5-mention/package.json
  9. 43 13
      packages/ckeditor5-mention/src/mention.js
  10. 71 11
      packages/ckeditor5-mention/src/mentioncommand.js
  11. 30 26
      packages/ckeditor5-mention/src/mentionediting.js
  12. 22 8
      packages/ckeditor5-mention/src/mentionui.js
  13. 10 8
      packages/ckeditor5-mention/src/textwatcher.js
  14. 33 27
      packages/ckeditor5-mention/tests/manual/mention-custom-renderer.js
  15. 17 8
      packages/ckeditor5-mention/tests/manual/mention-custom-renderer.md
  16. 2 2
      packages/ckeditor5-mention/tests/manual/mention-custom-view.html
  17. 22 30
      packages/ckeditor5-mention/tests/manual/mention-custom-view.js
  18. 5 5
      packages/ckeditor5-mention/tests/manual/mention-custom-view.md
  19. 2 2
      packages/ckeditor5-mention/tests/manual/mention.html
  20. 14 15
      packages/ckeditor5-mention/tests/manual/mention.js
  21. 4 1
      packages/ckeditor5-mention/tests/manual/mention.md
  22. 17 17
      packages/ckeditor5-mention/tests/mention-integration.js
  23. 43 0
      packages/ckeditor5-mention/tests/mention.js
  24. 64 17
      packages/ckeditor5-mention/tests/mentioncommand.js
  25. 55 49
      packages/ckeditor5-mention/tests/mentionediting.js
  26. 172 15
      packages/ckeditor5-mention/tests/mentionui.js

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

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

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

@@ -20,7 +20,7 @@ ClassicEditor
 			feeds: [
 				{
 					marker: '@',
-					feed: [ 'Barney', 'Lily', 'Marshall', 'Robin', 'Ted' ]
+					feed: [ '@Barney', '@Lily', '@Marshall', '@Robin', '@Ted' ]
 				}
 			]
 		}

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

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

+ 28 - 25
packages/ckeditor5-mention/docs/_snippets/features/mention-customization.js

@@ -22,8 +22,7 @@ ClassicEditor
 				{
 					marker: '@',
 					feed: getFeedItems,
-					itemRenderer: customItemRenderer,
-					minimumCharacters: 1
+					itemRenderer: customItemRenderer
 				}
 			]
 		}
@@ -36,8 +35,8 @@ ClassicEditor
 	} );
 
 function MentionCustomization( editor ) {
-	// The upcast converter will convert <a class="mention" href="" data-user-id="">
-	// elements to the model 'mention' attribute.
+	// 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',
@@ -52,23 +51,21 @@ function MentionCustomization( editor ) {
 			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' ),
-
+				// in the model is a plain object with a set of additional attributes.
+				// In order to create a proper object use the toMentionAttribute() helper method:
+				const mentionAttribute = editor.plugins.get( 'Mention' ).toMentionAttribute( viewItem, {
 					// Add any other properties that you need.
 					link: viewItem.getAttribute( 'href' ),
-					id: viewItem.getAttribute( 'data-user-id' )
-				};
+					userId: viewItem.getAttribute( 'data-user-id' )
+				} );
 
-				return mentionValue;
+				return mentionAttribute;
 			}
 		},
 		converterPriority: 'high'
 	} );
 
-	// Do not forget to define a downcast converter as well:
+	// Downcast the model 'mention' text attribute to a view <a> element.
 	editor.conversion.for( 'downcast' ).attributeToElement( {
 		model: 'mention',
 		view: ( modelAttributeValue, viewWriter ) => {
@@ -79,8 +76,8 @@ function MentionCustomization( editor ) {
 
 			return viewWriter.createAttributeElement( 'a', {
 				class: 'mention',
-				'data-mention': modelAttributeValue.name,
-				'data-user-id': modelAttributeValue.id,
+				'data-mention': modelAttributeValue.id,
+				'data-user-id': modelAttributeValue.userId,
 				'href': modelAttributeValue.link
 			} );
 		},
@@ -89,20 +86,26 @@ function MentionCustomization( editor ) {
 }
 
 const items = [
-	{ id: '1', name: 'Barney Stinson', username: 'swarley', link: 'https://www.imdb.com/title/tt0460649/characters/nm0000439' },
-	{ id: '2', name: 'Lily Aldrin', username: 'lilypad', link: 'https://www.imdb.com/title/tt0460649/characters/nm0004989' },
-	{ id: '3', name: 'Marshall Eriksen', username: 'marshmallow', link: 'https://www.imdb.com/title/tt0460649/characters/nm0781981' },
-	{ id: '4', name: 'Robin Scherbatsky', username: 'rsparkles', link: 'https://www.imdb.com/title/tt0460649/characters/nm1130627' },
-	{ id: '5', name: 'Ted Mosby', username: 'tdog', link: 'https://www.imdb.com/title/tt0460649/characters/nm1102140' }
+	{ id: '@swarley', userId: '1', name: 'Barney Stinson', link: 'https://www.imdb.com/title/tt0460649/characters/nm0000439' },
+	{ id: '@lilypad', userId: '2', name: 'Lily Aldrin', link: 'https://www.imdb.com/title/tt0460649/characters/nm0004989' },
+	{ id: '@marshmallow', userId: '3', name: 'Marshall Eriksen', link: 'https://www.imdb.com/title/tt0460649/characters/nm0781981' },
+	{ id: '@rsparkles', userId: '4', name: 'Robin Scherbatsky', link: 'https://www.imdb.com/title/tt0460649/characters/nm1130627' },
+	{ id: '@tdog', userId: '5', name: 'Ted Mosby', link: 'https://www.imdb.com/title/tt0460649/characters/nm1102140' }
 ];
 
 function getFeedItems( 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.
+	// This can be a server request or any sort of delayed action.
 	return new Promise( resolve => {
 		setTimeout( () => {
-			resolve( items.filter( isItemMatching ) );
+			const itemsToDisplay = items
+				// Filter out the full list of all items to only those matching queryText.
+				.filter( isItemMatching )
+				// Return 10 items max - needed for generic queries when the list may contain hundreds of elements.
+				.slice( 0, 10 );
+
+			resolve( itemsToDisplay );
 		}, 100 );
 	} );
 
@@ -114,7 +117,7 @@ function getFeedItems( queryText ) {
 		// 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 )
+			item.id.toLowerCase().includes( searchString )
 		);
 	}
 }
@@ -123,13 +126,13 @@ function customItemRenderer( item ) {
 	const itemElement = document.createElement( 'span' );
 
 	itemElement.classList.add( 'custom-item' );
-	itemElement.id = `mention-list-item-id-${ item.id }`;
+	itemElement.id = `mention-list-item-id-${ item.userId }`;
 	itemElement.textContent = `${ item.name } `;
 
 	const usernameElement = document.createElement( 'span' );
 
 	usernameElement.classList.add( 'custom-item-username' );
-	usernameElement.textContent = `@${ item.username }`;
+	usernameElement.textContent = item.id;
 
 	itemElement.appendChild( usernameElement );
 

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

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

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

@@ -20,7 +20,7 @@ ClassicEditor
 			feeds: [
 				{
 					marker: '@',
-					feed: [ 'Barney', 'Lily', 'Marshall', 'Robin', 'Ted' ]
+					feed: [ '@Barney', '@Lily', '@Marshall', '@Robin', '@Ted' ]
 				}
 			]
 		}

+ 61 - 42
packages/ckeditor5-mention/docs/features/mention.md

@@ -31,7 +31,7 @@ ClassicEditor
 			feeds: [
 				{
 					marker: '@',
-					feed: [ 'Barney', 'Lily', 'Marshall', 'Robin', 'Ted' ],
+					feed: [ '@Barney', '@Lily', '@Marshall', '@Robin', '@Ted' ],
 					minimumCharacters: 1
 				}
 			}
@@ -65,12 +65,30 @@ When using a callback you can return a `Promise` that resolves with the list of
 The callback receives the query text which should be used to filter item suggestions. It should return a `Promise` and resolve it with an array of items that match to the feed text.
 
 ```js
+ClassicEditor
+	.create( document.querySelector( '#editor' ), {
+		// This feature is not available in any of the builds.
+		// See the "Installation" section.
+		plugins: [ Mention, ... ],
+
+		mention: {
+			feeds: [
+				{
+					marker: '@',
+					feed: getFeedItems
+				}
+			}
+		}
+	} )
+	.then( ... )
+	.catch( ... );
+
 const items = [
-	{ id: '1', name: 'Barney Stinson', username: 'swarley', link: 'https://www.imdb.com/title/tt0460649/characters/nm0000439' },
-	{ id: '2', name: 'Lily Aldrin', username: 'lilypad', link: 'https://www.imdb.com/title/tt0460649/characters/nm0004989' },
-	{ id: '3', name: 'Marshall Eriksen', username: 'marshmallow', link: 'https://www.imdb.com/title/tt0460649/characters/nm0781981' },
-	{ id: '4', name: 'Robin Scherbatsky', username: 'rsparkles', link: 'https://www.imdb.com/title/tt0460649/characters/nm1130627' },
-	{ id: '5', name: 'Ted Mosby', username: 'tdog', link: 'https://www.imdb.com/title/tt0460649/characters/nm1102140' }
+	{ id: '@swarley', userId: '1', name: 'Barney Stinson', link: 'https://www.imdb.com/title/tt0460649/characters/nm0000439' },
+	{ id: '@lilypad', userId: '2', name: 'Lily Aldrin', link: 'https://www.imdb.com/title/tt0460649/characters/nm0004989' },
+	{ id: '@marshmallow', userId: '3', name: 'Marshall Eriksen', link: 'https://www.imdb.com/title/tt0460649/characters/nm0781981' },
+	{ id: '@rsparkles', userId: '4', name: 'Robin Scherbatsky', link: 'https://www.imdb.com/title/tt0460649/characters/nm1130627' },
+	{ id: '@tdog', userId: '5', name: 'Ted Mosby', link: 'https://www.imdb.com/title/tt0460649/characters/nm1102140' }
 ];
 
 function getFeedItems( queryText ) {
@@ -97,7 +115,7 @@ function getFeedItems( queryText ) {
 		// 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 )
+			item.id.toLowerCase().includes( searchString )
 		);
 	}
 }
@@ -132,13 +150,13 @@ function customItemRenderer( item ) {
 	const itemElement = document.createElement( 'span' );
 
 	itemElement.classList.add( 'custom-item' );
-	itemElement.id = `mention-list-item-id-${ item.id }`;
+	itemElement.id = `mention-list-item-id-${ item.userId }`;
 	itemElement.textContent = `${ item.name } `;
 
 	const usernameElement = document.createElement( 'span' );
 
 	usernameElement.classList.add( 'custom-item-username' );
-	usernameElement.textContent = `@${ item.username }`;
+	usernameElement.textContent = item.id;
 
 	itemElement.appendChild( usernameElement );
 
@@ -155,13 +173,13 @@ In order to change the markup generated by the editor for mentions, you can over
 The example below defined a plugin which overrides the default output:
 
 ```html
-<span data-mention="Ted" class="mention">@Ted</span>
+<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>
+<a class="mention" data-mention="@Ted" data-user-id="5" href="https://www.imdb.com/title/tt0460649/characters/nm1102140">@tdog</a>
 ```
 
 The converters must be defined with a `'high'` priority to be executed before the {@link features/link link} feature's converter and before the default converter of the mention feature. A mention is stored in the model as a {@link framework/guides/architecture/editing-engine#text-attributes text attribute} which stores an object (see {@link module:mention/mention~MentionFeedItem}).
@@ -194,17 +212,15 @@ function MentionCustomization( editor ) {
 			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' ),
-
+				// in the model is a plain object with a set of additional attributes.
+				// In order to create a proper object use the toMentionAttribute() helper method:
+				const mentionAttribute = editor.plugins.get( 'Mention' ).toMentionAttribute( viewItem, {
 					// Add any other properties that you need.
 					link: viewItem.getAttribute( 'href' ),
-					id: viewItem.getAttribute( 'data-user-id' )
-				};
+					userId: viewItem.getAttribute( 'data-user-id' )
+				} );
 
-				return mentionValue;
+				return mentionAttribute;
 			}
 		},
 		converterPriority: 'high'
@@ -221,8 +237,8 @@ function MentionCustomization( editor ) {
 
 			return viewWriter.createAttributeElement( 'a', {
 				class: 'mention',
-				'data-mention': modelAttributeValue.name,
-				'data-user-id': modelAttributeValue.id,
+				'data-mention': modelAttributeValue.id,
+				'data-user-id': modelAttributeValue.userId,
 				'href': modelAttributeValue.link
 			} );
 		},
@@ -254,8 +270,7 @@ ClassicEditor
 				{
 					marker: '@',
 					feed: getFeedItems,
-					itemRenderer: customItemRenderer,
-					minimumCharacters: 1
+					itemRenderer: customItemRenderer
 				}
 			]
 		}
@@ -284,17 +299,15 @@ function MentionCustomization( editor ) {
 			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' ),
-
+				// in the model is a plain object with set of additional attributes.
+				// In order to create proper object use `toMentionAttribute` helper method:
+				const mentionAttribute = editor.plugins.get( 'Mention' ).toMentionAttribute( viewItem, {
 					// Add any other properties that you need.
 					link: viewItem.getAttribute( 'href' ),
-					id: viewItem.getAttribute( 'data-user-id' )
-				};
+					userId: viewItem.getAttribute( 'data-user-id' )
+				} );
 
-				return mentionValue;
+				return mentionAttribute;
 			}
 		},
 		converterPriority: 'high'
@@ -311,8 +324,8 @@ function MentionCustomization( editor ) {
 
 			return viewWriter.createAttributeElement( 'a', {
 				class: 'mention',
-				'data-mention': modelAttributeValue.name,
-				'data-user-id': modelAttributeValue.id,
+				'data-mention': modelAttributeValue.id,
+				'data-user-id': modelAttributeValue.userId,
 				'href': modelAttributeValue.link
 			} );
 		},
@@ -321,11 +334,11 @@ function MentionCustomization( editor ) {
 }
 
 const items = [
-	{ id: '1', name: 'Barney Stinson', username: 'swarley', link: 'https://www.imdb.com/title/tt0460649/characters/nm0000439' },
-	{ id: '2', name: 'Lily Aldrin', username: 'lilypad', link: 'https://www.imdb.com/title/tt0460649/characters/nm0004989' },
-	{ id: '3', name: 'Marshall Eriksen', username: 'marshmallow', link: 'https://www.imdb.com/title/tt0460649/characters/nm0781981' },
-	{ id: '4', name: 'Robin Scherbatsky', username: 'rsparkles', link: 'https://www.imdb.com/title/tt0460649/characters/nm1130627' },
-	{ id: '5', name: 'Ted Mosby', username: 'tdog', link: 'https://www.imdb.com/title/tt0460649/characters/nm1102140' }
+	{ id: '@swarley', userId: '1', name: 'Barney Stinson', link: 'https://www.imdb.com/title/tt0460649/characters/nm0000439' },
+	{ id: '@lilypad', userId: '2', name: 'Lily Aldrin', link: 'https://www.imdb.com/title/tt0460649/characters/nm0004989' },
+	{ id: '@marshmallow', userId: '3', name: 'Marshall Eriksen', link: 'https://www.imdb.com/title/tt0460649/characters/nm0781981' },
+	{ id: '@rsparkles', userId: '4', name: 'Robin Scherbatsky', link: 'https://www.imdb.com/title/tt0460649/characters/nm1130627' },
+	{ id: '@tdog', userId: '5', name: 'Ted Mosby', link: 'https://www.imdb.com/title/tt0460649/characters/nm1102140' }
 ];
 
 function getFeedItems( queryText ) {
@@ -334,7 +347,13 @@ function getFeedItems( queryText ) {
 	// This can be a server request or any sort of delayed action.
 	return new Promise( resolve => {
 		setTimeout( () => {
-			resolve( items.filter( isItemMatching ) );
+			const itemsToDisplay = items
+				// Filter out the full list of all items to only those matching queryText.
+				.filter( isItemMatching )
+				// Return 10 items max - needed for generic queries when the list may contain hundreds of elements.
+				.slice( 0, 10 );
+
+			resolve( itemsToDisplay );
 		}, 100 );
 	} );
 
@@ -346,7 +365,7 @@ function getFeedItems( queryText ) {
 		// 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 )
+			item.id.toLowerCase().includes( searchString )
 		);
 	}
 }
@@ -355,13 +374,13 @@ function customItemRenderer( item ) {
 	const itemElement = document.createElement( 'span' );
 
 	itemElement.classList.add( 'custom-item' );
-	itemElement.id = `mention-list-item-id-${ item.id }`;
+	itemElement.id = `mention-list-item-id-${ item.userId }`;
 	itemElement.textContent = `${ item.name } `;
 
 	const usernameElement = document.createElement( 'span' );
 
 	usernameElement.classList.add( 'custom-item-username' );
-	usernameElement.textContent = `@${ item.username }`;
+	usernameElement.textContent = item.id;
 
 	itemElement.appendChild( usernameElement );
 

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

@@ -16,17 +16,13 @@
   },
   "devDependencies": {
     "@ckeditor/ckeditor5-basic-styles": "^11.0.0",
+    "@ckeditor/ckeditor5-block-quote": "^11.0.0",
     "@ckeditor/ckeditor5-clipboard": "^11.0.0",
     "@ckeditor/ckeditor5-editor-classic": "^12.0.0",
     "@ckeditor/ckeditor5-engine": "^13.0.0",
-    "@ckeditor/ckeditor5-enter": "^11.0.0",
-    "@ckeditor/ckeditor5-heading": "^11.0.0",
-    "@ckeditor/ckeditor5-link": "^11.0.0",
+    "@ckeditor/ckeditor5-font": "^11.0.0",
     "@ckeditor/ckeditor5-paragraph": "^11.0.0",
-    "@ckeditor/ckeditor5-table": "^12.0.0",
-    "@ckeditor/ckeditor5-typing": "^12.0.0",
     "@ckeditor/ckeditor5-undo": "^11.0.0",
-    "@ckeditor/ckeditor5-widget": "^11.0.0",
     "eslint": "^5.5.0",
     "eslint-config-ckeditor5": "^1.0.11",
     "husky": "^1.3.1",

+ 43 - 13
packages/ckeditor5-mention/src/mention.js

@@ -9,7 +9,7 @@
 
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 
-import MentionEditing from './mentionediting';
+import MentionEditing, { _toMentionAttribute } from './mentionediting';
 import MentionUI from './mentionui';
 
 import '../theme/mention.css';
@@ -22,6 +22,23 @@ import '../theme/mention.css';
  * @extends module:core/plugin~Plugin
  */
 export default class Mention extends Plugin {
+	/**
+	 * Creates a mention attribute value from the provided view element and optional data.
+	 *
+	 *		editor.plugins.get( 'Mention' ).toMentionAttribute( viewElement, { userId: '1234' } );
+	 *
+	 *		// for a viewElement: <span data-mention="@joe">@John Doe</span>
+	 *		// it will return:
+	 *		// { id: '@joe', userId: '1234', _uid: '7a7bc7...', _text: '@John Doe' }
+	 *
+	 * @param {module:engine/view/element~Element} viewElement
+	 * @param {String|Object} [data] Additional data to be stored in the mention attribute.
+	 * @returns {module:mention/mention~MentionAttribute}
+	 */
+	toMentionAttribute( viewElement, data ) {
+		return _toMentionAttribute( viewElement, data );
+	}
+
 	/**
 	 * @inheritDoc
 	 */
@@ -73,7 +90,7 @@ export default class Mention extends Plugin {
  *					feeds: [
  *						{
  *							marker: '@',
- *							feed: [ 'Barney', 'Lily', 'Marshall', 'Robin', 'Ted' ]
+ *							feed: [ '@Barney', '@Lily', '@Marshall', '@Robin', '@Ted' ]
  *						},
  *						...
  * 					]
@@ -96,7 +113,7 @@ export default class Mention extends Plugin {
  *		// Static configuration.
  *		const mentionFeedPeople = {
  *			marker: '@',
- *			feed: [ 'Alice', 'Bob', ... ],
+ *			feed: [ '@Alice', '@Bob', ... ],
  *			minimumCharacters: 2
  *		};
  *
@@ -107,7 +124,7 @@ export default class Mention extends Plugin {
  *				return tags
  *					// Filter the tags list.
  *					.filter( tag => {
- *						return tag.toLowerCase() == queryText.toLowerCase();
+ *						return tag.toLowerCase().includes( queryText.toLowerCase() );
  *					} )
  *					// Return 10 items max - needed for generic queries when the list may contain hundreds of elements.
  *					.slice( 0, 10 );
@@ -136,7 +153,7 @@ export default class Mention extends Plugin {
  *		}
  *
  * @typedef {Object} module:mention/mention~MentionFeed
- * @property {String} [marker='@'] The character which triggers autocompletion for mention.
+ * @property {String} [marker] The character which triggers autocompletion for mention. It must be a single character.
  * @property {Array.<module:mention/mention~MentionFeedItem>|Function} feed The autocomplete items. Provide an array for
  * a static configuration (the mention feature will show matching items automatically) or a function which returns an array of
  * matching items (directly, or via a promise).
@@ -148,8 +165,8 @@ export default class Mention extends Plugin {
 /**
  * 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
+ * When defining a feed item as a plain object, the `id` property is obligatory. The additional properties
+ * can be used when customizing the mention feature bahavior
  * (see {@glink features/mention#customizing-the-autocomplete-list "Customizing the autocomplete list"}
  * and {@glink features/mention#customizing-the-output "Customizing the output"} sections).
  *
@@ -163,23 +180,23 @@ export default class Mention extends Plugin {
  *							marker: '@',
  *							feed: [
  *								{
- *									name: 'Barney',
+ *									id: '@Barney',
  *									fullName: 'Barney Bloom'
  *								},
  *								{
- *									name: 'Lily',
+ *									id: '@Lily',
  *									fullName: 'Lily Smith'
  *								},
  *								{
- *									name: 'Marshall',
+ *									id: '@Marshall',
  *									fullName: 'Marshall McDonald'
  *								},
  *								{
- *									name: 'Robin',
+ *									id: '@Robin',
  *									fullName: 'Robin Hood'
  *								},
  *								{
- *									name: 'Ted',
+ *									id: '@Ted',
  *									fullName: 'Ted Cruze'
  *								},
  *								// ...
@@ -198,5 +215,18 @@ export default class Mention extends Plugin {
  *			.catch( ... );
  *
  * @typedef {Object|String} module:mention/mention~MentionFeedItem
- * @property {String} name Name of the mention.
+ * @property {String} id Unique id of the mention. It must start with the marker character.
+ * @property {String} [text] Text inserted into the editor when creating a mention.
+ */
+
+/**
+ * Represents mention in the model.
+ *
+ * See {@link module:mention/mention~Mention#toMentionAttribute `Mention#toMentionAttribute()`}.
+ *
+ * @interface module:mention/mention~MentionAttribute
+ * @property {String} id Id of a mention – identifies the mention item in mention feed.
+ * @property {String} _uid Internal mention view item id. Should be passed as an `option.id` when using
+ * {@link module:engine/view/downcastwriter~DowncastWriter#createAttributeElement writer.createAttributeElement()}.
+ * @property {String} _text Helper property that holds text of inserted mention. Used for detecting broken mention in the editing area.
  */

+ 71 - 11
packages/ckeditor5-mention/src/mentioncommand.js

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

+ 30 - 26
packages/ckeditor5-mention/src/mentionediting.js

@@ -17,7 +17,7 @@ import MentionCommand from './mentioncommand';
  *
  * It introduces the {@link module:mention/mentioncommand~MentionCommand command} and the `mention`
  * attribute in the {@link module:engine/model/model~Model model} which renders in the {@link module:engine/view/view view}
- * as a `<span class="mention" data-mention="name">`.
+ * as a `<span class="mention" data-mention="@mention">`.
  *
  * @extends module:core/plugin~Plugin
  */
@@ -48,7 +48,7 @@ export default class MentionEditing extends Plugin {
 			},
 			model: {
 				key: 'mention',
-				value: parseMentionViewItemAttributes
+				value: _toMentionAttribute
 			}
 		} );
 
@@ -65,33 +65,37 @@ export default class MentionEditing extends Plugin {
 	}
 }
 
-// Parses matched view element to mention attribute value.
-//
-// @param {module:engine/view/element} viewElement
-// @returns {Object} Mention attribute value
-function parseMentionViewItemAttributes( viewElement ) {
-	const dataMention = viewElement.getAttribute( 'data-mention' );
-
-	const textNode = viewElement.getChild( 0 );
-
-	// Do not parse empty mentions.
-	if ( !textNode || !textNode.is( 'text' ) ) {
-		return;
-	}
+export function _addMentionAttributes( baseMentionData, data ) {
+	return Object.assign( { _uid: uid() }, baseMentionData, data || {} );
+}
 
-	const mentionString = textNode.data;
+/**
+ * Creates mention attribute value from provided view element and optional data.
+ *
+ * This function is exposed as
+ * {@link module:mention/mention~Mention#toMentionAttribute `editor.plugins.get( 'Mention' ).toMentionAttribute()`}.
+ *
+ * @protected
+ * @param {module:engine/view/element~Element} viewElementOrMention
+ * @param {String|Object} [data] Mention data to be extended.
+ * @return {module:mention/mention~MentionAttribute}
+ */
+export function _toMentionAttribute( viewElementOrMention, data ) {
+	const dataMention = viewElementOrMention.getAttribute( 'data-mention' );
 
-	// Assume that mention is set as marker + mention name.
-	const marker = mentionString.slice( 0, 1 );
-	const name = mentionString.slice( 1 );
+	const textNode = viewElementOrMention.getChild( 0 );
 
-	// Do not upcast partial mentions - might come from copy-paste of partially selected mention.
-	if ( name != dataMention ) {
+	// Do not convert empty mentions.
+	if ( !textNode ) {
 		return;
 	}
 
-	// Set UID for mention to not merge mentions in the same block that are next to each other.
-	return { name: dataMention, _marker: marker, _id: uid() };
+	const baseMentionData = {
+		id: dataMention,
+		_text: textNode.data
+	};
+
+	return _addMentionAttributes( baseMentionData, data );
 }
 
 // Creates mention element from mention data.
@@ -106,11 +110,11 @@ function createViewMentionElement( mention, viewWriter ) {
 
 	const attributes = {
 		class: 'mention',
-		'data-mention': mention.name
+		'data-mention': mention.id
 	};
 
 	const options = {
-		id: mention._id,
+		id: mention._uid,
 		priority: 20
 	};
 
@@ -227,7 +231,7 @@ function isBrokenMentionNode( node ) {
 	const text = node.data;
 	const mention = node.getAttribute( 'mention' );
 
-	const expectedText = mention._marker + mention.name;
+	const expectedText = mention._text;
 
 	return text != expectedText;
 }

+ 22 - 8
packages/ckeditor5-mention/src/mentionui.js

@@ -109,7 +109,7 @@ export default class MentionUI extends Plugin {
 		for ( const mentionDescription of feeds ) {
 			const feed = mentionDescription.feed;
 
-			const marker = mentionDescription.marker || '@';
+			const marker = mentionDescription.marker;
 			const minimumCharacters = mentionDescription.minimumCharacters || 0;
 			const feedCallback = typeof feed == 'function' ? feed : createFeedCallback( feed );
 			const watcher = this._setupTextWatcherForFeed( marker, minimumCharacters );
@@ -209,6 +209,7 @@ export default class MentionUI extends Plugin {
 
 			editor.execute( 'mention', {
 				mention: item,
+				text: item.text,
 				marker,
 				range
 			} );
@@ -297,8 +298,8 @@ export default class MentionUI extends Plugin {
 				.then( feed => {
 					this._items.clear();
 
-					for ( const name of feed ) {
-						const item = typeof name != 'object' ? { name } : name;
+					for ( const feedItem of feed ) {
+						const item = typeof feedItem != 'object' ? { id: feedItem, text: feedItem } : feedItem;
 
 						this._items.add( { item, marker } );
 					}
@@ -370,17 +371,24 @@ export default class MentionUI extends Plugin {
 		const editor = this.editor;
 
 		let view;
+		let label = item.id;
 
 		const renderer = this._getItemRenderer( marker );
 
 		if ( renderer ) {
-			const domNode = renderer( item );
+			const renderResult = renderer( item );
 
-			view = new DomWrapperView( editor.locale, domNode );
-		} else {
+			if ( typeof renderResult != 'string' ) {
+				view = new DomWrapperView( editor.locale, renderResult );
+			} else {
+				label = renderResult;
+			}
+		}
+
+		if ( !view ) {
 			const buttonView = new ButtonView( editor.locale );
 
-			buttonView.label = item.name;
+			buttonView.label = label;
 			buttonView.withText = true;
 
 			view = buttonView;
@@ -529,7 +537,13 @@ function createFeedCallback( feedItems ) {
 	return feedText => {
 		const filteredItems = feedItems
 		// Make default mention feed case-insensitive.
-			.filter( item => item.toLowerCase().includes( feedText.toLowerCase() ) )
+			.filter( item => {
+				// Item might be defined as object.
+				const itemId = typeof item == 'string' ? item : String( item.id );
+
+				// The default feed is case insensitive.
+				return itemId.toLowerCase().includes( feedText.toLowerCase() );
+			} )
 			// Do not return more than 10 items.
 			.slice( 0, 10 );
 

+ 10 - 8
packages/ckeditor5-mention/src/textwatcher.js

@@ -129,17 +129,19 @@ export default class TextWatcher {
 
 		const block = selection.focus.parent;
 
-		return getText( block ).slice( 0, selection.focus.offset );
+		return _getText( editor.model.createRangeIn( block ) ).slice( 0, selection.focus.offset );
 	}
 }
 
-// Returns whole text from parent element by adding all data from text nodes together.
-//
-// @private
-// @param {module:engine/model/element~Element} element
-// @returns {String}
-function getText( element ) {
-	return Array.from( element.getChildren() ).reduce( ( a, b ) => a + b.data, '' );
+/**
+ * Returns whole text from given range by adding all data from text nodes together.
+ *
+ * @protected
+ * @param {module:engine/model/range~Range} range
+ * @returns {String}
+ */
+export function _getText( range ) {
+	return Array.from( range.getItems() ).reduce( ( a, b ) => a + b.data, '' );
 }
 
 mix( TextWatcher, EmitterMixin );

+ 33 - 27
packages/ckeditor5-mention/tests/manual/mention-custom-renderer.js

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

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

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

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

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

+ 22 - 30
packages/ckeditor5-mention/tests/manual/mention-custom-view.js

@@ -8,23 +8,12 @@
 import global from '@ckeditor/ckeditor5-utils/src/dom/global';
 
 import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
-import Bold from '@ckeditor/ckeditor5-basic-styles/src/bold';
-import Enter from '@ckeditor/ckeditor5-enter/src/enter';
-import Heading from '@ckeditor/ckeditor5-heading/src/heading';
-import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
-import Typing from '@ckeditor/ckeditor5-typing/src/typing';
-import Undo from '@ckeditor/ckeditor5-undo/src/undo';
-import Widget from '@ckeditor/ckeditor5-widget/src/widget';
-import Clipboard from '@ckeditor/ckeditor5-clipboard/src/clipboard';
-import ShiftEnter from '@ckeditor/ckeditor5-enter/src/shiftenter';
-import Table from '@ckeditor/ckeditor5-table/src/table';
-import Link from '@ckeditor/ckeditor5-link/src/link';
-import Italic from '@ckeditor/ckeditor5-basic-styles/src/italic';
 import Underline from '@ckeditor/ckeditor5-basic-styles/src/underline';
+import ArticlePluginSet from '@ckeditor/ckeditor5-core/tests/_utils/articlepluginset';
+import Font from '@ckeditor/ckeditor5-font/src/font';
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 
-import MentionUI from '../../src/mentionui';
-import MentionEditing from '../../src/mentionediting';
+import Mention from '../../src/mention';
 
 class CustomMentionAttributeView extends Plugin {
 	init() {
@@ -42,12 +31,9 @@ class CustomMentionAttributeView extends Plugin {
 			model: {
 				key: 'mention',
 				value: viewItem => {
-					const mentionValue = {
-						name: viewItem.getAttribute( 'data-mention' ),
+					return editor.plugins.get( 'Mention' ).toMentionAttribute( viewItem, {
 						link: viewItem.getAttribute( 'href' )
-					};
-
-					return mentionValue;
+					} );
 				}
 			},
 			converterPriority: 'high'
@@ -62,9 +48,9 @@ class CustomMentionAttributeView extends Plugin {
 
 				return viewWriter.createAttributeElement( 'a', {
 					class: 'mention',
-					'data-mention': modelAttributeValue.name,
+					'data-mention': modelAttributeValue.id,
 					'href': modelAttributeValue.link
-				} );
+				}, { id: modelAttributeValue._uid } );
 			},
 			converterPriority: 'high'
 		} );
@@ -73,9 +59,15 @@ class CustomMentionAttributeView extends Plugin {
 
 ClassicEditor
 	.create( global.document.querySelector( '#editor' ), {
-		plugins: [ Enter, Typing, Paragraph, Link, Heading, Bold, Italic, Underline, Undo, Clipboard, Widget, ShiftEnter, Table,
-			MentionEditing, CustomMentionAttributeView, MentionUI ],
-		toolbar: [ 'heading', '|', 'bold', 'italic', 'underline', 'link', '|', 'insertTable', '|', 'undo', 'redo' ],
+		plugins: [ ArticlePluginSet, Underline, Font, Mention, CustomMentionAttributeView ],
+		toolbar: [
+			'heading',
+			'|', 'bulletedList', 'numberedList', 'blockQuote',
+			'|', 'bold', 'italic', 'underline', 'link',
+			'|', 'fontFamily', 'fontSize', 'fontColor', 'fontBackgroundColor',
+			'|', 'insertTable',
+			'|', 'undo', 'redo'
+		],
 		mention: {
 			feeds: [
 				{ feed: getFeed }
@@ -91,14 +83,14 @@ ClassicEditor
 
 function getFeed( feedText ) {
 	return [
-		{ id: '1', name: 'Barney Stinson', link: 'https://www.imdb.com/title/tt0460649/characters/nm0000439' },
-		{ id: '2', name: 'Lily Aldrin', link: 'https://www.imdb.com/title/tt0460649/characters/nm0004989' },
-		{ id: '3', name: 'Marshall Eriksen', link: 'https://www.imdb.com/title/tt0460649/characters/nm0781981' },
-		{ id: '4', name: 'Robin Scherbatsky', link: 'https://www.imdb.com/title/tt0460649/characters/nm1130627' },
-		{ id: '5', name: 'Ted Mosby', link: 'https://www.imdb.com/title/tt0460649/characters/nm1102140' }
+		{ id: '@Barney Stinson', text: 'Barney Stinson', link: 'https://www.imdb.com/title/tt0460649/characters/nm0000439' },
+		{ id: '@Lily Aldrin', text: 'Lily Aldrin', link: 'https://www.imdb.com/title/tt0460649/characters/nm0004989' },
+		{ id: '@Marshall Eriksen', text: 'Marshall Eriksen', link: 'https://www.imdb.com/title/tt0460649/characters/nm0781981' },
+		{ id: '@Robin Scherbatsky', text: 'Robin Scherbatsky', link: 'https://www.imdb.com/title/tt0460649/characters/nm1130627' },
+		{ id: '@Ted Mosby', text: 'Ted Mosby', link: 'https://www.imdb.com/title/tt0460649/characters/nm1102140' }
 	].filter( item => {
 		const searchString = feedText.toLowerCase();
 
-		return item.name.toLowerCase().includes( searchString );
+		return item.id.toLowerCase().includes( searchString );
 	} );
 }

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

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

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

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

+ 14 - 15
packages/ckeditor5-mention/tests/manual/mention.js

@@ -8,28 +8,27 @@
 import global from '@ckeditor/ckeditor5-utils/src/dom/global';
 
 import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
-import Bold from '@ckeditor/ckeditor5-basic-styles/src/bold';
-import Enter from '@ckeditor/ckeditor5-enter/src/enter';
-import Heading from '@ckeditor/ckeditor5-heading/src/heading';
-import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
-import Typing from '@ckeditor/ckeditor5-typing/src/typing';
-import Undo from '@ckeditor/ckeditor5-undo/src/undo';
-import Widget from '@ckeditor/ckeditor5-widget/src/widget';
-import Clipboard from '@ckeditor/ckeditor5-clipboard/src/clipboard';
-import ShiftEnter from '@ckeditor/ckeditor5-enter/src/shiftenter';
-import Table from '@ckeditor/ckeditor5-table/src/table';
 import Mention from '../../src/mention';
-import Link from '@ckeditor/ckeditor5-link/src/link';
-import Italic from '@ckeditor/ckeditor5-basic-styles/src/italic';
 import Underline from '@ckeditor/ckeditor5-basic-styles/src/underline';
+import ArticlePluginSet from '@ckeditor/ckeditor5-core/tests/_utils/articlepluginset';
+import Font from '@ckeditor/ckeditor5-font/src/font';
 
 ClassicEditor
 	.create( global.document.querySelector( '#editor' ), {
-		plugins: [ Enter, Typing, Paragraph, Heading, Link, Bold, Italic, Underline, Undo, Clipboard, Widget, ShiftEnter, Table, Mention ],
-		toolbar: [ 'heading', '|', 'bold', 'italic', 'underline', 'link', '|', 'insertTable', '|', 'undo', 'redo' ],
+		plugins: [ ArticlePluginSet, Underline, Font, Mention ],
+		toolbar: [
+			'heading',
+			'|', 'bulletedList', 'numberedList', 'blockQuote',
+			'|', 'bold', 'italic', 'underline', 'link',
+			'|', 'fontFamily', 'fontSize', 'fontColor', 'fontBackgroundColor',
+			'|', 'insertTable',
+			'|', 'undo', 'redo'
+		],
 		mention: {
 			feeds: [
-				{ feed: [ 'Barney', 'Lily', 'Marshall', 'Robin', 'Ted' ] },
+				{
+					feed: [ 'Barney', 'Lily', 'Marshall', 'Robin', 'Ted' ]
+				},
 				{
 					marker: '#',
 					feed: [

+ 4 - 1
packages/ckeditor5-mention/tests/manual/mention.md

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

+ 17 - 17
packages/ckeditor5-mention/tests/mention-integration.js

@@ -47,9 +47,9 @@ describe( 'Mention feature - integration', () => {
 
 		// Failing test. See ckeditor/ckeditor5#1645.
 		it( 'should restore removed mention on adding a text inside mention', () => {
-			editor.setData( '<p>foo <span class="mention" data-mention="John">@John</span> bar</p>' );
+			editor.setData( '<p>foo <span class="mention" data-mention="@John">@John</span> bar</p>' );
 
-			expect( editor.getData() ).to.equal( '<p>foo <span class="mention" data-mention="John">@John</span> bar</p>' );
+			expect( editor.getData() ).to.equal( '<p>foo <span class="mention" data-mention="@John">@John</span> bar</p>' );
 
 			model.change( writer => {
 				const paragraph = doc.getRoot().getChild( 0 );
@@ -64,16 +64,16 @@ describe( 'Mention feature - integration', () => {
 
 			editor.execute( 'undo' );
 
-			expect( editor.getData() ).to.equal( '<p>foo <span class="mention" data-mention="John">@John</span> bar</p>' );
+			expect( editor.getData() ).to.equal( '<p>foo <span class="mention" data-mention="@John">@John</span> bar</p>' );
 			expect( getViewData( editor.editing.view, { withoutSelection: true } ) )
-				.to.equal( '<p>foo <span class="mention" data-mention="John">@John</span> bar</p>' );
+				.to.equal( '<p>foo <span class="mention" data-mention="@John">@John</span> bar</p>' );
 		} );
 
 		// Failing test. See ckeditor/ckeditor5#1645.
 		it( 'should restore removed mention on removing a text inside mention', () => {
-			editor.setData( '<p>foo <span class="mention" data-mention="John">@John</span> bar</p>' );
+			editor.setData( '<p>foo <span class="mention" data-mention="@John">@John</span> bar</p>' );
 
-			expect( editor.getData() ).to.equal( '<p>foo <span class="mention" data-mention="John">@John</span> bar</p>' );
+			expect( editor.getData() ).to.equal( '<p>foo <span class="mention" data-mention="@John">@John</span> bar</p>' );
 
 			model.change( writer => {
 				const paragraph = doc.getRoot().getChild( 0 );
@@ -89,15 +89,15 @@ describe( 'Mention feature - integration', () => {
 
 			editor.execute( 'undo' );
 
-			expect( editor.getData() ).to.equal( '<p>foo <span class="mention" data-mention="John">@John</span> bar</p>' );
+			expect( editor.getData() ).to.equal( '<p>foo <span class="mention" data-mention="@John">@John</span> bar</p>' );
 			expect( getViewData( editor.editing.view, { withoutSelection: true } ) )
-				.to.equal( '<p>foo <span class="mention" data-mention="John">@John</span> bar</p>' );
+				.to.equal( '<p>foo <span class="mention" data-mention="@John">@John</span> bar</p>' );
 		} );
 
 		it( 'should work with attribute post-fixer (beginning formatted)', () => {
 			testAttributePostFixer(
-				'<p>foo <span class="mention" data-mention="John">@John</span> bar</p>',
-				'<p><strong>foo <span class="mention" data-mention="John">@John</span></strong> bar</p>',
+				'<p>foo <span class="mention" data-mention="@John">@John</span> bar</p>',
+				'<p><strong>foo <span class="mention" data-mention="@John">@John</span></strong> bar</p>',
 				() => {
 					model.change( writer => {
 						const paragraph = doc.getRoot().getChild( 0 );
@@ -113,8 +113,8 @@ describe( 'Mention feature - integration', () => {
 
 		it( 'should work with attribute post-fixer (end formatted)', () => {
 			testAttributePostFixer(
-				'<p>foo <span class="mention" data-mention="John">@John</span> bar</p>',
-				'<p>foo <strong><span class="mention" data-mention="John">@John</span> ba</strong>r</p>',
+				'<p>foo <span class="mention" data-mention="@John">@John</span> bar</p>',
+				'<p>foo <strong><span class="mention" data-mention="@John">@John</span> ba</strong>r</p>',
 				() => {
 					model.change( writer => {
 						const paragraph = doc.getRoot().getChild( 0 );
@@ -130,8 +130,8 @@ describe( 'Mention feature - integration', () => {
 
 		it( 'should work with attribute post-fixer (middle formatted)', () => {
 			testAttributePostFixer(
-				'<p>foo <span class="mention" data-mention="John">@John</span> bar</p>',
-				'<p>foo <strong><span class="mention" data-mention="John">@John</span></strong> bar</p>',
+				'<p>foo <span class="mention" data-mention="@John">@John</span> bar</p>',
+				'<p>foo <strong><span class="mention" data-mention="@John">@John</span></strong> bar</p>',
 				() => {
 					model.change( writer => {
 						const paragraph = doc.getRoot().getChild( 0 );
@@ -187,7 +187,7 @@ describe( 'Mention feature - integration', () => {
 				} );
 		} );
 
-		it( 'should fix broken mention inside pasted content', () => {
+		it( 'should not fix broken mention inside pasted content', () => {
 			editor.setData( '<p>foobar</p>' );
 
 			model.change( writer => {
@@ -195,11 +195,11 @@ describe( 'Mention feature - integration', () => {
 			} );
 
 			clipboard.fire( 'inputTransformation', {
-				content: parseView( '<blockquote><p>xxx<span class="mention" data-mention="John">@Joh</span></p></blockquote>' )
+				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>' +
+				'<blockquote><p>xxx<span class="mention" data-mention="@John">@Joh</span></p></blockquote>' +
 				'<p>bar</p>';
 
 			expect( editor.getData() )

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

@@ -5,6 +5,8 @@
 
 import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor';
 import global from '@ckeditor/ckeditor5-utils/src/dom/global';
+import Element from '@ckeditor/ckeditor5-engine/src/view/element';
+import Text from '@ckeditor/ckeditor5-engine/src/view/text';
 
 import Mention from '../src/mention';
 import MentionEditing from '../src/mentionediting';
@@ -47,4 +49,45 @@ describe( 'Mention', () => {
 	it( 'should load MentionUI plugin', () => {
 		expect( editor.plugins.get( MentionUI ) ).to.instanceOf( MentionUI );
 	} );
+
+	describe( 'toMentionAttribute()', () => {
+		it( 'should create mention attribute with default properties', () => {
+			const text = new Text( 'John Doe' );
+
+			const viewElement = new Element( 'span', {
+				'data-mention': '@John'
+			}, text );
+
+			const mentionAttribute = editor.plugins.get( 'Mention' ).toMentionAttribute( viewElement );
+
+			expect( mentionAttribute ).to.have.property( 'id', '@John' );
+			expect( mentionAttribute ).to.have.property( '_uid' );
+			expect( mentionAttribute ).to.have.property( '_text', 'John Doe' );
+		} );
+
+		it( 'should create mention attribute with provided attributes', () => {
+			const text = new Text( 'John Doe' );
+
+			const viewElement = new Element( 'span', {
+				'data-mention': '@John'
+			}, text );
+
+			const mentionAttribute = editor.plugins.get( 'Mention' ).toMentionAttribute( viewElement, { foo: 'bar' } );
+
+			expect( mentionAttribute ).to.have.property( 'id', '@John' );
+			expect( mentionAttribute ).to.have.property( 'foo', 'bar' );
+			expect( mentionAttribute ).to.have.property( '_uid' );
+			expect( mentionAttribute ).to.have.property( '_text', 'John Doe' );
+		} );
+
+		it( 'should return undefined if Element has no text node', () => {
+			const viewElement = new Element( 'span', {
+				'data-mention': '@John'
+			} );
+
+			const mentionAttribute = editor.plugins.get( 'Mention' ).toMentionAttribute( viewElement );
+
+			expect( mentionAttribute ).to.be.undefined;
+		} );
+	} );
 } );

+ 64 - 17
packages/ckeditor5-mention/tests/mentioncommand.js

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

+ 55 - 49
packages/ckeditor5-mention/tests/mentionediting.js

@@ -67,18 +67,18 @@ describe( 'MentionEditing', () => {
 				} );
 		} );
 
-		it( 'should convert <span class="mention" data-mention="John"> to mention attribute', () => {
-			editor.setData( '<p>foo <span class="mention" data-mention="John">@John</span> bar</p>' );
+		it( 'should convert <span class="mention" data-mention="@John"> to mention attribute', () => {
+			editor.setData( '<p>foo <span class="mention" data-mention="@John">@John</span> bar</p>' );
 
 			const textNode = doc.getRoot().getChild( 0 ).getChild( 1 );
 
 			expect( textNode ).to.not.be.null;
 			expect( textNode.hasAttribute( 'mention' ) ).to.be.true;
-			expect( textNode.getAttribute( 'mention' ) ).to.have.property( '_id' );
-			expect( textNode.getAttribute( 'mention' ) ).to.have.property( '_marker', '@' );
-			expect( textNode.getAttribute( 'mention' ) ).to.have.property( 'name', 'John' );
+			expect( textNode.getAttribute( 'mention' ) ).to.have.property( 'id', '@John' );
+			expect( textNode.getAttribute( 'mention' ) ).to.have.property( '_text', '@John' );
+			expect( textNode.getAttribute( 'mention' ) ).to.have.property( '_uid' );
 
-			const expectedView = '<p>foo <span class="mention" data-mention="John">@John</span> bar</p>';
+			const expectedView = '<p>foo <span class="mention" data-mention="@John">@John</span> bar</p>';
 
 			expect( editor.getData() ).to.equal( expectedView );
 			expect( getViewData( editor.editing.view, { withoutSelection: true } ) ).to.equal( expectedView );
@@ -87,8 +87,8 @@ describe( 'MentionEditing', () => {
 		it( 'should convert consecutive mentions spans as two text nodes and two spans in the view', () => {
 			editor.setData(
 				'<p>' +
-					'<span class="mention" data-mention="John">@John</span>' +
-					'<span class="mention" data-mention="John">@John</span>' +
+					'<span class="mention" data-mention="@John">@John</span>' +
+					'<span class="mention" data-mention="@John">@John</span>' +
 				'</p>'
 			);
 
@@ -102,13 +102,13 @@ describe( 'MentionEditing', () => {
 			assertTextNode( paragraph.getChild( 0 ) );
 			assertTextNode( paragraph.getChild( 1 ) );
 
-			const firstMentionId = paragraph.getChild( 0 ).getAttribute( 'mention' )._id;
-			const secondMentionId = paragraph.getChild( 1 ).getAttribute( 'mention' )._id;
+			const firstMentionId = paragraph.getChild( 0 ).getAttribute( 'mention' )._uid;
+			const secondMentionId = paragraph.getChild( 1 ).getAttribute( 'mention' )._uid;
 
 			expect( firstMentionId ).to.not.equal( secondMentionId );
 
-			const expectedView = '<p><span class="mention" data-mention="John">@John</span>' +
-				'<span class="mention" data-mention="John">@John</span></p>';
+			const expectedView = '<p><span class="mention" data-mention="@John">@John</span>' +
+				'<span class="mention" data-mention="@John">@John</span></p>';
 
 			expect( editor.getData() ).to.equal( expectedView );
 			expect( getViewData( editor.editing.view, { withoutSelection: true } ) ).to.equal( expectedView );
@@ -116,25 +116,31 @@ describe( 'MentionEditing', () => {
 			function assertTextNode( textNode ) {
 				expect( textNode ).to.not.be.null;
 				expect( textNode.hasAttribute( 'mention' ) ).to.be.true;
-				expect( textNode.getAttribute( 'mention' ) ).to.have.property( '_id' );
-				expect( textNode.getAttribute( 'mention' ) ).to.have.property( '_marker', '@' );
-				expect( textNode.getAttribute( 'mention' ) ).to.have.property( 'name', 'John' );
+				expect( textNode.getAttribute( 'mention' ) ).to.have.property( 'id', '@John' );
+				expect( textNode.getAttribute( 'mention' ) ).to.have.property( '_text', '@John' );
+				expect( textNode.getAttribute( 'mention' ) ).to.have.property( '_uid' );
 			}
 		} );
 
 		it( 'should not convert partial mentions', () => {
-			editor.setData( '<p><span class="mention" data-mention="John">@Jo</span></p>' );
+			editor.setData( '<p><span class="mention" data-mention="@John">@Jo</span></p>' );
 
-			expect( getModelData( model, { withoutSelection: true } ) ).to.equal( '<paragraph>@Jo</paragraph>' );
+			const textNode = doc.getRoot().getChild( 0 ).getChild( 0 );
 
-			const expectedView = '<p>@Jo</p>';
+			expect( textNode ).to.not.be.null;
+			expect( textNode.hasAttribute( 'mention' ) ).to.be.true;
+			expect( textNode.getAttribute( 'mention' ) ).to.have.property( 'id', '@John' );
+			expect( textNode.getAttribute( 'mention' ) ).to.have.property( '_text', '@Jo' );
+			expect( textNode.getAttribute( 'mention' ) ).to.have.property( '_uid' );
+
+			const expectedView = '<p><span class="mention" data-mention="@John">@Jo</span></p>';
 
 			expect( editor.getData() ).to.equal( expectedView );
 			expect( getViewData( editor.editing.view, { withoutSelection: true } ) ).to.equal( expectedView );
 		} );
 
 		it( 'should not convert empty mentions', () => {
-			editor.setData( '<p>foo<span class="mention" data-mention="John"></span></p>' );
+			editor.setData( '<p>foo<span class="mention" data-mention="@John"></span></p>' );
 
 			expect( getModelData( model, { withoutSelection: true } ) ).to.equal( '<paragraph>foo</paragraph>' );
 
@@ -156,7 +162,7 @@ describe( 'MentionEditing', () => {
 		} );
 
 		it( 'should remove mention attribute from a selection if selection is on right side of a mention', () => {
-			editor.setData( '<p>foo <span class="mention" data-mention="John">@John</span>bar</p>' );
+			editor.setData( '<p>foo <span class="mention" data-mention="@John">@John</span>bar</p>' );
 
 			model.change( writer => {
 				const paragraph = doc.getRoot().getChild( 0 );
@@ -168,7 +174,7 @@ describe( 'MentionEditing', () => {
 		} );
 
 		it( 'should allow to type after a mention', () => {
-			editor.setData( '<p>foo <span class="mention" data-mention="John">@John</span>bar</p>' );
+			editor.setData( '<p>foo <span class="mention" data-mention="@John">@John</span>bar</p>' );
 
 			model.change( writer => {
 				const paragraph = doc.getRoot().getChild( 0 );
@@ -178,7 +184,7 @@ describe( 'MentionEditing', () => {
 				writer.insertText( ' ', paragraph, 9 );
 			} );
 
-			expect( editor.getData() ).to.equal( '<p>foo <span class="mention" data-mention="John">@John</span> bar</p>' );
+			expect( editor.getData() ).to.equal( '<p>foo <span class="mention" data-mention="@John">@John</span> bar</p>' );
 		} );
 	} );
 
@@ -193,15 +199,15 @@ describe( 'MentionEditing', () => {
 		} );
 
 		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>' );
+			editor.setData( '<p>foo <span class="mention" data-mention="@John">@John</span> bar</p>' );
 
 			const textNode = doc.getRoot().getChild( 0 ).getChild( 1 );
 
 			expect( textNode ).to.not.be.null;
 			expect( textNode.hasAttribute( 'mention' ) ).to.be.true;
-			expect( textNode.getAttribute( 'mention' ) ).to.have.property( '_id' );
-			expect( textNode.getAttribute( 'mention' ) ).to.have.property( '_marker', '@' );
-			expect( textNode.getAttribute( 'mention' ) ).to.have.property( 'name', 'John' );
+			expect( textNode.getAttribute( 'mention' ) ).to.have.property( 'id', '@John' );
+			expect( textNode.getAttribute( 'mention' ) ).to.have.property( '_text', '@John' );
+			expect( textNode.getAttribute( 'mention' ) ).to.have.property( '_uid' );
 
 			model.change( writer => {
 				const paragraph = doc.getRoot().getChild( 0 );
@@ -218,7 +224,7 @@ describe( 'MentionEditing', () => {
 		} );
 
 		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>' );
+			editor.setData( '<p>foo <span class="mention" data-mention="@John">@John</span> bar</p>' );
 
 			const textNode = doc.getRoot().getChild( 0 ).getChild( 1 );
 
@@ -239,7 +245,7 @@ describe( 'MentionEditing', () => {
 		} );
 
 		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>' );
+			editor.setData( '<p>foo <span class="mention" data-mention="@John">@John</span> bar</p>' );
 
 			const paragraph = doc.getRoot().getChild( 0 );
 
@@ -256,7 +262,7 @@ describe( 'MentionEditing', () => {
 		} );
 
 		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>' );
+			editor.setData( '<p>foo <span class="mention" data-mention="@John">@John</span> bar</p>' );
 
 			const paragraph = doc.getRoot().getChild( 0 );
 
@@ -273,7 +279,7 @@ describe( 'MentionEditing', () => {
 		} );
 
 		it( 'should remove mention on removing a text at the and of a mention', () => {
-			editor.setData( '<p>foo <span class="mention" data-mention="John">@John</span> bar</p>' );
+			editor.setData( '<p>foo <span class="mention" data-mention="@John">@John</span> bar</p>' );
 
 			const paragraph = doc.getRoot().getChild( 0 );
 
@@ -290,7 +296,7 @@ describe( 'MentionEditing', () => {
 		} );
 
 		it( 'should not remove mention on removing a text just after a mention', () => {
-			editor.setData( '<p>foo <span class="mention" data-mention="John">@John</span> bar</p>' );
+			editor.setData( '<p>foo <span class="mention" data-mention="@John">@John</span> bar</p>' );
 
 			const paragraph = doc.getRoot().getChild( 0 );
 
@@ -304,11 +310,11 @@ describe( 'MentionEditing', () => {
 				model.deleteContent( doc.selection );
 			} );
 
-			expect( editor.getData() ).to.equal( '<p>foo <span class="mention" data-mention="John">@John</span>bar</p>' );
+			expect( editor.getData() ).to.equal( '<p>foo <span class="mention" data-mention="@John">@John</span>bar</p>' );
 		} );
 
 		it( 'should remove mention on inserting text node inside a mention', () => {
-			editor.setData( '<p>foo <span class="mention" data-mention="John">@John</span> bar</p>' );
+			editor.setData( '<p>foo <span class="mention" data-mention="@John">@John</span> bar</p>' );
 
 			const paragraph = doc.getRoot().getChild( 0 );
 
@@ -326,7 +332,7 @@ describe( 'MentionEditing', () => {
 			} );
 			editor.conversion.elementToElement( { model: 'inline', view: 'br' } );
 
-			editor.setData( '<p>foo <span class="mention" data-mention="John">@John</span> bar</p>' );
+			editor.setData( '<p>foo <span class="mention" data-mention="@John">@John</span> bar</p>' );
 
 			const paragraph = doc.getRoot().getChild( 0 );
 
@@ -338,7 +344,7 @@ describe( 'MentionEditing', () => {
 		} );
 
 		it( 'should remove mention when splitting paragraph with a mention', () => {
-			editor.setData( '<p>foo <span class="mention" data-mention="John">@John</span> bar</p>' );
+			editor.setData( '<p>foo <span class="mention" data-mention="@John">@John</span> bar</p>' );
 
 			const paragraph = doc.getRoot().getChild( 0 );
 
@@ -356,7 +362,7 @@ describe( 'MentionEditing', () => {
 			} );
 
 			editor.conversion.elementToElement( { model: 'blockQuote', view: 'blockquote' } );
-			editor.setData( '<blockquote><p>foo <span class="mention" data-mention="John">@John</span> bar</p></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 );
@@ -382,7 +388,7 @@ describe( 'MentionEditing', () => {
 			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>' );
+			editor.setData( '<p>foo <span class="mention" data-mention="@John">@John</span> bar</p>' );
 
 			const paragraph = doc.getRoot().getChild( 0 );
 
@@ -396,14 +402,14 @@ describe( 'MentionEditing', () => {
 			} );
 
 			expect( editor.getData() )
-				.to.equal( '<p><strong>foo <span class="mention" data-mention="John">@John</span></strong> bar</p>' );
+				.to.equal( '<p><strong>foo <span class="mention" data-mention="@John">@John</span></strong> bar</p>' );
 		} );
 
 		it( 'should set attribute on whole mention when formatting part of a mention (end formatted)', () => {
 			model.schema.extend( '$text', { allowAttributes: [ 'bold' ] } );
 			editor.conversion.attributeToElement( { model: 'bold', view: 'strong' } );
 
-			editor.setData( '<p>foo <span class="mention" data-mention="John">@John</span> bar</p>' );
+			editor.setData( '<p>foo <span class="mention" data-mention="@John">@John</span> bar</p>' );
 
 			const paragraph = doc.getRoot().getChild( 0 );
 
@@ -417,14 +423,14 @@ describe( 'MentionEditing', () => {
 			} );
 
 			expect( editor.getData() )
-				.to.equal( '<p>foo <strong><span class="mention" data-mention="John">@John</span> ba</strong>r</p>' );
+				.to.equal( '<p>foo <strong><span class="mention" data-mention="@John">@John</span> ba</strong>r</p>' );
 		} );
 
 		it( 'should set attribute on whole mention when formatting part of a mention (middle of mention formatted)', () => {
 			model.schema.extend( '$text', { allowAttributes: [ 'bold' ] } );
 			editor.conversion.attributeToElement( { model: 'bold', view: 'strong' } );
 
-			editor.setData( '<p>foo <span class="mention" data-mention="John">@John</span> bar</p>' );
+			editor.setData( '<p>foo <span class="mention" data-mention="@John">@John</span> bar</p>' );
 
 			const paragraph = doc.getRoot().getChild( 0 );
 
@@ -438,7 +444,7 @@ describe( 'MentionEditing', () => {
 			} );
 
 			expect( editor.getData() )
-				.to.equal( '<p>foo <strong><span class="mention" data-mention="John">@John</span></strong> bar</p>' );
+				.to.equal( '<p>foo <strong><span class="mention" data-mention="@John">@John</span></strong> bar</p>' );
 		} );
 
 		it( 'should set attribute on whole mention when formatting part of two mentions', () => {
@@ -446,7 +452,7 @@ describe( 'MentionEditing', () => {
 			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>'
+				'<p><span class="mention" data-mention="@John">@John</span><span class="mention" data-mention="@John">@John</span></p>'
 			);
 
 			const paragraph = doc.getRoot().getChild( 0 );
@@ -463,8 +469,8 @@ describe( 'MentionEditing', () => {
 			expect( editor.getData() ).to.equal(
 				'<p>' +
 					'<strong>' +
-						'<span class="mention" data-mention="John">@John</span>' +
-						'<span class="mention" data-mention="John">@John</span>' +
+						'<span class="mention" data-mention="@John">@John</span>' +
+						'<span class="mention" data-mention="@John">@John</span>' +
 					'</strong>' +
 				'</p>'
 			);
@@ -492,8 +498,8 @@ describe( 'MentionEditing', () => {
 
 			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' +
+					'<span class="mark-a">foo <span class="mention" data-mention="@John">@John</span></span>' +
+					'<span class="mention" data-mention="@John">@John</span> bar' +
 				'</p>'
 			);
 
@@ -509,8 +515,8 @@ describe( 'MentionEditing', () => {
 				'<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 class="mention" data-mention="@John">@John</span>' +
+						'<span class="mention" data-mention="@John">@John</span>' +
 					'</span> bar' +
 				'</p>'
 			);

+ 172 - 15
packages/ckeditor5-mention/tests/mentionui.js

@@ -25,7 +25,10 @@ describe( 'MentionUI', () => {
 
 	const staticConfig = {
 		feeds: [
-			{ feed: [ 'Barney', 'Lily', 'Marshall', 'Robin', 'Ted' ] }
+			{
+				feed: [ '@Barney', '@Lily', '@Marshall', '@Robin', '@Ted' ],
+				marker: '@'
+			}
 		]
 	};
 
@@ -286,7 +289,7 @@ describe( 'MentionUI', () => {
 			const bigList = {
 				marker: '@',
 				feed: [
-					'a01', 'a02', 'a03', 'a04', 'a05', 'a06', 'a07', 'a08', 'a09', 'a10', 'a11', 'a12'
+					'@a01', '@a02', '@a03', '@a04', '@a05', '@a06', '@a07', '@a08', '@a09', '@a10', '@a11', '@a12'
 				]
 			};
 
@@ -405,7 +408,7 @@ describe( 'MentionUI', () => {
 			it( 'should not show panel when selection is inside a mention', () => {
 				setData( model, '<paragraph>foo [@John] bar</paragraph>' );
 				model.change( writer => {
-					writer.setAttribute( 'mention', { name: 'John', _marker: '@', _id: 1234 }, doc.selection.getFirstRange() );
+					writer.setAttribute( 'mention', { id: '@John', _uid: 1234 }, doc.selection.getFirstRange() );
 				} );
 
 				model.change( writer => {
@@ -422,7 +425,7 @@ describe( 'MentionUI', () => {
 			it( 'should not show panel when selection is at the end of a mention', () => {
 				setData( model, '<paragraph>foo [@John] bar</paragraph>' );
 				model.change( writer => {
-					writer.setAttribute( 'mention', { name: 'John', _marker: '@', _id: 1234 }, doc.selection.getFirstRange() );
+					writer.setAttribute( 'mention', { id: '@John', _uid: 1234 }, doc.selection.getFirstRange() );
 				} );
 
 				model.change( writer => {
@@ -462,7 +465,7 @@ 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() );
+					writer.setAttribute( 'mention', { id: '@John', _uid: 1234 }, doc.selection.getFirstRange() );
 				} );
 
 				return waitForDebounce()
@@ -559,7 +562,7 @@ describe( 'MentionUI', () => {
 
 		describe( 'asynchronous list with custom trigger', () => {
 			beforeEach( () => {
-				const issuesNumbers = [ '100', '101', '102', '103' ];
+				const issuesNumbers = [ '#100', '#101', '#102', '#103' ];
 
 				return createClassicTestEditor( {
 					feeds: [
@@ -760,7 +763,8 @@ describe( 'MentionUI', () => {
 		} );
 
 		describe( 'default list item', () => {
-			const feedItems = staticConfig.feeds[ 0 ].feed.map( name => ( { name } ) );
+			// Create map of expected feed items as objects as they will be stored internally.
+			const feedItems = staticConfig.feeds[ 0 ].feed.map( text => ( { text: `${ text }`, id: `${ text }` } ) );
 
 			beforeEach( () => {
 				return createClassicTestEditor( staticConfig );
@@ -845,13 +849,166 @@ describe( 'MentionUI', () => {
 			} );
 		} );
 
-		describe( 'custom list item', () => {
+		describe( 'default list item with custom feed', () => {
 			const issues = [
-				{ id: '1002', title: 'Some bug in editor.' },
-				{ id: '1003', title: 'Introduce this feature.' },
-				{ id: '1004', title: 'Missing docs.' },
-				{ id: '1005', title: 'Another bug.' },
-				{ id: '1006', title: 'More bugs' }
+				{ id: '@Ted' },
+				{ id: '@Barney' },
+				{ id: '@Robin' },
+				{ id: '@Lily' },
+				{ id: '@Marshal' }
+			];
+
+			beforeEach( () => {
+				return createClassicTestEditor( {
+					feeds: [
+						{
+							marker: '@',
+							feed: feedText => issues.filter( issue => issue.id.includes( feedText ) )
+						}
+					]
+				} );
+			} );
+
+			it( 'should show panel for matched marker', () => {
+				setData( model, '<paragraph>foo []</paragraph>' );
+
+				model.change( writer => {
+					writer.insertText( '@', doc.selection.getFirstPosition() );
+				} );
+
+				return waitForDebounce()
+					.then( () => {
+						expect( panelView.isVisible ).to.be.true;
+						expect( editor.model.markers.has( 'mention' ) ).to.be.true;
+						expect( listView.items ).to.have.length( 5 );
+					} );
+			} );
+		} );
+
+		describe( 'custom list item (string)', () => {
+			const issues = [
+				{ id: '@1002', title: 'Some bug in editor.' },
+				{ id: '@1003', title: 'Introduce this feature.' },
+				{ id: '@1004', title: 'Missing docs.' },
+				{ id: '@1005', title: 'Another bug.' },
+				{ id: '@1006', title: 'More bugs' }
+			];
+
+			beforeEach( () => {
+				return createClassicTestEditor( {
+					feeds: [
+						{
+							marker: '@',
+							feed: issues,
+							itemRenderer: item => item.title
+						}
+					]
+				} );
+			} );
+
+			it( 'should show panel for matched marker', () => {
+				setData( model, '<paragraph>foo []</paragraph>' );
+
+				model.change( writer => {
+					writer.insertText( '@', doc.selection.getFirstPosition() );
+				} );
+
+				return waitForDebounce()
+					.then( () => {
+						expect( panelView.isVisible ).to.be.true;
+						expect( editor.model.markers.has( 'mention' ) ).to.be.true;
+						expect( 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( 'custom list item (DOM Element)', () => {
+			const issues = [
+				{ id: '@1002', title: 'Some bug in editor.' },
+				{ id: '@1003', title: 'Introduce this feature.' },
+				{ id: '@1004', title: 'Missing docs.' },
+				{ id: '@1005', title: 'Another bug.' },
+				{ id: '@1006', title: 'More bugs' }
 			];
 
 			beforeEach( () => {
@@ -865,7 +1022,7 @@ describe( 'MentionUI', () => {
 							itemRenderer: item => {
 								const span = global.document.createElementNS( 'http://www.w3.org/1999/xhtml', 'span' );
 
-								span.innerHTML = `<span id="issue-${ item.id }">@${ item.title }</span>`;
+								span.innerHTML = `<span id="issue-${ item.id.slice( 1 ) }">@${ item.title }</span>`;
 
 								return span;
 							}
@@ -1106,7 +1263,7 @@ describe( 'MentionUI', () => {
 
 					const commandOptions = spy.getCall( 0 ).args[ 0 ];
 
-					assertCommandOptions( commandOptions, '@', { name: 'Barney' } );
+					assertCommandOptions( commandOptions, '@', { id: '@Barney', text: '@Barney' } );
 
 					const start = model.createPositionAt( doc.getRoot().getChild( 0 ), 4 );
 					const expectedRange = model.createRange( start, start.getShiftedBy( 1 ) );