mirror of
https://github.com/philomena-dev/philomena.git
synced 2025-03-18 01:37:15 +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