mirror of
				https://github.com/SillyTavern/SillyTavern.git
				synced 2025-06-05 21:59:27 +02:00 
			
		
		
		
	
		
			
				
	
	
		
			885 lines
		
	
	
		
			32 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			885 lines
		
	
	
		
			32 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
'use strict';
 | 
						|
 | 
						|
import {
 | 
						|
    characterGroupOverlay,
 | 
						|
    callPopup,
 | 
						|
    characters,
 | 
						|
    event_types,
 | 
						|
    eventSource,
 | 
						|
    getCharacters,
 | 
						|
    getRequestHeaders,
 | 
						|
    buildAvatarList,
 | 
						|
    characterToEntity,
 | 
						|
    printCharactersDebounced,
 | 
						|
    deleteCharacter,
 | 
						|
} from '../script.js';
 | 
						|
 | 
						|
import { favsToHotswap } from './RossAscends-mods.js';
 | 
						|
import { hideLoader, showLoader } from './loader.js';
 | 
						|
import { convertCharacterToPersona } from './personas.js';
 | 
						|
import { createTagInput, getTagKeyForEntity, getTagsList, printTagList, tag_map, compareTagsForSort, removeTagFromMap, importTags, tag_import_setting } from './tags.js';
 | 
						|
 | 
						|
/**
 | 
						|
 * Static object representing the actions of the
 | 
						|
 * character context menu override.
 | 
						|
 */
 | 
						|
class CharacterContextMenu {
 | 
						|
    /**
 | 
						|
     * Tag one or more characters,
 | 
						|
     * opens a popup.
 | 
						|
     *
 | 
						|
     * @param {Array<number>} selectedCharacters
 | 
						|
     */
 | 
						|
    static tag = (selectedCharacters) => {
 | 
						|
        characterGroupOverlay.bulkTagPopupHandler.show(selectedCharacters);
 | 
						|
    };
 | 
						|
 | 
						|
    /**
 | 
						|
     * Duplicate one or more characters
 | 
						|
     *
 | 
						|
     * @param {number} characterId
 | 
						|
     * @returns {Promise<any>}
 | 
						|
     */
 | 
						|
    static duplicate = async (characterId) => {
 | 
						|
        const character = CharacterContextMenu.#getCharacter(characterId);
 | 
						|
        const body = { avatar_url: character.avatar };
 | 
						|
 | 
						|
        const result = await fetch('/api/characters/duplicate', {
 | 
						|
            method: 'POST',
 | 
						|
            headers: getRequestHeaders(),
 | 
						|
            body: JSON.stringify(body),
 | 
						|
        });
 | 
						|
 | 
						|
        if (!result.ok) {
 | 
						|
            throw new Error('Character not duplicated');
 | 
						|
        }
 | 
						|
 | 
						|
        const data = await result.json();
 | 
						|
        await eventSource.emit(event_types.CHARACTER_DUPLICATED, { oldAvatar: body.avatar_url, newAvatar: data.path });
 | 
						|
    };
 | 
						|
 | 
						|
    /**
 | 
						|
     * Favorite a character
 | 
						|
     * and highlight it.
 | 
						|
     *
 | 
						|
     * @param {number} characterId
 | 
						|
     * @returns {Promise<void>}
 | 
						|
     */
 | 
						|
    static favorite = async (characterId) => {
 | 
						|
        const character = CharacterContextMenu.#getCharacter(characterId);
 | 
						|
        const newFavState = !character.data.extensions.fav;
 | 
						|
 | 
						|
        const data = {
 | 
						|
            name: character.name,
 | 
						|
            avatar: character.avatar,
 | 
						|
            data: {
 | 
						|
                extensions: {
 | 
						|
                    fav: newFavState,
 | 
						|
                },
 | 
						|
            },
 | 
						|
            fav: newFavState,
 | 
						|
        };
 | 
						|
 | 
						|
        const mergeResponse = await fetch('/api/characters/merge-attributes', {
 | 
						|
            method: 'POST',
 | 
						|
            headers: getRequestHeaders(),
 | 
						|
            body: JSON.stringify(data),
 | 
						|
        });
 | 
						|
 | 
						|
        if (!mergeResponse.ok) {
 | 
						|
            mergeResponse.json().then(json => toastr.error(`Character not saved. Error: ${json.message}. Field: ${json.error}`));
 | 
						|
        }
 | 
						|
 | 
						|
        const element = document.getElementById(`CharID${characterId}`);
 | 
						|
        element.classList.toggle('is_fav');
 | 
						|
    };
 | 
						|
 | 
						|
    /**
 | 
						|
     * Convert one or more characters to persona,
 | 
						|
     * may open a popup for one or more characters.
 | 
						|
     *
 | 
						|
     * @param {number} characterId
 | 
						|
     * @returns {Promise<void>}
 | 
						|
     */
 | 
						|
    static persona = async (characterId) => await convertCharacterToPersona(characterId);
 | 
						|
 | 
						|
    /**
 | 
						|
     * Delete one or more characters,
 | 
						|
     * opens a popup.
 | 
						|
     *
 | 
						|
     * @param {string|string[]} characterKey
 | 
						|
     * @param {boolean} [deleteChats]
 | 
						|
     * @returns {Promise<void>}
 | 
						|
     */
 | 
						|
    static delete = async (characterKey, deleteChats = false) => {
 | 
						|
        await deleteCharacter(characterKey, { deleteChats: deleteChats });
 | 
						|
    };
 | 
						|
 | 
						|
    static #getCharacter = (characterId) => characters[characterId] ?? null;
 | 
						|
 | 
						|
    /**
 | 
						|
     * Show the context menu at the given position
 | 
						|
     *
 | 
						|
     * @param positionX
 | 
						|
     * @param positionY
 | 
						|
     */
 | 
						|
    static show = (positionX, positionY) => {
 | 
						|
        let contextMenu = document.getElementById(BulkEditOverlay.contextMenuId);
 | 
						|
        contextMenu.style.left = `${positionX}px`;
 | 
						|
        contextMenu.style.top = `${positionY}px`;
 | 
						|
 | 
						|
        document.getElementById(BulkEditOverlay.contextMenuId).classList.remove('hidden');
 | 
						|
 | 
						|
        // Adjust position if context menu is outside of viewport
 | 
						|
        const boundingRect = contextMenu.getBoundingClientRect();
 | 
						|
        if (boundingRect.right > window.innerWidth) {
 | 
						|
            contextMenu.style.left = `${positionX - (boundingRect.right - window.innerWidth)}px`;
 | 
						|
        }
 | 
						|
        if (boundingRect.bottom > window.innerHeight) {
 | 
						|
            contextMenu.style.top = `${positionY - (boundingRect.bottom - window.innerHeight)}px`;
 | 
						|
        }
 | 
						|
    };
 | 
						|
 | 
						|
    /**
 | 
						|
     * Hide the context menu
 | 
						|
     */
 | 
						|
    static hide = () => document.getElementById(BulkEditOverlay.contextMenuId).classList.add('hidden');
 | 
						|
 | 
						|
    /**
 | 
						|
     * Sets up the context menu for the given overlay
 | 
						|
     *
 | 
						|
     * @param characterGroupOverlay
 | 
						|
     */
 | 
						|
    constructor(characterGroupOverlay) {
 | 
						|
        const contextMenuItems = [
 | 
						|
            { id: 'character_context_menu_favorite', callback: characterGroupOverlay.handleContextMenuFavorite },
 | 
						|
            { id: 'character_context_menu_duplicate', callback: characterGroupOverlay.handleContextMenuDuplicate },
 | 
						|
            { id: 'character_context_menu_delete', callback: characterGroupOverlay.handleContextMenuDelete },
 | 
						|
            { id: 'character_context_menu_persona', callback: characterGroupOverlay.handleContextMenuPersona },
 | 
						|
            { id: 'character_context_menu_tag', callback: characterGroupOverlay.handleContextMenuTag },
 | 
						|
        ];
 | 
						|
 | 
						|
        contextMenuItems.forEach(contextMenuItem => document.getElementById(contextMenuItem.id).addEventListener('click', contextMenuItem.callback));
 | 
						|
    }
 | 
						|
}
 | 
						|
 | 
						|
