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

Feature: Implemented debounced mechanism for requesting a mention feed. Closes ckeditor/ckeditor5#4619.

Marek Lewandowski 6 лет назад
Родитель
Сommit
3de4757154

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

@@ -13,7 +13,8 @@
     "@ckeditor/ckeditor5-core": "^16.0.0",
     "@ckeditor/ckeditor5-ui": "^16.0.0",
     "@ckeditor/ckeditor5-typing": "^16.0.0",
-    "@ckeditor/ckeditor5-utils": "^16.0.0"
+    "@ckeditor/ckeditor5-utils": "^16.0.0",
+    "lodash-es": "^4.17.10"
   },
   "devDependencies": {
     "@ckeditor/ckeditor5-basic-styles": "^16.0.0",
@@ -31,6 +32,7 @@
     "eslint-config-ckeditor5": "^2.0.0",
     "husky": "^1.3.1",
     "lint-staged": "^7.0.0",
+    "lodash": "^4.17.11",
     "stylelint": "^11.1.1",
     "stylelint-config-ckeditor5": "^1.0.0"
   },

+ 1 - 1
packages/ckeditor5-mention/src/mentionediting.js

@@ -81,7 +81,7 @@ export function _addMentionAttributes( baseMentionData, data ) {
  * @protected
  * @param {module:engine/view/element~Element} viewElementOrMention
  * @param {String|Object} [data] Mention data to be extended.
- * @return {module:mention/mention~MentionAttribute}
+ * @returns {module:mention/mention~MentionAttribute}
  */
 export function _toMentionAttribute( viewElementOrMention, data ) {
 	const dataMention = viewElementOrMention.getAttribute( 'data-mention' );

+ 183 - 62
packages/ckeditor5-mention/src/mentionui.js

@@ -7,6 +7,8 @@
  * @module mention/mentionui
  */
 
+/* global console */
+
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
 import Collection from '@ckeditor/ckeditor5-utils/src/collection';
@@ -14,8 +16,9 @@ import clickOutsideHandler from '@ckeditor/ckeditor5-ui/src/bindings/clickoutsid
 import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard';
 import env from '@ckeditor/ckeditor5-utils/src/env';
 import Rect from '@ckeditor/ckeditor5-utils/src/dom/rect';
-import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
+import CKEditorError, { attachLinkToDocumentation } from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 import ContextualBalloon from '@ckeditor/ckeditor5-ui/src/panel/balloon/contextualballoon';
+import { debounce } from 'lodash-es';
 
 import TextWatcher from '@ckeditor/ckeditor5-typing/src/textwatcher';
 
@@ -25,6 +28,16 @@ import MentionListItemView from './ui/mentionlistitemview';
 
 const VERTICAL_SPACING = 3;
 
+// The key codes that mention UI handles when it is open.
+const handledKeyCodes = [
+	keyCodes.arrowup,
+	keyCodes.arrowdown,
+	keyCodes.enter,
+	keyCodes.tab,
+	keyCodes.space,
+	keyCodes.esc
+];
+
 /**
  * The mention UI feature.
  *
@@ -38,6 +51,13 @@ export default class MentionUI extends Plugin {
 		return 'MentionUI';
 	}
 
+	/**
+	 * @inheritDoc
+	 */
+	static get requires() {
+		return [ ContextualBalloon ];
+	}
+
 	/**
 	 * @inheritDoc
 	 */
@@ -60,6 +80,16 @@ export default class MentionUI extends Plugin {
 		 */
 		this._mentionsConfigurations = new Map();
 
+		/**
+		 * Debounced feed requester. It uses `lodash#debounce` method to delay function call.
+		 *
+		 * @private
+		 * @param {String} marker
+		 * @param {String} feedText
+		 * @method
+		 */
+		this._requestFeedDebounced = debounce( this._requestFeed, 100 );
+
 		editor.config.define( 'mention', { feeds: [] } );
 	}
 
@@ -116,7 +146,7 @@ export default class MentionUI extends Plugin {
 
 			const marker = mentionDescription.marker;
 
-			if ( !marker || marker.length != 1 ) {
+			if ( !isValidMentionMarker( marker ) ) {
 				/**
 				 * The marker must be a single character.
 				 *
@@ -143,6 +173,9 @@ export default class MentionUI extends Plugin {
 
 			this._mentionsConfigurations.set( marker, definition );
 		}
+
+		this.on( 'requestFeed:response', ( evt, data ) => this._handleFeedResponse( data ) );
+		this.on( 'requestFeed:error', () => this._hideUIAndRemoveMarker() );
 	}
 
 	/**
@@ -155,13 +188,6 @@ export default class MentionUI extends Plugin {
 		this._mentionsView.destroy();
 	}
 
-	/**
-	 * @inheritDoc
-	 */
-	static get requires() {
-		return [ ContextualBalloon ];
-	}
-
 	/**
 	 * Returns true when {@link #_mentionsView} is in the {@link module:ui/panel/balloon/contextualballoon~ContextualBalloon} and it is
 	 * currently visible.
@@ -252,17 +278,80 @@ export default class MentionUI extends Plugin {
 	}
 
 	/**
-	 * Returns a promise that resolves with autocomplete items for a given text.
+	 * Requests a feed from a configured callbacks.
 	 *
+	 * @private
+	 * @fires module:mention/mentionui~MentionUI#event:requestFeed:response
+	 * @fires module:mention/mentionui~MentionUI#event:requestFeed:discarded
+	 * @fires module:mention/mentionui~MentionUI#event:requestFeed:error
 	 * @param {String} marker
 	 * @param {String} feedText
-	 * @return {Promise<module:mention/mention~MentionFeedItem>}
-	 * @private
 	 */
-	_getFeed( marker, feedText ) {
+	_requestFeed( marker, feedText ) {
+		// Store the last requested feed - it is used to discard any out-of order requests.
+		this._lastRequested = feedText;
+
 		const { feedCallback } = this._mentionsConfigurations.get( marker );
+		const feedResponse = feedCallback( feedText );
+
+		const isAsynchronous = feedResponse instanceof Promise;
+
+		// For synchronous feeds (e.g. callbacks, arrays) fire the response event immediately.
+		if ( !isAsynchronous ) {
+			/**
+			 * Fired whenever requested feed has a response.
+			 *
+			 * @event requestFeed:response
+			 * @param {Object} data Event data.
+			 * @param {Array.<module:mention/mention~MentionFeedItem>} data.feed Autocomplete items.
+			 * @param {String} data.marker The character which triggers autocompletion for mention.
+			 * @param {String} data.feedText The text for which feed items were requested.
+			 */
+			this.fire( 'requestFeed:response', { feed: feedResponse, marker, feedText } );
+
+			return;
+		}
+
+		// Handle the asynchronous responses.
+		feedResponse
+			.then( response => {
+				// Check the feed text of this response with the last requested one so either:
+				if ( this._lastRequested == feedText ) {
+					// It is the same and fire the response event.
+					this.fire( 'requestFeed:response', { feed: response, marker, feedText } );
+				} else {
+					// It is different - most probably out-of-order one, so fire the discarded event.
+					/**
+					 * Fired whenever the requested feed was discarded. This happens when the response was delayed and
+					 * other feed was already requested.
+					 *
+					 * @event requestFeed:discarded
+					 * @param {Object} data Event data.
+					 * @param {Array.<module:mention/mention~MentionFeedItem>} data.feed Autocomplete items.
+					 * @param {String} data.marker The character which triggers autocompletion for mention.
+					 * @param {String} data.feedText The text for which feed items were requested.
+					 */
+					this.fire( 'requestFeed:discarded', { feed: response, marker, feedText } );
+				}
+			} )
+			.catch( error => {
+				/**
+				 * Fired whenever the requested {@link module:mention/mention~MentionFeed#feed} promise fails with error.
+				 *
+				 * @event requestFeed:error
+				 * @param {Object} data Event data.
+				 * @param {Error} data.error The error that was caught.
+				 */
+				this.fire( 'requestFeed:error', { error } );
 
-		return Promise.resolve().then( () => feedCallback( feedText ) );
+				/**
+				 * The callback used for obtaining mention autocomplete feed thrown and error and the mention UI was hidden or
+				 * not displayed at all.
+				 *
+				 * @error mention-feed-callback-error
+				 */
+				console.warn( attachLinkToDocumentation( 'mention-feed-callback-error: Could not obtain mention autocomplete feed.' ) );
+			} );
 	}
 
 	/**
@@ -282,20 +371,13 @@ export default class MentionUI extends Plugin {
 			const selection = editor.model.document.selection;
 			const focus = selection.focus;
 
-			// The text watcher listens only to changed range in selection - so the selection attributes are not yet available
-			// and you cannot use selection.hasAttribute( 'mention' ) just yet.
-			// See https://github.com/ckeditor/ckeditor5-engine/issues/1723.
-			const hasMention = focus.textNode && focus.textNode.hasAttribute( 'mention' );
-
-			const nodeBefore = focus.nodeBefore;
-
-			if ( hasMention || nodeBefore && nodeBefore.is( 'text' ) && nodeBefore.hasAttribute( 'mention' ) ) {
+			if ( hasExistingMention( focus ) ) {
 				this._hideUIAndRemoveMarker();
 
 				return;
 			}
 
-			const feedText = getFeedText( marker, data.text );
+			const feedText = requestFeedText( marker, data.text );
 			const matchedTextLength = marker.length + feedText.length;
 
 			// Create a marker range.
@@ -304,34 +386,20 @@ export default class MentionUI extends Plugin {
 
 			const markerRange = editor.model.createRange( start, end );
 
-			let mentionMarker;
+			if ( checkIfStillInCompletionMode( editor ) ) {
+				const mentionMarker = editor.model.markers.get( 'mention' );
 
-			if ( editor.model.markers.has( 'mention' ) ) {
-				mentionMarker = editor.model.markers.get( 'mention' );
+				// Update the marker - user might've moved the selection to other mention trigger.
+				editor.model.change( writer => {
+					writer.updateMarker( mentionMarker, { range: markerRange } );
+				} );
 			} else {
-				mentionMarker = editor.model.change( writer => writer.addMarker( 'mention', {
-					range: markerRange,
-					usingOperation: false,
-					affectsData: false
-				} ) );
+				editor.model.change( writer => {
+					writer.addMarker( 'mention', { range: markerRange, usingOperation: false, affectsData: false } );
+				} );
 			}
 
-			this._getFeed( marker, feedText )
-				.then( feed => {
-					this._items.clear();
-
-					for ( const feedItem of feed ) {
-						const item = typeof feedItem != 'object' ? { id: feedItem, text: feedItem } : feedItem;
-
-						this._items.add( { item, marker } );
-					}
-
-					if ( this._items.length ) {
-						this._showUI( mentionMarker );
-					} else {
-						this._hideUIAndRemoveMarker();
-					}
-				} );
+			this._requestFeedDebounced( marker, feedText );
 		} );
 
 		watcher.on( 'unmatched', () => {
@@ -341,12 +409,45 @@ export default class MentionUI extends Plugin {
 		return watcher;
 	}
 
+	/**
+	 * Handles the feed response event data.
+	 *
+	 * @param data
+	 * @private
+	 */
+	_handleFeedResponse( data ) {
+		const { feed, marker } = data;
+
+		// If the marker is not in the document happens when the selection had changed and the 'mention' marker was removed.
+		if ( !checkIfStillInCompletionMode( this.editor ) ) {
+			return;
+		}
+
+		// Reset the view.
+		this._items.clear();
+
+		for ( const feedItem of feed ) {
+			const item = typeof feedItem != 'object' ? { id: feedItem, text: feedItem } : feedItem;
+
+			this._items.add( { item, marker } );
+		}
+
+		const mentionMarker = this.editor.model.markers.get( 'mention' );
+
+		if ( this._items.length ) {
+			this._showOrUpdateUI( mentionMarker );
+		} else {
+			// Do not show empty mention UI.
+			this._hideUIAndRemoveMarker();
+		}
+	}
+
 	/**
 	 * Shows the mentions balloon. If the panel is already visible, it will reposition it.
 	 *
 	 * @private
 	 */
-	_showUI( markerMarker ) {
+	_showOrUpdateUI( markerMarker ) {
 		if ( this._isUIVisible ) {
 			// Update balloon position as the mention list view may change its size.
 			this._balloon.updatePosition( this._getBalloonPanelPositionData( markerMarker, this._mentionsView.position ) );
@@ -360,7 +461,6 @@ export default class MentionUI extends Plugin {
 		}
 
 		this._mentionsView.position = this._balloon.view.position;
-
 		this._mentionsView.selectFirst();
 	}
 
@@ -375,7 +475,7 @@ export default class MentionUI extends Plugin {
 			this._balloon.remove( this._mentionsView );
 		}
 
-		if ( this.editor.model.markers.has( 'mention' ) ) {
+		if ( checkIfStillInCompletionMode( this.editor ) ) {
 			this.editor.model.change( writer => writer.removeMarker( 'mention' ) );
 		}
 
@@ -432,7 +532,7 @@ export default class MentionUI extends Plugin {
 	 */
 	_getBalloonPanelPositionData( mentionMarker, preferredPosition ) {
 		const editor = this.editor;
-		const editing = this.editor.editing;
+		const editing = editor.editing;
 		const domConverter = editing.view.domConverter;
 		const mapper = editing.mapper;
 
@@ -566,7 +666,7 @@ function createTestCallback( marker, minimumCharacters ) {
 //
 // @param {String} marker
 // @returns {Function}
-function getFeedText( marker, text ) {
+function requestFeedText( marker, text ) {
 	const regExp = createRegExp( marker, 0 );
 
 	const match = text.match( regExp );
@@ -589,7 +689,7 @@ function createFeedCallback( feedItems ) {
 			// Do not return more than 10 items.
 			.slice( 0, 10 );
 
-		return Promise.resolve( filteredItems );
+		return filteredItems;
 	};
 }
 
@@ -598,14 +698,35 @@ function createFeedCallback( feedItems ) {
 // @param {Number}
 // @returns {Boolean}
 function isHandledKey( keyCode ) {
-	const handledKeyCodes = [
-		keyCodes.arrowup,
-		keyCodes.arrowdown,
-		keyCodes.enter,
-		keyCodes.tab,
-		keyCodes.space,
-		keyCodes.esc
-	];
-
 	return handledKeyCodes.includes( keyCode );
 }
+
+// Checks if position in inside or right after a text with a mention.
+//
+// @param {module:engine/model/position~Position} position.
+// @returns {Boolean}
+function hasExistingMention( position ) {
+	// The text watcher listens only to changed range in selection - so the selection attributes are not yet available
+	// and you cannot use selection.hasAttribute( 'mention' ) just yet.
+	// See https://github.com/ckeditor/ckeditor5-engine/issues/1723.
+	const hasMention = position.textNode && position.textNode.hasAttribute( 'mention' );
+
+	const nodeBefore = position.nodeBefore;
+
+	return hasMention || nodeBefore && nodeBefore.is( 'text' ) && nodeBefore.hasAttribute( 'mention' );
+}
+
+// Checks if string is a valid mention marker.
+//
+// @param {String} marker
+// @returns {Boolean}
+function isValidMentionMarker( marker ) {
+	return marker && marker.length == 1;
+}
+
+// Checks the mention plugins is in completion mode (e.g. when typing is after a valid mention string like @foo).
+//
+// @returns {Boolean}
+function checkIfStillInCompletionMode( editor ) {
+	return editor.model.markers.has( 'mention' );
+}

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
packages/ckeditor5-mention/tests/_utils/asyncserver/data/db.json


+ 73 - 0
packages/ckeditor5-mention/tests/_utils/asyncserver/index.js

@@ -0,0 +1,73 @@
+/**
+ * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/* eslint-env node */
+
+const http = require( 'http' );
+const fs = require( 'fs' );
+const querystring = require( 'querystring' );
+const url = require( 'url' );
+const { upperFirst } = require( 'lodash' );
+
+const hostname = '127.0.0.1';
+const port = 3000;
+
+const server = http.createServer( function( req, res ) {
+	res.statusCode = 200;
+	res.setHeader( 'Content-Type', 'application/json' );
+
+	const { search } = querystring.parse( url.parse( req.url ).query.toLowerCase() );
+
+	readEntries( getTimeout() )
+		.then( entries => entries
+			.map( ( { picture, name, login } ) => ( {
+				id: `@${ login.username }`,
+				username: login.username,
+				fullName: `${ upperFirst( name.first ) } ${ upperFirst( name.last ) }`,
+				thumbnail: picture.thumbnail
+			} ) )
+			.sort( ( a, b ) => a.username.localeCompare( b.username ) )
+			.filter( entry => entry.fullName.toLowerCase().includes( search ) || entry.username.toLowerCase().includes( search ) )
+			.slice( 0, 10 )
+		)
+		.then( entries => {
+			res.setHeader( 'Access-Control-Allow-Origin', '*' );
+			res.setHeader( 'Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept' );
+
+			res.end( JSON.stringify( entries ) + '\n' );
+		} );
+} );
+
+server.listen( port, hostname, () => {
+	console.log( `server running at http://${ hostname }:${ port }/` );
+} );
+
+function readEntries( timeOut ) {
+	return new Promise( ( resolve, reject ) => {
+		fs.readFile( './data/db.json', ( err, data ) => {
+			if ( err ) {
+				reject( err );
+			}
+
+			const entries = JSON.parse( data );
+
+			setTimeout( () => {
+				resolve( entries );
+			}, timeOut );
+		} );
+	} );
+}
+
+function getTimeout() {
+	const type = parseInt( Math.random() * 10 );
+
+	// 60% of requests completes in 150ms.
+	if ( type < 6 ) {
+		return 150;
+	}
+
+	// 40% of requests completes in 400ms, 1s, 2s or 4s.
+	return [ 400, 1000, 2000, 4000 ][ ( Math.random() * 3 ).toFixed( 0 ) ];
+}

+ 52 - 0
packages/ckeditor5-mention/tests/manual/mention-asynchronous.html

@@ -0,0 +1,52 @@
+<head>
+	<meta http-equiv="Content-Security-Policy" content="default-src 'none'; connect-src 'self' http://localhost:3000 https://cksource.com http://*.cke-cs.com; script-src 'self' https://cksource.com; img-src * data:; style-src 'self' 'unsafe-inline'; frame-src *" />
+</head>
+
+<p>
+	<label>Use cache: <input id="cache-control" type="checkbox"></label>
+</p>
+
+<div id="editor">
+	<p>Hello @</p>
+
+	<figure class="image">
+		<img src="sample.jpg" />
+		<figcaption>CKEditor logo - caption</figcaption>
+	</figure>
+</div>
+
+<style>
+	.ck-mentions .custom.mention__item {
+		display: flex;
+		flex-direction: row;
+		width: 220px;
+	}
+
+	.mention__item .mention__item__thumbnail {
+		/* Prevent images re-draw */
+		width: 48px;
+		height: 48px;
+		border-radius: 100%;
+		border: 2px solid #fff;
+	}
+
+	.mention__item .mention__item__body {
+		flex: 1;
+		margin-left: 1em;
+		display: flex;
+		flex-direction: column;
+	}
+
+	.mention__item .mention__item__username {
+		float: left;
+		color: #666;
+	}
+
+	.mention__item.ck-on .mention__item__username {
+		color: #ddd;
+	}
+
+	.mention__item.ck-on .mention__item__full-name {
+		float: left;
+	}
+</style>

+ 92 - 0
packages/ckeditor5-mention/tests/manual/mention-asynchronous.js

@@ -0,0 +1,92 @@
+/**
+ * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/* global console, window, fetch, document */
+
+import global from '@ckeditor/ckeditor5-utils/src/dom/global';
+
+import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
+import Mention from '../../src/mention';
+import Underline from '@ckeditor/ckeditor5-basic-styles/src/underline';
+import ArticlePluginSet from '@ckeditor/ckeditor5-core/tests/_utils/articlepluginset';
+import Font from '@ckeditor/ckeditor5-font/src/font';
+
+ClassicEditor
+	.create( global.document.querySelector( '#editor' ), {
+		plugins: [ ArticlePluginSet, Underline, Font, Mention ],
+		toolbar: [
+			'heading',
+			'|', 'bulletedList', 'numberedList', 'blockQuote',
+			'|', 'bold', 'italic', 'underline', 'link',
+			'|', 'fontFamily', 'fontSize', 'fontColor', 'fontBackgroundColor',
+			'|', 'insertTable',
+			'|', 'undo', 'redo'
+		],
+		image: {
+			toolbar: [ 'imageStyle:full', 'imageStyle:side', '|', 'imageTextAlternative' ]
+		},
+		table: {
+			contentToolbar: [ 'tableColumn', 'tableRow', 'mergeTableCells' ],
+			tableToolbar: [ 'bold', 'italic' ]
+		},
+		mention: {
+			feeds: [
+				{
+					marker: '@',
+					feed: getFeed,
+					itemRenderer: ( { fullName, id, thumbnail } ) => {
+						const div = document.createElement( 'div' );
+
+						div.classList.add( 'custom' );
+						div.classList.add( 'mention__item' );
+
+						div.innerHTML =
+							`<img class="mention__item__thumbnail" src="${ thumbnail }">` +
+							'<div class="mention__item__body">' +
+								`<span class="mention__item__full-name">${ fullName }</span>` +
+								`<span class="mention__item__username">${ id }</span>` +
+							'</div>';
+
+						return div;
+					}
+				}
+			]
+		}
+	} )
+	.then( editor => {
+		window.editor = editor;
+	} )
+	.catch( err => {
+		console.error( err.stack );
+	} );
+
+// Simplest cache:
+const cache = new Map();
+
+function getFeed( text ) {
+	const useCache = document.querySelector( '#cache-control' ).checked;
+
+	if ( useCache && cache.has( text ) ) {
+		console.log( `Loading from cache for: "${ text }".` );
+
+		return cache.get( text );
+	}
+
+	const fetchOptions = {
+		method: 'get',
+		mode: 'cors'
+	};
+
+	return fetch( `http://localhost:3000?search=${ text }`, fetchOptions )
+		.then( response => {
+			const feedItems = response.json();
+
+			if ( useCache ) {
+				cache.set( text, feedItems );
+			}
+
+			return feedItems;
+		} );
+}

+ 31 - 0
packages/ckeditor5-mention/tests/manual/mention-asynchronous.md

@@ -0,0 +1,31 @@
+## Mention asynchronous feeds
+
+### Configuration
+
+The feed is asynchronous list that is loaded from server (`@` marker) after random delay:
+
+- 60% of requests completes in 150ms.
+- 40% of requests completes in 400ms, 1s, 2s or 4s.
+
+In order to run the server go to the `tests/_utils/asyncserver/` and run:
+
+```sh
+node index.js
+```  
+
+### Interaction
+
+Controlling the cache:
+
+- You can enable caching mechanism of the tests `getFeed()` callback - if checked it will save results and load them from cache for the same query.
+- If cache is disabled then no loading nor saving will be performed.
+
+### Behavior
+
+There should be no errors even if request took longer time or came out-of-order.
+
+If the asyncserver is not running the notification should be shown for failed requests. 
+
+### Disclaimer
+
+This manual tests uses data generated by [Random User Generator](https://randomuser.me/).

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

@@ -22,6 +22,10 @@ ClassicEditor
 			'|', 'insertTable',
 			'|', 'undo', 'redo'
 		],
+		table: {
+			contentToolbar: [ 'tableColumn', 'tableRow', 'mergeTableCells' ],
+			tableToolbar: [ 'bold', 'italic' ]
+		},
 		mention: {
 			feeds: [
 				{

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

@@ -68,6 +68,10 @@ ClassicEditor
 			'|', 'insertTable',
 			'|', 'undo', 'redo'
 		],
+		table: {
+			contentToolbar: [ 'tableColumn', 'tableRow', 'mergeTableCells' ],
+			tableToolbar: [ 'bold', 'italic' ]
+		},
 		mention: {
 			feeds: [
 				{

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

@@ -120,6 +120,10 @@ ClassicEditor
 		image: {
 			toolbar: [ 'imageStyle:full', 'imageStyle:side', '|', 'imageTextAlternative' ]
 		},
+		table: {
+			contentToolbar: [ 'tableColumn', 'tableRow', 'mergeTableCells' ],
+			tableToolbar: [ 'bold', 'italic' ]
+		},
 		mention: {
 			feeds: [
 				{

+ 359 - 12
packages/ckeditor5-mention/tests/mentionui.js

@@ -3,7 +3,7 @@
  * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
  */
 
-/* global window, document, setTimeout, Event */
+/* global window, document, setTimeout, Event, console */
 
 import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor';
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
@@ -323,6 +323,34 @@ describe( 'MentionUI', () => {
 				} );
 		} );
 
+		it( 'should update the marker if the selection was moved from one valid position to another', () => {
+			const spy = sinon.spy();
+
+			return createClassicTestEditor( staticConfig )
+				.then( () => {
+					setData( model, '<paragraph>foo @ bar []</paragraph>' );
+
+					model.change( writer => {
+						writer.insertText( '@', doc.selection.getFirstPosition() );
+					} );
+				} )
+				.then( waitForDebounce )
+				.then( () => {
+					expect( panelView.isVisible ).to.be.true;
+					expect( editor.model.markers.has( 'mention' ) ).to.be.true;
+				} )
+				.then( () => {
+					editor.model.markers.on( 'update', spy );
+
+					model.change( writer => {
+						writer.setSelection( doc.getRoot().getChild( 0 ), 5 );
+					} );
+
+					sinon.assert.calledOnce( spy );
+					expect( editor.model.markers.has( 'mention' ) ).to.be.true;
+				} );
+		} );
+
 		describe( 'static list with large set of results', () => {
 			const bigList = {
 				marker: '@',
@@ -884,20 +912,30 @@ describe( 'MentionUI', () => {
 		} );
 
 		describe( 'asynchronous list with custom trigger', () => {
+			const issuesNumbers = [ '#100', '#101', '#102', '#103' ];
+
+			let feedCallbackStub, feedCallbackTimeout, feedCallbackCallTimes;
+
 			beforeEach( () => {
-				const issuesNumbers = [ '#100', '#101', '#102', '#103' ];
+				feedCallbackTimeout = 20;
+				feedCallbackCallTimes = 0;
+
+				function feedCallback( feedText ) {
+					return new Promise( resolve => {
+						setTimeout( () => {
+							feedCallbackCallTimes++;
+							resolve( issuesNumbers.filter( number => number.includes( feedText ) ) );
+						}, feedCallbackTimeout );
+					} );
+				}
+
+				feedCallbackStub = testUtils.sinon.stub().callsFake( feedCallback );
 
 				return createClassicTestEditor( {
 					feeds: [
 						{
 							marker: '#',
-							feed: feedText => {
-								return new Promise( resolve => {
-									setTimeout( () => {
-										resolve( issuesNumbers.filter( number => number.includes( feedText ) ) );
-									}, 20 );
-								} );
-							}
+							feed: feedCallbackStub
 						}
 					]
 				} );
@@ -918,6 +956,33 @@ describe( 'MentionUI', () => {
 					} );
 			} );
 
+			it( 'should fire requestFeed:response when request feed return a response', () => {
+				setData( model, '<paragraph>foo []</paragraph>' );
+				const eventSpy = sinon.spy();
+				mentionUI.on( 'requestFeed:response', eventSpy );
+
+				model.change( writer => {
+					writer.insertText( '#', doc.selection.getFirstPosition() );
+				} );
+
+				return waitForDebounce()
+					.then( () => {
+						sinon.assert.calledOnce( eventSpy );
+						sinon.assert.calledWithExactly(
+							eventSpy,
+							sinon.match.any,
+							{
+								feed: issuesNumbers,
+								marker: '#',
+								feedText: ''
+							}
+						);
+						expect( panelView.isVisible ).to.be.true;
+						expect( editor.model.markers.has( 'mention' ) ).to.be.true;
+						expect( mentionsView.items ).to.have.length( 4 );
+					} );
+			} );
+
 			it( 'should show filtered results for matched text', () => {
 				setData( model, '<paragraph>foo []</paragraph>' );
 
@@ -979,6 +1044,284 @@ describe( 'MentionUI', () => {
 					.then( waitForDebounce )
 					.then( () => expect( panelView.isVisible ).to.be.false );
 			} );
+
+			it( 'should show panel debounced', () => {
+				setData( model, '<paragraph>foo []</paragraph>' );
+
+				model.change( writer => {
+					writer.insertText( '#', doc.selection.getFirstPosition() );
+				} );
+
+				sinon.assert.notCalled( feedCallbackStub );
+
+				return Promise.resolve()
+					.then( wait( 20 ) )
+					.then( () => {
+						sinon.assert.notCalled( feedCallbackStub );
+
+						model.change( writer => {
+							writer.insertText( '1', doc.selection.getFirstPosition() );
+						} );
+					} )
+					.then( wait( 20 ) )
+					.then( () => {
+						sinon.assert.notCalled( feedCallbackStub );
+
+						model.change( writer => {
+							writer.insertText( '0', doc.selection.getFirstPosition() );
+						} );
+					} )
+					.then( waitForDebounce )
+					.then( () => {
+						sinon.assert.calledOnce( feedCallbackStub );
+
+						// Should be called with all typed letters before debounce.
+						sinon.assert.calledWithExactly( feedCallbackStub, '10' );
+
+						expect( panelView.isVisible ).to.be.true;
+						expect( editor.model.markers.has( 'mention' ) ).to.be.true;
+						expect( mentionsView.items ).to.have.length( 4 );
+					} );
+			} );
+
+			it( 'should discard requested feed if they came out of order', () => {
+				setData( model, '<paragraph>foo []</paragraph>' );
+
+				model.change( writer => {
+					writer.insertText( '#', doc.selection.getFirstPosition() );
+				} );
+
+				sinon.assert.notCalled( feedCallbackStub );
+
+				const panelShowSpy = sinon.spy( panelView, 'show' );
+
+				// Increase the response time to extend the debounce time out.
+				feedCallbackTimeout = 300;
+
+				return Promise.resolve()
+					.then( wait( 20 ) )
+					.then( () => {
+						sinon.assert.notCalled( feedCallbackStub );
+
+						model.change( writer => {
+							writer.insertText( '1', doc.selection.getFirstPosition() );
+						} );
+					} )
+					.then( waitForDebounce )
+					.then( () => {
+						sinon.assert.calledOnce( feedCallbackStub );
+						sinon.assert.calledWithExactly( feedCallbackStub, '1' );
+
+						expect( panelView.isVisible, 'panel is hidden' ).to.be.false;
+						expect( editor.model.markers.has( 'mention' ), 'marker is inserted' ).to.be.true;
+
+						// Make second callback resolve before first.
+						feedCallbackTimeout = 50;
+
+						model.change( writer => {
+							writer.insertText( '0', doc.selection.getFirstPosition() );
+						} );
+					} )
+					.then( wait( 300 ) ) // Wait longer so the longer callback will be resolved.
+					.then( () => {
+						sinon.assert.calledTwice( feedCallbackStub );
+						sinon.assert.calledWithExactly( feedCallbackStub.getCall( 1 ), '10' );
+						sinon.assert.calledOnce( panelShowSpy );
+						expect( feedCallbackCallTimes ).to.equal( 2 );
+
+						expect( panelView.isVisible, 'panel is visible' ).to.be.true;
+						expect( editor.model.markers.has( 'mention' ), 'marker is inserted' ).to.be.true;
+						expect( mentionsView.items ).to.have.length( 4 );
+					} );
+			} );
+
+			it( 'should fire requestFeed:discarded event when requested feed came out of order', () => {
+				setData( model, '<paragraph>foo []</paragraph>' );
+
+				model.change( writer => {
+					writer.insertText( '#', doc.selection.getFirstPosition() );
+				} );
+
+				sinon.assert.notCalled( feedCallbackStub );
+
+				const panelShowSpy = sinon.spy( panelView, 'show' );
+				const eventSpy = sinon.spy();
+				mentionUI.on( 'requestFeed:discarded', eventSpy );
+
+				// Increase the response time to extend the debounce time out.
+				feedCallbackTimeout = 300;
+
+				return Promise.resolve()
+					.then( wait( 20 ) )
+					.then( () => {
+						sinon.assert.notCalled( feedCallbackStub );
+
+						model.change( writer => {
+							writer.insertText( '1', doc.selection.getFirstPosition() );
+						} );
+					} )
+					.then( waitForDebounce )
+					.then( () => {
+						sinon.assert.calledOnce( feedCallbackStub );
+						sinon.assert.calledWithExactly( feedCallbackStub, '1' );
+
+						expect( panelView.isVisible, 'panel is hidden' ).to.be.false;
+						expect( editor.model.markers.has( 'mention' ), 'marker is inserted' ).to.be.true;
+
+						// Make second callback resolve before first.
+						feedCallbackTimeout = 50;
+
+						model.change( writer => {
+							writer.insertText( '0', doc.selection.getFirstPosition() );
+						} );
+					} )
+					.then( wait( 300 ) ) // Wait longer so the longer callback will be resolved.
+					.then( () => {
+						sinon.assert.calledTwice( feedCallbackStub );
+						sinon.assert.calledWithExactly( feedCallbackStub.getCall( 1 ), '10' );
+						sinon.assert.calledOnce( panelShowSpy );
+						sinon.assert.calledOnce( eventSpy );
+						sinon.assert.calledWithExactly(
+							eventSpy,
+							sinon.match.any,
+							{
+								feed: issuesNumbers,
+								marker: '#',
+								feedText: '1'
+							}
+						);
+						expect( feedCallbackCallTimes ).to.equal( 2 );
+
+						expect( panelView.isVisible, 'panel is visible' ).to.be.true;
+						expect( editor.model.markers.has( 'mention' ), 'marker is inserted' ).to.be.true;
+						expect( mentionsView.items ).to.have.length( 4 );
+					} );
+			} );
+
+			it( 'should discard requested feed if mention UI is hidden', () => {
+				setData( model, '<paragraph>foo []</paragraph>' );
+
+				model.change( writer => {
+					writer.insertText( '#', doc.selection.getFirstPosition() );
+				} );
+
+				sinon.assert.notCalled( feedCallbackStub );
+
+				feedCallbackTimeout = 200;
+
+				return Promise.resolve()
+					.then( waitForDebounce )
+					.then( () => {
+						expect( panelView.isVisible ).to.be.false; // Should be still hidden;
+						// Should be called with empty string.
+						sinon.assert.calledWithExactly( feedCallbackStub, '' );
+
+						model.change( writer => {
+							writer.setSelection( doc.getRoot().getChild( 0 ), 0 );
+						} );
+					} )
+					.then( waitForDebounce )
+					.then( () => {
+						expect( panelView.isVisible ).to.be.false;
+						expect( editor.model.markers.has( 'mention' ) ).to.be.false;
+					} );
+			} );
+
+			it( 'should fire requestFeed:error and log warning if requested feed failed', () => {
+				setData( model, '<paragraph>foo []</paragraph>' );
+
+				feedCallbackStub.returns( Promise.reject( 'Request timeout' ) );
+
+				const warnSpy = sinon.spy( console, 'warn' );
+				const eventSpy = sinon.spy();
+				mentionUI.on( 'requestFeed:error', eventSpy );
+
+				model.change( writer => {
+					writer.insertText( '#', doc.selection.getFirstPosition() );
+				} );
+
+				return waitForDebounce()
+					.then( () => {
+						expect( panelView.isVisible, 'panel is hidden' ).to.be.false;
+						expect( editor.model.markers.has( 'mention' ), 'there is no marker' ).to.be.false;
+
+						sinon.assert.calledWithExactly( warnSpy, sinon.match( /^mention-feed-callback-error:/ ) );
+						sinon.assert.calledOnce( eventSpy );
+					} );
+			} );
+
+			it( 'should not fail if marker was removed', () => {
+				setData( model, '<paragraph>foo []</paragraph>' );
+				const selectFirstMentionSpy = sinon.spy( mentionsView, 'selectFirst' );
+
+				model.change( writer => {
+					writer.insertText( '#', doc.selection.getFirstPosition() );
+				} );
+
+				sinon.assert.notCalled( feedCallbackStub );
+
+				// Increase the response time to extend the debounce time out.
+				feedCallbackTimeout = 500;
+
+				return Promise.resolve()
+					.then( waitForDebounce )
+					.then( wait( 20 ) )
+					.then( () => {
+						model.change( writer => {
+							writer.setSelection( doc.getRoot().getChild( 0 ), 2 );
+						} );
+					} )
+					.then( wait( 20 ) )
+					.then( () => {
+						feedCallbackTimeout = 1000;
+						model.change( writer => {
+							writer.setSelection( doc.getRoot().getChild( 0 ), 'end' );
+						} );
+					} )
+					.then( wait( 500 ) )
+					.then( () => {
+						expect( panelView.isVisible, 'panel is visible' ).to.be.true;
+						// If there were any errors this will not get called.
+						// The errors might come from unhandled promise rejections errors.
+						sinon.assert.calledOnce( selectFirstMentionSpy );
+					} );
+			} );
+
+			it( 'should not show panel if selection was moved during fetching a feed', () => {
+				setData( model, '<paragraph>foo [#101] bar</paragraph><paragraph></paragraph>' );
+
+				model.change( writer => {
+					writer.setAttribute( 'mention', { id: '#101', _uid: 1234 }, doc.selection.getFirstRange() );
+				} );
+
+				// Increase the response time to extend the debounce time out.
+				feedCallbackTimeout = 300;
+
+				model.change( writer => {
+					writer.setSelection( doc.getRoot().getChild( 1 ), 0 );
+					writer.insertText( '#', doc.selection.getFirstPosition() );
+				} );
+
+				sinon.assert.notCalled( feedCallbackStub );
+
+				return Promise.resolve()
+					.then( waitForDebounce )
+					.then( () => {
+						sinon.assert.calledOnce( feedCallbackStub );
+
+						model.change( writer => {
+							writer.setSelection( doc.getRoot().getChild( 0 ), 6 );
+						} );
+
+						expect( panelView.isVisible ).to.be.false;
+					} )
+					.then( waitForDebounce )
+					.then( wait( 20 ) )
+					.then( () => {
+						expect( panelView.isVisible ).to.be.false;
+						expect( editor.model.markers.has( 'mention' ) ).to.be.false;
+					} );
+			} );
 		} );
 
 		function testOpeningPunctuationCharacter( character, skip = false ) {
@@ -1771,14 +2114,18 @@ describe( 'MentionUI', () => {
 			} );
 	}
 
-	function waitForDebounce() {
-		return new Promise( resolve => {
+	function wait( timeout ) {
+		return () => new Promise( resolve => {
 			setTimeout( () => {
 				resolve();
-			}, 50 );
+			}, timeout );
 		} );
 	}
 
+	function waitForDebounce() {
+		return wait( 180 )();
+	}
+
 	function fireKeyDownEvent( options ) {
 		const eventInfo = new EventInfo( editingView.document, 'keydown' );
 		const eventData = new DomEventData( editingView.document, {

Некоторые файлы не были показаны из-за большого количества измененных файлов