diff --git a/manifest.json b/manifest.json index e8a851e..9184427 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "name": "Furbooru Tagging Assistant", "description": "Small experimental extension for slightly quicker tagging experience. Furbooru Edition.", - "version": "0.7.0", + "version": "0.7.1", "browser_specific_settings": { "gecko": { "id": "furbooru-tagging-assistant@thecore.city", diff --git a/package-lock.json b/package-lock.json index 8f4a5ed..92eae16 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "furbooru-tagging-assistant", - "version": "0.7.0", + "version": "0.7.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "furbooru-tagging-assistant", - "version": "0.7.0", + "version": "0.7.1", "dependencies": { "@fortawesome/fontawesome-free": "^7.2.0", "@sveltejs/adapter-static": "^3.0.10", diff --git a/package.json b/package.json index 3e0b22a..6961400 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "furbooru-tagging-assistant", - "version": "0.7.0", + "version": "0.7.1", "private": true, "type": "module", "scripts": { diff --git a/src/components/features/PresetView.svelte b/src/components/features/PresetView.svelte index 18c24ca..bdd1a32 100644 --- a/src/components/features/PresetView.svelte +++ b/src/components/features/PresetView.svelte @@ -10,6 +10,7 @@ let { preset }: PresetViewProps = $props(); const sortedTagsList = $derived(preset.settings.tags.toSorted((a, b) => a.localeCompare(b))); + const requiredTagsList = $derived(preset.settings.requiredTags.toSorted((a, b) => a.localeCompare(b))); @@ -18,3 +19,17 @@ +{#if preset.settings.exclusive} + + Only one tag in this preset should be active at a time. If you will click on other non-active tag, other tags will + be automatically removed from the editor. + +{/if} +{#if preset.settings.conditional} + + This preset will only appear when one of the tags below are present on image. + + + + +{/if} diff --git a/src/content/components/events/comms.ts b/src/content/components/events/comms.ts index 7127a25..e5ba62a 100644 --- a/src/content/components/events/comms.ts +++ b/src/content/components/events/comms.ts @@ -1,4 +1,4 @@ -import type { MaintenancePopupEventsMap } from "$content/components/events/maintenance-popup-events"; +import type { TaggingProfilePopupEventsMap } from "$content/components/events/tagging-profile-popup-events"; import { BaseComponent } from "$content/components/base/BaseComponent"; import type { FullscreenViewerEventsMap } from "$content/components/events/fullscreen-viewer-events"; import type { BooruEventsMap } from "$content/components/events/booru-events"; @@ -7,7 +7,7 @@ import type { TagDropdownEvents } from "$content/components/events/tag-dropdown- import type { PresetBlockEventsMap } from "$content/components/events/preset-block-events"; type EventsMapping = - MaintenancePopupEventsMap + TaggingProfilePopupEventsMap & FullscreenViewerEventsMap & BooruEventsMap & TagsFormEventsMap diff --git a/src/content/components/events/maintenance-popup-events.ts b/src/content/components/events/tagging-profile-popup-events.ts similarity index 51% rename from src/content/components/events/maintenance-popup-events.ts rename to src/content/components/events/tagging-profile-popup-events.ts index 95c780f..668c850 100644 --- a/src/content/components/events/maintenance-popup-events.ts +++ b/src/content/components/events/tagging-profile-popup-events.ts @@ -1,13 +1,13 @@ import type TaggingProfile from "$entities/TaggingProfile"; export const EVENT_ACTIVE_PROFILE_CHANGED = 'active-profile-changed'; -export const EVENT_MAINTENANCE_STATE_CHANGED = 'maintenance-state-change'; +export const EVENT_PROFILE_POPUP_STATE_CHANGED = 'maintenance-state-change'; export const EVENT_TAGS_UPDATED = 'tags-updated'; -type MaintenanceState = 'processing' | 'failed' | 'complete' | 'waiting'; +export type ProfilePopupState = 'ready' | 'processing' | 'failed' | 'complete' | 'waiting'; -export interface MaintenancePopupEventsMap { +export interface TaggingProfilePopupEventsMap { [EVENT_ACTIVE_PROFILE_CHANGED]: TaggingProfile | null; - [EVENT_MAINTENANCE_STATE_CHANGED]: MaintenanceState; + [EVENT_PROFILE_POPUP_STATE_CHANGED]: ProfilePopupState; [EVENT_TAGS_UPDATED]: Map | null; } diff --git a/src/content/components/extension/MediaBoxTools.ts b/src/content/components/extension/MediaBoxTools.ts index 0c05ee5..1de65b7 100644 --- a/src/content/components/extension/MediaBoxTools.ts +++ b/src/content/components/extension/MediaBoxTools.ts @@ -2,13 +2,13 @@ import { BaseComponent } from "$content/components/base/BaseComponent"; import { getComponent } from "$content/components/base/component-utils"; import { TaggingProfilePopup } from "$content/components/extension/profiles/TaggingProfilePopup"; import { on } from "$content/components/events/comms"; -import { EVENT_ACTIVE_PROFILE_CHANGED } from "$content/components/events/maintenance-popup-events"; +import { EVENT_ACTIVE_PROFILE_CHANGED } from "$content/components/events/tagging-profile-popup-events"; import type { MediaBox } from "$content/components/philomena/MediaBox"; import type TaggingProfile from "$entities/TaggingProfile"; export class MediaBoxTools extends BaseComponent { #mediaBox: MediaBox | null = null; - #maintenancePopup: TaggingProfilePopup | null = null; + #popup: TaggingProfilePopup | null = null; init() { const mediaBoxElement = this.container.closest('.media-box'); @@ -34,8 +34,8 @@ export class MediaBoxTools extends BaseComponent { component.initialize(); } - if (!this.#maintenancePopup && component instanceof TaggingProfilePopup) { - this.#maintenancePopup = component; + if (!this.#popup && component instanceof TaggingProfilePopup) { + this.#popup = component; } } @@ -46,10 +46,6 @@ export class MediaBoxTools extends BaseComponent { this.container.classList.toggle('has-active-profile', profileChangedEvent.detail !== null); } - get maintenancePopup(): TaggingProfilePopup | null { - return this.#maintenancePopup; - } - get mediaBox(): MediaBox | null { return this.#mediaBox; } diff --git a/src/content/components/extension/presets/PresetTableRow.ts b/src/content/components/extension/presets/PresetTableRow.ts index 61bcafd..a38fa9e 100644 --- a/src/content/components/extension/presets/PresetTableRow.ts +++ b/src/content/components/extension/presets/PresetTableRow.ts @@ -5,10 +5,13 @@ import { EVENT_PRESET_TAG_CHANGE_APPLIED } from "$content/components/events/pres import { createFontAwesomeIcon } from "$lib/dom-utils"; export default class PresetTableRow extends BaseComponent { - #preset: TagEditorPreset; + readonly #preset: TagEditorPreset; + readonly #applyAllButton = document.createElement('button'); + readonly #removeAllButton = document.createElement('button'); + readonly #exclusiveWarning = document.createElement('div'); + readonly #alternateColorDummy = document.createElement('span'); + #tagsList: HTMLElement[] = []; - #applyAllButton = document.createElement('button'); - #removeAllButton = document.createElement('button'); constructor(container: HTMLElement, preset: TagEditorPreset) { super(container); @@ -32,6 +35,7 @@ export default class PresetTableRow extends BaseComponent { nameCell.textContent = this.#preset.settings.name; const tagsCell = document.createElement('td'); + tagsCell.style.width = '70%'; const tagsListContainer = document.createElement('div'); tagsListContainer.classList.add('tag-list'); @@ -52,6 +56,18 @@ export default class PresetTableRow extends BaseComponent { this.#removeAllButton.append(createFontAwesomeIcon('circle-minus')); this.#removeAllButton.title = 'Remove all tags from this preset from the editor'; + if (this.#preset.settings.exclusive) { + this.#applyAllButton.disabled = true; + this.#applyAllButton.title = "You can't add all tags from this preset since it only allows one tag to be active"; + + this.#exclusiveWarning.classList.add('block', 'block--fixed', 'block--warning'); + this.#exclusiveWarning.textContent = ' Multiple tags from this preset present in the editor! If you will click one of the tags here, other tags will be cleared automatically.' + this.#exclusiveWarning.prepend(createFontAwesomeIcon('triangle-exclamation')); + this.#exclusiveWarning.style.display = 'none'; + + tagsCell.append(this.#exclusiveWarning); + } + actionsContainer.append( this.#applyAllButton, this.#removeAllButton, @@ -64,6 +80,8 @@ export default class PresetTableRow extends BaseComponent { tagsCell, actionsCell, ); + + this.#alternateColorDummy.style.display = 'none'; } protected init() { @@ -85,6 +103,30 @@ export default class PresetTableRow extends BaseComponent { const tagName = targetElement.dataset.tagName; const isMissing = targetElement.classList.contains(PresetTableRow.#tagMissingClassName); + if (!tagName) { + return; + } + + // If a user clicks on the tag which was missing, then we have to remove all other active tags that are in this + // preset. But only when clicking on a tag which is missing, just so they will be able to remove any cases where + // multiple tags from exclusive present are active. + if (this.#preset.settings.exclusive && isMissing) { + const tagNamesToRemove = this.#tagsList + .filter( + tagElement => tagElement !== targetElement + && !tagElement.classList.contains(PresetTableRow.#tagMissingClassName) + ) + .map(tagElement => tagElement.dataset.tagName) + .filter(tagName => typeof tagName === 'string'); + + emit(this, EVENT_PRESET_TAG_CHANGE_APPLIED, { + addedTags: new Set([tagName]), + removedTags: new Set(tagNamesToRemove) + }); + + return; + } + emit(this, EVENT_PRESET_TAG_CHANGE_APPLIED, { [isMissing ? 'addedTags' : 'removedTags']: new Set([tagName]) }); @@ -108,13 +150,53 @@ export default class PresetTableRow extends BaseComponent { }); } + #maybeRefreshVisibilityFromTags(sourceTags: Set) { + if (!this.#preset.settings.conditional || this.#isMatchesConditional(sourceTags)) { + this.container.style.display = ''; + this.#alternateColorDummy.remove(); + return; + } + + this.container.style.display = 'none'; + this.container.after(this.#alternateColorDummy); + } + + #isMatchesConditional(sourceTags: Set): boolean { + const listOfRequiredTags = this.#preset.settings.requiredTags; + + return Boolean( + listOfRequiredTags.length + && listOfRequiredTags.some(tagName => sourceTags.has(tagName)) + ); + } + updateTags(tags: Set) { + let presentTagsAmount = 0; + for (const tagElement of this.#tagsList) { - tagElement.classList.toggle( + const isTagMissing = tagElement.classList.toggle( PresetTableRow.#tagMissingClassName, !tags.has(tagElement.dataset.tagName || ''), ); + + if (!isTagMissing) { + presentTagsAmount++; + } } + + if (this.#preset.settings.exclusive) { + const multipleTagsInExclusivePreset = presentTagsAmount > 1; + + this.container.classList.toggle(PresetTableRow.#presetWarningClassName, multipleTagsInExclusivePreset); + + if (multipleTagsInExclusivePreset) { + this.#exclusiveWarning.style.removeProperty('display'); + } else { + this.#exclusiveWarning.style.display = 'none'; + } + } + + this.#maybeRefreshVisibilityFromTags(tags); } remove() { @@ -126,4 +208,5 @@ export default class PresetTableRow extends BaseComponent { } static #tagMissingClassName = 'is-missing'; + static #presetWarningClassName = 'has-warning'; } diff --git a/src/content/components/extension/profiles/TaggingProfilePopup.ts b/src/content/components/extension/profiles/TaggingProfilePopup.ts index a63b9b4..0b912cd 100644 --- a/src/content/components/extension/profiles/TaggingProfilePopup.ts +++ b/src/content/components/extension/profiles/TaggingProfilePopup.ts @@ -7,9 +7,9 @@ import { tagsBlacklist } from "$config/tags"; import { emitterAt } from "$content/components/events/comms"; import { EVENT_ACTIVE_PROFILE_CHANGED, - EVENT_MAINTENANCE_STATE_CHANGED, + EVENT_PROFILE_POPUP_STATE_CHANGED, EVENT_TAGS_UPDATED -} from "$content/components/events/maintenance-popup-events"; +} from "$content/components/events/tagging-profile-popup-events"; import type { MediaBoxTools } from "$content/components/extension/MediaBoxTools"; import { resolveTagCategoryFromTagName } from "$lib/philomena/tag-utils"; @@ -183,7 +183,20 @@ export class TaggingProfilePopup extends BaseComponent { } this.#isPlanningToSubmit = true; - this.#emitter.emit(EVENT_MAINTENANCE_STATE_CHANGED, 'waiting'); + this.#emitter.emit(EVENT_PROFILE_POPUP_STATE_CHANGED, 'waiting'); + } + + // 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) { + this.#isPlanningToSubmit = false; + this.#emitter.emit(EVENT_PROFILE_POPUP_STATE_CHANGED, 'ready'); + TaggingProfilePopup.#notifyAboutPendingSubmission(false); + + // Probably shouldn't ever happen, but make sure we cancel any delayed submission. + if (this.#tagsSubmissionTimer) { + clearTimeout(this.#tagsSubmissionTimer); + } } } @@ -210,7 +223,7 @@ export class TaggingProfilePopup extends BaseComponent { this.#isPlanningToSubmit = false; this.#isSubmitting = true; - this.#emitter.emit(EVENT_MAINTENANCE_STATE_CHANGED, 'processing'); + this.#emitter.emit(EVENT_PROFILE_POPUP_STATE_CHANGED, 'processing'); let maybeTagsAndAliasesAfterUpdate; @@ -252,7 +265,7 @@ export class TaggingProfilePopup extends BaseComponent { TaggingProfilePopup.#notifyAboutPendingSubmission(false); - this.#emitter.emit(EVENT_MAINTENANCE_STATE_CHANGED, 'failed'); + this.#emitter.emit(EVENT_PROFILE_POPUP_STATE_CHANGED, 'failed'); this.#isSubmitting = false; return; @@ -262,7 +275,7 @@ export class TaggingProfilePopup extends BaseComponent { this.#emitter.emit(EVENT_TAGS_UPDATED, maybeTagsAndAliasesAfterUpdate); } - this.#emitter.emit(EVENT_MAINTENANCE_STATE_CHANGED, 'complete'); + this.#emitter.emit(EVENT_PROFILE_POPUP_STATE_CHANGED, 'complete'); this.#tagsToAdd.clear(); this.#tagsToRemove.clear(); @@ -360,7 +373,7 @@ export class TaggingProfilePopup extends BaseComponent { } }); - const unsubscribeFromMaintenanceSettings = this.#preferences.subscribe(settings => { + const unsubscribeFromPreferences = this.#preferences.subscribe(settings => { if (settings.activeProfile === lastActiveProfileId) { return; } @@ -382,7 +395,7 @@ export class TaggingProfilePopup extends BaseComponent { return () => { unsubscribeFromProfilesChanges(); - unsubscribeFromMaintenanceSettings(); + unsubscribeFromPreferences(); } } diff --git a/src/content/components/extension/profiles/TaggingProfileStatusIcon.ts b/src/content/components/extension/profiles/TaggingProfileStatusIcon.ts index 6e5ab8b..dc77501 100644 --- a/src/content/components/extension/profiles/TaggingProfileStatusIcon.ts +++ b/src/content/components/extension/profiles/TaggingProfileStatusIcon.ts @@ -1,7 +1,10 @@ import { BaseComponent } from "$content/components/base/BaseComponent"; import { getComponent } from "$content/components/base/component-utils"; import { on } from "$content/components/events/comms"; -import { EVENT_MAINTENANCE_STATE_CHANGED } from "$content/components/events/maintenance-popup-events"; +import { + EVENT_PROFILE_POPUP_STATE_CHANGED, + type ProfilePopupState +} from "$content/components/events/tagging-profile-popup-events"; import type { MediaBoxTools } from "$content/components/extension/MediaBoxTools"; export class TaggingProfileStatusIcon extends BaseComponent { @@ -22,10 +25,10 @@ export class TaggingProfileStatusIcon extends BaseComponent { throw new Error('Status icon element initialized outside of the media box!'); } - on(this.#mediaBoxTools, EVENT_MAINTENANCE_STATE_CHANGED, this.#onMaintenanceStateChanged.bind(this)); + on(this.#mediaBoxTools, EVENT_PROFILE_POPUP_STATE_CHANGED, this.#onPopupStateChanged.bind(this)); } - #onMaintenanceStateChanged(stateChangeEvent: CustomEvent) { + #onPopupStateChanged(stateChangeEvent: CustomEvent) { // TODO Replace those with FontAwesome icons later. Those icons can probably be sourced from the website itself. switch (stateChangeEvent.detail) { case "ready": diff --git a/src/content/components/philomena/MediaBox.ts b/src/content/components/philomena/MediaBox.ts index 2403b69..c7b1cc6 100644 --- a/src/content/components/philomena/MediaBox.ts +++ b/src/content/components/philomena/MediaBox.ts @@ -2,7 +2,7 @@ import { BaseComponent } from "$content/components/base/BaseComponent"; 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/maintenance-popup-events"; +import { EVENT_TAGS_UPDATED } from "$content/components/events/tagging-profile-popup-events"; export class MediaBox extends BaseComponent { #thumbnailContainer: HTMLElement | null = null; diff --git a/src/content/components/philomena/TagsForm.ts b/src/content/components/philomena/TagsForm.ts index 7a27cdc..8307ed8 100644 --- a/src/content/components/philomena/TagsForm.ts +++ b/src/content/components/philomena/TagsForm.ts @@ -9,8 +9,8 @@ import { EVENT_PRESET_TAG_CHANGE_APPLIED, type PresetTagChange } from "$content/ export class TagsForm extends BaseComponent { #togglePresetsButton: HTMLButtonElement = document.createElement('button'); #presetsList = EditorPresetsBlock.create(); - #plainEditorTextarea: HTMLTextAreaElement|null = null; - #fancyEditorInput: HTMLInputElement|null = null; + #plainEditorTextarea: HTMLTextAreaElement | null = null; + #fancyEditorInput: HTMLInputElement | null = null; #tagsSet: Set = new Set(); protected build() { @@ -172,7 +172,8 @@ export class TagsForm extends BaseComponent { } #onTagChangeRequested(event: CustomEvent) { - const { addedTags = null, removedTags = null } = event.detail; + const targetElement = event.target instanceof HTMLElement ? event.target : null; + const {addedTags = null, removedTags = null} = event.detail; let tagChangeList: string[] = []; if (addedTags) { @@ -187,23 +188,33 @@ export class TagsForm extends BaseComponent { ); } - const offsetBeforeSubmission = this.#presetsList.container.offsetTop; - - this.#applyTagChangesWithFancyTagEditor( - tagChangeList.join(',') + this.#executeAndCompensateForLayoutShift( + () => this.#applyTagChangesWithFancyTagEditor(tagChangeList.join(',')), + [this.#presetsList.container, targetElement], ); + } - const offsetDifference = this.#presetsList.container.offsetTop - offsetBeforeSubmission; + #executeAndCompensateForLayoutShift(executeOperation: () => void, elements: (HTMLElement | null)[]) { + const offsetsListBefore = TagsForm.#gatherOffsetsFromElements(elements); + executeOperation(); + const offsetsListAfter = TagsForm.#gatherOffsetsFromElements(elements); - // Compensating for the layout shift: when user clicks on a tag (or on "add/remove all tags"), tag editor might - // overflow the current line and wrap tags around to the next line, causing presets section to shift. We need to - // avoid that for better UX. - if (offsetDifference !== 0) { - window.scrollTo({ - top: window.scrollY + offsetDifference, - behavior: 'instant', - }); + const resultDifference = offsetsListAfter + .map((offsetAfter, index) => + offsetAfter !== null && offsetsListBefore[index] !== null + ? offsetAfter - offsetsListBefore[index] + : null) + .filter(difference => difference !== null) + .reduce((summary, difference) => summary + difference, 0); + + if (resultDifference === 0) { + return; } + + window.scrollTo({ + top: scrollY + resultDifference, + behavior: 'instant', + }) } #applyTagChangesWithFancyTagEditor(tagsListWithChanges: string): void { @@ -232,7 +243,7 @@ export class TagsForm extends BaseComponent { this.refreshTagColors(); } - #onPlainEditorReloadRequested(event: CustomEvent) { + #onPlainEditorReloadRequested(event: CustomEvent) { if (!event.detail?.skipTagColorRefresh) { this.refreshTagColors(); } @@ -242,6 +253,14 @@ export class TagsForm extends BaseComponent { } } + static #gatherOffsetsFromElements(elements: (HTMLElement | null)[]): (number | null)[] { + return elements.map( + maybeElement => maybeElement?.checkVisibility() + ? maybeElement?.offsetTop + : null + ); + } + static watchForEditors() { document.body.addEventListener('click', event => { const targetElement = event.target; diff --git a/src/lib/extension/entities/TagEditorPreset.ts b/src/lib/extension/entities/TagEditorPreset.ts index d904d5f..ba708a6 100644 --- a/src/lib/extension/entities/TagEditorPreset.ts +++ b/src/lib/extension/entities/TagEditorPreset.ts @@ -3,6 +3,9 @@ import StorageEntity from "$lib/extension/base/StorageEntity"; interface TagEditorPresetSettings { name: string; tags: string[]; + exclusive: boolean; + conditional: boolean; + requiredTags: string[]; } export default class TagEditorPreset extends StorageEntity { @@ -10,6 +13,9 @@ export default class TagEditorPreset extends StorageEntity([]); + let isExclusive = $state(false); + let isConditional = $state(false); + let requiredTags = $state([]); $effect(() => { if (presetId === 'new') { @@ -39,6 +43,9 @@ presetName = targetPreset.settings.name; tagsList = [...targetPreset.settings.tags].sort((a, b) => a.localeCompare(b)); + isExclusive = targetPreset.settings.exclusive; + isConditional = targetPreset.settings.conditional; + requiredTags = [...targetPreset.settings.requiredTags].sort((a, b) => a.localeCompare(b)); }); async function savePreset() { @@ -49,6 +56,9 @@ targetPreset.settings.name = presetName; targetPreset.settings.tags = [...tagsList]; + targetPreset.settings.exclusive = isExclusive; + targetPreset.settings.conditional = isConditional; + targetPreset.settings.requiredTags = [...requiredTags]; await targetPreset.save(); await goto(`/features/presets/${targetPreset.id}`); @@ -67,6 +77,21 @@ + + + Keep only one tag from this preset active at a time. + + + + + Show this preset only when any of specified tags are provided. + + + {#if isConditional} + + + + {/if}
diff --git a/src/styles/booru-vars.scss b/src/styles/booru-vars.scss index 462d9c5..1f9d5f6 100644 --- a/src/styles/booru-vars.scss +++ b/src/styles/booru-vars.scss @@ -4,6 +4,7 @@ $media-box-color: var(--media-box-color); $padding-small: var(--padding-small); $padding-normal: var(--padding-normal); $padding-large: var(--padding-large); +$block-spacing: var(--block-spacing); // These variables are defined dynamically based on the category of the tag $resolved-tag-background: var(--tag-background); diff --git a/src/styles/content/tag-presets.scss b/src/styles/content/tag-presets.scss index cf24ef6..db66a8e 100644 --- a/src/styles/content/tag-presets.scss +++ b/src/styles/content/tag-presets.scss @@ -3,6 +3,7 @@ .block.tag-presets { .tag { cursor: pointer; + user-select: none; &.is-missing { opacity: 0.5; @@ -13,4 +14,11 @@ background: booru-vars.$resolved-tag-color; } } + + .block.block--fixed.block--warning { + margin: { + top: booru-vars.$block-spacing; + bottom: 0; + } + } }