From 40daf1ca1d04902997aa9eed67f3beffcb44ecdd Mon Sep 17 00:00:00 2001 From: Wolfsblvt Date: Wed, 27 Mar 2024 03:36:09 +0100 Subject: [PATCH 01/16] Bulk edit tag improvements - Show mutual tags on bulk edit - Update tag list on tag added/removed in bulk edit - Add "remove mutual" button to bulk edit tags --- public/scripts/BulkEditOverlay.js | 85 +++++++++++++++++++++++++++---- public/scripts/tags.js | 84 ++++++++++++++++++++++-------- public/style.css | 8 +++ 3 files changed, 144 insertions(+), 33 deletions(-) diff --git a/public/scripts/BulkEditOverlay.js b/public/scripts/BulkEditOverlay.js index eb69f279b..d11b56aa9 100644 --- a/public/scripts/BulkEditOverlay.js +++ b/public/scripts/BulkEditOverlay.js @@ -15,7 +15,7 @@ import { import { favsToHotswap } from './RossAscends-mods.js'; import { hideLoader, showLoader } from './loader.js'; import { convertCharacterToPersona } from './personas.js'; -import { createTagInput, getTagKeyForEntity, tag_map } from './tags.js'; +import { createTagInput, getTagKeyForEntity, getTagsList, printTagList, tag_map, compareTagsForSort, removeTagFromMap } from './tags.js'; // Utility object for popup messages. const popupMessage = { @@ -193,8 +193,9 @@ class BulkTagPopupHandler { return `
-

Add tags to ${characterIds.length} characters

-
+

Modify tags of ${characterIds.length} characters

+ This popup allows you to modify the mutual tags of all selected characters. +
@@ -203,8 +204,15 @@ class BulkTagPopupHandler {
+ + -
@@ -215,13 +223,47 @@ class BulkTagPopupHandler { /** * Append and show the tag control * - * @param characters - The characters assigned to this control + * @param characterIds - The characters assigned to this control */ - static show(characters) { - document.body.insertAdjacentHTML('beforeend', this.#getHtml(characters)); - createTagInput('#bulkTagInput', '#bulkTagList'); + static show(characterIds) { + if (characterIds.length == 0) { + console.log('No characters selected for bulk edit tags.'); + return; + } + + document.body.insertAdjacentHTML('beforeend', this.#getHtml(characterIds)); + + this.mutualTags = this.getMutualTags(characterIds); + + // 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 } }); + + // Tag input with empty tags so new tag gets added and it doesn't get emptied on redraw + createTagInput('#bulkTagInput', '#bulkTagList', { tags: () => this.getMutualTags(characterIds), 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_cancel').addEventListener('click', this.hide.bind(this)); - document.querySelector('#bulk_tag_popup_reset').addEventListener('click', this.resetTags.bind(this, characters)); + } + + static getMutualTags(characterIds) { + if (characterIds.length == 0) { + return []; + } + + if (characterIds.length === 1) { + // Just use tags of the single character + return getTagsList(getTagKeyForEntity(characterIds[0])); + } + + // Find mutual tags for multiple characters + const allTags = characterIds.map(c => getTagsList(getTagKeyForEntity(c))); + const mutualTags = allTags.reduce((mutual, characterTags) => + mutual.filter(tag => characterTags.some(cTag => cTag.id === tag.id)) + ); + + this.mutualTags = mutualTags.sort(compareTagsForSort); + return this.mutualTags; } /** @@ -242,10 +284,31 @@ class BulkTagPopupHandler { * @param characterIds */ static resetTags(characterIds) { - characterIds.forEach((characterId) => { + for (const characterId of characterIds) { const key = getTagKeyForEntity(characterId); if (key) tag_map[key] = []; - }); + } + + $('#bulkTagList').empty(); + + printCharacters(true); + } + + /** + * Empty the tag map for the given characters + * + * @param characterIds + */ + static removeMutual(characterIds) { + const mutualTags = this.getMutualTags(characterIds); + + for (const characterId of characterIds) { + for(const tag of mutualTags) { + removeTagFromMap(tag.id, characterId); + } + } + + $('#bulkTagList').empty(); printCharacters(true); } diff --git a/public/scripts/tags.js b/public/scripts/tags.js index 2615d87b3..8c92cd265 100644 --- a/public/scripts/tags.js +++ b/public/scripts/tags.js @@ -36,6 +36,7 @@ export { importTags, sortTags, compareTagsForSort, + removeTagFromMap, }; const CHARACTER_FILTER_SELECTOR = '#rm_characters_block .rm_tag_filter'; @@ -57,12 +58,12 @@ export const tag_filter_types = { }; const ACTIONABLE_TAGS = { - FAV: { id: 1, name: 'Show only favorites', color: 'rgba(255, 255, 0, 0.5)', action: filterByFav, icon: 'fa-solid fa-star', class: 'filterByFavorites' }, - GROUP: { id: 0, name: 'Show only groups', color: 'rgba(100, 100, 100, 0.5)', action: filterByGroups, icon: 'fa-solid fa-users', class: 'filterByGroups' }, - FOLDER: { id: 4, name: 'Always show folders', color: 'rgba(120, 120, 120, 0.5)', action: filterByFolder, icon: 'fa-solid fa-folder-plus', class: 'filterByFolder' }, - VIEW: { id: 2, name: 'Manage tags', color: 'rgba(150, 100, 100, 0.5)', action: onViewTagsListClick, icon: 'fa-solid fa-gear', class: 'manageTags' }, - HINT: { id: 3, name: 'Show Tag List', color: 'rgba(150, 100, 100, 0.5)', action: onTagListHintClick, icon: 'fa-solid fa-tags', class: 'showTagList' }, - UNFILTER: { id: 5, name: 'Clear all filters', action: onClearAllFiltersClick, icon: 'fa-solid fa-filter-circle-xmark', class: 'clearAllFilters' }, + FAV: { id: 1, sort_order: 1, name: 'Show only favorites', color: 'rgba(255, 255, 0, 0.5)', action: filterByFav, icon: 'fa-solid fa-star', class: 'filterByFavorites' }, + GROUP: { id: 0, sort_order: 2, name: 'Show only groups', color: 'rgba(100, 100, 100, 0.5)', action: filterByGroups, icon: 'fa-solid fa-users', class: 'filterByGroups' }, + FOLDER: { id: 4, sort_order: 3, name: 'Always show folders', color: 'rgba(120, 120, 120, 0.5)', action: filterByFolder, icon: 'fa-solid fa-folder-plus', class: 'filterByFolder' }, + VIEW: { id: 2, sort_order: 4, name: 'Manage tags', color: 'rgba(150, 100, 100, 0.5)', action: onViewTagsListClick, icon: 'fa-solid fa-gear', class: 'manageTags' }, + HINT: { id: 3, sort_order: 5, name: 'Show Tag List', color: 'rgba(150, 100, 100, 0.5)', action: onTagListHintClick, icon: 'fa-solid fa-tags', class: 'showTagList' }, + UNFILTER: { id: 5, sort_order: 6, name: 'Clear all filters', action: onClearAllFiltersClick, icon: 'fa-solid fa-filter-circle-xmark', class: 'clearAllFilters' }, }; const InListActionable = { @@ -390,7 +391,15 @@ function findTag(request, resolve, listSelector) { resolve(result); } -function selectTag(event, ui, listSelector) { +/** + * Select a tag and add it to the list. This function is mostly used as an event handler for the tag selector control. + * @param {*} event - + * @param {*} ui - + * @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. + * @returns {boolean} false, to keep the input clear + */ +function selectTag(event, ui, listSelector, tagListOptions = {}) { let tagName = ui.item.value; let tag = tags.find(t => t.name === tagName); @@ -414,9 +423,28 @@ function selectTag(event, ui, listSelector) { saveSettingsDebounced(); + // If we have a manual list of tags to print, we should add this tag here to that manual list, otherwise it may not get printed + if (tagListOptions.tags !== undefined) { + const tagExists = (tags, tag) => tags.some(x => x.id === tag.id); + + if (typeof tagListOptions.tags === 'function') { + // If 'tags' is a function, wrap it to include new tag upon invocation + const originalTagsFunction = tagListOptions.tags; + tagListOptions.tags = () => { + const currentTags = originalTagsFunction(); + return tagExists(currentTags, tag) ? currentTags : [...currentTags, tag]; + }; + } else { + tagListOptions.tags = tagExists(tagListOptions.tags, tag) ? tags : [...tagListOptions.tags, tag]; + } + } + // add tag to the UI and internal map - we reprint so sorting and new markup is done correctly - printTagList(listSelector, { tagOptions: { removable: true } }); - printTagList($(getInlineListSelector())); + printTagList(listSelector, tagListOptions); + const inlineSelector = getInlineListSelector(); + if (inlineSelector) { + printTagList($(inlineSelector), tagListOptions); + } printTagFilters(tag_filter_types.character); printTagFilters(tag_filter_types.group_member); @@ -492,7 +520,7 @@ function createNewTag(tagName) { } /** - * @typedef {object} TagOptions + * @typedef {object} TagOptions - Options for tag behavior. (Same object will be passed into "appendTagToList") * @property {boolean} [removable=false] - Whether tags can be removed. * @property {boolean} [selectable=false] - Whether tags can be selected. * @property {function} [action=undefined] - Action to perform on tag interaction. @@ -500,20 +528,24 @@ function createNewTag(tagName) { * @property {boolean} [skipExistsCheck=false] - If true, the tag gets added even if a tag with the same id already exists. */ +/** + * @typedef {object} PrintTagListOptions - Optional parameters for printing the tag list. + * @property {Array|function(): Array} [tags=undefined] Optional override of tags that should be printed. Those will not be sorted. If no supplied, tags for the relevant character are printed. Can also be a function that returns the tags. + * @property {object|number|string} [forEntityOrKey=undefined] - Optional override for the chosen entity, otherwise the currently selected is chosen. Can be an entity with id property (character, group, tag), or directly an id or tag key. + * @property {boolean} [empty=true] - Whether the list should be initially empty. + * @property {function(object): function} [tagActionSelector=undefined] - An optional override for the action property that can be assigned to each tag via tagOptions. + * If set, the selector is executed on each tag as input argument. This allows a list of tags to be provided and each tag can have it's action based on the tag object itself. + * @property {TagOptions} [tagOptions={}] - Options for tag behavior. (Same object will be passed into "appendTagToList") + */ + /** * Prints the list of tags. * @param {JQuery} element - The container element where the tags are to be printed. - * @param {object} [options] - Optional parameters for printing the tag list. - * @param {Array} [options.tags] Optional override of tags that should be printed. Those will not be sorted. If no supplied, tags for the relevant character are printed. - * @param {object|number|string} [options.forEntityOrKey=undefined] - Optional override for the chosen entity, otherwise the currently selected is chosen. Can be an entity with id property (character, group, tag), or directly an id or tag key. - * @param {boolean} [options.empty=true] - Whether the list should be initially empty. - * @param {function(object): function} [options.tagActionSelector=undefined] - An optional override for the action property that can be assigned to each tag via tagOptions. - * If set, the selector is executed on each tag as input argument. This allows a list of tags to be provided and each tag can have it's action based on the tag object itself. - * @param {TagOptions} [options.tagOptions={}] - Options for tag behavior. (Same object will be passed into "appendTagToList") + * @param {PrintTagListOptions} [options] - Optional parameters for printing the tag list. */ function printTagList(element, { tags = undefined, forEntityOrKey = undefined, empty = true, tagActionSelector = undefined, tagOptions = {} } = {}) { const key = forEntityOrKey !== undefined ? getTagKeyForEntity(forEntityOrKey) : getTagKey(); - const printableTags = tags ?? getTagsList(key); + const printableTags = tags !== undefined ? (typeof tags === 'function' ? tags() : tags).sort(compareTagsForSort) : getTagsList(key); if (empty) { $(element).empty(); @@ -730,6 +762,8 @@ function onTagRemoveClick(event) { printTagFilters(tag_filter_types.character); printTagFilters(tag_filter_types.group_member); saveSettingsDebounced(); + + } // @ts-ignore @@ -764,12 +798,18 @@ function applyTagsOnGroupSelect() { // Nothing to do here at the moment. Tags in group interface get automatically redrawn. } -export function createTagInput(inputSelector, listSelector) { +/** + * + * @param {string} inputSelector - the selector for the tag input control + * @param {string} listSelector - the selector for the list of the tags modified by the input control + * @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. + */ +export function createTagInput(inputSelector, listSelector, tagListOptions = {}) { $(inputSelector) // @ts-ignore .autocomplete({ source: (i, o) => findTag(i, o, listSelector), - select: (e, u) => selectTag(e, u, listSelector), + select: (e, u) => selectTag(e, u, listSelector, tagListOptions), minLength: 0, }) .focus(onTagInputFocus); // <== show tag list on click @@ -1152,8 +1192,8 @@ function onClearAllFiltersClick() { } jQuery(() => { - createTagInput('#tagInput', '#tagList'); - createTagInput('#groupTagInput', '#groupTagList'); + createTagInput('#tagInput', '#tagList', { tagOptions: { removable: true } }); + createTagInput('#groupTagInput', '#groupTagList', { tagOptions: { removable: true } }); $(document).on('click', '#rm_button_create', onCharacterCreateClick); $(document).on('click', '#rm_button_group_chats', onGroupCreateClick); diff --git a/public/style.css b/public/style.css index 91932cb47..6817bcc45 100644 --- a/public/style.css +++ b/public/style.css @@ -37,6 +37,7 @@ --fullred: rgba(255, 0, 0, 1); --crimson70a: rgba(100, 0, 0, 0.7); + --crimson-hover: rgba(150, 50, 50, 0.5); --okGreen70a: rgba(0, 100, 0, 0.7); --cobalt30a: rgba(100, 100, 255, 0.3); --greyCAIbg: rgb(36, 36, 37); @@ -2113,11 +2114,18 @@ grammarly-extension { } #bulk_tag_popup_reset, +#bulk_tag_popup_remove_mutual, #dialogue_popup_ok { background-color: var(--crimson70a); cursor: pointer; } +#bulk_tag_popup_reset:hover, +#bulk_tag_popup_remove_mutual:hover, +#dialogue_popup_ok:hover { + background-color: var(--crimson-hover); +} + #dialogue_popup_input { margin: 10px 0; width: 100%; From 4547e684970aea45c25b834cce085ccbb5991e0c Mon Sep 17 00:00:00 2001 From: Wolfsblvt Date: Wed, 27 Mar 2024 04:28:24 +0100 Subject: [PATCH 02/16] Fix tag display issues (char create, auto load) - Fix tags not working on new character dialog - Fix display of tags for auto-loaded character on enabled auto load --- public/scripts/RossAscends-mods.js | 6 ++++- public/scripts/tags.js | 41 ++++++++++++++---------------- 2 files changed, 24 insertions(+), 23 deletions(-) diff --git a/public/scripts/RossAscends-mods.js b/public/scripts/RossAscends-mods.js index da93d1ed7..9aa3430a7 100644 --- a/public/scripts/RossAscends-mods.js +++ b/public/scripts/RossAscends-mods.js @@ -27,7 +27,7 @@ import { import { LoadLocal, SaveLocal, LoadLocalBool } from './f-localStorage.js'; import { selected_group, is_group_generating, openGroupById } from './group-chats.js'; -import { getTagKeyForEntity } from './tags.js'; +import { getTagKeyForEntity, applyTagsOnCharacterSelect } from './tags.js'; import { SECRET_KEYS, secret_state, @@ -252,6 +252,10 @@ async function RA_autoloadchat() { const active_character_id = characters.findIndex(x => getTagKeyForEntity(x) === active_character); if (active_character_id !== null) { await selectCharacterById(String(active_character_id)); + + // Do a little tomfoolery to spoof the tag selector + const selectedCharElement = $(`#rm_print_characters_block .character_select[chid="${active_character_id}"]`) + applyTagsOnCharacterSelect.call(selectedCharElement); } } diff --git a/public/scripts/tags.js b/public/scripts/tags.js index 8c92cd265..c4bf4626c 100644 --- a/public/scripts/tags.js +++ b/public/scripts/tags.js @@ -423,21 +423,8 @@ function selectTag(event, ui, listSelector, tagListOptions = {}) { saveSettingsDebounced(); - // If we have a manual list of tags to print, we should add this tag here to that manual list, otherwise it may not get printed - if (tagListOptions.tags !== undefined) { - const tagExists = (tags, tag) => tags.some(x => x.id === tag.id); - - if (typeof tagListOptions.tags === 'function') { - // If 'tags' is a function, wrap it to include new tag upon invocation - const originalTagsFunction = tagListOptions.tags; - tagListOptions.tags = () => { - const currentTags = originalTagsFunction(); - return tagExists(currentTags, tag) ? currentTags : [...currentTags, tag]; - }; - } else { - tagListOptions.tags = tagExists(tagListOptions.tags, tag) ? tags : [...tagListOptions.tags, tag]; - } - } + // 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 + tagListOptions.addTag = tag; // add tag to the UI and internal map - we reprint so sorting and new markup is done correctly printTagList(listSelector, tagListOptions); @@ -530,9 +517,10 @@ function createNewTag(tagName) { /** * @typedef {object} PrintTagListOptions - Optional parameters for printing the tag list. - * @property {Array|function(): Array} [tags=undefined] Optional override of tags that should be printed. Those will not be sorted. If no supplied, tags for the relevant character are printed. Can also be a function that returns the tags. + * @property {Array|function(): Array} [tags=undefined] - Optional override of tags that should be printed. Those will not be sorted. If no supplied, tags for the relevant character are printed. Can also be a function that returns the tags. + * @property {object} [addTag=undefined] - Optionally provide a tag that should be manually added to this print. Either to the overriden tag list or the found tags based on the entity/key. Will respect the tag exists check. * @property {object|number|string} [forEntityOrKey=undefined] - Optional override for the chosen entity, otherwise the currently selected is chosen. Can be an entity with id property (character, group, tag), or directly an id or tag key. - * @property {boolean} [empty=true] - Whether the list should be initially empty. + * @property {boolean|string} [empty=true] - Whether the list should be initially empty. If a string string is provided, 'always' will always empty the list, otherwise it'll evaluate to a boolean. * @property {function(object): function} [tagActionSelector=undefined] - An optional override for the action property that can be assigned to each tag via tagOptions. * If set, the selector is executed on each tag as input argument. This allows a list of tags to be provided and each tag can have it's action based on the tag object itself. * @property {TagOptions} [tagOptions={}] - Options for tag behavior. (Same object will be passed into "appendTagToList") @@ -543,18 +531,27 @@ function createNewTag(tagName) { * @param {JQuery} element - The container element where the tags are to be printed. * @param {PrintTagListOptions} [options] - Optional parameters for printing the tag list. */ -function printTagList(element, { tags = undefined, forEntityOrKey = undefined, empty = true, tagActionSelector = undefined, tagOptions = {} } = {}) { +function printTagList(element, { tags = undefined, addTag = undefined, forEntityOrKey = undefined, empty = true, tagActionSelector = undefined, tagOptions = {} } = {}) { const key = forEntityOrKey !== undefined ? getTagKeyForEntity(forEntityOrKey) : getTagKey(); - const printableTags = tags !== undefined ? (typeof tags === 'function' ? tags() : tags).sort(compareTagsForSort) : getTagsList(key); + let printableTags = tags ? (typeof tags === 'function' ? tags() : tags) : getTagsList(key); - if (empty) { + if (empty === 'always' || (empty && (printableTags?.length > 0 || key))) { $(element).empty(); } + if (addTag && (tagOptions.skipExistsCheck || !printableTags.some(x => x.id === addTag.id))) { + printableTags = [...printableTags, addTag]; + } + + // one last sort, because we might have modified the tag list or manually retrieved it from a function + printableTags = printableTags.sort(compareTagsForSort); + + const customAction = typeof tagActionSelector === 'function' ? tagActionSelector : null; + for (const tag of printableTags) { // If we have a custom action selector, we override that tag options for each tag - if (tagActionSelector && typeof tagActionSelector === 'function') { - const action = tagActionSelector(tag); + if (customAction) { + const action = customAction(tag); if (action && typeof action !== 'function') { console.error('The action parameter must return a function for tag.', tag); } else { From a4c4f36fc69162b714593818751793b094535a73 Mon Sep 17 00:00:00 2001 From: Wolfsblvt Date: Wed, 27 Mar 2024 08:22:03 +0100 Subject: [PATCH 03/16] Bulk edit select improvements & bulk tag edit inline avatars - bulk edit tags shows inline avatars for all selected characters - allow shift-click selecting/deselecting multiple characters on bulk edit - bulk select all button added - bulk select shows selected character count --- public/index.html | 4 +- public/script.js | 25 ++++---- public/scripts/BulkEditOverlay.js | 101 +++++++++++++++++++++++++----- public/scripts/bulk-edit.js | 47 +++++++++++--- public/style.css | 4 ++ 5 files changed, 145 insertions(+), 36 deletions(-) diff --git a/public/index.html b/public/index.html index 751cce6b3..bca9823f9 100644 --- a/public/index.html +++ b/public/index.html @@ -4319,7 +4319,9 @@
- +
+ +
diff --git a/public/script.js b/public/script.js index 38ed270cd..6cf82dc27 100644 --- a/public/script.js +++ b/public/script.js @@ -279,6 +279,7 @@ export { default_ch_mes, extension_prompt_types, mesForShowdownParse, + characterGroupOverlay, printCharacters, isOdd, countOccurrences, @@ -1343,19 +1344,19 @@ async function printCharacters(fullRefresh = false) { favsToHotswap(); } +export function characterToEntity(character, id) { + return { item: character, id, type: 'character' }; +} + +export function groupToEntity(group) { + return { item: group, id: group.id, type: 'group' }; +} + +export function tagToEntity(tag) { + return { item: structuredClone(tag), id: tag.id, type: 'tag', entities: [] }; +} + export function getEntitiesList({ doFilter = false, doSort = true } = {}) { - function characterToEntity(character, id) { - return { item: character, id, type: 'character' }; - } - - function groupToEntity(group) { - return { item: group, id: group.id, type: 'group' }; - } - - function tagToEntity(tag) { - return { item: structuredClone(tag), id: tag.id, type: 'tag', entities: [] }; - } - let entities = [ ...characters.map((item, index) => characterToEntity(item, index)), ...groups.map(item => groupToEntity(item)), diff --git a/public/scripts/BulkEditOverlay.js b/public/scripts/BulkEditOverlay.js index d11b56aa9..c2d3e44e8 100644 --- a/public/scripts/BulkEditOverlay.js +++ b/public/scripts/BulkEditOverlay.js @@ -10,6 +10,8 @@ import { getPastCharacterChats, getRequestHeaders, printCharacters, + buildAvatarList, + characterToEntity, } from '../script.js'; import { favsToHotswap } from './RossAscends-mods.js'; @@ -194,7 +196,8 @@ class BulkTagPopupHandler {

Modify tags of ${characterIds.length} characters

- This popup allows you to modify the mutual tags of all selected characters. + Add or remove the mutual tags of all selected characters. +

@@ -233,7 +236,8 @@ class BulkTagPopupHandler { document.body.insertAdjacentHTML('beforeend', this.#getHtml(characterIds)); - this.mutualTags = this.getMutualTags(characterIds); + const entities = 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 } }); @@ -257,7 +261,7 @@ class BulkTagPopupHandler { } // Find mutual tags for multiple characters - const allTags = characterIds.map(c => getTagsList(getTagKeyForEntity(c))); + const allTags = characterIds.map(cid => getTagsList(getTagKeyForEntity(cid))); const mutualTags = allTags.reduce((mutual, characterTags) => mutual.filter(tag => characterTags.some(cTag => cTag.id === tag.id)) ); @@ -345,6 +349,7 @@ class BulkEditOverlay { static selectModeClass = 'group_overlay_mode_select'; static selectedClass = 'character_selected'; static legacySelectedClass = 'bulk_select_checkbox'; + static bulkSelectedCountId = 'bulkSelectedCount'; static longPressDelay = 2500; @@ -353,6 +358,17 @@ class BulkEditOverlay { #stateChangeCallbacks = []; #selectedCharacters = []; + /** + * @typedef {object} LastSelected - An object noting the last selected character and its state. + * @property {string} [characterId] - The character id of the last selected character. + * @property {boolean} [select] - The selected state of the last selected character. true if it was selected, false 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 * @@ -588,27 +604,80 @@ class BulkEditOverlay { event.stopPropagation(); const character = event.currentTarget; - const characterId = character.getAttribute('chid'); - const alreadySelected = this.selectedCharacters.includes(characterId); + 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(); - const legacyBulkEditCheckbox = character.querySelector('.' + BulkEditOverlay.legacySelectedClass); - - // Only toggle when context menu is closed and wasn't just closed. - if (!this.#contextMenuOpen && !this.#cancelNextToggle) - if (alreadySelected) { - character.classList.remove(BulkEditOverlay.selectedClass); - if (legacyBulkEditCheckbox) legacyBulkEditCheckbox.checked = false; - this.dismissCharacter(characterId); + this.handleShiftClick(character); } else { - character.classList.add(BulkEditOverlay.selectedClass); - if (legacyBulkEditCheckbox) legacyBulkEditCheckbox.checked = true; - this.selectCharacter(characterId); + this.toggleSingleCharacter(character); } + } this.#cancelNextToggle = false; }; + handleShiftClick = (currentCharacter) => { + const characterId = currentCharacter.getAttribute('chid'); + const select = !this.selectedCharacters.includes(characterId); + + if (this.lastSelected.characterId && this.lastSelected.select !== undefined) { + // Only if select state and the last select state match we execute the range select + if (select === this.lastSelected.select) { + this.selectCharactersInRange(currentCharacter, select); + } + } + }; + + toggleSingleCharacter = (character, { markState = true } = {}) => { + const characterId = character.getAttribute('chid'); + + const select = !this.selectedCharacters.includes(characterId); + const legacyBulkEditCheckbox = character.querySelector('.' + BulkEditOverlay.legacySelectedClass); + + if (select) { + character.classList.add(BulkEditOverlay.selectedClass); + if (legacyBulkEditCheckbox) legacyBulkEditCheckbox.checked = true; + this.selectCharacter(characterId); + } else { + character.classList.remove(BulkEditOverlay.selectedClass); + if (legacyBulkEditCheckbox) legacyBulkEditCheckbox.checked = false; + this.dismissCharacter(characterId); + } + + this.updateSelectedCount(); + + if (markState) { + this.lastSelected.characterId = characterId; + this.lastSelected.select = select; + } + }; + + updateSelectedCount = (countOverride = undefined) => { + const count = countOverride ?? this.selectedCharacters.length; + $(`#${BulkEditOverlay.bulkSelectedCountId}`).text(count).attr('title', `${count} characters selected`); + }; + + selectCharactersInRange = (currentCharacter, select) => { + const currentCharacterId = currentCharacter.getAttribute('chid'); + const characters = Array.from(document.querySelectorAll('#' + BulkEditOverlay.containerId + ' .' + BulkEditOverlay.characterClass)); + + const startIndex = characters.findIndex(c => c.getAttribute('chid') === this.lastSelected.characterId); + const endIndex = characters.findIndex(c => c.getAttribute('chid') === currentCharacterId); + + for (let i = Math.min(startIndex, endIndex); i <= Math.max(startIndex, endIndex); i++) { + const character = characters[i]; + const characterId = character.getAttribute('chid'); + const isCharacterSelected = this.selectedCharacters.includes(characterId); + + if (select && !isCharacterSelected || !select && isCharacterSelected) { + this.toggleSingleCharacter(character, { markState: currentCharacterId == i }); + } + } + }; + handleContextMenuShow = (event) => { event.preventDefault(); CharacterContextMenu.show(...this.#getContextMenuPosition(event)); diff --git a/public/scripts/bulk-edit.js b/public/scripts/bulk-edit.js index 7cb0d17b9..95d0d79bb 100644 --- a/public/scripts/bulk-edit.js +++ b/public/scripts/bulk-edit.js @@ -1,4 +1,4 @@ -import { characters, getCharacters, handleDeleteCharacter, callPopup } from '../script.js'; +import { characters, getCharacters, handleDeleteCharacter, callPopup, characterGroupOverlay } from '../script.js'; import { BulkEditOverlay, BulkEditOverlayState } from './BulkEditOverlay.js'; @@ -6,18 +6,20 @@ let is_bulk_edit = false; const enableBulkEdit = () => { enableBulkSelect(); - (new BulkEditOverlay()).selectState(); - // show the delete button - $('#bulkDeleteButton').show(); + characterGroupOverlay.selectState(); + // show the bulk edit option buttons + $('.bulkEditOptionElement').show(); is_bulk_edit = true; + characterGroupOverlay.updateSelectedCount(0); }; const disableBulkEdit = () => { disableBulkSelect(); - (new BulkEditOverlay()).browseState(); - // hide the delete button - $('#bulkDeleteButton').hide(); + characterGroupOverlay.browseState(); + // hide the bulk edit option buttons + $('.bulkEditOptionElement').hide(); is_bulk_edit = false; + characterGroupOverlay.updateSelectedCount(0); }; const toggleBulkEditMode = (isBulkEdit) => { @@ -41,6 +43,32 @@ function onEditButtonClick() { toggleBulkEditMode(is_bulk_edit); } +/** + * Toggles the select state of all characters in bulk edit mode to selected. If all are selected, they'll be deselected. + */ +function onSelectAllButtonClick() { + console.log('Bulk select all button clicked'); + const characters = Array.from(document.querySelectorAll('#' + BulkEditOverlay.containerId + ' .' + BulkEditOverlay.characterClass)); + let atLeastOneSelected = false; + for (const character of characters) { + const checked = $(character).find('.bulk_select_checkbox:checked').length > 0; + if (!checked) { + characterGroupOverlay.toggleSingleCharacter(character); + atLeastOneSelected = true; + } + } + + if (!atLeastOneSelected) { + // 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) { + characterGroupOverlay.toggleSingleCharacter(character); + } + } + } +} + /** * Deletes the character with the given chid. * @@ -89,6 +117,10 @@ async function onDeleteButtonClick() { */ function enableBulkSelect() { $('#rm_print_characters_block .character_select').each((i, el) => { + // Prevent checkbox from adding multiple times (because of stage change callback) + if ($(el).find('.bulk_select_checkbox').length > 0) { + return; + } const checkbox = $(''); checkbox.on('change', () => { // Do something when the checkbox is changed @@ -115,5 +147,6 @@ function disableBulkSelect() { */ jQuery(() => { $('#bulkEditButton').on('click', onEditButtonClick); + $('#bulkSelectAllButton').on('click', onSelectAllButtonClick); $('#bulkDeleteButton').on('click', onDeleteButtonClick); }); diff --git a/public/style.css b/public/style.css index 6817bcc45..9efcbc55f 100644 --- a/public/style.css +++ b/public/style.css @@ -2126,6 +2126,10 @@ grammarly-extension { background-color: var(--crimson-hover); } +#bulk_tags_avatars_block { + max-height: 70vh; +} + #dialogue_popup_input { margin: 10px 0; width: 100%; From 167673fcf56f164c6c6adb10b6917ce1199c02fc Mon Sep 17 00:00:00 2001 From: Wolfsblvt Date: Fri, 29 Mar 2024 04:41:16 +0100 Subject: [PATCH 04/16] Updated code documentation - Updated code documentation for all methods added/changed with this PR - Expanded tooltip to "bulk edit" to explain how it works --- public/index.html | 2 +- public/scripts/BulkEditOverlay.js | 74 ++++++++++++++++++++++--------- public/scripts/tags.js | 14 +++--- 3 files changed, 63 insertions(+), 27 deletions(-) diff --git a/public/index.html b/public/index.html index 8b2ee23a5..098ceb30d 100644 --- a/public/index.html +++ b/public/index.html @@ -4327,7 +4327,7 @@
- +
diff --git a/public/scripts/BulkEditOverlay.js b/public/scripts/BulkEditOverlay.js index 8da4dc86b..48d95e68b 100644 --- a/public/scripts/BulkEditOverlay.js +++ b/public/scripts/BulkEditOverlay.js @@ -40,7 +40,7 @@ class CharacterContextMenu { * Tag one or more characters, * opens a popup. * - * @param selectedCharacters + * @param {Array} selectedCharacters */ static tag = (selectedCharacters) => { BulkTagPopupHandler.show(selectedCharacters); @@ -49,7 +49,7 @@ class CharacterContextMenu { /** * Duplicate one or more characters * - * @param characterId + * @param {number} characterId * @returns {Promise} */ static duplicate = async (characterId) => { @@ -74,7 +74,7 @@ class CharacterContextMenu { * Favorite a character * and highlight it. * - * @param characterId + * @param {number} characterId * @returns {Promise} */ static favorite = async (characterId) => { @@ -110,7 +110,7 @@ class CharacterContextMenu { * Convert one or more characters to persona, * may open a popup for one or more characters. * - * @param characterId + * @param {number} characterId * @returns {Promise} */ static persona = async (characterId) => await convertCharacterToPersona(characterId); @@ -119,8 +119,8 @@ class CharacterContextMenu { * Delete one or more characters, * opens a popup. * - * @param characterId - * @param deleteChats + * @param {number} characterId + * @param {boolean} [deleteChats] * @returns {Promise} */ static delete = async (characterId, deleteChats = false) => { @@ -234,7 +234,7 @@ class BulkTagPopupHandler { /** * Append and show the tag control * - * @param characterIds - The characters assigned to this control + * @param {Array} characterIds - The characters assigned to this control */ static show(characterIds) { if (characterIds.length == 0) { @@ -250,7 +250,7 @@ class BulkTagPopupHandler { // 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 } }); - // Tag input with empty tags so new tag gets added and it doesn't get emptied on redraw + // 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 }}); document.querySelector('#bulk_tag_popup_reset').addEventListener('click', this.resetTags.bind(this, characterIds)); @@ -258,6 +258,12 @@ class BulkTagPopupHandler { 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) { return []; @@ -293,7 +299,7 @@ class BulkTagPopupHandler { /** * Empty the tag map for the given characters * - * @param characterIds + * @param {Array} characterIds */ static resetTags(characterIds) { for (const characterId of characterIds) { @@ -307,9 +313,9 @@ class BulkTagPopupHandler { } /** - * Empty the tag map for the given characters + * Remove the mutual tags for all given characters * - * @param characterIds + * @param {Array} characterIds */ static removeMutual(characterIds) { const mutualTags = this.getMutualTags(characterIds); @@ -627,6 +633,15 @@ class BulkEditOverlay { 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 = currentCharacter.getAttribute('chid'); const select = !this.selectedCharacters.includes(characterId); @@ -634,11 +649,18 @@ class BulkEditOverlay { if (this.lastSelected.characterId && this.lastSelected.select !== undefined) { // Only if select state and the last select state match we execute the range select if (select === this.lastSelected.select) { - this.selectCharactersInRange(currentCharacter, select); + this.toggleCharactersInRange(currentCharacter, select); } } }; + /** + * Toggles the selection of a given characters + * + * @param {HTMLElement} character - The html element of a character + * @param {object} param1 - Optional params + * @param {boolean} [param1.markState] - Whether the toggle of this character should be remembered as the last done toggle + */ toggleSingleCharacter = (character, { markState = true } = {}) => { const characterId = character.getAttribute('chid'); @@ -648,11 +670,11 @@ class BulkEditOverlay { if (select) { character.classList.add(BulkEditOverlay.selectedClass); if (legacyBulkEditCheckbox) legacyBulkEditCheckbox.checked = true; - this.selectCharacter(characterId); + this.#selectedCharacters.push(String(characterId)); } else { character.classList.remove(BulkEditOverlay.selectedClass); if (legacyBulkEditCheckbox) legacyBulkEditCheckbox.checked = false; - this.dismissCharacter(characterId); + this.#selectedCharacters = this.#selectedCharacters.filter(item => String(characterId) !== item) } this.updateSelectedCount(); @@ -663,12 +685,24 @@ class BulkEditOverlay { } }; + /** + * 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`); }; - selectCharactersInRange = (currentCharacter, select) => { + /** + * 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 - true if the characters in the range are to be selected, false if deselected + */ + toggleCharactersInRange = (currentCharacter, select) => { const currentCharacterId = currentCharacter.getAttribute('chid'); const characters = Array.from(document.querySelectorAll('#' + BulkEditOverlay.containerId + ' .' + BulkEditOverlay.characterClass)); @@ -680,8 +714,10 @@ class BulkEditOverlay { const characterId = character.getAttribute('chid'); const isCharacterSelected = this.selectedCharacters.includes(characterId); - if (select && !isCharacterSelected || !select && isCharacterSelected) { - this.toggleSingleCharacter(character, { markState: currentCharacterId == i }); + // 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 }); } } }; @@ -771,10 +807,6 @@ class BulkEditOverlay { addStateChangeCallback = callback => this.stateChangeCallbacks.push(callback); - selectCharacter = characterId => this.selectedCharacters.push(String(characterId)); - - dismissCharacter = characterId => this.#selectedCharacters = this.selectedCharacters.filter(item => String(characterId) !== item); - /** * Clears internal character storage and * removes visual highlight. diff --git a/public/scripts/tags.js b/public/scripts/tags.js index 8895545e8..816087d73 100644 --- a/public/scripts/tags.js +++ b/public/scripts/tags.js @@ -320,7 +320,8 @@ function getTagKey() { /** * Gets the tag key for any provided entity/id/key. If a valid tag key is provided, it just returns this. - * Robust method to find a valid tag key for any entity + * Robust method to find a valid tag key for any entity. + * * @param {object|number|string} entityOrKey An entity with id property (character, group, tag), or directly an id or tag key. * @returns {string} The tag key that can be found. */ @@ -394,9 +395,10 @@ function findTag(request, resolve, listSelector) { } /** - * Select a tag and add it to the list. This function is mostly used as an event handler for the tag selector control. - * @param {*} event - - * @param {*} ui - + * Select a tag and add it to the list. This function is (mostly) used as an event handler for the tag selector control. + * + * @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. * @returns {boolean} false, to keep the input clear @@ -529,7 +531,8 @@ function createNewTag(tagName) { */ /** - * Prints the list of tags. + * Prints the list of tags + * * @param {JQuery} element - The container element where the tags are to be printed. * @param {PrintTagListOptions} [options] - Optional parameters for printing the tag list. */ @@ -798,6 +801,7 @@ function applyTagsOnGroupSelect() { } /** + * Create a tag input by enabling the autocomplete feature of a given input element. Tags will be added to the given list. * * @param {string} inputSelector - the selector for the tag input control * @param {string} listSelector - the selector for the list of the tags modified by the input control From bf8b6b80d70b0bd779625351c033e8202daee9f2 Mon Sep 17 00:00:00 2001 From: Wolfsblvt Date: Fri, 29 Mar 2024 05:53:26 +0100 Subject: [PATCH 05/16] Refactor and improve bulk delete popup - Improve bulk edit popup with display of avatars and better format - Refactor both calls of bulk delete to use the same method - Add display of filename on avatar hover for inline avatars (@Cohee you forgot this one (: ) --- public/script.js | 2 +- public/scripts/BulkEditOverlay.js | 59 +++++++++++++++++++++---------- public/scripts/bulk-edit.js | 28 ++------------- public/style.css | 2 +- 4 files changed, 45 insertions(+), 46 deletions(-) diff --git a/public/script.js b/public/script.js index 9093abf67..e9bd89da0 100644 --- a/public/script.js +++ b/public/script.js @@ -5357,7 +5357,7 @@ function buildAvatarList(block, entities, { templateId = 'inline_avatar_template avatarTemplate.attr('data-type', entity.type); avatarTemplate.attr({ 'chid': id, 'id': `CharID${id}` }); avatarTemplate.find('img').attr('src', this_avatar).attr('alt', entity.item.name); - avatarTemplate.attr('title', `[Character] ${entity.item.name}`); + avatarTemplate.attr('title', `[Character] ${entity.item.name}\nFile: ${entity.item.avatar}`); if (highlightFavs) { avatarTemplate.toggleClass('is_fav', entity.item.fav || entity.item.fav == 'true'); avatarTemplate.find('.ch_fav').val(entity.item.fav); diff --git a/public/scripts/BulkEditOverlay.js b/public/scripts/BulkEditOverlay.js index 48d95e68b..19955e7ac 100644 --- a/public/scripts/BulkEditOverlay.js +++ b/public/scripts/BulkEditOverlay.js @@ -19,18 +19,6 @@ import { hideLoader, showLoader } from './loader.js'; import { convertCharacterToPersona } from './personas.js'; import { createTagInput, getTagKeyForEntity, getTagsList, printTagList, tag_map, compareTagsForSort, removeTagFromMap } from './tags.js'; -// Utility object for popup messages. -const popupMessage = { - deleteChat(characterCount) { - return `

Delete ${characterCount} characters?

- THIS IS PERMANENT!

-
`; - }, -}; - /** * Static object representing the actions of the * character context menu override. @@ -198,6 +186,12 @@ class CharacterContextMenu { * Represents a tag control not bound to a single character */ class BulkTagPopupHandler { + /** + * 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 }); return `
@@ -227,8 +221,7 @@ class BulkTagPopupHandler {
- - `; + `; }; /** @@ -430,7 +423,7 @@ class BulkEditOverlay { /** * - * @returns {*[]} + * @returns {number[]} */ get selectedCharacters() { return this.#selectedCharacters; @@ -775,6 +768,29 @@ class BulkEditOverlay { this.browseState(); }; + /** + * Gets the HTML as a string that is displayed inside the popup for the bulk delete + * + * @param {Array} characterIds - The characters that are shown inside the popup + * @returns String containing the html for the popup content + */ + static #getDeletePopupContentHtml = (characterIds) => { + return ` +

Delete ${characterIds.length} characters?

+ + + THIS IS PERMANENT! + +
+
+
+ +
`; + } + /** * Request user input before concurrently handle deletion * requests. @@ -782,8 +798,9 @@ class BulkEditOverlay { * @returns {Promise} */ handleContextMenuDelete = () => { - callPopup( - popupMessage.deleteChat(this.selectedCharacters.length), null) + const characterIds = this.selectedCharacters; + const popupContent = BulkEditOverlay.#getDeletePopupContentHtml(characterIds); + const promise = callPopup(popupContent, null) .then((accept) => { if (true !== accept) return; @@ -791,11 +808,17 @@ class BulkEditOverlay { showLoader(); toastr.info('We\'re deleting your characters, please wait...', 'Working on it'); - Promise.allSettled(this.selectedCharacters.map(async characterId => CharacterContextMenu.delete(characterId, deleteChats))) + return Promise.allSettled(characterIds.map(async characterId => CharacterContextMenu.delete(characterId, deleteChats))) .then(() => getCharacters()) .then(() => this.browseState()) .finally(() => hideLoader()); }); + + // At this moment the popup is already changed in the dom, but not yet closed/resolved. We build the avatar list here + const entities = characterIds.map(id => characterToEntity(characters[id], id)).filter(entity => entity.item !== undefined); + buildAvatarList($('#bulk_delete_avatars_block'), entities); + + return promise; }; /** diff --git a/public/scripts/bulk-edit.js b/public/scripts/bulk-edit.js index 95d0d79bb..c266e148b 100644 --- a/public/scripts/bulk-edit.js +++ b/public/scripts/bulk-edit.js @@ -84,32 +84,8 @@ async function deleteCharacter(this_chid) { async function onDeleteButtonClick() { console.log('Delete button clicked'); - // Create a mapping of chid to avatar - let toDelete = []; - $('.bulk_select_checkbox:checked').each((i, el) => { - const chid = $(el).parent().attr('chid'); - const avatar = characters[chid].avatar; - // Add the avatar to the list of avatars to delete - toDelete.push(avatar); - }); - - const confirm = await callPopup('

Are you sure you want to delete these characters?

You would need to delete the chat files manually.
', 'confirm'); - - if (!confirm) { - console.log('User cancelled delete'); - return; - } - - // Delete the characters - for (const avatar of toDelete) { - console.log(`Deleting character with avatar ${avatar}`); - await getCharacters(); - - //chid should be the key of the character with the given avatar - const chid = Object.keys(characters).find((key) => characters[key].avatar === avatar); - console.log(`Deleting character with chid ${chid}`); - await deleteCharacter(chid); - } + // We just let the button trigger the context menu delete option + await characterGroupOverlay.handleContextMenuDelete(); } /** diff --git a/public/style.css b/public/style.css index f8aecba0c..e6218e820 100644 --- a/public/style.css +++ b/public/style.css @@ -3135,7 +3135,7 @@ body.big-avatars .missing-avatar { } } -span.warning { +.warning { color: var(--warning); font-weight: bolder; } From 6a688cc3835404907034ec935b3fce4524ec2f4c Mon Sep 17 00:00:00 2001 From: Cohee <18619528+Cohee1207@users.noreply.github.com> Date: Fri, 29 Mar 2024 18:07:45 +0200 Subject: [PATCH 06/16] Add fallback if tag_map is uninitialized --- public/scripts/tags.js | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/public/scripts/tags.js b/public/scripts/tags.js index 816087d73..a20f0ff40 100644 --- a/public/scripts/tags.js +++ b/public/scripts/tags.js @@ -339,6 +339,12 @@ export function getTagKeyForEntity(entityOrKey) { x = character.avatar; } + // Uninitialized character tag map + if (character && !(x in tag_map)) { + tag_map[x] = []; + return x; + } + // We should hopefully have a key now. Let's check if (x in tag_map) { return x; @@ -349,7 +355,7 @@ export function getTagKeyForEntity(entityOrKey) { } function addTagToMap(tagId, characterId = null) { - const key = getTagKey() ?? getTagKeyForEntity(characterId); + const key = characterId !== null && characterId !== undefined ? getTagKeyForEntity(characterId) : getTagKey(); if (!key) { return; @@ -365,7 +371,7 @@ function addTagToMap(tagId, characterId = null) { } function removeTagFromMap(tagId, characterId = null) { - const key = getTagKey() ?? getTagKeyForEntity(characterId); + const key = characterId !== null && characterId !== undefined ? getTagKeyForEntity(characterId) : getTagKey(); if (!key) { return; From 80f4bd4d9efefd65f39454ea6598ee662b1b0113 Mon Sep 17 00:00:00 2001 From: Wolfsblvt Date: Sat, 30 Mar 2024 03:06:40 +0100 Subject: [PATCH 07/16] 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(); } From ea4ba57408869eb1dbecd7189ec5ad060cddc915 Mon Sep 17 00:00:00 2001 From: Wolfsblvt Date: Sat, 30 Mar 2024 05:41:54 +0100 Subject: [PATCH 08/16] Fix horizontal scrollbar appearing in popups - Fix that annoying horizontal scrollbar appearing in popups, e.g. the tag popup when you drag tags around - Still provide possibility to make popups actually utilize scrollbars --- public/script.js | 7 ++++--- public/style.css | 9 +++++++++ 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/public/script.js b/public/script.js index 9df870852..9057cfec3 100644 --- a/public/script.js +++ b/public/script.js @@ -6810,18 +6810,19 @@ function onScenarioOverrideRemoveClick() { * @param {string} type * @param {string} inputValue - Value to set the input to. * @param {PopupOptions} options - Options for the popup. - * @typedef {{okButton?: string, rows?: number, wide?: boolean, large?: boolean }} PopupOptions - Options for the popup. + * @typedef {{okButton?: string, rows?: number, wide?: boolean, large?: boolean, allowHorizontalScrolling?: boolean, allowVerticalScrolling?: boolean }} PopupOptions - Options for the popup. * @returns */ -function callPopup(text, type, inputValue = '', { okButton, rows, wide, large } = {}) { +function callPopup(text, type, inputValue = '', { okButton, rows, wide, large, allowHorizontalScrolling, allowVerticalScrolling } = {}) { dialogueCloseStop = true; if (type) { popup_type = type; } $('#dialogue_popup').toggleClass('wide_dialogue_popup', !!wide); - $('#dialogue_popup').toggleClass('large_dialogue_popup', !!large); + $('#dialogue_popup').toggleClass('horizontal_scrolling_dialogue_popup', !!allowHorizontalScrolling); + $('#dialogue_popup').toggleClass('vertical_scrolling_dialogue_popup', !!allowVerticalScrolling); $('#dialogue_popup_cancel').css('display', 'inline-block'); switch (popup_type) { diff --git a/public/style.css b/public/style.css index e6218e820..90c04de9f 100644 --- a/public/style.css +++ b/public/style.css @@ -2081,6 +2081,7 @@ grammarly-extension { display: flex; flex-direction: column; overflow-y: hidden; + overflow-x: hidden; } .rm_stat_block { @@ -2101,6 +2102,14 @@ grammarly-extension { min-width: var(--sheldWidth); } +.horizontal_scrolling_dialogue_popup { + overflow-x: unset !important; +} + +.vertical_scrolling_dialogue_popup { + overflow-y: unset !important; +} + #bulk_tag_popup_holder, #dialogue_popup_holder { display: flex; From b747bdf89ba4e3b10ce299bcdfc27e36e9aa2796 Mon Sep 17 00:00:00 2001 From: Cohee <18619528+Cohee1207@users.noreply.github.com> Date: Sat, 30 Mar 2024 13:46:46 +0200 Subject: [PATCH 09/16] Fix nav styles for narrower screens --- public/style.css | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/public/style.css b/public/style.css index 90c04de9f..711d4fcca 100644 --- a/public/style.css +++ b/public/style.css @@ -3887,6 +3887,7 @@ body:not(.movingUI) .drawer-content.maximized { .paginationjs-size-changer select { width: unset; margin: 0; + font-size: calc(var(--mainFontSize) * 0.85); } .paginationjs-pages ul li a { @@ -3916,10 +3917,10 @@ body:not(.movingUI) .drawer-content.maximized { } .paginationjs-nav { - padding: 5px; + padding: 2px; font-size: calc(var(--mainFontSize) * .8); font-weight: bold; - width: max-content; + width: auto; } .onboarding { From 6fe7c1fdaf873f85f8a064cdbb35b1f7e6aa60c9 Mon Sep 17 00:00:00 2001 From: Wolfsblvt Date: Sat, 30 Mar 2024 20:33:08 +0100 Subject: [PATCH 10/16] Fix reprint loop on tag filters - Fix endless loop if a tag was selected - Tag selection is now saved, both 'selected' and 'excluded' (old state is lost though) - Streamlined reprinting even more by refactoring bogus drilldown --- public/scripts/filters.js | 3 +- public/scripts/tags.js | 61 +++++++++++++++++---------------------- 2 files changed, 29 insertions(+), 35 deletions(-) diff --git a/public/scripts/filters.js b/public/scripts/filters.js index d880cbf37..92ed6992c 100644 --- a/public/scripts/filters.js +++ b/public/scripts/filters.js @@ -24,6 +24,7 @@ export const FILTER_STATES = { EXCLUDED: { key: 'EXCLUDED', class: 'excluded' }, UNDEFINED: { key: 'UNDEFINED', class: 'undefined' }, }; +export const DEFAULT_FILTER_STATE = FILTER_STATES.UNDEFINED.key; /** * Robust check if one state equals the other. It does not care whether it's the state key or the state value object. @@ -203,7 +204,7 @@ export class FilterHelper { return this.filterDataByState(data, state, isFolder); } - filterDataByState(data, state, filterFunc, { includeFolders } = {}) { + filterDataByState(data, state, filterFunc, { includeFolders = false } = {}) { if (isFilterState(state, FILTER_STATES.SELECTED)) { return data.filter(entity => filterFunc(entity) || (includeFolders && entity.type == 'tag')); } diff --git a/public/scripts/tags.js b/public/scripts/tags.js index 8c38ec864..dddc3c337 100644 --- a/public/scripts/tags.js +++ b/public/scripts/tags.js @@ -13,7 +13,7 @@ import { event_types, } from '../script.js'; // eslint-disable-next-line no-unused-vars -import { FILTER_TYPES, FILTER_STATES, isFilterState, FilterHelper } from './filters.js'; +import { FILTER_TYPES, FILTER_STATES, DEFAULT_FILTER_STATE, isFilterState, FilterHelper } from './filters.js'; import { groupCandidatesFilter, groups, selected_group } from './group-chats.js'; import { download, onlyUnique, parseJsonFile, uuidv4, getSortableDelay, debounce } from './utils.js'; @@ -180,6 +180,7 @@ function isBogusFolderOpen() { /** * Function to be called when a specific tag/folder is chosen to "drill down". + * * @param {*} source The jQuery element clicked when choosing the folder * @param {string} tagId The tag id that is behind the chosen folder * @param {boolean} remove Whether the given tag should be removed (otherwise it is added/chosen) @@ -197,12 +198,9 @@ function chooseBogusFolder(source, tagId, remove = false) { // Instead of manually updating the filter conditions, we just "click" on the filter tag // We search inside which filter block we are located in and use that one const FILTER_SELECTOR = ($(source).closest('#rm_characters_block') ?? $(source).closest('#rm_group_chats_block')).find('.rm_tag_filter'); - if (remove) { - // Click twice to skip over the 'excluded' state - $(FILTER_SELECTOR).find(`.tag[id=${tagId}]`).trigger('click').trigger('click'); - } else { - $(FILTER_SELECTOR).find(`.tag[id=${tagId}]`).trigger('click'); - } + const tagElement = $(FILTER_SELECTOR).find(`.tag[id=${tagId}]`); + + toggleTagThreeState(tagElement, { stateOverride: DEFAULT_FILTER_STATE, simulateClick: true }); } /** @@ -603,8 +601,8 @@ function appendTagToList(listElement, tag, { removable = false, selectable = fal tagElement.find('.tag_name').text('').attr('title', tag.name).addClass(tag.icon); } - if (tag.excluded && isGeneralList) { - toggleTagThreeState(tagElement, { stateOverride: FILTER_STATES.EXCLUDED }); + if (selectable && isGeneralList) { + toggleTagThreeState(tagElement, { stateOverride: tag.filterState ?? DEFAULT_FILTER_STATE }); } if (selectable) { @@ -629,34 +627,28 @@ function onTagFilterClick(listElement) { let state = toggleTagThreeState($(this)); - // Manual undefined check required for three-state boolean if (existingTag) { - existingTag.excluded = isFilterState(state, FILTER_STATES.EXCLUDED); - + existingTag.filterState = state; saveSettingsDebounced(); } - // Update bogus folder if applicable - if (isBogusFolder(existingTag)) { - // Update bogus drilldown - if ($(this).hasClass('selected')) { - appendTagToList($('.rm_tag_controls .rm_tag_bogus_drilldown'), existingTag, { removable: true }); - } else { - $(listElement).closest('.rm_tag_controls').find(`.rm_tag_bogus_drilldown .tag[id=${tagId}]`).remove(); - } - } - + // We don't print anything manually, updating the filter will automatically trigger a redraw of all relevant stuff runTagFilters(listElement); - updateTagFilterIndicator(); } function toggleTagThreeState(element, { stateOverride = undefined, simulateClick = false } = {}) { const states = Object.keys(FILTER_STATES); + // Make it clear we're getting indexes and handling the 'not found' case in one place + function getStateIndex(key, fallback) { + const index = states.indexOf(key); + return index !== -1 ? index : states.indexOf(fallback); + } + const overrideKey = states.includes(stateOverride) ? stateOverride : Object.keys(FILTER_STATES).find(key => FILTER_STATES[key] === stateOverride); - const currentStateIndex = states.indexOf(element.attr('data-toggle-state')) ?? states.length - 1; - const targetStateIndex = overrideKey !== undefined ? states.indexOf(overrideKey) : (currentStateIndex + 1) % states.length; + const currentStateIndex = getStateIndex(element.attr('data-toggle-state'), DEFAULT_FILTER_STATE); + const targetStateIndex = overrideKey !== undefined ? getStateIndex(overrideKey, DEFAULT_FILTER_STATE) : (currentStateIndex + 1) % states.length; if (simulateClick) { // Calculate how many clicks are needed to go from the current state to the target state @@ -695,10 +687,8 @@ function runTagFilters(listElement) { } function printTagFilters(type = tag_filter_types.character) { - const filterData = structuredClone(entitiesFilter.getFilterData(FILTER_TYPES.TAG)); const FILTER_SELECTOR = type === tag_filter_types.character ? CHARACTER_FILTER_SELECTOR : GROUP_FILTER_SELECTOR; $(FILTER_SELECTOR).empty(); - $(FILTER_SELECTOR).siblings('.rm_tag_bogus_drilldown').empty(); // Print all action tags. (Exclude folder if that setting isn't chosen) const actionTags = Object.values(ACTIONABLE_TAGS).filter(tag => power_user.bogus_folders || tag.id != ACTIONABLE_TAGS.FOLDER.id); @@ -708,18 +698,21 @@ function printTagFilters(type = tag_filter_types.character) { printTagList($(FILTER_SELECTOR), { empty: false, tags: inListActionTags, tagActionSelector: tag => tag.action, tagOptions: { isGeneralList: true } }); const characterTagIds = Object.values(tag_map).flat(); - const tagsToDisplay = tags - .filter(x => characterTagIds.includes(x.id)) - .sort(compareTagsForSort); + const tagsToDisplay = tags.filter(x => characterTagIds.includes(x.id)).sort(compareTagsForSort); printTagList($(FILTER_SELECTOR), { empty: false, tags: tagsToDisplay, tagOptions: { selectable: true, isGeneralList: true } }); - runTagFilters(FILTER_SELECTOR); + // Print bogus folder navigation + const bogusDrilldown = $(FILTER_SELECTOR).siblings('.rm_tag_bogus_drilldown'); + bogusDrilldown.empty(); + if (power_user.bogus_folders && bogusDrilldown.length > 0) { + const filterData = structuredClone(entitiesFilter.getFilterData(FILTER_TYPES.TAG)); + const navigatedTags = filterData.selected.map(x => tags.find(t => t.id == x)).filter(x => isBogusFolder(x)); - // Simulate clicks on all "selected" tags when we reprint, otherwise their filter gets lost. "excluded" is persisted. - for (const tagId of filterData.selected) { - toggleTagThreeState($(`${FILTER_SELECTOR} .tag[id="${tagId}"]`), { stateOverride: FILTER_STATES.SELECTED, simulateClick: true }); + printTagList(bogusDrilldown, { tags: navigatedTags, tagOptions: { removable: true } }); } + runTagFilters(FILTER_SELECTOR); + if (power_user.show_tag_filters) { $('.rm_tag_controls .showTagList').addClass('selected'); $('.rm_tag_controls').find('.tag:not(.actionable)').show(); From 71a630ad8500f2d83532c7d6a943f05c2224ec06 Mon Sep 17 00:00:00 2001 From: Wolfsblvt Date: Sat, 30 Mar 2024 22:06:50 +0100 Subject: [PATCH 11/16] Code documentation for tags & bogus state - Add lots of code documentation for tag functions (I'm sorry, I live in object oriented languages...) - Fix bogus folder setting not being respected for some controls --- public/script.js | 43 +++++++++++++- public/scripts/tags.js | 125 +++++++++++++++++++++++++++++++++-------- 2 files changed, 145 insertions(+), 23 deletions(-) diff --git a/public/script.js b/public/script.js index 9b5438791..8ed0323e2 100644 --- a/public/script.js +++ b/public/script.js @@ -1324,7 +1324,7 @@ async function printCharacters(fullRefresh = false) { showNavigator: true, callback: function (data) { $(listId).empty(); - if (isBogusFolderOpen()) { + if (power_user.bogus_folders && isBogusFolderOpen()) { $(listId).append(getBackBlock()); } if (!data.length) { @@ -1368,18 +1368,59 @@ async function printCharacters(fullRefresh = false) { favsToHotswap(); } +/** @typedef {object} Character - A character */ +/** @typedef {object} Group - A group */ + +/** + * @typedef {object} Entity - Object representing a display entity + * @property {Character|Group|import('./scripts/tags.js').Tag|*} item - The item + * @property {string|number} id - The id + * @property {string} type - The type of this entity (character, group, tag) + * @property {Entity[]} [entities] - An optional list of entities relevant for this item + * @property {number} [hidden] - An optional number representing how many hidden entities this entity contains + */ + +/** + * Converts the given character to its entity representation + * + * @param {Character} character - The character + * @param {string|number} id - The id of this character + * @returns {Entity} The entity for this character + */ export function characterToEntity(character, id) { return { item: character, id, type: 'character' }; } +/** + * Converts the given group to its entity representation + * + * @param {Group} group - The group + * @returns {Entity} The entity for this group + */ export function groupToEntity(group) { return { item: group, id: group.id, type: 'group' }; } +/** + * Converts the given tag to its entity representation + * + * @param {import('./scripts/tags.js').Tag} tag - The tag + * @returns {Entity} The entity for this tag + */ export function tagToEntity(tag) { return { item: structuredClone(tag), id: tag.id, type: 'tag', entities: [] }; } +/** + * Builds the full list of all entities available + * + * They will be correctly marked and filtered. + * + * @param {object} param0 - Optional parameters + * @param {boolean} [param0.doFilter] - Whether this entity list should already be filtered based on the global filters + * @param {boolean} [param0.doSort] - Whether the entity list should be sorted when returned + * @returns {Entity[]} All entities + */ export function getEntitiesList({ doFilter = false, doSort = true } = {}) { let entities = [ ...characters.map((item, index) => characterToEntity(item, index)), diff --git a/public/scripts/tags.js b/public/scripts/tags.js index dddc3c337..a33b338f0 100644 --- a/public/scripts/tags.js +++ b/public/scripts/tags.js @@ -6,7 +6,6 @@ import { menu_type, getCharacters, entitiesFilter, - printCharacters, printCharactersDebounced, buildAvatarList, eventSource, @@ -55,12 +54,12 @@ export const tag_filter_types = { }; const ACTIONABLE_TAGS = { - FAV: { id: 1, sort_order: 1, name: 'Show only favorites', color: 'rgba(255, 255, 0, 0.5)', action: filterByFav, icon: 'fa-solid fa-star', class: 'filterByFavorites' }, - GROUP: { id: 0, sort_order: 2, name: 'Show only groups', color: 'rgba(100, 100, 100, 0.5)', action: filterByGroups, icon: 'fa-solid fa-users', class: 'filterByGroups' }, - FOLDER: { id: 4, sort_order: 3, name: 'Always show folders', color: 'rgba(120, 120, 120, 0.5)', action: filterByFolder, icon: 'fa-solid fa-folder-plus', class: 'filterByFolder' }, - VIEW: { id: 2, sort_order: 4, name: 'Manage tags', color: 'rgba(150, 100, 100, 0.5)', action: onViewTagsListClick, icon: 'fa-solid fa-gear', class: 'manageTags' }, - HINT: { id: 3, sort_order: 5, name: 'Show Tag List', color: 'rgba(150, 100, 100, 0.5)', action: onTagListHintClick, icon: 'fa-solid fa-tags', class: 'showTagList' }, - UNFILTER: { id: 5, sort_order: 6, name: 'Clear all filters', action: onClearAllFiltersClick, icon: 'fa-solid fa-filter-circle-xmark', class: 'clearAllFilters' }, + FAV: { id: "1", sort_order: 1, name: 'Show only favorites', color: 'rgba(255, 255, 0, 0.5)', action: filterByFav, icon: 'fa-solid fa-star', class: 'filterByFavorites' }, + GROUP: { id: "0", sort_order: 2, name: 'Show only groups', color: 'rgba(100, 100, 100, 0.5)', action: filterByGroups, icon: 'fa-solid fa-users', class: 'filterByGroups' }, + FOLDER: { id: "4", sort_order: 3, name: 'Always show folders', color: 'rgba(120, 120, 120, 0.5)', action: filterByFolder, icon: 'fa-solid fa-folder-plus', class: 'filterByFolder' }, + VIEW: { id: "2", sort_order: 4, name: 'Manage tags', color: 'rgba(150, 100, 100, 0.5)', action: onViewTagsListClick, icon: 'fa-solid fa-gear', class: 'manageTags' }, + HINT: { id: "3", sort_order: 5, name: 'Show Tag List', color: 'rgba(150, 100, 100, 0.5)', action: onTagListHintClick, icon: 'fa-solid fa-tags', class: 'showTagList' }, + UNFILTER: { id: "5", sort_order: 6, name: 'Clear all filters', action: onClearAllFiltersClick, icon: 'fa-solid fa-filter-circle-xmark', class: 'clearAllFilters' }, }; const InListActionable = { @@ -82,8 +81,31 @@ const TAG_FOLDER_TYPES = { }; const TAG_FOLDER_DEFAULT_TYPE = 'NONE'; +/** + * @typedef {object} Tag - Object representing a tag + * @property {string} id - The id of the tag (As a kind of has string. This is used whenever the tag is referenced or linked, as the name might change) + * @property {string} name - The name of the tag + * @property {string} [folder_type] - The bogus folder type of this tag (based on `TAG_FOLDER_TYPES`) + * @property {string} [filter_state] - The saved state of the filter chosen of this tag (based on `FILTER_STATES`) + * @property {number} [sort_order] - A custom integer representing the sort order if tags are sorted + * @property {string} [color] - The background color of the tag + * @property {string} [color2] - The foreground color of the tag + * @property {number} [create_date] - A number representing the date when this tag was created + * + * @property {string} [class] - An optional css class added to the control representing this tag when printed. Used for custom tags in the filters. + * @property {string} [icon] - An optional css class of an icon representing this tag when printed. This will replace the tag name with the icon. Used for custom tags in the filters. + */ +/** + * An list of all tags that are available + * @type {Tag[]} + */ let tags = []; + +/** + * A map representing the key of an entity (character avatar, group id, etc) with a corresponding array of tags this entity has assigned. The array might not exist if no tags were assigned yet. + * @type {Object.} + */ let tag_map = {}; /** @@ -136,6 +158,15 @@ function filterByTagState(entities, { globalDisplayFilters = false, subForEntity return entities; } +/** + * Filter a a list of entities based on a given tag, returning all entities that represent "sub entities" + * + * @param {Tag} tag - The to filter the entities for + * @param {object[]} entities - The list of possible entities (tag, group, folder) that should get filtered + * @param {object} param2 - optional parameteres + * @param {boolean} [param2.filterHidden] - Whether hidden entities should be filtered out too + * @returns {object[]} The filtered list of entities that apply to the given tag + */ function filterTagSubEntities(tag, entities, { filterHidden = true } = {}) { const filterData = structuredClone(entitiesFilter.getFilterData(FILTER_TYPES.TAG)); @@ -160,7 +191,9 @@ function filterTagSubEntities(tag, entities, { filterHidden = true } = {}) { /** * Indicates whether a given tag is defined as a folder. Meaning it's neither undefined nor 'NONE'. - * @returns {boolean} If it's a tag folder + * + * @param {Tag} tag - The tag to check + * @returns {boolean} Whether it's a tag folder */ function isBogusFolder(tag) { return tag?.folder_type !== undefined && tag.folder_type !== TAG_FOLDER_DEFAULT_TYPE; @@ -168,6 +201,7 @@ function isBogusFolder(tag) { /** * Indicates whether a user is currently in a bogus folder. + * * @returns {boolean} If currently viewing a folder */ function isBogusFolderOpen() { @@ -205,21 +239,22 @@ function chooseBogusFolder(source, tagId, remove = false) { /** * Builds the tag block for the specified item. - * @param {Object} item The tag item + * + * @param {Tag} tag The tag item * @param {*} entities The list ob sub items for this tag * @param {*} hidden A count of how many sub items are hidden * @returns The html for the tag block */ -function getTagBlock(item, entities, hidden = 0) { +function getTagBlock(tag, entities, hidden = 0) { let count = entities.length; - const tagFolder = TAG_FOLDER_TYPES[item.folder_type]; + const tagFolder = TAG_FOLDER_TYPES[tag.folder_type]; const template = $('#bogus_folder_template .bogus_folder_select').clone(); template.addClass(tagFolder.class); - template.attr({ 'tagid': item.id, 'id': `BogusFolder${item.id}` }); - template.find('.avatar').css({ 'background-color': item.color, 'color': item.color2 }).attr('title', `[Folder] ${item.name}`); - template.find('.ch_name').text(item.name).attr('title', `[Folder] ${item.name}`); + template.attr({ 'tagid': tag.id, 'id': `BogusFolder${tag.id}` }); + template.find('.avatar').css({ 'background-color': tag.color, 'color': tag.color2 }).attr('title', `[Folder] ${tag.name}`); + template.find('.ch_name').text(tag.name).attr('title', `[Folder] ${tag.name}`); template.find('.bogus_folder_hidden_counter').text(hidden > 0 ? `${hidden} hidden` : ''); template.find('.bogus_folder_counter').text(`${count} ${count != 1 ? 'characters' : 'character'}`); template.find('.bogus_folder_icon').addClass(tagFolder.fa_icon); @@ -275,6 +310,13 @@ function createTagMapFromList(listElement, key) { saveSettingsDebounced(); } +/** + * Gets a list of all tags for a given entity key. + * If you have an entity, you can get it's key easily via `getTagKeyForEntity(entity)`. + * + * @param {string} key - The key for which to get tags via the tag map + * @returns {Tag[]} A list of tags + */ function getTagsList(key) { if (!Array.isArray(tag_map[key])) { tag_map[key] = []; @@ -299,6 +341,9 @@ function getInlineListSelector() { return null; } +/** + * Gets the current tag key based on the currently selected character or group + */ function getTagKey() { if (selected_group && menu_type === 'group_edit') { return selected_group; @@ -442,6 +487,12 @@ function selectTag(event, ui, listSelector, { tagListOptions = {} } = {}) { return false; } +/** + * Get a list of existing tags matching a list of provided new tag names + * + * @param {string[]} new_tags - A list of strings representing tag names + * @returns List of existing tags + */ function getExistingTags(new_tags) { let existing_tags = []; for (let tag of new_tags) { @@ -495,11 +546,18 @@ async function importTags(imported_char) { return false; } +/** + * Creates a new tag with default properties and a randomly generated id + * + * @param {string} tagName - name of the tag + * @returns {Tag} + */ function createNewTag(tagName) { const tag = { id: uuidv4(), name: tagName, folder_type: TAG_FOLDER_DEFAULT_TYPE, + filter_state: DEFAULT_FILTER_STATE, sort_order: tags.length, color: '', color2: '', @@ -520,8 +578,8 @@ function createNewTag(tagName) { /** * @typedef {object} PrintTagListOptions - Optional parameters for printing the tag list. - * @property {Array|function(): Array} [tags=undefined] - Optional override of tags that should be printed. Those will not be sorted. If no supplied, tags for the relevant character are printed. Can also be a function that returns the tags. - * @property {object} [addTag=undefined] - Optionally provide a tag that should be manually added to this print. Either to the overriden tag list or the found tags based on the entity/key. Will respect the tag exists check. + * @property {Tag[]|function(): Tag[]} [tags=undefined] - Optional override of tags that should be printed. Those will not be sorted. If no supplied, tags for the relevant character are printed. Can also be a function that returns the tags. + * @property {Tag} [addTag=undefined] - Optionally provide a tag that should be manually added to this print. Either to the overriden tag list or the found tags based on the entity/key. Will respect the tag exists check. * @property {object|number|string} [forEntityOrKey=undefined] - Optional override for the chosen entity, otherwise the currently selected is chosen. Can be an entity with id property (character, group, tag), or directly an id or tag key. * @property {boolean|string} [empty=true] - Whether the list should be initially empty. If a string string is provided, 'always' will always empty the list, otherwise it'll evaluate to a boolean. * @property {function(object): function} [tagActionSelector=undefined] - An optional override for the action property that can be assigned to each tag via tagOptions. @@ -568,10 +626,11 @@ function printTagList(element, { tags = undefined, addTag = undefined, forEntity } /** - * Appends a tag to the list element. - * @param {JQuery} listElement List element. - * @param {object} tag Tag object to append. - * @param {TagOptions} [options={}] - Options for tag behavior. + * Appends a tag to the list element + * + * @param {JQuery} listElement - List element + * @param {Tag} tag - Tag object to append + * @param {TagOptions} [options={}] - Options for tag behavior * @returns {void} */ function appendTagToList(listElement, tag, { removable = false, selectable = false, action = undefined, isGeneralList = false, skipExistsCheck = false } = {}) { @@ -602,7 +661,7 @@ function appendTagToList(listElement, tag, { removable = false, selectable = fal } if (selectable && isGeneralList) { - toggleTagThreeState(tagElement, { stateOverride: tag.filterState ?? DEFAULT_FILTER_STATE }); + toggleTagThreeState(tagElement, { stateOverride: tag.filter_state ?? DEFAULT_FILTER_STATE }); } if (selectable) { @@ -628,7 +687,7 @@ function onTagFilterClick(listElement) { let state = toggleTagThreeState($(this)); if (existingTag) { - existingTag.filterState = state; + existingTag.filter_state = state; saveSettingsDebounced(); } @@ -636,6 +695,15 @@ function onTagFilterClick(listElement) { runTagFilters(listElement); } +/** + * Toggle the filter state of a given tag element + * + * @param {JQuery} element - The jquery element representing the tag for which the state should be toggled + * @param {object} param1 - Optional parameters + * @param {string} [param1.stateOverride] - Optional state override to which the state should be toggled to. If not set, the state will move to the next one in the chain. + * @param {boolean} [param1.simulateClick] - Optionally specify that the state should not just be set on the html element, but actually achieved via triggering the "click" on it, which follows up with the general click handlers and reprinting + * @returns {string} The string representing the new state + */ function toggleTagThreeState(element, { stateOverride = undefined, simulateClick = false } = {}) { const states = Object.keys(FILTER_STATES); @@ -900,10 +968,23 @@ function makeTagListDraggable(tagContainer) { }); } +/** + * Sorts the given tags, returning a shallow copy of it + * + * @param {Tag[]} tags - The tags + * @returns {Tag[]} The sorted tags + */ function sortTags(tags) { return tags.slice().sort(compareTagsForSort); } +/** + * Compares two given tags and returns the compare result + * + * @param {Tag} a - First tag + * @param {Tag} b - Second tag + * @returns The compare result + */ function compareTagsForSort(a, b) { if (a.sort_order !== undefined && b.sort_order !== undefined) { return a.sort_order - b.sort_order; From 32cde5f13f65a690bd9ecf65dd1ee075983e697b Mon Sep 17 00:00:00 2001 From: Cohee <18619528+Cohee1207@users.noreply.github.com> Date: Sat, 30 Mar 2024 23:20:46 +0200 Subject: [PATCH 12/16] Fix tag map cleanup on tag deletion, run lint --- public/scripts/tags.js | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/public/scripts/tags.js b/public/scripts/tags.js index a33b338f0..06a8fbc66 100644 --- a/public/scripts/tags.js +++ b/public/scripts/tags.js @@ -15,7 +15,7 @@ import { import { FILTER_TYPES, FILTER_STATES, DEFAULT_FILTER_STATE, isFilterState, FilterHelper } from './filters.js'; import { groupCandidatesFilter, groups, selected_group } from './group-chats.js'; -import { download, onlyUnique, parseJsonFile, uuidv4, getSortableDelay, debounce } from './utils.js'; +import { download, onlyUnique, parseJsonFile, uuidv4, getSortableDelay } from './utils.js'; import { power_user } from './power-user.js'; export { @@ -54,12 +54,12 @@ export const tag_filter_types = { }; const ACTIONABLE_TAGS = { - FAV: { id: "1", sort_order: 1, name: 'Show only favorites', color: 'rgba(255, 255, 0, 0.5)', action: filterByFav, icon: 'fa-solid fa-star', class: 'filterByFavorites' }, - GROUP: { id: "0", sort_order: 2, name: 'Show only groups', color: 'rgba(100, 100, 100, 0.5)', action: filterByGroups, icon: 'fa-solid fa-users', class: 'filterByGroups' }, - FOLDER: { id: "4", sort_order: 3, name: 'Always show folders', color: 'rgba(120, 120, 120, 0.5)', action: filterByFolder, icon: 'fa-solid fa-folder-plus', class: 'filterByFolder' }, - VIEW: { id: "2", sort_order: 4, name: 'Manage tags', color: 'rgba(150, 100, 100, 0.5)', action: onViewTagsListClick, icon: 'fa-solid fa-gear', class: 'manageTags' }, - HINT: { id: "3", sort_order: 5, name: 'Show Tag List', color: 'rgba(150, 100, 100, 0.5)', action: onTagListHintClick, icon: 'fa-solid fa-tags', class: 'showTagList' }, - UNFILTER: { id: "5", sort_order: 6, name: 'Clear all filters', action: onClearAllFiltersClick, icon: 'fa-solid fa-filter-circle-xmark', class: 'clearAllFilters' }, + FAV: { id: '1', sort_order: 1, name: 'Show only favorites', color: 'rgba(255, 255, 0, 0.5)', action: filterByFav, icon: 'fa-solid fa-star', class: 'filterByFavorites' }, + GROUP: { id: '0', sort_order: 2, name: 'Show only groups', color: 'rgba(100, 100, 100, 0.5)', action: filterByGroups, icon: 'fa-solid fa-users', class: 'filterByGroups' }, + FOLDER: { id: '4', sort_order: 3, name: 'Always show folders', color: 'rgba(120, 120, 120, 0.5)', action: filterByFolder, icon: 'fa-solid fa-folder-plus', class: 'filterByFolder' }, + VIEW: { id: '2', sort_order: 4, name: 'Manage tags', color: 'rgba(150, 100, 100, 0.5)', action: onViewTagsListClick, icon: 'fa-solid fa-gear', class: 'manageTags' }, + HINT: { id: '3', sort_order: 5, name: 'Show Tag List', color: 'rgba(150, 100, 100, 0.5)', action: onTagListHintClick, icon: 'fa-solid fa-tags', class: 'showTagList' }, + UNFILTER: { id: '5', sort_order: 6, name: 'Clear all filters', action: onClearAllFiltersClick, icon: 'fa-solid fa-filter-circle-xmark', class: 'clearAllFilters' }, }; const InListActionable = { @@ -983,7 +983,7 @@ function sortTags(tags) { * * @param {Tag} a - First tag * @param {Tag} b - Second tag - * @returns The compare result + * @returns {number} The compare result */ function compareTagsForSort(a, b) { if (a.sort_order !== undefined && b.sort_order !== undefined) { @@ -1196,7 +1196,7 @@ function onTagDeleteClick() { const id = $(this).closest('.tag_view_item').attr('id'); for (const key of Object.keys(tag_map)) { - tag_map[key] = tag_map[key].filter(x => x.id !== id); + tag_map[key] = tag_map[key].filter(x => x !== id); } const index = tags.findIndex(x => x.id === id); tags.splice(index, 1); From 8c5a81baff8407ce9b5e0274ce1d42c56c9b9511 Mon Sep 17 00:00:00 2001 From: Cohee <18619528+Cohee1207@users.noreply.github.com> Date: Sat, 30 Mar 2024 23:23:14 +0200 Subject: [PATCH 13/16] Only transition actionable tag filters --- public/css/tags.css | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/public/css/tags.css b/public/css/tags.css index b919b8300..93cc8b284 100644 --- a/public/css/tags.css +++ b/public/css/tags.css @@ -139,11 +139,13 @@ cursor: pointer; opacity: 0.6; filter: brightness(0.8); +} + +.rm_tag_filter .tag.actionable { transition: opacity 200ms; } .rm_tag_filter .tag:hover { - opacity: 1; filter: brightness(1); } From c58fcfd4da6902541fccab5457c824677d6c9056 Mon Sep 17 00:00:00 2001 From: Wolfsblvt Date: Sun, 31 Mar 2024 00:21:33 +0100 Subject: [PATCH 14/16] Fix actionable filters and bogus selection again - Fix actionable filters and their toggle state - Make bogus folders clickable again - Even more code documentation --- public/scripts/filters.js | 24 ++++++++++++++++-------- public/scripts/tags.js | 33 +++++++++++++++++++++++++++++---- 2 files changed, 45 insertions(+), 12 deletions(-) diff --git a/public/scripts/filters.js b/public/scripts/filters.js index 92ed6992c..2743ccdba 100644 --- a/public/scripts/filters.js +++ b/public/scripts/filters.js @@ -2,8 +2,8 @@ import { fuzzySearchCharacters, fuzzySearchGroups, fuzzySearchPersonas, fuzzySea import { tag_map } from './tags.js'; /** - * The filter types. - * @type {Object.} + * The filter types + * @type {{ SEARCH: string, TAG: string, FOLDER: string, FAV: string, GROUP: string, WORLD_INFO_SEARCH: string, PERSONA_SEARCH: string, [key: string]: string }} */ export const FILTER_TYPES = { SEARCH: 'search', @@ -16,26 +16,34 @@ export const FILTER_TYPES = { }; /** - * The filter states. - * @type {Object.} + * @typedef FilterState One of the filter states + * @property {string} key - The key of the state + * @property {string} class - The css class for this state + */ + +/** + * The filter states + * @type {{ SELECTED: FilterState, EXCLUDED: FilterState, UNDEFINED: FilterState, [key: string]: FilterState }} */ export const FILTER_STATES = { SELECTED: { key: 'SELECTED', class: 'selected' }, EXCLUDED: { key: 'EXCLUDED', class: 'excluded' }, UNDEFINED: { key: 'UNDEFINED', class: 'undefined' }, }; +/** @type {string} the default filter state of `FILTER_STATES` */ export const DEFAULT_FILTER_STATE = FILTER_STATES.UNDEFINED.key; /** * Robust check if one state equals the other. It does not care whether it's the state key or the state value object. - * @param {Object} a First state - * @param {Object} b Second state + * @param {FilterState|string} a First state + * @param {FilterState|string} b Second state + * @returns {boolean} */ export function isFilterState(a, b) { const states = Object.keys(FILTER_STATES); - const aKey = states.includes(a) ? a : states.find(key => FILTER_STATES[key] === a); - const bKey = states.includes(b) ? b : states.find(key => FILTER_STATES[key] === b); + const aKey = typeof a == 'string' && states.includes(a) ? a : states.find(key => FILTER_STATES[key] === a); + const bKey = typeof b == 'string' && states.includes(b) ? b : states.find(key => FILTER_STATES[key] === b); return aKey === bKey; } diff --git a/public/scripts/tags.js b/public/scripts/tags.js index 06a8fbc66..ac2e722da 100644 --- a/public/scripts/tags.js +++ b/public/scripts/tags.js @@ -53,6 +53,10 @@ export const tag_filter_types = { group_member: 1, }; +/** + * @type {{ FAV: Tag, GROUP: Tag, FOLDER: Tag, VIEW: Tag, HINT: Tag, UNFILTER: Tag }} + * A collection of global actional tags for the filter panel + * */ const ACTIONABLE_TAGS = { FAV: { id: '1', sort_order: 1, name: 'Show only favorites', color: 'rgba(255, 255, 0, 0.5)', action: filterByFav, icon: 'fa-solid fa-star', class: 'filterByFavorites' }, GROUP: { id: '0', sort_order: 2, name: 'Show only groups', color: 'rgba(100, 100, 100, 0.5)', action: filterByGroups, icon: 'fa-solid fa-users', class: 'filterByGroups' }, @@ -62,9 +66,11 @@ const ACTIONABLE_TAGS = { UNFILTER: { id: '5', sort_order: 6, name: 'Clear all filters', action: onClearAllFiltersClick, icon: 'fa-solid fa-filter-circle-xmark', class: 'clearAllFilters' }, }; +/** @type {{[key: string]: Tag}} An optional list of actionables that can be utilized by extensions */ const InListActionable = { }; +/** @type {Tag[]} A list of default tags */ const DEFAULT_TAGS = [ { id: uuidv4(), name: 'Plain Text', create_date: Date.now() }, { id: uuidv4(), name: 'OpenAI', create_date: Date.now() }, @@ -74,6 +80,20 @@ const DEFAULT_TAGS = [ { id: uuidv4(), name: 'AliChat', create_date: Date.now() }, ]; +/** + * @typedef FolderType Bogus folder type + * @property {string} icon - The icon as a string representation / character + * @property {string} class - The class to apply to the folder type element + * @property {string} [fa_icon] - Optional font-awesome icon class representing the folder type element + * @property {string} [tooltip] - Optional tooltip for the folder type element + * @property {string} [color] - Optional color for the folder type element + * @property {string} [size] - A string representation of the size that the folder type element should be + */ + +/** + * @type {{ OPEN: FolderType, CLOSED: FolderType, NONE: FolderType, [key: string]: FolderType }} + * The list of all possible tag folder types + */ const TAG_FOLDER_TYPES = { OPEN: { icon: '✔', class: 'folder_open', fa_icon: 'fa-folder-open', tooltip: 'Open Folder (Show all characters even if not selected)', color: 'green', size: '1' }, CLOSED: { icon: '👁', class: 'folder_closed', fa_icon: 'fa-eye-slash', tooltip: 'Closed Folder (Hide all characters unless selected)', color: 'lightgoldenrodyellow', size: '0.7' }, @@ -92,6 +112,7 @@ const TAG_FOLDER_DEFAULT_TYPE = 'NONE'; * @property {string} [color2] - The foreground color of the tag * @property {number} [create_date] - A number representing the date when this tag was created * + * @property {function} [action] - An optional function that gets executed when this tag is an actionable tag and is clicked on. * @property {string} [class] - An optional css class added to the control representing this tag when printed. Used for custom tags in the filters. * @property {string} [icon] - An optional css class of an icon representing this tag when printed. This will replace the tag name with the icon. Used for custom tags in the filters. */ @@ -234,7 +255,7 @@ function chooseBogusFolder(source, tagId, remove = false) { const FILTER_SELECTOR = ($(source).closest('#rm_characters_block') ?? $(source).closest('#rm_group_chats_block')).find('.rm_tag_filter'); const tagElement = $(FILTER_SELECTOR).find(`.tag[id=${tagId}]`); - toggleTagThreeState(tagElement, { stateOverride: DEFAULT_FILTER_STATE, simulateClick: true }); + toggleTagThreeState(tagElement, { stateOverride: !remove ? FILTER_STATES.SELECTED : DEFAULT_FILTER_STATE, simulateClick: true }); } /** @@ -271,6 +292,7 @@ function getTagBlock(tag, entities, hidden = 0) { */ function filterByFav(filterHelper) { const state = toggleTagThreeState($(this)); + ACTIONABLE_TAGS.FAV.filter_state = state; filterHelper.setFilterData(FILTER_TYPES.FAV, state); } @@ -280,6 +302,7 @@ function filterByFav(filterHelper) { */ function filterByGroups(filterHelper) { const state = toggleTagThreeState($(this)); + ACTIONABLE_TAGS.GROUP.filter_state = state; filterHelper.setFilterData(FILTER_TYPES.GROUP, state); } @@ -289,6 +312,7 @@ function filterByGroups(filterHelper) { */ function filterByFolder(filterHelper) { const state = toggleTagThreeState($(this)); + ACTIONABLE_TAGS.FOLDER.filter_state = state; filterHelper.setFilterData(FILTER_TYPES.FOLDER, state); } @@ -660,7 +684,8 @@ function appendTagToList(listElement, tag, { removable = false, selectable = fal tagElement.find('.tag_name').text('').attr('title', tag.name).addClass(tag.icon); } - if (selectable && isGeneralList) { + // If this is a tag for a general list and its either selectable or actionable, lets mark its current state + if ((selectable || action) && isGeneralList) { toggleTagThreeState(tagElement, { stateOverride: tag.filter_state ?? DEFAULT_FILTER_STATE }); } @@ -700,7 +725,7 @@ function onTagFilterClick(listElement) { * * @param {JQuery} element - The jquery element representing the tag for which the state should be toggled * @param {object} param1 - Optional parameters - * @param {string} [param1.stateOverride] - Optional state override to which the state should be toggled to. If not set, the state will move to the next one in the chain. + * @param {import('./filters.js').FilterState|string} [param1.stateOverride] - Optional state override to which the state should be toggled to. If not set, the state will move to the next one in the chain. * @param {boolean} [param1.simulateClick] - Optionally specify that the state should not just be set on the html element, but actually achieved via triggering the "click" on it, which follows up with the general click handlers and reprinting * @returns {string} The string representing the new state */ @@ -713,7 +738,7 @@ function toggleTagThreeState(element, { stateOverride = undefined, simulateClick return index !== -1 ? index : states.indexOf(fallback); } - const overrideKey = states.includes(stateOverride) ? stateOverride : Object.keys(FILTER_STATES).find(key => FILTER_STATES[key] === stateOverride); + const overrideKey = typeof stateOverride == 'string' && states.includes(stateOverride) ? stateOverride : Object.keys(FILTER_STATES).find(key => FILTER_STATES[key] === stateOverride); const currentStateIndex = getStateIndex(element.attr('data-toggle-state'), DEFAULT_FILTER_STATE); const targetStateIndex = overrideKey !== undefined ? getStateIndex(overrideKey, DEFAULT_FILTER_STATE) : (currentStateIndex + 1) % states.length; From e99baac9c041098b79b632a7d2aa7cc0792ad89c Mon Sep 17 00:00:00 2001 From: Cohee <18619528+Cohee1207@users.noreply.github.com> Date: Sun, 31 Mar 2024 10:48:23 +0300 Subject: [PATCH 15/16] Adjust drilldown arrow style This thing was huge --- public/css/tags.css | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/public/css/tags.css b/public/css/tags.css index 93cc8b284..9a3e02064 100644 --- a/public/css/tags.css +++ b/public/css/tags.css @@ -232,18 +232,16 @@ .rm_tag_bogus_drilldown .tag:not(:first-child) { position: relative; - margin-left: calc(var(--mainFontSize) * 2); + margin-left: 1em; } .rm_tag_bogus_drilldown .tag:not(:first-child)::before { + font-family: 'Font Awesome 6 Free'; + content: "\f054"; position: absolute; - left: calc(var(--mainFontSize) * -2); - top: -1px; - content: "\21E8"; - font-size: calc(var(--mainFontSize) * 2); + left: -1em; + top: auto; color: var(--SmartThemeBodyColor); - line-height: calc(var(--mainFontSize) * 1.3); - text-align: center; text-shadow: 1px 1px 0px black, -1px -1px 0px black, -1px 1px 0px black, From b2f42f1b9f17a1a28533e719116893a5d3f15cbb Mon Sep 17 00:00:00 2001 From: Cohee <18619528+Cohee1207@users.noreply.github.com> Date: Sun, 31 Mar 2024 10:54:23 +0300 Subject: [PATCH 16/16] Close context menu immediately when clicked on mass tag --- public/scripts/BulkEditOverlay.js | 1 + 1 file changed, 1 insertion(+) diff --git a/public/scripts/BulkEditOverlay.js b/public/scripts/BulkEditOverlay.js index f3df8156d..fccf12da3 100644 --- a/public/scripts/BulkEditOverlay.js +++ b/public/scripts/BulkEditOverlay.js @@ -853,6 +853,7 @@ class BulkEditOverlay { */ handleContextMenuTag = () => { CharacterContextMenu.tag(this.selectedCharacters); + this.browseState(); }; addStateChangeCallback = callback => this.stateChangeCallbacks.push(callback);