@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-content.css +4 -0
- package/dist/index-editor.css +17 -0
- package/dist/index.css +34 -0
- package/dist/index.css.map +1 -0
- package/dist/index.js +1108 -0
- package/dist/index.js.map +1 -0
- package/dist/types/augmentation.d.ts +27 -0
- package/dist/types/index.d.ts +20 -0
- package/dist/types/mention.d.ts +81 -0
- package/dist/types/mentioncommand.d.ts +81 -0
- package/dist/types/mentionconfig.d.ts +269 -0
- package/dist/types/mentionediting.d.ts +47 -0
- package/dist/types/mentionui.d.ts +106 -0
- package/dist/types/ui/domwrapperview.d.ts +45 -0
- package/dist/types/ui/mentionlistitemview.d.ts +19 -0
- package/dist/types/ui/mentionsview.d.ts +64 -0
- package/package.json +3 -2
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
|