Blame Raw
Cohee · 51ad27fb · · 890 lines (32.8 KB)
2 contributors
1'use strict';
2
3import {
4 characterGroupOverlay,
5 characters,
6 event_types,
7 eventSource,
8 getCharacters,
9 getRequestHeaders,
10 buildAvatarList,
11 characterToEntity,
12 printCharactersDebounced,
13 deleteCharacter,
14} from '../script.js';
15
16import { favsToHotswap } from './RossAscends-mods.js';
17import { loader } from './action-loader.js';
18import { convertCharacterToPersona } from './personas.js';
19import { callGenericPopup, POPUP_TYPE } from './popup.js';
20import { createTagInput, getTagKeyForEntity, getTagsList, printTagList, tag_map, compareTagsForSort, removeTagFromMap, importTags, tag_import_setting } from './tags.js';
21import { t } from './i18n.js';
22
23/**
24 * Static object representing the actions of the
25 * character context menu override.
26 */
27class CharacterContextMenu {
28 /**
29 * Tag one or more characters,
30 * opens a popup.
31 *
32 * @param {Array<number>} selectedCharacters
33 */
34 static tag = (selectedCharacters) => {
35 characterGroupOverlay.bulkTagPopupHandler.show(selectedCharacters);
36 };
37
38 /**
39 * Duplicate one or more characters
40 *
41 * @param {number} characterId
42 * @returns {Promise<any>}
43 */
44 static duplicate = async (characterId) => {
45 const character = CharacterContextMenu.#getCharacter(characterId);
46 const body = { avatar_url: character.avatar };
47
48 const result = await fetch('/api/characters/duplicate', {
49 method: 'POST',
50 headers: getRequestHeaders(),
51 body: JSON.stringify(body),
52 });
53
54 if (!result.ok) {
55 throw new Error('Character not duplicated');
56 }
57
58 const data = await result.json();
59 await eventSource.emit(event_types.CHARACTER_DUPLICATED, { oldAvatar: body.avatar_url, newAvatar: data.path });
60 };
61
62 /**
63 * Favorite a character
64 * and highlight it.
65 *
66 * @param {number} characterId
67 * @returns {Promise<void>}
68 */
69 static favorite = async (characterId) => {
70 const character = CharacterContextMenu.#getCharacter(characterId);
71 const newFavState = !character.data.extensions.fav;
72
73 const data = {
74 name: character.name,
75 avatar: character.avatar,
76 data: {
77 extensions: {
78 fav: newFavState,
79 },
80 },
81 fav: newFavState,
82 };
83
84 const mergeResponse = await fetch('/api/characters/merge-attributes', {
85 method: 'POST',
86 headers: getRequestHeaders(),
87 body: JSON.stringify(data),
88 });
89
90 if (!mergeResponse.ok) {
91 mergeResponse.json().then(json => toastr.error(`Character not saved. Error: ${json.message}. Field: ${json.error}`));
92 }
93
94 const element = document.getElementById(`CharID${characterId}`);
95 element.classList.toggle('is_fav');
96 };
97
98 /**
99 * Convert one or more characters to persona,
100 * may open a popup for one or more characters.
101 *
102 * @param {number} characterId
103 * @returns {Promise<void>}
104 */
105 static persona = async (characterId) => void (await convertCharacterToPersona(characterId));
106
107 /**
108 * Delete one or more characters,
109 * opens a popup.
110 *
111 * @param {string|string[]} characterKey
112 * @param {boolean} [deleteChats]
113 * @returns {Promise<void>}
114 */
115 static delete = async (characterKey, deleteChats = false) => {
116 await deleteCharacter(characterKey, { deleteChats: deleteChats });
117 };
118
119 static #getCharacter = (characterId) => characters[characterId] ?? null;
120
121 /**
122 * Show the context menu at the given position
123 *
124 * @param positionX
125 * @param positionY
126 */
127 static show = (positionX, positionY) => {
128 let contextMenu = document.getElementById(BulkEditOverlay.contextMenuId);
129 contextMenu.style.left = `${positionX}px`;
130 contextMenu.style.top = `${positionY}px`;
131
132 document.getElementById(BulkEditOverlay.contextMenuId).classList.remove('hidden');
133
134 // Adjust position if context menu is outside of viewport
135 const boundingRect = contextMenu.getBoundingClientRect();
136 if (boundingRect.right > window.innerWidth) {
137 contextMenu.style.left = `${positionX - (boundingRect.right - window.innerWidth)}px`;
138 }
139 if (boundingRect.bottom > window.innerHeight) {
140 contextMenu.style.top = `${positionY - (boundingRect.bottom - window.innerHeight)}px`;
141 }
142 };
143
144 /**
145 * Hide the context menu
146 */
147 static hide = () => document.getElementById(BulkEditOverlay.contextMenuId).classList.add('hidden');
148
149 /**
150 * Sets up the context menu for the given overlay
151 *
152 * @param characterGroupOverlay
153 */
154 constructor(characterGroupOverlay) {
155 const contextMenuItems = [
156 { id: 'character_context_menu_favorite', callback: characterGroupOverlay.handleContextMenuFavorite },
157 { id: 'character_context_menu_duplicate', callback: characterGroupOverlay.handleContextMenuDuplicate },
158 { id: 'character_context_menu_delete', callback: characterGroupOverlay.handleContextMenuDelete },
159 { id: 'character_context_menu_persona', callback: characterGroupOverlay.handleContextMenuPersona },
160 { id: 'character_context_menu_tag', callback: characterGroupOverlay.handleContextMenuTag },
161 ];
162
163 contextMenuItems.forEach(contextMenuItem => document.getElementById(contextMenuItem.id).addEventListener('click', contextMenuItem.callback));
164 }
165}
166
167/**
168 * Represents a tag control not bound to a single character
169 */
170class BulkTagPopupHandler {
171 /**
172 * The characters for this popup
173 * @type {number[]}
174 */
175 characterIds;
176
177 /**
178 * A storage of the current mutual tags, as calculated by getMutualTags()
179 * @type {object[]}
180 */
181 currentMutualTags;
182
183 /**
184 * Sets up the bulk popup menu handler for the given overlay.
185 *
186 * Characters can be passed in with the show() call.
187 */
188 constructor() { }
189
190 /**
191 * Gets the HTML as a string that is going to be the popup for the bulk tag edit
192 *
193 * @returns String containing the html for the popup
194 */
195 #getHtml = () => {
196 const characterData = JSON.stringify({ characterIds: this.characterIds });
197 return `<div id="bulk_tag_shadow_popup">
198 <div id="bulk_tag_popup" class="wider_dialogue_popup">
199 <div id="bulk_tag_popup_holder">
200 <h3 class="marginBot5">Modify tags of ${this.characterIds.length} characters</h3>
201 <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>
202 <div id="bulk_tags_avatars_block" class="avatars_inline avatars_inline_small tags tags_inline"></div>
203 <br>
204 <div id="bulk_tags_div" class="marginBot5" data-characters='${characterData}'>
205 <div class="tag_controls">
206 <input id="bulkTagInput" class="text_pole tag_input wide100p margin0" data-i18n="[placeholder]Search / Create Tags" placeholder="Search / Create tags" maxlength="25" />
207 <div class="tags_view menu_button fa-solid fa-tags" title="View all tags" data-i18n="[title]View all tags"></div>
208 </div>
209 <div id="bulkTagList" class="m-t-1 tags"></div>
210 </div>
211 <div id="dialogue_popup_controls" class="m-t-1">
212 <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">
213 <i class="fa-solid fa-trash-can margin-right-10px"></i>
214 All
215 </div>
216 <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">
217 <i class="fa-solid fa-trash-can margin-right-10px"></i>
218 Mutual
219 </div>
220 <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">
221 Import All
222 </div>
223 <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">
224 Import Existing
225 </div>
226 <div id="bulk_tag_popup_cancel" class="menu_button" data-i18n="Cancel">Close</div>
227 </div>
228 </div>
229 </div>
230 </div>`;
231 };
232
233 /**
234 * Append and show the tag control
235 *
236 * @param {number[]} characterIds - The characters that are shown inside the popup
237 */
238 show(characterIds) {
239 // shallow copy character ids persistently into this tooltip
240 this.characterIds = characterIds.slice();
241
242 if (this.characterIds.length == 0) {
243 console.log('No characters selected for bulk edit tags.');
244 return;
245 }
246
247 document.body.insertAdjacentHTML('beforeend', this.#getHtml());
248
249 const entities = this.characterIds.map(id => characterToEntity(characters[id], id)).filter(entity => entity.item !== undefined);
250 buildAvatarList($('#bulk_tags_avatars_block'), entities);
251
252 // Print the tag list with all mutuable tags, marking them as removable. That is the initial fill
253 printTagList($('#bulkTagList'), { tags: () => this.getMutualTags(), tagOptions: { removable: true } });
254
255 // Tag input with resolvable list for the mutual tags to get redrawn, so that newly added tags get sorted correctly
256 createTagInput('#bulkTagInput', '#bulkTagList', { tags: () => this.getMutualTags(), tagOptions: { removable: true } });
257
258 document.querySelector('#bulk_tag_popup_reset').addEventListener('click', this.resetTags.bind(this));
259 document.querySelector('#bulk_tag_popup_remove_mutual').addEventListener('click', this.removeMutual.bind(this));
260 document.querySelector('#bulk_tag_popup_cancel').addEventListener('click', this.hide.bind(this));
261 document.querySelector('#bulk_tag_popup_import_all_tags').addEventListener('click', this.importAllTags.bind(this));
262 document.querySelector('#bulk_tag_popup_import_existing_tags').addEventListener('click', this.importExistingTags.bind(this));
263 }
264
265 /**
266 * Import existing tags for all selected characters
267 */
268 async importExistingTags() {
269 for (const characterId of this.characterIds) {
270 await importTags(characters[characterId], { importSetting: tag_import_setting.ONLY_EXISTING });
271 }
272
273 $('#bulkTagList').empty();
274 }
275
276 /**
277 * Import all tags for all selected characters
278 */
279 async importAllTags() {
280 for (const characterId of this.characterIds) {
281 await importTags(characters[characterId], { importSetting: tag_import_setting.ALL });
282 }
283
284 $('#bulkTagList').empty();
285 }
286
287 /**
288 * Builds a list of all tags that the provided characters have in common.
289 *
290 * @returns {Array<object>} A list of mutual tags
291 */
292 getMutualTags() {
293 if (this.characterIds.length == 0) {
294 return [];
295 }
296
297 if (this.characterIds.length === 1) {
298 // Just use tags of the single character
299 return getTagsList(getTagKeyForEntity(this.characterIds[0]));
300 }
301
302 // Find mutual tags for multiple characters
303 const allTags = this.characterIds.map(cid => getTagsList(getTagKeyForEntity(cid)));
304 const mutualTags = allTags.reduce((mutual, characterTags) =>
305 mutual.filter(tag => characterTags.some(cTag => cTag.id === tag.id)),
306 );
307
308 this.currentMutualTags = mutualTags.sort(compareTagsForSort);
309 return this.currentMutualTags;
310 }
311
312 /**
313 * Hide and remove the tag control
314 */
315 hide() {
316 let popupElement = document.querySelector('#bulk_tag_shadow_popup');
317 if (popupElement) {
318 document.body.removeChild(popupElement);
319 }
320
321 // No need to redraw here, all tags actions were redrawn when they happened
322 }
323
324 /**
325 * Empty the tag map for the given characters
326 */
327 resetTags() {
328 for (const characterId of this.characterIds) {
329 const key = getTagKeyForEntity(characterId);
330 if (key) tag_map[key] = [];
331 }
332
333 $('#bulkTagList').empty();
334
335 printCharactersDebounced();
336 }
337
338 /**
339 * Remove the mutual tags for all given characters
340 */
341 removeMutual() {
342 const mutualTags = this.getMutualTags();
343
344 for (const characterId of this.characterIds) {
345 for (const tag of mutualTags) {
346 removeTagFromMap(tag.id, characterId.toString());
347 }
348 }
349
350 $('#bulkTagList').empty();
351
352 printCharactersDebounced();
353 }
354}
355
356class BulkEditOverlayState {
357 /**
358 *
359 * @type {number}
360 */
361 static browse = 0;
362
363 /**
364 *
365 * @type {number}
366 */
367 static select = 1;
368}
369
370/**
371 * Implement a SingletonPattern, allowing access to the group overlay instance
372 * from everywhere via (new CharacterGroupOverlay())
373 *
374 * @type {Readonly<BulkEditOverlay>}
375 */
376let bulkEditOverlayInstance = null;
377
378class BulkEditOverlay {
379 static containerId = 'rm_print_characters_block';
380 static contextMenuId = 'character_context_menu';
381 static characterClass = 'character_select';
382 static groupClass = 'group_select';
383 static bogusFolderClass = 'bogus_folder_select';
384 static selectModeClass = 'group_overlay_mode_select';
385 static selectedClass = 'character_selected';
386 static legacySelectedClass = 'bulk_select_checkbox';
387 static bulkSelectedCountId = 'bulkSelectedCount';
388
389 static longPressDelay = 2500;
390
391 #state = BulkEditOverlayState.browse;
392 #longPress = false;
393 #stateChangeCallbacks = [];
394 #selectedCharacters = [];
395 #bulkTagPopupHandler = new BulkTagPopupHandler();
396
397 /**
398 * @typedef {object} LastSelected - An object noting the last selected character and its state.
399 * @property {number} [characterId] - The character id of the last selected character.
400 * @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.
401 */
402
403 /**
404 * @type {LastSelected} - An object noting the last selected character and its state.
405 */
406 lastSelected = { characterId: undefined, select: undefined };
407
408 /**
409 * Locks other pointer actions when the context menu is open
410 *
411 * @type {boolean}
412 */
413 #contextMenuOpen = false;
414
415 /**
416 * Whether the next character select should be skipped
417 *
418 * @type {boolean}
419 */
420 #cancelNextToggle = false;
421
422 /**
423 * @type HTMLElement
424 */
425 container = null;
426
427 get state() {
428 return this.#state;
429 }
430
431 set state(newState) {
432 if (this.#state === newState) return;
433
434 eventSource.emit(event_types.CHARACTER_GROUP_OVERLAY_STATE_CHANGE_BEFORE, newState)
435 .then(() => {
436 this.#state = newState;
437 eventSource.emit(event_types.CHARACTER_GROUP_OVERLAY_STATE_CHANGE_AFTER, this.state);
438 });
439 }
440
441 get isLongPress() {
442 return this.#longPress;
443 }
444
445 set isLongPress(longPress) {
446 this.#longPress = longPress;
447 }
448
449 get stateChangeCallbacks() {
450 return this.#stateChangeCallbacks;
451 }
452
453 /**
454 *
455 * @returns {number[]}
456 */
457 get selectedCharacters() {
458 return this.#selectedCharacters;
459 }
460
461 /**
462 * The instance of the bulk tag popup handler that handles tagging of all selected characters
463 *
464 * @returns {BulkTagPopupHandler}
465 */
466 get bulkTagPopupHandler() {
467 return this.#bulkTagPopupHandler;
468 }
469
470 constructor() {
471 if (bulkEditOverlayInstance instanceof BulkEditOverlay)
472 return bulkEditOverlayInstance;
473
474 this.container = document.getElementById(BulkEditOverlay.containerId);
475
476 eventSource.on(event_types.CHARACTER_GROUP_OVERLAY_STATE_CHANGE_AFTER, this.handleStateChange);
477 bulkEditOverlayInstance = Object.freeze(this);
478 }
479
480 /**
481 * Set the overlay to browse mode
482 */
483 browseState = () => this.state = BulkEditOverlayState.browse;
484
485 /**
486 * Set the overlay to select mode
487 */
488 selectState = () => this.state = BulkEditOverlayState.select;
489
490 /**
491 * Set up a Sortable grid for the loaded page
492 */
493 onPageLoad = () => {
494 this.browseState();
495
496 const elements = this.#getEnabledElements();
497 elements.forEach(element => element.addEventListener('touchstart', this.handleHold));
498 elements.forEach(element => element.addEventListener('mousedown', this.handleHold));
499 elements.forEach(element => element.addEventListener('contextmenu', this.handleDefaultContextMenu));
500
501 elements.forEach(element => element.addEventListener('touchend', this.handleLongPressEnd));
502 elements.forEach(element => element.addEventListener('mouseup', this.handleLongPressEnd));
503 elements.forEach(element => element.addEventListener('dragend', this.handleLongPressEnd));
504 elements.forEach(element => element.addEventListener('touchmove', this.handleLongPressEnd));
505
506 // Cohee: It only triggers when clicking on a margin between the elements?
507 // Feel free to fix or remove this, I'm not sure how to.
508 //this.container.addEventListener('click', this.handleCancelClick);
509 };
510
511 /**
512 * Handle state changes
513 *
514 *
515 */
516 handleStateChange = () => {
517 switch (this.state) {
518 case BulkEditOverlayState.browse:
519 this.container.classList.remove(BulkEditOverlay.selectModeClass);
520 this.#contextMenuOpen = false;
521 this.#enableClickEventsForCharacters();
522 this.#enableClickEventsForGroups();
523 this.clearSelectedCharacters();
524 this.disableContextMenu();
525 this.#disableBulkEditButtonHighlight();
526 CharacterContextMenu.hide();
527 break;
528 case BulkEditOverlayState.select:
529 this.container.classList.add(BulkEditOverlay.selectModeClass);
530 this.#disableClickEventsForCharacters();
531 this.#disableClickEventsForGroups();
532 this.enableContextMenu();
533 this.#enableBulkEditButtonHighlight();
534 break;
535 }
536
537 this.stateChangeCallbacks.forEach(callback => callback(this.state));
538 };
539
540 /**
541 * Block the browsers native context menu and
542 * set a click event to hide the custom context menu.
543 */
544 enableContextMenu = () => {
545 this.container.addEventListener('contextmenu', this.handleContextMenuShow);
546 document.addEventListener('click', this.handleContextMenuHide);
547 };
548
549 /**
550 * Remove event listeners, allowing the native browser context
551 * menu to be opened.
552 */
553 disableContextMenu = () => {
554 this.container.removeEventListener('contextmenu', this.handleContextMenuShow);
555 document.removeEventListener('click', this.handleContextMenuHide);
556 };
557
558 handleDefaultContextMenu = (event) => {
559 if (this.isLongPress) {
560 event.preventDefault();
561 event.stopPropagation();
562 return false;
563 }
564 };
565
566 /**
567 * Opens menu on long-press.
568 *
569 * @param event - Pointer event
570 */
571 handleHold = (event) => {
572 if (0 !== event.button && event.type !== 'touchstart') return;
573 if (this.#contextMenuOpen) {
574 this.#contextMenuOpen = false;
575 this.#cancelNextToggle = true;
576 CharacterContextMenu.hide();
577 return;
578 }
579
580 let cancel = false;
581
582 const cancelHold = (event) => cancel = true;
583 this.container.addEventListener('mouseup', cancelHold);
584 this.container.addEventListener('touchend', cancelHold);
585
586 this.isLongPress = true;
587
588 setTimeout(() => {
589 if (this.isLongPress && !cancel) {
590 if (this.state === BulkEditOverlayState.browse) {
591 this.selectState();
592 } else if (this.state === BulkEditOverlayState.select) {
593 this.#contextMenuOpen = true;
594 const [x, y] = this.#getContextMenuPosition(event);
595 CharacterContextMenu.show(x, y);
596 }
597 }
598
599 this.container.removeEventListener('mouseup', cancelHold);
600 this.container.removeEventListener('touchend', cancelHold);
601 }, BulkEditOverlay.longPressDelay);
602 };
603
604 handleLongPressEnd = (event) => {
605 this.isLongPress = false;
606 if (this.#contextMenuOpen) event.stopPropagation();
607 };
608
609 handleCancelClick = () => {
610 if (false === this.#contextMenuOpen) this.state = BulkEditOverlayState.browse;
611 this.#contextMenuOpen = false;
612 };
613
614 /**
615 * Returns the position of the mouse/touch location
616 *
617 * @param event
618 * @returns {(boolean|number|*)[]}
619 */
620 #getContextMenuPosition = (event) => [
621 event.clientX || event.touches[0].clientX,
622 event.clientY || event.touches[0].clientY,
623 ];
624
625 #stopEventPropagation = (event) => {
626 if (this.#contextMenuOpen) {
627 this.handleContextMenuHide(event);
628 }
629 event.stopPropagation();
630 };
631
632 #enableClickEventsForGroups = () => this.#getDisabledElements().forEach((element) => element.removeEventListener('click', this.#stopEventPropagation));
633
634 #disableClickEventsForGroups = () => this.#getDisabledElements().forEach((element) => element.addEventListener('click', this.#stopEventPropagation));
635
636 #enableClickEventsForCharacters = () => this.#getEnabledElements().forEach(element => element.removeEventListener('click', this.toggleCharacterSelected));
637
638 #disableClickEventsForCharacters = () => this.#getEnabledElements().forEach(element => element.addEventListener('click', this.toggleCharacterSelected));
639
640 #enableBulkEditButtonHighlight = () => document.getElementById('bulkEditButton').classList.add('bulk_edit_overlay_active');
641
642 #disableBulkEditButtonHighlight = () => document.getElementById('bulkEditButton').classList.remove('bulk_edit_overlay_active');
643
644 #getEnabledElements = () => [...this.container.getElementsByClassName(BulkEditOverlay.characterClass)];
645
646 #getDisabledElements = () => [...this.container.getElementsByClassName(BulkEditOverlay.groupClass), ...this.container.getElementsByClassName(BulkEditOverlay.bogusFolderClass)];
647
648 toggleCharacterSelected = event => {
649 event.stopPropagation();
650
651 const character = event.currentTarget;
652
653 if (!this.#contextMenuOpen && !this.#cancelNextToggle) {
654 if (event.shiftKey) {
655 // Shift click might have selected text that we don't want to. Unselect it.
656 document.getSelection().removeAllRanges();
657
658 this.handleShiftClick(character);
659 } else {
660 this.toggleSingleCharacter(character);
661 }
662 }
663
664 this.#cancelNextToggle = false;
665 };
666
667 /**
668 * When shift click was held down, this function handles the multi select of characters in a single click.
669 *
670 * If the last clicked character was deselected, and the current one was deselected too, it will deselect all currently selected characters between those two.
671 * 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.
672 * If the states do not match, nothing will happen.
673 *
674 * @param {HTMLElement} currentCharacter - The html element of the currently toggled character
675 */
676 handleShiftClick = (currentCharacter) => {
677 const characterId = Number(currentCharacter.getAttribute('data-chid'));
678 const select = !this.selectedCharacters.includes(characterId);
679
680 if (this.lastSelected.characterId >= 0 && this.lastSelected.select !== undefined) {
681 // Only if select state and the last select state match we execute the range select
682 if (select === this.lastSelected.select) {
683 this.toggleCharactersInRange(currentCharacter, select);
684 }
685 }
686 };
687
688 /**
689 * Toggles the selection of a given characters
690 *
691 * @param {HTMLElement} character - The html element of a character
692 * @param {object} param1 - Optional params
693 * @param {boolean} [param1.markState] - Whether the toggle of this character should be remembered as the last done toggle
694 */
695 toggleSingleCharacter = (character, { markState = true } = {}) => {
696 const characterId = Number(character.getAttribute('data-chid'));
697
698 const select = !this.selectedCharacters.includes(characterId);
699 const legacyBulkEditCheckbox = /** @type {HTMLInputElement} */ (character.querySelector('.' + BulkEditOverlay.legacySelectedClass));
700
701 if (select) {
702 character.classList.add(BulkEditOverlay.selectedClass);
703 if (legacyBulkEditCheckbox) legacyBulkEditCheckbox.checked = true;
704 this.#selectedCharacters.push(characterId);
705 } else {
706 character.classList.remove(BulkEditOverlay.selectedClass);
707 if (legacyBulkEditCheckbox) legacyBulkEditCheckbox.checked = false;
708 this.#selectedCharacters = this.#selectedCharacters.filter(item => characterId !== item);
709 }
710
711 this.updateSelectedCount();
712
713 if (markState) {
714 this.lastSelected.characterId = characterId;
715 this.lastSelected.select = select;
716 }
717 };
718
719 /**
720 * Updates the selected count element with the current count
721 *
722 * @param {number} [countOverride] - optional override for a manual number to set
723 */
724 updateSelectedCount = (countOverride = undefined) => {
725 const count = countOverride ?? this.selectedCharacters.length;
726 $(`#${BulkEditOverlay.bulkSelectedCountId}`).text(count).attr('title', `${count} characters selected`);
727 };
728
729 /**
730 * Toggles the selection of characters in a given range.
731 * The range is provided by the given character and the last selected one remembered in the selection state.
732 *
733 * @param {HTMLElement} currentCharacter - The html element of the currently toggled character
734 * @param {boolean} select - <c>true</c> if the characters in the range are to be selected, <c>false</c> if deselected
735 */
736 toggleCharactersInRange = (currentCharacter, select) => {
737 const currentCharacterId = Number(currentCharacter.getAttribute('data-chid'));
738 const characters = Array.from(document.querySelectorAll('#' + BulkEditOverlay.containerId + ' .' + BulkEditOverlay.characterClass));
739
740 const startIndex = characters.findIndex(c => Number(c.getAttribute('data-chid')) === Number(this.lastSelected.characterId));
741 const endIndex = characters.findIndex(c => Number(c.getAttribute('data-chid')) === currentCharacterId);
742
743 for (let i = Math.min(startIndex, endIndex); i <= Math.max(startIndex, endIndex); i++) {
744 const character = characters[i];
745 const characterId = Number(character.getAttribute('data-chid'));
746 const isCharacterSelected = this.selectedCharacters.includes(characterId);
747
748 // Only toggle the character if it wasn't on the state we have are toggling towards.
749 // Also doing a weird type check, because typescript checker doesn't like the return of 'querySelectorAll'.
750 if ((select && !isCharacterSelected || !select && isCharacterSelected) && character instanceof HTMLElement) {
751 this.toggleSingleCharacter(character, { markState: currentCharacterId == characterId });
752 }
753 }
754 };
755
756 handleContextMenuShow = (event) => {
757 event.preventDefault();
758 const [x, y] = this.#getContextMenuPosition(event);
759 CharacterContextMenu.show(x, y);
760 this.#contextMenuOpen = true;
761 };
762
763 handleContextMenuHide = (event) => {
764 let contextMenu = document.getElementById(BulkEditOverlay.contextMenuId);
765 if (false === contextMenu.contains(event.target)) {
766 CharacterContextMenu.hide();
767 this.#contextMenuOpen = false;
768 }
769 };
770
771 /**
772 * Concurrently handle character favorite requests.
773 *
774 * @returns {Promise<void>}
775 */
776 handleContextMenuFavorite = async () => {
777 const promises = [];
778
779 for (const characterId of this.selectedCharacters) {
780 promises.push(CharacterContextMenu.favorite(characterId));
781 }
782
783 await Promise.allSettled(promises);
784 await getCharacters();
785 await favsToHotswap();
786 this.browseState();
787 };
788
789 /**
790 * Concurrently handle character duplicate requests.
791 *
792 * @returns {Promise<number>}
793 */
794 handleContextMenuDuplicate = () => Promise.all(this.selectedCharacters.map(async characterId => CharacterContextMenu.duplicate(characterId)))
795 .then(() => getCharacters())
796 .then(() => this.browseState());
797
798 /**
799 * Sequentially handle all character-to-persona conversions.
800 *
801 * @returns {Promise<void>}
802 */
803 handleContextMenuPersona = async () => {
804 for (const characterId of this.selectedCharacters) {
805 await CharacterContextMenu.persona(characterId);
806 }
807
808 this.browseState();
809 };
810
811 /**
812 * Gets the HTML as a string that is displayed inside the popup for the bulk delete
813 *
814 * @param {Array<number>} characterIds - The characters that are shown inside the popup
815 * @returns String containing the html for the popup content
816 */
817 static #getDeletePopupContentHtml = (characterIds) => {
818 return `
819 <h3 class="marginBot5">Delete ${characterIds.length} characters?</h3>
820 <span class="bulk_delete_note">
821 <i class="fa-solid fa-triangle-exclamation warning margin-r5"></i>
822 <b>THIS IS PERMANENT!</b>
823 </span>
824 <div id="bulk_delete_avatars_block" class="avatars_inline avatars_inline_small tags tags_inline m-t-1"></div>
825 <br>
826 <div id="bulk_delete_options" class="m-b-1">
827 <label for="del_char_checkbox" class="checkbox_label justifyCenter">
828 <input type="checkbox" id="del_char_checkbox" />
829 <span>Also delete the chat files</span>
830 </label>
831 </div>`;
832 };
833
834 /**
835 * Request user input before concurrently handle deletion
836 * requests.
837 *
838 * @returns {Promise<number>}
839 */
840 handleContextMenuDelete = () => {
841 const characterIds = this.selectedCharacters;
842 const popupContent = $(BulkEditOverlay.#getDeletePopupContentHtml(characterIds));
843 const checkbox = popupContent.find('#del_char_checkbox');
844 const promise = callGenericPopup(popupContent, POPUP_TYPE.CONFIRM)
845 .then((accept) => {
846 if (!accept) return;
847
848 const deleteChats = checkbox.prop('checked') ?? false;
849
850 const loaderHandle = loader.show({
851 slug: 'bulk-delete',
852 title: t`Bulk Delete`,
853 message: t`Deleting ${characterIds.length} character(s)…`,
854 toastMode: loader.ToastMode.STATIC,
855 });
856 const avatarList = characterIds.map(id => characters[id]?.avatar).filter(a => a);
857 return CharacterContextMenu.delete(avatarList, deleteChats)
858 .then(() => this.browseState())
859 .finally(() => loaderHandle.hide());
860 });
861
862 // At this moment the popup is already changed in the dom, but not yet closed/resolved. We build the avatar list here
863 const entities = characterIds.map(id => characterToEntity(characters[id], id)).filter(entity => entity.item !== undefined);
864 buildAvatarList($('#bulk_delete_avatars_block'), entities);
865
866 return promise;
867 };
868
869 /**
870 * Attaches and opens the tag menu
871 */
872 handleContextMenuTag = () => {
873 CharacterContextMenu.tag(this.selectedCharacters);
874 this.browseState();
875 };
876
877 addStateChangeCallback = callback => this.stateChangeCallbacks.push(callback);
878
879 /**
880 * Clears internal character storage and
881 * removes visual highlight.
882 */
883 clearSelectedCharacters = () => {
884 document.querySelectorAll('#' + BulkEditOverlay.containerId + ' .' + BulkEditOverlay.selectedClass)
885 .forEach(element => element.classList.remove(BulkEditOverlay.selectedClass));
886 this.selectedCharacters.length = 0;
887 };
888}
889
890export { BulkEditOverlayState, CharacterContextMenu, BulkEditOverlay };