Spaces:
Paused
Paused
| ; | |
| 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 }; | |