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()),
		};
	}
}

Главная | Обратная связь

drupal hosting | друпал хостинг | it patrol .inc