Spaces:
Running
Running
; | |
import { | |
characterGroupOverlay, | |
callPopup, | |
characters, | |
event_types, | |
eventSource, | |
getCharacters, | |
getPastCharacterChats, | |
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 {number} characterId | |
* @param {boolean} [deleteChats] | |
* @returns {Promise<void>} | |
*/ | |
static delete = async (characterId, deleteChats = false) => { | |
const character = CharacterContextMenu.#getCharacter(characterId); | |
await deleteCharacter(character.avatar, { 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 {string} [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 = currentCharacter.getAttribute('chid'); | |
const select = !this.selectedCharacters.includes(characterId); | |
if (this.lastSelected.characterId && 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 = character.getAttribute('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(String(characterId)); | |
} else { | |
character.classList.remove(BulkEditOverlay.selectedClass); | |
if (legacyBulkEditCheckbox) legacyBulkEditCheckbox.checked = false; | |
this.#selectedCharacters = this.#selectedCharacters.filter(item => String(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 = currentCharacter.getAttribute('chid'); | |
const characters = Array.from(document.querySelectorAll('#' + BulkEditOverlay.containerId + ' .' + BulkEditOverlay.characterClass)); | |
const startIndex = characters.findIndex(c => c.getAttribute('chid') === this.lastSelected.characterId); | |
const endIndex = characters.findIndex(c => c.getAttribute('chid') === currentCharacterId); | |
for (let i = Math.min(startIndex, endIndex); i <= Math.max(startIndex, endIndex); i++) { | |
const character = characters[i]; | |
const characterId = character.getAttribute('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(); | |
toastr.info('We\'re deleting your characters, please wait...', 'Working on it'); | |
return Promise.allSettled(characterIds.map(async characterId => CharacterContextMenu.delete(characterId, deleteChats))) | |
.then(() => getCharacters()) | |
.then(() => this.browseState()) | |
.finally(() => 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 }; | |