Refactoring of autocomplete and tag inputs

This commit is contained in:
MareStare 2025-02-12 02:59:15 +00:00
parent a6fee28bf8
commit a9d42683ee
4 changed files with 80 additions and 33 deletions

View file

@ -15,17 +15,21 @@ import {
TermSuggestion, TermSuggestion,
} from './utils/suggestions'; } from './utils/suggestions';
type InputFieldElement = HTMLInputElement | HTMLTextAreaElement; type AcEnabledInputElement = HTMLInputElement | HTMLTextAreaElement;
let inputField: InputFieldElement | null = null, function hasAcEnabled(element: unknown): element is AcEnabledInputElement {
originalTerm: string | undefined, return (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) && Boolean(element.dataset.ac);
originalQuery: string | undefined, }
selectedTerm: TermContext | null = null;
let inputField: AcEnabledInputElement | null = null;
let originalTerm: string | undefined;
let originalQuery: string | undefined;
let selectedTerm: TermContext | null = null;
const popup = new SuggestionsPopup(); const popup = new SuggestionsPopup();
function isSearchField(targetInput: HTMLElement): boolean { function isSearchField(targetInput: HTMLElement): boolean {
return targetInput && targetInput.dataset.acMode === 'search'; return targetInput.dataset.acMode === 'search';
} }
function restoreOriginalValue() { function restoreOriginalValue() {
@ -113,7 +117,7 @@ function keydownHandler(event: KeyboardEvent) {
} }
} }
function findSelectedTerm(targetInput: InputFieldElement, searchQuery: string): TermContext | null { function findSelectedTerm(targetInput: AcEnabledInputElement, searchQuery: string): TermContext | null {
if (targetInput.selectionStart === null || targetInput.selectionEnd === null) return null; if (targetInput.selectionStart === null || targetInput.selectionEnd === null) return null;
const selectionIndex = Math.min(targetInput.selectionStart, targetInput.selectionEnd); const selectionIndex = Math.min(targetInput.selectionStart, targetInput.selectionEnd);
@ -139,10 +143,15 @@ function findSelectedTerm(targetInput: InputFieldElement, searchQuery: string):
return term; return term;
} }
function toggleSearchAutocomplete() { /**
* 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 enable = store.get('enable_search_ac');
for (const searchField of $$<InputFieldElement>(':is(input, textarea)[data-ac-mode=search]')) { for (const searchField of $$<AcEnabledInputElement>(':is(input, textarea)[data-ac][data-ac-mode=search]')) {
if (enable) { if (enable) {
searchField.autocomplete = 'off'; searchField.autocomplete = 'off';
} else { } else {
@ -156,11 +165,15 @@ function trimPrefixes(targetTerm: string): string {
return targetTerm.trim().replace(/^-/, ''); return targetTerm.trim().replace(/^-/, '');
} }
function listenAutocomplete() { /**
* We control the autocomplete with `data-ac*` 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 serverSideSuggestionsTimeout: number | undefined;
let localAc: LocalAutocompleter | null = null; let localAc: LocalAutocompleter | null = null;
let isLocalLoading = false;
document.addEventListener('focusin', loadAutocompleteFromEvent); document.addEventListener('focusin', loadAutocompleteFromEvent);
@ -169,12 +182,10 @@ function listenAutocomplete() {
loadAutocompleteFromEvent(event); loadAutocompleteFromEvent(event);
window.clearTimeout(serverSideSuggestionsTimeout); window.clearTimeout(serverSideSuggestionsTimeout);
if (!(event.target instanceof HTMLInputElement) && !(event.target instanceof HTMLTextAreaElement)) return; if (!hasAcEnabled(event.target)) return;
const targetedInput = event.target; const targetedInput = event.target;
if (!targetedInput.dataset.ac) return;
targetedInput.addEventListener('keydown', keydownHandler as EventListener); targetedInput.addEventListener('keydown', keydownHandler as EventListener);
if (localAc !== null) { if (localAc !== null) {
@ -193,7 +204,7 @@ function listenAutocomplete() {
originalTerm = selectedTerm[1].toLowerCase(); originalTerm = selectedTerm[1].toLowerCase();
} else { } else {
originalTerm = `${inputField.value}`.toLowerCase(); originalTerm = inputField.value.toLowerCase();
} }
const suggestions = localAc const suggestions = localAc
@ -236,19 +247,19 @@ function listenAutocomplete() {
} }
}); });
function loadAutocompleteFromEvent(event: Event) { // Lazy-load the local AC index from the server only once.
if (!(event.target instanceof HTMLInputElement) && !(event.target instanceof HTMLTextAreaElement)) return; let localAcFetchInitiated = false;
if (!isLocalLoading && event.target.dataset.ac) { async function loadAutocompleteFromEvent(event: Event) {
isLocalLoading = true; if (!hasAcEnabled(event.target) || localAcFetchInitiated) {
return;
fetchLocalAutocomplete().then(autocomplete => {
localAc = autocomplete;
});
} }
localAcFetchInitiated = true;
localAc = await fetchLocalAutocomplete();
} }
toggleSearchAutocomplete(); toggleSearchNativeAutocomplete();
popup.onItemSelected((event: CustomEvent<TermSuggestion>) => { popup.onItemSelected((event: CustomEvent<TermSuggestion>) => {
if (!event.detail || !inputField) return; if (!event.detail || !inputField) return;
@ -272,5 +283,3 @@ function listenAutocomplete() {
); );
}); });
} }
export { listenAutocomplete };

View file

@ -75,6 +75,11 @@ export class SuggestionsPopup {
} }
private watchItem(listItem: HTMLElement, suggestion: TermSuggestion) { private watchItem(listItem: HTMLElement, suggestion: TermSuggestion) {
// This makes sure the item isn't selected if the mouse pointer happens to
// be right on top of the item when the list is rendered. So, the item may
// only be selected on the first `mousemove` event occurring on the element.
// See more details about this problem in the PR description:
// https://github.com/philomena-dev/philomena/pull/350
mouseMoveThenOver(listItem, () => this.updateSelection(listItem)); mouseMoveThenOver(listItem, () => this.updateSelection(listItem));
listItem.addEventListener('mouseout', () => this.clearSelection()); listItem.addEventListener('mouseout', () => this.clearSelection());

View file

@ -10,9 +10,22 @@ header.header
' Derpibooru ' Derpibooru
a.header__link.hide-mobile href="/images/new" title="Upload" a.header__link.hide-mobile href="/images/new" title="Upload"
i.fa.fa-upload i.fa.fa-upload
= form_for @conn, ~p"/search", [method: "get", class: "header__search flex flex--no-wrap flex--centered", enforce_utf8: false], fn f -> = form_for @conn, ~p"/search", [method: "get", class: "header__search flex flex--no-wrap flex--centered", enforce_utf8: false], fn f ->
input.input.header__input.header__input--search#q name="q" title="For terms all required, separate with ',' or 'AND'; also supports 'OR' for optional terms and '-' or 'NOT' for negation. Search with a blank query for more options or click the ? for syntax help." value=@conn.params["q"] placeholder="Search" autocapitalize="none" data-ac="true" data-ac-min-length="3" data-ac-mode="search" - title = \
"For terms all required, separate with ',' or 'AND'; also supports 'OR' " <> \
"for optional terms and '-' or 'NOT' for negation. Search with a blank " <> \
"query for more options or click the ? for syntax help"
input.input.header__input.header__input--search#q[
name="q"
title=title
value=@conn.params["q"]
placeholder="Search"
autocapitalize="none"
data-ac="true"
data-ac-min-length="3"
data-ac-mode="search"
]
= if present?(@conn.params["sf"]) do = if present?(@conn.params["sf"]) do
input type="hidden" name="sf" value=@conn.params["sf"] input type="hidden" name="sf" value=@conn.params["sf"]

View file

@ -9,8 +9,28 @@ elixir:
.js-tag-block class="fancy-tag-#{@type}" .js-tag-block class="fancy-tag-#{@type}"
= textarea @f, @name, html_options = textarea @f, @name, html_options
.js-taginput.input.input--wide.tagsinput.hidden class="js-taginput-fancy" data-click-focus=".js-taginput-input.js-taginput-#{@name}" .js-taginput.input.input--wide.tagsinput.hidden class="js-taginput-fancy" data-click-focus=".js-taginput-input.js-taginput-#{@name}"
input.input class="js-taginput-input js-taginput-#{@name}" id="taginput-fancy-#{@name}" type="text" placeholder="add a tag" autocomplete="off" autocapitalize="none" data-ac="true" data-ac-min-length="3" data-ac-source="/autocomplete/tags?term=" input.input [
button.button.button--state-primary.button--bold class="js-taginput-show" data-click-show=".js-taginput-fancy,.js-taginput-hide" data-click-hide=".js-taginput-plain,.js-taginput-show" data-click-focus=".js-taginput-input.js-taginput-#{@name}" class="js-taginput-input js-taginput-#{@name}"
' Fancy Editor id="taginput-fancy-#{@name}"
button.hidden.button.button--state-primary.button--bold class="js-taginput-hide" data-click-show=".js-taginput-plain,.js-taginput-show" data-click-hide=".js-taginput-fancy,.js-taginput-hide" data-click-focus=".js-taginput-plain.js-taginput-#{@name}" type="text"
' Plain Editor placeholder="add a tag"
autocomplete="off"
autocapitalize="none"
data-ac="true"
data-ac-min-length="3"
data-ac-source="/autocomplete/tags?term="
]
button.button.button--state-primary.button--bold[
class="js-taginput-show"
data-click-show=".js-taginput-fancy,.js-taginput-hide"
data-click-hide=".js-taginput-plain,.js-taginput-show"
data-click-focus=".js-taginput-input.js-taginput-#{@name}"
]
| Fancy Editor
button.hidden.button.button--state-primary.button--bold [
class="js-taginput-hide"
data-click-show=".js-taginput-plain,.js-taginput-show"
data-click-hide=".js-taginput-fancy,.js-taginput-hide"
data-click-focus=".js-taginput-plain.js-taginput-#{@name}"
]
| Plain Editor