@ckeditor/ckeditor5-mention 41.3.0-alpha.1 → 41.3.0-alpha.3

Sign up to get free protection for your applications and to get access to all the features.
package/dist/index.js DELETED
@@ -1,1223 +0,0 @@
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