ckeditor_taxonomy_glossary-1.0.0-alpha1/js/ckeditor5_plugins/glossaryLink/src/glossarylinkui.js
js/ckeditor5_plugins/glossaryLink/src/glossarylinkui.js
/**
* @file
* The glossary link UI.
*/
import { Plugin } from "ckeditor5/src/core";
import { ButtonView } from "ckeditor5/src/ui";
import { ContextualBalloon } from "ckeditor5/src/ui";
import { clickOutsideHandler } from "ckeditor5/src/utils";
import GlossaryLinkFormView from "./glossarylinkformview";
import glossaryLinkIcon from "../theme/icons/glossary-link.svg";
// Note: autocomplete.js is now loaded as a Drupal behavior
// Access jQuery from global scope
const $ = jQuery;
/**
* The glossary link UI plugin.
*/
export default class GlossaryLinkUI extends Plugin {
/**
* @inheritdoc
*/
static get requires() {
return [ContextualBalloon];
}
/**
* @inheritdoc
*/
static get pluginName() {
return "GlossaryLinkUI";
}
/**
* @inheritdoc
*/
init() {
this._createToolbarButton();
this._createFormView();
}
/**
* Creates the toolbar button.
*/
_createToolbarButton() {
const editor = this.editor;
const command = editor.commands.get("glossaryLink");
editor.ui.componentFactory.add("glossaryLink", (locale) => {
const button = new ButtonView(locale);
button.set({
label: Drupal.t("Glossary Link"),
icon: glossaryLinkIcon,
tooltip: true,
isToggleable: true,
});
button.bind("isOn").to(command, "value", (value) => !!value);
button.bind("isEnabled").to(command, "isEnabled");
button.on("execute", () => {
this._showForm();
});
return button;
});
}
/**
* Creates the form view.
*/
_createFormView() {
const editor = this.editor;
const options = editor.config.get("ckeditorTaxonomyGlossary") || {};
this.formView = new GlossaryLinkFormView(editor.locale, options);
this.formView.on("submit", () => {
const $input = $(this.formView.termIdInputView.fieldView.element);
const termId =
$input.data("glossary-id") ||
this.formView.termIdInputView.fieldView.element.value;
if (termId) {
// Validate that termId is numeric
if (!/^\d+$/.test(termId)) {
// Show error message for invalid term ID using Drupal's messaging system
if (typeof Drupal !== "undefined" && Drupal.announce) {
Drupal.announce(
Drupal.t("Please select a valid glossary term from the autocomplete suggestions."),
"assertive"
);
}
return;
}
editor.execute("glossaryLink", termId);
}
this._hideForm();
});
this.formView.on("cancel", () => {
this._hideForm();
});
this.formView.on("removeLink", () => {
editor.execute("glossaryLink", null);
this._hideForm();
});
}
/**
* Shows the form.
*/
async _showForm() {
const editor = this.editor;
const balloon = this.editor.plugins.get(ContextualBalloon);
if (balloon.hasView(this.formView)) {
return;
}
// Initialize form values properly
await this._initializeFormValues();
// Add balloon close listeners
this._addBalloonCloseListeners();
balloon.add({
view: this.formView,
position: this._getBalloonPositionData(),
});
this.formView.focus();
// Initialize autocomplete after the form is shown
this._initializeAutocomplete();
}
/**
* Initializes form values from the current command state.
*/
async _initializeFormValues() {
const command = this.editor.commands.get("glossaryLink");
const $input = $(this.formView.termIdInputView.fieldView.element);
// Force command refresh to get current selection state
command.refresh();
// Only populate if we have a valid command value for existing links
if (
command.value &&
this.formView.termIdInputView &&
this.formView.termIdInputView.fieldView
) {
// For existing links, fetch the term name and display "Term Name (ID)"
try {
const response = await fetch(`/glossary/term/${command.value}`);
if (response.ok) {
const termData = await response.json();
const displayValue = `${termData.name} (${termData.id})`;
this.formView.termIdInputView.fieldView.set("value", displayValue);
$input.data("glossary-id", termData.id);
} else {
// Fallback to just the ID if fetch fails
this.formView.termIdInputView.fieldView.set("value", command.value);
}
} catch (error) {
console.warn("Failed to fetch term name:", error);
// Fallback to just the ID if fetch fails
this.formView.termIdInputView.fieldView.set("value", command.value);
}
}
// Set editing mode based on whether we have an existing link
this.formView.setEditingMode(!!command.value);
}
/**
* Adds balloon close listeners.
*/
_addBalloonCloseListeners() {
// Bind methods to preserve context
this._boundOnEscapeKey = this._onEscapeKey.bind(this);
this._boundOnClickOutside = this._onClickOutside.bind(this);
// Add listeners with a slight delay to prevent immediate closure
setTimeout(() => {
document.addEventListener("keydown", this._boundOnEscapeKey);
document.addEventListener("click", this._boundOnClickOutside);
}, 100);
}
/**
* Removes balloon close listeners.
*/
_removeBalloonCloseListeners() {
if (this._boundOnEscapeKey) {
document.removeEventListener("keydown", this._boundOnEscapeKey);
this._boundOnEscapeKey = null;
}
if (this._boundOnClickOutside) {
document.removeEventListener("click", this._boundOnClickOutside);
this._boundOnClickOutside = null;
}
}
/**
* Handles escape key press.
*/
_onEscapeKey(event) {
if (event.key === "Escape" && this._isBalloonVisible()) {
event.preventDefault();
event.stopPropagation();
this._hideForm();
}
}
/**
* Handles clicks outside the balloon.
*/
_onClickOutside(event) {
if (!this._isBalloonVisible()) {
return;
}
const balloon = this.editor.plugins.get(ContextualBalloon);
const balloonElement = balloon.view.element;
// Check if click is inside balloon
if (balloonElement?.contains(event.target)) {
return;
}
// Check if click is on the glossary button (prevent closure when opening)
const glossaryButton = document.querySelector(
'[data-cke-tooltip-text="Glossary Link"]',
);
if (
glossaryButton &&
(glossaryButton === event.target || glossaryButton.contains(event.target))
) {
return;
}
// Check if click is on the autocomplete dropdown
const autocompleteDropdown = document.querySelector(".ui-autocomplete");
if (autocompleteDropdown?.contains(event.target)) {
return;
}
// Check if click is on any autocomplete item
const autocompleteItem = event.target.closest(
".glossary-autocomplete-item, .glossary-autocomplete-wrapper, .ui-menu-item",
);
if (autocompleteItem) {
return;
}
this._hideForm();
}
/**
* Checks if the balloon is currently visible.
*/
_isBalloonVisible() {
const balloon = this.editor.plugins.get(ContextualBalloon);
return balloon?.hasView(this.formView);
}
/**
* Initializes the autocomplete functionality.
*/
_initializeAutocomplete() {
const $input = $(this.formView.termIdInputView.fieldView.element);
// Destroy existing autocomplete widget if it exists to ensure clean state
if ($input.hasClass("ui-autocomplete-input")) {
$input.autocomplete("destroy");
}
// Clear existing classes and data to prevent state persistence
$input.removeClass(
"glossary-autocomplete ui-autocomplete-input ck-input_focused",
);
$input.removeData("glossary-id ui-autocomplete autocomplete");
$input.off(".autocomplete");
$input.val("");
// Store references to editor and form view for term creation callback
$input.data("ckeditor-instance", this.editor);
$input.data("form-view", this.formView);
$input.data("ui-instance", this);
// Initialize autocomplete directly
const autocompleteWidget = $input.autocomplete({
source: (request, response) => {
$.ajax({
url: `/glossary/autocomplete/${encodeURIComponent(request.term)}`,
dataType: "json",
success: (data) => {
// Check if there's an exact match (case-insensitive)
const exactMatch = data.some(item =>
item.label.toLowerCase() === request.term.toLowerCase()
);
// Add "Create new term" option if user has permission and no exact match exists
if (
!exactMatch &&
typeof Drupal !== "undefined" &&
typeof drupalSettings !== "undefined" &&
drupalSettings.user?.permissions &&
drupalSettings.user.permissions.indexOf("create glossary terms via editor") !== -1 &&
request.term.length >= 2
) {
data.push({
id: "create_new",
label: Drupal.t('Create new term: "@term"', { "@term": request.term }),
description: Drupal.t("Click to create a new glossary term"),
is_create_option: true,
term_name: request.term,
});
}
response(data);
},
error: () => {
response([]);
},
});
},
minLength: 2,
delay: 300,
select: (event, ui) => {
if (ui.item.is_create_option) {
// Handle "Create new term" selection
event.preventDefault();
if (typeof Drupal !== "undefined" && Drupal.glossaryAutocomplete) {
Drupal.glossaryAutocomplete.showCreateTermModal(
ui.item.term_name,
$input,
);
}
return false;
}
// Store the selected term ID
$input.data("glossary-id", ui.item.id);
// Display the term name with ID for clarity
$input.val(`${ui.item.label} (${ui.item.id})`);
return false;
},
focus: (event, ui) => {
if (ui.item.is_create_option) {
return false;
}
// Show term name during focus
$input.val(ui.item.label);
return false;
}
});
// Override the _renderItem method after autocomplete is initialized
autocompleteWidget.autocomplete("widget").menu("option", "items", "> :not(.ui-autocomplete-category)");
// Custom render function
autocompleteWidget.data("ui-autocomplete")._renderItem = (ul, item) => {
const $li = $("<li>");
const $wrapper = $("<div class='glossary-autocomplete-wrapper ui-menu-item-wrapper'>");
if (item.is_create_option) {
// Special styling for "Create new" option
$wrapper.addClass("create-new-option");
$wrapper.html(
`<strong style='color: #0073aa;'>+ ${item.label}</strong><br><small>${item.description}</small>`,
);
} else {
// Create the header with language indicator and term name
let headerContent = '';
// Add language indicator first if available - show as badge with language code
if (item.langcode) {
const langBadge = item.langcode.toUpperCase();
headerContent += `<span class='glossary-autocomplete-language'>[${langBadge}]</span>`;
}
headerContent += `<span class='glossary-autocomplete-label'>${item.label}</span>`;
let content = `<div class='glossary-autocomplete-header'>${headerContent}</div>`;
// Add description if available
if (item.description) {
content += `<div class='glossary-autocomplete-description'>${item.description}</div>`;
}
$wrapper.html(content);
}
$li.append($wrapper);
return $li.appendTo(ul);
};
}
/**
* Hides the form.
*/
_hideForm() {
const balloon = this.editor.plugins.get(ContextualBalloon);
// Clear the input field and remove stored data
if (this.formView?.termIdInputView?.fieldView) {
this.formView.termIdInputView.fieldView.set("value", "");
const $input = $(this.formView.termIdInputView.fieldView.element);
$input.removeData("glossary-id");
}
// Only remove the view if it exists in the balloon
if (balloon.hasView(this.formView)) {
balloon.remove(this.formView);
}
this.editor.editing.view.focus();
// Remove balloon close listeners
this._removeBalloonCloseListeners();
}
/**
* Returns the balloon position data.
*/
_getBalloonPositionData() {
const view = this.editor.editing.view;
const viewDocument = view.document;
const selection = viewDocument.selection;
return {
target: () => view.domConverter.viewRangeToDom(selection.getFirstRange()),
};
}
}
