mirror of
https://github.com/koloml/furbooru-tagging-assistant.git
synced 2025-12-24 07:12:57 +00:00
Merge remote-tracking branch 'origin/release/0.4' into feature/auto-remove-temporary-profiles
# Conflicts: # src/lib/components/TagDropdownWrapper.js
This commit is contained in:
12
src/lib/booru/tag-categories.js
Normal file
12
src/lib/booru/tag-categories.js
Normal file
@@ -0,0 +1,12 @@
|
||||
export const categories = [
|
||||
'rating',
|
||||
'spoiler',
|
||||
'origin',
|
||||
'oc',
|
||||
'error',
|
||||
'character',
|
||||
'content-official',
|
||||
'content-fanmade',
|
||||
'species',
|
||||
'body-type',
|
||||
];
|
||||
@@ -1,13 +1,14 @@
|
||||
import {BaseComponent} from "$lib/components/base/BaseComponent.js";
|
||||
import MiscSettings from "$lib/extension/settings/MiscSettings.ts";
|
||||
|
||||
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} */
|
||||
@@ -16,15 +17,33 @@ export class FullscreenViewer extends BaseComponent {
|
||||
#startY = null;
|
||||
/** @type {boolean|null} */
|
||||
#isClosingSwipeStarted = null;
|
||||
#isSizeFetched = false;
|
||||
/** @type {App.ImageURIs|null} */
|
||||
#currentURIs = null;
|
||||
|
||||
/**
|
||||
* @protected
|
||||
*/
|
||||
build() {
|
||||
this.container.classList.add('fullscreen-viewer');
|
||||
this.container.append(this.#spinnerElement);
|
||||
|
||||
this.container.append(
|
||||
this.#spinnerElement,
|
||||
this.#sizeSelectorElement,
|
||||
this.#closeButtonElement,
|
||||
);
|
||||
|
||||
this.#spinnerElement.classList.add('spinner', 'fa', 'fa-circle-notch', 'fa-spin');
|
||||
this.#closeButtonElement.classList.add('close', 'fa', 'fa-xmark');
|
||||
this.#sizeSelectorElement.classList.add('size-selector', 'input');
|
||||
|
||||
for (const [sizeKey, sizeName] of Object.entries(FullscreenViewer.#previewSizes)) {
|
||||
const sizeOptionElement = document.createElement('option');
|
||||
sizeOptionElement.value = sizeKey;
|
||||
sizeOptionElement.innerText = sizeName;
|
||||
|
||||
this.#sizeSelectorElement.append(sizeOptionElement);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -40,6 +59,12 @@ export class FullscreenViewer extends BaseComponent {
|
||||
|
||||
this.#videoElement.addEventListener('loadeddata', this.#onLoaded.bind(this));
|
||||
this.#imageElement.addEventListener('load', this.#onLoaded.bind(this));
|
||||
this.#sizeSelectorElement.addEventListener('click', event => event.stopPropagation());
|
||||
|
||||
FullscreenViewer.#miscSettings
|
||||
.resolveFullscreenViewerPreviewSize()
|
||||
.then(this.#onSizeResolved.bind(this))
|
||||
.then(this.#watchForSizeSelectionChanges.bind(this));
|
||||
}
|
||||
|
||||
#onLoaded() {
|
||||
@@ -163,7 +188,49 @@ export class FullscreenViewer extends BaseComponent {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("$lib/extension/settings/MiscSettings.js").FullscreenViewerSize} size
|
||||
*/
|
||||
#onSizeResolved(size) {
|
||||
this.#sizeSelectorElement.value = size;
|
||||
this.#isSizeFetched = true;
|
||||
|
||||
this.emit('size-loaded');
|
||||
}
|
||||
|
||||
#watchForSizeSelectionChanges() {
|
||||
let lastActiveSize = this.#sizeSelectorElement.value;
|
||||
|
||||
FullscreenViewer.#miscSettings.subscribe(settings => {
|
||||
const targetSize = settings.fullscreenViewerSize;
|
||||
|
||||
if (!targetSize || lastActiveSize === targetSize) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastActiveSize = targetSize;
|
||||
this.#sizeSelectorElement.value = targetSize;
|
||||
});
|
||||
|
||||
this.#sizeSelectorElement.addEventListener('input', () => {
|
||||
const targetSize = this.#sizeSelectorElement.value;
|
||||
|
||||
if (this.#currentURIs) {
|
||||
void this.show(this.#currentURIs);
|
||||
}
|
||||
|
||||
if (!targetSize || targetSize === lastActiveSize || !(targetSize in FullscreenViewer.#previewSizes)) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastActiveSize = targetSize;
|
||||
void FullscreenViewer.#miscSettings.setFullscreenViewerPreviewSize(targetSize);
|
||||
});
|
||||
}
|
||||
|
||||
#close() {
|
||||
this.#currentURIs = null;
|
||||
|
||||
this.container.classList.remove(FullscreenViewer.#shownState);
|
||||
document.body.style.overflow = null;
|
||||
|
||||
@@ -175,9 +242,44 @@ export class FullscreenViewer extends BaseComponent {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} url
|
||||
* @param {App.ImageURIs} imageUris
|
||||
* @return {Promise<string|null>}
|
||||
*/
|
||||
show(url) {
|
||||
async #resolveCurrentSelectedSizeUrl(imageUris) {
|
||||
if (!this.#isSizeFetched) {
|
||||
await new Promise(resolve => this.on('size-loaded', resolve))
|
||||
}
|
||||
|
||||
let targetSize = this.#sizeSelectorElement.value;
|
||||
|
||||
if (!imageUris.hasOwnProperty(targetSize)) {
|
||||
targetSize = FullscreenViewer.#fallbackSize;
|
||||
}
|
||||
|
||||
if (!imageUris.hasOwnProperty(targetSize)) {
|
||||
targetSize = Object.keys(imageUris)[0];
|
||||
}
|
||||
|
||||
if (!targetSize) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return imageUris[targetSize];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {App.ImageURIs} imageUris
|
||||
*/
|
||||
async show(imageUris) {
|
||||
this.#currentURIs = imageUris;
|
||||
|
||||
const url = await this.#resolveCurrentSelectedSizeUrl(imageUris);
|
||||
|
||||
if (!url) {
|
||||
console.warn('Failed to resolve media for the viewer!');
|
||||
return;
|
||||
}
|
||||
|
||||
this.container.classList.add('loading');
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
@@ -214,9 +316,23 @@ export class FullscreenViewer extends BaseComponent {
|
||||
return url.endsWith('.mp4') || url.endsWith('.webm');
|
||||
}
|
||||
|
||||
static #miscSettings = new MiscSettings();
|
||||
|
||||
static #offsetProperty = '--offset';
|
||||
static #opacityProperty = '--opacity';
|
||||
static #shownState = 'shown';
|
||||
static #swipeState = 'swiped';
|
||||
static #minRequiredDistance = 50;
|
||||
|
||||
/**
|
||||
* @type {Record<import("$lib/extension/settings/MiscSettings.js").FullscreenViewerSize, string>}
|
||||
*/
|
||||
static #previewSizes = {
|
||||
full: 'Full',
|
||||
large: 'Large',
|
||||
medium: 'Medium',
|
||||
small: 'Small'
|
||||
}
|
||||
|
||||
static #fallbackSize = 'large';
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ export class ImageShowFullscreenButton extends BaseComponent {
|
||||
})
|
||||
.then(() => {
|
||||
ImageShowFullscreenButton.#miscSettings.subscribe(settings => {
|
||||
this.#isFullscreenButtonEnabled = settings.fullscreenViewer;
|
||||
this.#isFullscreenButtonEnabled = settings.fullscreenViewer ?? true;
|
||||
this.#updateFullscreenButtonVisibility();
|
||||
})
|
||||
})
|
||||
@@ -47,7 +47,7 @@ export class ImageShowFullscreenButton extends BaseComponent {
|
||||
#onButtonClicked() {
|
||||
ImageShowFullscreenButton
|
||||
.#resolveViewer()
|
||||
.show(this.#mediaBoxTools.mediaBox.imageLinks.large);
|
||||
.show(this.#mediaBoxTools.mediaBox.imageLinks);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -56,7 +56,7 @@ export class MediaBoxWrapper extends BaseComponent {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {ImageURIs}
|
||||
* @return {App.ImageURIs}
|
||||
*/
|
||||
get imageLinks() {
|
||||
return JSON.parse(this.#thumbnailContainer.dataset.uris);
|
||||
@@ -100,10 +100,3 @@ export function calculateMediaBoxesPositions(mediaBoxesList) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} ImageURIs
|
||||
* @property {string} full
|
||||
* @property {string} large
|
||||
* @property {string} small
|
||||
*/
|
||||
|
||||
@@ -2,10 +2,12 @@ import {BaseComponent} from "$lib/components/base/BaseComponent.js";
|
||||
import MaintenanceProfile from "$entities/MaintenanceProfile.ts";
|
||||
import MaintenanceSettings from "$lib/extension/settings/MaintenanceSettings.ts";
|
||||
import {getComponent} from "$lib/components/base/ComponentUtils.js";
|
||||
import CustomCategoriesResolver from "$lib/extension/CustomCategoriesResolver";
|
||||
|
||||
const isTagEditorProcessedKey = Symbol();
|
||||
const categoriesResolver = new CustomCategoriesResolver();
|
||||
|
||||
class TagDropdownWrapper extends BaseComponent {
|
||||
export class TagDropdownWrapper extends BaseComponent {
|
||||
/**
|
||||
* Container with dropdown elements to insert options into.
|
||||
* @type {HTMLElement}
|
||||
@@ -36,6 +38,11 @@ class TagDropdownWrapper extends BaseComponent {
|
||||
*/
|
||||
#isEntered = false;
|
||||
|
||||
/**
|
||||
* @type {string|undefined|null}
|
||||
*/
|
||||
#originalCategory = null;
|
||||
|
||||
build() {
|
||||
this.#dropdownContainer = this.container.querySelector('.dropdown__content');
|
||||
}
|
||||
@@ -53,10 +60,45 @@ class TagDropdownWrapper extends BaseComponent {
|
||||
});
|
||||
}
|
||||
|
||||
get #tagName() {
|
||||
get tagName() {
|
||||
return this.container.dataset.tagName;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {string|undefined}
|
||||
*/
|
||||
get tagCategory() {
|
||||
return this.container.dataset.tagCategory;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string|undefined} targetCategory
|
||||
*/
|
||||
set tagCategory(targetCategory) {
|
||||
// Make sure original category is properly stored.
|
||||
this.originalCategory;
|
||||
|
||||
this.container.dataset.tagCategory = targetCategory;
|
||||
|
||||
if (targetCategory) {
|
||||
this.container.setAttribute('data-tag-category', targetCategory);
|
||||
return;
|
||||
}
|
||||
|
||||
this.container.removeAttribute('data-tag-category');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {string|undefined}
|
||||
*/
|
||||
get originalCategory() {
|
||||
if (this.#originalCategory === null) {
|
||||
this.#originalCategory = this.tagCategory;
|
||||
}
|
||||
|
||||
return this.#originalCategory;
|
||||
}
|
||||
|
||||
#onDropdownEntered() {
|
||||
this.#isEntered = true;
|
||||
this.#updateButtons();
|
||||
@@ -89,7 +131,7 @@ class TagDropdownWrapper extends BaseComponent {
|
||||
const profileName = this.#activeProfile.settings.name;
|
||||
let profileSpecificButtonText = `Add to profile "${profileName}"`;
|
||||
|
||||
if (this.#activeProfile.settings.tags.includes(this.#tagName)) {
|
||||
if (this.#activeProfile.settings.tags.includes(this.tagName)) {
|
||||
profileSpecificButtonText = `Remove from profile "${profileName}"`;
|
||||
}
|
||||
|
||||
@@ -108,7 +150,7 @@ class TagDropdownWrapper extends BaseComponent {
|
||||
async #onAddToNewClicked() {
|
||||
const profile = new MaintenanceProfile(crypto.randomUUID(), {
|
||||
name: 'Temporary Profile (' + (new Date().toISOString()) + ')',
|
||||
tags: [this.#tagName],
|
||||
tags: [this.tagName],
|
||||
temporary: true,
|
||||
});
|
||||
|
||||
@@ -122,7 +164,7 @@ class TagDropdownWrapper extends BaseComponent {
|
||||
}
|
||||
|
||||
const tagsList = new Set(this.#activeProfile.settings.tags);
|
||||
const targetTagName = this.#tagName;
|
||||
const targetTagName = this.tagName;
|
||||
|
||||
if (tagsList.has(targetTagName)) {
|
||||
tagsList.delete(targetTagName);
|
||||
@@ -196,7 +238,10 @@ export function wrapTagDropdown(element) {
|
||||
return;
|
||||
}
|
||||
|
||||
new TagDropdownWrapper(element).initialize();
|
||||
const tagDropdown = new TagDropdownWrapper(element);
|
||||
tagDropdown.initialize();
|
||||
|
||||
categoriesResolver.addElement(tagDropdown);
|
||||
}
|
||||
|
||||
export function watchTagDropdownsInTagsEditor() {
|
||||
|
||||
115
src/lib/extension/CustomCategoriesResolver.ts
Normal file
115
src/lib/extension/CustomCategoriesResolver.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import type {TagDropdownWrapper} from "$lib/components/TagDropdownWrapper";
|
||||
import TagGroup from "$entities/TagGroup.ts";
|
||||
import {escapeRegExp} from "$lib/utils";
|
||||
|
||||
export default class CustomCategoriesResolver {
|
||||
#tagCategories = new Map<string, string>();
|
||||
#compiledRegExps = new Map<RegExp, string>();
|
||||
#tagDropdowns: TagDropdownWrapper[] = [];
|
||||
#lastProcessedIndex = -1;
|
||||
#nextQueuedUpdate = -1;
|
||||
|
||||
constructor() {
|
||||
TagGroup.subscribe(this.#onTagGroupsReceived.bind(this));
|
||||
TagGroup.readAll().then(this.#onTagGroupsReceived.bind(this));
|
||||
}
|
||||
|
||||
public addElement(tagDropdown: TagDropdownWrapper): void {
|
||||
this.#tagDropdowns.push(tagDropdown);
|
||||
|
||||
if (!this.#tagCategories.size && !this.#compiledRegExps.size) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.#queueUpdatingTags();
|
||||
}
|
||||
|
||||
#queueUpdatingTags() {
|
||||
clearTimeout(this.#nextQueuedUpdate);
|
||||
|
||||
this.#nextQueuedUpdate = setTimeout(
|
||||
this.#updateUnprocessedTags.bind(this),
|
||||
CustomCategoriesResolver.#unprocessedTagsTimeout
|
||||
);
|
||||
}
|
||||
|
||||
#updateUnprocessedTags() {
|
||||
const startIndex = Math.max(0, this.#lastProcessedIndex);
|
||||
|
||||
this.#tagDropdowns
|
||||
.slice(startIndex)
|
||||
.filter(CustomCategoriesResolver.#skipTagsWithOriginalCategory)
|
||||
.filter(this.#applyCustomCategoryForExactMatches.bind(this))
|
||||
.filter(this.#matchCustomCategoryByRegExp.bind(this))
|
||||
.forEach(CustomCategoriesResolver.#resetToOriginalCategory);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply custom categories for the exact tag names.
|
||||
* @param tagDropdown Element to try applying the category for.
|
||||
* @return {boolean} Will return false when tag is processed and true when it is not found.
|
||||
* @private
|
||||
*/
|
||||
#applyCustomCategoryForExactMatches(tagDropdown: TagDropdownWrapper): boolean {
|
||||
const tagName = tagDropdown.tagName!;
|
||||
|
||||
if (!this.#tagCategories.has(tagName)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
tagDropdown.tagCategory = this.#tagCategories.get(tagName)!;
|
||||
return false;
|
||||
}
|
||||
|
||||
#matchCustomCategoryByRegExp(tagDropdown: TagDropdownWrapper) {
|
||||
const tagName = tagDropdown.tagName!;
|
||||
|
||||
for (const targetRegularExpression of this.#compiledRegExps.keys()) {
|
||||
if (!targetRegularExpression.test(tagName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
tagDropdown.tagCategory = this.#compiledRegExps.get(targetRegularExpression)!;
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
#onTagGroupsReceived(tagGroups: TagGroup[]) {
|
||||
this.#tagCategories.clear();
|
||||
this.#compiledRegExps.clear();
|
||||
this.#lastProcessedIndex = -1;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
for (const tagPrefix of tagGroup.settings.prefixes) {
|
||||
this.#compiledRegExps.set(
|
||||
new RegExp(`^${escapeRegExp(tagPrefix)}`),
|
||||
categoryName
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
this.#queueUpdatingTags();
|
||||
}
|
||||
|
||||
static #skipTagsWithOriginalCategory(tagDropdown: TagDropdownWrapper): boolean {
|
||||
return !tagDropdown.originalCategory;
|
||||
}
|
||||
|
||||
static #resetToOriginalCategory(tagDropdown: TagDropdownWrapper): void {
|
||||
tagDropdown.tagCategory = tagDropdown.originalCategory;
|
||||
}
|
||||
|
||||
static #unprocessedTagsTimeout = 0;
|
||||
}
|
||||
21
src/lib/extension/entities/TagGroup.ts
Normal file
21
src/lib/extension/entities/TagGroup.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import StorageEntity from "$lib/extension/base/StorageEntity.ts";
|
||||
|
||||
export interface TagGroupSettings {
|
||||
name: string;
|
||||
tags: string[];
|
||||
prefixes: string[];
|
||||
category: string;
|
||||
}
|
||||
|
||||
export default class TagGroup extends StorageEntity<TagGroupSettings> {
|
||||
constructor(id: string, settings: Partial<TagGroupSettings>) {
|
||||
super(id, {
|
||||
name: settings.name || '',
|
||||
tags: settings.tags || [],
|
||||
prefixes: settings.prefixes || [],
|
||||
category: settings.category || ''
|
||||
});
|
||||
}
|
||||
|
||||
static _entityName = 'groups';
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import CacheableSettings from "$lib/extension/base/CacheableSettings.ts";
|
||||
|
||||
export type FullscreenViewerSize = 'small' | 'medium' | 'large' | 'full';
|
||||
|
||||
interface MiscSettingsFields {
|
||||
fullscreenViewer: boolean;
|
||||
fullscreenViewerSize: FullscreenViewerSize;
|
||||
}
|
||||
|
||||
export default class MiscSettings extends CacheableSettings<MiscSettingsFields> {
|
||||
@@ -13,7 +16,15 @@ export default class MiscSettings extends CacheableSettings<MiscSettingsFields>
|
||||
return this._resolveSetting("fullscreenViewer", true);
|
||||
}
|
||||
|
||||
async resolveFullscreenViewerPreviewSize() {
|
||||
return this._resolveSetting('fullscreenViewerSize', 'large');
|
||||
}
|
||||
|
||||
async setFullscreenViewerEnabled(isEnabled: boolean) {
|
||||
return this._writeSetting("fullscreenViewer", isEnabled);
|
||||
}
|
||||
|
||||
async setFullscreenViewerPreviewSize(size: FullscreenViewerSize | string) {
|
||||
return this._writeSetting('fullscreenViewerSize', size as FullscreenViewerSize);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,15 @@ const entitiesExporters: ExportersMap = {
|
||||
tags: entity.settings.tags,
|
||||
}
|
||||
},
|
||||
groups: entity => {
|
||||
return {
|
||||
v: 1,
|
||||
id: entity.id,
|
||||
name: entity.settings.name,
|
||||
tags: entity.settings.tags,
|
||||
prefixes: entity.settings.prefixes,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export function exportEntityToObject(entityInstance: StorageEntity<any>, entityName: string): Record<string, any> {
|
||||
|
||||
@@ -21,3 +21,23 @@ export function findDeepObject(targetObject, path) {
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches all the characters needing replacement.
|
||||
*
|
||||
* Gathered from right here: https://stackoverflow.com/a/3561711/16048617. Because I don't want to introduce some
|
||||
* library for that.
|
||||
*
|
||||
* @type {RegExp}
|
||||
*/
|
||||
const unsafeRegExpCharacters = /[/\-\\^$*+?.()|[\]{}]/g;
|
||||
|
||||
/**
|
||||
* Escape all the RegExp syntax-related characters in the following value.
|
||||
* @param {string} value Original value.
|
||||
* @return {string} Resulting value with all needed characters escaped.
|
||||
*/
|
||||
export function escapeRegExp(value) {
|
||||
unsafeRegExpCharacters.lastIndex = 0;
|
||||
return value.replace(unsafeRegExpCharacters, "\\$&");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user