philomena/assets/js/autocomplete.ts

231 lines
7 KiB
TypeScript
Raw Normal View History

2019-10-05 02:09:52 +02:00
/**
* Autocomplete.
*/
import { LocalAutocompleter } from './utils/local-autocompleter';
2024-05-30 19:55:41 +02:00
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';
2021-12-27 01:16:21 +01:00
2024-08-28 00:36:33 +02:00
let inputField: HTMLInputElement | null = null,
originalTerm: string | undefined,
originalQuery: string | undefined,
selectedTerm: TermContext | null = null;
2019-10-05 02:09:52 +02:00
const popup = new SuggestionsPopup();
2019-10-05 02:09:52 +02:00
function isSearchField(targetInput: HTMLElement): boolean {
2024-08-28 00:36:33 +02:00
return targetInput && targetInput.dataset.acMode === 'search';
}
function restoreOriginalValue() {
if (!inputField) return;
2024-08-28 00:36:33 +02:00
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();
}
}
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) {
if (inputField !== event.currentTarget) return;
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 (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)) {
2024-07-04 02:27:59 +02:00
// ArrowLeft || ArrowRight
requestAnimationFrame(() => {
if (isSelectionOutsideCurrentTerm()) popup.hide();
2024-05-30 19:55:41 +02:00
});
}
}
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
2024-07-04 02:27:59 +02:00
if (event.keyCode === 38 || event.keyCode === 40) {
// ArrowUp || ArrowDown
if (popup.selectedTerm) {
applySelectedValue(popup.selectedTerm);
} else {
restoreOriginalValue();
}
2019-10-05 02:09:52 +02:00
event.preventDefault();
2019-10-05 02:09:52 +02:00
}
}
function findSelectedTerm(targetInput: HTMLInputElement, searchQuery: string): TermContext | null {
if (targetInput.selectionStart === null || targetInput.selectionEnd === null) return null;
2019-10-05 02:09:52 +02:00
const selectionIndex = Math.min(targetInput.selectionStart, targetInput.selectionEnd);
const terms = getTermContexts(searchQuery);
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() {
let serverSideSuggestionsTimeout: number | undefined;
2019-10-05 02:09:52 +02:00
2024-08-28 00:36:33 +02:00
let localAc: LocalAutocompleter | null = null;
let isLocalLoading = false;
2021-12-27 01:16:21 +01:00
document.addEventListener('focusin', loadAutocompleteFromEvent);
2021-12-27 01:16:21 +01:00
2019-10-05 02:09:52 +02:00
document.addEventListener('input', event => {
popup.hide();
loadAutocompleteFromEvent(event);
window.clearTimeout(serverSideSuggestionsTimeout);
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 (!targetedInput.dataset.ac) return;
targetedInput.addEventListener('keydown', keydownHandler);
if (localAc !== null) {
2024-08-28 00:36:33 +02:00
inputField = targetedInput;
let suggestionsCount = 5;
2024-08-28 00:36:33 +02:00
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();
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) {
popup.renderSuggestions(suggestions).showForField(targetedInput);
return;
}
2021-12-27 01:16:21 +01:00
}
2019-10-05 02:09:52 +02:00
const { acMinLength: minTermLength, acSource: endpointUrl } = targetedInput.dataset;
if (!endpointUrl) return;
2019-10-05 02:09:52 +02:00
// Use a timeout to delay requests until the user has stopped typing
serverSideSuggestionsTimeout = 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;
2019-10-05 02:09:52 +02:00
if (minTermLength && fetchedTerm.length < parseInt(minTermLength, 10)) return;
2024-08-28 00:36:33 +02:00
fetchSuggestions(endpointUrl, fetchedTerm).then(suggestions => {
// inputField could get overwritten while the suggestions are being fetched - use previously targeted input
if (fetchedTerm === targetedInput.value) {
popup.renderSuggestions(suggestions).showForField(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) popup.hide();
2024-08-28 00:36:33 +02:00
if (inputField && event.target === inputField && isSearchField(inputField) && isSelectionOutsideCurrentTerm()) {
popup.hide();
2024-08-28 00:36:33 +02:00
}
2019-10-05 02:09:52 +02:00
});
2021-12-27 01:16:21 +01:00
function loadAutocompleteFromEvent(event: Event) {
2024-08-28 00:36:33 +02:00
if (!(event.target instanceof HTMLInputElement)) return;
if (!isLocalLoading && event.target.dataset.ac) {
isLocalLoading = true;
2023-03-30 15:45:14 +02:00
fetchLocalAutocomplete().then(autocomplete => {
localAc = autocomplete;
});
2021-12-27 01:16:21 +01:00
}
}
toggleSearchAutocomplete();
popup.onItemSelected((event: CustomEvent<TermSuggestion>) => {
if (!event.detail || !inputField) return;
const originalSuggestion = event.detail;
applySelectedValue(originalSuggestion.value);
inputField.dispatchEvent(
new CustomEvent('autocomplete', {
detail: Object.assign(
{
type: 'click',
},
originalSuggestion,
),
}),
);
});
2019-10-05 02:09:52 +02:00
}
export { listenAutocomplete };