SillyTavern/public/scripts/BulkEditOverlay.js

873 lines
32 KiB
JavaScript
Raw Normal View History

2023-12-02 19:04:51 +01:00
'use strict';
2023-10-21 15:12:09 +02:00
import {
characterGroupOverlay,
2023-10-21 15:12:09 +02:00
callPopup,
2023-11-04 19:33:15 +01:00
characters,
deleteCharacter,
2023-10-21 15:12:09 +02:00
event_types,
eventSource,
2023-12-09 15:09:10 +01:00
getCharacters,
getPastCharacterChats,
2023-11-04 19:33:15 +01:00
getRequestHeaders,
buildAvatarList,
characterToEntity,
printCharactersDebounced,
2023-12-02 19:04:51 +01:00
} from '../script.js';
2023-11-05 18:23:14 +01:00
2023-12-02 19:04:51 +01:00
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';
2023-10-21 15:12:09 +02:00
2023-11-05 18:23:14 +01:00
/**
* Static object representing the actions of the
* character context menu override.
*/
2023-10-21 15:12:09 +02:00
class CharacterContextMenu {
2023-11-05 18:23:14 +01:00
/**
* Tag one or more characters,
* opens a popup.
*
* @param {Array<number>} selectedCharacters
2023-11-05 18:23:14 +01:00
*/
2023-11-04 19:33:15 +01:00
static tag = (selectedCharacters) => {
characterGroupOverlay.bulkTagPopupHandler.show(selectedCharacters);
2023-12-02 20:11:06 +01:00
};
2023-11-04 19:33:15 +01:00
2023-10-21 15:12:09 +02:00
/**
2023-11-05 18:23:14 +01:00
* Duplicate one or more characters
2023-10-21 15:12:09 +02:00
*
* @param {number} characterId
2024-03-28 00:13:54 +01:00
* @returns {Promise<any>}
2023-10-21 15:12:09 +02:00
*/
static duplicate = async (characterId) => {
2023-11-05 18:23:14 +01:00
const character = CharacterContextMenu.#getCharacter(characterId);
2024-03-28 00:13:54 +01:00
const body = { avatar_url: character.avatar };
2023-10-21 15:12:09 +02:00
2024-03-28 00:13:54 +01:00
const result = await fetch('/api/characters/duplicate', {
2023-10-21 15:12:09 +02:00
method: 'POST',
headers: getRequestHeaders(),
2024-03-28 00:15:14 +01:00
body: JSON.stringify(body),
2023-10-21 15:12:09 +02:00
});
2024-03-28 00:13:54 +01:00
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 });
2023-12-02 20:11:06 +01:00
};
2023-10-21 15:12:09 +02:00
/**
* Favorite a character
2023-11-05 18:23:14 +01:00
* and highlight it.
2023-10-21 15:12:09 +02:00
*
* @param {number} characterId
2023-10-21 15:12:09 +02:00
* @returns {Promise<void>}
*/
static favorite = async (characterId) => {
2023-11-05 18:23:14 +01:00
const character = CharacterContextMenu.#getCharacter(characterId);
2023-12-06 00:56:07 +01:00
const newFavState = !character.data.extensions.fav;
2023-11-05 18:23:14 +01:00
2023-10-21 15:12:09 +02:00
const data = {
name: character.name,
avatar: character.avatar,
data: {
extensions: {
2023-12-06 00:56:07 +01:00
fav: newFavState,
2023-12-02 21:06:57 +01:00
},
},
2023-12-06 00:56:07 +01:00
fav: newFavState,
2023-10-21 15:12:09 +02:00
};
2023-12-06 00:56:07 +01:00
const mergeResponse = await fetch('/api/characters/merge-attributes', {
2023-12-02 19:04:51 +01:00
method: 'POST',
2023-10-21 15:12:09 +02:00
headers: getRequestHeaders(),
body: JSON.stringify(data),
});
2023-12-06 00:56:07 +01:00
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');
2023-12-02 20:11:06 +01:00
};
2023-10-21 15:12:09 +02:00
/**
2023-11-05 18:23:14 +01:00
* 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);
2023-10-21 15:12:09 +02:00
2023-11-05 18:23:14 +01:00
/**
* Delete one or more characters,
* opens a popup.
*
* @param {number} characterId
* @param {boolean} [deleteChats]
2023-11-05 18:23:14 +01:00
* @returns {Promise<void>}
*/
2023-10-21 15:12:09 +02:00
static delete = async (characterId, deleteChats = false) => {
2023-11-05 18:23:14 +01:00
const character = CharacterContextMenu.#getCharacter(characterId);
2023-10-21 15:12:09 +02:00
return fetch('/api/characters/delete', {
2023-10-21 15:12:09 +02:00
method: 'POST',
headers: getRequestHeaders(),
2023-12-09 15:09:10 +01:00
body: JSON.stringify({ avatar_url: character.avatar, delete_chats: deleteChats }),
2023-10-21 15:12:09 +02:00
cache: 'no-cache',
}).then(response => {
if (response.ok) {
2024-03-17 19:35:49 +01:00
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);
}
});
2023-12-02 20:11:06 +01:00
});
2023-10-21 15:12:09 +02:00
}
});
2023-12-02 20:11:06 +01:00
};
2023-10-21 15:12:09 +02:00
2023-11-05 18:23:14 +01:00
static #getCharacter = (characterId) => characters[characterId] ?? null;
2023-10-21 15:12:09 +02:00
2023-11-05 18:23:14 +01:00
/**
* Show the context menu at the given position
*
* @param positionX
* @param positionY
*/
2023-10-21 15:12:09 +02:00
static show = (positionX, positionY) => {
let contextMenu = document.getElementById(BulkEditOverlay.contextMenuId);
2023-10-21 15:12:09 +02:00
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`;
}
2023-12-02 20:11:06 +01:00
};
2023-10-21 15:12:09 +02:00
2023-11-05 18:23:14 +01:00
/**
* Hide the context menu
*/
static hide = () => document.getElementById(BulkEditOverlay.contextMenuId).classList.add('hidden');
2023-10-21 15:12:09 +02:00
2023-11-05 18:23:14 +01:00
/**
* Sets up the context menu for the given overlay
*
* @param characterGroupOverlay
*/
2023-10-21 15:12:09 +02:00
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 },
2023-12-02 21:06:57 +01:00
{ id: 'character_context_menu_tag', callback: characterGroupOverlay.handleContextMenuTag },
2023-10-21 15:12:09 +02:00
];
2023-12-02 20:11:06 +01:00
contextMenuItems.forEach(contextMenuItem => document.getElementById(contextMenuItem.id).addEventListener('click', contextMenuItem.callback));
2023-10-21 15:12:09 +02:00
}
}
2023-11-04 19:33:15 +01:00
/**
2023-11-05 18:23:14 +01:00
* Represents a tag control not bound to a single character
2023-11-04 19:33:15 +01:00
*/
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 });
2023-11-04 19:33:15 +01:00
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>
2023-11-04 19:33:15 +01:00
<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>
2023-11-04 19:33:15 +01:00
<div id="bulk_tag_popup_cancel" class="menu_button" data-i18n="Cancel">Close</div>
</div>
</div>
</div>
</div>`;
2023-11-04 19:33:15 +01:00
};
2023-11-05 18:23:14 +01:00
/**
* Append and show the tag control
*
* @param {number[]} characterIds - The characters that are shown inside the popup
2023-11-05 18:23:14 +01:00
*/
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
2024-04-12 13:22:12 +02:00
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));
2023-11-04 19:33:15 +01:00
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) =>
2024-04-12 13:22:12 +02:00
mutual.filter(tag => characterTags.some(cTag => cTag.id === tag.id)),
);
this.currentMutualTags = mutualTags.sort(compareTagsForSort);
return this.currentMutualTags;
2023-11-04 19:33:15 +01:00
}
2023-11-05 18:23:14 +01:00
/**
* Hide and remove the tag control
*/
hide() {
2023-11-04 19:33:15 +01:00
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
2023-11-04 19:33:15 +01:00
}
2023-11-05 18:23:14 +01:00
/**
* Empty the tag map for the given characters
*/
resetTags() {
for (const characterId of this.characterIds) {
const key = getTagKeyForEntity(characterId);
2023-11-04 19:33:15 +01:00
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();
2023-11-04 19:33:15 +01:00
}
}
2023-11-06 17:20:18 +01:00
class BulkEditOverlayState {
2023-11-05 18:23:14 +01:00
/**
*
* @type {number}
*/
static browse = 0;
/**
*
* @type {number}
*/
static select = 1;
}
2023-11-04 19:33:15 +01:00
/**
* Implement a SingletonPattern, allowing access to the group overlay instance
* from everywhere via (new CharacterGroupOverlay())
*
* @type BulkEditOverlay
*/
let bulkEditOverlayInstance = null;
class BulkEditOverlay {
2023-10-21 15:12:09 +02:00
static containerId = 'rm_print_characters_block';
static contextMenuId = 'character_context_menu';
static characterClass = 'character_select';
static groupClass = 'group_select';
2023-11-10 21:18:48 +01:00
static bogusFolderClass = 'bogus_folder_select';
2023-10-21 15:12:09 +02:00
static selectModeClass = 'group_overlay_mode_select';
static selectedClass = 'character_selected';
static legacySelectedClass = 'bulk_select_checkbox';
static bulkSelectedCountId = 'bulkSelectedCount';
2023-10-21 15:12:09 +02:00
static longPressDelay = 2500;
2023-11-06 15:31:27 +01:00
2023-11-06 17:20:18 +01:00
#state = BulkEditOverlayState.browse;
2023-10-21 15:12:09 +02:00
#longPress = false;
#stateChangeCallbacks = [];
#selectedCharacters = [];
#bulkTagPopupHandler = new BulkTagPopupHandler();
2023-10-21 15:12:09 +02:00
/**
* @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 };
/**
2023-11-09 15:24:24 +01:00
* 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;
2023-10-21 15:12:09 +02:00
/**
* @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;
2023-12-02 20:11:06 +01:00
eventSource.emit(event_types.CHARACTER_GROUP_OVERLAY_STATE_CHANGE_AFTER, this.state);
2023-10-21 15:12:09 +02:00
});
}
get isLongPress() {
return this.#longPress;
}
set isLongPress(longPress) {
this.#longPress = longPress;
}
get stateChangeCallbacks() {
return this.#stateChangeCallbacks;
}
2023-11-04 19:33:15 +01:00
/**
*
* @returns {number[]}
2023-11-04 19:33:15 +01:00
*/
2023-10-21 15:12:09 +02:00
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;
}
2023-10-21 15:12:09 +02:00
constructor() {
2023-11-04 19:33:15 +01:00
if (bulkEditOverlayInstance instanceof BulkEditOverlay)
2023-12-02 20:11:06 +01:00
return bulkEditOverlayInstance;
2023-10-21 15:12:09 +02:00
this.container = document.getElementById(BulkEditOverlay.containerId);
2023-10-21 15:12:09 +02:00
eventSource.on(event_types.CHARACTER_GROUP_OVERLAY_STATE_CHANGE_AFTER, this.handleStateChange);
2023-11-04 19:33:15 +01:00
bulkEditOverlayInstance = Object.freeze(this);
2023-10-21 15:12:09 +02:00
}
2023-11-05 18:23:14 +01:00
/**
* Set the overlay to browse mode
*/
2023-11-06 17:20:18 +01:00
browseState = () => this.state = BulkEditOverlayState.browse;
2023-11-05 18:23:14 +01:00
/**
* Set the overlay to select mode
*/
2023-11-06 17:20:18 +01:00
selectState = () => this.state = BulkEditOverlayState.select;
2023-10-21 15:12:09 +02:00
/**
* Set up a Sortable grid for the loaded page
*/
onPageLoad = () => {
this.browseState();
2023-10-21 15:12:09 +02:00
2023-11-06 15:31:27 +01:00
const elements = this.#getEnabledElements();
2023-10-21 15:12:09 +02:00
elements.forEach(element => element.addEventListener('touchstart', this.handleHold));
elements.forEach(element => element.addEventListener('mousedown', this.handleHold));
elements.forEach(element => element.addEventListener('contextmenu', this.handleDefaultContextMenu));
2023-10-21 15:12:09 +02:00
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));
2023-10-21 15:12:09 +02:00
// 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);
2023-12-02 20:11:06 +01:00
};
2023-10-21 15:12:09 +02:00
2023-11-05 18:23:14 +01:00
/**
* Handle state changes
*
*
*/
handleStateChange = () => {
switch (this.state) {
2023-11-06 17:20:18 +01:00
case BulkEditOverlayState.browse:
2023-11-05 18:23:14 +01:00
this.container.classList.remove(BulkEditOverlay.selectModeClass);
this.#contextMenuOpen = false;
2023-11-05 18:23:14 +01:00
this.#enableClickEventsForCharacters();
this.#enableClickEventsForGroups();
2023-11-05 18:23:14 +01:00
this.clearSelectedCharacters();
this.disableContextMenu();
this.#disableBulkEditButtonHighlight();
CharacterContextMenu.hide();
break;
2023-11-06 17:20:18 +01:00
case BulkEditOverlayState.select:
2023-11-05 18:23:14 +01:00
this.container.classList.add(BulkEditOverlay.selectModeClass);
this.#disableClickEventsForCharacters();
this.#disableClickEventsForGroups();
2023-11-05 18:23:14 +01:00
this.enableContextMenu();
this.#enableBulkEditButtonHighlight();
break;
}
this.stateChangeCallbacks.forEach(callback => callback(this.state));
2023-12-02 20:11:06 +01:00
};
2023-11-05 18:23:14 +01:00
2023-10-21 15:12:09 +02:00
/**
* Block the browsers native context menu and
* set a click event to hide the custom context menu.
*/
enableContextMenu = () => {
this.container.addEventListener('contextmenu', this.handleContextMenuShow);
2023-10-21 15:12:09 +02:00
document.addEventListener('click', this.handleContextMenuHide);
2023-12-02 20:11:06 +01:00
};
2023-10-21 15:12:09 +02:00
/**
* Remove event listeners, allowing the native browser context
* menu to be opened.
*/
disableContextMenu = () => {
this.container.removeEventListener('contextmenu', this.handleContextMenuShow);
2023-10-21 15:12:09 +02:00
document.removeEventListener('click', this.handleContextMenuHide);
2023-12-02 20:11:06 +01:00
};
2023-10-21 15:12:09 +02:00
handleDefaultContextMenu = (event) => {
if (this.isLongPress) {
event.preventDefault();
event.stopPropagation();
return false;
}
2023-12-02 20:11:06 +01:00
};
/**
* Opens menu on long-press.
*
* @param event - Pointer event
*/
handleHold = (event) => {
2023-11-06 15:31:27 +01:00
if (0 !== event.button && event.type !== 'touchstart') return;
if (this.#contextMenuOpen) {
this.#contextMenuOpen = false;
this.#cancelNextToggle = true;
CharacterContextMenu.hide();
return;
}
2023-11-06 15:31:27 +01:00
let cancel = false;
const cancelHold = (event) => cancel = true;
this.container.addEventListener('mouseup', cancelHold);
this.container.addEventListener('touchend', cancelHold);
2023-10-21 15:12:09 +02:00
this.isLongPress = true;
2023-10-21 15:12:09 +02:00
setTimeout(() => {
2023-12-09 15:09:10 +01:00
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));
}
2023-12-09 15:09:10 +01:00
}
2023-12-09 15:09:10 +01:00
this.container.removeEventListener('mouseup', cancelHold);
this.container.removeEventListener('touchend', cancelHold);
},
2024-04-12 13:22:12 +02:00
BulkEditOverlay.longPressDelay);
2023-12-02 20:11:06 +01:00
};
2023-10-21 15:12:09 +02:00
handleLongPressEnd = (event) => {
2023-10-21 15:12:09 +02:00
this.isLongPress = false;
if (this.#contextMenuOpen) event.stopPropagation();
2023-12-02 20:11:06 +01:00
};
2023-10-21 15:12:09 +02:00
handleCancelClick = () => {
if (false === this.#contextMenuOpen) this.state = BulkEditOverlayState.browse;
this.#contextMenuOpen = false;
2023-12-02 20:11:06 +01:00
};
2023-10-21 15:12:09 +02:00
2023-11-06 15:31:27 +01:00
/**
* 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();
2023-12-02 20:11:06 +01:00
};
#enableClickEventsForGroups = () => this.#getDisabledElements().forEach((element) => element.removeEventListener('click', this.#stopEventPropagation));
#disableClickEventsForGroups = () => this.#getDisabledElements().forEach((element) => element.addEventListener('click', this.#stopEventPropagation));
2023-11-06 15:31:27 +01:00
#enableClickEventsForCharacters = () => this.#getEnabledElements().forEach(element => element.removeEventListener('click', this.toggleCharacterSelected));
2023-11-06 15:31:27 +01:00
#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');
2023-11-06 15:31:27 +01:00
#getEnabledElements = () => [...this.container.getElementsByClassName(BulkEditOverlay.characterClass)];
2023-12-06 00:56:07 +01:00
#getDisabledElements = () => [...this.container.getElementsByClassName(BulkEditOverlay.groupClass), ...this.container.getElementsByClassName(BulkEditOverlay.bogusFolderClass)];
2023-10-21 15:12:09 +02:00
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();
2023-11-06 15:31:27 +01:00
this.handleShiftClick(character);
} else {
this.toggleSingleCharacter(character);
}
}
this.#cancelNextToggle = false;
2023-12-02 20:11:06 +01:00
};
2023-10-21 15:12:09 +02:00
/**
* 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;
2024-04-12 13:22:12 +02:00
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 });
}
}
};
2023-10-21 15:12:09 +02:00
handleContextMenuShow = (event) => {
event.preventDefault();
2023-11-06 15:31:27 +01:00
CharacterContextMenu.show(...this.#getContextMenuPosition(event));
this.#contextMenuOpen = true;
2023-12-02 20:11:06 +01:00
};
2023-10-21 15:12:09 +02:00
handleContextMenuHide = (event) => {
let contextMenu = document.getElementById(BulkEditOverlay.contextMenuId);
2023-10-21 15:12:09 +02:00
if (false === contextMenu.contains(event.target)) {
CharacterContextMenu.hide();
}
2023-12-02 20:11:06 +01:00
};
2023-10-21 15:12:09 +02:00
2023-11-06 15:31:27 +01:00
/**
* Concurrently handle character favorite requests.
*
2023-12-06 00:56:07 +01:00
* @returns {Promise<void>}
2023-11-06 15:31:27 +01:00
*/
2023-12-06 00:56:07 +01:00
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();
};
2023-10-21 15:12:09 +02:00
2023-11-06 15:31:27 +01:00
/**
* Concurrently handle character duplicate requests.
*
* @returns {Promise<number>}
*/
2023-10-21 15:12:09 +02:00
handleContextMenuDuplicate = () => Promise.all(this.selectedCharacters.map(async characterId => CharacterContextMenu.duplicate(characterId)))
.then(() => getCharacters())
2023-12-02 20:11:06 +01:00
.then(() => this.browseState());
2023-10-21 15:12:09 +02:00
/**
* Sequentially handle all character-to-persona conversions.
*
* @returns {Promise<void>}
*/
handleContextMenuPersona = async () => {
for (const characterId of this.selectedCharacters) {
2023-12-02 20:11:06 +01:00
await CharacterContextMenu.persona(characterId);
}
this.browseState();
2023-12-02 20:11:06 +01:00
};
2023-10-21 15:12:09 +02:00
/**
* 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>`;
2024-04-12 13:22:12 +02:00
};
2023-11-06 15:31:27 +01:00
/**
* Request user input before concurrently handle deletion
* requests.
*
* @returns {Promise<number>}
*/
2023-10-21 15:12:09 +02:00
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();
2023-12-02 19:04:51 +01:00
toastr.info('We\'re deleting your characters, please wait...', 'Working on it');
return Promise.allSettled(characterIds.map(async characterId => CharacterContextMenu.delete(characterId, deleteChats)))
2023-10-21 15:12:09 +02:00
.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;
2023-12-02 20:11:06 +01:00
};
2023-10-21 15:12:09 +02:00
2023-11-06 15:31:27 +01:00
/**
* Attaches and opens the tag menu
*/
2023-11-04 19:33:15 +01:00
handleContextMenuTag = () => {
CharacterContextMenu.tag(this.selectedCharacters);
this.browseState();
2023-12-02 20:11:06 +01:00
};
2023-11-04 19:33:15 +01:00
2023-10-21 15:12:09 +02:00
addStateChangeCallback = callback => this.stateChangeCallbacks.push(callback);
2023-11-05 16:57:14 +01:00
/**
* Clears internal character storage and
* removes visual highlight.
*/
2023-10-21 15:12:09 +02:00
clearSelectedCharacters = () => {
2023-11-05 16:57:14 +01:00
document.querySelectorAll('#' + BulkEditOverlay.containerId + ' .' + BulkEditOverlay.selectedClass)
.forEach(element => element.classList.remove(BulkEditOverlay.selectedClass));
2023-10-21 15:12:09 +02:00
this.selectedCharacters.length = 0;
2023-12-02 20:11:06 +01:00
};
2023-10-21 15:12:09 +02:00
}
export { BulkEditOverlayState, CharacterContextMenu, BulkEditOverlay };