diff --git a/src/content/tags-editor.ts b/src/content/tags-editor.ts index 1f32314..6ab8292 100644 --- a/src/content/tags-editor.ts +++ b/src/content/tags-editor.ts @@ -1,3 +1,6 @@ import { TagsForm } from "$lib/components/TagsForm"; +import { initializeAllTagsLists, watchForUpdatedTagLists } from "$lib/components/TagsListBlock"; +initializeAllTagsLists(); +watchForUpdatedTagLists(); TagsForm.watchForEditors(); diff --git a/src/lib/components/TagDropdownWrapper.ts b/src/lib/components/TagDropdownWrapper.ts index cca7caa..33a3be8 100644 --- a/src/lib/components/TagDropdownWrapper.ts +++ b/src/lib/components/TagDropdownWrapper.ts @@ -5,6 +5,8 @@ import { getComponent } from "$lib/components/base/component-utils"; import CustomCategoriesResolver from "$lib/extension/CustomCategoriesResolver"; import { on } from "$lib/components/events/comms"; import { eventFormEditorUpdated } from "$lib/components/events/tags-form-events"; +import { eventTagCustomGroupResolved } from "$lib/components/events/tag-dropdown-events"; +import type TagGroup from "$entities/TagGroup"; const categoriesResolver = new CustomCategoriesResolver(); @@ -51,6 +53,23 @@ export class TagDropdownWrapper extends BaseComponent { this.#updateButtons(); } }); + + on(this, eventTagCustomGroupResolved, this.#onTagGroupResolved.bind(this)); + } + + #onTagGroupResolved(resolvedGroupEvent: CustomEvent) { + if (this.originalCategory) { + return; + } + + const maybeTagGroup = resolvedGroupEvent.detail; + + if (!maybeTagGroup) { + this.tagCategory = this.originalCategory; + return; + } + + this.tagCategory = maybeTagGroup.settings.category; } get tagName() { @@ -188,7 +207,7 @@ export class TagDropdownWrapper extends BaseComponent { * @param onActiveProfileChange Callback to call when profile was * changed. */ - static #watchActiveProfile(onActiveProfileChange: (profile: MaintenanceProfile|null) => void) { + static #watchActiveProfile(onActiveProfileChange: (profile: MaintenanceProfile | null) => void) { let lastActiveProfile: string | null = null; this.#maintenanceSettings.subscribe((settings) => { diff --git a/src/lib/components/TagsListBlock.ts b/src/lib/components/TagsListBlock.ts new file mode 100644 index 0000000..3603fcf --- /dev/null +++ b/src/lib/components/TagsListBlock.ts @@ -0,0 +1,201 @@ +import { BaseComponent } from "$lib/components/base/BaseComponent"; +import type TagGroup from "$entities/TagGroup"; +import type { TagDropdownWrapper } from "$lib/components/TagDropdownWrapper"; +import { on } from "$lib/components/events/comms"; +import { eventFormEditorUpdated } from "$lib/components/events/tags-form-events"; +import { getComponent } from "$lib/components/base/component-utils"; +import { eventTagCustomGroupResolved } from "$lib/components/events/tag-dropdown-events"; +import TagSettings from "$lib/extension/settings/TagSettings"; + +export class TagsListBlock extends BaseComponent { + #tagsListContainer: HTMLElement | null = null; + #tagSettings = new TagSettings(); + + #shouldDisplaySeparation = false; + + #separatedGroups = new Map(); + #separatedHeaders = new Map(); + #groupsCount = new Map(); + #lastTagGroup = new WeakMap; + + #isReorderingPlanned = false; + + protected build() { + this.#tagsListContainer = this.container.querySelector('.tag-list'); + } + + init() { + this.#tagSettings.resolveGroupSeparation().then(this.#onTagSeparationChange.bind(this)); + this.#tagSettings.subscribe(settings => { + this.#onTagSeparationChange(Boolean(settings.groupSeparation)) + }); + + on( + this, + eventTagCustomGroupResolved, + this.#onTagDropdownCustomGroupResolved.bind(this) + ); + } + + #onTagSeparationChange(isSeparationEnabled: boolean) { + if (this.#shouldDisplaySeparation === isSeparationEnabled) { + return; + } + + this.#shouldDisplaySeparation = isSeparationEnabled; + this.#reorderSeparatedGroups(); + } + + #onTagDropdownCustomGroupResolved(resolvedCustomGroupEvent: CustomEvent) { + const maybeDropdownElement = resolvedCustomGroupEvent.target; + + if (!(maybeDropdownElement instanceof HTMLElement)) { + return; + } + + const tagDropdown = getComponent(maybeDropdownElement); + + if (!tagDropdown) { + return; + } + + const tagGroup = resolvedCustomGroupEvent.detail; + + if (tagGroup) { + this.#handleTagGroupChanges(tagGroup); + } + + this.#handleResolvedTagGroup(tagGroup, tagDropdown); + + if (!this.#isReorderingPlanned) { + this.#isReorderingPlanned = true; + + requestAnimationFrame(this.#reorderSeparatedGroups.bind(this)); + } + } + + #handleTagGroupChanges(tagGroup: TagGroup) { + const groupId = tagGroup.id; + const processedGroup = this.#separatedGroups.get(groupId); + + if (!tagGroup.settings.separate && processedGroup) { + this.#separatedGroups.delete(groupId); + this.#separatedHeaders.get(groupId)?.remove(); + this.#separatedHeaders.delete(groupId); + return; + } + + // Every time group is updated, a new object is being initialized + if (tagGroup !== processedGroup) { + this.#createOrUpdateHeaderForGroup(tagGroup); + this.#separatedGroups.set(groupId, tagGroup); + } + } + + #createOrUpdateHeaderForGroup(group: TagGroup) { + let heading = this.#separatedHeaders.get(group.id); + + if (!heading) { + heading = document.createElement('h2'); + + // Heading is hidden by default and shown on next frame if there are tags to show in the section. + heading.style.display = 'none'; + heading.style.order = `var(${TagsListBlock.#orderCssVariableForGroup(group.id)}, 0)`; + heading.style.flexBasis = '100%'; + + // We're inserting heading to the top just to make sure that heading is always in front of the tags related to + // this category. + this.#tagsListContainer?.insertAdjacentElement('afterbegin', heading); + + this.#separatedHeaders.set(group.id, heading); + } + + heading.innerText = group.settings.name; + } + + #handleResolvedTagGroup(resolvedGroup: TagGroup | null, tagComponent: TagDropdownWrapper) { + const previousGroupId = this.#lastTagGroup.get(tagComponent)?.id; + const currentGroupId = resolvedGroup?.id; + const isDifferentId = currentGroupId !== previousGroupId; + const isSeparationEnabled = resolvedGroup?.settings.separate; + + if (isDifferentId) { + // Make sure to subtract the element from counters if there was a count before. + if (previousGroupId && this.#groupsCount.has(previousGroupId)) { + this.#groupsCount.set(previousGroupId, this.#groupsCount.get(previousGroupId)! - 1); + } + + // We only need to count groups which have separation enabled. + if (currentGroupId && isSeparationEnabled) { + const count = this.#groupsCount.get(resolvedGroup.id) ?? 0; + this.#groupsCount.set(currentGroupId, count + 1); + } + } + + // We're adding the CSS order for the tag as the CSS variable. This variable is updated later. + if (currentGroupId && isSeparationEnabled) { + tagComponent.container.style.order = `var(${TagsListBlock.#orderCssVariableForGroup(currentGroupId)}, 0)`; + } else { + tagComponent.container.style.removeProperty('order'); + } + + // If separation is disabled in the new group, then we should remove the tag from map, so it can be recaptured + // when tag group is getting enabled later. + if (currentGroupId && !isSeparationEnabled) { + this.#lastTagGroup.delete(tagComponent); + return; + } + + // Mark this tag component as related to the following group. + this.#lastTagGroup.set(tagComponent, resolvedGroup); + } + + #reorderSeparatedGroups() { + this.#isReorderingPlanned = false; + + const tagGroups = Array.from(this.#separatedGroups.values()) + .toSorted((a, b) => a.settings.name.localeCompare(b.settings.name)); + + for (let index = 0; index < tagGroups.length; index++) { + const tagGroup = tagGroups[index]; + const groupId = tagGroup.id; + const usedCount = this.#groupsCount.get(groupId); + const relatedHeading = this.#separatedHeaders.get(groupId); + + if (this.#shouldDisplaySeparation) { + this.container.style.setProperty(TagsListBlock.#orderCssVariableForGroup(groupId), (index + 1).toString()); + } else { + this.container.style.removeProperty(TagsListBlock.#orderCssVariableForGroup(groupId)); + } + + if (relatedHeading) { + if (!this.#shouldDisplaySeparation || !usedCount || usedCount <= 0) { + relatedHeading.style.display = 'none'; + } else { + relatedHeading.style.removeProperty('display'); + } + } + } + } + + static #orderCssVariableForGroup(groupId: string): string { + return `--ta-order-${groupId}`; + } +} + +export function initializeAllTagsLists() { + for (let element of document.querySelectorAll('#image_tags_and_source')) { + if (getComponent(element)) { + return; + } + + new TagsListBlock(element) + .initialize(); + } +} + +export function watchForUpdatedTagLists() { + on(document, eventFormEditorUpdated, event => { + event.detail.closest('#image_tags_and_source') + }); +} diff --git a/src/lib/components/base/component-utils.ts b/src/lib/components/base/component-utils.ts index 5f6bd42..49442ce 100644 --- a/src/lib/components/base/component-utils.ts +++ b/src/lib/components/base/component-utils.ts @@ -1,6 +1,6 @@ import type { BaseComponent } from "$lib/components/base/BaseComponent"; -const instanceSymbol = Symbol('instance'); +const instanceSymbol = Symbol.for('instance'); interface ElementWithComponent extends HTMLElement { [instanceSymbol]?: T; diff --git a/src/lib/components/events/comms.ts b/src/lib/components/events/comms.ts index 3624491..7682ee7 100644 --- a/src/lib/components/events/comms.ts +++ b/src/lib/components/events/comms.ts @@ -3,12 +3,14 @@ import { BaseComponent } from "$lib/components/base/BaseComponent"; import type { FullscreenViewerEventsMap } from "$lib/components/events/fullscreen-viewer-events"; import type { BooruEventsMap } from "$lib/components/events/booru-events"; import type { TagsFormEventsMap } from "$lib/components/events/tags-form-events"; +import type { TagDropdownEvents } from "$lib/components/events/tag-dropdown-events"; type EventsMapping = MaintenancePopupEventsMap & FullscreenViewerEventsMap & BooruEventsMap - & TagsFormEventsMap; + & TagsFormEventsMap + & TagDropdownEvents; type EventCallback = (event: CustomEvent) => void; export type UnsubscribeFunction = () => void; diff --git a/src/lib/components/events/tag-dropdown-events.ts b/src/lib/components/events/tag-dropdown-events.ts new file mode 100644 index 0000000..352b5ce --- /dev/null +++ b/src/lib/components/events/tag-dropdown-events.ts @@ -0,0 +1,7 @@ +import type TagGroup from "$entities/TagGroup"; + +export const eventTagCustomGroupResolved = 'tag-group-resolved'; + +export interface TagDropdownEvents { + [eventTagCustomGroupResolved]: TagGroup | null; +} diff --git a/src/lib/extension/CustomCategoriesResolver.ts b/src/lib/extension/CustomCategoriesResolver.ts index e822d34..790b9e1 100644 --- a/src/lib/extension/CustomCategoriesResolver.ts +++ b/src/lib/extension/CustomCategoriesResolver.ts @@ -1,10 +1,12 @@ import type { TagDropdownWrapper } from "$lib/components/TagDropdownWrapper"; import TagGroup from "$entities/TagGroup"; import { escapeRegExp } from "$lib/utils"; +import { emit } from "$lib/components/events/comms"; +import { eventTagCustomGroupResolved } from "$lib/components/events/tag-dropdown-events"; export default class CustomCategoriesResolver { - #tagCategories = new Map(); - #compiledRegExps = new Map(); + #exactGroupMatches = new Map(); + #regExpGroupMatches = new Map(); #tagDropdowns: TagDropdownWrapper[] = []; #nextQueuedUpdate: Timeout | null = null; @@ -16,7 +18,7 @@ export default class CustomCategoriesResolver { public addElement(tagDropdown: TagDropdownWrapper): void { this.#tagDropdowns.push(tagDropdown); - if (!this.#tagCategories.size && !this.#compiledRegExps.size) { + if (!this.#exactGroupMatches.size && !this.#regExpGroupMatches.size) { return; } @@ -36,7 +38,6 @@ export default class CustomCategoriesResolver { #updateUnprocessedTags() { this.#tagDropdowns - .filter(CustomCategoriesResolver.#skipTagsWithOriginalCategory) .filter(this.#applyCustomCategoryForExactMatches.bind(this)) .filter(this.#matchCustomCategoryByRegExp.bind(this)) .forEach(CustomCategoriesResolver.#resetToOriginalCategory); @@ -51,23 +52,33 @@ export default class CustomCategoriesResolver { #applyCustomCategoryForExactMatches(tagDropdown: TagDropdownWrapper): boolean { const tagName = tagDropdown.tagName!; - if (!this.#tagCategories.has(tagName)) { + if (!this.#exactGroupMatches.has(tagName)) { return true; } - tagDropdown.tagCategory = this.#tagCategories.get(tagName)!; + emit( + tagDropdown, + eventTagCustomGroupResolved, + this.#exactGroupMatches.get(tagName)! + ); + return false; } #matchCustomCategoryByRegExp(tagDropdown: TagDropdownWrapper) { const tagName = tagDropdown.tagName!; - for (const targetRegularExpression of this.#compiledRegExps.keys()) { + for (const targetRegularExpression of this.#regExpGroupMatches.keys()) { if (!targetRegularExpression.test(tagName)) { continue; } - tagDropdown.tagCategory = this.#compiledRegExps.get(targetRegularExpression)!; + emit( + tagDropdown, + eventTagCustomGroupResolved, + this.#regExpGroupMatches.get(targetRegularExpression)! + ); + return false; } @@ -75,31 +86,29 @@ export default class CustomCategoriesResolver { } #onTagGroupsReceived(tagGroups: TagGroup[]) { - this.#tagCategories.clear(); - this.#compiledRegExps.clear(); + this.#exactGroupMatches.clear(); + this.#regExpGroupMatches.clear(); if (!tagGroups.length) { return; } for (const tagGroup of tagGroups) { - const categoryName = tagGroup.settings.category; - for (const tagName of tagGroup.settings.tags) { - this.#tagCategories.set(tagName, categoryName); + this.#exactGroupMatches.set(tagName, tagGroup); } for (const tagPrefix of tagGroup.settings.prefixes) { - this.#compiledRegExps.set( + this.#regExpGroupMatches.set( new RegExp(`^${escapeRegExp(tagPrefix)}`), - categoryName + tagGroup, ); } for (let tagSuffix of tagGroup.settings.suffixes) { - this.#compiledRegExps.set( + this.#regExpGroupMatches.set( new RegExp(`${escapeRegExp(tagSuffix)}$`), - categoryName + tagGroup, ); } } @@ -107,12 +116,12 @@ export default class CustomCategoriesResolver { this.#queueUpdatingTags(); } - static #skipTagsWithOriginalCategory(tagDropdown: TagDropdownWrapper): boolean { - return !tagDropdown.originalCategory; - } - static #resetToOriginalCategory(tagDropdown: TagDropdownWrapper): void { - tagDropdown.tagCategory = tagDropdown.originalCategory; + emit( + tagDropdown, + eventTagCustomGroupResolved, + null, + ); } static #unprocessedTagsTimeout = 0; diff --git a/src/lib/extension/entities/TagGroup.ts b/src/lib/extension/entities/TagGroup.ts index fc39897..c95b751 100644 --- a/src/lib/extension/entities/TagGroup.ts +++ b/src/lib/extension/entities/TagGroup.ts @@ -6,6 +6,7 @@ export interface TagGroupSettings { prefixes: string[]; suffixes: string[]; category: string; + separate: boolean; } export default class TagGroup extends StorageEntity { @@ -15,7 +16,8 @@ export default class TagGroup extends StorageEntity { tags: settings.tags || [], prefixes: settings.prefixes || [], suffixes: settings.suffixes || [], - category: settings.category || '' + category: settings.category || '', + separate: Boolean(settings.separate), }); } diff --git a/src/lib/extension/settings/TagSettings.ts b/src/lib/extension/settings/TagSettings.ts new file mode 100644 index 0000000..e95f7e6 --- /dev/null +++ b/src/lib/extension/settings/TagSettings.ts @@ -0,0 +1,19 @@ +import CacheableSettings from "$lib/extension/base/CacheableSettings"; + +interface TagSettingsFields { + groupSeparation: boolean; +} + +export default class TagSettings extends CacheableSettings { + constructor() { + super("tag"); + } + + async resolveGroupSeparation() { + return this._resolveSetting("groupSeparation", true); + } + + async setGroupSeparation(value: boolean) { + return this._writeSetting("groupSeparation", Boolean(value)); + } +} diff --git a/src/lib/extension/transporting/exporters.ts b/src/lib/extension/transporting/exporters.ts index c57b529..8477617 100644 --- a/src/lib/extension/transporting/exporters.ts +++ b/src/lib/extension/transporting/exporters.ts @@ -21,6 +21,8 @@ const entitiesExporters: ExportersMap = { tags: entity.settings.tags, prefixes: entity.settings.prefixes, suffixes: entity.settings.suffixes, + category: entity.settings.category, + separate: entity.settings.separate, } } }; diff --git a/src/routes/features/groups/[id]/edit/+page.svelte b/src/routes/features/groups/[id]/edit/+page.svelte index 8d68f71..55adba3 100644 --- a/src/routes/features/groups/[id]/edit/+page.svelte +++ b/src/routes/features/groups/[id]/edit/+page.svelte @@ -11,6 +11,7 @@ import TagsEditor from "$components/tags/TagsEditor.svelte"; import TagGroup from "$entities/TagGroup"; import { tagGroups } from "$stores/entities/tag-groups"; + import CheckboxField from "$components/ui/forms/CheckboxField.svelte"; let groupId = $derived(page.params.id); @@ -26,7 +27,8 @@ let tagsList = $state([]); let prefixesList = $state([]); let suffixesList = $state([]); - let tagCategory = $state(''); + let tagCategory = $state('') + let separateGroup = $state(false); $effect(() => { if (groupId === 'new') { @@ -43,6 +45,7 @@ prefixesList = [...targetGroup.settings.prefixes].sort((a, b) => a.localeCompare(b)); suffixesList = [...targetGroup.settings.suffixes].sort((a, b) => a.localeCompare(b)); tagCategory = targetGroup.settings.category; + separateGroup = targetGroup.settings.separate; }); async function saveGroup() { @@ -56,6 +59,7 @@ targetGroup.settings.prefixes = [...prefixesList]; targetGroup.settings.suffixes = [...suffixesList]; targetGroup.settings.category = tagCategory; + targetGroup.settings.separate = separateGroup; await targetGroup.save(); await goto(`/features/groups/${targetGroup.id}`); @@ -71,13 +75,18 @@ - Back + Back
+ + + Display tags found by this group in separate list after all other tags. + + diff --git a/src/routes/preferences/tags/+page.svelte b/src/routes/preferences/tags/+page.svelte index c5e55ba..2a0ed83 100644 --- a/src/routes/preferences/tags/+page.svelte +++ b/src/routes/preferences/tags/+page.svelte @@ -5,6 +5,7 @@ import Menu from "$components/ui/menu/Menu.svelte"; import MenuItem from "$components/ui/menu/MenuItem.svelte"; import { stripBlacklistedTagsEnabled } from "$stores/preferences/maintenance"; + import { shouldSeparateTagGroups } from "$stores/preferences/tag"; @@ -17,4 +18,9 @@ Automatically remove black-listed tags from the images + + + Enable separation of custom tag groups on the image pages + + diff --git a/src/stores/preferences/tag.ts b/src/stores/preferences/tag.ts new file mode 100644 index 0000000..70722c9 --- /dev/null +++ b/src/stores/preferences/tag.ts @@ -0,0 +1,18 @@ +import { writable } from "svelte/store"; +import TagSettings from "$lib/extension/settings/TagSettings"; + +const tagSettings = new TagSettings(); + +export const shouldSeparateTagGroups = writable(false); + +tagSettings.resolveGroupSeparation() + .then(value => shouldSeparateTagGroups.set(value)) + .then(() => { + shouldSeparateTagGroups.subscribe(value => { + void tagSettings.setGroupSeparation(value); + }); + + tagSettings.subscribe(settings => { + shouldSeparateTagGroups.set(Boolean(settings.groupSeparation)); + }); + })