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)
This commit is contained in:
parent
fc6146fa00
commit
18379ec602
|
@ -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);
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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.<string, 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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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<Object>} 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);
|
||||
|
|
|
@ -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;
|
||||
|
|
Loading…
Reference in New Issue