mirror of
https://github.com/philomena-dev/philomena.git
synced 2024-11-27 13:47:58 +01:00
Merge pull request #348 from koloml/ts-autocomplete
Converting autocomplete.js to TypeScript
This commit is contained in:
commit
073ca2881b
1 changed files with 121 additions and 85 deletions
|
@ -6,52 +6,67 @@ import { LocalAutocompleter } from './utils/local-autocompleter';
|
||||||
import { handleError } from './utils/requests';
|
import { handleError } from './utils/requests';
|
||||||
import { getTermContexts } from './match_query';
|
import { getTermContexts } from './match_query';
|
||||||
import store from './utils/store';
|
import store from './utils/store';
|
||||||
|
import { TermContext } from './query/lex.ts';
|
||||||
|
import { $, $$, makeEl, removeEl } from './utils/dom.ts';
|
||||||
|
|
||||||
const cache = {};
|
type TermSuggestion = {
|
||||||
/** @type {HTMLInputElement} */
|
label: string;
|
||||||
let inputField,
|
value: string;
|
||||||
/** @type {string} */
|
};
|
||||||
originalTerm,
|
|
||||||
/** @type {string} */
|
const cachedSuggestions: Record<string, TermSuggestion[]> = {};
|
||||||
originalQuery,
|
let inputField: HTMLInputElement | null = null,
|
||||||
/** @type {TermContext} */
|
originalTerm: string | undefined,
|
||||||
selectedTerm;
|
originalQuery: string | undefined,
|
||||||
|
selectedTerm: TermContext | null = null;
|
||||||
|
|
||||||
function removeParent() {
|
function removeParent() {
|
||||||
const parent = document.querySelector('.autocomplete');
|
const parent = $<HTMLElement>('.autocomplete');
|
||||||
if (parent) parent.parentNode.removeChild(parent);
|
if (parent) removeEl(parent);
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeSelected() {
|
function removeSelected() {
|
||||||
const selected = document.querySelector('.autocomplete__item--selected');
|
const selected = $<HTMLElement>('.autocomplete__item--selected');
|
||||||
if (selected) selected.classList.remove('autocomplete__item--selected');
|
if (selected) selected.classList.remove('autocomplete__item--selected');
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSearchField() {
|
function isSearchField(targetInput: HTMLElement): boolean {
|
||||||
return inputField && inputField.dataset.acMode === 'search';
|
return targetInput && targetInput.dataset.acMode === 'search';
|
||||||
}
|
}
|
||||||
|
|
||||||
function restoreOriginalValue() {
|
function restoreOriginalValue() {
|
||||||
inputField.value = isSearchField() ? originalQuery : originalTerm;
|
if (!inputField) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSearchField(inputField) && originalQuery) {
|
||||||
|
inputField.value = originalQuery;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (originalTerm) {
|
||||||
|
inputField.value = originalTerm;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function applySelectedValue(selection) {
|
function applySelectedValue(selection: string) {
|
||||||
if (!isSearchField()) {
|
if (!inputField) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isSearchField(inputField)) {
|
||||||
inputField.value = selection;
|
inputField.value = selection;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!selectedTerm) {
|
if (selectedTerm && originalQuery) {
|
||||||
return;
|
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();
|
||||||
}
|
}
|
||||||
|
|
||||||
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 changeSelected(firstOrLast, current, sibling) {
|
function changeSelected(firstOrLast: Element | null, current: Element | null, sibling: Element | null) {
|
||||||
if (current && sibling) {
|
if (current && sibling) {
|
||||||
// if the currently selected item has a sibling, move selection to it
|
// if the currently selected item has a sibling, move selection to it
|
||||||
current.classList.remove('autocomplete__item--selected');
|
current.classList.remove('autocomplete__item--selected');
|
||||||
|
@ -66,19 +81,22 @@ function changeSelected(firstOrLast, current, sibling) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSelectionOutsideCurrentTerm() {
|
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 selectionIndex = Math.min(inputField.selectionStart, inputField.selectionEnd);
|
||||||
const [startIndex, endIndex] = selectedTerm[0];
|
const [startIndex, endIndex] = selectedTerm[0];
|
||||||
|
|
||||||
return startIndex > selectionIndex || endIndex < selectionIndex;
|
return startIndex > selectionIndex || endIndex < selectionIndex;
|
||||||
}
|
}
|
||||||
|
|
||||||
function keydownHandler(event) {
|
function keydownHandler(event: KeyboardEvent) {
|
||||||
const selected = document.querySelector('.autocomplete__item--selected'),
|
const selected = $<HTMLElement>('.autocomplete__item--selected'),
|
||||||
firstItem = document.querySelector('.autocomplete__item:first-of-type'),
|
firstItem = $<HTMLElement>('.autocomplete__item:first-of-type'),
|
||||||
lastItem = document.querySelector('.autocomplete__item:last-of-type');
|
lastItem = $<HTMLElement>('.autocomplete__item:last-of-type');
|
||||||
|
|
||||||
if (isSearchField()) {
|
if (inputField && isSearchField(inputField)) {
|
||||||
// Prevent submission of the search field when Enter was hit
|
// Prevent submission of the search field when Enter was hit
|
||||||
if (selected && event.keyCode === 13) event.preventDefault(); // Enter
|
if (selected && event.keyCode === 13) event.preventDefault(); // Enter
|
||||||
|
|
||||||
|
@ -91,20 +109,21 @@ function keydownHandler(event) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.keyCode === 38) changeSelected(lastItem, selected, selected && selected.previousSibling); // ArrowUp
|
if (event.keyCode === 38) changeSelected(lastItem, selected, selected && selected.previousElementSibling); // ArrowUp
|
||||||
if (event.keyCode === 40) changeSelected(firstItem, selected, selected && selected.nextSibling); // ArrowDown
|
if (event.keyCode === 40) changeSelected(firstItem, selected, selected && selected.nextElementSibling); // ArrowDown
|
||||||
if (event.keyCode === 13 || event.keyCode === 27 || event.keyCode === 188) removeParent(); // Enter || Esc || Comma
|
if (event.keyCode === 13 || event.keyCode === 27 || event.keyCode === 188) removeParent(); // Enter || Esc || Comma
|
||||||
if (event.keyCode === 38 || event.keyCode === 40) {
|
if (event.keyCode === 38 || event.keyCode === 40) {
|
||||||
// ArrowUp || ArrowDown
|
// ArrowUp || ArrowDown
|
||||||
const newSelected = document.querySelector('.autocomplete__item--selected');
|
const newSelected = $<HTMLElement>('.autocomplete__item--selected');
|
||||||
if (newSelected) applySelectedValue(newSelected.dataset.value);
|
if (newSelected?.dataset.value) applySelectedValue(newSelected.dataset.value);
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createItem(list, suggestion) {
|
function createItem(list: HTMLUListElement, suggestion: TermSuggestion) {
|
||||||
const item = document.createElement('li');
|
const item = makeEl('li', {
|
||||||
item.className = 'autocomplete__item';
|
className: 'autocomplete__item',
|
||||||
|
});
|
||||||
|
|
||||||
item.textContent = suggestion.label;
|
item.textContent = suggestion.label;
|
||||||
item.dataset.value = suggestion.value;
|
item.dataset.value = suggestion.value;
|
||||||
|
@ -119,7 +138,10 @@ function createItem(list, suggestion) {
|
||||||
});
|
});
|
||||||
|
|
||||||
item.addEventListener('click', () => {
|
item.addEventListener('click', () => {
|
||||||
|
if (!inputField || !item.dataset.value) return;
|
||||||
|
|
||||||
applySelectedValue(item.dataset.value);
|
applySelectedValue(item.dataset.value);
|
||||||
|
|
||||||
inputField.dispatchEvent(
|
inputField.dispatchEvent(
|
||||||
new CustomEvent('autocomplete', {
|
new CustomEvent('autocomplete', {
|
||||||
detail: {
|
detail: {
|
||||||
|
@ -134,66 +156,71 @@ function createItem(list, suggestion) {
|
||||||
list.appendChild(item);
|
list.appendChild(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
function createList(suggestions) {
|
function createList(parentElement: HTMLElement, suggestions: TermSuggestion[]) {
|
||||||
const parent = document.querySelector('.autocomplete'),
|
const list = makeEl('ul', {
|
||||||
list = document.createElement('ul');
|
className: 'autocomplete__list',
|
||||||
list.className = 'autocomplete__list';
|
});
|
||||||
|
|
||||||
suggestions.forEach(suggestion => createItem(list, suggestion));
|
suggestions.forEach(suggestion => createItem(list, suggestion));
|
||||||
|
|
||||||
parent.appendChild(list);
|
parentElement.appendChild(list);
|
||||||
}
|
}
|
||||||
|
|
||||||
function createParent() {
|
function createParent(): HTMLElement {
|
||||||
const parent = document.createElement('div');
|
const parent = makeEl('div');
|
||||||
parent.className = 'autocomplete';
|
parent.className = 'autocomplete';
|
||||||
|
|
||||||
// Position the parent below the inputfield
|
if (inputField && inputField.parentElement) {
|
||||||
parent.style.position = 'absolute';
|
// Position the parent below the inputfield
|
||||||
parent.style.left = `${inputField.offsetLeft}px`;
|
parent.style.position = 'absolute';
|
||||||
// Take the inputfield offset, add its height and subtract the amount by which the parent element has scrolled
|
parent.style.left = `${inputField.offsetLeft}px`;
|
||||||
parent.style.top = `${inputField.offsetTop + inputField.offsetHeight - inputField.parentNode.scrollTop}px`;
|
// Take the inputfield offset, add its height and subtract the amount by which the parent element has scrolled
|
||||||
|
parent.style.top = `${inputField.offsetTop + inputField.offsetHeight - inputField.parentElement.scrollTop}px`;
|
||||||
|
}
|
||||||
|
|
||||||
// We append the parent at the end of body
|
// We append the parent at the end of body
|
||||||
document.body.appendChild(parent);
|
document.body.appendChild(parent);
|
||||||
|
|
||||||
|
return parent;
|
||||||
}
|
}
|
||||||
|
|
||||||
function showAutocomplete(suggestions, fetchedTerm, targetInput) {
|
function showAutocomplete(suggestions: TermSuggestion[], fetchedTerm: string, targetInput: HTMLInputElement) {
|
||||||
// Remove old autocomplete suggestions
|
// Remove old autocomplete suggestions
|
||||||
removeParent();
|
removeParent();
|
||||||
|
|
||||||
// Save suggestions in cache
|
// Save suggestions in cache
|
||||||
cache[fetchedTerm] = suggestions;
|
cachedSuggestions[fetchedTerm] = suggestions;
|
||||||
|
|
||||||
// If the input target is not empty, still visible, and suggestions were found
|
// If the input target is not empty, still visible, and suggestions were found
|
||||||
if (targetInput.value && targetInput.style.display !== 'none' && suggestions.length) {
|
if (targetInput.value && targetInput.style.display !== 'none' && suggestions.length) {
|
||||||
createParent();
|
createList(createParent(), suggestions);
|
||||||
createList(suggestions);
|
targetInput.addEventListener('keydown', keydownHandler);
|
||||||
inputField.addEventListener('keydown', keydownHandler);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSuggestions(term) {
|
async function getSuggestions(term: string): Promise<TermSuggestion[]> {
|
||||||
// In case source URL was not given at all, do not try sending the request.
|
// In case source URL was not given at all, do not try sending the request.
|
||||||
if (!inputField.dataset.acSource) return [];
|
if (!inputField?.dataset.acSource) return [];
|
||||||
return fetch(`${inputField.dataset.acSource}${term}`).then(response => response.json());
|
|
||||||
|
return await fetch(`${inputField.dataset.acSource}${term}`)
|
||||||
|
.then(handleError)
|
||||||
|
.then(response => response.json());
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSelectedTerm() {
|
function getSelectedTerm(): TermContext | null {
|
||||||
if (!inputField || !originalQuery) {
|
if (!inputField || !originalQuery) return null;
|
||||||
return null;
|
if (inputField.selectionStart === null || inputField.selectionEnd === null) return null;
|
||||||
}
|
|
||||||
|
|
||||||
const selectionIndex = Math.min(inputField.selectionStart, inputField.selectionEnd);
|
const selectionIndex = Math.min(inputField.selectionStart, inputField.selectionEnd);
|
||||||
const terms = getTermContexts(originalQuery);
|
const terms = getTermContexts(originalQuery);
|
||||||
|
|
||||||
return terms.find(([range]) => range[0] < selectionIndex && range[1] >= selectionIndex);
|
return terms.find(([range]) => range[0] < selectionIndex && range[1] >= selectionIndex) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleSearchAutocomplete() {
|
function toggleSearchAutocomplete() {
|
||||||
const enable = store.get('enable_search_ac');
|
const enable = store.get('enable_search_ac');
|
||||||
|
|
||||||
for (const searchField of document.querySelectorAll('input[data-ac-mode=search]')) {
|
for (const searchField of $$<HTMLInputElement>('input[data-ac-mode=search]')) {
|
||||||
if (enable) {
|
if (enable) {
|
||||||
searchField.autocomplete = 'off';
|
searchField.autocomplete = 'off';
|
||||||
} else {
|
} else {
|
||||||
|
@ -204,10 +231,9 @@ function toggleSearchAutocomplete() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function listenAutocomplete() {
|
function listenAutocomplete() {
|
||||||
let timeout;
|
let timeout: number | undefined;
|
||||||
|
|
||||||
/** @type {LocalAutocompleter} */
|
let localAc: LocalAutocompleter | null = null;
|
||||||
let localAc = null;
|
|
||||||
let localFetched = false;
|
let localFetched = false;
|
||||||
|
|
||||||
document.addEventListener('focusin', fetchLocalAutocomplete);
|
document.addEventListener('focusin', fetchLocalAutocomplete);
|
||||||
|
@ -217,11 +243,15 @@ function listenAutocomplete() {
|
||||||
fetchLocalAutocomplete(event);
|
fetchLocalAutocomplete(event);
|
||||||
window.clearTimeout(timeout);
|
window.clearTimeout(timeout);
|
||||||
|
|
||||||
if (localAc !== null && 'ac' in event.target.dataset) {
|
if (!(event.target instanceof HTMLInputElement)) return;
|
||||||
inputField = event.target;
|
|
||||||
|
const targetedInput = event.target;
|
||||||
|
|
||||||
|
if (localAc !== null && 'ac' in targetedInput.dataset) {
|
||||||
|
inputField = targetedInput;
|
||||||
let suggestionsCount = 5;
|
let suggestionsCount = 5;
|
||||||
|
|
||||||
if (isSearchField()) {
|
if (isSearchField(inputField)) {
|
||||||
originalQuery = inputField.value;
|
originalQuery = inputField.value;
|
||||||
selectedTerm = getSelectedTerm();
|
selectedTerm = getSelectedTerm();
|
||||||
suggestionsCount = 10;
|
suggestionsCount = 10;
|
||||||
|
@ -242,29 +272,31 @@ function listenAutocomplete() {
|
||||||
.map(({ name, imageCount }) => ({ label: `${name} (${imageCount})`, value: name }));
|
.map(({ name, imageCount }) => ({ label: `${name} (${imageCount})`, value: name }));
|
||||||
|
|
||||||
if (suggestions.length) {
|
if (suggestions.length) {
|
||||||
return showAutocomplete(suggestions, originalTerm, event.target);
|
return showAutocomplete(suggestions, originalTerm, targetedInput);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use a timeout to delay requests until the user has stopped typing
|
// Use a timeout to delay requests until the user has stopped typing
|
||||||
timeout = window.setTimeout(() => {
|
timeout = window.setTimeout(() => {
|
||||||
inputField = event.target;
|
inputField = targetedInput;
|
||||||
originalTerm = inputField.value;
|
originalTerm = inputField.value;
|
||||||
|
|
||||||
const fetchedTerm = inputField.value;
|
const fetchedTerm = inputField.value;
|
||||||
const { ac, acMinLength, acSource } = inputField.dataset;
|
const { ac, acMinLength, acSource } = inputField.dataset;
|
||||||
|
|
||||||
if (ac && acSource && fetchedTerm.length >= acMinLength) {
|
if (!ac || !acSource || (acMinLength && fetchedTerm.length < parseInt(acMinLength, 10))) {
|
||||||
if (cache[fetchedTerm]) {
|
return;
|
||||||
showAutocomplete(cache[fetchedTerm], fetchedTerm, event.target);
|
}
|
||||||
} else {
|
|
||||||
// inputField could get overwritten while the suggestions are being fetched - use event.target
|
if (cachedSuggestions[fetchedTerm]) {
|
||||||
getSuggestions(fetchedTerm).then(suggestions => {
|
showAutocomplete(cachedSuggestions[fetchedTerm], fetchedTerm, targetedInput);
|
||||||
if (fetchedTerm === event.target.value) {
|
} else {
|
||||||
showAutocomplete(suggestions, fetchedTerm, event.target);
|
// inputField could get overwritten while the suggestions are being fetched - use event.target
|
||||||
}
|
getSuggestions(fetchedTerm).then(suggestions => {
|
||||||
});
|
if (fetchedTerm === targetedInput.value) {
|
||||||
}
|
showAutocomplete(suggestions, fetchedTerm, targetedInput);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}, 300);
|
}, 300);
|
||||||
});
|
});
|
||||||
|
@ -272,10 +304,14 @@ function listenAutocomplete() {
|
||||||
// If there's a click outside the inputField, remove autocomplete
|
// If there's a click outside the inputField, remove autocomplete
|
||||||
document.addEventListener('click', event => {
|
document.addEventListener('click', event => {
|
||||||
if (event.target && event.target !== inputField) removeParent();
|
if (event.target && event.target !== inputField) removeParent();
|
||||||
if (event.target === inputField && isSearchField() && isSelectionOutsideCurrentTerm()) removeParent();
|
if (inputField && event.target === inputField && isSearchField(inputField) && isSelectionOutsideCurrentTerm()) {
|
||||||
|
removeParent();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function fetchLocalAutocomplete(event) {
|
function fetchLocalAutocomplete(event: Event) {
|
||||||
|
if (!(event.target instanceof HTMLInputElement)) return;
|
||||||
|
|
||||||
if (!localFetched && event.target.dataset && 'ac' in event.target.dataset) {
|
if (!localFetched && event.target.dataset && 'ac' in event.target.dataset) {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const cacheKey = `${now.getUTCFullYear()}-${now.getUTCMonth()}-${now.getUTCDate()}`;
|
const cacheKey = `${now.getUTCFullYear()}-${now.getUTCMonth()}-${now.getUTCDate()}`;
|
Loading…
Reference in a new issue