From 2ae5832ae71a0268eb8fb97dfadedcf39dc8a3ca Mon Sep 17 00:00:00 2001 From: MareStare Date: Tue, 4 Mar 2025 02:32:45 +0000 Subject: [PATCH 01/33] Add search history settings to cookies handling on backend --- .../controllers/setting_controller.ex | 40 +++++++++++++------ 1 file changed, 28 insertions(+), 12 deletions(-) diff --git a/lib/philomena_web/controllers/setting_controller.ex b/lib/philomena_web/controllers/setting_controller.ex index f7928bf9..f9ec9536 100644 --- a/lib/philomena_web/controllers/setting_controller.ex +++ b/lib/philomena_web/controllers/setting_controller.ex @@ -1,4 +1,5 @@ defmodule PhilomenaWeb.SettingController do + require Logger use PhilomenaWeb, :controller alias Philomena.Users @@ -37,21 +38,36 @@ defmodule PhilomenaWeb.SettingController do defp update_local_settings(conn, user_params) do conn - |> set_cookie(user_params, "hidpi", "hidpi") - |> set_cookie(user_params, "webm", "webm") - |> set_cookie(user_params, "serve_webm", "serve_webm") - |> set_cookie(user_params, "unmute_videos", "unmute_videos") - |> set_cookie(user_params, "chan_nsfw", "chan_nsfw") - |> set_cookie(user_params, "hide_staff_tools", "hide_staff_tools") - |> set_cookie(user_params, "hide_uploader", "hide_uploader") - |> set_cookie(user_params, "hide_score", "hide_score") - |> set_cookie(user_params, "unfilter_tag_suggestions", "unfilter_tag_suggestions") - |> set_cookie(user_params, "enable_search_ac", "enable_search_ac") + |> set_bool_cookie(user_params, "hidpi", "hidpi") + |> set_bool_cookie(user_params, "webm", "webm") + |> set_bool_cookie(user_params, "serve_webm", "serve_webm") + |> set_bool_cookie(user_params, "unmute_videos", "unmute_videos") + |> set_bool_cookie(user_params, "chan_nsfw", "chan_nsfw") + |> set_bool_cookie(user_params, "hide_staff_tools", "hide_staff_tools") + |> set_bool_cookie(user_params, "hide_uploader", "hide_uploader") + |> set_bool_cookie(user_params, "hide_score", "hide_score") + |> set_bool_cookie(user_params, "unfilter_tag_suggestions", "unfilter_tag_suggestions") + |> set_bool_cookie(user_params, "enable_search_ac", "enable_search_ac") + |> set_bool_cookie( + user_params, + "autocomplete_search_history_hidden", + "autocomplete_search_history_hidden" + ) + |> set_cookie( + "autocomplete_search_history_max_suggestions_when_typing", + user_params["autocomplete_search_history_max_suggestions_when_typing"] + ) end - defp set_cookie(conn, params, param_name, cookie_name) do + defp set_bool_cookie(conn, params, param_name, cookie_name) do + set_cookie(conn, cookie_name, to_string(params[param_name] == "true")) + end + + defp set_cookie(conn, _, nil), do: conn + + defp set_cookie(conn, cookie_name, value) do # JS wants access; max-age is set to 25 years from now - Conn.put_resp_cookie(conn, cookie_name, to_string(params[param_name] == "true"), + Conn.put_resp_cookie(conn, cookie_name, value, max_age: 788_923_800, http_only: false, extra: "SameSite=Lax" From a77b8dd81cc970a35b48b9835fabc09d87e2040a Mon Sep 17 00:00:00 2001 From: MareStare Date: Tue, 4 Mar 2025 02:57:31 +0000 Subject: [PATCH 02/33] Remove the extraneous require --- lib/philomena_web/controllers/setting_controller.ex | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/philomena_web/controllers/setting_controller.ex b/lib/philomena_web/controllers/setting_controller.ex index f9ec9536..b628cdaf 100644 --- a/lib/philomena_web/controllers/setting_controller.ex +++ b/lib/philomena_web/controllers/setting_controller.ex @@ -1,5 +1,4 @@ defmodule PhilomenaWeb.SettingController do - require Logger use PhilomenaWeb, :controller alias Philomena.Users From 99e597d94dd9c8cf901e85ef2e501433e403caf1 Mon Sep 17 00:00:00 2001 From: MareStare Date: Tue, 4 Mar 2025 04:03:14 +0000 Subject: [PATCH 03/33] Add autocomplete history store --- assets/js/autocomplete/history/store.ts | 95 +++++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 assets/js/autocomplete/history/store.ts diff --git a/assets/js/autocomplete/history/store.ts b/assets/js/autocomplete/history/store.ts new file mode 100644 index 00000000..18cfed02 --- /dev/null +++ b/assets/js/autocomplete/history/store.ts @@ -0,0 +1,95 @@ +import store from '../../utils/store'; +/** + * The root JSON object that contains the history records and is persisted to disk. + */ +interface History { + /** + * Used to track the version of the schema layout just in case we do any + * breaking changes to this schema so that we can properly migrate old + * search history data. It's also used to prevent older versions of + * the frontend code from trying to use the newer incompatible schema they + * know nothing about (extremely improbable, but just in case). + */ + schemaVersion: 1; + + /** + * The list of history records sorted from the last recently used to the oldest unused. + */ + records: string[]; +} + +/** + * History store backend is responsible for parsing and serializing the data + * to/from `localStorage`. It handles versioning of the schema, and transparently + * disables writing to the storage if the schema version is unknown to prevent + * data loss (extremely improbable, but just in case). + */ +export class HistoryStore { + private writable: boolean = true; + private readonly key: string; + + constructor(key: string) { + this.key = key; + } + + read(): string[] { + return this.extractRecords(store.get(this.key)); + } + + write(records: string[]): void { + if (!this.writable) { + return; + } + + const history: History = { + schemaVersion: 1, + records, + }; + + const start = performance.now(); + store.set(this.key, history); + + const end = performance.now(); + console.debug( + `Writing ${records.length} history records to the localStorage took ${end - start}ms. ` + + `Records: ${records.length}`, + ); + } + + /** + * Extracts the records from the history. To do this, we first need to migrate + * the history object to the latest schema version if necessary. + */ + private extractRecords(history: History | null): string[] { + // `null` here means we are starting from the initial state (empty list of records). + if (history === null) { + return []; + } + + // We have only one version at the time of this writing, so we don't need + // to do any migration yet. Hopefully we never need to do a breaking change + // and this stays at version `1` forever. + const latestSchemaVersion = 1; + + switch (history.schemaVersion) { + case latestSchemaVersion: + return history.records; + default: + // It's very unlikely that we ever hit this branch. + console.warn( + `Unknown search history schema version: '${history.schemaVersion}'. ` + + `This frontend code was built with the maximum supported schema version ` + + `'${latestSchemaVersion}'. The search history will be disabled for this ` + + `session to prevent potential history data loss. The cause of the version ` + + `mismatch may be that a newer version of the frontend code is running in a ` + + `separate tab, or you were mistakenly served with an older version of the ` + + `frontend code.`, + ); + + // Disallow writing to the storage to prevent data loss. + this.writable = false; + + return []; + } + } +} From 1dac0ef3f2bf48e3b91ee071860eaf84f09defe2 Mon Sep 17 00:00:00 2001 From: MareStare Date: Tue, 4 Mar 2025 04:07:24 +0000 Subject: [PATCH 04/33] Add an empty line after the import statement --- assets/js/autocomplete/history/store.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/assets/js/autocomplete/history/store.ts b/assets/js/autocomplete/history/store.ts index 18cfed02..e8befc96 100644 --- a/assets/js/autocomplete/history/store.ts +++ b/assets/js/autocomplete/history/store.ts @@ -1,4 +1,5 @@ import store from '../../utils/store'; + /** * The root JSON object that contains the history records and is persisted to disk. */ From e884da9b62f78ec0b6945b379f3f6982e13212e3 Mon Sep 17 00:00:00 2001 From: MareStare Date: Tue, 4 Mar 2025 04:11:56 +0000 Subject: [PATCH 05/33] Add `InputHistory` high level API for history suggestions --- assets/js/autocomplete/history/history.ts | 94 +++++++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 assets/js/autocomplete/history/history.ts diff --git a/assets/js/autocomplete/history/history.ts b/assets/js/autocomplete/history/history.ts new file mode 100644 index 00000000..58473e45 --- /dev/null +++ b/assets/js/autocomplete/history/history.ts @@ -0,0 +1,94 @@ +import { HistoryStore } from './store'; + +/** + * Maximum number of records we keep in the history. If the limit is reached, + * the least popular records will be removed to make space for new ones. + */ +const maxRecords = 1000; + +/** + * Maximum length of the input content we store in the history. If the input + * exceeds this value it won't be saved in the history. + */ +const maxInputLength = 256; + +/** + * Input history is a mini DB limited in size and stored in the `localStorage`. + * It provides a simple CRUD API for the search history data. + * + * Note that `localStorage` is not transactional. Other browser tabs may modify + * it concurrently, which may lead to version mismatches and potential TOCTOU + * issues. However, search history data is not critical, and the probability of + * concurrent usage patterns is almost 0. The worst thing that can happen in + * such a rare scenario is that a search query may not be saved to the storage + * or the search history may be temporarily disabled for the current session + * until the page is reloaded with a newer version of the frontend code. + */ +export class InputHistory { + private readonly store: HistoryStore; + + /** + * The list of history records sorted from the last recently used to the oldest unused. + */ + private records: string[]; + + constructor(store: HistoryStore) { + this.store = store; + + const parsing = performance.now(); + this.records = store.read(); + + const end = performance.now(); + console.debug(`Loading input history took ${end - parsing}ms. Records: ${this.records.length}.`); + } + + /** + * Save the input into the history and commit it to the `localStorage`. + * Expects a value trimmed from whitespace by the caller. + */ + write(input: string) { + if (input === '') { + return; + } + + if (input.length > maxInputLength) { + console.warn(`The input is too long to be saved in the search history (length: ${input.length}).`); + return; + } + + const index = this.records.findIndex(historyRecord => historyRecord === input); + + if (index >= 0) { + this.records.splice(index, 1); + } else if (this.records.length >= maxRecords) { + // Bye-bye, the oldest unused record! 👋 Nopony will miss you 🔪🩸 + this.records.pop(); + } + + // Put the record on the top of the list as the last recently used. + this.records.unshift(input); + + this.store.write(this.records); + } + + listSuggestions(query: string, limit: number): string[] { + // Waiting for iterator combinators such as `Iterator.prototype.filter()` + // and `Iterator.prototype.take()` to reach a greater availability 🙏: + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Iterator/filter + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Iterator/take + + const results = []; + + for (const record of this.records) { + if (results.length >= limit) { + break; + } + + if (record.startsWith(query)) { + results.push(record); + } + } + + return results; + } +} From d49b252eb12ad2cd03f7c53cf9f7d5e9b6223420 Mon Sep 17 00:00:00 2001 From: MareStare Date: Tue, 4 Mar 2025 04:26:14 +0000 Subject: [PATCH 06/33] Remove humor --- assets/js/autocomplete/history/history.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/assets/js/autocomplete/history/history.ts b/assets/js/autocomplete/history/history.ts index 58473e45..cf43cc2d 100644 --- a/assets/js/autocomplete/history/history.ts +++ b/assets/js/autocomplete/history/history.ts @@ -61,7 +61,6 @@ export class InputHistory { if (index >= 0) { this.records.splice(index, 1); } else if (this.records.length >= maxRecords) { - // Bye-bye, the oldest unused record! 👋 Nopony will miss you 🔪🩸 this.records.pop(); } From aa2e5dd3af7bbf976ff937a8266a21c4b3b3cbb8 Mon Sep 17 00:00:00 2001 From: MareStare Date: Thu, 13 Mar 2025 22:32:12 +0000 Subject: [PATCH 07/33] Simplify the `listSuggestions` with Array combinators --- assets/js/autocomplete/history/history.ts | 19 +------------------ 1 file changed, 1 insertion(+), 18 deletions(-) diff --git a/assets/js/autocomplete/history/history.ts b/assets/js/autocomplete/history/history.ts index cf43cc2d..aca06129 100644 --- a/assets/js/autocomplete/history/history.ts +++ b/assets/js/autocomplete/history/history.ts @@ -71,23 +71,6 @@ export class InputHistory { } listSuggestions(query: string, limit: number): string[] { - // Waiting for iterator combinators such as `Iterator.prototype.filter()` - // and `Iterator.prototype.take()` to reach a greater availability 🙏: - // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Iterator/filter - // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Iterator/take - - const results = []; - - for (const record of this.records) { - if (results.length >= limit) { - break; - } - - if (record.startsWith(query)) { - results.push(record); - } - } - - return results; + return this.records.filter(record => record.startsWith(query)).slice(0, limit); } } From e63899b91e36c9e86ceb69ac272d27acd034854b Mon Sep 17 00:00:00 2001 From: MareStare Date: Tue, 4 Mar 2025 04:19:43 +0000 Subject: [PATCH 08/33] Add `AutocomplableInput` model for UI binding --- assets/js/autocomplete/input.ts | 199 ++++++++++++++++++++++++++++++++ 1 file changed, 199 insertions(+) create mode 100644 assets/js/autocomplete/input.ts diff --git a/assets/js/autocomplete/input.ts b/assets/js/autocomplete/input.ts new file mode 100644 index 00000000..dddd86f6 --- /dev/null +++ b/assets/js/autocomplete/input.ts @@ -0,0 +1,199 @@ +import store from '../utils/store'; +import { getTermContexts } from '../match_query'; +import { Range } from '../query/lex'; + +export type TextInputElement = HTMLInputElement | HTMLTextAreaElement; + +/** + * Describes the term, that the cursor is currently on, which is known as "active". + * If any tag completion is accepted, this term will be overwritten in the input. + * The rest of the input will be left untouched. + */ +interface ActiveTerm { + range: Range; + + /** + * The term itself. Stripped from the `prefix` if it's present, and also lowercased. + */ + term: string; + + /** + * Optional `-` prefix is only relevant for the `single-tag` autocompletion type. + * This prefix is extracted automatically from the `term` value and is used to + * signal that the tag should be removed from the list. + */ + prefix: '-' | ''; +} + +/** + * Captures the value of the input at the time when the `AutocompletableInput` was created. + */ +interface AutocompleteInputSnapshot { + /** + * Original value of the input element at the time when it was created unmodified. + */ + origValue: string; + + /** + * The value of the input element at the time when it was created, but + * trimmed from whitespace. + */ + trimmedValue: string; + + /** + * Can be `null` if the input value is empty. + */ + activeTerm: ActiveTerm | null; + + /** + * Cursor selection at the time when the snapshot was taken. + */ + selection: { + start: number | null; + end: number | null; + direction: TextInputElement['selectionDirection']; + }; +} + +/** + * The `multi-tags` autocompletion type is used to power inputs with complex + * search queries like `(tag1 OR tag2), tag3` and tag lists like `tag1, tag2, tag3` + * in the plain tag search/edit inputs. + * + * The `single-tag` autocompletion type is used to power the fancy tag editor + * that manages separate input elements for every tag. In this mode the user + * can input `-tag` prefix to remove the tag from the list. See more details + * about how it works here: https://github.com/philomena-dev/philomena/pull/383 + */ +type AutocompleteInputType = 'multi-tags' | 'single-tag'; + +/** + * Parsed version of `TextInputElement`. Its behavior is controlled with various + * `data-autocomplete*` attributes. + */ +export class AutocompletableInput { + /** + * HTML element that autocomplete is attached to. + */ + readonly element: TextInputElement; + + readonly type: AutocompleteInputType; + + /** + * Captures the value of the input at the time when the `AutocompletableInput` was created. + */ + readonly snapshot: AutocompleteInputSnapshot; + + /** + * Defines the name of the parameter in `localStorage` that should be read + * to conditionally enable the autocomplete feature. + */ + readonly condition?: string; + + /** + * An integer that overrides the default limit of maximum suggestions to show. + */ + readonly maxSuggestions: number; + + /** + * If present enables the history feature for the input element. The value + * of this property defines the key in the `localStorage` where the history + * records are stored. + */ + readonly historyId?: string; + + /** + * Returns `null` only if the element is not autocomplete-capable. + */ + static fromElement(element: unknown): AutocompletableInput | null { + if (!(element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement)) { + return null; + } + + // This attribute marks the element as autocomplete-capable. It doesn't necessarily + // mean that the autocomplete **will** show up for the element. It may be disabled + // based on the setting value from the key specified under the attribute + // `data-autocomplete-condition`. + if (!element.dataset.autocomplete) { + return null; + } + + return new AutocompletableInput(element); + } + + private constructor(element: TextInputElement) { + this.element = element; + this.condition = element.dataset.autocompleteCondition; + this.historyId = element.dataset.autocompleteHistoryId; + + const type = element.dataset.autocomplete; + + if (type !== 'multi-tags' && type !== 'single-tag') { + throw new Error(`BUG: invalid autocomplete type: ${type}`); + } + + this.type = type; + this.snapshot = { + origValue: element.value, + trimmedValue: element.value.trim(), + activeTerm: findActiveTerm(type, element), + selection: { + start: element.selectionStart, + end: element.selectionEnd, + direction: element.selectionDirection, + }, + }; + + const maxSuggestions = element.dataset.autocompleteMaxSuggestions; + + this.maxSuggestions = maxSuggestions ? parseInt(maxSuggestions, 10) : 10; + } + + hasHistory(): this is this & { historyId: string } { + return Boolean(this.historyId); + } + + isEnabled(): boolean { + return !this.condition || store.get(this.condition) || false; + } +} + +function findActiveTerm( + autocompleteType: AutocompleteInputType, + { value, selectionStart, selectionEnd }: TextInputElement, +): ActiveTerm | null { + if (selectionStart === null || selectionEnd === null) return null; + + // Technically the user may select several characters and several terms at once, + // but we just take the first one from the selection as the "cursor" index. + const cursorIndex = Math.min(selectionStart, selectionEnd); + + // Multi-line textarea elements should treat each line as different search queries. + // Here we're looking for the actively edited line and use it instead of the whole value. + const lineStart = value.lastIndexOf('\n', cursorIndex) + 1; + const lineEnd = Math.max(value.indexOf('\n', cursorIndex), value.length); + const line = value.slice(lineStart, lineEnd); + + const terms = getTermContexts(line); + const searchIndex = cursorIndex - lineStart; + + const term = terms.find(({ range }) => range.start <= searchIndex && range.end >= searchIndex) ?? null; + + if (!term) { + return null; + } + + const { range } = term; + const content = term.content.toLowerCase(); + const stripDash = content.startsWith('-') && autocompleteType === 'single-tag'; + + return { + term: stripDash ? content.slice(1) : content, + prefix: stripDash ? '-' : '', + range: { + // Convert line-specific indexes back to absolute ones. + start: range.start + lineStart, + end: range.end + lineStart, + }, + }; +} From c15e082b977baf9ef3c3f45d603153b73abdef38 Mon Sep 17 00:00:00 2001 From: MareStare Date: Tue, 4 Mar 2025 04:17:38 +0000 Subject: [PATCH 09/33] Add input history listener binding with UI --- assets/js/autocomplete/history/index.ts | 74 +++++++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 assets/js/autocomplete/history/index.ts diff --git a/assets/js/autocomplete/history/index.ts b/assets/js/autocomplete/history/index.ts new file mode 100644 index 00000000..ff5709f5 --- /dev/null +++ b/assets/js/autocomplete/history/index.ts @@ -0,0 +1,74 @@ +import { HistorySuggestion } from '../../utils/suggestions'; +import { InputHistory } from './history'; +import { HistoryStore } from './store'; +import { AutocompletableInput } from '../input'; + +/** + * Stores a set of histories identified by their unique IDs. + */ +class InputHistoriesPool { + private histories = new Map(); + + load(historyId: string): InputHistory { + const existing = this.histories.get(historyId); + + if (existing) { + return existing; + } + + const store = new HistoryStore(historyId); + const newHistory = new InputHistory(store); + this.histories.set(historyId, newHistory); + + return newHistory; + } +} + +const histories = new InputHistoriesPool(); + +export function listen() { + // Only load the history for the input element when it gets focused. + document.addEventListener('focusin', event => { + const input = AutocompletableInput.fromElement(event.target); + + if (!input?.historyId) { + return; + } + + histories.load(input.historyId); + }); + + document.addEventListener('submit', event => { + if (!(event.target instanceof HTMLFormElement)) { + return; + } + + const input = [...event.target.elements] + .map(elem => AutocompletableInput.fromElement(elem)) + .find(it => it !== null && it.hasHistory()); + + if (!input) { + return; + } + + histories.load(input.historyId).write(input.snapshot.trimmedValue); + }); +} + +/** + * Returns suggestions based on history for the input. Unless the `limit` is + * specified as an argument, it will return the maximum number of suggestions + * allowed by the input. + */ +export function listSuggestions(input: AutocompletableInput, limit?: number): HistorySuggestion[] { + if (!input.hasHistory()) { + return []; + } + + const value = input.snapshot.trimmedValue.toLowerCase(); + + return histories + .load(input.historyId) + .listSuggestions(value, limit ?? input.maxSuggestions) + .map(content => new HistorySuggestion(content, value.length)); +} From 3c2a3e956a58063a6631c2b47b13a19691865070 Mon Sep 17 00:00:00 2001 From: MareStare Date: Fri, 14 Mar 2025 22:48:21 +0000 Subject: [PATCH 10/33] Use existing event delegation with `submit` subscription for history tracking --- assets/js/autocomplete/history/index.ts | 22 +++++++++------------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/assets/js/autocomplete/history/index.ts b/assets/js/autocomplete/history/index.ts index ff5709f5..ab4ad3a5 100644 --- a/assets/js/autocomplete/history/index.ts +++ b/assets/js/autocomplete/history/index.ts @@ -2,6 +2,7 @@ import { HistorySuggestion } from '../../utils/suggestions'; import { InputHistory } from './history'; import { HistoryStore } from './store'; import { AutocompletableInput } from '../input'; +import { delegate } from 'utils/events'; /** * Stores a set of histories identified by their unique IDs. @@ -38,20 +39,15 @@ export function listen() { histories.load(input.historyId); }); - document.addEventListener('submit', event => { - if (!(event.target instanceof HTMLFormElement)) { - return; - } + delegate(document, 'submit', { + '[data-autocomplete-history-id]'(_event, target) { + const input = AutocompletableInput.fromElement(target); + if (!input || !input.hasHistory()) { + return; + } - const input = [...event.target.elements] - .map(elem => AutocompletableInput.fromElement(elem)) - .find(it => it !== null && it.hasHistory()); - - if (!input) { - return; - } - - histories.load(input.historyId).write(input.snapshot.trimmedValue); + histories.load(input.historyId).write(input.snapshot.trimmedValue); + }, }); } From 6948aa5d1c7ff3aea15b302aa9b1d0eeed33d0de Mon Sep 17 00:00:00 2001 From: MareStare Date: Tue, 4 Mar 2025 04:29:15 +0000 Subject: [PATCH 11/33] Add `AutocompleteClient` wrapper over the HTTP API --- assets/js/autocomplete/client.ts | 73 ++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 assets/js/autocomplete/client.ts diff --git a/assets/js/autocomplete/client.ts b/assets/js/autocomplete/client.ts new file mode 100644 index 00000000..43fea48c --- /dev/null +++ b/assets/js/autocomplete/client.ts @@ -0,0 +1,73 @@ +import { HttpClient } from '../utils/http-client.ts'; + +export interface TagSuggestion { + /** + * If present, then this suggestion is for a tag alias. + * If absent, then this suggestion is for the `canonical` tag name. + */ + alias?: null | string; + + /** + * The canonical name of the tag (non-alias). + */ + canonical: string; + + /** + * Number of images tagged with this tag. + */ + images: number; +} + +export interface GetTagSuggestionsResponse { + suggestions: TagSuggestion[]; +} + +export interface GetTagSuggestionsRequest { + /** + * Term to complete. + */ + term: string; + + /** + * Maximum number of suggestions to return. + */ + limit: number; +} + +/** + * Autocomplete API client for Philomena backend. + */ +export class AutocompleteClient { + private http: HttpClient = new HttpClient(); + + /** + * Fetches server-side tag suggestions for the given term. The provided incomplete + * term is expected to be normalized by the caller (i.e. lowercased and trimmed). + * This is because the caller is responsible for caching the normalized term. + */ + async getTagSuggestions(request: GetTagSuggestionsRequest): Promise { + return this.http.fetchJson('/autocomplete/tags', { + query: { + vsn: '2', + term: request.term, + limit: request.limit.toString(), + }, + }); + } + + /** + * Issues a GET request to fetch the compiled autocomplete index. + */ + async getCompiledAutocomplete(): Promise { + const now = new Date(); + const key = `${now.getUTCFullYear()}-${now.getUTCMonth()}-${now.getUTCDate()}`; + + const response = await this.http.fetch(`/autocomplete/compiled`, { + query: { vsn: '2', key }, + credentials: 'omit', + cache: 'force-cache', + }); + + return response.arrayBuffer(); + } +} From 4e43b59b99610a7ffc770151e9b6f3987c87d79d Mon Sep 17 00:00:00 2001 From: MareStare Date: Tue, 4 Mar 2025 04:36:27 +0000 Subject: [PATCH 12/33] Redesign the autocomplete suggestions popup --- assets/js/utils/suggestions.ts | 408 +++++++++++++++++++++++---------- 1 file changed, 282 insertions(+), 126 deletions(-) diff --git a/assets/js/utils/suggestions.ts b/assets/js/utils/suggestions.ts index b50e770b..23d3a928 100644 --- a/assets/js/utils/suggestions.ts +++ b/assets/js/utils/suggestions.ts @@ -1,125 +1,335 @@ import { makeEl } from './dom.ts'; -import { mouseMoveThenOver } from './events.ts'; -import { handleError } from './requests.ts'; -import { LocalAutocompleter, Result } from './local-autocompleter.ts'; -export interface TermSuggestion { - label: string; - value: string; +export interface TagSuggestionParams { + /** + * If present, then this suggestion is for a tag alias. + * If absent, then this suggestion is for the `canonical` tag name. + */ + alias?: null | string; + + /** + * The canonical name of the tag (non-alias). + */ + canonical: string; + + /** + * Number of images tagged with this tag. + */ + images: number; + + /** + * Length of the prefix in the suggestion that matches the prefix of the current input. + */ + matchLength: number; } -const selectedSuggestionClassName = 'autocomplete__item--selected'; +export class TagSuggestion { + alias?: null | string; + canonical: string; + images: number; + matchLength: number; + constructor(params: TagSuggestionParams) { + this.alias = params.alias; + this.canonical = params.canonical; + this.images = params.images; + this.matchLength = params.matchLength; + } + + value(): string { + return this.canonical; + } + + render(): HTMLElement[] { + const { alias: aliasName, canonical: canonicalName, images: imageCount } = this; + + const label = aliasName ? `${aliasName} → ${canonicalName}` : canonicalName; + + return [ + makeEl('div', { className: 'autocomplete__item__content' }, [ + makeEl('i', { className: 'fa-solid fa-tag' }), + makeEl('b', { + className: 'autocomplete__item__tag__match', + textContent: ` ${label.slice(0, this.matchLength)}`, + }), + makeEl('span', { + textContent: label.slice(this.matchLength), + }), + ]), + makeEl('span', { + className: 'autocomplete__item__tag__count', + textContent: ` ${TagSuggestion.formatImageCount(imageCount)}`, + }), + ]; + } + + static formatImageCount(count: number): string { + const chars = [...count.toString()]; + + for (let i = chars.length - 3; i > 0; i -= 3) { + chars.splice(i, 0, ' '); + } + + return chars.join(''); + } +} + +export class HistorySuggestion { + /** + * Full query string that was previously searched and retrieved from the history. + */ + content: string; + + /** + * Length of the prefix in the suggestion that matches the prefix of the current input. + */ + matchLength: number; + + constructor(content: string, matchIndex: number) { + this.content = content; + this.matchLength = matchIndex; + } + + value(): string { + return this.content; + } + + render(): HTMLElement[] { + return [ + makeEl('div', { className: 'autocomplete__item__content' }, [ + makeEl('i', { + className: 'autocomplete__item__history__icon fa-solid fa-history', + }), + makeEl('b', { + textContent: ` ${this.content.slice(0, this.matchLength)}`, + className: 'autocomplete__item__history__match', + }), + makeEl('span', { + textContent: this.content.slice(this.matchLength), + }), + ]), + // Here will be a `delete` button to remove the item from the history. + ]; + } +} + +export type Suggestion = TagSuggestion | HistorySuggestion; + +export interface Suggestions { + history: HistorySuggestion[]; + tags: TagSuggestion[]; +} + +export interface ItemSelectedEvent { + suggestion: Suggestion; + shiftKey: boolean; + ctrlKey: boolean; +} + +interface SuggestionItem { + element: HTMLElement; + suggestion: Suggestion; +} + +/** + * Responsible for rendering the suggestions dropdown. + */ export class SuggestionsPopup { + /** + * Index of the currently selected suggestion. -1 means an imaginary item + * before the first item that represents the state where no item is selected. + */ + private cursor: number = -1; + private items: SuggestionItem[]; private readonly container: HTMLElement; - private readonly listElement: HTMLUListElement; - private selectedElement: HTMLElement | null = null; constructor() { this.container = makeEl('div', { - className: 'autocomplete', + className: 'autocomplete hidden', + tabIndex: -1, }); - this.listElement = makeEl('ul', { - className: 'autocomplete__list', - }); - - this.container.appendChild(this.listElement); + // Make the container connected to DOM to make sure it's rendered when we unhide it + document.body.appendChild(this.container); + this.items = []; } - get selectedTerm(): string | null { - return this.selectedElement?.dataset.value || null; + get selectedSuggestion(): Suggestion | null { + return this.selectedItem?.suggestion ?? null; } - get isActive(): boolean { - return this.container.isConnected; + private get selectedItem(): SuggestionItem | null { + if (this.cursor < 0) { + return null; + } + + return this.items[this.cursor]; + } + + get isHidden(): boolean { + return this.container.classList.contains('hidden'); } hide() { this.clearSelection(); - this.container.remove(); + this.container.classList.add('hidden'); } private clearSelection() { - if (!this.selectedElement) return; - - this.selectedElement.classList.remove(selectedSuggestionClassName); - this.selectedElement = null; + this.setSelection(-1); } - private updateSelection(targetItem: HTMLElement) { - this.clearSelection(); + private setSelection(index: number) { + if (this.cursor === index) { + return; + } - this.selectedElement = targetItem; - this.selectedElement.classList.add(selectedSuggestionClassName); + // This can't be triggered via the public API of this class + /* v8 ignore start */ + if (index < -1 || index >= this.items.length) { + throw new Error(`BUG: setSelection(): invalid selection index: ${index}`); + } + /* v8 ignore end */ + + const selectedClass = 'autocomplete__item--selected'; + + this.selectedItem?.element.classList.remove(selectedClass); + this.cursor = index; + + if (index >= 0) { + this.selectedItem?.element.classList.add(selectedClass); + } } - renderSuggestions(suggestions: TermSuggestion[]): SuggestionsPopup { - this.clearSelection(); + setSuggestions(params: Suggestions): SuggestionsPopup { + this.cursor = -1; + this.items = []; + this.container.innerHTML = ''; - this.listElement.innerHTML = ''; + for (const suggestion of params.history) { + this.appendSuggestion(suggestion); + } - for (const suggestedTerm of suggestions) { - const listItem = makeEl('li', { - className: 'autocomplete__item', - innerText: suggestedTerm.label, - }); + if (params.tags.length > 0 && params.history.length > 0) { + this.container.appendChild(makeEl('hr', { className: 'autocomplete__separator' })); + } - listItem.dataset.value = suggestedTerm.value; - - this.watchItem(listItem, suggestedTerm); - this.listElement.appendChild(listItem); + for (const suggestion of params.tags) { + this.appendSuggestion(suggestion); } return this; } - 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)); + appendSuggestion(suggestion: Suggestion) { + const type = suggestion instanceof TagSuggestion ? 'tag' : 'history'; - listItem.addEventListener('mouseout', () => this.clearSelection()); + const element = makeEl( + 'div', + { + className: `autocomplete__item autocomplete__item__${type}`, + }, + suggestion.render(), + ); - listItem.addEventListener('click', () => { - if (!listItem.dataset.value) { - return; - } + const item: SuggestionItem = { element, suggestion }; - this.container.dispatchEvent(new CustomEvent('item_selected', { detail: suggestion })); + this.watchItem(item); + + this.items.push(item); + this.container.appendChild(element); + } + + private watchItem(item: SuggestionItem) { + item.element.addEventListener('click', event => { + const detail: ItemSelectedEvent = { + suggestion: item.suggestion, + shiftKey: event.shiftKey, + ctrlKey: event.ctrlKey, + }; + + this.container.dispatchEvent(new CustomEvent('item_selected', { detail })); }); } private changeSelection(direction: number) { - let nextTargetElement: Element | null; - - if (!this.selectedElement) { - nextTargetElement = direction > 0 ? this.listElement.firstElementChild : this.listElement.lastElementChild; - } else { - nextTargetElement = - direction > 0 ? this.selectedElement.nextElementSibling : this.selectedElement.previousElementSibling; - } - - if (!(nextTargetElement instanceof HTMLElement)) { - this.clearSelection(); + if (this.items.length === 0) { return; } - this.updateSelection(nextTargetElement); + const index = this.cursor + direction; + + if (index === -1 || index >= this.items.length) { + this.clearSelection(); + } else if (index < -1) { + this.setSelection(this.items.length - 1); + } else { + this.setSelection(index); + } } - selectNext() { + selectDown() { this.changeSelection(1); } - selectPrevious() { + selectUp() { this.changeSelection(-1); } - showForField(targetElement: HTMLElement) { + /** + * The user wants to jump to the next lower block of types of suggestions. + */ + selectCtrlDown() { + if (this.items.length === 0) { + return; + } + + if (this.cursor >= this.items.length - 1) { + this.setSelection(0); + return; + } + + let index = this.cursor + 1; + const type = this.itemType(index); + + while (index < this.items.length - 1 && this.itemType(index) === type) { + index += 1; + } + + this.setSelection(index); + } + + /** + * The user wants to jump to the next upper block of types of suggestions. + */ + selectCtrlUp() { + if (this.items.length === 0) { + return; + } + + if (this.cursor <= 0) { + this.setSelection(this.items.length - 1); + return; + } + + let index = this.cursor - 1; + const type = this.itemType(index); + + while (index > 0 && this.itemType(index) === type) { + index -= 1; + } + + this.setSelection(index); + } + + /** + * Returns the item's prototype that can be viewed as the item's type identifier. + */ + private itemType(index: number) { + return this.items[index].suggestion instanceof TagSuggestion ? 'tag' : 'history'; + } + + showForElement(targetElement: HTMLElement) { this.container.style.position = 'absolute'; this.container.style.left = `${targetElement.offsetLeft}px`; @@ -130,66 +340,12 @@ export class SuggestionsPopup { } this.container.style.top = `${topPosition}px`; - - document.body.appendChild(this.container); + this.container.classList.remove('hidden'); } - onItemSelected(callback: (event: CustomEvent) => void) { - this.container.addEventListener('item_selected', callback as EventListener); - } -} - -const cachedSuggestions = new Map>(); - -export async function fetchSuggestions(endpoint: string, targetTerm: string): Promise { - const normalizedTerm = targetTerm.trim().toLowerCase(); - - if (cachedSuggestions.has(normalizedTerm)) { - return cachedSuggestions.get(normalizedTerm)!; - } - - const promisedSuggestions: Promise = fetch(`${endpoint}${targetTerm}`) - .then(handleError) - .then(response => response.json()) - .catch(() => { - // Deleting the promised result from cache to allow retrying - cachedSuggestions.delete(normalizedTerm); - - // And resolve failed promise with empty array - return []; + onItemSelected(callback: (event: ItemSelectedEvent) => void) { + this.container.addEventListener('item_selected', event => { + callback((event as CustomEvent).detail); }); - - cachedSuggestions.set(normalizedTerm, promisedSuggestions); - - return promisedSuggestions; -} - -export function purgeSuggestionsCache() { - cachedSuggestions.clear(); -} - -export async function fetchLocalAutocomplete(): Promise { - const now = new Date(); - const cacheKey = `${now.getUTCFullYear()}-${now.getUTCMonth()}-${now.getUTCDate()}`; - - return await fetch(`/autocomplete/compiled?vsn=2&key=${cacheKey}`, { - credentials: 'omit', - cache: 'force-cache', - }) - .then(handleError) - .then(resp => resp.arrayBuffer()) - .then(buf => new LocalAutocompleter(buf)); -} - -export function formatLocalAutocompleteResult(result: Result): TermSuggestion { - let tagName = result.name; - - if (tagName !== result.aliasName) { - tagName = `${result.aliasName} ⇒ ${tagName}`; } - - return { - value: result.name, - label: `${tagName} (${result.imageCount})`, - }; } From b9440e79cfe62088efcb95c0c89514bfbeca7a3a Mon Sep 17 00:00:00 2001 From: MareStare Date: Tue, 4 Mar 2025 04:36:50 +0000 Subject: [PATCH 13/33] Update tests for the suggestions popup --- assets/js/utils/__tests__/suggestions.spec.ts | 400 +++++++----------- 1 file changed, 157 insertions(+), 243 deletions(-) diff --git a/assets/js/utils/__tests__/suggestions.spec.ts b/assets/js/utils/__tests__/suggestions.spec.ts index 29de9691..5e6c8774 100644 --- a/assets/js/utils/__tests__/suggestions.spec.ts +++ b/assets/js/utils/__tests__/suggestions.spec.ts @@ -1,37 +1,35 @@ -import { fetchMock } from '../../../test/fetch-mock.ts'; import { - fetchLocalAutocomplete, - fetchSuggestions, - formatLocalAutocompleteResult, - purgeSuggestionsCache, SuggestionsPopup, - TermSuggestion, + TagSuggestion, + TagSuggestionParams, + Suggestions, + HistorySuggestion, + ItemSelectedEvent, } from '../suggestions.ts'; -import fs from 'fs'; -import path from 'path'; -import { LocalAutocompleter } from '../local-autocompleter.ts'; import { afterEach } from 'vitest'; import { fireEvent } from '@testing-library/dom'; -import { getRandomIntBetween } from '../../../test/randomness.ts'; +import { assertNotNull } from '../assert.ts'; -const mockedSuggestionsEndpoint = '/endpoint?term='; -const mockedSuggestionsResponse = [ - { label: 'artist:assasinmonkey (1)', value: 'artist:assasinmonkey' }, - { label: 'artist:hydrusbeta (1)', value: 'artist:hydrusbeta' }, - { label: 'artist:the sexy assistant (1)', value: 'artist:the sexy assistant' }, - { label: 'artist:devinian (1)', value: 'artist:devinian' }, - { label: 'artist:moe (1)', value: 'artist:moe' }, -]; +const mockedSuggestions: Suggestions = { + history: ['foo bar', 'bar baz', 'baz qux'].map(content => new HistorySuggestion(content, 0)), + tags: [ + { images: 10, canonical: 'artist:assasinmonkey' }, + { images: 10, canonical: 'artist:hydrusbeta' }, + { images: 10, canonical: 'artist:the sexy assistant' }, + { images: 10, canonical: 'artist:devinian' }, + { images: 10, canonical: 'artist:moe' }, + ].map(tags => new TagSuggestion({ ...tags, matchLength: 0 })), +}; function mockBaseSuggestionsPopup(includeMockedSuggestions: boolean = false): [SuggestionsPopup, HTMLInputElement] { const input = document.createElement('input'); const popup = new SuggestionsPopup(); document.body.append(input); - popup.showForField(input); + popup.showForElement(input); if (includeMockedSuggestions) { - popup.renderSuggestions(mockedSuggestionsResponse); + popup.setSuggestions(mockedSuggestions); } return [popup, input]; @@ -40,27 +38,9 @@ function mockBaseSuggestionsPopup(includeMockedSuggestions: boolean = false): [S const selectedItemClassName = 'autocomplete__item--selected'; describe('Suggestions', () => { - let mockedAutocompleteBuffer: ArrayBuffer; let popup: SuggestionsPopup | undefined; let input: HTMLInputElement | undefined; - beforeAll(async () => { - fetchMock.enableMocks(); - - mockedAutocompleteBuffer = await fs.promises - .readFile(path.join(__dirname, 'autocomplete-compiled-v2.bin')) - .then(fileBuffer => fileBuffer.buffer); - }); - - afterAll(() => { - fetchMock.disableMocks(); - }); - - beforeEach(() => { - purgeSuggestionsCache(); - fetchMock.resetMocks(); - }); - afterEach(() => { if (input) { input.remove(); @@ -69,6 +49,7 @@ describe('Suggestions', () => { if (popup) { popup.hide(); + popup.setSuggestions({ history: [], tags: [] }); popup = undefined; } }); @@ -78,113 +59,113 @@ describe('Suggestions', () => { [popup, input] = mockBaseSuggestionsPopup(); expect(document.querySelector('.autocomplete')).toBeInstanceOf(HTMLElement); - expect(popup.isActive).toBe(true); - }); - - it('should be removed when hidden', () => { - [popup, input] = mockBaseSuggestionsPopup(); - - popup.hide(); - - expect(document.querySelector('.autocomplete')).not.toBeInstanceOf(HTMLElement); - expect(popup.isActive).toBe(false); + expect(popup.isHidden).toBe(false); }); it('should render suggestions', () => { [popup, input] = mockBaseSuggestionsPopup(true); - expect(document.querySelectorAll('.autocomplete__item').length).toBe(mockedSuggestionsResponse.length); + expect(document.querySelectorAll('.autocomplete__item').length).toBe( + mockedSuggestions.history.length + mockedSuggestions.tags.length, + ); }); - it('should initially select first element when selectNext called', () => { + it('should initially select first element when selectDown is called', () => { [popup, input] = mockBaseSuggestionsPopup(true); - popup.selectNext(); + popup.selectDown(); expect(document.querySelector('.autocomplete__item:first-child')).toHaveClass(selectedItemClassName); }); - it('should initially select last element when selectPrevious called', () => { + it('should initially select last element when selectUp is called', () => { [popup, input] = mockBaseSuggestionsPopup(true); - popup.selectPrevious(); + popup.selectUp(); expect(document.querySelector('.autocomplete__item:last-child')).toHaveClass(selectedItemClassName); }); - it('should select and de-select items when hovering items over', () => { + it('should jump to the next lower block when selectCtrlDown is called', () => { [popup, input] = mockBaseSuggestionsPopup(true); - const firstItem = document.querySelector('.autocomplete__item:first-child'); - const lastItem = document.querySelector('.autocomplete__item:last-child'); + popup.selectCtrlDown(); - if (firstItem) { - fireEvent.mouseOver(firstItem); - fireEvent.mouseMove(firstItem); - } + expect(popup.selectedSuggestion).toBe(mockedSuggestions.tags[0]); + expect(document.querySelector('.autocomplete__item__tag')).toHaveClass(selectedItemClassName); - expect(firstItem).toHaveClass(selectedItemClassName); + popup.selectCtrlDown(); - if (lastItem) { - fireEvent.mouseOver(lastItem); - fireEvent.mouseMove(lastItem); - } + expect(popup.selectedSuggestion).toBe(mockedSuggestions.tags.at(-1)); + expect(document.querySelector('.autocomplete__item__tag:last-child')).toHaveClass(selectedItemClassName); - expect(firstItem).not.toHaveClass(selectedItemClassName); - expect(lastItem).toHaveClass(selectedItemClassName); - - if (lastItem) { - fireEvent.mouseOut(lastItem); - } - - expect(lastItem).not.toHaveClass(selectedItemClassName); + // Should loop around + popup.selectCtrlDown(); + expect(popup.selectedSuggestion).toBe(mockedSuggestions.history[0]); + expect(document.querySelector('.autocomplete__item:first-child')).toHaveClass(selectedItemClassName); }); - it('should allow switching between mouse and selection', () => { + it('should jump to the next upper block when selectCtrlUp is called', () => { [popup, input] = mockBaseSuggestionsPopup(true); - const secondItem = document.querySelector('.autocomplete__item:nth-child(2)'); - const thirdItem = document.querySelector('.autocomplete__item:nth-child(3)'); + popup.selectCtrlUp(); - if (secondItem) { - fireEvent.mouseOver(secondItem); - fireEvent.mouseMove(secondItem); - } + expect(popup.selectedSuggestion).toBe(mockedSuggestions.tags.at(-1)); + expect(document.querySelector('.autocomplete__item__tag:last-child')).toHaveClass(selectedItemClassName); - expect(secondItem).toHaveClass(selectedItemClassName); + popup.selectCtrlUp(); - popup.selectNext(); + expect(popup.selectedSuggestion).toBe(mockedSuggestions.history.at(-1)); + expect( + document.querySelector(`.autocomplete__item__history:nth-child(${mockedSuggestions.history.length})`), + ).toHaveClass(selectedItemClassName); - expect(secondItem).not.toHaveClass(selectedItemClassName); - expect(thirdItem).toHaveClass(selectedItemClassName); + popup.selectCtrlUp(); + + expect(popup.selectedSuggestion).toBe(mockedSuggestions.history[0]); + expect(document.querySelector('.autocomplete__item:first-child')).toHaveClass(selectedItemClassName); + + // Should loop around + popup.selectCtrlUp(); + + expect(popup.selectedSuggestion).toBe(mockedSuggestions.tags.at(-1)); + expect(document.querySelector('.autocomplete__item__tag:last-child')).toHaveClass(selectedItemClassName); + }); + + it('should do nothing on selection changes when empty', () => { + [popup, input] = mockBaseSuggestionsPopup(); + + popup.selectDown(); + popup.selectUp(); + popup.selectCtrlDown(); + popup.selectCtrlUp(); + + expect(document.querySelector(`.${selectedItemClassName}`)).toBeNull(); }); it('should loop around when selecting next on last and previous on first', () => { [popup, input] = mockBaseSuggestionsPopup(true); - const firstItem = document.querySelector('.autocomplete__item:first-child'); - const lastItem = document.querySelector('.autocomplete__item:last-child'); + const firstItem = assertNotNull(document.querySelector('.autocomplete__item:first-child')); + const lastItem = assertNotNull(document.querySelector('.autocomplete__item:last-child')); - if (lastItem) { - fireEvent.mouseOver(lastItem); - fireEvent.mouseMove(lastItem); - } + popup.selectUp(); expect(lastItem).toHaveClass(selectedItemClassName); - popup.selectNext(); + popup.selectDown(); expect(document.querySelector(`.${selectedItemClassName}`)).toBeNull(); - popup.selectNext(); + popup.selectDown(); expect(firstItem).toHaveClass(selectedItemClassName); - popup.selectPrevious(); + popup.selectUp(); expect(document.querySelector(`.${selectedItemClassName}`)).toBeNull(); - popup.selectPrevious(); + popup.selectUp(); expect(lastItem).toHaveClass(selectedItemClassName); }); @@ -192,176 +173,109 @@ describe('Suggestions', () => { it('should return selected item value', () => { [popup, input] = mockBaseSuggestionsPopup(true); - expect(popup.selectedTerm).toBe(null); + expect(popup.selectedSuggestion).toBe(null); - popup.selectNext(); + popup.selectDown(); - expect(popup.selectedTerm).toBe(mockedSuggestionsResponse[0].value); + expect(popup.selectedSuggestion).toBe(mockedSuggestions.history[0]); }); - it('should emit an event when item was clicked with mouse', () => { + it('should emit an event when an item was clicked with a mouse', () => { [popup, input] = mockBaseSuggestionsPopup(true); - let clickEvent: CustomEvent | undefined; - - const itemSelectedHandler = vi.fn((event: CustomEvent) => { - clickEvent = event; - }); + const itemSelectedHandler = vi.fn<(event: ItemSelectedEvent) => void>(); popup.onItemSelected(itemSelectedHandler); - const firstItem = document.querySelector('.autocomplete__item'); + const firstItem = assertNotNull(document.querySelector('.autocomplete__item')); - if (firstItem) { - fireEvent.click(firstItem); - } + fireEvent.click(firstItem); expect(itemSelectedHandler).toBeCalledTimes(1); - expect(clickEvent?.detail).toEqual(mockedSuggestionsResponse[0]); - }); - - it('should not emit selection on items without value', () => { - [popup, input] = mockBaseSuggestionsPopup(); - - popup.renderSuggestions([{ label: 'Option without value', value: '' }]); - - const itemSelectionHandler = vi.fn(); - - popup.onItemSelected(itemSelectionHandler); - - const firstItem = document.querySelector('.autocomplete__item:first-child')!; - - if (firstItem) { - fireEvent.click(firstItem); - } - - expect(itemSelectionHandler).not.toBeCalled(); + expect(itemSelectedHandler).toBeCalledWith({ + ctrlKey: false, + shiftKey: false, + suggestion: mockedSuggestions.history[0], + }); }); }); - describe('fetchSuggestions', () => { - it('should only call fetch once per single term', () => { - fetchSuggestions(mockedSuggestionsEndpoint, 'art'); - fetchSuggestions(mockedSuggestionsEndpoint, 'art'); - - expect(fetch).toHaveBeenCalledTimes(1); - }); - - it('should be case-insensitive to terms and trim spaces', () => { - fetchSuggestions(mockedSuggestionsEndpoint, 'art'); - fetchSuggestions(mockedSuggestionsEndpoint, 'Art'); - fetchSuggestions(mockedSuggestionsEndpoint, ' ART '); - - expect(fetch).toHaveBeenCalledTimes(1); - }); - - it('should return the same suggestions from cache', async () => { - fetchMock.mockResolvedValueOnce(new Response(JSON.stringify(mockedSuggestionsResponse), { status: 200 })); - - const firstSuggestions = await fetchSuggestions(mockedSuggestionsEndpoint, 'art'); - const secondSuggestions = await fetchSuggestions(mockedSuggestionsEndpoint, 'art'); - - expect(firstSuggestions).toBe(secondSuggestions); - }); - - it('should parse and return array of suggestions', async () => { - fetchMock.mockResolvedValueOnce(new Response(JSON.stringify(mockedSuggestionsResponse), { status: 200 })); - - const resolvedSuggestions = await fetchSuggestions(mockedSuggestionsEndpoint, 'art'); - - expect(resolvedSuggestions).toBeInstanceOf(Array); - expect(resolvedSuggestions.length).toBe(mockedSuggestionsResponse.length); - expect(resolvedSuggestions).toEqual(mockedSuggestionsResponse); - }); - - it('should return empty array on server error', async () => { - fetchMock.mockResolvedValueOnce(new Response('', { status: 500 })); - - const resolvedSuggestions = await fetchSuggestions(mockedSuggestionsEndpoint, 'unknown tag'); - - expect(resolvedSuggestions).toBeInstanceOf(Array); - expect(resolvedSuggestions.length).toBe(0); - }); - - it('should return empty array on invalid response format', async () => { - fetchMock.mockResolvedValueOnce(new Response('invalid non-JSON response', { status: 200 })); - - const resolvedSuggestions = await fetchSuggestions(mockedSuggestionsEndpoint, 'invalid response'); - - expect(resolvedSuggestions).toBeInstanceOf(Array); - expect(resolvedSuggestions.length).toBe(0); + describe('HistorySuggestion', () => { + it('should render the suggestion', () => { + expectHistoryRender('foo bar').toMatchInlineSnapshot(` + { + "label": " foo bar", + "value": "foo bar", + } + `); }); }); - describe('purgeSuggestionsCache', () => { - it('should clear cached responses', async () => { - fetchMock.mockResolvedValueOnce(new Response(JSON.stringify(mockedSuggestionsResponse), { status: 200 })); - - const firstResult = await fetchSuggestions(mockedSuggestionsEndpoint, 'art'); - purgeSuggestionsCache(); - const resultAfterPurge = await fetchSuggestions(mockedSuggestionsEndpoint, 'art'); - - expect(fetch).toBeCalledTimes(2); - expect(firstResult).not.toBe(resultAfterPurge); - }); - }); - - describe('fetchLocalAutocomplete', () => { - it('should request binary with date-related cache key', () => { - fetchMock.mockResolvedValue(new Response(mockedAutocompleteBuffer, { status: 200 })); - - const now = new Date(); - const cacheKey = `${now.getUTCFullYear()}-${now.getUTCMonth()}-${now.getUTCDate()}`; - const expectedEndpoint = `/autocomplete/compiled?vsn=2&key=${cacheKey}`; - - fetchLocalAutocomplete(); - - expect(fetch).toBeCalledWith(expectedEndpoint, { credentials: 'omit', cache: 'force-cache' }); - }); - - it('should return auto-completer instance', async () => { - fetchMock.mockResolvedValue(new Response(mockedAutocompleteBuffer, { status: 200 })); - - const autocomplete = await fetchLocalAutocomplete(); - - expect(autocomplete).toBeInstanceOf(LocalAutocompleter); - }); - - it('should throw generic server error on failing response', async () => { - fetchMock.mockResolvedValue(new Response('error', { status: 500 })); - - expect(() => fetchLocalAutocomplete()).rejects.toThrowError('Received error from server'); - }); - }); - - describe('formatLocalAutocompleteResult', () => { + describe('TagSuggestion', () => { it('should format suggested tags as tag name and the count', () => { - const tagName = 'safe'; - const tagCount = getRandomIntBetween(5, 10); - - const resultObject = formatLocalAutocompleteResult({ - name: tagName, - aliasName: tagName, - imageCount: tagCount, - }); - - expect(resultObject.label).toBe(`${tagName} (${tagCount})`); - expect(resultObject.value).toBe(tagName); + expectTagRender({ canonical: 'safe', images: 10 }).toMatchInlineSnapshot(` + { + "label": " safe 10", + "value": "safe", + } + `); + expectTagRender({ canonical: 'safe', images: 10_000 }).toMatchInlineSnapshot(` + { + "label": " safe 10 000", + "value": "safe", + } + `); + expectTagRender({ canonical: 'safe', images: 100_000 }).toMatchInlineSnapshot(` + { + "label": " safe 100 000", + "value": "safe", + } + `); + expectTagRender({ canonical: 'safe', images: 1000_000 }).toMatchInlineSnapshot(` + { + "label": " safe 1 000 000", + "value": "safe", + } + `); + expectTagRender({ canonical: 'safe', images: 10_000_000 }).toMatchInlineSnapshot(` + { + "label": " safe 10 000 000", + "value": "safe", + } + `); }); - it('should display original alias name for aliased tags', () => { - const tagName = 'safe'; - const tagAlias = 'rating:safe'; - const tagCount = getRandomIntBetween(5, 10); - - const resultObject = formatLocalAutocompleteResult({ - name: tagName, - aliasName: tagAlias, - imageCount: tagCount, - }); - - expect(resultObject.label).toBe(`${tagAlias} ⇒ ${tagName} (${tagCount})`); - expect(resultObject.value).toBe(tagName); + it('should display alias -> canonical for aliased tags', () => { + expectTagRender({ images: 10, canonical: 'safe', alias: 'rating:safe' }).toMatchInlineSnapshot( + ` + { + "label": " rating:safe → safe 10", + "value": "safe", + } + `, + ); }); }); }); + +function expectHistoryRender(content: string) { + const suggestion = new HistorySuggestion(content, 0); + const label = suggestion + .render() + .map(el => el.textContent) + .join(''); + const value = suggestion.value(); + + return expect({ label, value }); +} + +function expectTagRender(params: Omit) { + const suggestion = new TagSuggestion({ ...params, matchLength: 0 }); + const label = suggestion + .render() + .map(el => el.textContent) + .join(''); + const value = suggestion.value(); + + return expect({ label, value }); +} From b119660a54f2b89976d8fbe93da09ad5bb467e2a Mon Sep 17 00:00:00 2001 From: MareStare Date: Tue, 4 Mar 2025 04:39:20 +0000 Subject: [PATCH 14/33] Add new styles for the suggestions popup to CSS --- assets/css/views/tags.css | 84 +++++++++++++++++++++++++++++++++++---- 1 file changed, 77 insertions(+), 7 deletions(-) diff --git a/assets/css/views/tags.css b/assets/css/views/tags.css index 057f8ca9..f0b87514 100644 --- a/assets/css/views/tags.css +++ b/assets/css/views/tags.css @@ -24,27 +24,97 @@ } /* Autocomplete */ -.autocomplete__list { +.autocomplete { cursor: pointer; display: inline-block; - list-style: none; margin: 0; padding: 0; position: absolute; user-select: none; white-space: nowrap; z-index: 999; + font-family: var(--font-family-monospace); + background: var(--background-color); + + /* Borders */ + border-style: solid; + border-width: 1px; + border-top-width: 0; + border-color: var(--meta-border-color); + + /* Poor man's hack to make sure autocomplete doesn't grow beyond the viewport */ + max-width: 70vw; +} + +.autocomplete__separator { + margin: 0; } .autocomplete__item { - background: var(--base-color); - color: var(--link-light-color); padding: 5px; } -.autocomplete__item--selected { - background: var(--link-light-color); - color: var(--base-color); +.autocomplete__item__content { + /* Squash overly long suggestions */ + text-overflow: ellipsis; + overflow: hidden; +} + +.autocomplete__item__tag { + color: var(--foreground-color); + display: flex; + justify-content: space-between; + white-space: pre; +} + +.autocomplete__item__history { + color: var(--block-header-link-text-color); +} + +.autocomplete__item__history__icon { + /* + Makes the history icon aligned in width with the autocomplete__item__tag's icon. + Yes, it's a dirty hack, don't look at me like that >_<, but turns out font-awesome + icons aren't actually all of the same size! + */ + font-size: 11.38px; +} + +.autocomplete__item__history__match { + font-weight: bold; + + /* Use a lighter color to highlight the matched part of the query */ + color: lch(from var(--block-header-link-text-color) calc(l + 20) c h); +} + +.autocomplete__item__tag__match { + font-weight: bold; +} + +.autocomplete__item__tag__match:not(.autocomplete__item--selected) { + /* Use a lighter color to highlight the matched part of the query */ + color: lch(from var(--foreground-color) calc(l + 20) c h); +} + +.autocomplete__item__tag__count { + color: var(--foreground-half-color); + + /* + Reduce the space size between groups of 3 digits in big numbers like "1 000 000". + This way the number is more compact and easier to read. + */ + word-spacing: -3px; +} + +.autocomplete__item:hover:not(.autocomplete__item--selected) { + background: lch(from var(--background-color) calc(l + 10) c h); +} + +.autocomplete__item--selected, +.autocomplete__item--selected .autocomplete__item__history__match, +.autocomplete__item--selected .autocomplete__item__tag__match { + background: var(--foreground-color); + color: var(--background-color); } /* Tags */ From 182bb0ef2402b3c6077793bb28d9ccf8484263f5 Mon Sep 17 00:00:00 2001 From: MareStare Date: Tue, 4 Mar 2025 04:40:00 +0000 Subject: [PATCH 15/33] Indent comment --- assets/css/views/tags.css | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/assets/css/views/tags.css b/assets/css/views/tags.css index f0b87514..e2092e45 100644 --- a/assets/css/views/tags.css +++ b/assets/css/views/tags.css @@ -73,9 +73,9 @@ .autocomplete__item__history__icon { /* - Makes the history icon aligned in width with the autocomplete__item__tag's icon. - Yes, it's a dirty hack, don't look at me like that >_<, but turns out font-awesome - icons aren't actually all of the same size! + Makes the history icon aligned in width with the autocomplete__item__tag's icon. + Yes, it's a dirty hack, don't look at me like that >_<, but turns out font-awesome + icons aren't actually all of the same size! */ font-size: 11.38px; } From cae4df68bce98ac48b9cbe2d03bd896ad315c904 Mon Sep 17 00:00:00 2001 From: MareStare Date: Tue, 4 Mar 2025 04:49:44 +0000 Subject: [PATCH 16/33] Remove the no-longer needed `mouseMoveThenOver` utility --- assets/js/utils/__tests__/events.spec.ts | 59 +----------------------- assets/js/utils/events.ts | 11 ----- 2 files changed, 1 insertion(+), 69 deletions(-) diff --git a/assets/js/utils/__tests__/events.spec.ts b/assets/js/utils/__tests__/events.spec.ts index f53b22ce..00e2a5ee 100644 --- a/assets/js/utils/__tests__/events.spec.ts +++ b/assets/js/utils/__tests__/events.spec.ts @@ -1,12 +1,4 @@ -import { - delegate, - fire, - mouseMoveThenOver, - leftClick, - on, - PhilomenaAvailableEventsMap, - oncePersistedPageShown, -} from '../events'; +import { delegate, fire, leftClick, on, PhilomenaAvailableEventsMap, oncePersistedPageShown } from '../events'; import { getRandomArrayItem } from '../../../test/randomness'; import { fireEvent } from '@testing-library/dom'; @@ -88,55 +80,6 @@ describe('Event utils', () => { }); }); - describe('mouseMoveThenOver', () => { - it('should NOT fire on first mouseover', () => { - const mockButton = document.createElement('button'); - const mockHandler = vi.fn(); - - mouseMoveThenOver(mockButton, mockHandler); - - fireEvent.mouseOver(mockButton); - - expect(mockHandler).toHaveBeenCalledTimes(0); - }); - - it('should fire on the first mousemove', () => { - const mockButton = document.createElement('button'); - const mockHandler = vi.fn(); - - mouseMoveThenOver(mockButton, mockHandler); - - fireEvent.mouseMove(mockButton); - - expect(mockHandler).toHaveBeenCalledTimes(1); - }); - - it('should fire on subsequent mouseover', () => { - const mockButton = document.createElement('button'); - const mockHandler = vi.fn(); - - mouseMoveThenOver(mockButton, mockHandler); - - fireEvent.mouseMove(mockButton); - fireEvent.mouseOver(mockButton); - - expect(mockHandler).toHaveBeenCalledTimes(2); - }); - - it('should NOT fire on subsequent mousemove', () => { - const mockButton = document.createElement('button'); - const mockHandler = vi.fn(); - - mouseMoveThenOver(mockButton, mockHandler); - - fireEvent.mouseMove(mockButton); - fireEvent.mouseOver(mockButton); - fireEvent.mouseMove(mockButton); - - expect(mockHandler).toHaveBeenCalledTimes(2); - }); - }); - describe('oncePersistedPageShown', () => { it('should NOT fire on usual page show', () => { const mockHandler = vi.fn(); diff --git a/assets/js/utils/events.ts b/assets/js/utils/events.ts index f9d31c29..fa692fde 100644 --- a/assets/js/utils/events.ts +++ b/assets/js/utils/events.ts @@ -43,17 +43,6 @@ export function leftClick(func }; } -export function mouseMoveThenOver(element: El, func: (e: MouseEvent) => void) { - element.addEventListener( - 'mousemove', - (event: MouseEvent) => { - func(event); - element.addEventListener('mouseover', func); - }, - { once: true }, - ); -} - export function oncePersistedPageShown(func: (e: PageTransitionEvent) => void) { const controller = new AbortController(); From 7cf02793e1c79925d58a84cf27dedbaaf8c8c971 Mon Sep 17 00:00:00 2001 From: MareStare Date: Thu, 13 Mar 2025 23:19:46 +0000 Subject: [PATCH 17/33] Move some color definitions for autocomplete into dark/light.css --- assets/css/themes/base/dark.css | 9 +++++++++ assets/css/themes/base/light.css | 9 +++++++++ assets/css/views/tags.css | 10 +++++----- 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/assets/css/themes/base/dark.css b/assets/css/themes/base/dark.css index af34c2f5..d1bba897 100644 --- a/assets/css/themes/base/dark.css +++ b/assets/css/themes/base/dark.css @@ -165,5 +165,14 @@ --dnp-warning-hover-color: hsl(from $vote-down-color h s calc(l + 10)); --poll-form-label-background: hsl(from $border-color h s calc(l + 8)); --tag-dropdown-hover-background: hsl(from $meta-color h s calc(l - 4)); + + --autocomplete-history-color: $block-header-link-text-color + --autocomplete-history-match-color: hsl(from $block-header-link-text-color h s calc(l + 20)); + + --autocomplete-tag-color: $foreground-color; + --autocomplete-tag-match-color: hsl(from $foreground-color h s calc(l + 20)); + --autocomplete-tag-count-color: $foreground-half-color + + --autocomplete-match-selected-color: hsl(from $background-color h s calc(l + 10)); } } diff --git a/assets/css/themes/base/light.css b/assets/css/themes/base/light.css index a72ad6f8..b62dcb05 100644 --- a/assets/css/themes/base/light.css +++ b/assets/css/themes/base/light.css @@ -162,5 +162,14 @@ --dnp-warning-hover-color: hsl(from $vote-down-color h s calc(l + 10)); --poll-form-label-background: hsl(from $base-color h calc(s - 16) calc(l + 36)); --tag-dropdown-hover-background: hsl(from $foreground-color h s calc(l - 10)); + + --autocomplete-history-color: $block-header-link-text-color + --autocomplete-history-match-color: hsl(from $block-header-link-text-color h s calc(l + 20)); + + --autocomplete-tag-color: $foreground-color; + --autocomplete-tag-match-color: hsl(from $foreground-color h s calc(l + 20)); + --autocomplete-tag-count-color: $foreground-half-color + + --autocomplete-match-selected-color: hsl(from $background-color h s calc(l + 10)); } } diff --git a/assets/css/views/tags.css b/assets/css/views/tags.css index e2092e45..62a87914 100644 --- a/assets/css/views/tags.css +++ b/assets/css/views/tags.css @@ -68,7 +68,7 @@ } .autocomplete__item__history { - color: var(--block-header-link-text-color); + color: var(--autocomplete-history-color); } .autocomplete__item__history__icon { @@ -84,7 +84,7 @@ font-weight: bold; /* Use a lighter color to highlight the matched part of the query */ - color: lch(from var(--block-header-link-text-color) calc(l + 20) c h); + color: var(--autocomplete-history-match-color); } .autocomplete__item__tag__match { @@ -93,11 +93,11 @@ .autocomplete__item__tag__match:not(.autocomplete__item--selected) { /* Use a lighter color to highlight the matched part of the query */ - color: lch(from var(--foreground-color) calc(l + 20) c h); + color: var(--autocomplete-tag-match-color); } .autocomplete__item__tag__count { - color: var(--foreground-half-color); + color: var(--autocomplete-tag-count-color); /* Reduce the space size between groups of 3 digits in big numbers like "1 000 000". @@ -107,7 +107,7 @@ } .autocomplete__item:hover:not(.autocomplete__item--selected) { - background: lch(from var(--background-color) calc(l + 10) c h); + background: var(--autocomplete-match-selected-color); } .autocomplete__item--selected, From 84ecf1ea4e777ec01f0fb58251ef070acb2cc78c Mon Sep 17 00:00:00 2001 From: MareStare Date: Thu, 13 Mar 2025 23:20:35 +0000 Subject: [PATCH 18/33] Remove redundant comment from suggestions.ts --- assets/js/utils/suggestions.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/assets/js/utils/suggestions.ts b/assets/js/utils/suggestions.ts index 23d3a928..19bd798f 100644 --- a/assets/js/utils/suggestions.ts +++ b/assets/js/utils/suggestions.ts @@ -149,7 +149,6 @@ export class SuggestionsPopup { tabIndex: -1, }); - // Make the container connected to DOM to make sure it's rendered when we unhide it document.body.appendChild(this.container); this.items = []; } From 84b6ef74dfcbaa0ccfeb4f6347027712b5b5c823 Mon Sep 17 00:00:00 2001 From: MareStare Date: Thu, 13 Mar 2025 23:36:19 +0000 Subject: [PATCH 19/33] Use `Intl.NumberFormat` with French style instead of a manual impl --- assets/css/views/tags.css | 6 ------ assets/js/utils/__tests__/suggestions.spec.ts | 11 +++++++---- assets/js/utils/suggestions.ts | 10 ++++------ 3 files changed, 11 insertions(+), 16 deletions(-) diff --git a/assets/css/views/tags.css b/assets/css/views/tags.css index 62a87914..bc3ba2fc 100644 --- a/assets/css/views/tags.css +++ b/assets/css/views/tags.css @@ -98,12 +98,6 @@ .autocomplete__item__tag__count { color: var(--autocomplete-tag-count-color); - - /* - Reduce the space size between groups of 3 digits in big numbers like "1 000 000". - This way the number is more compact and easier to read. - */ - word-spacing: -3px; } .autocomplete__item:hover:not(.autocomplete__item--selected) { diff --git a/assets/js/utils/__tests__/suggestions.spec.ts b/assets/js/utils/__tests__/suggestions.spec.ts index 5e6c8774..63886cd7 100644 --- a/assets/js/utils/__tests__/suggestions.spec.ts +++ b/assets/js/utils/__tests__/suggestions.spec.ts @@ -213,6 +213,8 @@ describe('Suggestions', () => { describe('TagSuggestion', () => { it('should format suggested tags as tag name and the count', () => { + // The snapshots in this test contain a "narrow no-break space" + /* eslint-disable no-irregular-whitespace */ expectTagRender({ canonical: 'safe', images: 10 }).toMatchInlineSnapshot(` { "label": " safe 10", @@ -221,28 +223,29 @@ describe('Suggestions', () => { `); expectTagRender({ canonical: 'safe', images: 10_000 }).toMatchInlineSnapshot(` { - "label": " safe 10 000", + "label": " safe 10 000", "value": "safe", } `); expectTagRender({ canonical: 'safe', images: 100_000 }).toMatchInlineSnapshot(` { - "label": " safe 100 000", + "label": " safe 100 000", "value": "safe", } `); expectTagRender({ canonical: 'safe', images: 1000_000 }).toMatchInlineSnapshot(` { - "label": " safe 1 000 000", + "label": " safe 1 000 000", "value": "safe", } `); expectTagRender({ canonical: 'safe', images: 10_000_000 }).toMatchInlineSnapshot(` { - "label": " safe 10 000 000", + "label": " safe 10 000 000", "value": "safe", } `); + /* eslint-enable no-irregular-whitespace */ }); it('should display alias -> canonical for aliased tags', () => { diff --git a/assets/js/utils/suggestions.ts b/assets/js/utils/suggestions.ts index 19bd798f..5cfc8888 100644 --- a/assets/js/utils/suggestions.ts +++ b/assets/js/utils/suggestions.ts @@ -64,13 +64,11 @@ export class TagSuggestion { } static formatImageCount(count: number): string { - const chars = [...count.toString()]; + // We use the 'fr' (French) number formatting style with space-separated + // groups of 3 digits. + const formatter = new Intl.NumberFormat('fr', { useGrouping: true }); - for (let i = chars.length - 3; i > 0; i -= 3) { - chars.splice(i, 0, ' '); - } - - return chars.join(''); + return formatter.format(count); } } From 671e9deda223ab6da596e75a1f167df687414c33 Mon Sep 17 00:00:00 2001 From: MareStare Date: Tue, 4 Mar 2025 04:45:28 +0000 Subject: [PATCH 20/33] Add user-facing autocomplete settings --- assets/js/settings.ts | 36 +++++++++++++++---- .../templates/setting/edit.html.slime | 28 +++++++++++++-- 2 files changed, 56 insertions(+), 8 deletions(-) diff --git a/assets/js/settings.ts b/assets/js/settings.ts index a272b5e4..9bad96c8 100644 --- a/assets/js/settings.ts +++ b/assets/js/settings.ts @@ -28,17 +28,41 @@ function setupThemeSettings() { themeColorSelect.addEventListener('change', themePreviewCallback); } +function hideIf(element: HTMLElement, condition: boolean) { + if (condition) { + element.classList.add('hidden'); + } else { + element.classList.remove('hidden'); + } +} + +function setupAutocompleteSettings() { + const autocompleteSettings = assertNotNull($('.autocomplete-settings')); + + // Don't show search history settings if autocomplete is entirely disabled. + assertNotNull($('#user_enable_search_ac')).addEventListener('change', event => { + hideIf(autocompleteSettings, !(event.target as HTMLInputElement).checked); + }); + + const autocompleteSearchHistorySettings = assertNotNull($('.autocomplete-search-history-settings')); + + assertNotNull($('#user_autocomplete_search_history_hidden')).addEventListener('change', event => { + hideIf(autocompleteSearchHistorySettings, (event.target as HTMLInputElement).checked); + }); +} + export function setupSettings() { if (!$('#js-setting-table')) return; - const localCheckboxes = $$('[data-tab="local"] input[type="checkbox"]'); - // Local settings - localCheckboxes.forEach(checkbox => { - checkbox.addEventListener('change', () => { - store.set(checkbox.id.replace('user_', ''), checkbox.checked); + for (const input of $$('[data-tab="local"] input')) { + input.addEventListener('change', () => { + const newValue = input.type === 'checkbox' ? input.checked : input.value; + + store.set(input.id.replace('user_', ''), newValue); }); - }); + } setupThemeSettings(); + setupAutocompleteSettings(); } diff --git a/lib/philomena_web/templates/setting/edit.html.slime b/lib/philomena_web/templates/setting/edit.html.slime index 829c3cd2..fdba84fc 100644 --- a/lib/philomena_web/templates/setting/edit.html.slime +++ b/lib/philomena_web/templates/setting/edit.html.slime @@ -183,8 +183,32 @@ h1 Content Settings .fieldlabel: i Show streams marked as NSFW on the channels page. .field => label f, :enable_search_ac, "Enable search auto-completion" - => checkbox f, :enable_search_ac, checked: @conn.cookies["enable_search_ac"] === "true" - .fieldlabel: i Enable the auto-completion of tags in search fields. + => checkbox f, :enable_search_ac, checked: @conn.cookies["enable_search_ac"] == "true" + + .autocomplete-settings class=if(@conn.cookies["enable_search_ac"] != "true", do: "hidden", else: "") + .field + => label f, + :autocomplete_search_history_hidden, + "Hide search history in auto-completion" + => checkbox f, + :autocomplete_search_history_hidden, + checked: @conn.cookies["autocomplete_search_history_hidden"] == "true" + + .autocomplete-search-history-settings[ + class=if(@conn.cookies["autocomplete_search_history_hidden"] == "true", do: "hidden", else: "") + ] + .field + => label f, + :autocomplete_search_history_max_suggestions_when_typing, + "Maximum number of search history suggestions in autocompletion when typing" + => number_input f, + :autocomplete_search_history_max_suggestions_when_typing, + min: 0, + max: 10, + step: 1, + value: @conn.cookies["autocomplete_search_history_max_suggestions_when_typing"] || 3, + class: "input" + = if staff?(@conn.assigns.current_user) do .field => label f, :hide_staff_tools From c9f3677bd45d636e635a9d2fd8833f92f1cca97f Mon Sep 17 00:00:00 2001 From: MareStare Date: Wed, 12 Mar 2025 00:54:48 +0000 Subject: [PATCH 21/33] Move hideIf to utils and refactor a bit --- assets/js/settings.ts | 23 ++++++++--------------- assets/js/utils/dom.ts | 8 ++++++++ 2 files changed, 16 insertions(+), 15 deletions(-) diff --git a/assets/js/settings.ts b/assets/js/settings.ts index 9bad96c8..772e4b76 100644 --- a/assets/js/settings.ts +++ b/assets/js/settings.ts @@ -3,7 +3,7 @@ */ import { assertNotNull, assertNotUndefined } from './utils/assert'; -import { $, $$ } from './utils/dom'; +import { $, $$, hideIf } from './utils/dom'; import store from './utils/store'; function setupThemeSettings() { @@ -28,26 +28,19 @@ function setupThemeSettings() { themeColorSelect.addEventListener('change', themePreviewCallback); } -function hideIf(element: HTMLElement, condition: boolean) { - if (condition) { - element.classList.add('hidden'); - } else { - element.classList.remove('hidden'); - } -} - function setupAutocompleteSettings() { const autocompleteSettings = assertNotNull($('.autocomplete-settings')); + const autocompleteSearchHistorySettings = assertNotNull($('.autocomplete-search-history-settings')); + const enableSearchAutocomplete = assertNotNull($('#user_enable_search_ac')); + const userSearchHistoryHidden = assertNotNull($('#user_autocomplete_search_history_hidden')); // Don't show search history settings if autocomplete is entirely disabled. - assertNotNull($('#user_enable_search_ac')).addEventListener('change', event => { - hideIf(autocompleteSettings, !(event.target as HTMLInputElement).checked); + enableSearchAutocomplete.addEventListener('change', () => { + hideIf(!enableSearchAutocomplete.checked, autocompleteSettings); }); - const autocompleteSearchHistorySettings = assertNotNull($('.autocomplete-search-history-settings')); - - assertNotNull($('#user_autocomplete_search_history_hidden')).addEventListener('change', event => { - hideIf(autocompleteSearchHistorySettings, (event.target as HTMLInputElement).checked); + userSearchHistoryHidden.addEventListener('change', () => { + hideIf(userSearchHistoryHidden.checked, autocompleteSearchHistorySettings); }); } diff --git a/assets/js/utils/dom.ts b/assets/js/utils/dom.ts index 220dc9e4..9f10726a 100644 --- a/assets/js/utils/dom.ts +++ b/assets/js/utils/dom.ts @@ -110,3 +110,11 @@ export function escapeCss(css: string): string { export function findFirstTextNode(of: Node): N { return Array.prototype.filter.call(of.childNodes, el => el.nodeType === Node.TEXT_NODE)[0]; } + +export function hideIf(condition: boolean, element: HTMLElement) { + if (condition) { + element.classList.add('hidden'); + } else { + element.classList.remove('hidden'); + } +} From 007a3e629a957db928e131c2de1ac6e8b17002fb Mon Sep 17 00:00:00 2001 From: MareStare Date: Wed, 12 Mar 2025 00:56:26 +0000 Subject: [PATCH 22/33] Add tests for `hideIf` --- assets/js/utils/__tests__/dom.spec.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/assets/js/utils/__tests__/dom.spec.ts b/assets/js/utils/__tests__/dom.spec.ts index dfd0faa2..f81e5cc2 100644 --- a/assets/js/utils/__tests__/dom.spec.ts +++ b/assets/js/utils/__tests__/dom.spec.ts @@ -14,6 +14,7 @@ import { findFirstTextNode, disableEl, enableEl, + hideIf, } from '../dom'; import { getRandomArrayItem, getRandomIntBetween } from '../../../test/randomness'; import { fireEvent } from '@testing-library/dom'; @@ -444,4 +445,18 @@ describe('DOM Utilities', () => { expect(result).toBe(undefined); }); }); + + describe('hideIf', () => { + it('should add "hidden" class if condition is true', () => { + const element = document.createElement('div'); + hideIf(true, element); + expect(element).toHaveClass('hidden'); + }); + it('should remove "hidden" class if condition is false', () => { + const element = document.createElement('div'); + element.classList.add('hidden'); + hideIf(false, element); + expect(element).not.toHaveClass('hidden'); + }); + }); }); From 6b615558af21434802a7a55f67c937980e24d2c3 Mon Sep 17 00:00:00 2001 From: MareStare Date: Tue, 4 Mar 2025 04:54:12 +0000 Subject: [PATCH 23/33] Delete the old autocomplete impl --- assets/js/autocomplete.ts | 292 -------------------------------------- 1 file changed, 292 deletions(-) delete mode 100644 assets/js/autocomplete.ts diff --git a/assets/js/autocomplete.ts b/assets/js/autocomplete.ts deleted file mode 100644 index 72dbbfdb..00000000 --- a/assets/js/autocomplete.ts +++ /dev/null @@ -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 = $$( - '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) => { - if (!event.detail || !inputField) return; - - const originalSuggestion = event.detail; - applySelectedValue(originalSuggestion.value); - - if (originalTerm?.startsWith('-')) { - originalSuggestion.value = `-${originalSuggestion.value}`; - } - - inputField.dispatchEvent( - new CustomEvent('autocomplete', { - detail: Object.assign( - { - type: 'click', - }, - originalSuggestion, - ), - }), - ); - }); -} From 7aa5562a21b31a69aefb493abdc6ada8cd1b9db3 Mon Sep 17 00:00:00 2001 From: MareStare Date: Tue, 4 Mar 2025 04:55:10 +0000 Subject: [PATCH 24/33] Add the main `Autocomplete` ui binding glue --- assets/js/autocomplete/index.ts | 410 ++++++++++++++++++++++++++++++++ 1 file changed, 410 insertions(+) create mode 100644 assets/js/autocomplete/index.ts diff --git a/assets/js/autocomplete/index.ts b/assets/js/autocomplete/index.ts new file mode 100644 index 00000000..d07aeea0 --- /dev/null +++ b/assets/js/autocomplete/index.ts @@ -0,0 +1,410 @@ +import { LocalAutocompleter } from '../utils/local-autocompleter'; +import * as history from './history'; +import { AutocompletableInput, TextInputElement } from './input'; +import { + SuggestionsPopup, + Suggestions, + TagSuggestion, + Suggestion, + HistorySuggestion, + ItemSelectedEvent, +} from '../utils/suggestions'; +import { $$ } from '../utils/dom'; +import { AutocompleteClient, GetTagSuggestionsRequest } from './client'; +import { DebouncedCache } from '../utils/debounced-cache'; +import store from '../utils/store'; + +// This lint is dumb, especially in this case because this type alias depends on +// the `Autocomplete` symbol, and methods on the `Autocomplete` class depend on +// this type alias, so either way there is a circular dependency in type annotations +// eslint-disable-next-line no-use-before-define +type ActiveAutocomplete = Autocomplete & { input: AutocompletableInput }; + +function readHistoryConfig() { + if (store.get('autocomplete_search_history_hidden')) { + return null; + } + + return { + maxSuggestionsWhenTyping: store.get('autocomplete_search_history_max_suggestions_when_typing') ?? 3, + }; +} + +class Autocomplete { + index: null | 'fetching' | 'unavailable' | LocalAutocompleter = null; + input: AutocompletableInput | null = null; + popup = new SuggestionsPopup(); + client = new AutocompleteClient(); + serverSideTagSuggestions = new DebouncedCache(this.client.getTagSuggestions.bind(this.client)); + + constructor() { + this.popup.onItemSelected(this.confirmSuggestion.bind(this)); + } + + /** + * Lazy-load the local autocomplete data. + */ + async fetchLocalAutocomplete() { + if (this.index) { + // The index is already either fetching or initialized/unavailable, so nothing to do. + return; + } + + // Indicate that the index is in the process of fetching so that + // we don't try to fetch it again while it's still loading. + this.index = 'fetching'; + + try { + const index = await this.client.getCompiledAutocomplete(); + this.index = new LocalAutocompleter(index); + this.refresh(); + } catch (error) { + this.index = 'unavailable'; + console.error('Failed to fetch local autocomplete data', error); + } + } + + refresh() { + this.serverSideTagSuggestions.abortLastSchedule('[Autocomplete] A new user input was received'); + + this.input = AutocompletableInput.fromElement(document.activeElement); + if (!this.isActive()) { + this.popup.hide(); + return; + } + + const { input } = this; + + // Initiate the lazy local autocomplete fetch on background if it hasn't been done yet. + this.fetchLocalAutocomplete(); + + const historyConfig = readHistoryConfig(); + + // Show all history suggestions if the input is empty. + if (historyConfig && input.snapshot.trimmedValue === '') { + this.showSuggestions({ + history: history.listSuggestions(input), + tags: [], + }); + return; + } + + // When the input is not empty the history suggestions take up + // only a small portion of the suggestions. + const suggestions: Suggestions = { + history: historyConfig ? history.listSuggestions(input, historyConfig.maxSuggestionsWhenTyping) : [], + tags: [], + }; + + // There are several scenarios where we don't try to fetch server-side suggestions, + // even if we could. + // + // 1. The `index` is still `fetching`. + // We should wait until it's done. Doing concurrent server-side suggestions + // request in this case would be optimistically wasteful. + // + // 2. The `index` is `unavailable`. + // We shouldn't fetch server suggestions either because there may be something + // horribly wrong on the backend, so we don't want to spam it with even more + // requests. This scenario should be extremely rare though. + if ( + !input.snapshot.activeTerm || + !(this.index instanceof LocalAutocompleter) || + suggestions.history.length === this.input.maxSuggestions + ) { + this.showSuggestions(suggestions); + return; + } + + const activeTerm = input.snapshot.activeTerm.term; + + suggestions.tags = this.index + .matchPrefix(activeTerm, input.maxSuggestions - suggestions.history.length) + .map(result => new TagSuggestion({ ...result, matchLength: activeTerm.length })); + + // Used for debugging server-side completions, to ensure local autocomplete + // doesn't prevent sever-side completions from being shown. Use these console + // commands to enable/disable server-side completions: + // ```js + // localStorage.setItem('SERVER_SIDE_COMPLETIONS_ONLY', true) + // localStorage.removeItem('SERVER_SIDE_COMPLETIONS_ONLY') + // ``` + if (store.get('SERVER_SIDE_COMPLETIONS_ONLY')) { + suggestions.tags = []; + } + + // Show suggestions that we already have early without waiting for a potential + // server-side suggestions request. + this.showSuggestions(suggestions); + + // Only if the index had its chance to provide suggestions + // and produced nothing, do we try to fetch server-side suggestions. + if (suggestions.tags.length > 0 || activeTerm.length < 3) { + return; + } + + this.scheduleServerSideSuggestions(activeTerm, suggestions.history); + } + + scheduleServerSideSuggestions(this: ActiveAutocomplete, term: string, historySuggestions: HistorySuggestion[]) { + const request: GetTagSuggestionsRequest = { + term, + + // We always use the `maxSuggestions` value for the limit, because it's a + // reasonably small and limited value. Yes, we may overfetch in some cases, + // but otherwise the cache hits rate of `DebouncedCache` also increases due + // to the less variation in the cache key (request params). + limit: this.input.maxSuggestions, + }; + + this.serverSideTagSuggestions.schedule(request, response => { + if (!this.isActive()) { + return; + } + + // Truncate the suggestions to the leftover space shared with history suggestions. + const maxTags = this.input.maxSuggestions - historySuggestions.length; + + const tags = response.suggestions.slice(0, maxTags).map( + suggestion => + new TagSuggestion({ + ...suggestion, + matchLength: term.length, + }), + ); + + this.showSuggestions({ + history: historySuggestions, + tags, + }); + }); + } + + showSuggestions(this: ActiveAutocomplete, suggestions: Suggestions) { + this.popup.setSuggestions(suggestions).showForElement(this.input.element); + } + + onFocusIn() { + // The purpose of `focusin` subscription is to bring up the popup with the + // initial history suggestions if there is no popup yet. If there is a popup + // already, e.g. when we are re-focusing back to the input after the user + // selected some suggestion then there is no need to refresh the popup. + if (!this.popup.isHidden) { + return; + } + + // The event we are processing comes before the input's selection is updated. + // Defer the refresh to the next frame to get the updated selection. + requestAnimationFrame(() => { + // Double-check the popup is still hidden on a new spin of the event loop. + // Just in case =) + if (!this.popup.isHidden) { + return; + } + + this.refresh(); + }); + } + + onClick(event: MouseEvent) { + if (this.input?.isEnabled() && this.input.element !== event.target) { + // We lost focus. Hide the popup. + // We use this method instead of the `focusout` event because this way it's + // easier to work in the developer tools when you want to inspect the element. + // When you inspect it, a `focusout` happens. + this.popup.hide(); + this.input = null; + } + } + + onKeyDown(event: KeyboardEvent) { + if (!this.isActive() || this.input.element !== event.target) { + return; + } + if ((event.key === ',' || event.code === 'Enter') && this.input.type === 'single-tag') { + // Coma means the end of input for the current tag in single-tag mode. + this.popup.hide(); + return; + } + + switch (event.code) { + case 'Enter': { + const { selectedSuggestion } = this.popup; + if (!selectedSuggestion) { + return; + } + + // Prevent submission of the form when Enter was hit. + // Note, however, that `confirmSuggestion` may still submit the form + // manually if the selected suggestion is a history suggestion and + // no `Shift` key was pressed. + event.preventDefault(); + + this.confirmSuggestion({ + suggestion: selectedSuggestion, + shiftKey: event.shiftKey, + ctrlKey: event.ctrlKey, + }); + return; + } + case 'Escape': { + this.popup.hide(); + return; + } + case 'ArrowLeft': + case 'ArrowRight': { + // The event we are processing comes before the input's selection is updated. + // Defer the refresh to the next frame to get the updated selection. + requestAnimationFrame(() => this.refresh()); + return; + } + case 'ArrowUp': + case 'ArrowDown': { + if (event.code === 'ArrowUp') { + if (event.ctrlKey) { + this.popup.selectCtrlUp(); + } else { + this.popup.selectUp(); + } + } else { + if (event.ctrlKey) { + this.popup.selectCtrlDown(); + } else { + this.popup.selectDown(); + } + } + + if (this.popup.selectedSuggestion) { + this.updateInputWithSelectedValue(this.popup.selectedSuggestion); + } else { + this.updateInputWithOriginalValue(); + } + + // Prevent the cursor from moving to the start or end of the input field, + // which is the default behavior of the arrow keys are used in a text input. + event.preventDefault(); + + return; + } + default: + } + } + + updateInputWithOriginalValue(this: ActiveAutocomplete) { + const { element, snapshot } = this.input; + const { selection } = snapshot; + element.value = snapshot.origValue; + element.setSelectionRange(selection.start, selection.end, selection.direction ?? undefined); + } + + confirmSuggestion({ suggestion, shiftKey, ctrlKey }: ItemSelectedEvent) { + this.assertActive(); + + this.updateInputWithSelectedValue(suggestion); + + const prefix = this.input.snapshot.activeTerm?.prefix ?? ''; + + const detail = `${prefix}${suggestion.value()}`; + + const newEvent = new CustomEvent('autocomplete', { detail }); + + this.input.element.dispatchEvent(newEvent); + + if (ctrlKey || (suggestion instanceof HistorySuggestion && !shiftKey)) { + // We use `requestSubmit()` instead of `submit()` because it triggers the + // 'submit' event on the form. We have a handler subscribed to that event + // that records the input's value for history tracking. + this.input.element.form?.requestSubmit(); + } + + // XXX: it's important to focus the input element first before hiding the popup, + // because if we do it the other way around our `onFocusIn` handler will refresh + // the popup and bring it back up, which is not what we want. We want to give a + // brief moment of silence for the user without the popup before they type + // something else, otherwise we'd show some more completions for the current term. + this.input.element.focus(); + this.popup.hide(); + } + + updateInputWithSelectedValue(this: ActiveAutocomplete, suggestion: Suggestion) { + const { + element, + snapshot: { activeTerm, origValue }, + } = this.input; + + const value = suggestion.value(); + + if (!activeTerm || suggestion instanceof HistorySuggestion) { + element.value = value; + return; + } + + const { range, prefix } = activeTerm; + + element.value = origValue.slice(0, range.start) + prefix + value + origValue.slice(range.end); + + const newCursorIndex = range.start + value.length; + element.setSelectionRange(newCursorIndex, newCursorIndex); + } + + isActive(): this is ActiveAutocomplete { + return Boolean(this.input?.isEnabled()); + } + + assertActive(): asserts this is ActiveAutocomplete { + if (this.isActive()) { + return; + } + + console.debug('Current input when the error happened', this.input); + throw new Error(`BUG: expected autocomplete to be active, but it isn't`); + } +} + +/** + * 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 refreshNativeAutocomplete() { + const elements = $$( + 'input[data-autocomplete][data-autocomplete-condition], ' + + 'textarea[data-autocomplete][data-autocomplete-condition]', + ); + + for (const element of elements) { + const input = AutocompletableInput.fromElement(element); + if (!input) { + continue; + } + + element.autocomplete = input.isEnabled() ? 'off' : 'on'; + } +} + +export function listenAutocomplete() { + history.listen(); + + const autocomplete = new Autocomplete(); + + // Refresh all the state in case any autocomplete settings change. + store.watchAll(key => { + if (key && key !== 'enable_search_ac' && !key.startsWith('autocomplete')) { + return; + } + + refreshNativeAutocomplete(); + autocomplete.refresh(); + }); + + refreshNativeAutocomplete(); + + // By the time this script loads, the input elements may already be focused, + // so we refresh the autocomplete state immediately to trigger the initial completions. + autocomplete.refresh(); + + document.addEventListener('focusin', autocomplete.onFocusIn.bind(autocomplete)); + document.addEventListener('input', autocomplete.refresh.bind(autocomplete)); + document.addEventListener('click', autocomplete.onClick.bind(autocomplete)); + document.addEventListener('keydown', autocomplete.onKeyDown.bind(autocomplete)); +} From 6b9b9d212fe9d30a26100c41016a0074f4546a59 Mon Sep 17 00:00:00 2001 From: MareStare Date: Tue, 4 Mar 2025 04:56:01 +0000 Subject: [PATCH 25/33] Update the format of `LexResult` to a more readable one (array to object conversion) --- assets/js/query/lex.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/assets/js/query/lex.ts b/assets/js/query/lex.ts index e98d8840..d3e1a2de 100644 --- a/assets/js/query/lex.ts +++ b/assets/js/query/lex.ts @@ -22,8 +22,15 @@ const tokenList: Token[] = [ export type ParseTerm = (term: string, fuzz: number, boost: number) => AstMatcher; -export type Range = [number, number]; -export type TermContext = [Range, string]; +export interface Range { + start: number; + end: number; +} + +export interface TermContext { + range: Range; + content: string; +} export interface LexResult { tokenList: TokenList; @@ -61,7 +68,11 @@ export function generateLexResult(searchStr: string, parseTerm: ParseTerm): LexR if (searchTerm !== null) { // Push to stack. ret.tokenList.push(parseTerm(searchTerm, fuzz, boost)); - ret.termContexts.push([[termIndex, termIndex + searchTerm.length], searchTerm]); + + ret.termContexts.push({ + range: { start: termIndex, end: termIndex + searchTerm.length }, + content: searchTerm, + }); // Reset term and options data. boost = 1; fuzz = 0; From 696a2fd74a9a89a5f160a5897849e877d778a507 Mon Sep 17 00:00:00 2001 From: MareStare Date: Tue, 4 Mar 2025 04:56:50 +0000 Subject: [PATCH 26/33] Update the `tagsinput.ts` with the new `'autocomplete'` event shape --- assets/js/__tests__/tagsinput.spec.ts | 3 +-- assets/js/tagsinput.ts | 5 ++--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/assets/js/__tests__/tagsinput.spec.ts b/assets/js/__tests__/tagsinput.spec.ts index e32b2dfc..3c46eddc 100644 --- a/assets/js/__tests__/tagsinput.spec.ts +++ b/assets/js/__tests__/tagsinput.spec.ts @@ -1,6 +1,5 @@ import { $, $$, hideEl } from '../utils/dom'; import { assertNotNull } from '../utils/assert'; -import { TermSuggestion } from '../utils/suggestions'; import { setupTagsInput, addTag, reloadTagsInput } from '../tagsinput'; const formData = `
@@ -96,7 +95,7 @@ describe('Fancy tags input', () => { it('should respond to autocomplete events', () => { setupTagsInput(tagBlock); - fancyText.dispatchEvent(new CustomEvent('autocomplete', { detail: { value: 'a', label: 'a' } })); + fancyText.dispatchEvent(new CustomEvent('autocomplete', { detail: 'a' })); expect($$('span.tag', fancyInput)).toHaveLength(1); }); diff --git a/assets/js/tagsinput.ts b/assets/js/tagsinput.ts index 8a7d4b06..19396d80 100644 --- a/assets/js/tagsinput.ts +++ b/assets/js/tagsinput.ts @@ -4,7 +4,6 @@ import { assertNotNull, assertType } from './utils/assert'; import { $, $$, clearEl, removeEl, showEl, hideEl, escapeCss, escapeHtml } from './utils/dom'; -import { TermSuggestion } from './utils/suggestions'; export function setupTagsInput(tagBlock: HTMLDivElement) { const form = assertNotNull(tagBlock.closest('form')); @@ -48,8 +47,8 @@ export function setupTagsInput(tagBlock: HTMLDivElement) { importTags(); } - function handleAutocomplete(event: CustomEvent) { - insertTag(event.detail.value); + function handleAutocomplete(event: CustomEvent) { + insertTag(event.detail); inputField.focus(); } From b8b3ed598275a1905a0579e3166b467a59145e68 Mon Sep 17 00:00:00 2001 From: MareStare Date: Tue, 4 Mar 2025 04:58:35 +0000 Subject: [PATCH 27/33] Update the slime HTML templates with new `data` attributes --- .../templates/filter/_form.html.slime | 4 ++-- .../templates/layout/_header.html.slime | 8 +++++--- .../templates/profile/artist_link/_form.html.slime | 2 +- .../templates/search/_form.html.slime | 13 ++++++++++++- .../templates/tag/_tag_editor.html.slime | 5 ++--- lib/philomena_web/templates/tag/index.html.slime | 14 ++++++++++++-- 6 files changed, 34 insertions(+), 12 deletions(-) diff --git a/lib/philomena_web/templates/filter/_form.html.slime b/lib/philomena_web/templates/filter/_form.html.slime index 95eca5dd..54b913ba 100644 --- a/lib/philomena_web/templates/filter/_form.html.slime +++ b/lib/philomena_web/templates/filter/_form.html.slime @@ -26,7 +26,7 @@ .field = label f, :spoilered_complex_str, "Complex Spoiler Filter" br - = textarea f, :spoilered_complex_str, class: "input input--wide", autocapitalize: "none", data: [autocomplete: "true", autocomplete_min_length: 3, autocomplete_mode: "search"] + = textarea f, :spoilered_complex_str, class: "input input--wide", autocapitalize: "none", autocomplete: "off", data: [autocomplete: "multi-tags"] br = error_tag f, :spoilered_complex_str .fieldlabel @@ -51,7 +51,7 @@ .field = label f, :hidden_complex_str, "Complex Hide Filter" br - = textarea f, :hidden_complex_str, class: "input input--wide", autocapitalize: "none", data: [autocomplete: "true", autocomplete_min_length: 3, autocomplete_mode: "search"] + = textarea f, :hidden_complex_str, class: "input input--wide", autocapitalize: "none", autocomplete: "off", data: [autocomplete: "multi-tags"] br = error_tag f, :hidden_complex_str .fieldlabel diff --git a/lib/philomena_web/templates/layout/_header.html.slime b/lib/philomena_web/templates/layout/_header.html.slime index b839e6b8..80a84975 100644 --- a/lib/philomena_web/templates/layout/_header.html.slime +++ b/lib/philomena_web/templates/layout/_header.html.slime @@ -23,9 +23,11 @@ header.header value=@conn.params["q"] placeholder="Search" autocapitalize="none" - data-autocomplete="true" - data-autocomplete-min-length="3" - data-autocomplete-mode="search" + autocomplete=if(@conn.cookies["enable_search_ac"], do: "on", else: "off") + inputmode="search" + data-autocomplete="multi-tags" + data-autocomplete-condition="enable_search_ac" + data-autocomplete-history-id="search-history" ] = if present?(@conn.params["sf"]) do diff --git a/lib/philomena_web/templates/profile/artist_link/_form.html.slime b/lib/philomena_web/templates/profile/artist_link/_form.html.slime index 00ea7cd7..e999f2ed 100644 --- a/lib/philomena_web/templates/profile/artist_link/_form.html.slime +++ b/lib/philomena_web/templates/profile/artist_link/_form.html.slime @@ -10,7 +10,7 @@ ' Artist Link validation is intended for artists. Validating your link will give you control over your content on the site, allowing you to create a a> href="/commissions" commissions ' listing and request takedowns or DNPs. Do not request a link if the source contains no artwork which you have created. - = text_input f, :tag_name, value: assigns[:tag_name], class: "input", autocomplete: "off", placeholder: "artist:your-name", data: [autocomplete: "true", autocomplete_min_length: "3", autocomplete_source: "/autocomplete/tags?term="] + = text_input f, :tag_name, value: assigns[:tag_name], class: "input", autocomplete: "off", placeholder: "artist:your-name", data: [autocomplete: "single-tag"] = error_tag f, :tag .field diff --git a/lib/philomena_web/templates/search/_form.html.slime b/lib/philomena_web/templates/search/_form.html.slime index adb789bf..0eafd0d8 100644 --- a/lib/philomena_web/templates/search/_form.html.slime +++ b/lib/philomena_web/templates/search/_form.html.slime @@ -1,7 +1,18 @@ h1 Search = form_for :search, ~p"/search", [id: "searchform", method: "get", class: "js-search-form", enforce_utf8: false], fn f -> - = text_input f, :q, class: "input input--wide js-search-field", placeholder: "Search terms are chained with commas", autocapitalize: "none", name: "q", value: @conn.params["q"], data: [autocomplete: "true", autocomplete_min_length: 3, autocomplete_mode: "search"] + = text_input f, :q, class: "input input--wide js-search-field", + placeholder: "Search terms are chained with commas", + autocapitalize: "none", + name: "q", + value: @conn.params["q"], + autocomplete: if(@conn.cookies["enable_search_ac"], do: "on", else: "off"), + inputmode: "search", + data: [ \ + autocomplete: "multi-tags", + autocomplete_condition: "enable_search_ac", + autocomplete_history_id: "search-history", + ] .block .block__header.flex diff --git a/lib/philomena_web/templates/tag/_tag_editor.html.slime b/lib/philomena_web/templates/tag/_tag_editor.html.slime index 821ff12d..3cb3e078 100644 --- a/lib/philomena_web/templates/tag/_tag_editor.html.slime +++ b/lib/philomena_web/templates/tag/_tag_editor.html.slime @@ -16,9 +16,8 @@ elixir: placeholder="add a tag" autocomplete="off" autocapitalize="none" - data-autocomplete="true" - data-autocomplete-min-length="3" - data-autocomplete-source="/autocomplete/tags?term=" + data-autocomplete="single-tag" + data-autocomplete-max-suggestions=5 ] button.button.button--state-primary.button--bold[ class="js-taginput-show" diff --git a/lib/philomena_web/templates/tag/index.html.slime b/lib/philomena_web/templates/tag/index.html.slime index 89931cea..e4584234 100644 --- a/lib/philomena_web/templates/tag/index.html.slime +++ b/lib/philomena_web/templates/tag/index.html.slime @@ -2,7 +2,17 @@ h1 Tags = form_for :tags, ~p"/tags", [method: "get", class: "hform", enforce_utf8: false], fn f -> .field - = text_input f, :tq, name: :tq, value: @conn.params["tq"] || "*", class: "input hform__text", placeholder: "Search tags", autocapitalize: "none" + = text_input f, :tq, name: :tq, value: @conn.params["tq"] || "*", + class: "input hform__text", + placeholder: "Search tags", + autocapitalize: "none", + autocomplete: if(@conn.cookies["enable_search_ac"], do: "on", else: "off"), + inputmode: "search", + data: [ \ + autocomplete: "multi-tags", + autocomplete_condition: "enable_search_ac", + ] + = submit "Search", class: "hform__button button" .fieldlabel @@ -31,7 +41,7 @@ h2 Search Results .block.block--fixed.block--danger ' Oops, there was an error parsing your query! Check for mistakes like mismatched parentheses. The error was: pre = assigns[:error] - + - true -> p ' No tags found! From 4f50d0de3e66f8f62cdf4219efa84bb6ac3d9aa6 Mon Sep 17 00:00:00 2001 From: MareStare Date: Tue, 4 Mar 2025 04:59:28 +0000 Subject: [PATCH 28/33] Add integration tests for various parts of the autocompletion business logic, including history suggestions --- assets/js/autocomplete/__tests__/context.ts | 269 ++++++++++++++++++ .../js/autocomplete/__tests__/history.spec.ts | 102 +++++++ .../autocomplete/__tests__/keyboard.spec.ts | 114 ++++++++ .../__tests__/server-side-completions.spec.ts | 48 ++++ 4 files changed, 533 insertions(+) create mode 100644 assets/js/autocomplete/__tests__/context.ts create mode 100644 assets/js/autocomplete/__tests__/history.spec.ts create mode 100644 assets/js/autocomplete/__tests__/keyboard.spec.ts create mode 100644 assets/js/autocomplete/__tests__/server-side-completions.spec.ts diff --git a/assets/js/autocomplete/__tests__/context.ts b/assets/js/autocomplete/__tests__/context.ts new file mode 100644 index 00000000..fabfab23 --- /dev/null +++ b/assets/js/autocomplete/__tests__/context.ts @@ -0,0 +1,269 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { fetchMock } from '../../../test/fetch-mock'; +import { listenAutocomplete } from '..'; +import { fireEvent } from '@testing-library/dom'; +import { assertNotNull } from '../../utils/assert'; +import { TextInputElement } from '../input'; +import store from '../../utils/store'; +import { GetTagSuggestionsResponse } from 'autocomplete/client'; + +/** + * A reusable test environment for autocompletion tests. Note that it does no + * attempt to provide environment cleanup functionality. Yes, if you use this + * in several tests in one file, then tests will conflict with each other. + * + * The main problem of implementing the cleanup here is that autocomplete code + * adds event listeners to the `document` object. Some of them could be moved + * to the `` element, but events such as `'storage'` are only available + * on the document object. + * + * Unfortunately, there isn't a good easy way to reload the DOM completely in + * `jsdom`, so it's expected that you define a single test per file so that + * `vitest` runs every test in an isolated process, where no cleanup is needed. + * + * I wish `vitest` actually did that by default, because cleanup logic and test + * in-process test isolation is just boilerplate that we could avoid at this + * scale at least. + */ +export class TestContext { + private input: TextInputElement; + private popup: HTMLElement; + readonly fakeAutocompleteResponse: Response; + + constructor(fakeAutocompleteResponse: Response) { + this.fakeAutocompleteResponse = fakeAutocompleteResponse; + + vi.useFakeTimers().setSystemTime(0); + fetchMock.enableMocks(); + + // Our mock backend implementation. + fetchMock.mockResponse(request => { + if (request.url.includes('/autocomplete/compiled')) { + return this.fakeAutocompleteResponse; + } + + const url = new URL(request.url); + if (url.searchParams.get('term')?.toLowerCase() !== 'mar') { + const suggestions: GetTagSuggestionsResponse = { suggestions: [] }; + return JSON.stringify(suggestions); + } + + const suggestions: GetTagSuggestionsResponse = { + suggestions: [ + { + alias: 'marvelous', + canonical: 'beautiful', + images: 30, + }, + { + canonical: 'mare', + images: 20, + }, + { + canonical: 'market', + images: 10, + }, + ], + }; + + return JSON.stringify(suggestions); + }); + + store.set('enable_search_ac', true); + + document.body.innerHTML = ` + + + + `; + + listenAutocomplete(); + + this.input = assertNotNull(document.querySelector('.test-input')); + this.popup = assertNotNull(document.querySelector('.autocomplete')); + + expect(fetch).not.toBeCalled(); + } + + async submitForm(input?: string) { + if (input) { + await this.setInput(input); + } + + this.input.form!.submit(); + + await this.setInput(''); + } + + async focusInput() { + this.input.focus(); + await vi.runAllTimersAsync(); + } + + /** + * Sets the input to `value`. Allows for a special `<>` syntax. These characters + * are removed from the input. Their position is used to set the selection. + * + * - `<` denotes the `selectionStart` + * - `>` denotes the `selectionEnd`. + */ + async setInput(value: string) { + if (document.activeElement !== this.input) { + await this.focusInput(); + } + + const valueChars = [...value]; + + const selectionStart = valueChars.indexOf('<'); + if (selectionStart >= 0) { + valueChars.splice(selectionStart, 1); + } + + const selectionEnd = valueChars.indexOf('>'); + if (selectionEnd >= 0) { + valueChars.splice(selectionEnd, 1); + } + + this.input.value = valueChars.join(''); + if (selectionStart >= 0) { + this.input.selectionStart = selectionStart; + } + if (selectionEnd >= 0) { + this.input.selectionEnd = selectionEnd; + } + + fireEvent.input(this.input, { target: { value: this.input.value } }); + + await vi.runAllTimersAsync(); + } + + async keyDown(code: string, params?: { ctrlKey?: boolean }) { + fireEvent.keyDown(this.input, { code, ...(params ?? {}) }); + await vi.runAllTimersAsync(); + } + + expectRequests() { + const snapshot = vi.mocked(fetch).mock.calls.map(([input]) => { + const request = input as unknown as Request; + const meta: Record = {}; + + const url = new URL(request.url); + + const methodAndUrl = `${request.method} ${url}`; + + if (request.credentials !== 'same-origin') { + meta.credentials = request.credentials; + } + + if (request.cache !== 'default') { + meta.cache = request.cache; + } + + if (Object.getOwnPropertyNames(meta).length === 0) { + return methodAndUrl; + } + + return { + dest: methodAndUrl, + meta, + }; + }); + + return expect(snapshot); + } + + /** + * The snapshot of the UI uses some special syntax like `<>` to denote the + * selection start (`<`) and end (`>`), as well as some markers for the + * currently selected item and history suggestions. + */ + expectUi() { + const input = this.inputSnapshot(); + const suggestions = this.suggestionsSnapshot(); + + return expect({ input, suggestions }); + } + + suggestionsSnapshot() { + const { popup } = this; + + if (popup.classList.contains('hidden')) { + return []; + } + + return [...popup.children].map(el => { + if (el.tagName === 'HR') { + return '-----------'; + } + + let content = el.textContent!.trim(); + + if (el.classList.contains('autocomplete__item__history')) { + content = `(history) ${content}`; + } + + if (el.classList.contains('autocomplete__item--selected')) { + return `👉 ${content}`; + } + return content; + }); + } + + inputSnapshot() { + const { input } = this; + + const value = [...input.value]; + + if (input.selectionStart) { + value.splice(input.selectionStart, 0, '<'); + } + + if (input.selectionEnd) { + const shift = input.selectionStart && input.selectionStart <= input.selectionEnd ? 1 : 0; + + value.splice(input.selectionEnd + shift, 0, '>'); + } + + return value.join(''); + } +} + +export async function init(): Promise { + const fakeAutocompleteBuffer = await fs.promises + .readFile(path.join(__dirname, '../../utils/__tests__/autocomplete-compiled-v2.bin')) + .then(({ buffer }) => new Response(buffer)); + + const ctx = new TestContext(fakeAutocompleteBuffer); + + expect(fetch).not.toHaveBeenCalled(); + + // Initialize the lazy autocomplete index cache + await ctx.focusInput(); + + ctx.expectRequests().toMatchInlineSnapshot(` + [ + { + "dest": "GET http://localhost:3000/autocomplete/compiled?vsn=2&key=1970-0-1", + "meta": { + "cache": "force-cache", + "credentials": "omit", + }, + }, + ] + `); + + ctx.expectUi().toMatchInlineSnapshot(` + { + "input": "", + "suggestions": [], + } + `); + + return ctx; +} diff --git a/assets/js/autocomplete/__tests__/history.spec.ts b/assets/js/autocomplete/__tests__/history.spec.ts new file mode 100644 index 00000000..394442cf --- /dev/null +++ b/assets/js/autocomplete/__tests__/history.spec.ts @@ -0,0 +1,102 @@ +import { init } from './context'; + +it('records search history', async () => { + const ctx = await init(); + + await ctx.submitForm('foo1'); + + // Empty input should show all latest history items + ctx.expectUi().toMatchInlineSnapshot(` + { + "input": "", + "suggestions": [ + "(history) foo1", + ], + } + `); + + await ctx.submitForm('foo2'); + + ctx.expectUi().toMatchInlineSnapshot(` + { + "input": "", + "suggestions": [ + "(history) foo2", + "(history) foo1", + ], + } + `); + + await ctx.submitForm('a complex OR (query AND bar)'); + + ctx.expectUi().toMatchInlineSnapshot(` + { + "input": "", + "suggestions": [ + "(history) a complex OR (query AND bar)", + "(history) foo2", + "(history) foo1", + ], + } + `); + + // Last recently used item should be on top + await ctx.submitForm('foo2'); + + ctx.expectUi().toMatchInlineSnapshot(` + { + "input": "", + "suggestions": [ + "(history) foo2", + "(history) a complex OR (query AND bar)", + "(history) foo1", + ], + } + `); + + await ctx.setInput('a com'); + + ctx.expectUi().toMatchInlineSnapshot(` + { + "input": "a com<>", + "suggestions": [ + "(history) a complex OR (query AND bar)", + ], + } + `); + + await ctx.setInput('f'); + + ctx.expectUi().toMatchInlineSnapshot(` + { + "input": "f<>", + "suggestions": [ + "(history) foo2", + "(history) foo1", + "-----------", + "forest 3", + "fog 1", + "force field 1", + "flower 1", + ], + } + `); + + // History items must be selectable + await ctx.keyDown('ArrowDown'); + + ctx.expectUi().toMatchInlineSnapshot(` + { + "input": "foo2<>", + "suggestions": [ + "👉 (history) foo2", + "(history) foo1", + "-----------", + "forest 3", + "fog 1", + "force field 1", + "flower 1", + ], + } + `); +}); diff --git a/assets/js/autocomplete/__tests__/keyboard.spec.ts b/assets/js/autocomplete/__tests__/keyboard.spec.ts new file mode 100644 index 00000000..3c5a0882 --- /dev/null +++ b/assets/js/autocomplete/__tests__/keyboard.spec.ts @@ -0,0 +1,114 @@ +import { init } from './context'; + +it('supports navigation via keyboard', async () => { + const ctx = await init(); + + await ctx.setInput('f'); + + await ctx.keyDown('ArrowDown'); + + ctx.expectUi().toMatchInlineSnapshot(` + { + "input": "forest<>", + "suggestions": [ + "👉 forest 3", + "fog 1", + "force field 1", + "flower 1", + ], + } + `); + + await ctx.keyDown('ArrowDown'); + + ctx.expectUi().toMatchInlineSnapshot(` + { + "input": "fog<>", + "suggestions": [ + "forest 3", + "👉 fog 1", + "force field 1", + "flower 1", + ], + } + `); + + await ctx.keyDown('ArrowDown', { ctrlKey: true }); + + ctx.expectUi().toMatchInlineSnapshot(` + { + "input": "flower<>", + "suggestions": [ + "forest 3", + "fog 1", + "force field 1", + "👉 flower 1", + ], + } + `); + + await ctx.keyDown('ArrowUp', { ctrlKey: true }); + + ctx.expectUi().toMatchInlineSnapshot(` + { + "input": "forest<>", + "suggestions": [ + "👉 forest 3", + "fog 1", + "force field 1", + "flower 1", + ], + } + `); + + await ctx.keyDown('Enter'); + + ctx.expectUi().toMatchInlineSnapshot(` + { + "input": "forest<>", + "suggestions": [], + } + `); + + await ctx.setInput('forest, t<>, safe'); + + ctx.expectUi().toMatchInlineSnapshot(` + { + "input": "forest, t<>, safe", + "suggestions": [ + "artist:test 1", + ], + } + `); + + await ctx.keyDown('ArrowDown'); + + ctx.expectUi().toMatchInlineSnapshot(` + { + "input": "forest, artist:test<>, safe", + "suggestions": [ + "👉 artist:test 1", + ], + } + `); + + await ctx.keyDown('Escape'); + + ctx.expectUi().toMatchInlineSnapshot(` + { + "input": "forest, artist:test<>, safe", + "suggestions": [], + } + `); + + await ctx.setInput('forest, t<>, safe'); + await ctx.keyDown('ArrowDown'); + await ctx.keyDown('Enter'); + + ctx.expectUi().toMatchInlineSnapshot(` + { + "input": "forest, artist:test<>, safe", + "suggestions": [], + } + `); +}); diff --git a/assets/js/autocomplete/__tests__/server-side-completions.spec.ts b/assets/js/autocomplete/__tests__/server-side-completions.spec.ts new file mode 100644 index 00000000..fcf53eb7 --- /dev/null +++ b/assets/js/autocomplete/__tests__/server-side-completions.spec.ts @@ -0,0 +1,48 @@ +import { init } from './context'; + +it('requests server-side autocomplete if local autocomplete returns no results', async () => { + const ctx = await init(); + + await ctx.setInput('mar'); + + // 1. Request the local autocomplete index. + // 2. Request the server-side suggestions. + expect(fetch).toHaveBeenCalledTimes(2); + + ctx.expectUi().toMatchInlineSnapshot(` + { + "input": "mar<>", + "suggestions": [ + "marvelous → beautiful 30", + "mare 20", + "market 10", + ], + } + `); + + await ctx.setInput(''); + + // Make sure the response caching is insensitive to term case and leading whitespace. + await ctx.setInput('mar'); + await ctx.setInput(' mar'); + await ctx.setInput(' Mar'); + await ctx.setInput(' MAR'); + + ctx.expectUi().toMatchInlineSnapshot(` + { + "input": " MAR<>", + "suggestions": [ + "marvelous → beautiful 30", + "mare 20", + "market 10", + ], + } + `); + + expect(fetch).toHaveBeenCalledTimes(2); + + // Trailing whitespace is still significant because terms may have internal spaces. + await ctx.setInput('mar '); + + expect(fetch).toHaveBeenCalledTimes(3); +}); From 76f434b0398d3f40f8889a3d76fe3eb2faf4c137 Mon Sep 17 00:00:00 2001 From: MareStare Date: Fri, 14 Mar 2025 23:10:06 +0000 Subject: [PATCH 29/33] Revert back the event delegation suggestion because in this case we search the closest descendant, not the closest ancestor. --- assets/js/autocomplete/history/index.ts | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/assets/js/autocomplete/history/index.ts b/assets/js/autocomplete/history/index.ts index ab4ad3a5..ff5709f5 100644 --- a/assets/js/autocomplete/history/index.ts +++ b/assets/js/autocomplete/history/index.ts @@ -2,7 +2,6 @@ import { HistorySuggestion } from '../../utils/suggestions'; import { InputHistory } from './history'; import { HistoryStore } from './store'; import { AutocompletableInput } from '../input'; -import { delegate } from 'utils/events'; /** * Stores a set of histories identified by their unique IDs. @@ -39,15 +38,20 @@ export function listen() { histories.load(input.historyId); }); - delegate(document, 'submit', { - '[data-autocomplete-history-id]'(_event, target) { - const input = AutocompletableInput.fromElement(target); - if (!input || !input.hasHistory()) { - return; - } + document.addEventListener('submit', event => { + if (!(event.target instanceof HTMLFormElement)) { + return; + } - histories.load(input.historyId).write(input.snapshot.trimmedValue); - }, + const input = [...event.target.elements] + .map(elem => AutocompletableInput.fromElement(elem)) + .find(it => it !== null && it.hasHistory()); + + if (!input) { + return; + } + + histories.load(input.historyId).write(input.snapshot.trimmedValue); }); } From caa2688c734d546137225bbb21884764634d65ee Mon Sep 17 00:00:00 2001 From: MareStare Date: Fri, 14 Mar 2025 23:29:52 +0000 Subject: [PATCH 30/33] [Missing change] Adapt local autocompleter to the new API --- .../__tests__/local-autocompleter.spec.ts | 77 ++++++++++++------- assets/js/utils/local-autocompleter.ts | 37 +++++++-- 2 files changed, 78 insertions(+), 36 deletions(-) diff --git a/assets/js/utils/__tests__/local-autocompleter.spec.ts b/assets/js/utils/__tests__/local-autocompleter.spec.ts index 8ff5c01c..03963d69 100644 --- a/assets/js/utils/__tests__/local-autocompleter.spec.ts +++ b/assets/js/utils/__tests__/local-autocompleter.spec.ts @@ -3,9 +3,8 @@ import { promises } from 'fs'; import { join } from 'path'; import { TextDecoder } from 'util'; -describe('Local Autocompleter', () => { +describe('LocalAutocompleter', () => { let mockData: ArrayBuffer; - const defaultK = 5; beforeAll(async () => { const mockDataPath = join(__dirname, 'autocomplete-compiled-v2.bin'); @@ -44,59 +43,81 @@ describe('Local Autocompleter', () => { }); }); - describe('topK', () => { + describe('matchPrefix', () => { const termStem = ['f', 'o'].join(''); - let localAutocomplete: LocalAutocompleter; + function expectLocalAutocomplete(term: string, topK = 5) { + const localAutocomplete = new LocalAutocompleter(mockData); + const results = localAutocomplete.matchPrefix(term, topK); + const actual = results.map(result => { + const canonical = `${result.canonical} (${result.images})`; + return result.alias ? `${result.alias} -> ${canonical}` : canonical; + }); - beforeAll(() => { - localAutocomplete = new LocalAutocompleter(mockData); - }); + return expect(actual); + } beforeEach(() => { window.booru.hiddenTagList = []; }); it('should return suggestions for exact tag name match', () => { - const result = localAutocomplete.matchPrefix('safe', defaultK); - expect(result).toEqual([expect.objectContaining({ aliasName: 'safe', name: 'safe', imageCount: 6 })]); + expectLocalAutocomplete('safe').toMatchInlineSnapshot(` + [ + "safe (6)", + ] + `); }); - it('should return suggestion for original tag when passed an alias', () => { - const result = localAutocomplete.matchPrefix('flowers', defaultK); - expect(result).toEqual([expect.objectContaining({ aliasName: 'flowers', name: 'flower', imageCount: 1 })]); + it('should return suggestion for an alias', () => { + expectLocalAutocomplete('flowers').toMatchInlineSnapshot(` + [ + "flowers -> flower (1)", + ] + `); + }); + + it('should prefer canonical tag over an alias when both match', () => { + expectLocalAutocomplete('flo').toMatchInlineSnapshot(` + [ + "flower (1)", + ] + `); }); it('should return suggestions sorted by image count', () => { - const result = localAutocomplete.matchPrefix(termStem, defaultK); - expect(result).toEqual([ - expect.objectContaining({ aliasName: 'forest', name: 'forest', imageCount: 3 }), - expect.objectContaining({ aliasName: 'fog', name: 'fog', imageCount: 1 }), - expect.objectContaining({ aliasName: 'force field', name: 'force field', imageCount: 1 }), - ]); + expectLocalAutocomplete(termStem).toMatchInlineSnapshot(` + [ + "forest (3)", + "fog (1)", + "force field (1)", + ] + `); }); it('should return namespaced suggestions without including namespace', () => { - const result = localAutocomplete.matchPrefix('test', defaultK); - expect(result).toEqual([ - expect.objectContaining({ aliasName: 'artist:test', name: 'artist:test', imageCount: 1 }), - ]); + expectLocalAutocomplete('test').toMatchInlineSnapshot(` + [ + "artist:test (1)", + ] + `); }); it('should return only the required number of suggestions', () => { - const result = localAutocomplete.matchPrefix(termStem, 1); - expect(result).toEqual([expect.objectContaining({ aliasName: 'forest', name: 'forest', imageCount: 3 })]); + expectLocalAutocomplete(termStem, 1).toMatchInlineSnapshot(` + [ + "forest (3)", + ] + `); }); it('should NOT return suggestions associated with hidden tags', () => { window.booru.hiddenTagList = [1]; - const result = localAutocomplete.matchPrefix(termStem, defaultK); - expect(result).toEqual([]); + expectLocalAutocomplete(termStem).toMatchInlineSnapshot(`[]`); }); it('should return empty array for empty prefix', () => { - const result = localAutocomplete.matchPrefix('', defaultK); - expect(result).toEqual([]); + expectLocalAutocomplete('').toMatchInlineSnapshot(`[]`); }); }); }); diff --git a/assets/js/utils/local-autocompleter.ts b/assets/js/utils/local-autocompleter.ts index 15c460ea..f1015f0c 100644 --- a/assets/js/utils/local-autocompleter.ts +++ b/assets/js/utils/local-autocompleter.ts @@ -3,9 +3,21 @@ import { UniqueHeap } from './unique-heap'; import store from './store'; export interface Result { - aliasName: string; - name: string; - imageCount: number; + /** + * If present, then this suggestion is for a tag alias. + * If absent, then this suggestion is for the `canonical` tag name. + */ + alias?: null | string; + + /** + * The canonical name of the tag (non-alias). + */ + canonical: string; + + /** + * Number of images tagged with this tag. + */ + images: number; } /** @@ -253,10 +265,19 @@ export class LocalAutocompleter { this.scanResults(referenceToAliasIndex, namespaceMatch, hasFilteredAssociation, isAlias, results); // Convert top K from heap into result array - return results.topK(k).map((i: TagReferenceIndex) => ({ - aliasName: this.decoder.decode(this.referenceToName(i, false)), - name: this.decoder.decode(this.referenceToName(i)), - imageCount: this.getImageCount(i), - })); + return results.topK(k).map((i: TagReferenceIndex) => { + const alias = this.decoder.decode(this.referenceToName(i, false)); + const canonical = this.decoder.decode(this.referenceToName(i)); + const result: Result = { + canonical, + images: this.getImageCount(i), + }; + + if (alias !== canonical) { + result.alias = alias; + } + + return result; + }); } } From b51e2bc20541cab2e877e576452b22bfa947b5db Mon Sep 17 00:00:00 2001 From: MareStare Date: Fri, 14 Mar 2025 23:32:05 +0000 Subject: [PATCH 31/33] Remove temporary ignores for coverage --- assets/js/utils/http-client.ts | 4 ---- assets/js/utils/store.ts | 4 ---- 2 files changed, 8 deletions(-) diff --git a/assets/js/utils/http-client.ts b/assets/js/utils/http-client.ts index d04df180..a41e1f35 100644 --- a/assets/js/utils/http-client.ts +++ b/assets/js/utils/http-client.ts @@ -1,6 +1,3 @@ -// Ignoring a non-100% coverage for HTTP client for now. -// It will be 100% in https://github.com/philomena-dev/philomena/pull/453 -/* v8 ignore start */ import { retry } from './retry'; interface RequestParams extends RequestInit { @@ -97,4 +94,3 @@ function generateId(prefix: string) { return chars.join(''); } -/* v8 ignore end */ diff --git a/assets/js/utils/store.ts b/assets/js/utils/store.ts index e2c97e36..2abad439 100644 --- a/assets/js/utils/store.ts +++ b/assets/js/utils/store.ts @@ -1,6 +1,3 @@ -// Ignoring a non-100% coverage for HTTP client for now. -// It will be 100% in https://github.com/philomena-dev/philomena/pull/453 -/* v8 ignore start */ /** * localStorage utils */ @@ -81,4 +78,3 @@ export default { return lastUpdatedTime === null || Date.now() > lastUpdatedTime; }, }; -/* v8 ignore end */ From 1e0def08db5d98ed3bdc12abff7eda8be29faa14 Mon Sep 17 00:00:00 2001 From: MareStare Date: Sat, 15 Mar 2025 00:33:18 +0000 Subject: [PATCH 32/33] Fix and improve styling for different themes --- assets/css/themes/base/dark.css | 12 ++++++------ assets/css/themes/base/light.css | 12 ++++++------ assets/css/views/tags.css | 2 +- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/assets/css/themes/base/dark.css b/assets/css/themes/base/dark.css index d1bba897..3168b776 100644 --- a/assets/css/themes/base/dark.css +++ b/assets/css/themes/base/dark.css @@ -166,13 +166,13 @@ --poll-form-label-background: hsl(from $border-color h s calc(l + 8)); --tag-dropdown-hover-background: hsl(from $meta-color h s calc(l - 4)); - --autocomplete-history-color: $block-header-link-text-color - --autocomplete-history-match-color: hsl(from $block-header-link-text-color h s calc(l + 20)); + --autocomplete-history-color: hsl(from var(--block-header-link-text-color) h s calc(l)); + --autocomplete-history-match-color: hsl(from var(--block-header-link-text-color) h s calc(l + 20)); - --autocomplete-tag-color: $foreground-color; - --autocomplete-tag-match-color: hsl(from $foreground-color h s calc(l + 20)); - --autocomplete-tag-count-color: $foreground-half-color + --autocomplete-tag-color: hsl(from var(--foreground-color) h s calc(l - 5)); + --autocomplete-tag-match-color: hsl(from var(--foreground-color) h s calc(l + 20)); + --autocomplete-tag-count-color: var(--foreground-half-color); - --autocomplete-match-selected-color: hsl(from $background-color h s calc(l + 10)); + --autocomplete-match-selected-color: hsl(from var(--background-color) h s calc(l + 10)); } } diff --git a/assets/css/themes/base/light.css b/assets/css/themes/base/light.css index b62dcb05..76c2351b 100644 --- a/assets/css/themes/base/light.css +++ b/assets/css/themes/base/light.css @@ -163,13 +163,13 @@ --poll-form-label-background: hsl(from $base-color h calc(s - 16) calc(l + 36)); --tag-dropdown-hover-background: hsl(from $foreground-color h s calc(l - 10)); - --autocomplete-history-color: $block-header-link-text-color - --autocomplete-history-match-color: hsl(from $block-header-link-text-color h s calc(l + 20)); + --autocomplete-history-color: var(--block-header-link-text-color); + --autocomplete-history-match-color: hsl(from var(--block-header-link-text-color) h calc(s + 40) calc(l - 15)); - --autocomplete-tag-color: $foreground-color; - --autocomplete-tag-match-color: hsl(from $foreground-color h s calc(l + 20)); - --autocomplete-tag-count-color: $foreground-half-color + --autocomplete-tag-color: hsl(from var(--foreground-color) h s calc(l + 20)); + --autocomplete-tag-match-color: hsl(from var(--foreground-color) h s calc(l - 20)); + --autocomplete-tag-count-color: var(--foreground-half-color); - --autocomplete-match-selected-color: hsl(from $background-color h s calc(l + 10)); + --autocomplete-match-selected-color: hsl(from var(--background-color) h s calc(l + 10)); } } diff --git a/assets/css/views/tags.css b/assets/css/views/tags.css index bc3ba2fc..d4d577bd 100644 --- a/assets/css/views/tags.css +++ b/assets/css/views/tags.css @@ -61,7 +61,7 @@ } .autocomplete__item__tag { - color: var(--foreground-color); + color: var(--autocomplete-tag-color); display: flex; justify-content: space-between; white-space: pre; From b671d0dc3def5276c63cf507797d8ca311df0994 Mon Sep 17 00:00:00 2001 From: MareStare Date: Sat, 15 Mar 2025 03:57:08 +0000 Subject: [PATCH 33/33] Remove redundant relative color leftover from experiments --- assets/css/themes/base/dark.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/css/themes/base/dark.css b/assets/css/themes/base/dark.css index 3168b776..a459e5c8 100644 --- a/assets/css/themes/base/dark.css +++ b/assets/css/themes/base/dark.css @@ -166,7 +166,7 @@ --poll-form-label-background: hsl(from $border-color h s calc(l + 8)); --tag-dropdown-hover-background: hsl(from $meta-color h s calc(l - 4)); - --autocomplete-history-color: hsl(from var(--block-header-link-text-color) h s calc(l)); + --autocomplete-history-color: var(--block-header-link-text-color); --autocomplete-history-match-color: hsl(from var(--block-header-link-text-color) h s calc(l + 20)); --autocomplete-tag-color: hsl(from var(--foreground-color) h s calc(l - 5));