/**
 | 
						|
 * Represents a tag control not bound to a single character
 | 
						|
 */
 | 
						|
class BulkTagPopupHandler {
 | 
						|
    /**
 | 
						|
     * The characters for this popup
 | 
						|
     * @type {number[]}
 | 
						|
     */
 | 
						|
    characterIds;
 | 
						|
 | 
						|
    /**
 | 
						|
     * A storage of the current mutual tags, as calculated by getMutualTags()
 | 
						|
     * @type {object[]}
 | 
						|
     */
 | 
						|
    currentMutualTags;
 | 
						|
 | 
						|
    /**
 | 
						|
     * Sets up the bulk popup menu handler for the given overlay.
 | 
						|
     *
 | 
						|
     * Characters can be passed in with the show() call.
 | 
						|
     */
 | 
						|
    constructor() { }
 | 
						|
 | 
						|
    /**
 | 
						|
     * Gets the HTML as a string that is going to be the popup for the bulk tag edit
 | 
						|
     *
 | 
						|
     * @returns String containing the html for the popup
 | 
						|
     */
 | 
						|
    #getHtml = () => {
 | 
						|
        const characterData = JSON.stringify({ characterIds: this.characterIds });
 | 
						|
        return `<div id="bulk_tag_shadow_popup">
 | 
						|
            <div id="bulk_tag_popup" class="wider_dialogue_popup">
 | 
						|
                <div id="bulk_tag_popup_holder">
 | 
						|
                    <h3 class="marginBot5">Modify tags of ${this.characterIds.length} characters</h3>
 | 
						|
                    <small class="bulk_tags_desc m-b-1">Add or remove the mutual tags of all selected characters. Import all or existing tags for all selected characters.</small>
 | 
						|
                    <div id="bulk_tags_avatars_block" class="avatars_inline avatars_inline_small tags tags_inline"></div>
 | 
						|
                    <br>
 | 
						|
                    <div id="bulk_tags_div" class="marginBot5" data-characters='${characterData}'>
 | 
						|
                        <div class="tag_controls">
 | 
						|
                            <input id="bulkTagInput" class="text_pole tag_input wide100p margin0" data-i18n="[placeholder]Search / Create Tags" placeholder="Search / Create tags" maxlength="25" />
 | 
						|
                            <div class="tags_view menu_button fa-solid fa-tags" title="View all tags" data-i18n="[title]View all tags"></div>
 | 
						|
                        </div>
 | 
						|
                        <div id="bulkTagList" class="m-t-1 tags"></div>
 | 
						|
                    </div>
 | 
						|
                    <div id="dialogue_popup_controls" class="m-t-1">
 | 
						|
                        <div id="bulk_tag_popup_reset" class="menu_button" title="Remove all tags from the selected characters" data-i18n="[title]Remove all tags from the selected characters">
 | 
						|
                            <i class="fa-solid fa-trash-can margin-right-10px"></i>
 | 
						|
                            All
 | 
						|
                        </div>
 | 
						|
                        <div id="bulk_tag_popup_remove_mutual" class="menu_button" title="Remove all mutual tags from the selected characters" data-i18n="[title]Remove all mutual tags from the selected characters">
 | 
						|
                            <i class="fa-solid fa-trash-can margin-right-10px"></i>
 | 
						|
                            Mutual
 | 
						|
                        </div>
 | 
						|
                        <div id="bulk_tag_popup_import_all_tags" class="menu_button" title="Import all tags from selected characters" data-i18n="[title]Import all tags from selected characters">
 | 
						|
                            Import All
 | 
						|
                        </div>
 | 
						|
                        <div id="bulk_tag_popup_import_existing_tags" class="menu_button" title="Import existing tags from selected characters" data-i18n="[title]Import existing tags from selected characters">
 | 
						|
                            Import Existing
 | 
						|
                        </div>
 | 
						|
                        <div id="bulk_tag_popup_cancel" class="menu_button" data-i18n="Cancel">Close</div>
 | 
						|
                    </div>
 | 
						|
                </div>
 | 
						|
            </div>
 | 
						|
        </div>`;
 | 
						|
    };
 | 
						|
 | 
						|
    /**
 | 
						|
     * Append and show the tag control
 | 
						|
     *
 | 
						|
     * @param {number[]} characterIds - The characters that are shown inside the popup
 | 
						|
     */
 | 
						|
    show(characterIds) {
 | 
						|
        // shallow copy character ids persistently into this tooltip
 | 
						|
        this.characterIds = characterIds.slice();
 | 
						|
 | 
						|
        if (this.characterIds.length == 0) {
 | 
						|
            console.log('No characters selected for bulk edit tags.');
 | 
						|
            return;
 | 
						|
        }
 | 
						|
 | 
						|
        document.body.insertAdjacentHTML('beforeend', this.#getHtml());
 | 
						|
 | 
						|
        const entities = this.characterIds.map(id => characterToEntity(characters[id], id)).filter(entity => entity.item !== undefined);
 | 
						|
        buildAvatarList($('#bulk_tags_avatars_block'), entities);
 | 
						|
 | 
						|
        // Print the tag list with all mutuable tags, marking them as removable. That is the initial fill
 | 
						|
        printTagList($('#bulkTagList'), { tags: () => this.getMutualTags(), tagOptions: { removable: true } });
 | 
						|
 | 
						|
        // Tag input with resolvable list for the mutual tags to get redrawn, so that newly added tags get sorted correctly
 | 
						|
        createTagInput('#bulkTagInput', '#bulkTagList', { tags: () => this.getMutualTags(), tagOptions: { removable: true } });
 | 
						|
 | 
						|
        document.querySelector('#bulk_tag_popup_reset').addEventListener('click', this.resetTags.bind(this));
 | 
						|
        document.querySelector('#bulk_tag_popup_remove_mutual').addEventListener('click', this.removeMutual.bind(this));
 | 
						|
        document.querySelector('#bulk_tag_popup_cancel').addEventListener('click', this.hide.bind(this));
 | 
						|
        document.querySelector('#bulk_tag_popup_import_all_tags').addEventListener('click', this.importAllTags.bind(this));
 | 
						|
        document.querySelector('#bulk_tag_popup_import_existing_tags').addEventListener('click', this.importExistingTags.bind(this));
 | 
						|
    }
 | 
						|
 | 
						|
    /**
 | 
						|
     * Import existing tags for all selected characters
 | 
						|
     */
 | 
						|
    async importExistingTags() {
 | 
						|
        for (const characterId of this.characterIds) {
 | 
						|
            await importTags(characters[characterId], { importSetting: tag_import_setting.ONLY_EXISTING });
 | 
						|
        }
 | 
						|
 | 
						|
        $('#bulkTagList').empty();
 | 
						|
    }
 | 
						|
 | 
						|
    /**
 | 
						|
     * Import all tags for all selected characters
 | 
						|
     */
 | 
						|
    async importAllTags() {
 | 
						|
        for (const characterId of this.characterIds) {
 | 
						|
            await importTags(characters[characterId], { importSetting: tag_import_setting.ALL });
 | 
						|
        }
 | 
						|
 | 
						|
        $('#bulkTagList').empty();
 | 
						|
    }
 | 
						|
 | 
						|
    /**
 | 
						|
     * Builds a list of all tags that the provided characters have in common.
 | 
						|
     *
 | 
						|
     * @returns {Array<object>} A list of mutual tags
 | 
						|
     */
 | 
						|
    getMutualTags() {
 | 
						|
        if (this.characterIds.length == 0) {
 | 
						|
            return [];
 | 
						|
        }
 | 
						|
 | 
						|
        if (this.characterIds.length === 1) {
 | 
						|
            // Just use tags of the single character
 | 
						|
            return getTagsList(getTagKeyForEntity(this.characterIds[0]));
 | 
						|
        }
 | 
						|
 | 
						|
        // Find mutual tags for multiple characters
 | 
						|
        const allTags = this.characterIds.map(cid => getTagsList(getTagKeyForEntity(cid)));
 | 
						|
        const mutualTags = allTags.reduce((mutual, characterTags) =>
 | 
						|
            mutual.filter(tag => characterTags.some(cTag => cTag.id === tag.id)),
 | 
						|
        );
 | 
						|
 | 
						|
        this.currentMutualTags = mutualTags.sort(compareTagsForSort);
 | 
						|
        return this.currentMutualTags;
 | 
						|
    }
 | 
						|
 | 
						|
    /**
 | 
						|
     * Hide and remove the tag control
 | 
						|
     */
 | 
						|
    hide() {
 | 
						|
        let popupElement = document.querySelector('#bulk_tag_shadow_popup');
 | 
						|
        if (popupElement) {
 | 
						|
            document.body.removeChild(popupElement);
 | 
						|
        }
 | 
						|
 | 
						|
        // No need to redraw here, all tags actions were redrawn when they happened
 | 
						|
    }
 | 
						|
 | 
						|
    /**
 | 
						|
     * Empty the tag map for the given characters
 | 
						|
     */
 | 
						|
    resetTags() {
 | 
						|
        for (const characterId of this.characterIds) {
 | 
						|
            const key = getTagKeyForEntity(characterId);
 | 
						|
            if (key) tag_map[key] = [];
 | 
						|
        }
 | 
						|
 | 
						|
        $('#bulkTagList').empty();
 | 
						|
 | 
						|
        printCharactersDebounced();
 | 
						|
    }
 | 
						|
 | 
						|
    /**
 | 
						|
     * Remove the mutual tags for all given characters
 | 
						|
     */
 | 
						|
    removeMutual() {
 | 
						|
        const mutualTags = this.getMutualTags();
 | 
						|
 | 
						|
        for (const characterId of this.characterIds) {
 | 
						|
            for (const tag of mutualTags) {
 | 
						|
                removeTagFromMap(tag.id, characterId);
 | 
						|
            }
 | 
						|
        }
 | 
						|
 | 
						|
        $('#bulkTagList').empty();
 | 
						|
 | 
						|
        printCharactersDebounced();
 | 
						|
    }
 | 
						|
}
 | 
						|
 | 
						|
