UNPKG

@ckeditor/ckeditor5-mention

Version:

Mention feature for CKEditor 5.

238 lines (237 loc) • 9.24 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/mentionediting */ import { Plugin } from 'ckeditor5/src/core.js'; import { uid } from 'ckeditor5/src/utils.js'; import MentionCommand from './mentioncommand.js'; /** * 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">`. */ export default 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 */ export 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 */ export 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; }