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