class BulkEditOverlayState {
 | 
						|
    /**
 | 
						|
     *
 | 
						|
     * @type {number}
 | 
						|
     */
 | 
						|
    static browse = 0;
 | 
						|
 | 
						|
    /**
 | 
						|
     *
 | 
						|
     * @type {number}
 | 
						|
     */
 | 
						|
    static select = 1;
 | 
						|
}
 | 
						|
 | 
						|
/**
 | 
						|
 * Implement a SingletonPattern, allowing access to the group overlay instance
 | 
						|
 * from everywhere via (new CharacterGroupOverlay())
 | 
						|
 *
 | 
						|
 * @type BulkEditOverlay
 | 
						|
 */
 | 
						|
let bulkEditOverlayInstance = null;
 | 
						|
 | 
						|
class BulkEditOverlay {
 | 
						|
    static containerId = 'rm_print_characters_block';
 | 
						|
    static contextMenuId = 'character_context_menu';
 | 
						|
    static characterClass = 'character_select';
 | 
						|
    static groupClass = 'group_select';
 | 
						|
    static bogusFolderClass = 'bogus_folder_select';
 | 
						|
    static selectModeClass = 'group_overlay_mode_select';
 | 
						|
    static selectedClass = 'character_selected';
 | 
						|
    static legacySelectedClass = 'bulk_select_checkbox';
 | 
						|
    static bulkSelectedCountId = 'bulkSelectedCount';
 | 
						|
 | 
						|
    static longPressDelay = 2500;
 | 
						|
 | 
						|
    #state = BulkEditOverlayState.browse;
 | 
						|
    #longPress = false;
 | 
						|
    #stateChangeCallbacks = [];
 | 
						|
    #selectedCharacters = [];
 | 
						|
    #bulkTagPopupHandler = new BulkTagPopupHandler();
 | 
						|
 | 
						|
    /**
 | 
						|
     * @typedef {object} LastSelected - An object noting the last selected character and its state.
 | 
						|
     * @property {number} [characterId] - The character id of the last selected character.
 | 
						|
     * @property {boolean} [select] - The selected state of the last selected character. <c>true</c> if it was selected, <c>false</c> if it was deselected.
 | 
						|
     */
 | 
						|
 | 
						|
    /**
 | 
						|
     * @type {LastSelected} - An object noting the last selected character and its state.
 | 
						|
     */
 | 
						|
    lastSelected = { characterId: undefined, select: undefined };
 | 
						|
 | 
						|
    /**
 | 
						|
     * Locks other pointer actions when the context menu is open
 | 
						|
     *
 | 
						|
     * @type {boolean}
 | 
						|
     */
 | 
						|
    #contextMenuOpen = false;
 | 
						|
 | 
						|
    /**
 | 
						|
     * Whether the next character select should be skipped
 | 
						|
     *
 | 
						|
     * @type {boolean}
 | 
						|
     */
 | 
						|
    #cancelNextToggle = false;
 | 
						|
 | 
						|
    /**
 | 
						|
     * @type HTMLElement
 | 
						|
     */
 | 
						|
    container = null;
 | 
						|
 | 
						|
    get state() {
 | 
						|
        return this.#state;
 | 
						|
    }
 | 
						|
 | 
						|
    set state(newState) {
 | 
						|
        if (this.#state === newState) return;
 | 
						|
 | 
						|
        eventSource.emit(event_types.CHARACTER_GROUP_OVERLAY_STATE_CHANGE_BEFORE, newState)
 | 
						|
            .then(() => {
 | 
						|
                this.#state = newState;
 | 
						|
                eventSource.emit(event_types.CHARACTER_GROUP_OVERLAY_STATE_CHANGE_AFTER, this.state);
 | 
						|
            });
 | 
						|
    }
 | 
						|
 | 
						|
    get isLongPress() {
 | 
						|
        return this.#longPress;
 | 
						|
    }
 | 
						|
 | 
						|
    set isLongPress(longPress) {
 | 
						|
        this.#longPress = longPress;
 | 
						|
    }
 | 
						|
 | 
						|
    get stateChangeCallbacks() {
 | 
						|
        return this.#stateChangeCallbacks;
 | 
						|
    }
 | 
						|
 | 
						|
    /**
 | 
						|
     *
 | 
						|
     * @returns {number[]}
 | 
						|
     */
 | 
						|
    get selectedCharacters() {
 | 
						|
        return this.#selectedCharacters;
 | 
						|
    }
 | 
						|
 | 
						|
    /**
 | 
						|
     * The instance of the bulk tag popup handler that handles tagging of all selected characters
 | 
						|
     *
 | 
						|
     * @returns {BulkTagPopupHandler}
 | 
						|
     */
 | 
						|
    get bulkTagPopupHandler() {
 | 
						|
        return this.#bulkTagPopupHandler;
 | 
						|
    }
 | 
						|
 | 
						|
    constructor() {
 | 
						|
        if (bulkEditOverlayInstance instanceof BulkEditOverlay)
 | 
						|
            return bulkEditOverlayInstance;
 | 
						|
 | 
						|
        this.container = document.getElementById(BulkEditOverlay.containerId);
 | 
						|
 | 
						|
        eventSource.on(event_types.CHARACTER_GROUP_OVERLAY_STATE_CHANGE_AFTER, this.handleStateChange);
 | 
						|
        bulkEditOverlayInstance = Object.freeze(this);
 | 
						|
    }
 | 
						|
 | 
						|
    /**
 | 
						|
     * Set the overlay to browse mode
 | 
						|
     */
 | 
						|
    browseState = () => this.state = BulkEditOverlayState.browse;
 | 
						|
 | 
						|
    /**
 | 
						|
     * Set the overlay to select mode
 | 
						|
     */
 | 
						|
    selectState = () => this.state = BulkEditOverlayState.select;
 | 
						|
 | 
						|
    /**
 | 
						|
     * Set up a Sortable grid for the loaded page
 | 
						|
     */
 | 
						|
    onPageLoad = () => {
 | 
						|
        this.browseState();
 | 
						|
 | 
						|
        const elements = this.#getEnabledElements();
 | 
						|
        elements.forEach(element => element.addEventListener('touchstart', this.handleHold));
 | 
						|
        elements.forEach(element => element.addEventListener('mousedown', this.handleHold));
 | 
						|
        elements.forEach(element => element.addEventListener('contextmenu', this.handleDefaultContextMenu));
 | 
						|
 | 
						|
        elements.forEach(element => element.addEventListener('touchend', this.handleLongPressEnd));
 | 
						|
        elements.forEach(element => element.addEventListener('mouseup', this.handleLongPressEnd));
 | 
						|
        elements.forEach(element => element.addEventListener('dragend', this.handleLongPressEnd));
 | 
						|
        elements.forEach(element => element.addEventListener('touchmove', this.handleLongPressEnd));
 | 
						|
 | 
						|
        // Cohee: It only triggers when clicking on a margin between the elements?
 | 
						|
        // Feel free to fix or remove this, I'm not sure how to.
 | 
						|
        //this.container.addEventListener('click', this.handleCancelClick);
 | 
						|
    };
 | 
						|
 | 
						|
    /**
 | 
						|
     * Handle state changes
 | 
						|
     *
 | 
						|
     *
 | 
						|
     */
 | 
						|
    handleStateChange = () => {
 | 
						|
        switch (this.state) {
 | 
						|
            case BulkEditOverlayState.browse:
 | 
						|
                this.container.classList.remove(BulkEditOverlay.selectModeClass);
 | 
						|
                this.#contextMenuOpen = false;
 | 
						|
                this.#enableClickEventsForCharacters();
 | 
						|
                this.#enableClickEventsForGroups();
 | 
						|
                this.clearSelectedCharacters();
 | 
						|
                this.disableContextMenu();
 | 
						|
                this.#disableBulkEditButtonHighlight();
 | 
						|
                CharacterContextMenu.hide();
 | 
						|
                break;
 | 
						|
            case BulkEditOverlayState.select:
 | 
						|
                this.container.classList.add(BulkEditOverlay.selectModeClass);
 | 
						|
                this.#disableClickEventsForCharacters();
 | 
						|
                this.#disableClickEventsForGroups();
 | 
						|
                this.enableContextMenu();
 | 
						|
                this.#enableBulkEditButtonHighlight();
 | 
						|
                break;
 | 
						|
        }
 | 
						|
 | 
						|
        this.stateChangeCallbacks.forEach(callback => callback(this.state));
 | 
						|
    };
 | 
						|
 | 
						|
    /**
 | 
						|
     * Block the browsers native context menu and
 | 
						|
     * set a click event to hide the custom context menu.
 | 
						|
     */
 | 
						|
    enableContextMenu = () => {
 | 
						|
        this.container.addEventListener('contextmenu', this.handleContextMenuShow);
 | 
						|
        document.addEventListener('click', this.handleContextMenuHide);
 | 
						|
    };
 | 
						|
 | 
						|
    /**
 | 
						|
     * Remove event listeners, allowing the native browser context
 | 
						|
     * menu to be opened.
 | 
						|
     */
 | 
						|
    disableContextMenu = () => {
 | 
						|
        this.container.removeEventListener('contextmenu', this.handleContextMenuShow);
 | 
						|
        document.removeEventListener('click', this.handleContextMenuHide);
 | 
						|
    };
 | 
						|
 | 
						|
    handleDefaultContextMenu = (event) => {
 | 
						|
        if (this.isLongPress) {
 | 
						|
            event.preventDefault();
 | 
						|
            event.stopPropagation();
 | 
						|
            return false;
 | 
						|
        }
 | 
						|
    };
 | 
						|
 | 
						|
    /**
 | 
						|
     * Opens menu on long-press.
 | 
						|
     *
 | 
						|
     * @param event - Pointer event
 | 
						|
     */
 | 
						|
    handleHold = (event) => {
 | 
						|
        if (0 !== event.button && event.type !== 'touchstart') return;
 | 
						|
        if (this.#contextMenuOpen) {
 | 
						|
            this.#contextMenuOpen = false;
 | 
						|
            this.#cancelNextToggle = true;
 | 
						|
            CharacterContextMenu.hide();
 | 
						|
            return;
 | 
						|
        }
 | 
						|
 | 
						|
        let cancel = false;
 | 
						|
 | 
						|
        const cancelHold = (event) => cancel = true;
 | 
						|
        this.container.addEventListener('mouseup', cancelHold);
 | 
						|
        this.container.addEventListener('touchend', cancelHold);
 | 
						|
 | 
						|
        this.isLongPress = true;
 | 
						|
 | 
						|
        setTimeout(() => {
 | 
						|
            if (this.isLongPress && !cancel) {
 | 
						|
                if (this.state === BulkEditOverlayState.browse) {
 | 
						|
                    this.selectState();
 | 
						|
                } else if (this.state === BulkEditOverlayState.select) {
 | 
						|
                    this.#contextMenuOpen = true;
 | 
						|
                    CharacterContextMenu.show(...this.#getContextMenuPosition(event));
 | 
						|
                }
 | 
						|
            }
 | 
						|
 | 
						|
            this.container.removeEventListener('mouseup', cancelHold);
 | 
						|
            this.container.removeEventListener('touchend', cancelHold);
 | 
						|
        }, BulkEditOverlay.longPressDelay);
 | 
						|
    };
 | 
						|
 | 
						|
    handleLongPressEnd = (event) => {
 | 
						|
        this.isLongPress = false;
 | 
						|
        if (this.#contextMenuOpen) event.stopPropagation();
 | 
						|
    };
 | 
						|
 | 
						|
    handleCancelClick = () => {
 | 
						|
        if (false === this.#contextMenuOpen) this.state = BulkEditOverlayState.browse;
 | 
						|
        this.#contextMenuOpen = false;
 | 
						|
    };
 | 
						|
 | 
						|
    /**
 | 
						|
     * Returns the position of the mouse/touch location
 | 
						|
     *
 | 
						|
     * @param event
 | 
						|
     * @returns {(boolean|number|*)[]}
 | 
						|
     */
 | 
						|
    #getContextMenuPosition = (event) => [
 | 
						|
        event.clientX || event.touches[0].clientX,
 | 
						|
        event.clientY || event.touches[0].clientY,
 | 
						|
    ];
 | 
						|
 | 
						|
    #stopEventPropagation = (event) => {
 | 
						|
        if (this.#contextMenuOpen) {
 | 
						|
            this.handleContextMenuHide(event);
 | 
						|
        }
 | 
						|
        event.stopPropagation();
 | 
						|
    };
 | 
						|
 | 
						|
    #enableClickEventsForGroups = () => this.#getDisabledElements().forEach((element) => element.removeEventListener('click', this.#stopEventPropagation));
 | 
						|
 | 
						|
    #disableClickEventsForGroups = () => this.#getDisabledElements().forEach((element) => element.addEventListener('click', this.#stopEventPropagation));
 | 
						|
 | 
						|
    #enableClickEventsForCharacters = () => this.#getEnabledElements().forEach(element => element.removeEventListener('click', this.toggleCharacterSelected));
 | 
						|
 | 
						|
    #disableClickEventsForCharacters = () => this.#getEnabledElements().forEach(element => element.addEventListener('click', this.toggleCharacterSelected));
 | 
						|
 | 
						|
    #enableBulkEditButtonHighlight = () => document.getElementById('bulkEditButton').classList.add('bulk_edit_overlay_active');
 | 
						|
 | 
						|
    #disableBulkEditButtonHighlight = () => document.getElementById('bulkEditButton').classList.remove('bulk_edit_overlay_active');
 | 
						|
 | 
						|
    #getEnabledElements = () => [...this.container.getElementsByClassName(BulkEditOverlay.characterClass)];
 | 
						|
 | 
						|
    #getDisabledElements = () => [...this.container.getElementsByClassName(BulkEditOverlay.groupClass), ...this.container.getElementsByClassName(BulkEditOverlay.bogusFolderClass)];
 | 
						|
 | 
						|
    toggleCharacterSelected = event => {
 | 
						|
        event.stopPropagation();
 | 
						|
 | 
						|
        const character = event.currentTarget;
 | 
						|
 | 
						|
        if (!this.#contextMenuOpen && !this.#cancelNextToggle) {
 | 
						|
            if (event.shiftKey) {
 | 
						|
                // Shift click might have selected text that we don't want to. Unselect it.
 | 
						|
                document.getSelection().removeAllRanges();
 | 
						|
 | 
						|
                this.handleShiftClick(character);
 | 
						|
            } else {
 | 
						|
                this.toggleSingleCharacter(character);
 | 
						|
            }
 | 
						|
        }
 | 
						|
 | 
						|
        this.#cancelNextToggle = false;
 | 
						|
    };
 | 
						|
 | 
						|
    /**
 | 
						|
     * When shift click was held down, this function handles the multi select of characters in a single click.
 | 
						|
     *
 | 
						|
     * If the last clicked character was deselected, and the current one was deselected too, it will deselect all currently selected characters between those two.
 | 
						|
     * If the last clicked character was selected, and the current one was selected too, it will select all currently not selected characters between those two.
 | 
						|
     * If the states do not match, nothing will happen.
 | 
						|
     *
 | 
						|
     * @param {HTMLElement} currentCharacter - The html element of the currently toggled character
 | 
						|
     */
 | 
						|
    handleShiftClick = (currentCharacter) => {
 | 
						|
        const characterId = Number(currentCharacter.getAttribute('data-chid'));
 | 
						|
        const select = !this.selectedCharacters.includes(characterId);
 | 
						|
 | 
						|
        if (this.lastSelected.characterId >= 0 && this.lastSelected.select !== undefined) {
 | 
						|
            // Only if select state and the last select state match we execute the range select
 | 
						|
            if (select === this.lastSelected.select) {
 | 
						|
                this.toggleCharactersInRange(currentCharacter, select);
 | 
						|
            }
 | 
						|
        }
 | 
						|
    };
 | 
						|
 | 
						|
    /**
 | 
						|
     * Toggles the selection of a given characters
 | 
						|
     *
 | 
						|
     * @param {HTMLElement} character - The html element of a character
 | 
						|
     * @param {object} param1 - Optional params
 | 
						|
     * @param {boolean} [param1.markState] - Whether the toggle of this character should be remembered as the last done toggle
 | 
						|
     */
 | 
						|
    toggleSingleCharacter = (character, { markState = true } = {}) => {
 | 
						|
        const characterId = Number(character.getAttribute('data-chid'));
 | 
						|
 | 
						|
        const select = !this.selectedCharacters.includes(characterId);
 | 
						|
        const legacyBulkEditCheckbox = character.querySelector('.' + BulkEditOverlay.legacySelectedClass);
 | 
						|
 | 
						|
        if (select) {
 | 
						|
            character.classList.add(BulkEditOverlay.selectedClass);
 | 
						|
            if (legacyBulkEditCheckbox) legacyBulkEditCheckbox.checked = true;
 | 
						|
            this.#selectedCharacters.push(characterId);
 | 
						|
        } else {
 | 
						|
            character.classList.remove(BulkEditOverlay.selectedClass);
 | 
						|
            if (legacyBulkEditCheckbox) legacyBulkEditCheckbox.checked = false;
 | 
						|
            this.#selectedCharacters = this.#selectedCharacters.filter(item => characterId !== item);
 | 
						|
        }
 | 
						|
 | 
						|
        this.updateSelectedCount();
 | 
						|
 | 
						|
        if (markState) {
 | 
						|
            this.lastSelected.characterId = characterId;
 | 
						|
            this.lastSelected.select = select;
 | 
						|
        }
 | 
						|
    };
 | 
						|
 | 
						|
    /**
 | 
						|
     * Updates the selected count element with the current count
 | 
						|
     *
 | 
						|
     * @param {number} [countOverride] - optional override for a manual number to set
 | 
						|
     */
 | 
						|
    updateSelectedCount = (countOverride = undefined) => {
 | 
						|
        const count = countOverride ?? this.selectedCharacters.length;
 | 
						|
        $(`#${BulkEditOverlay.bulkSelectedCountId}`).text(count).attr('title', `${count} characters selected`);
 | 
						|
    };
 | 
						|
 | 
						|
    /**
 | 
						|
     * Toggles the selection of characters in a given range.
 | 
						|
     * The range is provided by the given character and the last selected one remembered in the selection state.
 | 
						|
     *
 | 
						|
     * @param {HTMLElement} currentCharacter - The html element of the currently toggled character
 | 
						|
     * @param {boolean} select - <c>true</c> if the characters in the range are to be selected, <c>false</c> if deselected
 | 
						|
     */
 | 
						|
    toggleCharactersInRange = (currentCharacter, select) => {
 | 
						|
        const currentCharacterId = Number(currentCharacter.getAttribute('data-chid'));
 | 
						|
        const characters = Array.from(document.querySelectorAll('#' + BulkEditOverlay.containerId + ' .' + BulkEditOverlay.characterClass));
 | 
						|
 | 
						|
        const startIndex = characters.findIndex(c => Number(c.getAttribute('data-chid')) === Number(this.lastSelected.characterId));
 | 
						|
        const endIndex = characters.findIndex(c => Number(c.getAttribute('data-chid')) === currentCharacterId);
 | 
						|
 | 
						|
        for (let i = Math.min(startIndex, endIndex); i <= Math.max(startIndex, endIndex); i++) {
 | 
						|
            const character = characters[i];
 | 
						|
            const characterId = Number(character.getAttribute('data-chid'));
 | 
						|
            const isCharacterSelected = this.selectedCharacters.includes(characterId);
 | 
						|
 | 
						|
            // Only toggle the character if it wasn't on the state we have are toggling towards.
 | 
						|
            // Also doing a weird type check, because typescript checker doesn't like the return of 'querySelectorAll'.
 | 
						|
            if ((select && !isCharacterSelected || !select && isCharacterSelected) && character instanceof HTMLElement) {
 | 
						|
                this.toggleSingleCharacter(character, { markState: currentCharacterId == characterId });
 | 
						|
            }
 | 
						|
        }
 | 
						|
    };
 | 
						|
 | 
						|
    handleContextMenuShow = (event) => {
 | 
						|
        event.preventDefault();
 | 
						|
        CharacterContextMenu.show(...this.#getContextMenuPosition(event));
 | 
						|
        this.#contextMenuOpen = true;
 | 
						|
    };
 | 
						|
 | 
						|
    handleContextMenuHide = (event) => {
 | 
						|
        let contextMenu = document.getElementById(BulkEditOverlay.contextMenuId);
 | 
						|
        if (false === contextMenu.contains(event.target)) {
 | 
						|
            CharacterContextMenu.hide();
 | 
						|
        }
 | 
						|
    };
 | 
						|
 | 
						|
    /**
 | 
						|
     * Concurrently handle character favorite requests.
 | 
						|
     *
 | 
						|
     * @returns {Promise<void>}
 | 
						|
     */
 | 
						|
    handleContextMenuFavorite = async () => {
 | 
						|
        const promises = [];
 | 
						|
 | 
						|
        for (const characterId of this.selectedCharacters) {
 | 
						|
            promises.push(CharacterContextMenu.favorite(characterId));
 | 
						|
        }
 | 
						|
 | 
						|
        await Promise.allSettled(promises);
 | 
						|
        await getCharacters();
 | 
						|
        await favsToHotswap();
 | 
						|
        this.browseState();
 | 
						|
    };
 | 
						|
 | 
						|
    /**
 | 
						|
     * Concurrently handle character duplicate requests.
 | 
						|
     *
 | 
						|
     * @returns {Promise<number>}
 | 
						|
     */
 | 
						|
    handleContextMenuDuplicate = () => Promise.all(this.selectedCharacters.map(async characterId => CharacterContextMenu.duplicate(characterId)))
 | 
						|
        .then(() => getCharacters())
 | 
						|
        .then(() => this.browseState());
 | 
						|
 | 
						|
    /**
 | 
						|
     * Sequentially handle all character-to-persona conversions.
 | 
						|
     *
 | 
						|
     * @returns {Promise<void>}
 | 
						|
     */
 | 
						|
    handleContextMenuPersona = async () => {
 | 
						|
        for (const characterId of this.selectedCharacters) {
 | 
						|
            await CharacterContextMenu.persona(characterId);
 | 
						|
        }
 | 
						|
 | 
						|
        this.browseState();
 | 
						|
    };
 | 
						|
 | 
						|
    /**
 | 
						|
     * Gets the HTML as a string that is displayed inside the popup for the bulk delete
 | 
						|
     *
 | 
						|
     * @param {Array<number>} characterIds - The characters that are shown inside the popup
 | 
						|
     * @returns String containing the html for the popup content
 | 
						|
     */
 | 
						|
    static #getDeletePopupContentHtml = (characterIds) => {
 | 
						|
        return `
 | 
						|
            <h3 class="marginBot5">Delete ${characterIds.length} characters?</h3>
 | 
						|
            <span class="bulk_delete_note">
 | 
						|
                <i class="fa-solid fa-triangle-exclamation warning margin-r5"></i>
 | 
						|
                <b>THIS IS PERMANENT!</b>
 | 
						|
            </span>
 | 
						|
            <div id="bulk_delete_avatars_block" class="avatars_inline avatars_inline_small tags tags_inline m-t-1"></div>
 | 
						|
            <br>
 | 
						|
            <div id="bulk_delete_options" class="m-b-1">
 | 
						|
                <label for="del_char_checkbox" class="checkbox_label justifyCenter">
 | 
						|
                    <input type="checkbox" id="del_char_checkbox" />
 | 
						|
                    <span>Also delete the chat files</span>
 | 
						|
                </label>
 | 
						|
            </div>`;
 | 
						|
    };
 | 
						|
 | 
						|
    /**
 | 
						|
     * Request user input before concurrently handle deletion
 | 
						|
     * requests.
 | 
						|
     *
 | 
						|
     * @returns {Promise<number>}
 | 
						|
     */
 | 
						|
    handleContextMenuDelete = () => {
 | 
						|
        const characterIds = this.selectedCharacters;
 | 
						|
        const popupContent = BulkEditOverlay.#getDeletePopupContentHtml(characterIds);
 | 
						|
        const promise = callPopup(popupContent, null)
 | 
						|
            .then((accept) => {
 | 
						|
                if (true !== accept) return;
 | 
						|
 | 
						|
                const deleteChats = document.getElementById('del_char_checkbox').checked ?? false;
 | 
						|
 | 
						|
                showLoader();
 | 
						|
                const toast = toastr.info('We\'re deleting your characters, please wait...', 'Working on it');
 | 
						|
                const avatarList = characterIds.map(id => characters[id]?.avatar).filter(a => a);
 | 
						|
                return CharacterContextMenu.delete(avatarList, deleteChats)
 | 
						|
                    .then(() => this.browseState())
 | 
						|
                    .finally(() => {
 | 
						|
                        toastr.clear(toast);
 | 
						|
                        hideLoader();
 | 
						|
                    });
 | 
						|
            });
 | 
						|
 | 
						|
        // At this moment the popup is already changed in the dom, but not yet closed/resolved. We build the avatar list here
 | 
						|
        const entities = characterIds.map(id => characterToEntity(characters[id], id)).filter(entity => entity.item !== undefined);
 | 
						|
        buildAvatarList($('#bulk_delete_avatars_block'), entities);
 | 
						|
 | 
						|
        return promise;
 | 
						|
    };
 | 
						|
 | 
						|
    /**
 | 
						|
     * Attaches and opens the tag menu
 | 
						|
     */
 | 
						|
    handleContextMenuTag = () => {
 | 
						|
        CharacterContextMenu.tag(this.selectedCharacters);
 | 
						|
        this.browseState();
 | 
						|
    };
 | 
						|
 | 
						|
    addStateChangeCallback = callback => this.stateChangeCallbacks.push(callback);
 | 
						|
 | 
						|
    /**
 | 
						|
     * Clears internal character storage and
 | 
						|
     * removes visual highlight.
 | 
						|
     */
 | 
						|
    clearSelectedCharacters = () => {
 | 
						|
        document.querySelectorAll('#' + BulkEditOverlay.containerId + ' .' + BulkEditOverlay.selectedClass)
 | 
						|
            .forEach(element => element.classList.remove(BulkEditOverlay.selectedClass));
 | 
						|
        this.selectedCharacters.length = 0;
 | 
						|
    };
 | 
						|
}
 | 
						|
 | 
						|
export { BulkEditOverlayState, CharacterContextMenu, BulkEditOverlay };
 |