Add taxon filter controls to Group Chat member list (#5006) * Add taxon controls to top of group member list * Refactor/cleanup getGroupCharacters * Fix favorites, refactor and cleanup code * Fix clearing filters, only show relevant filters in groups * Fix issues and add persistence - Fix group member tag listing requiring character list to be init to display - Fix character tag updates to actually show up in group member contexts - Persist filter changes for group member tag controls * Apply suggestions from code review Co-authored-by: Cohee <18619528+Cohee1207@users.noreply.github.com> * Avoid sibling selectors * Err on invalid tag type * avoid hardcoded ids * cleanup: don't use `group_member`, use `group_candidates_list` * Support both selectors and jquery instances in getFilterHelper * Sanitize missing tag filters before rendering * Unscrew jsdoc formatting * Show all tags, mark absents specially * Improve JSDoc * Fix tag indicator for group contexts * Don't use deprecated fields * Add a comment on potentially undefined group id --------- Co-authored-by: Cohee <18619528+Cohee1207@users.noreply.github.com>
Signed| @@ -67,6 +67,10 @@ | ||
| 67 | 67 | display: none; |
| 68 | 68 | } |
| 69 | 69 | |
| 70 | +.tag.tag-absent { | |
| 71 | + text-decoration: line-through; | |
| 72 | +} | |
| 73 | + | |
| 70 | 74 | .tag.actionable { |
| 71 | 75 | border-radius: 50%; |
| 72 | 76 | aspect-ratio: 1 / 1; |
| @@ -6126,6 +6126,12 @@ | ||
| 6126 | 6126 | </div> |
| 6127 | 6127 | <div class="inline-drawer-content"> |
| 6128 | 6128 | <div id="currentGroupMembers" name="Current Group Members" class="flex-container flexFlowColumn overflowYAuto flex1"> |
| 6129 | + <div id="rm_group_members_header"> | |
| 6130 | + <input id="rm_group_members_filter" class="text_pole margin0" type="search" data-i18n="[placeholder]Search..." placeholder="Search..." /> | |
| 6131 | + </div> | |
| 6132 | + <div class="rm_tag_controls"> | |
| 6133 | + <div class="tags rm_tag_filter"></div> | |
| 6134 | + </div> | |
| 6129 | 6135 | <div id="rm_group_members_pagination" class="rm_group_members_pagination group_pagination"></div> |
| 6130 | 6136 | <div id="rm_group_members" class="rm_group_members overflowYAuto flex-container" group_empty_text="Group is empty." data-i18n="[group_empty_text]Group is empty."></div> |
| 6131 | 6137 | </div> |
| @@ -6137,7 +6143,7 @@ | ||
| 6137 | 6143 | <div class="fa-solid fa-circle-chevron-down inline-drawer-icon down"></div> |
| 6138 | 6144 | </div> |
| 6139 | 6145 | <div class="inline-drawer-content"> |
| 6140 | 6146 | <div id="unaddedCharList" name="Unadded Char List" class="flex-container flexFlowColumn overflowYAuto flex1"> |
| 6141 | 6147 | <div id="rm_group_add_members_header"> |
| 6142 | 6148 | <input id="rm_group_filter" class="text_pole margin0" type="search" data-i18n="[placeholder]Search..." placeholder="Search..." /> |
| 6143 | 6149 | </div> |
| @@ -953,7 +953,8 @@ export async function printCharacters(fullRefresh = false) { | ||
| 953 | 953 | |
| 954 | 954 | // We are actually always reprinting filters, as it "doesn't hurt", and this way they are always up to date |
| 955 | 955 | printTagFilters(tag_filter_type.character); |
| 956 | 956 | printTagFilters(tag_filter_type.group_membergroup_members_list); |
| 957 | + printTagFilters(tag_filter_type.group_candidates_list); | |
| 957 | 958 | |
| 958 | 959 | // We are also always reprinting the lists on character/group edit window, as these ones doesn't get updated otherwise |
| 959 | 960 | applyTagsOnCharacterSelect(); |
| @@ -80,7 +80,7 @@ import { | ||
| 80 | 80 | chatElement, |
| 81 | 81 | ensureMessageMediaIsArray, |
| 82 | 82 | } from '../script.js'; |
| 83 | 83 | import { printTagList, createTagMapFromList, applyTagsOnCharacterSelect, tag_map, applyTagsOnGroupSelect, printTagFilters, tag_filter_type } from './tags.js'; |
| 84 | 84 | import { FILTER_TYPES, FilterHelper } from './filters.js'; |
| 85 | 85 | import { isExternalMediaAllowed } from './chats.js'; |
| 86 | 86 | import { POPUP_TYPE, Popup, callGenericPopup } from './popup.js'; |
| @@ -134,6 +134,7 @@ export const group_generation_mode = { | ||
| 134 | 134 | export const DEFAULT_AUTO_MODE_DELAY = 5; |
| 135 | 135 | |
| 136 | 136 | export const groupCandidatesFilter = new FilterHelper(debounce(printGroupCandidates, debounce_timeout.quick)); |
| 137 | +export const groupMembersFilter = new FilterHelper(debounce(printGroupMembers, debounce_timeout.quick)); | |
| 137 | 138 | let autoModeWorker = null; |
| 138 | 139 | const saveGroupDebounced = debounce(async (group, reload) => await _save(group, reload), debounce_timeout.relaxed); |
| 139 | 140 | /** @type {Map<string, number>} */ |
| @@ -1449,6 +1450,10 @@ async function modifyGroupMember(groupId, groupMember, isDelete) { | ||
| 1449 | 1450 | printGroupCandidates(); |
| 1450 | 1451 | printGroupMembers(); |
| 1451 | 1452 | |
| 1453 | + // Refresh the tag filters for both lists to reflect any new tags | |
| 1454 | + printTagFilters(tag_filter_type.group_candidates_list); | |
| 1455 | + printTagFilters(tag_filter_type.group_members_list); | |
| 1456 | + | |
| 1452 | 1457 | const groupHasMembers = getGroupCharacters({ doFilter: false, onlyMembers: true }).length > 0; |
| 1453 | 1458 | $('#rm_group_submit').prop('disabled', !groupHasMembers); |
| 1454 | 1459 | } |
| @@ -1557,31 +1562,63 @@ function isGroupMember(group, avatarId) { | ||
| 1557 | 1562 | * @returns {Array<{item: Character, id: number, type: string}>} Array of group character objects |
| 1558 | 1563 | */ |
| 1559 | 1564 | function getGroupCharacters({ doFilter = false, onlyMembers = false } = {}) { |
| 1560 | 1565 | function sortMembersFnapplyFilterAndSort(aresults, bfilter, filterSelector) { |
| 1566 | + let filtered = results; | |
| 1567 | + if (doFilter) { | |
| 1568 | + filtered = filter.applyFilters(filtered); | |
| 1569 | + } | |
| 1570 | + const useFilterOrder = doFilter && !!$(filterSelector).val(); | |
| 1571 | + sortEntitiesList(filtered, useFilterOrder, filter); | |
| 1572 | + filter.clearFuzzySearchCaches(); | |
| 1573 | + return filtered; | |
| 1574 | + } | |
| 1575 | + | |
| 1576 | + function handleMembers(results, thisGroup) { | |
| 1561 | 1577 | const membersArray = thisGroup?.members ?? newGroupMembers; |
| 1562 | - const aIndex = membersArray.indexOf(a.item.avatar); | |
| 1578 | + | |
| 1563 | - const bIndex = membersArray.indexOf(b.item.avatar); | |
| 1579 | + // Create index map for O(1) lookups in member sort function | |
| 1564 | - return aIndex - bIndex; | |
| 1580 | + // (separate from characterIndexMap which maps character objects to their array indices) | |
| 1581 | + const memberIndexMap = new Map(membersArray.map((avatar, index) => [avatar, index])); | |
| 1582 | + | |
| 1583 | + function sortMembersFn(a, b) { | |
| 1584 | + const aIndex = memberIndexMap.get(a.item.avatar) ?? -1; | |
| 1585 | + const bIndex = memberIndexMap.get(b.item.avatar) ?? -1; | |
| 1586 | + return aIndex - bIndex; | |
| 1587 | + } | |
| 1588 | + | |
| 1589 | + // Apply manual member sort before filter and sort | |
| 1590 | + let filtered = results; | |
| 1591 | + if (doFilter) { | |
| 1592 | + filtered = groupMembersFilter.applyFilters(filtered); | |
| 1593 | + } | |
| 1594 | + filtered.sort(sortMembersFn); | |
| 1595 | + | |
| 1596 | + // Apply conditional filter-based sort and cleanup | |
| 1597 | + const useFilterOrder = doFilter && !!$('#rm_group_members_filter').val(); | |
| 1598 | + if (useFilterOrder) { | |
| 1599 | + sortEntitiesList(filtered, useFilterOrder, groupMembersFilter); | |
| 1600 | + } | |
| 1601 | + groupMembersFilter.clearFuzzySearchCaches(); | |
| 1602 | + return filtered; | |
| 1565 | 1603 | } |
| 1566 | 1604 | |
| 1567 | 1605 | const thisGroup = openGroupId && groups.find((x) => x.id == openGroupId); |
| 1568 | - let candidates = characters | |
| 1569 | - .filter((x) => isGroupMember(thisGroup, x.avatar) == onlyMembers) | |
| 1570 | - .map((x) => ({ item: x, id: characters.indexOf(x), type: 'character' })); | |
| 1571 | 1606 | |
| 1572 | - if (doFilter) { | |
| 1607 | + // Create index map for O(1) lookups when mapping characters to their array indices | |
| 1573 | - candidates = groupCandidatesFilter.applyFilters(candidates); | |
| 1608 | + // (separate from memberIndexMap used later for sorting members by their group order) | |
| 1574 | - } | |
| 1609 | + const characterIndexMap = new Map(characters.map((char, index) => [char, index])); | |
| 1575 | 1610 | |
| 1576 | - if (onlyMembers) { | |
| 1611 | + const results = characters | |
| 1577 | - candidates.sort(sortMembersFn); | |
| 1612 | + .filter((x) => isGroupMember(thisGroup, x.avatar) == onlyMembers) | |
| 1578 | - } else { | |
| 1613 | + .map((x) => ({ item: x, id: characterIndexMap.get(x), type: 'character' })); | |
| 1579 | - const useFilterOrder = doFilter && !!$('#rm_group_filter').val(); | |
| 1614 | + | |
| 1580 | - sortEntitiesList(candidates, useFilterOrder, groupCandidatesFilter); | |
| 1615 | + // Early return for candidates (non-members) | |
| 1616 | + if (!onlyMembers) { | |
| 1617 | + return applyFilterAndSort(results, groupCandidatesFilter, '#rm_group_filter'); | |
| 1581 | 1618 | } |
| 1582 | 1619 | |
| 1583 | - groupCandidatesFilter.clearFuzzySearchCaches(); | |
| 1620 | + // Handle members with manual sort capability | |
| 1584 | 1621 | return candidateshandleMembers(results, thisGroup); |
| 1585 | 1622 | } |
| 1586 | 1623 | |
| 1587 | 1624 | function printGroupCandidates() { |
| @@ -1621,7 +1658,7 @@ function printGroupMembers() { | ||
| 1621 | 1658 | const pageSize = Number(accountStorage.getItem(storageKey)) || 5; |
| 1622 | 1659 | const sizeChangerOptions = [5, 10, 25, 50, 100, 200, 500, 1000]; |
| 1623 | 1660 | $(this).pagination({ |
| 1624 | 1661 | dataSource: getGroupCharacters({ doFilter: falsetrue, onlyMembers: true }), |
| 1625 | 1662 | pageRange: 1, |
| 1626 | 1663 | position: 'top', |
| 1627 | 1664 | showPageNumbers: false, |
| @@ -1782,6 +1819,7 @@ function select_group_chats(groupId, skipAnimation) { | ||
| 1782 | 1819 | $('#group_avatar_preview').empty().append(getGroupAvatar(group)); |
| 1783 | 1820 | $('#rm_group_restore_avatar').toggle(!!group && isValidImageUrl(group.avatar_url)); |
| 1784 | 1821 | $('#rm_group_filter').val('').trigger('input'); |
| 1822 | + $('#rm_group_members_filter').val('').trigger('input'); | |
| 1785 | 1823 | $('#rm_group_activation_strategy').val(replyStrategy); |
| 1786 | 1824 | $(`#rm_group_activation_strategy option[value="${replyStrategy}"]`).prop('selected', true); |
| 1787 | 1825 | $('#rm_group_generation_mode').val(generationMode); |
| @@ -2049,6 +2087,11 @@ function filterGroupMembers() { | ||
| 2049 | 2087 | groupCandidatesFilter.setFilterData(FILTER_TYPES.SEARCH, searchValue); |
| 2050 | 2088 | } |
| 2051 | 2089 | |
| 2090 | +function filterGroupMemberList() { | |
| 2091 | + const searchValue = String($(this).val()).toLowerCase(); | |
| 2092 | + groupMembersFilter.setFilterData(FILTER_TYPES.SEARCH, searchValue); | |
| 2093 | +} | |
| 2094 | + | |
| 2052 | 2095 | async function createGroup() { |
| 2053 | 2096 | let name = $('#rm_group_chat_name').val().toString(); |
| 2054 | 2097 | let allowSelfResponses = !!$('#rm_group_allow_self_responses').prop('checked'); |
| @@ -2424,6 +2467,7 @@ jQuery(() => { | ||
| 2424 | 2467 | openGroupById(groupId); |
| 2425 | 2468 | }); |
| 2426 | 2469 | $('#rm_group_filter').on('input', filterGroupMembers); |
| 2470 | + $('#rm_group_members_filter').on('input', filterGroupMemberList); | |
| 2427 | 2471 | $('#rm_group_submit').on('click', createGroup); |
| 2428 | 2472 | $('#rm_group_scenario').on('click', setCharacterSettingsOverrides); |
| 2429 | 2473 | $('#rm_group_automode').on('input', function () { |
| @@ -15,7 +15,7 @@ import { | ||
| 15 | 15 | } from '../script.js'; |
| 16 | 16 | import { FILTER_TYPES, FILTER_STATES, DEFAULT_FILTER_STATE, isFilterState, FilterHelper } from './filters.js'; |
| 17 | 17 | |
| 18 | 18 | import { groupCandidatesFilter, groupMembersFilter, groups, selected_group } from './group-chats.js'; |
| 19 | 19 | import { download, onlyUnique, parseJsonFile, uuidv4, getSortableDelay, flashHighlight, equalsIgnoreCaseAndAccents, includesIgnoreCaseAndAccents, removeFromArray, getFreeName, debounce, findChar } from './utils.js'; |
| 20 | 20 | import { power_user } from './power-user.js'; |
| 21 | 21 | import { SlashCommandParser } from './slash-commands/SlashCommandParser.js'; |
| @@ -53,16 +53,113 @@ export { | ||
| 53 | 53 | removeTagFromMap, |
| 54 | 54 | }; |
| 55 | 55 | |
| 56 | -/** @typedef {import('../script.js').Character} Character */ | |
| 57 | - | |
| 58 | 56 | const CHARACTER_FILTER_SELECTOR = '#rm_characters_block .rm_tag_filter'; |
| 59 | 57 | const GROUP_FILTER_SELECTOR = '#rm_group_chats_blockrm_group_add_members_header ~ .rm_tag_controls .rm_tag_filter'; |
| 58 | +const GROUP_MEMBERS_FILTER_SELECTOR = '#rm_group_members_header ~ .rm_tag_controls .rm_tag_filter'; | |
| 60 | 59 | const TAG_TEMPLATE = $('#tag_template .tag'); |
| 61 | 60 | const FOLDER_TEMPLATE = $('#bogus_folder_template .bogus_folder_select'); |
| 62 | 61 | const VIEW_TAG_TEMPLATE = $('#tag_view_template .tag_view_item'); |
| 63 | 62 | |
| 63 | +/** | |
| 64 | + * Gets the context information (selector and search input) for a filter helper. | |
| 65 | + * Used to reduce code duplication when working with different filter contexts. | |
| 66 | + * @param {FilterHelper} filterHelper - The filter helper instance | |
| 67 | + * @returns {{selector: string, searchInput: string}|null} Context info or null if unknown | |
| 68 | + */ | |
| 69 | +function getFilterContext(filterHelper) { | |
| 70 | + if (filterHelper === entitiesFilter) { | |
| 71 | + return { | |
| 72 | + selector: CHARACTER_FILTER_SELECTOR, | |
| 73 | + searchInput: '#character_search_bar', | |
| 74 | + }; | |
| 75 | + } else if (filterHelper === groupCandidatesFilter) { | |
| 76 | + return { | |
| 77 | + selector: GROUP_FILTER_SELECTOR, | |
| 78 | + searchInput: '#rm_group_filter', | |
| 79 | + }; | |
| 80 | + } else if (filterHelper === groupMembersFilter) { | |
| 81 | + return { | |
| 82 | + selector: GROUP_MEMBERS_FILTER_SELECTOR, | |
| 83 | + searchInput: '#rm_group_members_filter', | |
| 84 | + }; | |
| 85 | + } | |
| 86 | + return null; | |
| 87 | +} | |
| 88 | + | |
| 89 | +/** | |
| 90 | + * Get the filter helper for a given list selector. | |
| 91 | + * @param {string|JQuery<HTMLElement>} listSelector - jQuery selector for the list | |
| 92 | + * @returns {FilterHelper} The appropriate filter helper instance | |
| 93 | + */ | |
| 64 | 94 | function getFilterHelper(listSelector) { |
| 65 | - return $(listSelector).is(GROUP_FILTER_SELECTOR) ? groupCandidatesFilter : entitiesFilter; | |
| 95 | + const $element = typeof listSelector === 'string' ? $(listSelector) : listSelector; | |
| 96 | + | |
| 97 | + // Check if this filter is in the group members section | |
| 98 | + if ($element.closest('#currentGroupMembers').length > 0) { | |
| 99 | + return groupMembersFilter; | |
| 100 | + } | |
| 101 | + | |
| 102 | + // Check if this filter is in the group candidates (add members) section | |
| 103 | + if ($element.closest('#unaddedCharList').length > 0) { | |
| 104 | + return groupCandidatesFilter; | |
| 105 | + } | |
| 106 | + | |
| 107 | + // Default to character list filter | |
| 108 | + return entitiesFilter; | |
| 109 | +} | |
| 110 | + | |
| 111 | +/** | |
| 112 | + * Checks if the given type is a group context. | |
| 113 | + * @param {tag_filter_type} type - The filter type to check | |
| 114 | + * @returns {boolean} True if this is a group context | |
| 115 | + */ | |
| 116 | +function isGroupContext(type) { | |
| 117 | + return [tag_filter_type.group_candidates_list, tag_filter_type.group_members_list].includes(type); | |
| 118 | +} | |
| 119 | + | |
| 120 | +/** | |
| 121 | + * Gets visible character avatars for a group context. | |
| 122 | + * @param {tag_filter_type} type - The filter type | |
| 123 | + * @param {object} currentGroup - The current group object | |
| 124 | + * @returns {string[]} Array of visible character avatars | |
| 125 | + */ | |
| 126 | +function getVisibleAvatarsForGroupContext(type, currentGroup) { | |
| 127 | + if (!currentGroup || !Array.isArray(currentGroup.members)) { | |
| 128 | + return []; | |
| 129 | + } | |
| 130 | + | |
| 131 | + switch (type) { | |
| 132 | + case tag_filter_type.group_members_list: | |
| 133 | + return currentGroup.members; | |
| 134 | + case tag_filter_type.group_candidates_list: | |
| 135 | + return characters | |
| 136 | + .filter(c => !currentGroup.members.includes(c.avatar)) | |
| 137 | + .map(c => c.avatar); | |
| 138 | + default: | |
| 139 | + console.warn('getVisibleAvatarsForGroupContext got invalid type, expected 1 or 2, got ', type); | |
| 140 | + return []; | |
| 141 | + } | |
| 142 | +} | |
| 143 | + | |
| 144 | +/** | |
| 145 | + * Filters actionable tags for group contexts. | |
| 146 | + * In group contexts, hide GROUP and FOLDER filters but keep Favorites and utility buttons. | |
| 147 | + * @param {object[]} actionTags - Array of actionable tag objects | |
| 148 | + * @returns {object[]} Filtered array of actionable tags | |
| 149 | + */ | |
| 150 | +function filterActionableTagsForGroupContext(actionTags) { | |
| 151 | + return actionTags.filter(tag => { | |
| 152 | + // Always show Favorites | |
| 153 | + if (tag.id === ACTIONABLE_TAGS.FAV.id) { | |
| 154 | + return true; | |
| 155 | + } | |
| 156 | + // Hide GROUP and FOLDER filters in group contexts (not relevant) | |
| 157 | + if (tag.id === ACTIONABLE_TAGS.GROUP.id || tag.id === ACTIONABLE_TAGS.FOLDER.id) { | |
| 158 | + return false; | |
| 159 | + } | |
| 160 | + // Show utility buttons (VIEW, HINT, UNFILTER) | |
| 161 | + return true; | |
| 162 | + }); | |
| 66 | 163 | } |
| 67 | 164 | |
| 68 | 165 | const ACTIONABLE_FILTER_STORAGE_KEYS = Object.freeze({ |
| @@ -71,12 +168,79 @@ const ACTIONABLE_FILTER_STORAGE_KEYS = Object.freeze({ | ||
| 71 | 168 | FOLDER: 'TagFilterState_FOLDER', |
| 72 | 169 | }); |
| 73 | 170 | |
| 171 | +/** | |
| 172 | + * Gets the storage key prefix for a filter helper to enable persistence. | |
| 173 | + * @param {FilterHelper} filterHelper - The filter helper to check | |
| 174 | + * @returns {string|null} Storage key prefix or null if no persistence | |
| 175 | + */ | |
| 176 | +function getFilterStorageKey(filterHelper) { | |
| 177 | + if (filterHelper === entitiesFilter) { | |
| 178 | + return 'CharacterList'; | |
| 179 | + } else if (filterHelper === groupCandidatesFilter) { | |
| 180 | + return 'GroupCandidates'; | |
| 181 | + } else if (filterHelper === groupMembersFilter) { | |
| 182 | + return 'GroupMembers'; | |
| 183 | + } | |
| 184 | + return null; | |
| 185 | +} | |
| 186 | + | |
| 187 | +/** | |
| 188 | + * Checks if the given filter helper is the main character list filter. | |
| 189 | + * @param {FilterHelper} filterHelper - The filter helper to check | |
| 190 | + * @returns {boolean} True if this is the main character list | |
| 191 | + */ | |
| 192 | +function isMainCharacterList(filterHelper) { | |
| 193 | + return filterHelper === entitiesFilter; | |
| 194 | +} | |
| 195 | + | |
| 74 | 196 | /** @enum {number} */ |
| 75 | 197 | export const tag_filter_type = { |
| 76 | 198 | character: 0, |
| 199 | + /** @deprecated use `group_candidates_list` instead */ | |
| 77 | 200 | group_member: 1, |
| 201 | + group_candidates_list: 1, | |
| 202 | + group_members_list: 2, | |
| 78 | 203 | }; |
| 79 | 204 | |
| 205 | +/** | |
| 206 | + * Gets the power_user setting key for tag filter visibility for a given context. | |
| 207 | + * @param {number} type - The tag_filter_type | |
| 208 | + * @returns {string} The power_user setting key | |
| 209 | + */ | |
| 210 | +function getTagFilterVisibilitySetting(type) { | |
| 211 | + switch (type) { | |
| 212 | + case tag_filter_type.character: | |
| 213 | + return 'show_tag_filters'; | |
| 214 | + case tag_filter_type.group_candidates_list: | |
| 215 | + return 'show_tag_filters_group_candidates'; | |
| 216 | + case tag_filter_type.group_members_list: | |
| 217 | + return 'show_tag_filters_group_members'; | |
| 218 | + default: | |
| 219 | + return 'show_tag_filters'; | |
| 220 | + } | |
| 221 | +} | |
| 222 | + | |
| 223 | +/** | |
| 224 | + * Gets the tag filter visibility state for a given context. | |
| 225 | + * @param {number} type - The tag_filter_type | |
| 226 | + * @returns {boolean} Whether tag filters should be shown | |
| 227 | + */ | |
| 228 | +function getTagFilterVisibility(type) { | |
| 229 | + const settingKey = getTagFilterVisibilitySetting(type); | |
| 230 | + return power_user[settingKey] ?? false; | |
| 231 | +} | |
| 232 | + | |
| 233 | +/** | |
| 234 | + * Sets the tag filter visibility state for a given context. | |
| 235 | + * @param {number} type - The tag_filter_type | |
| 236 | + * @param {boolean} visible - Whether tag filters should be shown | |
| 237 | + */ | |
| 238 | +function setTagFilterVisibility(type, visible) { | |
| 239 | + const settingKey = getTagFilterVisibilitySetting(type); | |
| 240 | + power_user[settingKey] = visible; | |
| 241 | + saveSettingsDebounced(); | |
| 242 | +} | |
| 243 | + | |
| 80 | 244 | /** @enum {number} */ |
| 81 | 245 | export const tag_import_setting = { |
| 82 | 246 | ASK: 1, |
| @@ -93,18 +257,33 @@ export const tag_sort_mode = { | ||
| 93 | 257 | }; |
| 94 | 258 | |
| 95 | 259 | /** |
| 96 | - * @type {{ FAV: Tag, GROUP: Tag, FOLDER: Tag, VIEW: Tag, HINT: Tag, UNFILTER: Tag }} | |
| 260 | + * A collection of global actionable tags for the filter panel. | |
| 97 | - * A collection of global actional tags for the filter panel | |
| 261 | + * | |
| 98 | - * */ | |
| 262 | + * Tags with `filter_state` property (FAV, GROUP, FOLDER) maintain persistent state: | |
| 263 | + * - Each context (character list, group candidates, group members) saves state independently | |
| 264 | + * - Main character list also maintains tag.filter_state for backward compatibility | |
| 265 | + * | |
| 266 | + * Tags without `filter_state` (VIEW, HINT, UNFILTER) are action buttons only. | |
| 267 | + */ | |
| 99 | 268 | const ACTIONABLE_TAGS = { |
| 100 | 269 | FAV: { id: '1', sort_order: 1, name: 'Show only favorites', color: 'rgba(255, 255, 0, 0.5)', filter_state: undefined, action: filterByFav, icon: 'fa-solid fa-star', class: 'filterByFavorites' }, |
| 101 | 270 | GROUP: { id: '0', sort_order: 2, name: 'Show only groups', color: 'rgba(100, 100, 100, 0.5)', filter_state: undefined, action: filterByGroups, icon: 'fa-solid fa-users', class: 'filterByGroups' }, |
| 102 | 271 | FOLDER: { id: '4', sort_order: 3, name: 'Show only folders', color: 'rgba(120, 120, 120, 0.5)', filter_state: undefined, action: filterByFolder, icon: 'fa-solid fa-folder-plus', class: 'filterByFolder' }, |
| 103 | 272 | 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' }, |
| 104 | 273 | 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' }, |
| 105 | 274 | UNFILTER: { id: '5', sort_order: 6, name: 'Clear all filters', action: onClearAllFiltersClick, icon: 'fa-solid fa-filter-circle-xmark', class: 'clearAllFilters' }, |
| 106 | 275 | }; |
| 107 | 276 | |
| 277 | +/** | |
| 278 | + * Map of tag IDs to their corresponding filter types. | |
| 279 | + * Used for actionable tags (Favorites, Groups, Folders). | |
| 280 | + */ | |
| 281 | +const TAG_ID_TO_FILTER_TYPE = new Map([ | |
| 282 | + [ACTIONABLE_TAGS.FAV.id, FILTER_TYPES.FAV], | |
| 283 | + [ACTIONABLE_TAGS.GROUP.id, FILTER_TYPES.GROUP], | |
| 284 | + [ACTIONABLE_TAGS.FOLDER.id, FILTER_TYPES.FOLDER], | |
| 285 | +]); | |
| 286 | + | |
| 108 | 287 | /** @type {{[key: string]: Tag}} An optional list of actionables that can be utilized by extensions */ |
| 109 | 288 | const InListActionable = { |
| 110 | 289 | }; |
| @@ -345,15 +524,68 @@ function getTagBlock(tag, entities, hidden = 0, isUseless = false) { | ||
| 345 | 524 | } |
| 346 | 525 | |
| 347 | 526 | /** |
| 348 | - * Applies the favorite filter to the character list. | |
| 527 | + * Common logic for applying actionable tag filters (Favorites, Groups, Folders). | |
| 349 | - * @param {FilterHelper} _filterHelper Instance of FilterHelper class. Unused since it needs to be applied to both filters. | |
| 528 | + * Persists state to storage for all filter contexts. | |
| 529 | + * @param {FilterHelper} filterHelper - Instance of FilterHelper class | |
| 530 | + * @param {object} tag - The actionable tag object | |
| 531 | + * @param {string} filterType - The filter type constant | |
| 532 | + * @param {string} storageKey - The storage key base for persistence | |
| 350 | 533 | */ |
| 351 | -function filterByFav(_filterHelper) { | |
| 534 | +function applyActionableTagFilter(filterHelper, tag, filterType, storageKey) { | |
| 352 | 535 | const state = toggleTagThreeState($(this)); |
| 353 | - ACTIONABLE_TAGS.FAV.filter_state = state; | |
| 536 | + | |
| 354 | - accountStorage.setItem(ACTIONABLE_FILTER_STORAGE_KEYS.FAV, state); | |
| 537 | + // Persist to storage for all contexts | |
| 355 | - entitiesFilter.setFilterData(FILTER_TYPES.FAV, state); | |
| 538 | + const storagePrefix = getFilterStorageKey(filterHelper); | |
| 356 | - groupCandidatesFilter.setFilterData(FILTER_TYPES.FAV, state); | |
| 539 | + if (storagePrefix) { | |
| 540 | + const contextStorageKey = `${storagePrefix}_${storageKey}`; | |
| 541 | + accountStorage.setItem(contextStorageKey, state); | |
| 542 | + } | |
| 543 | + | |
| 544 | + // Also update global state for main character list (backward compatibility) | |
| 545 | + if (isMainCharacterList(filterHelper)) { | |
| 546 | + tag.filter_state = state; | |
| 547 | + } | |
| 548 | + | |
| 549 | + // Update the filter helper for the current context | |
| 550 | + filterHelper.setFilterData(filterType, state); | |
| 551 | +} | |
| 552 | + | |
| 553 | +/** | |
| 554 | + * Determines the filter state for a tag based on context. | |
| 555 | + * For actionable tags: reads from persisted state via filter helper. | |
| 556 | + * For regular tags: reads from the filter helper's TAG filter data. | |
| 557 | + * @param {FilterHelper} filterHelper - The filter helper for the current context | |
| 558 | + * @param {object} tag - The tag object | |
| 559 | + * @param {boolean} isFilterActionable - Whether the tag is an actionable filter tag | |
| 560 | + * @returns {string} The filter state | |
| 561 | + */ | |
| 562 | +function determineTagFilterState(filterHelper, tag, isFilterActionable) { | |
| 563 | + if (isFilterActionable) { | |
| 564 | + // For actionable tags: read from filter helper (which is loaded from storage) | |
| 565 | + const filterType = TAG_ID_TO_FILTER_TYPE.get(tag.id) || null; | |
| 566 | + if (filterType) { | |
| 567 | + return filterHelper.getFilterData(filterType) || DEFAULT_FILTER_STATE; | |
| 568 | + } | |
| 569 | + } else { | |
| 570 | + // For regular tags: read from the filter helper's TAG filter data | |
| 571 | + const tagFilterData = filterHelper.getFilterData(FILTER_TYPES.TAG); | |
| 572 | + if (tagFilterData.excluded.includes(tag.id)) { | |
| 573 | + return 'EXCLUDED'; | |
| 574 | + } | |
| 575 | + if (tagFilterData.selected.includes(tag.id)) { | |
| 576 | + return 'SELECTED'; | |
| 577 | + } | |
| 578 | + } | |
| 579 | + | |
| 580 | + return DEFAULT_FILTER_STATE; | |
| 581 | +} | |
| 582 | + | |
| 583 | +/** | |
| 584 | + * Applies the favorite filter to the character list. | |
| 585 | + * @param {FilterHelper} filterHelper Instance of FilterHelper class. | |
| 586 | + */ | |
| 587 | +function filterByFav(filterHelper) { | |
| 588 | + applyActionableTagFilter.call(this, filterHelper, ACTIONABLE_TAGS.FAV, FILTER_TYPES.FAV, ACTIONABLE_FILTER_STORAGE_KEYS.FAV); | |
| 357 | 589 | } |
| 358 | 590 | |
| 359 | 591 | /** |
| @@ -361,10 +593,7 @@ function filterByFav(_filterHelper) { | ||
| 361 | 593 | * @param {FilterHelper} filterHelper Instance of FilterHelper class. |
| 362 | 594 | */ |
| 363 | 595 | function filterByGroups(filterHelper) { |
| 364 | - const state = toggleTagThreeState($(this)); | |
| 596 | + applyActionableTagFilter.call(this, filterHelper, ACTIONABLE_TAGS.GROUP, FILTER_TYPES.GROUP, ACTIONABLE_FILTER_STORAGE_KEYS.GROUP); | |
| 365 | - ACTIONABLE_TAGS.GROUP.filter_state = state; | |
| 366 | - accountStorage.setItem(ACTIONABLE_FILTER_STORAGE_KEYS.GROUP, state); | |
| 367 | - filterHelper.setFilterData(FILTER_TYPES.GROUP, state); | |
| 368 | 597 | } |
| 369 | 598 | |
| 370 | 599 | /** |
| @@ -379,10 +608,7 @@ function filterByFolder(filterHelper) { | ||
| 379 | 608 | return; |
| 380 | 609 | } |
| 381 | 610 | |
| 382 | - const state = toggleTagThreeState($(this)); | |
| 611 | + applyActionableTagFilter.call(this, filterHelper, ACTIONABLE_TAGS.FOLDER, FILTER_TYPES.FOLDER, ACTIONABLE_FILTER_STORAGE_KEYS.FOLDER); | |
| 383 | - ACTIONABLE_TAGS.FOLDER.filter_state = state; | |
| 384 | - accountStorage.setItem(ACTIONABLE_FILTER_STORAGE_KEYS.FOLDER, state); | |
| 385 | - filterHelper.setFilterData(FILTER_TYPES.FOLDER, state); | |
| 386 | 612 | } |
| 387 | 613 | |
| 388 | 614 | function loadTagsSettings(settings) { |
| @@ -942,6 +1168,7 @@ function newTag(tagName) { | ||
| 942 | 1168 | * @property {boolean} [isGeneralList=false] - If true, indicates that this is the general list of tags. |
| 943 | 1169 | * @property {boolean} [skipExistsCheck=false] - If true, the tag gets added even if a tag with the same id already exists. |
| 944 | 1170 | * @property {boolean} [isCharacterList=false] - If true, indicates that this is the character's list of tags. |
| 1171 | + * @property {boolean} [isInactive=false] - If true, indicates that the tag is inactive (for styling purposes). | |
| 945 | 1172 | */ |
| 946 | 1173 | |
| 947 | 1174 | /** |
| @@ -954,6 +1181,7 @@ function newTag(tagName) { | ||
| 954 | 1181 | * @property {function(object): function} [tagActionSelector=undefined] - An optional override for the action property that can be assigned to each tag via tagOptions. |
| 955 | 1182 | * 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. |
| 956 | 1183 | * @property {TagOptions} [tagOptions={}] - Options for tag behavior. (Same object will be passed into "appendTagToList") |
| 1184 | + * @property {string[]} [inactiveTags=[]] - List of tag IDs that are considered inactive (for styling purposes). | |
| 957 | 1185 | */ |
| 958 | 1186 | |
| 959 | 1187 | /** |
| @@ -962,7 +1190,7 @@ function newTag(tagName) { | ||
| 962 | 1190 | * @param {JQuery<HTMLElement>|string} element - The container element where the tags are to be printed. (Optionally can also be a string selector for the element, which will then be resolved) |
| 963 | 1191 | * @param {PrintTagListOptions} [options] - Optional parameters for printing the tag list. |
| 964 | 1192 | */ |
| 965 | 1193 | function printTagList(element, { tags = undefined, addTag = undefined, forEntityOrKey = undefined, empty = true, sort = true, tagActionSelector = undefined, tagOptions = {}, inactiveTags = [] } = {}) { |
| 966 | 1194 | const $element = (typeof element === 'string') ? $(element) : element; |
| 967 | 1195 | const key = forEntityOrKey !== undefined ? getTagKeyForEntity(forEntityOrKey) : getTagKey(); |
| 968 | 1196 | let printableTags = tags ? (typeof tags === 'function' ? tags() : tags) : getTagsList(key, sort); |
| @@ -1018,7 +1246,9 @@ function printTagList(element, { tags = undefined, addTag = undefined, forEntity | ||
| 1018 | 1246 | |
| 1019 | 1247 | // Check if we should print this tag |
| 1020 | 1248 | if (shouldPrintTag(tag) || additionalTagsPrinted++ < availableSlotsForAdditionalTags) { |
| 1021 | - appendTagToList($element, tag, tagOptions); | |
| 1249 | + // Check if this tag is in the inactive list | |
| 1250 | + const isInactive = inactiveTags.includes(tag.id); | |
| 1251 | + appendTagToList($element, tag, { ...tagOptions, isInactive }); | |
| 1022 | 1252 | } else { |
| 1023 | 1253 | tagsSkipped++; |
| 1024 | 1254 | } |
| @@ -1040,7 +1270,7 @@ function printTagList(element, { tags = undefined, addTag = undefined, forEntity | ||
| 1040 | 1270 | |
| 1041 | 1271 | // Do not bubble further, we are just expanding |
| 1042 | 1272 | event.stopPropagation(); |
| 1043 | 1273 | printTagList($element, { tags: tags, addTag: addTag, forEntityOrKey: forEntityOrKey, empty: empty, tagActionSelector: tagActionSelector, tagOptions: tagOptions, inactiveTags: inactiveTags }); |
| 1044 | 1274 | }; |
| 1045 | 1275 | |
| 1046 | 1276 | // Print the placeholder object with its styling and action to show the remaining tags |
| @@ -1061,7 +1291,7 @@ function printTagList(element, { tags = undefined, addTag = undefined, forEntity | ||
| 1061 | 1291 | * @param {TagOptions} [options={}] - Options for tag behavior |
| 1062 | 1292 | * @returns {void} |
| 1063 | 1293 | */ |
| 1064 | 1294 | function appendTagToList(listElement, tag, { removable = false, isFilter = false, action = undefined, removeAction = undefined, isGeneralList = false, skipExistsCheck = false, isInactive = false } = {}) { |
| 1065 | 1295 | if (!listElement) { |
| 1066 | 1296 | return; |
| 1067 | 1297 | } |
| @@ -1097,13 +1327,22 @@ function appendTagToList(listElement, tag, { removable = false, isFilter = false | ||
| 1097 | 1327 | tagElement.find('.tag_name').text('').attr('title', `${translate(tag.name)} ${tag.title || ''}`.trim()).addClass(tag.icon); |
| 1098 | 1328 | tagElement.addClass('actionable'); |
| 1099 | 1329 | } |
| 1330 | + if (isInactive) { | |
| 1331 | + tagElement.addClass('tag-absent'); | |
| 1332 | + } | |
| 1100 | 1333 | |
| 1101 | 1334 | // We could have multiple ways of actions passed in. The manual arguments have precendence in front of a specified tag action |
| 1102 | 1335 | const clickableAction = action ?? tag.action; |
| 1103 | 1336 | |
| 1104 | 1337 | // If this is a tag for a general list and its either a filter or actionable, lets mark its current state |
| 1105 | 1338 | if ((isFilter || clickableAction) && isGeneralList) { |
| 1106 | - toggleTagThreeState(tagElement, { stateOverride: tag.filter_state ?? DEFAULT_FILTER_STATE }); | |
| 1339 | + const filterHelper = getFilterHelper($(listElement)); | |
| 1340 | + const isFilterActionable = clickableAction && 'filter_state' in tag; | |
| 1341 | + | |
| 1342 | + if (isFilter || isFilterActionable) { | |
| 1343 | + const filterState = determineTagFilterState(filterHelper, tag, isFilterActionable); | |
| 1344 | + toggleTagThreeState(tagElement, { stateOverride: filterState }); | |
| 1345 | + } | |
| 1107 | 1346 | } |
| 1108 | 1347 | |
| 1109 | 1348 | if (isFilter) { |
| @@ -1127,16 +1366,77 @@ function onTagFilterClick(listElement) { | ||
| 1127 | 1366 | |
| 1128 | 1367 | let state = toggleTagThreeState($(this)); |
| 1129 | 1368 | |
| 1130 | - if (existingTag) { | |
| 1369 | + const filterHelper = getFilterHelper($(listElement)); | |
| 1370 | + | |
| 1371 | + // Update the tag's filter_state for the main character list (backward compatibility) | |
| 1372 | + if (existingTag && isMainCharacterList(filterHelper)) { | |
| 1131 | 1373 | existingTag.filter_state = state; |
| 1132 | 1374 | saveSettingsDebounced(); |
| 1133 | 1375 | } |
| 1134 | 1376 | |
| 1135 | - // We don't print anything manually, updating the filter will automatically trigger a redraw of all relevant stuff | |
| 1377 | + // Persist to storage for all contexts | |
| 1378 | + const storagePrefix = getFilterStorageKey(filterHelper); | |
| 1379 | + if (storagePrefix && existingTag) { | |
| 1380 | + const storageKey = `${storagePrefix}_tag_${tagId}`; | |
| 1381 | + accountStorage.setItem(storageKey, state); | |
| 1382 | + } | |
| 1383 | + | |
| 1384 | + // Apply all tag filters by reading from DOM state (this triggers the filter helper update) | |
| 1136 | 1385 | runTagFilters(listElement); |
| 1137 | 1386 | |
| 1138 | 1387 | // Focus the tag again we were at, if possible. To improve keyboard navigation |
| 1139 | 1388 | setTimeout(() => parent.find(`.tag[id="${tagId}"]`).trigger('focus'), DEFAULT_PRINT_TIMEOUT + 1); |
| 1389 | + | |
| 1390 | + updateTagFilterIndicator(listElement); | |
| 1391 | +} | |
| 1392 | + | |
| 1393 | +/** | |
| 1394 | + * Loads persisted filter states for a given filter context. | |
| 1395 | + * @param {FilterHelper} filterHelper - The filter helper instance | |
| 1396 | + * @param {string} storagePrefix - The storage key prefix for this context | |
| 1397 | + */ | |
| 1398 | +function loadFilterStatesForContext(filterHelper, storagePrefix) { | |
| 1399 | + const validStates = new Set(Object.keys(FILTER_STATES)); | |
| 1400 | + const readState = (/** @type {string} */ storageKey) => { | |
| 1401 | + const v = accountStorage.getItem(storageKey); | |
| 1402 | + return v && validStates.has(v) ? v : null; | |
| 1403 | + }; | |
| 1404 | + | |
| 1405 | + // Load actionable tag states (Favorites, Groups, Folders) | |
| 1406 | + const favState = readState(`${storagePrefix}_${ACTIONABLE_FILTER_STORAGE_KEYS.FAV}`); | |
| 1407 | + if (favState) { | |
| 1408 | + filterHelper.setFilterData(FILTER_TYPES.FAV, favState, true); | |
| 1409 | + } | |
| 1410 | + | |
| 1411 | + const groupState = readState(`${storagePrefix}_${ACTIONABLE_FILTER_STORAGE_KEYS.GROUP}`); | |
| 1412 | + if (groupState) { | |
| 1413 | + filterHelper.setFilterData(FILTER_TYPES.GROUP, groupState, true); | |
| 1414 | + } | |
| 1415 | + | |
| 1416 | + const folderState = readState(`${storagePrefix}_${ACTIONABLE_FILTER_STORAGE_KEYS.FOLDER}`); | |
| 1417 | + if (folderState) { | |
| 1418 | + filterHelper.setFilterData(FILTER_TYPES.FOLDER, folderState, true); | |
| 1419 | + } | |
| 1420 | + | |
| 1421 | + // Load regular tag filter states | |
| 1422 | + const tagFilterData = filterHelper.getFilterData(FILTER_TYPES.TAG); | |
| 1423 | + for (const tag of tags) { | |
| 1424 | + const storageKey = `${storagePrefix}_tag_${tag.id}`; | |
| 1425 | + const state = readState(storageKey); | |
| 1426 | + | |
| 1427 | + if (state) { | |
| 1428 | + if (state === 'SELECTED') { | |
| 1429 | + if (!tagFilterData.selected.includes(tag.id)) { | |
| 1430 | + tagFilterData.selected.push(tag.id); | |
| 1431 | + } | |
| 1432 | + } else if (state === 'EXCLUDED') { | |
| 1433 | + if (!tagFilterData.excluded.includes(tag.id)) { | |
| 1434 | + tagFilterData.excluded.push(tag.id); | |
| 1435 | + } | |
| 1436 | + } | |
| 1437 | + } | |
| 1438 | + } | |
| 1439 | + filterHelper.setFilterData(FILTER_TYPES.TAG, tagFilterData, true); | |
| 1140 | 1440 | } |
| 1141 | 1441 | |
| 1142 | 1442 | /** |
| @@ -1201,20 +1501,77 @@ function runTagFilters(listElement) { | ||
| 1201 | 1501 | } |
| 1202 | 1502 | |
| 1203 | 1503 | function printTagFilters(type = tag_filter_type.character) { |
| 1204 | - const FILTER_SELECTOR = type === tag_filter_type.character ? CHARACTER_FILTER_SELECTOR : GROUP_FILTER_SELECTOR; | |
| 1504 | + removeMissingTagFilters(); | |
| 1505 | + | |
| 1506 | + let FILTER_SELECTOR; | |
| 1507 | + switch (type) { | |
| 1508 | + case tag_filter_type.character: | |
| 1509 | + FILTER_SELECTOR = CHARACTER_FILTER_SELECTOR; | |
| 1510 | + break; | |
| 1511 | + case tag_filter_type.group_candidates_list: | |
| 1512 | + FILTER_SELECTOR = GROUP_FILTER_SELECTOR; | |
| 1513 | + break; | |
| 1514 | + case tag_filter_type.group_members_list: | |
| 1515 | + FILTER_SELECTOR = GROUP_MEMBERS_FILTER_SELECTOR; | |
| 1516 | + break; | |
| 1517 | + default: | |
| 1518 | + FILTER_SELECTOR = CHARACTER_FILTER_SELECTOR; | |
| 1519 | + break; | |
| 1520 | + } | |
| 1521 | + | |
| 1205 | 1522 | $(FILTER_SELECTOR).empty(); |
| 1206 | 1523 | |
| 1207 | 1524 | // Print all action tags. (Rework 'Folder' button to some kind of onboarding if no folders are enabled yet) |
| 1208 | 1525 | constlet actionTags = Object.values(ACTIONABLE_TAGS); |
| 1209 | 1526 | actionTags.find(x => x == ACTIONABLE_TAGS.FOLDER).name = power_user.bogus_folders ? 'Show only folders' : 'Enable \'Tags as Folder\'\n\nAllows characters to be grouped in folders by their assigned tags.\nTags have to be explicitly chosen as folder to show up.\n\nClick here to start'; |
| 1527 | + | |
| 1528 | + // For group contexts, filter actionable tags to only show relevant ones | |
| 1529 | + if (isGroupContext(type)) { | |
| 1530 | + actionTags = filterActionableTagsForGroupContext(actionTags); | |
| 1531 | + } | |
| 1532 | + | |
| 1210 | 1533 | printTagList($(FILTER_SELECTOR), { empty: false, sort: false, tags: actionTags, tagActionSelector: tag => tag.action, tagOptions: { isGeneralList: true } }); |
| 1211 | 1534 | |
| 1212 | 1535 | const inListActionTags = Object.values(InListActionable); |
| 1213 | 1536 | printTagList($(FILTER_SELECTOR), { empty: false, sort: false, tags: inListActionTags, tagActionSelector: tag => tag.action, tagOptions: { isGeneralList: true } }); |
| 1214 | 1537 | |
| 1215 | - const characterTagIds = Object.values(tag_map).flat(); | |
| 1538 | + // Determine which character tags to display based on context | |
| 1216 | - const tagsToDisplay = tags.filter(x => characterTagIds.includes(x.id)).sort(compareTagsForSort); | |
| 1539 | + let tagsToDisplay; | |
| 1217 | - printTagList($(FILTER_SELECTOR), { empty: false, tags: tagsToDisplay, tagOptions: { isFilter: true, isGeneralList: true } }); | |
| 1540 | + let inactiveTags = []; | |
| 1541 | + | |
| 1542 | + if (isGroupContext(type)) { | |
| 1543 | + // For group contexts, show all tags but mark ones without presence in current context as inactive | |
| 1544 | + // CAUTION: when called by openGroupById, the selected_group variable might not yet be updated | |
| 1545 | + const currentGroup = selected_group ? groups.find(x => x.id == selected_group) : null; | |
| 1546 | + const visibleAvatars = getVisibleAvatarsForGroupContext(type, currentGroup); | |
| 1547 | + | |
| 1548 | + if (visibleAvatars.length > 0) { | |
| 1549 | + // Get tags that are assigned to at least one visible character | |
| 1550 | + const activeCharacterTagIds = visibleAvatars | |
| 1551 | + .map(avatar => tag_map[avatar] || []) | |
| 1552 | + .flat() | |
| 1553 | + .filter(onlyUnique); | |
| 1554 | + | |
| 1555 | + // Show all tags that exist in the tag_map | |
| 1556 | + const allCharacterTagIds = Object.values(tag_map).flat().filter(onlyUnique); | |
| 1557 | + tagsToDisplay = tags.filter(x => allCharacterTagIds.includes(x.id)).sort(compareTagsForSort); | |
| 1558 | + | |
| 1559 | + // Mark tags that are not in the active set as inactive | |
| 1560 | + inactiveTags = tagsToDisplay | |
| 1561 | + .filter(x => !activeCharacterTagIds.includes(x.id)) | |
| 1562 | + .map(x => x.id); | |
| 1563 | + } else { | |
| 1564 | + // No group selected, show no tags | |
| 1565 | + tagsToDisplay = []; | |
| 1566 | + } | |
| 1567 | + } else { | |
| 1568 | + // For main character list, show all tags as before | |
| 1569 | + const characterTagIds = Object.values(tag_map).flat(); | |
| 1570 | + tagsToDisplay = tags.filter(x => characterTagIds.includes(x.id)).sort(compareTagsForSort); | |
| 1571 | + } | |
| 1572 | + | |
| 1573 | + printTagList($(FILTER_SELECTOR), { empty: false, tags: tagsToDisplay, tagOptions: { isFilter: true, isGeneralList: true }, inactiveTags: inactiveTags }); | |
| 1574 | + | |
| 1218 | 1575 | |
| 1219 | 1576 | // Print bogus folder navigation |
| 1220 | 1577 | const bogusDrilldown = $(FILTER_SELECTOR).siblings('.rm_tag_bogus_drilldown'); |
| @@ -1224,22 +1581,36 @@ function printTagFilters(type = tag_filter_type.character) { | ||
| 1224 | 1581 | printTagList(bogusDrilldown, { tags: navigatedTags, tagOptions: { removable: true } }); |
| 1225 | 1582 | } |
| 1226 | 1583 | |
| 1227 | - runTagFilters(FILTER_SELECTOR); | |
| 1584 | + // Don't call runTagFilters here - it would overwrite the loaded filter states with the DOM state. | |
| 1585 | + // The visual state (CSS classes) already matches the filter helper state set by loadFilterStatesForContext. | |
| 1586 | + // runTagFilters is only needed when user clicks a tag (handled in onTagFilterClick). | |
| 1228 | 1587 | |
| 1229 | - if (power_user.show_tag_filters) { | |
| 1588 | + // Initialize the tag list visibility based on saved settings for this context | |
| 1230 | - $('.rm_tag_controls .showTagList').addClass('selected'); | |
| 1589 | + const shouldShowTags = getTagFilterVisibility(type); | |
| 1231 | 1590 | const showTagListButton = $(FILTER_SELECTOR).closest('.rm_tag_controls').find('.tag:not(.actionable)showTagList').show(); |
| 1232 | - } | |
| 1233 | 1591 | |
| 1234 | - updateTagFilterIndicator(); | |
| 1592 | + // Update button state to match the saved setting | |
| 1235 | -} | |
| 1593 | + showTagListButton.toggleClass('selected', shouldShowTags); | |
| 1236 | 1594 | |
| 1237 | 1595 | function if updateTagFilterIndicator(shouldShowTags) { |
| 1238 | 1596 | if ( $('.rm_tag_controls'FILTER_SELECTOR).find('.tag:not(.actionable)').isshow('.selected, .excluded')) {; |
| 1239 | - $('.rm_tag_controls .showTagList').addClass('indicator'); | |
| 1240 | 1597 | } else { |
| 1241 | 1598 | $(FILTER_SELECTOR).find('.rm_tag_controls tag:not(.showTagListactionable)').removeClasshide('indicator'); |
| 1242 | 1599 | } |
| 1600 | + | |
| 1601 | + updateTagFilterIndicator(FILTER_SELECTOR); | |
| 1602 | +} | |
| 1603 | + | |
| 1604 | +/** | |
| 1605 | + * Updates the tag filter indicator based on the selected/excluded tags in the given filter selector | |
| 1606 | + * @param {string|JQuery<HTMLElement>} filterSelector - The selector or jQuery element for the tag filter container | |
| 1607 | + */ | |
| 1608 | +function updateTagFilterIndicator(filterSelector) { | |
| 1609 | + const selector = filterSelector || CHARACTER_FILTER_SELECTOR; | |
| 1610 | + const tagFilter = typeof selector === 'string' ? $(selector) : selector; | |
| 1611 | + const showTagListButton = tagFilter.closest('.rm_tag_controls').find('.showTagList'); | |
| 1612 | + const hasActiveTags = tagFilter.find('.tag:not(.actionable)').is('.selected, .excluded'); | |
| 1613 | + showTagListButton.toggleClass('indicator', hasActiveTags); | |
| 1243 | 1614 | } |
| 1244 | 1615 | |
| 1245 | 1616 | function onTagRemoveClick(event) { |
| @@ -1316,6 +1687,8 @@ export function applyTagsOnGroupSelect(groupId = null) { | ||
| 1316 | 1687 | |
| 1317 | 1688 | groupId = groupId ?? (selected_group ? Number(selected_group) : undefined); |
| 1318 | 1689 | printTagList($('#groupTagList'), { forEntityOrKey: groupId, tagOptions: { removable: true } }); |
| 1690 | + printTagFilters(tag_filter_type.group_candidates_list); | |
| 1691 | + printTagFilters(tag_filter_type.group_members_list); | |
| 1319 | 1692 | } |
| 1320 | 1693 | |
| 1321 | 1694 | /** |
| @@ -1841,17 +2214,39 @@ function onTagListHintClick() { | ||
| 1841 | 2214 | } |
| 1842 | 2215 | |
| 1843 | 2216 | $(this).siblings('.innerActionable').toggleClass('hidden'); |
| 1844 | - power_user.show_tag_filters = $(this).hasClass('selected'); | |
| 2217 | + | |
| 1845 | - saveSettingsDebounced(); | |
| 2218 | + // Determine which context this button belongs to and save the setting | |
| 1846 | - console.debug('show_tag_filters', power_user.show_tag_filters); | |
| 2219 | + let filterType = tag_filter_type.character; | |
| 2220 | + | |
| 2221 | + // Check which section we're in by looking at the sibling header | |
| 2222 | + const $tagControls = $(this).closest('.rm_tag_controls'); | |
| 2223 | + if ($tagControls.prev().is('#rm_group_add_members_header')) { | |
| 2224 | + filterType = tag_filter_type.group_candidates_list; | |
| 2225 | + } else if ($tagControls.prev().is('#rm_group_members_header')) { | |
| 2226 | + filterType = tag_filter_type.group_members_list; | |
| 2227 | + } | |
| 2228 | + | |
| 2229 | + const isSelected = $(this).hasClass('selected'); | |
| 2230 | + setTagFilterVisibility(filterType, isSelected); | |
| 2231 | + console.debug('show_tag_filters for type', filterType, ':', isSelected); | |
| 1847 | 2232 | } |
| 1848 | 2233 | |
| 1849 | -function onClearAllFiltersClick() { | |
| 2234 | +/** | |
| 2235 | + * Clears all filters for the current list context. | |
| 2236 | + * @param {FilterHelper} filterHelper - The filter helper for the current context | |
| 2237 | + */ | |
| 2238 | +function onClearAllFiltersClick(filterHelper) { | |
| 1850 | 2239 | console.debug('clear all filters clicked'); |
| 1851 | 2240 | |
| 2241 | + const context = getFilterContext(filterHelper); | |
| 2242 | + if (!context) { | |
| 2243 | + console.warn('Unknown filter helper in onClearAllFiltersClick'); | |
| 2244 | + return; | |
| 2245 | + } | |
| 2246 | + | |
| 1852 | 2247 | // We have to manually go through the elements and unfilter by clicking... |
| 1853 | 2248 | // Thankfully nearly all filter controls are three-state-toggles |
| 1854 | 2249 | const filterTags = $('.rm_tag_controls context.rm_tag_filter'selector).find('.tag'); |
| 1855 | 2250 | for (const tag of filterTags) { |
| 1856 | 2251 | const toggleState = $(tag).attr('data-toggle-state'); |
| 1857 | 2252 | if (toggleState !== undefined && !isFilterState(toggleState ?? FILTER_STATES.UNDEFINED, FILTER_STATES.UNDEFINED)) { |
| @@ -1859,8 +2254,8 @@ function onClearAllFiltersClick() { | ||
| 1859 | 2254 | } |
| 1860 | 2255 | } |
| 1861 | 2256 | |
| 1862 | 2257 | // Reset search tooinput for this context |
| 1863 | 2258 | $('#character_search_bar'context.searchInput).val('').trigger('input'); |
| 1864 | 2259 | } |
| 1865 | 2260 | |
| 1866 | 2261 | /** |
| @@ -1891,6 +2286,37 @@ function printViewTagList(tagContainer, empty = true) { | ||
| 1891 | 2286 | } |
| 1892 | 2287 | } |
| 1893 | 2288 | |
| 2289 | +function removeMissingTagFilters() { | |
| 2290 | + const tagIds = new Set(tags.map(tag => tag.id)); | |
| 2291 | + | |
| 2292 | + for (const helper of [groupCandidatesFilter, groupMembersFilter, entitiesFilter]) { | |
| 2293 | + const { selected, excluded } = helper.getFilterData(FILTER_TYPES.TAG); | |
| 2294 | + let anyRemoved = false; | |
| 2295 | + | |
| 2296 | + if (Array.isArray(selected)) { | |
| 2297 | + for (let i = selected.length - 1; i >= 0; i--) { | |
| 2298 | + if (!tagIds.has(selected[i])) { | |
| 2299 | + selected.splice(i, 1); | |
| 2300 | + anyRemoved = true; | |
| 2301 | + } | |
| 2302 | + } | |
| 2303 | + } | |
| 2304 | + | |
| 2305 | + if (Array.isArray(excluded)) { | |
| 2306 | + for (let i = excluded.length - 1; i >= 0; i--) { | |
| 2307 | + if (!tagIds.has(excluded[i])) { | |
| 2308 | + excluded.splice(i, 1); | |
| 2309 | + anyRemoved = true; | |
| 2310 | + } | |
| 2311 | + } | |
| 2312 | + } | |
| 2313 | + | |
| 2314 | + if (anyRemoved) { | |
| 2315 | + helper.setFilterData(FILTER_TYPES.TAG, { selected, excluded }); | |
| 2316 | + } | |
| 2317 | + } | |
| 2318 | +} | |
| 2319 | + | |
| 1894 | 2320 | function registerTagsSlashCommands() { |
| 1895 | 2321 | /** |
| 1896 | 2322 | * Gets a tag by its name. Optionally can create the tag if it does not exist. |
| @@ -2237,7 +2663,8 @@ function normalizeTagName(name) { | ||
| 2237 | 2663 | .toLowerCase(); |
| 2238 | 2664 | } |
| 2239 | 2665 | |
| 2240 | -/** Extracts the character avatar file name from the avatar source URL. | |
| 2666 | +/** | |
| 2667 | + * Extracts the character avatar file name from the avatar source URL. | |
| 2241 | 2668 | * @param {string} avatarSrc The source URL of the character avatar. |
| 2242 | 2669 | * @returns {string|null} The normalized avatar file name, or null if the input is falsy or doesn't contain a valid file name. |
| 2243 | 2670 | */ |
| @@ -2270,8 +2697,12 @@ function restoreSavedTagFilters() { | ||
| 2270 | 2697 | if (favState) { |
| 2271 | 2698 | ACTIONABLE_TAGS.FAV.filter_state = favState; |
| 2272 | 2699 | entitiesFilter.setFilterData(FILTER_TYPES.FAV, favState, true); |
| 2273 | - groupCandidatesFilter.setFilterData(FILTER_TYPES.FAV, favState, true); | |
| 2274 | 2700 | } |
| 2701 | + | |
| 2702 | + // Load persisted filter states for all contexts (including character list) | |
| 2703 | + loadFilterStatesForContext(entitiesFilter, 'CharacterList'); | |
| 2704 | + loadFilterStatesForContext(groupCandidatesFilter, 'GroupCandidates'); | |
| 2705 | + loadFilterStatesForContext(groupMembersFilter, 'GroupMembers'); | |
| 2275 | 2706 | if (groupState) { |
| 2276 | 2707 | ACTIONABLE_TAGS.GROUP.filter_state = groupState; |
| 2277 | 2708 | entitiesFilter.setFilterData(FILTER_TYPES.GROUP, groupState, true); |
| @@ -2280,6 +2711,10 @@ function restoreSavedTagFilters() { | ||
| 2280 | 2711 | ACTIONABLE_TAGS.FOLDER.filter_state = folderState; |
| 2281 | 2712 | entitiesFilter.setFilterData(FILTER_TYPES.FOLDER, folderState, true); |
| 2282 | 2713 | } |
| 2714 | + | |
| 2715 | + // Note: Regular tag filter states are now loaded from storage via loadFilterStatesForContext() | |
| 2716 | + // The old tag.filter_state property is only maintained for backward compatibility with | |
| 2717 | + // the main character list's actionable tags (Favorites, Groups, Folders) | |
| 2283 | 2718 | } catch (e) { |
| 2284 | 2719 | console.warn('Failed to restore actionable filter states from account storage', e); |
| 2285 | 2720 | } |