mirror of
https://github.com/SillyTavern/SillyTavern.git
synced 2025-06-05 21:59:27 +02:00
Merge pull request #1982 from Wolfsblvt/improve-bulk-edit-and-fixes
Improve bulk edit and bug fixes to tags
This commit is contained in:
@ -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);
|
||||
}
|
||||
@ -230,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,
|
||||
|
@ -4396,8 +4396,10 @@
|
||||
|
||||
<div id="rm_print_characters_pagination">
|
||||
<i id="charListGridToggle" class="fa-solid fa-table-cells-large menu_button" title="Toggle character grid view" data-i18n="[title]Toggle character grid view"></i>
|
||||
<i id="bulkEditButton" class="fa-solid fa-edit menu_button bulkEditButton" title="Bulk edit characters" data-i18n="[title]Bulk edit characters"></i>
|
||||
<i id="bulkDeleteButton" class="fa-solid fa-trash menu_button bulkDeleteButton" title="Bulk delete characters" data-i18n="[title]Bulk delete characters" style="display: none;"></i>
|
||||
<i id="bulkEditButton" class="fa-solid fa-edit menu_button bulkEditButton" title="Bulk edit characters Click to toggle characters Shift + Click to select/deselect a range of characters Right-click for actions" data-i18n="[title]Bulk edit characters Click to toggle characters Shift + Click to select/deselect a range of characters Right-click for actions"></i>
|
||||
<div id="bulkSelectedCount" class="bulkEditOptionElement paginationjs-nav"></div>
|
||||
<i id="bulkSelectAllButton" class="fa-solid fa-check-double menu_button bulkEditOptionElement bulkSelectAllButton" title="Bulk select all characters" data-i18n="[title]Bulk select all characters" style="display: none;"></i>
|
||||
<i id="bulkDeleteButton" class="fa-solid fa-trash menu_button bulkEditOptionElement bulkDeleteButton" title="Bulk delete characters" data-i18n="[title]Bulk delete characters" style="display: none;"></i>
|
||||
</div>
|
||||
<div id="rm_print_characters_block" class="flexFlowColumn"></div>
|
||||
</div>
|
||||
|
118
public/script.js
118
public/script.js
@ -280,7 +280,9 @@ export {
|
||||
default_ch_mes,
|
||||
extension_prompt_types,
|
||||
mesForShowdownParse,
|
||||
characterGroupOverlay,
|
||||
printCharacters,
|
||||
printCharactersDebounced,
|
||||
isOdd,
|
||||
countOccurrences,
|
||||
};
|
||||
@ -497,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
|
||||
*/
|
||||
@ -837,7 +847,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() {
|
||||
@ -1276,19 +1286,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,
|
||||
@ -1304,7 +1326,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) {
|
||||
@ -1341,26 +1363,67 @@ async function printCharacters(fullRefresh = false) {
|
||||
saveCharactersPage = e;
|
||||
},
|
||||
afterRender: function () {
|
||||
$(listId).scrollTop(0);
|
||||
$(listId).scrollTop(currentScrollTop);
|
||||
},
|
||||
});
|
||||
|
||||
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 } = {}) {
|
||||
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)),
|
||||
@ -5442,7 +5505,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);
|
||||
@ -6900,18 +6963,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) {
|
||||
|
@ -1,6 +1,7 @@
|
||||
'use strict';
|
||||
|
||||
import {
|
||||
characterGroupOverlay,
|
||||
callPopup,
|
||||
characters,
|
||||
deleteCharacter,
|
||||
@ -9,25 +10,15 @@ import {
|
||||
getCharacters,
|
||||
getPastCharacterChats,
|
||||
getRequestHeaders,
|
||||
printCharacters,
|
||||
buildAvatarList,
|
||||
characterToEntity,
|
||||
printCharactersDebounced,
|
||||
} from '../script.js';
|
||||
|
||||
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';
|
||||
|
||||
// Utility object for popup messages.
|
||||
const popupMessage = {
|
||||
deleteChat(characterCount) {
|
||||
return `<h3>Delete ${characterCount} characters?</h3>
|
||||
<b>THIS IS PERMANENT!<br><br>
|
||||
<label for="del_char_checkbox" class="checkbox_label justifyCenter">
|
||||
<input type="checkbox" id="del_char_checkbox" />
|
||||
<span>Also delete the chat files</span>
|
||||
</label><br></b>`;
|
||||
},
|
||||
};
|
||||
import { createTagInput, getTagKeyForEntity, getTagsList, printTagList, tag_map, compareTagsForSort, removeTagFromMap } from './tags.js';
|
||||
|
||||
/**
|
||||
* Static object representing the actions of the
|
||||
@ -38,16 +29,16 @@ class CharacterContextMenu {
|
||||
* Tag one or more characters,
|
||||
* opens a popup.
|
||||
*
|
||||
* @param selectedCharacters
|
||||
* @param {Array<number>} selectedCharacters
|
||||
*/
|
||||
static tag = (selectedCharacters) => {
|
||||
BulkTagPopupHandler.show(selectedCharacters);
|
||||
characterGroupOverlay.bulkTagPopupHandler.show(selectedCharacters);
|
||||
};
|
||||
|
||||
/**
|
||||
* Duplicate one or more characters
|
||||
*
|
||||
* @param characterId
|
||||
* @param {number} characterId
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
static duplicate = async (characterId) => {
|
||||
@ -72,7 +63,7 @@ class CharacterContextMenu {
|
||||
* Favorite a character
|
||||
* and highlight it.
|
||||
*
|
||||
* @param characterId
|
||||
* @param {number} characterId
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
static favorite = async (characterId) => {
|
||||
@ -108,7 +99,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<void>}
|
||||
*/
|
||||
static persona = async (characterId) => await convertCharacterToPersona(characterId);
|
||||
@ -117,8 +108,8 @@ class CharacterContextMenu {
|
||||
* Delete one or more characters,
|
||||
* opens a popup.
|
||||
*
|
||||
* @param characterId
|
||||
* @param deleteChats
|
||||
* @param {number} characterId
|
||||
* @param {boolean} [deleteChats]
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
static delete = async (characterId, deleteChats = false) => {
|
||||
@ -196,13 +187,39 @@ class CharacterContextMenu {
|
||||
* Represents a tag control not bound to a single character
|
||||
*/
|
||||
class BulkTagPopupHandler {
|
||||
static #getHtml = (characterIds) => {
|
||||
const characterData = JSON.stringify({ characterIds: characterIds });
|
||||
/**
|
||||
* 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
|
||||
*
|
||||
* @returns String containing the html for the popup
|
||||
*/
|
||||
#getHtml = () => {
|
||||
const characterData = JSON.stringify({ characterIds: this.characterIds });
|
||||
return `<div id="bulk_tag_shadow_popup">
|
||||
<div id="bulk_tag_popup">
|
||||
<div id="bulk_tag_popup_holder">
|
||||
<h3 class="m-b-1">Add tags to ${characterIds.length} characters</h3>
|
||||
<br>
|
||||
<h3 class="marginBot5">Modify tags of ${this.characterIds.length} characters</h3>
|
||||
<small class="bulk_tags_desc m-b-1">Add or remove the mutual tags of all selected characters.</small>
|
||||
<div id="bulk_tags_avatars_block" class="avatars_inline avatars_inline_small tags tags_inline"></div>
|
||||
<br>
|
||||
<div id="bulk_tags_div" class="marginBot5" data-characters='${characterData}'>
|
||||
<div class="tag_controls">
|
||||
<input id="bulkTagInput" class="text_pole tag_input wide100p margin0" data-i18n="[placeholder]Search / Create Tags" placeholder="Search / Create tags" maxlength="25" />
|
||||
@ -211,51 +228,117 @@ class BulkTagPopupHandler {
|
||||
<div id="bulkTagList" class="m-t-1 tags"></div>
|
||||
</div>
|
||||
<div id="dialogue_popup_controls" class="m-t-1">
|
||||
<div id="bulk_tag_popup_reset" class="menu_button" title="Remove all tags from the selected characters" data-i18n="[title]Remove all tags from the selected characters">
|
||||
<i class="fa-solid fa-trash-can margin-right-10px"></i>
|
||||
All
|
||||
</div>
|
||||
<div id="bulk_tag_popup_remove_mutual" class="menu_button" title="Remove all mutual tags from the selected characters" data-i18n="[title]Remove all mutual tags from the selected characters">
|
||||
<i class="fa-solid fa-trash-can margin-right-10px"></i>
|
||||
Mutual
|
||||
</div>
|
||||
<div id="bulk_tag_popup_cancel" class="menu_button" data-i18n="Cancel">Close</div>
|
||||
<div id="bulk_tag_popup_reset" class="menu_button" data-i18n="Cancel">Remove all</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
</div>`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Append and show the tag control
|
||||
*
|
||||
* @param characters - The characters assigned to this control
|
||||
* @param {number[]} characterIds - The characters that are shown inside the popup
|
||||
*/
|
||||
static show(characters) {
|
||||
document.body.insertAdjacentHTML('beforeend', this.#getHtml(characters));
|
||||
createTagInput('#bulkTagInput', '#bulkTagList');
|
||||
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());
|
||||
|
||||
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(), 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(), tagOptions: { removable: true }});
|
||||
|
||||
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));
|
||||
document.querySelector('#bulk_tag_popup_reset').addEventListener('click', this.resetTags.bind(this, characters));
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a list of all tags that the provided characters have in common.
|
||||
*
|
||||
* @returns {Array<object>} A list of mutual tags
|
||||
*/
|
||||
getMutualTags() {
|
||||
if (this.characterIds.length == 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (this.characterIds.length === 1) {
|
||||
// Just use tags of the single character
|
||||
return getTagsList(getTagKeyForEntity(this.characterIds[0]));
|
||||
}
|
||||
|
||||
// Find mutual tags for multiple characters
|
||||
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.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 characterIds
|
||||
*/
|
||||
static resetTags(characterIds) {
|
||||
characterIds.forEach((characterId) => {
|
||||
resetTags() {
|
||||
for (const characterId of this.characterIds) {
|
||||
const key = getTagKeyForEntity(characterId);
|
||||
if (key) tag_map[key] = [];
|
||||
});
|
||||
}
|
||||
|
||||
printCharacters(true);
|
||||
$('#bulkTagList').empty();
|
||||
|
||||
printCharactersDebounced();
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the mutual tags for all given characters
|
||||
*/
|
||||
removeMutual() {
|
||||
const mutualTags = this.getMutualTags();
|
||||
|
||||
for (const characterId of this.characterIds) {
|
||||
for(const tag of mutualTags) {
|
||||
removeTagFromMap(tag.id, characterId);
|
||||
}
|
||||
}
|
||||
|
||||
$('#bulkTagList').empty();
|
||||
|
||||
printCharactersDebounced();
|
||||
}
|
||||
}
|
||||
|
||||
@ -290,6 +373,7 @@ class BulkEditOverlay {
|
||||
static selectModeClass = 'group_overlay_mode_select';
|
||||
static selectedClass = 'character_selected';
|
||||
static legacySelectedClass = 'bulk_select_checkbox';
|
||||
static bulkSelectedCountId = 'bulkSelectedCount';
|
||||
|
||||
static longPressDelay = 2500;
|
||||
|
||||
@ -297,6 +381,18 @@ class BulkEditOverlay {
|
||||
#longPress = false;
|
||||
#stateChangeCallbacks = [];
|
||||
#selectedCharacters = [];
|
||||
#bulkTagPopupHandler = new BulkTagPopupHandler();
|
||||
|
||||
/**
|
||||
* @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. <c>true</c> if it was selected, <c>false</c> 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
|
||||
@ -345,12 +441,21 @@ class BulkEditOverlay {
|
||||
|
||||
/**
|
||||
*
|
||||
* @returns {*[]}
|
||||
* @returns {number[]}
|
||||
*/
|
||||
get selectedCharacters() {
|
||||
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;
|
||||
@ -533,27 +638,110 @@ 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;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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);
|
||||
|
||||
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.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');
|
||||
|
||||
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.#selectedCharacters.push(String(characterId));
|
||||
} else {
|
||||
character.classList.remove(BulkEditOverlay.selectedClass);
|
||||
if (legacyBulkEditCheckbox) legacyBulkEditCheckbox.checked = false;
|
||||
this.#selectedCharacters = this.#selectedCharacters.filter(item => String(characterId) !== item)
|
||||
}
|
||||
|
||||
this.updateSelectedCount();
|
||||
|
||||
if (markState) {
|
||||
this.lastSelected.characterId = characterId;
|
||||
this.lastSelected.select = select;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 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`);
|
||||
};
|
||||
|
||||
/**
|
||||
* 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 - <c>true</c> if the characters in the range are to be selected, <c>false</c> if deselected
|
||||
*/
|
||||
toggleCharactersInRange = (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);
|
||||
|
||||
// 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 });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
handleContextMenuShow = (event) => {
|
||||
event.preventDefault();
|
||||
CharacterContextMenu.show(...this.#getContextMenuPosition(event));
|
||||
@ -607,6 +795,29 @@ class BulkEditOverlay {
|
||||
this.browseState();
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets the HTML as a string that is displayed inside the popup for the bulk delete
|
||||
*
|
||||
* @param {Array<number>} characterIds - The characters that are shown inside the popup
|
||||
* @returns String containing the html for the popup content
|
||||
*/
|
||||
static #getDeletePopupContentHtml = (characterIds) => {
|
||||
return `
|
||||
<h3 class="marginBot5">Delete ${characterIds.length} characters?</h3>
|
||||
<span class="bulk_delete_note">
|
||||
<i class="fa-solid fa-triangle-exclamation warning margin-r5"></i>
|
||||
<b>THIS IS PERMANENT!</b>
|
||||
</span>
|
||||
<div id="bulk_delete_avatars_block" class="avatars_inline avatars_inline_small tags tags_inline m-t-1"></div>
|
||||
<br>
|
||||
<div id="bulk_delete_options" class="m-b-1">
|
||||
<label for="del_char_checkbox" class="checkbox_label justifyCenter">
|
||||
<input type="checkbox" id="del_char_checkbox" />
|
||||
<span>Also delete the chat files</span>
|
||||
</label>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request user input before concurrently handle deletion
|
||||
* requests.
|
||||
@ -614,8 +825,9 @@ class BulkEditOverlay {
|
||||
* @returns {Promise<number>}
|
||||
*/
|
||||
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;
|
||||
|
||||
@ -623,11 +835,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;
|
||||
};
|
||||
|
||||
/**
|
||||
@ -635,14 +853,11 @@ class BulkEditOverlay {
|
||||
*/
|
||||
handleContextMenuTag = () => {
|
||||
CharacterContextMenu.tag(this.selectedCharacters);
|
||||
this.browseState();
|
||||
};
|
||||
|
||||
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.
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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) => {
|
||||
@ -28,7 +30,7 @@ const toggleBulkEditMode = (isBulkEdit) => {
|
||||
}
|
||||
};
|
||||
|
||||
(new BulkEditOverlay()).addStateChangeCallback((state) => {
|
||||
characterGroupOverlay.addStateChangeCallback((state) => {
|
||||
if (state === BulkEditOverlayState.select) enableBulkEdit();
|
||||
if (state === BulkEditOverlayState.browse) disableBulkEdit();
|
||||
});
|
||||
@ -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 && character instanceof HTMLElement) {
|
||||
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 && character instanceof HTMLElement) {
|
||||
characterGroupOverlay.toggleSingleCharacter(character);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the character with the given chid.
|
||||
*
|
||||
@ -56,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('<h3>Are you sure you want to delete these characters?</h3>You would need to delete the chat files manually.<br>', '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();
|
||||
}
|
||||
|
||||
/**
|
||||
@ -89,6 +93,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 = $('<input type=\'checkbox\' class=\'bulk_select_checkbox\'>');
|
||||
checkbox.on('change', () => {
|
||||
// Do something when the checkbox is changed
|
||||
@ -115,5 +123,6 @@ function disableBulkSelect() {
|
||||
*/
|
||||
jQuery(() => {
|
||||
$('#bulkEditButton').on('click', onEditButtonClick);
|
||||
$('#bulkSelectAllButton').on('click', onSelectAllButtonClick);
|
||||
$('#bulkDeleteButton').on('click', onDeleteButtonClick);
|
||||
});
|
||||
|
@ -2,8 +2,8 @@ import { fuzzySearchCharacters, fuzzySearchGroups, fuzzySearchPersonas, fuzzySea
|
||||
import { tag_map } from './tags.js';
|
||||
|
||||
/**
|
||||
* The filter types.
|
||||
* @type {Object.<string, string>}
|
||||
* 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,25 +16,34 @@ export const FILTER_TYPES = {
|
||||
};
|
||||
|
||||
/**
|
||||
* The filter states.
|
||||
* @type {Object.<string, 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;
|
||||
}
|
||||
@ -203,7 +212,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'));
|
||||
}
|
||||
|
@ -10,7 +10,7 @@ import {
|
||||
eventSource,
|
||||
event_types,
|
||||
getCurrentChatId,
|
||||
printCharacters,
|
||||
printCharactersDebounced,
|
||||
setCharacterId,
|
||||
setEditedMessageId,
|
||||
renderTemplate,
|
||||
@ -1295,7 +1295,7 @@ async function applyTheme(name) {
|
||||
key: 'bogus_folders',
|
||||
action: async () => {
|
||||
$('#bogus_folders').prop('checked', power_user.bogus_folders);
|
||||
await printCharacters(true);
|
||||
printCharactersDebounced();
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -3052,7 +3052,7 @@ $(document).ready(() => {
|
||||
|
||||
$('#show_card_avatar_urls').on('input', function () {
|
||||
power_user.show_card_avatar_urls = !!$(this).prop('checked');
|
||||
printCharacters();
|
||||
printCharactersDebounced();
|
||||
saveSettingsDebounced();
|
||||
});
|
||||
|
||||
@ -3075,7 +3075,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();
|
||||
});
|
||||
|
||||
@ -3372,15 +3372,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 () {
|
||||
|
@ -6,16 +6,16 @@ import {
|
||||
menu_type,
|
||||
getCharacters,
|
||||
entitiesFilter,
|
||||
printCharacters,
|
||||
printCharactersDebounced,
|
||||
buildAvatarList,
|
||||
eventSource,
|
||||
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';
|
||||
import { download, onlyUnique, parseJsonFile, uuidv4, getSortableDelay } from './utils.js';
|
||||
import { power_user } from './power-user.js';
|
||||
|
||||
export {
|
||||
@ -38,6 +38,7 @@ export {
|
||||
importTags,
|
||||
sortTags,
|
||||
compareTagsForSort,
|
||||
removeTagFromMap,
|
||||
};
|
||||
|
||||
const CHARACTER_FILTER_SELECTOR = '#rm_characters_block .rm_tag_filter';
|
||||
@ -47,29 +48,29 @@ 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,
|
||||
};
|
||||
|
||||
/**
|
||||
* @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, 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' },
|
||||
};
|
||||
|
||||
/** @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() },
|
||||
@ -79,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' },
|
||||
@ -86,8 +101,32 @@ 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 {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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* 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.<string, string[]?>}
|
||||
*/
|
||||
let tag_map = {};
|
||||
|
||||
/**
|
||||
@ -140,6 +179,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));
|
||||
|
||||
@ -164,7 +212,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;
|
||||
@ -172,6 +222,7 @@ function isBogusFolder(tag) {
|
||||
|
||||
/**
|
||||
* Indicates whether a user is currently in a bogus folder.
|
||||
*
|
||||
* @returns {boolean} If currently viewing a folder
|
||||
*/
|
||||
function isBogusFolderOpen() {
|
||||
@ -184,6 +235,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)
|
||||
@ -201,31 +253,29 @@ 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: !remove ? FILTER_STATES.SELECTED : DEFAULT_FILTER_STATE, simulateClick: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
@ -242,6 +292,7 @@ function getTagBlock(item, entities, hidden = 0) {
|
||||
*/
|
||||
function filterByFav(filterHelper) {
|
||||
const state = toggleTagThreeState($(this));
|
||||
ACTIONABLE_TAGS.FAV.filter_state = state;
|
||||
filterHelper.setFilterData(FILTER_TYPES.FAV, state);
|
||||
}
|
||||
|
||||
@ -251,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);
|
||||
}
|
||||
|
||||
@ -260,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);
|
||||
}
|
||||
|
||||
@ -281,6 +334,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] = [];
|
||||
@ -305,6 +365,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;
|
||||
@ -319,7 +382,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.
|
||||
*/
|
||||
@ -337,6 +401,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;
|
||||
@ -347,7 +417,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;
|
||||
@ -363,7 +433,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;
|
||||
@ -392,7 +462,17 @@ 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 - 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 {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} <c>false</c>, 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,19 +494,29 @@ function selectTag(event, ui, listSelector) {
|
||||
addTagToMap(tag.id);
|
||||
}
|
||||
|
||||
printCharactersDebounced();
|
||||
saveSettingsDebounced();
|
||||
|
||||
// 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()));
|
||||
// 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;
|
||||
|
||||
printTagFilters(tag_filter_types.character);
|
||||
printTagFilters(tag_filter_types.group_member);
|
||||
// add tag to the UI and internal map - we reprint so sorting and new markup is done correctly
|
||||
printTagList(listSelector, tagListOptions);
|
||||
const inlineSelector = getInlineListSelector();
|
||||
if (inlineSelector) {
|
||||
printTagList($(inlineSelector), tagListOptions);
|
||||
}
|
||||
|
||||
// need to return false to keep the input clear
|
||||
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) {
|
||||
@ -470,20 +560,28 @@ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: '',
|
||||
@ -494,7 +592,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.
|
||||
@ -503,28 +601,43 @@ function createNewTag(tagName) {
|
||||
*/
|
||||
|
||||
/**
|
||||
* Prints the list of tags.
|
||||
* @param {JQuery<HTMLElement>} element - The container element where the tags are to be printed.
|
||||
* @param {object} [options] - Optional parameters for printing the tag list.
|
||||
* @param {Array<object>} [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.
|
||||
* @typedef {object} PrintTagListOptions - Optional parameters for printing the tag list.
|
||||
* @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.
|
||||
* 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")
|
||||
* @property {TagOptions} [tagOptions={}] - Options for tag behavior. (Same object will be passed into "appendTagToList")
|
||||
*/
|
||||
function printTagList(element, { tags = undefined, forEntityOrKey = undefined, empty = true, tagActionSelector = undefined, tagOptions = {} } = {}) {
|
||||
const key = forEntityOrKey !== undefined ? getTagKeyForEntity(forEntityOrKey) : getTagKey();
|
||||
const printableTags = tags ?? getTagsList(key);
|
||||
|
||||
if (empty) {
|
||||
/**
|
||||
* Prints the list of tags
|
||||
*
|
||||
* @param {JQuery<HTMLElement>} 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, addTag = undefined, forEntityOrKey = undefined, empty = true, tagActionSelector = undefined, tagOptions = {} } = {}) {
|
||||
const key = forEntityOrKey !== undefined ? getTagKeyForEntity(forEntityOrKey) : getTagKey();
|
||||
let printableTags = tags ? (typeof tags === 'function' ? tags() : tags) : getTagsList(key);
|
||||
|
||||
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 {
|
||||
@ -537,10 +650,11 @@ function printTagList(element, { tags = undefined, forEntityOrKey = undefined, e
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends a tag to the list element.
|
||||
* @param {JQuery<HTMLElement>} 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<HTMLElement>} 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 } = {}) {
|
||||
@ -570,8 +684,9 @@ 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 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 });
|
||||
}
|
||||
|
||||
if (selectable) {
|
||||
@ -596,34 +711,37 @@ 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.filter_state = 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();
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the filter state of a given tag element
|
||||
*
|
||||
* @param {JQuery<HTMLElement>} element - The jquery element representing the tag for which the state should be toggled
|
||||
* @param {object} param1 - Optional parameters
|
||||
* @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
|
||||
*/
|
||||
function toggleTagThreeState(element, { stateOverride = undefined, simulateClick = false } = {}) {
|
||||
const states = Object.keys(FILTER_STATES);
|
||||
|
||||
const overrideKey = states.includes(stateOverride) ? stateOverride : Object.keys(FILTER_STATES).find(key => FILTER_STATES[key] === stateOverride);
|
||||
// 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 currentStateIndex = states.indexOf(element.attr('data-toggle-state')) ?? states.length - 1;
|
||||
const targetStateIndex = overrideKey !== undefined ? states.indexOf(overrideKey) : (currentStateIndex + 1) % states.length;
|
||||
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;
|
||||
|
||||
if (simulateClick) {
|
||||
// Calculate how many clicks are needed to go from the current state to the target state
|
||||
@ -662,10 +780,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);
|
||||
@ -675,18 +791,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();
|
||||
@ -729,9 +848,10 @@ function onTagRemoveClick(event) {
|
||||
|
||||
$(`${getInlineListSelector()} .tag[id="${tagId}"]`).remove();
|
||||
|
||||
printTagFilters(tag_filter_types.character);
|
||||
printTagFilters(tag_filter_types.group_member);
|
||||
printCharactersDebounced();
|
||||
saveSettingsDebounced();
|
||||
|
||||
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
@ -766,12 +886,19 @@ function applyTagsOnGroupSelect() {
|
||||
// Nothing to do here at the moment. Tags in group interface get automatically redrawn.
|
||||
}
|
||||
|
||||
export function createTagInput(inputSelector, listSelector) {
|
||||
/**
|
||||
* 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
|
||||
* @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: tagListOptions }),
|
||||
minLength: 0,
|
||||
})
|
||||
.focus(onTagInputFocus); // <== show tag list on click
|
||||
@ -853,10 +980,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
|
||||
@ -867,10 +993,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 {number} The compare result
|
||||
*/
|
||||
function compareTagsForSort(a, b) {
|
||||
if (a.sort_order !== undefined && b.sort_order !== undefined) {
|
||||
return a.sort_order - b.sort_order;
|
||||
@ -956,8 +1095,9 @@ async function onTagRestoreFileSelect(e) {
|
||||
}
|
||||
|
||||
$('#tag_view_restore_input').val('');
|
||||
printCharactersDebounced();
|
||||
saveSettingsDebounced();
|
||||
printCharacters(true);
|
||||
|
||||
onViewTagsListClick();
|
||||
}
|
||||
|
||||
@ -982,7 +1122,8 @@ function onTagsBackupClick() {
|
||||
function onTagCreateClick() {
|
||||
const tag = createNewTag('New Tag');
|
||||
appendViewTagToList($('#tag_view_list .tag_view_list_tags'), tag, []);
|
||||
printCharacters(false);
|
||||
|
||||
printCharactersDebounced();
|
||||
saveSettingsDebounced();
|
||||
}
|
||||
|
||||
@ -1051,7 +1192,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();
|
||||
|
||||
}
|
||||
@ -1080,13 +1221,14 @@ 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);
|
||||
$(`.tag[id="${id}"]`).remove();
|
||||
$(`.tag_view_item[id="${id}"]`).remove();
|
||||
printCharacters(false);
|
||||
|
||||
printCharactersDebounced();
|
||||
saveSettingsDebounced();
|
||||
}
|
||||
|
||||
@ -1164,8 +1306,8 @@ function copyTags(data) {
|
||||
}
|
||||
|
||||
export function initTags() {
|
||||
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);
|
||||
|
@ -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);
|
||||
@ -2080,6 +2081,7 @@ grammarly-extension {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: hidden;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.rm_stat_block {
|
||||
@ -2100,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;
|
||||
@ -2122,11 +2132,22 @@ 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);
|
||||
}
|
||||
|
||||
#bulk_tags_avatars_block {
|
||||
max-height: 70vh;
|
||||
}
|
||||
|
||||
#dialogue_popup_input {
|
||||
margin: 10px 0;
|
||||
width: 100%;
|
||||
@ -3123,7 +3144,7 @@ body.big-avatars .missing-avatar {
|
||||
}
|
||||
}
|
||||
|
||||
span.warning {
|
||||
.warning {
|
||||
color: var(--warning);
|
||||
font-weight: bolder;
|
||||
}
|
||||
@ -3866,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 {
|
||||
@ -3895,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 {
|
||||
|
Reference in New Issue
Block a user