From c0ddf55b48a1348bb88337d70684c81d7732b94e Mon Sep 17 00:00:00 2001 From: Liam Date: Tue, 28 May 2024 22:54:45 -0400 Subject: [PATCH 01/10] Add lex stage with intermediate result generation --- assets/js/booru.js | 2 +- assets/js/match_query.ts | 8 +-- assets/js/query/__tests__/lex.spec.ts | 4 +- assets/js/query/lex.ts | 74 +++++++++++++++++++-------- assets/js/query/parse.ts | 8 +-- assets/js/utils/__tests__/tag.spec.ts | 2 +- 6 files changed, 68 insertions(+), 30 deletions(-) diff --git a/assets/js/booru.js b/assets/js/booru.js index 03d0dff8..1f963dbe 100644 --- a/assets/js/booru.js +++ b/assets/js/booru.js @@ -1,5 +1,5 @@ import { $ } from './utils/dom'; -import parseSearch from './match_query'; +import { parseSearch } from './match_query'; import store from './utils/store'; /** diff --git a/assets/js/match_query.ts b/assets/js/match_query.ts index 5ddafea3..d6142b94 100644 --- a/assets/js/match_query.ts +++ b/assets/js/match_query.ts @@ -1,5 +1,5 @@ import { defaultMatcher } from './query/matcher'; -import { generateLexArray } from './query/lex'; +import { generateLexArray, generateLexResult } from './query/lex'; import { parseTokens } from './query/parse'; import { getAstMatcherForTerm } from './query/term'; @@ -7,9 +7,11 @@ function parseWithDefaultMatcher(term: string, fuzz: number) { return getAstMatcherForTerm(term, fuzz, defaultMatcher); } -function parseSearch(query: string) { +export function parseSearch(query: string) { const tokens = generateLexArray(query, parseWithDefaultMatcher); return parseTokens(tokens); } -export default parseSearch; +export function getTermContexts(query: string) { + return generateLexResult(query, parseWithDefaultMatcher).termContexts; +} diff --git a/assets/js/query/__tests__/lex.spec.ts b/assets/js/query/__tests__/lex.spec.ts index 427f4dfb..19516a4a 100644 --- a/assets/js/query/__tests__/lex.spec.ts +++ b/assets/js/query/__tests__/lex.spec.ts @@ -170,8 +170,8 @@ describe('Lexical analysis', () => { expect(array).toEqual([noMatch, noMatch, 'or_op', noMatch, 'or_op', noMatch, 'or_op']); }); - it('should throw exception on mismatched parentheses', () => { + it('should mark error on mismatched parentheses', () => { expect(() => generateLexArray('(safe OR solo AND fluttershy', parseTerm)).toThrow('Mismatched parentheses.'); - // expect(() => generateLexArray(')bad', parseTerm)).toThrow('Mismatched parentheses.'); + // expect(() => generateLexArray(')bad', parseTerm).error).toThrow('Mismatched parentheses.'); }); }); diff --git a/assets/js/query/lex.ts b/assets/js/query/lex.ts index d234b1c8..2c950bd1 100644 --- a/assets/js/query/lex.ts +++ b/assets/js/query/lex.ts @@ -22,10 +22,18 @@ const tokenList: Token[] = [ export type ParseTerm = (term: string, fuzz: number, boost: number) => AstMatcher; -export function generateLexArray(searchStr: string, parseTerm: ParseTerm): TokenList { +export type Range = [number, number]; +export type TermContext = [Range, string]; + +export interface LexResult { + tokenList: TokenList, + termContexts: TermContext[], + error: ParseError | null +} + +export function generateLexResult(searchStr: string, parseTerm: ParseTerm): LexResult { const opQueue: string[] = [], - groupNegate: boolean[] = [], - tokenStack: TokenList = []; + groupNegate: boolean[] = []; let searchTerm: string | null = null; let boostFuzzStr = ''; @@ -35,10 +43,25 @@ export function generateLexArray(searchStr: string, parseTerm: ParseTerm): Token let fuzz = 0; let lparenCtr = 0; - const pushTerm = () => { + let termIndex = 0; + let index = 0; + + const ret: LexResult = { + tokenList: [], + termContexts: [], + error: null + }; + + const beginTerm = (token: string) => { + searchTerm = token; + termIndex = index; + }; + + const endTerm = () => { if (searchTerm !== null) { // Push to stack. - tokenStack.push(parseTerm(searchTerm, fuzz, boost)); + ret.tokenList.push(parseTerm(searchTerm, fuzz, boost)); + ret.termContexts.push([[termIndex, termIndex + searchTerm.length], searchTerm]); // Reset term and options data. boost = 1; fuzz = 0; @@ -48,7 +71,7 @@ export function generateLexArray(searchStr: string, parseTerm: ParseTerm): Token } if (negate) { - tokenStack.push('not_op'); + ret.tokenList.push('not_op'); negate = false; } }; @@ -64,19 +87,19 @@ export function generateLexArray(searchStr: string, parseTerm: ParseTerm): Token const token = match[0]; if (searchTerm !== null && (['and_op', 'or_op'].indexOf(tokenName) !== -1 || tokenName === 'rparen' && lparenCtr === 0)) { - pushTerm(); + endTerm(); } switch (tokenName) { case 'and_op': while (opQueue[0] === 'and_op') { - tokenStack.push(assertNotUndefined(opQueue.shift())); + ret.tokenList.push(assertNotUndefined(opQueue.shift())); } opQueue.unshift('and_op'); break; case 'or_op': while (opQueue[0] === 'and_op' || opQueue[0] === 'or_op') { - tokenStack.push(assertNotUndefined(opQueue.shift())); + ret.tokenList.push(assertNotUndefined(opQueue.shift())); } opQueue.unshift('or_op'); break; @@ -113,10 +136,10 @@ export function generateLexArray(searchStr: string, parseTerm: ParseTerm): Token if (op === 'lparen') { break; } - tokenStack.push(op); + ret.tokenList.push(op); } if (groupNegate.length > 0 && groupNegate.pop()) { - tokenStack.push('not_op'); + ret.tokenList.push('not_op'); } } break; @@ -128,7 +151,7 @@ export function generateLexArray(searchStr: string, parseTerm: ParseTerm): Token boostFuzzStr += token; } else { - searchTerm = token; + beginTerm(token); } break; case 'boost': @@ -137,7 +160,7 @@ export function generateLexArray(searchStr: string, parseTerm: ParseTerm): Token boostFuzzStr += token; } else { - searchTerm = token; + beginTerm(token); } break; case 'quoted_lit': @@ -145,7 +168,7 @@ export function generateLexArray(searchStr: string, parseTerm: ParseTerm): Token searchTerm += token; } else { - searchTerm = token; + beginTerm(token); } break; case 'word': @@ -159,7 +182,7 @@ export function generateLexArray(searchStr: string, parseTerm: ParseTerm): Token searchTerm += token; } else { - searchTerm = token; + beginTerm(token); } break; default: @@ -171,6 +194,7 @@ export function generateLexArray(searchStr: string, parseTerm: ParseTerm): Token // Truncate string and restart the token tests. localSearchStr = localSearchStr.substring(token.length); + index += token.length; // Break since we have found a match. break; @@ -178,14 +202,24 @@ export function generateLexArray(searchStr: string, parseTerm: ParseTerm): Token } // Append final tokens to the stack. - pushTerm(); + endTerm(); if (opQueue.indexOf('rparen') !== -1 || opQueue.indexOf('lparen') !== -1) { - throw new ParseError('Mismatched parentheses.'); + ret.error = new ParseError('Mismatched parentheses.'); } - // Concatenatte remaining operators to the token stack. - tokenStack.push(...opQueue); + // Concatenate remaining operators to the token stack. + ret.tokenList.push(...opQueue); - return tokenStack; + return ret; +} + +export function generateLexArray(searchStr: string, parseTerm: ParseTerm): TokenList { + const ret = generateLexResult(searchStr, parseTerm); + + if (ret.error) { + throw ret.error; + } + + return ret.tokenList; } diff --git a/assets/js/query/parse.ts b/assets/js/query/parse.ts index f5a09fcc..fea7659b 100644 --- a/assets/js/query/parse.ts +++ b/assets/js/query/parse.ts @@ -4,9 +4,11 @@ import { AstMatcher, ParseError, TokenList } from './types'; export function parseTokens(lexicalArray: TokenList): AstMatcher { const operandStack: AstMatcher[] = []; - lexicalArray.forEach((token, i) => { + for (let i = 0; i < lexicalArray.length; i += 1) { + const token = lexicalArray[i]; + if (token === 'not_op') { - return; + continue; } let intermediate: AstMatcher; @@ -36,7 +38,7 @@ export function parseTokens(lexicalArray: TokenList): AstMatcher { else { operandStack.push(intermediate); } - }); + } if (operandStack.length > 1) { throw new ParseError('Missing operator.'); diff --git a/assets/js/utils/__tests__/tag.spec.ts b/assets/js/utils/__tests__/tag.spec.ts index 44bc565f..61a196b8 100644 --- a/assets/js/utils/__tests__/tag.spec.ts +++ b/assets/js/utils/__tests__/tag.spec.ts @@ -1,7 +1,7 @@ import { displayTags, getHiddenTags, getSpoileredTags, imageHitsComplex, imageHitsTags, TagData } from '../tag'; import { mockStorage } from '../../../test/mock-storage'; import { getRandomArrayItem } from '../../../test/randomness'; -import parseSearch from '../../match_query'; +import { parseSearch } from '../../match_query'; import { SpoilerType } from '../../../types/booru-object'; describe('Tag utilities', () => { From 8c988b002ded94b64a11aa9ca84027973194f0cd Mon Sep 17 00:00:00 2001 From: KoloMl Date: Wed, 29 May 2024 23:57:36 +0400 Subject: [PATCH 02/10] Support search field autocompletion, enabled autocomplete for header --- assets/js/autocomplete.js | 71 +++++++++++++++++-- .../templates/layout/_header.html.slime | 2 +- 2 files changed, 67 insertions(+), 6 deletions(-) diff --git a/assets/js/autocomplete.js b/assets/js/autocomplete.js index 1844dda5..4d15b860 100644 --- a/assets/js/autocomplete.js +++ b/assets/js/autocomplete.js @@ -4,9 +4,19 @@ import { LocalAutocompleter } from './utils/local-autocompleter'; import { handleError } from './utils/requests'; +import { getTermContexts } from "./match_query"; const cache = {}; -let inputField, originalTerm; +/** @type {HTMLInputElement} */ +let inputField, + /** @type {string} */ + originalTerm, + /** @type {string} */ + originalQuery, + /** @type {TermContext[]} */ + searchTokens, + /** @type {TermContext} */ + selectedTerm; function removeParent() { const parent = document.querySelector('.autocomplete'); @@ -18,13 +28,37 @@ function removeSelected() { if (selected) selected.classList.remove('autocomplete__item--selected'); } +function isSearchField() { + return inputField && inputField.name === 'q'; +} + +function restoreOriginalValue() { + inputField.value = isSearchField() ? originalQuery : originalTerm; +} + +function applySelectedValue(selection) { + if (!isSearchField()) { + inputField.value = selection; + return; + } + + if (!selectedTerm) { + return; + } + + const [startIndex, endIndex] = selectedTerm[0]; + inputField.value = originalQuery.slice(0, startIndex) + selection + originalQuery.slice(endIndex); + inputField.setSelectionRange(startIndex + selection.length, startIndex + selection.length); + inputField.focus(); +} + function changeSelected(firstOrLast, current, sibling) { if (current && sibling) { // if the currently selected item has a sibling, move selection to it current.classList.remove('autocomplete__item--selected'); sibling.classList.add('autocomplete__item--selected'); } else if (current) { // if the next keypress will take the user outside the list, restore the unautocompleted term - inputField.value = originalTerm; + restoreOriginalValue(); removeSelected(); } else if (firstOrLast) { // if no item in the list is selected, select the first or last @@ -37,12 +71,15 @@ function keydownHandler(event) { firstItem = document.querySelector('.autocomplete__item:first-of-type'), lastItem = document.querySelector('.autocomplete__item:last-of-type'); + // Prevent submission of the search field when Enter was hit + if (event.keyCode === 13 && isSearchField() && selected) event.preventDefault(); // Enter + if (event.keyCode === 38) changeSelected(lastItem, selected, selected && selected.previousSibling); // ArrowUp if (event.keyCode === 40) changeSelected(firstItem, selected, selected && selected.nextSibling); // ArrowDown if (event.keyCode === 13 || event.keyCode === 27 || event.keyCode === 188) removeParent(); // Enter || Esc || Comma if (event.keyCode === 38 || event.keyCode === 40) { // ArrowUp || ArrowDown const newSelected = document.querySelector('.autocomplete__item--selected'); - if (newSelected) inputField.value = newSelected.dataset.value; + if (newSelected) applySelectedValue(newSelected.dataset.value); event.preventDefault(); } } @@ -64,7 +101,7 @@ function createItem(list, suggestion) { }); item.addEventListener('click', () => { - inputField.value = item.dataset.value; + applySelectedValue(item.dataset.value); inputField.dispatchEvent( new CustomEvent('autocomplete', { detail: { @@ -122,6 +159,17 @@ function getSuggestions(term) { return fetch(`${inputField.dataset.acSource}${term}`).then(response => response.json()); } +function getSelectedTerm() { + if (!inputField || !originalQuery) { + return null; + } + + const selectionIndex = Math.min(inputField.selectionStart, inputField.selectionEnd); + const terms = getTermContexts(originalQuery); + + return terms.find(([range]) => range[0] < selectionIndex && range[1] >= selectionIndex); +} + function listenAutocomplete() { let timeout; @@ -138,7 +186,20 @@ function listenAutocomplete() { if (localAc !== null && 'ac' in event.target.dataset) { inputField = event.target; - originalTerm = `${inputField.value}`.toLowerCase(); + + if (isSearchField()) { + originalQuery = inputField.value; + selectedTerm = getSelectedTerm(); + + // We don't need to run auto-completion if user is not selecting tag at all + if (!selectedTerm) { + return; + } + + originalTerm = selectedTerm[1]; + } else { + originalTerm = `${inputField.value}`.toLowerCase(); + } const suggestions = localAc.topK(originalTerm, 5).map(({ name, imageCount }) => ({ label: `${name} (${imageCount})`, value: name })); diff --git a/lib/philomena_web/templates/layout/_header.html.slime b/lib/philomena_web/templates/layout/_header.html.slime index 23935c9c..ea268bf3 100644 --- a/lib/philomena_web/templates/layout/_header.html.slime +++ b/lib/philomena_web/templates/layout/_header.html.slime @@ -12,7 +12,7 @@ header.header i.fa.fa-upload = form_for @conn, Routes.search_path(@conn, :index), [method: "get", class: "header__search flex flex--no-wrap flex--centered", enforce_utf8: false], fn f -> - input.input.header__input.header__input--search#q name="q" title="For terms all required, separate with ',' or 'AND'; also supports 'OR' for optional terms and '-' or 'NOT' for negation. Search with a blank query for more options or click the ? for syntax help." value=@conn.params["q"] placeholder="Search" autocapitalize="none" + input.input.header__input.header__input--search#q name="q" title="For terms all required, separate with ',' or 'AND'; also supports 'OR' for optional terms and '-' or 'NOT' for negation. Search with a blank query for more options or click the ? for syntax help." value=@conn.params["q"] placeholder="Search" autocapitalize="none" autocomplete="off" data-ac="true" data-ac-min-length="3" data-ac-source="/autocomplete/tags?term=" = if present?(@conn.params["sf"]) do input type="hidden" name="sf" value=@conn.params["sf"] From f31fcf86b363eb0bf51dcd7e446bd829ca10751f Mon Sep 17 00:00:00 2001 From: KoloMl Date: Thu, 30 May 2024 00:32:03 +0400 Subject: [PATCH 03/10] Close the autocomplete window once user moved outside current term --- assets/js/autocomplete.js | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/assets/js/autocomplete.js b/assets/js/autocomplete.js index 4d15b860..4a6862ef 100644 --- a/assets/js/autocomplete.js +++ b/assets/js/autocomplete.js @@ -71,8 +71,20 @@ function keydownHandler(event) { firstItem = document.querySelector('.autocomplete__item:first-of-type'), lastItem = document.querySelector('.autocomplete__item:last-of-type'); - // Prevent submission of the search field when Enter was hit - if (event.keyCode === 13 && isSearchField() && selected) event.preventDefault(); // Enter + if (isSearchField()) { + // Prevent submission of the search field when Enter was hit + if (selected && event.keyCode === 13) event.preventDefault(); // Enter + + // Close autocompletion popup when text cursor is outside current tag + if (selectedTerm && firstItem && (event.keyCode === 37 || event.keyCode === 39)) { // ArrowLeft || ArrowRight + requestAnimationFrame(() => { + const selectionIndex = Math.min(inputField.selectionStart, inputField.selectionEnd); + const [startIndex, endIndex] = selectedTerm[0]; + + if (startIndex > selectionIndex || endIndex < selectionIndex) removeParent(); + }) + } + } if (event.keyCode === 38) changeSelected(lastItem, selected, selected && selected.previousSibling); // ArrowUp if (event.keyCode === 40) changeSelected(firstItem, selected, selected && selected.nextSibling); // ArrowDown From f1aec2fd581ddd8f26ee3c4d85c0e6057fa5122f Mon Sep 17 00:00:00 2001 From: KoloMl Date: Thu, 30 May 2024 01:37:35 +0400 Subject: [PATCH 04/10] Close autocomplete window when selection moved outside active term by click --- assets/js/autocomplete.js | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/assets/js/autocomplete.js b/assets/js/autocomplete.js index 4a6862ef..392af624 100644 --- a/assets/js/autocomplete.js +++ b/assets/js/autocomplete.js @@ -66,6 +66,13 @@ function changeSelected(firstOrLast, current, sibling) { } } +function isSelectionOutsideCurrentTerm() { + const selectionIndex = Math.min(inputField.selectionStart, inputField.selectionEnd); + const [startIndex, endIndex] = selectedTerm[0]; + + return startIndex > selectionIndex || endIndex < selectionIndex; +} + function keydownHandler(event) { const selected = document.querySelector('.autocomplete__item--selected'), firstItem = document.querySelector('.autocomplete__item:first-of-type'), @@ -78,10 +85,7 @@ function keydownHandler(event) { // Close autocompletion popup when text cursor is outside current tag if (selectedTerm && firstItem && (event.keyCode === 37 || event.keyCode === 39)) { // ArrowLeft || ArrowRight requestAnimationFrame(() => { - const selectionIndex = Math.min(inputField.selectionStart, inputField.selectionEnd); - const [startIndex, endIndex] = selectedTerm[0]; - - if (startIndex > selectionIndex || endIndex < selectionIndex) removeParent(); + if (isSelectionOutsideCurrentTerm()) removeParent(); }) } } @@ -247,6 +251,7 @@ function listenAutocomplete() { // If there's a click outside the inputField, remove autocomplete document.addEventListener('click', event => { if (event.target && event.target !== inputField) removeParent(); + if (event.target === inputField && isSearchField() && isSelectionOutsideCurrentTerm()) removeParent(); }); function fetchLocalAutocomplete(event) { From 3f2e887aec215c28085fa57547840ef4b4a4c7f4 Mon Sep 17 00:00:00 2001 From: KoloMl Date: Thu, 30 May 2024 21:55:41 +0400 Subject: [PATCH 05/10] Fixed issues marked by linter --- assets/js/autocomplete.js | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/assets/js/autocomplete.js b/assets/js/autocomplete.js index 392af624..9ac4c2b8 100644 --- a/assets/js/autocomplete.js +++ b/assets/js/autocomplete.js @@ -4,19 +4,17 @@ import { LocalAutocompleter } from './utils/local-autocompleter'; import { handleError } from './utils/requests'; -import { getTermContexts } from "./match_query"; +import { getTermContexts } from './match_query'; const cache = {}; /** @type {HTMLInputElement} */ let inputField, - /** @type {string} */ - originalTerm, - /** @type {string} */ - originalQuery, - /** @type {TermContext[]} */ - searchTokens, - /** @type {TermContext} */ - selectedTerm; + /** @type {string} */ + originalTerm, + /** @type {string} */ + originalQuery, + /** @type {TermContext} */ + selectedTerm; function removeParent() { const parent = document.querySelector('.autocomplete'); @@ -86,7 +84,7 @@ function keydownHandler(event) { if (selectedTerm && firstItem && (event.keyCode === 37 || event.keyCode === 39)) { // ArrowLeft || ArrowRight requestAnimationFrame(() => { if (isSelectionOutsideCurrentTerm()) removeParent(); - }) + }); } } @@ -213,7 +211,8 @@ function listenAutocomplete() { } originalTerm = selectedTerm[1]; - } else { + } + else { originalTerm = `${inputField.value}`.toLowerCase(); } From 9dd26f2f876afe9ea9a06543f82c195e2243550e Mon Sep 17 00:00:00 2001 From: KoloMl Date: Sun, 2 Jun 2024 19:58:01 +0400 Subject: [PATCH 06/10] Added separate property to control autocompletion mode This is better than using hardcoded field name. --- assets/js/autocomplete.js | 2 +- lib/philomena_web/templates/layout/_header.html.slime | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/assets/js/autocomplete.js b/assets/js/autocomplete.js index 9ac4c2b8..28c0f409 100644 --- a/assets/js/autocomplete.js +++ b/assets/js/autocomplete.js @@ -27,7 +27,7 @@ function removeSelected() { } function isSearchField() { - return inputField && inputField.name === 'q'; + return inputField && inputField.dataset.acMode === 'search'; } function restoreOriginalValue() { diff --git a/lib/philomena_web/templates/layout/_header.html.slime b/lib/philomena_web/templates/layout/_header.html.slime index ea268bf3..406728ff 100644 --- a/lib/philomena_web/templates/layout/_header.html.slime +++ b/lib/philomena_web/templates/layout/_header.html.slime @@ -12,7 +12,7 @@ header.header i.fa.fa-upload = form_for @conn, Routes.search_path(@conn, :index), [method: "get", class: "header__search flex flex--no-wrap flex--centered", enforce_utf8: false], fn f -> - input.input.header__input.header__input--search#q name="q" title="For terms all required, separate with ',' or 'AND'; also supports 'OR' for optional terms and '-' or 'NOT' for negation. Search with a blank query for more options or click the ? for syntax help." value=@conn.params["q"] placeholder="Search" autocapitalize="none" autocomplete="off" data-ac="true" data-ac-min-length="3" data-ac-source="/autocomplete/tags?term=" + input.input.header__input.header__input--search#q name="q" title="For terms all required, separate with ',' or 'AND'; also supports 'OR' for optional terms and '-' or 'NOT' for negation. Search with a blank query for more options or click the ? for syntax help." value=@conn.params["q"] placeholder="Search" autocapitalize="none" autocomplete="off" data-ac="true" data-ac-min-length="3" data-ac-source="/autocomplete/tags?term=" data-ac-mode="search" = if present?(@conn.params["sf"]) do input type="hidden" name="sf" value=@conn.params["sf"] From 7fa141bb54f20531c940937cb0230893d739d6b8 Mon Sep 17 00:00:00 2001 From: KoloMl Date: Sun, 2 Jun 2024 19:59:06 +0400 Subject: [PATCH 07/10] Increased the suggestions count to 10 for search fields specifically Default 5 entries feel not enough for search field --- assets/js/autocomplete.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/assets/js/autocomplete.js b/assets/js/autocomplete.js index 28c0f409..b9ca92c5 100644 --- a/assets/js/autocomplete.js +++ b/assets/js/autocomplete.js @@ -200,10 +200,12 @@ function listenAutocomplete() { if (localAc !== null && 'ac' in event.target.dataset) { inputField = event.target; + let suggestionsCount = 5; if (isSearchField()) { originalQuery = inputField.value; selectedTerm = getSelectedTerm(); + suggestionsCount = 10; // We don't need to run auto-completion if user is not selecting tag at all if (!selectedTerm) { @@ -216,7 +218,7 @@ function listenAutocomplete() { originalTerm = `${inputField.value}`.toLowerCase(); } - const suggestions = localAc.topK(originalTerm, 5).map(({ name, imageCount }) => ({ label: `${name} (${imageCount})`, value: name })); + const suggestions = localAc.topK(originalTerm, suggestionsCount).map(({ name, imageCount }) => ({ label: `${name} (${imageCount})`, value: name })); if (suggestions.length) { return showAutocomplete(suggestions, originalTerm, event.target); From 1a7d59cb593c35908f59f6435a5a21a3654114ac Mon Sep 17 00:00:00 2001 From: KoloMl Date: Sun, 2 Jun 2024 20:40:09 +0400 Subject: [PATCH 08/10] Added the local setting to disable auto-completion --- assets/js/autocomplete.js | 12 ++++++++++++ lib/philomena_web/controllers/setting_controller.ex | 1 + lib/philomena_web/templates/setting/edit.html.slime | 4 ++++ 3 files changed, 17 insertions(+) diff --git a/assets/js/autocomplete.js b/assets/js/autocomplete.js index b9ca92c5..978c7cab 100644 --- a/assets/js/autocomplete.js +++ b/assets/js/autocomplete.js @@ -5,6 +5,7 @@ import { LocalAutocompleter } from './utils/local-autocompleter'; import { handleError } from './utils/requests'; import { getTermContexts } from './match_query'; +import store from './utils/store'; const cache = {}; /** @type {HTMLInputElement} */ @@ -184,6 +185,15 @@ function getSelectedTerm() { return terms.find(([range]) => range[0] < selectionIndex && range[1] >= selectionIndex); } +function toggleSearchAutocomplete() { + if (!store.get('disable_search_ac')) return; + + for (const searchField of document.querySelectorAll('input[data-ac-mode=search]')) { + searchField.removeAttribute('data-ac'); + searchField.autocomplete = 'on'; + } +} + function listenAutocomplete() { let timeout; @@ -268,6 +278,8 @@ function listenAutocomplete() { .then(buf => localAc = new LocalAutocompleter(buf)); } } + + toggleSearchAutocomplete(); } export { listenAutocomplete }; diff --git a/lib/philomena_web/controllers/setting_controller.ex b/lib/philomena_web/controllers/setting_controller.ex index 7c3d6db9..ce8aec3d 100644 --- a/lib/philomena_web/controllers/setting_controller.ex +++ b/lib/philomena_web/controllers/setting_controller.ex @@ -45,6 +45,7 @@ defmodule PhilomenaWeb.SettingController do |> 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, "disable_search_ac", "disable_search_ac") end defp set_cookie(conn, params, param_name, cookie_name) do diff --git a/lib/philomena_web/templates/setting/edit.html.slime b/lib/philomena_web/templates/setting/edit.html.slime index 92e13038..fd6677bb 100644 --- a/lib/philomena_web/templates/setting/edit.html.slime +++ b/lib/philomena_web/templates/setting/edit.html.slime @@ -174,6 +174,10 @@ h1 Content Settings => label f, :chan_nsfw, "Show NSFW channels" => checkbox f, :chan_nsfw, checked: @conn.cookies["chan_nsfw"] == "true" .fieldlabel: i Show streams marked as NSFW on the channels page. + .field + => label f, :disable_search_ac, "Disable search auto-completion" + => checkbox f, :disable_search_ac, checked: @conn.cookies["disable_search_ac"] === "true" + .fieldlabel: i Disable the auto-completion of tags in the search fields. This will bring back default browser's behaviour. = if staff?(@conn.assigns.current_user) do .field => label f, :hide_staff_tools From 8d2c0824133ad13fc02418b9aa1a25cfa6c52bd5 Mon Sep 17 00:00:00 2001 From: KoloMl Date: Mon, 3 Jun 2024 17:48:42 +0400 Subject: [PATCH 09/10] Disable server-side autocompletion when `acSource` is not set --- assets/js/autocomplete.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/assets/js/autocomplete.js b/assets/js/autocomplete.js index 978c7cab..758754dd 100644 --- a/assets/js/autocomplete.js +++ b/assets/js/autocomplete.js @@ -171,6 +171,8 @@ function showAutocomplete(suggestions, fetchedTerm, targetInput) { } function getSuggestions(term) { + // In case source URL was not given at all, do not try sending the request. + if (!inputField.dataset.acSource) return []; return fetch(`${inputField.dataset.acSource}${term}`).then(response => response.json()); } @@ -241,9 +243,9 @@ function listenAutocomplete() { originalTerm = inputField.value; const fetchedTerm = inputField.value; - const {ac, acMinLength} = inputField.dataset; + const {ac, acMinLength, acSource} = inputField.dataset; - if (ac && (fetchedTerm.length >= acMinLength)) { + if (ac && acSource && (fetchedTerm.length >= acMinLength)) { if (cache[fetchedTerm]) { showAutocomplete(cache[fetchedTerm], fetchedTerm, event.target); } From ac12837941a675d82ae0323ce2e44a411e2c5bdf Mon Sep 17 00:00:00 2001 From: KoloMl Date: Mon, 3 Jun 2024 17:49:01 +0400 Subject: [PATCH 10/10] Disable server-side autocompletion for search field --- lib/philomena_web/templates/layout/_header.html.slime | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/philomena_web/templates/layout/_header.html.slime b/lib/philomena_web/templates/layout/_header.html.slime index 9a4d4125..60aa3b6f 100644 --- a/lib/philomena_web/templates/layout/_header.html.slime +++ b/lib/philomena_web/templates/layout/_header.html.slime @@ -12,7 +12,7 @@ header.header i.fa.fa-upload = form_for @conn, ~p"/search", [method: "get", class: "header__search flex flex--no-wrap flex--centered", enforce_utf8: false], fn f -> - input.input.header__input.header__input--search#q name="q" title="For terms all required, separate with ',' or 'AND'; also supports 'OR' for optional terms and '-' or 'NOT' for negation. Search with a blank query for more options or click the ? for syntax help." value=@conn.params["q"] placeholder="Search" autocapitalize="none" autocomplete="off" data-ac="true" data-ac-min-length="3" data-ac-source="/autocomplete/tags?term=" data-ac-mode="search" + input.input.header__input.header__input--search#q name="q" title="For terms all required, separate with ',' or 'AND'; also supports 'OR' for optional terms and '-' or 'NOT' for negation. Search with a blank query for more options or click the ? for syntax help." value=@conn.params["q"] placeholder="Search" autocapitalize="none" autocomplete="off" data-ac="true" data-ac-min-length="3" data-ac-mode="search" = if present?(@conn.params["sf"]) do input type="hidden" name="sf" value=@conn.params["sf"]