import { makeEl } from './dom.ts'; export interface TagSuggestionParams { /** * If present, then this suggestion is for a tag alias. * If absent, then this suggestion is for the `canonical` tag name. */ alias?: null | string; /** * The canonical name of the tag (non-alias). */ canonical: string; /** * Number of images tagged with this tag. */ images: number; /** * Length of the prefix in the suggestion that matches the prefix of the current input. */ matchLength: number; } export class TagSuggestion { alias?: null | string; canonical: string; images: number; matchLength: number; constructor(params: TagSuggestionParams) { this.alias = params.alias; this.canonical = params.canonical; this.images = params.images; this.matchLength = params.matchLength; } value(): string { return this.canonical; } render(): HTMLElement[] { const { alias: aliasName, canonical: canonicalName, images: imageCount } = this; const label = aliasName ? `${aliasName} → ${canonicalName}` : canonicalName; return [ makeEl('div', { className: 'autocomplete__item__content' }, [ makeEl('i', { className: 'fa-solid fa-tag' }), makeEl('b', { className: 'autocomplete__item__tag__match', textContent: ` ${label.slice(0, this.matchLength)}`, }), makeEl('span', { textContent: label.slice(this.matchLength), }), ]), makeEl('span', { className: 'autocomplete__item__tag__count', textContent: ` ${TagSuggestion.formatImageCount(imageCount)}`, }), ]; } static formatImageCount(count: number): string { // We use the 'fr' (French) number formatting style with space-separated // groups of 3 digits. const formatter = new Intl.NumberFormat('fr', { useGrouping: true }); return formatter.format(count); } } export class HistorySuggestion { /** * Full query string that was previously searched and retrieved from the history. */ content: string; /** * Length of the prefix in the suggestion that matches the prefix of the current input. */ matchLength: number; constructor(content: string, matchIndex: number) { this.content = content; this.matchLength = matchIndex; } value(): string { return this.content; } render(): HTMLElement[] { return [ makeEl('div', { className: 'autocomplete__item__content' }, [ makeEl('i', { className: 'autocomplete__item__history__icon fa-solid fa-history', }), makeEl('b', { textContent: ` ${this.content.slice(0, this.matchLength)}`, className: 'autocomplete__item__history__match', }), makeEl('span', { textContent: this.content.slice(this.matchLength), }), ]), // Here will be a `delete` button to remove the item from the history. ]; } } export type Suggestion = TagSuggestion | HistorySuggestion; export interface Suggestions { history: HistorySuggestion[]; tags: TagSuggestion[]; } export interface ItemSelectedEvent { suggestion: Suggestion; shiftKey: boolean; ctrlKey: boolean; } interface SuggestionItem { element: HTMLElement; suggestion: Suggestion; } /** * Responsible for rendering the suggestions dropdown. */ export class SuggestionsPopup { /** * Index of the currently selected suggestion. -1 means an imaginary item * before the first item that represents the state where no item is selected. */ private cursor: number = -1; private items: SuggestionItem[]; private readonly container: HTMLElement; constructor() { this.container = makeEl('div', { className: 'autocomplete hidden', tabIndex: -1, }); document.body.appendChild(this.container); this.items = []; } get selectedSuggestion(): Suggestion | null { return this.selectedItem?.suggestion ?? null; } private get selectedItem(): SuggestionItem | null { if (this.cursor < 0) { return null; } return this.items[this.cursor]; } get isHidden(): boolean { return this.container.classList.contains('hidden'); } hide() { this.clearSelection(); this.container.classList.add('hidden'); } private clearSelection() { this.setSelection(-1); } private setSelection(index: number) { if (this.cursor === index) { return; } // This can't be triggered via the public API of this class /* v8 ignore start */ if (index < -1 || index >= this.items.length) { throw new Error(`BUG: setSelection(): invalid selection index: ${index}`); } /* v8 ignore end */ const selectedClass = 'autocomplete__item--selected'; this.selectedItem?.element.classList.remove(selectedClass); this.cursor = index; if (index >= 0) { this.selectedItem?.element.classList.add(selectedClass); } } setSuggestions(params: Suggestions): SuggestionsPopup { this.cursor = -1; this.items = []; this.container.innerHTML = ''; for (const suggestion of params.history) { this.appendSuggestion(suggestion); } if (params.tags.length > 0 && params.history.length > 0) { this.container.appendChild(makeEl('hr', { className: 'autocomplete__separator' })); } for (const suggestion of params.tags) { this.appendSuggestion(suggestion); } return this; } appendSuggestion(suggestion: Suggestion) { const type = suggestion instanceof TagSuggestion ? 'tag' : 'history'; const element = makeEl( 'div', { className: `autocomplete__item autocomplete__item__${type}`, }, suggestion.render(), ); const item: SuggestionItem = { element, suggestion }; this.watchItem(item); this.items.push(item); this.container.appendChild(element); } private watchItem(item: SuggestionItem) { item.element.addEventListener('click', event => { const detail: ItemSelectedEvent = { suggestion: item.suggestion, shiftKey: event.shiftKey, ctrlKey: event.ctrlKey, }; this.container.dispatchEvent(new CustomEvent('item_selected', { detail })); }); } private changeSelection(direction: number) { if (this.items.length === 0) { return; } const index = this.cursor + direction; if (index === -1 || index >= this.items.length) { this.clearSelection(); } else if (index < -1) { this.setSelection(this.items.length - 1); } else { this.setSelection(index); } } selectDown() { this.changeSelection(1); } selectUp() { this.changeSelection(-1); } /** * The user wants to jump to the next lower block of types of suggestions. */ selectCtrlDown() { if (this.items.length === 0) { return; } if (this.cursor >= this.items.length - 1) { this.setSelection(0); return; } let index = this.cursor + 1; const type = this.itemType(index); while (index < this.items.length - 1 && this.itemType(index) === type) { index += 1; } this.setSelection(index); } /** * The user wants to jump to the next upper block of types of suggestions. */ selectCtrlUp() { if (this.items.length === 0) { return; } if (this.cursor <= 0) { this.setSelection(this.items.length - 1); return; } let index = this.cursor - 1; const type = this.itemType(index); while (index > 0 && this.itemType(index) === type) { index -= 1; } this.setSelection(index); } /** * Returns the item's prototype that can be viewed as the item's type identifier. */ private itemType(index: number) { return this.items[index].suggestion instanceof TagSuggestion ? 'tag' : 'history'; } showForElement(targetElement: HTMLElement) { this.container.style.position = 'absolute'; this.container.style.left = `${targetElement.offsetLeft}px`; let topPosition = targetElement.offsetTop + targetElement.offsetHeight; if (targetElement.parentElement) { topPosition -= targetElement.parentElement.scrollTop; } this.container.style.top = `${topPosition}px`; this.container.classList.remove('hidden'); } onItemSelected(callback: (event: ItemSelectedEvent) => void) { this.container.addEventListener('item_selected', event => { callback((event as CustomEvent).detail); }); } }