mentionui.js 16 KB

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