@ckeditor/ckeditor5-mention 41.2.0 → 41.3.0-alpha.0

Sign up to get free protection for your applications and to get access to all the features.
package/dist/index.js ADDED
@@ -0,0 +1,1223 @@
1
+ /**
2
+ * @license Copyright (c) 2003-2024, 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
+ import { Command, Plugin } from '@ckeditor/ckeditor5-core/dist/index.js';
6
+ import { CKEditorError, toMap, uid, Rect, keyCodes, Collection, logWarning, env } from '@ckeditor/ckeditor5-utils/dist/index.js';
7
+ import { ListView, View, ListItemView, ContextualBalloon, clickOutsideHandler, ButtonView } from '@ckeditor/ckeditor5-ui/dist/index.js';
8
+ import { TextWatcher } from '@ckeditor/ckeditor5-typing/dist/index.js';
9
+ import { debounce } from 'lodash-es';
10
+
11
+ /**
12
+ * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved.
13
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
14
+ */
15
+ /**
16
+ * @module mention/mentioncommand
17
+ */
18
+ const BRACKET_PAIRS = {
19
+ '(': ')',
20
+ '[': ']',
21
+ '{': '}'
22
+ };
23
+ /**
24
+ * The mention command.
25
+ *
26
+ * The command is registered by {@link module:mention/mentionediting~MentionEditing} as `'mention'`.
27
+ *
28
+ * To insert a mention into a range, execute the command and specify a mention object with a range to replace:
29
+ *
30
+ * ```ts
31
+ * const focus = editor.model.document.selection.focus;
32
+ *
33
+ * // It will replace one character before the selection focus with the '#1234' text
34
+ * // with the mention attribute filled with passed attributes.
35
+ * editor.execute( 'mention', {
36
+ * marker: '#',
37
+ * mention: {
38
+ * id: '#1234',
39
+ * name: 'Foo',
40
+ * title: 'Big Foo'
41
+ * },
42
+ * range: editor.model.createRange( focus.getShiftedBy( -1 ), focus )
43
+ * } );
44
+ *
45
+ * // It will replace one character before the selection focus with the 'The "Big Foo"' text
46
+ * // with the mention attribute filled with passed attributes.
47
+ * editor.execute( 'mention', {
48
+ * marker: '#',
49
+ * mention: {
50
+ * id: '#1234',
51
+ * name: 'Foo',
52
+ * title: 'Big Foo'
53
+ * },
54
+ * text: 'The "Big Foo"',
55
+ * range: editor.model.createRange( focus.getShiftedBy( -1 ), focus )
56
+ * } );
57
+ * ```
58
+ */
59
+ class MentionCommand extends Command {
60
+ /**
61
+ * @inheritDoc
62
+ */
63
+ constructor(editor) {
64
+ super(editor);
65
+ // Since this command may pass range in execution parameters, it should be checked directly in execute block.
66
+ this._isEnabledBasedOnSelection = false;
67
+ }
68
+ /**
69
+ * @inheritDoc
70
+ */
71
+ refresh() {
72
+ const model = this.editor.model;
73
+ const doc = model.document;
74
+ this.isEnabled = model.schema.checkAttributeInSelection(doc.selection, 'mention');
75
+ }
76
+ /**
77
+ * Executes the command.
78
+ *
79
+ * @param options Options for the executed command.
80
+ * @param options.mention The mention object to insert. When a string is passed, it will be used to create a plain
81
+ * object with the name attribute that equals the passed string.
82
+ * @param options.marker The marker character (e.g. `'@'`).
83
+ * @param options.text The text of the inserted mention. Defaults to the full mention string composed from `marker` and
84
+ * `mention` string or `mention.id` if an object is passed.
85
+ * @param options.range The range to replace.
86
+ * Note that the replaced range might be shorter than the inserted text with the mention attribute.
87
+ * @fires execute
88
+ */
89
+ execute(options) {
90
+ const model = this.editor.model;
91
+ const document = model.document;
92
+ const selection = document.selection;
93
+ const mentionData = typeof options.mention == 'string' ? { id: options.mention } : options.mention;
94
+ const mentionID = mentionData.id;
95
+ const range = options.range || selection.getFirstRange();
96
+ // Don't execute command if range is in non-editable place.
97
+ if (!model.canEditAt(range)) {
98
+ return;
99
+ }
100
+ const mentionText = options.text || mentionID;
101
+ const mention = _addMentionAttributes({ _text: mentionText, id: mentionID }, mentionData);
102
+ if (options.marker.length != 1) {
103
+ /**
104
+ * The marker must be a single character.
105
+ *
106
+ * Correct markers: `'@'`, `'#'`.
107
+ *
108
+ * Incorrect markers: `'@@'`, `'[@'`.
109
+ *
110
+ * See {@link module:mention/mentionconfig~MentionConfig}.
111
+ *
112
+ * @error mentioncommand-incorrect-marker
113
+ */
114
+ throw new CKEditorError('mentioncommand-incorrect-marker', this);
115
+ }
116
+ if (mentionID.charAt(0) != options.marker) {
117
+ /**
118
+ * The feed item ID must start with the marker character.
119
+ *
120
+ * Correct mention feed setting:
121
+ *
122
+ * ```ts
123
+ * mentions: [
124
+ * {
125
+ * marker: '@',
126
+ * feed: [ '@Ann', '@Barney', ... ]
127
+ * }
128
+ * ]
129
+ * ```
130
+ *
131
+ * Incorrect mention feed setting:
132
+ *
133
+ * ```ts
134
+ * mentions: [
135
+ * {
136
+ * marker: '@',
137
+ * feed: [ 'Ann', 'Barney', ... ]
138
+ * }
139
+ * ]
140
+ * ```
141
+ *
142
+ * See {@link module:mention/mentionconfig~MentionConfig}.
143
+ *
144
+ * @error mentioncommand-incorrect-id
145
+ */
146
+ throw new CKEditorError('mentioncommand-incorrect-id', this);
147
+ }
148
+ model.change(writer => {
149
+ const currentAttributes = toMap(selection.getAttributes());
150
+ const attributesWithMention = new Map(currentAttributes.entries());
151
+ attributesWithMention.set('mention', mention);
152
+ // Replace a range with the text with a mention.
153
+ const insertionRange = model.insertContent(writer.createText(mentionText, attributesWithMention), range);
154
+ const nodeBefore = insertionRange.start.nodeBefore;
155
+ const nodeAfter = insertionRange.end.nodeAfter;
156
+ const isFollowedByWhiteSpace = nodeAfter && nodeAfter.is('$text') && nodeAfter.data.startsWith(' ');
157
+ let isInsertedInBrackets = false;
158
+ if (nodeBefore && nodeAfter && nodeBefore.is('$text') && nodeAfter.is('$text')) {
159
+ const precedingCharacter = nodeBefore.data.slice(-1);
160
+ const isPrecededByOpeningBracket = precedingCharacter in BRACKET_PAIRS;
161
+ const isFollowedByBracketClosure = isPrecededByOpeningBracket && nodeAfter.data.startsWith(BRACKET_PAIRS[precedingCharacter]);
162
+ isInsertedInBrackets = isPrecededByOpeningBracket && isFollowedByBracketClosure;
163
+ }
164
+ // Don't add a white space if either of the following is true:
165
+ // * there's already one after the mention;
166
+ // * the mention was inserted in the empty matching brackets.
167
+ // https://github.com/ckeditor/ckeditor5/issues/4651
168
+ if (!isInsertedInBrackets && !isFollowedByWhiteSpace) {
169
+ model.insertContent(writer.createText(' ', currentAttributes), range.start.getShiftedBy(mentionText.length));
170
+ }
171
+ });
172
+ }
173
+ }
174
+
175
+ /**
176
+ * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved.
177
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
178
+ */
179
+ /**
180
+ * @module mention/mentionediting
181
+ */
182
+ /**
183
+ * The mention editing feature.
184
+ *
185
+ * It introduces the {@link module:mention/mentioncommand~MentionCommand command} and the `mention`
186
+ * attribute in the {@link module:engine/model/model~Model model} which renders in the {@link module:engine/view/view view}
187
+ * as a `<span class="mention" data-mention="@mention">`.
188
+ */
189
+ class MentionEditing extends Plugin {
190
+ /**
191
+ * @inheritDoc
192
+ */
193
+ static get pluginName() {
194
+ return 'MentionEditing';
195
+ }
196
+ /**
197
+ * @inheritDoc
198
+ */
199
+ init() {
200
+ const editor = this.editor;
201
+ const model = editor.model;
202
+ const doc = model.document;
203
+ // Allow the mention attribute on all text nodes.
204
+ model.schema.extend('$text', { allowAttributes: 'mention' });
205
+ // Upcast conversion.
206
+ editor.conversion.for('upcast').elementToAttribute({
207
+ view: {
208
+ name: 'span',
209
+ key: 'data-mention',
210
+ classes: 'mention'
211
+ },
212
+ model: {
213
+ key: 'mention',
214
+ value: (viewElement) => _toMentionAttribute(viewElement)
215
+ }
216
+ });
217
+ // Downcast conversion.
218
+ editor.conversion.for('downcast').attributeToElement({
219
+ model: 'mention',
220
+ view: createViewMentionElement
221
+ });
222
+ editor.conversion.for('downcast').add(preventPartialMentionDowncast);
223
+ doc.registerPostFixer(writer => removePartialMentionPostFixer(writer, doc, model.schema));
224
+ doc.registerPostFixer(writer => extendAttributeOnMentionPostFixer(writer, doc));
225
+ doc.registerPostFixer(writer => selectionMentionAttributePostFixer(writer, doc));
226
+ editor.commands.add('mention', new MentionCommand(editor));
227
+ }
228
+ }
229
+ /**
230
+ * @internal
231
+ */
232
+ function _addMentionAttributes(baseMentionData, data) {
233
+ return Object.assign({ uid: uid() }, baseMentionData, data || {});
234
+ }
235
+ /**
236
+ * Creates a mention attribute value from the provided view element and optional data.
237
+ *
238
+ * This function is exposed as
239
+ * {@link module:mention/mention~Mention#toMentionAttribute `editor.plugins.get( 'Mention' ).toMentionAttribute()`}.
240
+ *
241
+ * @internal
242
+ */
243
+ function _toMentionAttribute(viewElementOrMention, data) {
244
+ const dataMention = viewElementOrMention.getAttribute('data-mention');
245
+ const textNode = viewElementOrMention.getChild(0);
246
+ // Do not convert empty mentions.
247
+ if (!textNode) {
248
+ return;
249
+ }
250
+ const baseMentionData = {
251
+ id: dataMention,
252
+ _text: textNode.data
253
+ };
254
+ return _addMentionAttributes(baseMentionData, data);
255
+ }
256
+ /**
257
+ * A converter that blocks partial mention from being converted.
258
+ *
259
+ * This converter is registered with 'highest' priority in order to consume mention attribute before it is converted by
260
+ * any other converters. This converter only consumes partial mention - those whose `_text` attribute is not equal to text with mention
261
+ * attribute. This may happen when copying part of mention text.
262
+ */
263
+ function preventPartialMentionDowncast(dispatcher) {
264
+ dispatcher.on('attribute:mention', (evt, data, conversionApi) => {
265
+ const mention = data.attributeNewValue;
266
+ if (!data.item.is('$textProxy') || !mention) {
267
+ return;
268
+ }
269
+ const start = data.range.start;
270
+ const textNode = start.textNode || start.nodeAfter;
271
+ if (textNode.data != mention._text) {
272
+ // Consume item to prevent partial mention conversion.
273
+ conversionApi.consumable.consume(data.item, evt.name);
274
+ }
275
+ }, { priority: 'highest' });
276
+ }
277
+ /**
278
+ * Creates a mention element from the mention data.
279
+ */
280
+ function createViewMentionElement(mention, { writer }) {
281
+ if (!mention) {
282
+ return;
283
+ }
284
+ const attributes = {
285
+ class: 'mention',
286
+ 'data-mention': mention.id
287
+ };
288
+ const options = {
289
+ id: mention.uid,
290
+ priority: 20
291
+ };
292
+ return writer.createAttributeElement('span', attributes, options);
293
+ }
294
+ /**
295
+ * Model post-fixer that disallows typing with selection when the selection is placed after the text node with the mention attribute or
296
+ * before a text node with mention attribute.
297
+ */
298
+ function selectionMentionAttributePostFixer(writer, doc) {
299
+ const selection = doc.selection;
300
+ const focus = selection.focus;
301
+ if (selection.isCollapsed && selection.hasAttribute('mention') && shouldNotTypeWithMentionAt(focus)) {
302
+ writer.removeSelectionAttribute('mention');
303
+ return true;
304
+ }
305
+ return false;
306
+ }
307
+ /**
308
+ * Helper function to detect if mention attribute should be removed from selection.
309
+ * This check makes only sense if the selection has mention attribute.
310
+ *
311
+ * The mention attribute should be removed from a selection when selection focus is placed:
312
+ * a) after a text node
313
+ * b) the position is at parents start - the selection will set attributes from node after.
314
+ */
315
+ function shouldNotTypeWithMentionAt(position) {
316
+ const isAtStart = position.isAtStart;
317
+ const isAfterAMention = position.nodeBefore && position.nodeBefore.is('$text');
318
+ return isAfterAMention || isAtStart;
319
+ }
320
+ /**
321
+ * Model post-fixer that removes the mention attribute from the modified text node.
322
+ */
323
+ function removePartialMentionPostFixer(writer, doc, schema) {
324
+ const changes = doc.differ.getChanges();
325
+ let wasChanged = false;
326
+ for (const change of changes) {
327
+ if (change.type == 'attribute') {
328
+ continue;
329
+ }
330
+ // Checks the text node on the current position.
331
+ const position = change.position;
332
+ if (change.name == '$text') {
333
+ const nodeAfterInsertedTextNode = position.textNode && position.textNode.nextSibling;
334
+ // Checks the text node where the change occurred.
335
+ wasChanged = checkAndFix(position.textNode, writer) || wasChanged;
336
+ // Occurs on paste inside a text node with mention.
337
+ wasChanged = checkAndFix(nodeAfterInsertedTextNode, writer) || wasChanged;
338
+ wasChanged = checkAndFix(position.nodeBefore, writer) || wasChanged;
339
+ wasChanged = checkAndFix(position.nodeAfter, writer) || wasChanged;
340
+ }
341
+ // Checks text nodes in inserted elements (might occur when splitting a paragraph or pasting content inside text with mention).
342
+ if (change.name != '$text' && change.type == 'insert') {
343
+ const insertedNode = position.nodeAfter;
344
+ for (const item of writer.createRangeIn(insertedNode).getItems()) {
345
+ wasChanged = checkAndFix(item, writer) || wasChanged;
346
+ }
347
+ }
348
+ // Inserted inline elements might break mention.
349
+ if (change.type == 'insert' && schema.isInline(change.name)) {
350
+ const nodeAfterInserted = position.nodeAfter && position.nodeAfter.nextSibling;
351
+ wasChanged = checkAndFix(position.nodeBefore, writer) || wasChanged;
352
+ wasChanged = checkAndFix(nodeAfterInserted, writer) || wasChanged;
353
+ }
354
+ }
355
+ return wasChanged;
356
+ }
357
+ /**
358
+ * This post-fixer will extend the attribute applied on the part of the mention so the whole text node of the mention will have
359
+ * the added attribute.
360
+ */
361
+ function extendAttributeOnMentionPostFixer(writer, doc) {
362
+ const changes = doc.differ.getChanges();
363
+ let wasChanged = false;
364
+ for (const change of changes) {
365
+ if (change.type === 'attribute' && change.attributeKey != 'mention') {
366
+ // Checks the node on the left side of the range...
367
+ const nodeBefore = change.range.start.nodeBefore;
368
+ // ... and on the right side of the range.
369
+ const nodeAfter = change.range.end.nodeAfter;
370
+ for (const node of [nodeBefore, nodeAfter]) {
371
+ if (isBrokenMentionNode(node) && node.getAttribute(change.attributeKey) != change.attributeNewValue) {
372
+ writer.setAttribute(change.attributeKey, change.attributeNewValue, node);
373
+ wasChanged = true;
374
+ }
375
+ }
376
+ }
377
+ }
378
+ return wasChanged;
379
+ }
380
+ /**
381
+ * Checks if a node has a correct mention attribute if present.
382
+ * Returns `true` if the node is text and has a mention attribute whose text does not match the expected mention text.
383
+ */
384
+ function isBrokenMentionNode(node) {
385
+ if (!node || !(node.is('$text') || node.is('$textProxy')) || !node.hasAttribute('mention')) {
386
+ return false;
387
+ }
388
+ const text = node.data;
389
+ const mention = node.getAttribute('mention');
390
+ const expectedText = mention._text;
391
+ return text != expectedText;
392
+ }
393
+ /**
394
+ * Fixes a mention on a text node if it needs a fix.
395
+ */
396
+ function checkAndFix(textNode, writer) {
397
+ if (isBrokenMentionNode(textNode)) {
398
+ writer.removeAttribute('mention', textNode);
399
+ return true;
400
+ }
401
+ return false;
402
+ }
403
+
404
+ /**
405
+ * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved.
406
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
407
+ */
408
+ /**
409
+ * @module mention/ui/mentionsview
410
+ */
411
+ /**
412
+ * The mention ui view.
413
+ */
414
+ class MentionsView extends ListView {
415
+ /**
416
+ * @inheritDoc
417
+ */
418
+ constructor(locale) {
419
+ super(locale);
420
+ this.extendTemplate({
421
+ attributes: {
422
+ class: [
423
+ 'ck-mentions'
424
+ ],
425
+ tabindex: '-1'
426
+ }
427
+ });
428
+ }
429
+ /**
430
+ * {@link #select Selects} the first item.
431
+ */
432
+ selectFirst() {
433
+ this.select(0);
434
+ }
435
+ /**
436
+ * Selects next item to the currently {@link #select selected}.
437
+ *
438
+ * If the last item is already selected, it will select the first item.
439
+ */
440
+ selectNext() {
441
+ const item = this.selected;
442
+ const index = this.items.getIndex(item);
443
+ this.select(index + 1);
444
+ }
445
+ /**
446
+ * Selects previous item to the currently {@link #select selected}.
447
+ *
448
+ * If the first item is already selected, it will select the last item.
449
+ */
450
+ selectPrevious() {
451
+ const item = this.selected;
452
+ const index = this.items.getIndex(item);
453
+ this.select(index - 1);
454
+ }
455
+ /**
456
+ * Marks item at a given index as selected.
457
+ *
458
+ * Handles selection cycling when passed index is out of bounds:
459
+ * - if the index is lower than 0, it will select the last item,
460
+ * - if the index is higher than the last item index, it will select the first item.
461
+ *
462
+ * @param index Index of an item to be marked as selected.
463
+ */
464
+ select(index) {
465
+ let indexToGet = 0;
466
+ if (index > 0 && index < this.items.length) {
467
+ indexToGet = index;
468
+ }
469
+ else if (index < 0) {
470
+ indexToGet = this.items.length - 1;
471
+ }
472
+ const item = this.items.get(indexToGet);
473
+ // Return early if item is already selected.
474
+ if (this.selected === item) {
475
+ return;
476
+ }
477
+ // Remove highlight of previously selected item.
478
+ if (this.selected) {
479
+ this.selected.removeHighlight();
480
+ }
481
+ item.highlight();
482
+ this.selected = item;
483
+ // Scroll the mentions view to the selected element.
484
+ if (!this._isItemVisibleInScrolledArea(item)) {
485
+ this.element.scrollTop = item.element.offsetTop;
486
+ }
487
+ }
488
+ /**
489
+ * Triggers the `execute` event on the {@link #select selected} item.
490
+ */
491
+ executeSelected() {
492
+ this.selected.fire('execute');
493
+ }
494
+ /**
495
+ * Checks if an item is visible in the scrollable area.
496
+ *
497
+ * The item is considered visible when:
498
+ * - its top boundary is inside the scrollable rect
499
+ * - its bottom boundary is inside the scrollable rect (the whole item must be visible)
500
+ */
501
+ _isItemVisibleInScrolledArea(item) {
502
+ return new Rect(this.element).contains(new Rect(item.element));
503
+ }
504
+ }
505
+
506
+ /**
507
+ * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved.
508
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
509
+ */
510
+ /**
511
+ * @module mention/ui/domwrapperview
512
+ */
513
+ /**
514
+ * This class wraps DOM element as a CKEditor5 UI View.
515
+ *
516
+ * It allows to render any DOM element and use it in mentions list.
517
+ */
518
+ class DomWrapperView extends View {
519
+ /**
520
+ * Creates an instance of {@link module:mention/ui/domwrapperview~DomWrapperView} class.
521
+ *
522
+ * Also see {@link #render}.
523
+ */
524
+ constructor(locale, domElement) {
525
+ super(locale);
526
+ // Disable template rendering on this view.
527
+ this.template = undefined;
528
+ this.domElement = domElement;
529
+ // Render dom wrapper as a button.
530
+ this.domElement.classList.add('ck-button');
531
+ this.set('isOn', false);
532
+ // Handle isOn state as in buttons.
533
+ this.on('change:isOn', (evt, name, isOn) => {
534
+ if (isOn) {
535
+ this.domElement.classList.add('ck-on');
536
+ this.domElement.classList.remove('ck-off');
537
+ }
538
+ else {
539
+ this.domElement.classList.add('ck-off');
540
+ this.domElement.classList.remove('ck-on');
541
+ }
542
+ });
543
+ // Pass click event as execute event.
544
+ this.listenTo(this.domElement, 'click', () => {
545
+ this.fire('execute');
546
+ });
547
+ }
548
+ /**
549
+ * @inheritDoc
550
+ */
551
+ render() {
552
+ super.render();
553
+ this.element = this.domElement;
554
+ }
555
+ /**
556
+ * Focuses the DOM element.
557
+ */
558
+ focus() {
559
+ this.domElement.focus();
560
+ }
561
+ }
562
+
563
+ /**
564
+ * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved.
565
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
566
+ */
567
+ /**
568
+ * @module mention/ui/mentionlistitemview
569
+ */
570
+ class MentionListItemView extends ListItemView {
571
+ highlight() {
572
+ const child = this.children.first;
573
+ child.isOn = true;
574
+ }
575
+ removeHighlight() {
576
+ const child = this.children.first;
577
+ child.isOn = false;
578
+ }
579
+ }
580
+
581
+ /**
582
+ * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved.
583
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
584
+ */
585
+ /**
586
+ * @module mention/mentionui
587
+ */
588
+ const VERTICAL_SPACING = 3;
589
+ // The key codes that mention UI handles when it is open (without commit keys).
590
+ const defaultHandledKeyCodes = [
591
+ keyCodes.arrowup,
592
+ keyCodes.arrowdown,
593
+ keyCodes.esc
594
+ ];
595
+ // Dropdown commit key codes.
596
+ const defaultCommitKeyCodes = [
597
+ keyCodes.enter,
598
+ keyCodes.tab
599
+ ];
600
+ /**
601
+ * The mention UI feature.
602
+ */
603
+ class MentionUI extends Plugin {
604
+ /**
605
+ * @inheritDoc
606
+ */
607
+ static get pluginName() {
608
+ return 'MentionUI';
609
+ }
610
+ /**
611
+ * @inheritDoc
612
+ */
613
+ static get requires() {
614
+ return [ContextualBalloon];
615
+ }
616
+ /**
617
+ * @inheritDoc
618
+ */
619
+ constructor(editor) {
620
+ super(editor);
621
+ this._items = new Collection();
622
+ this._mentionsView = this._createMentionView();
623
+ this._mentionsConfigurations = new Map();
624
+ this._requestFeedDebounced = debounce(this._requestFeed, 100);
625
+ editor.config.define('mention', { feeds: [] });
626
+ }
627
+ /**
628
+ * @inheritDoc
629
+ */
630
+ init() {
631
+ const editor = this.editor;
632
+ const commitKeys = editor.config.get('mention.commitKeys') || defaultCommitKeyCodes;
633
+ const handledKeyCodes = defaultHandledKeyCodes.concat(commitKeys);
634
+ this._balloon = editor.plugins.get(ContextualBalloon);
635
+ // Key listener that handles navigation in mention view.
636
+ editor.editing.view.document.on('keydown', (evt, data) => {
637
+ if (isHandledKey(data.keyCode) && this._isUIVisible) {
638
+ data.preventDefault();
639
+ evt.stop(); // Required for Enter key overriding.
640
+ if (data.keyCode == keyCodes.arrowdown) {
641
+ this._mentionsView.selectNext();
642
+ }
643
+ if (data.keyCode == keyCodes.arrowup) {
644
+ this._mentionsView.selectPrevious();
645
+ }
646
+ if (commitKeys.includes(data.keyCode)) {
647
+ this._mentionsView.executeSelected();
648
+ }
649
+ if (data.keyCode == keyCodes.esc) {
650
+ this._hideUIAndRemoveMarker();
651
+ }
652
+ }
653
+ }, { priority: 'highest' }); // Required to override the Enter key.
654
+ // Close the dropdown upon clicking outside of the plugin UI.
655
+ clickOutsideHandler({
656
+ emitter: this._mentionsView,
657
+ activator: () => this._isUIVisible,
658
+ contextElements: () => [this._balloon.view.element],
659
+ callback: () => this._hideUIAndRemoveMarker()
660
+ });
661
+ const feeds = editor.config.get('mention.feeds');
662
+ for (const mentionDescription of feeds) {
663
+ const { feed, marker, dropdownLimit } = mentionDescription;
664
+ if (!isValidMentionMarker(marker)) {
665
+ /**
666
+ * The marker must be a single character.
667
+ *
668
+ * Correct markers: `'@'`, `'#'`.
669
+ *
670
+ * Incorrect markers: `'$$'`, `'[@'`.
671
+ *
672
+ * See {@link module:mention/mentionconfig~MentionConfig}.
673
+ *
674
+ * @error mentionconfig-incorrect-marker
675
+ * @param marker Configured marker
676
+ */
677
+ throw new CKEditorError('mentionconfig-incorrect-marker', null, { marker });
678
+ }
679
+ const feedCallback = typeof feed == 'function' ? feed.bind(this.editor) : createFeedCallback(feed);
680
+ const itemRenderer = mentionDescription.itemRenderer;
681
+ const definition = { marker, feedCallback, itemRenderer, dropdownLimit };
682
+ this._mentionsConfigurations.set(marker, definition);
683
+ }
684
+ this._setupTextWatcher(feeds);
685
+ this.listenTo(editor, 'change:isReadOnly', () => {
686
+ this._hideUIAndRemoveMarker();
687
+ });
688
+ this.on('requestFeed:response', (evt, data) => this._handleFeedResponse(data));
689
+ this.on('requestFeed:error', () => this._hideUIAndRemoveMarker());
690
+ /**
691
+ * Checks if a given key code is handled by the mention UI.
692
+ */
693
+ function isHandledKey(keyCode) {
694
+ return handledKeyCodes.includes(keyCode);
695
+ }
696
+ }
697
+ /**
698
+ * @inheritDoc
699
+ */
700
+ destroy() {
701
+ super.destroy();
702
+ // Destroy created UI components as they are not automatically destroyed (see ckeditor5#1341).
703
+ this._mentionsView.destroy();
704
+ }
705
+ /**
706
+ * Returns true when {@link #_mentionsView} is in the {@link module:ui/panel/balloon/contextualballoon~ContextualBalloon} and it is
707
+ * currently visible.
708
+ */
709
+ get _isUIVisible() {
710
+ return this._balloon.visibleView === this._mentionsView;
711
+ }
712
+ /**
713
+ * Creates the {@link #_mentionsView}.
714
+ */
715
+ _createMentionView() {
716
+ const locale = this.editor.locale;
717
+ const mentionsView = new MentionsView(locale);
718
+ mentionsView.items.bindTo(this._items).using(data => {
719
+ const { item, marker } = data;
720
+ const { dropdownLimit: markerDropdownLimit } = this._mentionsConfigurations.get(marker);
721
+ // Set to 10 by default for backwards compatibility. See: #10479
722
+ const dropdownLimit = markerDropdownLimit || this.editor.config.get('mention.dropdownLimit') || 10;
723
+ if (mentionsView.items.length >= dropdownLimit) {
724
+ return null;
725
+ }
726
+ const listItemView = new MentionListItemView(locale);
727
+ const view = this._renderItem(item, marker);
728
+ view.delegate('execute').to(listItemView);
729
+ listItemView.children.add(view);
730
+ listItemView.item = item;
731
+ listItemView.marker = marker;
732
+ listItemView.on('execute', () => {
733
+ mentionsView.fire('execute', {
734
+ item,
735
+ marker
736
+ });
737
+ });
738
+ return listItemView;
739
+ });
740
+ mentionsView.on('execute', (evt, data) => {
741
+ const editor = this.editor;
742
+ const model = editor.model;
743
+ const item = data.item;
744
+ const marker = data.marker;
745
+ const mentionMarker = editor.model.markers.get('mention');
746
+ // Create a range on matched text.
747
+ const end = model.createPositionAt(model.document.selection.focus);
748
+ const start = model.createPositionAt(mentionMarker.getStart());
749
+ const range = model.createRange(start, end);
750
+ this._hideUIAndRemoveMarker();
751
+ editor.execute('mention', {
752
+ mention: item,
753
+ text: item.text,
754
+ marker,
755
+ range
756
+ });
757
+ editor.editing.view.focus();
758
+ });
759
+ return mentionsView;
760
+ }
761
+ /**
762
+ * Returns item renderer for the marker.
763
+ */
764
+ _getItemRenderer(marker) {
765
+ const { itemRenderer } = this._mentionsConfigurations.get(marker);
766
+ return itemRenderer;
767
+ }
768
+ /**
769
+ * Requests a feed from a configured callbacks.
770
+ */
771
+ _requestFeed(marker, feedText) {
772
+ // @if CK_DEBUG_MENTION // console.log( '%c[Feed]%c Requesting for', 'color: blue', 'color: black', `"${ feedText }"` );
773
+ // Store the last requested feed - it is used to discard any out-of order requests.
774
+ this._lastRequested = feedText;
775
+ const { feedCallback } = this._mentionsConfigurations.get(marker);
776
+ const feedResponse = feedCallback(feedText);
777
+ const isAsynchronous = feedResponse instanceof Promise;
778
+ // For synchronous feeds (e.g. callbacks, arrays) fire the response event immediately.
779
+ if (!isAsynchronous) {
780
+ this.fire('requestFeed:response', { feed: feedResponse, marker, feedText });
781
+ return;
782
+ }
783
+ // Handle the asynchronous responses.
784
+ feedResponse
785
+ .then(response => {
786
+ // Check the feed text of this response with the last requested one so either:
787
+ if (this._lastRequested == feedText) {
788
+ // It is the same and fire the response event.
789
+ this.fire('requestFeed:response', { feed: response, marker, feedText });
790
+ }
791
+ else {
792
+ // It is different - most probably out-of-order one, so fire the discarded event.
793
+ this.fire('requestFeed:discarded', { feed: response, marker, feedText });
794
+ }
795
+ })
796
+ .catch(error => {
797
+ this.fire('requestFeed:error', { error });
798
+ /**
799
+ * The callback used for obtaining mention autocomplete feed thrown and error and the mention UI was hidden or
800
+ * not displayed at all.
801
+ *
802
+ * @error mention-feed-callback-error
803
+ */
804
+ logWarning('mention-feed-callback-error', { marker });
805
+ });
806
+ }
807
+ /**
808
+ * Registers a text watcher for the marker.
809
+ */
810
+ _setupTextWatcher(feeds) {
811
+ const editor = this.editor;
812
+ const feedsWithPattern = feeds.map(feed => ({
813
+ ...feed,
814
+ pattern: createRegExp(feed.marker, feed.minimumCharacters || 0)
815
+ }));
816
+ const watcher = new TextWatcher(editor.model, createTestCallback(feedsWithPattern));
817
+ watcher.on('matched', (evt, data) => {
818
+ const markerDefinition = getLastValidMarkerInText(feedsWithPattern, data.text);
819
+ const selection = editor.model.document.selection;
820
+ const focus = selection.focus;
821
+ const markerPosition = editor.model.createPositionAt(focus.parent, markerDefinition.position);
822
+ if (isPositionInExistingMention(focus) || isMarkerInExistingMention(markerPosition)) {
823
+ this._hideUIAndRemoveMarker();
824
+ return;
825
+ }
826
+ const feedText = requestFeedText(markerDefinition, data.text);
827
+ const matchedTextLength = markerDefinition.marker.length + feedText.length;
828
+ // Create a marker range.
829
+ const start = focus.getShiftedBy(-matchedTextLength);
830
+ const end = focus.getShiftedBy(-feedText.length);
831
+ const markerRange = editor.model.createRange(start, end);
832
+ // @if CK_DEBUG_MENTION // console.group( '%c[TextWatcher]%c matched', 'color: red', 'color: black', `"${ feedText }"` );
833
+ // @if CK_DEBUG_MENTION // console.log( 'data#text', `"${ data.text }"` );
834
+ // @if CK_DEBUG_MENTION // console.log( 'data#range', data.range.start.path, data.range.end.path );
835
+ // @if CK_DEBUG_MENTION // console.log( 'marker definition', markerDefinition );
836
+ // @if CK_DEBUG_MENTION // console.log( 'marker range', markerRange.start.path, markerRange.end.path );
837
+ if (checkIfStillInCompletionMode(editor)) {
838
+ const mentionMarker = editor.model.markers.get('mention');
839
+ // Update the marker - user might've moved the selection to other mention trigger.
840
+ editor.model.change(writer => {
841
+ // @if CK_DEBUG_MENTION // console.log( '%c[Editing]%c Updating the marker.', 'color: purple', 'color: black' );
842
+ writer.updateMarker(mentionMarker, { range: markerRange });
843
+ });
844
+ }
845
+ else {
846
+ editor.model.change(writer => {
847
+ // @if CK_DEBUG_MENTION // console.log( '%c[Editing]%c Adding the marker.', 'color: purple', 'color: black' );
848
+ writer.addMarker('mention', { range: markerRange, usingOperation: false, affectsData: false });
849
+ });
850
+ }
851
+ this._requestFeedDebounced(markerDefinition.marker, feedText);
852
+ // @if CK_DEBUG_MENTION // console.groupEnd();
853
+ });
854
+ watcher.on('unmatched', () => {
855
+ this._hideUIAndRemoveMarker();
856
+ });
857
+ const mentionCommand = editor.commands.get('mention');
858
+ watcher.bind('isEnabled').to(mentionCommand);
859
+ return watcher;
860
+ }
861
+ /**
862
+ * Handles the feed response event data.
863
+ */
864
+ _handleFeedResponse(data) {
865
+ const { feed, marker } = data;
866
+ // eslint-disable-next-line max-len
867
+ // @if CK_DEBUG_MENTION // console.log( `%c[Feed]%c Response for "${ data.feedText }" (${ feed.length })`, 'color: blue', 'color: black', feed );
868
+ // If the marker is not in the document happens when the selection had changed and the 'mention' marker was removed.
869
+ if (!checkIfStillInCompletionMode(this.editor)) {
870
+ return;
871
+ }
872
+ // Reset the view.
873
+ this._items.clear();
874
+ for (const feedItem of feed) {
875
+ const item = typeof feedItem != 'object' ? { id: feedItem, text: feedItem } : feedItem;
876
+ this._items.add({ item, marker });
877
+ }
878
+ const mentionMarker = this.editor.model.markers.get('mention');
879
+ if (this._items.length) {
880
+ this._showOrUpdateUI(mentionMarker);
881
+ }
882
+ else {
883
+ // Do not show empty mention UI.
884
+ this._hideUIAndRemoveMarker();
885
+ }
886
+ }
887
+ /**
888
+ * Shows the mentions balloon. If the panel is already visible, it will reposition it.
889
+ */
890
+ _showOrUpdateUI(markerMarker) {
891
+ if (this._isUIVisible) {
892
+ // @if CK_DEBUG_MENTION // console.log( '%c[UI]%c Updating position.', 'color: green', 'color: black' );
893
+ // Update balloon position as the mention list view may change its size.
894
+ this._balloon.updatePosition(this._getBalloonPanelPositionData(markerMarker, this._mentionsView.position));
895
+ }
896
+ else {
897
+ // @if CK_DEBUG_MENTION // console.log( '%c[UI]%c Showing the UI.', 'color: green', 'color: black' );
898
+ this._balloon.add({
899
+ view: this._mentionsView,
900
+ position: this._getBalloonPanelPositionData(markerMarker, this._mentionsView.position),
901
+ singleViewMode: true
902
+ });
903
+ }
904
+ this._mentionsView.position = this._balloon.view.position;
905
+ this._mentionsView.selectFirst();
906
+ }
907
+ /**
908
+ * Hides the mentions balloon and removes the 'mention' marker from the markers collection.
909
+ */
910
+ _hideUIAndRemoveMarker() {
911
+ // Remove the mention view from balloon before removing marker - it is used by balloon position target().
912
+ if (this._balloon.hasView(this._mentionsView)) {
913
+ // @if CK_DEBUG_MENTION // console.log( '%c[UI]%c Hiding the UI.', 'color: green', 'color: black' );
914
+ this._balloon.remove(this._mentionsView);
915
+ }
916
+ if (checkIfStillInCompletionMode(this.editor)) {
917
+ // @if CK_DEBUG_MENTION // console.log( '%c[Editing]%c Removing marker.', 'color: purple', 'color: black' );
918
+ this.editor.model.change(writer => writer.removeMarker('mention'));
919
+ }
920
+ // Make the last matched position on panel view undefined so the #_getBalloonPanelPositionData() method will return all positions
921
+ // on the next call.
922
+ this._mentionsView.position = undefined;
923
+ }
924
+ /**
925
+ * Renders a single item in the autocomplete list.
926
+ */
927
+ _renderItem(item, marker) {
928
+ const editor = this.editor;
929
+ let view;
930
+ let label = item.id;
931
+ const renderer = this._getItemRenderer(marker);
932
+ if (renderer) {
933
+ const renderResult = renderer(item);
934
+ if (typeof renderResult != 'string') {
935
+ view = new DomWrapperView(editor.locale, renderResult);
936
+ }
937
+ else {
938
+ label = renderResult;
939
+ }
940
+ }
941
+ if (!view) {
942
+ const buttonView = new ButtonView(editor.locale);
943
+ buttonView.label = label;
944
+ buttonView.withText = true;
945
+ view = buttonView;
946
+ }
947
+ return view;
948
+ }
949
+ /**
950
+ * Creates a position options object used to position the balloon panel.
951
+ *
952
+ * @param mentionMarker
953
+ * @param preferredPosition The name of the last matched position name.
954
+ */
955
+ _getBalloonPanelPositionData(mentionMarker, preferredPosition) {
956
+ const editor = this.editor;
957
+ const editing = editor.editing;
958
+ const domConverter = editing.view.domConverter;
959
+ const mapper = editing.mapper;
960
+ const uiLanguageDirection = editor.locale.uiLanguageDirection;
961
+ return {
962
+ target: () => {
963
+ let modelRange = mentionMarker.getRange();
964
+ // Target the UI to the model selection range - the marker has been removed so probably the UI will not be shown anyway.
965
+ // The logic is used by ContextualBalloon to display another panel in the same place.
966
+ if (modelRange.start.root.rootName == '$graveyard') {
967
+ modelRange = editor.model.document.selection.getFirstRange();
968
+ }
969
+ const viewRange = mapper.toViewRange(modelRange);
970
+ const rangeRects = Rect.getDomRangeRects(domConverter.viewRangeToDom(viewRange));
971
+ return rangeRects.pop();
972
+ },
973
+ limiter: () => {
974
+ const view = this.editor.editing.view;
975
+ const viewDocument = view.document;
976
+ const editableElement = viewDocument.selection.editableElement;
977
+ if (editableElement) {
978
+ return view.domConverter.mapViewToDom(editableElement.root);
979
+ }
980
+ return null;
981
+ },
982
+ positions: getBalloonPanelPositions(preferredPosition, uiLanguageDirection)
983
+ };
984
+ }
985
+ }
986
+ /**
987
+ * Returns the balloon positions data callbacks.
988
+ */
989
+ function getBalloonPanelPositions(preferredPosition, uiLanguageDirection) {
990
+ const positions = {
991
+ // Positions the panel to the southeast of the caret rectangle.
992
+ 'caret_se': (targetRect) => {
993
+ return {
994
+ top: targetRect.bottom + VERTICAL_SPACING,
995
+ left: targetRect.right,
996
+ name: 'caret_se',
997
+ config: {
998
+ withArrow: false
999
+ }
1000
+ };
1001
+ },
1002
+ // Positions the panel to the northeast of the caret rectangle.
1003
+ 'caret_ne': (targetRect, balloonRect) => {
1004
+ return {
1005
+ top: targetRect.top - balloonRect.height - VERTICAL_SPACING,
1006
+ left: targetRect.right,
1007
+ name: 'caret_ne',
1008
+ config: {
1009
+ withArrow: false
1010
+ }
1011
+ };
1012
+ },
1013
+ // Positions the panel to the southwest of the caret rectangle.
1014
+ 'caret_sw': (targetRect, balloonRect) => {
1015
+ return {
1016
+ top: targetRect.bottom + VERTICAL_SPACING,
1017
+ left: targetRect.right - balloonRect.width,
1018
+ name: 'caret_sw',
1019
+ config: {
1020
+ withArrow: false
1021
+ }
1022
+ };
1023
+ },
1024
+ // Positions the panel to the northwest of the caret rect.
1025
+ 'caret_nw': (targetRect, balloonRect) => {
1026
+ return {
1027
+ top: targetRect.top - balloonRect.height - VERTICAL_SPACING,
1028
+ left: targetRect.right - balloonRect.width,
1029
+ name: 'caret_nw',
1030
+ config: {
1031
+ withArrow: false
1032
+ }
1033
+ };
1034
+ }
1035
+ };
1036
+ // Returns only the last position if it was matched to prevent the panel from jumping after the first match.
1037
+ if (Object.prototype.hasOwnProperty.call(positions, preferredPosition)) {
1038
+ return [
1039
+ positions[preferredPosition]
1040
+ ];
1041
+ }
1042
+ // By default, return all position callbacks ordered depending on the UI language direction.
1043
+ return uiLanguageDirection !== 'rtl' ? [
1044
+ positions.caret_se,
1045
+ positions.caret_sw,
1046
+ positions.caret_ne,
1047
+ positions.caret_nw
1048
+ ] : [
1049
+ positions.caret_sw,
1050
+ positions.caret_se,
1051
+ positions.caret_nw,
1052
+ positions.caret_ne
1053
+ ];
1054
+ }
1055
+ /**
1056
+ * Returns a marker definition of the last valid occurring marker in a given string.
1057
+ * If there is no valid marker in a string, it returns undefined.
1058
+ *
1059
+ * Example of returned object:
1060
+ *
1061
+ * ```ts
1062
+ * {
1063
+ * marker: '@',
1064
+ * position: 4,
1065
+ * minimumCharacters: 0
1066
+ * }
1067
+ * ````
1068
+ *
1069
+ * @param feedsWithPattern Registered feeds in editor for mention plugin with created RegExp for matching marker.
1070
+ * @param text String to find the marker in
1071
+ * @returns Matched marker's definition
1072
+ */
1073
+ function getLastValidMarkerInText(feedsWithPattern, text) {
1074
+ let lastValidMarker;
1075
+ for (const feed of feedsWithPattern) {
1076
+ const currentMarkerLastIndex = text.lastIndexOf(feed.marker);
1077
+ if (currentMarkerLastIndex > 0 && !text.substring(currentMarkerLastIndex - 1).match(feed.pattern)) {
1078
+ continue;
1079
+ }
1080
+ if (!lastValidMarker || currentMarkerLastIndex >= lastValidMarker.position) {
1081
+ lastValidMarker = {
1082
+ marker: feed.marker,
1083
+ position: currentMarkerLastIndex,
1084
+ minimumCharacters: feed.minimumCharacters,
1085
+ pattern: feed.pattern
1086
+ };
1087
+ }
1088
+ }
1089
+ return lastValidMarker;
1090
+ }
1091
+ /**
1092
+ * Creates a RegExp pattern for the marker.
1093
+ *
1094
+ * Function has to be exported to achieve 100% code coverage.
1095
+ */
1096
+ function createRegExp(marker, minimumCharacters) {
1097
+ const numberOfCharacters = minimumCharacters == 0 ? '*' : `{${minimumCharacters},}`;
1098
+ const openAfterCharacters = env.features.isRegExpUnicodePropertySupported ? '\\p{Ps}\\p{Pi}"\'' : '\\(\\[{"\'';
1099
+ const mentionCharacters = '.';
1100
+ // The pattern consists of 3 groups:
1101
+ // - 0 (non-capturing): Opening sequence - start of the line, space or an opening punctuation character like "(" or "\"",
1102
+ // - 1: The marker character,
1103
+ // - 2: Mention input (taking the minimal length into consideration to trigger the UI),
1104
+ //
1105
+ // The pattern matches up to the caret (end of string switch - $).
1106
+ // (0: opening sequence )(1: marker )(2: typed mention )$
1107
+ const pattern = `(?:^|[ ${openAfterCharacters}])([${marker}])(${mentionCharacters}${numberOfCharacters})$`;
1108
+ return new RegExp(pattern, 'u');
1109
+ }
1110
+ /**
1111
+ * Creates a test callback for the marker to be used in the text watcher instance.
1112
+ *
1113
+ * @param feedsWithPattern Feeds of mention plugin configured in editor with RegExp to match marker in text
1114
+ */
1115
+ function createTestCallback(feedsWithPattern) {
1116
+ const textMatcher = (text) => {
1117
+ const markerDefinition = getLastValidMarkerInText(feedsWithPattern, text);
1118
+ if (!markerDefinition) {
1119
+ return false;
1120
+ }
1121
+ let splitStringFrom = 0;
1122
+ if (markerDefinition.position !== 0) {
1123
+ splitStringFrom = markerDefinition.position - 1;
1124
+ }
1125
+ const textToTest = text.substring(splitStringFrom);
1126
+ return markerDefinition.pattern.test(textToTest);
1127
+ };
1128
+ return textMatcher;
1129
+ }
1130
+ /**
1131
+ * Creates a text matcher from the marker.
1132
+ */
1133
+ function requestFeedText(markerDefinition, text) {
1134
+ let splitStringFrom = 0;
1135
+ if (markerDefinition.position !== 0) {
1136
+ splitStringFrom = markerDefinition.position - 1;
1137
+ }
1138
+ const regExp = createRegExp(markerDefinition.marker, 0);
1139
+ const textToMatch = text.substring(splitStringFrom);
1140
+ const match = textToMatch.match(regExp);
1141
+ return match[2];
1142
+ }
1143
+ /**
1144
+ * The default feed callback.
1145
+ */
1146
+ function createFeedCallback(feedItems) {
1147
+ return (feedText) => {
1148
+ const filteredItems = feedItems
1149
+ // Make the default mention feed case-insensitive.
1150
+ .filter(item => {
1151
+ // Item might be defined as object.
1152
+ const itemId = typeof item == 'string' ? item : String(item.id);
1153
+ // The default feed is case insensitive.
1154
+ return itemId.toLowerCase().includes(feedText.toLowerCase());
1155
+ });
1156
+ return filteredItems;
1157
+ };
1158
+ }
1159
+ /**
1160
+ * Checks if position in inside or right after a text with a mention.
1161
+ */
1162
+ function isPositionInExistingMention(position) {
1163
+ // The text watcher listens only to changed range in selection - so the selection attributes are not yet available
1164
+ // and you cannot use selection.hasAttribute( 'mention' ) just yet.
1165
+ // See https://github.com/ckeditor/ckeditor5-engine/issues/1723.
1166
+ const hasMention = position.textNode && position.textNode.hasAttribute('mention');
1167
+ const nodeBefore = position.nodeBefore;
1168
+ return hasMention || nodeBefore && nodeBefore.is('$text') && nodeBefore.hasAttribute('mention');
1169
+ }
1170
+ /**
1171
+ * Checks if the closest marker offset is at the beginning of a mention.
1172
+ *
1173
+ * See https://github.com/ckeditor/ckeditor5/issues/11400.
1174
+ */
1175
+ function isMarkerInExistingMention(markerPosition) {
1176
+ const nodeAfter = markerPosition.nodeAfter;
1177
+ return nodeAfter && nodeAfter.is('$text') && nodeAfter.hasAttribute('mention');
1178
+ }
1179
+ /**
1180
+ * Checks if string is a valid mention marker.
1181
+ */
1182
+ function isValidMentionMarker(marker) {
1183
+ return marker && marker.length == 1;
1184
+ }
1185
+ /**
1186
+ * Checks the mention plugins is in completion mode (e.g. when typing is after a valid mention string like @foo).
1187
+ */
1188
+ function checkIfStillInCompletionMode(editor) {
1189
+ return editor.model.markers.has('mention');
1190
+ }
1191
+
1192
+ /**
1193
+ * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved.
1194
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
1195
+ */
1196
+ /**
1197
+ * @module mention/mention
1198
+ */
1199
+ /**
1200
+ * The mention plugin.
1201
+ *
1202
+ * For a detailed overview, check the {@glink features/mentions Mention feature} guide.
1203
+ */
1204
+ class Mention extends Plugin {
1205
+ toMentionAttribute(viewElement, data) {
1206
+ return _toMentionAttribute(viewElement, data);
1207
+ }
1208
+ /**
1209
+ * @inheritDoc
1210
+ */
1211
+ static get pluginName() {
1212
+ return 'Mention';
1213
+ }
1214
+ /**
1215
+ * @inheritDoc
1216
+ */
1217
+ static get requires() {
1218
+ return [MentionEditing, MentionUI];
1219
+ }
1220
+ }
1221
+
1222
+ export { Mention, MentionEditing, MentionUI };
1223
+ //# sourceMappingURL=index.js.map