@ckeditor/ckeditor5-mention 41.3.1 → 41.4.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,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