UNPKG

@ckeditor/ckeditor5-mention

Version:

Mention feature for CKEditor 5.

1,191 lines (1,182 loc) • 46.4 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 */ import { Command, Plugin } from '@ckeditor/ckeditor5-core/dist/index.js'; import { CKEditorError, toMap, uid, Rect, keyCodes, Collection, logWarning, env } from '@ckeditor/ckeditor5-utils/dist/index.js'; import { ListView, View, ListItemView, ContextualBalloon, clickOutsideHandler, ButtonView } from '@ckeditor/ckeditor5-ui/dist/index.js'; import { TextWatcher } from '@ckeditor/ckeditor5-typing/dist/index.js'; import { debounce } from 'es-toolkit/compat'; const BRACKET_PAIRS = { '(': ')', '[': ']', '{': '}' }; /** * The mention command. * * The command is registered by {@link module:mention/mentionediting~MentionEditing} as `'mention'`. * * To insert a mention into a range, execute the command and specify a mention object with a range to replace: * * ```ts * const focus = editor.model.document.selection.focus; * * // It will replace one character before the selection focus with the '#1234' text * // with the mention attribute filled with passed attributes. * editor.execute( 'mention', { * marker: '#', * mention: { * id: '#1234', * name: 'Foo', * title: 'Big Foo' * }, * range: editor.model.createRange( focus.getShiftedBy( -1 ), focus ) * } ); * * // It will replace one character before the selection focus with the 'The "Big Foo"' text * // with the mention attribute filled with passed attributes. * editor.execute( 'mention', { * marker: '#', * mention: { * id: '#1234', * name: 'Foo', * title: 'Big Foo' * }, * text: 'The "Big Foo"', * range: editor.model.createRange( focus.getShiftedBy( -1 ), focus ) * } ); * ``` */ class MentionCommand extends Command { /** * @inheritDoc */ constructor(editor){ super(editor); // Since this command may pass range in execution parameters, it should be checked directly in execute block. this._isEnabledBasedOnSelection = false; } /** * @inheritDoc */ refresh() { const model = this.editor.model; const doc = model.document; this.isEnabled = model.schema.checkAttributeInSelection(doc.selection, 'mention'); } /** * Executes the command. * * @param options Options for the executed command. * @param options.mention The mention object to insert. When a string is passed, it will be used to create a plain * object with the name attribute that equals the passed string. * @param options.marker The marker character (e.g. `'@'`). * @param options.text The text of the inserted mention. Defaults to the full mention string composed from `marker` and * `mention` string or `mention.id` if an object is passed. * @param options.range The range to replace. * Note that the replaced range might be shorter than the inserted text with the mention attribute. * @fires execute */ execute(options) { const model = this.editor.model; const document = model.document; const selection = document.selection; const mentionData = typeof options.mention == 'string' ? { id: options.mention } : options.mention; const mentionID = mentionData.id; const range = options.range || selection.getFirstRange(); // Don't execute command if range is in non-editable place. if (!model.canEditAt(range)) { return; } const mentionText = options.text || mentionID; const mention = _addMentionAttributes({ _text: mentionText, id: mentionID }, mentionData); if (!mentionID.startsWith(options.marker)) { /** * The feed item ID must start with the marker character(s). * * Correct mention feed setting: * * ```ts * mentions: [ * { * marker: '@', * feed: [ '@Ann', '@Barney', ... ] * } * ] * ``` * * Incorrect mention feed setting: * * ```ts * mentions: [ * { * marker: '@', * feed: [ 'Ann', 'Barney', ... ] * } * ] * ``` * * See {@link module:mention/mentionconfig~MentionConfig}. * * @error mentioncommand-incorrect-id */ throw new CKEditorError('mentioncommand-incorrect-id', this); } model.change((writer)=>{ const currentAttributes = toMap(selection.getAttributes()); const attributesWithMention = new Map(currentAttributes.entries()); attributesWithMention.set('mention', mention); // Replace a range with the text with a mention. const insertionRange = model.insertContent(writer.createText(mentionText, attributesWithMention), range); const nodeBefore = insertionRange.start.nodeBefore; const nodeAfter = insertionRange.end.nodeAfter; const isFollowedByWhiteSpace = nodeAfter && nodeAfter.is('$text') && nodeAfter.data.startsWith(' '); let isInsertedInBrackets = false; if (nodeBefore && nodeAfter && nodeBefore.is('$text') && nodeAfter.is('$text')) { const precedingCharacter = nodeBefore.data.slice(-1); const isPrecededByOpeningBracket = precedingCharacter in BRACKET_PAIRS; const isFollowedByBracketClosure = isPrecededByOpeningBracket && nodeAfter.data.startsWith(BRACKET_PAIRS[precedingCharacter]); isInsertedInBrackets = isPrecededByOpeningBracket && isFollowedByBracketClosure; } // Don't add a white space if either of the following is true: // * there's already one after the mention; // * the mention was inserted in the empty matching brackets. // https://github.com/ckeditor/ckeditor5/issues/4651 if (!isInsertedInBrackets && !isFollowedByWhiteSpace) { model.insertContent(writer.createText(' ', currentAttributes), range.start.getShiftedBy(mentionText.length)); } }); } } /** * The mention editing feature. * * It introduces the {@link module:mention/mentioncommand~MentionCommand command} and the `mention` * attribute in the {@link module:engine/model/model~Model model} which renders in the {@link module:engine/view/view view} * as a `<span class="mention" data-mention="@mention">`. */ class MentionEditing extends Plugin { /** * @inheritDoc */ static get pluginName() { return 'MentionEditing'; } /** * @inheritDoc */ static get isOfficialPlugin() { return true; } /** * @inheritDoc */ init() { const editor = this.editor; const model = editor.model; const doc = model.document; // Allow the mention attribute on all text nodes. model.schema.extend('$text', { allowAttributes: 'mention' }); // Upcast conversion. editor.conversion.for('upcast').elementToAttribute({ view: { name: 'span', attributes: 'data-mention', classes: 'mention' }, model: { key: 'mention', value: (viewElement)=>_toMentionAttribute(viewElement) } }); // Downcast conversion. editor.conversion.for('downcast').attributeToElement({ model: 'mention', view: createViewMentionElement }); editor.conversion.for('downcast').add(preventPartialMentionDowncast); doc.registerPostFixer((writer)=>removePartialMentionPostFixer(writer, doc, model.schema)); doc.registerPostFixer((writer)=>extendAttributeOnMentionPostFixer(writer, doc)); doc.registerPostFixer((writer)=>selectionMentionAttributePostFixer(writer, doc)); editor.commands.add('mention', new MentionCommand(editor)); } } /** * @internal */ function _addMentionAttributes(baseMentionData, data) { return Object.assign({ uid: uid() }, baseMentionData, data || {}); } /** * Creates a mention attribute value from the provided view element and optional data. * * This function is exposed as * {@link module:mention/mention~Mention#toMentionAttribute `editor.plugins.get( 'Mention' ).toMentionAttribute()`}. * * @internal */ function _toMentionAttribute(viewElementOrMention, data) { const dataMention = viewElementOrMention.getAttribute('data-mention'); const textNode = viewElementOrMention.getChild(0); // Do not convert empty mentions. if (!textNode) { return; } const baseMentionData = { id: dataMention, _text: textNode.data }; return _addMentionAttributes(baseMentionData, data); } /** * A converter that blocks partial mention from being converted. * * This converter is registered with 'highest' priority in order to consume mention attribute before it is converted by * any other converters. This converter only consumes partial mention - those whose `_text` attribute is not equal to text with mention * attribute. This may happen when copying part of mention text. */ function preventPartialMentionDowncast(dispatcher) { dispatcher.on('attribute:mention', (evt, data, conversionApi)=>{ const mention = data.attributeNewValue; if (!data.item.is('$textProxy') || !mention) { return; } const start = data.range.start; const textNode = start.textNode || start.nodeAfter; if (textNode.data != mention._text) { // Consume item to prevent partial mention conversion. conversionApi.consumable.consume(data.item, evt.name); } }, { priority: 'highest' }); } /** * Creates a mention element from the mention data. */ function createViewMentionElement(mention, { writer }) { if (!mention) { return; } const attributes = { class: 'mention', 'data-mention': mention.id }; const options = { id: mention.uid, priority: 20 }; return writer.createAttributeElement('span', attributes, options); } /** * Model post-fixer that disallows typing with selection when the selection is placed after the text node with the mention attribute or * before a text node with mention attribute. */ function selectionMentionAttributePostFixer(writer, doc) { const selection = doc.selection; const focus = selection.focus; if (selection.isCollapsed && selection.hasAttribute('mention') && shouldNotTypeWithMentionAt(focus)) { writer.removeSelectionAttribute('mention'); return true; } return false; } /** * Helper function to detect if mention attribute should be removed from selection. * This check makes only sense if the selection has mention attribute. * * The mention attribute should be removed from a selection when selection focus is placed: * a) after a text node * b) the position is at parents start - the selection will set attributes from node after. */ function shouldNotTypeWithMentionAt(position) { const isAtStart = position.isAtStart; const isAfterAMention = position.nodeBefore && position.nodeBefore.is('$text'); return isAfterAMention || isAtStart; } /** * Model post-fixer that removes the mention attribute from the modified text node. */ function removePartialMentionPostFixer(writer, doc, schema) { const changes = doc.differ.getChanges(); let wasChanged = false; for (const change of changes){ if (change.type == 'attribute') { continue; } // Checks the text node on the current position. const position = change.position; if (change.name == '$text') { const nodeAfterInsertedTextNode = position.textNode && position.textNode.nextSibling; // Checks the text node where the change occurred. wasChanged = checkAndFix(position.textNode, writer) || wasChanged; // Occurs on paste inside a text node with mention. wasChanged = checkAndFix(nodeAfterInsertedTextNode, writer) || wasChanged; wasChanged = checkAndFix(position.nodeBefore, writer) || wasChanged; wasChanged = checkAndFix(position.nodeAfter, writer) || wasChanged; } // Checks text nodes in inserted elements (might occur when splitting a paragraph or pasting content inside text with mention). if (change.name != '$text' && change.type == 'insert') { const insertedNode = position.nodeAfter; for (const item of writer.createRangeIn(insertedNode).getItems()){ wasChanged = checkAndFix(item, writer) || wasChanged; } } // Inserted inline elements might break mention. if (change.type == 'insert' && schema.isInline(change.name)) { const nodeAfterInserted = position.nodeAfter && position.nodeAfter.nextSibling; wasChanged = checkAndFix(position.nodeBefore, writer) || wasChanged; wasChanged = checkAndFix(nodeAfterInserted, writer) || wasChanged; } } return wasChanged; } /** * This post-fixer will extend the attribute applied on the part of the mention so the whole text node of the mention will have * the added attribute. */ function extendAttributeOnMentionPostFixer(writer, doc) { const changes = doc.differ.getChanges(); let wasChanged = false; for (const change of changes){ if (change.type === 'attribute' && change.attributeKey != 'mention') { // Checks the node on the left side of the range... const nodeBefore = change.range.start.nodeBefore; // ... and on the right side of the range. const nodeAfter = change.range.end.nodeAfter; for (const node of [ nodeBefore, nodeAfter ]){ if (isBrokenMentionNode(node) && node.getAttribute(change.attributeKey) != change.attributeNewValue) { writer.setAttribute(change.attributeKey, change.attributeNewValue, node); wasChanged = true; } } } } return wasChanged; } /** * Checks if a node has a correct mention attribute if present. * Returns `true` if the node is text and has a mention attribute whose text does not match the expected mention text. */ function isBrokenMentionNode(node) { if (!node || !(node.is('$text') || node.is('$textProxy')) || !node.hasAttribute('mention')) { return false; } const text = node.data; const mention = node.getAttribute('mention'); const expectedText = mention._text; return text != expectedText; } /** * Fixes a mention on a text node if it needs a fix. */ function checkAndFix(textNode, writer) { if (isBrokenMentionNode(textNode)) { writer.removeAttribute('mention', textNode); return true; } return false; } /** * The mention ui view. */ class MentionsView extends ListView { selected; position; /** * @inheritDoc */ constructor(locale){ super(locale); this.extendTemplate({ attributes: { class: [ 'ck-mentions' ], tabindex: '-1' } }); } /** * {@link #select Selects} the first item. */ selectFirst() { this.select(0); } /** * Selects next item to the currently {@link #select selected}. * * If the last item is already selected, it will select the first item. */ selectNext() { const item = this.selected; const index = this.items.getIndex(item); this.select(index + 1); } /** * Selects previous item to the currently {@link #select selected}. * * If the first item is already selected, it will select the last item. */ selectPrevious() { const item = this.selected; const index = this.items.getIndex(item); this.select(index - 1); } /** * Marks item at a given index as selected. * * Handles selection cycling when passed index is out of bounds: * - if the index is lower than 0, it will select the last item, * - if the index is higher than the last item index, it will select the first item. * * @param index Index of an item to be marked as selected. */ select(index) { let indexToGet = 0; if (index > 0 && index < this.items.length) { indexToGet = index; } else if (index < 0) { indexToGet = this.items.length - 1; } const item = this.items.get(indexToGet); // Return early if item is already selected. if (this.selected === item) { return; } // Remove highlight of previously selected item. if (this.selected) { this.selected.removeHighlight(); } item.highlight(); this.selected = item; // Scroll the mentions view to the selected element. if (!this._isItemVisibleInScrolledArea(item)) { this.element.scrollTop = item.element.offsetTop; } } /** * Triggers the `execute` event on the {@link #select selected} item. */ executeSelected() { this.selected.fire('execute'); } /** * Checks if an item is visible in the scrollable area. * * The item is considered visible when: * - its top boundary is inside the scrollable rect * - its bottom boundary is inside the scrollable rect (the whole item must be visible) */ _isItemVisibleInScrolledArea(item) { return new Rect(this.element).contains(new Rect(item.element)); } } /** * This class wraps DOM element as a CKEditor5 UI View. * * It allows to render any DOM element and use it in mentions list. */ class DomWrapperView extends View { /** * The DOM element for which wrapper was created. */ domElement; /** * Creates an instance of {@link module:mention/ui/domwrapperview~DomWrapperView} class. * * Also see {@link #render}. */ constructor(locale, domElement){ super(locale); // Disable template rendering on this view. this.template = undefined; this.domElement = domElement; // Render dom wrapper as a button. this.domElement.classList.add('ck-button'); this.set('isOn', false); // Handle isOn state as in buttons. this.on('change:isOn', (evt, name, isOn)=>{ if (isOn) { this.domElement.classList.add('ck-on'); this.domElement.classList.remove('ck-off'); } else { this.domElement.classList.add('ck-off'); this.domElement.classList.remove('ck-on'); } }); // Pass click event as execute event. this.listenTo(this.domElement, 'click', ()=>{ this.fire('execute'); }); } /** * @inheritDoc */ render() { super.render(); this.element = this.domElement; } /** * Focuses the DOM element. */ focus() { this.domElement.focus(); } } class MentionListItemView extends ListItemView { item; marker; highlight() { const child = this.children.first; child.isOn = true; } removeHighlight() { const child = this.children.first; child.isOn = false; } } 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. */ 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. */ 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'); } /** * The mention plugin. * * For a detailed overview, check the {@glink features/mentions Mention feature} guide. */ class Mention extends Plugin { toMentionAttribute(viewElement, data) { return _toMentionAttribute(viewElement, data); } /** * @inheritDoc */ static get pluginName() { return 'Mention'; } /** * @inheritDoc */ static get isOfficialPlugin() { return true; } /** * @inheritDoc */ static get requires() { return [ MentionEditing, MentionUI ]; } } export { DomWrapperView, Mention, MentionEditing, MentionListItemView, MentionUI, MentionsView }; //# sourceMappingURL=index.js.map