diff --git a/src/config/tags.ts b/src/config/tags.ts index 2065e88..70bd295 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/profiles/TaggingProfilePopup.ts b/src/content/components/extension/profiles/TaggingProfilePopup.ts index 0b912cd..ac32c78 100644 --- a/src/content/components/extension/profiles/TaggingProfilePopup.ts +++ b/src/content/components/extension/profiles/TaggingProfilePopup.ts @@ -4,31 +4,21 @@ 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(); #isPlanningToSubmit: boolean = false; #isSubmitting: boolean = false; #tagsSubmissionTimer: Timeout | null = null; @@ -41,11 +31,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 +54,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; @@ -82,101 +69,30 @@ export class TaggingProfilePopup extends BaseComponent { #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 +104,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); @@ -232,29 +148,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 +171,7 @@ export class TaggingProfilePopup extends BaseComponent { this.#emitter.emit(EVENT_PROFILE_POPUP_STATE_CHANGED, 'complete'); - this.#tagsToAdd.clear(); - this.#tagsToRemove.clear(); - - this.#refreshTagsList(); + this.#refreshTagsToggler(); TaggingProfilePopup.#notifyAboutPendingSubmission(false); this.#isSubmitting = false; @@ -291,37 +182,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 +199,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 +282,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..e9b8c37 --- /dev/null +++ b/src/content/components/extension/profiles/TagsListToggler.ts @@ -0,0 +1,195 @@ +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); + } + + #renderAndInsertTags(toggleableTags: string[], activeTags: Set, shouldPrepend: boolean = false) { + toggleableTags.sort((a, b) => a.localeCompare(b)); + + let lastInserted: HTMLElement|null = null; + + 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); + + 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; + } + } + + #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/lib/philomena/scraping/ScrapedAPI.ts b/src/lib/philomena/scraping/ScrapedAPI.ts index 39cdf71..292b7ff 100644 --- a/src/lib/philomena/scraping/ScrapedAPI.ts +++ b/src/lib/philomena/scraping/ScrapedAPI.ts @@ -1,6 +1,6 @@ import PostParser from "$lib/philomena/scraping/parsing/PostParser"; -type UpdaterFunction = (tags: Set) => 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);