@ckeditor/ckeditor5-mention 39.0.2 → 40.0.0

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