@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
JavaScript
/**
* @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);
};