diff --git a/manifest.json b/manifest.json index 34d55d7..5571e7a 100644 --- a/manifest.json +++ b/manifest.json @@ -41,17 +41,6 @@ "src/styles/content/listing.scss" ] }, - { - "matches": [ - "*://*.furbooru.org/*" - ], - "js": [ - "src/content/header.ts" - ], - "css": [ - "src/styles/content/header.scss" - ] - }, { "matches": [ "*://*.furbooru.org/images/*" diff --git a/src/content/header.ts b/src/content/header.ts deleted file mode 100644 index a306a89..0000000 --- a/src/content/header.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { initializeSiteHeader } from "$lib/components/SiteHeaderWrapper"; - -const siteHeader = document.querySelector('.header'); - -if (siteHeader) { - initializeSiteHeader(siteHeader); -} diff --git a/src/lib/components/SearchWrapper.ts b/src/lib/components/SearchWrapper.ts deleted file mode 100644 index aeebdde..0000000 --- a/src/lib/components/SearchWrapper.ts +++ /dev/null @@ -1,429 +0,0 @@ -import { BaseComponent } from "$lib/components/base/BaseComponent"; -import { QueryLexer, QuotedTermToken, TermToken, Token } from "$lib/booru/search/QueryLexer"; -import SearchSettings, { type SuggestionsPosition } from "$lib/extension/settings/SearchSettings"; - -export class SearchWrapper extends BaseComponent { - #searchField: HTMLInputElement | null = null; - #lastParsedSearchValue: string | null = null; - #cachedParsedQuery: Token[] = []; - #searchSettings: SearchSettings = new SearchSettings(); - #arePropertiesSuggestionsEnabled: boolean = false; - #propertiesSuggestionsPosition: SuggestionsPosition = "start"; - #cachedAutocompleteContainer: HTMLElement | null = null; - #lastTermToken: TermToken | QuotedTermToken | null = null; - - build() { - this.#searchField = this.container.querySelector('input[name=q]'); - } - - init() { - if (this.#searchField) { - this.#searchField.addEventListener('input', this.#onInputFindProperties.bind(this)) - } - - this.#searchSettings.resolvePropertiesSuggestionsEnabled() - .then(isEnabled => this.#arePropertiesSuggestionsEnabled = isEnabled); - this.#searchSettings.resolvePropertiesSuggestionsPosition() - .then(position => this.#propertiesSuggestionsPosition = position); - - this.#searchSettings.subscribe(settings => { - this.#arePropertiesSuggestionsEnabled = Boolean(settings.suggestProperties); - this.#propertiesSuggestionsPosition = settings.suggestPropertiesPosition || "start"; - }); - } - - /** - * Catch the user input and execute suggestions logic. - * @param event Source event to find the input element from. - */ - #onInputFindProperties(event: Event) { - // Ignore events until option is enabled. - if (!this.#arePropertiesSuggestionsEnabled || !(event.currentTarget instanceof HTMLInputElement)) { - return; - } - - const currentFragment = this.#findCurrentTagFragment(); - - if (!currentFragment) { - return; - } - - this.#renderSuggestions( - SearchWrapper.#resolveSuggestionsFromTerm(currentFragment), - event.currentTarget - ); - } - - /** - * Get the selection position in the search field. - */ - #getInputUserSelection(): number { - if (!this.#searchField) { - throw new Error('Missing search field!'); - } - - return Math.min( - this.#searchField.selectionStart ?? 0, - this.#searchField.selectionEnd ?? 0, - ); - } - - /** - * Parse the search query and return the list of parsed tokens. Result will be cached for current search query. - */ - #resolveQueryTokens(): Token[] { - if (!this.#searchField) { - throw new Error('Missing search field!'); - } - - 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; - } - - /** - * Find the currently selected term. - * @return Selected term or null if none found. - */ - #findCurrentTagFragment(): string | null { - if (!this.#searchField) { - return null; - } - - let searchValue = this.#searchField.value; - - if (!searchValue) { - this.#lastTermToken = null; - return null; - } - - const token = SearchWrapper.#findActiveSearchTermPosition( - this.#resolveQueryTokens(), - this.#getInputUserSelection(), - ); - - if (token instanceof TermToken) { - this.#lastTermToken = token; - return token.value; - } - - if (token instanceof QuotedTermToken) { - this.#lastTermToken = token; - return token.decodedValue; - } - - this.#lastTermToken = null; - return searchValue; - } - - /** - * Resolve the autocomplete container from the document. Once resolved, it can be safely reused without breaking - * anything. Assuming refactored autocomplete handler is still implemented the way it is. - * - * This means, that properties will only be suggested once actual autocomplete logic was activated. - * - * @return Resolved element or nothing. - */ - #resolveAutocompleteContainer(): HTMLElement | null { - if (this.#cachedAutocompleteContainer) { - return this.#cachedAutocompleteContainer; - } - - this.#cachedAutocompleteContainer = document.querySelector('.autocomplete'); - - return this.#cachedAutocompleteContainer; - } - - /** - * Render the list of suggestions into the existing popup or create and populate a new one. - * @param suggestions List of suggestion to render the popup from. - * @param targetInput Target input to attach the popup to. - */ - #renderSuggestions(suggestions: string[], targetInput: HTMLInputElement) { - const suggestedListItems = suggestions - .map(suggestedTerm => this.#renderTermSuggestion(suggestedTerm)); - - requestAnimationFrame(() => { - const autocompleteContainer = this.#resolveAutocompleteContainer(); - - if (!autocompleteContainer) { - return; - } - - // Since the autocomplete popup was refactored to re-use the same element over and over again, we need to remove - // the options from the popup manually when autocomplete was removed from the DOM, since site is not doing that. - const termsToRemove = autocompleteContainer.isConnected - // Only removing properties when element is still connected to the DOM (popup is used by the website) - ? autocompleteContainer.querySelectorAll('.autocomplete__item--property') - // Remove everything if popup was disconnected from the DOM. - : autocompleteContainer.querySelectorAll('.autocomplete__item') - - for (let existingTerm of termsToRemove) { - existingTerm.remove(); - } - - const listContainer = autocompleteContainer.querySelector('ul'); - - if (!listContainer) { - return; - } - - switch (this.#propertiesSuggestionsPosition) { - case "start": - listContainer.prepend(...suggestedListItems); - break; - - case "end": - listContainer.append(...suggestedListItems); - break; - - default: - console.warn("Invalid position for property suggestions!"); - } - - const parentScrollTop = targetInput.parentElement?.scrollTop ?? 0; - - autocompleteContainer.style.position = 'absolute'; - autocompleteContainer.style.left = `${targetInput.offsetLeft}px`; - autocompleteContainer.style.top = `${targetInput.offsetTop + targetInput.offsetHeight - parentScrollTop}px`; - - document.body.append(autocompleteContainer); - }) - } - - /** - * Loosely estimate where current selected search term is located and return it if found. - * @param tokens Search value to find the actively selected term from. - * @param userSelectionIndex The index of the user selection. - * @return Search term object or NULL if nothing found. - */ - static #findActiveSearchTermPosition(tokens: Token[], userSelectionIndex: number): Token | null { - return tokens.find( - token => token.index < userSelectionIndex && token.index + token.value.length >= userSelectionIndex - ) ?? null; - } - - /** - * Regular expression to search the properties' syntax. - */ - static #propertySearchTermHeadingRegExp = /^(?[a-z\d_]+)(?\.(?[a-z]*))?(?:(?.*))?$/; - - /** - * Create a list of suggested elements using the input received from the user. - * @param searchTermValue Original decoded term received from the user. - * @return {string[]} List of suggestions. Could be empty. - */ - static #resolveSuggestionsFromTerm(searchTermValue: string): string[] { - const suggestionsList: string[] = []; - - this.#propertySearchTermHeadingRegExp.lastIndex = 0; - const parsedResult = this.#propertySearchTermHeadingRegExp.exec(searchTermValue); - - if (!parsedResult) { - return suggestionsList; - } - - const propertyName = parsedResult.groups?.name; - - if (!propertyName) { - return suggestionsList; - } - - const propertyType = this.#properties.get(propertyName); - const hasOperatorSyntax = Boolean(parsedResult.groups?.op_syntax); - const hasValueSyntax = Boolean(parsedResult.groups?.value_syntax); - - // No suggestions for values for now, maybe could add suggestions for namespaces like my:* - if (hasValueSyntax && propertyType) { - if (this.#typeValues.has(propertyType)) { - const givenValue = parsedResult.groups?.value; - const candidateValues = this.#typeValues.get(propertyType) || []; - - for (let candidateValue of candidateValues) { - if (givenValue && !candidateValue.startsWith(givenValue)) { - continue; - } - - suggestionsList.push(`${propertyName}${parsedResult.groups?.op_syntax ?? ''}:${candidateValue}`); - } - } - - return suggestionsList; - } - - // If at least one dot placed, start suggesting operators - if (hasOperatorSyntax && propertyType) { - if (this.#typeOperators.has(propertyType)) { - const operatorName = parsedResult.groups?.op; - const candidateOperators = this.#typeOperators.get(propertyType) ?? []; - - for (let candidateOperator of candidateOperators) { - if (operatorName && !candidateOperator.startsWith(operatorName)) { - continue; - } - - suggestionsList.push(`${propertyName}.${candidateOperator}:`); - } - } - - return suggestionsList; - } - - // Otherwise, search for properties with names starting with the term - for (let [candidateProperty] of this.#properties) { - if (propertyName && !candidateProperty.startsWith(propertyName)) { - continue; - } - - suggestionsList.push(candidateProperty); - } - - return suggestionsList; - } - - /** - * Render a single suggestion item and connect required events to interact with the user. - * @param suggestedTerm Term to use for suggestion item. - * @return Resulting element. - */ - #renderTermSuggestion(suggestedTerm: string): HTMLElement { - const suggestionItem = document.createElement('li'); - suggestionItem.classList.add('autocomplete__item', 'autocomplete__item--property'); - suggestionItem.dataset.value = suggestedTerm; - suggestionItem.innerText = suggestedTerm; - - const propertyIcon = document.createElement('i'); - propertyIcon.classList.add('fa', 'fa-info-circle'); - suggestionItem.insertAdjacentElement('afterbegin', propertyIcon); - - suggestionItem.addEventListener('mouseover', () => { - SearchWrapper.#findAndResetSelectedSuggestion(suggestionItem); - suggestionItem.classList.add('autocomplete__item--selected'); - }); - - suggestionItem.addEventListener('mouseout', () => { - SearchWrapper.#findAndResetSelectedSuggestion(suggestionItem); - }); - - suggestionItem.addEventListener('click', () => { - this.#replaceLastActiveTokenWithSuggestion(suggestedTerm); - }); - - return suggestionItem; - } - - /** - * Automatically replace the last active token stored in the variable with the new value. - * @param suggestedTerm Term to replace the value with. - */ - #replaceLastActiveTokenWithSuggestion(suggestedTerm: string) { - if (!this.#lastTermToken || !this.#searchField) { - return; - } - - const searchQuery = this.#searchField.value; - const beforeToken = searchQuery.substring(0, this.#lastTermToken.index); - const afterToken = searchQuery.substring(this.#lastTermToken.index + this.#lastTermToken.value.length); - - let replacementValue = suggestedTerm; - - if (replacementValue.includes('"')) { - replacementValue = `"${QuotedTermToken.encode(replacementValue)}"` - } - - this.#searchField.value = beforeToken + replacementValue + afterToken; - } - - /** - * Find the selected suggestion item(s) and unselect them. Similar to the logic implemented by the Philomena's - * front-end. - * @param suggestedElement Target element to search from. If element is disconnected from the DOM, search will be - * halted. - */ - static #findAndResetSelectedSuggestion(suggestedElement: HTMLElement) { - if (!suggestedElement.parentElement) { - return; - } - - for (let selectedElement of suggestedElement.parentElement.querySelectorAll('.autocomplete__item--selected')) { - selectedElement.classList.remove('autocomplete__item--selected'); - } - } - - static #typeNumeric = Symbol(); - static #typeDate = Symbol(); - static #typeLiteral = Symbol(); - static #typePersonal = Symbol(); - static #typeBoolean = Symbol(); - - static #properties = new Map([ - ['animated', SearchWrapper.#typeBoolean], - ['aspect_ratio', SearchWrapper.#typeNumeric], - ['body_type_tag_count', SearchWrapper.#typeNumeric], - ['character_tag_count', SearchWrapper.#typeNumeric], - ['comment_count', SearchWrapper.#typeNumeric], - ['content_fanmade_tag_count', SearchWrapper.#typeNumeric], - ['content_official_tag_count', SearchWrapper.#typeNumeric], - ['created_at', SearchWrapper.#typeDate], - ['description', SearchWrapper.#typeLiteral], - ['downvotes', SearchWrapper.#typeNumeric], - ['duration', SearchWrapper.#typeNumeric], - ['error_tag_count', SearchWrapper.#typeNumeric], - ['faved_by', SearchWrapper.#typeLiteral], - ['faved_by_id', SearchWrapper.#typeNumeric], - ['faves', SearchWrapper.#typeNumeric], - ['file_name', SearchWrapper.#typeLiteral], - ['first_seen_at', SearchWrapper.#typeDate], - ['height', SearchWrapper.#typeNumeric], - ['id', SearchWrapper.#typeNumeric], - ['oc_tag_count', SearchWrapper.#typeNumeric], - ['orig_sha512_hash', SearchWrapper.#typeLiteral], - ['original_format', SearchWrapper.#typeLiteral], - ['pixels', SearchWrapper.#typeNumeric], - ['rating_tag_count', SearchWrapper.#typeNumeric], - ['score', SearchWrapper.#typeNumeric], - ['sha512_hash', SearchWrapper.#typeLiteral], - ['size', SearchWrapper.#typeNumeric], - ['source_count', SearchWrapper.#typeNumeric], - ['source_url', SearchWrapper.#typeLiteral], - ['species_tag_count', SearchWrapper.#typeNumeric], - ['spoiler_tag_count', SearchWrapper.#typeNumeric], - ['tag_count', SearchWrapper.#typeNumeric], - ['updated_at', SearchWrapper.#typeDate], - ['uploader', SearchWrapper.#typeLiteral], - ['uploader_id', SearchWrapper.#typeNumeric], - ['upvotes', SearchWrapper.#typeNumeric], - ['width', SearchWrapper.#typeNumeric], - ['wilson_score', SearchWrapper.#typeNumeric], - ['my', SearchWrapper.#typePersonal], - ]); - - static #comparisonOperators = ['gt', 'gte', 'lt', 'lte']; - - static #typeOperators = new Map([ - [SearchWrapper.#typeNumeric, SearchWrapper.#comparisonOperators], - [SearchWrapper.#typeDate, SearchWrapper.#comparisonOperators], - ]); - - static #typeValues = new Map([ - [SearchWrapper.#typePersonal, [ - 'comments', - 'faves', - 'posts', - 'uploads', - 'upvotes', - 'watched', - ]], - [SearchWrapper.#typeBoolean, [ - 'true', - 'false', - ]] - ]); -} diff --git a/src/lib/components/SiteHeaderWrapper.ts b/src/lib/components/SiteHeaderWrapper.ts deleted file mode 100644 index c1b22fe..0000000 --- a/src/lib/components/SiteHeaderWrapper.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { BaseComponent } from "$lib/components/base/BaseComponent"; -import { SearchWrapper } from "$lib/components/SearchWrapper"; - -class SiteHeaderWrapper extends BaseComponent { - #searchWrapper: SearchWrapper | null = 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: HTMLElement) { - new SiteHeaderWrapper(siteHeaderElement) - .initialize(); -} diff --git a/src/lib/extension/settings/SearchSettings.ts b/src/lib/extension/settings/SearchSettings.ts deleted file mode 100644 index 373c613..0000000 --- a/src/lib/extension/settings/SearchSettings.ts +++ /dev/null @@ -1,30 +0,0 @@ -import CacheableSettings from "$lib/extension/base/CacheableSettings"; - -export type SuggestionsPosition = "start" | "end"; - -interface SearchSettingsFields { - suggestProperties: boolean; - suggestPropertiesPosition: SuggestionsPosition; -} - -export default class SearchSettings extends CacheableSettings { - constructor() { - super("search"); - } - - async resolvePropertiesSuggestionsEnabled() { - return this._resolveSetting("suggestProperties", false); - } - - async resolvePropertiesSuggestionsPosition() { - return this._resolveSetting("suggestPropertiesPosition", "start"); - } - - async setPropertiesSuggestions(isEnabled: boolean) { - return this._writeSetting("suggestProperties", isEnabled); - } - - async setPropertiesSuggestionsPosition(position: "start" | "end") { - return this._writeSetting("suggestPropertiesPosition", position); - } -} diff --git a/src/routes/preferences/+page.svelte b/src/routes/preferences/+page.svelte index 5937831..5f861a2 100644 --- a/src/routes/preferences/+page.svelte +++ b/src/routes/preferences/+page.svelte @@ -7,7 +7,6 @@ Back
Tagging - Search Misc & Tools
Debug diff --git a/src/routes/preferences/search/+page.svelte b/src/routes/preferences/search/+page.svelte deleted file mode 100644 index 5e55d90..0000000 --- a/src/routes/preferences/search/+page.svelte +++ /dev/null @@ -1,32 +0,0 @@ - - - - Back -
-
- - - - Auto-complete properties - - - {#if $searchPropertiesSuggestionsEnabled} - - - - {/if} - diff --git a/src/stores/preferences/search.ts b/src/stores/preferences/search.ts deleted file mode 100644 index 58a8227..0000000 --- a/src/stores/preferences/search.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { type Writable, writable } from "svelte/store"; -import SearchSettings, { type SuggestionsPosition } from "$lib/extension/settings/SearchSettings"; - -export const searchPropertiesSuggestionsEnabled = writable(false); - -export const searchPropertiesSuggestionsPosition: Writable = writable('start'); - -const searchSettings = new SearchSettings(); - -Promise.allSettled([ - // First we wait for all properties to load and save - searchSettings.resolvePropertiesSuggestionsEnabled().then(v => searchPropertiesSuggestionsEnabled.set(v)), - searchSettings.resolvePropertiesSuggestionsPosition().then(v => searchPropertiesSuggestionsPosition.set(v)) -]).then(() => { - // And then we can start reading value changes from the writable objects - searchPropertiesSuggestionsEnabled.subscribe(value => { - void searchSettings.setPropertiesSuggestions(value); - }); - - searchPropertiesSuggestionsPosition.subscribe(value => { - void searchSettings.setPropertiesSuggestionsPosition(value); - }); - - searchSettings.subscribe(settings => { - searchPropertiesSuggestionsEnabled.set(Boolean(settings.suggestProperties)); - searchPropertiesSuggestionsPosition.set(settings.suggestPropertiesPosition || 'start'); - }); -}) diff --git a/src/styles/content/header.scss b/src/styles/content/header.scss deleted file mode 100644 index 22b76e0..0000000 --- a/src/styles/content/header.scss +++ /dev/null @@ -1,9 +0,0 @@ -.autocomplete { - &__item { - &--property { - i { - margin-right: .5em; - } - } - } -}