2024-08-31 18:48:30 +04:00
|
|
|
import { makeEl } from './dom.ts';
|
2025-03-17 23:05:51 +00:00
|
|
|
import { MatchPart, TagSuggestion } from './suggestions-model.ts';
|
2024-08-31 18:48:30 +04:00
|
|
|
|
2025-03-17 23:05:51 +00:00
|
|
|
export class TagSuggestionComponent {
|
|
|
|
data: TagSuggestion;
|
2025-03-04 04:36:27 +00:00
|
|
|
|
2025-03-17 23:05:51 +00:00
|
|
|
constructor(data: TagSuggestion) {
|
|
|
|
this.data = data;
|
2025-03-04 04:36:27 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
value(): string {
|
2025-03-17 23:05:51 +00:00
|
|
|
if (typeof this.data.canonical === 'string') {
|
|
|
|
return this.data.canonical;
|
|
|
|
}
|
|
|
|
|
|
|
|
return this.data.canonical.map(part => (typeof part === 'string' ? part : part.matched)).join('');
|
2025-03-04 04:36:27 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
render(): HTMLElement[] {
|
2025-03-17 23:05:51 +00:00
|
|
|
const { data } = this;
|
2025-03-04 04:36:27 +00:00
|
|
|
|
|
|
|
return [
|
|
|
|
makeEl('div', { className: 'autocomplete__item__content' }, [
|
|
|
|
makeEl('i', { className: 'fa-solid fa-tag' }),
|
2025-03-17 23:05:51 +00:00
|
|
|
' ',
|
|
|
|
...this.renderLabel(),
|
2025-03-04 04:36:27 +00:00
|
|
|
]),
|
|
|
|
makeEl('span', {
|
|
|
|
className: 'autocomplete__item__tag__count',
|
2025-03-17 23:05:51 +00:00
|
|
|
textContent: ` ${TagSuggestionComponent.renderImageCount(data.images)}`,
|
2025-03-04 04:36:27 +00:00
|
|
|
}),
|
|
|
|
];
|
|
|
|
}
|
|
|
|
|
2025-03-17 23:05:51 +00:00
|
|
|
renderLabel(): (HTMLElement | string)[] {
|
|
|
|
const { data } = this;
|
|
|
|
|
|
|
|
if (!data.alias) {
|
|
|
|
return TagSuggestionComponent.renderMatchParts(data.canonical);
|
|
|
|
}
|
|
|
|
|
|
|
|
return [...TagSuggestionComponent.renderMatchParts(data.alias), ` → ${data.canonical}`];
|
|
|
|
}
|
|
|
|
|
|
|
|
static renderMatchParts(parts: MatchPart[]): (HTMLElement | string)[] {
|
|
|
|
return parts.map(part => {
|
|
|
|
if (typeof part === 'string') {
|
|
|
|
return part;
|
|
|
|
}
|
|
|
|
|
|
|
|
return makeEl('b', {
|
|
|
|
className: 'autocomplete__item__tag__match',
|
|
|
|
textContent: part.matched,
|
|
|
|
});
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
static renderImageCount(count: number): string {
|
2025-03-13 23:36:19 +00:00
|
|
|
// We use the 'fr' (French) number formatting style with space-separated
|
|
|
|
// groups of 3 digits.
|
|
|
|
const formatter = new Intl.NumberFormat('fr', { useGrouping: true });
|
2025-03-04 04:36:27 +00:00
|
|
|
|
2025-03-13 23:36:19 +00:00
|
|
|
return formatter.format(count);
|
2025-03-04 04:36:27 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2025-03-17 23:05:51 +00:00
|
|
|
export class HistorySuggestionComponent {
|
2025-03-04 04:36:27 +00:00
|
|
|
/**
|
|
|
|
* 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.
|
|
|
|
];
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2025-03-17 23:05:51 +00:00
|
|
|
export type Suggestion = TagSuggestionComponent | HistorySuggestionComponent;
|
2025-03-04 04:36:27 +00:00
|
|
|
|
|
|
|
export interface Suggestions {
|
2025-03-17 23:05:51 +00:00
|
|
|
history: HistorySuggestionComponent[];
|
|
|
|
tags: TagSuggestionComponent[];
|
2025-03-04 04:36:27 +00:00
|
|
|
}
|
2024-08-31 18:48:30 +04:00
|
|
|
|
2025-03-04 04:36:27 +00:00
|
|
|
export interface ItemSelectedEvent {
|
|
|
|
suggestion: Suggestion;
|
|
|
|
shiftKey: boolean;
|
|
|
|
ctrlKey: boolean;
|
|
|
|
}
|
|
|
|
|
|
|
|
interface SuggestionItem {
|
|
|
|
element: HTMLElement;
|
|
|
|
suggestion: Suggestion;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Responsible for rendering the suggestions dropdown.
|
|
|
|
*/
|
2025-03-17 23:05:51 +00:00
|
|
|
export class SuggestionsPopupComponent {
|
2025-03-04 04:36:27 +00:00
|
|
|
/**
|
|
|
|
* 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[];
|
2024-08-31 18:48:30 +04:00
|
|
|
private readonly container: HTMLElement;
|
|
|
|
|
|
|
|
constructor() {
|
|
|
|
this.container = makeEl('div', {
|
2025-03-04 04:36:27 +00:00
|
|
|
className: 'autocomplete hidden',
|
|
|
|
tabIndex: -1,
|
2024-08-31 18:48:30 +04:00
|
|
|
});
|
|
|
|
|
2025-03-04 04:36:27 +00:00
|
|
|
document.body.appendChild(this.container);
|
|
|
|
this.items = [];
|
|
|
|
}
|
2024-08-31 18:48:30 +04:00
|
|
|
|
2025-03-04 04:36:27 +00:00
|
|
|
get selectedSuggestion(): Suggestion | null {
|
|
|
|
return this.selectedItem?.suggestion ?? null;
|
2024-08-31 18:48:30 +04:00
|
|
|
}
|
|
|
|
|
2025-03-04 04:36:27 +00:00
|
|
|
private get selectedItem(): SuggestionItem | null {
|
|
|
|
if (this.cursor < 0) {
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
|
|
|
|
return this.items[this.cursor];
|
2024-08-31 18:48:30 +04:00
|
|
|
}
|
|
|
|
|
2025-03-04 04:36:27 +00:00
|
|
|
get isHidden(): boolean {
|
|
|
|
return this.container.classList.contains('hidden');
|
2024-08-31 18:48:30 +04:00
|
|
|
}
|
|
|
|
|
|
|
|
hide() {
|
|
|
|
this.clearSelection();
|
2025-03-04 04:36:27 +00:00
|
|
|
this.container.classList.add('hidden');
|
2024-08-31 18:48:30 +04:00
|
|
|
}
|
|
|
|
|
|
|
|
private clearSelection() {
|
2025-03-04 04:36:27 +00:00
|
|
|
this.setSelection(-1);
|
2024-08-31 18:48:30 +04:00
|
|
|
}
|
|
|
|
|
2025-03-04 04:36:27 +00:00
|
|
|
private setSelection(index: number) {
|
|
|
|
if (this.cursor === index) {
|
|
|
|
return;
|
|
|
|
}
|
2024-08-31 18:48:30 +04:00
|
|
|
|
2025-03-04 04:36:27 +00:00
|
|
|
// 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 */
|
2024-08-31 18:48:30 +04:00
|
|
|
|
2025-03-04 04:36:27 +00:00
|
|
|
const selectedClass = 'autocomplete__item--selected';
|
2024-08-31 18:48:30 +04:00
|
|
|
|
2025-03-04 04:36:27 +00:00
|
|
|
this.selectedItem?.element.classList.remove(selectedClass);
|
|
|
|
this.cursor = index;
|
2024-08-31 18:48:30 +04:00
|
|
|
|
2025-03-04 04:36:27 +00:00
|
|
|
if (index >= 0) {
|
|
|
|
this.selectedItem?.element.classList.add(selectedClass);
|
|
|
|
}
|
|
|
|
}
|
2024-08-31 18:48:30 +04:00
|
|
|
|
2025-03-17 23:05:51 +00:00
|
|
|
setSuggestions(params: Suggestions): SuggestionsPopupComponent {
|
2025-03-04 04:36:27 +00:00
|
|
|
this.cursor = -1;
|
|
|
|
this.items = [];
|
|
|
|
this.container.innerHTML = '';
|
2024-08-31 18:48:30 +04:00
|
|
|
|
2025-03-04 04:36:27 +00:00
|
|
|
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);
|
2024-08-31 18:48:30 +04:00
|
|
|
}
|
|
|
|
|
|
|
|
return this;
|
|
|
|
}
|
|
|
|
|
2025-03-04 04:36:27 +00:00
|
|
|
appendSuggestion(suggestion: Suggestion) {
|
2025-03-17 23:05:51 +00:00
|
|
|
const type = suggestion instanceof TagSuggestionComponent ? 'tag' : 'history';
|
2025-03-04 04:36:27 +00:00
|
|
|
|
|
|
|
const element = makeEl(
|
|
|
|
'div',
|
|
|
|
{
|
|
|
|
className: `autocomplete__item autocomplete__item__${type}`,
|
|
|
|
},
|
|
|
|
suggestion.render(),
|
|
|
|
);
|
2024-08-31 18:48:30 +04:00
|
|
|
|
2025-03-04 04:36:27 +00:00
|
|
|
const item: SuggestionItem = { element, suggestion };
|
2024-08-31 18:48:30 +04:00
|
|
|
|
2025-03-04 04:36:27 +00:00
|
|
|
this.watchItem(item);
|
2024-08-31 18:48:30 +04:00
|
|
|
|
2025-03-04 04:36:27 +00:00
|
|
|
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 }));
|
2024-08-31 18:48:30 +04:00
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
private changeSelection(direction: number) {
|
2025-03-04 04:36:27 +00:00
|
|
|
if (this.items.length === 0) {
|
|
|
|
return;
|
2024-08-31 18:48:30 +04:00
|
|
|
}
|
|
|
|
|
2025-03-04 04:36:27 +00:00
|
|
|
const index = this.cursor + direction;
|
|
|
|
|
|
|
|
if (index === -1 || index >= this.items.length) {
|
2024-08-31 18:48:30 +04:00
|
|
|
this.clearSelection();
|
2025-03-04 04:36:27 +00:00
|
|
|
} else if (index < -1) {
|
|
|
|
this.setSelection(this.items.length - 1);
|
|
|
|
} else {
|
|
|
|
this.setSelection(index);
|
2024-08-31 18:48:30 +04:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2025-03-04 04:36:27 +00:00
|
|
|
selectDown() {
|
2024-09-02 09:32:50 -04:00
|
|
|
this.changeSelection(1);
|
2024-08-31 18:48:30 +04:00
|
|
|
}
|
|
|
|
|
2025-03-04 04:36:27 +00:00
|
|
|
selectUp() {
|
2024-09-02 09:32:50 -04:00
|
|
|
this.changeSelection(-1);
|
2024-08-31 18:48:30 +04:00
|
|
|
}
|
|
|
|
|
2025-03-04 04:36:27 +00:00
|
|
|
/**
|
|
|
|
* The user wants to jump to the next lower block of types of suggestions.
|
|
|
|
*/
|
|
|
|
selectCtrlDown() {
|
|
|
|
if (this.items.length === 0) {
|
|
|
|
return;
|
|
|
|
}
|
2024-08-31 18:48:30 +04:00
|
|
|
|
2025-03-04 04:36:27 +00:00
|
|
|
if (this.cursor >= this.items.length - 1) {
|
|
|
|
this.setSelection(0);
|
|
|
|
return;
|
2024-08-31 18:48:30 +04:00
|
|
|
}
|
|
|
|
|
2025-03-04 04:36:27 +00:00
|
|
|
let index = this.cursor + 1;
|
|
|
|
const type = this.itemType(index);
|
2024-08-31 18:48:30 +04:00
|
|
|
|
2025-03-04 04:36:27 +00:00
|
|
|
while (index < this.items.length - 1 && this.itemType(index) === type) {
|
|
|
|
index += 1;
|
|
|
|
}
|
2024-08-31 18:48:30 +04:00
|
|
|
|
2025-03-04 04:36:27 +00:00
|
|
|
this.setSelection(index);
|
2024-08-31 18:48:30 +04:00
|
|
|
}
|
|
|
|
|
2025-03-04 04:36:27 +00:00
|
|
|
/**
|
|
|
|
* The user wants to jump to the next upper block of types of suggestions.
|
|
|
|
*/
|
|
|
|
selectCtrlUp() {
|
|
|
|
if (this.items.length === 0) {
|
|
|
|
return;
|
|
|
|
}
|
2024-08-31 18:48:30 +04:00
|
|
|
|
2025-03-04 04:36:27 +00:00
|
|
|
if (this.cursor <= 0) {
|
|
|
|
this.setSelection(this.items.length - 1);
|
|
|
|
return;
|
|
|
|
}
|
2024-08-31 18:48:30 +04:00
|
|
|
|
2025-03-04 04:36:27 +00:00
|
|
|
let index = this.cursor - 1;
|
|
|
|
const type = this.itemType(index);
|
2024-08-31 18:48:30 +04:00
|
|
|
|
2025-03-04 04:36:27 +00:00
|
|
|
while (index > 0 && this.itemType(index) === type) {
|
|
|
|
index -= 1;
|
|
|
|
}
|
2024-08-31 18:48:30 +04:00
|
|
|
|
2025-03-04 04:36:27 +00:00
|
|
|
this.setSelection(index);
|
|
|
|
}
|
2024-08-31 18:48:30 +04:00
|
|
|
|
2025-03-04 04:36:27 +00:00
|
|
|
/**
|
|
|
|
* Returns the item's prototype that can be viewed as the item's type identifier.
|
|
|
|
*/
|
|
|
|
private itemType(index: number) {
|
2025-03-17 23:05:51 +00:00
|
|
|
return this.items[index].suggestion instanceof TagSuggestionComponent ? 'tag' : 'history';
|
2025-03-04 04:36:27 +00:00
|
|
|
}
|
2024-08-31 21:11:53 +04:00
|
|
|
|
2025-03-04 04:36:27 +00:00
|
|
|
showForElement(targetElement: HTMLElement) {
|
|
|
|
this.container.style.position = 'absolute';
|
|
|
|
this.container.style.left = `${targetElement.offsetLeft}px`;
|
2024-08-31 21:11:53 +04:00
|
|
|
|
2025-03-04 04:36:27 +00:00
|
|
|
let topPosition = targetElement.offsetTop + targetElement.offsetHeight;
|
2025-02-10 05:45:34 +04:00
|
|
|
|
2025-03-04 04:36:27 +00:00
|
|
|
if (targetElement.parentElement) {
|
|
|
|
topPosition -= targetElement.parentElement.scrollTop;
|
|
|
|
}
|
2025-02-10 05:45:34 +04:00
|
|
|
|
2025-03-04 04:36:27 +00:00
|
|
|
this.container.style.top = `${topPosition}px`;
|
|
|
|
this.container.classList.remove('hidden');
|
2025-02-12 20:51:31 +04:00
|
|
|
}
|
2025-02-10 05:45:34 +04:00
|
|
|
|
2025-03-04 04:36:27 +00:00
|
|
|
onItemSelected(callback: (event: ItemSelectedEvent) => void) {
|
|
|
|
this.container.addEventListener('item_selected', event => {
|
|
|
|
callback((event as CustomEvent<ItemSelectedEvent>).detail);
|
|
|
|
});
|
|
|
|
}
|
2025-02-10 05:45:34 +04:00
|
|
|
}
|