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.
This commit is contained in:
koloml committed 2026-10-14 00:42:51 +02:00
1 parent a2452a5fea
commit 690faa854f
9 files changed
+290 -56

No files matched your search

@@ -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> | 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<void> {
this.#currentURIs = imageUris;
async show(mediaBox: MediaBox): Promise<void> {
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');
}
@@ -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 {
@@ -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.
@@ -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);
@@ -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<string>, 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;
+24 -7
View File
@@ -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<string, string> | 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<string>|null {
const tagsAndAliases = this.tagsAndAliases;
if (tagsAndAliases === null) {
return null;
}
return new Set(tagsAndAliases.values());
}
get tagsAndAliases(): Map<string, string> | 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<MediaBoxTools>(mediaBoxToolsElement);
mediaBoxToolsComponent?.initialize();
mediaBox.#mediaBoxTools = mediaBoxToolsComponent;
}
static findElements(): NodeListOf<HTMLElement> {
+5 -7
View File
@@ -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(() => {
+2
View File
@@ -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);
+71 -23
View File
@@ -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;
}
}
}