diff --git a/assets/js/autocomplete.ts b/assets/js/autocomplete.ts deleted file mode 100644 index 72dbbfdb..00000000 --- a/assets/js/autocomplete.ts +++ /dev/null @@ -1,292 +0,0 @@ -/** - * 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 { - formatLocalAutocompleteResult, - fetchLocalAutocomplete, - fetchSuggestions, - SuggestionsPopup, - TermSuggestion, -} from './utils/suggestions'; - -type AutocompletableInputElement = HTMLInputElement | HTMLTextAreaElement; - -function hasAutocompleteEnabled(element: unknown): element is AutocompletableInputElement { - return ( - (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) && - Boolean(element.dataset.autocomplete) - ); -} - -let inputField: AutocompletableInputElement | null = null; -let originalTerm: string | undefined; -let originalQuery: string | undefined; -let selectedTerm: TermContext | null = null; - -const popup = new SuggestionsPopup(); - -function isSearchField(targetInput: HTMLElement): boolean { - return targetInput.dataset.autocompleteMode === '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: AutocompletableInputElement, searchQuery: string): TermContext | null { - if (targetInput.selectionStart === null || targetInput.selectionEnd === null) return null; - - const selectionIndex = Math.min(targetInput.selectionStart, targetInput.selectionEnd); - - // Multi-line textarea elements should treat each line as the different search queries. Here we're looking for the - // actively edited line and use it instead of the whole value. - const activeLineStart = searchQuery.slice(0, selectionIndex).lastIndexOf('\n') + 1; - const lengthAfterSelectionIndex = Math.max(searchQuery.slice(selectionIndex).indexOf('\n'), 0); - const targetQuery = searchQuery.slice(activeLineStart, selectionIndex + lengthAfterSelectionIndex); - - const terms = getTermContexts(targetQuery); - const searchIndex = selectionIndex - activeLineStart; - const term = terms.find(([range]) => range[0] < searchIndex && range[1] >= searchIndex) ?? null; - - // Converting line-specific indexes back to absolute ones. - if (term) { - const [range] = term; - - range[0] += activeLineStart; - range[1] += activeLineStart; - } - - return term; -} - -/** - * Our custom autocomplete isn't compatible with the native browser autocomplete, - * so we have to turn it off if our autocomplete is enabled, or turn it back on - * if it's disabled. - */ -function toggleSearchNativeAutocomplete() { - const enable = store.get('enable_search_ac'); - - const searchFields = $$( - 'input[data-autocomplete][data-autocomplete-mode=search], textarea[data-autocomplete][data-autocomplete-mode=search]', - ); - - for (const searchField of searchFields) { - if (enable) { - searchField.autocomplete = 'off'; - } else { - searchField.removeAttribute('data-autocomplete'); - searchField.autocomplete = 'on'; - } - } -} - -function trimPrefixes(targetTerm: string): string { - return targetTerm.trim().replace(/^-/, ''); -} - -/** - * We control the autocomplete with `data-autocomplete*` attributes in HTML, and subscribe - * event listeners to the `document`. This pattern is described in more detail - * here: https://javascript.info/event-delegation - */ -export function listenAutocomplete() { - let serverSideSuggestionsTimeout: number | undefined; - - let localAutocomplete: LocalAutocompleter | null = null; - - document.addEventListener('focusin', loadAutocompleteFromEvent); - - document.addEventListener('input', event => { - popup.hide(); - loadAutocompleteFromEvent(event); - window.clearTimeout(serverSideSuggestionsTimeout); - - if (!hasAutocompleteEnabled(event.target)) return; - - const targetedInput = event.target; - - targetedInput.addEventListener('keydown', keydownHandler as EventListener); - - if (localAutocomplete !== 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 = localAutocomplete - .matchPrefix(trimPrefixes(originalTerm), suggestionsCount) - .map(formatLocalAutocompleteResult); - - if (suggestions.length) { - popup.renderSuggestions(suggestions).showForField(targetedInput); - return; - } - } - - const { autocompleteMinLength: minTermLength, autocompleteSource: 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(); - } - }); - - // Lazy-load the local autocomplete index from the server only once. - let localAutocompleteFetchNeeded = true; - - async function loadAutocompleteFromEvent(event: Event) { - if (!localAutocompleteFetchNeeded || !hasAutocompleteEnabled(event.target)) { - return; - } - - localAutocompleteFetchNeeded = false; - localAutocomplete = await fetchLocalAutocomplete(); - } - - toggleSearchNativeAutocomplete(); - - 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, - ), - }), - ); - }); -}