From 690faa854fa2ce7f157cc67663aeef329bd185d7 Mon Sep 17 00:00:00 2001 From: KoloMl Date: Wed, 14 Oct 2026 00:42:51 +0200 Subject: [PATCH] Added tags list and active profile tags into fullscreen viewer Images now can be toggled with tagging profiles using fullscreen viewer and not just with the popup over the small media box. Submission of tag changes is still processed by the popup inside original media box. Fullscreen viewer is just sending these changes there and then, once it's closed, tag changes will be sent. --- .../components/extension/FullscreenViewer.ts | 125 ++++++++++++++++-- .../extension/ImageShowFullscreenButton.ts | 8 +- .../components/extension/MediaBoxTools.ts | 4 + .../extension/profiles/TaggingProfilePopup.ts | 35 ++++- .../extension/profiles/TagsListToggler.ts | 35 +++++ src/content/components/philomena/MediaBox.ts | 31 ++++- src/content/listing.ts | 12 +- src/styles/booru-vars.scss | 2 + src/styles/content/listing.scss | 94 +++++++++---- 9 files changed, 290 insertions(+), 56 deletions(-) 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; + } + } }