Bulk edit tag improvements

- Show mutual tags on bulk edit
- Update tag list on tag added/removed in bulk edit
- Add "remove mutual" button to bulk edit tags
This commit is contained in:
Wolfsblvt 2024-03-27 03:36:09 +01:00
parent 4e7cd6d63b
commit 40daf1ca1d
3 changed files with 144 additions and 33 deletions

View File

@ -15,7 +15,7 @@ import {
import { favsToHotswap } from './RossAscends-mods.js';
import { hideLoader, showLoader } from './loader.js';
import { convertCharacterToPersona } from './personas.js';
import { createTagInput, getTagKeyForEntity, tag_map } from './tags.js';
import { createTagInput, getTagKeyForEntity, getTagsList, printTagList, tag_map, compareTagsForSort, removeTagFromMap } from './tags.js';
// Utility object for popup messages.
const popupMessage = {
@ -193,8 +193,9 @@ class BulkTagPopupHandler {
return `<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 ${characterIds.length} characters</h3>
<small class="bulk_tags_desc m-b-1">This popup allows you to modify the mutual tags of all selected characters.</small>
<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" />
@ -203,8 +204,15 @@ 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>
@ -215,13 +223,47 @@ class BulkTagPopupHandler {
/**
* Append and show the tag control
*
* @param characters - The characters assigned to this control
* @param characterIds - The characters assigned to this control
*/
static show(characters) {
document.body.insertAdjacentHTML('beforeend', this.#getHtml(characters));
createTagInput('#bulkTagInput', '#bulkTagList');
static show(characterIds) {
if (characterIds.length == 0) {
console.log('No characters selected for bulk edit tags.');
return;
}
document.body.insertAdjacentHTML('beforeend', this.#getHtml(characterIds));
this.mutualTags = this.getMutualTags(characterIds);
// Print the tag list with all mutuable tags, marking them as removable. That is the initial fill
printTagList($('#bulkTagList'), { tags: () => this.getMutualTags(characterIds), tagOptions: { removable: true } });
// Tag input with empty tags so new tag gets added and it doesn't get emptied on redraw
createTagInput('#bulkTagInput', '#bulkTagList', { tags: () => this.getMutualTags(characterIds), tagOptions: { removable: true }});
document.querySelector('#bulk_tag_popup_reset').addEventListener('click', this.resetTags.bind(this, characterIds));
document.querySelector('#bulk_tag_popup_remove_mutual').addEventListener('click', this.removeMutual.bind(this, characterIds));
document.querySelector('#bulk_tag_popup_cancel').addEventListener('click', this.hide.bind(this));
document.querySelector('#bulk_tag_popup_reset').addEventListener('click', this.resetTags.bind(this, characters));
}
static getMutualTags(characterIds) {
if (characterIds.length == 0) {
return [];
}
if (characterIds.length === 1) {
// Just use tags of the single character
return getTagsList(getTagKeyForEntity(characterIds[0]));
}
// Find mutual tags for multiple characters
const allTags = characterIds.map(c => getTagsList(getTagKeyForEntity(c)));
const mutualTags = allTags.reduce((mutual, characterTags) =>
mutual.filter(tag => characterTags.some(cTag => cTag.id === tag.id))
);
this.mutualTags = mutualTags.sort(compareTagsForSort);
return this.mutualTags;
}
/**
@ -242,10 +284,31 @@ class BulkTagPopupHandler {
* @param characterIds
*/
static resetTags(characterIds) {
characterIds.forEach((characterId) => {
for (const characterId of characterIds) {
const key = getTagKeyForEntity(characterId);
if (key) tag_map[key] = [];
});
}
$('#bulkTagList').empty();
printCharacters(true);
}
/**
* Empty the tag map for the given characters
*
* @param characterIds
*/
static removeMutual(characterIds) {
const mutualTags = this.getMutualTags(characterIds);
for (const characterId of characterIds) {
for(const tag of mutualTags) {
removeTagFromMap(tag.id, characterId);
}
}
$('#bulkTagList').empty();
printCharacters(true);
}

View File

@ -36,6 +36,7 @@ export {
importTags,
sortTags,
compareTagsForSort,
removeTagFromMap,
};
const CHARACTER_FILTER_SELECTOR = '#rm_characters_block .rm_tag_filter';
@ -57,12 +58,12 @@ export const tag_filter_types = {
};
const ACTIONABLE_TAGS = {
FAV: { id: 1, name: 'Show only favorites', color: 'rgba(255, 255, 0, 0.5)', action: filterByFav, icon: 'fa-solid fa-star', class: 'filterByFavorites' },
GROUP: { id: 0, name: 'Show only groups', color: 'rgba(100, 100, 100, 0.5)', action: filterByGroups, icon: 'fa-solid fa-users', class: 'filterByGroups' },
FOLDER: { id: 4, name: 'Always show folders', color: 'rgba(120, 120, 120, 0.5)', action: filterByFolder, icon: 'fa-solid fa-folder-plus', class: 'filterByFolder' },
VIEW: { id: 2, name: 'Manage tags', color: 'rgba(150, 100, 100, 0.5)', action: onViewTagsListClick, icon: 'fa-solid fa-gear', class: 'manageTags' },
HINT: { id: 3, name: 'Show Tag List', color: 'rgba(150, 100, 100, 0.5)', action: onTagListHintClick, icon: 'fa-solid fa-tags', class: 'showTagList' },
UNFILTER: { id: 5, name: 'Clear all filters', action: onClearAllFiltersClick, icon: 'fa-solid fa-filter-circle-xmark', class: 'clearAllFilters' },
FAV: { id: 1, sort_order: 1, name: 'Show only favorites', color: 'rgba(255, 255, 0, 0.5)', action: filterByFav, icon: 'fa-solid fa-star', class: 'filterByFavorites' },
GROUP: { id: 0, sort_order: 2, name: 'Show only groups', color: 'rgba(100, 100, 100, 0.5)', action: filterByGroups, icon: 'fa-solid fa-users', class: 'filterByGroups' },
FOLDER: { id: 4, sort_order: 3, name: 'Always show folders', color: 'rgba(120, 120, 120, 0.5)', action: filterByFolder, icon: 'fa-solid fa-folder-plus', class: 'filterByFolder' },
VIEW: { id: 2, sort_order: 4, name: 'Manage tags', color: 'rgba(150, 100, 100, 0.5)', action: onViewTagsListClick, icon: 'fa-solid fa-gear', class: 'manageTags' },
HINT: { id: 3, sort_order: 5, name: 'Show Tag List', color: 'rgba(150, 100, 100, 0.5)', action: onTagListHintClick, icon: 'fa-solid fa-tags', class: 'showTagList' },
UNFILTER: { id: 5, sort_order: 6, name: 'Clear all filters', action: onClearAllFiltersClick, icon: 'fa-solid fa-filter-circle-xmark', class: 'clearAllFilters' },
};
const InListActionable = {
@ -390,7 +391,15 @@ function findTag(request, resolve, listSelector) {
resolve(result);
}
function selectTag(event, ui, listSelector) {
/**
* Select a tag and add it to the list. This function is mostly used as an event handler for the tag selector control.
* @param {*} event -
* @param {*} ui -
* @param {*} listSelector - The selector of the list to print/add to
* @param {PrintTagListOptions} [tagListOptions] - Optional parameters for printing the tag list. Can be set to be consistent with the expected behavior of tags in the list that was defined before.
* @returns {boolean} <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,9 +423,28 @@ function selectTag(event, ui, listSelector) {
saveSettingsDebounced();
// If we have a manual list of tags to print, we should add this tag here to that manual list, otherwise it may not get printed
if (tagListOptions.tags !== undefined) {
const tagExists = (tags, tag) => tags.some(x => x.id === tag.id);
if (typeof tagListOptions.tags === 'function') {
// If 'tags' is a function, wrap it to include new tag upon invocation
const originalTagsFunction = tagListOptions.tags;
tagListOptions.tags = () => {
const currentTags = originalTagsFunction();
return tagExists(currentTags, tag) ? currentTags : [...currentTags, tag];
};
} else {
tagListOptions.tags = tagExists(tagListOptions.tags, tag) ? tags : [...tagListOptions.tags, tag];
}
}
// add tag to the UI and internal map - we reprint so sorting and new markup is done correctly
printTagList(listSelector, { tagOptions: { removable: true } });
printTagList($(getInlineListSelector()));
printTagList(listSelector, tagListOptions);
const inlineSelector = getInlineListSelector();
if (inlineSelector) {
printTagList($(inlineSelector), tagListOptions);
}
printTagFilters(tag_filter_types.character);
printTagFilters(tag_filter_types.group_member);
@ -492,7 +520,7 @@ function createNewTag(tagName) {
}
/**
* @typedef {object} TagOptions
* @typedef {object} TagOptions - Options for tag behavior. (Same object will be passed into "appendTagToList")
* @property {boolean} [removable=false] - Whether tags can be removed.
* @property {boolean} [selectable=false] - Whether tags can be selected.
* @property {function} [action=undefined] - Action to perform on tag interaction.
@ -500,20 +528,24 @@ function createNewTag(tagName) {
* @property {boolean} [skipExistsCheck=false] - If true, the tag gets added even if a tag with the same id already exists.
*/
/**
* @typedef {object} PrintTagListOptions - Optional parameters for printing the tag list.
* @property {Array<object>|function(): Array<object>} [tags=undefined] Optional override of tags that should be printed. Those will not be sorted. If no supplied, tags for the relevant character are printed. Can also be a function that returns the tags.
* @property {object|number|string} [forEntityOrKey=undefined] - Optional override for the chosen entity, otherwise the currently selected is chosen. Can be an entity with id property (character, group, tag), or directly an id or tag key.
* @property {boolean} [empty=true] - Whether the list should be initially empty.
* @property {function(object): function} [tagActionSelector=undefined] - An optional override for the action property that can be assigned to each tag via tagOptions.
* If set, the selector is executed on each tag as input argument. This allows a list of tags to be provided and each tag can have it's action based on the tag object itself.
* @property {TagOptions} [tagOptions={}] - Options for tag behavior. (Same object will be passed into "appendTagToList")
*/
/**
* Prints the list of tags.
* @param {JQuery<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.
* If set, the selector is executed on each tag as input argument. This allows a list of tags to be provided and each tag can have it's action based on the tag object itself.
* @param {TagOptions} [options.tagOptions={}] - Options for tag behavior. (Same object will be passed into "appendTagToList")
* @param {PrintTagListOptions} [options] - Optional parameters for printing the tag list.
*/
function printTagList(element, { tags = undefined, forEntityOrKey = undefined, empty = true, tagActionSelector = undefined, tagOptions = {} } = {}) {
const key = forEntityOrKey !== undefined ? getTagKeyForEntity(forEntityOrKey) : getTagKey();
const printableTags = tags ?? getTagsList(key);
const printableTags = tags !== undefined ? (typeof tags === 'function' ? tags() : tags).sort(compareTagsForSort) : getTagsList(key);
if (empty) {
$(element).empty();
@ -730,6 +762,8 @@ function onTagRemoveClick(event) {
printTagFilters(tag_filter_types.character);
printTagFilters(tag_filter_types.group_member);
saveSettingsDebounced();
}
// @ts-ignore
@ -764,12 +798,18 @@ function applyTagsOnGroupSelect() {
// Nothing to do here at the moment. Tags in group interface get automatically redrawn.
}
export function createTagInput(inputSelector, listSelector) {
/**
*
* @param {string} inputSelector - the selector for the tag input control
* @param {string} listSelector - the selector for the list of the tags modified by the input control
* @param {PrintTagListOptions} [tagListOptions] - Optional parameters for printing the tag list. Can be set to be consistent with the expected behavior of tags in the list that was defined before.
*/
export function createTagInput(inputSelector, listSelector, tagListOptions = {}) {
$(inputSelector)
// @ts-ignore
.autocomplete({
source: (i, o) => findTag(i, o, listSelector),
select: (e, u) => selectTag(e, u, listSelector),
select: (e, u) => selectTag(e, u, listSelector, tagListOptions),
minLength: 0,
})
.focus(onTagInputFocus); // <== show tag list on click
@ -1152,8 +1192,8 @@ function onClearAllFiltersClick() {
}
jQuery(() => {
createTagInput('#tagInput', '#tagList');
createTagInput('#groupTagInput', '#groupTagList');
createTagInput('#tagInput', '#tagList', { tagOptions: { removable: true } });
createTagInput('#groupTagInput', '#groupTagList', { tagOptions: { removable: true } });
$(document).on('click', '#rm_button_create', onCharacterCreateClick);
$(document).on('click', '#rm_button_group_chats', onGroupCreateClick);

View File

@ -37,6 +37,7 @@
--fullred: rgba(255, 0, 0, 1);
--crimson70a: rgba(100, 0, 0, 0.7);
--crimson-hover: rgba(150, 50, 50, 0.5);
--okGreen70a: rgba(0, 100, 0, 0.7);
--cobalt30a: rgba(100, 100, 255, 0.3);
--greyCAIbg: rgb(36, 36, 37);
@ -2113,11 +2114,18 @@ grammarly-extension {
}
#bulk_tag_popup_reset,
#bulk_tag_popup_remove_mutual,
#dialogue_popup_ok {
background-color: var(--crimson70a);
cursor: pointer;
}
#bulk_tag_popup_reset:hover,
#bulk_tag_popup_remove_mutual:hover,
#dialogue_popup_ok:hover {
background-color: var(--crimson-hover);
}
#dialogue_popup_input {
margin: 10px 0;
width: 100%;