mentionui.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638
  1. /**
  2. * @license Copyright (c) 2003-2019, 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 Rect from '@ckeditor/ckeditor5-utils/src/dom/rect';
  14. import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
  15. import ContextualBalloon from '@ckeditor/ckeditor5-ui/src/panel/balloon/contextualballoon';
  16. import TextWatcher from './textwatcher';
  17. import MentionsView from './ui/mentionsview';
  18. import DomWrapperView from './ui/domwrapperview';
  19. import MentionListItemView from './ui/mentionlistitemview';
  20. const VERTICAL_SPACING = 3;
  21. /**
  22. * The mention UI feature.
  23. *
  24. * @extends module:core/plugin~Plugin
  25. */
  26. export default class MentionUI extends Plugin {
  27. /**
  28. * @inheritDoc
  29. */
  30. static get pluginName() {
  31. return 'MentionUI';
  32. }
  33. /**
  34. * @inheritDoc
  35. */
  36. constructor( editor ) {
  37. super( editor );
  38. /**
  39. * The mention view.
  40. *
  41. * @type {module:mention/ui/mentionsview~MentionsView}
  42. * @private
  43. */
  44. this._mentionsView = this._createMentionView();
  45. /**
  46. * Stores mention feeds configurations.
  47. *
  48. * @type {Map<String, Object>}
  49. * @private
  50. */
  51. this._mentionsConfigurations = new Map();
  52. editor.config.define( 'mention', { feeds: [] } );
  53. }
  54. /**
  55. * @inheritDoc
  56. */
  57. init() {
  58. const editor = this.editor;
  59. /**
  60. * The contextual balloon plugin instance.
  61. *
  62. * @private
  63. * @member {module:ui/panel/balloon/contextualballoon~ContextualBalloon}
  64. */
  65. this._balloon = editor.plugins.get( ContextualBalloon );
  66. // Key listener that handles navigation in mention view.
  67. editor.editing.view.document.on( 'keydown', ( evt, data ) => {
  68. if ( isHandledKey( data.keyCode ) && this._isUIVisible ) {
  69. data.preventDefault();
  70. evt.stop(); // Required for Enter key overriding.
  71. if ( data.keyCode == keyCodes.arrowdown ) {
  72. this._mentionsView.selectNext();
  73. }
  74. if ( data.keyCode == keyCodes.arrowup ) {
  75. this._mentionsView.selectPrevious();
  76. }
  77. if ( data.keyCode == keyCodes.enter || data.keyCode == keyCodes.tab || data.keyCode == keyCodes.space ) {
  78. this._mentionsView.executeSelected();
  79. }
  80. if ( data.keyCode == keyCodes.esc ) {
  81. this._hideUIAndRemoveMarker();
  82. }
  83. }
  84. }, { priority: 'highest' } ); // Required to override the Enter key.
  85. // Close the dropdown upon clicking outside of the plugin UI.
  86. clickOutsideHandler( {
  87. emitter: this._mentionsView,
  88. activator: () => this._isUIVisible,
  89. contextElements: [ this._balloon.view.element ],
  90. callback: () => this._hideUIAndRemoveMarker()
  91. } );
  92. const feeds = editor.config.get( 'mention.feeds' );
  93. for ( const mentionDescription of feeds ) {
  94. const feed = mentionDescription.feed;
  95. const marker = mentionDescription.marker;
  96. if ( !marker || marker.length != 1 ) {
  97. /**
  98. * The marker must be a single character.
  99. *
  100. * Correct markers: `'@'`, `'#'`.
  101. *
  102. * Incorrect markers: `'$$'`, `'[@'`.
  103. *
  104. * See {@link module:mention/mention~MentionConfig}.
  105. *
  106. * @error mentionconfig-incorrect-marker
  107. */
  108. throw new CKEditorError( 'mentionconfig-incorrect-marker: The marker must be provided and it must be a single character.' );
  109. }
  110. const minimumCharacters = mentionDescription.minimumCharacters || 0;
  111. const feedCallback = typeof feed == 'function' ? feed : createFeedCallback( feed );
  112. const watcher = this._setupTextWatcherForFeed( marker, minimumCharacters );
  113. const itemRenderer = mentionDescription.itemRenderer;
  114. const definition = { watcher, marker, feedCallback, itemRenderer };
  115. this._mentionsConfigurations.set( marker, definition );
  116. }
  117. }
  118. /**
  119. * @inheritDoc
  120. */
  121. destroy() {
  122. super.destroy();
  123. // Destroy created UI components as they are not automatically destroyed (see ckeditor5#1341).
  124. this._mentionsView.destroy();
  125. }
  126. /**
  127. * @inheritDoc
  128. */
  129. static get requires() {
  130. return [ ContextualBalloon ];
  131. }
  132. /**
  133. * Returns true when {@link #_mentionsView} is in the {@link module:ui/panel/balloon/contextualballoon~ContextualBalloon} and it is
  134. * currently visible.
  135. *
  136. * @readonly
  137. * @protected
  138. * @type {Boolean}
  139. */
  140. get _isUIVisible() {
  141. return this._balloon.visibleView === this._mentionsView;
  142. }
  143. /**
  144. * Creates the {@link #_mentionsView}.
  145. *
  146. * @private
  147. * @returns {module:mention/ui/mentionsview~MentionsView}
  148. */
  149. _createMentionView() {
  150. const locale = this.editor.locale;
  151. const mentionsView = new MentionsView( locale );
  152. this._items = new Collection();
  153. mentionsView.items.bindTo( this._items ).using( data => {
  154. const { item, marker } = data;
  155. const listItemView = new MentionListItemView( locale );
  156. const view = this._renderItem( item, marker );
  157. view.delegate( 'execute' ).to( listItemView );
  158. listItemView.children.add( view );
  159. listItemView.item = item;
  160. listItemView.marker = marker;
  161. listItemView.on( 'execute', () => {
  162. mentionsView.fire( 'execute', {
  163. item,
  164. marker
  165. } );
  166. } );
  167. return listItemView;
  168. } );
  169. mentionsView.on( 'execute', ( evt, data ) => {
  170. const editor = this.editor;
  171. const model = editor.model;
  172. const item = data.item;
  173. const marker = data.marker;
  174. const watcher = this._getWatcher( marker );
  175. const text = watcher.last;
  176. const textMatcher = createTextMatcher( marker );
  177. const matched = textMatcher( text );
  178. const matchedTextLength = matched.marker.length + matched.feedText.length;
  179. // Create a range on matched text.
  180. const end = model.createPositionAt( model.document.selection.focus );
  181. const start = end.getShiftedBy( -matchedTextLength );
  182. const range = model.createRange( start, end );
  183. this._hideUIAndRemoveMarker();
  184. editor.execute( 'mention', {
  185. mention: item,
  186. text: item.text,
  187. marker,
  188. range
  189. } );
  190. editor.editing.view.focus();
  191. } );
  192. return mentionsView;
  193. }
  194. /**
  195. * Returns item renderer for the marker.
  196. *
  197. * @private
  198. * @param {String} marker
  199. * @returns {Function|null}
  200. */
  201. _getItemRenderer( marker ) {
  202. const { itemRenderer } = this._mentionsConfigurations.get( marker );
  203. return itemRenderer;
  204. }
  205. /**
  206. * Returns a promise that resolves with autocomplete items for a given text.
  207. *
  208. * @param {String} marker
  209. * @param {String} feedText
  210. * @return {Promise<module:mention/mention~MentionFeedItem>}
  211. * @private
  212. */
  213. _getFeed( marker, feedText ) {
  214. const { feedCallback } = this._mentionsConfigurations.get( marker );
  215. this._lastRequested = feedText;
  216. return new Promise( ( resolve, reject ) => {
  217. const response = feedCallback( feedText );
  218. const isAsynchronous = response instanceof Promise;
  219. if ( !isAsynchronous ) {
  220. resolve( response );
  221. }
  222. return response.then( response => {
  223. if ( this._lastRequested == feedText ) {
  224. resolve( response );
  225. } else {
  226. reject( `Feed discarded: ${ feedText }` );
  227. }
  228. } );
  229. } );
  230. }
  231. /**
  232. * Registers a text watcher for the marker.
  233. *
  234. * @private
  235. * @param {String} marker
  236. * @param {Number} minimumCharacters
  237. * @returns {module:mention/textwatcher~TextWatcher}
  238. */
  239. _setupTextWatcherForFeed( marker, minimumCharacters ) {
  240. const editor = this.editor;
  241. const watcher = new TextWatcher( editor, createTestCallback( marker, minimumCharacters ), createTextMatcher( marker ) );
  242. watcher.on( 'matched', ( evt, data ) => {
  243. const matched = data.matched;
  244. const selection = editor.model.document.selection;
  245. const focus = selection.focus;
  246. // The text watcher listens only to changed range in selection - so the selection attributes are not yet available
  247. // and you cannot use selection.hasAttribute( 'mention' ) just yet.
  248. // See https://github.com/ckeditor/ckeditor5-engine/issues/1723.
  249. const hasMention = focus.textNode && focus.textNode.hasAttribute( 'mention' );
  250. const nodeBefore = focus.nodeBefore;
  251. if ( hasMention || nodeBefore && nodeBefore.is( 'text' ) && nodeBefore.hasAttribute( 'mention' ) ) {
  252. return;
  253. }
  254. const { feedText, marker } = matched;
  255. const matchedTextLength = marker.length + feedText.length;
  256. // Create a marker range.
  257. const start = focus.getShiftedBy( -matchedTextLength );
  258. const end = focus.getShiftedBy( -feedText.length );
  259. const markerRange = editor.model.createRange( start, end );
  260. let mentionMarker;
  261. if ( editor.model.markers.has( 'mention' ) ) {
  262. mentionMarker = editor.model.markers.get( 'mention' );
  263. } else {
  264. mentionMarker = editor.model.change( writer => writer.addMarker( 'mention', {
  265. range: markerRange,
  266. usingOperation: false,
  267. affectsData: false
  268. } ) );
  269. }
  270. this._getFeed( marker, feedText )
  271. .catch( () => {
  272. // Discard error.
  273. } )
  274. .then( feed => {
  275. if ( !feed ) {
  276. return;
  277. }
  278. if ( !this.editor.model.markers.has( 'mention' ) ) {
  279. // already hidden
  280. return;
  281. }
  282. this._items.clear();
  283. for ( const feedItem of feed ) {
  284. const item = typeof feedItem != 'object' ? { id: feedItem, text: feedItem } : feedItem;
  285. this._items.add( { item, marker } );
  286. }
  287. if ( this._items.length ) {
  288. this._showUI( mentionMarker );
  289. } else {
  290. this._hideUIAndRemoveMarker();
  291. }
  292. } );
  293. } );
  294. watcher.on( 'unmatched', () => {
  295. this._hideUIAndRemoveMarker();
  296. } );
  297. return watcher;
  298. }
  299. /**
  300. * Returns the registered text watcher for the marker.
  301. *
  302. * @private
  303. * @param {String} marker
  304. * @returns {module:mention/textwatcher~TextWatcher}
  305. */
  306. _getWatcher( marker ) {
  307. const { watcher } = this._mentionsConfigurations.get( marker );
  308. return watcher;
  309. }
  310. /**
  311. * Shows the mentions balloon. If the panel is already visible, it will reposition it.
  312. *
  313. * @private
  314. */
  315. _showUI( markerMarker ) {
  316. if ( this._isUIVisible ) {
  317. // Update balloon position as the mention list view may change its size.
  318. this._balloon.updatePosition( this._getBalloonPanelPositionData( markerMarker, this._mentionsView.position ) );
  319. } else {
  320. this._balloon.add( {
  321. view: this._mentionsView,
  322. position: this._getBalloonPanelPositionData( markerMarker, this._mentionsView.position ),
  323. withArrow: false
  324. } );
  325. }
  326. this._mentionsView.position = this._balloon.view.position;
  327. this._mentionsView.selectFirst();
  328. }
  329. /**
  330. * Hides the mentions balloon and removes the 'mention' marker from the markers collection.
  331. *
  332. * @private
  333. */
  334. _hideUIAndRemoveMarker() {
  335. if ( this.editor.model.markers.has( 'mention' ) ) {
  336. this.editor.model.change( writer => writer.removeMarker( 'mention' ) );
  337. }
  338. if ( this._isUIVisible ) {
  339. this._balloon.remove( this._mentionsView );
  340. }
  341. // Make the last matched position on panel view undefined so the #_getBalloonPanelPositionData() method will return all positions
  342. // on the next call.
  343. this._mentionsView.position = undefined;
  344. }
  345. /**
  346. * Renders a single item in the autocomplete list.
  347. *
  348. * @private
  349. * @param {module:mention/mention~MentionFeedItem} item
  350. * @param {String} marker
  351. * @returns {module:ui/button/buttonview~ButtonView|module:mention/ui/domwrapperview~DomWrapperView}
  352. */
  353. _renderItem( item, marker ) {
  354. const editor = this.editor;
  355. let view;
  356. let label = item.id;
  357. const renderer = this._getItemRenderer( marker );
  358. if ( renderer ) {
  359. const renderResult = renderer( item );
  360. if ( typeof renderResult != 'string' ) {
  361. view = new DomWrapperView( editor.locale, renderResult );
  362. } else {
  363. label = renderResult;
  364. }
  365. }
  366. if ( !view ) {
  367. const buttonView = new ButtonView( editor.locale );
  368. buttonView.label = label;
  369. buttonView.withText = true;
  370. view = buttonView;
  371. }
  372. return view;
  373. }
  374. /**
  375. * Creates a position options object used to position the balloon panel.
  376. *
  377. * @param {module:engine/model/markercollection~Marker} mentionMarker
  378. * @param {String|undefined} preferredPosition The name of the last matched position name.
  379. * @returns {module:utils/dom/position~Options}
  380. * @private
  381. */
  382. _getBalloonPanelPositionData( mentionMarker, preferredPosition ) {
  383. const editing = this.editor.editing;
  384. const domConverter = editing.view.domConverter;
  385. const mapper = editing.mapper;
  386. return {
  387. target: () => {
  388. const viewRange = mapper.toViewRange( mentionMarker.getRange() );
  389. const rangeRects = Rect.getDomRangeRects( domConverter.viewRangeToDom( viewRange ) );
  390. return rangeRects.pop();
  391. },
  392. limiter: () => {
  393. const view = this.editor.editing.view;
  394. const viewDocument = view.document;
  395. const editableElement = viewDocument.selection.editableElement;
  396. if ( editableElement ) {
  397. return view.domConverter.mapViewToDom( editableElement.root );
  398. }
  399. return null;
  400. },
  401. positions: getBalloonPanelPositions( preferredPosition )
  402. };
  403. }
  404. }
  405. // Returns the balloon positions data callbacks.
  406. //
  407. // @param {String} preferredPosition
  408. // @returns {Array.<module:utils/dom/position~Position>}
  409. function getBalloonPanelPositions( preferredPosition ) {
  410. const positions = {
  411. // Positions the panel to the southeast of the caret rectangle.
  412. 'caret_se': targetRect => {
  413. return {
  414. top: targetRect.bottom + VERTICAL_SPACING,
  415. left: targetRect.right,
  416. name: 'caret_se'
  417. };
  418. },
  419. // Positions the panel to the northeast of the caret rectangle.
  420. 'caret_ne': ( targetRect, balloonRect ) => {
  421. return {
  422. top: targetRect.top - balloonRect.height - VERTICAL_SPACING,
  423. left: targetRect.right,
  424. name: 'caret_ne'
  425. };
  426. },
  427. // Positions the panel to the southwest of the caret rectangle.
  428. 'caret_sw': ( targetRect, balloonRect ) => {
  429. return {
  430. top: targetRect.bottom + VERTICAL_SPACING,
  431. left: targetRect.right - balloonRect.width,
  432. name: 'caret_sw'
  433. };
  434. },
  435. // Positions the panel to the northwest of the caret rect.
  436. 'caret_nw': ( targetRect, balloonRect ) => {
  437. return {
  438. top: targetRect.top - balloonRect.height - VERTICAL_SPACING,
  439. left: targetRect.right - balloonRect.width,
  440. name: 'caret_nw'
  441. };
  442. }
  443. };
  444. // Returns only the last position if it was matched to prevent the panel from jumping after the first match.
  445. if ( positions.hasOwnProperty( preferredPosition ) ) {
  446. return [
  447. positions[ preferredPosition ]
  448. ];
  449. }
  450. // By default return all position callbacks.
  451. return [
  452. positions.caret_se,
  453. positions.caret_sw,
  454. positions.caret_ne,
  455. positions.caret_nw
  456. ];
  457. }
  458. // Creates a regex pattern for the marker.
  459. //
  460. // @param {String} marker
  461. // @param {Number} minimumCharacters
  462. // @returns {String}
  463. function createPattern( marker, minimumCharacters ) {
  464. const numberOfCharacters = minimumCharacters == 0 ? '*' : `{${ minimumCharacters },}`;
  465. return `(^| )(\\${ marker })([_a-zA-Z0-9À-ž]${ numberOfCharacters }?)$`;
  466. }
  467. // Creates a test callback for the marker to be used in the text watcher instance.
  468. //
  469. // @param {String} marker
  470. // @param {Number} minimumCharacters
  471. // @returns {Function}
  472. function createTestCallback( marker, minimumCharacters ) {
  473. const regExp = new RegExp( createPattern( marker, minimumCharacters ) );
  474. return text => regExp.test( text );
  475. }
  476. // Creates a text matcher from the marker.
  477. //
  478. // @param {String} marker
  479. // @returns {Function}
  480. function createTextMatcher( marker ) {
  481. const regExp = new RegExp( createPattern( marker, 0 ) );
  482. return text => {
  483. const match = text.match( regExp );
  484. const marker = match[ 2 ];
  485. const feedText = match[ 3 ];
  486. return { marker, feedText };
  487. };
  488. }
  489. // The default feed callback.
  490. function createFeedCallback( feedItems ) {
  491. return feedText => {
  492. const filteredItems = feedItems
  493. // Make the default mention feed case-insensitive.
  494. .filter( item => {
  495. // Item might be defined as object.
  496. const itemId = typeof item == 'string' ? item : String( item.id );
  497. // The default feed is case insensitive.
  498. return itemId.toLowerCase().includes( feedText.toLowerCase() );
  499. } )
  500. // Do not return more than 10 items.
  501. .slice( 0, 10 );
  502. return filteredItems;
  503. };
  504. }
  505. // Checks if a given key code is handled by the mention UI.
  506. //
  507. // @param {Number}
  508. // @returns {Boolean}
  509. function isHandledKey( keyCode ) {
  510. const handledKeyCodes = [
  511. keyCodes.arrowup,
  512. keyCodes.arrowdown,
  513. keyCodes.enter,
  514. keyCodes.tab,
  515. keyCodes.space,
  516. keyCodes.esc
  517. ];
  518. return handledKeyCodes.includes( keyCode );
  519. }