diff --git a/src/config/tags.ts b/src/config/tags.ts index 98583ad..34080d8 100644 --- a/src/config/tags.ts +++ b/src/config/tags.ts @@ -1,18 +1,24 @@ +/** + * Enum-like object with categories used by the Philomena, available for calling in the code. Values are technical + * names of these categories. + */ +export const CategoriesEnum = { + Rating: 'rating', + Spoiler: 'spoiler', + Origin: 'origin', + OC: 'oc', + Error: 'error', + Character: 'character', + ContentOfficial: 'content-official', + ContentFanmade: 'content-fanmade', + Species: 'species', + BodyType: 'body-type', +} + /** * List of categories defined by the sites. */ -export const categories: string[] = [ - 'rating', - 'spoiler', - 'origin', - 'oc', - 'error', - 'character', - 'content-official', - 'content-fanmade', - 'species', - 'body-type', -]; +export const categories: string[] = Object.values(CategoriesEnum); /** * Mapping of namespaces to their respective categories. These namespaces are automatically assigned to them, so we can @@ -23,21 +29,21 @@ export const categories: string[] = [ * @see https://github.com/philomena-dev/philomena/blob/6086757b654da8792ae52adb2a2f501ea6c30d12/lib/philomena/tags/tag.ex#L33-L45 */ export const namespaceCategories: Map = new Map([ - ['artist', 'origin'], - ['art pack', 'content-fanmade'], - ['colorist', 'origin'], - ['comic', 'content-fanmade'], - ['editor', 'origin'], - ['fanfic', 'content-fanmade'], - ['oc', 'oc'], - ['photographer', 'origin'], - ['series', 'content-fanmade'], - ['spoiler', 'spoiler'], - ['video', 'content-fanmade'], - ...(__CURRENT_SITE__ === 'tantabus' ? [ - ["prompter", "origin"], - ["creator", "origin"], - ["generator", "origin"] + ['artist', CategoriesEnum.Origin], + ['art pack', CategoriesEnum.ContentFanmade], + ['colorist', CategoriesEnum.Origin], + ['comic', CategoriesEnum.ContentFanmade], + ['editor', CategoriesEnum.Origin], + ['fanfic', CategoriesEnum.ContentFanmade], + ['oc', CategoriesEnum.OC], + ['photographer', CategoriesEnum.Origin], + ['series', CategoriesEnum.ContentFanmade], + ['spoiler', CategoriesEnum.Spoiler], + ['video', CategoriesEnum.ContentFanmade], + ...(__CURRENT_SITE__ === 'tantabus' ? [ + ["prompter", CategoriesEnum.Origin], + ["creator", CategoriesEnum.Origin], + ["generator", CategoriesEnum.Origin] ] : []) ]); diff --git a/src/content/components/events/comms.ts b/src/content/components/events/comms.ts index e5ba62a..643cae2 100644 --- a/src/content/components/events/comms.ts +++ b/src/content/components/events/comms.ts @@ -5,6 +5,7 @@ import type { BooruEventsMap } from "$content/components/events/booru-events"; import type { TagsFormEventsMap } from "$content/components/events/tags-form-events"; import type { TagDropdownEvents } from "$content/components/events/tag-dropdown-events"; import type { PresetBlockEventsMap } from "$content/components/events/preset-block-events"; +import type { TagsListTogglerEventsMap } from "$content/components/events/tags-list-toggler-events"; type EventsMapping = TaggingProfilePopupEventsMap @@ -12,7 +13,8 @@ type EventsMapping = & BooruEventsMap & TagsFormEventsMap & TagDropdownEvents - & PresetBlockEventsMap; + & PresetBlockEventsMap + & TagsListTogglerEventsMap; type EventCallback = (event: CustomEvent) => void; export type UnsubscribeFunction = () => void; diff --git a/src/content/components/events/tags-list-toggler-events.ts b/src/content/components/events/tags-list-toggler-events.ts new file mode 100644 index 0000000..824fca1 --- /dev/null +++ b/src/content/components/events/tags-list-toggler-events.ts @@ -0,0 +1,5 @@ +export const EVENT_TAG_TOGGLED = 'tag-toggled'; + +export interface TagsListTogglerEventsMap { + [EVENT_TAG_TOGGLED]: [string, boolean]; +} diff --git a/src/content/components/extension/FullscreenViewer.ts b/src/content/components/extension/FullscreenViewer.ts index 616acdf..dad4ab5 100644 --- a/src/content/components/extension/FullscreenViewer.ts +++ b/src/content/components/extension/FullscreenViewer.ts @@ -1,28 +1,62 @@ 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(); + #tagsPopupHider: HTMLElement = document.createElement('div'); + #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(); + + const overflowContainer = document.createElement('div'); + overflowContainer.classList.add('tags-panel__scroller'); + + overflowContainer.append( + this.#profileTagsToggler.container, + document.createElement('hr'), + this.#imageTagsToggler.container, + ); + + this.#tagsPopupElement.append( + this.#tagsPopupHider, + overflowContainer, + ); + + this.#tagsPopupHider.classList.add('tags-panel__toggle'); + this.#tagsPopupHider.textContent = 'Toggle Tags'; + + 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 +86,20 @@ 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()); + this.#tagsPopupHider.addEventListener('click', () => this.#togglePopupVisibility()); FullscreenViewer.#preferences .fullscreenViewerSize.get() .then(this.#onSizeResolved.bind(this)) .then(this.#watchForSizeSelectionChanges.bind(this)); + + FullscreenViewer.#preferences + .fullscreenViewerPopupShown.get() + .then(this.#togglePopupVisibility.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 +236,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 +249,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.#profileTagsToggler.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 +337,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 +382,45 @@ 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); + } + + #togglePopupVisibility(maybeState: boolean | undefined = undefined) { + if (typeof maybeState === 'undefined') { + const isHidden = this.#tagsPopupElement.classList.toggle(FullscreenViewer.#collapsedPopupState); + void FullscreenViewer.#preferences.fullscreenViewerPopupShown.set(!isHidden); + return; + } + + this.#tagsPopupElement.classList.toggle(FullscreenViewer.#collapsedPopupState, !maybeState); + } + static #isVideoUrl(url: string): boolean { return url.endsWith('.mp4') || url.endsWith('.webm'); } @@ -296,6 +432,7 @@ export class FullscreenViewer extends BaseComponent { static #shownState = 'shown'; static #swipeState = 'swiped'; static #minRequiredDistance = 50; + static #collapsedPopupState = 'is-collapsed'; static #previewSizes: Record = { full: 'Full', 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 0b912cd..b332279 100644 --- a/src/content/components/extension/profiles/TaggingProfilePopup.ts +++ b/src/content/components/extension/profiles/TaggingProfilePopup.ts @@ -4,31 +4,22 @@ import { BaseComponent } from "$content/components/base/BaseComponent"; import { getComponent } from "$content/components/base/component-utils"; import ScrapedAPI from "$lib/philomena/scraping/ScrapedAPI"; import { tagsBlacklist } from "$config/tags"; -import { emitterAt } from "$content/components/events/comms"; +import { emitterAt, on } from "$content/components/events/comms"; import { EVENT_ACTIVE_PROFILE_CHANGED, EVENT_PROFILE_POPUP_STATE_CHANGED, EVENT_TAGS_UPDATED } from "$content/components/events/tagging-profile-popup-events"; import type { MediaBoxTools } from "$content/components/extension/MediaBoxTools"; -import { resolveTagCategoryFromTagName } from "$lib/philomena/tag-utils"; +import TagsListToggler, { BlackListedTagsEncounteredError } from "$content/components/extension/profiles/TagsListToggler"; +import { EVENT_TAG_TOGGLED } from "$content/components/events/tags-list-toggler-events"; -class BlackListedTagsEncounteredError extends Error { - constructor(tagName: string) { - super(`This tag is blacklisted and prevents submission: ${tagName}`, { - cause: tagName - }); - } -} export class TaggingProfilePopup extends BaseComponent { - #tagsListElement: HTMLElement = document.createElement('div'); - #tagsList: HTMLElement[] = []; - #suggestedInvalidTags: Map = new Map(); + #tagsListToggler = TagsListToggler.create(); #activeProfile: TaggingProfile | null = null; #mediaBoxTools: MediaBoxTools | null = null; - #tagsToRemove: Set = new Set(); - #tagsToAdd: Set = new Set(); + #isSubmissionLocked: boolean = false; #isPlanningToSubmit: boolean = false; #isSubmitting: boolean = false; #tagsSubmissionTimer: Timeout | null = null; @@ -41,11 +32,8 @@ export class TaggingProfilePopup extends BaseComponent { this.container.innerHTML = ''; this.container.classList.add('maintenance-popup'); - this.#tagsListElement.classList.add('tags-list'); - - this.container.append( - this.#tagsListElement, - ); + this.#tagsListToggler.initialize(); + this.container.append(this.#tagsListToggler.container); } /** @@ -67,7 +55,7 @@ export class TaggingProfilePopup extends BaseComponent { this.#mediaBoxTools = mediaBoxTools; TaggingProfilePopup.#watchActiveProfile(this.#onActiveProfileChanged.bind(this)); - this.#tagsListElement.addEventListener('click', this.#handleTagClick.bind(this)); + on(this.#tagsListToggler, EVENT_TAG_TOGGLED, this.#onTagToggled.bind(this)); const mediaBox = this.#mediaBoxTools.mediaBox; @@ -75,108 +63,58 @@ 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) { this.#activeProfile = activeProfile; this.container.classList.toggle('is-active', activeProfile !== null); - this.#refreshTagsList(); + + this.#refreshTagsToggler(); this.#emitter.emit(EVENT_ACTIVE_PROFILE_CHANGED, activeProfile); } - #refreshTagsList() { + #refreshTagsToggler() { if (!this.#mediaBoxTools?.mediaBox) { return; } const activeProfileTagsList: string[] = this.#activeProfile?.settings.tags || []; + const currentPostTags = new Set(this.#mediaBoxTools.mediaBox.tagsAndAliases?.keys() || []); - for (const tagElement of this.#tagsList) { - tagElement.remove(); - } - - for (const tagElement of this.#suggestedInvalidTags.values()) { - tagElement.remove(); - } - - this.#tagsList = new Array(activeProfileTagsList.length); - this.#suggestedInvalidTags.clear(); - - const currentPostTags = this.#mediaBoxTools.mediaBox.tagsAndAliases; - - activeProfileTagsList - .sort((a, b) => a.localeCompare(b)) - .forEach((tagName, index) => { - const tagElement = TaggingProfilePopup.#buildTagElement(tagName); - this.#tagsList[index] = tagElement; - this.#tagsListElement.appendChild(tagElement); - - const isPresent = currentPostTags?.has(tagName); - - tagElement.classList.toggle('is-present', isPresent); - tagElement.classList.toggle('is-missing', !isPresent); - tagElement.classList.toggle('is-aliased', isPresent && currentPostTags?.get(tagName) !== tagName); - - // Just to prevent duplication, we need to include this tag to the map of suggested invalid tags - if (tagsBlacklist.includes(tagName)) { - TaggingProfilePopup.#markTagElementWithCategory(tagElement, 'error'); - this.#suggestedInvalidTags.set(tagName, tagElement); - } else { - TaggingProfilePopup.#markTagElementWithCategory( - tagElement, - resolveTagCategoryFromTagName(tagName) ?? '', - ); - } - }); + this.#tagsListToggler.replaceTags(activeProfileTagsList, currentPostTags); } /** * Detect and process clicks made directly to the tags. */ - #handleTagClick(event: MouseEvent) { - const targetObject = event.target; + #onTagToggled() { + const hasPendingChanges = this.#tagsListToggler.hasChanges; - - if (!targetObject || !(targetObject instanceof HTMLElement)) { - return; - } - - let tagElement: HTMLElement | null = targetObject; - - if (!tagElement.classList.contains('tag')) { - tagElement = tagElement.closest('.tag'); - } - - if (!tagElement?.dataset.name) { - return; - } - - const tagName = tagElement.dataset.name; - - if (tagElement.classList.contains('is-present')) { - const isToBeRemoved = tagElement.classList.toggle('is-removed'); - - if (isToBeRemoved) { - this.#tagsToRemove.add(tagName); - } else { - this.#tagsToRemove.delete(tagName); - } - } - - if (tagElement.classList.contains('is-missing')) { - const isToBeAdded = tagElement.classList.toggle('is-added'); - - if (isToBeAdded) { - this.#tagsToAdd.add(tagName); - } else { - this.#tagsToAdd.delete(tagName); - } - } - - if (this.#tagsToAdd.size || this.#tagsToRemove.size) { + if (hasPendingChanges) { // Notify only once, when first planning to submit if (!this.#isPlanningToSubmit) { TaggingProfilePopup.#notifyAboutPendingSubmission(true); @@ -188,7 +126,7 @@ export class TaggingProfilePopup extends BaseComponent { // Whenever user undoes the change they wanted to do in the popup, it's better to not send the submission and just // do nothing. - if (!this.#tagsToAdd.size && !this.#tagsToRemove.size && this.#isPlanningToSubmit) { + if (!hasPendingChanges && this.#isPlanningToSubmit) { this.#isPlanningToSubmit = false; this.#emitter.emit(EVENT_PROFILE_POPUP_STATE_CHANGED, 'ready'); TaggingProfilePopup.#notifyAboutPendingSubmission(false); @@ -200,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 @@ -216,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; } @@ -232,29 +170,7 @@ export class TaggingProfilePopup extends BaseComponent { try { maybeTagsAndAliasesAfterUpdate = await TaggingProfilePopup.#scrapedAPI.updateImageTags( this.#mediaBoxTools.mediaBox.imageId, - tagsList => { - for (let tagName of this.#tagsToRemove) { - tagsList.delete(tagName); - } - - for (let tagName of this.#tagsToAdd) { - tagsList.add(tagName); - } - - if (shouldAutoRemove) { - for (let tagName of tagsBlacklist) { - tagsList.delete(tagName); - } - } else { - for (let tagName of tagsList) { - if (tagsBlacklist.includes(tagName)) { - throw new BlackListedTagsEncounteredError(tagName); - } - } - } - - return tagsList; - } + this.#tagsListToggler.createTagsUpdater(shouldAutoRemove), ); } catch (e) { if (e instanceof BlackListedTagsEncounteredError) { @@ -277,10 +193,8 @@ export class TaggingProfilePopup extends BaseComponent { this.#emitter.emit(EVENT_PROFILE_POPUP_STATE_CHANGED, 'complete'); - this.#tagsToAdd.clear(); - this.#tagsToRemove.clear(); - - this.#refreshTagsList(); + this.#tagsListToggler.clearChangeSet(); + this.#refreshTagsToggler(); TaggingProfilePopup.#notifyAboutPendingSubmission(false); this.#isSubmitting = false; @@ -291,37 +205,13 @@ export class TaggingProfilePopup extends BaseComponent { return; } - const tagsAndAliases = this.#mediaBoxTools.mediaBox.tagsAndAliases; + const activeTags = new Set(this.#mediaBoxTools.mediaBox.tagsAndAliases?.keys() || []); - if (!tagsAndAliases) { + if (!activeTags.size) { return; } - const firstTagInList = this.#tagsList[0]; - - for (let tagName of tagsBlacklist) { - if (tagsAndAliases.has(tagName)) { - if (this.#suggestedInvalidTags.has(tagName)) { - continue; - } - - const tagElement = TaggingProfilePopup.#buildTagElement(tagName); - TaggingProfilePopup.#markTagElementWithCategory(tagElement, 'error'); - tagElement.classList.add('is-present'); - - this.#suggestedInvalidTags.set(tagName, tagElement); - - if (firstTagInList && firstTagInList.isConnected) { - this.#tagsListElement.insertBefore(tagElement, firstTagInList); - } else { - this.#tagsListElement.appendChild(tagElement); - } - } - } - } - - get isActive() { - return this.container.classList.contains('is-active'); + this.#tagsListToggler.prependTags(tagsBlacklist, activeTags); } static create(): HTMLElement { @@ -332,24 +222,6 @@ export class TaggingProfilePopup extends BaseComponent { return container; } - static #buildTagElement(tagName: string): HTMLElement { - const tagElement = document.createElement('span'); - tagElement.classList.add('tag'); - tagElement.innerText = tagName; - tagElement.dataset.name = tagName; - - return tagElement; - } - - /** - * Mark the tag element with specified category. - * @param tagElement Element to mark. - * @param category Code name of category to mark. - */ - static #markTagElementWithCategory(tagElement: HTMLElement, category: string) { - tagElement.dataset.tagCategory = category; - tagElement.setAttribute('data-tag-category', category); - } /** * Controller with maintenance settings. @@ -433,5 +305,5 @@ export class TaggingProfilePopup extends BaseComponent { /** * Amount of pending submissions or NULL if logic was not yet initialized. */ - static #pendingSubmissionCount: number|null = null; + static #pendingSubmissionCount: number | null = null; } diff --git a/src/content/components/extension/profiles/TagsListToggler.ts b/src/content/components/extension/profiles/TagsListToggler.ts new file mode 100644 index 0000000..f70d727 --- /dev/null +++ b/src/content/components/extension/profiles/TagsListToggler.ts @@ -0,0 +1,230 @@ +import { BaseComponent } from "$content/components/base/BaseComponent"; +import { CategoriesEnum, tagsBlacklist } from "$config/tags"; +import { resolveTagCategoryFromTagName } from "$lib/philomena/tag-utils"; +import { EVENT_TAG_TOGGLED } from "$content/components/events/tags-list-toggler-events"; +import { emit } from "$content/components/events/comms"; +import type { TagsUpdaterFunction } from "$lib/philomena/scraping/ScrapedAPI"; + +export default class TagsListToggler extends BaseComponent { + #tagsList: HTMLElement[] = []; + #invalidTagsList: Map = new Map(); + #tagsToAdd: Set = new Set(); + #tagsToRemove: Set = new Set(); + + protected build() { + this.container.classList.add('tags-list'); + } + + protected init() { + this.container.addEventListener('click', this.#handleTagClick.bind(this)); + } + + get hasChanges(): boolean { + return (this.#tagsToAdd.size + this.#tagsToRemove.size) > 0; + } + + replaceTags(tags: string[], activeTags: Set) { + for (const tagElement of this.#tagsList) { + tagElement.remove(); + } + + for (const tagElement of this.#invalidTagsList.values()) { + tagElement.remove(); + } + + this.#tagsList = []; + this.#invalidTagsList = new Map(); + + this.#renderAndInsertTags(tags, activeTags); + } + + prependTags(tags: string[], activeTags: Set) { + 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); + + this.#tagsList.push(tagElement); + + const isPresent = activeTags.has(tagName); + + 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) + || ''; + + TagsListToggler.#markTagElementWithCategory(tagElement, tagCategory); + + if (tagCategory === CategoriesEnum.Error) { + this.#invalidTagsList.set(tagName, tagElement); + } + + if (shouldPrepend) { + if (lastInserted) { + lastInserted.after(tagElement); + } else { + this.container.prepend(tagElement); + } + + lastInserted = tagElement; + } else { + this.container.append(tagElement); + } + } + } + + createTagsUpdater(shouldAutoRemoveInvalid: boolean): TagsUpdaterFunction { + return tagsList => { + for (let tagName of this.#tagsToRemove) { + tagsList.delete(tagName); + } + + for (let tagName of this.#tagsToAdd) { + tagsList.add(tagName); + } + + if (shouldAutoRemoveInvalid) { + for (let tagName of tagsBlacklist) { + tagsList.delete(tagName); + } + } else { + for (let tagName of tagsList) { + if (tagsBlacklist.includes(tagName)) { + throw new BlackListedTagsEncounteredError(tagName); + } + } + } + + return tagsList; + } + } + + 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; + + + if (!targetObject || !(targetObject instanceof HTMLElement)) { + return; + } + + let tagElement: HTMLElement | null = targetObject; + + if (!tagElement.classList.contains('tag')) { + tagElement = tagElement.closest('.tag'); + } + + if (!tagElement?.dataset.name) { + return; + } + + const tagName = tagElement.dataset.name; + + let willBeActive = false; + + if (tagElement.classList.contains(TagsListToggler.#containsClass)) { + const isToBeRemoved = tagElement.classList.toggle(TagsListToggler.#removedClass); + willBeActive = !isToBeRemoved; + + if (isToBeRemoved) { + this.#tagsToRemove.add(tagName); + } else { + this.#tagsToRemove.delete(tagName); + } + } + + if (tagElement.classList.contains(TagsListToggler.#missingClass)) { + const isToBeAdded = tagElement.classList.toggle(TagsListToggler.#addedClass); + willBeActive = isToBeAdded; + + if (isToBeAdded) { + this.#tagsToAdd.add(tagName); + } else { + this.#tagsToAdd.delete(tagName); + } + } + + emit(this.container, EVENT_TAG_TOGGLED, [tagName, willBeActive]); + } + + static #buildTagElement(tagName: string): HTMLElement { + const tagElement = document.createElement('span'); + tagElement.classList.add('tag'); + tagElement.innerText = tagName; + tagElement.dataset.name = tagName; + + return tagElement; + } + + /** + * Mark the tag element with specified category. + * @param tagElement Element to mark. + * @param category Code name of category to mark. + */ + static #markTagElementWithCategory(tagElement: HTMLElement, category: string) { + tagElement.dataset.tagCategory = category; + tagElement.setAttribute('data-tag-category', category); + } + + static create(): TagsListToggler { + const tagsListContainer = document.createElement('div'); + return new TagsListToggler(tagsListContainer); + } + + static #containsClass = 'is-present'; + static #missingClass = 'is-missing'; + + static #addedClass = 'is-added'; + static #removedClass = 'is-removed'; +} + +export class BlackListedTagsEncounteredError extends Error { + constructor(tagName: string) { + super(`This tag is blacklisted and prevents submission: ${tagName}`, { + cause: tagName + }); + } +} diff --git a/src/content/components/philomena/MediaBox.ts b/src/content/components/philomena/MediaBox.ts index b3fcd95..9c1f62e 100644 --- a/src/content/components/philomena/MediaBox.ts +++ b/src/content/components/philomena/MediaBox.ts @@ -4,10 +4,12 @@ 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 ImageContainer from "$content/components/philomena/ImageContainer"; +import type { MediaBoxTools } from "$content/components/extension/MediaBoxTools"; export class MediaBox extends BaseComponent { #imageContainer: ImageContainer | null = null; #tagsAndAliases: Map | null = null; + #mediaBoxTools: MediaBoxTools | null = null; init() { const imageContainerElement = this.container.querySelector('.image-container'); @@ -38,6 +40,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(); @@ -66,17 +78,22 @@ export class MediaBox extends BaseComponent { return sourceUrls; } + 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/lib/extension/preferences/MiscPreferences.ts b/src/lib/extension/preferences/MiscPreferences.ts index 0c14051..2482d9a 100644 --- a/src/lib/extension/preferences/MiscPreferences.ts +++ b/src/lib/extension/preferences/MiscPreferences.ts @@ -8,6 +8,7 @@ export type FullscreenViewerSize = keyof App.ImageURIs; interface MiscPreferencesFields { fullscreenViewer: boolean; fullscreenViewerSize: FullscreenViewerSize; + fullscreenViewerPopupShown: boolean; } export default class MiscPreferences extends CacheablePreferences implements WithFields { @@ -24,4 +25,9 @@ export default class MiscPreferences extends CacheablePreferences) => Set; +export type TagsUpdaterFunction = (tags: Set) => Set; export default class ScrapedAPI { /** @@ -9,7 +9,7 @@ export default class ScrapedAPI { * @param callback Callback to call to change the content. * @return Updated tags and aliases list for updating internal cached state. */ - async updateImageTags(imageId: number, callback: UpdaterFunction): Promise | null> { + async updateImageTags(imageId: number, callback: TagsUpdaterFunction): Promise | null> { const postParser = new PostParser(imageId); const formData = await postParser.resolveTagEditorFormData(); const tagsFieldValue = formData.get(PostParser.tagsInputName); diff --git a/src/styles/booru-vars.scss b/src/styles/booru-vars.scss index 1f9d5f6..1819a9e 100644 --- a/src/styles/booru-vars.scss +++ b/src/styles/booru-vars.scss @@ -5,8 +5,15 @@ $padding-small: var(--padding-small); $padding-normal: var(--padding-normal); $padding-large: var(--padding-large); $block-spacing: var(--block-spacing); +$block-header-light-color: var(--block-header-light-color); +$block-header-link-text-color: var(--block-header-link-text-color); +$border: var(--border); +$header-spacing: var(--header-spacing); +$block-header-sub-height: var(--block-header-sub-height); // These variables are defined dynamically based on the category of the tag $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..a296615 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,64 @@ opacity: 1; } } + + .tags-panel { + @extend %box; + + display: flex; + z-index: 1; + width: 96vw; + max-width: booru-vars.$limited-layout-width; + max-height: 50vh; + position: fixed; + bottom: 0; + left: 0; + right: 0; + margin-left: auto; + margin-right: auto; + transition: transform .25s ease-in-out; + border: { + top: booru-vars.$media-border; + left: booru-vars.$media-border; + right: booru-vars.$media-border; + } + + &__toggle { + position: absolute; + left: -1px; + bottom: calc(100% + 1px); + border: booru-vars.$border; + background: booru-vars.$block-header-light-color; + color: booru-vars.$block-header-link-text-color; + font-weight: 700; + padding: 0 calc(#{booru-vars.$header-spacing} * .5); + line-height: booru-vars.$block-header-sub-height; + cursor: pointer; + } + + &__scroller { + overflow-y: auto; + overflow-x: hidden; + } + + &.is-collapsed { + transform: translateY(100%); + } + + .tags-list { + @extend %tags-list; + + &.is-hidden { + display: none; + + & + hr { + display: none; + } + } + } + + .tag { + @extend %toggleable-tag; + } + } }