mirror of
https://github.com/koloml/furbooru-tagging-assistant.git
synced 2025-12-24 07:12:57 +00:00
Merge remote-tracking branch 'origin/master' into feature/bulk-import-and-export
# Conflicts: # src/lib/extension/transporting/exporters.ts
This commit is contained in:
3
src/app.d.ts
vendored
3
src/app.d.ts
vendored
@@ -4,6 +4,9 @@ import MaintenanceProfile from "$entities/MaintenanceProfile";
|
||||
import type TagGroup from "$entities/TagGroup";
|
||||
|
||||
declare global {
|
||||
// Helper type to not deal with differences between the setTimeout of @types/node and usual web browser's type.
|
||||
type Timeout = ReturnType<typeof setTimeout>;
|
||||
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
let { group }: GroupViewProps = $props();
|
||||
|
||||
let sortedTagsList = $derived<string[]>(group.settings.tags.sort((a, b) => a.localeCompare(b))),
|
||||
sortedPrefixes = $derived<string[]>(group.settings.prefixes.sort((a, b) => a.localeCompare(b)));
|
||||
sortedPrefixes = $derived<string[]>(group.settings.prefixes.sort((a, b) => a.localeCompare(b))),
|
||||
sortedSuffixes = $derived<string[]>(group.settings.suffixes.sort((a, b) => a.localeCompare(b)));
|
||||
|
||||
</script>
|
||||
|
||||
@@ -41,6 +42,18 @@
|
||||
</TagsColorContainer>
|
||||
</div>
|
||||
{/if}
|
||||
{#if sortedSuffixes.length}
|
||||
<div class="block">
|
||||
<strong>Suffixes:</strong>
|
||||
<TagsColorContainer targetCategory={group.settings.category}>
|
||||
<div class="tags-list">
|
||||
{#each sortedSuffixes as suffixName}
|
||||
<span class="tag">*{suffixName}</span>
|
||||
{/each}
|
||||
</div>
|
||||
</TagsColorContainer>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
.tags-list {
|
||||
|
||||
@@ -4,10 +4,12 @@
|
||||
interface TagEditorProps {
|
||||
// List of tags to edit. Any duplicated tags present in the array will be removed on the first edit.
|
||||
tags?: string[];
|
||||
mapTagNames?: (tagName: string) => string;
|
||||
}
|
||||
|
||||
let {
|
||||
tags = $bindable([])
|
||||
tags = $bindable([]),
|
||||
mapTagNames,
|
||||
}: TagEditorProps = $props();
|
||||
|
||||
let uniqueTags = $state<Set<string>>(new Set());
|
||||
@@ -87,7 +89,7 @@
|
||||
<div class="tags-editor">
|
||||
{#each uniqueTags.values() as tagName}
|
||||
<div class="tag">
|
||||
{tagName}
|
||||
{mapTagNames?.(tagName) ?? tagName}
|
||||
<span class="remove" onclick={createTagRemoveHandler(tagName)}
|
||||
onkeydown={createTagRemoveHandler(tagName)}
|
||||
role="button" tabindex="0">x</span>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { initializeSiteHeader } from "$lib/components/SiteHeaderWrapper";
|
||||
|
||||
const siteHeader = document.querySelector('.header');
|
||||
const siteHeader = document.querySelector<HTMLElement>('.header');
|
||||
|
||||
if (siteHeader) {
|
||||
initializeSiteHeader(siteHeader);
|
||||
@@ -4,8 +4,7 @@ import { calculateMediaBoxesPositions, initializeMediaBox } from "$lib/component
|
||||
import { createMaintenanceStatusIcon } from "$lib/components/MaintenanceStatusIcon";
|
||||
import { createImageShowFullscreenButton } from "$lib/components/ImageShowFullscreenButton";
|
||||
|
||||
/** @type {NodeListOf<HTMLElement>} */
|
||||
const mediaBoxes = document.querySelectorAll('.media-box');
|
||||
const mediaBoxes = document.querySelectorAll<HTMLElement>('.media-box');
|
||||
|
||||
mediaBoxes.forEach(mediaBoxElement => {
|
||||
initializeMediaBox(mediaBoxElement, [
|
||||
@@ -1,3 +0,0 @@
|
||||
import { TagsForm } from "$lib/components/TagsForm";
|
||||
|
||||
TagsForm.watchForEditors();
|
||||
6
src/content/tags-editor.ts
Normal file
6
src/content/tags-editor.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { TagsForm } from "$lib/components/TagsForm";
|
||||
import { initializeAllTagsLists, watchForUpdatedTagLists } from "$lib/components/TagsListBlock";
|
||||
|
||||
initializeAllTagsLists();
|
||||
watchForUpdatedTagLists();
|
||||
TagsForm.watchForEditors();
|
||||
@@ -1,6 +1,6 @@
|
||||
import { watchTagDropdownsInTagsEditor, wrapTagDropdown } from "$lib/components/TagDropdownWrapper";
|
||||
|
||||
for (let tagDropdownElement of document.querySelectorAll('.tag.dropdown')) {
|
||||
for (let tagDropdownElement of document.querySelectorAll<HTMLElement>('.tag.dropdown')) {
|
||||
wrapTagDropdown(tagDropdownElement);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
/** @type {import('@sveltejs/kit').Reroute} */
|
||||
export function reroute({url}) {
|
||||
import type { Reroute } from "@sveltejs/kit";
|
||||
|
||||
export const reroute: Reroute = ({url}) => {
|
||||
// Reroute index.html as just / for the root.
|
||||
// Browser extension starts from with the index.html file in the pathname which is not correct for the router.
|
||||
if (url.pathname === '/index.html') {
|
||||
if (url.searchParams.has('path')) {
|
||||
return url.searchParams.get('path')!;
|
||||
}
|
||||
|
||||
return "/";
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1,19 +1,25 @@
|
||||
import PostParser from "$lib/booru/scraped/parsing/PostParser";
|
||||
|
||||
type UpdaterFunction = (tags: Set<string>) => Set<string>;
|
||||
|
||||
export default class ScrapedAPI {
|
||||
/**
|
||||
* Update the tags of the image using callback.
|
||||
* @param {number} imageId ID of the image.
|
||||
* @param {function(Set<string>): Set<string>} callback Callback to call to change the content.
|
||||
* @return {Promise<Map<string,string>|null>} Updated tags and aliases list for updating internal cached state.
|
||||
* @param imageId ID of the image.
|
||||
* @param callback Callback to call to change the content.
|
||||
* @return Updated tags and aliases list for updating internal cached state.
|
||||
*/
|
||||
async updateImageTags(imageId, callback) {
|
||||
async updateImageTags(imageId: number, callback: UpdaterFunction): Promise<Map<string, string> | null> {
|
||||
const postParser = new PostParser(imageId);
|
||||
const formData = await postParser.resolveTagEditorFormData();
|
||||
const tagsFieldValue = formData.get(PostParser.tagsInputName);
|
||||
|
||||
if (typeof tagsFieldValue !== 'string') {
|
||||
throw new Error('Missing tags field!');
|
||||
}
|
||||
|
||||
const tagsList = new Set(
|
||||
formData
|
||||
.get(PostParser.tagsInputName)
|
||||
tagsFieldValue
|
||||
.split(',')
|
||||
.map(tagName => tagName.trim())
|
||||
);
|
||||
@@ -1,17 +1,12 @@
|
||||
export default class PageParser {
|
||||
/** @type {string} */
|
||||
#url;
|
||||
/** @type {DocumentFragment|null} */
|
||||
#fragment = null;
|
||||
readonly #url: string;
|
||||
#fragment: DocumentFragment | null = null;
|
||||
|
||||
constructor(url) {
|
||||
constructor(url: string) {
|
||||
this.#url = url;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {Promise<DocumentFragment>}
|
||||
*/
|
||||
async resolveFragment() {
|
||||
async resolveFragment(): Promise<DocumentFragment> {
|
||||
if (this.#fragment) {
|
||||
return this.#fragment;
|
||||
}
|
||||
@@ -34,12 +29,12 @@ export default class PageParser {
|
||||
/**
|
||||
* Create a document fragment from the following response.
|
||||
*
|
||||
* @param {Response} response Response to create a fragment from. Note, that this response will be used. If you need
|
||||
* to use the same response somewhere else, then you need to pass a cloned version of the response.
|
||||
* @param response Response to create a fragment from. Note, that this response will be used. If you need to use the
|
||||
* same response somewhere else, then you need to pass a cloned version of the response.
|
||||
*
|
||||
* @return {Promise<DocumentFragment>} Resulting document fragment ready for processing.
|
||||
* @return Resulting document fragment ready for processing.
|
||||
*/
|
||||
static async resolveFragmentFromResponse(response) {
|
||||
static async resolveFragmentFromResponse(response: Response): Promise<DocumentFragment> {
|
||||
const documentFragment = document.createDocumentFragment();
|
||||
const template = document.createElement('template');
|
||||
template.innerHTML = await response.text();
|
||||
@@ -2,23 +2,19 @@ import PageParser from "$lib/booru/scraped/parsing/PageParser";
|
||||
import { buildTagsAndAliasesMap } from "$lib/booru/tag-utils";
|
||||
|
||||
export default class PostParser extends PageParser {
|
||||
/** @type {HTMLFormElement} */
|
||||
#tagEditorForm;
|
||||
#tagEditorForm: HTMLFormElement | null = null;
|
||||
|
||||
constructor(imageId) {
|
||||
constructor(imageId: number) {
|
||||
super(`/images/${imageId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {Promise<HTMLFormElement>}
|
||||
*/
|
||||
async resolveTagEditorForm() {
|
||||
async resolveTagEditorForm(): Promise<HTMLFormElement> {
|
||||
if (this.#tagEditorForm) {
|
||||
return this.#tagEditorForm;
|
||||
}
|
||||
|
||||
const documentFragment = await this.resolveFragment();
|
||||
const tagsFormElement = documentFragment.querySelector("#tags-form");
|
||||
const tagsFormElement = documentFragment.querySelector<HTMLFormElement>("#tags-form");
|
||||
|
||||
if (!tagsFormElement) {
|
||||
throw new Error("Failed to find the tag editor form");
|
||||
@@ -37,10 +33,8 @@ export default class PostParser extends PageParser {
|
||||
|
||||
/**
|
||||
* Resolve the tags and aliases mapping from the post page.
|
||||
*
|
||||
* @return {Promise<Map<string, string>|null>}
|
||||
*/
|
||||
async resolveTagsAndAliases() {
|
||||
async resolveTagsAndAliases(): Promise<Map<string, string> | null> {
|
||||
return PostParser.resolveTagsAndAliasesFromPost(
|
||||
await this.resolveFragment()
|
||||
);
|
||||
@@ -49,25 +43,32 @@ export default class PostParser extends PageParser {
|
||||
/**
|
||||
* Resolve the list of tags and aliases from the post content.
|
||||
*
|
||||
* @param {DocumentFragment} documentFragment Real content to parse the data from.
|
||||
* @param documentFragment Real content to parse the data from.
|
||||
*
|
||||
* @return {Map<string, string>|null} Tags and aliases or null if failed to parse.
|
||||
* @return Tags and aliases or null if failed to parse.
|
||||
*/
|
||||
static resolveTagsAndAliasesFromPost(documentFragment) {
|
||||
const imageShowContainer = documentFragment.querySelector('.image-show-container');
|
||||
const tagsForm = documentFragment.querySelector('#tags-form');
|
||||
static resolveTagsAndAliasesFromPost(documentFragment: DocumentFragment): Map<string, string> | null {
|
||||
const imageShowContainer = documentFragment.querySelector<HTMLElement>('.image-show-container');
|
||||
const tagsForm = documentFragment.querySelector<HTMLFormElement>('#tags-form');
|
||||
|
||||
if (!imageShowContainer || !tagsForm) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tagsFormData = new FormData(tagsForm);
|
||||
const tagsAndAliasesValue = imageShowContainer.dataset.imageTagAliases;
|
||||
const tagsValue = tagsFormData.get(this.tagsInputName);
|
||||
|
||||
const tagsAndAliasesList = imageShowContainer.dataset.imageTagAliases
|
||||
if (!tagsAndAliasesValue || !tagsValue || typeof tagsValue !== 'string') {
|
||||
console.warn('Failed to locate tags & aliases!');
|
||||
return null;
|
||||
}
|
||||
|
||||
const tagsAndAliasesList = tagsAndAliasesValue
|
||||
.split(',')
|
||||
.map(tagName => tagName.trim());
|
||||
|
||||
const actualTagsList = tagsFormData.get(this.tagsInputName)
|
||||
const actualTagsList = tagsValue
|
||||
.split(',')
|
||||
.map(tagName => tagName.trim());
|
||||
|
||||
@@ -22,7 +22,7 @@ export default class StorageHelper {
|
||||
* @return The JSON object or the default value if the entry does not exist.
|
||||
*/
|
||||
async read<Type = any, DefaultType = any>(key: string, defaultValue: DefaultType | null = null): Promise<Type | DefaultType> {
|
||||
return (await this.#storageArea.get(key))?.[key] || defaultValue;
|
||||
return (await this.#storageArea.get(key))?.[key] ?? defaultValue;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,30 +1,22 @@
|
||||
import { BaseComponent } from "$lib/components/base/BaseComponent";
|
||||
import MiscSettings from "$lib/extension/settings/MiscSettings";
|
||||
import MiscSettings, { type FullscreenViewerSize } from "$lib/extension/settings/MiscSettings";
|
||||
import { emit, on } from "$lib/components/events/comms";
|
||||
import { EVENT_SIZE_LOADED } from "$lib/components/events/fullscreen-viewer-events";
|
||||
|
||||
export class FullscreenViewer extends BaseComponent {
|
||||
/** @type {HTMLVideoElement} */
|
||||
#videoElement = document.createElement('video');
|
||||
/** @type {HTMLImageElement} */
|
||||
#imageElement = document.createElement('img');
|
||||
#spinnerElement = document.createElement('i');
|
||||
#sizeSelectorElement = document.createElement('select');
|
||||
#closeButtonElement = document.createElement('i');
|
||||
/** @type {number|null} */
|
||||
#touchId = null;
|
||||
/** @type {number|null} */
|
||||
#startX = null;
|
||||
/** @type {number|null} */
|
||||
#startY = null;
|
||||
/** @type {boolean|null} */
|
||||
#isClosingSwipeStarted = null;
|
||||
#isSizeFetched = false;
|
||||
/** @type {App.ImageURIs|null} */
|
||||
#currentURIs = null;
|
||||
#videoElement: HTMLVideoElement = document.createElement('video');
|
||||
#imageElement: HTMLImageElement = document.createElement('img');
|
||||
#spinnerElement: HTMLElement = document.createElement('i');
|
||||
#sizeSelectorElement: HTMLSelectElement = document.createElement('select');
|
||||
#closeButtonElement: HTMLElement = document.createElement('i');
|
||||
#touchId: number | null = null;
|
||||
#startX: number | null = null;
|
||||
#startY: number | null = null;
|
||||
#isClosingSwipeStarted: boolean | null = null;
|
||||
#isSizeFetched: boolean = false;
|
||||
#currentURIs: App.ImageURIs | null = null;
|
||||
|
||||
/**
|
||||
* @protected
|
||||
*/
|
||||
build() {
|
||||
protected build() {
|
||||
this.container.classList.add('fullscreen-viewer');
|
||||
|
||||
this.container.append(
|
||||
@@ -71,10 +63,7 @@ export class FullscreenViewer extends BaseComponent {
|
||||
this.container.classList.remove('loading');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {TouchEvent} event
|
||||
*/
|
||||
#onTouchStart(event) {
|
||||
#onTouchStart(event: TouchEvent) {
|
||||
if (this.#touchId !== null) {
|
||||
return;
|
||||
}
|
||||
@@ -88,14 +77,12 @@ export class FullscreenViewer extends BaseComponent {
|
||||
this.#touchId = firstTouch.identifier;
|
||||
this.#startX = firstTouch.clientX;
|
||||
this.#startY = firstTouch.clientY;
|
||||
|
||||
this.container.classList.add(FullscreenViewer.#swipeState);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {TouchEvent} event
|
||||
*/
|
||||
#onTouchEnd(event) {
|
||||
if (this.#touchId === null) {
|
||||
#onTouchEnd(event: TouchEvent) {
|
||||
if (this.#touchId === null || this.#startY === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -126,11 +113,8 @@ export class FullscreenViewer extends BaseComponent {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {TouchEvent} event
|
||||
*/
|
||||
#onTouchMove(event) {
|
||||
if (this.#touchId === null) {
|
||||
#onTouchMove(event: TouchEvent) {
|
||||
if (this.#touchId === null || this.#startY === null || this.#startX === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -179,23 +163,17 @@ export class FullscreenViewer extends BaseComponent {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {KeyboardEvent} event
|
||||
*/
|
||||
#onDocumentKeyPressed(event) {
|
||||
#onDocumentKeyPressed(event: KeyboardEvent) {
|
||||
if (event.code === 'Escape' || event.code === 'Esc') {
|
||||
this.#close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("$lib/extension/settings/MiscSettings").FullscreenViewerSize} size
|
||||
*/
|
||||
#onSizeResolved(size) {
|
||||
#onSizeResolved(size: FullscreenViewerSize) {
|
||||
this.#sizeSelectorElement.value = size;
|
||||
this.#isSizeFetched = true;
|
||||
|
||||
this.emit('size-loaded');
|
||||
emit(this.container, EVENT_SIZE_LOADED, size);
|
||||
}
|
||||
|
||||
#watchForSizeSelectionChanges() {
|
||||
@@ -232,7 +210,7 @@ export class FullscreenViewer extends BaseComponent {
|
||||
this.#currentURIs = null;
|
||||
|
||||
this.container.classList.remove(FullscreenViewer.#shownState);
|
||||
document.body.style.overflow = null;
|
||||
document.body.style.removeProperty('overflow');
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
this.#videoElement.volume = 0;
|
||||
@@ -241,16 +219,18 @@ export class FullscreenViewer extends BaseComponent {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {App.ImageURIs} imageUris
|
||||
* @return {Promise<string|null>}
|
||||
*/
|
||||
async #resolveCurrentSelectedSizeUrl(imageUris) {
|
||||
async #resolveCurrentSelectedSizeUrl(imageUris: App.ImageURIs): Promise<string | null> {
|
||||
if (!this.#isSizeFetched) {
|
||||
await new Promise(resolve => this.on('size-loaded', resolve))
|
||||
await new Promise(
|
||||
resolve => on(
|
||||
this.container,
|
||||
EVENT_SIZE_LOADED,
|
||||
resolve
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
let targetSize = this.#sizeSelectorElement.value;
|
||||
let targetSize: FullscreenViewerSize | string = this.#sizeSelectorElement.value;
|
||||
|
||||
if (!imageUris.hasOwnProperty(targetSize)) {
|
||||
targetSize = FullscreenViewer.#fallbackSize;
|
||||
@@ -264,13 +244,10 @@ export class FullscreenViewer extends BaseComponent {
|
||||
return null;
|
||||
}
|
||||
|
||||
return imageUris[targetSize];
|
||||
return imageUris[targetSize as FullscreenViewerSize];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {App.ImageURIs} imageUris
|
||||
*/
|
||||
async show(imageUris) {
|
||||
async show(imageUris: App.ImageURIs): Promise<void> {
|
||||
this.#currentURIs = imageUris;
|
||||
|
||||
const url = await this.#resolveCurrentSelectedSizeUrl(imageUris);
|
||||
@@ -308,11 +285,7 @@ export class FullscreenViewer extends BaseComponent {
|
||||
this.container.append(this.#imageElement);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} url
|
||||
* @return {boolean}
|
||||
*/
|
||||
static #isVideoUrl(url) {
|
||||
static #isVideoUrl(url: string): boolean {
|
||||
return url.endsWith('.mp4') || url.endsWith('.webm');
|
||||
}
|
||||
|
||||
@@ -324,10 +297,7 @@ export class FullscreenViewer extends BaseComponent {
|
||||
static #swipeState = 'swiped';
|
||||
static #minRequiredDistance = 50;
|
||||
|
||||
/**
|
||||
* @type {Record<import("$lib/extension/settings/MiscSettings").FullscreenViewerSize, string>}
|
||||
*/
|
||||
static #previewSizes = {
|
||||
static #previewSizes: Record<FullscreenViewerSize, string> = {
|
||||
full: 'Full',
|
||||
large: 'Large',
|
||||
medium: 'Medium',
|
||||
@@ -2,21 +2,23 @@ import { BaseComponent } from "$lib/components/base/BaseComponent";
|
||||
import { getComponent } from "$lib/components/base/component-utils";
|
||||
import MiscSettings from "$lib/extension/settings/MiscSettings";
|
||||
import { FullscreenViewer } from "$lib/components/FullscreenViewer";
|
||||
import type { MediaBoxTools } from "$lib/components/MediaBoxTools";
|
||||
|
||||
export class ImageShowFullscreenButton extends BaseComponent {
|
||||
/**
|
||||
* @type {import('./MediaBoxTools').MediaBoxTools|null}
|
||||
*/
|
||||
#mediaBoxTools= null;
|
||||
#isFullscreenButtonEnabled = false;
|
||||
#mediaBoxTools: MediaBoxTools | null = null;
|
||||
#isFullscreenButtonEnabled: boolean = false;
|
||||
|
||||
build() {
|
||||
protected build() {
|
||||
this.container.innerText = '🔍';
|
||||
|
||||
ImageShowFullscreenButton.#miscSettings ??= new MiscSettings();
|
||||
}
|
||||
|
||||
init() {
|
||||
protected init() {
|
||||
if (!this.container.parentElement) {
|
||||
throw new Error('Missing parent element!');
|
||||
}
|
||||
|
||||
this.#mediaBoxTools = getComponent(this.container.parentElement);
|
||||
|
||||
if (!this.#mediaBoxTools) {
|
||||
@@ -32,7 +34,7 @@ export class ImageShowFullscreenButton extends BaseComponent {
|
||||
this.#updateFullscreenButtonVisibility();
|
||||
})
|
||||
.then(() => {
|
||||
ImageShowFullscreenButton.#miscSettings.subscribe(settings => {
|
||||
ImageShowFullscreenButton.#miscSettings?.subscribe(settings => {
|
||||
this.#isFullscreenButtonEnabled = settings.fullscreenViewer ?? true;
|
||||
this.#updateFullscreenButtonVisibility();
|
||||
})
|
||||
@@ -45,28 +47,25 @@ export class ImageShowFullscreenButton extends BaseComponent {
|
||||
}
|
||||
|
||||
#onButtonClicked() {
|
||||
const imageLinks = this.#mediaBoxTools?.mediaBox?.imageLinks;
|
||||
|
||||
if (!imageLinks) {
|
||||
throw new Error('Failed to resolve image links from media box tools!');
|
||||
}
|
||||
|
||||
ImageShowFullscreenButton
|
||||
.#resolveViewer()
|
||||
.show(this.#mediaBoxTools.mediaBox.imageLinks);
|
||||
?.show(imageLinks);
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {FullscreenViewer|null}
|
||||
*/
|
||||
static #viewer = null;
|
||||
static #viewer: FullscreenViewer | null = null;
|
||||
|
||||
/**
|
||||
* @return {FullscreenViewer}
|
||||
*/
|
||||
static #resolveViewer() {
|
||||
static #resolveViewer(): FullscreenViewer {
|
||||
this.#viewer ??= this.#buildViewer();
|
||||
return this.#viewer;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {FullscreenViewer}
|
||||
*/
|
||||
static #buildViewer() {
|
||||
static #buildViewer(): FullscreenViewer {
|
||||
const element = document.createElement('div');
|
||||
const viewer = new FullscreenViewer(element);
|
||||
|
||||
@@ -77,10 +76,7 @@ export class ImageShowFullscreenButton extends BaseComponent {
|
||||
return viewer;
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {MiscSettings|null}
|
||||
*/
|
||||
static #miscSettings = null;
|
||||
static #miscSettings: MiscSettings | null = null;
|
||||
}
|
||||
|
||||
export function createImageShowFullscreenButton() {
|
||||
@@ -6,51 +6,31 @@ import ScrapedAPI from "$lib/booru/scraped/ScrapedAPI";
|
||||
import { tagsBlacklist } from "$config/tags";
|
||||
import { emitterAt } from "$lib/components/events/comms";
|
||||
import {
|
||||
eventActiveProfileChanged,
|
||||
eventMaintenanceStateChanged,
|
||||
eventTagsUpdated
|
||||
EVENT_ACTIVE_PROFILE_CHANGED,
|
||||
EVENT_MAINTENANCE_STATE_CHANGED,
|
||||
EVENT_TAGS_UPDATED
|
||||
} from "$lib/components/events/maintenance-popup-events";
|
||||
import type { MediaBoxTools } from "$lib/components/MediaBoxTools";
|
||||
|
||||
class BlackListedTagsEncounteredError extends Error {
|
||||
/**
|
||||
* @param {string} tagName
|
||||
*/
|
||||
constructor(tagName) {
|
||||
super(`This tag is blacklisted and prevents submission: ${tagName}`);
|
||||
constructor(tagName: string) {
|
||||
super(`This tag is blacklisted and prevents submission: ${tagName}`, {
|
||||
cause: tagName
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class MaintenancePopup extends BaseComponent {
|
||||
/** @type {HTMLElement} */
|
||||
#tagsListElement = null;
|
||||
|
||||
/** @type {HTMLElement[]} */
|
||||
#tagsList = [];
|
||||
|
||||
/** @type {Map<string, HTMLElement>} */
|
||||
#suggestedInvalidTags = new Map();
|
||||
|
||||
/** @type {MaintenanceProfile|null} */
|
||||
#activeProfile = null;
|
||||
|
||||
/** @type {import('$lib/components/MediaBoxTools').MediaBoxTools} */
|
||||
#mediaBoxTools = null;
|
||||
|
||||
/** @type {Set<string>} */
|
||||
#tagsToRemove = new Set();
|
||||
|
||||
/** @type {Set<string>} */
|
||||
#tagsToAdd = new Set();
|
||||
|
||||
/** @type {boolean} */
|
||||
#isPlanningToSubmit = false;
|
||||
|
||||
/** @type {boolean} */
|
||||
#isSubmitting = false;
|
||||
|
||||
/** @type {number|null} */
|
||||
#tagsSubmissionTimer = null;
|
||||
|
||||
#tagsListElement: HTMLElement = document.createElement('div');
|
||||
#tagsList: HTMLElement[] = [];
|
||||
#suggestedInvalidTags: Map<string, HTMLElement> = new Map();
|
||||
#activeProfile: MaintenanceProfile | null = null;
|
||||
#mediaBoxTools: MediaBoxTools | null = null;
|
||||
#tagsToRemove: Set<string> = new Set();
|
||||
#tagsToAdd: Set<string> = new Set();
|
||||
#isPlanningToSubmit: boolean = false;
|
||||
#isSubmitting: boolean = false;
|
||||
#tagsSubmissionTimer: Timeout | null = null;
|
||||
#emitter = emitterAt(this);
|
||||
|
||||
/**
|
||||
@@ -60,7 +40,6 @@ export class MaintenancePopup extends BaseComponent {
|
||||
this.container.innerHTML = '';
|
||||
this.container.classList.add('maintenance-popup');
|
||||
|
||||
this.#tagsListElement = document.createElement('div');
|
||||
this.#tagsListElement.classList.add('tags-list');
|
||||
|
||||
this.container.append(
|
||||
@@ -72,14 +51,13 @@ export class MaintenancePopup extends BaseComponent {
|
||||
* @protected
|
||||
*/
|
||||
init() {
|
||||
const mediaBoxToolsElement = this.container.closest('.media-box-tools');
|
||||
const mediaBoxToolsElement = this.container.closest<HTMLElement>('.media-box-tools');
|
||||
|
||||
if (!mediaBoxToolsElement) {
|
||||
throw new Error('Maintenance popup initialized outside of the media box tools!');
|
||||
}
|
||||
|
||||
/** @type {MediaBoxTools|null} */
|
||||
const mediaBoxTools = getComponent(mediaBoxToolsElement);
|
||||
const mediaBoxTools = getComponent<MediaBoxTools>(mediaBoxToolsElement);
|
||||
|
||||
if (!mediaBoxTools) {
|
||||
throw new Error('Media box tools component not found!');
|
||||
@@ -92,24 +70,28 @@ export class MaintenancePopup extends BaseComponent {
|
||||
|
||||
const mediaBox = this.#mediaBoxTools.mediaBox;
|
||||
|
||||
if (!mediaBox) {
|
||||
throw new Error('Media box component not found!');
|
||||
}
|
||||
|
||||
mediaBox.on('mouseout', this.#onMouseLeftArea.bind(this));
|
||||
mediaBox.on('mouseover', this.#onMouseEnteredArea.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MaintenanceProfile|null} activeProfile
|
||||
*/
|
||||
#onActiveProfileChanged(activeProfile) {
|
||||
#onActiveProfileChanged(activeProfile: MaintenanceProfile | null) {
|
||||
this.#activeProfile = activeProfile;
|
||||
this.container.classList.toggle('is-active', activeProfile !== null);
|
||||
this.#refreshTagsList();
|
||||
|
||||
this.#emitter.emit(eventActiveProfileChanged, activeProfile);
|
||||
this.#emitter.emit(EVENT_ACTIVE_PROFILE_CHANGED, activeProfile);
|
||||
}
|
||||
|
||||
#refreshTagsList() {
|
||||
/** @type {string[]} */
|
||||
const activeProfileTagsList = this.#activeProfile?.settings.tags || [];
|
||||
if (!this.#mediaBoxTools?.mediaBox) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeProfileTagsList: string[] = this.#activeProfile?.settings.tags || [];
|
||||
|
||||
for (const tagElement of this.#tagsList) {
|
||||
tagElement.remove();
|
||||
@@ -131,11 +113,11 @@ export class MaintenancePopup extends BaseComponent {
|
||||
this.#tagsList[index] = tagElement;
|
||||
this.#tagsListElement.appendChild(tagElement);
|
||||
|
||||
const isPresent = currentPostTags.has(tagName);
|
||||
const isPresent = currentPostTags?.has(tagName);
|
||||
|
||||
tagElement.classList.toggle('is-present', isPresent);
|
||||
tagElement.classList.toggle('is-missing', !isPresent);
|
||||
tagElement.classList.toggle('is-aliased', isPresent && currentPostTags.get(tagName) !== tagName);
|
||||
tagElement.classList.toggle('is-aliased', isPresent && currentPostTags?.get(tagName) !== tagName);
|
||||
|
||||
// Just to prevent duplication, we need to include this tag to the map of suggested invalid tags
|
||||
if (tagsBlacklist.includes(tagName)) {
|
||||
@@ -147,17 +129,22 @@ export class MaintenancePopup extends BaseComponent {
|
||||
|
||||
/**
|
||||
* Detect and process clicks made directly to the tags.
|
||||
* @param {MouseEvent} event
|
||||
*/
|
||||
#handleTagClick(event) {
|
||||
/** @type {HTMLElement} */
|
||||
let tagElement = event.target;
|
||||
#handleTagClick(event: MouseEvent) {
|
||||
const targetObject = event.target;
|
||||
|
||||
if (!tagElement.classList.contains('tag')) {
|
||||
tagElement = tagElement.closest('.tag');
|
||||
|
||||
if (!targetObject || !(targetObject instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!tagElement) {
|
||||
let tagElement: HTMLElement | null = targetObject;
|
||||
|
||||
if (!tagElement.classList.contains('tag')) {
|
||||
tagElement = tagElement.closest<HTMLElement>('.tag');
|
||||
}
|
||||
|
||||
if (!tagElement?.dataset.name) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -190,7 +177,7 @@ export class MaintenancePopup extends BaseComponent {
|
||||
}
|
||||
|
||||
this.#isPlanningToSubmit = true;
|
||||
this.#emitter.emit(eventMaintenanceStateChanged, 'waiting');
|
||||
this.#emitter.emit(EVENT_MAINTENANCE_STATE_CHANGED, 'waiting');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,14 +197,14 @@ export class MaintenancePopup extends BaseComponent {
|
||||
}
|
||||
|
||||
async #onSubmissionTimerPassed() {
|
||||
if (!this.#isPlanningToSubmit || this.#isSubmitting) {
|
||||
if (!this.#isPlanningToSubmit || this.#isSubmitting || !this.#mediaBoxTools?.mediaBox) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.#isPlanningToSubmit = false;
|
||||
this.#isSubmitting = true;
|
||||
|
||||
this.#emitter.emit(eventMaintenanceStateChanged, 'processing');
|
||||
this.#emitter.emit(EVENT_MAINTENANCE_STATE_CHANGED, 'processing');
|
||||
|
||||
let maybeTagsAndAliasesAfterUpdate;
|
||||
|
||||
@@ -259,17 +246,17 @@ export class MaintenancePopup extends BaseComponent {
|
||||
|
||||
MaintenancePopup.#notifyAboutPendingSubmission(false);
|
||||
|
||||
this.#emitter.emit(eventMaintenanceStateChanged, 'failed');
|
||||
this.#emitter.emit(EVENT_MAINTENANCE_STATE_CHANGED, 'failed');
|
||||
this.#isSubmitting = false;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (maybeTagsAndAliasesAfterUpdate) {
|
||||
this.#emitter.emit(eventTagsUpdated, maybeTagsAndAliasesAfterUpdate);
|
||||
this.#emitter.emit(EVENT_TAGS_UPDATED, maybeTagsAndAliasesAfterUpdate);
|
||||
}
|
||||
|
||||
this.#emitter.emit(eventMaintenanceStateChanged, 'complete');
|
||||
this.#emitter.emit(EVENT_MAINTENANCE_STATE_CHANGED, 'complete');
|
||||
|
||||
this.#tagsToAdd.clear();
|
||||
this.#tagsToRemove.clear();
|
||||
@@ -281,6 +268,10 @@ export class MaintenancePopup extends BaseComponent {
|
||||
}
|
||||
|
||||
#revealInvalidTags() {
|
||||
if (!this.#mediaBoxTools?.mediaBox) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tagsAndAliases = this.#mediaBoxTools.mediaBox.tagsAndAliases;
|
||||
|
||||
if (!tagsAndAliases) {
|
||||
@@ -310,18 +301,11 @@ export class MaintenancePopup extends BaseComponent {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {boolean}
|
||||
*/
|
||||
get isActive() {
|
||||
return this.container.classList.contains('is-active');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} tagName
|
||||
* @return {HTMLElement}
|
||||
*/
|
||||
static #buildTagElement(tagName) {
|
||||
static #buildTagElement(tagName: string): HTMLElement {
|
||||
const tagElement = document.createElement('span');
|
||||
tagElement.classList.add('tag');
|
||||
tagElement.innerText = tagName;
|
||||
@@ -332,28 +316,26 @@ export class MaintenancePopup extends BaseComponent {
|
||||
|
||||
/**
|
||||
* Marks the tag with red color.
|
||||
* @param {HTMLElement} tagElement Element to mark.
|
||||
* @param tagElement Element to mark.
|
||||
*/
|
||||
static #markTagAsInvalid(tagElement) {
|
||||
static #markTagAsInvalid(tagElement: HTMLElement) {
|
||||
tagElement.dataset.tagCategory = 'error';
|
||||
tagElement.setAttribute('data-tag-category', 'error');
|
||||
}
|
||||
|
||||
/**
|
||||
* Controller with maintenance settings.
|
||||
* @type {MaintenanceSettings}
|
||||
*/
|
||||
static #maintenanceSettings = new MaintenanceSettings();
|
||||
|
||||
/**
|
||||
* Subscribe to all necessary feeds to watch for every active profile change. Additionally, will execute the callback
|
||||
* at the very start to retrieve the currently active profile.
|
||||
* @param {function(MaintenanceProfile|null):void} callback Callback to execute whenever selection of active profile
|
||||
* or profile itself has been changed.
|
||||
* @return {function(): void} Unsubscribe function. Call it to stop watching for changes.
|
||||
* @param callback Callback to execute whenever selection of active profile or profile itself has been changed.
|
||||
* @return Unsubscribe function. Call it to stop watching for changes.
|
||||
*/
|
||||
static #watchActiveProfile(callback) {
|
||||
let lastActiveProfileId;
|
||||
static #watchActiveProfile(callback: (profile: MaintenanceProfile | null) => void): () => void {
|
||||
let lastActiveProfileId: string | null | undefined = null;
|
||||
|
||||
const unsubscribeFromProfilesChanges = MaintenanceProfile.subscribe(profiles => {
|
||||
if (lastActiveProfileId) {
|
||||
@@ -393,9 +375,9 @@ export class MaintenancePopup extends BaseComponent {
|
||||
|
||||
/**
|
||||
* Notify the frontend about new pending submission started.
|
||||
* @param {boolean} isStarted True if started, false if ended.
|
||||
* @param isStarted True if started, false if ended.
|
||||
*/
|
||||
static #notifyAboutPendingSubmission(isStarted) {
|
||||
static #notifyAboutPendingSubmission(isStarted: boolean) {
|
||||
if (this.#pendingSubmissionCount === null) {
|
||||
this.#pendingSubmissionCount = 0;
|
||||
this.#initializeExitPromptHandler();
|
||||
@@ -424,9 +406,8 @@ export class MaintenancePopup extends BaseComponent {
|
||||
|
||||
/**
|
||||
* Amount of pending submissions or NULL if logic was not yet initialized.
|
||||
* @type {number|null}
|
||||
*/
|
||||
static #pendingSubmissionCount = null;
|
||||
static #pendingSubmissionCount: number|null = null;
|
||||
}
|
||||
|
||||
export function createMaintenancePopup() {
|
||||
@@ -1,30 +1,31 @@
|
||||
import { BaseComponent } from "$lib/components/base/BaseComponent";
|
||||
import { getComponent } from "$lib/components/base/component-utils";
|
||||
import { on } from "$lib/components/events/comms";
|
||||
import { eventMaintenanceStateChanged } from "$lib/components/events/maintenance-popup-events";
|
||||
import { EVENT_MAINTENANCE_STATE_CHANGED } from "$lib/components/events/maintenance-popup-events";
|
||||
import type { MediaBoxTools } from "$lib/components/MediaBoxTools";
|
||||
|
||||
export class MaintenanceStatusIcon extends BaseComponent {
|
||||
/** @type {import('./MediaBoxTools').MediaBoxTools} */
|
||||
#mediaBoxTools;
|
||||
#mediaBoxTools: MediaBoxTools | null = null;
|
||||
|
||||
build() {
|
||||
this.container.innerText = '🔧';
|
||||
}
|
||||
|
||||
init() {
|
||||
if (!this.container.parentElement) {
|
||||
throw new Error('Missing parent element for the maintenance status icon!');
|
||||
}
|
||||
|
||||
this.#mediaBoxTools = getComponent(this.container.parentElement);
|
||||
|
||||
if (!this.#mediaBoxTools) {
|
||||
throw new Error('Status icon element initialized outside of the media box!');
|
||||
}
|
||||
|
||||
on(this.#mediaBoxTools, eventMaintenanceStateChanged, this.#onMaintenanceStateChanged.bind(this));
|
||||
on(this.#mediaBoxTools, EVENT_MAINTENANCE_STATE_CHANGED, this.#onMaintenanceStateChanged.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {CustomEvent<string>} stateChangeEvent
|
||||
*/
|
||||
#onMaintenanceStateChanged(stateChangeEvent) {
|
||||
#onMaintenanceStateChanged(stateChangeEvent: CustomEvent<string>) {
|
||||
// TODO Replace those with FontAwesome icons later. Those icons can probably be sourced from the website itself.
|
||||
switch (stateChangeEvent.detail) {
|
||||
case "ready":
|
||||
@@ -2,17 +2,16 @@ import { BaseComponent } from "$lib/components/base/BaseComponent";
|
||||
import { getComponent } from "$lib/components/base/component-utils";
|
||||
import { MaintenancePopup } from "$lib/components/MaintenancePopup";
|
||||
import { on } from "$lib/components/events/comms";
|
||||
import { eventActiveProfileChanged } from "$lib/components/events/maintenance-popup-events";
|
||||
import { EVENT_ACTIVE_PROFILE_CHANGED } from "$lib/components/events/maintenance-popup-events";
|
||||
import type { MediaBoxWrapper } from "$lib/components/MediaBoxWrapper";
|
||||
import type MaintenanceProfile from "$entities/MaintenanceProfile";
|
||||
|
||||
export class MediaBoxTools extends BaseComponent {
|
||||
/** @type {import('./MediaBoxWrapper').MediaBoxWrapper|null} */
|
||||
#mediaBox;
|
||||
|
||||
/** @type {MaintenancePopup|null} */
|
||||
#maintenancePopup = null;
|
||||
#mediaBox: MediaBoxWrapper | null = null;
|
||||
#maintenancePopup: MaintenancePopup | null = null;
|
||||
|
||||
init() {
|
||||
const mediaBoxElement = this.container.closest('.media-box');
|
||||
const mediaBoxElement = this.container.closest<HTMLElement>('.media-box');
|
||||
|
||||
if (!mediaBoxElement) {
|
||||
throw new Error('Toolbox element initialized outside of the media box!');
|
||||
@@ -21,6 +20,10 @@ export class MediaBoxTools extends BaseComponent {
|
||||
this.#mediaBox = getComponent(mediaBoxElement);
|
||||
|
||||
for (let childElement of this.container.children) {
|
||||
if (!(childElement instanceof HTMLElement)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const component = getComponent(childElement);
|
||||
|
||||
if (!component) {
|
||||
@@ -36,37 +39,28 @@ export class MediaBoxTools extends BaseComponent {
|
||||
}
|
||||
}
|
||||
|
||||
on(this, eventActiveProfileChanged, this.#onActiveProfileChanged.bind(this));
|
||||
on(this, EVENT_ACTIVE_PROFILE_CHANGED, this.#onActiveProfileChanged.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {CustomEvent<import('$entities/MaintenanceProfile').default|null>} profileChangedEvent
|
||||
*/
|
||||
#onActiveProfileChanged(profileChangedEvent) {
|
||||
#onActiveProfileChanged(profileChangedEvent: CustomEvent<MaintenanceProfile | null>) {
|
||||
this.container.classList.toggle('has-active-profile', profileChangedEvent.detail !== null);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {MaintenancePopup|null}
|
||||
*/
|
||||
get maintenancePopup() {
|
||||
get maintenancePopup(): MaintenancePopup | null {
|
||||
return this.#maintenancePopup;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {import('./MediaBoxWrapper').MediaBoxWrapper|null}
|
||||
*/
|
||||
get mediaBox() {
|
||||
get mediaBox(): MediaBoxWrapper | null {
|
||||
return this.#mediaBox;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a maintenance popup element.
|
||||
* @param {HTMLElement[]} childrenElements List of children elements to append to the component.
|
||||
* @return {HTMLElement} The maintenance popup element.
|
||||
* @param childrenElements List of children elements to append to the component.
|
||||
* @return The maintenance popup element.
|
||||
*/
|
||||
export function createMediaBoxTools(...childrenElements) {
|
||||
export function createMediaBoxTools(...childrenElements: HTMLElement[]): HTMLElement {
|
||||
const mediaBoxToolsContainer = document.createElement('div');
|
||||
mediaBoxToolsContainer.classList.add('media-box-tools');
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
import { BaseComponent } from "$lib/components/base/BaseComponent";
|
||||
import { getComponent } from "$lib/components/base/component-utils";
|
||||
import { buildTagsAndAliasesMap } from "$lib/booru/tag-utils";
|
||||
import { on } from "$lib/components/events/comms";
|
||||
import { eventTagsUpdated } from "$lib/components/events/maintenance-popup-events";
|
||||
|
||||
export class MediaBoxWrapper extends BaseComponent {
|
||||
#thumbnailContainer = null;
|
||||
#imageLinkElement = null;
|
||||
|
||||
/** @type {Map<string,string>|null} */
|
||||
#tagsAndAliases = null;
|
||||
|
||||
init() {
|
||||
this.#thumbnailContainer = this.container.querySelector('.image-container');
|
||||
this.#imageLinkElement = this.#thumbnailContainer.querySelector('a');
|
||||
|
||||
on(this, eventTagsUpdated, this.#onTagsUpdatedRefreshTagsAndAliases.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {CustomEvent<Map<string,string>|null>} tagsUpdatedEvent
|
||||
*/
|
||||
#onTagsUpdatedRefreshTagsAndAliases(tagsUpdatedEvent) {
|
||||
const updatedMap = tagsUpdatedEvent.detail;
|
||||
|
||||
if (!(updatedMap instanceof Map)) {
|
||||
throw new TypeError("Tags and aliases should be stored as Map!");
|
||||
}
|
||||
|
||||
this.#tagsAndAliases = updatedMap;
|
||||
}
|
||||
|
||||
#calculateMediaBoxTags() {
|
||||
/** @type {string[]|string[]} */
|
||||
const
|
||||
tagAliases = this.#thumbnailContainer.dataset.imageTagAliases?.split(', ') || [],
|
||||
actualTags = this.#imageLinkElement.title.split(' | Tagged: ')[1]?.split(', ') || [];
|
||||
|
||||
return buildTagsAndAliasesMap(tagAliases, actualTags);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {Map<string, string>|null}
|
||||
*/
|
||||
get tagsAndAliases() {
|
||||
if (!this.#tagsAndAliases) {
|
||||
this.#tagsAndAliases = this.#calculateMediaBoxTags();
|
||||
}
|
||||
|
||||
return this.#tagsAndAliases;
|
||||
}
|
||||
|
||||
get imageId() {
|
||||
return parseInt(
|
||||
this.container.dataset.imageId
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {App.ImageURIs}
|
||||
*/
|
||||
get imageLinks() {
|
||||
return JSON.parse(this.#thumbnailContainer.dataset.uris);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap the media box element into the special wrapper.
|
||||
* @param {HTMLElement} mediaBoxContainer
|
||||
* @param {HTMLElement[]} childComponentElements
|
||||
*/
|
||||
export function initializeMediaBox(mediaBoxContainer, childComponentElements) {
|
||||
new MediaBoxWrapper(mediaBoxContainer)
|
||||
.initialize();
|
||||
|
||||
for (let childComponentElement of childComponentElements) {
|
||||
mediaBoxContainer.appendChild(childComponentElement);
|
||||
getComponent(childComponentElement)?.initialize();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {NodeListOf<HTMLElement>} mediaBoxesList
|
||||
*/
|
||||
export function calculateMediaBoxesPositions(mediaBoxesList) {
|
||||
window.addEventListener('resize', () => {
|
||||
/** @type {HTMLElement|null} */
|
||||
let lastMediaBox = null,
|
||||
/** @type {number|null} */
|
||||
lastMediaBoxPosition = null;
|
||||
|
||||
for (let mediaBoxElement of mediaBoxesList) {
|
||||
const yPosition = mediaBoxElement.getBoundingClientRect().y;
|
||||
const isOnTheSameLine = yPosition === lastMediaBoxPosition;
|
||||
|
||||
mediaBoxElement.classList.toggle('media-box--first', !isOnTheSameLine);
|
||||
lastMediaBox?.classList.toggle('media-box--last', !isOnTheSameLine);
|
||||
|
||||
lastMediaBox = mediaBoxElement;
|
||||
lastMediaBoxPosition = yPosition;
|
||||
}
|
||||
})
|
||||
}
|
||||
99
src/lib/components/MediaBoxWrapper.ts
Normal file
99
src/lib/components/MediaBoxWrapper.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { BaseComponent } from "$lib/components/base/BaseComponent";
|
||||
import { getComponent } from "$lib/components/base/component-utils";
|
||||
import { buildTagsAndAliasesMap } from "$lib/booru/tag-utils";
|
||||
import { on } from "$lib/components/events/comms";
|
||||
import { EVENT_TAGS_UPDATED } from "$lib/components/events/maintenance-popup-events";
|
||||
|
||||
export class MediaBoxWrapper extends BaseComponent {
|
||||
#thumbnailContainer: HTMLElement | null = null;
|
||||
#imageLinkElement: HTMLAnchorElement | null = null;
|
||||
#tagsAndAliases: Map<string, string> | null = null;
|
||||
|
||||
init() {
|
||||
this.#thumbnailContainer = this.container.querySelector('.image-container');
|
||||
this.#imageLinkElement = this.#thumbnailContainer?.querySelector('a') || null;
|
||||
|
||||
on(this, EVENT_TAGS_UPDATED, this.#onTagsUpdatedRefreshTagsAndAliases.bind(this));
|
||||
}
|
||||
|
||||
#onTagsUpdatedRefreshTagsAndAliases(tagsUpdatedEvent: CustomEvent<Map<string, string> | null>) {
|
||||
const updatedMap = tagsUpdatedEvent.detail;
|
||||
|
||||
if (!(updatedMap instanceof Map)) {
|
||||
throw new TypeError("Tags and aliases should be stored as Map!");
|
||||
}
|
||||
|
||||
this.#tagsAndAliases = updatedMap;
|
||||
}
|
||||
|
||||
#calculateMediaBoxTags() {
|
||||
const tagAliases: string[] = this.#thumbnailContainer?.dataset.imageTagAliases?.split(', ') || [];
|
||||
const actualTags = this.#imageLinkElement?.title.split(' | Tagged: ')[1]?.split(', ') || [];
|
||||
|
||||
return buildTagsAndAliasesMap(tagAliases, actualTags);
|
||||
}
|
||||
|
||||
get tagsAndAliases(): Map<string, string> | null {
|
||||
if (!this.#tagsAndAliases) {
|
||||
this.#tagsAndAliases = this.#calculateMediaBoxTags();
|
||||
}
|
||||
|
||||
return this.#tagsAndAliases;
|
||||
}
|
||||
|
||||
get imageId(): number {
|
||||
const imageId = this.container.dataset.imageId;
|
||||
|
||||
if (!imageId) {
|
||||
throw new Error('Missing image ID');
|
||||
}
|
||||
|
||||
return parseInt(imageId);
|
||||
}
|
||||
|
||||
get imageLinks(): App.ImageURIs {
|
||||
const jsonUris = this.#thumbnailContainer?.dataset.uris;
|
||||
|
||||
if (!jsonUris) {
|
||||
throw new Error('Missing URIs!');
|
||||
}
|
||||
|
||||
return JSON.parse(jsonUris);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap the media box element into the special wrapper.
|
||||
*/
|
||||
export function initializeMediaBox(mediaBoxContainer: HTMLElement, childComponentElements: HTMLElement[]) {
|
||||
new MediaBoxWrapper(mediaBoxContainer)
|
||||
.initialize();
|
||||
|
||||
for (let childComponentElement of childComponentElements) {
|
||||
mediaBoxContainer.appendChild(childComponentElement);
|
||||
getComponent(childComponentElement)?.initialize();
|
||||
}
|
||||
}
|
||||
|
||||
export function calculateMediaBoxesPositions(mediaBoxesList: NodeListOf<HTMLElement>) {
|
||||
window.addEventListener('resize', () => {
|
||||
let lastMediaBox: HTMLElement | null = null;
|
||||
let lastMediaBoxPosition: number | null = null;
|
||||
|
||||
for (const mediaBoxElement of mediaBoxesList) {
|
||||
const yPosition = mediaBoxElement.getBoundingClientRect().y;
|
||||
const isOnTheSameLine = yPosition === lastMediaBoxPosition;
|
||||
|
||||
mediaBoxElement.classList.toggle('media-box--first', !isOnTheSameLine);
|
||||
lastMediaBox?.classList.toggle('media-box--last', !isOnTheSameLine);
|
||||
|
||||
lastMediaBox = mediaBoxElement;
|
||||
lastMediaBoxPosition = yPosition;
|
||||
}
|
||||
|
||||
// Last-ever media box is checked separately
|
||||
if (lastMediaBox && !lastMediaBox.nextElementSibling) {
|
||||
lastMediaBox.classList.add('media-box--last');
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,29 +1,25 @@
|
||||
import { BaseComponent } from "$lib/components/base/BaseComponent";
|
||||
import { QueryLexer, QuotedTermToken, TermToken, Token } from "$lib/booru/search/QueryLexer";
|
||||
import SearchSettings from "$lib/extension/settings/SearchSettings";
|
||||
import SearchSettings, { type SuggestionsPosition } from "$lib/extension/settings/SearchSettings";
|
||||
|
||||
export class SearchWrapper extends BaseComponent {
|
||||
/** @type {HTMLInputElement|null} */
|
||||
#searchField = null;
|
||||
/** @type {string|null} */
|
||||
#lastParsedSearchValue = null;
|
||||
/** @type {Token[]} */
|
||||
#cachedParsedQuery = [];
|
||||
#searchSettings = new SearchSettings();
|
||||
#arePropertiesSuggestionsEnabled = false;
|
||||
/** @type {"start"|"end"} */
|
||||
#propertiesSuggestionsPosition = "start";
|
||||
/** @type {HTMLElement|null} */
|
||||
#cachedAutocompleteContainer = null;
|
||||
/** @type {TermToken|QuotedTermToken|null} */
|
||||
#lastTermToken = null;
|
||||
#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() {
|
||||
this.#searchField.addEventListener('input', this.#onInputFindProperties.bind(this));
|
||||
if (this.#searchField) {
|
||||
this.#searchField.addEventListener('input', this.#onInputFindProperties.bind(this))
|
||||
}
|
||||
|
||||
this.#searchSettings.resolvePropertiesSuggestionsEnabled()
|
||||
.then(isEnabled => this.#arePropertiesSuggestionsEnabled = isEnabled);
|
||||
@@ -31,18 +27,18 @@ export class SearchWrapper extends BaseComponent {
|
||||
.then(position => this.#propertiesSuggestionsPosition = position);
|
||||
|
||||
this.#searchSettings.subscribe(settings => {
|
||||
this.#arePropertiesSuggestionsEnabled = settings.suggestProperties;
|
||||
this.#propertiesSuggestionsPosition = settings.suggestPropertiesPosition;
|
||||
this.#arePropertiesSuggestionsEnabled = Boolean(settings.suggestProperties);
|
||||
this.#propertiesSuggestionsPosition = settings.suggestPropertiesPosition || "start";
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Catch the user input and execute suggestions logic.
|
||||
* @param {InputEvent} event Source event to find the input element from.
|
||||
* @param event Source event to find the input element from.
|
||||
*/
|
||||
#onInputFindProperties(event) {
|
||||
#onInputFindProperties(event: Event) {
|
||||
// Ignore events until option is enabled.
|
||||
if (!this.#arePropertiesSuggestionsEnabled) {
|
||||
if (!this.#arePropertiesSuggestionsEnabled || !(event.currentTarget instanceof HTMLInputElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -60,20 +56,26 @@ export class SearchWrapper extends BaseComponent {
|
||||
|
||||
/**
|
||||
* Get the selection position in the search field.
|
||||
* @return {number}
|
||||
*/
|
||||
#getInputUserSelection() {
|
||||
#getInputUserSelection(): number {
|
||||
if (!this.#searchField) {
|
||||
throw new Error('Missing search field!');
|
||||
}
|
||||
|
||||
return Math.min(
|
||||
this.#searchField.selectionStart,
|
||||
this.#searchField.selectionEnd
|
||||
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.
|
||||
* @return {Token[]}
|
||||
*/
|
||||
#resolveQueryTokens() {
|
||||
#resolveQueryTokens(): Token[] {
|
||||
if (!this.#searchField) {
|
||||
throw new Error('Missing search field!');
|
||||
}
|
||||
|
||||
const searchValue = this.#searchField.value;
|
||||
|
||||
if (searchValue === this.#lastParsedSearchValue && this.#cachedParsedQuery) {
|
||||
@@ -88,9 +90,9 @@ export class SearchWrapper extends BaseComponent {
|
||||
|
||||
/**
|
||||
* Find the currently selected term.
|
||||
* @return {string|null} Selected term or null if none found.
|
||||
* @return Selected term or null if none found.
|
||||
*/
|
||||
#findCurrentTagFragment() {
|
||||
#findCurrentTagFragment(): string | null {
|
||||
if (!this.#searchField) {
|
||||
return null;
|
||||
}
|
||||
@@ -127,9 +129,9 @@ export class SearchWrapper extends BaseComponent {
|
||||
*
|
||||
* This means, that properties will only be suggested once actual autocomplete logic was activated.
|
||||
*
|
||||
* @return {HTMLElement|null} Resolved element or nothing.
|
||||
* @return Resolved element or nothing.
|
||||
*/
|
||||
#resolveAutocompleteContainer() {
|
||||
#resolveAutocompleteContainer(): HTMLElement | null {
|
||||
if (this.#cachedAutocompleteContainer) {
|
||||
return this.#cachedAutocompleteContainer;
|
||||
}
|
||||
@@ -141,11 +143,10 @@ export class SearchWrapper extends BaseComponent {
|
||||
|
||||
/**
|
||||
* Render the list of suggestions into the existing popup or create and populate a new one.
|
||||
* @param {string[]} suggestions List of suggestion to render the popup from.
|
||||
* @param {HTMLInputElement} targetInput Target input to attach the popup to.
|
||||
* @param suggestions List of suggestion to render the popup from.
|
||||
* @param targetInput Target input to attach the popup to.
|
||||
*/
|
||||
#renderSuggestions(suggestions, targetInput) {
|
||||
/** @type {HTMLElement[]} */
|
||||
#renderSuggestions(suggestions: string[], targetInput: HTMLInputElement) {
|
||||
const suggestedListItems = suggestions
|
||||
.map(suggestedTerm => this.#renderTermSuggestion(suggestedTerm));
|
||||
|
||||
@@ -170,6 +171,10 @@ export class SearchWrapper extends BaseComponent {
|
||||
|
||||
const listContainer = autocompleteContainer.querySelector('ul');
|
||||
|
||||
if (!listContainer) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (this.#propertiesSuggestionsPosition) {
|
||||
case "start":
|
||||
listContainer.prepend(...suggestedListItems);
|
||||
@@ -183,10 +188,11 @@ export class SearchWrapper extends BaseComponent {
|
||||
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 - targetInput.parentElement.scrollTop}px`;
|
||||
autocompleteContainer.style.top = `${targetInput.offsetTop + targetInput.offsetHeight - parentScrollTop}px`;
|
||||
|
||||
document.body.append(autocompleteContainer);
|
||||
})
|
||||
@@ -194,30 +200,28 @@ export class SearchWrapper extends BaseComponent {
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* @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, userSelectionIndex) {
|
||||
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.
|
||||
* @type {RegExp}
|
||||
*/
|
||||
static #propertySearchTermHeadingRegExp = /^(?<name>[a-z\d_]+)(?<op_syntax>\.(?<op>[a-z]*))?(?<value_syntax>:(?<value>.*))?$/;
|
||||
|
||||
/**
|
||||
* Create a list of suggested elements using the input received from the user.
|
||||
* @param {string} searchTermValue Original decoded term received from the user.
|
||||
* @param searchTermValue Original decoded term received from the user.
|
||||
* @return {string[]} List of suggestions. Could be empty.
|
||||
*/
|
||||
static #resolveSuggestionsFromTerm(searchTermValue) {
|
||||
/** @type {string[]} */
|
||||
const suggestionsList = [];
|
||||
static #resolveSuggestionsFromTerm(searchTermValue: string): string[] {
|
||||
const suggestionsList: string[] = [];
|
||||
|
||||
this.#propertySearchTermHeadingRegExp.lastIndex = 0;
|
||||
const parsedResult = this.#propertySearchTermHeadingRegExp.exec(searchTermValue);
|
||||
@@ -226,22 +230,28 @@ export class SearchWrapper extends BaseComponent {
|
||||
return suggestionsList;
|
||||
}
|
||||
|
||||
const propertyName = parsedResult.groups.name;
|
||||
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);
|
||||
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) {
|
||||
if (hasValueSyntax && propertyType) {
|
||||
if (this.#typeValues.has(propertyType)) {
|
||||
const givenValue = parsedResult.groups.value;
|
||||
const givenValue = parsedResult.groups?.value;
|
||||
const candidateValues = this.#typeValues.get(propertyType) || [];
|
||||
|
||||
for (let candidateValue of this.#typeValues.get(propertyType)) {
|
||||
for (let candidateValue of candidateValues) {
|
||||
if (givenValue && !candidateValue.startsWith(givenValue)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
suggestionsList.push(`${propertyName}${parsedResult.groups.op_syntax ?? ''}:${candidateValue}`);
|
||||
suggestionsList.push(`${propertyName}${parsedResult.groups?.op_syntax ?? ''}:${candidateValue}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,11 +259,12 @@ export class SearchWrapper extends BaseComponent {
|
||||
}
|
||||
|
||||
// If at least one dot placed, start suggesting operators
|
||||
if (hasOperatorSyntax) {
|
||||
if (hasOperatorSyntax && propertyType) {
|
||||
if (this.#typeOperators.has(propertyType)) {
|
||||
const operatorName = parsedResult.groups.op;
|
||||
const operatorName = parsedResult.groups?.op;
|
||||
const candidateOperators = this.#typeOperators.get(propertyType) ?? [];
|
||||
|
||||
for (let candidateOperator of this.#typeOperators.get(propertyType)) {
|
||||
for (let candidateOperator of candidateOperators) {
|
||||
if (operatorName && !candidateOperator.startsWith(operatorName)) {
|
||||
continue;
|
||||
}
|
||||
@@ -279,11 +290,10 @@ export class SearchWrapper extends BaseComponent {
|
||||
|
||||
/**
|
||||
* Render a single suggestion item and connect required events to interact with the user.
|
||||
* @param {string} suggestedTerm Term to use for suggestion item.
|
||||
* @return {HTMLElement} Resulting element.
|
||||
* @param suggestedTerm Term to use for suggestion item.
|
||||
* @return Resulting element.
|
||||
*/
|
||||
#renderTermSuggestion(suggestedTerm) {
|
||||
/** @type {HTMLElement} */
|
||||
#renderTermSuggestion(suggestedTerm: string): HTMLElement {
|
||||
const suggestionItem = document.createElement('li');
|
||||
suggestionItem.classList.add('autocomplete__item', 'autocomplete__item--property');
|
||||
suggestionItem.dataset.value = suggestedTerm;
|
||||
@@ -311,10 +321,10 @@ export class SearchWrapper extends BaseComponent {
|
||||
|
||||
/**
|
||||
* Automatically replace the last active token stored in the variable with the new value.
|
||||
* @param {string} suggestedTerm Term to replace the value with.
|
||||
* @param suggestedTerm Term to replace the value with.
|
||||
*/
|
||||
#replaceLastActiveTokenWithSuggestion(suggestedTerm) {
|
||||
if (!this.#lastTermToken) {
|
||||
#replaceLastActiveTokenWithSuggestion(suggestedTerm: string) {
|
||||
if (!this.#lastTermToken || !this.#searchField) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -334,10 +344,10 @@ export class SearchWrapper extends BaseComponent {
|
||||
/**
|
||||
* Find the selected suggestion item(s) and unselect them. Similar to the logic implemented by the Philomena's
|
||||
* front-end.
|
||||
* @param {HTMLElement} suggestedElement Target element to search from. If element is disconnected from the DOM,
|
||||
* search will be halted.
|
||||
* @param suggestedElement Target element to search from. If element is disconnected from the DOM, search will be
|
||||
* halted.
|
||||
*/
|
||||
static #findAndResetSelectedSuggestion(suggestedElement) {
|
||||
static #findAndResetSelectedSuggestion(suggestedElement: HTMLElement) {
|
||||
if (!suggestedElement.parentElement) {
|
||||
return;
|
||||
}
|
||||
@@ -2,11 +2,10 @@ import { BaseComponent } from "$lib/components/base/BaseComponent";
|
||||
import { SearchWrapper } from "$lib/components/SearchWrapper";
|
||||
|
||||
class SiteHeaderWrapper extends BaseComponent {
|
||||
/** @type {SearchWrapper|null} */
|
||||
#searchWrapper = null;
|
||||
#searchWrapper: SearchWrapper | null = null;
|
||||
|
||||
build() {
|
||||
const searchForm = this.container.querySelector('.header__search');
|
||||
const searchForm = this.container.querySelector<HTMLElement>('.header__search');
|
||||
this.#searchWrapper = searchForm && new SearchWrapper(searchForm) || null;
|
||||
}
|
||||
|
||||
@@ -17,7 +16,7 @@ class SiteHeaderWrapper extends BaseComponent {
|
||||
}
|
||||
}
|
||||
|
||||
export function initializeSiteHeader(siteHeaderElement) {
|
||||
export function initializeSiteHeader(siteHeaderElement: HTMLElement) {
|
||||
new SiteHeaderWrapper(siteHeaderElement)
|
||||
.initialize();
|
||||
}
|
||||
@@ -3,45 +3,40 @@ import MaintenanceProfile from "$entities/MaintenanceProfile";
|
||||
import MaintenanceSettings from "$lib/extension/settings/MaintenanceSettings";
|
||||
import { getComponent } from "$lib/components/base/component-utils";
|
||||
import CustomCategoriesResolver from "$lib/extension/CustomCategoriesResolver";
|
||||
import { on } from "$lib/components/events/comms";
|
||||
import { EVENT_FORM_EDITOR_UPDATED } from "$lib/components/events/tags-form-events";
|
||||
import { EVENT_TAG_GROUP_RESOLVED } from "$lib/components/events/tag-dropdown-events";
|
||||
import type TagGroup from "$entities/TagGroup";
|
||||
|
||||
const isTagEditorProcessedKey = Symbol();
|
||||
const categoriesResolver = new CustomCategoriesResolver();
|
||||
|
||||
export class TagDropdownWrapper extends BaseComponent {
|
||||
/**
|
||||
* Container with dropdown elements to insert options into.
|
||||
* @type {HTMLElement}
|
||||
*/
|
||||
#dropdownContainer;
|
||||
#dropdownContainer: HTMLElement | null = null;
|
||||
|
||||
/**
|
||||
* Button to add or remove the current tag into/from the active profile.
|
||||
* @type {HTMLAnchorElement|null}
|
||||
*/
|
||||
#toggleOnExistingButton = null;
|
||||
#toggleOnExistingButton: HTMLAnchorElement | null = null;
|
||||
|
||||
/**
|
||||
* Button to create a new profile, make it active and add the current tag into the active profile.
|
||||
* @type {HTMLAnchorElement|null}
|
||||
*/
|
||||
#addToNewButton = null;
|
||||
#addToNewButton: HTMLAnchorElement | null = null;
|
||||
|
||||
/**
|
||||
* Local clone of the currently active profile used for updating the list of tags.
|
||||
* @type {MaintenanceProfile|null}
|
||||
*/
|
||||
#activeProfile = null;
|
||||
#activeProfile: MaintenanceProfile | null = null;
|
||||
|
||||
/**
|
||||
* Is cursor currently entered the dropdown.
|
||||
* @type {boolean}
|
||||
*/
|
||||
#isEntered = false;
|
||||
#isEntered: boolean = false;
|
||||
|
||||
/**
|
||||
* @type {string|undefined|null}
|
||||
*/
|
||||
#originalCategory = null;
|
||||
#originalCategory: string | undefined | null = null;
|
||||
|
||||
build() {
|
||||
this.#dropdownContainer = this.container.querySelector('.dropdown__content');
|
||||
@@ -58,6 +53,23 @@ export class TagDropdownWrapper extends BaseComponent {
|
||||
this.#updateButtons();
|
||||
}
|
||||
});
|
||||
|
||||
on(this, EVENT_TAG_GROUP_RESOLVED, this.#onTagGroupResolved.bind(this));
|
||||
}
|
||||
|
||||
#onTagGroupResolved(resolvedGroupEvent: CustomEvent<TagGroup | null>) {
|
||||
if (this.originalCategory) {
|
||||
return;
|
||||
}
|
||||
|
||||
const maybeTagGroup = resolvedGroupEvent.detail;
|
||||
|
||||
if (!maybeTagGroup) {
|
||||
this.tagCategory = this.originalCategory;
|
||||
return;
|
||||
}
|
||||
|
||||
this.tagCategory = maybeTagGroup.settings.category;
|
||||
}
|
||||
|
||||
get tagName() {
|
||||
@@ -116,7 +128,7 @@ export class TagDropdownWrapper extends BaseComponent {
|
||||
);
|
||||
|
||||
if (!this.#addToNewButton.isConnected) {
|
||||
this.#dropdownContainer.append(this.#addToNewButton);
|
||||
this.#dropdownContainer?.append(this.#addToNewButton);
|
||||
}
|
||||
} else {
|
||||
this.#addToNewButton?.remove();
|
||||
@@ -130,15 +142,16 @@ export class TagDropdownWrapper extends BaseComponent {
|
||||
|
||||
const profileName = this.#activeProfile.settings.name;
|
||||
let profileSpecificButtonText = `Add to profile "${profileName}"`;
|
||||
const tagName = this.tagName;
|
||||
|
||||
if (this.#activeProfile.settings.tags.includes(this.tagName)) {
|
||||
if (tagName && this.#activeProfile.settings.tags.includes(tagName)) {
|
||||
profileSpecificButtonText = `Remove from profile "${profileName}"`;
|
||||
}
|
||||
|
||||
this.#toggleOnExistingButton.innerText = profileSpecificButtonText;
|
||||
|
||||
if (!this.#toggleOnExistingButton.isConnected) {
|
||||
this.#dropdownContainer.append(this.#toggleOnExistingButton);
|
||||
this.#dropdownContainer?.append(this.#toggleOnExistingButton);
|
||||
}
|
||||
|
||||
return;
|
||||
@@ -148,6 +161,12 @@ export class TagDropdownWrapper extends BaseComponent {
|
||||
}
|
||||
|
||||
async #onAddToNewClicked() {
|
||||
const tagName = this.tagName;
|
||||
|
||||
if (!tagName) {
|
||||
throw new Error('Missing tag name to create the profile!');
|
||||
}
|
||||
|
||||
const profile = new MaintenanceProfile(crypto.randomUUID(), {
|
||||
name: 'Temporary Profile (' + (new Date().toISOString()) + ')',
|
||||
tags: [this.tagName],
|
||||
@@ -166,6 +185,10 @@ export class TagDropdownWrapper extends BaseComponent {
|
||||
const tagsList = new Set(this.#activeProfile.settings.tags);
|
||||
const targetTagName = this.tagName;
|
||||
|
||||
if (!targetTagName) {
|
||||
throw new Error('Missing tag name!');
|
||||
}
|
||||
|
||||
if (tagsList.has(targetTagName)) {
|
||||
tagsList.delete(targetTagName);
|
||||
} else {
|
||||
@@ -181,14 +204,14 @@ export class TagDropdownWrapper extends BaseComponent {
|
||||
|
||||
/**
|
||||
* Watch for changes to active profile.
|
||||
* @param {(profile: MaintenanceProfile|null) => void} onActiveProfileChange Callback to call when profile was
|
||||
* @param onActiveProfileChange Callback to call when profile was
|
||||
* changed.
|
||||
*/
|
||||
static #watchActiveProfile(onActiveProfileChange) {
|
||||
let lastActiveProfile;
|
||||
static #watchActiveProfile(onActiveProfileChange: (profile: MaintenanceProfile | null) => void) {
|
||||
let lastActiveProfile: string | null = null;
|
||||
|
||||
this.#maintenanceSettings.subscribe((settings) => {
|
||||
lastActiveProfile = settings.activeProfile;
|
||||
lastActiveProfile = settings.activeProfile ?? null;
|
||||
|
||||
this.#maintenanceSettings
|
||||
.resolveActiveProfileAsObject()
|
||||
@@ -199,7 +222,8 @@ export class TagDropdownWrapper extends BaseComponent {
|
||||
const activeProfile = profiles
|
||||
.find(profile => profile.id === lastActiveProfile);
|
||||
|
||||
onActiveProfileChange(activeProfile);
|
||||
onActiveProfileChange(activeProfile ?? null
|
||||
);
|
||||
});
|
||||
|
||||
this.#maintenanceSettings
|
||||
@@ -212,12 +236,11 @@ export class TagDropdownWrapper extends BaseComponent {
|
||||
|
||||
/**
|
||||
* Create element for dropdown.
|
||||
* @param {string} text Base text for the option.
|
||||
* @param {(event: MouseEvent) => void} onClickHandler Click handler. Event will be prevented by default.
|
||||
* @return {HTMLAnchorElement}
|
||||
* @param text Base text for the option.
|
||||
* @param onClickHandler Click handler. Event will be prevented by default.
|
||||
* @return
|
||||
*/
|
||||
static #createDropdownLink(text, onClickHandler) {
|
||||
/** @type {HTMLAnchorElement} */
|
||||
static #createDropdownLink(text: string, onClickHandler: (event: MouseEvent) => void): HTMLAnchorElement {
|
||||
const dropdownLink = document.createElement('a');
|
||||
dropdownLink.href = '#';
|
||||
dropdownLink.innerText = text;
|
||||
@@ -232,7 +255,7 @@ export class TagDropdownWrapper extends BaseComponent {
|
||||
}
|
||||
}
|
||||
|
||||
export function wrapTagDropdown(element) {
|
||||
export function wrapTagDropdown(element: HTMLElement) {
|
||||
// Skip initialization when tag component is already wrapped
|
||||
if (getComponent(element)) {
|
||||
return;
|
||||
@@ -244,6 +267,8 @@ export function wrapTagDropdown(element) {
|
||||
categoriesResolver.addElement(tagDropdown);
|
||||
}
|
||||
|
||||
const processedElementsSet = new WeakSet<HTMLElement>();
|
||||
|
||||
export function watchTagDropdownsInTagsEditor() {
|
||||
// We only need to watch for new editor elements if there is a tag editor present on the page
|
||||
if (!document.querySelector('#image_tags_and_source')) {
|
||||
@@ -251,26 +276,35 @@ export function watchTagDropdownsInTagsEditor() {
|
||||
}
|
||||
|
||||
document.body.addEventListener('mouseover', event => {
|
||||
/** @type {HTMLElement} */
|
||||
const targetElement = event.target;
|
||||
|
||||
if (targetElement[isTagEditorProcessedKey]) {
|
||||
if (!(targetElement instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
/** @type {HTMLElement|null} */
|
||||
const closestTagEditor = targetElement.closest('#image_tags_and_source');
|
||||
|
||||
if (!closestTagEditor || closestTagEditor[isTagEditorProcessedKey]) {
|
||||
targetElement[isTagEditorProcessedKey] = true;
|
||||
if (processedElementsSet.has(targetElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
targetElement[isTagEditorProcessedKey] = true;
|
||||
closestTagEditor[isTagEditorProcessedKey] = true;
|
||||
const closestTagEditor = targetElement.closest<HTMLElement>('#image_tags_and_source');
|
||||
|
||||
for (const tagDropdownElement of closestTagEditor.querySelectorAll('.tag.dropdown')) {
|
||||
if (!closestTagEditor || processedElementsSet.has(closestTagEditor)) {
|
||||
processedElementsSet.add(targetElement);
|
||||
return;
|
||||
}
|
||||
|
||||
processedElementsSet.add(targetElement);
|
||||
processedElementsSet.add(closestTagEditor);
|
||||
|
||||
for (const tagDropdownElement of closestTagEditor.querySelectorAll<HTMLElement>('.tag.dropdown')) {
|
||||
wrapTagDropdown(tagDropdownElement);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
// When form is submitted, its DOM is completely updated. We need to fetch those tags in this case.
|
||||
on(document.body, EVENT_FORM_EDITOR_UPDATED, event => {
|
||||
for (const tagDropdownElement of event.detail.querySelectorAll<HTMLElement>('.tag.dropdown')) {
|
||||
wrapTagDropdown(tagDropdownElement);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
import { BaseComponent } from "$lib/components/base/BaseComponent";
|
||||
import { getComponent } from "$lib/components/base/component-utils";
|
||||
|
||||
export class TagsForm extends BaseComponent {
|
||||
/**
|
||||
* Collect all the tag categories available on the page and color the tags in the editor according to them.
|
||||
*/
|
||||
refreshTagColors() {
|
||||
const tagCategories = this.#gatherTagCategories();
|
||||
const editableTags = this.container.querySelectorAll('.tag');
|
||||
|
||||
for (let tagElement of editableTags) {
|
||||
// Tag name is stored in the "remove" link and not in the tag itself.
|
||||
const removeLink = tagElement.querySelector('a');
|
||||
|
||||
if (!removeLink) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const tagName = removeLink.dataset.tagName;
|
||||
|
||||
if (!tagCategories.has(tagName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const categoryName = tagCategories.get(tagName);
|
||||
|
||||
tagElement.dataset.tagCategory = categoryName;
|
||||
tagElement.setAttribute('data-tag-category', categoryName);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect list of categories from the tags on the page.
|
||||
* @return {Map<string, string>}
|
||||
*/
|
||||
#gatherTagCategories() {
|
||||
/** @type {Map<string, string>} */
|
||||
const tagCategories = new Map();
|
||||
|
||||
for (let tagElement of document.querySelectorAll('.tag[data-tag-name][data-tag-category]')) {
|
||||
tagCategories.set(tagElement.dataset.tagName, tagElement.dataset.tagCategory);
|
||||
}
|
||||
|
||||
return tagCategories;
|
||||
}
|
||||
|
||||
static watchForEditors() {
|
||||
document.body.addEventListener('click', event => {
|
||||
const targetElement = event.target;
|
||||
|
||||
if (!(targetElement instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tagEditorWrapper = targetElement.closest('#image_tags_and_source');
|
||||
|
||||
if (!tagEditorWrapper) {
|
||||
return;
|
||||
}
|
||||
|
||||
const refreshTrigger = targetElement.closest('.js-taginput-show, #edit-tags')
|
||||
|
||||
if (!refreshTrigger) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tagFormElement = tagEditorWrapper.querySelector('#tags-form');
|
||||
|
||||
/** @type {TagsForm|null} */
|
||||
let tagEditor = getComponent(tagFormElement);
|
||||
|
||||
if (!tagEditor || (!tagEditor instanceof TagsForm)) {
|
||||
tagEditor = new TagsForm(tagFormElement);
|
||||
tagEditor.initialize();
|
||||
}
|
||||
|
||||
tagEditor.refreshTagColors();
|
||||
});
|
||||
}
|
||||
}
|
||||
150
src/lib/components/TagsForm.ts
Normal file
150
src/lib/components/TagsForm.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { BaseComponent } from "$lib/components/base/BaseComponent";
|
||||
import { getComponent } from "$lib/components/base/component-utils";
|
||||
import { emit, on, type UnsubscribeFunction } from "$lib/components/events/comms";
|
||||
import { EVENT_FETCH_COMPLETE } from "$lib/components/events/booru-events";
|
||||
import { EVENT_FORM_EDITOR_UPDATED } from "$lib/components/events/tags-form-events";
|
||||
|
||||
export class TagsForm extends BaseComponent {
|
||||
protected init() {
|
||||
// Site sending the event when form is submitted vie Fetch API. We use this event to reload our logic here.
|
||||
const unsubscribe = on(
|
||||
this.container,
|
||||
EVENT_FETCH_COMPLETE,
|
||||
() => this.#waitAndDetectUpdatedForm(unsubscribe),
|
||||
);
|
||||
}
|
||||
|
||||
#waitAndDetectUpdatedForm(unsubscribe: UnsubscribeFunction): void {
|
||||
const elementContainingTagEditor = this.container
|
||||
.closest('#image_tags_and_source')
|
||||
?.parentElement;
|
||||
|
||||
if (!elementContainingTagEditor) {
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
const tagsFormElement = elementContainingTagEditor.querySelector<HTMLElement>('#tags-form');
|
||||
|
||||
if (!tagsFormElement || getComponent(tagsFormElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tagFormComponent = new TagsForm(tagsFormElement);
|
||||
tagFormComponent.initialize();
|
||||
|
||||
const fullTagEditor = tagFormComponent.parentTagEditorElement;
|
||||
|
||||
if (fullTagEditor) {
|
||||
emit(document.body, EVENT_FORM_EDITOR_UPDATED, fullTagEditor);
|
||||
} else {
|
||||
console.info('Tag form is not in the tag editor. Event is not sent.');
|
||||
}
|
||||
|
||||
observer.disconnect();
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
observer.observe(elementContainingTagEditor, {
|
||||
subtree: true,
|
||||
childList: true,
|
||||
});
|
||||
|
||||
// Make sure to forcibly disconnect everything after a while.
|
||||
setTimeout(() => {
|
||||
observer.disconnect();
|
||||
unsubscribe();
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
get parentTagEditorElement(): HTMLElement | null {
|
||||
return this.container.closest<HTMLElement>('.js-tagsauce')
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect all the tag categories available on the page and color the tags in the editor according to them.
|
||||
*/
|
||||
refreshTagColors() {
|
||||
const tagCategories = this.#gatherTagCategories();
|
||||
const editableTags = this.container.querySelectorAll<HTMLElement>('.tag');
|
||||
|
||||
for (const tagElement of editableTags) {
|
||||
// Tag name is stored in the "remove" link and not in the tag itself.
|
||||
const removeLink = tagElement.querySelector('a');
|
||||
|
||||
if (!removeLink) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const tagName = removeLink.dataset.tagName;
|
||||
|
||||
if (!tagName || !tagCategories.has(tagName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const categoryName = tagCategories.get(tagName)!;
|
||||
|
||||
tagElement.dataset.tagCategory = categoryName;
|
||||
tagElement.setAttribute('data-tag-category', categoryName);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect list of categories from the tags on the page.
|
||||
* @return
|
||||
*/
|
||||
#gatherTagCategories(): Map<string, string> {
|
||||
const tagCategories: Map<string, string> = new Map();
|
||||
|
||||
for (const tagElement of document.querySelectorAll<HTMLElement>('.tag[data-tag-name][data-tag-category]')) {
|
||||
const tagName = tagElement.dataset.tagName;
|
||||
const tagCategory = tagElement.dataset.tagCategory;
|
||||
|
||||
if (!tagName || !tagCategory) {
|
||||
console.warn('Missing tag name or category!');
|
||||
continue;
|
||||
}
|
||||
|
||||
tagCategories.set(tagName, tagCategory);
|
||||
}
|
||||
|
||||
return tagCategories;
|
||||
}
|
||||
|
||||
static watchForEditors() {
|
||||
document.body.addEventListener('click', event => {
|
||||
const targetElement = event.target;
|
||||
|
||||
if (!(targetElement instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tagEditorWrapper = targetElement.closest('#image_tags_and_source');
|
||||
|
||||
if (!tagEditorWrapper) {
|
||||
return;
|
||||
}
|
||||
|
||||
const refreshTrigger = targetElement.closest<HTMLElement>('.js-taginput-show, #edit-tags')
|
||||
|
||||
if (!refreshTrigger) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tagFormElement = tagEditorWrapper.querySelector<HTMLElement>('#tags-form');
|
||||
|
||||
if (!tagFormElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
let tagEditor = getComponent(tagFormElement);
|
||||
|
||||
if (!tagEditor || !(tagEditor instanceof TagsForm)) {
|
||||
tagEditor = new TagsForm(tagFormElement);
|
||||
tagEditor.initialize();
|
||||
}
|
||||
|
||||
(tagEditor as TagsForm).refreshTagColors();
|
||||
});
|
||||
}
|
||||
}
|
||||
243
src/lib/components/TagsListBlock.ts
Normal file
243
src/lib/components/TagsListBlock.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
import { BaseComponent } from "$lib/components/base/BaseComponent";
|
||||
import type TagGroup from "$entities/TagGroup";
|
||||
import type { TagDropdownWrapper } from "$lib/components/TagDropdownWrapper";
|
||||
import { on } from "$lib/components/events/comms";
|
||||
import { EVENT_FORM_EDITOR_UPDATED } from "$lib/components/events/tags-form-events";
|
||||
import { getComponent } from "$lib/components/base/component-utils";
|
||||
import { EVENT_TAG_GROUP_RESOLVED } from "$lib/components/events/tag-dropdown-events";
|
||||
import TagSettings from "$lib/extension/settings/TagSettings";
|
||||
|
||||
export class TagsListBlock extends BaseComponent {
|
||||
#tagsListButtonsContainer: HTMLElement | null = null;
|
||||
#tagsListContainer: HTMLElement | null = null;
|
||||
|
||||
#toggleGroupingButton = document.createElement('a');
|
||||
#toggleGroupingButtonIcon = document.createElement('i');
|
||||
|
||||
#tagSettings = new TagSettings();
|
||||
|
||||
#shouldDisplaySeparation = false;
|
||||
|
||||
#separatedGroups = new Map<string, TagGroup>();
|
||||
#separatedHeaders = new Map<string, HTMLElement>();
|
||||
#groupsCount = new Map<string, number>();
|
||||
#lastTagGroup = new WeakMap<TagDropdownWrapper, TagGroup | null>;
|
||||
|
||||
#isReorderingPlanned = false;
|
||||
|
||||
protected build() {
|
||||
this.#tagsListButtonsContainer = this.container.querySelector('.block.tagsauce .block__header__buttons');
|
||||
this.#tagsListContainer = this.container.querySelector('.tag-list');
|
||||
|
||||
this.#toggleGroupingButton.innerText = ' Grouping';
|
||||
this.#toggleGroupingButton.href = 'javascript:void(0)';
|
||||
this.#toggleGroupingButton.classList.add('button', 'button--link', 'button--inline');
|
||||
this.#toggleGroupingButton.title = 'Toggle the global groups separation option. This will only toggle global ' +
|
||||
'setting without changing the separation of specific groups.';
|
||||
|
||||
this.#toggleGroupingButtonIcon.classList.add('fas', TagsListBlock.#iconGroupingDisabled);
|
||||
this.#toggleGroupingButton.prepend(this.#toggleGroupingButtonIcon);
|
||||
|
||||
if (this.#tagsListButtonsContainer) {
|
||||
this.#tagsListButtonsContainer.append(this.#toggleGroupingButton);
|
||||
}
|
||||
}
|
||||
|
||||
init() {
|
||||
this.#tagSettings.resolveGroupSeparation().then(this.#onTagSeparationChange.bind(this));
|
||||
this.#tagSettings.subscribe(settings => {
|
||||
this.#onTagSeparationChange(Boolean(settings.groupSeparation))
|
||||
});
|
||||
|
||||
on(
|
||||
this,
|
||||
EVENT_TAG_GROUP_RESOLVED,
|
||||
this.#onTagDropdownCustomGroupResolved.bind(this)
|
||||
);
|
||||
|
||||
this.#toggleGroupingButton.addEventListener('click', this.#onToggleGroupingClicked.bind(this));
|
||||
}
|
||||
|
||||
#onTagSeparationChange(isSeparationEnabled: boolean) {
|
||||
if (this.#shouldDisplaySeparation === isSeparationEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.#shouldDisplaySeparation = isSeparationEnabled;
|
||||
this.#reorderSeparatedGroups();
|
||||
this.#updateToggleSeparationButton();
|
||||
}
|
||||
|
||||
#updateToggleSeparationButton() {
|
||||
this.#toggleGroupingButtonIcon.classList.toggle(TagsListBlock.#iconGroupingEnabled, this.#shouldDisplaySeparation);
|
||||
this.#toggleGroupingButtonIcon.classList.toggle(TagsListBlock.#iconGroupingDisabled, !this.#shouldDisplaySeparation);
|
||||
}
|
||||
|
||||
#onTagDropdownCustomGroupResolved(resolvedCustomGroupEvent: CustomEvent<TagGroup | null>) {
|
||||
const maybeDropdownElement = resolvedCustomGroupEvent.target;
|
||||
|
||||
if (!(maybeDropdownElement instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tagDropdown = getComponent<TagDropdownWrapper>(maybeDropdownElement);
|
||||
|
||||
if (!tagDropdown) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tagGroup = resolvedCustomGroupEvent.detail;
|
||||
|
||||
if (tagGroup) {
|
||||
this.#handleTagGroupChanges(tagGroup);
|
||||
}
|
||||
|
||||
this.#handleResolvedTagGroup(tagGroup, tagDropdown);
|
||||
|
||||
if (!this.#isReorderingPlanned) {
|
||||
this.#isReorderingPlanned = true;
|
||||
|
||||
requestAnimationFrame(this.#reorderSeparatedGroups.bind(this));
|
||||
}
|
||||
}
|
||||
|
||||
#onToggleGroupingClicked(event: Event) {
|
||||
event.preventDefault();
|
||||
void this.#tagSettings.setGroupSeparation(!this.#shouldDisplaySeparation);
|
||||
}
|
||||
|
||||
#handleTagGroupChanges(tagGroup: TagGroup) {
|
||||
const groupId = tagGroup.id;
|
||||
const processedGroup = this.#separatedGroups.get(groupId);
|
||||
|
||||
if (!tagGroup.settings.separate && processedGroup) {
|
||||
this.#separatedGroups.delete(groupId);
|
||||
this.#separatedHeaders.get(groupId)?.remove();
|
||||
this.#separatedHeaders.delete(groupId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Every time group is updated, a new object is being initialized
|
||||
if (tagGroup !== processedGroup) {
|
||||
this.#createOrUpdateHeaderForGroup(tagGroup);
|
||||
this.#separatedGroups.set(groupId, tagGroup);
|
||||
}
|
||||
}
|
||||
|
||||
#createOrUpdateHeaderForGroup(group: TagGroup) {
|
||||
let heading = this.#separatedHeaders.get(group.id);
|
||||
|
||||
if (!heading) {
|
||||
heading = document.createElement('h2');
|
||||
|
||||
// Heading is hidden by default and shown on next frame if there are tags to show in the section.
|
||||
heading.style.display = 'none';
|
||||
heading.style.order = `var(${TagsListBlock.#orderCssVariableForGroup(group.id)}, 0)`;
|
||||
heading.style.flexBasis = '100%';
|
||||
|
||||
// We're inserting heading to the top just to make sure that heading is always in front of the tags related to
|
||||
// this category.
|
||||
this.#tagsListContainer?.insertAdjacentElement('afterbegin', heading);
|
||||
|
||||
this.#separatedHeaders.set(group.id, heading);
|
||||
}
|
||||
|
||||
heading.innerText = group.settings.name;
|
||||
}
|
||||
|
||||
#handleResolvedTagGroup(resolvedGroup: TagGroup | null, tagComponent: TagDropdownWrapper) {
|
||||
const previousGroupId = this.#lastTagGroup.get(tagComponent)?.id;
|
||||
const currentGroupId = resolvedGroup?.id;
|
||||
const isDifferentId = currentGroupId !== previousGroupId;
|
||||
const isSeparationEnabled = resolvedGroup?.settings.separate;
|
||||
|
||||
if (isDifferentId) {
|
||||
// Make sure to subtract the element from counters if there was a count before.
|
||||
if (previousGroupId && this.#groupsCount.has(previousGroupId)) {
|
||||
this.#groupsCount.set(previousGroupId, this.#groupsCount.get(previousGroupId)! - 1);
|
||||
}
|
||||
|
||||
// We only need to count groups which have separation enabled.
|
||||
if (currentGroupId && isSeparationEnabled) {
|
||||
const count = this.#groupsCount.get(resolvedGroup.id) ?? 0;
|
||||
this.#groupsCount.set(currentGroupId, count + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// We're adding the CSS order for the tag as the CSS variable. This variable is updated later.
|
||||
if (currentGroupId && isSeparationEnabled) {
|
||||
tagComponent.container.style.order = `var(${TagsListBlock.#orderCssVariableForGroup(currentGroupId)}, 0)`;
|
||||
} else {
|
||||
tagComponent.container.style.removeProperty('order');
|
||||
}
|
||||
|
||||
// If separation is disabled in the new group, then we should remove the tag from map, so it can be recaptured
|
||||
// when tag group is getting enabled later.
|
||||
if (currentGroupId && !isSeparationEnabled) {
|
||||
this.#lastTagGroup.delete(tagComponent);
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark this tag component as related to the following group.
|
||||
this.#lastTagGroup.set(tagComponent, resolvedGroup);
|
||||
}
|
||||
|
||||
#reorderSeparatedGroups() {
|
||||
this.#isReorderingPlanned = false;
|
||||
|
||||
const tagGroups = Array.from(this.#separatedGroups.values())
|
||||
.toSorted((a, b) => a.settings.name.localeCompare(b.settings.name));
|
||||
|
||||
for (let index = 0; index < tagGroups.length; index++) {
|
||||
const tagGroup = tagGroups[index];
|
||||
const groupId = tagGroup.id;
|
||||
const usedCount = this.#groupsCount.get(groupId);
|
||||
const relatedHeading = this.#separatedHeaders.get(groupId);
|
||||
|
||||
if (this.#shouldDisplaySeparation) {
|
||||
this.container.style.setProperty(TagsListBlock.#orderCssVariableForGroup(groupId), (index + 1).toString());
|
||||
} else {
|
||||
this.container.style.removeProperty(TagsListBlock.#orderCssVariableForGroup(groupId));
|
||||
}
|
||||
|
||||
if (relatedHeading) {
|
||||
if (!this.#shouldDisplaySeparation || !usedCount || usedCount <= 0) {
|
||||
relatedHeading.style.display = 'none';
|
||||
} else {
|
||||
relatedHeading.style.removeProperty('display');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static #orderCssVariableForGroup(groupId: string): string {
|
||||
return `--ta-order-${groupId}`;
|
||||
}
|
||||
|
||||
static #iconGroupingDisabled = 'fa-folder';
|
||||
static #iconGroupingEnabled = 'fa-folder-tree';
|
||||
}
|
||||
|
||||
export function initializeAllTagsLists() {
|
||||
for (let element of document.querySelectorAll<HTMLElement>('#image_tags_and_source')) {
|
||||
if (getComponent(element)) {
|
||||
return;
|
||||
}
|
||||
|
||||
new TagsListBlock(element)
|
||||
.initialize();
|
||||
}
|
||||
}
|
||||
|
||||
export function watchForUpdatedTagLists() {
|
||||
on(document, EVENT_FORM_EDITOR_UPDATED, event => {
|
||||
const tagsListElement = event.detail.closest<HTMLElement>('#image_tags_and_source');
|
||||
|
||||
if (!tagsListElement || getComponent(tagsListElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
new TagsListBlock(tagsListElement)
|
||||
.initialize();
|
||||
});
|
||||
}
|
||||
@@ -1,18 +1,14 @@
|
||||
import { bindComponent } from "$lib/components/base/component-utils";
|
||||
|
||||
/**
|
||||
* @abstract
|
||||
*/
|
||||
export class BaseComponent {
|
||||
/** @type {HTMLElement} */
|
||||
#container;
|
||||
type ComponentEventListener<EventName extends keyof HTMLElementEventMap> =
|
||||
(this: HTMLElement, event: HTMLElementEventMap[EventName]) => void;
|
||||
|
||||
export class BaseComponent<ContainerType extends HTMLElement = HTMLElement> {
|
||||
readonly #container: ContainerType;
|
||||
|
||||
#isInitialized = false;
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} container
|
||||
*/
|
||||
constructor(container) {
|
||||
constructor(container: ContainerType) {
|
||||
this.#container = container;
|
||||
|
||||
bindComponent(container, this);
|
||||
@@ -29,42 +25,33 @@ export class BaseComponent {
|
||||
this.init();
|
||||
}
|
||||
|
||||
/**
|
||||
* @protected
|
||||
*/
|
||||
build() {
|
||||
protected build(): void {
|
||||
// This method can be implemented by the component classes to modify or create the inner elements.
|
||||
}
|
||||
|
||||
/**
|
||||
* @protected
|
||||
*/
|
||||
init() {
|
||||
protected init(): void {
|
||||
// This method can be implemented by the component classes to initialize the component.
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @return {HTMLElement}
|
||||
*/
|
||||
get container() {
|
||||
get container(): ContainerType {
|
||||
return this.#container;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the component is initialized already. If not checked, subsequent calls to the `initialize` method will
|
||||
* throw an error.
|
||||
* @return {boolean}
|
||||
* @return
|
||||
*/
|
||||
get isInitialized() {
|
||||
get isInitialized(): boolean {
|
||||
return this.#isInitialized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit the custom event on the container element.
|
||||
* @param {keyof HTMLElementEventMap|string} event The event name.
|
||||
* @param {any} [detail] The event detail. Can be omitted.
|
||||
* @param event The event name.
|
||||
* @param [detail] The event detail. Can be omitted.
|
||||
*/
|
||||
emit(event, detail = undefined) {
|
||||
emit(event: keyof HTMLElementEventMap | string, detail: any = undefined): void {
|
||||
this.#container.dispatchEvent(
|
||||
new CustomEvent(
|
||||
event,
|
||||
@@ -78,12 +65,16 @@ export class BaseComponent {
|
||||
|
||||
/**
|
||||
* Subscribe to the DOM event on the container element.
|
||||
* @param {keyof HTMLElementEventMap|string} event The event name.
|
||||
* @param {function(Event): void} listener The event listener.
|
||||
* @param {AddEventListenerOptions|undefined} [options] The event listener options. Can be omitted.
|
||||
* @return {function(): void} The unsubscribe function.
|
||||
* @param event The event name.
|
||||
* @param listener The event listener.
|
||||
* @param [options] The event listener options. Can be omitted.
|
||||
* @return The unsubscribe function.
|
||||
*/
|
||||
on(event, listener, options = undefined) {
|
||||
on<EventName extends keyof HTMLElementEventMap>(
|
||||
event: EventName,
|
||||
listener: ComponentEventListener<EventName>,
|
||||
options?: AddEventListenerOptions,
|
||||
): () => void {
|
||||
this.#container.addEventListener(event, listener, options);
|
||||
|
||||
return () => void this.#container.removeEventListener(event, listener, options);
|
||||
@@ -91,12 +82,16 @@ export class BaseComponent {
|
||||
|
||||
/**
|
||||
* Subscribe to the DOM event on the container element. The event listener will be called only once.
|
||||
* @param {keyof HTMLElementEventMap|string} event The event name.
|
||||
* @param {function(Event): void} listener The event listener.
|
||||
* @param {AddEventListenerOptions|undefined} [options] The event listener options. Can be omitted.
|
||||
* @return {function(): void} The unsubscribe function.
|
||||
* @param event The event name.
|
||||
* @param listener The event listener.
|
||||
* @param [options] The event listener options. Can be omitted.
|
||||
* @return The unsubscribe function.
|
||||
*/
|
||||
once(event, listener, options = undefined) {
|
||||
once<EventName extends keyof HTMLElementEventMap>(
|
||||
event: EventName,
|
||||
listener: ComponentEventListener<EventName>,
|
||||
options?: AddEventListenerOptions,
|
||||
): () => void {
|
||||
options = options || {};
|
||||
options.once = true;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { BaseComponent } from "$lib/components/base/BaseComponent";
|
||||
|
||||
const instanceSymbol = Symbol('instance');
|
||||
const instanceSymbol = Symbol.for('instance');
|
||||
|
||||
interface ElementWithComponent extends HTMLElement {
|
||||
[instanceSymbol]?: BaseComponent;
|
||||
interface ElementWithComponent<T> extends HTMLElement {
|
||||
[instanceSymbol]?: T;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -11,7 +11,7 @@ interface ElementWithComponent extends HTMLElement {
|
||||
* @param {HTMLElement} element
|
||||
* @return
|
||||
*/
|
||||
export function getComponent(element: ElementWithComponent): BaseComponent | null {
|
||||
export function getComponent<T extends BaseComponent = BaseComponent>(element: ElementWithComponent<T>): T | null {
|
||||
return element[instanceSymbol] || null;
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ export function getComponent(element: ElementWithComponent): BaseComponent | nul
|
||||
* @param element The element to bind the component to.
|
||||
* @param instance The component instance.
|
||||
*/
|
||||
export function bindComponent(element: ElementWithComponent, instance: BaseComponent): void {
|
||||
export function bindComponent<T extends BaseComponent = BaseComponent>(element: ElementWithComponent<T>, instance: T): void {
|
||||
if (element[instanceSymbol]) {
|
||||
throw new Error('The element is already bound to a component.');
|
||||
}
|
||||
|
||||
5
src/lib/components/events/booru-events.ts
Normal file
5
src/lib/components/events/booru-events.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const EVENT_FETCH_COMPLETE = 'fetchcomplete';
|
||||
|
||||
export interface BooruEventsMap {
|
||||
[EVENT_FETCH_COMPLETE]: null; // Site sends the response, but extension will not get it due to isolation.
|
||||
}
|
||||
@@ -1,11 +1,19 @@
|
||||
import type { MaintenancePopupEventsMap } from "$lib/components/events/maintenance-popup-events";
|
||||
import { BaseComponent } from "$lib/components/base/BaseComponent";
|
||||
import type { FullscreenViewerEventsMap } from "$lib/components/events/fullscreen-viewer-events";
|
||||
import type { BooruEventsMap } from "$lib/components/events/booru-events";
|
||||
import type { TagsFormEventsMap } from "$lib/components/events/tags-form-events";
|
||||
import type { TagDropdownEvents } from "$lib/components/events/tag-dropdown-events";
|
||||
|
||||
interface EventsMapping extends MaintenancePopupEventsMap {
|
||||
}
|
||||
type EventsMapping =
|
||||
MaintenancePopupEventsMap
|
||||
& FullscreenViewerEventsMap
|
||||
& BooruEventsMap
|
||||
& TagsFormEventsMap
|
||||
& TagDropdownEvents;
|
||||
|
||||
type EventCallback<EventDetails> = (event: CustomEvent<EventDetails>) => void;
|
||||
type UnsubscribeFunction = () => void;
|
||||
export type UnsubscribeFunction = () => void;
|
||||
type ResolvableTarget = EventTarget | BaseComponent;
|
||||
|
||||
function resolveTarget(componentOrElement: ResolvableTarget): EventTarget {
|
||||
|
||||
7
src/lib/components/events/fullscreen-viewer-events.ts
Normal file
7
src/lib/components/events/fullscreen-viewer-events.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { FullscreenViewerSize } from "$lib/extension/settings/MiscSettings";
|
||||
|
||||
export const EVENT_SIZE_LOADED = 'size-loaded';
|
||||
|
||||
export interface FullscreenViewerEventsMap {
|
||||
[EVENT_SIZE_LOADED]: FullscreenViewerSize;
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
import type MaintenanceProfile from "$entities/MaintenanceProfile";
|
||||
|
||||
export const eventActiveProfileChanged = 'active-profile-changed';
|
||||
export const eventMaintenanceStateChanged = 'maintenance-state-change';
|
||||
export const eventTagsUpdated = 'tags-updated';
|
||||
export const EVENT_ACTIVE_PROFILE_CHANGED = 'active-profile-changed';
|
||||
export const EVENT_MAINTENANCE_STATE_CHANGED = 'maintenance-state-change';
|
||||
export const EVENT_TAGS_UPDATED = 'tags-updated';
|
||||
|
||||
type MaintenanceState = 'processing' | 'failed' | 'complete' | 'waiting';
|
||||
|
||||
export interface MaintenancePopupEventsMap {
|
||||
[eventActiveProfileChanged]: MaintenanceProfile | null;
|
||||
[eventMaintenanceStateChanged]: MaintenanceState;
|
||||
[eventTagsUpdated]: Map<string, string> | null;
|
||||
[EVENT_ACTIVE_PROFILE_CHANGED]: MaintenanceProfile | null;
|
||||
[EVENT_MAINTENANCE_STATE_CHANGED]: MaintenanceState;
|
||||
[EVENT_TAGS_UPDATED]: Map<string, string> | null;
|
||||
}
|
||||
|
||||
7
src/lib/components/events/tag-dropdown-events.ts
Normal file
7
src/lib/components/events/tag-dropdown-events.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type TagGroup from "$entities/TagGroup";
|
||||
|
||||
export const EVENT_TAG_GROUP_RESOLVED = 'tag-group-resolved';
|
||||
|
||||
export interface TagDropdownEvents {
|
||||
[EVENT_TAG_GROUP_RESOLVED]: TagGroup | null;
|
||||
}
|
||||
5
src/lib/components/events/tags-form-events.ts
Normal file
5
src/lib/components/events/tags-form-events.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const EVENT_FORM_EDITOR_UPDATED = 'tags-form-updated';
|
||||
|
||||
export interface TagsFormEventsMap {
|
||||
[EVENT_FORM_EDITOR_UPDATED]: HTMLElement;
|
||||
}
|
||||
@@ -2,12 +2,16 @@ import StorageHelper, { type StorageChangeSubscriber } from "$lib/browser/Storag
|
||||
|
||||
export default class ConfigurationController {
|
||||
readonly #configurationName: string;
|
||||
readonly #storage: StorageHelper;
|
||||
|
||||
/**
|
||||
* @param {string} configurationName Name of the configuration to work with.
|
||||
* @param {StorageHelper} [storage] Selected storage where the settings are stored in. If not provided, local storage
|
||||
* is used.
|
||||
*/
|
||||
constructor(configurationName: string) {
|
||||
constructor(configurationName: string, storage: StorageHelper = new StorageHelper(chrome.storage.local)) {
|
||||
this.#configurationName = configurationName;
|
||||
this.#storage = storage;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -19,7 +23,7 @@ export default class ConfigurationController {
|
||||
* @return The setting value or the default value if the setting does not exist.
|
||||
*/
|
||||
async readSetting<Type = any, DefaultType = any>(settingName: string, defaultValue: DefaultType | null = null): Promise<Type | DefaultType> {
|
||||
const settings = await ConfigurationController.#storageHelper.read(this.#configurationName, {});
|
||||
const settings = await this.#storage.read(this.#configurationName, {});
|
||||
return settings[settingName] ?? defaultValue;
|
||||
}
|
||||
|
||||
@@ -32,11 +36,11 @@ export default class ConfigurationController {
|
||||
* @return {Promise<void>}
|
||||
*/
|
||||
async writeSetting(settingName: string, value: any): Promise<void> {
|
||||
const settings = await ConfigurationController.#storageHelper.read(this.#configurationName, {});
|
||||
const settings = await this.#storage.read(this.#configurationName, {});
|
||||
|
||||
settings[settingName] = value;
|
||||
|
||||
ConfigurationController.#storageHelper.write(this.#configurationName, settings);
|
||||
this.#storage.write(this.#configurationName, settings);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -45,11 +49,11 @@ export default class ConfigurationController {
|
||||
* @param {string} settingName Setting name to delete.
|
||||
*/
|
||||
async deleteSetting(settingName: string): Promise<void> {
|
||||
const settings = await ConfigurationController.#storageHelper.read(this.#configurationName, {});
|
||||
const settings = await this.#storage.read(this.#configurationName, {});
|
||||
|
||||
delete settings[settingName];
|
||||
|
||||
ConfigurationController.#storageHelper.write(this.#configurationName, settings);
|
||||
this.#storage.write(this.#configurationName, settings);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -69,10 +73,8 @@ export default class ConfigurationController {
|
||||
callback(changes[this.#configurationName].newValue);
|
||||
}
|
||||
|
||||
ConfigurationController.#storageHelper.subscribe(subscriber);
|
||||
this.#storage.subscribe(subscriber);
|
||||
|
||||
return () => ConfigurationController.#storageHelper.unsubscribe(subscriber);
|
||||
return () => this.#storage.unsubscribe(subscriber);
|
||||
}
|
||||
|
||||
static #storageHelper = new StorageHelper(chrome.storage.local);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import type { TagDropdownWrapper } from "$lib/components/TagDropdownWrapper";
|
||||
import TagGroup from "$entities/TagGroup";
|
||||
import { escapeRegExp } from "$lib/utils";
|
||||
import { emit } from "$lib/components/events/comms";
|
||||
import { EVENT_TAG_GROUP_RESOLVED } from "$lib/components/events/tag-dropdown-events";
|
||||
|
||||
export default class CustomCategoriesResolver {
|
||||
#tagCategories = new Map<string, string>();
|
||||
#compiledRegExps = new Map<RegExp, string>();
|
||||
#exactGroupMatches = new Map<string, TagGroup>();
|
||||
#regExpGroupMatches = new Map<RegExp, TagGroup>();
|
||||
#tagDropdowns: TagDropdownWrapper[] = [];
|
||||
#nextQueuedUpdate = -1;
|
||||
#nextQueuedUpdate: Timeout | null = null;
|
||||
|
||||
constructor() {
|
||||
TagGroup.subscribe(this.#onTagGroupsReceived.bind(this));
|
||||
@@ -16,7 +18,7 @@ export default class CustomCategoriesResolver {
|
||||
public addElement(tagDropdown: TagDropdownWrapper): void {
|
||||
this.#tagDropdowns.push(tagDropdown);
|
||||
|
||||
if (!this.#tagCategories.size && !this.#compiledRegExps.size) {
|
||||
if (!this.#exactGroupMatches.size && !this.#regExpGroupMatches.size) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -24,7 +26,9 @@ export default class CustomCategoriesResolver {
|
||||
}
|
||||
|
||||
#queueUpdatingTags() {
|
||||
clearTimeout(this.#nextQueuedUpdate);
|
||||
if (this.#nextQueuedUpdate) {
|
||||
clearTimeout(this.#nextQueuedUpdate);
|
||||
}
|
||||
|
||||
this.#nextQueuedUpdate = setTimeout(
|
||||
this.#updateUnprocessedTags.bind(this),
|
||||
@@ -34,7 +38,6 @@ export default class CustomCategoriesResolver {
|
||||
|
||||
#updateUnprocessedTags() {
|
||||
this.#tagDropdowns
|
||||
.filter(CustomCategoriesResolver.#skipTagsWithOriginalCategory)
|
||||
.filter(this.#applyCustomCategoryForExactMatches.bind(this))
|
||||
.filter(this.#matchCustomCategoryByRegExp.bind(this))
|
||||
.forEach(CustomCategoriesResolver.#resetToOriginalCategory);
|
||||
@@ -49,23 +52,33 @@ export default class CustomCategoriesResolver {
|
||||
#applyCustomCategoryForExactMatches(tagDropdown: TagDropdownWrapper): boolean {
|
||||
const tagName = tagDropdown.tagName!;
|
||||
|
||||
if (!this.#tagCategories.has(tagName)) {
|
||||
if (!this.#exactGroupMatches.has(tagName)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
tagDropdown.tagCategory = this.#tagCategories.get(tagName)!;
|
||||
emit(
|
||||
tagDropdown,
|
||||
EVENT_TAG_GROUP_RESOLVED,
|
||||
this.#exactGroupMatches.get(tagName)!
|
||||
);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
#matchCustomCategoryByRegExp(tagDropdown: TagDropdownWrapper) {
|
||||
const tagName = tagDropdown.tagName!;
|
||||
|
||||
for (const targetRegularExpression of this.#compiledRegExps.keys()) {
|
||||
for (const targetRegularExpression of this.#regExpGroupMatches.keys()) {
|
||||
if (!targetRegularExpression.test(tagName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
tagDropdown.tagCategory = this.#compiledRegExps.get(targetRegularExpression)!;
|
||||
emit(
|
||||
tagDropdown,
|
||||
EVENT_TAG_GROUP_RESOLVED,
|
||||
this.#regExpGroupMatches.get(targetRegularExpression)!
|
||||
);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -73,24 +86,29 @@ export default class CustomCategoriesResolver {
|
||||
}
|
||||
|
||||
#onTagGroupsReceived(tagGroups: TagGroup[]) {
|
||||
this.#tagCategories.clear();
|
||||
this.#compiledRegExps.clear();
|
||||
this.#exactGroupMatches.clear();
|
||||
this.#regExpGroupMatches.clear();
|
||||
|
||||
if (!tagGroups.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const tagGroup of tagGroups) {
|
||||
const categoryName = tagGroup.settings.category;
|
||||
|
||||
for (const tagName of tagGroup.settings.tags) {
|
||||
this.#tagCategories.set(tagName, categoryName);
|
||||
this.#exactGroupMatches.set(tagName, tagGroup);
|
||||
}
|
||||
|
||||
for (const tagPrefix of tagGroup.settings.prefixes) {
|
||||
this.#compiledRegExps.set(
|
||||
this.#regExpGroupMatches.set(
|
||||
new RegExp(`^${escapeRegExp(tagPrefix)}`),
|
||||
categoryName
|
||||
tagGroup,
|
||||
);
|
||||
}
|
||||
|
||||
for (let tagSuffix of tagGroup.settings.suffixes) {
|
||||
this.#regExpGroupMatches.set(
|
||||
new RegExp(`${escapeRegExp(tagSuffix)}$`),
|
||||
tagGroup,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -98,12 +116,12 @@ export default class CustomCategoriesResolver {
|
||||
this.#queueUpdatingTags();
|
||||
}
|
||||
|
||||
static #skipTagsWithOriginalCategory(tagDropdown: TagDropdownWrapper): boolean {
|
||||
return !tagDropdown.originalCategory;
|
||||
}
|
||||
|
||||
static #resetToOriginalCategory(tagDropdown: TagDropdownWrapper): void {
|
||||
tagDropdown.tagCategory = tagDropdown.originalCategory;
|
||||
emit(
|
||||
tagDropdown,
|
||||
EVENT_TAG_GROUP_RESOLVED,
|
||||
null,
|
||||
);
|
||||
}
|
||||
|
||||
static #unprocessedTagsTimeout = 0;
|
||||
|
||||
@@ -4,7 +4,9 @@ export interface TagGroupSettings {
|
||||
name: string;
|
||||
tags: string[];
|
||||
prefixes: string[];
|
||||
suffixes: string[];
|
||||
category: string;
|
||||
separate: boolean;
|
||||
}
|
||||
|
||||
export default class TagGroup extends StorageEntity<TagGroupSettings> {
|
||||
@@ -13,7 +15,9 @@ export default class TagGroup extends StorageEntity<TagGroupSettings> {
|
||||
name: settings.name || '',
|
||||
tags: settings.tags || [],
|
||||
prefixes: settings.prefixes || [],
|
||||
category: settings.category || ''
|
||||
suffixes: settings.suffixes || [],
|
||||
category: settings.category || '',
|
||||
separate: Boolean(settings.separate),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import CacheableSettings from "$lib/extension/base/CacheableSettings";
|
||||
|
||||
export type FullscreenViewerSize = 'small' | 'medium' | 'large' | 'full';
|
||||
export type FullscreenViewerSize = keyof App.ImageURIs;
|
||||
|
||||
interface MiscSettingsFields {
|
||||
fullscreenViewer: boolean;
|
||||
|
||||
19
src/lib/extension/settings/TagSettings.ts
Normal file
19
src/lib/extension/settings/TagSettings.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import CacheableSettings from "$lib/extension/base/CacheableSettings";
|
||||
|
||||
interface TagSettingsFields {
|
||||
groupSeparation: boolean;
|
||||
}
|
||||
|
||||
export default class TagSettings extends CacheableSettings<TagSettingsFields> {
|
||||
constructor() {
|
||||
super("tag");
|
||||
}
|
||||
|
||||
async resolveGroupSeparation() {
|
||||
return this._resolveSetting("groupSeparation", true);
|
||||
}
|
||||
|
||||
async setGroupSeparation(value: boolean) {
|
||||
return this._writeSetting("groupSeparation", Boolean(value));
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,9 @@ const entitiesExporters: ExportersMap = {
|
||||
name: entity.settings.name,
|
||||
tags: entity.settings.tags,
|
||||
prefixes: entity.settings.prefixes,
|
||||
suffixes: entity.settings.suffixes,
|
||||
category: entity.settings.category,
|
||||
separate: entity.settings.separate,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
48
src/lib/popup-links.ts
Normal file
48
src/lib/popup-links.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
function resolveReplaceableLink(target: EventTarget | null = null): HTMLAnchorElement | null {
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const closestLink = target.closest('a');
|
||||
|
||||
if (
|
||||
closestLink instanceof HTMLAnchorElement
|
||||
&& !closestLink.search
|
||||
&& closestLink.origin === location.origin
|
||||
) {
|
||||
return closestLink;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function replaceLink(linkElement: HTMLAnchorElement) {
|
||||
const params = new URLSearchParams([
|
||||
['path', linkElement.pathname]
|
||||
]);
|
||||
|
||||
linkElement.search = params.toString();
|
||||
linkElement.pathname = "/index.html";
|
||||
}
|
||||
|
||||
export function initializeLinksReplacement(): () => void {
|
||||
const abortController = new AbortController();
|
||||
const replacementHandler = (event: Event) => {
|
||||
const closestLink = resolveReplaceableLink(event.target);
|
||||
|
||||
if (closestLink) {
|
||||
replaceLink(closestLink);
|
||||
}
|
||||
}
|
||||
|
||||
// Dynamically replace the links from the Svelte default links to the links usable for the popup.
|
||||
document.body.addEventListener('mousedown', replacementHandler, {
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
document.body.addEventListener('click', replacementHandler, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
|
||||
return () => abortController.abort();
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
import "../styles/popup.scss";
|
||||
import Header from "$components/layout/Header.svelte";
|
||||
import Footer from "$components/layout/Footer.svelte";
|
||||
import { initializeLinksReplacement } from "$lib/popup-links";
|
||||
import { onDestroy } from "svelte";
|
||||
|
||||
interface Props {
|
||||
children?: import('svelte').Snippet;
|
||||
@@ -12,6 +14,12 @@
|
||||
// Sort of a hack, detect if we rendered in the browser tab or in the popup.
|
||||
// Popup will always should have fixed 320px size, otherwise we consider it opened in the tab.
|
||||
document.body.classList.toggle('is-in-tab', window.innerWidth > 320);
|
||||
|
||||
const disconnectLinkReplacement = initializeLinksReplacement();
|
||||
|
||||
onDestroy(() => {
|
||||
disconnectLinkReplacement();
|
||||
})
|
||||
</script>
|
||||
|
||||
<Header/>
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import TagsEditor from "$components/tags/TagsEditor.svelte";
|
||||
import TagGroup from "$entities/TagGroup";
|
||||
import { tagGroups } from "$stores/entities/tag-groups";
|
||||
import CheckboxField from "$components/ui/forms/CheckboxField.svelte";
|
||||
|
||||
let groupId = $derived(page.params.id);
|
||||
|
||||
@@ -25,7 +26,9 @@
|
||||
let groupName = $state<string>('');
|
||||
let tagsList = $state<string[]>([]);
|
||||
let prefixesList = $state<string[]>([]);
|
||||
let tagCategory = $state<string>('');
|
||||
let suffixesList = $state<string[]>([]);
|
||||
let tagCategory = $state<string>('')
|
||||
let separateGroup = $state<boolean>(false);
|
||||
|
||||
$effect(() => {
|
||||
if (groupId === 'new') {
|
||||
@@ -40,7 +43,9 @@
|
||||
groupName = targetGroup.settings.name;
|
||||
tagsList = [...targetGroup.settings.tags].sort((a, b) => a.localeCompare(b));
|
||||
prefixesList = [...targetGroup.settings.prefixes].sort((a, b) => a.localeCompare(b));
|
||||
suffixesList = [...targetGroup.settings.suffixes].sort((a, b) => a.localeCompare(b));
|
||||
tagCategory = targetGroup.settings.category;
|
||||
separateGroup = targetGroup.settings.separate;
|
||||
});
|
||||
|
||||
async function saveGroup() {
|
||||
@@ -52,21 +57,36 @@
|
||||
targetGroup.settings.name = groupName;
|
||||
targetGroup.settings.tags = [...tagsList];
|
||||
targetGroup.settings.prefixes = [...prefixesList];
|
||||
targetGroup.settings.suffixes = [...suffixesList];
|
||||
targetGroup.settings.category = tagCategory;
|
||||
targetGroup.settings.separate = separateGroup;
|
||||
|
||||
await targetGroup.save();
|
||||
await goto(`/features/groups/${targetGroup.id}`);
|
||||
}
|
||||
|
||||
function mapPrefixNames(tagName: string): string {
|
||||
return `${tagName}*`;
|
||||
}
|
||||
|
||||
function mapSuffixNames(tagName: string): string {
|
||||
return `*${tagName}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Menu>
|
||||
<MenuItem href="/features/groups/{groupId}" icon="arrow-left">Back</MenuItem>
|
||||
<MenuItem href="/features/groups/{groupId === 'new' ? '' : groupId}" icon="arrow-left">Back</MenuItem>
|
||||
<hr>
|
||||
</Menu>
|
||||
<FormContainer>
|
||||
<FormControl label="Group Name">
|
||||
<TextField bind:value={groupName} placeholder="Group Name"></TextField>
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<CheckboxField bind:checked={separateGroup}>
|
||||
Display tags found by this group in separate list after all other tags.
|
||||
</CheckboxField>
|
||||
</FormControl>
|
||||
<FormControl label="Group Color">
|
||||
<TagCategorySelectField bind:value={tagCategory}/>
|
||||
</FormControl>
|
||||
@@ -77,7 +97,12 @@
|
||||
</TagsColorContainer>
|
||||
<TagsColorContainer targetCategory={tagCategory}>
|
||||
<FormControl label="Tag Prefixes">
|
||||
<TagsEditor bind:tags={prefixesList}/>
|
||||
<TagsEditor bind:tags={prefixesList} mapTagNames={mapPrefixNames}/>
|
||||
</FormControl>
|
||||
</TagsColorContainer>
|
||||
<TagsColorContainer targetCategory={tagCategory}>
|
||||
<FormControl label="Tag Suffixes">
|
||||
<TagsEditor bind:tags={suffixesList} mapTagNames={mapSuffixNames}/>
|
||||
</FormControl>
|
||||
</TagsColorContainer>
|
||||
</FormContainer>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import Menu from "$components/ui/menu/Menu.svelte";
|
||||
import MenuItem from "$components/ui/menu/MenuItem.svelte";
|
||||
import { stripBlacklistedTagsEnabled } from "$stores/preferences/maintenance";
|
||||
import { shouldSeparateTagGroups } from "$stores/preferences/tag";
|
||||
</script>
|
||||
|
||||
<Menu>
|
||||
@@ -17,4 +18,9 @@
|
||||
Automatically remove black-listed tags from the images
|
||||
</CheckboxField>
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<CheckboxField bind:checked={$shouldSeparateTagGroups}>
|
||||
Enable separation of custom tag groups on the image pages
|
||||
</CheckboxField>
|
||||
</FormControl>
|
||||
</FormContainer>
|
||||
|
||||
18
src/stores/preferences/tag.ts
Normal file
18
src/stores/preferences/tag.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { writable } from "svelte/store";
|
||||
import TagSettings from "$lib/extension/settings/TagSettings";
|
||||
|
||||
const tagSettings = new TagSettings();
|
||||
|
||||
export const shouldSeparateTagGroups = writable(false);
|
||||
|
||||
tagSettings.resolveGroupSeparation()
|
||||
.then(value => shouldSeparateTagGroups.set(value))
|
||||
.then(() => {
|
||||
shouldSeparateTagGroups.subscribe(value => {
|
||||
void tagSettings.setGroupSeparation(value);
|
||||
});
|
||||
|
||||
tagSettings.subscribe(settings => {
|
||||
shouldSeparateTagGroups.set(Boolean(settings.groupSeparation));
|
||||
});
|
||||
})
|
||||
@@ -203,6 +203,7 @@
|
||||
top: 5px;
|
||||
left: 5px;
|
||||
z-index: 1;
|
||||
background-color: booru-vars.$background-color;
|
||||
}
|
||||
|
||||
.close {
|
||||
|
||||
Reference in New Issue
Block a user