philomena/assets/js/utils/suggestions.ts

348 lines
8.3 KiB
TypeScript

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<ItemSelectedEvent>).detail);
});
}
}