From f863b3a4757ecd123f63878e70b3b011fde0e3dc Mon Sep 17 00:00:00 2001 From: KoloMl Date: Wed, 29 May 2024 00:19:22 +0400 Subject: [PATCH] Implementation of the search field autocomplete feature --- manifest.json | 11 + src/content/header.js | 7 + src/lib/booru/search/QueryLexer.js | 266 ++++++++++++++++++++++++ src/lib/components/SearchWrapper.js | 186 +++++++++++++++++ src/lib/components/SiteHeaderWrapper.js | 23 ++ src/styles/content/header.scss | 11 + 6 files changed, 504 insertions(+) create mode 100644 src/content/header.js create mode 100644 src/lib/booru/search/QueryLexer.js create mode 100644 src/lib/components/SearchWrapper.js create mode 100644 src/lib/components/SiteHeaderWrapper.js create mode 100644 src/styles/content/header.scss diff --git a/manifest.json b/manifest.json index 55ae042..15f0880 100644 --- a/manifest.json +++ b/manifest.json @@ -19,6 +19,17 @@ "css": [ "src/styles/content/listing.scss" ] + }, + { + "matches": [ + "*://*.furbooru.org/*" + ], + "js": [ + "src/content/header.js" + ], + "css": [ + "src/styles/content/header.scss" + ] } ], "action": { diff --git a/src/content/header.js b/src/content/header.js new file mode 100644 index 0000000..b9f3cec --- /dev/null +++ b/src/content/header.js @@ -0,0 +1,7 @@ +import {initializeSiteHeader} from "$lib/components/SiteHeaderWrapper.js"; + +const siteHeader = document.querySelector('.header'); + +if (siteHeader) { + initializeSiteHeader(siteHeader); +} diff --git a/src/lib/booru/search/QueryLexer.js b/src/lib/booru/search/QueryLexer.js new file mode 100644 index 0000000..07e9c22 --- /dev/null +++ b/src/lib/booru/search/QueryLexer.js @@ -0,0 +1,266 @@ +export class Token { + index; + value; + + constructor(index, value) { + this.index = index; + this.value = value; + } +} + + +export class AndToken extends Token { +} + +export class NotToken extends Token { +} + +export class OrToken extends Token { +} + +export class GroupStartToken extends Token { +} + +export class GroupEndToken extends Token { +} + +export class BoostToken extends Token { +} + +export class QuotedTermToken extends Token { + /** + * @type {string} + */ + #quotedValue; + + constructor(index, value, quotedValue) { + super(index, value); + + this.#quotedValue = quotedValue; + } + + get decodedValue() { + return QuotedTermToken.decode(this.#quotedValue); + } + + /** + * @param {string} value + * @return {string} + */ + static decode(value) { + return value.replace(/\\([\\"])/g, "$1"); + } + + /** + * @param {string} value + * @return {string} + */ + static encode(value) { + return value.replace(/[\\"]/g, "\\$&"); + } +} + +export class TermToken extends Token { +} + +/** + * Search query tokenizer. Should mostly work for the cases of parsing and finding the selected term for + * auto-completion. Follows the rules described in the Philomena booru engine. + */ +export class QueryLexer { + /** + * The original value to be parsed. + * @type {string} + */ + #value; + + /** + * Current position of the parser in the value. + * @type {number} + */ + #index = 0; + + /** + * @param {string} value + */ + constructor(value) { + this.#value = value; + } + + /** + * Parse the query and get the list of tokens. + * + * @return {Token[]} List of tokens. + */ + parse() { + /** @type {Token[]} */ + const tokens = []; + + /** + * @type {{match: RegExpMatchArray|null}} + */ + const result = {}; + + let dirtyText; + + while (this.#index < this.#value.length) { + if (this.#value[this.#index] === QueryLexer.#commaCharacter) { + tokens.push(new AndToken(this.#index, this.#value[this.#index])); + this.#index++; + continue; + } + + if (this.#match(QueryLexer.#negotiationOperator, result)) { + tokens.push(new NotToken(this.#index, result.match[0])); + this.#index += result.match[0].length; + continue; + } + + if (this.#match(QueryLexer.#andOperator, result)) { + tokens.push(new AndToken(this.#index, result.match[0])); + this.#index += result.match[0].length; + continue; + } + + if (this.#match(QueryLexer.#orOperator, result)) { + tokens.push(new OrToken(this.#index, result.match[0])); + this.#index += result.match[0].length; + continue; + } + + if (this.#match(QueryLexer.#notOperator, result)) { + tokens.push(new NotToken(this.#index, result.match[0])); + this.#index += result.match[0].length; + continue; + } + + if (this.#value[this.#index] === QueryLexer.#bracketsOpenCharacter) { + tokens.push(new GroupStartToken(this.#index, this.#value[this.#index])); + this.#index++; + continue; + } + + if (this.#value[this.#index] === QueryLexer.#bracketsCloseCharacter) { + tokens.push(new GroupEndToken(this.#index, this.#value[this.#index])); + this.#index++; + continue; + } + + if (this.#match(QueryLexer.#boostOperator, result)) { + tokens.push(new BoostToken(this.#index, result.match[0])); + this.#index += result.match[0].length; + continue; + } + + if (this.#match(QueryLexer.#whitespaces, result)) { + this.#index += result.match[0].length; + continue; + } + + if (this.#match(QueryLexer.#quotedText, result)) { + tokens.push(new QuotedTermToken(this.#index, result.match[0], result.match[1])); + this.#index += result.match[0].length; + continue; + } + + dirtyText = this.#parseDirtyText(this.#index); + + if (dirtyText) { + tokens.push(new TermToken(this.#index, dirtyText)); + this.#index += dirtyText.length; + continue; + } + + break; + } + + return tokens; + } + + /** + * Match the provided regular expression on the string with the current parser position. + * + * @param {RegExp} targetRegExp Target RegExp to parse with. + * @param {{match: any}} [resultCarrier] Object for passing the results into. + * + * @return {boolean} Is there a match? + */ + #match(targetRegExp, resultCarrier = {}) { + return this.#matchAt(targetRegExp, this.#index, resultCarrier); + } + + /** + * Match the provided regular expression in the string with the specific index. + * + * @param {RegExp} targetRegExp Target RegExp to parse with. + * @param {number} index Index to match the expression from. + * @param {{match: any}} [resultCarrier] Object for passing the results into. + * + * @return {boolean} Is there a match? + */ + #matchAt(targetRegExp, index, resultCarrier = {}) { + targetRegExp.lastIndex = index; + resultCarrier.match = this.#value.match(targetRegExp); + + return resultCarrier.match !== null; + } + + /** + * Parse the dirty text. + * + * @param {number} index Index to start the parsing from. + * + * @return {string} Matched text. + */ + #parseDirtyText(index) { + let resultValue = ''; + + /** @type {{match: RegExpMatchArray|null}} */ + const result = {match: null}; + + // Loop over + while (index < this.#value.length) { + // If the stop word found then return the value. + if (this.#matchAt(QueryLexer.#dirtyTextStopWords, index)) { + break; + } + + if (this.#matchAt(QueryLexer.#dirtyTextContent, index, result)) { + resultValue += result.match[0]; + index += result.match[0].length; + continue; + } + + if (this.#value[index] === QueryLexer.#bracketsOpenCharacter) { + let bracketsContent = QueryLexer.#bracketsOpenCharacter + this.#parseDirtyText(index + 1); + + if (this.#value[index + bracketsContent.length + 1] === QueryLexer.#bracketsCloseCharacter) { + bracketsContent += QueryLexer.#bracketsCloseCharacter; + } + + // There could be an error about brackets not being open + + resultValue += bracketsContent; + index += bracketsContent.length; + continue; + } + + break; + } + + return resultValue; + } + + static #commaCharacter = ','; + static #negotiationOperator = /[!-]/y; + static #andOperator = /\s+(?:AND|&&)\s+/y; + static #orOperator = /\s+(?:OR|\|\|)\s+/y; + static #notOperator = /NOT\s+/y; + static #bracketsOpenCharacter = "("; + static #bracketsCloseCharacter = ")"; + static #boostOperator = /\^[+-]?\d+(?:\.\d+)?/y; + static #whitespaces = /\s+/y; + static #quotedText = /"((?:\\.|[^\\"])+)"/y; + static #dirtyTextStopWords = /,|\s+(?:AND|&&|OR|\|\|)\s+|\s+(?:\)|\^[+-]?\d+(?:\.\d+)?)/y; + static #dirtyTextContent = /\\.|[^()]/y; +} diff --git a/src/lib/components/SearchWrapper.js b/src/lib/components/SearchWrapper.js new file mode 100644 index 0000000..d845589 --- /dev/null +++ b/src/lib/components/SearchWrapper.js @@ -0,0 +1,186 @@ +import {BaseComponent} from "$lib/components/base/BaseComponent.js"; +import {QueryLexer, QuotedTermToken, TermToken, Token} from "$lib/booru/search/QueryLexer.js"; + +export class SearchWrapper extends BaseComponent { + /** @type {HTMLInputElement|null} */ + #searchField = null; + /** @type {HTMLInputElement|null} */ + #autoCompleteField = null; + /** @type {string|null} */ + #lastParsedSearchValue = null; + /** @type {Token[]} */ + #cachedParsedQuery = []; + + build() { + this.container.classList.add('header__search--completable'); + + this.#searchField = this.container.querySelector('input[name=q]'); + this.#searchField.autocomplete = 'off'; // Browser's auto-complete will get in the way! + + const autoCompleteField = document.createElement('input'); + autoCompleteField.dataset.ac = 'true'; + autoCompleteField.dataset.acMinLength = '3'; + autoCompleteField.dataset.acSource = '/autocomplete/tags?term='; + autoCompleteField.classList.add('search-autocomplete-dummy'); + + this.#autoCompleteField = autoCompleteField; + + this.container.appendChild(autoCompleteField); + } + + init() { + this.#searchField.addEventListener('input', this.#updateAutoCompletedFragment.bind(this)); + this.#searchField.addEventListener('keydown', this.#onSearchFieldKeyPressed.bind(this)); + this.#searchField.addEventListener('selectionchange', this.#updateAutoCompletedFragment.bind(this)); + } + + #updateAutoCompletedFragment() { + const searchableFragment = this.#findCurrentTagFragment(); + this.#emitAutoComplete(searchableFragment || ''); + } + + #getInputUserSelection() { + return Math.min( + this.#searchField.selectionStart, + this.#searchField.selectionEnd + ); + } + + #resolveQueryTokens() { + const searchValue = this.#searchField.value; + + if (searchValue === this.#lastParsedSearchValue && this.#cachedParsedQuery) { + return this.#cachedParsedQuery; + } + + this.#lastParsedSearchValue = searchValue; + this.#cachedParsedQuery = new QueryLexer(searchValue).parse(); + + return this.#cachedParsedQuery; + } + + /** + * @param {KeyboardEvent} event + */ + #onSearchFieldKeyPressed(event) { + // On enter, attempt to replace the current active tag in the query with autocomplete selection + if (event.code === 'Enter') { + this.#onEnterPressed(event); + } + + this.#autoCompleteField.dispatchEvent( + new KeyboardEvent('keydown', { + keyCode: event.keyCode + }) + ); + + // Similarly to the site's autocomplete logic, we need to prevent the arrows up/down from causing any issues + if (event.keyCode === 38 || event.keyCode === 40) { + event.preventDefault(); + } + } + + /** + * @param {KeyboardEvent} event + */ + #onEnterPressed(event) { + const autocompleteSelection = document.querySelector('.autocomplete__item--selected'); + + if (!autocompleteSelection) { + return; + } + + const activeToken = SearchWrapper.#findActiveSearchTermPosition( + this.#resolveQueryTokens(), + this.#getInputUserSelection(), + ); + + if (activeToken instanceof TermToken || activeToken instanceof QuotedTermToken) { + const selectionStart = activeToken.index; + const selectionEnd = activeToken.index + activeToken.value.length; + + let autocompletedValue = autocompleteSelection.dataset.value; + + if (activeToken instanceof QuotedTermToken) { + autocompletedValue = `"${QuotedTermToken.encode(autocompletedValue)}"`; + } + + this.#searchField.value = this.#searchField.value.slice(0, selectionStart) + + autocompletedValue + + this.#searchField.value.slice(selectionEnd); + + const newSelectionEnd = selectionStart + autocompletedValue.length; + + // Place the caret at the end of the currently active tag. + // Actually, this does not work for some reason. After the tag is sent to the field and selection was changed to + // the end of the inserted tag, browser just does not scroll the input to the caret position. + this.#searchField.focus(); + this.#searchField.setSelectionRange(newSelectionEnd, newSelectionEnd); + + event.preventDefault(); + } + } + + /** + * @return {string|null} + */ + #findCurrentTagFragment() { + if (!this.#searchField) { + return null; + } + + let searchValue = this.#searchField.value; + + if (!searchValue) { + return null; + } + + const token = SearchWrapper.#findActiveSearchTermPosition( + this.#resolveQueryTokens(), + this.#getInputUserSelection(), + ); + + if (token instanceof TermToken) { + return token.value; + } + + if (token instanceof QuotedTermToken) { + return token.decodedValue; + } + + return searchValue; + } + + #emitAutoComplete(userInputFragment) { + this.#autoCompleteField.value = userInputFragment; + + // Should be at least one frame away, since input event always removes autocomplete window + requestAnimationFrame(() => { + this.#autoCompleteField.dispatchEvent( + new InputEvent('input', {bubbles: true}) + ); + + const autocompleteContainer = document.querySelector('.autocomplete'); + + if (autocompleteContainer) { + autocompleteContainer.style.left = `${this.container.offsetLeft}px`; + } + }); + } + + /** + * Loosely estimate where current selected search term is located and return it if found. + * @param {Token[]} tokens Search value to find the actively selected term from. + * @param {number} userSelectionIndex The index of the user selection. + * @return {Token|null} Search term object or NULL if nothing found. + */ + static #findActiveSearchTermPosition(tokens, userSelectionIndex) { + return tokens.find( + token => token.index < userSelectionIndex && token.index + token.value.length >= userSelectionIndex + ); + } +} + +export function initializeSearWrapper(formElement) { + new SearchWrapper(formElement).initialize(); +} diff --git a/src/lib/components/SiteHeaderWrapper.js b/src/lib/components/SiteHeaderWrapper.js new file mode 100644 index 0000000..f8a1160 --- /dev/null +++ b/src/lib/components/SiteHeaderWrapper.js @@ -0,0 +1,23 @@ +import {BaseComponent} from "$lib/components/base/BaseComponent.js"; +import {SearchWrapper} from "$lib/components/SearchWrapper.js"; + +class SiteHeaderWrapper extends BaseComponent { + /** @type {SearchWrapper|null} */ + #searchWrapper = null; + + build() { + const searchForm = this.container.querySelector('.header__search'); + this.#searchWrapper = searchForm && new SearchWrapper(searchForm) || null; + } + + init() { + if (this.#searchWrapper) { + this.#searchWrapper.initialize(); + } + } +} + +export function initializeSiteHeader(siteHeaderElement) { + new SiteHeaderWrapper(siteHeaderElement) + .initialize(); +} diff --git a/src/styles/content/header.scss b/src/styles/content/header.scss new file mode 100644 index 0000000..81ea625 --- /dev/null +++ b/src/styles/content/header.scss @@ -0,0 +1,11 @@ +.header__search--completable { + .search-autocomplete-dummy { + position: absolute; + height: 0; + opacity: 0; + pointer-events: none; + padding: 0; + margin: 0; + align-self: flex-end; + } +}