1
0
mirror of https://github.com/koloml/furbooru-tagging-assistant.git synced 2025-12-23 23:02:58 +00:00

Merge pull request #108 from koloml/feature/option-to-display-groups-separately

Tag Groups: Added option to display the tags captured by the group in the separate list
This commit is contained in:
2025-03-10 23:58:56 +04:00
committed by GitHub
13 changed files with 325 additions and 28 deletions

View File

@@ -1,3 +1,6 @@
import { TagsForm } from "$lib/components/TagsForm";
import { initializeAllTagsLists, watchForUpdatedTagLists } from "$lib/components/TagsListBlock";
initializeAllTagsLists();
watchForUpdatedTagLists();
TagsForm.watchForEditors();

View File

@@ -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<TagGroup | null>) {
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) => {

View File

@@ -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<string, TagGroup>();
#separatedHeaders = new Map<string, HTMLElement>();
#groupsCount = new Map<string, number>();
#lastTagGroup = new WeakMap<TagDropdownWrapper, TagGroup | null>;
#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<TagGroup | null>) {
const maybeDropdownElement = resolvedCustomGroupEvent.target;
if (!(maybeDropdownElement instanceof HTMLElement)) {
return;
}
const tagDropdown = getComponent<TagDropdownWrapper>(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<HTMLElement>('#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')
});
}

View File

@@ -1,6 +1,6 @@
import type { BaseComponent } from "$lib/components/base/BaseComponent";
const instanceSymbol = Symbol('instance');
const instanceSymbol = Symbol.for('instance');
interface ElementWithComponent<T> extends HTMLElement {
[instanceSymbol]?: T;

View File

@@ -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<EventDetails> = (event: CustomEvent<EventDetails>) => void;
export type UnsubscribeFunction = () => void;

View File

@@ -0,0 +1,7 @@
import type TagGroup from "$entities/TagGroup";
export const eventTagCustomGroupResolved = 'tag-group-resolved';
export interface TagDropdownEvents {
[eventTagCustomGroupResolved]: TagGroup | null;
}

View File

@@ -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<string, string>();
#compiledRegExps = new Map<RegExp, string>();
#exactGroupMatches = new Map<string, TagGroup>();
#regExpGroupMatches = new Map<RegExp, TagGroup>();
#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;

View File

@@ -6,6 +6,7 @@ export interface TagGroupSettings {
prefixes: string[];
suffixes: string[];
category: string;
separate: boolean;
}
export default class TagGroup extends StorageEntity<TagGroupSettings> {
@@ -15,7 +16,8 @@ export default class TagGroup extends StorageEntity<TagGroupSettings> {
tags: settings.tags || [],
prefixes: settings.prefixes || [],
suffixes: settings.suffixes || [],
category: settings.category || ''
category: settings.category || '',
separate: Boolean(settings.separate),
});
}

View File

@@ -0,0 +1,19 @@
import CacheableSettings from "$lib/extension/base/CacheableSettings";
interface TagSettingsFields {
groupSeparation: boolean;
}
export default class TagSettings extends CacheableSettings<TagSettingsFields> {
constructor() {
super("tag");
}
async resolveGroupSeparation() {
return this._resolveSetting("groupSeparation", true);
}
async setGroupSeparation(value: boolean) {
return this._writeSetting("groupSeparation", Boolean(value));
}
}

View File

@@ -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,
}
}
};

View File

@@ -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<string[]>([]);
let prefixesList = $state<string[]>([]);
let suffixesList = $state<string[]>([]);
let tagCategory = $state<string>('');
let tagCategory = $state<string>('')
let separateGroup = $state<boolean>(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 @@
</script>
<Menu>
<MenuItem href="/features/groups/{groupId}" icon="arrow-left">Back</MenuItem>
<MenuItem href="/features/groups/{groupId !== 'new' ? '' : groupId}" icon="arrow-left">Back</MenuItem>
<hr>
</Menu>
<FormContainer>
<FormControl label="Group Name">
<TextField bind:value={groupName} placeholder="Group Name"></TextField>
</FormControl>
<FormControl>
<CheckboxField bind:checked={separateGroup}>
Display tags found by this group in separate list after all other tags.
</CheckboxField>
</FormControl>
<FormControl label="Group Color">
<TagCategorySelectField bind:value={tagCategory}/>
</FormControl>

View File

@@ -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";
</script>
<Menu>
@@ -17,4 +18,9 @@
Automatically remove black-listed tags from the images
</CheckboxField>
</FormControl>
<FormControl>
<CheckboxField bind:checked={$shouldSeparateTagGroups}>
Enable separation of custom tag groups on the image pages
</CheckboxField>
</FormControl>
</FormContainer>

View File

@@ -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));
});
})