/** * Компонент автокомплита для ссылок. Показывает выпадающий список файлов и промптов * при вводе @/@@ и #/## с умным позиционированием относительно курсора. */ import { AutocompleteItem, AutocompleteManager } from './AutocompleteManager'; import { ReferenceMatch } from './ReferenceParser'; import { setIcon } from 'obsidian'; // ----------------------------------------------- Reference Autocomplete Component --------------------------------------------------------------- const CHAT_CONTAINER_SELECTOR = 'chat-input-container'; const SETTINGS_CONTAINER_SELECTOR = 'vertical-tab-content-container'; export class ReferenceAutocomplete { private container: HTMLElement; private dropdown: HTMLElement; private inputElement: HTMLElement; private autocompleteManager: AutocompleteManager; private currentReference: ReferenceMatch | null = null; private selectedIndex = 0; private currentOptions: AutocompleteItem[] = []; private onItemSelectedCallback?: (item: AutocompleteItem, match: ReferenceMatch) => void; private mouseIsOverDropdown = false; // Флаг для отслеживания наведения мыши constructor(inputElement: HTMLElement, autocompleteManager: AutocompleteManager) { this.inputElement = inputElement; this.autocompleteManager = autocompleteManager; // Ищем контейнер чата, а если не находим (мы в настройках), // используем родителя элемента или body const chatContainer = this.inputElement.closest(`.${CHAT_CONTAINER_SELECTOR}`); const settingsContainer = this.inputElement.closest(`.${SETTINGS_CONTAINER_SELECTOR}`); this.container = (chatContainer || settingsContainer || document.body) as HTMLElement; this.createDropdown(); this.setupEventListeners(); } // ----------------------------------------------- Public Methods --------------------------------------------------------------- /** * Показывает автокомплит с опциями. * @param reference - объект ReferenceMatch, определяющий текущий вводимый референс. * @param cursorRect - DOMRect объекта курсора, для определения позиции. */ async showAutocomplete(reference: ReferenceMatch, cursorRect: DOMRect): Promise { this.currentReference = reference; // Для внешних файлов ($, !$) сразу открываем диалог if (reference.type === 'external') { const selectedFile = await this.autocompleteManager.handleExternalFileSelection(); if (selectedFile && this.onItemSelectedCallback) { this.onItemSelectedCallback(selectedFile, reference); } return; } // Для файлов и промптов работаем как раньше let options: AutocompleteItem[] = []; if (reference.type === 'file') { options = await this.autocompleteManager.getFileOptions(reference.name); } else if (reference.type === 'prompt') { options = await this.autocompleteManager.getPromptOptions(reference.name); } else if (reference.type === 'url') { options = await this.autocompleteManager.getUrlOptions(reference.name); } if (options.length === 0) { this.hideAutocomplete(); return; } this.currentOptions = options; if (this.selectedIndex >= this.currentOptions.length) { this.selectedIndex = 0; } this.renderOptions(); this.positionDropdown(cursorRect); this.dropdown.style.display = 'block'; } /** * Скрывает автокомплит */ hideAutocomplete(): void { this.dropdown.style.display = 'none'; this.currentReference = null; this.currentOptions = []; this.selectedIndex = 0; } /** * Обрабатывает нажатия клавиш для навигации * @param event - событие клавиатуры * @returns true если событие было обработано */ handleKeydown(event: KeyboardEvent): boolean { // Мы хотим, чтобы стрелки и Enter обрабатывались только если автокомплит видим if (!this.isVisible()) { return false; } switch (event.key) { case 'ArrowDown': event.preventDefault(); // Предотвращаем прокрутку страницы this.moveSelection(1); return true; case 'ArrowUp': event.preventDefault(); // Предотвращаем прокрутку страницы this.moveSelection(-1); return true; case 'Enter': case 'Tab': // Разрешаем Tab завершать автокомплит event.preventDefault(); this.selectCurrentItem(); return true; case 'Escape': event.preventDefault(); this.hideAutocomplete(); return true; default: return false; } } /** * Устанавливает коллбек для выбора элемента * @param callback - функция обратного вызова */ onItemSelected(callback: (item: AutocompleteItem, match: ReferenceMatch) => void): void { this.onItemSelectedCallback = callback; } /** * Проверяет, виден ли автокомплит * @returns true если виден */ isVisible(): boolean { return this.dropdown.style.display === 'block'; } // ----------------------------------------------- Private Methods --------------------------------------------------------------- /** * Создает элемент выпадающего списка */ private createDropdown(): void { this.dropdown = document.createElement('div'); this.dropdown.className = 'reference-autocomplete'; this.dropdown.style.display = 'none'; // Если мы в настройках, добавляем фиксированное позиционирование if (this.container === document.body) { this.dropdown.style.position = 'fixed'; } // Добавляем dropdown в контейнер this.container.appendChild(this.dropdown); } /** * Настраивает обработчики событий */ private setupEventListeners(): void { // Закрытие при клике вне dropdown document.addEventListener('click', (e) => { if (!this.dropdown.contains(e.target as Node) && !this.inputElement.contains(e.target as Node)) { this.hideAutocomplete(); } }); // Отслеживание наведения мыши для предотвращения скрытия по blur this.dropdown.addEventListener('mouseenter', () => { this.mouseIsOverDropdown = true; }); this.dropdown.addEventListener('mouseleave', () => { this.mouseIsOverDropdown = false; }); // Скрытие при потере фокуса input элементом, если мышь не над дропдауном this.inputElement.addEventListener('blur', () => { // Задержка позволяет клику на элемент дропдауна сработать if (!this.mouseIsOverDropdown) { this.hideAutocomplete(); } }); } /** * Отрисовывает опции в dropdown */ private renderOptions(): void { this.dropdown.innerHTML = ''; this.currentOptions.forEach((option, index) => { const optionElement = this.createOptionElement(option, index === this.selectedIndex); this.dropdown.appendChild(optionElement); }); this.scrollToSelected(); // Убеждаемся, что выбранный элемент виден } /** * Создает элемент опции * @param option - опция для отображения * @param isSelected - выбрана ли опция * @returns HTML элемент опции */ private createOptionElement(option: AutocompleteItem, isSelected: boolean): HTMLElement { const optionDiv = document.createElement('div'); optionDiv.className = `reference-option ${isSelected ? 'selected' : ''}`; // Иконка const iconSpan = document.createElement('span'); iconSpan.className = 'reference-option-icon'; setIcon(iconSpan, option.icon); // Имя файла const nameSpan = document.createElement('span'); nameSpan.className = 'reference-option-name'; nameSpan.textContent = option.name; // Путь к файлу const pathSpan = document.createElement('span'); pathSpan.className = 'reference-option-path'; pathSpan.textContent = option.path; optionDiv.appendChild(iconSpan); optionDiv.appendChild(nameSpan); optionDiv.appendChild(pathSpan); // Обработчик клика optionDiv.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); this.selectItem(option); }); return optionDiv; } /** * Позиционирует dropdown относительно курсора * @param cursorRect - DOMRect объекта курсора, для определения позиции. */ private positionDropdown(cursorRect: DOMRect): void { // Чтобы получить корректные размеры, устанавливаем display: 'block' this.dropdown.style.display = 'block'; const dropdownRect = this.dropdown.getBoundingClientRect(); const viewportHeight = window.innerHeight; const viewportWidth = window.innerWidth; let top: number; let left: number; let dropdownMinWidth: number; const isChat = this.container.classList.contains(CHAT_CONTAINER_SELECTOR); console.log("isChat: " + isChat) const inputRect = this.inputElement.getBoundingClientRect(); const containerRect = this.container.getBoundingClientRect(); if (!isChat) { // Позиционируем относительно inputElement, т.к. он уже в потоке документа // Левый край дропдауна совпадает с левым краем курсора left = containerRect.x - 35; // 35 - эмпирика, но что там должно быть, не понял console.log("left: " + left) // Дефолтное положение - под курсором top = cursorRect.bottom - containerRect.top + 5; // +5px отступ // Проверяем, есть ли место снизу const spaceBelow = viewportHeight - cursorRect.bottom; const spaceAbove = cursorRect.top; if (spaceBelow < dropdownRect.height + 10 && spaceAbove > dropdownRect.height + 10) { // Если снизу мало места и сверху достаточно, размещаем сверху top = cursorRect.top - containerRect.top - dropdownRect.height - 5; // -5px отступ } dropdownMinWidth = 200; } else { // Старая логика для чата (Relative к container) // Позиционируем относительно inputElement, т.к. он уже в потоке документа // Левый край дропдауна совпадает с левым краем курсора left = cursorRect.left - containerRect.left; // Дефолтное положение - под курсором top = cursorRect.bottom - containerRect.top + 5; // +5px отступ // Проверяем, есть ли место снизу const spaceBelow = viewportHeight - cursorRect.bottom; const spaceAbove = cursorRect.top; if (spaceBelow < dropdownRect.height + 10 && spaceAbove > dropdownRect.height + 10) { // Если снизу мало места и сверху достаточно, размещаем сверху top = cursorRect.top - containerRect.top - dropdownRect.height - 5; // -5px отступ } // Убедимся, что дропдаун не выходит за границы контейнера справа const maxRight = inputRect.right - containerRect.left; if (left + dropdownRect.width > maxRight) { left = maxRight - dropdownRect.width; if (left < 0) left = 0; // Если дропдаун шире контейнера, прижимаем к левому краю } // Убедимся, что дропдаун не выходит за границы контейнера слева if (left < 0) { left = 0; } // Устанавливаем ширину дропдауна равной ширине inputElement, если он больше // Это предотвратит слишком узкий дропдаун, если имя файла короткое dropdownMinWidth = inputRect.width / 2; // Минимум половина ширины инпута } this.dropdown.style.top = `${top}px`; this.dropdown.style.left = `${left}px`; this.dropdown.style.minWidth = `${dropdownMinWidth}px`; this.dropdown.style.width = 'auto'; // Позволяем контенту определить ширину, но не меньше minWidth } /** * Перемещает выделение в списке * @param direction - направление (1 = вниз, -1 = вверх) */ private moveSelection(direction: number): void { this.selectedIndex += direction; if (this.selectedIndex < 0) { this.selectedIndex = this.currentOptions.length - 1; } else if (this.selectedIndex >= this.currentOptions.length) { this.selectedIndex = 0; } this.renderOptions(); this.scrollToSelected(); } /** * Прокручивает список к выбранному элементу */ private scrollToSelected(): void { const selectedElement = this.dropdown.querySelector('.reference-option.selected') as HTMLElement; if (selectedElement) { selectedElement.scrollIntoView({ block: 'nearest' }); } } /** * Выбирает текущий элемент */ private selectCurrentItem(): void { if (this.selectedIndex >= 0 && this.selectedIndex < this.currentOptions.length) { this.selectItem(this.currentOptions[this.selectedIndex]); } } /** * Выбирает указанный элемент * @param item - выбранный элемент */ private selectItem(item: AutocompleteItem): void { if (this.onItemSelectedCallback && this.currentReference) { this.onItemSelectedCallback(item, this.currentReference); } this.hideAutocomplete(); } /** * Очищает ресурсы */ destroy(): void { if (this.dropdown.parentNode) { this.dropdown.parentNode.removeChild(this.dropdown); } } }