diff --git a/public/css/tags.css b/public/css/tags.css index 6484fb8c7..b919b8300 100644 --- a/public/css/tags.css +++ b/public/css/tags.css @@ -68,6 +68,11 @@ align-items: center; } +.tag.actionable.clearAllFilters { + border: 0; + background: none; +} + .tagListHint { align-self: center; display: flex; @@ -123,7 +128,9 @@ .rm_tag_controls { display: flex; column-gap: 10px; + row-gap: 5px; flex-direction: row; + flex-wrap: wrap; align-items: flex-start; margin: 5px; } @@ -157,13 +164,13 @@ border: 1px solid red; } -.tag.excluded:after { +.tag.excluded::after { position: absolute; - top: 0; - bottom: 0; + height: calc(var(--mainFontSize)*1.5); left: 0; right: 0; content: "\d7"; + pointer-events: none; font-size: calc(var(--mainFontSize) *3); color: red; line-height: calc(var(--mainFontSize)*1.3); @@ -174,3 +181,80 @@ 1px -1px 0px black; opacity: 1; } + +.tag_as_folder { + filter: brightness(75%) saturate(0.6); +} + +.tag_as_folder:hover { + filter: brightness(150%) saturate(0.6) !important; +} + +.tag_as_folder.no_folder { + filter: brightness(25%) saturate(0.25); +} + +.tag_as_folder .tag_folder_indicator { + position: absolute; + top: calc(var(--mainFontSize) * -0.5); + right: calc(var(--mainFontSize) * -0.5); + font-size: calc(var(--mainFontSize) * 1); + line-height: calc(var(--mainFontSize) * 1.3); + text-align: center; + text-shadow: 1px 1px 0px black, + -1px -1px 0px black, + -1px 1px 0px black, + 1px -1px 0px black; + opacity: 1; +} + +.tag.indicator::after { + position: absolute; + top: calc(var(--mainFontSize) * -0.5); + right: -2px; + content: "\25CF"; + font-size: calc(var(--mainFontSize) * 1); + 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, + 1px -1px 0px black; + opacity: 1; +} + +.rm_tag_bogus_drilldown { + height: calc(var(--mainFontSize)* 2 - 2); +} + +.rm_tag_bogus_drilldown .tag:not(:first-child) { + position: relative; + margin-left: calc(var(--mainFontSize) * 2); +} + +.rm_tag_bogus_drilldown .tag:not(:first-child)::before { + position: absolute; + left: calc(var(--mainFontSize) * -2); + top: -1px; + content: "\21E8"; + font-size: calc(var(--mainFontSize) * 2); + 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, + 1px -1px 0px black; + opacity: 1; +} + +.bogus_folder_select_back .avatar { + display: none !important; +} + +.bogus_folder_select_back .bogus_folder_back_placeholder { + min-height: calc(var(--mainFontSize)*2); + width: var(--avatar-base-width); + justify-content: center; +} diff --git a/public/css/toggle-dependent.css b/public/css/toggle-dependent.css index e405b0eca..13067d50a 100644 --- a/public/css/toggle-dependent.css +++ b/public/css/toggle-dependent.css @@ -1,3 +1,9 @@ +:root { + --big-avatar-height-factor: 1.8; + --big-avatar-width-factor: 1.2; + --big-avatar-border-factor: 5; +} + body.tts .mes[is_system="true"] .mes_narrate { display: none; } @@ -18,10 +24,8 @@ body.no-modelIcons .icon-svg { } body.square-avatars .avatar, -body.square-avatars .avatar img, -body.square-avatars .hotswapAvatar, -body.square-avatars .hotswapAvatar img { - border-radius: 2px !important; +body.square-avatars .avatar img { + border-radius: var(--avatar-base-border-radius) !important; } /*char list grid mode*/ @@ -37,6 +41,7 @@ body.charListGrid #rm_print_characters_block { body.charListGrid #rm_print_characters_block .bogus_folder_select, body.charListGrid #rm_print_characters_block .character_select, +body.charListGrid #rm_print_characters_block .group_select, #user_avatar_block.gridView .avatar-container { width: 30%; align-items: flex-start; @@ -46,10 +51,20 @@ body.charListGrid #rm_print_characters_block .character_select, max-width: 100px; } +/* Save a bit of space here */ +body.charListGrid #rm_print_characters_block .character_name_block { + gap: 0; + margin-bottom: 0; +} + body.charListGrid #rm_print_characters_block .bogus_folder_select .ch_name, +body.charListGrid #rm_print_characters_block .bogus_folder_select .bogus_folder_counter, body.charListGrid #rm_print_characters_block .character_select .ch_name, body.charListGrid #rm_print_characters_block .group_select .ch_name, -#user_avatar_block.gridView .avatar-container .ch_name { +body.charListGrid #rm_print_characters_block .group_select .group_select_counter, +#user_avatar_block.gridView .avatar-container .ch_name, +#user_avatar_block.gridView .avatar-container .bogus_folder_counter, +#user_avatar_block.gridView .avatar-container .group_select_counter { width: 100%; max-width: 100px; text-align: center; @@ -58,6 +73,7 @@ body.charListGrid #rm_print_characters_block .group_select .ch_name, body.charListGrid #rm_print_characters_block .bogus_folder_select .character_name_block, body.charListGrid #rm_print_characters_block .character_select .character_name_block, +body.charListGrid #rm_print_characters_block .group_select .group_name_block, #user_avatar_block.gridView .avatar-container .character_name_block { width: 100%; flex-direction: column; @@ -70,7 +86,9 @@ body.charListGrid #rm_print_characters_block .character_select .character_name_b body.charListGrid #rm_print_characters_block .bogus_folder_select .character_select_container, body.charListGrid #rm_print_characters_block .character_select .character_select_container, -#user_avatar_block.gridView .avatar-container .character_select_container { +body.charListGrid #rm_print_characters_block .group_select .group_select_container, +#user_avatar_block.gridView .avatar-container .character_select_container, +#user_avatar_block.gridView .avatar-container .group_select_container { width: 100%; justify-content: center; max-width: 100px; @@ -89,29 +107,38 @@ body.charListGrid #rm_print_characters_block .group_select .group_name_block { width: 100%; } -body.charListGrid #rm_print_characters_block .bogus_folder_counter_block, body.charListGrid #rm_print_characters_block .ch_description, body.charListGrid #rm_print_characters_block .tags_inline, -body.charListGrid #rm_print_characters_block .character_version, +body.charListGrid #rm_print_characters_block .group_select_block_list, body.charListGrid #rm_print_characters_block .ch_avatar_url, -#user_avatar_block.gridView .avatar-container .ch_description { +body.charListGrid #rm_print_characters_block .character_version, +body.charListGrid #rm_print_characters_block .character_name_block_sub_line, +#user_avatar_block.gridView .avatar-container .ch_description, +body.charListGrid #rm_print_characters_block .bogus_folder_select_back .bogus_folder_back_placeholder { display: none; } +body.charListGrid #rm_print_characters_block .bogus_folder_select_back .avatar { + display: flex !important; +} + +/* Hack for keeping the spacing */ +body.charListGrid #rm_print_characters_block .ch_add_placeholder { + display: flex !important; + opacity: 0; +} + /*big avatars mode page-wide changes*/ body.big-avatars .character_select .avatar, +body.big-avatars .group_select .avatar, body.big-avatars .bogus_folder_select .avatar { flex: unset; } -body:not(.big-avatars) .avatar { - border-radius: 50%; -} - body.big-avatars .avatar { - width: 60px; - height: 90px; + width: calc(var(--avatar-base-width) * var(--big-avatar-width-factor)); + height: calc(var(--avatar-base-height) * var(--big-avatar-height-factor)); /* width: unset; */ border-style: none; display: flex; @@ -120,33 +147,36 @@ body.big-avatars .avatar { align-items: center; /* align-self: unset; */ overflow: visible; - border-radius: 10px; - flex: 1 + border-radius: calc(var(--avatar-base-border-radius) * var(--big-avatar-border-factor)); } body.big-avatars #user_avatar_block .avatar, body.big-avatars #user_avatar_block .avatar_upload { - height: 90px; - width: 60px; - border-radius: 10px; + width: calc(var(--avatar-base-width) * var(--big-avatar-width-factor)); + height: calc(var(--avatar-base-height) * var(--big-avatar-height-factor)); + border-radius: calc(var(--avatar-base-border-radius) * var(--big-avatar-border-factor)); } body.big-avatars #user_avatar_block .avatar img { - height: 90px; - width: 60px; + width: calc(var(--avatar-base-width) * var(--big-avatar-width-factor)); + height: calc(var(--avatar-base-height) * var(--big-avatar-height-factor)); } body.big-avatars .avatar img { - width: 60px; - height: 90px; + width: calc(var(--avatar-base-width) * var(--big-avatar-width-factor)); + height: calc(var(--avatar-base-height) * var(--big-avatar-height-factor)); object-fit: cover; object-position: center; border: 1px solid var(--SmartThemeBorderColor); - border-radius: 10px; + border-radius: calc(var(--avatar-base-border-radius) * var(--big-avatar-border-factor)); +} + +body.big-avatars .bogus_folder_select_back .bogus_folder_back_placeholder { + width: calc(var(--avatar-base-width) * var(--big-avatar-width-factor)); } body:not(.big-avatars) .avatar_collage { - min-width: 50px; + min-width: var(--avatar-base-width); aspect-ratio: 1 / 1; } @@ -155,8 +185,8 @@ body:not(.big-avatars) .avatar_collage img { } body.big-avatars .avatar_collage { - min-width: 60px; - max-width: 60px; + min-width: calc(var(--avatar-base-width) * var(--big-avatar-width-factor)); + max-width: calc(var(--avatar-base-width) * var(--big-avatar-width-factor)); aspect-ratio: 2 / 3; } @@ -169,42 +199,65 @@ body.big-avatars .avatar-container .ch_description { text-overflow: unset; } +body.big-avatars .avatars_inline_small .avatar, +body.big-avatars .avatars_inline_small .avatar img { + width: calc(var(--avatar-base-width) * var(--big-avatar-width-factor) * var(--inline-avatar-small-factor)); + height: calc(var(--avatar-base-height) * var(--big-avatar-height-factor) * var(--inline-avatar-small-factor)); +} + +body.big-avatars .avatars_inline { + max-height: calc(var(--avatar-base-height) * var(--big-avatar-height-factor) + 2 * var(--avatar-base-border-radius)); +} + +body.big-avatars .avatars_inline.avatars_inline_small { + height: calc(var(--avatar-base-height) * var(--big-avatar-height-factor) * var(--inline-avatar-small-factor) + 2 * var(--avatar-base-border-radius)); +} + +body:not(.big-avatars) .avatars_inline_small .avatar_collage { + min-width: calc(var(--avatar-base-width) * var(--inline-avatar-small-factor)); +} + +body.big-avatars .avatars_inline_small .avatar_collage { + min-width: calc(var(--avatar-base-width) * var(--big-avatar-width-factor) * var(--inline-avatar-small-factor)); + max-width: calc(var(--avatar-base-width) * var(--big-avatar-width-factor) * var(--inline-avatar-small-factor)); +} + /* border radius for big avatars collages */ body.big-avatars .collage_2 .img_1 { - border-radius: 10px 0 0 10px !important; + border-radius: calc(var(--avatar-base-border-radius) * var(--big-avatar-border-factor)) 0 0 calc(var(--avatar-base-border-radius) * var(--big-avatar-border-factor)) !important; } body.big-avatars .collage_2 .img_2 { - border-radius: 0 10px 10px 0 !important; + border-radius: 0 calc(var(--avatar-base-border-radius) * var(--big-avatar-border-factor)) calc(var(--avatar-base-border-radius) * var(--big-avatar-border-factor)) 0 !important; } body.big-avatars .collage_3 .img_1 { - border-radius: 10px 0 0 0 !important; + border-radius: calc(var(--avatar-base-border-radius) * var(--big-avatar-border-factor)) 0 0 0 !important; } body.big-avatars .collage_3 .img_2 { - border-radius: 0 10px 0 0 !important; + border-radius: 0 calc(var(--avatar-base-border-radius) * var(--big-avatar-border-factor)) 0 0 !important; } body.big-avatars .collage_3 .img_3 { - border-radius: 0 0 10px 10px !important; + border-radius: 0 0 calc(var(--avatar-base-border-radius) * var(--big-avatar-border-factor)) calc(var(--avatar-base-border-radius) * var(--big-avatar-border-factor)) !important; } body.big-avatars .collage_4 .img_1 { - border-radius: 10px 0 0 0 !important; + border-radius: calc(var(--avatar-base-border-radius) * var(--big-avatar-border-factor)) 0 0 0 !important; } body.big-avatars .collage_4 .img_2 { - border-radius: 0 10px 0 0 !important; + border-radius: 0 calc(var(--avatar-base-border-radius) * var(--big-avatar-border-factor)) 0 0 !important; } body.big-avatars .collage_4 .img_3 { - border-radius: 0 0 0 10px !important; + border-radius: 0 0 0 calc(var(--avatar-base-border-radius) * var(--big-avatar-border-factor)) !important; } body.big-avatars .collage_4 .img_4 { - border-radius: 0 0 10px 0 !important; + border-radius: 0 0 calc(var(--avatar-base-border-radius) * var(--big-avatar-border-factor)) 0 !important; } diff --git a/public/index.html b/public/index.html index de9d7e0f3..970251a55 100644 --- a/public/index.html +++ b/public/index.html @@ -3939,7 +3939,7 @@
-
+

@@ -4249,6 +4249,7 @@
+

@@ -4581,6 +4582,10 @@
+
+
+ +
@@ -4794,8 +4799,9 @@
- - + +++ + +
@@ -5042,10 +5048,15 @@
-
-
+
+
+
+ +
+ in this group +
@@ -5053,24 +5064,26 @@
- +
+
-
- - character card(s) -
+ +
-
+
+
+ +
Go back @@ -5078,9 +5091,9 @@
-
-
- +
+
+
diff --git a/public/script.js b/public/script.js index ec8d6c6a4..3f50b0d95 100644 --- a/public/script.js +++ b/public/script.js @@ -156,14 +156,20 @@ import { COMMENT_NAME_DEFAULT, executeSlashCommands, getSlashCommandsHelp, proce import { tag_map, tags, + filterByTagState, + isBogusFolder, + isBogusFolderOpen, + chooseBogusFolder, + getTagBlock, loadTagsSettings, printTagFilters, - getTagsList, - appendTagToList, + getTagKeyForEntity, + printTagList, createTagMapFromList, renameTagKey, importTags, tag_filter_types, + compareTagsForSort, } from './scripts/tags.js'; import { SECRET_KEYS, @@ -243,6 +249,7 @@ export { scrollChatToBottom, isStreamingEnabled, getThumbnailUrl, + buildAvatarList, getStoppingStrings, reloadMarkdownProcessor, getCurrentChatId, @@ -802,8 +809,11 @@ let token; var PromptArrayItemForRawPromptDisplay; +/** The tag of the active character. (NOT the id) */ 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 personasFilter = new FilterHelper(debounce(getUserAvatars, 100)); @@ -876,12 +886,12 @@ export function setAnimationDuration(ms = null) { animation_duration = ms ?? ANIMATION_DURATION_DEFAULT; } -export function setActiveCharacter(character) { - active_character = character; +export function setActiveCharacter(entityOrKey) { + active_character = getTagKeyForEntity(entityOrKey); } -export function setActiveGroup(group) { - active_group = group; +export function setActiveGroup(entityOrKey) { + active_group = getTagKeyForEntity(entityOrKey); } /** @@ -1167,23 +1177,6 @@ export async function selectCharacterById(id) { } } -function getTagBlock(item, entities) { - let count = 0; - - for (const entity of entities) { - if (entitiesFilter.isElementTagged(entity, item.id)) { - count++; - } - } - - const template = $('#bogus_folder_template .bogus_folder_select').clone(); - template.attr({ 'tagid': item.id, 'id': `BogusFolder${item.id}` }); - template.find('.avatar').css({ 'background-color': item.color, 'color': item.color2 }); - template.find('.ch_name').text(item.name); - template.find('.bogus_folder_counter').text(count); - return template; -} - function getBackBlock() { const template = $('#bogus_folder_back_template .bogus_folder_select').clone(); return template; @@ -1194,7 +1187,7 @@ function getEmptyBlock() { const texts = ['Here be dragons', 'Otterly empty', 'Kiwibunga', 'Pump-a-Rum', 'Croak it']; const roll = new Date().getMinutes() % icons.length; const emptyBlock = ` -
+

${texts[roll]}

There are no items to display.

@@ -1202,6 +1195,20 @@ function getEmptyBlock() { return $(emptyBlock); } +/** + * @param {number} hidden Number of hidden characters + */ +function getHiddenBlock(hidden) { + const hiddenBlock = ` +
+ +

${hidden} ${hidden > 1 ? 'characters' : 'character'} hidden.

+
+
+
`; + return $(hiddenBlock); +} + function getCharacterBlock(item, id) { let this_avatar = default_avatar; if (item.avatar != 'none') { @@ -1210,9 +1217,9 @@ function getCharacterBlock(item, id) { // Populate the template const template = $('#character_template .character_select').clone(); template.attr({ 'chid': id, 'id': `CharID${id}` }); - template.find('img').attr('src', this_avatar); - template.find('.avatar').attr('title', item.avatar); - template.find('.ch_name').text(item.name); + template.find('img').attr('src', this_avatar).attr('alt', item.name); + template.find('.avatar').attr('title', `[Character] ${item.name}`); + template.find('.ch_name').text(item.name).attr('title', `[Character] ${item.name}`); if (power_user.show_card_avatar_urls) { template.find('.ch_avatar_url').text(item.avatar); } @@ -1238,9 +1245,8 @@ function getCharacterBlock(item, id) { } // Display inline tags - const tags = getTagsList(item.avatar); const tagsElement = template.find('.tags'); - tags.forEach(tag => appendTagToList(tagsElement, tag, {})); + printTagList(tagsElement, { forEntityOrKey: id }); // Add to the list return template; @@ -1252,11 +1258,6 @@ async function printCharacters(fullRefresh = false) { printTagFilters(tag_filter_types.character); printTagFilters(tag_filter_types.group_member); - // Return to main list - if (isBogusFolderOpen()) { - entitiesFilter.setFilterData(FILTER_TYPES.TAG, { excluded: [], selected: [] }); - } - await delay(1); } @@ -1285,19 +1286,28 @@ async function printCharacters(fullRefresh = false) { if (!data.length) { $(listId).append(getEmptyBlock()); } + let displayCount = 0; for (const i of data) { switch (i.type) { case 'character': $(listId).append(getCharacterBlock(i.item, i.id)); + displayCount++; break; case 'group': $(listId).append(getGroupBlock(i.item)); + displayCount++; break; case 'tag': - $(listId).append(getTagBlock(i.item, entities)); + $(listId).append(getTagBlock(i.item, i.entities, i.hidden)); break; } } + + const hidden = (characters.length + groups.length) - displayCount; + if (hidden > 0) { + $(listId).append(getHiddenBlock(hidden)); + } + eventSource.emit(event_types.CHARACTER_PAGE_LOADED); }, afterSizeSelectorChange: function (e) { @@ -1314,15 +1324,7 @@ async function printCharacters(fullRefresh = false) { favsToHotswap(); } -/** - * Indicates whether a user is currently in a bogus folder. - * @returns {boolean} If currently viewing a folder - */ -function isBogusFolderOpen() { - return !!entitiesFilter.getFilterData(FILTER_TYPES.TAG)?.bogus; -} - -export function getEntitiesList({ doFilter } = {}) { +export function getEntitiesList({ doFilter = false, doSort = true } = {}) { function characterToEntity(character, id) { return { item: character, id, type: 'character' }; } @@ -1332,36 +1334,53 @@ export function getEntitiesList({ doFilter } = {}) { } function tagToEntity(tag) { - return { item: structuredClone(tag), id: tag.id, type: '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)), - ...(power_user.bogus_folders ? tags.map(item => tagToEntity(item)) : []), + ...(power_user.bogus_folders ? tags.filter(isBogusFolder).sort(compareTagsForSort).map(item => tagToEntity(item)) : []), ]; + // We need to do multiple filter runs in a specific order, otherwise different settings might override each other + // and screw up tags and search filter, sub lists or similar. + // The specific filters are written inside the "filterByTagState" method and its different parameters. + // Generally what we do is the following: + // 1. First swipe over the list to remove the most obvious things + // 2. Build sub entity lists for all folders, filtering them similarly to the second swipe + // 3. We do the last run, where global filters are applied, and the search filters last + + // First run filters, that will hide what should never be displayed if (doFilter) { + entities = filterByTagState(entities); + } + + // Run over all entities between first and second filter to save some states + for (const entity of entities) { + // For folders, we remember the sub entities so they can be displayed later, even if they might be filtered + // Those sub entities should be filtered and have the search filters applied too + if (entity.type === 'tag') { + let subEntities = filterByTagState(entities, { subForEntity: entity, filterHidden: false }); + const subCount = subEntities.length; + subEntities = filterByTagState(entities, { subForEntity: entity }); + if (doFilter) { + subEntities = entitiesFilter.applyFilters(subEntities); + } + entity.entities = subEntities; + entity.hidden = subCount - subEntities.length; + } + } + + // Second run filters, hiding whatever should be filtered later + if (doFilter) { + entities = filterByTagState(entities, { globalDisplayFilters: true }); entities = entitiesFilter.applyFilters(entities); } - if (isBogusFolderOpen()) { - // Get tags of entities within the bogus folder - const filterData = structuredClone(entitiesFilter.getFilterData(FILTER_TYPES.TAG)); - entities = entities.filter(x => x.type !== 'tag'); - const otherTags = tags.filter(x => !filterData.selected.includes(x.id)); - const bogusTags = []; - for (const entity of entities) { - for (const tag of otherTags) { - if (!bogusTags.includes(tag) && entitiesFilter.isElementTagged(entity, tag.id)) { - bogusTags.push(tag); - } - } - } - entities.push(...bogusTags.map(item => tagToEntity(item))); + if (doSort) { + sortEntitiesList(entities); } - - sortEntitiesList(entities); return entities; } @@ -5261,6 +5280,51 @@ function getThumbnailUrl(type, file) { return `/thumbnail?type=${type}&file=${encodeURIComponent(file)}`; } +function buildAvatarList(block, entities, { templateId = 'inline_avatar_template', empty = true, selectable = false, highlightFavs = true } = {}) { + if (empty) { + block.empty(); + } + + for (const entity of entities) { + const id = entity.id; + + // Populate the template + const avatarTemplate = $(`#${templateId} .avatar`).clone(); + + let this_avatar = default_avatar; + if (entity.item.avatar !== undefined && entity.item.avatar != 'none') { + this_avatar = getThumbnailUrl('avatar', entity.item.avatar); + } + + 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}`); + if (highlightFavs) { + avatarTemplate.toggleClass('is_fav', entity.item.fav || entity.item.fav == 'true'); + avatarTemplate.find('.ch_fav').val(entity.item.fav); + } + + // If this is a group, we need to hack slightly. We still want to keep most of the css classes and layout, but use a group avatar instead. + if (entity.type === 'group') { + const grpTemplate = getGroupAvatar(entity.item); + + avatarTemplate.addClass(grpTemplate.attr('class')); + avatarTemplate.empty(); + avatarTemplate.append(grpTemplate.children()); + avatarTemplate.attr('title', `[Group] ${entity.item.name}`); + } + + if (selectable) { + avatarTemplate.addClass('selectable'); + avatarTemplate.toggleClass('character_select', entity.type === 'character'); + avatarTemplate.toggleClass('group_select', entity.type === 'group'); + } + + block.append(avatarTemplate); + } +} + async function getChat() { //console.log('/api/chats/get -- entered for -- ' + characters[this_chid].name); try { @@ -5287,9 +5351,12 @@ async function getChat() { await getChatResult(); eventSource.emit('chatLoaded', { detail: { id: this_chid, character: characters[this_chid] } }); + // Focus on the textarea if not already focused on a visible text input setTimeout(function () { - $('#send_textarea').click(); - $('#send_textarea').focus(); + if ($(document.activeElement).is('input:visible, textarea:visible')) { + return; + } + $('#send_textarea').trigger('click').trigger('focus'); }, 200); } catch (error) { await getChatResult(); @@ -8271,25 +8338,8 @@ jQuery(async function () { $(document).on('click', '.bogus_folder_select', function () { const tagId = $(this).attr('tagid'); - console.log('Bogus folder clicked', tagId); - - const filterData = structuredClone(entitiesFilter.getFilterData(FILTER_TYPES.TAG)); - - if (!Array.isArray(filterData.selected)) { - filterData.selected = []; - filterData.excluded = []; - filterData.bogus = false; - } - - if (tagId === 'back') { - filterData.selected.pop(); - filterData.bogus = filterData.selected.length > 0; - } else { - filterData.selected.push(tagId); - filterData.bogus = true; - } - - entitiesFilter.setFilterData(FILTER_TYPES.TAG, filterData); + console.debug('Bogus folder clicked', tagId); + chooseBogusFolder($(this), tagId); }); $(document).on('input', '.edit_textarea', function () { diff --git a/public/scripts/BulkEditOverlay.js b/public/scripts/BulkEditOverlay.js index d12e1599e..e1db7f77b 100644 --- a/public/scripts/BulkEditOverlay.js +++ b/public/scripts/BulkEditOverlay.js @@ -15,7 +15,7 @@ import { import { favsToHotswap } from './RossAscends-mods.js'; import { hideLoader, showLoader } from './loader.js'; import { convertCharacterToPersona } from './personas.js'; -import { createTagInput, getTagKeyForCharacter, tag_map } from './tags.js'; +import { createTagInput, getTagKeyForEntity, tag_map } from './tags.js'; // Utility object for popup messages. const popupMessage = { @@ -243,7 +243,7 @@ class BulkTagPopupHandler { */ static resetTags(characterIds) { characterIds.forEach((characterId) => { - const key = getTagKeyForCharacter(characterId); + const key = getTagKeyForEntity(characterId); if (key) tag_map[key] = []; }); diff --git a/public/scripts/RossAscends-mods.js b/public/scripts/RossAscends-mods.js index 077de3050..8589c8ae2 100644 --- a/public/scripts/RossAscends-mods.js +++ b/public/scripts/RossAscends-mods.js @@ -11,7 +11,7 @@ import { setActiveGroup, setActiveCharacter, getEntitiesList, - getThumbnailUrl, + buildAvatarList, selectCharacterById, eventSource, menu_type, @@ -26,7 +26,8 @@ import { } from './power-user.js'; import { LoadLocal, SaveLocal, LoadLocalBool } from './f-localStorage.js'; -import { selected_group, is_group_generating, getGroupAvatar, groups, openGroupById } from './group-chats.js'; +import { selected_group, is_group_generating, openGroupById } from './group-chats.js'; +import { getTagKeyForEntity } from './tags.js'; import { SECRET_KEYS, secret_state, @@ -247,13 +248,14 @@ export function RA_CountCharTokens() { async function RA_autoloadchat() { if (document.querySelector('#rm_print_characters_block .character_select') !== null) { // active character is the name, we should look it up in the character list and get the id - let active_character_id = Object.keys(characters).find(key => characters[key].avatar === active_character); - - if (active_character_id !== null) { - await selectCharacterById(String(active_character_id)); + if (active_character !== null && active_character !== undefined) { + const active_character_id = characters.findIndex(x => getTagKeyForEntity(x) === active_character); + if (active_character_id !== null) { + await selectCharacterById(String(active_character_id)); + } } - if (active_group != null) { + if (active_group !== null && active_group !== undefined) { await openGroupById(String(active_group)); } @@ -264,84 +266,16 @@ async function RA_autoloadchat() { export async function favsToHotswap() { const entities = getEntitiesList({ doFilter: false }); const container = $('#right-nav-panel .hotswap'); - const template = $('#hotswap_template .hotswapAvatar'); - const DEFAULT_COUNT = 6; - const WIDTH_PER_ITEM = 60; // 50px + 5px gap + 5px padding - const containerWidth = container.outerWidth(); - const maxCount = containerWidth > 0 ? Math.floor(containerWidth / WIDTH_PER_ITEM) : DEFAULT_COUNT; - let count = 0; - const promises = []; - const newContainer = container.clone(); - newContainer.empty(); + const favs = entities.filter(x => x.item.fav || x.item.fav == 'true'); - for (const entity of entities) { - if (count >= maxCount) { - break; - } - - const isFavorite = entity.item.fav || entity.item.fav == 'true'; - - if (!isFavorite) { - continue; - } - - const isCharacter = entity.type === 'character'; - const isGroup = entity.type === 'group'; - - const grid = isGroup ? entity.id : ''; - const chid = isCharacter ? entity.id : ''; - - let slot = template.clone(); - slot.toggleClass('character_select', isCharacter); - slot.toggleClass('group_select', isGroup); - slot.attr('grid', isGroup ? grid : ''); - slot.attr('chid', isCharacter ? chid : ''); - slot.data('id', isGroup ? grid : chid); - - if (isGroup) { - const group = groups.find(x => x.id === grid); - const avatar = getGroupAvatar(group); - $(slot).find('img').replaceWith(avatar); - $(slot).attr('title', group.name); - } - - if (isCharacter) { - const imgLoadPromise = new Promise((resolve) => { - const avatarUrl = getThumbnailUrl('avatar', entity.item.avatar); - $(slot).find('img').attr('src', avatarUrl).on('load', resolve); - $(slot).attr('title', entity.item.avatar); - }); - - // if the image doesn't load in 500ms, resolve the promise anyway - promises.push(Promise.race([imgLoadPromise, delay(500)])); - } - - $(slot).css('cursor', 'pointer'); - newContainer.append(slot); - count++; - } - - // don't fill leftover spaces with avatar placeholders - // just evenly space the selected avatars instead - /* - if (count < maxCount) { //if any space is left over - let leftOverSlots = maxCount - count; - for (let i = 1; i <= leftOverSlots; i++) { - newContainer.append(template.clone()); - } - } - */ - - await Promise.allSettled(promises); //helpful instruction message if no characters are favorited - if (count === 0) { - container.html(' Favorite characters to add them to HotSwaps'); - } - //otherwise replace with fav'd characters - if (count > 0) { - container.replaceWith(newContainer); + if (favs.length == 0) { + container.html(' Favorite characters to add them to HotSwaps'); + return; } + + buildAvatarList(container, favs, { selectable: true, highlightFavs: false }); } //changes input bar and send button display depending on connection status @@ -873,14 +807,14 @@ export function initRossMods() { // when a char is selected from the list, save their name as the auto-load character for next page load $(document).on('click', '.character_select', function () { - const characterId = $(this).find('.avatar').attr('title') || $(this).attr('title'); + const characterId = $(this).attr('chid') || $(this).data('id'); setActiveCharacter(characterId); setActiveGroup(null); saveSettingsDebounced(); }); $(document).on('click', '.group_select', function () { - const groupId = $(this).data('id') || $(this).attr('grid'); + const groupId = $(this).attr('chid') || $(this).attr('grid') || $(this).data('id'); setActiveCharacter(null); setActiveGroup(groupId); saveSettingsDebounced(); diff --git a/public/scripts/filters.js b/public/scripts/filters.js index 4a0efc3fc..d880cbf37 100644 --- a/public/scripts/filters.js +++ b/public/scripts/filters.js @@ -8,12 +8,37 @@ import { tag_map } from './tags.js'; export const FILTER_TYPES = { SEARCH: 'search', TAG: 'tag', + FOLDER: 'folder', FAV: 'fav', GROUP: 'group', WORLD_INFO_SEARCH: 'world_info_search', PERSONA_SEARCH: 'persona_search', }; +/** + * The filter states. + * @type {Object.} + */ +export const FILTER_STATES = { + SELECTED: { key: 'SELECTED', class: 'selected' }, + EXCLUDED: { key: 'EXCLUDED', class: 'excluded' }, + UNDEFINED: { key: 'UNDEFINED', class: 'undefined' }, +}; + +/** + * Robust check if one state equals the other. It does not care whether it's the state key or the state value object. + * @param {Object} a First state + * @param {Object} b Second state + */ +export function isFilterState(a, b) { + const states = Object.keys(FILTER_STATES); + + const aKey = states.includes(a) ? a : states.find(key => FILTER_STATES[key] === a); + const bKey = states.includes(b) ? b : states.find(key => FILTER_STATES[key] === b); + + return aKey === bKey; +} + /** * Helper class for filtering data. * @example @@ -36,8 +61,9 @@ export class FilterHelper { */ filterFunctions = { [FILTER_TYPES.SEARCH]: this.searchFilter.bind(this), - [FILTER_TYPES.GROUP]: this.groupFilter.bind(this), [FILTER_TYPES.FAV]: this.favFilter.bind(this), + [FILTER_TYPES.GROUP]: this.groupFilter.bind(this), + [FILTER_TYPES.FOLDER]: this.folderFilter.bind(this), [FILTER_TYPES.TAG]: this.tagFilter.bind(this), [FILTER_TYPES.WORLD_INFO_SEARCH]: this.wiSearchFilter.bind(this), [FILTER_TYPES.PERSONA_SEARCH]: this.personaSearchFilter.bind(this), @@ -49,8 +75,9 @@ export class FilterHelper { */ filterData = { [FILTER_TYPES.SEARCH]: '', - [FILTER_TYPES.GROUP]: false, [FILTER_TYPES.FAV]: false, + [FILTER_TYPES.GROUP]: false, + [FILTER_TYPES.FOLDER]: false, [FILTER_TYPES.TAG]: { excluded: [], selected: [] }, [FILTER_TYPES.WORLD_INFO_SEARCH]: '', [FILTER_TYPES.PERSONA_SEARCH]: '', @@ -116,6 +143,7 @@ export class FilterHelper { } const getIsTagged = (entity) => { + const isTag = entity.type === 'tag'; const tagFlags = selected.map(tagId => this.isElementTagged(entity, tagId)); const trueFlags = tagFlags.filter(x => x); const isTagged = TAG_LOGIC_AND ? tagFlags.length === trueFlags.length : trueFlags.length > 0; @@ -123,7 +151,9 @@ export class FilterHelper { const excludedTagFlags = excluded.map(tagId => this.isElementTagged(entity, tagId)); const isExcluded = excludedTagFlags.includes(true); - if (isExcluded) { + if (isTag) { + return true; + } else if (isExcluded) { return false; } else if (selected.length > 0 && !isTagged) { return false; @@ -141,11 +171,10 @@ export class FilterHelper { * @returns {any[]} The filtered data. */ favFilter(data) { - if (!this.filterData[FILTER_TYPES.FAV]) { - return data; - } + const state = this.filterData[FILTER_TYPES.FAV]; + const isFav = entity => entity.item.fav || entity.item.fav == 'true'; - return data.filter(entity => entity.item.fav || entity.item.fav == 'true'); + return this.filterDataByState(data, state, isFav, { includeFolders: true }); } /** @@ -154,11 +183,35 @@ export class FilterHelper { * @returns {any[]} The filtered data. */ groupFilter(data) { - if (!this.filterData[FILTER_TYPES.GROUP]) { - return data; + const state = this.filterData[FILTER_TYPES.GROUP]; + const isGroup = entity => entity.type === 'group'; + + return this.filterDataByState(data, state, isGroup, { includeFolders: true }); + } + + /** + * Applies a "folder" filter to the data. + * @param {any[]} data The data to filter. + * @returns {any[]} The filtered data. + */ + folderFilter(data) { + const state = this.filterData[FILTER_TYPES.FOLDER]; + // Slightly different than the other filters, as a positive folder filter means it doesn't filter anything (folders get "not hidden" at another place), + // while a negative state should then filter out all folders. + const isFolder = entity => isFilterState(state, FILTER_STATES.SELECTED) ? true : entity.type === 'tag'; + + return this.filterDataByState(data, state, isFolder); + } + + filterDataByState(data, state, filterFunc, { includeFolders } = {}) { + if (isFilterState(state, FILTER_STATES.SELECTED)) { + return data.filter(entity => filterFunc(entity) || (includeFolders && entity.type == 'tag')); + } + if (isFilterState(state, FILTER_STATES.EXCLUDED)) { + return data.filter(entity => !filterFunc(entity) || (includeFolders && entity.type == 'tag')); } - return data.filter(entity => entity.type === 'group'); + return data; } /** diff --git a/public/scripts/group-chats.js b/public/scripts/group-chats.js index 52ec5078c..57da890ee 100644 --- a/public/scripts/group-chats.js +++ b/public/scripts/group-chats.js @@ -69,7 +69,7 @@ import { loadItemizedPrompts, animation_duration, } from '../script.js'; -import { appendTagToList, createTagMapFromList, getTagsList, applyTagsOnCharacterSelect, tag_map } from './tags.js'; +import { printTagList, createTagMapFromList, applyTagsOnCharacterSelect, tag_map } from './tags.js'; import { FILTER_TYPES, FilterHelper } from './filters.js'; export { @@ -530,18 +530,33 @@ async function getGroups() { } export function getGroupBlock(group) { + let count = 0; + let namesList = []; + + // Build inline name list + if (Array.isArray(group.members) && group.members.length) { + for (const member of group.members) { + const character = characters.find(x => x.avatar === member || x.name === member); + if (character) { + namesList.push(character.name); + count++; + } + } + } + const template = $('#group_list_template .group_select').clone(); template.data('id', group.id); template.attr('grid', group.id); - template.find('.ch_name').text(group.name); + template.find('.ch_name').text(group.name).attr('title', `[Group] ${group.name}`); template.find('.group_fav_icon').css('display', 'none'); template.addClass(group.fav ? 'is_fav' : ''); template.find('.ch_fav').val(group.fav); + template.find('.group_select_counter').text(`${count} ${count != 1 ? 'characters' : 'character'}`); + template.find('.group_select_block_list').text(namesList.join(', ')); // Display inline tags - const tags = getTagsList(group.id); const tagsElement = template.find('.tags'); - tags.forEach(tag => appendTagToList(tagsElement, tag, {})); + printTagList(tagsElement, { forEntityOrKey: group.id }); const avatar = getGroupAvatar(group); if (avatar) { @@ -559,6 +574,8 @@ function updateGroupAvatar(group) { $(this).find('.avatar').replaceWith(getGroupAvatar(group)); } }); + + favsToHotswap(); } // check if isDataURLor if it's a valid local file url @@ -576,7 +593,7 @@ function getGroupAvatar(group) { } // if isDataURL or if it's a valid local file url if (isValidImageUrl(group.avatar_url)) { - return $(`
`); + return $(`
`); } const memberAvatars = []; @@ -602,6 +619,7 @@ function getGroupAvatar(group) { groupAvatar.find(`.img_${i + 1}`).attr('src', memberAvatars[i]); } + groupAvatar.attr('title', `[Group] ${group.name}`); return groupAvatar; } @@ -613,6 +631,7 @@ function getGroupAvatar(group) { // default avatar const groupAvatar = $('#group_avatars_template .collage_1').clone(); groupAvatar.find('.img_1').attr('src', group.avatar_url || system_avatar); + groupAvatar.attr('title', `[Group] ${group.name}`); return groupAvatar; } @@ -1176,9 +1195,8 @@ function getGroupCharacterBlock(character) { template.toggleClass('disabled', isGroupMemberDisabled(character.avatar)); // Display inline tags - const tags = getTagsList(character.avatar); const tagsElement = template.find('.tags'); - tags.forEach(tag => appendTagToList(tagsElement, tag, {})); + printTagList(tagsElement, { forEntityOrKey: characters.indexOf(character) }); if (!openGroupId) { template.find('[data-action="speak"]').hide(); @@ -1254,6 +1272,9 @@ function select_group_chats(groupId, skipAnimation) { selectRightMenuWithAnimation('rm_group_chats_block'); } + // render tags + printTagList($('#groupTagList'), { forEntityOrKey: groupId, tagOptions: { removable: true } }); + // render characters list printGroupCandidates(); printGroupMembers(); @@ -1751,7 +1772,7 @@ function doCurMemberListPopout() { jQuery(() => { $(document).on('click', '.group_select', function () { - const groupId = $(this).data('id'); + const groupId = $(this).attr('chid') || $(this).attr('grid') || $(this).data('id'); openGroupById(groupId); }); $('#rm_group_filter').on('input', filterGroupMembers); diff --git a/public/scripts/power-user.js b/public/scripts/power-user.js index 4762443f7..a9d64c1a8 100644 --- a/public/scripts/power-user.js +++ b/public/scripts/power-user.js @@ -235,6 +235,7 @@ let power_user = { encode_tags: false, servers: [], bogus_folders: false, + show_tag_filters: false, aux_field: 'character_version', restore_user_input: true, reduced_motion: false, diff --git a/public/scripts/tags.js b/public/scripts/tags.js index f6a756278..2615d87b3 100644 --- a/public/scripts/tags.js +++ b/public/scripts/tags.js @@ -7,23 +7,35 @@ import { getCharacters, entitiesFilter, printCharacters, + buildAvatarList, } from '../script.js'; // eslint-disable-next-line no-unused-vars -import { FILTER_TYPES, FilterHelper } from './filters.js'; +import { FILTER_TYPES, FILTER_STATES, isFilterState, FilterHelper } from './filters.js'; import { groupCandidatesFilter, groups, selected_group } from './group-chats.js'; -import { download, onlyUnique, parseJsonFile, uuidv4 } from './utils.js'; +import { download, onlyUnique, parseJsonFile, uuidv4, getSortableDelay, debounce } from './utils.js'; +import { power_user } from './power-user.js'; export { + TAG_FOLDER_TYPES, + TAG_FOLDER_DEFAULT_TYPE, tags, tag_map, + filterByTagState, + isBogusFolder, + isBogusFolderOpen, + chooseBogusFolder, + getTagBlock, loadTagsSettings, printTagFilters, getTagsList, + printTagList, appendTagToList, createTagMapFromList, renameTagKey, importTags, + sortTags, + compareTagsForSort, }; const CHARACTER_FILTER_SELECTOR = '#rm_characters_block .rm_tag_filter'; @@ -33,16 +45,24 @@ 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, }; const ACTIONABLE_TAGS = { - FAV: { id: 1, name: 'Show only favorites', color: 'rgba(255, 255, 0, 0.5)', action: applyFavFilter, icon: 'fa-solid fa-star', class: 'filterByFavorites' }, + FAV: { id: 1, name: 'Show only favorites', color: 'rgba(255, 255, 0, 0.5)', action: filterByFav, icon: 'fa-solid fa-star', class: 'filterByFavorites' }, GROUP: { id: 0, name: 'Show only groups', color: 'rgba(100, 100, 100, 0.5)', action: filterByGroups, icon: 'fa-solid fa-users', class: 'filterByGroups' }, + FOLDER: { id: 4, name: 'Always show folders', color: 'rgba(120, 120, 120, 0.5)', action: filterByFolder, icon: 'fa-solid fa-folder-plus', class: 'filterByFolder' }, VIEW: { id: 2, name: 'Manage tags', color: 'rgba(150, 100, 100, 0.5)', action: onViewTagsListClick, icon: 'fa-solid fa-gear', class: 'manageTags' }, HINT: { id: 3, name: 'Show Tag List', color: 'rgba(150, 100, 100, 0.5)', action: onTagListHintClick, icon: 'fa-solid fa-tags', class: 'showTagList' }, + UNFILTER: { id: 5, name: 'Clear all filters', action: onClearAllFiltersClick, icon: 'fa-solid fa-filter-circle-xmark', class: 'clearAllFilters' }, }; const InListActionable = { @@ -57,19 +77,170 @@ const DEFAULT_TAGS = [ { id: uuidv4(), name: 'AliChat', create_date: Date.now() }, ]; +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' }, + NONE: { icon: '✕', class: 'no_folder', tooltip: 'No Folder', color: 'red', size: '1' }, +}; +const TAG_FOLDER_DEFAULT_TYPE = 'NONE'; + + let tags = []; let tag_map = {}; +/** + * Applies the basic filter for the current state of the tags and their selection on an entity list. + * @param {Array} entities List of entities for display, consisting of tags, characters and groups. + * @param {Object} param1 Optional parameters, explained below. + * @param {Boolean} [param1.globalDisplayFilters] When enabled, applies the final filter for the global list. Icludes filtering out entities in closed/hidden folders and empty folders. + * @param {Object} [param1.subForEntity] When given an entity, the list of entities gets filtered specifically for that one as a "sub list", filtering out other tags, elements not tagged for this and hidden elements. + * @param {Boolean} [param1.filterHidden] Optional switch with which filtering out hidden items (from closed folders) can be disabled. + * @returns The filtered list of entities + */ +function filterByTagState(entities, { globalDisplayFilters = false, subForEntity = undefined, filterHidden = true } = {}) { + const filterData = structuredClone(entitiesFilter.getFilterData(FILTER_TYPES.TAG)); + + entities = entities.filter(entity => { + if (entity.type === 'tag') { + // Remove folders that are already filtered on + if (filterData.selected.includes(entity.id) || filterData.excluded.includes(entity.id)) { + return false; + } + } + + return true; + }); + + if (globalDisplayFilters) { + // Prepare some data for caching and performance + const closedFolders = entities.filter(x => x.type === 'tag' && TAG_FOLDER_TYPES[x.item.folder_type] === TAG_FOLDER_TYPES.CLOSED); + + entities = entities.filter(entity => { + // Hide entities that are in a closed folder, unless that one is opened + if (filterHidden && entity.type !== 'tag' && closedFolders.some(f => entitiesFilter.isElementTagged(entity, f.id) && !filterData.selected.includes(f.id))) { + return false; + } + + // Hide folders that have 0 visible sub entities after the first filtering round + const alwaysFolder = isFilterState(entitiesFilter.getFilterData(FILTER_TYPES.FOLDER), FILTER_STATES.SELECTED); + if (entity.type === 'tag') { + return alwaysFolder || entity.entities.length > 0; + } + + return true; + }); + } + + if (subForEntity !== undefined && subForEntity.type === 'tag') { + entities = filterTagSubEntities(subForEntity.item, entities, { filterHidden : filterHidden }); + } + + return entities; +} + +function filterTagSubEntities(tag, entities, { filterHidden = true } = {}) { + const filterData = structuredClone(entitiesFilter.getFilterData(FILTER_TYPES.TAG)); + + const closedFolders = entities.filter(x => x.type === 'tag' && TAG_FOLDER_TYPES[x.item.folder_type] === TAG_FOLDER_TYPES.CLOSED); + + entities = entities.filter(sub => { + // Filter out all tags and and all who isn't tagged for this item + if (sub.type === 'tag' || !entitiesFilter.isElementTagged(sub, tag.id)) { + return false; + } + + // Hide entities that are in a closed folder, unless the closed folder is opened or we display a closed folder + if (filterHidden && sub.type !== 'tag' && TAG_FOLDER_TYPES[tag.folder_type] !== TAG_FOLDER_TYPES.CLOSED && closedFolders.some(f => entitiesFilter.isElementTagged(sub, f.id) && !filterData.selected.includes(f.id))) { + return false; + } + + return true; + }); + + return entities; +} + +/** + * 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 + */ +function isBogusFolder(tag) { + return tag?.folder_type !== undefined && tag.folder_type !== TAG_FOLDER_DEFAULT_TYPE; +} + +/** + * Indicates whether a user is currently in a bogus folder. + * @returns {boolean} If currently viewing a folder + */ +function isBogusFolderOpen() { + const anyIsFolder = entitiesFilter.getFilterData(FILTER_TYPES.TAG)?.selected + .map(tagId => tags.find(x => x.id === tagId)) + .some(isBogusFolder); + + return !!anyIsFolder; +} + +/** + * 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) + */ +function chooseBogusFolder(source, tagId, remove = false) { + // If we are here via the 'back' action, we implicitly take the last filtered folder as one to remove + const isBack = tagId === 'back'; + if (isBack) { + const drilldown = $(source).closest('#rm_characters_block').find('.rm_tag_bogus_drilldown'); + const lastTag = drilldown.find('.tag:last').last(); + tagId = lastTag.attr('id'); + remove = true; + } + + // 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'); + } +} + +/** + * Builds the tag block for the specified item. + * @param {Object} item 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) { + let count = entities.length; + + const tagFolder = TAG_FOLDER_TYPES[item.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.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); + + // Fill inline character images + buildAvatarList(template.find('.bogus_folder_avatars_block'), entities); + + return template; +} + /** * Applies the favorite filter to the character list. * @param {FilterHelper} filterHelper Instance of FilterHelper class. */ -function applyFavFilter(filterHelper) { - const isSelected = $(this).hasClass('selected'); - const displayFavoritesOnly = !isSelected; - $(this).toggleClass('selected', displayFavoritesOnly); - - filterHelper.setFilterData(FILTER_TYPES.FAV, displayFavoritesOnly); +function filterByFav(filterHelper) { + const state = toggleTagThreeState($(this)); + filterHelper.setFilterData(FILTER_TYPES.FAV, state); } /** @@ -77,11 +248,17 @@ function applyFavFilter(filterHelper) { * @param {FilterHelper} filterHelper Instance of FilterHelper class. */ function filterByGroups(filterHelper) { - const isSelected = $(this).hasClass('selected'); - const displayGroupsOnly = !isSelected; - $(this).toggleClass('selected', displayGroupsOnly); + const state = toggleTagThreeState($(this)); + filterHelper.setFilterData(FILTER_TYPES.GROUP, state); +} - filterHelper.setFilterData(FILTER_TYPES.GROUP, displayGroupsOnly); +/** + * Applies the "only folder" filter to the character list. + * @param {FilterHelper} filterHelper Instance of FilterHelper class. + */ +function filterByFolder(filterHelper) { + const state = toggleTagThreeState($(this)); + filterHelper.setFilterData(FILTER_TYPES.FOLDER, state); } function loadTagsSettings(settings) { @@ -111,7 +288,7 @@ function getTagsList(key) { return tag_map[key] .map(x => tags.find(y => y.id === x)) .filter(x => x) - .sort((a, b) => a.name.localeCompare(b.name)); + .sort(compareTagsForSort); } function getInlineListSelector() { @@ -138,12 +315,37 @@ function getTagKey() { return null; } -export function getTagKeyForCharacter(characterId = null) { - return characters[characterId]?.avatar; +/** + * 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 + * @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. + */ +export function getTagKeyForEntity(entityOrKey) { + let x = entityOrKey; + + // If it's an object and has an 'id' property, we take this for further processing + if (typeof x === 'object' && x !== null && 'id' in x) { + x = x.id; + } + + // Next lets check if its a valid character or character id, so we can swith it to its tag + const character = characters.indexOf(x) >= 0 ? x : characters[x]; + if (character) { + x = character.avatar; + } + + // We should hopefully have a key now. Let's check + if (x in tag_map) { + return x; + } + + // If none of the above, we cannot find a valid tag key + return undefined; } function addTagToMap(tagId, characterId = null) { - const key = getTagKey() ?? getTagKeyForCharacter(characterId); + const key = getTagKey() ?? getTagKeyForEntity(characterId); if (!key) { return; @@ -159,7 +361,7 @@ function addTagToMap(tagId, characterId = null) { } function removeTagFromMap(tagId, characterId = null) { - const key = getTagKey() ?? getTagKeyForCharacter(characterId); + const key = getTagKey() ?? getTagKeyForEntity(characterId); if (!key) { return; @@ -200,10 +402,6 @@ function selectTag(event, ui, listSelector) { // unfocus and clear the input $(event.target).val('').trigger('input'); - // add tag to the UI and internal map - appendTagToList(listSelector, tag, { removable: true }); - appendTagToList(getInlineListSelector(), tag, { removable: false }); - // Optional, check for multiple character ids being present. const characterData = event.target.closest('#bulk_tags_div')?.dataset.characters; const characterIds = characterData ? JSON.parse(characterData).characterIds : null; @@ -215,6 +413,11 @@ function selectTag(event, ui, listSelector) { } 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())); + printTagFilters(tag_filter_types.character); printTagFilters(tag_filter_types.group_member); @@ -245,7 +448,9 @@ async function importTags(imported_char) { } else { selected_tags = await callPopup(`

Importing Tags For ${imported_char.name}

${existingTags.length} existing tags have been found${existingTagsString}.

The following ${newTags.length} new tags will be imported.

`, 'input', newTags.join(', ')); } + // @ts-ignore selected_tags = existingTags.concat(selected_tags.split(',')); + // @ts-ignore selected_tags = selected_tags.map(t => t.trim()).filter(t => t !== ''); //Anti-troll measure if (selected_tags.length > 15) { @@ -276,6 +481,8 @@ function createNewTag(tagName) { const tag = { id: uuidv4(), name: tagName, + folder_type: TAG_FOLDER_DEFAULT_TYPE, + sort_order: tags.length, color: '', color2: '', create_date: Date.now(), @@ -284,18 +491,63 @@ function createNewTag(tagName) { return tag; } +/** + * @typedef {object} TagOptions + * @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. + * @property {boolean} [isGeneralList=false] - If true, indicates that this is the general list of tags. + * @property {boolean} [skipExistsCheck=false] - If true, the tag gets added even if a tag with the same id already exists. + */ + +/** + * Prints the list of tags. + * @param {JQuery} element - The container element where the tags are to be printed. + * @param {object} [options] - Optional parameters for printing the tag list. + * @param {Array} [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") + */ +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) { + $(element).empty(); + } + + 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 (action && typeof action !== 'function') { + console.error('The action parameter must return a function for tag.', tag); + } else { + tagOptions.action = action; + } + } + + appendTagToList(element, tag, tagOptions); + } +} + /** * Appends a tag to the list element. - * @param {string} listElement List element selector. - * @param {object} tag Tag object. - * @param {TagOptions} options Options for the tag. - * @typedef {{removable?: boolean, selectable?: boolean, action?: function, isGeneralList?: boolean}} TagOptions + * @param {JQuery} listElement List element. + * @param {object} tag Tag object to append. + * @param {TagOptions} [options={}] - Options for tag behavior. * @returns {void} */ -function appendTagToList(listElement, tag, { removable, selectable, action, isGeneralList }) { +function appendTagToList(listElement, tag, { removable = false, selectable = false, action = undefined, isGeneralList = false, skipExistsCheck = false } = {}) { if (!listElement) { return; } + if (!skipExistsCheck && $(listElement).find(`.tag[id="${tag.id}"]`).length > 0) { + return; + } let tagElement = $('#tag_template .tag').clone(); tagElement.attr('id', tag.id); @@ -317,7 +569,7 @@ function appendTagToList(listElement, tag, { removable, selectable, action, isGe } if (tag.excluded && isGeneralList) { - $(tagElement).addClass('excluded'); + toggleTagThreeState(tagElement, { stateOverride: FILTER_STATES.EXCLUDED }); } if (selectable) { @@ -337,32 +589,67 @@ function appendTagToList(listElement, tag, { removable, selectable, action, isGe } function onTagFilterClick(listElement) { - let excludeTag; - if ($(this).hasClass('selected')) { - $(this).removeClass('selected'); - $(this).addClass('excluded'); - excludeTag = true; - } - else if ($(this).hasClass('excluded')) { - $(this).removeClass('excluded'); - excludeTag = false; - } - else { - $(this).addClass('selected'); - } + const tagId = $(this).attr('id'); + const existingTag = tags.find((tag) => tag.id === tagId); + + let state = toggleTagThreeState($(this)); // Manual undefined check required for three-state boolean - if (excludeTag !== undefined) { - const tagId = $(this).attr('id'); - const existingTag = tags.find((tag) => tag.id === tagId); - if (existingTag) { - existingTag.excluded = excludeTag; + if (existingTag) { + existingTag.excluded = isFilterState(state, FILTER_STATES.EXCLUDED); - saveSettingsDebounced(); + 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(); } } runTagFilters(listElement); + updateTagFilterIndicator(); +} + +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); + + const currentStateIndex = states.indexOf(element.attr('data-toggle-state')) ?? states.length - 1; + const targetStateIndex = overrideKey !== undefined ? states.indexOf(overrideKey) : (currentStateIndex + 1) % states.length; + + if (simulateClick) { + // Calculate how many clicks are needed to go from the current state to the target state + let clickCount = 0; + if (targetStateIndex >= currentStateIndex) { + clickCount = targetStateIndex - currentStateIndex; + } else { + clickCount = (states.length - currentStateIndex) + targetStateIndex; + } + + for (let i = 0; i < clickCount; i++) { + $(element).trigger('click'); + } + + console.debug('manually click-toggle three-way filter from', states[currentStateIndex], 'to', states[targetStateIndex], 'on', element); + } else { + element.attr('data-toggle-state', states[targetStateIndex]); + + // Update css class and remove all others + states.forEach(state => { + element.toggleClass(FILTER_STATES[state].class, state === states[targetStateIndex]); + }); + + console.debug('toggle three-way filter from', states[currentStateIndex], 'to', states[targetStateIndex], 'on', element); + } + + + return states[targetStateIndex]; } function runTagFilters(listElement) { @@ -373,32 +660,44 @@ 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; - const selectedTagIds = [...($(FILTER_SELECTOR).find('.tag.selected').map((_, el) => $(el).attr('id')))]; $(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); + printTagList($(FILTER_SELECTOR), { empty: false, tags: actionTags, tagActionSelector: tag => tag.action, tagOptions: { isGeneralList: true } }); + + const inListActionTags = Object.values(InListActionable); + 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((a, b) => a.name.localeCompare(b.name)); + .sort(compareTagsForSort); + printTagList($(FILTER_SELECTOR), { empty: false, tags: tagsToDisplay, tagOptions: { selectable: true, isGeneralList: true } }); - for (const tag of Object.values(ACTIONABLE_TAGS)) { - appendTagToList(FILTER_SELECTOR, tag, { removable: false, selectable: false, action: tag.action, isGeneralList: true }); + runTagFilters(FILTER_SELECTOR); + + // 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 }); } - $(FILTER_SELECTOR).find('.actionable').last().addClass('margin-right-10px'); - - for (const tag of Object.values(InListActionable)) { - appendTagToList(FILTER_SELECTOR, tag, { removable: false, selectable: false, action: tag.action, isGeneralList: true }); - } - for (const tag of tagsToDisplay) { - appendTagToList(FILTER_SELECTOR, tag, { removable: false, selectable: true, isGeneralList: true }); - if (tag.excluded) { - runTagFilters(FILTER_SELECTOR); - } + if (power_user.show_tag_filters) { + $('.rm_tag_controls .showTagList').addClass('selected'); + $('.rm_tag_controls').find('.tag:not(.actionable)').show(); } - for (const tagId of selectedTagIds) { - $(`${FILTER_SELECTOR} .tag[id="${tagId}"]`).trigger('click'); + updateTagFilterIndicator(); +} + +function updateTagFilterIndicator() { + if ($('.rm_tag_controls').find('.tag:not(.actionable)').is('.selected, .excluded')) { + $('.rm_tag_controls .showTagList').addClass('indicator'); + } else { + $('.rm_tag_controls .showTagList').removeClass('indicator'); } } @@ -407,6 +706,13 @@ function onTagRemoveClick(event) { const tag = $(this).closest('.tag'); const tagId = tag.attr('id'); + // Check if we are inside the drilldown. If so, we call remove on the bogus folder + if ($(this).closest('.rm_tag_bogus_drilldown').length > 0) { + console.debug('Bogus drilldown remove', tagId); + chooseBogusFolder($(this), tagId, true); + return; + } + // Optional, check for multiple character ids being present. const characterData = event.target.closest('#bulk_tags_div')?.dataset.characters; const characterIds = characterData ? JSON.parse(characterData).characterIds : null; @@ -426,13 +732,16 @@ function onTagRemoveClick(event) { saveSettingsDebounced(); } +// @ts-ignore function onTagInput(event) { let val = $(this).val(); if (tags.find(t => t.name === val)) return; + // @ts-ignore $(this).autocomplete('search', val); } function onTagInputFocus() { + // @ts-ignore $(this).autocomplete('search', $(this).val()); } @@ -441,40 +750,23 @@ function onCharacterCreateClick() { } function onGroupCreateClick() { - $('#groupTagList').empty(); - printTagFilters(tag_filter_types.character); - printTagFilters(tag_filter_types.group_member); + // Nothing to do here at the moment. Tags in group interface get automatically redrawn. } export function applyTagsOnCharacterSelect() { //clearTagsFilter(); const chid = Number($(this).attr('chid')); - const key = characters[chid].avatar; - const tags = getTagsList(key); - - $('#tagList').empty(); - - for (const tag of tags) { - appendTagToList('#tagList', tag, { removable: true }); - } + printTagList($('#tagList'), { forEntityOrKey: chid, tagOptions: { removable: true } }); } function applyTagsOnGroupSelect() { //clearTagsFilter(); - const key = $(this).attr('grid'); - const tags = getTagsList(key); - - $('#groupTagList').empty(); - printTagFilters(tag_filter_types.character); - printTagFilters(tag_filter_types.group_member); - - for (const tag of tags) { - appendTagToList('#groupTagList', tag, { removable: true }); - } + // Nothing to do here at the moment. Tags in group interface get automatically redrawn. } export function createTagInput(inputSelector, listSelector) { $(inputSelector) + // @ts-ignore .autocomplete({ source: (i, o) => findTag(i, o, listSelector), select: (e, u) => selectTag(e, u, listSelector), @@ -509,19 +801,86 @@ function onViewTagsListClick() {
+ Drag the handle to reorder.
+ ${(power_user.bogus_folders ? 'Click on the folder icon to use this tag as a folder.
' : '')} Click on the tag name to edit it.
Click on color box to assign new color.
`); - const sortedTags = tags.slice().sort((a, b) => a?.name?.toLowerCase()?.localeCompare(b?.name?.toLowerCase())); + const tagContainer = $('
'); + list.append(tagContainer); + + const sortedTags = sortTags(tags); + for (const tag of sortedTags) { - appendViewTagToList(list, tag, everything); + appendViewTagToList(tagContainer, tag, everything); } + makeTagListDraggable(tagContainer); + callPopup(list, 'text'); } +function makeTagListDraggable(tagContainer) { + const onTagsSort = () => { + tagContainer.find('.tag_view_item').each(function (i, tagElement) { + const id = $(tagElement).attr('id'); + const tag = tags.find(x => x.id === id); + + // Fix the defined colors, because if there is no color set, they seem to get automatically set to black + // based on the color picker after drag&drop, even if there was no color chosen. We just set them back. + const color = $(tagElement).find('.tagColorPickerHolder .tag-color').attr('color'); + const color2 = $(tagElement).find('.tagColorPicker2Holder .tag-color2').attr('color'); + if (color === '' || color === undefined) { + tag.color = ''; + fixColor('background-color', tag.color); + } + if (color2 === '' || color2 === undefined) { + tag.color2 = ''; + fixColor('color', tag.color2); + } + + // Update the sort order + tag.sort_order = i; + + function fixColor(property, color) { + $(tagElement).find('.tag_view_name').css(property, color); + $(`.tag[id="${id}"]`).css(property, color); + $(`.bogus_folder_select[tagid="${id}"] .avatar`).css(property, color); + } + }); + + 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(); + }; + + // @ts-ignore + $(tagContainer).sortable({ + delay: getSortableDelay(), + stop: () => onTagsSort(), + handle: '.drag-handle', + }); +} + +function sortTags(tags) { + return tags.slice().sort(compareTagsForSort); +} + +function compareTagsForSort(a, b) { + if (a.sort_order !== undefined && b.sort_order !== undefined) { + return a.sort_order - b.sort_order; + } else if (a.sort_order !== undefined) { + return -1; + } else if (b.sort_order !== undefined) { + return 1; + } else { + return a.name.toLowerCase().localeCompare(b.name.toLowerCase()); + } +} + async function onTagRestoreFileSelect(e) { const file = e.target.files[0]; @@ -620,7 +979,7 @@ function onTagsBackupClick() { function onTagCreateClick() { const tag = createNewTag('New Tag'); - appendViewTagToList($('#tag_view_list'), tag, []); + appendViewTagToList($('#tag_view_list .tag_view_list_tags'), tag, []); printCharacters(false); saveSettingsDebounced(); } @@ -636,9 +995,14 @@ function appendViewTagToList(list, tag, everything) { template.find('.tag_view_name').css('background-color', tag.color); template.find('.tag_view_name').css('color', tag.color2); + const tagAsFolderId = tag.id + '-tag-folder'; const colorPickerId = tag.id + '-tag-color'; const colorPicker2Id = tag.id + '-tag-color2'; + if (!power_user.bogus_folders) { + template.find('.tag_as_folder').hide(); + } + template.find('.tagColorPickerHolder').html( ``, ); @@ -646,6 +1010,7 @@ function appendViewTagToList(list, tag, everything) { ``, ); + template.find('.tag_as_folder').attr('id', tagAsFolderId); template.find('.tag-color').attr('id', colorPickerId); template.find('.tag-color2').attr('id', colorPicker2Id); @@ -663,10 +1028,49 @@ function appendViewTagToList(list, tag, everything) { }); }, 100); + updateDrawTagFolder(template, tag); + + // @ts-ignore $(colorPickerId).color = tag.color; + // @ts-ignore $(colorPicker2Id).color = tag.color2; } +function onTagAsFolderClick() { + const element = $(this).closest('.tag_view_item'); + const id = element.attr('id'); + const tag = tags.find(x => x.id === id); + + // Cycle through folder types + const types = Object.keys(TAG_FOLDER_TYPES); + const currentTypeIndex = types.indexOf(tag.folder_type); + tag.folder_type = types[(currentTypeIndex + 1) % types.length]; + + updateDrawTagFolder(element, tag); + + // If folder display has changed, we have to redraw the character list, otherwise this folders state would not change + printCharacters(true); + saveSettingsDebounced(); + +} + +function updateDrawTagFolder(element, tag) { + const tagFolder = TAG_FOLDER_TYPES[tag.folder_type] || TAG_FOLDER_TYPES[TAG_FOLDER_DEFAULT_TYPE]; + const folderElement = element.find('.tag_as_folder'); + + // Update css class and remove all others + Object.keys(TAG_FOLDER_TYPES).forEach(x => { + folderElement.toggleClass(TAG_FOLDER_TYPES[x].class, TAG_FOLDER_TYPES[x] === tagFolder); + }); + + // Draw/update css attributes for this class + folderElement.attr('title', tagFolder.tooltip); + const indicator = folderElement.find('.tag_folder_indicator'); + indicator.text(tagFolder.icon); + indicator.css('color', tagFolder.color); + indicator.css('font-size', `calc(var(--mainFontSize) * ${tagFolder.size})`); +} + function onTagDeleteClick() { if (!confirm('Are you sure?')) { return; @@ -720,10 +1124,31 @@ function onTagColorize2(evt) { } function onTagListHintClick() { - console.log($(this)); $(this).toggleClass('selected'); $(this).siblings('.tag:not(.actionable)').toggle(100); $(this).siblings('.innerActionable').toggleClass('hidden'); + + power_user.show_tag_filters = $(this).hasClass('selected'); + saveSettingsDebounced(); + + console.debug('show_tag_filters', power_user.show_tag_filters); +} + +function onClearAllFiltersClick() { + console.debug('clear all filters clicked'); + + // We have to manually go through the elements and unfilter by clicking... + // Thankfully nearly all filter controls are three-state-toggles + const filterTags = $('.rm_tag_controls .rm_tag_filter').find('.tag'); + for(const tag of filterTags) { + const toggleState = $(tag).attr('data-toggle-state'); + if (toggleState !== undefined && !isFilterState(toggleState ?? FILTER_STATES.UNDEFINED, FILTER_STATES.UNDEFINED)) { + toggleTagThreeState($(tag), { stateOverride: FILTER_STATES.UNDEFINED, simulateClick: true }); + } + } + + // Reset search too + $('#character_search_bar').val('').trigger('input'); } jQuery(() => { @@ -738,8 +1163,10 @@ jQuery(() => { $(document).on('input', '.tag_input', onTagInput); $(document).on('click', '.tags_view', onViewTagsListClick); $(document).on('click', '.tag_delete', onTagDeleteClick); + $(document).on('click', '.tag_as_folder', onTagAsFolderClick); $(document).on('input', '.tag_view_name', onTagRenameInput); $(document).on('click', '.tag_view_create', onTagCreateClick); $(document).on('click', '.tag_view_backup', onTagsBackupClick); $(document).on('click', '.tag_view_restore', onBackupRestoreClick); }); + diff --git a/public/scripts/world-info.js b/public/scripts/world-info.js index 216a759cd..3f6dbc862 100644 --- a/public/scripts/world-info.js +++ b/public/scripts/world-info.js @@ -7,7 +7,7 @@ import { isMobile } from './RossAscends-mods.js'; import { FILTER_TYPES, FilterHelper } from './filters.js'; import { getTokenCount } from './tokenizers.js'; import { power_user } from './power-user.js'; -import { getTagKeyForCharacter } from './tags.js'; +import { getTagKeyForEntity } from './tags.js'; import { resolveVariable } from './variables.js'; export { @@ -2055,7 +2055,7 @@ async function checkWorldInfo(chat, maxContext) { } if (entry.characterFilter && entry.characterFilter?.tags?.length > 0) { - const tagKey = getTagKeyForCharacter(this_chid); + const tagKey = getTagKeyForEntity(this_chid); if (tagKey) { const tagMapEntry = context.tagMap[tagKey]; diff --git a/public/style.css b/public/style.css index a969936f9..82ba18066 100644 --- a/public/style.css +++ b/public/style.css @@ -96,6 +96,12 @@ /*styles for the color picker*/ --tool-cool-color-picker-btn-bg: transparent; --tool-cool-color-picker-btn-border-color: transparent; + + --avatar-base-height: 50px; + --avatar-base-width: 50px; + --avatar-base-border-radius: 2px; + --avatar-base-border-radius-round: 50%; + --inline-avatar-small-factor: 0.6; } * { @@ -854,8 +860,8 @@ body.reduced-motion #bg_custom { } .add_avatar { - border: 2px solid var(--SmartThemeBodyColor); - margin: 2px; + border: var(--avatar-base-border-radius) solid var(--SmartThemeBodyColor); + margin: var(--avatar-base-border-radius); cursor: pointer; transition: filter 0.2s ease-in-out; } @@ -865,54 +871,42 @@ body.reduced-motion #bg_custom { } .avatar { - width: 50px; - height: 50px; + width: var(--avatar-base-width); + height: var(--avatar-base-height); + border-radius: var(--avatar-base-border-radius-round); border-style: none; flex: 1; + transition: 250ms; } .last_mes .mesAvatarWrapper { - padding-bottom: 50px; + padding-bottom: var(--avatar-base-height); } .mes .avatar { cursor: pointer; } -#HotSwapWrapper .hotswap { +.hotswap { + margin: 5px; justify-content: space-evenly; } -.hotswapAvatar, -.hotswapAvatar .avatar { - width: 50px !important; - height: 50px !important; - border-style: none; +#HotSwapWrapper { + justify-content: space-evenly; } -.hotswapAvatar { - opacity: 0.5; - transition: 250ms; - overflow: hidden; - padding: 0 !important; - order: 100; +.avatar.selectable { + opacity: 0.6; } -.hotswapAvatar:hover { +.avatar.selectable:hover { opacity: 1; background-color: transparent !important; cursor: pointer; } -.hotswapAvatar .avatar_collage, -.hotswapAvatar.group_select { - border-radius: 50% !important; - position: relative; - overflow: hidden; - min-width: 50px !important; -} - -.hotswapAvatar.group_select .avatar.avatar_collage img { +.avatar.avatar_collage img { width: 100%; height: 100%; object-fit: cover; @@ -920,32 +914,29 @@ body.reduced-motion #bg_custom { border: 1px solid var(--SmartThemeBorderColor); } -.hotswapAvatar .avatar { - width: 50px !important; - height: 50px !important; - object-fit: cover; - object-position: center center; - border-radius: 50% !important; - box-shadow: 0 0 5px var(--black50a); -} - -.hotswapAvatar img, .avatar img { - width: 50px; - height: 50px; + width: var(--avatar-base-width); + height: var(--avatar-base-height); object-fit: cover; object-position: center center; - border-radius: 50%; + border-radius: var(--avatar-base-border-radius-round); border: 1px solid var(--SmartThemeBorderColor); /*--black30a*/ box-shadow: 0 0 5px var(--black50a); } .bogus_folder_select .avatar, -.character_select .avatar { +.character_select .avatar, +.avatars_inline .avatar { flex: unset; } +.avatars_inline { + flex-wrap: wrap; + overflow: hidden; + max-height: calc(var(--avatar-base-height) + 2 * var(--avatar-base-border-radius)); +} + .bogus_folder_select .avatar { justify-content: center; background-color: var(--SmartThemeBlurTintColor); @@ -955,6 +946,30 @@ body.reduced-motion #bg_custom { outline-color: var(--SmartThemeBorderColor); } +.avatars_inline_small .avatar, +.avatars_inline_small .avatar img { + width: calc(var(--avatar-base-width) * var(--inline-avatar-small-factor)); + height: calc(var(--avatar-base-height) * var(--inline-avatar-small-factor)); +} + +.avatars_inline_small { + height: calc(var(--avatar-base-height) * var(--inline-avatar-small-factor) + 2 * var(--avatar-base-border-radius)); +} + +.bogus_folder_select:not(.folder_closed) .bogus_folder_avatars_block { + opacity: 1 !important; +} + +.avatars_inline .avatar { + margin-top: calc(var(--avatar-base-border-radius)); + margin-left: calc(var(--avatar-base-border-radius)); + margin-bottom: calc(var(--avatar-base-border-radius)); +} + +.avatars_inline .avatar:last-of-type { + margin-right: calc(var(--avatar-base-border-radius)); +} + .mes_block { padding-top: 0; padding-left: 10px; @@ -1289,20 +1304,32 @@ input[type="file"] { align-self: center; } +#rm_print_characters_block .text_block { + height: 100%; + width: 100%; + opacity: 0.5; + margin: 0 auto 1px auto; + padding: 5px; +} + #rm_print_characters_block .empty_block { display: flex; flex-direction: column; gap: 10px; flex-wrap: wrap; text-align: center; - height: 100%; - width: 100%; - opacity: 0.5; justify-content: center; - margin: 0 auto; align-items: center; } +.hidden_block { + cursor: default; +} + +#rm_print_characters_block .hidden_block p { + display: inline; +} + #rm_print_characters_block { overflow-y: auto; flex-grow: 1; @@ -1587,6 +1614,12 @@ input[type=search]:focus::-webkit-search-cancel-button { margin-bottom: 1px; } +.character_select.inline_avatar, +.missing-avatar.inline_avatar { + padding: unset; + border-radius: var(--avatar-base-border-radius-round); +} + /*applies to char list and mes_text char display name*/ .ch_name { @@ -1598,6 +1631,19 @@ input[type=search]:focus::-webkit-search-cancel-button { align-items: baseline; flex-direction: row; gap: 5px; + margin-bottom: 6px; +} + +.character_name_block_sub_line { + position: absolute; + right: 0px; + top: calc(var(--mainFontSize) + 2px); + font-size: calc(var(--mainFontSize) * 0.6); + color: var(--grey7070a); +} + +.character_name_block .ch_add_placeholder { + display: none; } .ch_avatar_url { @@ -1623,15 +1669,29 @@ input[type=search]:focus::-webkit-search-cancel-button { } /*applies to both groups and solos chars in the char list*/ +#rm_print_characters_block .character_select_container, +#rm_print_characters_block .group_select_container { + position: relative; +} + #rm_print_characters_block .ch_name, .avatar-container .ch_name { - flex: 1; + flex: 1 1 auto; white-space: nowrap; overflow: hidden; + text-wrap: nowrap; text-overflow: ellipsis; display: block; } +#rm_print_characters_block .character_name_block> :last-child { + flex: 0 100000 auto; + /* Force shrinking first */ + overflow: hidden; + text-wrap: nowrap; + text-overflow: ellipsis; +} + .bogus_folder_select:hover, .character_select:hover, .avatar-container:hover { @@ -1802,7 +1862,8 @@ input[type=search]:focus::-webkit-search-cancel-button { .character_select.is_fav .avatar, .group_select.is_fav .avatar, -.group_member.is_fav .avatar { +.group_member.is_fav .avatar, +.avatar.is_fav { outline: 2px solid var(--golden); } @@ -1866,8 +1927,8 @@ input[type=search]:focus::-webkit-search-cancel-button { cursor: pointer; margin-bottom: 1px; width: 100%; - outline: 2px solid transparent; - border: 2px solid transparent; + outline: var(--avatar-base-border-radius) solid transparent; + border: var(--avatar-base-border-radius) solid transparent; } .avatar-container .character_select_container { @@ -2321,18 +2382,18 @@ input[type="checkbox"]:not(#nav-toggle):not(#rm_button_panel_pin):not(#lm_button .avatar-container .avatar { cursor: pointer; - border-radius: 50%; + border-radius: var(--avatar-base-border-radius-round); align-self: center; - outline: 2px solid transparent; + outline: var(--avatar-base-border-radius) solid transparent; flex: unset; } .avatar-container.selected { - border: 2px solid rgba(255, 255, 255, 0.7); + border: var(--avatar-base-border-radius) solid rgba(255, 255, 255, 0.7); } .avatar-container.default_persona .avatar { - outline: 2px solid var(--golden); + outline: var(--avatar-base-border-radius) solid var(--golden); } .avatar-container.default_persona .set_default_persona { @@ -2989,8 +3050,27 @@ body .ui-widget-content li:hover { width: 0px; } +#group_avatar_preview .missing-avatar { + display: inline; + vertical-align: middle; +} + +body.big-avatars .group_select .missing-avatar { + display: flex; + justify-content: center; + align-items: center; +} + +body.big-avatars .missing-avatar { + width: calc(var(--avatar-base-width)* var(--big-avatar-width-factor)); + height: calc(var(--avatar-base-height)* var(--big-avatar-height-factor)); +} + .missing-avatar { font-size: 36px; + width: var(--avatar-base-width); + height: var(--avatar-base-height); + text-align: center; } @keyframes ellipsis {