UNPKG

@ckeditor/ckeditor5-mention

Version:

Mention feature for CKEditor 5.

649 lines (648 loc) • 26.9 kB
/** * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options */ /** * @module mention/mentionui */ import { Plugin } from 'ckeditor5/src/core.js'; import { ButtonView, ContextualBalloon, clickOutsideHandler } from 'ckeditor5/src/ui.js'; import { CKEditorError, Collection, Rect, env, keyCodes, logWarning } from 'ckeditor5/src/utils.js'; import { TextWatcher } from 'ckeditor5/src/typing.js'; import { debounce } from 'es-toolkit/compat'; import MentionsView from './ui/mentionsview.js'; import DomWrapperView from './ui/domwrapperview.js'; import MentionListItemView from './ui/mentionlistitemview.js'; const VERTICAL_SPACING = 3; // The key codes that mention UI handles when it is open (without commit keys). const defaultHandledKeyCodes = [ keyCodes.arrowup, keyCodes.arrowdown, keyCodes.esc ]; // Dropdown commit key codes. const defaultCommitKeyCodes = [ keyCodes.enter, keyCodes.tab ]; /** * The mention UI feature. */ export default class MentionUI extends Plugin { /** * The mention view. */ _mentionsView; /** * Stores mention feeds configurations. */ _mentionsConfigurations; /** * The contextual balloon plugin instance. */ _balloon; _items = new Collection(); _lastRequested; /** * Debounced feed requester. It uses `es-toolkit#debounce` method to delay function call. */ _requestFeedDebounced; /** * @inheritDoc */ static get pluginName() { return 'MentionUI'; } /** * @inheritDoc */ static get isOfficialPlugin() { return true; } /** * @inheritDoc */ static get requires() { return [ContextualBalloon]; } /** * @inheritDoc */ constructor(editor) { super(editor); this._mentionsView = this._createMentionView(); this._mentionsConfigurations = new Map(); this._requestFeedDebounced = debounce(this._requestFeed, 100); editor.config.define('mention', { feeds: [] }); } /** * @inheritDoc */ init() { const editor = this.editor; const commitKeys = editor.config.get('mention.commitKeys') || defaultCommitKeyCodes; const handledKeyCodes = defaultHandledKeyCodes.concat(commitKeys); this._balloon = editor.plugins.get(ContextualBalloon); // Key listener that handles navigation in mention view. editor.editing.view.document.on('keydown', (evt, data) => { if (isHandledKey(data.keyCode) && this._isUIVisible) { data.preventDefault(); evt.stop(); // Required for Enter key overriding. if (data.keyCode == keyCodes.arrowdown) { this._mentionsView.selectNext(); } if (data.keyCode == keyCodes.arrowup) { this._mentionsView.selectPrevious(); } if (commitKeys.includes(data.keyCode)) { this._mentionsView.executeSelected(); } if (data.keyCode == keyCodes.esc) { this._hideUIAndRemoveMarker(); } } }, { priority: 'highest' }); // Required to override the Enter key. // Close the dropdown upon clicking outside of the plugin UI. clickOutsideHandler({ emitter: this._mentionsView, activator: () => this._isUIVisible, contextElements: () => [this._balloon.view.element], callback: () => this._hideUIAndRemoveMarker() }); const feeds = editor.config.get('mention.feeds'); for (const mentionDescription of feeds) { const { feed, marker, dropdownLimit } = mentionDescription; if (!isValidMentionMarker(marker)) { /** * The marker must be a single character. * * Correct markers: `'@'`, `'#'`. * * Incorrect markers: `'$$'`, `'[@'`. * * See {@link module:mention/mentionconfig~MentionConfig}. * * @error mentionconfig-incorrect-marker * @param {string} marker Configured marker */ throw new CKEditorError('mentionconfig-incorrect-marker', null, { marker }); } const feedCallback = typeof feed == 'function' ? feed.bind(this.editor) : createFeedCallback(feed); const itemRenderer = mentionDescription.itemRenderer; const definition = { marker, feedCallback, itemRenderer, dropdownLimit }; this._mentionsConfigurations.set(marker, definition); } this._setupTextWatcher(feeds); this.listenTo(editor, 'change:isReadOnly', () => { this._hideUIAndRemoveMarker(); }); this.on('requestFeed:response', (evt, data) => this._handleFeedResponse(data)); this.on('requestFeed:error', () => this._hideUIAndRemoveMarker()); /** * Checks if a given key code is handled by the mention UI. */ function isHandledKey(keyCode) { return handledKeyCodes.includes(keyCode); } } /** * @inheritDoc */ destroy() { super.destroy(); // Destroy created UI components as they are not automatically destroyed (see ckeditor5#1341). this._mentionsView.destroy(); } /** * Returns true when {@link #_mentionsView} is in the {@link module:ui/panel/balloon/contextualballoon~ContextualBalloon} and it is * currently visible. */ get _isUIVisible() { return this._balloon.visibleView === this._mentionsView; } /** * Creates the {@link #_mentionsView}. */ _createMentionView() { const locale = this.editor.locale; const mentionsView = new MentionsView(locale); mentionsView.items.bindTo(this._items).using(data => { const { item, marker } = data; const { dropdownLimit: markerDropdownLimit } = this._mentionsConfigurations.get(marker); // Set to 10 by default for backwards compatibility. See: #10479 const dropdownLimit = markerDropdownLimit || this.editor.config.get('mention.dropdownLimit') || 10; if (mentionsView.items.length >= dropdownLimit) { return null; } const listItemView = new MentionListItemView(locale); const view = this._renderItem(item, marker); view.delegate('execute').to(listItemView); listItemView.children.add(view); listItemView.item = item; listItemView.marker = marker; listItemView.on('execute', () => { mentionsView.fire('execute', { item, marker }); }); return listItemView; }); mentionsView.on('execute', (evt, data) => { const editor = this.editor; const model = editor.model; const item = data.item; const marker = data.marker; const mentionMarker = editor.model.markers.get('mention'); // Create a range on matched text. const end = model.createPositionAt(model.document.selection.focus); const start = model.createPositionAt(mentionMarker.getStart()); const range = model.createRange(start, end); this._hideUIAndRemoveMarker(); editor.execute('mention', { mention: item, text: item.text, marker, range }); editor.editing.view.focus(); }); return mentionsView; } /** * Returns item renderer for the marker. */ _getItemRenderer(marker) { const { itemRenderer } = this._mentionsConfigurations.get(marker); return itemRenderer; } /** * Requests a feed from a configured callbacks. */ _requestFeed(marker, feedText) { // @if CK_DEBUG_MENTION // console.log( '%c[Feed]%c Requesting for', 'color: blue', 'color: black', `"${ feedText }"` ); // Store the last requested feed - it is used to discard any out-of order requests. this._lastRequested = feedText; const { feedCallback } = this._mentionsConfigurations.get(marker); const feedResponse = feedCallback(feedText); const isAsynchronous = feedResponse instanceof Promise; // For synchronous feeds (e.g. callbacks, arrays) fire the response event immediately. if (!isAsynchronous) { this.fire('requestFeed:response', { feed: feedResponse, marker, feedText }); return; } // Handle the asynchronous responses. feedResponse .then(response => { // Check the feed text of this response with the last requested one so either: if (this._lastRequested == feedText) { // It is the same and fire the response event. this.fire('requestFeed:response', { feed: response, marker, feedText }); } else { // It is different - most probably out-of-order one, so fire the discarded event. this.fire('requestFeed:discarded', { feed: response, marker, feedText }); } }) .catch(error => { this.fire('requestFeed:error', { error }); /** * The callback used for obtaining mention autocomplete feed thrown and error and the mention UI was hidden or * not displayed at all. * * @error mention-feed-callback-error */ logWarning('mention-feed-callback-error', { marker }); }); } /** * Registers a text watcher for the marker. */ _setupTextWatcher(feeds) { const editor = this.editor; const feedsWithPattern = feeds.map(feed => ({ ...feed, pattern: createRegExp(feed.marker, feed.minimumCharacters || 0) })); const watcher = new TextWatcher(editor.model, createTestCallback(feedsWithPattern)); watcher.on('matched', (evt, data) => { const markerDefinition = getLastValidMarkerInText(feedsWithPattern, data.text); const selection = editor.model.document.selection; const focus = selection.focus; const markerPosition = editor.model.createPositionAt(focus.parent, markerDefinition.position); if (isPositionInExistingMention(focus) || isMarkerInExistingMention(markerPosition)) { this._hideUIAndRemoveMarker(); return; } const feedText = requestFeedText(markerDefinition, data.text); const matchedTextLength = markerDefinition.marker.length + feedText.length; // Create a marker range. const start = focus.getShiftedBy(-matchedTextLength); const end = focus.getShiftedBy(-feedText.length); const markerRange = editor.model.createRange(start, end); // @if CK_DEBUG_MENTION // console.group( '%c[TextWatcher]%c matched', 'color: red', 'color: black', `"${ feedText }"` ); // @if CK_DEBUG_MENTION // console.log( 'data#text', `"${ data.text }"` ); // @if CK_DEBUG_MENTION // console.log( 'data#range', data.range.start.path, data.range.end.path ); // @if CK_DEBUG_MENTION // console.log( 'marker definition', markerDefinition ); // @if CK_DEBUG_MENTION // console.log( 'marker range', markerRange.start.path, markerRange.end.path ); if (checkIfStillInCompletionMode(editor)) { const mentionMarker = editor.model.markers.get('mention'); // Update the marker - user might've moved the selection to other mention trigger. editor.model.change(writer => { // @if CK_DEBUG_MENTION // console.log( '%c[Editing]%c Updating the marker.', 'color: purple', 'color: black' ); writer.updateMarker(mentionMarker, { range: markerRange }); }); } else { editor.model.change(writer => { // @if CK_DEBUG_MENTION // console.log( '%c[Editing]%c Adding the marker.', 'color: purple', 'color: black' ); writer.addMarker('mention', { range: markerRange, usingOperation: false, affectsData: false }); }); } this._requestFeedDebounced(markerDefinition.marker, feedText); // @if CK_DEBUG_MENTION // console.groupEnd(); }); watcher.on('unmatched', () => { this._hideUIAndRemoveMarker(); }); const mentionCommand = editor.commands.get('mention'); watcher.bind('isEnabled').to(mentionCommand); return watcher; } /** * Handles the feed response event data. */ _handleFeedResponse(data) { const { feed, marker } = data; // eslint-disable-next-line @stylistic/max-len // @if CK_DEBUG_MENTION // console.log( `%c[Feed]%c Response for "${ data.feedText }" (${ feed.length })`, 'color: blue', 'color: black', feed ); // If the marker is not in the document happens when the selection had changed and the 'mention' marker was removed. if (!checkIfStillInCompletionMode(this.editor)) { return; } // Reset the view. this._items.clear(); for (const feedItem of feed) { const item = typeof feedItem != 'object' ? { id: feedItem, text: feedItem } : feedItem; this._items.add({ item, marker }); } const mentionMarker = this.editor.model.markers.get('mention'); if (this._items.length) { this._showOrUpdateUI(mentionMarker); } else { // Do not show empty mention UI. this._hideUIAndRemoveMarker(); } } /** * Shows the mentions balloon. If the panel is already visible, it will reposition it. */ _showOrUpdateUI(markerMarker) { if (this._isUIVisible) { // @if CK_DEBUG_MENTION // console.log( '%c[UI]%c Updating position.', 'color: green', 'color: black' ); // Update balloon position as the mention list view may change its size. this._balloon.updatePosition(this._getBalloonPanelPositionData(markerMarker, this._mentionsView.position)); } else { // @if CK_DEBUG_MENTION // console.log( '%c[UI]%c Showing the UI.', 'color: green', 'color: black' ); this._balloon.add({ view: this._mentionsView, position: this._getBalloonPanelPositionData(markerMarker, this._mentionsView.position), singleViewMode: true, balloonClassName: 'ck-mention-balloon' }); } this._mentionsView.position = this._balloon.view.position; this._mentionsView.selectFirst(); } /** * Hides the mentions balloon and removes the 'mention' marker from the markers collection. */ _hideUIAndRemoveMarker() { // Remove the mention view from balloon before removing marker - it is used by balloon position target(). if (this._balloon.hasView(this._mentionsView)) { // @if CK_DEBUG_MENTION // console.log( '%c[UI]%c Hiding the UI.', 'color: green', 'color: black' ); this._balloon.remove(this._mentionsView); } if (checkIfStillInCompletionMode(this.editor)) { // @if CK_DEBUG_MENTION // console.log( '%c[Editing]%c Removing marker.', 'color: purple', 'color: black' ); this.editor.model.change(writer => writer.removeMarker('mention')); } // Make the last matched position on panel view undefined so the #_getBalloonPanelPositionData() method will return all positions // on the next call. this._mentionsView.position = undefined; } /** * Renders a single item in the autocomplete list. */ _renderItem(item, marker) { const editor = this.editor; let view; let label = item.id; const renderer = this._getItemRenderer(marker); if (renderer) { const renderResult = renderer(item); if (typeof renderResult != 'string') { view = new DomWrapperView(editor.locale, renderResult); } else { label = renderResult; } } if (!view) { const buttonView = new ButtonView(editor.locale); buttonView.label = label; buttonView.withText = true; view = buttonView; } return view; } /** * Creates a position options object used to position the balloon panel. * * @param mentionMarker * @param preferredPosition The name of the last matched position name. */ _getBalloonPanelPositionData(mentionMarker, preferredPosition) { const editor = this.editor; const editing = editor.editing; const domConverter = editing.view.domConverter; const mapper = editing.mapper; const uiLanguageDirection = editor.locale.uiLanguageDirection; return { target: () => { let modelRange = mentionMarker.getRange(); // Target the UI to the model selection range - the marker has been removed so probably the UI will not be shown anyway. // The logic is used by ContextualBalloon to display another panel in the same place. if (modelRange.start.root.rootName == '$graveyard') { modelRange = editor.model.document.selection.getFirstRange(); } const viewRange = mapper.toViewRange(modelRange); const rangeRects = Rect.getDomRangeRects(domConverter.viewRangeToDom(viewRange)); return rangeRects.pop(); }, limiter: () => { const view = this.editor.editing.view; const viewDocument = view.document; const editableElement = viewDocument.selection.editableElement; if (editableElement) { return view.domConverter.mapViewToDom(editableElement.root); } return null; }, positions: getBalloonPanelPositions(preferredPosition, uiLanguageDirection) }; } } /** * Returns the balloon positions data callbacks. */ function getBalloonPanelPositions(preferredPosition, uiLanguageDirection) { const positions = { // Positions the panel to the southeast of the caret rectangle. 'caret_se': (targetRect) => { return { top: targetRect.bottom + VERTICAL_SPACING, left: targetRect.right, name: 'caret_se', config: { withArrow: false } }; }, // Positions the panel to the northeast of the caret rectangle. 'caret_ne': (targetRect, balloonRect) => { return { top: targetRect.top - balloonRect.height - VERTICAL_SPACING, left: targetRect.right, name: 'caret_ne', config: { withArrow: false } }; }, // Positions the panel to the southwest of the caret rectangle. 'caret_sw': (targetRect, balloonRect) => { return { top: targetRect.bottom + VERTICAL_SPACING, left: targetRect.right - balloonRect.width, name: 'caret_sw', config: { withArrow: false } }; }, // Positions the panel to the northwest of the caret rect. 'caret_nw': (targetRect, balloonRect) => { return { top: targetRect.top - balloonRect.height - VERTICAL_SPACING, left: targetRect.right - balloonRect.width, name: 'caret_nw', config: { withArrow: false } }; } }; // Returns only the last position if it was matched to prevent the panel from jumping after the first match. if (Object.prototype.hasOwnProperty.call(positions, preferredPosition)) { return [ positions[preferredPosition] ]; } // By default, return all position callbacks ordered depending on the UI language direction. return uiLanguageDirection !== 'rtl' ? [ positions.caret_se, positions.caret_sw, positions.caret_ne, positions.caret_nw ] : [ positions.caret_sw, positions.caret_se, positions.caret_nw, positions.caret_ne ]; } /** * Returns a marker definition of the last valid occurring marker in a given string. * If there is no valid marker in a string, it returns undefined. * * Example of returned object: * * ```ts * { * marker: '@', * position: 4, * minimumCharacters: 0 * } * ```` * * @param feedsWithPattern Registered feeds in editor for mention plugin with created RegExp for matching marker. * @param text String to find the marker in * @returns Matched marker's definition */ function getLastValidMarkerInText(feedsWithPattern, text) { let lastValidMarker; for (const feed of feedsWithPattern) { const currentMarkerLastIndex = text.lastIndexOf(feed.marker); if (currentMarkerLastIndex > 0 && !text.substring(currentMarkerLastIndex - 1).match(feed.pattern)) { continue; } if (!lastValidMarker || currentMarkerLastIndex >= lastValidMarker.position) { lastValidMarker = { marker: feed.marker, position: currentMarkerLastIndex, minimumCharacters: feed.minimumCharacters, pattern: feed.pattern }; } } return lastValidMarker; } /** * Creates a RegExp pattern for the marker. * * Function has to be exported to achieve 100% code coverage. */ export function createRegExp(marker, minimumCharacters) { const numberOfCharacters = minimumCharacters == 0 ? '*' : `{${minimumCharacters},}`; const openAfterCharacters = env.features.isRegExpUnicodePropertySupported ? '\\p{Ps}\\p{Pi}"\'' : '\\(\\[{"\''; const mentionCharacters = '.'; // I wanted to make an util out of it, but since this regexp uses "u" flag, it became difficult. // When "u" flag is used, the regexp has "strict" escaping rules, i.e. if you try to escape a character that does not need // to be escaped, RegExp() will throw. It made it difficult to write a generic util, because different characters are // allowed in different context. For example, escaping "-" sometimes was correct, but sometimes it threw an error. marker = marker.replace(/[.*+?^${}()\-|[\]\\]/g, '\\$&'); // The pattern consists of 3 groups: // // - 0 (non-capturing): Opening sequence - start of the line, space or an opening punctuation character like "(" or "\"", // - 1: The marker character(s), // - 2: Mention input (taking the minimal length into consideration to trigger the UI), // // The pattern matches up to the caret (end of string switch - $). // (0: opening sequence )(1: marker )(2: typed mention )$ const pattern = `(?:^|[ ${openAfterCharacters}])(${marker})(${mentionCharacters}${numberOfCharacters})$`; return new RegExp(pattern, 'u'); } /** * Creates a test callback for the marker to be used in the text watcher instance. * * @param feedsWithPattern Feeds of mention plugin configured in editor with RegExp to match marker in text */ function createTestCallback(feedsWithPattern) { const textMatcher = (text) => { const markerDefinition = getLastValidMarkerInText(feedsWithPattern, text); if (!markerDefinition) { return false; } let splitStringFrom = 0; if (markerDefinition.position !== 0) { splitStringFrom = markerDefinition.position - 1; } const textToTest = text.substring(splitStringFrom); return markerDefinition.pattern.test(textToTest); }; return textMatcher; } /** * Creates a text matcher from the marker. */ function requestFeedText(markerDefinition, text) { let splitStringFrom = 0; if (markerDefinition.position !== 0) { splitStringFrom = markerDefinition.position - 1; } const regExp = createRegExp(markerDefinition.marker, 0); const textToMatch = text.substring(splitStringFrom); const match = textToMatch.match(regExp); return match[2]; } /** * The default feed callback. */ function createFeedCallback(feedItems) { return (feedText) => { const filteredItems = feedItems // Make the default mention feed case-insensitive. .filter(item => { // Item might be defined as object. const itemId = typeof item == 'string' ? item : String(item.id); // The default feed is case insensitive. return itemId.toLowerCase().includes(feedText.toLowerCase()); }); return filteredItems; }; } /** * Checks if position in inside or right after a text with a mention. */ function isPositionInExistingMention(position) { // The text watcher listens only to changed range in selection - so the selection attributes are not yet available // and you cannot use selection.hasAttribute( 'mention' ) just yet. // See https://github.com/ckeditor/ckeditor5-engine/issues/1723. const hasMention = position.textNode && position.textNode.hasAttribute('mention'); const nodeBefore = position.nodeBefore; return hasMention || nodeBefore && nodeBefore.is('$text') && nodeBefore.hasAttribute('mention'); } /** * Checks if the closest marker offset is at the beginning of a mention. * * See https://github.com/ckeditor/ckeditor5/issues/11400. */ function isMarkerInExistingMention(markerPosition) { const nodeAfter = markerPosition.nodeAfter; return nodeAfter && nodeAfter.is('$text') && nodeAfter.hasAttribute('mention'); } /** * Checks if string is a valid mention marker. */ function isValidMentionMarker(marker) { return !!marker; } /** * Checks the mention plugins is in completion mode (e.g. when typing is after a valid mention string like @foo). */ function checkIfStillInCompletionMode(editor) { return editor.model.markers.has('mention'); }