@ckeditor/ckeditor5-mention 40.0.0 → 40.1.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/LICENSE.md +3 -3
- package/package.json +2 -2
- package/src/augmentation.d.ts +23 -23
- package/src/augmentation.js +5 -5
- package/src/index.d.ts +13 -13
- package/src/index.js +11 -11
- package/src/mention.d.ts +77 -77
- package/src/mention.js +33 -33
- package/src/mentioncommand.d.ts +77 -77
- package/src/mentioncommand.js +145 -145
- package/src/mentionconfig.d.ts +265 -265
- package/src/mentionconfig.js +5 -5
- package/src/mentionediting.d.ts +43 -43
- package/src/mentionediting.js +231 -231
- package/src/mentionui.d.ts +102 -102
- package/src/mentionui.js +618 -618
- package/src/ui/domwrapperview.d.ts +41 -41
- package/src/ui/domwrapperview.js +57 -57
- package/src/ui/mentionlistitemview.d.ts +15 -15
- package/src/ui/mentionlistitemview.js +18 -18
- package/src/ui/mentionsview.d.ts +60 -60
- package/src/ui/mentionsview.js +104 -104
- package/build/mention.js.map +0 -1
package/src/mentionui.js
CHANGED
@@ -1,618 +1,618 @@
|
|
1
|
-
/**
|
2
|
-
* @license Copyright (c) 2003-2023, 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
|
-
/**
|
6
|
-
* @module mention/mentionui
|
7
|
-
*/
|
8
|
-
import { Plugin } from 'ckeditor5/src/core';
|
9
|
-
import { ButtonView, ContextualBalloon, clickOutsideHandler } from 'ckeditor5/src/ui';
|
10
|
-
import { CKEditorError, Collection, Rect, env, keyCodes, logWarning } from 'ckeditor5/src/utils';
|
11
|
-
import { TextWatcher } from 'ckeditor5/src/typing';
|
12
|
-
import { debounce } from 'lodash-es';
|
13
|
-
import MentionsView from './ui/mentionsview';
|
14
|
-
import DomWrapperView from './ui/domwrapperview';
|
15
|
-
import MentionListItemView from './ui/mentionlistitemview';
|
16
|
-
const VERTICAL_SPACING = 3;
|
17
|
-
// The key codes that mention UI handles when it is open (without commit keys).
|
18
|
-
const defaultHandledKeyCodes = [
|
19
|
-
keyCodes.arrowup,
|
20
|
-
keyCodes.arrowdown,
|
21
|
-
keyCodes.esc
|
22
|
-
];
|
23
|
-
// Dropdown commit key codes.
|
24
|
-
const defaultCommitKeyCodes = [
|
25
|
-
keyCodes.enter,
|
26
|
-
keyCodes.tab
|
27
|
-
];
|
28
|
-
/**
|
29
|
-
* The mention UI feature.
|
30
|
-
*/
|
31
|
-
export default class MentionUI extends Plugin {
|
32
|
-
/**
|
33
|
-
* @inheritDoc
|
34
|
-
*/
|
35
|
-
static get pluginName() {
|
36
|
-
return 'MentionUI';
|
37
|
-
}
|
38
|
-
/**
|
39
|
-
* @inheritDoc
|
40
|
-
*/
|
41
|
-
static get requires() {
|
42
|
-
return [ContextualBalloon];
|
43
|
-
}
|
44
|
-
/**
|
45
|
-
* @inheritDoc
|
46
|
-
*/
|
47
|
-
constructor(editor) {
|
48
|
-
super(editor);
|
49
|
-
this._items = new Collection();
|
50
|
-
this._mentionsView = this._createMentionView();
|
51
|
-
this._mentionsConfigurations = new Map();
|
52
|
-
this._requestFeedDebounced = debounce(this._requestFeed, 100);
|
53
|
-
editor.config.define('mention', { feeds: [] });
|
54
|
-
}
|
55
|
-
/**
|
56
|
-
* @inheritDoc
|
57
|
-
*/
|
58
|
-
init() {
|
59
|
-
const editor = this.editor;
|
60
|
-
const commitKeys = editor.config.get('mention.commitKeys') || defaultCommitKeyCodes;
|
61
|
-
const handledKeyCodes = defaultHandledKeyCodes.concat(commitKeys);
|
62
|
-
this._balloon = editor.plugins.get(ContextualBalloon);
|
63
|
-
// Key listener that handles navigation in mention view.
|
64
|
-
editor.editing.view.document.on('keydown', (evt, data) => {
|
65
|
-
if (isHandledKey(data.keyCode) && this._isUIVisible) {
|
66
|
-
data.preventDefault();
|
67
|
-
evt.stop(); // Required for Enter key overriding.
|
68
|
-
if (data.keyCode == keyCodes.arrowdown) {
|
69
|
-
this._mentionsView.selectNext();
|
70
|
-
}
|
71
|
-
if (data.keyCode == keyCodes.arrowup) {
|
72
|
-
this._mentionsView.selectPrevious();
|
73
|
-
}
|
74
|
-
if (commitKeys.includes(data.keyCode)) {
|
75
|
-
this._mentionsView.executeSelected();
|
76
|
-
}
|
77
|
-
if (data.keyCode == keyCodes.esc) {
|
78
|
-
this._hideUIAndRemoveMarker();
|
79
|
-
}
|
80
|
-
}
|
81
|
-
}, { priority: 'highest' }); // Required to override the Enter key.
|
82
|
-
// Close the dropdown upon clicking outside of the plugin UI.
|
83
|
-
clickOutsideHandler({
|
84
|
-
emitter: this._mentionsView,
|
85
|
-
activator: () => this._isUIVisible,
|
86
|
-
contextElements: () => [this._balloon.view.element],
|
87
|
-
callback: () => this._hideUIAndRemoveMarker()
|
88
|
-
});
|
89
|
-
const feeds = editor.config.get('mention.feeds');
|
90
|
-
for (const mentionDescription of feeds) {
|
91
|
-
const { feed, marker, dropdownLimit } = mentionDescription;
|
92
|
-
if (!isValidMentionMarker(marker)) {
|
93
|
-
/**
|
94
|
-
* The marker must be a single character.
|
95
|
-
*
|
96
|
-
* Correct markers: `'@'`, `'#'`.
|
97
|
-
*
|
98
|
-
* Incorrect markers: `'$$'`, `'[@'`.
|
99
|
-
*
|
100
|
-
* See {@link module:mention/mentionconfig~MentionConfig}.
|
101
|
-
*
|
102
|
-
* @error mentionconfig-incorrect-marker
|
103
|
-
* @param marker Configured marker
|
104
|
-
*/
|
105
|
-
throw new CKEditorError('mentionconfig-incorrect-marker', null, { marker });
|
106
|
-
}
|
107
|
-
const feedCallback = typeof feed == 'function' ? feed.bind(this.editor) : createFeedCallback(feed);
|
108
|
-
const itemRenderer = mentionDescription.itemRenderer;
|
109
|
-
const definition = { marker, feedCallback, itemRenderer, dropdownLimit };
|
110
|
-
this._mentionsConfigurations.set(marker, definition);
|
111
|
-
}
|
112
|
-
this._setupTextWatcher(feeds);
|
113
|
-
this.listenTo(editor, 'change:isReadOnly', () => {
|
114
|
-
this._hideUIAndRemoveMarker();
|
115
|
-
});
|
116
|
-
this.on('requestFeed:response', (evt, data) => this._handleFeedResponse(data));
|
117
|
-
this.on('requestFeed:error', () => this._hideUIAndRemoveMarker());
|
118
|
-
/**
|
119
|
-
* Checks if a given key code is handled by the mention UI.
|
120
|
-
*/
|
121
|
-
function isHandledKey(keyCode) {
|
122
|
-
return handledKeyCodes.includes(keyCode);
|
123
|
-
}
|
124
|
-
}
|
125
|
-
/**
|
126
|
-
* @inheritDoc
|
127
|
-
*/
|
128
|
-
destroy() {
|
129
|
-
super.destroy();
|
130
|
-
// Destroy created UI components as they are not automatically destroyed (see ckeditor5#1341).
|
131
|
-
this._mentionsView.destroy();
|
132
|
-
}
|
133
|
-
/**
|
134
|
-
* Returns true when {@link #_mentionsView} is in the {@link module:ui/panel/balloon/contextualballoon~ContextualBalloon} and it is
|
135
|
-
* currently visible.
|
136
|
-
*/
|
137
|
-
get _isUIVisible() {
|
138
|
-
return this._balloon.visibleView === this._mentionsView;
|
139
|
-
}
|
140
|
-
/**
|
141
|
-
* Creates the {@link #_mentionsView}.
|
142
|
-
*/
|
143
|
-
_createMentionView() {
|
144
|
-
const locale = this.editor.locale;
|
145
|
-
const mentionsView = new MentionsView(locale);
|
146
|
-
mentionsView.items.bindTo(this._items).using(data => {
|
147
|
-
const { item, marker } = data;
|
148
|
-
const { dropdownLimit: markerDropdownLimit } = this._mentionsConfigurations.get(marker);
|
149
|
-
// Set to 10 by default for backwards compatibility. See: #10479
|
150
|
-
const dropdownLimit = markerDropdownLimit || this.editor.config.get('mention.dropdownLimit') || 10;
|
151
|
-
if (mentionsView.items.length >= dropdownLimit) {
|
152
|
-
return null;
|
153
|
-
}
|
154
|
-
const listItemView = new MentionListItemView(locale);
|
155
|
-
const view = this._renderItem(item, marker);
|
156
|
-
view.delegate('execute').to(listItemView);
|
157
|
-
listItemView.children.add(view);
|
158
|
-
listItemView.item = item;
|
159
|
-
listItemView.marker = marker;
|
160
|
-
listItemView.on('execute', () => {
|
161
|
-
mentionsView.fire('execute', {
|
162
|
-
item,
|
163
|
-
marker
|
164
|
-
});
|
165
|
-
});
|
166
|
-
return listItemView;
|
167
|
-
});
|
168
|
-
mentionsView.on('execute', (evt, data) => {
|
169
|
-
const editor = this.editor;
|
170
|
-
const model = editor.model;
|
171
|
-
const item = data.item;
|
172
|
-
const marker = data.marker;
|
173
|
-
const mentionMarker = editor.model.markers.get('mention');
|
174
|
-
// Create a range on matched text.
|
175
|
-
const end = model.createPositionAt(model.document.selection.focus);
|
176
|
-
const start = model.createPositionAt(mentionMarker.getStart());
|
177
|
-
const range = model.createRange(start, end);
|
178
|
-
this._hideUIAndRemoveMarker();
|
179
|
-
editor.execute('mention', {
|
180
|
-
mention: item,
|
181
|
-
text: item.text,
|
182
|
-
marker,
|
183
|
-
range
|
184
|
-
});
|
185
|
-
editor.editing.view.focus();
|
186
|
-
});
|
187
|
-
return mentionsView;
|
188
|
-
}
|
189
|
-
/**
|
190
|
-
* Returns item renderer for the marker.
|
191
|
-
*/
|
192
|
-
_getItemRenderer(marker) {
|
193
|
-
const { itemRenderer } = this._mentionsConfigurations.get(marker);
|
194
|
-
return itemRenderer;
|
195
|
-
}
|
196
|
-
/**
|
197
|
-
* Requests a feed from a configured callbacks.
|
198
|
-
*/
|
199
|
-
_requestFeed(marker, feedText) {
|
200
|
-
// @if CK_DEBUG_MENTION // console.log( '%c[Feed]%c Requesting for', 'color: blue', 'color: black', `"${ feedText }"` );
|
201
|
-
// Store the last requested feed - it is used to discard any out-of order requests.
|
202
|
-
this._lastRequested = feedText;
|
203
|
-
const { feedCallback } = this._mentionsConfigurations.get(marker);
|
204
|
-
const feedResponse = feedCallback(feedText);
|
205
|
-
const isAsynchronous = feedResponse instanceof Promise;
|
206
|
-
// For synchronous feeds (e.g. callbacks, arrays) fire the response event immediately.
|
207
|
-
if (!isAsynchronous) {
|
208
|
-
this.fire('requestFeed:response', { feed: feedResponse, marker, feedText });
|
209
|
-
return;
|
210
|
-
}
|
211
|
-
// Handle the asynchronous responses.
|
212
|
-
feedResponse
|
213
|
-
.then(response => {
|
214
|
-
// Check the feed text of this response with the last requested one so either:
|
215
|
-
if (this._lastRequested == feedText) {
|
216
|
-
// It is the same and fire the response event.
|
217
|
-
this.fire('requestFeed:response', { feed: response, marker, feedText });
|
218
|
-
}
|
219
|
-
else {
|
220
|
-
// It is different - most probably out-of-order one, so fire the discarded event.
|
221
|
-
this.fire('requestFeed:discarded', { feed: response, marker, feedText });
|
222
|
-
}
|
223
|
-
})
|
224
|
-
.catch(error => {
|
225
|
-
this.fire('requestFeed:error', { error });
|
226
|
-
/**
|
227
|
-
* The callback used for obtaining mention autocomplete feed thrown and error and the mention UI was hidden or
|
228
|
-
* not displayed at all.
|
229
|
-
*
|
230
|
-
* @error mention-feed-callback-error
|
231
|
-
*/
|
232
|
-
logWarning('mention-feed-callback-error', { marker });
|
233
|
-
});
|
234
|
-
}
|
235
|
-
/**
|
236
|
-
* Registers a text watcher for the marker.
|
237
|
-
*/
|
238
|
-
_setupTextWatcher(feeds) {
|
239
|
-
const editor = this.editor;
|
240
|
-
const feedsWithPattern = feeds.map(feed => ({
|
241
|
-
...feed,
|
242
|
-
pattern: createRegExp(feed.marker, feed.minimumCharacters || 0)
|
243
|
-
}));
|
244
|
-
const watcher = new TextWatcher(editor.model, createTestCallback(feedsWithPattern));
|
245
|
-
watcher.on('matched', (evt, data) => {
|
246
|
-
const markerDefinition = getLastValidMarkerInText(feedsWithPattern, data.text);
|
247
|
-
const selection = editor.model.document.selection;
|
248
|
-
const focus = selection.focus;
|
249
|
-
const markerPosition = editor.model.createPositionAt(focus.parent, markerDefinition.position);
|
250
|
-
if (isPositionInExistingMention(focus) || isMarkerInExistingMention(markerPosition)) {
|
251
|
-
this._hideUIAndRemoveMarker();
|
252
|
-
return;
|
253
|
-
}
|
254
|
-
const feedText = requestFeedText(markerDefinition, data.text);
|
255
|
-
const matchedTextLength = markerDefinition.marker.length + feedText.length;
|
256
|
-
// Create a marker range.
|
257
|
-
const start = focus.getShiftedBy(-matchedTextLength);
|
258
|
-
const end = focus.getShiftedBy(-feedText.length);
|
259
|
-
const markerRange = editor.model.createRange(start, end);
|
260
|
-
// @if CK_DEBUG_MENTION // console.group( '%c[TextWatcher]%c matched', 'color: red', 'color: black', `"${ feedText }"` );
|
261
|
-
// @if CK_DEBUG_MENTION // console.log( 'data#text', `"${ data.text }"` );
|
262
|
-
// @if CK_DEBUG_MENTION // console.log( 'data#range', data.range.start.path, data.range.end.path );
|
263
|
-
// @if CK_DEBUG_MENTION // console.log( 'marker definition', markerDefinition );
|
264
|
-
// @if CK_DEBUG_MENTION // console.log( 'marker range', markerRange.start.path, markerRange.end.path );
|
265
|
-
if (checkIfStillInCompletionMode(editor)) {
|
266
|
-
const mentionMarker = editor.model.markers.get('mention');
|
267
|
-
// Update the marker - user might've moved the selection to other mention trigger.
|
268
|
-
editor.model.change(writer => {
|
269
|
-
// @if CK_DEBUG_MENTION // console.log( '%c[Editing]%c Updating the marker.', 'color: purple', 'color: black' );
|
270
|
-
writer.updateMarker(mentionMarker, { range: markerRange });
|
271
|
-
});
|
272
|
-
}
|
273
|
-
else {
|
274
|
-
editor.model.change(writer => {
|
275
|
-
// @if CK_DEBUG_MENTION // console.log( '%c[Editing]%c Adding the marker.', 'color: purple', 'color: black' );
|
276
|
-
writer.addMarker('mention', { range: markerRange, usingOperation: false, affectsData: false });
|
277
|
-
});
|
278
|
-
}
|
279
|
-
this._requestFeedDebounced(markerDefinition.marker, feedText);
|
280
|
-
// @if CK_DEBUG_MENTION // console.groupEnd();
|
281
|
-
});
|
282
|
-
watcher.on('unmatched', () => {
|
283
|
-
this._hideUIAndRemoveMarker();
|
284
|
-
});
|
285
|
-
const mentionCommand = editor.commands.get('mention');
|
286
|
-
watcher.bind('isEnabled').to(mentionCommand);
|
287
|
-
return watcher;
|
288
|
-
}
|
289
|
-
/**
|
290
|
-
* Handles the feed response event data.
|
291
|
-
*/
|
292
|
-
_handleFeedResponse(data) {
|
293
|
-
const { feed, marker } = data;
|
294
|
-
// eslint-disable-next-line max-len
|
295
|
-
// @if CK_DEBUG_MENTION // console.log( `%c[Feed]%c Response for "${ data.feedText }" (${ feed.length })`, 'color: blue', 'color: black', feed );
|
296
|
-
// If the marker is not in the document happens when the selection had changed and the 'mention' marker was removed.
|
297
|
-
if (!checkIfStillInCompletionMode(this.editor)) {
|
298
|
-
return;
|
299
|
-
}
|
300
|
-
// Reset the view.
|
301
|
-
this._items.clear();
|
302
|
-
for (const feedItem of feed) {
|
303
|
-
const item = typeof feedItem != 'object' ? { id: feedItem, text: feedItem } : feedItem;
|
304
|
-
this._items.add({ item, marker });
|
305
|
-
}
|
306
|
-
const mentionMarker = this.editor.model.markers.get('mention');
|
307
|
-
if (this._items.length) {
|
308
|
-
this._showOrUpdateUI(mentionMarker);
|
309
|
-
}
|
310
|
-
else {
|
311
|
-
// Do not show empty mention UI.
|
312
|
-
this._hideUIAndRemoveMarker();
|
313
|
-
}
|
314
|
-
}
|
315
|
-
/**
|
316
|
-
* Shows the mentions balloon. If the panel is already visible, it will reposition it.
|
317
|
-
*/
|
318
|
-
_showOrUpdateUI(markerMarker) {
|
319
|
-
if (this._isUIVisible) {
|
320
|
-
// @if CK_DEBUG_MENTION // console.log( '%c[UI]%c Updating position.', 'color: green', 'color: black' );
|
321
|
-
// Update balloon position as the mention list view may change its size.
|
322
|
-
this._balloon.updatePosition(this._getBalloonPanelPositionData(markerMarker, this._mentionsView.position));
|
323
|
-
}
|
324
|
-
else {
|
325
|
-
// @if CK_DEBUG_MENTION // console.log( '%c[UI]%c Showing the UI.', 'color: green', 'color: black' );
|
326
|
-
this._balloon.add({
|
327
|
-
view: this._mentionsView,
|
328
|
-
position: this._getBalloonPanelPositionData(markerMarker, this._mentionsView.position),
|
329
|
-
singleViewMode: true
|
330
|
-
});
|
331
|
-
}
|
332
|
-
this._mentionsView.position = this._balloon.view.position;
|
333
|
-
this._mentionsView.selectFirst();
|
334
|
-
}
|
335
|
-
/**
|
336
|
-
* Hides the mentions balloon and removes the 'mention' marker from the markers collection.
|
337
|
-
*/
|
338
|
-
_hideUIAndRemoveMarker() {
|
339
|
-
// Remove the mention view from balloon before removing marker - it is used by balloon position target().
|
340
|
-
if (this._balloon.hasView(this._mentionsView)) {
|
341
|
-
// @if CK_DEBUG_MENTION // console.log( '%c[UI]%c Hiding the UI.', 'color: green', 'color: black' );
|
342
|
-
this._balloon.remove(this._mentionsView);
|
343
|
-
}
|
344
|
-
if (checkIfStillInCompletionMode(this.editor)) {
|
345
|
-
// @if CK_DEBUG_MENTION // console.log( '%c[Editing]%c Removing marker.', 'color: purple', 'color: black' );
|
346
|
-
this.editor.model.change(writer => writer.removeMarker('mention'));
|
347
|
-
}
|
348
|
-
// Make the last matched position on panel view undefined so the #_getBalloonPanelPositionData() method will return all positions
|
349
|
-
// on the next call.
|
350
|
-
this._mentionsView.position = undefined;
|
351
|
-
}
|
352
|
-
/**
|
353
|
-
* Renders a single item in the autocomplete list.
|
354
|
-
*/
|
355
|
-
_renderItem(item, marker) {
|
356
|
-
const editor = this.editor;
|
357
|
-
let view;
|
358
|
-
let label = item.id;
|
359
|
-
const renderer = this._getItemRenderer(marker);
|
360
|
-
if (renderer) {
|
361
|
-
const renderResult = renderer(item);
|
362
|
-
if (typeof renderResult != 'string') {
|
363
|
-
view = new DomWrapperView(editor.locale, renderResult);
|
364
|
-
}
|
365
|
-
else {
|
366
|
-
label = renderResult;
|
367
|
-
}
|
368
|
-
}
|
369
|
-
if (!view) {
|
370
|
-
const buttonView = new ButtonView(editor.locale);
|
371
|
-
buttonView.label = label;
|
372
|
-
buttonView.withText = true;
|
373
|
-
view = buttonView;
|
374
|
-
}
|
375
|
-
return view;
|
376
|
-
}
|
377
|
-
/**
|
378
|
-
* Creates a position options object used to position the balloon panel.
|
379
|
-
*
|
380
|
-
* @param mentionMarker
|
381
|
-
* @param preferredPosition The name of the last matched position name.
|
382
|
-
*/
|
383
|
-
_getBalloonPanelPositionData(mentionMarker, preferredPosition) {
|
384
|
-
const editor = this.editor;
|
385
|
-
const editing = editor.editing;
|
386
|
-
const domConverter = editing.view.domConverter;
|
387
|
-
const mapper = editing.mapper;
|
388
|
-
const uiLanguageDirection = editor.locale.uiLanguageDirection;
|
389
|
-
return {
|
390
|
-
target: () => {
|
391
|
-
let modelRange = mentionMarker.getRange();
|
392
|
-
// Target the UI to the model selection range - the marker has been removed so probably the UI will not be shown anyway.
|
393
|
-
// The logic is used by ContextualBalloon to display another panel in the same place.
|
394
|
-
if (modelRange.start.root.rootName == '$graveyard') {
|
395
|
-
modelRange = editor.model.document.selection.getFirstRange();
|
396
|
-
}
|
397
|
-
const viewRange = mapper.toViewRange(modelRange);
|
398
|
-
const rangeRects = Rect.getDomRangeRects(domConverter.viewRangeToDom(viewRange));
|
399
|
-
return rangeRects.pop();
|
400
|
-
},
|
401
|
-
limiter: () => {
|
402
|
-
const view = this.editor.editing.view;
|
403
|
-
const viewDocument = view.document;
|
404
|
-
const editableElement = viewDocument.selection.editableElement;
|
405
|
-
if (editableElement) {
|
406
|
-
return view.domConverter.mapViewToDom(editableElement.root);
|
407
|
-
}
|
408
|
-
return null;
|
409
|
-
},
|
410
|
-
positions: getBalloonPanelPositions(preferredPosition, uiLanguageDirection)
|
411
|
-
};
|
412
|
-
}
|
413
|
-
}
|
414
|
-
/**
|
415
|
-
* Returns the balloon positions data callbacks.
|
416
|
-
*/
|
417
|
-
function getBalloonPanelPositions(preferredPosition, uiLanguageDirection) {
|
418
|
-
const positions = {
|
419
|
-
// Positions the panel to the southeast of the caret rectangle.
|
420
|
-
'caret_se': (targetRect) => {
|
421
|
-
return {
|
422
|
-
top: targetRect.bottom + VERTICAL_SPACING,
|
423
|
-
left: targetRect.right,
|
424
|
-
name: 'caret_se',
|
425
|
-
config: {
|
426
|
-
withArrow: false
|
427
|
-
}
|
428
|
-
};
|
429
|
-
},
|
430
|
-
// Positions the panel to the northeast of the caret rectangle.
|
431
|
-
'caret_ne': (targetRect, balloonRect) => {
|
432
|
-
return {
|
433
|
-
top: targetRect.top - balloonRect.height - VERTICAL_SPACING,
|
434
|
-
left: targetRect.right,
|
435
|
-
name: 'caret_ne',
|
436
|
-
config: {
|
437
|
-
withArrow: false
|
438
|
-
}
|
439
|
-
};
|
440
|
-
},
|
441
|
-
// Positions the panel to the southwest of the caret rectangle.
|
442
|
-
'caret_sw': (targetRect, balloonRect) => {
|
443
|
-
return {
|
444
|
-
top: targetRect.bottom + VERTICAL_SPACING,
|
445
|
-
left: targetRect.right - balloonRect.width,
|
446
|
-
name: 'caret_sw',
|
447
|
-
config: {
|
448
|
-
withArrow: false
|
449
|
-
}
|
450
|
-
};
|
451
|
-
},
|
452
|
-
// Positions the panel to the northwest of the caret rect.
|
453
|
-
'caret_nw': (targetRect, balloonRect) => {
|
454
|
-
return {
|
455
|
-
top: targetRect.top - balloonRect.height - VERTICAL_SPACING,
|
456
|
-
left: targetRect.right - balloonRect.width,
|
457
|
-
name: 'caret_nw',
|
458
|
-
config: {
|
459
|
-
withArrow: false
|
460
|
-
}
|
461
|
-
};
|
462
|
-
}
|
463
|
-
};
|
464
|
-
// Returns only the last position if it was matched to prevent the panel from jumping after the first match.
|
465
|
-
if (Object.prototype.hasOwnProperty.call(positions, preferredPosition)) {
|
466
|
-
return [
|
467
|
-
positions[preferredPosition]
|
468
|
-
];
|
469
|
-
}
|
470
|
-
// By default, return all position callbacks ordered depending on the UI language direction.
|
471
|
-
return uiLanguageDirection !== 'rtl' ? [
|
472
|
-
positions.caret_se,
|
473
|
-
positions.caret_sw,
|
474
|
-
positions.caret_ne,
|
475
|
-
positions.caret_nw
|
476
|
-
] : [
|
477
|
-
positions.caret_sw,
|
478
|
-
positions.caret_se,
|
479
|
-
positions.caret_nw,
|
480
|
-
positions.caret_ne
|
481
|
-
];
|
482
|
-
}
|
483
|
-
/**
|
484
|
-
* Returns a marker definition of the last valid occurring marker in a given string.
|
485
|
-
* If there is no valid marker in a string, it returns undefined.
|
486
|
-
*
|
487
|
-
* Example of returned object:
|
488
|
-
*
|
489
|
-
* ```ts
|
490
|
-
* {
|
491
|
-
* marker: '@',
|
492
|
-
* position: 4,
|
493
|
-
* minimumCharacters: 0
|
494
|
-
* }
|
495
|
-
* ````
|
496
|
-
*
|
497
|
-
* @param feedsWithPattern Registered feeds in editor for mention plugin with created RegExp for matching marker.
|
498
|
-
* @param text String to find the marker in
|
499
|
-
* @returns Matched marker's definition
|
500
|
-
*/
|
501
|
-
function getLastValidMarkerInText(feedsWithPattern, text) {
|
502
|
-
let lastValidMarker;
|
503
|
-
for (const feed of feedsWithPattern) {
|
504
|
-
const currentMarkerLastIndex = text.lastIndexOf(feed.marker);
|
505
|
-
if (currentMarkerLastIndex > 0 && !text.substring(currentMarkerLastIndex - 1).match(feed.pattern)) {
|
506
|
-
continue;
|
507
|
-
}
|
508
|
-
if (!lastValidMarker || currentMarkerLastIndex >= lastValidMarker.position) {
|
509
|
-
lastValidMarker = {
|
510
|
-
marker: feed.marker,
|
511
|
-
position: currentMarkerLastIndex,
|
512
|
-
minimumCharacters: feed.minimumCharacters,
|
513
|
-
pattern: feed.pattern
|
514
|
-
};
|
515
|
-
}
|
516
|
-
}
|
517
|
-
return lastValidMarker;
|
518
|
-
}
|
519
|
-
/**
|
520
|
-
* Creates a RegExp pattern for the marker.
|
521
|
-
*
|
522
|
-
* Function has to be exported to achieve 100% code coverage.
|
523
|
-
*/
|
524
|
-
export function createRegExp(marker, minimumCharacters) {
|
525
|
-
const numberOfCharacters = minimumCharacters == 0 ? '*' : `{${minimumCharacters},}`;
|
526
|
-
const openAfterCharacters = env.features.isRegExpUnicodePropertySupported ? '\\p{Ps}\\p{Pi}"\'' : '\\(\\[{"\'';
|
527
|
-
const mentionCharacters = '.';
|
528
|
-
// The pattern consists of 3 groups:
|
529
|
-
// - 0 (non-capturing): Opening sequence - start of the line, space or an opening punctuation character like "(" or "\"",
|
530
|
-
// - 1: The marker character,
|
531
|
-
// - 2: Mention input (taking the minimal length into consideration to trigger the UI),
|
532
|
-
//
|
533
|
-
// The pattern matches up to the caret (end of string switch - $).
|
534
|
-
// (0: opening sequence )(1: marker )(2: typed mention )$
|
535
|
-
const pattern = `(?:^|[ ${openAfterCharacters}])([${marker}])(${mentionCharacters}${numberOfCharacters})$`;
|
536
|
-
return new RegExp(pattern, 'u');
|
537
|
-
}
|
538
|
-
/**
|
539
|
-
* Creates a test callback for the marker to be used in the text watcher instance.
|
540
|
-
*
|
541
|
-
* @param feedsWithPattern Feeds of mention plugin configured in editor with RegExp to match marker in text
|
542
|
-
*/
|
543
|
-
function createTestCallback(feedsWithPattern) {
|
544
|
-
const textMatcher = (text) => {
|
545
|
-
const markerDefinition = getLastValidMarkerInText(feedsWithPattern, text);
|
546
|
-
if (!markerDefinition) {
|
547
|
-
return false;
|
548
|
-
}
|
549
|
-
let splitStringFrom = 0;
|
550
|
-
if (markerDefinition.position !== 0) {
|
551
|
-
splitStringFrom = markerDefinition.position - 1;
|
552
|
-
}
|
553
|
-
const textToTest = text.substring(splitStringFrom);
|
554
|
-
return markerDefinition.pattern.test(textToTest);
|
555
|
-
};
|
556
|
-
return textMatcher;
|
557
|
-
}
|
558
|
-
/**
|
559
|
-
* Creates a text matcher from the marker.
|
560
|
-
*/
|
561
|
-
function requestFeedText(markerDefinition, text) {
|
562
|
-
let splitStringFrom = 0;
|
563
|
-
if (markerDefinition.position !== 0) {
|
564
|
-
splitStringFrom = markerDefinition.position - 1;
|
565
|
-
}
|
566
|
-
const regExp = createRegExp(markerDefinition.marker, 0);
|
567
|
-
const textToMatch = text.substring(splitStringFrom);
|
568
|
-
const match = textToMatch.match(regExp);
|
569
|
-
return match[2];
|
570
|
-
}
|
571
|
-
/**
|
572
|
-
* The default feed callback.
|
573
|
-
*/
|
574
|
-
function createFeedCallback(feedItems) {
|
575
|
-
return (feedText) => {
|
576
|
-
const filteredItems = feedItems
|
577
|
-
// Make the default mention feed case-insensitive.
|
578
|
-
.filter(item => {
|
579
|
-
// Item might be defined as object.
|
580
|
-
const itemId = typeof item == 'string' ? item : String(item.id);
|
581
|
-
// The default feed is case insensitive.
|
582
|
-
return itemId.toLowerCase().includes(feedText.toLowerCase());
|
583
|
-
});
|
584
|
-
return filteredItems;
|
585
|
-
};
|
586
|
-
}
|
587
|
-
/**
|
588
|
-
* Checks if position in inside or right after a text with a mention.
|
589
|
-
*/
|
590
|
-
function isPositionInExistingMention(position) {
|
591
|
-
// The text watcher listens only to changed range in selection - so the selection attributes are not yet available
|
592
|
-
// and you cannot use selection.hasAttribute( 'mention' ) just yet.
|
593
|
-
// See https://github.com/ckeditor/ckeditor5-engine/issues/1723.
|
594
|
-
const hasMention = position.textNode && position.textNode.hasAttribute('mention');
|
595
|
-
const nodeBefore = position.nodeBefore;
|
596
|
-
return hasMention || nodeBefore && nodeBefore.is('$text') && nodeBefore.hasAttribute('mention');
|
597
|
-
}
|
598
|
-
/**
|
599
|
-
* Checks if the closest marker offset is at the beginning of a mention.
|
600
|
-
*
|
601
|
-
* See https://github.com/ckeditor/ckeditor5/issues/11400.
|
602
|
-
*/
|
603
|
-
function isMarkerInExistingMention(markerPosition) {
|
604
|
-
const nodeAfter = markerPosition.nodeAfter;
|
605
|
-
return nodeAfter && nodeAfter.is('$text') && nodeAfter.hasAttribute('mention');
|
606
|
-
}
|
607
|
-
/**
|
608
|
-
* Checks if string is a valid mention marker.
|
609
|
-
*/
|
610
|
-
function isValidMentionMarker(marker) {
|
611
|
-
return marker && marker.length == 1;
|
612
|
-
}
|
613
|
-
/**
|
614
|
-
* Checks the mention plugins is in completion mode (e.g. when typing is after a valid mention string like @foo).
|
615
|
-
*/
|
616
|
-
function checkIfStillInCompletionMode(editor) {
|
617
|
-
return editor.model.markers.has('mention');
|
618
|
-
}
|
1
|
+
/**
|
2
|
+
* @license Copyright (c) 2003-2023, 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
|
+
/**
|
6
|
+
* @module mention/mentionui
|
7
|
+
*/
|
8
|
+
import { Plugin } from 'ckeditor5/src/core';
|
9
|
+
import { ButtonView, ContextualBalloon, clickOutsideHandler } from 'ckeditor5/src/ui';
|
10
|
+
import { CKEditorError, Collection, Rect, env, keyCodes, logWarning } from 'ckeditor5/src/utils';
|
11
|
+
import { TextWatcher } from 'ckeditor5/src/typing';
|
12
|
+
import { debounce } from 'lodash-es';
|
13
|
+
import MentionsView from './ui/mentionsview';
|
14
|
+
import DomWrapperView from './ui/domwrapperview';
|
15
|
+
import MentionListItemView from './ui/mentionlistitemview';
|
16
|
+
const VERTICAL_SPACING = 3;
|
17
|
+
// The key codes that mention UI handles when it is open (without commit keys).
|
18
|
+
const defaultHandledKeyCodes = [
|
19
|
+
keyCodes.arrowup,
|
20
|
+
keyCodes.arrowdown,
|
21
|
+
keyCodes.esc
|
22
|
+
];
|
23
|
+
// Dropdown commit key codes.
|
24
|
+
const defaultCommitKeyCodes = [
|
25
|
+
keyCodes.enter,
|
26
|
+
keyCodes.tab
|
27
|
+
];
|
28
|
+
/**
|
29
|
+
* The mention UI feature.
|
30
|
+
*/
|
31
|
+
export default class MentionUI extends Plugin {
|
32
|
+
/**
|
33
|
+
* @inheritDoc
|
34
|
+
*/
|
35
|
+
static get pluginName() {
|
36
|
+
return 'MentionUI';
|
37
|
+
}
|
38
|
+
/**
|
39
|
+
* @inheritDoc
|
40
|
+
*/
|
41
|
+
static get requires() {
|
42
|
+
return [ContextualBalloon];
|
43
|
+
}
|
44
|
+
/**
|
45
|
+
* @inheritDoc
|
46
|
+
*/
|
47
|
+
constructor(editor) {
|
48
|
+
super(editor);
|
49
|
+
this._items = new Collection();
|
50
|
+
this._mentionsView = this._createMentionView();
|
51
|
+
this._mentionsConfigurations = new Map();
|
52
|
+
this._requestFeedDebounced = debounce(this._requestFeed, 100);
|
53
|
+
editor.config.define('mention', { feeds: [] });
|
54
|
+
}
|
55
|
+
/**
|
56
|
+
* @inheritDoc
|
57
|
+
*/
|
58
|
+
init() {
|
59
|
+
const editor = this.editor;
|
60
|
+
const commitKeys = editor.config.get('mention.commitKeys') || defaultCommitKeyCodes;
|
61
|
+
const handledKeyCodes = defaultHandledKeyCodes.concat(commitKeys);
|
62
|
+
this._balloon = editor.plugins.get(ContextualBalloon);
|
63
|
+
// Key listener that handles navigation in mention view.
|
64
|
+
editor.editing.view.document.on('keydown', (evt, data) => {
|
65
|
+
if (isHandledKey(data.keyCode) && this._isUIVisible) {
|
66
|
+
data.preventDefault();
|
67
|
+
evt.stop(); // Required for Enter key overriding.
|
68
|
+
if (data.keyCode == keyCodes.arrowdown) {
|
69
|
+
this._mentionsView.selectNext();
|
70
|
+
}
|
71
|
+
if (data.keyCode == keyCodes.arrowup) {
|
72
|
+
this._mentionsView.selectPrevious();
|
73
|
+
}
|
74
|
+
if (commitKeys.includes(data.keyCode)) {
|
75
|
+
this._mentionsView.executeSelected();
|
76
|
+
}
|
77
|
+
if (data.keyCode == keyCodes.esc) {
|
78
|
+
this._hideUIAndRemoveMarker();
|
79
|
+
}
|
80
|
+
}
|
81
|
+
}, { priority: 'highest' }); // Required to override the Enter key.
|
82
|
+
// Close the dropdown upon clicking outside of the plugin UI.
|
83
|
+
clickOutsideHandler({
|
84
|
+
emitter: this._mentionsView,
|
85
|
+
activator: () => this._isUIVisible,
|
86
|
+
contextElements: () => [this._balloon.view.element],
|
87
|
+
callback: () => this._hideUIAndRemoveMarker()
|
88
|
+
});
|
89
|
+
const feeds = editor.config.get('mention.feeds');
|
90
|
+
for (const mentionDescription of feeds) {
|
91
|
+
const { feed, marker, dropdownLimit } = mentionDescription;
|
92
|
+
if (!isValidMentionMarker(marker)) {
|
93
|
+
/**
|
94
|
+
* The marker must be a single character.
|
95
|
+
*
|
96
|
+
* Correct markers: `'@'`, `'#'`.
|
97
|
+
*
|
98
|
+
* Incorrect markers: `'$$'`, `'[@'`.
|
99
|
+
*
|
100
|
+
* See {@link module:mention/mentionconfig~MentionConfig}.
|
101
|
+
*
|
102
|
+
* @error mentionconfig-incorrect-marker
|
103
|
+
* @param marker Configured marker
|
104
|
+
*/
|
105
|
+
throw new CKEditorError('mentionconfig-incorrect-marker', null, { marker });
|
106
|
+
}
|
107
|
+
const feedCallback = typeof feed == 'function' ? feed.bind(this.editor) : createFeedCallback(feed);
|
108
|
+
const itemRenderer = mentionDescription.itemRenderer;
|
109
|
+
const definition = { marker, feedCallback, itemRenderer, dropdownLimit };
|
110
|
+
this._mentionsConfigurations.set(marker, definition);
|
111
|
+
}
|
112
|
+
this._setupTextWatcher(feeds);
|
113
|
+
this.listenTo(editor, 'change:isReadOnly', () => {
|
114
|
+
this._hideUIAndRemoveMarker();
|
115
|
+
});
|
116
|
+
this.on('requestFeed:response', (evt, data) => this._handleFeedResponse(data));
|
117
|
+
this.on('requestFeed:error', () => this._hideUIAndRemoveMarker());
|
118
|
+
/**
|
119
|
+
* Checks if a given key code is handled by the mention UI.
|
120
|
+
*/
|
121
|
+
function isHandledKey(keyCode) {
|
122
|
+
return handledKeyCodes.includes(keyCode);
|
123
|
+
}
|
124
|
+
}
|
125
|
+
/**
|
126
|
+
* @inheritDoc
|
127
|
+
*/
|
128
|
+
destroy() {
|
129
|
+
super.destroy();
|
130
|
+
// Destroy created UI components as they are not automatically destroyed (see ckeditor5#1341).
|
131
|
+
this._mentionsView.destroy();
|
132
|
+
}
|
133
|
+
/**
|
134
|
+
* Returns true when {@link #_mentionsView} is in the {@link module:ui/panel/balloon/contextualballoon~ContextualBalloon} and it is
|
135
|
+
* currently visible.
|
136
|
+
*/
|
137
|
+
get _isUIVisible() {
|
138
|
+
return this._balloon.visibleView === this._mentionsView;
|
139
|
+
}
|
140
|
+
/**
|
141
|
+
* Creates the {@link #_mentionsView}.
|
142
|
+
*/
|
143
|
+
_createMentionView() {
|
144
|
+
const locale = this.editor.locale;
|
145
|
+
const mentionsView = new MentionsView(locale);
|
146
|
+
mentionsView.items.bindTo(this._items).using(data => {
|
147
|
+
const { item, marker } = data;
|
148
|
+
const { dropdownLimit: markerDropdownLimit } = this._mentionsConfigurations.get(marker);
|
149
|
+
// Set to 10 by default for backwards compatibility. See: #10479
|
150
|
+
const dropdownLimit = markerDropdownLimit || this.editor.config.get('mention.dropdownLimit') || 10;
|
151
|
+
if (mentionsView.items.length >= dropdownLimit) {
|
152
|
+
return null;
|
153
|
+
}
|
154
|
+
const listItemView = new MentionListItemView(locale);
|
155
|
+
const view = this._renderItem(item, marker);
|
156
|
+
view.delegate('execute').to(listItemView);
|
157
|
+
listItemView.children.add(view);
|
158
|
+
listItemView.item = item;
|
159
|
+
listItemView.marker = marker;
|
160
|
+
listItemView.on('execute', () => {
|
161
|
+
mentionsView.fire('execute', {
|
162
|
+
item,
|
163
|
+
marker
|
164
|
+
});
|
165
|
+
});
|
166
|
+
return listItemView;
|
167
|
+
});
|
168
|
+
mentionsView.on('execute', (evt, data) => {
|
169
|
+
const editor = this.editor;
|
170
|
+
const model = editor.model;
|
171
|
+
const item = data.item;
|
172
|
+
const marker = data.marker;
|
173
|
+
const mentionMarker = editor.model.markers.get('mention');
|
174
|
+
// Create a range on matched text.
|
175
|
+
const end = model.createPositionAt(model.document.selection.focus);
|
176
|
+
const start = model.createPositionAt(mentionMarker.getStart());
|
177
|
+
const range = model.createRange(start, end);
|
178
|
+
this._hideUIAndRemoveMarker();
|
179
|
+
editor.execute('mention', {
|
180
|
+
mention: item,
|
181
|
+
text: item.text,
|
182
|
+
marker,
|
183
|
+
range
|
184
|
+
});
|
185
|
+
editor.editing.view.focus();
|
186
|
+
});
|
187
|
+
return mentionsView;
|
188
|
+
}
|
189
|
+
/**
|
190
|
+
* Returns item renderer for the marker.
|
191
|
+
*/
|
192
|
+
_getItemRenderer(marker) {
|
193
|
+
const { itemRenderer } = this._mentionsConfigurations.get(marker);
|
194
|
+
return itemRenderer;
|
195
|
+
}
|
196
|
+
/**
|
197
|
+
* Requests a feed from a configured callbacks.
|
198
|
+
*/
|
199
|
+
_requestFeed(marker, feedText) {
|
200
|
+
// @if CK_DEBUG_MENTION // console.log( '%c[Feed]%c Requesting for', 'color: blue', 'color: black', `"${ feedText }"` );
|
201
|
+
// Store the last requested feed - it is used to discard any out-of order requests.
|
202
|
+
this._lastRequested = feedText;
|
203
|
+
const { feedCallback } = this._mentionsConfigurations.get(marker);
|
204
|
+
const feedResponse = feedCallback(feedText);
|
205
|
+
const isAsynchronous = feedResponse instanceof Promise;
|
206
|
+
// For synchronous feeds (e.g. callbacks, arrays) fire the response event immediately.
|
207
|
+
if (!isAsynchronous) {
|
208
|
+
this.fire('requestFeed:response', { feed: feedResponse, marker, feedText });
|
209
|
+
return;
|
210
|
+
}
|
211
|
+
// Handle the asynchronous responses.
|
212
|
+
feedResponse
|
213
|
+
.then(response => {
|
214
|
+
// Check the feed text of this response with the last requested one so either:
|
215
|
+
if (this._lastRequested == feedText) {
|
216
|
+
// It is the same and fire the response event.
|
217
|
+
this.fire('requestFeed:response', { feed: response, marker, feedText });
|
218
|
+
}
|
219
|
+
else {
|
220
|
+
// It is different - most probably out-of-order one, so fire the discarded event.
|
221
|
+
this.fire('requestFeed:discarded', { feed: response, marker, feedText });
|
222
|
+
}
|
223
|
+
})
|
224
|
+
.catch(error => {
|
225
|
+
this.fire('requestFeed:error', { error });
|
226
|
+
/**
|
227
|
+
* The callback used for obtaining mention autocomplete feed thrown and error and the mention UI was hidden or
|
228
|
+
* not displayed at all.
|
229
|
+
*
|
230
|
+
* @error mention-feed-callback-error
|
231
|
+
*/
|
232
|
+
logWarning('mention-feed-callback-error', { marker });
|
233
|
+
});
|
234
|
+
}
|
235
|
+
/**
|
236
|
+
* Registers a text watcher for the marker.
|
237
|
+
*/
|
238
|
+
_setupTextWatcher(feeds) {
|
239
|
+
const editor = this.editor;
|
240
|
+
const feedsWithPattern = feeds.map(feed => ({
|
241
|
+
...feed,
|
242
|
+
pattern: createRegExp(feed.marker, feed.minimumCharacters || 0)
|
243
|
+
}));
|
244
|
+
const watcher = new TextWatcher(editor.model, createTestCallback(feedsWithPattern));
|
245
|
+
watcher.on('matched', (evt, data) => {
|
246
|
+
const markerDefinition = getLastValidMarkerInText(feedsWithPattern, data.text);
|
247
|
+
const selection = editor.model.document.selection;
|
248
|
+
const focus = selection.focus;
|
249
|
+
const markerPosition = editor.model.createPositionAt(focus.parent, markerDefinition.position);
|
250
|
+
if (isPositionInExistingMention(focus) || isMarkerInExistingMention(markerPosition)) {
|
251
|
+
this._hideUIAndRemoveMarker();
|
252
|
+
return;
|
253
|
+
}
|
254
|
+
const feedText = requestFeedText(markerDefinition, data.text);
|
255
|
+
const matchedTextLength = markerDefinition.marker.length + feedText.length;
|
256
|
+
// Create a marker range.
|
257
|
+
const start = focus.getShiftedBy(-matchedTextLength);
|
258
|
+
const end = focus.getShiftedBy(-feedText.length);
|
259
|
+
const markerRange = editor.model.createRange(start, end);
|
260
|
+
// @if CK_DEBUG_MENTION // console.group( '%c[TextWatcher]%c matched', 'color: red', 'color: black', `"${ feedText }"` );
|
261
|
+
// @if CK_DEBUG_MENTION // console.log( 'data#text', `"${ data.text }"` );
|
262
|
+
// @if CK_DEBUG_MENTION // console.log( 'data#range', data.range.start.path, data.range.end.path );
|
263
|
+
// @if CK_DEBUG_MENTION // console.log( 'marker definition', markerDefinition );
|
264
|
+
// @if CK_DEBUG_MENTION // console.log( 'marker range', markerRange.start.path, markerRange.end.path );
|
265
|
+
if (checkIfStillInCompletionMode(editor)) {
|
266
|
+
const mentionMarker = editor.model.markers.get('mention');
|
267
|
+
// Update the marker - user might've moved the selection to other mention trigger.
|
268
|
+
editor.model.change(writer => {
|
269
|
+
// @if CK_DEBUG_MENTION // console.log( '%c[Editing]%c Updating the marker.', 'color: purple', 'color: black' );
|
270
|
+
writer.updateMarker(mentionMarker, { range: markerRange });
|
271
|
+
});
|
272
|
+
}
|
273
|
+
else {
|
274
|
+
editor.model.change(writer => {
|
275
|
+
// @if CK_DEBUG_MENTION // console.log( '%c[Editing]%c Adding the marker.', 'color: purple', 'color: black' );
|
276
|
+
writer.addMarker('mention', { range: markerRange, usingOperation: false, affectsData: false });
|
277
|
+
});
|
278
|
+
}
|
279
|
+
this._requestFeedDebounced(markerDefinition.marker, feedText);
|
280
|
+
// @if CK_DEBUG_MENTION // console.groupEnd();
|
281
|
+
});
|
282
|
+
watcher.on('unmatched', () => {
|
283
|
+
this._hideUIAndRemoveMarker();
|
284
|
+
});
|
285
|
+
const mentionCommand = editor.commands.get('mention');
|
286
|
+
watcher.bind('isEnabled').to(mentionCommand);
|
287
|
+
return watcher;
|
288
|
+
}
|
289
|
+
/**
|
290
|
+
* Handles the feed response event data.
|
291
|
+
*/
|
292
|
+
_handleFeedResponse(data) {
|
293
|
+
const { feed, marker } = data;
|
294
|
+
// eslint-disable-next-line max-len
|
295
|
+
// @if CK_DEBUG_MENTION // console.log( `%c[Feed]%c Response for "${ data.feedText }" (${ feed.length })`, 'color: blue', 'color: black', feed );
|
296
|
+
// If the marker is not in the document happens when the selection had changed and the 'mention' marker was removed.
|
297
|
+
if (!checkIfStillInCompletionMode(this.editor)) {
|
298
|
+
return;
|
299
|
+
}
|
300
|
+
// Reset the view.
|
301
|
+
this._items.clear();
|
302
|
+
for (const feedItem of feed) {
|
303
|
+
const item = typeof feedItem != 'object' ? { id: feedItem, text: feedItem } : feedItem;
|
304
|
+
this._items.add({ item, marker });
|
305
|
+
}
|
306
|
+
const mentionMarker = this.editor.model.markers.get('mention');
|
307
|
+
if (this._items.length) {
|
308
|
+
this._showOrUpdateUI(mentionMarker);
|
309
|
+
}
|
310
|
+
else {
|
311
|
+
// Do not show empty mention UI.
|
312
|
+
this._hideUIAndRemoveMarker();
|
313
|
+
}
|
314
|
+
}
|
315
|
+
/**
|
316
|
+
* Shows the mentions balloon. If the panel is already visible, it will reposition it.
|
317
|
+
*/
|
318
|
+
_showOrUpdateUI(markerMarker) {
|
319
|
+
if (this._isUIVisible) {
|
320
|
+
// @if CK_DEBUG_MENTION // console.log( '%c[UI]%c Updating position.', 'color: green', 'color: black' );
|
321
|
+
// Update balloon position as the mention list view may change its size.
|
322
|
+
this._balloon.updatePosition(this._getBalloonPanelPositionData(markerMarker, this._mentionsView.position));
|
323
|
+
}
|
324
|
+
else {
|
325
|
+
// @if CK_DEBUG_MENTION // console.log( '%c[UI]%c Showing the UI.', 'color: green', 'color: black' );
|
326
|
+
this._balloon.add({
|
327
|
+
view: this._mentionsView,
|
328
|
+
position: this._getBalloonPanelPositionData(markerMarker, this._mentionsView.position),
|
329
|
+
singleViewMode: true
|
330
|
+
});
|
331
|
+
}
|
332
|
+
this._mentionsView.position = this._balloon.view.position;
|
333
|
+
this._mentionsView.selectFirst();
|
334
|
+
}
|
335
|
+
/**
|
336
|
+
* Hides the mentions balloon and removes the 'mention' marker from the markers collection.
|
337
|
+
*/
|
338
|
+
_hideUIAndRemoveMarker() {
|
339
|
+
// Remove the mention view from balloon before removing marker - it is used by balloon position target().
|
340
|
+
if (this._balloon.hasView(this._mentionsView)) {
|
341
|
+
// @if CK_DEBUG_MENTION // console.log( '%c[UI]%c Hiding the UI.', 'color: green', 'color: black' );
|
342
|
+
this._balloon.remove(this._mentionsView);
|
343
|
+
}
|
344
|
+
if (checkIfStillInCompletionMode(this.editor)) {
|
345
|
+
// @if CK_DEBUG_MENTION // console.log( '%c[Editing]%c Removing marker.', 'color: purple', 'color: black' );
|
346
|
+
this.editor.model.change(writer => writer.removeMarker('mention'));
|
347
|
+
}
|
348
|
+
// Make the last matched position on panel view undefined so the #_getBalloonPanelPositionData() method will return all positions
|
349
|
+
// on the next call.
|
350
|
+
this._mentionsView.position = undefined;
|
351
|
+
}
|
352
|
+
/**
|
353
|
+
* Renders a single item in the autocomplete list.
|
354
|
+
*/
|
355
|
+
_renderItem(item, marker) {
|
356
|
+
const editor = this.editor;
|
357
|
+
let view;
|
358
|
+
let label = item.id;
|
359
|
+
const renderer = this._getItemRenderer(marker);
|
360
|
+
if (renderer) {
|
361
|
+
const renderResult = renderer(item);
|
362
|
+
if (typeof renderResult != 'string') {
|
363
|
+
view = new DomWrapperView(editor.locale, renderResult);
|
364
|
+
}
|
365
|
+
else {
|
366
|
+
label = renderResult;
|
367
|
+
}
|
368
|
+
}
|
369
|
+
if (!view) {
|
370
|
+
const buttonView = new ButtonView(editor.locale);
|
371
|
+
buttonView.label = label;
|
372
|
+
buttonView.withText = true;
|
373
|
+
view = buttonView;
|
374
|
+
}
|
375
|
+
return view;
|
376
|
+
}
|
377
|
+
/**
|
378
|
+
* Creates a position options object used to position the balloon panel.
|
379
|
+
*
|
380
|
+
* @param mentionMarker
|
381
|
+
* @param preferredPosition The name of the last matched position name.
|
382
|
+
*/
|
383
|
+
_getBalloonPanelPositionData(mentionMarker, preferredPosition) {
|
384
|
+
const editor = this.editor;
|
385
|
+
const editing = editor.editing;
|
386
|
+
const domConverter = editing.view.domConverter;
|
387
|
+
const mapper = editing.mapper;
|
388
|
+
const uiLanguageDirection = editor.locale.uiLanguageDirection;
|
389
|
+
return {
|
390
|
+
target: () => {
|
391
|
+
let modelRange = mentionMarker.getRange();
|
392
|
+
// Target the UI to the model selection range - the marker has been removed so probably the UI will not be shown anyway.
|
393
|
+
// The logic is used by ContextualBalloon to display another panel in the same place.
|
394
|
+
if (modelRange.start.root.rootName == '$graveyard') {
|
395
|
+
modelRange = editor.model.document.selection.getFirstRange();
|
396
|
+
}
|
397
|
+
const viewRange = mapper.toViewRange(modelRange);
|
398
|
+
const rangeRects = Rect.getDomRangeRects(domConverter.viewRangeToDom(viewRange));
|
399
|
+
return rangeRects.pop();
|
400
|
+
},
|
401
|
+
limiter: () => {
|
402
|
+
const view = this.editor.editing.view;
|
403
|
+
const viewDocument = view.document;
|
404
|
+
const editableElement = viewDocument.selection.editableElement;
|
405
|
+
if (editableElement) {
|
406
|
+
return view.domConverter.mapViewToDom(editableElement.root);
|
407
|
+
}
|
408
|
+
return null;
|
409
|
+
},
|
410
|
+
positions: getBalloonPanelPositions(preferredPosition, uiLanguageDirection)
|
411
|
+
};
|
412
|
+
}
|
413
|
+
}
|
414
|
+
/**
|
415
|
+
* Returns the balloon positions data callbacks.
|
416
|
+
*/
|
417
|
+
function getBalloonPanelPositions(preferredPosition, uiLanguageDirection) {
|
418
|
+
const positions = {
|
419
|
+
// Positions the panel to the southeast of the caret rectangle.
|
420
|
+
'caret_se': (targetRect) => {
|
421
|
+
return {
|
422
|
+
top: targetRect.bottom + VERTICAL_SPACING,
|
423
|
+
left: targetRect.right,
|
424
|
+
name: 'caret_se',
|
425
|
+
config: {
|
426
|
+
withArrow: false
|
427
|
+
}
|
428
|
+
};
|
429
|
+
},
|
430
|
+
// Positions the panel to the northeast of the caret rectangle.
|
431
|
+
'caret_ne': (targetRect, balloonRect) => {
|
432
|
+
return {
|
433
|
+
top: targetRect.top - balloonRect.height - VERTICAL_SPACING,
|
434
|
+
left: targetRect.right,
|
435
|
+
name: 'caret_ne',
|
436
|
+
config: {
|
437
|
+
withArrow: false
|
438
|
+
}
|
439
|
+
};
|
440
|
+
},
|
441
|
+
// Positions the panel to the southwest of the caret rectangle.
|
442
|
+
'caret_sw': (targetRect, balloonRect) => {
|
443
|
+
return {
|
444
|
+
top: targetRect.bottom + VERTICAL_SPACING,
|
445
|
+
left: targetRect.right - balloonRect.width,
|
446
|
+
name: 'caret_sw',
|
447
|
+
config: {
|
448
|
+
withArrow: false
|
449
|
+
}
|
450
|
+
};
|
451
|
+
},
|
452
|
+
// Positions the panel to the northwest of the caret rect.
|
453
|
+
'caret_nw': (targetRect, balloonRect) => {
|
454
|
+
return {
|
455
|
+
top: targetRect.top - balloonRect.height - VERTICAL_SPACING,
|
456
|
+
left: targetRect.right - balloonRect.width,
|
457
|
+
name: 'caret_nw',
|
458
|
+
config: {
|
459
|
+
withArrow: false
|
460
|
+
}
|
461
|
+
};
|
462
|
+
}
|
463
|
+
};
|
464
|
+
// Returns only the last position if it was matched to prevent the panel from jumping after the first match.
|
465
|
+
if (Object.prototype.hasOwnProperty.call(positions, preferredPosition)) {
|
466
|
+
return [
|
467
|
+
positions[preferredPosition]
|
468
|
+
];
|
469
|
+
}
|
470
|
+
// By default, return all position callbacks ordered depending on the UI language direction.
|
471
|
+
return uiLanguageDirection !== 'rtl' ? [
|
472
|
+
positions.caret_se,
|
473
|
+
positions.caret_sw,
|
474
|
+
positions.caret_ne,
|
475
|
+
positions.caret_nw
|
476
|
+
] : [
|
477
|
+
positions.caret_sw,
|
478
|
+
positions.caret_se,
|
479
|
+
positions.caret_nw,
|
480
|
+
positions.caret_ne
|
481
|
+
];
|
482
|
+
}
|
483
|
+
/**
|
484
|
+
* Returns a marker definition of the last valid occurring marker in a given string.
|
485
|
+
* If there is no valid marker in a string, it returns undefined.
|
486
|
+
*
|
487
|
+
* Example of returned object:
|
488
|
+
*
|
489
|
+
* ```ts
|
490
|
+
* {
|
491
|
+
* marker: '@',
|
492
|
+
* position: 4,
|
493
|
+
* minimumCharacters: 0
|
494
|
+
* }
|
495
|
+
* ````
|
496
|
+
*
|
497
|
+
* @param feedsWithPattern Registered feeds in editor for mention plugin with created RegExp for matching marker.
|
498
|
+
* @param text String to find the marker in
|
499
|
+
* @returns Matched marker's definition
|
500
|
+
*/
|
501
|
+
function getLastValidMarkerInText(feedsWithPattern, text) {
|
502
|
+
let lastValidMarker;
|
503
|
+
for (const feed of feedsWithPattern) {
|
504
|
+
const currentMarkerLastIndex = text.lastIndexOf(feed.marker);
|
505
|
+
if (currentMarkerLastIndex > 0 && !text.substring(currentMarkerLastIndex - 1).match(feed.pattern)) {
|
506
|
+
continue;
|
507
|
+
}
|
508
|
+
if (!lastValidMarker || currentMarkerLastIndex >= lastValidMarker.position) {
|
509
|
+
lastValidMarker = {
|
510
|
+
marker: feed.marker,
|
511
|
+
position: currentMarkerLastIndex,
|
512
|
+
minimumCharacters: feed.minimumCharacters,
|
513
|
+
pattern: feed.pattern
|
514
|
+
};
|
515
|
+
}
|
516
|
+
}
|
517
|
+
return lastValidMarker;
|
518
|
+
}
|
519
|
+
/**
|
520
|
+
* Creates a RegExp pattern for the marker.
|
521
|
+
*
|
522
|
+
* Function has to be exported to achieve 100% code coverage.
|
523
|
+
*/
|
524
|
+
export function createRegExp(marker, minimumCharacters) {
|
525
|
+
const numberOfCharacters = minimumCharacters == 0 ? '*' : `{${minimumCharacters},}`;
|
526
|
+
const openAfterCharacters = env.features.isRegExpUnicodePropertySupported ? '\\p{Ps}\\p{Pi}"\'' : '\\(\\[{"\'';
|
527
|
+
const mentionCharacters = '.';
|
528
|
+
// The pattern consists of 3 groups:
|
529
|
+
// - 0 (non-capturing): Opening sequence - start of the line, space or an opening punctuation character like "(" or "\"",
|
530
|
+
// - 1: The marker character,
|
531
|
+
// - 2: Mention input (taking the minimal length into consideration to trigger the UI),
|
532
|
+
//
|
533
|
+
// The pattern matches up to the caret (end of string switch - $).
|
534
|
+
// (0: opening sequence )(1: marker )(2: typed mention )$
|
535
|
+
const pattern = `(?:^|[ ${openAfterCharacters}])([${marker}])(${mentionCharacters}${numberOfCharacters})$`;
|
536
|
+
return new RegExp(pattern, 'u');
|
537
|
+
}
|
538
|
+
/**
|
539
|
+
* Creates a test callback for the marker to be used in the text watcher instance.
|
540
|
+
*
|
541
|
+
* @param feedsWithPattern Feeds of mention plugin configured in editor with RegExp to match marker in text
|
542
|
+
*/
|
543
|
+
function createTestCallback(feedsWithPattern) {
|
544
|
+
const textMatcher = (text) => {
|
545
|
+
const markerDefinition = getLastValidMarkerInText(feedsWithPattern, text);
|
546
|
+
if (!markerDefinition) {
|
547
|
+
return false;
|
548
|
+
}
|
549
|
+
let splitStringFrom = 0;
|
550
|
+
if (markerDefinition.position !== 0) {
|
551
|
+
splitStringFrom = markerDefinition.position - 1;
|
552
|
+
}
|
553
|
+
const textToTest = text.substring(splitStringFrom);
|
554
|
+
return markerDefinition.pattern.test(textToTest);
|
555
|
+
};
|
556
|
+
return textMatcher;
|
557
|
+
}
|
558
|
+
/**
|
559
|
+
* Creates a text matcher from the marker.
|
560
|
+
*/
|
561
|
+
function requestFeedText(markerDefinition, text) {
|
562
|
+
let splitStringFrom = 0;
|
563
|
+
if (markerDefinition.position !== 0) {
|
564
|
+
splitStringFrom = markerDefinition.position - 1;
|
565
|
+
}
|
566
|
+
const regExp = createRegExp(markerDefinition.marker, 0);
|
567
|
+
const textToMatch = text.substring(splitStringFrom);
|
568
|
+
const match = textToMatch.match(regExp);
|
569
|
+
return match[2];
|
570
|
+
}
|
571
|
+
/**
|
572
|
+
* The default feed callback.
|
573
|
+
*/
|
574
|
+
function createFeedCallback(feedItems) {
|
575
|
+
return (feedText) => {
|
576
|
+
const filteredItems = feedItems
|
577
|
+
// Make the default mention feed case-insensitive.
|
578
|
+
.filter(item => {
|
579
|
+
// Item might be defined as object.
|
580
|
+
const itemId = typeof item == 'string' ? item : String(item.id);
|
581
|
+
// The default feed is case insensitive.
|
582
|
+
return itemId.toLowerCase().includes(feedText.toLowerCase());
|
583
|
+
});
|
584
|
+
return filteredItems;
|
585
|
+
};
|
586
|
+
}
|
587
|
+
/**
|
588
|
+
* Checks if position in inside or right after a text with a mention.
|
589
|
+
*/
|
590
|
+
function isPositionInExistingMention(position) {
|
591
|
+
// The text watcher listens only to changed range in selection - so the selection attributes are not yet available
|
592
|
+
// and you cannot use selection.hasAttribute( 'mention' ) just yet.
|
593
|
+
// See https://github.com/ckeditor/ckeditor5-engine/issues/1723.
|
594
|
+
const hasMention = position.textNode && position.textNode.hasAttribute('mention');
|
595
|
+
const nodeBefore = position.nodeBefore;
|
596
|
+
return hasMention || nodeBefore && nodeBefore.is('$text') && nodeBefore.hasAttribute('mention');
|
597
|
+
}
|
598
|
+
/**
|
599
|
+
* Checks if the closest marker offset is at the beginning of a mention.
|
600
|
+
*
|
601
|
+
* See https://github.com/ckeditor/ckeditor5/issues/11400.
|
602
|
+
*/
|
603
|
+
function isMarkerInExistingMention(markerPosition) {
|
604
|
+
const nodeAfter = markerPosition.nodeAfter;
|
605
|
+
return nodeAfter && nodeAfter.is('$text') && nodeAfter.hasAttribute('mention');
|
606
|
+
}
|
607
|
+
/**
|
608
|
+
* Checks if string is a valid mention marker.
|
609
|
+
*/
|
610
|
+
function isValidMentionMarker(marker) {
|
611
|
+
return marker && marker.length == 1;
|
612
|
+
}
|
613
|
+
/**
|
614
|
+
* Checks the mention plugins is in completion mode (e.g. when typing is after a valid mention string like @foo).
|
615
|
+
*/
|
616
|
+
function checkIfStillInCompletionMode(editor) {
|
617
|
+
return editor.model.markers.has('mention');
|
618
|
+
}
|