UNPKG

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