1
0
mirror of https://github.com/koloml/furbooru-tagging-assistant.git synced 2025-12-24 15:12:58 +00:00

Implementation of the search field autocomplete feature

This commit is contained in:
2024-05-29 00:19:22 +04:00
parent 0637a21eb7
commit f863b3a475
6 changed files with 504 additions and 0 deletions

View File

@@ -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;
}

View File

@@ -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();
}

View File

@@ -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();
}