UNPKG

@znuny/ckeditor5-autocomplete-plugin

Version:

A plugin for CKEditor 5 that provides an extendable autocomplete functionality with predefined mention and HTML replacement logic.

252 lines (251 loc) 11.9 kB
/** * @copyright Copyright (c) 2024, Znuny GmbH. * @copyright Copyright (c) 2003-2024, CKSource Holding sp. z o.o. * * @license GNU GPL version 3 * * This software comes with ABSOLUTELY NO WARRANTY. For details, see * the enclosed file COPYING for license information (GPL). If you * did not receive this file, see https://www.gnu.org/licenses/gpl-3.0.txt. */ import { uid } from 'ckeditor5/src/utils.js'; export class EditorPreparation { /** * Merges the baseMentionData with data (if set) and adds a unique mention element id, required by CKEditor. * @param baseMentionData * @param data * @returns */ static mergeMentionObjects(baseMentionData, data) { return Object.assign({ uid: uid() }, baseMentionData, data || {}); } /** * Creates a mention view element from mention model data. */ static createViewMentionElement(mentionModel, { writer }) { if (!mentionModel) { return; } const attributes = { class: 'mention', [EditorPreparation.identifyingMentionAttributeName]: mentionModel.name ?? mentionModel.content.toString() }; for (const completionElementAttribute of mentionModel.attributes ?? []) { if (completionElementAttribute.name !== 'class' && completionElementAttribute.name !== EditorPreparation.identifyingMentionAttributeName) { attributes[completionElementAttribute.name] = completionElementAttribute.value; } } const options = { id: mentionModel.uid, priority: 20 }; return writer.createAttributeElement(EditorPreparation.mentionCompletionElementTagName, attributes, options); } /** * 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 `content` attribute is not equal to content with mention attribute. * This may happen when copying part of mention text. */ static 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.content.toString()) { // Consume item to prevent partial mention conversion. conversionApi.consumable.consume(data.item, evt.name); } }, { priority: 'highest' }); } /** * 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. */ static 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 (EditorPreparation.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. */ static 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.content.toString(); return text != expectedText; } /** * Model post-fixer that removes the mention attribute from the modified text node. */ static removePartialMentionPostFixer(writer, model) { const changes = model.document.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 = EditorPreparation.checkAndFix(position.textNode, writer) || wasChanged; // Occurs on paste inside a text node with mention. wasChanged = EditorPreparation.checkAndFix(nodeAfterInsertedTextNode, writer) || wasChanged; wasChanged = EditorPreparation.checkAndFix(position.nodeBefore, writer) || wasChanged; wasChanged = EditorPreparation.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 = EditorPreparation.checkAndFix(item, writer) || wasChanged; } } // Inserted inline elements might break mention. if (change.type == 'insert' && model.schema.isInline(change.name)) { const nodeAfterInserted = position.nodeAfter && position.nodeAfter.nextSibling; wasChanged = EditorPreparation.checkAndFix(position.nodeBefore, writer) || wasChanged; wasChanged = EditorPreparation.checkAndFix(nodeAfterInserted, writer) || wasChanged; } } return wasChanged; } /** * Fixes a mention on a text node if it needs a fix, while fix means here removing the mention html representation from the (possibly incomplete) view text. */ static checkAndFix(textNode, writer) { if (EditorPreparation.isBrokenMentionNode(textNode)) { writer.removeAttribute('mention', textNode); return true; } return false; } /** * 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. */ static selectionMentionAttributePostFixer(writer, doc) { const selection = doc.selection; const focus = selection.focus; if (selection.isCollapsed && selection.hasAttribute('mention') && EditorPreparation.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. */ static shouldNotTypeWithMentionAt(position) { const isAtStart = position.isAtStart; const isAfterAMention = position.nodeBefore && position.nodeBefore.is('$text'); return isAfterAMention || isAtStart; } } EditorPreparation.identifyingMentionAttributeName = 'data-mention'; EditorPreparation.mentionCompletionElementTagName = 'span'; /** * Prepare the editors model conversion manager to be able to process our custom data- html attributes. * See https://ckeditor.com/docs/ckeditor5/latest/framework/deep-dive/conversion/upcast.html * @param editor */ EditorPreparation.makeItMentionable = (editor, mentionCompletionElementTagName) => { // allow the mention attribute on all text nodes. editor.model.schema.extend('$text', { allowAttributes: 'mention' }); if (mentionCompletionElementTagName !== undefined && EditorPreparation.isValidHtmlElementTagName(mentionCompletionElementTagName)) { EditorPreparation.mentionCompletionElementTagName = mentionCompletionElementTagName; } // upcast conversion (converts a view element / html into a model element / html) editor.conversion.for('upcast').elementToAttribute({ view: { name: EditorPreparation.mentionCompletionElementTagName, attributes: [ EditorPreparation.identifyingMentionAttributeName ], classes: 'mention' }, model: { key: 'mention', value: (viewElement) => EditorPreparation.toMentionAttribute(viewElement) } }); // downcast conversion (converts a model element / html into a view element / html) editor.conversion.for('downcast').attributeToElement({ model: 'mention', view: EditorPreparation.createViewMentionElement }); editor.conversion.for('downcast').add(EditorPreparation.preventPartialMentionDowncast); editor.model.document.registerPostFixer(writer => EditorPreparation.removePartialMentionPostFixer(writer, editor.model)); editor.model.document.registerPostFixer(writer => EditorPreparation.extendAttributeOnMentionPostFixer(writer, editor.model.document)); editor.model.document.registerPostFixer(writer => EditorPreparation.selectionMentionAttributePostFixer(writer, editor.model.document)); }; /** * Checks whether a given html element tag name is valid. * @param tagName */ EditorPreparation.isValidHtmlElementTagName = (tagName) => { // Create an element with the given tag name const element = document.createElement(tagName); // Check if the created element is an instance of HTMLElement and not an instance of HTMLUnknownElement return element instanceof HTMLElement && !(element instanceof HTMLUnknownElement); }; /** * Creates mention model data from a mention view element. */ EditorPreparation.toMentionAttribute = (viewElementOrMention, data) => { const attributes = []; for (const attributeKey of viewElementOrMention.getAttributeKeys()) { if (attributeKey !== 'class' && attributeKey !== EditorPreparation.identifyingMentionAttributeName) { const attributeValue = viewElementOrMention.getAttribute(attributeKey); if (attributeValue) { attributes.push({ name: attributeKey, value: attributeValue }); } } } const dataMention = viewElementOrMention.getAttribute(EditorPreparation.identifyingMentionAttributeName); const textNode = viewElementOrMention.getChild(0); // Do not convert empty mentions. if (!textNode) { return; } const baseMentionData = { name: dataMention, content: textNode.data, attributes }; return EditorPreparation.mergeMentionObjects(baseMentionData, data); };