philomena/assets/js/autocomplete.ts

252 lines
7.5 KiB
TypeScript

/**
* Autocomplete.
*/
import { LocalAutocompleter } from './utils/local-autocompleter';
import { getTermContexts } from './match_query';
import store from './utils/store';
import { TermContext } from './query/lex';
import { $$ } from './utils/dom';
import { fetchLocalAutocomplete, fetchSuggestions, SuggestionsPopup, TermSuggestion } from './utils/suggestions';
let inputField: HTMLInputElement | null = null,
originalTerm: string | undefined,
originalQuery: string | undefined,
selectedTerm: TermContext | null = null;
const popup = new SuggestionsPopup();
function isSearchField(targetInput: HTMLElement): boolean {
return targetInput && targetInput.dataset.acMode === 'search';
}
function restoreOriginalValue() {
if (!inputField) return;
if (isSearchField(inputField) && originalQuery) {
inputField.value = originalQuery;
if (selectedTerm) {
const [, selectedTermEnd] = selectedTerm[0];
inputField.setSelectionRange(selectedTermEnd, selectedTermEnd);
}
return;
}
if (originalTerm) {
inputField.value = originalTerm;
}
}
function applySelectedValue(selection: string) {
if (!inputField) return;
if (!isSearchField(inputField)) {
let resultValue = selection;
if (originalTerm?.startsWith('-')) {
resultValue = `-${selection}`;
}
inputField.value = resultValue;
return;
}
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();
}
}
function isSelectionOutsideCurrentTerm(): boolean {
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;
}
function keydownHandler(event: KeyboardEvent) {
if (inputField !== event.currentTarget) return;
if (inputField && isSearchField(inputField)) {
// Prevent submission of the search field when Enter was hit
if (popup.selectedTerm && event.keyCode === 13) event.preventDefault(); // Enter
// Close autocompletion popup when text cursor is outside current tag
if (selectedTerm && (event.keyCode === 37 || event.keyCode === 39)) {
// ArrowLeft || ArrowRight
requestAnimationFrame(() => {
if (isSelectionOutsideCurrentTerm()) popup.hide();
});
}
}
if (!popup.isActive) return;
if (event.keyCode === 38) popup.selectPrevious(); // ArrowUp
if (event.keyCode === 40) popup.selectNext(); // ArrowDown
if (event.keyCode === 13 || event.keyCode === 27 || event.keyCode === 188) popup.hide(); // Enter || Esc || Comma
if (event.keyCode === 38 || event.keyCode === 40) {
// ArrowUp || ArrowDown
if (popup.selectedTerm) {
applySelectedValue(popup.selectedTerm);
} else {
restoreOriginalValue();
}
event.preventDefault();
}
}
function findSelectedTerm(targetInput: HTMLInputElement, searchQuery: string): TermContext | null {
if (targetInput.selectionStart === null || targetInput.selectionEnd === null) return null;
const selectionIndex = Math.min(targetInput.selectionStart, targetInput.selectionEnd);
const terms = getTermContexts(searchQuery);
return terms.find(([range]) => range[0] < selectionIndex && range[1] >= selectionIndex) ?? null;
}
function toggleSearchAutocomplete() {
const enable = store.get('enable_search_ac');
for (const searchField of $$<HTMLInputElement>('input[data-ac-mode=search]')) {
if (enable) {
searchField.autocomplete = 'off';
} else {
searchField.removeAttribute('data-ac');
searchField.autocomplete = 'on';
}
}
}
function trimPrefixes(targetTerm: string): string {
return targetTerm.trim().replace(/^-/, '');
}
function listenAutocomplete() {
let serverSideSuggestionsTimeout: number | undefined;
let localAc: LocalAutocompleter | null = null;
let isLocalLoading = false;
document.addEventListener('focusin', loadAutocompleteFromEvent);
document.addEventListener('input', event => {
popup.hide();
loadAutocompleteFromEvent(event);
window.clearTimeout(serverSideSuggestionsTimeout);
if (!(event.target instanceof HTMLInputElement)) return;
const targetedInput = event.target;
if (!targetedInput.dataset.ac) return;
targetedInput.addEventListener('keydown', keydownHandler);
if (localAc !== null) {
inputField = targetedInput;
let suggestionsCount = 5;
if (isSearchField(inputField)) {
originalQuery = inputField.value;
selectedTerm = findSelectedTerm(inputField, originalQuery);
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();
} else {
originalTerm = `${inputField.value}`.toLowerCase();
}
const suggestions = localAc
.matchPrefix(trimPrefixes(originalTerm))
.topK(suggestionsCount)
.map(({ name, imageCount }) => ({ label: `${name} (${imageCount})`, value: name }));
if (suggestions.length) {
popup.renderSuggestions(suggestions).showForField(targetedInput);
return;
}
}
const { acMinLength: minTermLength, acSource: endpointUrl } = targetedInput.dataset;
if (!endpointUrl) return;
// Use a timeout to delay requests until the user has stopped typing
serverSideSuggestionsTimeout = window.setTimeout(() => {
inputField = targetedInput;
originalTerm = inputField.value;
const fetchedTerm = trimPrefixes(inputField.value);
if (minTermLength && fetchedTerm.length < parseInt(minTermLength, 10)) return;
fetchSuggestions(endpointUrl, fetchedTerm).then(suggestions => {
// inputField could get overwritten while the suggestions are being fetched - use previously targeted input
if (fetchedTerm === trimPrefixes(targetedInput.value)) {
popup.renderSuggestions(suggestions).showForField(targetedInput);
}
});
}, 300);
});
// If there's a click outside the inputField, remove autocomplete
document.addEventListener('click', event => {
if (event.target && event.target !== inputField) popup.hide();
if (inputField && event.target === inputField && isSearchField(inputField) && isSelectionOutsideCurrentTerm()) {
popup.hide();
}
});
function loadAutocompleteFromEvent(event: Event) {
if (!(event.target instanceof HTMLInputElement)) return;
if (!isLocalLoading && event.target.dataset.ac) {
isLocalLoading = true;
fetchLocalAutocomplete().then(autocomplete => {
localAc = autocomplete;
});
}
}
toggleSearchAutocomplete();
popup.onItemSelected((event: CustomEvent<TermSuggestion>) => {
if (!event.detail || !inputField) return;
const originalSuggestion = event.detail;
applySelectedValue(originalSuggestion.value);
if (originalTerm?.startsWith('-')) {
originalSuggestion.value = `-${originalSuggestion.value}`;
}
inputField.dispatchEvent(
new CustomEvent<TermSuggestion>('autocomplete', {
detail: Object.assign(
{
type: 'click',
},
originalSuggestion,
),
}),
);
});
}
export { listenAutocomplete };