/** * 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 $$('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) => { if (!event.detail || !inputField) return; const originalSuggestion = event.detail; applySelectedValue(originalSuggestion.value); if (originalTerm?.startsWith('-')) { originalSuggestion.value = `-${originalSuggestion.value}`; } inputField.dispatchEvent( new CustomEvent('autocomplete', { detail: Object.assign( { type: 'click', }, originalSuggestion, ), }), ); }); } export { listenAutocomplete };