From 18379ec602d923e5d68e368265cb6df16c4b51bd Mon Sep 17 00:00:00 2001 From: Wolfsblvt Date: Wed, 6 Mar 2024 23:13:22 +0100 Subject: [PATCH] Tag Folders: Improve global tag filters - Update global tag filters to three-state filters - Add filter for folders (showing empty folders or no folders) - Final fix of filtering (should be correct now) --- public/css/tags.css | 4 +- public/script.js | 17 +++++++- public/scripts/filters.js | 68 ++++++++++++++++++++++++++----- public/scripts/tags.js | 85 +++++++++++++++++++++++---------------- public/style.css | 3 ++ 5 files changed, 130 insertions(+), 47 deletions(-) diff --git a/public/css/tags.css b/public/css/tags.css index a278d941c..0c127d643 100644 --- a/public/css/tags.css +++ b/public/css/tags.css @@ -160,11 +160,11 @@ .tag.excluded::after { position: absolute; - top: 0; - bottom: 0; + height: calc(var(--mainFontSize)*1.5); left: 0; right: 0; content: "\d7"; + pointer-events: none; font-size: calc(var(--mainFontSize) *3); color: red; line-height: calc(var(--mainFontSize)*1.3); diff --git a/public/script.js b/public/script.js index 8d0648c86..51cd8f5a9 100644 --- a/public/script.js +++ b/public/script.js @@ -1309,23 +1309,36 @@ export function getEntitiesList({ doFilter = false, doSort = true } = {}) { ...(power_user.bogus_folders ? tags.filter(isBogusFolder).sort(compareTagsForSort).map(item => tagToEntity(item)) : []), ]; + // We need to do multiple filter runs in a specific order, otherwise different settings might override each other + // and screw up tags and search filter, sub lists or similar. + // The specific filters are written inside the "filterByTagState" method and its different parameters. + // Generally what we do is the following: + // 1. First swipe over the list to remove the most obvious things + // 2. Build sub entity lists for all folders, filtering them similarly to the second swipe + // 3. We do the last run, where global filters are applied, and the search filters last + // First run filters, that will hide what should never be displayed if (doFilter) { - entities = entitiesFilter.applyFilters(entities); entities = filterByTagState(entities); } // Run over all entities between first and second filter to save some states for(const entity of entities) { // For folders, we remember the sub entities so they can be displayed later, even if they might be filtered + // Those sub entities should be filtered and have the search filters applied too if (entity.type === 'tag') { - entity.entities = filterByTagState(entities, { subForEntity: entity }); + let subEntities = filterByTagState(entities, { subForEntity: entity }); + if (doFilter) { + subEntities = entitiesFilter.applyFilters(subEntities); + } + entity.entities = subEntities; } } // Second run filters, hiding whatever should be filtered later if (doFilter) { entities = filterByTagState(entities, { globalDisplayFilters: true }); + entities = entitiesFilter.applyFilters(entities); } if (doSort) { diff --git a/public/scripts/filters.js b/public/scripts/filters.js index 2f505ec93..1754d613d 100644 --- a/public/scripts/filters.js +++ b/public/scripts/filters.js @@ -8,12 +8,37 @@ import { tag_map } from './tags.js'; export const FILTER_TYPES = { SEARCH: 'search', TAG: 'tag', + FOLDER: 'folder', FAV: 'fav', GROUP: 'group', WORLD_INFO_SEARCH: 'world_info_search', PERSONA_SEARCH: 'persona_search', }; +/** + * The filter states. + * @type {Object.} + */ +export const FILTER_STATES = { + SELECTED: { key: 'SELECTED', class: 'selected' }, + EXCLUDED: { key: 'EXCLUDED', class: 'excluded' }, + UNDEFINED: { key: 'UNDEFINED', class: undefined }, +}; + +/** + * 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 + */ +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); + + return aKey === bKey; +} + /** * Helper class for filtering data. * @example @@ -36,8 +61,9 @@ export class FilterHelper { */ filterFunctions = { [FILTER_TYPES.SEARCH]: this.searchFilter.bind(this), - [FILTER_TYPES.GROUP]: this.groupFilter.bind(this), [FILTER_TYPES.FAV]: this.favFilter.bind(this), + [FILTER_TYPES.GROUP]: this.groupFilter.bind(this), + [FILTER_TYPES.FOLDER]: this.folderFilter.bind(this), [FILTER_TYPES.TAG]: this.tagFilter.bind(this), [FILTER_TYPES.WORLD_INFO_SEARCH]: this.wiSearchFilter.bind(this), [FILTER_TYPES.PERSONA_SEARCH]: this.personaSearchFilter.bind(this), @@ -49,8 +75,9 @@ export class FilterHelper { */ filterData = { [FILTER_TYPES.SEARCH]: '', - [FILTER_TYPES.GROUP]: false, [FILTER_TYPES.FAV]: false, + [FILTER_TYPES.GROUP]: false, + [FILTER_TYPES.FOLDER]: false, [FILTER_TYPES.TAG]: { excluded: [], selected: [] }, [FILTER_TYPES.WORLD_INFO_SEARCH]: '', [FILTER_TYPES.PERSONA_SEARCH]: '', @@ -144,11 +171,10 @@ export class FilterHelper { * @returns {any[]} The filtered data. */ favFilter(data) { - if (!this.filterData[FILTER_TYPES.FAV]) { - return data; - } + const state = this.filterData[FILTER_TYPES.FAV]; + const isFav = entity => entity.item.fav || entity.item.fav == 'true'; - return data.filter(entity => entity.item.fav || entity.item.fav == 'true'); + return this.filterDataByState(data, state, isFav, { includeFolders: true }); } /** @@ -157,11 +183,35 @@ export class FilterHelper { * @returns {any[]} The filtered data. */ groupFilter(data) { - if (!this.filterData[FILTER_TYPES.GROUP]) { - return data; + const state = this.filterData[FILTER_TYPES.GROUP]; + const isGroup = entity => entity.type === 'group'; + + return this.filterDataByState(data, state, isGroup, { includeFolders: true }); + } + + /** + * Applies a "folder" filter to the data. + * @param {any[]} data The data to filter. + * @returns {any[]} The filtered data. + */ + folderFilter(data) { + const state = this.filterData[FILTER_TYPES.FOLDER]; + // Slightly different than the other filters, as a positive folder filter means it doesn't filter anything (folders get "not hidden" at another place), + // while a negative state should then filter out all folders. + const isFolder = entity => isFilterState(state, FILTER_STATES.SELECTED) ? true : entity.type === 'tag'; + + return this.filterDataByState(data, state, isFolder); + } + + filterDataByState(data, state, filterFunc, { includeFolders } = {}) { + if (isFilterState(state, FILTER_STATES.SELECTED)) { + return data.filter(entity => filterFunc(entity) || (includeFolders && entity.type == 'tag')); + } + if (isFilterState(state, FILTER_STATES.EXCLUDED)) { + return data.filter(entity => !filterFunc(entity) || (includeFolders && entity.type == 'tag')); } - return data.filter(entity => entity.type === 'group'); + return data; } /** diff --git a/public/scripts/tags.js b/public/scripts/tags.js index 9f16d4db1..255455ddd 100644 --- a/public/scripts/tags.js +++ b/public/scripts/tags.js @@ -10,7 +10,7 @@ import { buildAvatarList, } from '../script.js'; // eslint-disable-next-line no-unused-vars -import { FILTER_TYPES, FilterHelper } from './filters.js'; +import { FILTER_TYPES, FILTER_STATES, isFilterState, FilterHelper } from './filters.js'; import { groupCandidatesFilter, groups, selected_group } from './group-chats.js'; import { download, onlyUnique, parseJsonFile, uuidv4, getSortableDelay } from './utils.js'; @@ -50,8 +50,9 @@ export const tag_filter_types = { }; const ACTIONABLE_TAGS = { - FAV: { id: 1, name: 'Show only favorites', color: 'rgba(255, 255, 0, 0.5)', action: applyFavFilter, icon: 'fa-solid fa-star', class: 'filterByFavorites' }, + 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' }, }; @@ -81,7 +82,11 @@ let tag_map = {}; /** * Applies the basic filter for the current state of the tags and their selection on an entity list. - * @param {*} entities List of entities for display, consisting of tags, characters and groups. + * @param {Array} entities List of entities for display, consisting of tags, characters and groups. + * @param {Object} param1 Optional parameters, explained below. + * @param {Boolean} [param1.globalDisplayFilters] When enabled, applies the final filter for the global list. Icludes filtering out entities in closed/hidden folders and empty folders. + * @param {Object} [param1.subForEntity] When given an entity, the list of entities gets filtered specifically for that one as a "sub list", filtering out other tags, elements not tagged for this and hidden elements. + * @returns The filtered list of entities */ function filterByTagState(entities, { globalDisplayFilters = false, subForEntity = undefined } = {}) { const filterData = structuredClone(entitiesFilter.getFilterData(FILTER_TYPES.TAG)); @@ -108,8 +113,9 @@ function filterByTagState(entities, { globalDisplayFilters = false, subForEntity } // Hide folders that have 0 visible sub entities after the first filtering round + const alwaysFolder = isFilterState(entitiesFilter.getFilterData(FILTER_TYPES.FOLDER), FILTER_STATES.SELECTED); if (entity.type === 'tag') { - return entity.entities.length > 0; + return alwaysFolder || entity.entities.length > 0; } return true; @@ -218,12 +224,9 @@ function getTagBlock(item, entities) { * Applies the favorite filter to the character list. * @param {FilterHelper} filterHelper Instance of FilterHelper class. */ -function applyFavFilter(filterHelper) { - const isSelected = $(this).hasClass('selected'); - const displayFavoritesOnly = !isSelected; - $(this).toggleClass('selected', displayFavoritesOnly); - - filterHelper.setFilterData(FILTER_TYPES.FAV, displayFavoritesOnly); +function filterByFav(filterHelper) { + const state = toggleTagThreeState($(this)); + filterHelper.setFilterData(FILTER_TYPES.FAV, state); } /** @@ -231,11 +234,17 @@ function applyFavFilter(filterHelper) { * @param {FilterHelper} filterHelper Instance of FilterHelper class. */ function filterByGroups(filterHelper) { - const isSelected = $(this).hasClass('selected'); - const displayGroupsOnly = !isSelected; - $(this).toggleClass('selected', displayGroupsOnly); + const state = toggleTagThreeState($(this)); + filterHelper.setFilterData(FILTER_TYPES.GROUP, state); +} - filterHelper.setFilterData(FILTER_TYPES.GROUP, displayGroupsOnly); +/** + * Applies the "only folder" filter to the character list. + * @param {FilterHelper} filterHelper Instance of FilterHelper class. + */ +function filterByFolder(filterHelper) { + const state = toggleTagThreeState($(this)); + filterHelper.setFilterData(FILTER_TYPES.FOLDER, state); } function loadTagsSettings(settings) { @@ -475,7 +484,7 @@ function appendTagToList(listElement, tag, { removable, selectable, action, isGe } if (tag.excluded && isGeneralList) { - $(tagElement).addClass('excluded'); + toggleTagThreeState(tagElement, FILTER_STATES.EXCLUDED); } if (selectable) { @@ -498,27 +507,13 @@ function onTagFilterClick(listElement) { const tagId = $(this).attr('id'); const existingTag = tags.find((tag) => tag.id === tagId); - let excludeTag; - if ($(this).hasClass('selected')) { - $(this).removeClass('selected'); - $(this).addClass('excluded'); - excludeTag = true; - } - else if ($(this).hasClass('excluded')) { - $(this).removeClass('excluded'); - excludeTag = false; - } - else { - $(this).addClass('selected'); - } + let state = toggleTagThreeState($(this)); // Manual undefined check required for three-state boolean - if (excludeTag !== undefined) { - if (existingTag) { - existingTag.excluded = excludeTag; + if (existingTag) { + existingTag.excluded = isFilterState(state, FILTER_STATES.EXCLUDED); - saveSettingsDebounced(); - } + saveSettingsDebounced(); } // Update bogus folder if applicable @@ -535,6 +530,28 @@ function onTagFilterClick(listElement) { updateTagFilterIndicator(); } +function toggleTagThreeState(element, stateOverride = undefined) { + const states = Object.keys(FILTER_STATES); + + const overrideKey = states.includes(stateOverride) ? stateOverride : states.find(key => FILTER_STATES[key] === stateOverride); + + const currentState = element.attr('data-toggle-state') ?? states[states.length - 1]; + const nextState = overrideKey ?? states[(states.indexOf(currentState) + 1) % states.length]; + + element.attr('data-toggle-state', nextState); + + console.debug('toggle three-way filter on', element, 'from', currentState, 'to', nextState); + + // Update css class and remove all others + Object.keys(FILTER_STATES).forEach(x => { + if (!isFilterState(x, FILTER_STATES.UNDEFINED)) { + element.toggleClass(FILTER_STATES[x].class, x === nextState); + } + }); + + return nextState; +} + function runTagFilters(listElement) { const tagIds = [...($(listElement).find('.tag.selected:not(.actionable)').map((_, el) => $(el).attr('id')))]; const excludedTagIds = [...($(listElement).find('.tag.excluded:not(.actionable)').map((_, el) => $(el).attr('id')))]; @@ -950,7 +967,7 @@ function onTagAsFolderClick() { // Cycle through folder types const types = Object.keys(TAG_FOLDER_TYPES); - let currentTypeIndex = types.indexOf(tag.folder_type); + const currentTypeIndex = types.indexOf(tag.folder_type); tag.folder_type = types[(currentTypeIndex + 1) % types.length]; updateDrawTagFolder(element, tag); diff --git a/public/style.css b/public/style.css index a79ea5fd7..bf9cad51a 100644 --- a/public/style.css +++ b/public/style.css @@ -955,6 +955,9 @@ hr { margin-left: calc(var(--avatar-base-border-radius)); margin-bottom: calc(var(--avatar-base-border-radius)); } +.avatars_inline .avatar:last-of-type { + margin-right: calc(var(--avatar-base-border-radius)); +} .group_select_block_list { display: flex;