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