// Credits go to Liam's Periodic Notes Plugin: https://github.com/liamcain/obsidian-periodic-notes import { App, ISuggestOwner, Scope } from 'obsidian'; import { createPopper, Instance as PopperInstance } from '@popperjs/core'; const wrapAround = (value: number, size: number): number => { return ((value % size) + size) % size; }; class Suggest { private owner: ISuggestOwner; private values: T[]; private suggestions: HTMLDivElement[]; private selectedItem: number; private containerEl: HTMLElement; constructor(owner: ISuggestOwner, containerEl: HTMLElement, scope: Scope) { this.owner = owner; this.containerEl = containerEl; containerEl.on( 'click', '.suggestion-item', this.onSuggestionClick.bind(this), ); containerEl.on( 'mousemove', '.suggestion-item', this.onSuggestionMouseover.bind(this), ); scope.register([], 'ArrowUp', (event) => { if (!event.isComposing) { this.setSelectedItem(this.selectedItem - 1, true); return false; } }); scope.register([], 'ArrowDown', (event) => { if (!event.isComposing) { this.setSelectedItem(this.selectedItem + 1, true); return false; } }); scope.register([], 'Enter', (event) => { if (!event.isComposing) { this.useSelectedItem(event); return false; } }); } onSuggestionClick(event: MouseEvent, el: HTMLDivElement): void { event.preventDefault(); const item = this.suggestions.indexOf(el); this.setSelectedItem(item, false); this.useSelectedItem(event); } onSuggestionMouseover(_event: MouseEvent, el: HTMLDivElement): void { const item = this.suggestions.indexOf(el); this.setSelectedItem(item, false); } setSuggestions(values: T[]) { 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) { const currentValue = this.values[this.selectedItem]; if (currentValue) { this.owner.selectSuggestion(currentValue, event); } } setSelectedItem(selectedIndex: number, scrollIntoView: boolean) { const normalizedIndex = wrapAround(selectedIndex, this.suggestions.length); 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 implements ISuggestOwner { protected app: App; protected inputEl: HTMLInputElement | HTMLTextAreaElement; private popper: PopperInstance; private scope: Scope; private suggestEl: HTMLElement; private suggest: Suggest; constructor(app: App, inputEl: HTMLInputElement | HTMLTextAreaElement) { 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) { this.close(); return; } if (suggestions.length > 0) { this.suggest.setSuggestions(suggestions); // eslint-disable-next-line @typescript-eslint/no-explicit-any this.open((this.app).dom.appContainerEl, this.inputEl); } else { this.close(); } } open(container: HTMLElement, inputEl: HTMLElement): void { // eslint-disable-next-line @typescript-eslint/no-explicit-any (this.app).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 }) => { // 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).keymap.popScope(this.scope); this.suggest.setSuggestions([]); if (this.popper) this.popper.destroy(); this.suggestEl.detach(); } abstract getSuggestions(inputStr: string): T[]; abstract renderSuggestion(item: T, el: HTMLElement): void; abstract selectSuggestion(item: T): void; }