obsidian-media-db-sync/src/settings/suggesters/Suggest.ts
2025-01-14 22:30:28 +01:00

185 lines
5.5 KiB
TypeScript

// Credits go to Liam's Periodic Notes Plugin: https://github.com/liamcain/obsidian-periodic-notes
import type { Instance as PopperInstance } from '@popperjs/core';
import { createPopper } from '@popperjs/core';
import type { App, ISuggestOwner } from 'obsidian';
import { Scope } from 'obsidian';
import { wrapAround } from 'src/utils/Utils';
export class Suggest<T> {
private owner: ISuggestOwner<T>;
private values: T[];
private suggestions: HTMLElement[];
private selectedItem: number;
private containerEl: HTMLElement;
constructor(owner: ISuggestOwner<T>, containerEl: HTMLElement, scope: Scope) {
this.owner = owner;
this.containerEl = containerEl;
this.values = [];
this.suggestions = [];
this.selectedItem = 0;
containerEl.on('click', '.suggestion-item', (e, el) => this.onSuggestionClick(e, el));
containerEl.on('mousemove', '.suggestion-item', (e, el) => this.onSuggestionMouseover(e, el));
scope.register([], 'ArrowUp', event => {
if (!event.isComposing) {
this.setSelectedItem(this.selectedItem - 1, true);
return false;
}
return undefined;
});
scope.register([], 'ArrowDown', event => {
if (!event.isComposing) {
this.setSelectedItem(this.selectedItem + 1, true);
return false;
}
return undefined;
});
scope.register([], 'Enter', event => {
if (!event.isComposing) {
this.useSelectedItem(event);
return false;
}
return undefined;
});
}
onSuggestionClick(event: MouseEvent, el: HTMLElement): void {
event.preventDefault();
const item = this.suggestions.indexOf(el);
this.setSelectedItem(item, false);
this.useSelectedItem(event);
}
onSuggestionMouseover(_event: MouseEvent, el: HTMLElement): void {
const item = this.suggestions.indexOf(el);
this.setSelectedItem(item, false);
}
setSuggestions(values: T[]): void {
this.containerEl.empty();
const suggestionEls: HTMLDivElement[] = [];
values.forEach(value => {
const suggestionEl = this.containerEl.createDiv('suggestion-item');
this.owner.renderSuggestion(value, suggestionEl);
suggestionEls.push(suggestionEl);
});
this.values = values;
this.suggestions = suggestionEls;
this.setSelectedItem(0, false);
}
useSelectedItem(event: MouseEvent | KeyboardEvent): void {
const currentValue = this.values[this.selectedItem];
if (currentValue) {
this.owner.selectSuggestion(currentValue, event);
}
}
setSelectedItem(selectedIndex: number, scrollIntoView: boolean): void {
const normalizedIndex = this.suggestions.length > 0 ? wrapAround(selectedIndex, this.suggestions.length) : 0;
const prevSelectedSuggestion = this.suggestions[this.selectedItem];
const selectedSuggestion = this.suggestions[normalizedIndex];
prevSelectedSuggestion?.removeClass('is-selected');
selectedSuggestion?.addClass('is-selected');
this.selectedItem = normalizedIndex;
if (scrollIntoView) {
selectedSuggestion.scrollIntoView(false);
}
}
}
export abstract class TextInputSuggest<T> implements ISuggestOwner<T> {
protected app: App;
protected inputEl: HTMLInputElement;
private popper?: PopperInstance;
private scope: Scope;
private suggestEl: HTMLElement;
private suggest: Suggest<T>;
constructor(app: App, inputEl: HTMLInputElement) {
this.app = app;
this.inputEl = inputEl;
this.scope = new Scope();
this.suggestEl = createDiv('suggestion-container');
const suggestion = this.suggestEl.createDiv('suggestion');
this.suggest = new Suggest(this, suggestion, this.scope);
this.scope.register([], 'Escape', this.close.bind(this));
this.inputEl.addEventListener('input', this.onInputChanged.bind(this));
this.inputEl.addEventListener('focus', this.onInputChanged.bind(this));
this.inputEl.addEventListener('blur', this.close.bind(this));
this.suggestEl.on('mousedown', '.suggestion-container', (event: MouseEvent) => {
event.preventDefault();
});
}
onInputChanged(): void {
const inputStr = this.inputEl.value;
const suggestions = this.getSuggestions(inputStr);
if (suggestions.length > 0) {
this.suggest.setSuggestions(suggestions);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
this.open((this.app as any).dom.appContainerEl, this.inputEl);
}
}
open(container: HTMLElement, inputEl: HTMLElement): void {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(this.app as any).keymap.pushScope(this.scope);
container.appendChild(this.suggestEl);
this.popper = createPopper(inputEl, this.suggestEl, {
placement: 'bottom-start',
modifiers: [
{
name: 'sameWidth',
enabled: true,
fn: ({ state, instance }): void => {
// Note: positioning needs to be calculated twice -
// first pass - positioning it according to the width of the popper
// second pass - position it with the width bound to the reference element
// we need to early exit to avoid an infinite loop
const targetWidth = `${state.rects.reference.width}px`;
if (state.styles.popper.width === targetWidth) {
return;
}
state.styles.popper.width = targetWidth;
instance.update();
},
phase: 'beforeWrite',
requires: ['computeStyles'],
},
],
});
}
close(): void {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(this.app as any).keymap.popScope(this.scope);
this.suggest.setSuggestions([]);
this.popper?.destroy();
this.suggestEl.detach();
}
abstract getSuggestions(inputStr: string): T[];
abstract renderSuggestion(item: T, el: HTMLElement): void;
abstract selectSuggestion(item: T): void;
}