@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
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 { 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];