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

Merge pull request #100 from koloml/feature/converting-content-components-to-ts

Converting all content scripts components to TypeScript, adding minor value checks and bug fixes found during conversion
This commit is contained in:
2025-02-22 10:40:59 -05:00
committed by GitHub
21 changed files with 469 additions and 404 deletions

View File

@@ -27,7 +27,7 @@
"*://*.furbooru.org/galleries/*"
],
"js": [
"src/content/listing.js"
"src/content/listing.ts"
],
"css": [
"src/styles/content/listing.scss"
@@ -38,7 +38,7 @@
"*://*.furbooru.org/*"
],
"js": [
"src/content/header.js"
"src/content/header.ts"
],
"css": [
"src/styles/content/header.scss"
@@ -59,7 +59,7 @@
"*://*.furbooru.org/filters/*"
],
"js": [
"src/content/tags.js"
"src/content/tags.ts"
]
},
{
@@ -67,7 +67,7 @@
"*://*.furbooru.org/images/*"
],
"js": [
"src/content/tags-editor.js"
"src/content/tags-editor.ts"
]
}
],

View File

@@ -1,30 +1,22 @@
import { BaseComponent } from "$lib/components/base/BaseComponent";
import MiscSettings from "$lib/extension/settings/MiscSettings";
import MiscSettings, { type FullscreenViewerSize } from "$lib/extension/settings/MiscSettings";
import { emit, on } from "$lib/components/events/comms";
import { eventSizeLoaded } from "$lib/components/events/fullscreen-viewer-events";
export class FullscreenViewer extends BaseComponent {
/** @type {HTMLVideoElement} */
#videoElement = document.createElement('video');
/** @type {HTMLImageElement} */
#imageElement = document.createElement('img');
#spinnerElement = document.createElement('i');
#sizeSelectorElement = document.createElement('select');
#closeButtonElement = document.createElement('i');
/** @type {number|null} */
#touchId = null;
/** @type {number|null} */
#startX = null;
/** @type {number|null} */
#startY = null;
/** @type {boolean|null} */
#isClosingSwipeStarted = null;
#isSizeFetched = false;
/** @type {App.ImageURIs|null} */
#currentURIs = null;
#videoElement: HTMLVideoElement = document.createElement('video');
#imageElement: HTMLImageElement = document.createElement('img');
#spinnerElement: HTMLElement = document.createElement('i');
#sizeSelectorElement: HTMLSelectElement = document.createElement('select');
#closeButtonElement: HTMLElement = document.createElement('i');
#touchId: number | null = null;
#startX: number | null = null;
#startY: number | null = null;
#isClosingSwipeStarted: boolean | null = null;
#isSizeFetched: boolean = false;
#currentURIs: App.ImageURIs | null = null;
/**
* @protected
*/
build() {
protected build() {
this.container.classList.add('fullscreen-viewer');
this.container.append(
@@ -71,10 +63,7 @@ export class FullscreenViewer extends BaseComponent {
this.container.classList.remove('loading');
}
/**
* @param {TouchEvent} event
*/
#onTouchStart(event) {
#onTouchStart(event: TouchEvent) {
if (this.#touchId !== null) {
return;
}
@@ -88,14 +77,12 @@ export class FullscreenViewer extends BaseComponent {
this.#touchId = firstTouch.identifier;
this.#startX = firstTouch.clientX;
this.#startY = firstTouch.clientY;
this.container.classList.add(FullscreenViewer.#swipeState);
}
/**
* @param {TouchEvent} event
*/
#onTouchEnd(event) {
if (this.#touchId === null) {
#onTouchEnd(event: TouchEvent) {
if (this.#touchId === null || this.#startY === null) {
return;
}
@@ -126,11 +113,8 @@ export class FullscreenViewer extends BaseComponent {
});
}
/**
* @param {TouchEvent} event
*/
#onTouchMove(event) {
if (this.#touchId === null) {
#onTouchMove(event: TouchEvent) {
if (this.#touchId === null || this.#startY === null || this.#startX === null) {
return;
}
@@ -179,23 +163,17 @@ export class FullscreenViewer extends BaseComponent {
}
}
/**
* @param {KeyboardEvent} event
*/
#onDocumentKeyPressed(event) {
#onDocumentKeyPressed(event: KeyboardEvent) {
if (event.code === 'Escape' || event.code === 'Esc') {
this.#close();
}
}
/**
* @param {import("$lib/extension/settings/MiscSettings").FullscreenViewerSize} size
*/
#onSizeResolved(size) {
#onSizeResolved(size: FullscreenViewerSize) {
this.#sizeSelectorElement.value = size;
this.#isSizeFetched = true;
this.emit('size-loaded');
emit(this.container, eventSizeLoaded, size);
}
#watchForSizeSelectionChanges() {
@@ -232,7 +210,7 @@ export class FullscreenViewer extends BaseComponent {
this.#currentURIs = null;
this.container.classList.remove(FullscreenViewer.#shownState);
document.body.style.overflow = null;
document.body.style.removeProperty('overflow');
requestAnimationFrame(() => {
this.#videoElement.volume = 0;
@@ -241,16 +219,18 @@ export class FullscreenViewer extends BaseComponent {
});
}
/**
* @param {App.ImageURIs} imageUris
* @return {Promise<string|null>}
*/
async #resolveCurrentSelectedSizeUrl(imageUris) {
async #resolveCurrentSelectedSizeUrl(imageUris: App.ImageURIs): Promise<string | null> {
if (!this.#isSizeFetched) {
await new Promise(resolve => this.on('size-loaded', resolve))
await new Promise(
resolve => on(
this.container,
eventSizeLoaded,
resolve
),
);
}
let targetSize = this.#sizeSelectorElement.value;
let targetSize: FullscreenViewerSize | string = this.#sizeSelectorElement.value;
if (!imageUris.hasOwnProperty(targetSize)) {
targetSize = FullscreenViewer.#fallbackSize;
@@ -264,13 +244,10 @@ export class FullscreenViewer extends BaseComponent {
return null;
}
return imageUris[targetSize];
return imageUris[targetSize as FullscreenViewerSize];
}
/**
* @param {App.ImageURIs} imageUris
*/
async show(imageUris) {
async show(imageUris: App.ImageURIs): Promise<void> {
this.#currentURIs = imageUris;
const url = await this.#resolveCurrentSelectedSizeUrl(imageUris);
@@ -308,11 +285,7 @@ export class FullscreenViewer extends BaseComponent {
this.container.append(this.#imageElement);
}
/**
* @param {string} url
* @return {boolean}
*/
static #isVideoUrl(url) {
static #isVideoUrl(url: string): boolean {
return url.endsWith('.mp4') || url.endsWith('.webm');
}
@@ -324,10 +297,7 @@ export class FullscreenViewer extends BaseComponent {
static #swipeState = 'swiped';
static #minRequiredDistance = 50;
/**
* @type {Record<import("$lib/extension/settings/MiscSettings").FullscreenViewerSize, string>}
*/
static #previewSizes = {
static #previewSizes: Record<FullscreenViewerSize, string> = {
full: 'Full',
large: 'Large',
medium: 'Medium',

View File

@@ -2,21 +2,23 @@ import { BaseComponent } from "$lib/components/base/BaseComponent";
import { getComponent } from "$lib/components/base/component-utils";
import MiscSettings from "$lib/extension/settings/MiscSettings";
import { FullscreenViewer } from "$lib/components/FullscreenViewer";
import type { MediaBoxTools } from "$lib/components/MediaBoxTools";
export class ImageShowFullscreenButton extends BaseComponent {
/**
* @type {import('./MediaBoxTools').MediaBoxTools|null}
*/
#mediaBoxTools= null;
#isFullscreenButtonEnabled = false;
#mediaBoxTools: MediaBoxTools | null = null;
#isFullscreenButtonEnabled: boolean = false;
build() {
protected build() {
this.container.innerText = '🔍';
ImageShowFullscreenButton.#miscSettings ??= new MiscSettings();
}
init() {
protected init() {
if (!this.container.parentElement) {
throw new Error('Missing parent element!');
}
this.#mediaBoxTools = getComponent(this.container.parentElement);
if (!this.#mediaBoxTools) {
@@ -32,7 +34,7 @@ export class ImageShowFullscreenButton extends BaseComponent {
this.#updateFullscreenButtonVisibility();
})
.then(() => {
ImageShowFullscreenButton.#miscSettings.subscribe(settings => {
ImageShowFullscreenButton.#miscSettings?.subscribe(settings => {
this.#isFullscreenButtonEnabled = settings.fullscreenViewer ?? true;
this.#updateFullscreenButtonVisibility();
})
@@ -45,28 +47,25 @@ export class ImageShowFullscreenButton extends BaseComponent {
}
#onButtonClicked() {
const imageLinks = this.#mediaBoxTools?.mediaBox.imageLinks;
if (!imageLinks) {
throw new Error('Failed to resolve image links from media box tools!');
}
ImageShowFullscreenButton
.#resolveViewer()
.show(this.#mediaBoxTools.mediaBox.imageLinks);
?.show(imageLinks);
}
/**
* @type {FullscreenViewer|null}
*/
static #viewer = null;
static #viewer: FullscreenViewer | null = null;
/**
* @return {FullscreenViewer}
*/
static #resolveViewer() {
static #resolveViewer(): FullscreenViewer {
this.#viewer ??= this.#buildViewer();
return this.#viewer;
}
/**
* @return {FullscreenViewer}
*/
static #buildViewer() {
static #buildViewer(): FullscreenViewer {
const element = document.createElement('div');
const viewer = new FullscreenViewer(element);
@@ -77,10 +76,7 @@ export class ImageShowFullscreenButton extends BaseComponent {
return viewer;
}
/**
* @type {MiscSettings|null}
*/
static #miscSettings = null;
static #miscSettings: MiscSettings | null = null;
}
export function createImageShowFullscreenButton() {

View File

@@ -10,47 +10,27 @@ import {
eventMaintenanceStateChanged,
eventTagsUpdated
} from "$lib/components/events/maintenance-popup-events";
import type { MediaBoxTools } from "$lib/components/MediaBoxTools";
class BlackListedTagsEncounteredError extends Error {
/**
* @param {string} tagName
*/
constructor(tagName) {
super(`This tag is blacklisted and prevents submission: ${tagName}`);
constructor(tagName: string) {
super(`This tag is blacklisted and prevents submission: ${tagName}`, {
cause: tagName
});
}
}
export class MaintenancePopup extends BaseComponent {
/** @type {HTMLElement} */
#tagsListElement = null;
/** @type {HTMLElement[]} */
#tagsList = [];
/** @type {Map<string, HTMLElement>} */
#suggestedInvalidTags = new Map();
/** @type {MaintenanceProfile|null} */
#activeProfile = null;
/** @type {import('$lib/components/MediaBoxTools').MediaBoxTools} */
#mediaBoxTools = null;
/** @type {Set<string>} */
#tagsToRemove = new Set();
/** @type {Set<string>} */
#tagsToAdd = new Set();
/** @type {boolean} */
#isPlanningToSubmit = false;
/** @type {boolean} */
#isSubmitting = false;
/** @type {number|null} */
#tagsSubmissionTimer = null;
#tagsListElement: HTMLElement = document.createElement('div');
#tagsList: HTMLElement[] = [];
#suggestedInvalidTags: Map<string, HTMLElement> = new Map();
#activeProfile: MaintenanceProfile | null = null;
#mediaBoxTools: MediaBoxTools | null = null;
#tagsToRemove: Set<string> = new Set();
#tagsToAdd: Set<string> = new Set();
#isPlanningToSubmit: boolean = false;
#isSubmitting: boolean = false;
#tagsSubmissionTimer: number | null = null;
#emitter = emitterAt(this);
/**
@@ -60,7 +40,6 @@ export class MaintenancePopup extends BaseComponent {
this.container.innerHTML = '';
this.container.classList.add('maintenance-popup');
this.#tagsListElement = document.createElement('div');
this.#tagsListElement.classList.add('tags-list');
this.container.append(
@@ -72,14 +51,13 @@ export class MaintenancePopup extends BaseComponent {
* @protected
*/
init() {
const mediaBoxToolsElement = this.container.closest('.media-box-tools');
const mediaBoxToolsElement = this.container.closest<HTMLElement>('.media-box-tools');
if (!mediaBoxToolsElement) {
throw new Error('Maintenance popup initialized outside of the media box tools!');
}
/** @type {MediaBoxTools|null} */
const mediaBoxTools = getComponent(mediaBoxToolsElement);
const mediaBoxTools = getComponent<MediaBoxTools>(mediaBoxToolsElement);
if (!mediaBoxTools) {
throw new Error('Media box tools component not found!');
@@ -96,10 +74,7 @@ export class MaintenancePopup extends BaseComponent {
mediaBox.on('mouseover', this.#onMouseEnteredArea.bind(this));
}
/**
* @param {MaintenanceProfile|null} activeProfile
*/
#onActiveProfileChanged(activeProfile) {
#onActiveProfileChanged(activeProfile: MaintenanceProfile | null) {
this.#activeProfile = activeProfile;
this.container.classList.toggle('is-active', activeProfile !== null);
this.#refreshTagsList();
@@ -108,8 +83,11 @@ export class MaintenancePopup extends BaseComponent {
}
#refreshTagsList() {
/** @type {string[]} */
const activeProfileTagsList = this.#activeProfile?.settings.tags || [];
if (!this.#mediaBoxTools) {
return;
}
const activeProfileTagsList: string[] = this.#activeProfile?.settings.tags || [];
for (const tagElement of this.#tagsList) {
tagElement.remove();
@@ -147,17 +125,22 @@ export class MaintenancePopup extends BaseComponent {
/**
* Detect and process clicks made directly to the tags.
* @param {MouseEvent} event
*/
#handleTagClick(event) {
/** @type {HTMLElement} */
let tagElement = event.target;
#handleTagClick(event: MouseEvent) {
const targetObject = event.target;
if (!tagElement.classList.contains('tag')) {
tagElement = tagElement.closest('.tag');
if (!targetObject || !(targetObject instanceof HTMLElement)) {
return;
}
if (!tagElement) {
let tagElement: HTMLElement | null = targetObject;
if (!tagElement.classList.contains('tag')) {
tagElement = tagElement.closest<HTMLElement>('.tag');
}
if (!tagElement?.dataset.name) {
return;
}
@@ -210,7 +193,7 @@ export class MaintenancePopup extends BaseComponent {
}
async #onSubmissionTimerPassed() {
if (!this.#isPlanningToSubmit || this.#isSubmitting) {
if (!this.#isPlanningToSubmit || this.#isSubmitting || !this.#mediaBoxTools) {
return;
}
@@ -281,6 +264,10 @@ export class MaintenancePopup extends BaseComponent {
}
#revealInvalidTags() {
if (!this.#mediaBoxTools) {
return;
}
const tagsAndAliases = this.#mediaBoxTools.mediaBox.tagsAndAliases;
if (!tagsAndAliases) {
@@ -310,18 +297,11 @@ export class MaintenancePopup extends BaseComponent {
}
}
/**
* @return {boolean}
*/
get isActive() {
return this.container.classList.contains('is-active');
}
/**
* @param {string} tagName
* @return {HTMLElement}
*/
static #buildTagElement(tagName) {
static #buildTagElement(tagName: string): HTMLElement {
const tagElement = document.createElement('span');
tagElement.classList.add('tag');
tagElement.innerText = tagName;
@@ -332,28 +312,26 @@ export class MaintenancePopup extends BaseComponent {
/**
* Marks the tag with red color.
* @param {HTMLElement} tagElement Element to mark.
* @param tagElement Element to mark.
*/
static #markTagAsInvalid(tagElement) {
static #markTagAsInvalid(tagElement: HTMLElement) {
tagElement.dataset.tagCategory = 'error';
tagElement.setAttribute('data-tag-category', 'error');
}
/**
* Controller with maintenance settings.
* @type {MaintenanceSettings}
*/
static #maintenanceSettings = new MaintenanceSettings();
/**
* Subscribe to all necessary feeds to watch for every active profile change. Additionally, will execute the callback
* at the very start to retrieve the currently active profile.
* @param {function(MaintenanceProfile|null):void} callback Callback to execute whenever selection of active profile
* or profile itself has been changed.
* @return {function(): void} Unsubscribe function. Call it to stop watching for changes.
* @param callback Callback to execute whenever selection of active profile or profile itself has been changed.
* @return Unsubscribe function. Call it to stop watching for changes.
*/
static #watchActiveProfile(callback) {
let lastActiveProfileId;
static #watchActiveProfile(callback: (profile: MaintenanceProfile | null) => void): () => void {
let lastActiveProfileId: string | null | undefined = null;
const unsubscribeFromProfilesChanges = MaintenanceProfile.subscribe(profiles => {
if (lastActiveProfileId) {
@@ -393,9 +371,9 @@ export class MaintenancePopup extends BaseComponent {
/**
* Notify the frontend about new pending submission started.
* @param {boolean} isStarted True if started, false if ended.
* @param isStarted True if started, false if ended.
*/
static #notifyAboutPendingSubmission(isStarted) {
static #notifyAboutPendingSubmission(isStarted: boolean) {
if (this.#pendingSubmissionCount === null) {
this.#pendingSubmissionCount = 0;
this.#initializeExitPromptHandler();
@@ -424,9 +402,8 @@ export class MaintenancePopup extends BaseComponent {
/**
* Amount of pending submissions or NULL if logic was not yet initialized.
* @type {number|null}
*/
static #pendingSubmissionCount = null;
static #pendingSubmissionCount: number|null = null;
}
export function createMaintenancePopup() {

View File

@@ -2,16 +2,20 @@ import { BaseComponent } from "$lib/components/base/BaseComponent";
import { getComponent } from "$lib/components/base/component-utils";
import { on } from "$lib/components/events/comms";
import { eventMaintenanceStateChanged } from "$lib/components/events/maintenance-popup-events";
import type { MediaBoxTools } from "$lib/components/MediaBoxTools";
export class MaintenanceStatusIcon extends BaseComponent {
/** @type {import('./MediaBoxTools').MediaBoxTools} */
#mediaBoxTools;
#mediaBoxTools: MediaBoxTools | null = null;
build() {
this.container.innerText = '🔧';
}
init() {
if (!this.container.parentElement) {
throw new Error('Missing parent element for the maintenance status icon!');
}
this.#mediaBoxTools = getComponent(this.container.parentElement);
if (!this.#mediaBoxTools) {
@@ -21,10 +25,7 @@ export class MaintenanceStatusIcon extends BaseComponent {
on(this.#mediaBoxTools, eventMaintenanceStateChanged, this.#onMaintenanceStateChanged.bind(this));
}
/**
* @param {CustomEvent<string>} stateChangeEvent
*/
#onMaintenanceStateChanged(stateChangeEvent) {
#onMaintenanceStateChanged(stateChangeEvent: CustomEvent<string>) {
// TODO Replace those with FontAwesome icons later. Those icons can probably be sourced from the website itself.
switch (stateChangeEvent.detail) {
case "ready":

View File

@@ -3,16 +3,15 @@ import { getComponent } from "$lib/components/base/component-utils";
import { MaintenancePopup } from "$lib/components/MaintenancePopup";
import { on } from "$lib/components/events/comms";
import { eventActiveProfileChanged } from "$lib/components/events/maintenance-popup-events";
import type { MediaBoxWrapper } from "$lib/components/MediaBoxWrapper";
import type MaintenanceProfile from "$entities/MaintenanceProfile";
export class MediaBoxTools extends BaseComponent {
/** @type {import('./MediaBoxWrapper').MediaBoxWrapper|null} */
#mediaBox;
/** @type {MaintenancePopup|null} */
#maintenancePopup = null;
#mediaBox: MediaBoxWrapper | null = null;
#maintenancePopup: MaintenancePopup | null = null;
init() {
const mediaBoxElement = this.container.closest('.media-box');
const mediaBoxElement = this.container.closest<HTMLElement>('.media-box');
if (!mediaBoxElement) {
throw new Error('Toolbox element initialized outside of the media box!');
@@ -21,6 +20,10 @@ export class MediaBoxTools extends BaseComponent {
this.#mediaBox = getComponent(mediaBoxElement);
for (let childElement of this.container.children) {
if (!(childElement instanceof HTMLElement)) {
continue;
}
const component = getComponent(childElement);
if (!component) {
@@ -39,34 +42,25 @@ export class MediaBoxTools extends BaseComponent {
on(this, eventActiveProfileChanged, this.#onActiveProfileChanged.bind(this));
}
/**
* @param {CustomEvent<import('$entities/MaintenanceProfile').default|null>} profileChangedEvent
*/
#onActiveProfileChanged(profileChangedEvent) {
#onActiveProfileChanged(profileChangedEvent: CustomEvent<MaintenanceProfile | null>) {
this.container.classList.toggle('has-active-profile', profileChangedEvent.detail !== null);
}
/**
* @return {MaintenancePopup|null}
*/
get maintenancePopup() {
get maintenancePopup(): MaintenancePopup | null {
return this.#maintenancePopup;
}
/**
* @return {import('./MediaBoxWrapper').MediaBoxWrapper|null}
*/
get mediaBox() {
get mediaBox(): MediaBoxWrapper | null {
return this.#mediaBox;
}
}
/**
* Create a maintenance popup element.
* @param {HTMLElement[]} childrenElements List of children elements to append to the component.
* @return {HTMLElement} The maintenance popup element.
* @param childrenElements List of children elements to append to the component.
* @return The maintenance popup element.
*/
export function createMediaBoxTools(...childrenElements) {
export function createMediaBoxTools(...childrenElements: HTMLElement[]): HTMLElement {
const mediaBoxToolsContainer = document.createElement('div');
mediaBoxToolsContainer.classList.add('media-box-tools');

View File

@@ -5,23 +5,18 @@ import { on } from "$lib/components/events/comms";
import { eventTagsUpdated } from "$lib/components/events/maintenance-popup-events";
export class MediaBoxWrapper extends BaseComponent {
#thumbnailContainer = null;
#imageLinkElement = null;
/** @type {Map<string,string>|null} */
#tagsAndAliases = null;
#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');
this.#imageLinkElement = this.#thumbnailContainer?.querySelector('a') || null;
on(this, eventTagsUpdated, this.#onTagsUpdatedRefreshTagsAndAliases.bind(this));
}
/**
* @param {CustomEvent<Map<string,string>|null>} tagsUpdatedEvent
*/
#onTagsUpdatedRefreshTagsAndAliases(tagsUpdatedEvent) {
#onTagsUpdatedRefreshTagsAndAliases(tagsUpdatedEvent: CustomEvent<Map<string, string> | null>) {
const updatedMap = tagsUpdatedEvent.detail;
if (!(updatedMap instanceof Map)) {
@@ -32,18 +27,13 @@ export class MediaBoxWrapper extends BaseComponent {
}
#calculateMediaBoxTags() {
/** @type {string[]|string[]} */
const
tagAliases = this.#thumbnailContainer.dataset.imageTagAliases?.split(', ') || [],
actualTags = this.#imageLinkElement.title.split(' | Tagged: ')[1]?.split(', ') || [];
const tagAliases: string[] = this.#thumbnailContainer?.dataset.imageTagAliases?.split(', ') || [];
const actualTags = this.#imageLinkElement?.title.split(' | Tagged: ')[1]?.split(', ') || [];
return buildTagsAndAliasesMap(tagAliases, actualTags);
}
/**
* @return {Map<string, string>|null}
*/
get tagsAndAliases() {
get tagsAndAliases(): Map<string, string> | null {
if (!this.#tagsAndAliases) {
this.#tagsAndAliases = this.#calculateMediaBoxTags();
}
@@ -51,26 +41,31 @@ export class MediaBoxWrapper extends BaseComponent {
return this.#tagsAndAliases;
}
get imageId() {
return parseInt(
this.container.dataset.imageId
);
get imageId(): number {
const imageId = this.container.dataset.imageId;
if (!imageId) {
throw new Error('Missing image ID');
}
return parseInt(imageId);
}
/**
* @return {App.ImageURIs}
*/
get imageLinks() {
return JSON.parse(this.#thumbnailContainer.dataset.uris);
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.
* @param {HTMLElement} mediaBoxContainer
* @param {HTMLElement[]} childComponentElements
*/
export function initializeMediaBox(mediaBoxContainer, childComponentElements) {
export function initializeMediaBox(mediaBoxContainer: HTMLElement, childComponentElements: HTMLElement[]) {
new MediaBoxWrapper(mediaBoxContainer)
.initialize();
@@ -80,17 +75,12 @@ export function initializeMediaBox(mediaBoxContainer, childComponentElements) {
}
}
/**
* @param {NodeListOf<HTMLElement>} mediaBoxesList
*/
export function calculateMediaBoxesPositions(mediaBoxesList) {
export function calculateMediaBoxesPositions(mediaBoxesList: NodeListOf<HTMLElement>) {
window.addEventListener('resize', () => {
/** @type {HTMLElement|null} */
let lastMediaBox = null,
/** @type {number|null} */
lastMediaBoxPosition = null;
let lastMediaBox: HTMLElement | null = null;
let lastMediaBoxPosition: number | null = null;
for (let mediaBoxElement of mediaBoxesList) {
for (const mediaBoxElement of mediaBoxesList) {
const yPosition = mediaBoxElement.getBoundingClientRect().y;
const isOnTheSameLine = yPosition === lastMediaBoxPosition;

View File

@@ -1,29 +1,25 @@
import { BaseComponent } from "$lib/components/base/BaseComponent";
import { QueryLexer, QuotedTermToken, TermToken, Token } from "$lib/booru/search/QueryLexer";
import SearchSettings from "$lib/extension/settings/SearchSettings";
import SearchSettings, { type SuggestionsPosition } from "$lib/extension/settings/SearchSettings";
export class SearchWrapper extends BaseComponent {
/** @type {HTMLInputElement|null} */
#searchField = null;
/** @type {string|null} */
#lastParsedSearchValue = null;
/** @type {Token[]} */
#cachedParsedQuery = [];
#searchSettings = new SearchSettings();
#arePropertiesSuggestionsEnabled = false;
/** @type {"start"|"end"} */
#propertiesSuggestionsPosition = "start";
/** @type {HTMLElement|null} */
#cachedAutocompleteContainer = null;
/** @type {TermToken|QuotedTermToken|null} */
#lastTermToken = null;
#searchField: HTMLInputElement | null = null;
#lastParsedSearchValue: string | null = null;
#cachedParsedQuery: Token[] = [];
#searchSettings: SearchSettings = new SearchSettings();
#arePropertiesSuggestionsEnabled: boolean = false;
#propertiesSuggestionsPosition: SuggestionsPosition = "start";
#cachedAutocompleteContainer: HTMLElement | null = null;
#lastTermToken: TermToken | QuotedTermToken | null = null;
build() {
this.#searchField = this.container.querySelector('input[name=q]');
}
init() {
this.#searchField.addEventListener('input', this.#onInputFindProperties.bind(this));
if (this.#searchField) {
this.#searchField.addEventListener('input', this.#onInputFindProperties.bind(this))
}
this.#searchSettings.resolvePropertiesSuggestionsEnabled()
.then(isEnabled => this.#arePropertiesSuggestionsEnabled = isEnabled);
@@ -31,18 +27,18 @@ export class SearchWrapper extends BaseComponent {
.then(position => this.#propertiesSuggestionsPosition = position);
this.#searchSettings.subscribe(settings => {
this.#arePropertiesSuggestionsEnabled = settings.suggestProperties;
this.#propertiesSuggestionsPosition = settings.suggestPropertiesPosition;
this.#arePropertiesSuggestionsEnabled = Boolean(settings.suggestProperties);
this.#propertiesSuggestionsPosition = settings.suggestPropertiesPosition || "start";
});
}
/**
* Catch the user input and execute suggestions logic.
* @param {InputEvent} event Source event to find the input element from.
* @param event Source event to find the input element from.
*/
#onInputFindProperties(event) {
#onInputFindProperties(event: Event) {
// Ignore events until option is enabled.
if (!this.#arePropertiesSuggestionsEnabled) {
if (!this.#arePropertiesSuggestionsEnabled || !(event.currentTarget instanceof HTMLInputElement)) {
return;
}
@@ -60,20 +56,26 @@ export class SearchWrapper extends BaseComponent {
/**
* Get the selection position in the search field.
* @return {number}
*/
#getInputUserSelection() {
#getInputUserSelection(): number {
if (!this.#searchField) {
throw new Error('Missing search field!');
}
return Math.min(
this.#searchField.selectionStart,
this.#searchField.selectionEnd
this.#searchField.selectionStart ?? 0,
this.#searchField.selectionEnd ?? 0,
);
}
/**
* Parse the search query and return the list of parsed tokens. Result will be cached for current search query.
* @return {Token[]}
*/
#resolveQueryTokens() {
#resolveQueryTokens(): Token[] {
if (!this.#searchField) {
throw new Error('Missing search field!');
}
const searchValue = this.#searchField.value;
if (searchValue === this.#lastParsedSearchValue && this.#cachedParsedQuery) {
@@ -88,9 +90,9 @@ export class SearchWrapper extends BaseComponent {
/**
* Find the currently selected term.
* @return {string|null} Selected term or null if none found.
* @return Selected term or null if none found.
*/
#findCurrentTagFragment() {
#findCurrentTagFragment(): string | null {
if (!this.#searchField) {
return null;
}
@@ -127,9 +129,9 @@ export class SearchWrapper extends BaseComponent {
*
* This means, that properties will only be suggested once actual autocomplete logic was activated.
*
* @return {HTMLElement|null} Resolved element or nothing.
* @return Resolved element or nothing.
*/
#resolveAutocompleteContainer() {
#resolveAutocompleteContainer(): HTMLElement | null {
if (this.#cachedAutocompleteContainer) {
return this.#cachedAutocompleteContainer;
}
@@ -141,11 +143,10 @@ export class SearchWrapper extends BaseComponent {
/**
* Render the list of suggestions into the existing popup or create and populate a new one.
* @param {string[]} suggestions List of suggestion to render the popup from.
* @param {HTMLInputElement} targetInput Target input to attach the popup to.
* @param suggestions List of suggestion to render the popup from.
* @param targetInput Target input to attach the popup to.
*/
#renderSuggestions(suggestions, targetInput) {
/** @type {HTMLElement[]} */
#renderSuggestions(suggestions: string[], targetInput: HTMLInputElement) {
const suggestedListItems = suggestions
.map(suggestedTerm => this.#renderTermSuggestion(suggestedTerm));
@@ -170,6 +171,10 @@ export class SearchWrapper extends BaseComponent {
const listContainer = autocompleteContainer.querySelector('ul');
if (!listContainer) {
return;
}
switch (this.#propertiesSuggestionsPosition) {
case "start":
listContainer.prepend(...suggestedListItems);
@@ -183,10 +188,11 @@ export class SearchWrapper extends BaseComponent {
console.warn("Invalid position for property suggestions!");
}
const parentScrollTop = targetInput.parentElement?.scrollTop ?? 0;
autocompleteContainer.style.position = 'absolute';
autocompleteContainer.style.left = `${targetInput.offsetLeft}px`;
autocompleteContainer.style.top = `${targetInput.offsetTop + targetInput.offsetHeight - targetInput.parentElement.scrollTop}px`;
autocompleteContainer.style.top = `${targetInput.offsetTop + targetInput.offsetHeight - parentScrollTop}px`;
document.body.append(autocompleteContainer);
})
@@ -194,30 +200,28 @@ export class SearchWrapper extends BaseComponent {
/**
* Loosely estimate where current selected search term is located and return it if found.
* @param {Token[]} tokens Search value to find the actively selected term from.
* @param {number} userSelectionIndex The index of the user selection.
* @return {Token|null} Search term object or NULL if nothing found.
* @param tokens Search value to find the actively selected term from.
* @param userSelectionIndex The index of the user selection.
* @return Search term object or NULL if nothing found.
*/
static #findActiveSearchTermPosition(tokens, userSelectionIndex) {
static #findActiveSearchTermPosition(tokens: Token[], userSelectionIndex: number): Token | null {
return tokens.find(
token => token.index < userSelectionIndex && token.index + token.value.length >= userSelectionIndex
);
) ?? null;
}
/**
* Regular expression to search the properties' syntax.
* @type {RegExp}
*/
static #propertySearchTermHeadingRegExp = /^(?<name>[a-z\d_]+)(?<op_syntax>\.(?<op>[a-z]*))?(?<value_syntax>:(?<value>.*))?$/;
/**
* Create a list of suggested elements using the input received from the user.
* @param {string} searchTermValue Original decoded term received from the user.
* @param searchTermValue Original decoded term received from the user.
* @return {string[]} List of suggestions. Could be empty.
*/
static #resolveSuggestionsFromTerm(searchTermValue) {
/** @type {string[]} */
const suggestionsList = [];
static #resolveSuggestionsFromTerm(searchTermValue: string): string[] {
const suggestionsList: string[] = [];
this.#propertySearchTermHeadingRegExp.lastIndex = 0;
const parsedResult = this.#propertySearchTermHeadingRegExp.exec(searchTermValue);
@@ -226,22 +230,28 @@ export class SearchWrapper extends BaseComponent {
return suggestionsList;
}
const propertyName = parsedResult.groups.name;
const propertyName = parsedResult.groups?.name;
if (!propertyName) {
return suggestionsList;
}
const propertyType = this.#properties.get(propertyName);
const hasOperatorSyntax = Boolean(parsedResult.groups.op_syntax);
const hasValueSyntax = Boolean(parsedResult.groups.value_syntax);
const hasOperatorSyntax = Boolean(parsedResult.groups?.op_syntax);
const hasValueSyntax = Boolean(parsedResult.groups?.value_syntax);
// No suggestions for values for now, maybe could add suggestions for namespaces like my:*
if (hasValueSyntax) {
if (hasValueSyntax && propertyType) {
if (this.#typeValues.has(propertyType)) {
const givenValue = parsedResult.groups.value;
const givenValue = parsedResult.groups?.value;
const candidateValues = this.#typeValues.get(propertyType) || [];
for (let candidateValue of this.#typeValues.get(propertyType)) {
for (let candidateValue of candidateValues) {
if (givenValue && !candidateValue.startsWith(givenValue)) {
continue;
}
suggestionsList.push(`${propertyName}${parsedResult.groups.op_syntax ?? ''}:${candidateValue}`);
suggestionsList.push(`${propertyName}${parsedResult.groups?.op_syntax ?? ''}:${candidateValue}`);
}
}
@@ -249,11 +259,12 @@ export class SearchWrapper extends BaseComponent {
}
// If at least one dot placed, start suggesting operators
if (hasOperatorSyntax) {
if (hasOperatorSyntax && propertyType) {
if (this.#typeOperators.has(propertyType)) {
const operatorName = parsedResult.groups.op;
const operatorName = parsedResult.groups?.op;
const candidateOperators = this.#typeOperators.get(propertyType) ?? [];
for (let candidateOperator of this.#typeOperators.get(propertyType)) {
for (let candidateOperator of candidateOperators) {
if (operatorName && !candidateOperator.startsWith(operatorName)) {
continue;
}
@@ -279,11 +290,10 @@ export class SearchWrapper extends BaseComponent {
/**
* Render a single suggestion item and connect required events to interact with the user.
* @param {string} suggestedTerm Term to use for suggestion item.
* @return {HTMLElement} Resulting element.
* @param suggestedTerm Term to use for suggestion item.
* @return Resulting element.
*/
#renderTermSuggestion(suggestedTerm) {
/** @type {HTMLElement} */
#renderTermSuggestion(suggestedTerm: string): HTMLElement {
const suggestionItem = document.createElement('li');
suggestionItem.classList.add('autocomplete__item', 'autocomplete__item--property');
suggestionItem.dataset.value = suggestedTerm;
@@ -311,10 +321,10 @@ export class SearchWrapper extends BaseComponent {
/**
* Automatically replace the last active token stored in the variable with the new value.
* @param {string} suggestedTerm Term to replace the value with.
* @param suggestedTerm Term to replace the value with.
*/
#replaceLastActiveTokenWithSuggestion(suggestedTerm) {
if (!this.#lastTermToken) {
#replaceLastActiveTokenWithSuggestion(suggestedTerm: string) {
if (!this.#lastTermToken || !this.#searchField) {
return;
}
@@ -334,10 +344,10 @@ export class SearchWrapper extends BaseComponent {
/**
* Find the selected suggestion item(s) and unselect them. Similar to the logic implemented by the Philomena's
* front-end.
* @param {HTMLElement} suggestedElement Target element to search from. If element is disconnected from the DOM,
* search will be halted.
* @param suggestedElement Target element to search from. If element is disconnected from the DOM, search will be
* halted.
*/
static #findAndResetSelectedSuggestion(suggestedElement) {
static #findAndResetSelectedSuggestion(suggestedElement: HTMLElement) {
if (!suggestedElement.parentElement) {
return;
}

View File

@@ -2,11 +2,10 @@ import { BaseComponent } from "$lib/components/base/BaseComponent";
import { SearchWrapper } from "$lib/components/SearchWrapper";
class SiteHeaderWrapper extends BaseComponent {
/** @type {SearchWrapper|null} */
#searchWrapper = null;
#searchWrapper: SearchWrapper | null = null;
build() {
const searchForm = this.container.querySelector('.header__search');
const searchForm = this.container.querySelector<HTMLElement>('.header__search');
this.#searchWrapper = searchForm && new SearchWrapper(searchForm) || null;
}
@@ -17,7 +16,7 @@ class SiteHeaderWrapper extends BaseComponent {
}
}
export function initializeSiteHeader(siteHeaderElement) {
export function initializeSiteHeader(siteHeaderElement: HTMLElement) {
new SiteHeaderWrapper(siteHeaderElement)
.initialize();
}

View File

@@ -4,44 +4,35 @@ import MaintenanceSettings from "$lib/extension/settings/MaintenanceSettings";
import { getComponent } from "$lib/components/base/component-utils";
import CustomCategoriesResolver from "$lib/extension/CustomCategoriesResolver";
const isTagEditorProcessedKey = Symbol();
const categoriesResolver = new CustomCategoriesResolver();
export class TagDropdownWrapper extends BaseComponent {
/**
* Container with dropdown elements to insert options into.
* @type {HTMLElement}
*/
#dropdownContainer;
#dropdownContainer: HTMLElement | null = null;
/**
* Button to add or remove the current tag into/from the active profile.
* @type {HTMLAnchorElement|null}
*/
#toggleOnExistingButton = null;
#toggleOnExistingButton: HTMLAnchorElement | null = null;
/**
* Button to create a new profile, make it active and add the current tag into the active profile.
* @type {HTMLAnchorElement|null}
*/
#addToNewButton = null;
#addToNewButton: HTMLAnchorElement | null = null;
/**
* Local clone of the currently active profile used for updating the list of tags.
* @type {MaintenanceProfile|null}
*/
#activeProfile = null;
#activeProfile: MaintenanceProfile | null = null;
/**
* Is cursor currently entered the dropdown.
* @type {boolean}
*/
#isEntered = false;
#isEntered: boolean = false;
/**
* @type {string|undefined|null}
*/
#originalCategory = null;
#originalCategory: string | undefined | null = null;
build() {
this.#dropdownContainer = this.container.querySelector('.dropdown__content');
@@ -116,7 +107,7 @@ export class TagDropdownWrapper extends BaseComponent {
);
if (!this.#addToNewButton.isConnected) {
this.#dropdownContainer.append(this.#addToNewButton);
this.#dropdownContainer?.append(this.#addToNewButton);
}
} else {
this.#addToNewButton?.remove();
@@ -130,15 +121,16 @@ export class TagDropdownWrapper extends BaseComponent {
const profileName = this.#activeProfile.settings.name;
let profileSpecificButtonText = `Add to profile "${profileName}"`;
const tagName = this.tagName;
if (this.#activeProfile.settings.tags.includes(this.tagName)) {
if (tagName && this.#activeProfile.settings.tags.includes(tagName)) {
profileSpecificButtonText = `Remove from profile "${profileName}"`;
}
this.#toggleOnExistingButton.innerText = profileSpecificButtonText;
if (!this.#toggleOnExistingButton.isConnected) {
this.#dropdownContainer.append(this.#toggleOnExistingButton);
this.#dropdownContainer?.append(this.#toggleOnExistingButton);
}
return;
@@ -148,6 +140,12 @@ export class TagDropdownWrapper extends BaseComponent {
}
async #onAddToNewClicked() {
const tagName = this.tagName;
if (!tagName) {
throw new Error('Missing tag name to create the profile!');
}
const profile = new MaintenanceProfile(crypto.randomUUID(), {
name: 'Temporary Profile (' + (new Date().toISOString()) + ')',
tags: [this.tagName],
@@ -166,6 +164,10 @@ export class TagDropdownWrapper extends BaseComponent {
const tagsList = new Set(this.#activeProfile.settings.tags);
const targetTagName = this.tagName;
if (!targetTagName) {
throw new Error('Missing tag name!');
}
if (tagsList.has(targetTagName)) {
tagsList.delete(targetTagName);
} else {
@@ -181,14 +183,14 @@ export class TagDropdownWrapper extends BaseComponent {
/**
* Watch for changes to active profile.
* @param {(profile: MaintenanceProfile|null) => void} onActiveProfileChange Callback to call when profile was
* @param onActiveProfileChange Callback to call when profile was
* changed.
*/
static #watchActiveProfile(onActiveProfileChange) {
let lastActiveProfile;
static #watchActiveProfile(onActiveProfileChange: (profile: MaintenanceProfile|null) => void) {
let lastActiveProfile: string | null = null;
this.#maintenanceSettings.subscribe((settings) => {
lastActiveProfile = settings.activeProfile;
lastActiveProfile = settings.activeProfile ?? null;
this.#maintenanceSettings
.resolveActiveProfileAsObject()
@@ -199,7 +201,8 @@ export class TagDropdownWrapper extends BaseComponent {
const activeProfile = profiles
.find(profile => profile.id === lastActiveProfile);
onActiveProfileChange(activeProfile);
onActiveProfileChange(activeProfile ?? null
);
});
this.#maintenanceSettings
@@ -212,12 +215,11 @@ export class TagDropdownWrapper extends BaseComponent {
/**
* Create element for dropdown.
* @param {string} text Base text for the option.
* @param {(event: MouseEvent) => void} onClickHandler Click handler. Event will be prevented by default.
* @return {HTMLAnchorElement}
* @param text Base text for the option.
* @param onClickHandler Click handler. Event will be prevented by default.
* @return
*/
static #createDropdownLink(text, onClickHandler) {
/** @type {HTMLAnchorElement} */
static #createDropdownLink(text: string, onClickHandler: (event: MouseEvent) => void): HTMLAnchorElement {
const dropdownLink = document.createElement('a');
dropdownLink.href = '#';
dropdownLink.innerText = text;
@@ -232,7 +234,7 @@ export class TagDropdownWrapper extends BaseComponent {
}
}
export function wrapTagDropdown(element) {
export function wrapTagDropdown(element: HTMLElement) {
// Skip initialization when tag component is already wrapped
if (getComponent(element)) {
return;
@@ -244,6 +246,8 @@ export function wrapTagDropdown(element) {
categoriesResolver.addElement(tagDropdown);
}
const processedElementsSet = new WeakSet<HTMLElement>();
export function watchTagDropdownsInTagsEditor() {
// We only need to watch for new editor elements if there is a tag editor present on the page
if (!document.querySelector('#image_tags_and_source')) {
@@ -251,25 +255,27 @@ export function watchTagDropdownsInTagsEditor() {
}
document.body.addEventListener('mouseover', event => {
/** @type {HTMLElement} */
const targetElement = event.target;
if (targetElement[isTagEditorProcessedKey]) {
if (!(targetElement instanceof HTMLElement)) {
return;
}
/** @type {HTMLElement|null} */
const closestTagEditor = targetElement.closest('#image_tags_and_source');
if (!closestTagEditor || closestTagEditor[isTagEditorProcessedKey]) {
targetElement[isTagEditorProcessedKey] = true;
if (processedElementsSet.has(targetElement)) {
return;
}
targetElement[isTagEditorProcessedKey] = true;
closestTagEditor[isTagEditorProcessedKey] = true;
const closestTagEditor = targetElement.closest<HTMLElement>('#image_tags_and_source');
for (const tagDropdownElement of closestTagEditor.querySelectorAll('.tag.dropdown')) {
if (!closestTagEditor || processedElementsSet.has(closestTagEditor)) {
processedElementsSet.add(targetElement);
return;
}
processedElementsSet.add(targetElement);
processedElementsSet.add(closestTagEditor);
for (const tagDropdownElement of closestTagEditor.querySelectorAll<HTMLElement>('.tag.dropdown')) {
wrapTagDropdown(tagDropdownElement);
}
})

View File

@@ -7,9 +7,9 @@ export class TagsForm extends BaseComponent {
*/
refreshTagColors() {
const tagCategories = this.#gatherTagCategories();
const editableTags = this.container.querySelectorAll('.tag');
const editableTags = this.container.querySelectorAll<HTMLElement>('.tag');
for (let tagElement of editableTags) {
for (const tagElement of editableTags) {
// Tag name is stored in the "remove" link and not in the tag itself.
const removeLink = tagElement.querySelector('a');
@@ -19,11 +19,11 @@ export class TagsForm extends BaseComponent {
const tagName = removeLink.dataset.tagName;
if (!tagCategories.has(tagName)) {
if (!tagName || !tagCategories.has(tagName)) {
continue;
}
const categoryName = tagCategories.get(tagName);
const categoryName = tagCategories.get(tagName)!;
tagElement.dataset.tagCategory = categoryName;
tagElement.setAttribute('data-tag-category', categoryName);
@@ -32,14 +32,21 @@ export class TagsForm extends BaseComponent {
/**
* Collect list of categories from the tags on the page.
* @return {Map<string, string>}
* @return
*/
#gatherTagCategories() {
/** @type {Map<string, string>} */
const tagCategories = new Map();
#gatherTagCategories(): Map<string, string> {
const tagCategories: Map<string, string> = new Map();
for (let tagElement of document.querySelectorAll('.tag[data-tag-name][data-tag-category]')) {
tagCategories.set(tagElement.dataset.tagName, tagElement.dataset.tagCategory);
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;
@@ -59,23 +66,26 @@ export class TagsForm extends BaseComponent {
return;
}
const refreshTrigger = targetElement.closest('.js-taginput-show, #edit-tags')
const refreshTrigger = targetElement.closest<HTMLElement>('.js-taginput-show, #edit-tags')
if (!refreshTrigger) {
return;
}
const tagFormElement = tagEditorWrapper.querySelector('#tags-form');
const tagFormElement = tagEditorWrapper.querySelector<HTMLElement>('#tags-form');
if (!tagFormElement) {
return;
}
/** @type {TagsForm|null} */
let tagEditor = getComponent(tagFormElement);
if (!tagEditor || (!tagEditor instanceof TagsForm)) {
if (!tagEditor || !(tagEditor instanceof TagsForm)) {
tagEditor = new TagsForm(tagFormElement);
tagEditor.initialize();
}
tagEditor.refreshTagColors();
(tagEditor as TagsForm).refreshTagColors();
});
}
}

View File

@@ -1,18 +1,14 @@
import { bindComponent } from "$lib/components/base/component-utils";
/**
* @abstract
*/
export class BaseComponent {
/** @type {HTMLElement} */
#container;
type ComponentEventListener<EventName extends keyof HTMLElementEventMap> =
(this: HTMLElement, event: HTMLElementEventMap[EventName]) => void;
export class BaseComponent<ContainerType extends HTMLElement = HTMLElement> {
readonly #container: ContainerType;
#isInitialized = false;
/**
* @param {HTMLElement} container
*/
constructor(container) {
constructor(container: ContainerType) {
this.#container = container;
bindComponent(container, this);
@@ -29,42 +25,33 @@ export class BaseComponent {
this.init();
}
/**
* @protected
*/
build() {
protected build(): void {
// This method can be implemented by the component classes to modify or create the inner elements.
}
/**
* @protected
*/
init() {
protected init(): void {
// This method can be implemented by the component classes to initialize the component.
}
};
/**
* @return {HTMLElement}
*/
get container() {
get container(): ContainerType {
return this.#container;
}
/**
* Check if the component is initialized already. If not checked, subsequent calls to the `initialize` method will
* throw an error.
* @return {boolean}
* @return
*/
get isInitialized() {
get isInitialized(): boolean {
return this.#isInitialized;
}
/**
* Emit the custom event on the container element.
* @param {keyof HTMLElementEventMap|string} event The event name.
* @param {any} [detail] The event detail. Can be omitted.
* @param event The event name.
* @param [detail] The event detail. Can be omitted.
*/
emit(event, detail = undefined) {
emit(event: keyof HTMLElementEventMap | string, detail: any = undefined): void {
this.#container.dispatchEvent(
new CustomEvent(
event,
@@ -78,12 +65,16 @@ export class BaseComponent {
/**
* Subscribe to the DOM event on the container element.
* @param {keyof HTMLElementEventMap|string} event The event name.
* @param {function(Event): void} listener The event listener.
* @param {AddEventListenerOptions|undefined} [options] The event listener options. Can be omitted.
* @return {function(): void} The unsubscribe function.
* @param event The event name.
* @param listener The event listener.
* @param [options] The event listener options. Can be omitted.
* @return The unsubscribe function.
*/
on(event, listener, options = undefined) {
on<EventName extends keyof HTMLElementEventMap>(
event: EventName,
listener: ComponentEventListener<EventName>,
options?: AddEventListenerOptions,
): () => void {
this.#container.addEventListener(event, listener, options);
return () => void this.#container.removeEventListener(event, listener, options);
@@ -91,12 +82,16 @@ export class BaseComponent {
/**
* Subscribe to the DOM event on the container element. The event listener will be called only once.
* @param {keyof HTMLElementEventMap|string} event The event name.
* @param {function(Event): void} listener The event listener.
* @param {AddEventListenerOptions|undefined} [options] The event listener options. Can be omitted.
* @return {function(): void} The unsubscribe function.
* @param event The event name.
* @param listener The event listener.
* @param [options] The event listener options. Can be omitted.
* @return The unsubscribe function.
*/
once(event, listener, options = undefined) {
once<EventName extends keyof HTMLElementEventMap>(
event: EventName,
listener: ComponentEventListener<EventName>,
options?: AddEventListenerOptions,
): () => void {
options = options || {};
options.once = true;

View File

@@ -2,8 +2,8 @@ import type { BaseComponent } from "$lib/components/base/BaseComponent";
const instanceSymbol = Symbol('instance');
interface ElementWithComponent extends HTMLElement {
[instanceSymbol]?: BaseComponent;
interface ElementWithComponent<T> extends HTMLElement {
[instanceSymbol]?: T;
}
/**
@@ -11,7 +11,7 @@ interface ElementWithComponent extends HTMLElement {
* @param {HTMLElement} element
* @return
*/
export function getComponent(element: ElementWithComponent): BaseComponent | null {
export function getComponent<T extends BaseComponent = BaseComponent>(element: ElementWithComponent<T>): T | null {
return element[instanceSymbol] || null;
}
@@ -20,7 +20,7 @@ export function getComponent(element: ElementWithComponent): BaseComponent | nul
* @param element The element to bind the component to.
* @param instance The component instance.
*/
export function bindComponent(element: ElementWithComponent, instance: BaseComponent): void {
export function bindComponent<T extends BaseComponent = BaseComponent>(element: ElementWithComponent<T>, instance: T): void {
if (element[instanceSymbol]) {
throw new Error('The element is already bound to a component.');
}

View File

@@ -1,7 +1,8 @@
import type { MaintenancePopupEventsMap } from "$lib/components/events/maintenance-popup-events";
import { BaseComponent } from "$lib/components/base/BaseComponent";
import type { FullscreenViewerEventsMap } from "$lib/components/events/fullscreen-viewer-events";
interface EventsMapping extends MaintenancePopupEventsMap {
interface EventsMapping extends MaintenancePopupEventsMap, FullscreenViewerEventsMap {
}
type EventCallback<EventDetails> = (event: CustomEvent<EventDetails>) => void;

View File

@@ -0,0 +1,7 @@
import type { FullscreenViewerSize } from "$lib/extension/settings/MiscSettings";
export const eventSizeLoaded = 'size-loaded';
export interface FullscreenViewerEventsMap {
[eventSizeLoaded]: FullscreenViewerSize;
}

View File

@@ -1,6 +1,6 @@
import CacheableSettings from "$lib/extension/base/CacheableSettings";
export type FullscreenViewerSize = 'small' | 'medium' | 'large' | 'full';
export type FullscreenViewerSize = keyof App.ImageURIs;
interface MiscSettingsFields {
fullscreenViewer: boolean;

View File

@@ -0,0 +1,109 @@
import { BaseComponent } from "$lib/components/base/BaseComponent";
import { getComponent } from "$lib/components/base/component-utils";
function randomString() {
return crypto.randomUUID();
}
describe('BaseComponent', () => {
it('should bind the component to the element', () => {
const element = document.createElement('div');
const component = new BaseComponent(element);
expect(getComponent(element)).toBe(component);
});
it('should throw an error when attempting to initialize component on same element multiple times', () => {
const element = document.createElement('div');
expect(() => new BaseComponent(element)).not.toThrowError();
expect(() => new BaseComponent(element)).toThrowError();
});
it('should return the element as component container', () => {
const element = document.createElement('div');
const component = new BaseComponent(element);
expect(component.container).toBe(element);
});
it('should mark itself as initialized after initialization', () => {
const element = document.createElement('div');
const component = new BaseComponent(element);
expect(component.isInitialized).toBe(false);
component.initialize();
expect(component.isInitialized).toBe(true);
});
it('should throw error when attempting to initialize component multiple times', () => {
const element = document.createElement('div');
const component = new BaseComponent(element);
expect(() => component.initialize()).not.toThrowError();
expect(() => component.initialize()).toThrowError();
});
it('should emit custom events on element', () => {
const element = document.createElement('div');
const component = new BaseComponent(element);
let receivedEvent: CustomEvent<string> | null = null;
const eventName = randomString();
const eventData = randomString();
const eventHandler = vi.fn(event => {
receivedEvent = event;
});
element.addEventListener(eventName, eventHandler);
component.emit(eventName, eventData);
expect(eventHandler).toBeCalled();
expect(receivedEvent).toBeInstanceOf(CustomEvent);
expect(receivedEvent!.detail).toBe(eventData);
});
it('should listen events on element', () => {
const element = document.createElement('div');
const component = new BaseComponent(element);
const eventName = 'click';
const eventHandler = vi.fn();
component.on(eventName, eventHandler);
element.dispatchEvent(new Event(eventName));
expect(eventHandler).toBeCalled();
});
it('should disconnect listener with unsubscribe function', () => {
const element = document.createElement('div');
const component = new BaseComponent(element);
const eventName = 'click';
const eventHandler = vi.fn();
const unsubscribe = component.on(eventName, eventHandler);
element.dispatchEvent(new Event(eventName));
unsubscribe();
element.dispatchEvent(new Event(eventName));
expect(eventHandler).toBeCalledTimes(1);
});
it('should listen for event once', () => {
const element = document.createElement('div');
const component = new BaseComponent(element);
const eventName = 'click';
const eventHandler = vi.fn();
component.once(eventName, eventHandler);
element.dispatchEvent(new Event(eventName));
element.dispatchEvent(new Event(eventName));
expect(eventHandler).toBeCalledTimes(1);
});
});