Global refactor of printCharacter and filter print

- (!) Refactor character list and filter redrawing to one global debounce
- Refactor all places where character list and filters where redrawn to the correct usage (hope I didn't miss any)
- Automatically redraw character list on each tag bulk edit
- Fix tags not being sorted in bulk edit mutual tags list
- Refactor bulk tag edit class to actually be an instance object
- Remember scroll position on character list redraw - unless it's a full refresh
This commit is contained in:
Wolfsblvt
2024-03-30 03:06:40 +01:00
parent 6a688cc383
commit 80f4bd4d9e
5 changed files with 122 additions and 78 deletions

View File

@ -282,6 +282,7 @@ export {
mesForShowdownParse, mesForShowdownParse,
characterGroupOverlay, characterGroupOverlay,
printCharacters, printCharacters,
printCharactersDebounced,
isOdd, isOdd,
countOccurrences, countOccurrences,
}; };
@ -498,6 +499,14 @@ const durationSaveEdit = 1000;
const saveSettingsDebounced = debounce(() => saveSettings(), durationSaveEdit); const saveSettingsDebounced = debounce(() => saveSettings(), durationSaveEdit);
export const saveCharacterDebounced = debounce(() => $('#create_button').trigger('click'), durationSaveEdit); export const saveCharacterDebounced = debounce(() => $('#create_button').trigger('click'), durationSaveEdit);
/**
* Prints the character list in a debounced fashion without blocking, with a delay of 100 milliseconds.
* Use this function instead of a direct `printCharacters()` whenever the reprinting of the character list is not the primary focus.
*
* The printing will also always reprint all filter options of the global list, to keep them up to date.
*/
const printCharactersDebounced = debounce(() => { printCharacters(false); }, 100);
/** /**
* @enum {string} System message types * @enum {string} System message types
*/ */
@ -836,7 +845,7 @@ export let active_character = '';
/** The tag of the active group. (Coincidentally also the id) */ /** The tag of the active group. (Coincidentally also the id) */
export let active_group = ''; export let active_group = '';
export const entitiesFilter = new FilterHelper(debounce(printCharacters, 100)); export const entitiesFilter = new FilterHelper(printCharactersDebounced);
export const personasFilter = new FilterHelper(debounce(getUserAvatars, 100)); export const personasFilter = new FilterHelper(debounce(getUserAvatars, 100));
export function getRequestHeaders() { export function getRequestHeaders() {
@ -1275,19 +1284,31 @@ function getCharacterBlock(item, id) {
return template; return template;
} }
/**
* Prints the global character list, optionally doing a full refresh of the list
* Use this function whenever the reprinting of the character list is the primary focus, otherwise using `printCharactersDebounced` is preferred for a cleaner, non-blocking experience.
*
* The printing will also always reprint all filter options of the global list, to keep them up to date.
*
* @param {boolean} fullRefresh - If true, the list is fully refreshed and the navigation is being reset
*/
async function printCharacters(fullRefresh = false) { async function printCharacters(fullRefresh = false) {
if (fullRefresh) {
saveCharactersPage = 0;
printTagFilters(tag_filter_types.character);
printTagFilters(tag_filter_types.group_member);
await delay(1);
}
const storageKey = 'Characters_PerPage'; const storageKey = 'Characters_PerPage';
const listId = '#rm_print_characters_block'; const listId = '#rm_print_characters_block';
const entities = getEntitiesList({ doFilter: true }); const entities = getEntitiesList({ doFilter: true });
let currentScrollTop = $(listId).scrollTop();
if (fullRefresh) {
saveCharactersPage = 0;
currentScrollTop = 0;
await delay(1);
}
// We are actually always reprinting filters, as it "doesn't hurt", and this way they are always up to date
printTagFilters(tag_filter_types.character);
printTagFilters(tag_filter_types.group_member);
$('#rm_print_characters_pagination').pagination({ $('#rm_print_characters_pagination').pagination({
dataSource: entities, dataSource: entities,
pageSize: Number(localStorage.getItem(storageKey)) || per_page_default, pageSize: Number(localStorage.getItem(storageKey)) || per_page_default,
@ -1340,7 +1361,7 @@ async function printCharacters(fullRefresh = false) {
saveCharactersPage = e; saveCharactersPage = e;
}, },
afterRender: function () { afterRender: function () {
$(listId).scrollTop(0); $(listId).scrollTop(currentScrollTop);
}, },
}); });

View File

@ -1,6 +1,7 @@
'use strict'; 'use strict';
import { import {
characterGroupOverlay,
callPopup, callPopup,
characters, characters,
deleteCharacter, deleteCharacter,
@ -9,9 +10,9 @@ import {
getCharacters, getCharacters,
getPastCharacterChats, getPastCharacterChats,
getRequestHeaders, getRequestHeaders,
printCharacters,
buildAvatarList, buildAvatarList,
characterToEntity, characterToEntity,
printCharactersDebounced,
} from '../script.js'; } from '../script.js';
import { favsToHotswap } from './RossAscends-mods.js'; import { favsToHotswap } from './RossAscends-mods.js';
@ -31,7 +32,7 @@ class CharacterContextMenu {
* @param {Array<number>} selectedCharacters * @param {Array<number>} selectedCharacters
*/ */
static tag = (selectedCharacters) => { static tag = (selectedCharacters) => {
BulkTagPopupHandler.show(selectedCharacters); characterGroupOverlay.bulkTagPopupHandler.show(selectedCharacters);
}; };
/** /**
@ -186,18 +187,36 @@ class CharacterContextMenu {
* Represents a tag control not bound to a single character * Represents a tag control not bound to a single character
*/ */
class BulkTagPopupHandler { 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 * Gets the HTML as a string that is going to be the popup for the bulk tag edit
* *
* @param {Array<number>} characterIds - The characters that are shown inside the popup
* @returns String containing the html for the popup * @returns String containing the html for the popup
*/ */
static #getHtml = (characterIds) => { #getHtml = () => {
const characterData = JSON.stringify({ characterIds: characterIds }); const characterData = JSON.stringify({ characterIds: this.characterIds });
return `<div id="bulk_tag_shadow_popup"> return `<div id="bulk_tag_shadow_popup">
<div id="bulk_tag_popup"> <div id="bulk_tag_popup">
<div id="bulk_tag_popup_holder"> <div id="bulk_tag_popup_holder">
<h3 class="marginBot5">Modify tags of ${characterIds.length} characters</h3> <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> <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> <div id="bulk_tags_avatars_block" class="avatars_inline avatars_inline_small tags tags_inline"></div>
<br> <br>
@ -227,93 +246,91 @@ class BulkTagPopupHandler {
/** /**
* Append and show the tag control * Append and show the tag control
* *
* @param {Array<number>} characterIds - The characters assigned to this control * @param {number[]} characterIds - The characters that are shown inside the popup
*/ */
static show(characterIds) { show(characterIds) {
if (characterIds.length == 0) { // 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.'); console.log('No characters selected for bulk edit tags.');
return; return;
} }
document.body.insertAdjacentHTML('beforeend', this.#getHtml(characterIds)); document.body.insertAdjacentHTML('beforeend', this.#getHtml());
const entities = characterIds.map(id => characterToEntity(characters[id], id)).filter(entity => entity.item !== undefined); const entities = this.characterIds.map(id => characterToEntity(characters[id], id)).filter(entity => entity.item !== undefined);
buildAvatarList($('#bulk_tags_avatars_block'), entities); buildAvatarList($('#bulk_tags_avatars_block'), entities);
// Print the tag list with all mutuable tags, marking them as removable. That is the initial fill // Print the tag list with all mutuable tags, marking them as removable. That is the initial fill
printTagList($('#bulkTagList'), { tags: () => this.getMutualTags(characterIds), tagOptions: { removable: true } }); 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 // 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(characterIds), tagOptions: { removable: true }}); createTagInput('#bulkTagInput', '#bulkTagList', { tags: () => this.getMutualTags(), tagOptions: { removable: true }});
document.querySelector('#bulk_tag_popup_reset').addEventListener('click', this.resetTags.bind(this, characterIds)); 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, characterIds)); 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_cancel').addEventListener('click', this.hide.bind(this));
} }
/** /**
* Builds a list of all tags that the provided characters have in common. * Builds a list of all tags that the provided characters have in common.
* *
* @param {Array<number>} characterIds - The characters to find mutual tags for
* @returns {Array<object>} A list of mutual tags * @returns {Array<object>} A list of mutual tags
*/ */
static getMutualTags(characterIds) { getMutualTags() {
if (characterIds.length == 0) { if (this.characterIds.length == 0) {
return []; return [];
} }
if (characterIds.length === 1) { if (this.characterIds.length === 1) {
// Just use tags of the single character // Just use tags of the single character
return getTagsList(getTagKeyForEntity(characterIds[0])); return getTagsList(getTagKeyForEntity(this.characterIds[0]));
} }
// Find mutual tags for multiple characters // Find mutual tags for multiple characters
const allTags = characterIds.map(cid => getTagsList(getTagKeyForEntity(cid))); const allTags = this.characterIds.map(cid => getTagsList(getTagKeyForEntity(cid)));
const mutualTags = allTags.reduce((mutual, characterTags) => const mutualTags = allTags.reduce((mutual, characterTags) =>
mutual.filter(tag => characterTags.some(cTag => cTag.id === tag.id)) mutual.filter(tag => characterTags.some(cTag => cTag.id === tag.id))
); );
this.mutualTags = mutualTags.sort(compareTagsForSort); this.currentMutualTags = mutualTags.sort(compareTagsForSort);
return this.mutualTags; return this.currentMutualTags;
} }
/** /**
* Hide and remove the tag control * Hide and remove the tag control
*/ */
static hide() { hide() {
let popupElement = document.querySelector('#bulk_tag_shadow_popup'); let popupElement = document.querySelector('#bulk_tag_shadow_popup');
if (popupElement) { if (popupElement) {
document.body.removeChild(popupElement); document.body.removeChild(popupElement);
} }
printCharacters(true); // No need to redraw here, all tags actions were redrawn when they happened
} }
/** /**
* Empty the tag map for the given characters * Empty the tag map for the given characters
*
* @param {Array<number>} characterIds
*/ */
static resetTags(characterIds) { resetTags() {
for (const characterId of characterIds) { for (const characterId of this.characterIds) {
const key = getTagKeyForEntity(characterId); const key = getTagKeyForEntity(characterId);
if (key) tag_map[key] = []; if (key) tag_map[key] = [];
} }
$('#bulkTagList').empty(); $('#bulkTagList').empty();
printCharacters(true); printCharactersDebounced();
} }
/** /**
* Remove the mutual tags for all given characters * Remove the mutual tags for all given characters
*
* @param {Array<number>} characterIds
*/ */
static removeMutual(characterIds) { removeMutual() {
const mutualTags = this.getMutualTags(characterIds); const mutualTags = this.getMutualTags();
for (const characterId of characterIds) { for (const characterId of this.characterIds) {
for(const tag of mutualTags) { for(const tag of mutualTags) {
removeTagFromMap(tag.id, characterId); removeTagFromMap(tag.id, characterId);
} }
@ -321,7 +338,7 @@ class BulkTagPopupHandler {
$('#bulkTagList').empty(); $('#bulkTagList').empty();
printCharacters(true); printCharactersDebounced();
} }
} }
@ -364,6 +381,7 @@ class BulkEditOverlay {
#longPress = false; #longPress = false;
#stateChangeCallbacks = []; #stateChangeCallbacks = [];
#selectedCharacters = []; #selectedCharacters = [];
#bulkTagPopupHandler = new BulkTagPopupHandler();
/** /**
* @typedef {object} LastSelected - An object noting the last selected character and its state. * @typedef {object} LastSelected - An object noting the last selected character and its state.
@ -429,6 +447,15 @@ class BulkEditOverlay {
return this.#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() { constructor() {
if (bulkEditOverlayInstance instanceof BulkEditOverlay) if (bulkEditOverlayInstance instanceof BulkEditOverlay)
return bulkEditOverlayInstance; return bulkEditOverlayInstance;

View File

@ -30,7 +30,7 @@ const toggleBulkEditMode = (isBulkEdit) => {
} }
}; };
(new BulkEditOverlay()).addStateChangeCallback((state) => { characterGroupOverlay.addStateChangeCallback((state) => {
if (state === BulkEditOverlayState.select) enableBulkEdit(); if (state === BulkEditOverlayState.select) enableBulkEdit();
if (state === BulkEditOverlayState.browse) disableBulkEdit(); if (state === BulkEditOverlayState.browse) disableBulkEdit();
}); });
@ -52,7 +52,7 @@ function onSelectAllButtonClick() {
let atLeastOneSelected = false; let atLeastOneSelected = false;
for (const character of characters) { for (const character of characters) {
const checked = $(character).find('.bulk_select_checkbox:checked').length > 0; const checked = $(character).find('.bulk_select_checkbox:checked').length > 0;
if (!checked) { if (!checked && character instanceof HTMLElement) {
characterGroupOverlay.toggleSingleCharacter(character); characterGroupOverlay.toggleSingleCharacter(character);
atLeastOneSelected = true; atLeastOneSelected = true;
} }
@ -62,7 +62,7 @@ function onSelectAllButtonClick() {
// If none was selected, trigger click on all to deselect all of them // If none was selected, trigger click on all to deselect all of them
for(const character of characters) { for(const character of characters) {
const checked = $(character).find('.bulk_select_checkbox:checked') ?? false; const checked = $(character).find('.bulk_select_checkbox:checked') ?? false;
if (checked) { if (checked && character instanceof HTMLElement) {
characterGroupOverlay.toggleSingleCharacter(character); characterGroupOverlay.toggleSingleCharacter(character);
} }
} }

View File

@ -10,7 +10,7 @@ import {
eventSource, eventSource,
event_types, event_types,
getCurrentChatId, getCurrentChatId,
printCharacters, printCharactersDebounced,
setCharacterId, setCharacterId,
setEditedMessageId, setEditedMessageId,
renderTemplate, renderTemplate,
@ -1288,7 +1288,7 @@ async function applyTheme(name) {
key: 'bogus_folders', key: 'bogus_folders',
action: async () => { action: async () => {
$('#bogus_folders').prop('checked', power_user.bogus_folders); $('#bogus_folders').prop('checked', power_user.bogus_folders);
await printCharacters(true); printCharactersDebounced();
}, },
}, },
{ {
@ -3045,7 +3045,7 @@ $(document).ready(() => {
$('#show_card_avatar_urls').on('input', function () { $('#show_card_avatar_urls').on('input', function () {
power_user.show_card_avatar_urls = !!$(this).prop('checked'); power_user.show_card_avatar_urls = !!$(this).prop('checked');
printCharacters(); printCharactersDebounced();
saveSettingsDebounced(); saveSettingsDebounced();
}); });
@ -3068,7 +3068,7 @@ $(document).ready(() => {
power_user.sort_field = $(this).find(':selected').data('field'); power_user.sort_field = $(this).find(':selected').data('field');
power_user.sort_order = $(this).find(':selected').data('order'); power_user.sort_order = $(this).find(':selected').data('order');
power_user.sort_rule = $(this).find(':selected').data('rule'); power_user.sort_rule = $(this).find(':selected').data('rule');
printCharacters(); printCharactersDebounced();
saveSettingsDebounced(); saveSettingsDebounced();
}); });
@ -3365,15 +3365,15 @@ $(document).ready(() => {
$('#bogus_folders').on('input', function () { $('#bogus_folders').on('input', function () {
const value = !!$(this).prop('checked'); const value = !!$(this).prop('checked');
power_user.bogus_folders = value; power_user.bogus_folders = value;
printCharactersDebounced();
saveSettingsDebounced(); saveSettingsDebounced();
printCharacters(true);
}); });
$('#aux_field').on('change', function () { $('#aux_field').on('change', function () {
const value = $(this).find(':selected').val(); const value = $(this).find(':selected').val();
power_user.aux_field = String(value); power_user.aux_field = String(value);
printCharactersDebounced();
saveSettingsDebounced(); saveSettingsDebounced();
printCharacters(false);
}); });
$('#restore_user_input').on('input', function () { $('#restore_user_input').on('input', function () {

View File

@ -7,6 +7,7 @@ import {
getCharacters, getCharacters,
entitiesFilter, entitiesFilter,
printCharacters, printCharacters,
printCharactersDebounced,
buildAvatarList, buildAvatarList,
eventSource, eventSource,
event_types, event_types,
@ -48,12 +49,6 @@ function getFilterHelper(listSelector) {
return $(listSelector).is(GROUP_FILTER_SELECTOR) ? groupCandidatesFilter : entitiesFilter; return $(listSelector).is(GROUP_FILTER_SELECTOR) ? groupCandidatesFilter : entitiesFilter;
} }
const redrawCharsAndFiltersDebounced = debounce(() => {
printCharacters(false);
printTagFilters(tag_filter_types.character);
printTagFilters(tag_filter_types.group_member);
}, 100);
export const tag_filter_types = { export const tag_filter_types = {
character: 0, character: 0,
group_member: 1, group_member: 1,
@ -406,10 +401,11 @@ function findTag(request, resolve, listSelector) {
* @param {*} event - The event that fired on autocomplete select * @param {*} event - The event that fired on autocomplete select
* @param {*} ui - An Object with label and value properties for the selected option * @param {*} ui - An Object with label and value properties for the selected option
* @param {*} listSelector - The selector of the list to print/add to * @param {*} listSelector - The selector of the list to print/add to
* @param {PrintTagListOptions} [tagListOptions] - Optional parameters for printing the tag list. Can be set to be consistent with the expected behavior of tags in the list that was defined before. * @param {object} param1 - Optional parameters for this method call
* @param {PrintTagListOptions} [param1.tagListOptions] - Optional parameters for printing the tag list. Can be set to be consistent with the expected behavior of tags in the list that was defined before.
* @returns {boolean} <c>false</c>, to keep the input clear * @returns {boolean} <c>false</c>, to keep the input clear
*/ */
function selectTag(event, ui, listSelector, tagListOptions = {}) { function selectTag(event, ui, listSelector, { tagListOptions = {} } = {}) {
let tagName = ui.item.value; let tagName = ui.item.value;
let tag = tags.find(t => t.name === tagName); let tag = tags.find(t => t.name === tagName);
@ -431,6 +427,7 @@ function selectTag(event, ui, listSelector, tagListOptions = {}) {
addTagToMap(tag.id); addTagToMap(tag.id);
} }
printCharactersDebounced();
saveSettingsDebounced(); saveSettingsDebounced();
// We should manually add the selected tag to the print tag function, so we cover places where the tag list did not automatically include it // We should manually add the selected tag to the print tag function, so we cover places where the tag list did not automatically include it
@ -443,9 +440,6 @@ function selectTag(event, ui, listSelector, tagListOptions = {}) {
printTagList($(inlineSelector), tagListOptions); printTagList($(inlineSelector), tagListOptions);
} }
printTagFilters(tag_filter_types.character);
printTagFilters(tag_filter_types.group_member);
// need to return false to keep the input clear // need to return false to keep the input clear
return false; return false;
} }
@ -493,10 +487,11 @@ async function importTags(imported_char) {
console.debug('added tag to map', tag, imported_char.name); console.debug('added tag to map', tag, imported_char.name);
} }
} }
saveSettingsDebounced(); saveSettingsDebounced();
// Await the character list, which will automatically reprint it and all tag filters
await getCharacters(); await getCharacters();
printTagFilters(tag_filter_types.character);
printTagFilters(tag_filter_types.group_member);
// need to return false to keep the input clear // need to return false to keep the input clear
return false; return false;
@ -767,8 +762,7 @@ function onTagRemoveClick(event) {
$(`${getInlineListSelector()} .tag[id="${tagId}"]`).remove(); $(`${getInlineListSelector()} .tag[id="${tagId}"]`).remove();
printTagFilters(tag_filter_types.character); printCharactersDebounced();
printTagFilters(tag_filter_types.group_member);
saveSettingsDebounced(); saveSettingsDebounced();
@ -818,7 +812,7 @@ export function createTagInput(inputSelector, listSelector, tagListOptions = {})
// @ts-ignore // @ts-ignore
.autocomplete({ .autocomplete({
source: (i, o) => findTag(i, o, listSelector), source: (i, o) => findTag(i, o, listSelector),
select: (e, u) => selectTag(e, u, listSelector, tagListOptions), select: (e, u) => selectTag(e, u, listSelector, { tagListOptions: tagListOptions }),
minLength: 0, minLength: 0,
}) })
.focus(onTagInputFocus); // <== show tag list on click .focus(onTagInputFocus); // <== show tag list on click
@ -900,10 +894,9 @@ function makeTagListDraggable(tagContainer) {
} }
}); });
saveSettingsDebounced();
// If the order of tags in display has changed, we need to redraw some UI elements. Do it debounced so it doesn't block and you can drag multiple tags. // If the order of tags in display has changed, we need to redraw some UI elements. Do it debounced so it doesn't block and you can drag multiple tags.
redrawCharsAndFiltersDebounced(); printCharactersDebounced();
saveSettingsDebounced();
}; };
// @ts-ignore // @ts-ignore
@ -1003,8 +996,9 @@ async function onTagRestoreFileSelect(e) {
} }
$('#tag_view_restore_input').val(''); $('#tag_view_restore_input').val('');
printCharactersDebounced();
saveSettingsDebounced(); saveSettingsDebounced();
printCharacters(true);
onViewTagsListClick(); onViewTagsListClick();
} }
@ -1029,7 +1023,8 @@ function onTagsBackupClick() {
function onTagCreateClick() { function onTagCreateClick() {
const tag = createNewTag('New Tag'); const tag = createNewTag('New Tag');
appendViewTagToList($('#tag_view_list .tag_view_list_tags'), tag, []); appendViewTagToList($('#tag_view_list .tag_view_list_tags'), tag, []);
printCharacters(false);
printCharactersDebounced();
saveSettingsDebounced(); saveSettingsDebounced();
} }
@ -1098,7 +1093,7 @@ function onTagAsFolderClick() {
updateDrawTagFolder(element, tag); updateDrawTagFolder(element, tag);
// If folder display has changed, we have to redraw the character list, otherwise this folders state would not change // If folder display has changed, we have to redraw the character list, otherwise this folders state would not change
printCharacters(true); printCharactersDebounced();
saveSettingsDebounced(); saveSettingsDebounced();
} }
@ -1133,7 +1128,8 @@ function onTagDeleteClick() {
tags.splice(index, 1); tags.splice(index, 1);
$(`.tag[id="${id}"]`).remove(); $(`.tag[id="${id}"]`).remove();
$(`.tag_view_item[id="${id}"]`).remove(); $(`.tag_view_item[id="${id}"]`).remove();
printCharacters(false);
printCharactersDebounced();
saveSettingsDebounced(); saveSettingsDebounced();
} }