Feature/add character tags to message div option (#4225) * Adding toggle. * Adding events for the new toggle. Reloading chat when toggled. * Adding chat reload when tags are added or removed to char. * Adding main applyCharacterTagsToMessageDivs function to add tags to divs. * Adding information that the class name is normalized and lowercased to the tooltip. * Correcting console.traces to console.debug for minimal console printing. * Lint indentation fix. * Adding simple cache. * Adding reload event when a new message is added to chat. * Changing tags applied from classes to HTML data API. * Adding guide showing how to select data attributes with CSS. * Fixing trailing comma pointed out by lint. * Simplifying re-rendering by deleting previous attributes. * Adding the feature as default behavior, removing toggles. Will add docs to ST docs. * Removing code that is no longer needed. * Re-rendering divs after other tag operations. * Filtering message ids to only re-render specific divs. Adding suggested changes. * After testing, it seems to add all messages by one, so it appears it's not needed call this in the printMessages function. * Lint fixes. * Cache jquery selector * Fix local variables assignment * Move new functions to tags module * Normalizing tag names before adding them as data-* * Replace commas with spaces to avoid issues with tag names containing commas. * Correcting where the comma replacing should go. * Add error handling for applyCharacterTagsToMessageDivs --------- Co-authored-by: Cohee <18619528+Cohee1207@users.noreply.github.com>
Signed| @@ -204,6 +204,7 @@ import { | |||
| 204 | applyTagsOnCharacterSelect, | 204 | applyTagsOnCharacterSelect, |
| 205 | applyTagsOnGroupSelect, | 205 | applyTagsOnGroupSelect, |
| 206 | tag_import_setting, | 206 | tag_import_setting, |
| 207 | applyCharacterTagsToMessageDivs, | ||
| 207 | } from './scripts/tags.js'; | 208 | } from './scripts/tags.js'; |
| 208 | import { | 209 | import { |
| 209 | SECRET_KEYS, | 210 | SECRET_KEYS, |
| @@ -2624,6 +2625,8 @@ export function addOneMessage(mes, { type = 'normal', insertAfter = null, scroll | |||
| 2624 | if (!insertAfter && !insertBefore && scroll) { | 2625 | if (!insertAfter && !insertBefore && scroll) { |
| 2625 | scrollChatToBottom(); | 2626 | scrollChatToBottom(); |
| 2626 | } | 2627 | } |
| 2628 | |||
| 2629 | applyCharacterTagsToMessageDivs({ mesIds: newMessageId }); | ||
| 2627 | } | 2630 | } |
| 2628 | 2631 | ||
| 2629 | /** | 2632 | /** |
| @@ -136,7 +136,7 @@ const TAG_FOLDER_DEFAULT_TYPE = 'NONE'; | |||
| 136 | * @property {string} [color] - The background color of the tag | 136 | * @property {string} [color] - The background color of the tag |
| 137 | * @property {string} [color2] - The foreground color of the tag | 137 | * @property {string} [color2] - The foreground color of the tag |
| 138 | * @property {number} [create_date] - A number representing the date when this tag was created | 138 | * @property {number} [create_date] - A number representing the date when this tag was created |
| 139 | * @property {boolean} is_hidden_on_character_card - Whether this tag is hidden on the character card | 139 | * @property {boolean} [is_hidden_on_character_card] - Whether this tag is hidden on the character card |
| 140 | * | 140 | * |
| 141 | * @property {function} [action] - An optional function that gets executed when this tag is an actionable tag and is clicked on. | 141 | * @property {function} [action] - An optional function that gets executed when this tag is an actionable tag and is clicked on. |
| 142 | * @property {string} [class] - An optional css class added to the control representing this tag when printed. Used for custom tags in the filters. | 142 | * @property {string} [class] - An optional css class added to the control representing this tag when printed. Used for custom tags in the filters. |
| @@ -686,6 +686,8 @@ function selectTag(event, ui, listSelector, { tagListOptions = {} } = {}) { | |||
| 686 | 686 | ||
| 687 | addTagsToEntity(tag, characterIds, { tagListSelector: listSelector, tagListOptions: tagListOptions }); | 687 | addTagsToEntity(tag, characterIds, { tagListSelector: listSelector, tagListOptions: tagListOptions }); |
| 688 | 688 | ||
| 689 | applyCharacterTagsToMessageDivs(); | ||
| 690 | |||
| 689 | // need to return false to keep the input clear | 691 | // need to return false to keep the input clear |
| 690 | return false; | 692 | return false; |
| 691 | } | 693 | } |
| @@ -1237,6 +1239,8 @@ function onTagRemoveClick(event) { | |||
| 1237 | const characterIds = characterData ? JSON.parse(characterData).characterIds : null; | 1239 | const characterIds = characterData ? JSON.parse(characterData).characterIds : null; |
| 1238 | 1240 | ||
| 1239 | removeTagFromEntity(tag, characterIds, { tagElement: tagElement }); | 1241 | removeTagFromEntity(tag, characterIds, { tagElement: tagElement }); |
| 1242 | |||
| 1243 | applyCharacterTagsToMessageDivs(); | ||
| 1240 | } | 1244 | } |
| 1241 | 1245 | ||
| 1242 | // @ts-ignore | 1246 | // @ts-ignore |
| @@ -1718,6 +1722,8 @@ async function onTagDeleteClick() { | |||
| 1718 | 1722 | ||
| 1719 | printCharactersDebounced(); | 1723 | printCharactersDebounced(); |
| 1720 | saveSettingsDebounced(); | 1724 | saveSettingsDebounced(); |
| 1725 | |||
| 1726 | applyCharacterTagsToMessageDivs(); | ||
| 1721 | } | 1727 | } |
| 1722 | 1728 | ||
| 1723 | function onTagRenameInput() { | 1729 | function onTagRenameInput() { |
| @@ -1728,6 +1734,8 @@ function onTagRenameInput() { | |||
| 1728 | $(this).attr('dirty', ''); | 1734 | $(this).attr('dirty', ''); |
| 1729 | $(`.tag[id="${id}"] .tag_name`).text(newName); | 1735 | $(`.tag[id="${id}"] .tag_name`).text(newName); |
| 1730 | saveSettingsDebounced(); | 1736 | saveSettingsDebounced(); |
| 1737 | |||
| 1738 | applyCharacterTagsToMessageDivs(); | ||
| 1731 | } | 1739 | } |
| 1732 | 1740 | ||
| 1733 | /** | 1741 | /** |
| @@ -1853,7 +1861,8 @@ function registerTagsSlashCommands() { | |||
| 1853 | }), | 1861 | }), |
| 1854 | ], | 1862 | ], |
| 1855 | unnamedArgumentList: [ | 1863 | unnamedArgumentList: [ |
| 1856 | SlashCommandArgument.fromProps({ description: 'tag name', | 1864 | SlashCommandArgument.fromProps({ |
| 1865 | description: 'tag name', | ||
| 1857 | typeList: [ARGUMENT_TYPE.STRING], | 1866 | typeList: [ARGUMENT_TYPE.STRING], |
| 1858 | isRequired: true, | 1867 | isRequired: true, |
| 1859 | enumProvider: commonEnumProviders.tagsForChar('not-existing'), | 1868 | enumProvider: commonEnumProviders.tagsForChar('not-existing'), |
| @@ -1890,7 +1899,8 @@ function registerTagsSlashCommands() { | |||
| 1890 | return String(result); | 1899 | return String(result); |
| 1891 | }, | 1900 | }, |
| 1892 | namedArgumentList: [ | 1901 | namedArgumentList: [ |
| 1893 | SlashCommandNamedArgument.fromProps({ name: 'name', | 1902 | SlashCommandNamedArgument.fromProps({ |
| 1903 | name: 'name', | ||
| 1894 | description: 'Character name - or unique character identifier (avatar key)', | 1904 | description: 'Character name - or unique character identifier (avatar key)', |
| 1895 | typeList: [ARGUMENT_TYPE.STRING], | 1905 | typeList: [ARGUMENT_TYPE.STRING], |
| 1896 | defaultValue: '{{char}}', | 1906 | defaultValue: '{{char}}', |
| @@ -1898,7 +1908,8 @@ function registerTagsSlashCommands() { | |||
| 1898 | }), | 1908 | }), |
| 1899 | ], | 1909 | ], |
| 1900 | unnamedArgumentList: [ | 1910 | unnamedArgumentList: [ |
| 1901 | SlashCommandArgument.fromProps({ description: 'tag name', | 1911 | SlashCommandArgument.fromProps({ |
| 1912 | description: 'tag name', | ||
| 1902 | typeList: [ARGUMENT_TYPE.STRING], | 1913 | typeList: [ARGUMENT_TYPE.STRING], |
| 1903 | isRequired: true, | 1914 | isRequired: true, |
| 1904 | /**@param {SlashCommandExecutor} executor */ | 1915 | /**@param {SlashCommandExecutor} executor */ |
| @@ -2002,6 +2013,171 @@ function registerTagsSlashCommands() { | |||
| 2002 | })); | 2013 | })); |
| 2003 | } | 2014 | } |
| 2004 | 2015 | ||
| 2016 | /** | ||
| 2017 | * Function to apply character tags to message divs when rendering the chat | ||
| 2018 | * @param {object} options Options for applying character tags | ||
| 2019 | * @param {number|number[]} [options.mesIds=[]] An id or array of message IDs to filter by. | ||
| 2020 | * If empty, all messages will be processed. | ||
| 2021 | * @returns {void} | ||
| 2022 | * @description This function iterates through the chat messages and applies character tags | ||
| 2023 | */ | ||
| 2024 | export function applyCharacterTagsToMessageDivs({ mesIds = [] } = {}) { | ||
| 2025 | try { | ||
| 2026 | const messagesFilter = buildMessagesFilter(mesIds); | ||
| 2027 | const messages = $('#chat').children(messagesFilter); | ||
| 2028 | |||
| 2029 | // Clear existing tags | ||
| 2030 | messages.each(function () { | ||
| 2031 | const element = this; // Get the raw DOM element | ||
| 2032 | |||
| 2033 | for (const attr of [...element.attributes]) { | ||
| 2034 | if (attr.name.startsWith('data-char-tag-') || attr.name === 'data-char-tags') { | ||
| 2035 | element.removeAttribute(attr.name); | ||
| 2036 | } | ||
| 2037 | } | ||
| 2038 | }); | ||
| 2039 | |||
| 2040 | const tagsList = tags, characterTagData = tag_map; | ||
| 2041 | |||
| 2042 | if (!tagsList?.length || !characterTagData) { | ||
| 2043 | return; | ||
| 2044 | } | ||
| 2045 | |||
| 2046 | const tagNamesById = tagsList.reduce((acc, tag) => { | ||
| 2047 | acc[tag.id] = tag.name; | ||
| 2048 | return acc; | ||
| 2049 | }, {}); | ||
| 2050 | |||
| 2051 | const characterTagsCache = new Map(); | ||
| 2052 | |||
| 2053 | // Iterate each message div | ||
| 2054 | messages.each(function () { | ||
| 2055 | const $this = $(this); // Store the jQuery object | ||
| 2056 | const avatarFileName = extractCharacterAvatar($this.find('.avatar img').attr('src')); | ||
| 2057 | |||
| 2058 | if (!avatarFileName) { | ||
| 2059 | return; | ||
| 2060 | } | ||
| 2061 | |||
| 2062 | let tagsForCharacter = characterTagsCache.get(avatarFileName); | ||
| 2063 | |||
| 2064 | // If tags are NOT in the cache, compute and store them | ||
| 2065 | if (!tagsForCharacter) { | ||
| 2066 | const tagIds = characterTagData[avatarFileName]; | ||
| 2067 | if (tagIds?.length) { | ||
| 2068 | const tagNames = tagIds | ||
| 2069 | .map(id => tagNamesById[id]) | ||
| 2070 | .filter(Boolean); | ||
| 2071 | |||
| 2072 | if (tagNames.length) { | ||
| 2073 | tagsForCharacter = { | ||
| 2074 | tagNames, | ||
| 2075 | joinedTagNames: tagNames | ||
| 2076 | .map(name => name?.replace(/,/g, ' ')) // replace commas with spaces to avoid issues with tag names containing commas | ||
| 2077 | .join(','), | ||
| 2078 | }; | ||
| 2079 | // Add the newly computed tags to the cache | ||
| 2080 | characterTagsCache.set(avatarFileName, tagsForCharacter); | ||
| 2081 | } | ||
| 2082 | } | ||
| 2083 | } | ||
| 2084 | |||
| 2085 | // If we have tags (either from cache or newly computed), apply them | ||
| 2086 | if (tagsForCharacter) { | ||
| 2087 | applyTags($this, tagsForCharacter); | ||
| 2088 | } | ||
| 2089 | }); | ||
| 2090 | } catch (error) { | ||
| 2091 | console.error('Error applying character tags to message divs:', error); | ||
| 2092 | } | ||
| 2093 | } | ||
| 2094 | |||
| 2095 | /** | ||
| 2096 | * Builds a jQuery selector string to filter messages by their IDs. | ||
| 2097 | * @param {number|number[]} mesIds - An id or array of message IDs to filter by. | ||
| 2098 | * @returns {string} A jQuery selector string that matches messages with the specified IDs. | ||
| 2099 | * If mesIds is empty, it returns '.mes' to select all messages. | ||
| 2100 | * @example | ||
| 2101 | * buildMessagesFilter([1, 5]); // Returns '.mes[mesid="1"],.mes[mesid="5"]' | ||
| 2102 | * buildMessagesFilter([]); // Returns '.mes' | ||
| 2103 | */ | ||
| 2104 | function buildMessagesFilter(mesIds) { | ||
| 2105 | const allMessages = '.mes'; | ||
| 2106 | |||
| 2107 | if (!mesIds) { | ||
| 2108 | return allMessages; // If no mesIds provided, select all messages | ||
| 2109 | } | ||
| 2110 | |||
| 2111 | const mesIdsArray = Array.isArray(mesIds) ? mesIds : [mesIds]; | ||
| 2112 | |||
| 2113 | if (mesIdsArray?.length) { | ||
| 2114 | // Create a valid jQuery selector for multiple attribute values. | ||
| 2115 | // Example output: '.mes[mesid="1"],.mes[mesid="5"]' | ||
| 2116 | return mesIdsArray.map(id => `.mes[mesid="${id}"]`).join(','); | ||
| 2117 | } | ||
| 2118 | |||
| 2119 | // If mesIds is empty, select all messages. | ||
| 2120 | return allMessages; | ||
| 2121 | } | ||
| 2122 | |||
| 2123 | /** | ||
| 2124 | * Helper function to apply all necessary data attributes to a DOM element. | ||
| 2125 | * @param {JQuery<HTMLElement>} $element - The jQuery object for the message div. | ||
| 2126 | * @param {object} tagData - An object containing tag information. | ||
| 2127 | * @param {string[]} tagData.tagNames - An array of tag names. | ||
| 2128 | * @param {string} tagData.joinedTagNames - A comma-separated string of tag names. | ||
| 2129 | */ | ||
| 2130 | function applyTags($element, tagData) { | ||
| 2131 | $element.attr('data-char-tags', tagData.joinedTagNames); | ||
| 2132 | tagData.tagNames.forEach(tagName => { | ||
| 2133 | const normalizedTagName = normalizeTagName(tagName); | ||
| 2134 | |||
| 2135 | if (!normalizedTagName) { | ||
| 2136 | return; // Skip empty tag names | ||
| 2137 | } | ||
| 2138 | |||
| 2139 | $element.attr(`data-char-tag-${normalizedTagName}`, ''); | ||
| 2140 | }); | ||
| 2141 | } | ||
| 2142 | |||
| 2143 | /** | ||
| 2144 | * Normalizes a tag name by trimming, converting spaces to hyphens, replacing accented characters, | ||
| 2145 | * removing special characters, and converting to lowercase. | ||
| 2146 | * @param {string} name The tag name to normalize. | ||
| 2147 | * @returns {string} The normalized tag name. | ||
| 2148 | */ | ||
| 2149 | function normalizeTagName(name) { | ||
| 2150 | if (!name?.trim()) { | ||
| 2151 | return ''; | ||
| 2152 | } | ||
| 2153 | |||
| 2154 | // Normalize the tag name by trimming, converting spaces to hyphens, replacing accented characters, removing special characters, and converting to lowercase | ||
| 2155 | return name.trim() | ||
| 2156 | .normalize('NFD') // Normalize accented characters | ||
| 2157 | .replace(/[\u0300-\u036f]/g, '') // Remove diacritical marks | ||
| 2158 | .replace(/[^a-zA-Z0-9\s_-]/g, '') // Remove special characters except spaces, underscores, and hyphens | ||
| 2159 | .replace(/[\s_]+/g, '-') // Replace spaces and underscores with hyphens | ||
| 2160 | .toLowerCase(); | ||
| 2161 | } | ||
| 2162 | |||
| 2163 | /** Extracts the character avatar file name from the avatar source URL. | ||
| 2164 | * @param {string} avatarSrc The source URL of the character avatar. | ||
| 2165 | * @returns {string|null} The normalized avatar file name, or null if the input is falsy or doesn't contain a valid file name. | ||
| 2166 | */ | ||
| 2167 | function extractCharacterAvatar(avatarSrc) { | ||
| 2168 | if (!avatarSrc) { | ||
| 2169 | return null; | ||
| 2170 | } | ||
| 2171 | |||
| 2172 | try { | ||
| 2173 | const url = new URL(avatarSrc, window.location.origin); | ||
| 2174 | return url?.searchParams.get('file'); | ||
| 2175 | } catch (error) { | ||
| 2176 | console.error('Unable to parse character avatar using avatarSrc', avatarSrc, error); | ||
| 2177 | return null; | ||
| 2178 | } | ||
| 2179 | } | ||
| 2180 | |||
| 2005 | export function initTags() { | 2181 | export function initTags() { |
| 2006 | createTagInput('#tagInput', '#tagList', { tagOptions: { removable: true } }); | 2182 | createTagInput('#tagInput', '#tagList', { tagOptions: { removable: true } }); |
| 2007 | createTagInput('#groupTagInput', '#groupTagList', { tagOptions: { removable: true } }); | 2183 | createTagInput('#groupTagInput', '#groupTagList', { tagOptions: { removable: true } }); |