llm-agent-plugin/src/references/ReferenceAutocomplete.ts

387 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Компонент автокомплита для ссылок. Показывает выпадающий список файлов и промптов
* при вводе @/@@ и #/## с умным позиционированием относительно курсора.
*/
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<void> {
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);
}
}
}