mirror of
https://github.com/koloml/philomena-tagging-assistant.git
synced 2026-11-07 16:18:19 +00:00
Merge pull request #184 from koloml/feature/fullscreen-tagging-profile
Upgraded fullscreen viewer with toggleable tags
This commit is contained in:
14 files changed
+615
-259
No files matched your search
+33
-27
@@ -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<string, string> = 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' ? <const> [
|
||||
["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' ? <const>[
|
||||
["prompter", CategoriesEnum.Origin],
|
||||
["creator", CategoriesEnum.Origin],
|
||||
["generator", CategoriesEnum.Origin]
|
||||
] : [])
|
||||
]);
|
||||
|
||||
|
||||
@@ -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<EventDetails> = (event: CustomEvent<EventDetails>) => void;
|
||||
export type UnsubscribeFunction = () => void;
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export const EVENT_TAG_TOGGLED = 'tag-toggled';
|
||||
|
||||
export interface TagsListTogglerEventsMap {
|
||||
[EVENT_TAG_TOGGLED]: [string, boolean];
|
||||
}
|
||||
@@ -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> | 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<void> {
|
||||
this.#currentURIs = imageUris;
|
||||
async show(mediaBox: MediaBox): Promise<void> {
|
||||
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<FullscreenViewerSize, string> = {
|
||||
full: 'Full',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<string, HTMLElement> = new Map();
|
||||
#tagsListToggler = TagsListToggler.create();
|
||||
#activeProfile: TaggingProfile | null = null;
|
||||
#mediaBoxTools: MediaBoxTools | null = null;
|
||||
#tagsToRemove: Set<string> = new Set();
|
||||
#tagsToAdd: Set<string> = 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<HTMLElement>('.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;
|
||||
}
|
||||
@@ -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<string, HTMLElement> = new Map();
|
||||
#tagsToAdd: Set<string> = new Set();
|
||||
#tagsToRemove: Set<string> = 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<string>) {
|
||||
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<string>) {
|
||||
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<string>, 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<HTMLElement>('.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
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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<string, string> | 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<string> | null {
|
||||
const tagsAndAliases = this.tagsAndAliases;
|
||||
|
||||
if (tagsAndAliases === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Set(tagsAndAliases.values());
|
||||
}
|
||||
|
||||
get tagsAndAliases(): Map<string, string> | 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<MediaBoxTools>(mediaBoxToolsElement);
|
||||
mediaBoxToolsComponent?.initialize();
|
||||
|
||||
mediaBox.#mediaBoxTools = mediaBoxToolsComponent;
|
||||
}
|
||||
|
||||
static findElements(): NodeListOf<HTMLElement> {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -8,6 +8,7 @@ export type FullscreenViewerSize = keyof App.ImageURIs;
|
||||
interface MiscPreferencesFields {
|
||||
fullscreenViewer: boolean;
|
||||
fullscreenViewerSize: FullscreenViewerSize;
|
||||
fullscreenViewerPopupShown: boolean;
|
||||
}
|
||||
|
||||
export default class MiscPreferences extends CacheablePreferences<MiscPreferencesFields> implements WithFields<MiscPreferencesFields> {
|
||||
@@ -24,4 +25,9 @@ export default class MiscPreferences extends CacheablePreferences<MiscPreference
|
||||
field: "fullscreenViewerSize",
|
||||
defaultValue: "large",
|
||||
});
|
||||
|
||||
readonly fullscreenViewerPopupShown = new PreferenceField(this, {
|
||||
field: "fullscreenViewerPopupShown",
|
||||
defaultValue: true,
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import PostParser from "$lib/philomena/scraping/parsing/PostParser";
|
||||
|
||||
type UpdaterFunction = (tags: Set<string>) => Set<string>;
|
||||
export type TagsUpdaterFunction = (tags: Set<string>) => Set<string>;
|
||||
|
||||
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<Map<string, string> | null> {
|
||||
async updateImageTags(imageId: number, callback: TagsUpdaterFunction): Promise<Map<string, string> | null> {
|
||||
const postParser = new PostParser(imageId);
|
||||
const formData = await postParser.resolveTagEditorFormData();
|
||||
const tagsFieldValue = formData.get(PostParser.tagsInputName);
|
||||
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user