From a9d42683eea78f5c4709426dfebf808e6a3455c7 Mon Sep 17 00:00:00 2001 From: MareStare Date: Wed, 12 Feb 2025 02:59:15 +0000 Subject: [PATCH] Refactoring of autocomplete and tag inputs --- assets/js/autocomplete.ts | 61 +++++++++++-------- assets/js/utils/suggestions.ts | 5 ++ .../templates/layout/_header.html.slime | 17 +++++- .../templates/tag/_tag_editor.html.slime | 30 +++++++-- 4 files changed, 80 insertions(+), 33 deletions(-) diff --git a/assets/js/autocomplete.ts b/assets/js/autocomplete.ts index d940794c..b8b7f260 100644 --- a/assets/js/autocomplete.ts +++ b/assets/js/autocomplete.ts @@ -15,17 +15,21 @@ import { TermSuggestion, } from './utils/suggestions'; -type InputFieldElement = HTMLInputElement | HTMLTextAreaElement; +type AcEnabledInputElement = HTMLInputElement | HTMLTextAreaElement; -let inputField: InputFieldElement | null = null, - originalTerm: string | undefined, - originalQuery: string | undefined, - selectedTerm: TermContext | null = null; +function hasAcEnabled(element: unknown): element is AcEnabledInputElement { + return (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) && Boolean(element.dataset.ac); +} + +let inputField: AcEnabledInputElement | 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 && targetInput.dataset.acMode === 'search'; + return targetInput.dataset.acMode === 'search'; } 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; const selectionIndex = Math.min(targetInput.selectionStart, targetInput.selectionEnd); @@ -139,10 +143,15 @@ function findSelectedTerm(targetInput: InputFieldElement, searchQuery: string): 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'); - for (const searchField of $$(':is(input, textarea)[data-ac-mode=search]')) { + for (const searchField of $$(':is(input, textarea)[data-ac][data-ac-mode=search]')) { if (enable) { searchField.autocomplete = 'off'; } else { @@ -156,11 +165,15 @@ function trimPrefixes(targetTerm: string): string { 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 localAc: LocalAutocompleter | null = null; - let isLocalLoading = false; document.addEventListener('focusin', loadAutocompleteFromEvent); @@ -169,12 +182,10 @@ function listenAutocomplete() { loadAutocompleteFromEvent(event); window.clearTimeout(serverSideSuggestionsTimeout); - if (!(event.target instanceof HTMLInputElement) && !(event.target instanceof HTMLTextAreaElement)) return; + if (!hasAcEnabled(event.target)) return; const targetedInput = event.target; - if (!targetedInput.dataset.ac) return; - targetedInput.addEventListener('keydown', keydownHandler as EventListener); if (localAc !== null) { @@ -193,7 +204,7 @@ function listenAutocomplete() { originalTerm = selectedTerm[1].toLowerCase(); } else { - originalTerm = `${inputField.value}`.toLowerCase(); + originalTerm = inputField.value.toLowerCase(); } const suggestions = localAc @@ -236,19 +247,19 @@ function listenAutocomplete() { } }); - function loadAutocompleteFromEvent(event: Event) { - if (!(event.target instanceof HTMLInputElement) && !(event.target instanceof HTMLTextAreaElement)) return; + // Lazy-load the local AC index from the server only once. + let localAcFetchInitiated = false; - if (!isLocalLoading && event.target.dataset.ac) { - isLocalLoading = true; - - fetchLocalAutocomplete().then(autocomplete => { - localAc = autocomplete; - }); + async function loadAutocompleteFromEvent(event: Event) { + if (!hasAcEnabled(event.target) || localAcFetchInitiated) { + return; } + + localAcFetchInitiated = true; + localAc = await fetchLocalAutocomplete(); } - toggleSearchAutocomplete(); + toggleSearchNativeAutocomplete(); popup.onItemSelected((event: CustomEvent) => { if (!event.detail || !inputField) return; @@ -272,5 +283,3 @@ function listenAutocomplete() { ); }); } - -export { listenAutocomplete }; diff --git a/assets/js/utils/suggestions.ts b/assets/js/utils/suggestions.ts index 099c9860..b50e770b 100644 --- a/assets/js/utils/suggestions.ts +++ b/assets/js/utils/suggestions.ts @@ -75,6 +75,11 @@ export class SuggestionsPopup { } 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)); listItem.addEventListener('mouseout', () => this.clearSelection()); diff --git a/lib/philomena_web/templates/layout/_header.html.slime b/lib/philomena_web/templates/layout/_header.html.slime index 0cf9fc2e..9d6ef883 100644 --- a/lib/philomena_web/templates/layout/_header.html.slime +++ b/lib/philomena_web/templates/layout/_header.html.slime @@ -10,9 +10,22 @@ header.header ' Derpibooru a.header__link.hide-mobile href="/images/new" title="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 -> - 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 input type="hidden" name="sf" value=@conn.params["sf"] diff --git a/lib/philomena_web/templates/tag/_tag_editor.html.slime b/lib/philomena_web/templates/tag/_tag_editor.html.slime index a45aecf5..7c157c58 100644 --- a/lib/philomena_web/templates/tag/_tag_editor.html.slime +++ b/lib/philomena_web/templates/tag/_tag_editor.html.slime @@ -9,8 +9,28 @@ elixir: .js-tag-block class="fancy-tag-#{@type}" = 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}" - 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=" -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 + 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=" + ] +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