// 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 { private owner: ISuggestOwner; private values: T[]; private suggestions: HTMLElement[]; private selectedItem: number; private containerEl: HTMLElement; constructor(owner: ISuggestOwner, 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 implements ISuggestOwner { protected app: App; protected inputEl: HTMLInputElement; private popper?: PopperInstance; private scope: Scope; private suggestEl: HTMLElement; private suggest: Suggest; 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; }