Преглед изворни кода

Merge pull request #33 from ckeditor/t/10

Other: Change mention attribute properties names. Closes #10. Closes #30.
Piotrek Koszuliński пре 6 година
родитељ
комит
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>
 </style>
 
 
 <div id="snippet-mention-custom-colors">
 <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>
 </div>

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

@@ -20,7 +20,7 @@ ClassicEditor
 			feeds: [
 			feeds: [
 				{
 				{
 					marker: '@',
 					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">
 <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>
 </div>
 
 
 <style>
 <style>

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

@@ -22,8 +22,7 @@ ClassicEditor
 				{
 				{
 					marker: '@',
 					marker: '@',
 					feed: getFeedItems,
 					feed: getFeedItems,
-					itemRenderer: customItemRenderer,
-					minimumCharacters: 1
+					itemRenderer: customItemRenderer
 				}
 				}
 			]
 			]
 		}
 		}
@@ -36,8 +35,8 @@ ClassicEditor
 	} );
 	} );
 
 
 function MentionCustomization( editor ) {
 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( {
 	editor.conversion.for( 'upcast' ).elementToAttribute( {
 		view: {
 		view: {
 			name: 'a',
 			name: 'a',
@@ -52,23 +51,21 @@ function MentionCustomization( editor ) {
 			key: 'mention',
 			key: 'mention',
 			value: viewItem => {
 			value: viewItem => {
 				// The mention feature expects that the mention attribute value
 				// 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.
 					// Add any other properties that you need.
 					link: viewItem.getAttribute( 'href' ),
 					link: viewItem.getAttribute( 'href' ),
-					id: viewItem.getAttribute( 'data-user-id' )
-				};
+					userId: viewItem.getAttribute( 'data-user-id' )
+				} );
 
 
-				return mentionValue;
+				return mentionAttribute;
 			}
 			}
 		},
 		},
 		converterPriority: 'high'
 		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( {
 	editor.conversion.for( 'downcast' ).attributeToElement( {
 		model: 'mention',
 		model: 'mention',
 		view: ( modelAttributeValue, viewWriter ) => {
 		view: ( modelAttributeValue, viewWriter ) => {
@@ -79,8 +76,8 @@ function MentionCustomization( editor ) {
 
 
 			return viewWriter.createAttributeElement( 'a', {
 			return viewWriter.createAttributeElement( 'a', {
 				class: 'mention',
 				class: 'mention',
-				'data-mention': modelAttributeValue.name,
-				'data-user-id': modelAttributeValue.id,
+				'data-mention': modelAttributeValue.id,
+				'data-user-id': modelAttributeValue.userId,
 				'href': modelAttributeValue.link
 				'href': modelAttributeValue.link
 			} );
 			} );
 		},
 		},
@@ -89,20 +86,26 @@ function MentionCustomization( editor ) {
 }
 }
 
 
 const items = [
 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 ) {
 function getFeedItems( queryText ) {
 	// As an example of an asynchronous action, let's return a promise
 	// As an example of an asynchronous action, let's return a promise
 	// that resolves after a 100ms timeout.
 	// 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 => {
 	return new Promise( resolve => {
 		setTimeout( () => {
 		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 );
 		}, 100 );
 	} );
 	} );
 
 
@@ -114,7 +117,7 @@ function getFeedItems( queryText ) {
 		// Include an item in the search results if name or username includes the current user input.
 		// Include an item in the search results if name or username includes the current user input.
 		return (
 		return (
 			item.name.toLowerCase().includes( searchString ) ||
 			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' );
 	const itemElement = document.createElement( 'span' );
 
 
 	itemElement.classList.add( 'custom-item' );
 	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 } `;
 	itemElement.textContent = `${ item.name } `;
 
 
 	const usernameElement = document.createElement( 'span' );
 	const usernameElement = document.createElement( 'span' );
 
 
 	usernameElement.classList.add( 'custom-item-username' );
 	usernameElement.classList.add( 'custom-item-username' );
-	usernameElement.textContent = `@${ item.username }`;
+	usernameElement.textContent = item.id;
 
 
 	itemElement.appendChild( usernameElement );
 	itemElement.appendChild( usernameElement );
 
 

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

@@ -1,3 +1,3 @@
 <div id="snippet-mention">
 <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>
 </div>

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

@@ -20,7 +20,7 @@ ClassicEditor
 			feeds: [
 			feeds: [
 				{
 				{
 					marker: '@',
 					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: [
 			feeds: [
 				{
 				{
 					marker: '@',
 					marker: '@',
-					feed: [ 'Barney', 'Lily', 'Marshall', 'Robin', 'Ted' ],
+					feed: [ '@Barney', '@Lily', '@Marshall', '@Robin', '@Ted' ],
 					minimumCharacters: 1
 					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.
 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
 ```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 = [
 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 ) {
 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.
 		// Include an item in the search results if name or username includes the current user input.
 		return (
 		return (
 			item.name.toLowerCase().includes( searchString ) ||
 			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' );
 	const itemElement = document.createElement( 'span' );
 
 
 	itemElement.classList.add( 'custom-item' );
 	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 } `;
 	itemElement.textContent = `${ item.name } `;
 
 
 	const usernameElement = document.createElement( 'span' );
 	const usernameElement = document.createElement( 'span' );
 
 
 	usernameElement.classList.add( 'custom-item-username' );
 	usernameElement.classList.add( 'custom-item-username' );
-	usernameElement.textContent = `@${ item.username }`;
+	usernameElement.textContent = item.id;
 
 
 	itemElement.appendChild( usernameElement );
 	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:
 The example below defined a plugin which overrides the default output:
 
 
 ```html
 ```html
-<span data-mention="Ted" class="mention">@Ted</span>
+<span data-mention="@Ted" class="mention">@Ted</span>
 ```
 ```
 
 
 To a link:
 To a link:
 
 
 ```html
 ```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}).
 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',
 			key: 'mention',
 			value: viewItem => {
 			value: viewItem => {
 				// The mention feature expects that the mention attribute value
 				// 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.
 					// Add any other properties that you need.
 					link: viewItem.getAttribute( 'href' ),
 					link: viewItem.getAttribute( 'href' ),
-					id: viewItem.getAttribute( 'data-user-id' )
-				};
+					userId: viewItem.getAttribute( 'data-user-id' )
+				} );
 
 
-				return mentionValue;
+				return mentionAttribute;
 			}
 			}
 		},
 		},
 		converterPriority: 'high'
 		converterPriority: 'high'
@@ -221,8 +237,8 @@ function MentionCustomization( editor ) {
 
 
 			return viewWriter.createAttributeElement( 'a', {
 			return viewWriter.createAttributeElement( 'a', {
 				class: 'mention',
 				class: 'mention',
-				'data-mention': modelAttributeValue.name,
-				'data-user-id': modelAttributeValue.id,
+				'data-mention': modelAttributeValue.id,
+				'data-user-id': modelAttributeValue.userId,
 				'href': modelAttributeValue.link
 				'href': modelAttributeValue.link
 			} );
 			} );
 		},
 		},
@@ -254,8 +270,7 @@ ClassicEditor
 				{
 				{
 					marker: '@',
 					marker: '@',
 					feed: getFeedItems,
 					feed: getFeedItems,
-					itemRenderer: customItemRenderer,
-					minimumCharacters: 1
+					itemRenderer: customItemRenderer
 				}
 				}
 			]
 			]
 		}
 		}
@@ -284,17 +299,15 @@ function MentionCustomization( editor ) {
 			key: 'mention',
 			key: 'mention',
 			value: viewItem => {
 			value: viewItem => {
 				// The mention feature expects that the mention attribute value
 				// 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.
 					// Add any other properties that you need.
 					link: viewItem.getAttribute( 'href' ),
 					link: viewItem.getAttribute( 'href' ),
-					id: viewItem.getAttribute( 'data-user-id' )
-				};
+					userId: viewItem.getAttribute( 'data-user-id' )
+				} );
 
 
-				return mentionValue;
+				return mentionAttribute;
 			}
 			}
 		},
 		},
 		converterPriority: 'high'
 		converterPriority: 'high'
@@ -311,8 +324,8 @@ function MentionCustomization( editor ) {
 
 
 			return viewWriter.createAttributeElement( 'a', {
 			return viewWriter.createAttributeElement( 'a', {
 				class: 'mention',
 				class: 'mention',
-				'data-mention': modelAttributeValue.name,
-				'data-user-id': modelAttributeValue.id,
+				'data-mention': modelAttributeValue.id,
+				'data-user-id': modelAttributeValue.userId,
 				'href': modelAttributeValue.link
 				'href': modelAttributeValue.link
 			} );
 			} );
 		},
 		},
@@ -321,11 +334,11 @@ function MentionCustomization( editor ) {
 }
 }
 
 
 const items = [
 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 ) {
 function getFeedItems( queryText ) {
@@ -334,7 +347,13 @@ function getFeedItems( queryText ) {
 	// 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 => {
 	return new Promise( resolve => {
 		setTimeout( () => {
 		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 );
 		}, 100 );
 	} );
 	} );
 
 
@@ -346,7 +365,7 @@ function getFeedItems( queryText ) {
 		// Include an item in the search results if name or username includes the current user input.
 		// Include an item in the search results if name or username includes the current user input.
 		return (
 		return (
 			item.name.toLowerCase().includes( searchString ) ||
 			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' );
 	const itemElement = document.createElement( 'span' );
 
 
 	itemElement.classList.add( 'custom-item' );
 	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 } `;
 	itemElement.textContent = `${ item.name } `;
 
 
 	const usernameElement = document.createElement( 'span' );
 	const usernameElement = document.createElement( 'span' );
 
 
 	usernameElement.classList.add( 'custom-item-username' );
 	usernameElement.classList.add( 'custom-item-username' );
-	usernameElement.textContent = `@${ item.username }`;
+	usernameElement.textContent = item.id;
 
 
 	itemElement.appendChild( usernameElement );
 	itemElement.appendChild( usernameElement );
 
 

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

@@ -16,17 +16,13 @@
   },
   },
   "devDependencies": {
   "devDependencies": {
     "@ckeditor/ckeditor5-basic-styles": "^11.0.0",
     "@ckeditor/ckeditor5-basic-styles": "^11.0.0",
+    "@ckeditor/ckeditor5-block-quote": "^11.0.0",
     "@ckeditor/ckeditor5-clipboard": "^11.0.0",
     "@ckeditor/ckeditor5-clipboard": "^11.0.0",
     "@ckeditor/ckeditor5-editor-classic": "^12.0.0",
     "@ckeditor/ckeditor5-editor-classic": "^12.0.0",
     "@ckeditor/ckeditor5-engine": "^13.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-paragraph": "^11.0.0",
-    "@ckeditor/ckeditor5-table": "^12.0.0",
-    "@ckeditor/ckeditor5-typing": "^12.0.0",
     "@ckeditor/ckeditor5-undo": "^11.0.0",
     "@ckeditor/ckeditor5-undo": "^11.0.0",
-    "@ckeditor/ckeditor5-widget": "^11.0.0",
     "eslint": "^5.5.0",
     "eslint": "^5.5.0",
     "eslint-config-ckeditor5": "^1.0.11",
     "eslint-config-ckeditor5": "^1.0.11",
     "husky": "^1.3.1",
     "husky": "^1.3.1",

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

@@ -9,7 +9,7 @@
 
 
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 
 
-import MentionEditing from './mentionediting';
+import MentionEditing, { _toMentionAttribute } from './mentionediting';
 import MentionUI from './mentionui';
 import MentionUI from './mentionui';
 
 
 import '../theme/mention.css';
 import '../theme/mention.css';
@@ -22,6 +22,23 @@ import '../theme/mention.css';
  * @extends module:core/plugin~Plugin
  * @extends module:core/plugin~Plugin
  */
  */
 export default class Mention extends 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
 	 * @inheritDoc
 	 */
 	 */
@@ -73,7 +90,7 @@ export default class Mention extends Plugin {
  *					feeds: [
  *					feeds: [
  *						{
  *						{
  *							marker: '@',
  *							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.
  *		// Static configuration.
  *		const mentionFeedPeople = {
  *		const mentionFeedPeople = {
  *			marker: '@',
  *			marker: '@',
- *			feed: [ 'Alice', 'Bob', ... ],
+ *			feed: [ '@Alice', '@Bob', ... ],
  *			minimumCharacters: 2
  *			minimumCharacters: 2
  *		};
  *		};
  *
  *
@@ -107,7 +124,7 @@ export default class Mention extends Plugin {
  *				return tags
  *				return tags
  *					// Filter the tags list.
  *					// Filter the tags list.
  *					.filter( tag => {
  *					.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.
  *					// Return 10 items max - needed for generic queries when the list may contain hundreds of elements.
  *					.slice( 0, 10 );
  *					.slice( 0, 10 );
@@ -136,7 +153,7 @@ export default class Mention extends Plugin {
  *		}
  *		}
  *
  *
  * @typedef {Object} module:mention/mention~MentionFeed
  * @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
  * @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
  * 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).
  * 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.
  * 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"}
  * (see {@glink features/mention#customizing-the-autocomplete-list "Customizing the autocomplete list"}
  * and {@glink features/mention#customizing-the-output "Customizing the output"} sections).
  * and {@glink features/mention#customizing-the-output "Customizing the output"} sections).
  *
  *
@@ -163,23 +180,23 @@ export default class Mention extends Plugin {
  *							marker: '@',
  *							marker: '@',
  *							feed: [
  *							feed: [
  *								{
  *								{
- *									name: 'Barney',
+ *									id: '@Barney',
  *									fullName: 'Barney Bloom'
  *									fullName: 'Barney Bloom'
  *								},
  *								},
  *								{
  *								{
- *									name: 'Lily',
+ *									id: '@Lily',
  *									fullName: 'Lily Smith'
  *									fullName: 'Lily Smith'
  *								},
  *								},
  *								{
  *								{
- *									name: 'Marshall',
+ *									id: '@Marshall',
  *									fullName: 'Marshall McDonald'
  *									fullName: 'Marshall McDonald'
  *								},
  *								},
  *								{
  *								{
- *									name: 'Robin',
+ *									id: '@Robin',
  *									fullName: 'Robin Hood'
  *									fullName: 'Robin Hood'
  *								},
  *								},
  *								{
  *								{
- *									name: 'Ted',
+ *									id: '@Ted',
  *									fullName: 'Ted Cruze'
  *									fullName: 'Ted Cruze'
  *								},
  *								},
  *								// ...
  *								// ...
@@ -198,5 +215,18 @@ export default class Mention extends Plugin {
  *			.catch( ... );
  *			.catch( ... );
  *
  *
  * @typedef {Object|String} module:mention/mention~MentionFeedItem
  * @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 Command from '@ckeditor/ckeditor5-core/src/command';
 import toMap from '@ckeditor/ckeditor5-utils/src/tomap';
 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.
  * The mention command.
@@ -20,13 +21,28 @@ import uid from '@ckeditor/ckeditor5-utils/src/uid';
  *
  *
  *		const focus = editor.model.document.selection.focus;
  *		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', {
  *		editor.execute( 'mention', {
+ *			marker: '#',
  *			mention: {
  *			mention: {
+ *				id: '#1234',
  *				name: 'Foo',
  *				name: 'Foo',
- *				id: '1234',
  *				title: 'Big Foo'
  *				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: '#',
  *			marker: '#',
+ *			mention: {
+ *				id: '#1234',
+ *				name: 'Foo',
+ *				title: 'Big Foo'
+ *			},
+ *			text: 'The "Big Foo"',
  *			range: model.createRange( focus, focus.getShiftedBy( -1 ) )
  *			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} [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
 	 * @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.
 	 * 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.
 	 * @param {String} [options.range] Range to replace. Note that replace range might be shorter then inserted text with mention attribute.
 	 * @fires execute
 	 * @fires execute
 	 */
 	 */
@@ -58,22 +76,64 @@ export default class MentionCommand extends Command {
 		const document = model.document;
 		const document = model.document;
 		const selection = document.selection;
 		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 => {
 		model.change( writer => {
 			const currentAttributes = toMap( selection.getAttributes() );
 			const currentAttributes = toMap( selection.getAttributes() );
 			const attributesWithMention = new Map( currentAttributes.entries() );
 			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.
 			// Replace range with a text with mention.
 			writer.remove( range );
 			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`
  * 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}
  * 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
  * @extends module:core/plugin~Plugin
  */
  */
@@ -48,7 +48,7 @@ export default class MentionEditing extends Plugin {
 			},
 			},
 			model: {
 			model: {
 				key: 'mention',
 				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;
 		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.
 // Creates mention element from mention data.
@@ -106,11 +110,11 @@ function createViewMentionElement( mention, viewWriter ) {
 
 
 	const attributes = {
 	const attributes = {
 		class: 'mention',
 		class: 'mention',
-		'data-mention': mention.name
+		'data-mention': mention.id
 	};
 	};
 
 
 	const options = {
 	const options = {
-		id: mention._id,
+		id: mention._uid,
 		priority: 20
 		priority: 20
 	};
 	};
 
 
@@ -227,7 +231,7 @@ function isBrokenMentionNode( node ) {
 	const text = node.data;
 	const text = node.data;
 	const mention = node.getAttribute( 'mention' );
 	const mention = node.getAttribute( 'mention' );
 
 
-	const expectedText = mention._marker + mention.name;
+	const expectedText = mention._text;
 
 
 	return text != expectedText;
 	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 ) {
 		for ( const mentionDescription of feeds ) {
 			const feed = mentionDescription.feed;
 			const feed = mentionDescription.feed;
 
 
-			const marker = mentionDescription.marker || '@';
+			const marker = mentionDescription.marker;
 			const minimumCharacters = mentionDescription.minimumCharacters || 0;
 			const minimumCharacters = mentionDescription.minimumCharacters || 0;
 			const feedCallback = typeof feed == 'function' ? feed : createFeedCallback( feed );
 			const feedCallback = typeof feed == 'function' ? feed : createFeedCallback( feed );
 			const watcher = this._setupTextWatcherForFeed( marker, minimumCharacters );
 			const watcher = this._setupTextWatcherForFeed( marker, minimumCharacters );
@@ -209,6 +209,7 @@ export default class MentionUI extends Plugin {
 
 
 			editor.execute( 'mention', {
 			editor.execute( 'mention', {
 				mention: item,
 				mention: item,
+				text: item.text,
 				marker,
 				marker,
 				range
 				range
 			} );
 			} );
@@ -297,8 +298,8 @@ export default class MentionUI extends Plugin {
 				.then( feed => {
 				.then( feed => {
 					this._items.clear();
 					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 } );
 						this._items.add( { item, marker } );
 					}
 					}
@@ -370,17 +371,24 @@ export default class MentionUI extends Plugin {
 		const editor = this.editor;
 		const editor = this.editor;
 
 
 		let view;
 		let view;
+		let label = item.id;
 
 
 		const renderer = this._getItemRenderer( marker );
 		const renderer = this._getItemRenderer( marker );
 
 
 		if ( renderer ) {
 		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 );
 			const buttonView = new ButtonView( editor.locale );
 
 
-			buttonView.label = item.name;
+			buttonView.label = label;
 			buttonView.withText = true;
 			buttonView.withText = true;
 
 
 			view = buttonView;
 			view = buttonView;
@@ -529,7 +537,13 @@ function createFeedCallback( feedItems ) {
 	return feedText => {
 	return feedText => {
 		const filteredItems = feedItems
 		const filteredItems = feedItems
 		// Make default mention feed case-insensitive.
 		// 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.
 			// Do not return more than 10 items.
 			.slice( 0, 10 );
 			.slice( 0, 10 );
 
 

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

@@ -129,17 +129,19 @@ export default class TextWatcher {
 
 
 		const block = selection.focus.parent;
 		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 );
 mix( TextWatcher, EmitterMixin );

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

@@ -3,44 +3,50 @@
  * For licensing, see LICENSE.md.
  * 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 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 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 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
 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: {
 		mention: {
 			feeds: [
 			feeds: [
 				{
 				{
 					feed: getFeed,
 					feed: getFeed,
 					itemRenderer: item => {
 					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.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;
 						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 ) {
 function getFeed( feedText ) {
 	return Promise.resolve( [
 	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 => {
 	].filter( item => {
 		const searchString = feedText.toLowerCase();
 		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
 ### 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
 ### Interaction
 
 

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

@@ -1,7 +1,7 @@
 <div id="editor">
 <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>
 </div>
 
 
 <style>
 <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 global from '@ckeditor/ckeditor5-utils/src/dom/global';
 
 
 import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
 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 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 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 {
 class CustomMentionAttributeView extends Plugin {
 	init() {
 	init() {
@@ -42,12 +31,9 @@ class CustomMentionAttributeView extends Plugin {
 			model: {
 			model: {
 				key: 'mention',
 				key: 'mention',
 				value: viewItem => {
 				value: viewItem => {
-					const mentionValue = {
-						name: viewItem.getAttribute( 'data-mention' ),
+					return editor.plugins.get( 'Mention' ).toMentionAttribute( viewItem, {
 						link: viewItem.getAttribute( 'href' )
 						link: viewItem.getAttribute( 'href' )
-					};
-
-					return mentionValue;
+					} );
 				}
 				}
 			},
 			},
 			converterPriority: 'high'
 			converterPriority: 'high'
@@ -62,9 +48,9 @@ class CustomMentionAttributeView extends Plugin {
 
 
 				return viewWriter.createAttributeElement( 'a', {
 				return viewWriter.createAttributeElement( 'a', {
 					class: 'mention',
 					class: 'mention',
-					'data-mention': modelAttributeValue.name,
+					'data-mention': modelAttributeValue.id,
 					'href': modelAttributeValue.link
 					'href': modelAttributeValue.link
-				} );
+				}, { id: modelAttributeValue._uid } );
 			},
 			},
 			converterPriority: 'high'
 			converterPriority: 'high'
 		} );
 		} );
@@ -73,9 +59,15 @@ class CustomMentionAttributeView extends Plugin {
 
 
 ClassicEditor
 ClassicEditor
 	.create( global.document.querySelector( '#editor' ), {
 	.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: {
 		mention: {
 			feeds: [
 			feeds: [
 				{ feed: getFeed }
 				{ feed: getFeed }
@@ -91,14 +83,14 @@ ClassicEditor
 
 
 function getFeed( feedText ) {
 function getFeed( feedText ) {
 	return [
 	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 => {
 	].filter( item => {
 		const searchString = feedText.toLowerCase();
 		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
 ### Configuration
 
 
 The feed:
 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
 ### Interaction
 
 

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

@@ -1,4 +1,4 @@
 <div id="editor">
 <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>
 </div>

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

@@ -8,28 +8,27 @@
 import global from '@ckeditor/ckeditor5-utils/src/dom/global';
 import global from '@ckeditor/ckeditor5-utils/src/dom/global';
 
 
 import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
 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 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 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
 ClassicEditor
 	.create( global.document.querySelector( '#editor' ), {
 	.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: {
 		mention: {
 			feeds: [
 			feeds: [
-				{ feed: [ 'Barney', 'Lily', 'Marshall', 'Robin', 'Ted' ] },
+				{
+					feed: [ 'Barney', 'Lily', 'Marshall', 'Robin', 'Ted' ]
+				},
 				{
 				{
 					marker: '#',
 					marker: '#',
 					feed: [
 					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:
 The feeds:
 
 
 1. Static list with `@` marker:
 1. Static list with `@` marker:
+
     - Barney
     - Barney
     - Lily
     - Lily
     - Marshall
     - Marshall
     - Robin
     - Robin
     - Ted
     - Ted
+
 2. Static list of 20 items (`#` marker)
 2. Static list of 20 items (`#` marker)
+
     - a01
     - a01
     - a02
     - a02
-    - ... 
+    - ...
     - a20
     - a20
 
 
 ### Interaction
 ### Interaction

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

@@ -47,9 +47,9 @@ describe( 'Mention feature - integration', () => {
 
 
 		// Failing test. See ckeditor/ckeditor5#1645.
 		// Failing test. See ckeditor/ckeditor5#1645.
 		it( 'should restore removed mention on adding a text inside mention', () => {
 		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 => {
 			model.change( writer => {
 				const paragraph = doc.getRoot().getChild( 0 );
 				const paragraph = doc.getRoot().getChild( 0 );
@@ -64,16 +64,16 @@ describe( 'Mention feature - integration', () => {
 
 
 			editor.execute( 'undo' );
 			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 } ) )
 			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.
 		// Failing test. See ckeditor/ckeditor5#1645.
 		it( 'should restore removed mention on removing a text inside mention', () => {
 		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 => {
 			model.change( writer => {
 				const paragraph = doc.getRoot().getChild( 0 );
 				const paragraph = doc.getRoot().getChild( 0 );
@@ -89,15 +89,15 @@ describe( 'Mention feature - integration', () => {
 
 
 			editor.execute( 'undo' );
 			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 } ) )
 			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)', () => {
 		it( 'should work with attribute post-fixer (beginning formatted)', () => {
 			testAttributePostFixer(
 			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 => {
 					model.change( writer => {
 						const paragraph = doc.getRoot().getChild( 0 );
 						const paragraph = doc.getRoot().getChild( 0 );
@@ -113,8 +113,8 @@ describe( 'Mention feature - integration', () => {
 
 
 		it( 'should work with attribute post-fixer (end formatted)', () => {
 		it( 'should work with attribute post-fixer (end formatted)', () => {
 			testAttributePostFixer(
 			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 => {
 					model.change( writer => {
 						const paragraph = doc.getRoot().getChild( 0 );
 						const paragraph = doc.getRoot().getChild( 0 );
@@ -130,8 +130,8 @@ describe( 'Mention feature - integration', () => {
 
 
 		it( 'should work with attribute post-fixer (middle formatted)', () => {
 		it( 'should work with attribute post-fixer (middle formatted)', () => {
 			testAttributePostFixer(
 			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 => {
 					model.change( writer => {
 						const paragraph = doc.getRoot().getChild( 0 );
 						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>' );
 			editor.setData( '<p>foobar</p>' );
 
 
 			model.change( writer => {
 			model.change( writer => {
@@ -195,11 +195,11 @@ describe( 'Mention feature - integration', () => {
 			} );
 			} );
 
 
 			clipboard.fire( 'inputTransformation', {
 			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>' +
 			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>';
 				'<p>bar</p>';
 
 
 			expect( editor.getData() )
 			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 ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor';
 import global from '@ckeditor/ckeditor5-utils/src/dom/global';
 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 Mention from '../src/mention';
 import MentionEditing from '../src/mentionediting';
 import MentionEditing from '../src/mentionediting';
@@ -47,4 +49,45 @@ describe( 'Mention', () => {
 	it( 'should load MentionUI plugin', () => {
 	it( 'should load MentionUI plugin', () => {
 		expect( editor.plugins.get( MentionUI ) ).to.instanceOf( MentionUI );
 		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 { setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
 
 
 import MentionCommand from '../src/mentioncommand';
 import MentionCommand from '../src/mentioncommand';
+import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 
 
 describe( 'MentionCommand', () => {
 describe( 'MentionCommand', () => {
 	let editor, command, model, doc, selection;
 	let editor, command, model, doc, selection;
@@ -54,26 +55,43 @@ describe( 'MentionCommand', () => {
 	} );
 	} );
 
 
 	describe( 'execute()', () => {
 	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>' );
 			setData( model, '<paragraph>foo @Jo[]bar</paragraph>' );
 
 
 			command.execute( {
 			command.execute( {
-				mention: { name: 'John' },
+				marker: '@',
+				mention: '@John',
 				range: model.createRange( selection.focus.getShiftedBy( -3 ), selection.focus )
 				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>' );
 			setData( model, '<paragraph>foo @Jo[]bar</paragraph>' );
 
 
 			command.execute( {
 			command.execute( {
-				mention: 'John',
+				marker: '@',
+				mention: '@John',
+				text: '@John Doe',
 				range: model.createRange( selection.focus.getShiftedBy( -3 ), selection.focus )
 				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', () => {
 		it( 'inserts mention attribute with passed marker for given range', () => {
@@ -83,22 +101,23 @@ describe( 'MentionCommand', () => {
 			const start = end.getShiftedBy( -3 );
 			const start = end.getShiftedBy( -3 );
 
 
 			command.execute( {
 			command.execute( {
-				mention: { name: 'John' },
+				marker: '#',
+				mention: '#John',
 				range: model.createRange( start, end ),
 				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', () => {
 		it( 'inserts mention attribute at current selection if no range was passed', () => {
 			setData( model, '<paragraph>foo []bar</paragraph>' );
 			setData( model, '<paragraph>foo []bar</paragraph>' );
 
 
 			command.execute( {
 			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', () => {
 		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>' );
 			setData( model, '<paragraph><$text bold="true">foo@John[]bar</$text></paragraph>' );
 
 
 			command.execute( {
 			command.execute( {
-				mention: { name: 'John' },
+				marker: '@',
+				mention: '@John',
 				range: model.createRange( selection.focus.getShiftedBy( -5 ), selection.focus )
 				range: model.createRange( selection.focus.getShiftedBy( -5 ), selection.focus )
 			} );
 			} );
 
 
 			const textNode = doc.getRoot().getChild( 0 ).getChild( 1 );
 			const textNode = doc.getRoot().getChild( 0 ).getChild( 1 );
-			assertMention( textNode, '@', 'John' );
+			assertMention( textNode, '@John' );
 			expect( textNode.hasAttribute( 'bold' ) ).to.be.true;
 			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.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 );
 			const textNode = doc.getRoot().getChild( 0 ).getChild( 1 );
 
 
 			expect( textNode ).to.not.be.null;
 			expect( textNode ).to.not.be.null;
 			expect( textNode.hasAttribute( 'mention' ) ).to.be.true;
 			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( editor.getData() ).to.equal( expectedView );
 			expect( getViewData( editor.editing.view, { withoutSelection: true } ) ).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', () => {
 		it( 'should convert consecutive mentions spans as two text nodes and two spans in the view', () => {
 			editor.setData(
 			editor.setData(
 				'<p>' +
 				'<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>'
 				'</p>'
 			);
 			);
 
 
@@ -102,13 +102,13 @@ describe( 'MentionEditing', () => {
 			assertTextNode( paragraph.getChild( 0 ) );
 			assertTextNode( paragraph.getChild( 0 ) );
 			assertTextNode( paragraph.getChild( 1 ) );
 			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 );
 			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( editor.getData() ).to.equal( expectedView );
 			expect( getViewData( editor.editing.view, { withoutSelection: true } ) ).to.equal( expectedView );
 			expect( getViewData( editor.editing.view, { withoutSelection: true } ) ).to.equal( expectedView );
@@ -116,25 +116,31 @@ describe( 'MentionEditing', () => {
 			function assertTextNode( textNode ) {
 			function assertTextNode( textNode ) {
 				expect( textNode ).to.not.be.null;
 				expect( textNode ).to.not.be.null;
 				expect( textNode.hasAttribute( 'mention' ) ).to.be.true;
 				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', () => {
 		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( editor.getData() ).to.equal( expectedView );
 			expect( getViewData( editor.editing.view, { withoutSelection: true } ) ).to.equal( expectedView );
 			expect( getViewData( editor.editing.view, { withoutSelection: true } ) ).to.equal( expectedView );
 		} );
 		} );
 
 
 		it( 'should not convert empty mentions', () => {
 		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>' );
 			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', () => {
 		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 => {
 			model.change( writer => {
 				const paragraph = doc.getRoot().getChild( 0 );
 				const paragraph = doc.getRoot().getChild( 0 );
@@ -168,7 +174,7 @@ describe( 'MentionEditing', () => {
 		} );
 		} );
 
 
 		it( 'should allow to type after a mention', () => {
 		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 => {
 			model.change( writer => {
 				const paragraph = doc.getRoot().getChild( 0 );
 				const paragraph = doc.getRoot().getChild( 0 );
@@ -178,7 +184,7 @@ describe( 'MentionEditing', () => {
 				writer.insertText( ' ', paragraph, 9 );
 				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)', () => {
 		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 );
 			const textNode = doc.getRoot().getChild( 0 ).getChild( 1 );
 
 
 			expect( textNode ).to.not.be.null;
 			expect( textNode ).to.not.be.null;
 			expect( textNode.hasAttribute( 'mention' ) ).to.be.true;
 			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 => {
 			model.change( writer => {
 				const paragraph = doc.getRoot().getChild( 0 );
 				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', () => {
 		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 );
 			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', () => {
 		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 );
 			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', () => {
 		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 );
 			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', () => {
 		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 );
 			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', () => {
 		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 );
 			const paragraph = doc.getRoot().getChild( 0 );
 
 
@@ -304,11 +310,11 @@ describe( 'MentionEditing', () => {
 				model.deleteContent( doc.selection );
 				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', () => {
 		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 );
 			const paragraph = doc.getRoot().getChild( 0 );
 
 
@@ -326,7 +332,7 @@ describe( 'MentionEditing', () => {
 			} );
 			} );
 			editor.conversion.elementToElement( { model: 'inline', view: 'br' } );
 			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 );
 			const paragraph = doc.getRoot().getChild( 0 );
 
 
@@ -338,7 +344,7 @@ describe( 'MentionEditing', () => {
 		} );
 		} );
 
 
 		it( 'should remove mention when splitting paragraph with a mention', () => {
 		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 );
 			const paragraph = doc.getRoot().getChild( 0 );
 
 
@@ -356,7 +362,7 @@ describe( 'MentionEditing', () => {
 			} );
 			} );
 
 
 			editor.conversion.elementToElement( { model: 'blockQuote', view: 'blockquote' } );
 			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 => {
 			model.change( writer => {
 				const paragraph = doc.getRoot().getChild( 0 ).getChild( 0 );
 				const paragraph = doc.getRoot().getChild( 0 ).getChild( 0 );
@@ -382,7 +388,7 @@ describe( 'MentionEditing', () => {
 			model.schema.extend( '$text', { allowAttributes: [ 'bold' ] } );
 			model.schema.extend( '$text', { allowAttributes: [ 'bold' ] } );
 			editor.conversion.attributeToElement( { model: 'bold', view: 'strong' } );
 			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 );
 			const paragraph = doc.getRoot().getChild( 0 );
 
 
@@ -396,14 +402,14 @@ describe( 'MentionEditing', () => {
 			} );
 			} );
 
 
 			expect( editor.getData() )
 			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)', () => {
 		it( 'should set attribute on whole mention when formatting part of a mention (end formatted)', () => {
 			model.schema.extend( '$text', { allowAttributes: [ 'bold' ] } );
 			model.schema.extend( '$text', { allowAttributes: [ 'bold' ] } );
 			editor.conversion.attributeToElement( { model: 'bold', view: 'strong' } );
 			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 );
 			const paragraph = doc.getRoot().getChild( 0 );
 
 
@@ -417,14 +423,14 @@ describe( 'MentionEditing', () => {
 			} );
 			} );
 
 
 			expect( editor.getData() )
 			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)', () => {
 		it( 'should set attribute on whole mention when formatting part of a mention (middle of mention formatted)', () => {
 			model.schema.extend( '$text', { allowAttributes: [ 'bold' ] } );
 			model.schema.extend( '$text', { allowAttributes: [ 'bold' ] } );
 			editor.conversion.attributeToElement( { model: 'bold', view: 'strong' } );
 			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 );
 			const paragraph = doc.getRoot().getChild( 0 );
 
 
@@ -438,7 +444,7 @@ describe( 'MentionEditing', () => {
 			} );
 			} );
 
 
 			expect( editor.getData() )
 			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', () => {
 		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.conversion.attributeToElement( { model: 'bold', view: 'strong' } );
 
 
 			editor.setData(
 			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 );
 			const paragraph = doc.getRoot().getChild( 0 );
@@ -463,8 +469,8 @@ describe( 'MentionEditing', () => {
 			expect( editor.getData() ).to.equal(
 			expect( editor.getData() ).to.equal(
 				'<p>' +
 				'<p>' +
 					'<strong>' +
 					'<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>' +
 					'</strong>' +
 				'</p>'
 				'</p>'
 			);
 			);
@@ -492,8 +498,8 @@ describe( 'MentionEditing', () => {
 
 
 			editor.setData(
 			editor.setData(
 				'<p>' +
 				'<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>'
 				'</p>'
 			);
 			);
 
 
@@ -509,8 +515,8 @@ describe( 'MentionEditing', () => {
 				'<p>' +
 				'<p>' +
 					'<span class="mark-a">foo </span>' +
 					'<span class="mark-a">foo </span>' +
 					'<span class="mark-b">' +
 					'<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' +
 					'</span> bar' +
 				'</p>'
 				'</p>'
 			);
 			);

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

@@ -25,7 +25,10 @@ describe( 'MentionUI', () => {
 
 
 	const staticConfig = {
 	const staticConfig = {
 		feeds: [
 		feeds: [
-			{ feed: [ 'Barney', 'Lily', 'Marshall', 'Robin', 'Ted' ] }
+			{
+				feed: [ '@Barney', '@Lily', '@Marshall', '@Robin', '@Ted' ],
+				marker: '@'
+			}
 		]
 		]
 	};
 	};
 
 
@@ -286,7 +289,7 @@ describe( 'MentionUI', () => {
 			const bigList = {
 			const bigList = {
 				marker: '@',
 				marker: '@',
 				feed: [
 				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', () => {
 			it( 'should not show panel when selection is inside a mention', () => {
 				setData( model, '<paragraph>foo [@John] bar</paragraph>' );
 				setData( model, '<paragraph>foo [@John] bar</paragraph>' );
 				model.change( writer => {
 				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 => {
 				model.change( writer => {
@@ -422,7 +425,7 @@ describe( 'MentionUI', () => {
 			it( 'should not show panel when selection is at the end of a mention', () => {
 			it( 'should not show panel when selection is at the end of a mention', () => {
 				setData( model, '<paragraph>foo [@John] bar</paragraph>' );
 				setData( model, '<paragraph>foo [@John] bar</paragraph>' );
 				model.change( writer => {
 				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 => {
 				model.change( writer => {
@@ -462,7 +465,7 @@ describe( 'MentionUI', () => {
 			it( 'should not show panel when selection is after existing mention', () => {
 			it( 'should not show panel when selection is after existing mention', () => {
 				setData( model, '<paragraph>foo [@John] bar[]</paragraph>' );
 				setData( model, '<paragraph>foo [@John] bar[]</paragraph>' );
 				model.change( writer => {
 				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()
 				return waitForDebounce()
@@ -559,7 +562,7 @@ describe( 'MentionUI', () => {
 
 
 		describe( 'asynchronous list with custom trigger', () => {
 		describe( 'asynchronous list with custom trigger', () => {
 			beforeEach( () => {
 			beforeEach( () => {
-				const issuesNumbers = [ '100', '101', '102', '103' ];
+				const issuesNumbers = [ '#100', '#101', '#102', '#103' ];
 
 
 				return createClassicTestEditor( {
 				return createClassicTestEditor( {
 					feeds: [
 					feeds: [
@@ -760,7 +763,8 @@ describe( 'MentionUI', () => {
 		} );
 		} );
 
 
 		describe( 'default list item', () => {
 		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( () => {
 			beforeEach( () => {
 				return createClassicTestEditor( staticConfig );
 				return createClassicTestEditor( staticConfig );
@@ -845,13 +849,166 @@ describe( 'MentionUI', () => {
 			} );
 			} );
 		} );
 		} );
 
 
-		describe( 'custom list item', () => {
+		describe( 'default list item with custom feed', () => {
 			const issues = [
 			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( () => {
 			beforeEach( () => {
@@ -865,7 +1022,7 @@ describe( 'MentionUI', () => {
 							itemRenderer: item => {
 							itemRenderer: item => {
 								const span = global.document.createElementNS( 'http://www.w3.org/1999/xhtml', 'span' );
 								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;
 								return span;
 							}
 							}
@@ -1106,7 +1263,7 @@ describe( 'MentionUI', () => {
 
 
 					const commandOptions = spy.getCall( 0 ).args[ 0 ];
 					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 start = model.createPositionAt( doc.getRoot().getChild( 0 ), 4 );
 					const expectedRange = model.createRange( start, start.getShiftedBy( 1 ) );
 					const expectedRange = model.createRange( start, start.getShiftedBy( 1 ) );