@ckeditor/ckeditor5-mention 30.0.0

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