mirror of
https://github.com/koloml/philomena-tagging-assistant.git
synced 2026-05-09 15:12:21 +00:00
Moving all content_scripts-related components under $content directory
Having $lib/component with just $component was a bit confusing, especially since $lib is also used in Svelte components all over the place. This move will hopefully make it less confusing for me.
This commit is contained in:
308
src/content/components/FullscreenViewer.ts
Normal file
308
src/content/components/FullscreenViewer.ts
Normal file
@@ -0,0 +1,308 @@
|
||||
import { BaseComponent } from "$content/components/base/BaseComponent";
|
||||
import MiscSettings, { type FullscreenViewerSize } from "$lib/extension/settings/MiscSettings";
|
||||
import { emit, on } from "$content/components/events/comms";
|
||||
import { EVENT_SIZE_LOADED } from "$content/components/events/fullscreen-viewer-events";
|
||||
|
||||
export class FullscreenViewer extends BaseComponent {
|
||||
#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() {
|
||||
this.container.classList.add('fullscreen-viewer');
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @protected
|
||||
*/
|
||||
init() {
|
||||
document.addEventListener('keydown', this.#onDocumentKeyPressed.bind(this));
|
||||
this.on('click', this.#close.bind(this));
|
||||
|
||||
this.on('touchstart', this.#onTouchStart.bind(this));
|
||||
this.on('touchmove', this.#onTouchMove.bind(this));
|
||||
this.on('touchend', this.#onTouchEnd.bind(this));
|
||||
|
||||
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() {
|
||||
this.container.classList.remove('loading');
|
||||
}
|
||||
|
||||
#onTouchStart(event: TouchEvent) {
|
||||
if (this.#touchId !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const firstTouch = event.touches.item(0);
|
||||
|
||||
if (!firstTouch) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.#touchId = firstTouch.identifier;
|
||||
this.#startX = firstTouch.clientX;
|
||||
this.#startY = firstTouch.clientY;
|
||||
|
||||
this.container.classList.add(FullscreenViewer.#swipeState);
|
||||
}
|
||||
|
||||
#onTouchEnd(event: TouchEvent) {
|
||||
if (this.#touchId === null || this.#startY === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const endedTouch = Array.from(event.changedTouches)
|
||||
.find(touch => touch.identifier === this.#touchId);
|
||||
|
||||
if (!endedTouch) {
|
||||
return;
|
||||
}
|
||||
|
||||
const verticalDistance = Math.abs(endedTouch.clientY - this.#startY);
|
||||
const requiredClosingDistance = window.innerHeight / 3;
|
||||
|
||||
if (this.#isClosingSwipeStarted && verticalDistance > requiredClosingDistance) {
|
||||
this.#close();
|
||||
}
|
||||
|
||||
this.#touchId = null;
|
||||
this.#startX = null;
|
||||
this.#startY = null;
|
||||
this.#isClosingSwipeStarted = null;
|
||||
|
||||
this.container.classList.remove(FullscreenViewer.#swipeState);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
this.container.style.removeProperty(FullscreenViewer.#offsetProperty);
|
||||
this.container.style.removeProperty(FullscreenViewer.#opacityProperty);
|
||||
});
|
||||
}
|
||||
|
||||
#onTouchMove(event: TouchEvent) {
|
||||
if (this.#touchId === null || this.#startY === null || this.#startX === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.#isClosingSwipeStarted === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const changedTouch of event.changedTouches) {
|
||||
if (changedTouch.identifier !== this.#touchId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const verticalDistance = changedTouch.clientY - this.#startY;
|
||||
|
||||
if (this.#isClosingSwipeStarted === null) {
|
||||
const horizontalDistance = changedTouch.clientX - this.#startX;
|
||||
|
||||
if (Math.abs(verticalDistance) >= FullscreenViewer.#minRequiredDistance) {
|
||||
this.#isClosingSwipeStarted = true;
|
||||
} else if (Math.abs(horizontalDistance) >= FullscreenViewer.#minRequiredDistance) {
|
||||
this.#isClosingSwipeStarted = false;
|
||||
break;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
this.container.style.setProperty(
|
||||
FullscreenViewer.#offsetProperty,
|
||||
verticalDistance.toString().concat('px')
|
||||
);
|
||||
|
||||
const maxDistance = window.innerHeight * 2;
|
||||
let opacity = 1;
|
||||
|
||||
if (verticalDistance !== 0) {
|
||||
opacity -= Math.min(1, Math.abs(verticalDistance) / maxDistance);
|
||||
}
|
||||
|
||||
this.container.style.setProperty(
|
||||
FullscreenViewer.#opacityProperty,
|
||||
opacity.toString()
|
||||
);
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
#onDocumentKeyPressed(event: KeyboardEvent) {
|
||||
if (event.code === 'Escape' || event.code === 'Esc') {
|
||||
this.#close();
|
||||
}
|
||||
}
|
||||
|
||||
#onSizeResolved(size: FullscreenViewerSize) {
|
||||
this.#sizeSelectorElement.value = size;
|
||||
this.#isSizeFetched = true;
|
||||
|
||||
emit(this.container, EVENT_SIZE_LOADED, size);
|
||||
}
|
||||
|
||||
#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.removeProperty('overflow');
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
this.#videoElement.volume = 0;
|
||||
this.#videoElement.pause();
|
||||
this.#videoElement.remove();
|
||||
});
|
||||
}
|
||||
|
||||
async #resolveCurrentSelectedSizeUrl(imageUris: App.ImageURIs): Promise<string | null> {
|
||||
if (!this.#isSizeFetched) {
|
||||
await new Promise(
|
||||
resolve => on(
|
||||
this.container,
|
||||
EVENT_SIZE_LOADED,
|
||||
resolve
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
let targetSize: FullscreenViewerSize | string = 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 as FullscreenViewerSize];
|
||||
}
|
||||
|
||||
async show(imageUris: App.ImageURIs): Promise<void> {
|
||||
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(() => {
|
||||
this.container.classList.add(FullscreenViewer.#shownState);
|
||||
document.body.style.overflow = 'hidden';
|
||||
});
|
||||
|
||||
if (FullscreenViewer.#isVideoUrl(url)) {
|
||||
this.#imageElement.remove();
|
||||
|
||||
this.#videoElement.src = url;
|
||||
this.#videoElement.volume = 0;
|
||||
this.#videoElement.autoplay = true;
|
||||
this.#videoElement.loop = true;
|
||||
this.#videoElement.controls = true;
|
||||
|
||||
this.container.append(this.#videoElement);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.#videoElement.remove();
|
||||
|
||||
this.#imageElement.src = url;
|
||||
|
||||
this.container.append(this.#imageElement);
|
||||
}
|
||||
|
||||
static #isVideoUrl(url: string): boolean {
|
||||
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;
|
||||
|
||||
static #previewSizes: Record<FullscreenViewerSize, string> = {
|
||||
full: 'Full',
|
||||
large: 'Large',
|
||||
medium: 'Medium',
|
||||
small: 'Small'
|
||||
}
|
||||
|
||||
static #fallbackSize = 'large';
|
||||
}
|
||||
89
src/content/components/ImageShowFullscreenButton.ts
Normal file
89
src/content/components/ImageShowFullscreenButton.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { BaseComponent } from "$content/components/base/BaseComponent";
|
||||
import { getComponent } from "$content/components/base/component-utils";
|
||||
import MiscSettings from "$lib/extension/settings/MiscSettings";
|
||||
import { FullscreenViewer } from "$content/components/FullscreenViewer";
|
||||
import type { MediaBoxTools } from "$content/components/MediaBoxTools";
|
||||
|
||||
export class ImageShowFullscreenButton extends BaseComponent {
|
||||
#mediaBoxTools: MediaBoxTools | null = null;
|
||||
#isFullscreenButtonEnabled: boolean = false;
|
||||
|
||||
protected build() {
|
||||
this.container.innerText = '🔍';
|
||||
|
||||
ImageShowFullscreenButton.#miscSettings ??= new MiscSettings();
|
||||
}
|
||||
|
||||
protected init() {
|
||||
if (!this.container.parentElement) {
|
||||
throw new Error('Missing parent element!');
|
||||
}
|
||||
|
||||
this.#mediaBoxTools = getComponent(this.container.parentElement);
|
||||
|
||||
if (!this.#mediaBoxTools) {
|
||||
throw new Error('Fullscreen button is placed outside of the tools container!');
|
||||
}
|
||||
|
||||
this.on('click', this.#onButtonClicked.bind(this));
|
||||
|
||||
if (ImageShowFullscreenButton.#miscSettings) {
|
||||
ImageShowFullscreenButton.#miscSettings.resolveFullscreenViewerEnabled()
|
||||
.then(isEnabled => {
|
||||
this.#isFullscreenButtonEnabled = isEnabled;
|
||||
this.#updateFullscreenButtonVisibility();
|
||||
})
|
||||
.then(() => {
|
||||
ImageShowFullscreenButton.#miscSettings?.subscribe(settings => {
|
||||
this.#isFullscreenButtonEnabled = settings.fullscreenViewer ?? true;
|
||||
this.#updateFullscreenButtonVisibility();
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#updateFullscreenButtonVisibility() {
|
||||
this.container.classList.toggle('is-visible', this.#isFullscreenButtonEnabled);
|
||||
}
|
||||
|
||||
#onButtonClicked() {
|
||||
const imageLinks = this.#mediaBoxTools?.mediaBox?.imageLinks;
|
||||
|
||||
if (!imageLinks) {
|
||||
throw new Error('Failed to resolve image links from media box tools!');
|
||||
}
|
||||
|
||||
ImageShowFullscreenButton
|
||||
.#resolveViewer()
|
||||
?.show(imageLinks);
|
||||
}
|
||||
|
||||
static #viewer: FullscreenViewer | null = null;
|
||||
|
||||
static #resolveViewer(): FullscreenViewer {
|
||||
this.#viewer ??= this.#buildViewer();
|
||||
return this.#viewer;
|
||||
}
|
||||
|
||||
static #buildViewer(): FullscreenViewer {
|
||||
const element = document.createElement('div');
|
||||
const viewer = new FullscreenViewer(element);
|
||||
|
||||
viewer.initialize();
|
||||
|
||||
document.body.append(element);
|
||||
|
||||
return viewer;
|
||||
}
|
||||
|
||||
static #miscSettings: MiscSettings | null = null;
|
||||
}
|
||||
|
||||
export function createImageShowFullscreenButton() {
|
||||
const element = document.createElement('div');
|
||||
element.classList.add('media-box-show-fullscreen');
|
||||
|
||||
new ImageShowFullscreenButton(element);
|
||||
|
||||
return element;
|
||||
}
|
||||
419
src/content/components/MaintenancePopup.ts
Normal file
419
src/content/components/MaintenancePopup.ts
Normal file
@@ -0,0 +1,419 @@
|
||||
import MaintenanceSettings from "$lib/extension/settings/MaintenanceSettings";
|
||||
import MaintenanceProfile from "$entities/MaintenanceProfile";
|
||||
import { BaseComponent } from "$content/components/base/BaseComponent";
|
||||
import { getComponent } from "$content/components/base/component-utils";
|
||||
import ScrapedAPI from "$lib/booru/scraped/ScrapedAPI";
|
||||
import { tagsBlacklist } from "$config/tags";
|
||||
import { emitterAt } from "$content/components/events/comms";
|
||||
import {
|
||||
EVENT_ACTIVE_PROFILE_CHANGED,
|
||||
EVENT_MAINTENANCE_STATE_CHANGED,
|
||||
EVENT_TAGS_UPDATED
|
||||
} from "$content/components/events/maintenance-popup-events";
|
||||
import type { MediaBoxTools } from "$content/components/MediaBoxTools";
|
||||
|
||||
class BlackListedTagsEncounteredError extends Error {
|
||||
constructor(tagName: string) {
|
||||
super(`This tag is blacklisted and prevents submission: ${tagName}`, {
|
||||
cause: tagName
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class MaintenancePopup extends BaseComponent {
|
||||
#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);
|
||||
|
||||
/**
|
||||
* @protected
|
||||
*/
|
||||
build() {
|
||||
this.container.innerHTML = '';
|
||||
this.container.classList.add('maintenance-popup');
|
||||
|
||||
this.#tagsListElement.classList.add('tags-list');
|
||||
|
||||
this.container.append(
|
||||
this.#tagsListElement,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @protected
|
||||
*/
|
||||
init() {
|
||||
const mediaBoxToolsElement = this.container.closest<HTMLElement>('.media-box-tools');
|
||||
|
||||
if (!mediaBoxToolsElement) {
|
||||
throw new Error('Maintenance popup initialized outside of the media box tools!');
|
||||
}
|
||||
|
||||
const mediaBoxTools = getComponent<MediaBoxTools>(mediaBoxToolsElement);
|
||||
|
||||
if (!mediaBoxTools) {
|
||||
throw new Error('Media box tools component not found!');
|
||||
}
|
||||
|
||||
this.#mediaBoxTools = mediaBoxTools;
|
||||
|
||||
MaintenancePopup.#watchActiveProfile(this.#onActiveProfileChanged.bind(this));
|
||||
this.#tagsListElement.addEventListener('click', this.#handleTagClick.bind(this));
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
#onActiveProfileChanged(activeProfile: MaintenanceProfile | null) {
|
||||
this.#activeProfile = activeProfile;
|
||||
this.container.classList.toggle('is-active', activeProfile !== null);
|
||||
this.#refreshTagsList();
|
||||
|
||||
this.#emitter.emit(EVENT_ACTIVE_PROFILE_CHANGED, activeProfile);
|
||||
}
|
||||
|
||||
#refreshTagsList() {
|
||||
if (!this.#mediaBoxTools?.mediaBox) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeProfileTagsList: string[] = this.#activeProfile?.settings.tags || [];
|
||||
|
||||
for (const tagElement of this.#tagsList) {
|
||||
tagElement.remove();
|
||||
}
|
||||
|
||||
for (const tagElement of this.#suggestedInvalidTags.values()) {
|
||||
tagElement.remove();
|
||||
}
|
||||
|
||||
this.#tagsList = new Array(activeProfileTagsList.length);
|
||||
this.#suggestedInvalidTags.clear();
|
||||
|
||||
const currentPostTags = this.#mediaBoxTools.mediaBox.tagsAndAliases;
|
||||
|
||||
activeProfileTagsList
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
.forEach((tagName, index) => {
|
||||
const tagElement = MaintenancePopup.#buildTagElement(tagName);
|
||||
this.#tagsList[index] = tagElement;
|
||||
this.#tagsListElement.appendChild(tagElement);
|
||||
|
||||
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);
|
||||
|
||||
// Just to prevent duplication, we need to include this tag to the map of suggested invalid tags
|
||||
if (tagsBlacklist.includes(tagName)) {
|
||||
MaintenancePopup.#markTagAsInvalid(tagElement);
|
||||
this.#suggestedInvalidTags.set(tagName, tagElement);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect and process clicks made directly to the tags.
|
||||
*/
|
||||
#handleTagClick(event: MouseEvent) {
|
||||
const targetObject = event.target;
|
||||
|
||||
|
||||
if (!targetObject || !(targetObject instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let tagElement: HTMLElement | null = targetObject;
|
||||
|
||||
if (!tagElement.classList.contains('tag')) {
|
||||
tagElement = tagElement.closest<HTMLElement>('.tag');
|
||||
}
|
||||
|
||||
if (!tagElement?.dataset.name) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tagName = tagElement.dataset.name;
|
||||
|
||||
if (tagElement.classList.contains('is-present')) {
|
||||
const isToBeRemoved = tagElement.classList.toggle('is-removed');
|
||||
|
||||
if (isToBeRemoved) {
|
||||
this.#tagsToRemove.add(tagName);
|
||||
} else {
|
||||
this.#tagsToRemove.delete(tagName);
|
||||
}
|
||||
}
|
||||
|
||||
if (tagElement.classList.contains('is-missing')) {
|
||||
const isToBeAdded = tagElement.classList.toggle('is-added');
|
||||
|
||||
if (isToBeAdded) {
|
||||
this.#tagsToAdd.add(tagName);
|
||||
} else {
|
||||
this.#tagsToAdd.delete(tagName);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.#tagsToAdd.size || this.#tagsToRemove.size) {
|
||||
// Notify only once, when first planning to submit
|
||||
if (!this.#isPlanningToSubmit) {
|
||||
MaintenancePopup.#notifyAboutPendingSubmission(true);
|
||||
}
|
||||
|
||||
this.#isPlanningToSubmit = true;
|
||||
this.#emitter.emit(EVENT_MAINTENANCE_STATE_CHANGED, 'waiting');
|
||||
}
|
||||
}
|
||||
|
||||
#onMouseEnteredArea() {
|
||||
if (this.#tagsSubmissionTimer) {
|
||||
clearTimeout(this.#tagsSubmissionTimer);
|
||||
}
|
||||
}
|
||||
|
||||
#onMouseLeftArea() {
|
||||
if (this.#isPlanningToSubmit && !this.#isSubmitting) {
|
||||
this.#tagsSubmissionTimer = setTimeout(
|
||||
this.#onSubmissionTimerPassed.bind(this),
|
||||
MaintenancePopup.#delayBeforeSubmissionMs
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async #onSubmissionTimerPassed() {
|
||||
if (!this.#isPlanningToSubmit || this.#isSubmitting || !this.#mediaBoxTools?.mediaBox) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.#isPlanningToSubmit = false;
|
||||
this.#isSubmitting = true;
|
||||
|
||||
this.#emitter.emit(EVENT_MAINTENANCE_STATE_CHANGED, 'processing');
|
||||
|
||||
let maybeTagsAndAliasesAfterUpdate;
|
||||
|
||||
const shouldAutoRemove = await MaintenancePopup.#maintenanceSettings.resolveStripBlacklistedTags();
|
||||
|
||||
try {
|
||||
maybeTagsAndAliasesAfterUpdate = await MaintenancePopup.#scrapedAPI.updateImageTags(
|
||||
this.#mediaBoxTools.mediaBox.imageId,
|
||||
tagsList => {
|
||||
for (let tagName of this.#tagsToRemove) {
|
||||
tagsList.delete(tagName);
|
||||
}
|
||||
|
||||
for (let tagName of this.#tagsToAdd) {
|
||||
tagsList.add(tagName);
|
||||
}
|
||||
|
||||
if (shouldAutoRemove) {
|
||||
for (let tagName of tagsBlacklist) {
|
||||
tagsList.delete(tagName);
|
||||
}
|
||||
} else {
|
||||
for (let tagName of tagsList) {
|
||||
if (tagsBlacklist.includes(tagName)) {
|
||||
throw new BlackListedTagsEncounteredError(tagName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tagsList;
|
||||
}
|
||||
);
|
||||
} catch (e) {
|
||||
if (e instanceof BlackListedTagsEncounteredError) {
|
||||
this.#revealInvalidTags();
|
||||
} else {
|
||||
console.warn('Tags submission failed:', e);
|
||||
}
|
||||
|
||||
MaintenancePopup.#notifyAboutPendingSubmission(false);
|
||||
|
||||
this.#emitter.emit(EVENT_MAINTENANCE_STATE_CHANGED, 'failed');
|
||||
this.#isSubmitting = false;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (maybeTagsAndAliasesAfterUpdate) {
|
||||
this.#emitter.emit(EVENT_TAGS_UPDATED, maybeTagsAndAliasesAfterUpdate);
|
||||
}
|
||||
|
||||
this.#emitter.emit(EVENT_MAINTENANCE_STATE_CHANGED, 'complete');
|
||||
|
||||
this.#tagsToAdd.clear();
|
||||
this.#tagsToRemove.clear();
|
||||
|
||||
this.#refreshTagsList();
|
||||
MaintenancePopup.#notifyAboutPendingSubmission(false);
|
||||
|
||||
this.#isSubmitting = false;
|
||||
}
|
||||
|
||||
#revealInvalidTags() {
|
||||
if (!this.#mediaBoxTools?.mediaBox) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tagsAndAliases = this.#mediaBoxTools.mediaBox.tagsAndAliases;
|
||||
|
||||
if (!tagsAndAliases) {
|
||||
return;
|
||||
}
|
||||
|
||||
const firstTagInList = this.#tagsList[0];
|
||||
|
||||
for (let tagName of tagsBlacklist) {
|
||||
if (tagsAndAliases.has(tagName)) {
|
||||
if (this.#suggestedInvalidTags.has(tagName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const tagElement = MaintenancePopup.#buildTagElement(tagName);
|
||||
MaintenancePopup.#markTagAsInvalid(tagElement);
|
||||
tagElement.classList.add('is-present');
|
||||
|
||||
this.#suggestedInvalidTags.set(tagName, tagElement);
|
||||
|
||||
if (firstTagInList && firstTagInList.isConnected) {
|
||||
this.#tagsListElement.insertBefore(tagElement, firstTagInList);
|
||||
} else {
|
||||
this.#tagsListElement.appendChild(tagElement);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get isActive() {
|
||||
return this.container.classList.contains('is-active');
|
||||
}
|
||||
|
||||
static #buildTagElement(tagName: string): HTMLElement {
|
||||
const tagElement = document.createElement('span');
|
||||
tagElement.classList.add('tag');
|
||||
tagElement.innerText = tagName;
|
||||
tagElement.dataset.name = tagName;
|
||||
|
||||
return tagElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks the tag with red color.
|
||||
* @param tagElement Element to mark.
|
||||
*/
|
||||
static #markTagAsInvalid(tagElement: HTMLElement) {
|
||||
tagElement.dataset.tagCategory = 'error';
|
||||
tagElement.setAttribute('data-tag-category', 'error');
|
||||
}
|
||||
|
||||
/**
|
||||
* Controller with maintenance settings.
|
||||
*/
|
||||
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 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: (profile: MaintenanceProfile | null) => void): () => void {
|
||||
let lastActiveProfileId: string | null | undefined = null;
|
||||
|
||||
const unsubscribeFromProfilesChanges = MaintenanceProfile.subscribe(profiles => {
|
||||
if (lastActiveProfileId) {
|
||||
callback(
|
||||
profiles.find(profile => profile.id === lastActiveProfileId) || null
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const unsubscribeFromMaintenanceSettings = this.#maintenanceSettings.subscribe(settings => {
|
||||
if (settings.activeProfile === lastActiveProfileId) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastActiveProfileId = settings.activeProfile;
|
||||
|
||||
this.#maintenanceSettings
|
||||
.resolveActiveProfileAsObject()
|
||||
.then(callback);
|
||||
});
|
||||
|
||||
this.#maintenanceSettings
|
||||
.resolveActiveProfileAsObject()
|
||||
.then(profileOrNull => {
|
||||
if (profileOrNull) {
|
||||
lastActiveProfileId = profileOrNull.id;
|
||||
}
|
||||
|
||||
callback(profileOrNull);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribeFromProfilesChanges();
|
||||
unsubscribeFromMaintenanceSettings();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify the frontend about new pending submission started.
|
||||
* @param isStarted True if started, false if ended.
|
||||
*/
|
||||
static #notifyAboutPendingSubmission(isStarted: boolean) {
|
||||
if (this.#pendingSubmissionCount === null) {
|
||||
this.#pendingSubmissionCount = 0;
|
||||
this.#initializeExitPromptHandler();
|
||||
}
|
||||
|
||||
this.#pendingSubmissionCount += isStarted ? 1 : -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to the global window closing event, show the prompt when there are pending submission.
|
||||
*/
|
||||
static #initializeExitPromptHandler() {
|
||||
window.addEventListener('beforeunload', event => {
|
||||
if (!this.#pendingSubmissionCount) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.returnValue = true;
|
||||
});
|
||||
}
|
||||
|
||||
static #scrapedAPI = new ScrapedAPI();
|
||||
|
||||
static #delayBeforeSubmissionMs = 500;
|
||||
|
||||
/**
|
||||
* Amount of pending submissions or NULL if logic was not yet initialized.
|
||||
*/
|
||||
static #pendingSubmissionCount: number|null = null;
|
||||
}
|
||||
|
||||
export function createMaintenancePopup() {
|
||||
const container = document.createElement('div');
|
||||
|
||||
new MaintenancePopup(container);
|
||||
|
||||
return container;
|
||||
}
|
||||
64
src/content/components/MaintenanceStatusIcon.ts
Normal file
64
src/content/components/MaintenanceStatusIcon.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { BaseComponent } from "$content/components/base/BaseComponent";
|
||||
import { getComponent } from "$content/components/base/component-utils";
|
||||
import { on } from "$content/components/events/comms";
|
||||
import { EVENT_MAINTENANCE_STATE_CHANGED } from "$content/components/events/maintenance-popup-events";
|
||||
import type { MediaBoxTools } from "$content/components/MediaBoxTools";
|
||||
|
||||
export class MaintenanceStatusIcon extends BaseComponent {
|
||||
#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, EVENT_MAINTENANCE_STATE_CHANGED, this.#onMaintenanceStateChanged.bind(this));
|
||||
}
|
||||
|
||||
#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":
|
||||
this.container.innerText = '🔧';
|
||||
break;
|
||||
|
||||
case "waiting":
|
||||
this.container.innerText = '⏳';
|
||||
break;
|
||||
|
||||
case "processing":
|
||||
this.container.innerText = '📤';
|
||||
break;
|
||||
|
||||
case "complete":
|
||||
this.container.innerText = '✅';
|
||||
break;
|
||||
|
||||
case "failed":
|
||||
this.container.innerText = '⚠️';
|
||||
break;
|
||||
|
||||
default:
|
||||
this.container.innerText = '❓';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createMaintenanceStatusIcon() {
|
||||
const element = document.createElement('div');
|
||||
element.classList.add('maintenance-status-icon');
|
||||
|
||||
new MaintenanceStatusIcon(element);
|
||||
|
||||
return element;
|
||||
}
|
||||
74
src/content/components/MediaBoxTools.ts
Normal file
74
src/content/components/MediaBoxTools.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { BaseComponent } from "$content/components/base/BaseComponent";
|
||||
import { getComponent } from "$content/components/base/component-utils";
|
||||
import { MaintenancePopup } from "$content/components/MaintenancePopup";
|
||||
import { on } from "$content/components/events/comms";
|
||||
import { EVENT_ACTIVE_PROFILE_CHANGED } from "$content/components/events/maintenance-popup-events";
|
||||
import type { MediaBoxWrapper } from "$content/components/MediaBoxWrapper";
|
||||
import type MaintenanceProfile from "$entities/MaintenanceProfile";
|
||||
|
||||
export class MediaBoxTools extends BaseComponent {
|
||||
#mediaBox: MediaBoxWrapper | null = null;
|
||||
#maintenancePopup: MaintenancePopup | null = null;
|
||||
|
||||
init() {
|
||||
const mediaBoxElement = this.container.closest<HTMLElement>('.media-box');
|
||||
|
||||
if (!mediaBoxElement) {
|
||||
throw new Error('Toolbox element initialized outside of the media box!');
|
||||
}
|
||||
|
||||
this.#mediaBox = getComponent(mediaBoxElement);
|
||||
|
||||
for (let childElement of this.container.children) {
|
||||
if (!(childElement instanceof HTMLElement)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const component = getComponent(childElement);
|
||||
|
||||
if (!component) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!component.isInitialized) {
|
||||
component.initialize();
|
||||
}
|
||||
|
||||
if (!this.#maintenancePopup && component instanceof MaintenancePopup) {
|
||||
this.#maintenancePopup = component;
|
||||
}
|
||||
}
|
||||
|
||||
on(this, EVENT_ACTIVE_PROFILE_CHANGED, this.#onActiveProfileChanged.bind(this));
|
||||
}
|
||||
|
||||
#onActiveProfileChanged(profileChangedEvent: CustomEvent<MaintenanceProfile | null>) {
|
||||
this.container.classList.toggle('has-active-profile', profileChangedEvent.detail !== null);
|
||||
}
|
||||
|
||||
get maintenancePopup(): MaintenancePopup | null {
|
||||
return this.#maintenancePopup;
|
||||
}
|
||||
|
||||
get mediaBox(): MediaBoxWrapper | null {
|
||||
return this.#mediaBox;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a maintenance popup element.
|
||||
* @param childrenElements List of children elements to append to the component.
|
||||
* @return The maintenance popup element.
|
||||
*/
|
||||
export function createMediaBoxTools(...childrenElements: HTMLElement[]): HTMLElement {
|
||||
const mediaBoxToolsContainer = document.createElement('div');
|
||||
mediaBoxToolsContainer.classList.add('media-box-tools');
|
||||
|
||||
if (childrenElements.length) {
|
||||
mediaBoxToolsContainer.append(...childrenElements);
|
||||
}
|
||||
|
||||
new MediaBoxTools(mediaBoxToolsContainer);
|
||||
|
||||
return mediaBoxToolsContainer;
|
||||
}
|
||||
99
src/content/components/MediaBoxWrapper.ts
Normal file
99
src/content/components/MediaBoxWrapper.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { BaseComponent } from "$content/components/base/BaseComponent";
|
||||
import { getComponent } from "$content/components/base/component-utils";
|
||||
import { buildTagsAndAliasesMap } from "$lib/booru/tag-utils";
|
||||
import { on } from "$content/components/events/comms";
|
||||
import { EVENT_TAGS_UPDATED } from "$content/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');
|
||||
}
|
||||
})
|
||||
}
|
||||
320
src/content/components/TagDropdownWrapper.ts
Normal file
320
src/content/components/TagDropdownWrapper.ts
Normal file
@@ -0,0 +1,320 @@
|
||||
import { BaseComponent } from "$content/components/base/BaseComponent";
|
||||
import MaintenanceProfile from "$entities/MaintenanceProfile";
|
||||
import MaintenanceSettings from "$lib/extension/settings/MaintenanceSettings";
|
||||
import { getComponent } from "$content/components/base/component-utils";
|
||||
import CustomCategoriesResolver from "$lib/extension/CustomCategoriesResolver";
|
||||
import { on } from "$content/components/events/comms";
|
||||
import { EVENT_FORM_EDITOR_UPDATED } from "$content/components/events/tags-form-events";
|
||||
import { EVENT_TAG_GROUP_RESOLVED } from "$content/components/events/tag-dropdown-events";
|
||||
import type TagGroup from "$entities/TagGroup";
|
||||
|
||||
const categoriesResolver = new CustomCategoriesResolver();
|
||||
|
||||
export class TagDropdownWrapper extends BaseComponent {
|
||||
/**
|
||||
* Container with dropdown elements to insert options into.
|
||||
*/
|
||||
#dropdownContainer: HTMLElement | null = null;
|
||||
|
||||
/**
|
||||
* Button to add or remove the current tag into/from the active profile.
|
||||
*/
|
||||
#toggleOnExistingButton: HTMLAnchorElement | null = null;
|
||||
|
||||
/**
|
||||
* Button to create a new profile, make it active and add the current tag into the active profile.
|
||||
*/
|
||||
#addToNewButton: HTMLAnchorElement | null = null;
|
||||
|
||||
/**
|
||||
* Local clone of the currently active profile used for updating the list of tags.
|
||||
*/
|
||||
#activeProfile: MaintenanceProfile | null = null;
|
||||
|
||||
/**
|
||||
* Is cursor currently entered the dropdown.
|
||||
*/
|
||||
#isEntered: boolean = false;
|
||||
|
||||
#originalCategory: string | undefined | null = null;
|
||||
|
||||
build() {
|
||||
this.#dropdownContainer = this.container.querySelector('.dropdown__content');
|
||||
}
|
||||
|
||||
init() {
|
||||
this.on('mouseenter', this.#onDropdownEntered.bind(this));
|
||||
this.on('mouseleave', this.#onDropdownLeft.bind(this));
|
||||
|
||||
TagDropdownWrapper.#watchActiveProfile(activeProfileOrNull => {
|
||||
this.#activeProfile = activeProfileOrNull;
|
||||
|
||||
if (this.#isEntered) {
|
||||
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() {
|
||||
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();
|
||||
}
|
||||
|
||||
#onDropdownLeft() {
|
||||
this.#isEntered = false;
|
||||
}
|
||||
|
||||
#updateButtons() {
|
||||
if (!this.#activeProfile) {
|
||||
this.#addToNewButton ??= TagDropdownWrapper.#createDropdownLink(
|
||||
'Add to new tagging profile',
|
||||
this.#onAddToNewClicked.bind(this)
|
||||
);
|
||||
|
||||
if (!this.#addToNewButton.isConnected) {
|
||||
this.#dropdownContainer?.append(this.#addToNewButton);
|
||||
}
|
||||
} else {
|
||||
this.#addToNewButton?.remove();
|
||||
}
|
||||
|
||||
if (this.#activeProfile) {
|
||||
this.#toggleOnExistingButton ??= TagDropdownWrapper.#createDropdownLink(
|
||||
'Add to existing tagging profile',
|
||||
this.#onToggleInExistingClicked.bind(this)
|
||||
);
|
||||
|
||||
const profileName = this.#activeProfile.settings.name;
|
||||
let profileSpecificButtonText = `Add to profile "${profileName}"`;
|
||||
const tagName = this.tagName;
|
||||
|
||||
if (tagName && this.#activeProfile.settings.tags.includes(tagName)) {
|
||||
profileSpecificButtonText = `Remove from profile "${profileName}"`;
|
||||
}
|
||||
|
||||
if (this.#toggleOnExistingButton.lastChild instanceof Text) {
|
||||
this.#toggleOnExistingButton.lastChild.textContent = ` ${profileSpecificButtonText}`;
|
||||
} else {
|
||||
// Just in case last child is missing, then update the text on the full element.
|
||||
this.#toggleOnExistingButton.textContent = profileSpecificButtonText;
|
||||
}
|
||||
|
||||
if (!this.#toggleOnExistingButton.isConnected) {
|
||||
this.#dropdownContainer?.append(this.#toggleOnExistingButton);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.#toggleOnExistingButton?.remove();
|
||||
}
|
||||
|
||||
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],
|
||||
temporary: true,
|
||||
});
|
||||
|
||||
await profile.save();
|
||||
await TagDropdownWrapper.#maintenanceSettings.setActiveProfileId(profile.id);
|
||||
}
|
||||
|
||||
async #onToggleInExistingClicked() {
|
||||
if (!this.#activeProfile) {
|
||||
return;
|
||||
}
|
||||
|
||||
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 {
|
||||
tagsList.add(targetTagName);
|
||||
}
|
||||
|
||||
this.#activeProfile.settings.tags = Array.from(tagsList.values());
|
||||
|
||||
await this.#activeProfile.save();
|
||||
}
|
||||
|
||||
static #maintenanceSettings = new MaintenanceSettings();
|
||||
|
||||
/**
|
||||
* Watch for changes to active profile.
|
||||
* @param onActiveProfileChange Callback to call when profile was
|
||||
* changed.
|
||||
*/
|
||||
static #watchActiveProfile(onActiveProfileChange: (profile: MaintenanceProfile | null) => void) {
|
||||
let lastActiveProfile: string | null = null;
|
||||
|
||||
this.#maintenanceSettings.subscribe((settings) => {
|
||||
lastActiveProfile = settings.activeProfile ?? null;
|
||||
|
||||
this.#maintenanceSettings
|
||||
.resolveActiveProfileAsObject()
|
||||
.then(onActiveProfileChange);
|
||||
});
|
||||
|
||||
MaintenanceProfile.subscribe(profiles => {
|
||||
const activeProfile = profiles
|
||||
.find(profile => profile.id === lastActiveProfile);
|
||||
|
||||
onActiveProfileChange(activeProfile ?? null
|
||||
);
|
||||
});
|
||||
|
||||
this.#maintenanceSettings
|
||||
.resolveActiveProfileAsObject()
|
||||
.then(activeProfile => {
|
||||
lastActiveProfile = activeProfile?.id ?? null;
|
||||
onActiveProfileChange(activeProfile);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create element for dropdown.
|
||||
* @param text Base text for the option.
|
||||
* @param onClickHandler Click handler. Event will be prevented by default.
|
||||
* @return
|
||||
*/
|
||||
static #createDropdownLink(text: string, onClickHandler: (event: MouseEvent) => void): HTMLAnchorElement {
|
||||
const dropdownLink = document.createElement('a');
|
||||
dropdownLink.href = '#';
|
||||
dropdownLink.className = 'tag__dropdown__link';
|
||||
|
||||
const dropdownLinkIcon = document.createElement('i');
|
||||
dropdownLinkIcon.classList.add('fa', 'fa-tags');
|
||||
|
||||
dropdownLink.textContent = ` ${text}`;
|
||||
dropdownLink.insertAdjacentElement('afterbegin', dropdownLinkIcon);
|
||||
|
||||
dropdownLink.addEventListener('click', event => {
|
||||
event.preventDefault();
|
||||
onClickHandler(event);
|
||||
});
|
||||
|
||||
return dropdownLink;
|
||||
}
|
||||
}
|
||||
|
||||
export function wrapTagDropdown(element: HTMLElement) {
|
||||
// Skip initialization when tag component is already wrapped
|
||||
if (getComponent(element)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tagDropdown = new TagDropdownWrapper(element);
|
||||
tagDropdown.initialize();
|
||||
|
||||
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')) {
|
||||
return;
|
||||
}
|
||||
|
||||
document.body.addEventListener('mouseover', event => {
|
||||
const targetElement = event.target;
|
||||
|
||||
if (!(targetElement instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (processedElementsSet.has(targetElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const closestTagEditor = targetElement.closest<HTMLElement>('#image_tags_and_source');
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
150
src/content/components/TagsForm.ts
Normal file
150
src/content/components/TagsForm.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { BaseComponent } from "$content/components/base/BaseComponent";
|
||||
import { getComponent } from "$content/components/base/component-utils";
|
||||
import { emit, on, type UnsubscribeFunction } from "$content/components/events/comms";
|
||||
import { EVENT_FETCH_COMPLETE } from "$content/components/events/booru-events";
|
||||
import { EVENT_FORM_EDITOR_UPDATED } from "$content/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();
|
||||
});
|
||||
}
|
||||
}
|
||||
244
src/content/components/TagsListBlock.ts
Normal file
244
src/content/components/TagsListBlock.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
import { BaseComponent } from "$content/components/base/BaseComponent";
|
||||
import type TagGroup from "$entities/TagGroup";
|
||||
import type { TagDropdownWrapper } from "$content/components/TagDropdownWrapper";
|
||||
import { on } from "$content/components/events/comms";
|
||||
import { EVENT_FORM_EDITOR_UPDATED } from "$content/components/events/tags-form-events";
|
||||
import { getComponent } from "$content/components/base/component-utils";
|
||||
import { EVENT_TAG_GROUP_RESOLVED } from "$content/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%';
|
||||
heading.classList.add('tag-category-headline');
|
||||
|
||||
// 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();
|
||||
});
|
||||
}
|
||||
100
src/content/components/base/BaseComponent.ts
Normal file
100
src/content/components/base/BaseComponent.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { bindComponent } from "$content/components/base/component-utils";
|
||||
|
||||
type ComponentEventListener<EventName extends keyof HTMLElementEventMap> =
|
||||
(this: HTMLElement, event: HTMLElementEventMap[EventName]) => void;
|
||||
|
||||
export class BaseComponent<ContainerType extends HTMLElement = HTMLElement> {
|
||||
readonly #container: ContainerType;
|
||||
|
||||
#isInitialized = false;
|
||||
|
||||
constructor(container: ContainerType) {
|
||||
this.#container = container;
|
||||
|
||||
bindComponent(container, this);
|
||||
}
|
||||
|
||||
initialize() {
|
||||
if (this.#isInitialized) {
|
||||
throw new Error('The component is already initialized.');
|
||||
}
|
||||
|
||||
this.#isInitialized = true;
|
||||
|
||||
this.build();
|
||||
this.init();
|
||||
}
|
||||
|
||||
protected build(): void {
|
||||
// This method can be implemented by the component classes to modify or create the inner elements.
|
||||
}
|
||||
|
||||
protected init(): void {
|
||||
// This method can be implemented by the component classes to initialize the component.
|
||||
};
|
||||
|
||||
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
|
||||
*/
|
||||
get isInitialized(): boolean {
|
||||
return this.#isInitialized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit the custom event on the container element.
|
||||
* @param event The event name.
|
||||
* @param [detail] The event detail. Can be omitted.
|
||||
*/
|
||||
emit(event: keyof HTMLElementEventMap | string, detail: any = undefined): void {
|
||||
this.#container.dispatchEvent(
|
||||
new CustomEvent(
|
||||
event,
|
||||
{
|
||||
detail,
|
||||
bubbles: true
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to the DOM event on the container element.
|
||||
* @param event The event name.
|
||||
* @param listener The event listener.
|
||||
* @param [options] The event listener options. Can be omitted.
|
||||
* @return The unsubscribe function.
|
||||
*/
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to the DOM event on the container element. The event listener will be called only once.
|
||||
* @param event The event name.
|
||||
* @param listener The event listener.
|
||||
* @param [options] The event listener options. Can be omitted.
|
||||
* @return The unsubscribe function.
|
||||
*/
|
||||
once<EventName extends keyof HTMLElementEventMap>(
|
||||
event: EventName,
|
||||
listener: ComponentEventListener<EventName>,
|
||||
options?: AddEventListenerOptions,
|
||||
): () => void {
|
||||
options = options || {};
|
||||
options.once = true;
|
||||
|
||||
return this.on(event, listener, options);
|
||||
}
|
||||
}
|
||||
29
src/content/components/base/component-utils.ts
Normal file
29
src/content/components/base/component-utils.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { BaseComponent } from "$content/components/base/BaseComponent";
|
||||
|
||||
const instanceSymbol = Symbol.for('instance');
|
||||
|
||||
interface ElementWithComponent<T> extends HTMLElement {
|
||||
[instanceSymbol]?: T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the component from the element, if there is one.
|
||||
* @param {HTMLElement} element
|
||||
* @return
|
||||
*/
|
||||
export function getComponent<T extends BaseComponent = BaseComponent>(element: ElementWithComponent<T>): T | null {
|
||||
return element[instanceSymbol] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind the component to the selected element.
|
||||
* @param element The element to bind the component to.
|
||||
* @param instance The component instance.
|
||||
*/
|
||||
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.');
|
||||
}
|
||||
|
||||
element[instanceSymbol] = instance;
|
||||
}
|
||||
5
src/content/components/events/booru-events.ts
Normal file
5
src/content/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.
|
||||
}
|
||||
100
src/content/components/events/comms.ts
Normal file
100
src/content/components/events/comms.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import type { MaintenancePopupEventsMap } from "$content/components/events/maintenance-popup-events";
|
||||
import { BaseComponent } from "$content/components/base/BaseComponent";
|
||||
import type { FullscreenViewerEventsMap } from "$content/components/events/fullscreen-viewer-events";
|
||||
import type { BooruEventsMap } from "$content/components/events/booru-events";
|
||||
import type { TagsFormEventsMap } from "$content/components/events/tags-form-events";
|
||||
import type { TagDropdownEvents } from "$content/components/events/tag-dropdown-events";
|
||||
|
||||
type EventsMapping =
|
||||
MaintenancePopupEventsMap
|
||||
& FullscreenViewerEventsMap
|
||||
& BooruEventsMap
|
||||
& TagsFormEventsMap
|
||||
& TagDropdownEvents;
|
||||
|
||||
type EventCallback<EventDetails> = (event: CustomEvent<EventDetails>) => void;
|
||||
export type UnsubscribeFunction = () => void;
|
||||
type ResolvableTarget = EventTarget | BaseComponent;
|
||||
|
||||
function resolveTarget(componentOrElement: ResolvableTarget): EventTarget {
|
||||
if (componentOrElement instanceof BaseComponent) {
|
||||
return componentOrElement.container;
|
||||
}
|
||||
|
||||
return componentOrElement;
|
||||
}
|
||||
|
||||
export function emit<Event extends keyof EventsMapping>(
|
||||
targetOrComponent: ResolvableTarget,
|
||||
event: Event,
|
||||
details: EventsMapping[Event]
|
||||
) {
|
||||
const target = resolveTarget(targetOrComponent);
|
||||
|
||||
target.dispatchEvent(
|
||||
new CustomEvent(event, {
|
||||
detail: details,
|
||||
bubbles: true
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export function on<Event extends keyof EventsMapping>(
|
||||
targetOrComponent: ResolvableTarget,
|
||||
eventName: Event,
|
||||
callback: EventCallback<EventsMapping[Event]>,
|
||||
options: AddEventListenerOptions | null = null
|
||||
): UnsubscribeFunction {
|
||||
const target = resolveTarget(targetOrComponent);
|
||||
const controller = new AbortController();
|
||||
|
||||
target.addEventListener(
|
||||
eventName,
|
||||
callback as EventListener,
|
||||
{
|
||||
signal: controller.signal,
|
||||
once: options?.once
|
||||
}
|
||||
);
|
||||
|
||||
return () => controller.abort();
|
||||
}
|
||||
|
||||
const onceOptions = {once: true};
|
||||
|
||||
export function once<Event extends keyof EventsMapping>(
|
||||
targetOrComponent: ResolvableTarget,
|
||||
eventName: Event,
|
||||
callback: EventCallback<EventsMapping[Event]>
|
||||
): UnsubscribeFunction {
|
||||
return on(
|
||||
targetOrComponent,
|
||||
eventName,
|
||||
callback,
|
||||
onceOptions
|
||||
);
|
||||
}
|
||||
|
||||
class TargetedEmitter {
|
||||
readonly #element: ResolvableTarget;
|
||||
|
||||
constructor(targetOrComponent: ResolvableTarget) {
|
||||
this.#element = targetOrComponent;
|
||||
}
|
||||
|
||||
emit<Event extends keyof EventsMapping>(eventName: Event, details: EventsMapping[Event]): void {
|
||||
emit(this.#element, eventName, details);
|
||||
}
|
||||
|
||||
on<Event extends keyof EventsMapping>(eventName: Event, callback: EventCallback<EventsMapping[Event]>, options: AddEventListenerOptions | null = null): UnsubscribeFunction {
|
||||
return on(this.#element, eventName, callback, options);
|
||||
}
|
||||
|
||||
once<Event extends keyof EventsMapping>(eventName: Event, callback: EventCallback<EventsMapping[Event]>): UnsubscribeFunction {
|
||||
return once(this.#element, eventName, callback);
|
||||
}
|
||||
}
|
||||
|
||||
export function emitterAt(targetOrComponent: ResolvableTarget): TargetedEmitter {
|
||||
return new TargetedEmitter(targetOrComponent);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
13
src/content/components/events/maintenance-popup-events.ts
Normal file
13
src/content/components/events/maintenance-popup-events.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type MaintenanceProfile from "$entities/MaintenanceProfile";
|
||||
|
||||
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 {
|
||||
[EVENT_ACTIVE_PROFILE_CHANGED]: MaintenanceProfile | null;
|
||||
[EVENT_MAINTENANCE_STATE_CHANGED]: MaintenanceState;
|
||||
[EVENT_TAGS_UPDATED]: Map<string, string> | null;
|
||||
}
|
||||
7
src/content/components/events/tag-dropdown-events.ts
Normal file
7
src/content/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/content/components/events/tags-form-events.ts
Normal file
5
src/content/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;
|
||||
}
|
||||
19
src/content/components/listing/ImageListContainer.ts
Normal file
19
src/content/components/listing/ImageListContainer.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { BaseComponent } from "$content/components/base/BaseComponent";
|
||||
import { ImageListInfo } from "$content/components/listing/ImageListInfo";
|
||||
|
||||
export class ImageListContainer extends BaseComponent {
|
||||
#info: ImageListInfo | null = null;
|
||||
|
||||
protected build() {
|
||||
const imageListInfoContainer = this.container.querySelector<HTMLElement>('.js-imagelist-info');
|
||||
|
||||
if (imageListInfoContainer) {
|
||||
this.#info = new ImageListInfo(imageListInfoContainer);
|
||||
this.#info.initialize();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function initializeImageListContainer(element: HTMLElement) {
|
||||
new ImageListContainer(element).initialize();
|
||||
}
|
||||
75
src/content/components/listing/ImageListInfo.ts
Normal file
75
src/content/components/listing/ImageListInfo.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { BaseComponent } from "$content/components/base/BaseComponent";
|
||||
|
||||
export class ImageListInfo extends BaseComponent {
|
||||
#tagElement: HTMLElement | null = null;
|
||||
#impliedTags: string[] = [];
|
||||
#showUntaggedImplicationsButton: HTMLAnchorElement = document.createElement('a');
|
||||
|
||||
protected build() {
|
||||
const sectionAfterImage = this.container.querySelector('.tag-info__image + .flex__grow');
|
||||
|
||||
this.#tagElement = sectionAfterImage?.querySelector<HTMLElement>('.tag.dropdown') ?? null;
|
||||
|
||||
const labels = this.container
|
||||
.querySelectorAll<HTMLElement>('.tag-info__image + .flex__grow strong');
|
||||
|
||||
let targetElementToInsertBefore: HTMLElement | null = null;
|
||||
|
||||
for (const potentialListStarter of labels) {
|
||||
if (potentialListStarter.innerText === ImageListInfo.#implicationsStarterText) {
|
||||
targetElementToInsertBefore = potentialListStarter;
|
||||
this.#collectImplicationsFromListStarter(potentialListStarter);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.#impliedTags.length && targetElementToInsertBefore) {
|
||||
this.#showUntaggedImplicationsButton.href = '#';
|
||||
this.#showUntaggedImplicationsButton.innerText = '(Q)';
|
||||
this.#showUntaggedImplicationsButton.title =
|
||||
'Query untagged implications\n\n' +
|
||||
'This will open the search results with all untagged implications for the current tag.';
|
||||
this.#showUntaggedImplicationsButton.classList.add('detail-link');
|
||||
|
||||
targetElementToInsertBefore.insertAdjacentElement('beforebegin', this.#showUntaggedImplicationsButton);
|
||||
}
|
||||
}
|
||||
|
||||
protected init() {
|
||||
this.#showUntaggedImplicationsButton.addEventListener('click', this.#onShowUntaggedImplicationsClicked.bind(this));
|
||||
}
|
||||
|
||||
#collectImplicationsFromListStarter(listStarter: HTMLElement) {
|
||||
let targetElement: Element | null = listStarter.nextElementSibling;
|
||||
|
||||
while (targetElement) {
|
||||
if (targetElement instanceof HTMLAnchorElement) {
|
||||
this.#impliedTags.push(targetElement.innerText.trim());
|
||||
}
|
||||
|
||||
// First line break is considered the end of the list.
|
||||
if (targetElement instanceof HTMLBRElement) {
|
||||
break;
|
||||
}
|
||||
|
||||
targetElement = targetElement.nextElementSibling;
|
||||
}
|
||||
}
|
||||
|
||||
#onShowUntaggedImplicationsClicked(event: Event) {
|
||||
event.preventDefault();
|
||||
|
||||
const url = new URL(window.location.href);
|
||||
|
||||
url.pathname = '/search';
|
||||
url.search = '';
|
||||
|
||||
const currentTagName = this.#tagElement?.dataset.tagName;
|
||||
|
||||
url.searchParams.set('q', `${currentTagName}, !(${this.#impliedTags.join(", ")})`);
|
||||
|
||||
location.assign(url.href);
|
||||
}
|
||||
|
||||
static #implicationsStarterText = 'Implies:';
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { createMaintenancePopup } from "$lib/components/MaintenancePopup";
|
||||
import { createMediaBoxTools } from "$lib/components/MediaBoxTools";
|
||||
import { calculateMediaBoxesPositions, initializeMediaBox } from "$lib/components/MediaBoxWrapper";
|
||||
import { createMaintenanceStatusIcon } from "$lib/components/MaintenanceStatusIcon";
|
||||
import { createImageShowFullscreenButton } from "$lib/components/ImageShowFullscreenButton";
|
||||
import { initializeImageListContainer } from "$lib/components/listing/ImageListContainer";
|
||||
import { createMaintenancePopup } from "$content/components/MaintenancePopup";
|
||||
import { createMediaBoxTools } from "$content/components/MediaBoxTools";
|
||||
import { calculateMediaBoxesPositions, initializeMediaBox } from "$content/components/MediaBoxWrapper";
|
||||
import { createMaintenanceStatusIcon } from "$content/components/MaintenanceStatusIcon";
|
||||
import { createImageShowFullscreenButton } from "$content/components/ImageShowFullscreenButton";
|
||||
import { initializeImageListContainer } from "$content/components/listing/ImageListContainer";
|
||||
|
||||
const mediaBoxes = document.querySelectorAll<HTMLElement>('.media-box');
|
||||
const imageListContainer = document.querySelector<HTMLElement>('#imagelist-container');
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { TagsForm } from "$lib/components/TagsForm";
|
||||
import { initializeAllTagsLists, watchForUpdatedTagLists } from "$lib/components/TagsListBlock";
|
||||
import { TagsForm } from "$content/components/TagsForm";
|
||||
import { initializeAllTagsLists, watchForUpdatedTagLists } from "$content/components/TagsListBlock";
|
||||
|
||||
initializeAllTagsLists();
|
||||
watchForUpdatedTagLists();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { watchTagDropdownsInTagsEditor, wrapTagDropdown } from "$lib/components/TagDropdownWrapper";
|
||||
import { watchTagDropdownsInTagsEditor, wrapTagDropdown } from "$content/components/TagDropdownWrapper";
|
||||
|
||||
for (let tagDropdownElement of document.querySelectorAll<HTMLElement>('.tag.dropdown')) {
|
||||
wrapTagDropdown(tagDropdownElement);
|
||||
|
||||
Reference in New Issue
Block a user