UNPKG

@znuny/ckeditor5-autocomplete-plugin

Version:

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

281 lines (280 loc) 12.8 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 { Collection, keyCodes, Rect } from 'ckeditor5/src/utils.js'; import { ButtonView, ContextualBalloon, clickOutsideHandler } from 'ckeditor5/src/ui.js'; import { MentionsView } from './View/MentionsView'; import { MentionListItemView } from './View/MentionListItemView'; import { DomWrapperView } from './View/DomWrapperView'; const VERTICAL_SPACING = 3; export class AutocompleteSelectionUi { constructor(editor, pluginMarkerName, commitKeyCodes, overallSelectionDropdownLimit) { this.availableMentionsViewItems = new Collection(); this.editor = editor; this.pluginMarkerName = pluginMarkerName; this.mentionsView = this.createMentionView(overallSelectionDropdownLimit); this.balloon = editor.plugins.get(ContextualBalloon); const commitKeys = commitKeyCodes || AutocompleteSelectionUi.defaultCommitKeyCodes; const handledKeyCodes = AutocompleteSelectionUi.defaultHandledKeyCodes.concat(commitKeys); // Key listener that handles navigation in mention view. editor.editing.view.document.on('keydown', (evt, data) => { if (handledKeyCodes.includes(data.keyCode) && this.isUIVisible()) { data.preventDefault(); evt.stop(); // Required for Enter key overriding. if (data.keyCode == keyCodes.arrowdown) { this.mentionsView.selectNext(); } if (data.keyCode == keyCodes.arrowup) { this.mentionsView.selectPrevious(); } if (commitKeys.includes(data.keyCode)) { this.mentionsView.executeSelected(); } if (data.keyCode == keyCodes.esc) { this.hideUIAndRemoveMarker(); } } }, { priority: 'highest' }); // Required to override the Enter key. // Close the dropdown upon clicking outside of the plugin UI. clickOutsideHandler({ emitter: this.mentionsView, activator: () => this.isUIVisible(), contextElements: () => [this.balloon.view.element], callback: () => this.hideUIAndRemoveMarker() }); } /** * Creates the {@link #mentionsView}. * @param overallSelectionDropdownLimit * @returns */ createMentionView(overallSelectionDropdownLimit) { const locale = this.editor.locale; const mentionsView = new MentionsView(locale); mentionsView.items.bindTo(this.availableMentionsViewItems).using(data => { const { item, marker } = data; const dropdownLimit = overallSelectionDropdownLimit ?? 10; if (dropdownLimit !== -1 && mentionsView.items.length >= dropdownLimit) { return null; } const listItemView = new MentionListItemView(locale); const view = this.renderItem(item); view.delegate('execute').to(listItemView); view.delegate('mouseover').to(listItemView); listItemView.children.add(view); listItemView.item = item; listItemView.marker = marker; listItemView.on('execute', () => { mentionsView.fire('execute', data); }); const nextIndex = mentionsView.items.length; listItemView.on('mouseover', () => { mentionsView.select(nextIndex); }); return listItemView; }); mentionsView.on('execute', (evt, data) => { // execute confirmed selection handler with custom logic or predefined content replacement (default) data.confirmedSelectionHandler(); this.hideUIAndRemoveMarker(); this.editor.editing.view.focus(); }); return mentionsView; } /** * Renders a single item in the autocomplete list. */ renderItem(item) { const editor = this.editor; let view; let label = item.name ?? item.content.toString().substring(0, 30); if (item.completionElementRenderer) { const renderResult = item.completionElementRenderer(item); if (typeof renderResult != 'string') { view = new DomWrapperView(editor.locale, renderResult); } else { label = renderResult; } } if (!view) { const buttonView = new ButtonView(editor.locale); buttonView.label = label; buttonView.withText = true; if (buttonView.template) { // add custom mouseover handling, that is also done in our custom DomWrapperView implementation buttonView.extendTemplate({ on: { mouseover: buttonView.bindTemplate.to('mouseover') } }); } // the above template extension does nothing more that this dirty approach here, but in a cleaner (and from the ckeditor developers expected) way /* buttonView.render(); buttonView.listenTo(buttonView.element as Node, 'mouseover', () => { buttonView.fire('mouseover'); });*/ view = buttonView; } return view; } /** * Shows the mentions balloon. If the panel is already visible, it will reposition it. */ showOrUpdateUI(markerMarker) { if (this.isUIVisible()) { // @if CK_DEBUG_MENTION // console.log( '%c[UI]%c Updating position.', 'color: green', 'color: black' ); // Update balloon position as the mention list view may change its size. this.balloon.updatePosition(this.getBalloonPanelPositionData(markerMarker, this.mentionsView.position)); } else { // @if CK_DEBUG_MENTION // console.log( '%c[UI]%c Showing the UI.', 'color: green', 'color: black' ); this.balloon.add({ view: this.mentionsView, position: this.getBalloonPanelPositionData(markerMarker, this.mentionsView.position), singleViewMode: true }); } this.mentionsView.position = this.balloon.view.position; this.mentionsView.selectFirst(); } /** * Hides the mentions balloon and removes the 'mention' marker from the markers collection. */ hideUIAndRemoveMarker() { if (this.isUIVisible()) { // Remove the mention view from balloon before removing marker - it is used by balloon position target(). if (this.balloon.hasView(this.mentionsView)) { // @if CK_DEBUG_MENTION // console.log( '%c[UI]%c Hiding the UI.', 'color: green', 'color: black' ); this.balloon.remove(this.mentionsView); } if (this.isInCompletionMode()) { // @if CK_DEBUG_MENTION // console.log( '%c[Editing]%c Removing marker.', 'color: purple', 'color: black' ); this.editor.model.change(writer => writer.removeMarker(this.pluginMarkerName)); } // Make the last matched position on panel view undefined so the #_getBalloonPanelPositionData() method will return all positions // on the next call. this.mentionsView.position = undefined; } } /** * Creates a position options object used to position the balloon panel. * * @param mentionMarker * @param preferredPosition The name of the last matched position name. */ getBalloonPanelPositionData(mentionMarker, preferredPosition) { const editor = this.editor; const editing = editor.editing; const domConverter = editing.view.domConverter; const mapper = editing.mapper; const uiLanguageDirection = editor.locale.uiLanguageDirection; return { target: () => { let modelRange = mentionMarker.getRange(); // Target the UI to the model selection range - the marker has been removed so probably the UI will not be shown anyway. // The logic is used by ContextualBalloon to display another panel in the same place. if (modelRange.start.root.rootName == '$graveyard') { modelRange = editor.model.document.selection.getFirstRange(); } const viewRange = mapper.toViewRange(modelRange); const rangeRects = Rect.getDomRangeRects(domConverter.viewRangeToDom(viewRange)); return rangeRects.pop(); }, limiter: () => { const view = this.editor.editing.view; const viewDocument = view.document; const editableElement = viewDocument.selection.editableElement; if (editableElement) { return view.domConverter.mapViewToDom(editableElement.root); } return null; }, positions: this.getBalloonPanelPositions(preferredPosition, uiLanguageDirection) }; } /** * Returns the balloon positions data callbacks. */ getBalloonPanelPositions(preferredPosition, uiLanguageDirection) { const positions = { // Positions the panel to the southeast of the caret rectangle. caret_se: (targetRect) => { return { top: targetRect.bottom + VERTICAL_SPACING, left: targetRect.right, name: 'caret_se', config: { withArrow: false } }; }, // Positions the panel to the northeast of the caret rectangle. caret_ne: (targetRect, balloonRect) => { return { top: targetRect.top - balloonRect.height - VERTICAL_SPACING, left: targetRect.right, name: 'caret_ne', config: { withArrow: false } }; }, // Positions the panel to the southwest of the caret rectangle. caret_sw: (targetRect, balloonRect) => { return { top: targetRect.bottom + VERTICAL_SPACING, left: targetRect.right - balloonRect.width, name: 'caret_sw', config: { withArrow: false } }; }, // Positions the panel to the northwest of the caret rect. caret_nw: (targetRect, balloonRect) => { return { top: targetRect.top - balloonRect.height - VERTICAL_SPACING, left: targetRect.right - balloonRect.width, name: 'caret_nw', config: { withArrow: false } }; } }; // Returns only the last position if it was matched to prevent the panel from jumping after the first match. if (Object.prototype.hasOwnProperty.call(positions, preferredPosition)) { return [positions[preferredPosition]]; } // By default, return all position callbacks ordered depending on the UI language direction. return uiLanguageDirection !== 'rtl' ? [positions.caret_se, positions.caret_sw, positions.caret_ne, positions.caret_nw] : [positions.caret_sw, positions.caret_se, positions.caret_nw, positions.caret_ne]; } /** * Checks the mention plugins is in completion mode (e.g. when typing is after a valid mention string like @foo). */ isInCompletionMode() { return this.editor.model.markers.has(this.pluginMarkerName); } /** * Returns true when {@link #_mentionsView} is in the {@link module:ui/panel/balloon/contextualballoon~ContextualBalloon} and it is * currently visible. */ isUIVisible() { return this.balloon.visibleView === this.mentionsView; } } // The key codes that mention UI handles when it is open (without commit keys). AutocompleteSelectionUi.defaultHandledKeyCodes = [keyCodes.arrowup, keyCodes.arrowdown, keyCodes.esc]; // Dropdown commit key codes. AutocompleteSelectionUi.defaultCommitKeyCodes = [keyCodes.enter, keyCodes.tab];