mirror of
https://github.com/philomena-dev/philomena.git
synced 2025-03-17 17:10:03 +01:00
Delete the old autocomplete impl
This commit is contained in:
parent
007a3e629a
commit
6b615558af
1 changed files with 0 additions and 292 deletions
|
@ -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 = $$<AutocompletableInputElement>(
|
||||
'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<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,
|
||||
),
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
Loading…
Add table
Reference in a new issue