From 80f4bd4d9efefd65f39454ea6598ee662b1b0113 Mon Sep 17 00:00:00 2001 From: Wolfsblvt Date: Sat, 30 Mar 2024 03:06:40 +0100 Subject: [PATCH] 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 --- public/script.js | 41 +++++++++---- public/scripts/BulkEditOverlay.js | 99 ++++++++++++++++++++----------- public/scripts/bulk-edit.js | 6 +- public/scripts/power-user.js | 12 ++-- public/scripts/tags.js | 42 ++++++------- 5 files changed, 122 insertions(+), 78 deletions(-) diff --git a/public/script.js b/public/script.js index e9bd89da0..9df870852 100644 --- a/public/script.js +++ b/public/script.js @@ -282,6 +282,7 @@ export { mesForShowdownParse, characterGroupOverlay, printCharacters, + printCharactersDebounced, isOdd, countOccurrences, }; @@ -498,6 +499,14 @@ const durationSaveEdit = 1000; const saveSettingsDebounced = debounce(() => saveSettings(), 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 */ @@ -836,7 +845,7 @@ export let active_character = ''; /** The tag of the active group. (Coincidentally also the id) */ 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 function getRequestHeaders() { @@ -1275,19 +1284,31 @@ function getCharacterBlock(item, id) { 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) { - if (fullRefresh) { - saveCharactersPage = 0; - printTagFilters(tag_filter_types.character); - printTagFilters(tag_filter_types.group_member); - - await delay(1); - } - const storageKey = 'Characters_PerPage'; const listId = '#rm_print_characters_block'; 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({ dataSource: entities, pageSize: Number(localStorage.getItem(storageKey)) || per_page_default, @@ -1340,7 +1361,7 @@ async function printCharacters(fullRefresh = false) { saveCharactersPage = e; }, afterRender: function () { - $(listId).scrollTop(0); + $(listId).scrollTop(currentScrollTop); }, }); diff --git a/public/scripts/BulkEditOverlay.js b/public/scripts/BulkEditOverlay.js index 19955e7ac..f3df8156d 100644 --- a/public/scripts/BulkEditOverlay.js +++ b/public/scripts/BulkEditOverlay.js @@ -1,6 +1,7 @@ 'use strict'; import { + characterGroupOverlay, callPopup, characters, deleteCharacter, @@ -9,9 +10,9 @@ import { getCharacters, getPastCharacterChats, getRequestHeaders, - printCharacters, buildAvatarList, characterToEntity, + printCharactersDebounced, } from '../script.js'; import { favsToHotswap } from './RossAscends-mods.js'; @@ -31,7 +32,7 @@ class CharacterContextMenu { * @param {Array} 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 */ 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 * - * @param {Array} characterIds - The characters that are shown inside the popup * @returns String containing the html for the popup */ - static #getHtml = (characterIds) => { - const characterData = JSON.stringify({ characterIds: characterIds }); + #getHtml = () => { + const characterData = JSON.stringify({ characterIds: this.characterIds }); return `
-

Modify tags of ${characterIds.length} characters

+

Modify tags of ${this.characterIds.length} characters

Add or remove the mutual tags of all selected characters.

@@ -227,93 +246,91 @@ class BulkTagPopupHandler { /** * Append and show the tag control * - * @param {Array} characterIds - The characters assigned to this control + * @param {number[]} characterIds - The characters that are shown inside the popup */ - static show(characterIds) { - if (characterIds.length == 0) { + 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(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); // 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 - 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_remove_mutual').addEventListener('click', this.removeMutual.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)); 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. * - * @param {Array} characterIds - The characters to find mutual tags for * @returns {Array} A list of mutual tags */ - static getMutualTags(characterIds) { - if (characterIds.length == 0) { + getMutualTags() { + if (this.characterIds.length == 0) { return []; } - if (characterIds.length === 1) { + if (this.characterIds.length === 1) { // Just use tags of the single character - return getTagsList(getTagKeyForEntity(characterIds[0])); + return getTagsList(getTagKeyForEntity(this.characterIds[0])); } // 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) => mutual.filter(tag => characterTags.some(cTag => cTag.id === tag.id)) ); - this.mutualTags = mutualTags.sort(compareTagsForSort); - return this.mutualTags; + this.currentMutualTags = mutualTags.sort(compareTagsForSort); + return this.currentMutualTags; } /** * Hide and remove the tag control */ - static hide() { + hide() { let popupElement = document.querySelector('#bulk_tag_shadow_popup'); if (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 - * - * @param {Array} characterIds */ - static resetTags(characterIds) { - for (const characterId of characterIds) { + resetTags() { + for (const characterId of this.characterIds) { const key = getTagKeyForEntity(characterId); if (key) tag_map[key] = []; } $('#bulkTagList').empty(); - printCharacters(true); + printCharactersDebounced(); } /** * Remove the mutual tags for all given characters - * - * @param {Array} characterIds */ - static removeMutual(characterIds) { - const mutualTags = this.getMutualTags(characterIds); + removeMutual() { + const mutualTags = this.getMutualTags(); - for (const characterId of characterIds) { + for (const characterId of this.characterIds) { for(const tag of mutualTags) { removeTagFromMap(tag.id, characterId); } @@ -321,7 +338,7 @@ class BulkTagPopupHandler { $('#bulkTagList').empty(); - printCharacters(true); + printCharactersDebounced(); } } @@ -364,6 +381,7 @@ class BulkEditOverlay { #longPress = false; #stateChangeCallbacks = []; #selectedCharacters = []; + #bulkTagPopupHandler = new BulkTagPopupHandler(); /** * @typedef {object} LastSelected - An object noting the last selected character and its state. @@ -429,6 +447,15 @@ class BulkEditOverlay { 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; diff --git a/public/scripts/bulk-edit.js b/public/scripts/bulk-edit.js index c266e148b..f09e290df 100644 --- a/public/scripts/bulk-edit.js +++ b/public/scripts/bulk-edit.js @@ -30,7 +30,7 @@ const toggleBulkEditMode = (isBulkEdit) => { } }; -(new BulkEditOverlay()).addStateChangeCallback((state) => { +characterGroupOverlay.addStateChangeCallback((state) => { if (state === BulkEditOverlayState.select) enableBulkEdit(); if (state === BulkEditOverlayState.browse) disableBulkEdit(); }); @@ -52,7 +52,7 @@ function onSelectAllButtonClick() { let atLeastOneSelected = false; for (const character of characters) { const checked = $(character).find('.bulk_select_checkbox:checked').length > 0; - if (!checked) { + if (!checked && character instanceof HTMLElement) { characterGroupOverlay.toggleSingleCharacter(character); atLeastOneSelected = true; } @@ -62,7 +62,7 @@ function onSelectAllButtonClick() { // If none was selected, trigger click on all to deselect all of them for(const character of characters) { const checked = $(character).find('.bulk_select_checkbox:checked') ?? false; - if (checked) { + if (checked && character instanceof HTMLElement) { characterGroupOverlay.toggleSingleCharacter(character); } } diff --git a/public/scripts/power-user.js b/public/scripts/power-user.js index d0cc3fc06..c7a9d3502 100644 --- a/public/scripts/power-user.js +++ b/public/scripts/power-user.js @@ -10,7 +10,7 @@ import { eventSource, event_types, getCurrentChatId, - printCharacters, + printCharactersDebounced, setCharacterId, setEditedMessageId, renderTemplate, @@ -1288,7 +1288,7 @@ async function applyTheme(name) { key: 'bogus_folders', action: async () => { $('#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 () { power_user.show_card_avatar_urls = !!$(this).prop('checked'); - printCharacters(); + printCharactersDebounced(); saveSettingsDebounced(); }); @@ -3068,7 +3068,7 @@ $(document).ready(() => { power_user.sort_field = $(this).find(':selected').data('field'); power_user.sort_order = $(this).find(':selected').data('order'); power_user.sort_rule = $(this).find(':selected').data('rule'); - printCharacters(); + printCharactersDebounced(); saveSettingsDebounced(); }); @@ -3365,15 +3365,15 @@ $(document).ready(() => { $('#bogus_folders').on('input', function () { const value = !!$(this).prop('checked'); power_user.bogus_folders = value; + printCharactersDebounced(); saveSettingsDebounced(); - printCharacters(true); }); $('#aux_field').on('change', function () { const value = $(this).find(':selected').val(); power_user.aux_field = String(value); + printCharactersDebounced(); saveSettingsDebounced(); - printCharacters(false); }); $('#restore_user_input').on('input', function () { diff --git a/public/scripts/tags.js b/public/scripts/tags.js index a20f0ff40..8c38ec864 100644 --- a/public/scripts/tags.js +++ b/public/scripts/tags.js @@ -7,6 +7,7 @@ import { getCharacters, entitiesFilter, printCharacters, + printCharactersDebounced, buildAvatarList, eventSource, event_types, @@ -48,12 +49,6 @@ function getFilterHelper(listSelector) { 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 = { character: 0, group_member: 1, @@ -406,10 +401,11 @@ function findTag(request, resolve, listSelector) { * @param {*} event - The event that fired on autocomplete select * @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 {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} false, to keep the input clear */ -function selectTag(event, ui, listSelector, tagListOptions = {}) { +function selectTag(event, ui, listSelector, { tagListOptions = {} } = {}) { let tagName = ui.item.value; let tag = tags.find(t => t.name === tagName); @@ -431,6 +427,7 @@ function selectTag(event, ui, listSelector, tagListOptions = {}) { addTagToMap(tag.id); } + printCharactersDebounced(); 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 @@ -443,9 +440,6 @@ function selectTag(event, ui, listSelector, tagListOptions = {}) { printTagList($(inlineSelector), tagListOptions); } - printTagFilters(tag_filter_types.character); - printTagFilters(tag_filter_types.group_member); - // need to return false to keep the input clear return false; } @@ -493,10 +487,11 @@ async function importTags(imported_char) { console.debug('added tag to map', tag, imported_char.name); } } + saveSettingsDebounced(); + + // Await the character list, which will automatically reprint it and all tag filters await getCharacters(); - printTagFilters(tag_filter_types.character); - printTagFilters(tag_filter_types.group_member); // need to return false to keep the input clear return false; @@ -767,8 +762,7 @@ function onTagRemoveClick(event) { $(`${getInlineListSelector()} .tag[id="${tagId}"]`).remove(); - printTagFilters(tag_filter_types.character); - printTagFilters(tag_filter_types.group_member); + printCharactersDebounced(); saveSettingsDebounced(); @@ -818,7 +812,7 @@ export function createTagInput(inputSelector, listSelector, tagListOptions = {}) // @ts-ignore .autocomplete({ 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, }) .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. - redrawCharsAndFiltersDebounced(); + printCharactersDebounced(); + saveSettingsDebounced(); }; // @ts-ignore @@ -1003,8 +996,9 @@ async function onTagRestoreFileSelect(e) { } $('#tag_view_restore_input').val(''); + printCharactersDebounced(); saveSettingsDebounced(); - printCharacters(true); + onViewTagsListClick(); } @@ -1029,7 +1023,8 @@ function onTagsBackupClick() { function onTagCreateClick() { const tag = createNewTag('New Tag'); appendViewTagToList($('#tag_view_list .tag_view_list_tags'), tag, []); - printCharacters(false); + + printCharactersDebounced(); saveSettingsDebounced(); } @@ -1098,7 +1093,7 @@ function onTagAsFolderClick() { updateDrawTagFolder(element, tag); // If folder display has changed, we have to redraw the character list, otherwise this folders state would not change - printCharacters(true); + printCharactersDebounced(); saveSettingsDebounced(); } @@ -1133,7 +1128,8 @@ function onTagDeleteClick() { tags.splice(index, 1); $(`.tag[id="${id}"]`).remove(); $(`.tag_view_item[id="${id}"]`).remove(); - printCharacters(false); + + printCharactersDebounced(); saveSettingsDebounced(); }