diff --git a/manifest.json b/manifest.json index dfc9922..8ee8dd0 100644 --- a/manifest.json +++ b/manifest.json @@ -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" ] } ], diff --git a/src/content/header.js b/src/content/header.ts similarity index 100% rename from src/content/header.js rename to src/content/header.ts diff --git a/src/content/listing.js b/src/content/listing.ts similarity index 100% rename from src/content/listing.js rename to src/content/listing.ts diff --git a/src/content/tags-editor.js b/src/content/tags-editor.ts similarity index 100% rename from src/content/tags-editor.js rename to src/content/tags-editor.ts diff --git a/src/content/tags.js b/src/content/tags.ts similarity index 100% rename from src/content/tags.js rename to src/content/tags.ts diff --git a/src/lib/components/FullscreenViewer.js b/src/lib/components/FullscreenViewer.ts similarity index 78% rename from src/lib/components/FullscreenViewer.js rename to src/lib/components/FullscreenViewer.ts index 9449dac..48f3c1c 100644 --- a/src/lib/components/FullscreenViewer.js +++ b/src/lib/components/FullscreenViewer.ts @@ -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} - */ - async #resolveCurrentSelectedSizeUrl(imageUris) { + async #resolveCurrentSelectedSizeUrl(imageUris: App.ImageURIs): Promise { 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 { 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} - */ - static #previewSizes = { + static #previewSizes: Record = { full: 'Full', large: 'Large', medium: 'Medium', diff --git a/src/lib/components/ImageShowFullscreenButton.js b/src/lib/components/ImageShowFullscreenButton.ts similarity index 70% rename from src/lib/components/ImageShowFullscreenButton.js rename to src/lib/components/ImageShowFullscreenButton.ts index fc00370..01a1eaf 100644 --- a/src/lib/components/ImageShowFullscreenButton.js +++ b/src/lib/components/ImageShowFullscreenButton.ts @@ -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() { diff --git a/src/lib/components/MaintenancePopup.js b/src/lib/components/MaintenancePopup.ts similarity index 82% rename from src/lib/components/MaintenancePopup.js rename to src/lib/components/MaintenancePopup.ts index 3966be1..718c6a8 100644 --- a/src/lib/components/MaintenancePopup.js +++ b/src/lib/components/MaintenancePopup.ts @@ -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} */ - #suggestedInvalidTags = new Map(); - - /** @type {MaintenanceProfile|null} */ - #activeProfile = null; - - /** @type {import('$lib/components/MediaBoxTools').MediaBoxTools} */ - #mediaBoxTools = null; - - /** @type {Set} */ - #tagsToRemove = new Set(); - - /** @type {Set} */ - #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 = new Map(); + #activeProfile: MaintenanceProfile | null = null; + #mediaBoxTools: MediaBoxTools | null = null; + #tagsToRemove: Set = new Set(); + #tagsToAdd: Set = 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('.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(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('.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() { diff --git a/src/lib/components/MaintenanceStatusIcon.js b/src/lib/components/MaintenanceStatusIcon.ts similarity index 83% rename from src/lib/components/MaintenanceStatusIcon.js rename to src/lib/components/MaintenanceStatusIcon.ts index 78ebd57..06c5bb6 100644 --- a/src/lib/components/MaintenanceStatusIcon.js +++ b/src/lib/components/MaintenanceStatusIcon.ts @@ -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} stateChangeEvent - */ - #onMaintenanceStateChanged(stateChangeEvent) { + #onMaintenanceStateChanged(stateChangeEvent: CustomEvent) { // TODO Replace those with FontAwesome icons later. Those icons can probably be sourced from the website itself. switch (stateChangeEvent.detail) { case "ready": diff --git a/src/lib/components/MediaBoxTools.js b/src/lib/components/MediaBoxTools.ts similarity index 66% rename from src/lib/components/MediaBoxTools.js rename to src/lib/components/MediaBoxTools.ts index f2de6c5..754a424 100644 --- a/src/lib/components/MediaBoxTools.js +++ b/src/lib/components/MediaBoxTools.ts @@ -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('.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} profileChangedEvent - */ - #onActiveProfileChanged(profileChangedEvent) { + #onActiveProfileChanged(profileChangedEvent: CustomEvent) { 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'); diff --git a/src/lib/components/MediaBoxWrapper.js b/src/lib/components/MediaBoxWrapper.ts similarity index 57% rename from src/lib/components/MediaBoxWrapper.js rename to src/lib/components/MediaBoxWrapper.ts index ff0ec34..9e75ed3 100644 --- a/src/lib/components/MediaBoxWrapper.js +++ b/src/lib/components/MediaBoxWrapper.ts @@ -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|null} */ - #tagsAndAliases = null; + #thumbnailContainer: HTMLElement | null = null; + #imageLinkElement: HTMLAnchorElement | null = null; + #tagsAndAliases: Map | 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|null>} tagsUpdatedEvent - */ - #onTagsUpdatedRefreshTagsAndAliases(tagsUpdatedEvent) { + #onTagsUpdatedRefreshTagsAndAliases(tagsUpdatedEvent: CustomEvent | 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|null} - */ - get tagsAndAliases() { + get tagsAndAliases(): Map | 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} mediaBoxesList - */ -export function calculateMediaBoxesPositions(mediaBoxesList) { +export function calculateMediaBoxesPositions(mediaBoxesList: NodeListOf) { 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; diff --git a/src/lib/components/SearchWrapper.js b/src/lib/components/SearchWrapper.ts similarity index 75% rename from src/lib/components/SearchWrapper.js rename to src/lib/components/SearchWrapper.ts index 80f2da4..aeebdde 100644 --- a/src/lib/components/SearchWrapper.js +++ b/src/lib/components/SearchWrapper.ts @@ -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 = /^(?[a-z\d_]+)(?\.(?[a-z]*))?(?:(?.*))?$/; /** * 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; } diff --git a/src/lib/components/SiteHeaderWrapper.js b/src/lib/components/SiteHeaderWrapper.ts similarity index 68% rename from src/lib/components/SiteHeaderWrapper.js rename to src/lib/components/SiteHeaderWrapper.ts index 3790097..c1b22fe 100644 --- a/src/lib/components/SiteHeaderWrapper.js +++ b/src/lib/components/SiteHeaderWrapper.ts @@ -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('.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(); } diff --git a/src/lib/components/TagDropdownWrapper.js b/src/lib/components/TagDropdownWrapper.ts similarity index 76% rename from src/lib/components/TagDropdownWrapper.js rename to src/lib/components/TagDropdownWrapper.ts index 3b09f08..0f3335c 100644 --- a/src/lib/components/TagDropdownWrapper.js +++ b/src/lib/components/TagDropdownWrapper.ts @@ -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(); + 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('#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('.tag.dropdown')) { wrapTagDropdown(tagDropdownElement); } }) diff --git a/src/lib/components/TagsForm.js b/src/lib/components/TagsForm.ts similarity index 57% rename from src/lib/components/TagsForm.js rename to src/lib/components/TagsForm.ts index 5376ca3..60aa018 100644 --- a/src/lib/components/TagsForm.js +++ b/src/lib/components/TagsForm.ts @@ -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('.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} + * @return */ - #gatherTagCategories() { - /** @type {Map} */ - const tagCategories = new Map(); + #gatherTagCategories(): Map { + const tagCategories: Map = 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('.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('.js-taginput-show, #edit-tags') if (!refreshTrigger) { return; } - const tagFormElement = tagEditorWrapper.querySelector('#tags-form'); + const tagFormElement = tagEditorWrapper.querySelector('#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(); }); } } diff --git a/src/lib/components/base/BaseComponent.js b/src/lib/components/base/BaseComponent.ts similarity index 52% rename from src/lib/components/base/BaseComponent.js rename to src/lib/components/base/BaseComponent.ts index 04ac8fd..fd3ecdd 100644 --- a/src/lib/components/base/BaseComponent.js +++ b/src/lib/components/base/BaseComponent.ts @@ -1,18 +1,14 @@ import { bindComponent } from "$lib/components/base/component-utils"; -/** - * @abstract - */ -export class BaseComponent { - /** @type {HTMLElement} */ - #container; +type ComponentEventListener = + (this: HTMLElement, event: HTMLElementEventMap[EventName]) => void; + +export class BaseComponent { + 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( + event: EventName, + listener: ComponentEventListener, + 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( + event: EventName, + listener: ComponentEventListener, + options?: AddEventListenerOptions, + ): () => void { options = options || {}; options.once = true; diff --git a/src/lib/components/base/component-utils.ts b/src/lib/components/base/component-utils.ts index 636b8f4..5f6bd42 100644 --- a/src/lib/components/base/component-utils.ts +++ b/src/lib/components/base/component-utils.ts @@ -2,8 +2,8 @@ import type { BaseComponent } from "$lib/components/base/BaseComponent"; const instanceSymbol = Symbol('instance'); -interface ElementWithComponent extends HTMLElement { - [instanceSymbol]?: BaseComponent; +interface ElementWithComponent 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(element: ElementWithComponent): 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(element: ElementWithComponent, instance: T): void { if (element[instanceSymbol]) { throw new Error('The element is already bound to a component.'); } diff --git a/src/lib/components/events/comms.ts b/src/lib/components/events/comms.ts index 8db137c..30f4961 100644 --- a/src/lib/components/events/comms.ts +++ b/src/lib/components/events/comms.ts @@ -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 = (event: CustomEvent) => void; diff --git a/src/lib/components/events/fullscreen-viewer-events.ts b/src/lib/components/events/fullscreen-viewer-events.ts new file mode 100644 index 0000000..333a917 --- /dev/null +++ b/src/lib/components/events/fullscreen-viewer-events.ts @@ -0,0 +1,7 @@ +import type { FullscreenViewerSize } from "$lib/extension/settings/MiscSettings"; + +export const eventSizeLoaded = 'size-loaded'; + +export interface FullscreenViewerEventsMap { + [eventSizeLoaded]: FullscreenViewerSize; +} diff --git a/src/lib/extension/settings/MiscSettings.ts b/src/lib/extension/settings/MiscSettings.ts index 0544c29..c8b4508 100644 --- a/src/lib/extension/settings/MiscSettings.ts +++ b/src/lib/extension/settings/MiscSettings.ts @@ -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; diff --git a/tests/lib/components/base/BaseComponent.spec.ts b/tests/lib/components/base/BaseComponent.spec.ts new file mode 100644 index 0000000..d15392c --- /dev/null +++ b/tests/lib/components/base/BaseComponent.spec.ts @@ -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 | 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); + }); +});