diff --git a/src/content/components/extension/FullscreenViewer.ts b/src/content/components/extension/FullscreenViewer.ts index 616acdf..784ebc5 100644 --- a/src/content/components/extension/FullscreenViewer.ts +++ b/src/content/components/extension/FullscreenViewer.ts @@ -1,28 +1,50 @@ import { BaseComponent } from "$content/components/base/BaseComponent"; import MiscPreferences, { type FullscreenViewerSize } from "$lib/extension/preferences/MiscPreferences"; -import { emit, on } from "$content/components/events/comms"; +import { emit, on, type UnsubscribeFunction } from "$content/components/events/comms"; import { EVENT_SIZE_LOADED } from "$content/components/events/fullscreen-viewer-events"; +import type { MediaBox } from "$content/components/philomena/MediaBox"; +import TagsListToggler from "$content/components/extension/profiles/TagsListToggler"; +import { EVENT_ACTIVE_PROFILE_CHANGED } from "$content/components/events/tagging-profile-popup-events"; +import type TaggingProfile from "$entities/TaggingProfile"; +import { EVENT_TAG_TOGGLED } from "$content/components/events/tags-list-toggler-events"; +import type { TaggingProfilePopup } from "$content/components/extension/profiles/TaggingProfilePopup"; export class FullscreenViewer extends BaseComponent { + #currentMediaBox: MediaBox | null = 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'); + #tagsPopupElement: HTMLElement = document.createElement('div'); + #imageTagsToggler: TagsListToggler = TagsListToggler.create(); + #profileTagsToggler: TagsListToggler = TagsListToggler.create(); + #activeProfileListenerUnsubscriber: UnsubscribeFunction | null = null; #touchId: number | null = null; #startX: number | null = null; #startY: number | null = null; #isClosingSwipeStarted: boolean | null = null; #isSizeFetched: boolean = false; - #currentURIs: App.ImageURIs | null = null; protected build() { this.container.classList.add('fullscreen-viewer'); + this.#imageTagsToggler.initialize(); + this.#profileTagsToggler.initialize(); + + this.#tagsPopupElement.append( + this.#profileTagsToggler.container, + document.createElement('hr'), + this.#imageTagsToggler.container, + ); + + this.#tagsPopupElement.classList.add('tags-panel'); + this.container.append( this.#spinnerElement, this.#sizeSelectorElement, this.#closeButtonElement, + this.#tagsPopupElement, ); this.#spinnerElement.classList.add('spinner', 'fa', 'fa-circle-notch', 'fa-spin'); @@ -52,11 +74,15 @@ export class FullscreenViewer extends BaseComponent { this.#videoElement.addEventListener('loadeddata', this.#onLoaded.bind(this)); this.#imageElement.addEventListener('load', this.#onLoaded.bind(this)); this.#sizeSelectorElement.addEventListener('click', event => event.stopPropagation()); + this.#tagsPopupElement.addEventListener('click', event => event.stopPropagation()); FullscreenViewer.#preferences .fullscreenViewerSize.get() .then(this.#onSizeResolved.bind(this)) .then(this.#watchForSizeSelectionChanges.bind(this)); + + on(this.#profileTagsToggler, EVENT_TAG_TOGGLED, this.#onTagToggled.bind(this)); + on(this.#imageTagsToggler, EVENT_TAG_TOGGLED, this.#onTagToggled.bind(this)); } #onLoaded() { @@ -193,8 +219,8 @@ export class FullscreenViewer extends BaseComponent { this.#sizeSelectorElement.addEventListener('input', () => { const targetSize = this.#sizeSelectorElement.value; - if (this.#currentURIs) { - void this.show(this.#currentURIs); + if (this.#currentMediaBox) { + void this.show(this.#currentMediaBox); } if (!targetSize || targetSize === lastActiveSize || !(targetSize in FullscreenViewer.#previewSizes)) { @@ -206,8 +232,55 @@ export class FullscreenViewer extends BaseComponent { }); } + #onTagToggled(event: CustomEvent<[string, boolean]>) { + const sourceTogglerContainer = event.currentTarget; + + if (!(sourceTogglerContainer instanceof HTMLElement)) { + return; + } + + if (sourceTogglerContainer !== this.#imageTagsToggler.container) { + this.#imageTagsToggler.refreshStateFromChanges(); + } + + if (sourceTogglerContainer !== this.#profileTagsToggler.container) { + this.#imageTagsToggler.refreshStateFromChanges(); + } + + this.#currentMediaBox?.tools?.popup?.tagsToggler.refreshStateFromChanges(); + } + + #onActiveProfileChanged(eventOrTaggingProfile: CustomEvent | TaggingProfile | null) { + let resolvedActiveTaggingProfile: TaggingProfile | null = null; + + if (eventOrTaggingProfile instanceof CustomEvent) { + resolvedActiveTaggingProfile = eventOrTaggingProfile.detail; + } else { + resolvedActiveTaggingProfile = eventOrTaggingProfile; + } + + this.#profileTagsToggler.container.classList.toggle('is-hidden', !resolvedActiveTaggingProfile); + + if (!resolvedActiveTaggingProfile) { + return; + } + + const uniqueTagsSet = this.#currentMediaBox?.uniqueTagsSet; + + this.#profileTagsToggler.replaceTags(resolvedActiveTaggingProfile.settings.tags, uniqueTagsSet ?? new Set()); + } + #close() { - this.#currentURIs = null; + const taggingPopup = this.#currentMediaBox?.tools?.popup; + + if (taggingPopup) { + this.#activeProfileListenerUnsubscriber?.(); + + taggingPopup.tagsToggler.disconnectChangeSet(); + taggingPopup.toggleSubmissionLock(false); + } + + this.#currentMediaBox = null; this.container.classList.remove(FullscreenViewer.#shownState); document.body.style.removeProperty('overflow'); @@ -247,16 +320,23 @@ export class FullscreenViewer extends BaseComponent { return imageUris[targetSize as FullscreenViewerSize]; } - async show(imageUris: App.ImageURIs): Promise { - this.#currentURIs = imageUris; + async show(mediaBox: MediaBox): Promise { + const isSameMediaBox = this.#currentMediaBox === mediaBox; + this.#currentMediaBox = mediaBox; - const url = await this.#resolveCurrentSelectedSizeUrl(imageUris); + const url = await this.#resolveCurrentSelectedSizeUrl(mediaBox.imageLinks); if (!url) { console.warn('Failed to resolve media for the viewer!'); return; } + const taggingPopup = mediaBox?.tools?.popup; + + if (taggingPopup && !isSameMediaBox) { + this.#connectAndRefreshTagTogglers(taggingPopup, mediaBox); + } + this.container.classList.add('loading'); requestAnimationFrame(() => { @@ -285,6 +365,35 @@ export class FullscreenViewer extends BaseComponent { this.container.append(this.#imageElement); } + /** + * When connecting to the new popup, we need to connect the tag editor inside the fullscreen viewer to the original + * popup, since submission is still being sent from the popup inside media box. + * @param taggingPopup + * @param mediaBox + * @private + */ + #connectAndRefreshTagTogglers(taggingPopup: TaggingProfilePopup, mediaBox: MediaBox) { + taggingPopup.toggleSubmissionLock(true); + + const sourceToggler = taggingPopup.tagsToggler; + sourceToggler.connectChangeSet(this.#profileTagsToggler); + sourceToggler.connectChangeSet(this.#imageTagsToggler); + + const uniqueTagsSet = mediaBox.uniqueTagsSet ?? new Set(); + const uniqueTags = Array.from(uniqueTagsSet); + + this.#imageTagsToggler.replaceTags(uniqueTags, uniqueTagsSet); + + this.#activeProfileListenerUnsubscriber?.(); + this.#activeProfileListenerUnsubscriber = on( + taggingPopup, + EVENT_ACTIVE_PROFILE_CHANGED, + this.#onActiveProfileChanged.bind(this), + ); + + this.#onActiveProfileChanged(taggingPopup.activeProfile); + } + static #isVideoUrl(url: string): boolean { return url.endsWith('.mp4') || url.endsWith('.webm'); } diff --git a/src/content/components/extension/ImageShowFullscreenButton.ts b/src/content/components/extension/ImageShowFullscreenButton.ts index 25422a8..6d21c91 100644 --- a/src/content/components/extension/ImageShowFullscreenButton.ts +++ b/src/content/components/extension/ImageShowFullscreenButton.ts @@ -45,15 +45,13 @@ export class ImageShowFullscreenButton extends BaseComponent { } #onButtonClicked() { - const imageLinks = this.#mediaBoxTools?.mediaBox?.imageLinks; + const mediaBox = this.#mediaBoxTools?.mediaBox; - if (!imageLinks) { + if (!mediaBox) { throw new Error('Failed to resolve image links from media box tools!'); } - ImageShowFullscreenButton - .#resolveViewer() - ?.show(imageLinks); + ImageShowFullscreenButton.#resolveViewer()?.show(mediaBox); } static create(): HTMLElement { diff --git a/src/content/components/extension/MediaBoxTools.ts b/src/content/components/extension/MediaBoxTools.ts index 1de65b7..7ea50a6 100644 --- a/src/content/components/extension/MediaBoxTools.ts +++ b/src/content/components/extension/MediaBoxTools.ts @@ -50,6 +50,10 @@ export class MediaBoxTools extends BaseComponent { return this.#mediaBox; } + get popup(): TaggingProfilePopup | null { + return this.#popup; + } + /** * Create a maintenance popup element. * @param childrenElements List of children elements to append to the component. diff --git a/src/content/components/extension/profiles/TaggingProfilePopup.ts b/src/content/components/extension/profiles/TaggingProfilePopup.ts index ac32c78..b332279 100644 --- a/src/content/components/extension/profiles/TaggingProfilePopup.ts +++ b/src/content/components/extension/profiles/TaggingProfilePopup.ts @@ -19,6 +19,7 @@ export class TaggingProfilePopup extends BaseComponent { #tagsListToggler = TagsListToggler.create(); #activeProfile: TaggingProfile | null = null; #mediaBoxTools: MediaBoxTools | null = null; + #isSubmissionLocked: boolean = false; #isPlanningToSubmit: boolean = false; #isSubmitting: boolean = false; #tagsSubmissionTimer: Timeout | null = null; @@ -62,8 +63,29 @@ export class TaggingProfilePopup extends BaseComponent { throw new Error('Media box component not found!'); } - mediaBox.on('mouseout', this.#onMouseLeftArea.bind(this)); - mediaBox.on('mouseover', this.#onMouseEnteredArea.bind(this)); + mediaBox.on('mouseout', this.#maybeEnqueueSubmission.bind(this)); + mediaBox.on('mouseover', this.#cancelQueuedSubmission.bind(this)); + } + + get tagsToggler() { + return this.#tagsListToggler; + } + + get activeProfile() { + return this.#activeProfile; + } + + toggleSubmissionLock(shouldLockSubmission: boolean) { + this.#isSubmissionLocked = shouldLockSubmission; + + if (shouldLockSubmission) { + this.#cancelQueuedSubmission(); + return; + } + + // First notify that tags might have changed after submission lock was released and then enqueue it. + this.#onTagToggled(); + this.#maybeEnqueueSubmission(); } #onActiveProfileChanged(activeProfile: TaggingProfile | null) { @@ -116,14 +138,14 @@ export class TaggingProfilePopup extends BaseComponent { } } - #onMouseEnteredArea() { + #cancelQueuedSubmission() { if (this.#tagsSubmissionTimer) { clearTimeout(this.#tagsSubmissionTimer); } } - #onMouseLeftArea() { - if (this.#isPlanningToSubmit && !this.#isSubmitting) { + #maybeEnqueueSubmission() { + if (this.#isPlanningToSubmit && !this.#isSubmitting && !this.#isSubmissionLocked) { this.#tagsSubmissionTimer = setTimeout( this.#onSubmissionTimerPassed.bind(this), TaggingProfilePopup.#delayBeforeSubmissionMs @@ -132,7 +154,7 @@ export class TaggingProfilePopup extends BaseComponent { } async #onSubmissionTimerPassed() { - if (!this.#isPlanningToSubmit || this.#isSubmitting || !this.#mediaBoxTools?.mediaBox) { + if (!this.#isPlanningToSubmit || this.#isSubmitting || !this.#mediaBoxTools?.mediaBox || this.#isSubmissionLocked) { return; } @@ -171,6 +193,7 @@ export class TaggingProfilePopup extends BaseComponent { this.#emitter.emit(EVENT_PROFILE_POPUP_STATE_CHANGED, 'complete'); + this.#tagsListToggler.clearChangeSet(); this.#refreshTagsToggler(); TaggingProfilePopup.#notifyAboutPendingSubmission(false); diff --git a/src/content/components/extension/profiles/TagsListToggler.ts b/src/content/components/extension/profiles/TagsListToggler.ts index e9b8c37..f70d727 100644 --- a/src/content/components/extension/profiles/TagsListToggler.ts +++ b/src/content/components/extension/profiles/TagsListToggler.ts @@ -42,11 +42,26 @@ export default class TagsListToggler extends BaseComponent { this.#renderAndInsertTags(tags, activeTags, true); } + refreshStateFromChanges() { + for (const tagElement of this.#tagsList) { + const tagName = tagElement.dataset.name; + + if (!tagName) { + throw new Error('Missing tag name!'); + } + + tagElement.classList.toggle(TagsListToggler.#addedClass, this.#tagsToAdd.has(tagName)); + tagElement.classList.toggle(TagsListToggler.#removedClass, this.#tagsToRemove.has(tagName)); + } + } + #renderAndInsertTags(toggleableTags: string[], activeTags: Set, shouldPrepend: boolean = false) { toggleableTags.sort((a, b) => a.localeCompare(b)); let lastInserted: HTMLElement|null = null; + const hasStoredChanges = this.hasChanges; + for (let tagIndex = 0; tagIndex < toggleableTags.length; tagIndex++) { const tagName = toggleableTags[tagIndex]; const tagElement = TagsListToggler.#buildTagElement(tagName); @@ -58,6 +73,11 @@ export default class TagsListToggler extends BaseComponent { tagElement.classList.toggle(TagsListToggler.#missingClass, !isPresent); tagElement.classList.toggle(TagsListToggler.#containsClass, isPresent); + if (hasStoredChanges) { + tagElement.classList.toggle(TagsListToggler.#addedClass, !isPresent && this.#tagsToAdd.has(tagName)); + tagElement.classList.toggle(TagsListToggler.#removedClass, isPresent && this.#tagsToRemove.has(tagName)); + } + const tagCategory = tagsBlacklist.includes(tagName) && CategoriesEnum.Error || resolveTagCategoryFromTagName(tagName) || ''; @@ -108,6 +128,21 @@ export default class TagsListToggler extends BaseComponent { } } + connectChangeSet(secondaryToggler: TagsListToggler) { + secondaryToggler.#tagsToAdd = this.#tagsToAdd; + secondaryToggler.#tagsToRemove = this.#tagsToRemove; + } + + disconnectChangeSet() { + this.#tagsToAdd = new Set(this.#tagsToAdd.values()); + this.#tagsToRemove = new Set(this.#tagsToRemove.values()); + } + + clearChangeSet() { + this.#tagsToAdd.clear(); + this.#tagsToRemove.clear(); + } + #handleTagClick(event: MouseEvent) { const targetObject = event.target; diff --git a/src/content/components/philomena/MediaBox.ts b/src/content/components/philomena/MediaBox.ts index c7b1cc6..6f1502b 100644 --- a/src/content/components/philomena/MediaBox.ts +++ b/src/content/components/philomena/MediaBox.ts @@ -3,11 +3,13 @@ import { getComponent } from "$content/components/base/component-utils"; import { buildTagsAndAliasesMap } from "$lib/philomena/tag-utils"; import { on } from "$content/components/events/comms"; import { EVENT_TAGS_UPDATED } from "$content/components/events/tagging-profile-popup-events"; +import type { MediaBoxTools } from "$content/components/extension/MediaBoxTools"; export class MediaBox extends BaseComponent { #thumbnailContainer: HTMLElement | null = null; #imageLinkElement: HTMLAnchorElement | null = null; #tagsAndAliases: Map | null = null; + #mediaBoxTools: MediaBoxTools | null = null; init() { this.#thumbnailContainer = this.container.querySelector('.image-container'); @@ -33,6 +35,16 @@ export class MediaBox extends BaseComponent { return buildTagsAndAliasesMap(tagAliases, actualTags); } + get uniqueTagsSet(): Set|null { + const tagsAndAliases = this.tagsAndAliases; + + if (tagsAndAliases === null) { + return null; + } + + return new Set(tagsAndAliases.values()); + } + get tagsAndAliases(): Map | null { if (!this.#tagsAndAliases) { this.#tagsAndAliases = this.#calculateMediaBoxTags(); @@ -61,17 +73,22 @@ export class MediaBox extends BaseComponent { return JSON.parse(jsonUris); } + get tools(): MediaBoxTools | null { + return this.#mediaBoxTools; + } + /** * Wrap the media box element into the special wrapper. */ - static initialize(mediaBoxContainer: HTMLElement, childComponentElements: HTMLElement[]) { - new MediaBox(mediaBoxContainer) - .initialize(); + static initialize(mediaBoxContainer: HTMLElement, mediaBoxToolsElement: HTMLElement) { + const mediaBox = new MediaBox(mediaBoxContainer); + mediaBox.initialize(); - for (let childComponentElement of childComponentElements) { - mediaBoxContainer.appendChild(childComponentElement); - getComponent(childComponentElement)?.initialize(); - } + mediaBoxContainer.appendChild(mediaBoxToolsElement); + const mediaBoxToolsComponent = getComponent(mediaBoxToolsElement); + mediaBoxToolsComponent?.initialize(); + + mediaBox.#mediaBoxTools = mediaBoxToolsComponent; } static findElements(): NodeListOf { diff --git a/src/content/listing.ts b/src/content/listing.ts index 21e240e..d91785e 100644 --- a/src/content/listing.ts +++ b/src/content/listing.ts @@ -8,13 +8,11 @@ import { ImageListContainer } from "$content/components/philomena/listing/ImageL const mediaBoxes = MediaBox.findElements(); mediaBoxes.forEach(mediaBoxElement => { - MediaBox.initialize(mediaBoxElement, [ - MediaBoxTools.create( - TaggingProfilePopup.create(), - TaggingProfileStatusIcon.create(), - ImageShowFullscreenButton.create(), - ) - ]); + MediaBox.initialize(mediaBoxElement, MediaBoxTools.create( + TaggingProfilePopup.create(), + TaggingProfileStatusIcon.create(), + ImageShowFullscreenButton.create(), + )); // Attempt to fix misplacement of media boxes requestAnimationFrame(() => { diff --git a/src/styles/booru-vars.scss b/src/styles/booru-vars.scss index 1f9d5f6..0b3c4b3 100644 --- a/src/styles/booru-vars.scss +++ b/src/styles/booru-vars.scss @@ -10,3 +10,5 @@ $block-spacing: var(--block-spacing); $resolved-tag-background: var(--tag-background); $resolved-tag-border: var(--tag-border); $resolved-tag-color: var(--tag-color); + +$limited-layout-width: var(--limited-layout-width); diff --git a/src/styles/content/listing.scss b/src/styles/content/listing.scss index 2306743..9064550 100644 --- a/src/styles/content/listing.scss +++ b/src/styles/content/listing.scss @@ -2,6 +2,38 @@ @use '$styles/booru-vars'; @use '$styles/environment'; +%box { + background: booru-vars.$background-color; + padding: 5px; +} + +%tags-list { + display: flex; + flex-wrap: wrap; + gap: 6px; + + .tag { + margin: 0; + } +} + +%toggleable-tag { + cursor: pointer; + user-select: none; + padding: 0 5px; + gap: 0; + + &:hover { + background: booru-vars.$resolved-tag-color; + color: booru-vars.$resolved-tag-background; + } + + &.is-missing:not(.is-added), + &.is-present.is-removed { + opacity: 0.5; + } +} + // This will fix wierd misplacing of the modified media boxes in the listing. .js-resizable-media-container { display: flex; @@ -40,14 +72,14 @@ } .maintenance-popup { + @extend %box; + display: none; position: absolute; top: 100%; - padding: 5px; left: -50%; right: -50%; z-index: 8; - background: booru-vars.$background-color; border: { left: booru-vars.$media-border; right: booru-vars.$media-border; @@ -55,31 +87,13 @@ }; .tags-list { - display: flex; - flex-wrap: wrap; - justify-content: center; - gap: 6px; + @extend %tags-list; - .tag { - margin: 0; - } + justify-content: center; } .tag { - cursor: pointer; - user-select: none; - padding: 0 5px; - gap: 0; - - &:hover { - background: booru-vars.$resolved-tag-color; - color: booru-vars.$resolved-tag-background; - } - - &.is-missing:not(.is-added), - &.is-present.is-removed { - opacity: 0.5; - } + @extend %toggleable-tag; } } @@ -244,4 +258,38 @@ opacity: 1; } } + + .tags-panel { + @extend %box; + + width: 96vw; + max-width: booru-vars.$limited-layout-width; + position: fixed; + bottom: 0; + left: 0; + right: 0; + margin-left: auto; + margin-right: auto; + border: { + top: booru-vars.$media-border; + left: booru-vars.$media-border; + right: booru-vars.$media-border; + } + + .tags-list { + @extend %tags-list; + + &.is-hidden { + display: none; + + & + hr { + display: none; + } + } + } + + .tag { + @extend %toggleable-tag; + } + } }