mentionui.js 21 KB

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