@znuny/ckeditor5-autocomplete-plugin
Version:
A plugin for CKEditor 5 that provides an extendable autocomplete functionality with predefined mention and HTML replacement logic.
383 lines (382 loc) • 22.6 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 { Plugin } from 'ckeditor5/src/core.js';
import { Range } from 'ckeditor5/src/engine.js';
// import { type TextWatcherUnmatchedEvent } from "@ckeditor/ckeditor5-typing/src/textwatcher";
import { TextWatcher } from 'ckeditor5/src/typing.js';
import { ContextualBalloon } from 'ckeditor5/src/ui.js';
import { toMap } from 'ckeditor5/src/utils.js';
// import typia from "typia";
// import { TypeGuardError } from "typia/lib/TypeGuardError";
import { AutocompleteSelectionUi } from './AutocompleteSelectionUi';
import { EditorPreparation } from './EditorPreparation';
import { InputMatchingStrategy } from './Interfaces/CompletionGroupConfiguration';
import './Theme/Mention.css';
export class Autocomplete extends Plugin {
/**
* @inheritDoc
*/
static get requires() {
return [ContextualBalloon];
}
/**
* @inheritDoc
*/
static get pluginName() {
return 'Autocomplete';
}
static get pluginMarkerName() {
return this.pluginName;
}
static get pluginConfigurationName() {
return this.pluginName.toLowerCase();
}
/**
* @inheritDoc
*/
constructor(editor) {
super(editor);
this.config = null;
this.selectionUi = null;
/**
* Returns an array with all positions (index) of a given marker within the inputText.
* @param inputText contains the content of the current cursor selection (in front of the cursor)
* @param marker completion group marker
*/
this.getAllMarkerPositions = (inputText, marker) => {
const positions = [];
let lastPosition = 0;
let nextSearchPosition = -1;
while (lastPosition != -1) {
lastPosition = inputText.indexOf(marker, nextSearchPosition + 1);
if (lastPosition != -1) {
positions.push(lastPosition);
nextSearchPosition = lastPosition;
}
}
return positions;
};
/**
* Checks whether the given selection (under to current cursor position) is part of an already fully inserted completion (mention) element.
* @param data
*/
this.isSelectionAboveFulfilledCompletionReplacement = (data) => {
const rangeToSelect = new Range(data.range.end.getShiftedBy(-1), data.range.end);
return this.editor.model.getSelectedContent(this.editor.model.createSelection(rangeToSelect))
.getChild(0)?.hasAttribute('mention') === true;
};
this.applyMatchingCompletionsToSelectionUi = (matchingCompletionsForSelectionUi, absolutePositionOfLastMarker, data) => {
let selectionUiMarker = this.editor.model.markers.get(Autocomplete.pluginMarkerName);
if (absolutePositionOfLastMarker !== undefined) {
// create a custom marker range to position the ui selection element at the (fixed) beginning of the marker characters
// it will also be used to replace the last marker + following characters occurrence of the matching, when an item is selected
const markerStartPosition = data.range.start.getShiftedBy(absolutePositionOfLastMarker);
const selectionUiMarkerRange = this.editor.model.createRange(markerStartPosition, markerStartPosition);
// update the marker - user might have moved the selection to another mention trigger
this.editor.model.change(writer => {
if (selectionUiMarker) {
writer.updateMarker(selectionUiMarker, {
range: selectionUiMarkerRange
});
}
else {
writer.addMarker(Autocomplete.pluginMarkerName, {
range: selectionUiMarkerRange,
usingOperation: false,
affectsData: false
});
selectionUiMarker = this.editor.model.markers.get(Autocomplete.pluginMarkerName);
}
});
this.selectionUi?.availableMentionsViewItems.clear();
for (const [matchingCompletionsGroup, completionElements] of matchingCompletionsForSelectionUi) {
this.addGroupCompletionsToSelectionUi(matchingCompletionsGroup, completionElements, markerStartPosition, data);
}
}
if (selectionUiMarker && matchingCompletionsForSelectionUi.size > 0) {
this.selectionUi?.showOrUpdateUI(selectionUiMarker);
}
else {
this.selectionUi?.hideUIAndRemoveMarker();
}
};
/**
* Adds the given completion elements to the selection ui (dropdown).
* To show elements out of different groups, just call this multiple times with different (group specific) parameters.
* @param completionGroup completion group from which the provided completions originate
* @param completions array of completion elements to show in the selection ui (dropdown)
*/
this.addGroupCompletionsToSelectionUi = (completionGroup, completions, replaceStartPosition, data) => {
const selectionDropdownLimit = completionGroup.selectionDropdownLimit || this.config?.overallSelectionDropdownLimit || 10;
// loop will stop at the last array element, or if the selection dropdown limit of the group (or the overall limit) has reached
// if one of the limits is -1 the loop will end with the last array element
for (let i = 0; i < completions.length && (i < selectionDropdownLimit || selectionDropdownLimit === -1); i++) {
const completion = completions[i];
this.selectionUi?.availableMentionsViewItems.add({
item: {
...completion,
completionElementRenderer: completion.completionElementRenderer ?? completionGroup.completionElementRenderer
},
marker: completionGroup.matchingMarker,
confirmedSelectionHandler: () => {
// don't run confirmed selection handler if range is in non-editable place
if (!this.editor.model.canEditAt(data.range)) {
return;
}
if (completionGroup.confirmedSelectionHandler !== undefined) {
completionGroup.confirmedSelectionHandler(this.editor.model, this.editor.data, data, completion);
}
else {
this.editor.model.change(writer => {
// need to create custom position data, since we only want to replace the last marker + following characters occurrence of the matching
const rangeToReplace = this.editor.model.createRange(replaceStartPosition, this.editor.model.document.selection.focus);
if (completion.useAsHTMLReplacement) {
const viewFragment = this.editor.data.processor.toView(completion.content.toString());
const modelFragment = this.editor.data.toModel(viewFragment);
this.editor.model.insertContent(modelFragment, rangeToReplace);
}
else {
const currentAttributes = toMap(this.editor.model.document.selection.getAttributes());
const attributesWithMention = new Map(currentAttributes.entries());
const content = completion.content.toString();
const mentionData = {
id: completion.name ?? content
};
const mention = EditorPreparation.mergeMentionObjects({
name: completion.name ?? content,
content,
attributes: completion.attributes
}, mentionData);
attributesWithMention.set('mention', mention);
this.editor.model.insertContent(writer.createText(content, attributesWithMention), rangeToReplace);
}
});
}
}
});
}
};
/**
* Creates a callback to test for all groups, if a matching marker is in the current editor line.
* @param completionGroups
* @returns Whether at least one valid completion marker was found in the current line / or word for any configured completion group
*/
this.createTextWatcherCallback = (completionGroups) => {
return (text) => {
// do nothing else than valid marker matching
for (const completionGroup of completionGroups) {
if (this.testMarkerMatchingForCompletionGroup(text, completionGroup)) {
return true;
}
}
return false;
};
};
/**
* Tests whether the word at the current cursor position contains a valid autocomplete configured marker (completionGroup.matchingMarker).
* Instead of the internal matching logic the external configured completionGroup.markerMatchingCallback(inputText, completionGroup.matchingMarker) will be called if available.
* @param inputText contains the content of the current editor line in front of the current cursor position
* @param completionGroup
* @returns Whether a valid completion marker was found in the current line / or word for the given completion group
*/
this.testMarkerMatchingForCompletionGroup = (inputText, completionGroup) => {
// check if a custom marker matching callback was configured and use it instead of the default logic
if (completionGroup.markerMatchingCallback && typeof completionGroup.markerMatchingCallback === 'function') {
return completionGroup.markerMatchingCallback(inputText, completionGroup.matchingMarker);
}
const inputWords = inputText.split(' ');
const lastInputWord = inputWords[inputWords.length - 1];
return lastInputWord.includes(completionGroup.matchingMarker);
};
/**
* Returns a predefined completion matching hander callback to check whether a inputText matches for a specific completion element or not.
* @param strategy
* @param isCaseSensitive
* @returns
*/
this.getMatchingHandlerByStrategy = (strategy, isCaseSensitive = false) => {
switch (strategy) {
case InputMatchingStrategy.NAME_STARTS_WITH:
return isCaseSensitive
? (inputText, completion) => {
return completion.name?.startsWith(inputText) ?? false;
}
: (inputText, completion) => {
return completion.name?.toLowerCase().startsWith(inputText.toLowerCase()) ?? false;
};
case InputMatchingStrategy.NAME_INCLUDES:
return isCaseSensitive
? (inputText, completion) => {
return completion.name?.includes(inputText) ?? false;
}
: (inputText, completion) => {
return completion.name?.toLowerCase().includes(inputText.toLowerCase()) ?? false;
};
case InputMatchingStrategy.CONTENT_STARTS_WITH:
return isCaseSensitive
? (inputText, completion) => {
return completion.content.toString().startsWith(inputText) ?? false;
}
: (inputText, completion) => {
return completion.content.toString().toLowerCase().startsWith(inputText.toLowerCase()) ?? false;
};
case InputMatchingStrategy.CONTENT_INCLUDES:
return isCaseSensitive
? (inputText, completion) => {
return completion.content.toString().includes(inputText) ?? false;
}
: (inputText, completion) => {
return completion.content.toString().toLowerCase().includes(inputText.toLowerCase()) ?? false;
};
case InputMatchingStrategy.EVERYTHING:
// eslint-disable-next-line @typescript-eslint/no-unused-vars
return (_inputText, _completion) => {
return true;
};
default:
// eslint-disable-next-line @typescript-eslint/no-unused-vars
return (_inputText, _completion) => {
return false;
};
}
};
this.processCSSOverwrite = () => {
if (this.config === null) {
return;
}
// Get the root document element
const rootElement = document.documentElement;
if (this.config.overwriteCSSMentionBackgroundColor) {
rootElement.style.setProperty('--ck-color-mention-background', this.config.overwriteCSSMentionBackgroundColor);
}
if (this.config.overwriteCSSMentionTextColor) {
rootElement.style.setProperty('--ck-color-mention-text', this.config.overwriteCSSMentionTextColor);
}
if (this.config.overwriteCSSSelectionBackgroundColor) {
rootElement.style.setProperty('--ck-color-mention-selection-background', this.config.overwriteCSSSelectionBackgroundColor);
}
if (this.config.overwriteCSSSelectionBackgroundColorSelected) {
rootElement.style.setProperty('--ck-color-mention-selection-background-selected', this.config.overwriteCSSSelectionBackgroundColorSelected);
}
if (this.config.overwriteCSSSelectionTextColor) {
rootElement.style.setProperty('--ck-color-mention-selection-text', this.config.overwriteCSSSelectionTextColor);
}
if (this.config.overwriteCSSSelectionTextColorSelected) {
rootElement.style.setProperty('--ck-color-mention-selection-text-selected', this.config.overwriteCSSSelectionTextColorSelected);
}
if (this.config.overwriteCSSSelectionListMaxHeight) {
rootElement.style.setProperty('--ck-mention-list-max-height', this.config.overwriteCSSSelectionListMaxHeight);
}
};
try {
this.editor.config.define(Autocomplete.pluginConfigurationName, []);
/* this.config = typia.assert<AutocompletePluginConfigurationInterface>(
this.editor.config.get(Autocomplete.pluginConfigurationName)
);*/
this.config = this.editor.config.get(Autocomplete.pluginConfigurationName);
this.selectionUi = new AutocompleteSelectionUi(this.editor, Autocomplete.pluginMarkerName, this.config.commitKeyCodes, this.config.overallSelectionDropdownLimit);
}
catch (error) {
/* if (error instanceof TypeGuardError) {
console.error(`Could not parse/validate ${Autocomplete.pluginName} plugin configuration!`);
console.error(error);
return;
} else {
throw error;
}*/
console.error(error);
throw error;
}
}
/**
* @inheritDoc
*/
init() {
// prevent plugin initialization without or with an invalid configuration
if (this.config === undefined || this.config === null || typeof this.config !== 'object' || this.selectionUi === null) {
console.error('Autocomplete init aborted due to invalid config', this.config);
return;
}
this.processCSSOverwrite();
EditorPreparation.makeItMentionable(this.editor, this.config.overwriteMentionCompletionElementTagName);
// watcher to watch for and handle marker recognition only (or better not?)
// see further processin in watcher.on<...>("matched" ...
const watcher = new TextWatcher(this.editor.model, this.createTextWatcherCallback(this.config.completionGroups));
watcher.on('unmatched', async () => {
// we lost the marker match somehow, somewhere - don't care
this.selectionUi?.hideUIAndRemoveMarker();
});
const config = this.config;
watcher.on('matched', async (_evt, data) => {
// we got a marker match from the text watcher callback
// since the TextWatcher callback only supports a boolean return value, we need to check it again to determine the matched completion group
// todo: may use a customized TextWatcher to get rid of this (pointless) double checking stuff (improvement: return an array of matching groups)
if (this.config?.allowAutocompletionAboveFulfilledCompletionReplacements !== true &&
this.isSelectionAboveFulfilledCompletionReplacement(data)) {
this.selectionUi?.hideUIAndRemoveMarker();
return;
}
// absolutePositionOfLastMarker should be equal for all groups with the same marker.
// since we possibly checking multiple markers (at different positions) for multiple completion groups with a single input word,
// we're simply using the first one
let absolutePositionOfLastMarker = undefined;
const matchingCompletionsForSelectionUi = new Map();
for (const completionGroup of config.completionGroups) {
// we need to do marker matching again, to find out which group is responsible for the further handling
if (this.testMarkerMatchingForCompletionGroup(data.text, completionGroup)) {
const inputWords = data.text.split(' ');
const lastInputWord = inputWords[inputWords.length - 1];
let matchingCompletions = [];
const markerPositionsToProcess = completionGroup.testAllPossibleMarkersOfASelection === true
? this.getAllMarkerPositions(lastInputWord, completionGroup.matchingMarker)
: [lastInputWord.lastIndexOf(completionGroup.matchingMarker)];
for (const markerPosition of markerPositionsToProcess) {
const relativePositionOfCharactersAfterLastMarker = markerPosition + completionGroup.matchingMarker.length;
const charactersAfterLastMarker = lastInputWord.substring(relativePositionOfCharactersAfterLastMarker);
// const charactersIncludingLastMarker = lastInputWord.substring(markerPosition);
if (typeof completionGroup.completions === 'function') {
// since these completions already comes from a callback we assume it's already correctly filtered
matchingCompletions = await completionGroup.completions(charactersAfterLastMarker);
}
else {
// only run completion matching, if there is at least one "non-marker" character to match with
if (charactersAfterLastMarker.length > 0) {
const selectedStrategicMatchingHandlerCallback = this.getMatchingHandlerByStrategy(completionGroup.completionMatchingHandler, completionGroup.completionMatchingHandlerCaseSensitivity);
for (const completion of completionGroup.completions) {
if (selectedStrategicMatchingHandlerCallback(charactersAfterLastMarker, completion)) {
matchingCompletions.push(completion);
}
}
}
else if (!completionGroup.offerCompletionOptionsWithMarkerMatchingOnly) {
// offer all completion elements, if the selection should be shown with only the marker typed yet
matchingCompletions = completionGroup.completions;
}
}
if (matchingCompletions.length > 0) {
if (absolutePositionOfLastMarker === undefined) {
absolutePositionOfLastMarker = data.text.length - lastInputWord.length + markerPosition;
}
matchingCompletionsForSelectionUi.set(completionGroup, matchingCompletions);
// break to ignore other markers, if we already have possible completions collected
break;
}
}
// break if multi group results are explicitely disallowed
if (this.config?.combineResultOfCompletionGroupsWithSameMarker === false) {
break;
}
}
}
this.applyMatchingCompletionsToSelectionUi(matchingCompletionsForSelectionUi, absolutePositionOfLastMarker, data);
});
}
}