8
0

mentionui.js 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731
  1. /**
  2. * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
  3. * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
  4. */
  5. /**
  6. * @module mention/mentionui
  7. */
  8. import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
  9. import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
  10. import Collection from '@ckeditor/ckeditor5-utils/src/collection';
  11. import clickOutsideHandler from '@ckeditor/ckeditor5-ui/src/bindings/clickoutsidehandler';
  12. import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard';
  13. import env from '@ckeditor/ckeditor5-utils/src/env';
  14. import Rect from '@ckeditor/ckeditor5-utils/src/dom/rect';
  15. import CKEditorError, { logWarning } from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
  16. import ContextualBalloon from '@ckeditor/ckeditor5-ui/src/panel/balloon/contextualballoon';
  17. import { debounce } from 'lodash-es';
  18. import TextWatcher from '@ckeditor/ckeditor5-typing/src/textwatcher';
  19. import MentionsView from './ui/mentionsview';
  20. import DomWrapperView from './ui/domwrapperview';
  21. import MentionListItemView from './ui/mentionlistitemview';
  22. const VERTICAL_SPACING = 3;
  23. // The key codes that mention UI handles when it is open.
  24. const handledKeyCodes = [
  25. keyCodes.arrowup,
  26. keyCodes.arrowdown,
  27. keyCodes.enter,
  28. keyCodes.tab,
  29. keyCodes.esc
  30. ];
  31. /**
  32. * The mention UI feature.
  33. *
  34. * @extends module:core/plugin~Plugin
  35. */
  36. export default class MentionUI extends Plugin {
  37. /**
  38. * @inheritDoc
  39. */
  40. static get pluginName() {
  41. return 'MentionUI';
  42. }
  43. /**
  44. * @inheritDoc
  45. */
  46. static get requires() {
  47. return [ ContextualBalloon ];
  48. }
  49. /**
  50. * @inheritDoc
  51. */
  52. constructor( editor ) {
  53. super( editor );
  54. /**
  55. * The mention view.
  56. *
  57. * @type {module:mention/ui/mentionsview~MentionsView}
  58. * @private
  59. */
  60. this._mentionsView = this._createMentionView();
  61. /**
  62. * Stores mention feeds configurations.
  63. *
  64. * @type {Map<String, Object>}
  65. * @private
  66. */
  67. this._mentionsConfigurations = new Map();
  68. /**
  69. * Debounced feed requester. It uses `lodash#debounce` method to delay function call.
  70. *
  71. * @private
  72. * @param {String} marker
  73. * @param {String} feedText
  74. * @method
  75. */
  76. this._requestFeedDebounced = debounce( this._requestFeed, 100 );
  77. editor.config.define( 'mention', { feeds: [] } );
  78. }
  79. /**
  80. * @inheritDoc
  81. */
  82. init() {
  83. const editor = this.editor;
  84. /**
  85. * The contextual balloon plugin instance.
  86. *
  87. * @private
  88. * @member {module:ui/panel/balloon/contextualballoon~ContextualBalloon}
  89. */
  90. this._balloon = editor.plugins.get( ContextualBalloon );
  91. // Key listener that handles navigation in mention view.
  92. editor.editing.view.document.on( 'keydown', ( evt, data ) => {
  93. if ( isHandledKey( data.keyCode ) && this._isUIVisible ) {
  94. data.preventDefault();
  95. evt.stop(); // Required for Enter key overriding.
  96. if ( data.keyCode == keyCodes.arrowdown ) {
  97. this._mentionsView.selectNext();
  98. }
  99. if ( data.keyCode == keyCodes.arrowup ) {
  100. this._mentionsView.selectPrevious();
  101. }
  102. if ( data.keyCode == keyCodes.enter || data.keyCode == keyCodes.tab ) {
  103. this._mentionsView.executeSelected();
  104. }
  105. if ( data.keyCode == keyCodes.esc ) {
  106. this._hideUIAndRemoveMarker();
  107. }
  108. }
  109. }, { priority: 'highest' } ); // Required to override the Enter key.
  110. // Close the dropdown upon clicking outside of the plugin UI.
  111. clickOutsideHandler( {
  112. emitter: this._mentionsView,
  113. activator: () => this._isUIVisible,
  114. contextElements: [ this._balloon.view.element ],
  115. callback: () => this._hideUIAndRemoveMarker()
  116. } );
  117. const feeds = editor.config.get( 'mention.feeds' );
  118. for ( const mentionDescription of feeds ) {
  119. const feed = mentionDescription.feed;
  120. const marker = mentionDescription.marker;
  121. if ( !isValidMentionMarker( marker ) ) {
  122. /**
  123. * The marker must be a single character.
  124. *
  125. * Correct markers: `'@'`, `'#'`.
  126. *
  127. * Incorrect markers: `'$$'`, `'[@'`.
  128. *
  129. * See {@link module:mention/mention~MentionConfig}.
  130. *
  131. * @error mentionconfig-incorrect-marker
  132. * @param {String} marker Configured marker
  133. */
  134. throw new CKEditorError( 'mentionconfig-incorrect-marker', null, { marker } );
  135. }
  136. const minimumCharacters = mentionDescription.minimumCharacters || 0;
  137. const feedCallback = typeof feed == 'function' ? feed.bind( this.editor ) : createFeedCallback( feed );
  138. const watcher = this._setupTextWatcherForFeed( marker, minimumCharacters );
  139. const itemRenderer = mentionDescription.itemRenderer;
  140. const definition = { watcher, marker, feedCallback, itemRenderer };
  141. this._mentionsConfigurations.set( marker, definition );
  142. }
  143. this.on( 'requestFeed:response', ( evt, data ) => this._handleFeedResponse( data ) );
  144. this.on( 'requestFeed:error', () => this._hideUIAndRemoveMarker() );
  145. }
  146. /**
  147. * @inheritDoc
  148. */
  149. destroy() {
  150. super.destroy();
  151. // Destroy created UI components as they are not automatically destroyed (see ckeditor5#1341).
  152. this._mentionsView.destroy();
  153. }
  154. /**
  155. * Returns true when {@link #_mentionsView} is in the {@link module:ui/panel/balloon/contextualballoon~ContextualBalloon} and it is
  156. * currently visible.
  157. *
  158. * @readonly
  159. * @protected
  160. * @type {Boolean}
  161. */
  162. get _isUIVisible() {
  163. return this._balloon.visibleView === this._mentionsView;
  164. }
  165. /**
  166. * Creates the {@link #_mentionsView}.
  167. *
  168. * @private
  169. * @returns {module:mention/ui/mentionsview~MentionsView}
  170. */
  171. _createMentionView() {
  172. const locale = this.editor.locale;
  173. const mentionsView = new MentionsView( locale );
  174. this._items = new Collection();
  175. mentionsView.items.bindTo( this._items ).using( data => {
  176. const { item, marker } = data;
  177. const listItemView = new MentionListItemView( locale );
  178. const view = this._renderItem( item, marker );
  179. view.delegate( 'execute' ).to( listItemView );
  180. listItemView.children.add( view );
  181. listItemView.item = item;
  182. listItemView.marker = marker;
  183. listItemView.on( 'execute', () => {
  184. mentionsView.fire( 'execute', {
  185. item,
  186. marker
  187. } );
  188. } );
  189. return listItemView;
  190. } );
  191. mentionsView.on( 'execute', ( evt, data ) => {
  192. const editor = this.editor;
  193. const model = editor.model;
  194. const item = data.item;
  195. const marker = data.marker;
  196. const mentionMarker = editor.model.markers.get( 'mention' );
  197. // Create a range on matched text.
  198. const end = model.createPositionAt( model.document.selection.focus );
  199. const start = model.createPositionAt( mentionMarker.getStart() );
  200. const range = model.createRange( start, end );
  201. this._hideUIAndRemoveMarker();
  202. editor.execute( 'mention', {
  203. mention: item,
  204. text: item.text,
  205. marker,
  206. range
  207. } );
  208. editor.editing.view.focus();
  209. } );
  210. return mentionsView;
  211. }
  212. /**
  213. * Returns item renderer for the marker.
  214. *
  215. * @private
  216. * @param {String} marker
  217. * @returns {Function|null}
  218. */
  219. _getItemRenderer( marker ) {
  220. const { itemRenderer } = this._mentionsConfigurations.get( marker );
  221. return itemRenderer;
  222. }
  223. /**
  224. * Requests a feed from a configured callbacks.
  225. *
  226. * @private
  227. * @fires module:mention/mentionui~MentionUI#event:requestFeed:response
  228. * @fires module:mention/mentionui~MentionUI#event:requestFeed:discarded
  229. * @fires module:mention/mentionui~MentionUI#event:requestFeed:error
  230. * @param {String} marker
  231. * @param {String} feedText
  232. */
  233. _requestFeed( marker, feedText ) {
  234. // Store the last requested feed - it is used to discard any out-of order requests.
  235. this._lastRequested = feedText;
  236. const { feedCallback } = this._mentionsConfigurations.get( marker );
  237. const feedResponse = feedCallback( feedText );
  238. const isAsynchronous = feedResponse instanceof Promise;
  239. // For synchronous feeds (e.g. callbacks, arrays) fire the response event immediately.
  240. if ( !isAsynchronous ) {
  241. /**
  242. * Fired whenever requested feed has a response.
  243. *
  244. * @event requestFeed:response
  245. * @param {Object} data Event data.
  246. * @param {Array.<module:mention/mention~MentionFeedItem>} data.feed Autocomplete items.
  247. * @param {String} data.marker The character which triggers autocompletion for mention.
  248. * @param {String} data.feedText The text for which feed items were requested.
  249. */
  250. this.fire( 'requestFeed:response', { feed: feedResponse, marker, feedText } );
  251. return;
  252. }
  253. // Handle the asynchronous responses.
  254. feedResponse
  255. .then( response => {
  256. // Check the feed text of this response with the last requested one so either:
  257. if ( this._lastRequested == feedText ) {
  258. // It is the same and fire the response event.
  259. this.fire( 'requestFeed:response', { feed: response, marker, feedText } );
  260. } else {
  261. // It is different - most probably out-of-order one, so fire the discarded event.
  262. /**
  263. * Fired whenever the requested feed was discarded. This happens when the response was delayed and
  264. * other feed was already requested.
  265. *
  266. * @event requestFeed:discarded
  267. * @param {Object} data Event data.
  268. * @param {Array.<module:mention/mention~MentionFeedItem>} data.feed Autocomplete items.
  269. * @param {String} data.marker The character which triggers autocompletion for mention.
  270. * @param {String} data.feedText The text for which feed items were requested.
  271. */
  272. this.fire( 'requestFeed:discarded', { feed: response, marker, feedText } );
  273. }
  274. } )
  275. .catch( error => {
  276. /**
  277. * Fired whenever the requested {@link module:mention/mention~MentionFeed#feed} promise fails with error.
  278. *
  279. * @event requestFeed:error
  280. * @param {Object} data Event data.
  281. * @param {Error} data.error The error that was caught.
  282. */
  283. this.fire( 'requestFeed:error', { error } );
  284. /**
  285. * The callback used for obtaining mention autocomplete feed thrown and error and the mention UI was hidden or
  286. * not displayed at all.
  287. *
  288. * @error mention-feed-callback-error
  289. * @param {String} marker Configured marker for which error occurred.
  290. */
  291. logWarning( 'mention-feed-callback-error', { marker } );
  292. } );
  293. }
  294. /**
  295. * Registers a text watcher for the marker.
  296. *
  297. * @private
  298. * @param {String} marker
  299. * @param {Number} minimumCharacters
  300. * @returns {module:typing/textwatcher~TextWatcher}
  301. */
  302. _setupTextWatcherForFeed( marker, minimumCharacters ) {
  303. const editor = this.editor;
  304. const watcher = new TextWatcher( editor.model, createTestCallback( marker, minimumCharacters ) );
  305. watcher.on( 'matched', ( evt, data ) => {
  306. const selection = editor.model.document.selection;
  307. const focus = selection.focus;
  308. if ( hasExistingMention( focus ) ) {
  309. this._hideUIAndRemoveMarker();
  310. return;
  311. }
  312. const feedText = requestFeedText( marker, data.text );
  313. const matchedTextLength = marker.length + feedText.length;
  314. // Create a marker range.
  315. const start = focus.getShiftedBy( -matchedTextLength );
  316. const end = focus.getShiftedBy( -feedText.length );
  317. const markerRange = editor.model.createRange( start, end );
  318. if ( checkIfStillInCompletionMode( editor ) ) {
  319. const mentionMarker = editor.model.markers.get( 'mention' );
  320. // Update the marker - user might've moved the selection to other mention trigger.
  321. editor.model.change( writer => {
  322. writer.updateMarker( mentionMarker, { range: markerRange } );
  323. } );
  324. } else {
  325. editor.model.change( writer => {
  326. writer.addMarker( 'mention', { range: markerRange, usingOperation: false, affectsData: false } );
  327. } );
  328. }
  329. this._requestFeedDebounced( marker, feedText );
  330. } );
  331. watcher.on( 'unmatched', () => {
  332. this._hideUIAndRemoveMarker();
  333. } );
  334. const mentionCommand = editor.commands.get( 'mention' );
  335. watcher.bind( 'isEnabled' ).to( mentionCommand );
  336. return watcher;
  337. }
  338. /**
  339. * Handles the feed response event data.
  340. *
  341. * @param data
  342. * @private
  343. */
  344. _handleFeedResponse( data ) {
  345. const { feed, marker } = data;
  346. // If the marker is not in the document happens when the selection had changed and the 'mention' marker was removed.
  347. if ( !checkIfStillInCompletionMode( this.editor ) ) {
  348. return;
  349. }
  350. // Reset the view.
  351. this._items.clear();
  352. for ( const feedItem of feed ) {
  353. const item = typeof feedItem != 'object' ? { id: feedItem, text: feedItem } : feedItem;
  354. this._items.add( { item, marker } );
  355. }
  356. const mentionMarker = this.editor.model.markers.get( 'mention' );
  357. if ( this._items.length ) {
  358. this._showOrUpdateUI( mentionMarker );
  359. } else {
  360. // Do not show empty mention UI.
  361. this._hideUIAndRemoveMarker();
  362. }
  363. }
  364. /**
  365. * Shows the mentions balloon. If the panel is already visible, it will reposition it.
  366. *
  367. * @private
  368. */
  369. _showOrUpdateUI( markerMarker ) {
  370. if ( this._isUIVisible ) {
  371. // Update balloon position as the mention list view may change its size.
  372. this._balloon.updatePosition( this._getBalloonPanelPositionData( markerMarker, this._mentionsView.position ) );
  373. } else {
  374. this._balloon.add( {
  375. view: this._mentionsView,
  376. position: this._getBalloonPanelPositionData( markerMarker, this._mentionsView.position ),
  377. withArrow: false,
  378. singleViewMode: true
  379. } );
  380. }
  381. this._mentionsView.position = this._balloon.view.position;
  382. this._mentionsView.selectFirst();
  383. }
  384. /**
  385. * Hides the mentions balloon and removes the 'mention' marker from the markers collection.
  386. *
  387. * @private
  388. */
  389. _hideUIAndRemoveMarker() {
  390. // Remove the mention view from balloon before removing marker - it is used by balloon position target().
  391. if ( this._balloon.hasView( this._mentionsView ) ) {
  392. this._balloon.remove( this._mentionsView );
  393. }
  394. if ( checkIfStillInCompletionMode( this.editor ) ) {
  395. this.editor.model.change( writer => writer.removeMarker( 'mention' ) );
  396. }
  397. // Make the last matched position on panel view undefined so the #_getBalloonPanelPositionData() method will return all positions
  398. // on the next call.
  399. this._mentionsView.position = undefined;
  400. }
  401. /**
  402. * Renders a single item in the autocomplete list.
  403. *
  404. * @private
  405. * @param {module:mention/mention~MentionFeedItem} item
  406. * @param {String} marker
  407. * @returns {module:ui/button/buttonview~ButtonView|module:mention/ui/domwrapperview~DomWrapperView}
  408. */
  409. _renderItem( item, marker ) {
  410. const editor = this.editor;
  411. let view;
  412. let label = item.id;
  413. const renderer = this._getItemRenderer( marker );
  414. if ( renderer ) {
  415. const renderResult = renderer( item );
  416. if ( typeof renderResult != 'string' ) {
  417. view = new DomWrapperView( editor.locale, renderResult );
  418. } else {
  419. label = renderResult;
  420. }
  421. }
  422. if ( !view ) {
  423. const buttonView = new ButtonView( editor.locale );
  424. buttonView.label = label;
  425. buttonView.withText = true;
  426. view = buttonView;
  427. }
  428. return view;
  429. }
  430. /**
  431. * Creates a position options object used to position the balloon panel.
  432. *
  433. * @param {module:engine/model/markercollection~Marker} mentionMarker
  434. * @param {String|undefined} preferredPosition The name of the last matched position name.
  435. * @returns {module:utils/dom/position~Options}
  436. * @private
  437. */
  438. _getBalloonPanelPositionData( mentionMarker, preferredPosition ) {
  439. const editor = this.editor;
  440. const editing = editor.editing;
  441. const domConverter = editing.view.domConverter;
  442. const mapper = editing.mapper;
  443. return {
  444. target: () => {
  445. let modelRange = mentionMarker.getRange();
  446. // Target the UI to the model selection range - the marker has been removed so probably the UI will not be shown anyway.
  447. // The logic is used by ContextualBalloon to display another panel in the same place.
  448. if ( modelRange.start.root.rootName == '$graveyard' ) {
  449. modelRange = editor.model.document.selection.getFirstRange();
  450. }
  451. const viewRange = mapper.toViewRange( modelRange );
  452. const rangeRects = Rect.getDomRangeRects( domConverter.viewRangeToDom( viewRange ) );
  453. return rangeRects.pop();
  454. },
  455. limiter: () => {
  456. const view = this.editor.editing.view;
  457. const viewDocument = view.document;
  458. const editableElement = viewDocument.selection.editableElement;
  459. if ( editableElement ) {
  460. return view.domConverter.mapViewToDom( editableElement.root );
  461. }
  462. return null;
  463. },
  464. positions: getBalloonPanelPositions( preferredPosition )
  465. };
  466. }
  467. }
  468. // Returns the balloon positions data callbacks.
  469. //
  470. // @param {String} preferredPosition
  471. // @returns {Array.<module:utils/dom/position~Position>}
  472. function getBalloonPanelPositions( preferredPosition ) {
  473. const positions = {
  474. // Positions the panel to the southeast of the caret rectangle.
  475. 'caret_se': targetRect => {
  476. return {
  477. top: targetRect.bottom + VERTICAL_SPACING,
  478. left: targetRect.right,
  479. name: 'caret_se'
  480. };
  481. },
  482. // Positions the panel to the northeast of the caret rectangle.
  483. 'caret_ne': ( targetRect, balloonRect ) => {
  484. return {
  485. top: targetRect.top - balloonRect.height - VERTICAL_SPACING,
  486. left: targetRect.right,
  487. name: 'caret_ne'
  488. };
  489. },
  490. // Positions the panel to the southwest of the caret rectangle.
  491. 'caret_sw': ( targetRect, balloonRect ) => {
  492. return {
  493. top: targetRect.bottom + VERTICAL_SPACING,
  494. left: targetRect.right - balloonRect.width,
  495. name: 'caret_sw'
  496. };
  497. },
  498. // Positions the panel to the northwest of the caret rect.
  499. 'caret_nw': ( targetRect, balloonRect ) => {
  500. return {
  501. top: targetRect.top - balloonRect.height - VERTICAL_SPACING,
  502. left: targetRect.right - balloonRect.width,
  503. name: 'caret_nw'
  504. };
  505. }
  506. };
  507. // Returns only the last position if it was matched to prevent the panel from jumping after the first match.
  508. if ( Object.prototype.hasOwnProperty.call( positions, preferredPosition ) ) {
  509. return [
  510. positions[ preferredPosition ]
  511. ];
  512. }
  513. // By default return all position callbacks.
  514. return [
  515. positions.caret_se,
  516. positions.caret_sw,
  517. positions.caret_ne,
  518. positions.caret_nw
  519. ];
  520. }
  521. // Creates a RegExp pattern for the marker.
  522. //
  523. // Function has to be exported to achieve 100% code coverage.
  524. //
  525. // @param {String} marker
  526. // @param {Number} minimumCharacters
  527. // @returns {RegExp}
  528. export function createRegExp( marker, minimumCharacters ) {
  529. const numberOfCharacters = minimumCharacters == 0 ? '*' : `{${ minimumCharacters },}`;
  530. const openAfterCharacters = env.features.isRegExpUnicodePropertySupported ? '\\p{Ps}\\p{Pi}"\'' : '\\(\\[{"\'';
  531. const mentionCharacters = '\\S';
  532. // The pattern consists of 3 groups:
  533. // - 0 (non-capturing): Opening sequence - start of the line, space or an opening punctuation character like "(" or "\"",
  534. // - 1: The marker character,
  535. // - 2: Mention input (taking the minimal length into consideration to trigger the UI),
  536. //
  537. // The pattern matches up to the caret (end of string switch - $).
  538. // (0: opening sequence )(1: marker )(2: typed mention )$
  539. const pattern = `(?:^|[ ${ openAfterCharacters }])([${ marker }])([${ mentionCharacters }]${ numberOfCharacters })$`;
  540. return new RegExp( pattern, 'u' );
  541. }
  542. // Creates a test callback for the marker to be used in the text watcher instance.
  543. //
  544. // @param {String} marker
  545. // @param {Number} minimumCharacters
  546. // @returns {Function}
  547. function createTestCallback( marker, minimumCharacters ) {
  548. const regExp = createRegExp( marker, minimumCharacters );
  549. return text => regExp.test( text );
  550. }
  551. // Creates a text matcher from the marker.
  552. //
  553. // @param {String} marker
  554. // @returns {Function}
  555. function requestFeedText( marker, text ) {
  556. const regExp = createRegExp( marker, 0 );
  557. const match = text.match( regExp );
  558. return match[ 2 ];
  559. }
  560. // The default feed callback.
  561. function createFeedCallback( feedItems ) {
  562. return feedText => {
  563. const filteredItems = feedItems
  564. // Make the default mention feed case-insensitive.
  565. .filter( item => {
  566. // Item might be defined as object.
  567. const itemId = typeof item == 'string' ? item : String( item.id );
  568. // The default feed is case insensitive.
  569. return itemId.toLowerCase().includes( feedText.toLowerCase() );
  570. } )
  571. // Do not return more than 10 items.
  572. .slice( 0, 10 );
  573. return filteredItems;
  574. };
  575. }
  576. // Checks if a given key code is handled by the mention UI.
  577. //
  578. // @param {Number}
  579. // @returns {Boolean}
  580. function isHandledKey( keyCode ) {
  581. return handledKeyCodes.includes( keyCode );
  582. }
  583. // Checks if position in inside or right after a text with a mention.
  584. //
  585. // @param {module:engine/model/position~Position} position.
  586. // @returns {Boolean}
  587. function hasExistingMention( position ) {
  588. // The text watcher listens only to changed range in selection - so the selection attributes are not yet available
  589. // and you cannot use selection.hasAttribute( 'mention' ) just yet.
  590. // See https://github.com/ckeditor/ckeditor5-engine/issues/1723.
  591. const hasMention = position.textNode && position.textNode.hasAttribute( 'mention' );
  592. const nodeBefore = position.nodeBefore;
  593. return hasMention || nodeBefore && nodeBefore.is( '$text' ) && nodeBefore.hasAttribute( 'mention' );
  594. }
  595. // Checks if string is a valid mention marker.
  596. //
  597. // @param {String} marker
  598. // @returns {Boolean}
  599. function isValidMentionMarker( marker ) {
  600. return marker && marker.length == 1;
  601. }
  602. // Checks the mention plugins is in completion mode (e.g. when typing is after a valid mention string like @foo).
  603. //
  604. // @returns {Boolean}
  605. function checkIfStillInCompletionMode( editor ) {
  606. return editor.model.markers.has( 'mention' );
  607. }