@ckeditor/ckeditor5-mention 30.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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
+ }