mirror of
https://github.com/SillyTavern/SillyTavern.git
synced 2024-12-15 18:59:07 +01:00
873 lines
32 KiB
JavaScript
873 lines
32 KiB
JavaScript
'use strict';
|
|
|
|
import {
|
|
characterGroupOverlay,
|
|
callPopup,
|
|
characters,
|
|
deleteCharacter,
|
|
event_types,
|
|
eventSource,
|
|
getCharacters,
|
|
getPastCharacterChats,
|
|
getRequestHeaders,
|
|
buildAvatarList,
|
|
characterToEntity,
|
|
printCharactersDebounced,
|
|
} 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 } 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);
|
|
|
|
return fetch('/api/characters/delete', {
|
|
method: 'POST',
|
|
headers: getRequestHeaders(),
|
|
body: JSON.stringify({ avatar_url: character.avatar, delete_chats: deleteChats }),
|
|
cache: 'no-cache',
|
|
}).then(response => {
|
|
if (response.ok) {
|
|
eventSource.emit(event_types.CHARACTER_DELETED, { id: characterId, character: character });
|
|
return deleteCharacter(character.name, character.avatar, false).then(() => {
|
|
if (deleteChats) getPastCharacterChats(characterId).then(pastChats => {
|
|
for (const chat of pastChats) {
|
|
const name = chat.file_name.replace('.jsonl', '');
|
|
eventSource.emit(event_types.CHAT_DELETED, name);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
});
|
|
};
|
|
|
|
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">
|
|
<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.</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_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));
|
|
}
|
|
|
|
/**
|
|
* 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 };
|