philomena/assets/js/autocomplete.ts

352 lines
11 KiB
TypeScript
Raw Normal View History

2019-10-05 02:09:52 +02:00
/**
* Autocomplete.
*/
import { LocalAutocompleter } from './utils/local-autocompleter';
import { handleError } from './utils/requests';
2024-05-30 19:55:41 +02:00
import { getTermContexts } from './match_query';
import store from './utils/store';
2024-08-28 00:36:33 +02:00
import { TermContext } from './query/lex.ts';
import { $, $$, makeEl, removeEl } from './utils/dom.ts';
2021-12-27 01:16:21 +01:00
2024-08-28 00:36:33 +02:00
type TermSuggestion = {
label: string;
value: string;
};
const cachedSuggestions: Record<string, TermSuggestion[]> = {};
let inputField: HTMLInputElement | null = null,
originalTerm: string | undefined,
originalQuery: string | undefined,
selectedTerm: TermContext | null = null;
2019-10-05 02:09:52 +02:00
function removeParent() {
2024-08-28 00:36:33 +02:00
const parent = $<HTMLElement>('.autocomplete');
if (parent) removeEl(parent);
2019-10-05 02:09:52 +02:00
}
function removeSelected() {
2024-08-28 00:36:33 +02:00
const selected = $<HTMLElement>('.autocomplete__item--selected');
2019-10-05 02:09:52 +02:00
if (selected) selected.classList.remove('autocomplete__item--selected');
}
function isSearchField(targetInput: HTMLElement): boolean {
2024-08-28 00:36:33 +02:00
return targetInput && targetInput.dataset.acMode === 'search';
}
function restoreOriginalValue() {
2024-08-28 00:36:33 +02:00
if (!inputField) {
return;
}
if (isSearchField(inputField) && originalQuery) {
inputField.value = originalQuery;
}
if (originalTerm) {
inputField.value = originalTerm;
}
}
2024-08-28 00:36:33 +02:00
function applySelectedValue(selection: string) {
if (!inputField) {
return;
}
2024-08-28 00:36:33 +02:00
if (!isSearchField(inputField)) {
inputField.value = selection;
return;
}
2024-08-28 00:36:33 +02:00
if (selectedTerm && originalQuery) {
const [startIndex, endIndex] = selectedTerm[0];
inputField.value = originalQuery.slice(0, startIndex) + selection + originalQuery.slice(endIndex);
inputField.setSelectionRange(startIndex + selection.length, startIndex + selection.length);
inputField.focus();
}
}
2024-08-28 00:36:33 +02:00
function changeSelected(firstOrLast: Element | null, current: Element | null, sibling: Element | null) {
2024-07-04 02:27:59 +02:00
if (current && sibling) {
// if the currently selected item has a sibling, move selection to it
2019-10-05 02:09:52 +02:00
current.classList.remove('autocomplete__item--selected');
sibling.classList.add('autocomplete__item--selected');
2024-07-04 02:27:59 +02:00
} else if (current) {
// if the next keypress will take the user outside the list, restore the unautocompleted term
restoreOriginalValue();
2019-10-05 02:09:52 +02:00
removeSelected();
2024-07-04 02:27:59 +02:00
} else if (firstOrLast) {
// if no item in the list is selected, select the first or last
2019-10-05 02:09:52 +02:00
firstOrLast.classList.add('autocomplete__item--selected');
}
}
function isSelectionOutsideCurrentTerm(): boolean {
2024-08-28 00:36:33 +02:00
if (!inputField || !selectedTerm) return true;
if (inputField.selectionStart === null || inputField.selectionEnd === null) return true;
const selectionIndex = Math.min(inputField.selectionStart, inputField.selectionEnd);
const [startIndex, endIndex] = selectedTerm[0];
return startIndex > selectionIndex || endIndex < selectionIndex;
}
2024-08-28 00:36:33 +02:00
function keydownHandler(event: KeyboardEvent) {
const selected = $<HTMLElement>('.autocomplete__item--selected'),
firstItem = $<HTMLElement>('.autocomplete__item:first-of-type'),
lastItem = $<HTMLElement>('.autocomplete__item:last-of-type');
2019-10-05 02:09:52 +02:00
2024-08-28 00:36:33 +02:00
if (inputField && isSearchField(inputField)) {
// Prevent submission of the search field when Enter was hit
if (selected && event.keyCode === 13) event.preventDefault(); // Enter
// Close autocompletion popup when text cursor is outside current tag
2024-07-04 02:27:59 +02:00
if (selectedTerm && firstItem && (event.keyCode === 37 || event.keyCode === 39)) {
// ArrowLeft || ArrowRight
requestAnimationFrame(() => {
if (isSelectionOutsideCurrentTerm()) removeParent();
2024-05-30 19:55:41 +02:00
});
}
}
2024-08-28 00:36:33 +02:00
if (event.keyCode === 38) changeSelected(lastItem, selected, selected && selected.previousElementSibling); // ArrowUp
if (event.keyCode === 40) changeSelected(firstItem, selected, selected && selected.nextElementSibling); // ArrowDown
2019-10-05 02:09:52 +02:00
if (event.keyCode === 13 || event.keyCode === 27 || event.keyCode === 188) removeParent(); // Enter || Esc || Comma
2024-07-04 02:27:59 +02:00
if (event.keyCode === 38 || event.keyCode === 40) {
// ArrowUp || ArrowDown
2024-08-28 00:36:33 +02:00
const newSelected = $<HTMLElement>('.autocomplete__item--selected');
if (newSelected?.dataset.value) applySelectedValue(newSelected.dataset.value);
2019-10-05 02:09:52 +02:00
event.preventDefault();
}
}
2024-08-28 00:36:33 +02:00
function createItem(list: HTMLUListElement, suggestion: TermSuggestion) {
const item = makeEl('li', {
className: 'autocomplete__item',
});
2019-10-05 02:09:52 +02:00
let ignoreMouseOver = true;
2019-10-05 02:09:52 +02:00
item.textContent = suggestion.label;
item.dataset.value = suggestion.value;
item.addEventListener('mouseover', () => {
// Prevent selection when mouse entered the element without actually moving.
if (ignoreMouseOver) {
return;
}
2019-10-05 02:09:52 +02:00
removeSelected();
item.classList.add('autocomplete__item--selected');
});
item.addEventListener('mouseout', () => {
removeSelected();
});
item.addEventListener(
'mousemove',
() => {
ignoreMouseOver = false;
item.dispatchEvent(new CustomEvent('mouseover'));
},
{
once: true,
},
);
2019-10-05 02:09:52 +02:00
item.addEventListener('click', () => {
2024-08-28 00:36:33 +02:00
if (!inputField || !item.dataset.value) return;
applySelectedValue(item.dataset.value);
2024-08-28 00:36:33 +02:00
2019-10-05 02:09:52 +02:00
inputField.dispatchEvent(
new CustomEvent('autocomplete', {
detail: {
type: 'click',
label: suggestion.label,
value: suggestion.value,
2024-07-04 02:27:59 +02:00
},
}),
2019-10-05 02:09:52 +02:00
);
});
list.appendChild(item);
}
2024-08-28 00:36:33 +02:00
function createList(parentElement: HTMLElement, suggestions: TermSuggestion[]) {
const list = makeEl('ul', {
className: 'autocomplete__list',
});
2019-10-05 02:09:52 +02:00
suggestions.forEach(suggestion => createItem(list, suggestion));
2024-08-28 00:36:33 +02:00
parentElement.appendChild(list);
2019-10-05 02:09:52 +02:00
}
2024-08-28 00:36:33 +02:00
function createParent(): HTMLElement {
const parent = makeEl('div');
2019-10-05 02:09:52 +02:00
parent.className = 'autocomplete';
2024-08-28 00:36:33 +02:00
if (inputField && inputField.parentElement) {
// Position the parent below the inputfield
parent.style.position = 'absolute';
parent.style.left = `${inputField.offsetLeft}px`;
// Take the inputfield offset, add its height and subtract the amount by which the parent element has scrolled
parent.style.top = `${inputField.offsetTop + inputField.offsetHeight - inputField.parentElement.scrollTop}px`;
}
2019-10-05 02:09:52 +02:00
// We append the parent at the end of body
document.body.appendChild(parent);
2024-08-28 00:36:33 +02:00
return parent;
2019-10-05 02:09:52 +02:00
}
2024-08-28 00:36:33 +02:00
function showAutocomplete(suggestions: TermSuggestion[], fetchedTerm: string, targetInput: HTMLInputElement) {
2019-10-05 02:09:52 +02:00
// Remove old autocomplete suggestions
removeParent();
// Save suggestions in cache
2024-08-28 00:36:33 +02:00
cachedSuggestions[fetchedTerm] = suggestions;
2019-10-05 02:09:52 +02:00
// If the input target is not empty, still visible, and suggestions were found
if (targetInput.value && targetInput.style.display !== 'none' && suggestions.length) {
2024-08-28 00:36:33 +02:00
createList(createParent(), suggestions);
targetInput.addEventListener('keydown', keydownHandler);
2019-10-05 02:09:52 +02:00
}
}
2024-08-28 00:36:33 +02:00
async function getSuggestions(term: string): Promise<TermSuggestion[]> {
// In case source URL was not given at all, do not try sending the request.
2024-08-28 00:36:33 +02:00
if (!inputField?.dataset.acSource) return [];
return await fetch(`${inputField.dataset.acSource}${term}`)
.then(handleError)
.then(response => response.json());
2019-10-05 02:09:52 +02:00
}
2024-08-28 00:36:33 +02:00
function getSelectedTerm(): TermContext | null {
if (!inputField || !originalQuery) return null;
if (inputField.selectionStart === null || inputField.selectionEnd === null) return null;
const selectionIndex = Math.min(inputField.selectionStart, inputField.selectionEnd);
const terms = getTermContexts(originalQuery);
2024-08-28 00:36:33 +02:00
return terms.find(([range]) => range[0] < selectionIndex && range[1] >= selectionIndex) ?? null;
}
function toggleSearchAutocomplete() {
const enable = store.get('enable_search_ac');
2024-08-28 00:36:33 +02:00
for (const searchField of $$<HTMLInputElement>('input[data-ac-mode=search]')) {
if (enable) {
searchField.autocomplete = 'off';
2024-07-04 02:27:59 +02:00
} else {
searchField.removeAttribute('data-ac');
searchField.autocomplete = 'on';
}
}
}
2019-10-05 02:09:52 +02:00
function listenAutocomplete() {
2024-08-28 00:36:33 +02:00
let timeout: number | undefined;
2019-10-05 02:09:52 +02:00
2024-08-28 00:36:33 +02:00
let localAc: LocalAutocompleter | null = null;
2021-12-27 01:16:21 +01:00
let localFetched = false;
document.addEventListener('focusin', fetchLocalAutocomplete);
2019-10-05 02:09:52 +02:00
document.addEventListener('input', event => {
removeParent();
2021-12-27 01:16:21 +01:00
fetchLocalAutocomplete(event);
window.clearTimeout(timeout);
2021-12-27 01:16:21 +01:00
2024-08-28 00:36:33 +02:00
if (!(event.target instanceof HTMLInputElement)) return;
const targetedInput = event.target;
if (localAc !== null && 'ac' in targetedInput.dataset) {
inputField = targetedInput;
let suggestionsCount = 5;
2024-08-28 00:36:33 +02:00
if (isSearchField(inputField)) {
originalQuery = inputField.value;
selectedTerm = getSelectedTerm();
suggestionsCount = 10;
// We don't need to run auto-completion if user is not selecting tag at all
if (!selectedTerm) {
return;
}
originalTerm = selectedTerm[1].toLowerCase();
2024-07-04 02:27:59 +02:00
} else {
originalTerm = `${inputField.value}`.toLowerCase();
}
2021-12-27 01:16:21 +01:00
2024-07-04 02:27:59 +02:00
const suggestions = localAc
.matchPrefix(originalTerm)
.topK(suggestionsCount)
2024-07-04 02:27:59 +02:00
.map(({ name, imageCount }) => ({ label: `${name} (${imageCount})`, value: name }));
if (suggestions.length) {
2024-08-28 00:36:33 +02:00
return showAutocomplete(suggestions, originalTerm, targetedInput);
}
2021-12-27 01:16:21 +01:00
}
2019-10-05 02:09:52 +02:00
// Use a timeout to delay requests until the user has stopped typing
timeout = window.setTimeout(() => {
2024-08-28 00:36:33 +02:00
inputField = targetedInput;
2019-10-05 02:09:52 +02:00
originalTerm = inputField.value;
const fetchedTerm = inputField.value;
2024-07-04 02:27:59 +02:00
const { ac, acMinLength, acSource } = inputField.dataset;
2019-10-05 02:09:52 +02:00
2024-08-28 00:36:33 +02:00
if (!ac || !acSource || (acMinLength && fetchedTerm.length < parseInt(acMinLength, 10))) {
return;
}
if (cachedSuggestions[fetchedTerm]) {
showAutocomplete(cachedSuggestions[fetchedTerm], fetchedTerm, targetedInput);
} else {
// inputField could get overwritten while the suggestions are being fetched - use event.target
getSuggestions(fetchedTerm).then(suggestions => {
if (fetchedTerm === targetedInput.value) {
showAutocomplete(suggestions, fetchedTerm, targetedInput);
}
});
2019-10-05 02:09:52 +02:00
}
}, 300);
});
// If there's a click outside the inputField, remove autocomplete
document.addEventListener('click', event => {
if (event.target && event.target !== inputField) removeParent();
2024-08-28 00:36:33 +02:00
if (inputField && event.target === inputField && isSearchField(inputField) && isSelectionOutsideCurrentTerm()) {
removeParent();
}
2019-10-05 02:09:52 +02:00
});
2021-12-27 01:16:21 +01:00
2024-08-28 00:36:33 +02:00
function fetchLocalAutocomplete(event: Event) {
if (!(event.target instanceof HTMLInputElement)) return;
2021-12-27 01:16:21 +01:00
if (!localFetched && event.target.dataset && 'ac' in event.target.dataset) {
2023-03-30 15:45:14 +02:00
const now = new Date();
const cacheKey = `${now.getUTCFullYear()}-${now.getUTCMonth()}-${now.getUTCDate()}`;
2021-12-27 01:16:21 +01:00
localFetched = true;
2023-03-30 15:45:14 +02:00
fetch(`/autocomplete/compiled?vsn=2&key=${cacheKey}`, { credentials: 'omit', cache: 'force-cache' })
2021-12-27 01:16:21 +01:00
.then(handleError)
.then(resp => resp.arrayBuffer())
2024-07-04 02:27:59 +02:00
.then(buf => {
localAc = new LocalAutocompleter(buf);
});
2021-12-27 01:16:21 +01:00
}
}
toggleSearchAutocomplete();
2019-10-05 02:09:52 +02:00
}
export { listenAutocomplete };