@ckeditor/ckeditor5-mention 0.0.0-nightly-20240423.0 → 0.0.0-nightly-20240425.0

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