Display additional images in expression list - Update Expressions List to display additional images per expression - Make additional images appear visually distinct - Fix small issues with custom labels not always being shown - Add tooltip on all expression images - Modify /api/sprites/get endpoint to correctly parse the label from filenames that might be additional files

126e4fa6983caee3798e7ba843a5e26a14fe52e0

Wolfsblvt <wolfsblvt@gmail.com>

4 files changed, +108 -25Ignore whitespace
public/scripts/extensions/expressions/index.js+74 -16
@@ -17,6 +17,22 @@ import { slashCommandReturnHelper } from '../../slash-commands/SlashCommandRetur
1717import { generateWebLlmChatPrompt, isWebLlmSupported } from '../shared.js';
1818export { MODULE_NAME };
1919
20+/**
21+ * @typedef {object} Expression Expression definition with label and file path
22+ * @property {string} label The label of the expression
23+ * @property {string} path The path to the expression image
24+ */
25+
26+/**
27+ * @typedef {object} ExpressionImage An expression image
28+ * @property {string?} [expression=null] - The expression
29+ * @property {boolean?} [isCustom=null] - If the expression is added by user
30+ * @property {string} fileName - The filename with extension
31+ * @property {string} title - The title for the image
32+ * @property {string} imageSrc - The image source / full path
33+ * @property {'success' | 'additional' | 'failure'} type - The type of the image
34+ */
35+
2036const MODULE_NAME = 'expressions';
2137const UPDATE_INTERVAL = 2000;
2238const STREAMING_UPDATE_INTERVAL = 10000;
@@ -62,6 +78,9 @@ const EXPRESSION_API = {
6278 webllm: 3,
6379};
6480
81+/** @type {ExpressionImage} */
82+const NO_IMAGE_PLACEHOLDER = { title: 'No Image', type: 'failure', fileName: 'No-Image-Placeholder.svg', imageSrc: '/img/No-Image-Placeholder.svg' };
83+
6584let expressionsList = null;
6685let lastCharacter = undefined;
6786let lastMessage = null;
@@ -1309,8 +1328,35 @@ async function validateImages(character, forceRedrawCached) {
13091328 spriteCache[character] = validExpressions;
13101329}
13111330
1331+/**
1332+ * Takes a given sprite as returned from the server, and enriches it with additional data for display/sorting
1333+ * @param {Expression} sprite
1334+ * @returns {ExpressionImage}
1335+ */
1336+function getExpressionImageData(sprite) {
1337+ const fileName = sprite.path.split('/').pop().split('?')[0];
1338+ const fileNameWithoutExtension = fileName.replace(/\.[^/.]+$/, '');
1339+ return {
1340+ expression: sprite.label,
1341+ fileName: fileName,
1342+ title: fileNameWithoutExtension,
1343+ imageSrc: sprite.path,
1344+ type: fileNameWithoutExtension == sprite.label ? 'success' : 'additional',
1345+ isCustom: extension_settings.expressions.custom?.includes(sprite.label),
1346+ };
1347+}
1348+
1349+/**
1350+ * Populate the character expression list with sprites for the given character.
1351+ * @param {string} character - The name of the character to populate the list for
1352+ * @param {string[]} labels - An array of expression labels that are valid
1353+ * @param {Expression[]} sprites - An array of sprites
1354+ * @returns {Promise<Expression[]>} An array of valid expression labels
1355+ */
13121356async function drawSpritesList(character, labels, sprites) {
1357+ /** @type {Expression[]} */
13131358 let validExpressions = [];
1359+
13141360 $('#no_chat_expressions').hide();
13151361 $('#open_chat_expressions').show();
13161362 $('#image_list').empty();
@@ -1321,33 +1367,45 @@ async function drawSpritesList(character, labels, sprites) {
13211367 return [];
13221368 }
13231369
13241370 for (const itemexpression of labels.sort()) {
1325- const sprite = sprites.find(x => x.label == item);
1371+ const isCustom = extension_settings.expressions.custom?.includes(expression);
1326- const isCustom = extension_settings.expressions.custom.includes(item);
1372+ const images = sprites
1373+ .filter(s => s.label === expression)
1374+ .map(getExpressionImageData)
1375+ .sort((a, b) => a.title.localeCompare(b.title));
13271376
13281377 if (spriteimages.length === 0) {
1329- validExpressions.push(sprite);
1378+ const listItem = await getListItem(expression, {
1330- const listItem = await getListItem(item, sprite.path, 'success', isCustom);
1379+ isCustom,
1331- $('#image_list').append(listItem);
1380+ images: [{ expression, isCustom, ...NO_IMAGE_PLACEHOLDER }],
1332- }
1381+ });
1333- else {
1334- const listItem = await getListItem(item, '/img/No-Image-Placeholder.svg', 'failure', isCustom);
13351382 $('#image_list').append(listItem);
1383+ continue;
13361384 }
1385+
1386+ // TODO: Fix valid expression lists/caching and group them correctly
1387+ validExpressions.push({ label: expression, paths: images });
1388+
1389+ // Render main = first file, additional = rest
1390+ let listItem = await getListItem(expression, {
1391+ isCustom,
1392+ images,
1393+ });
1394+ $('#image_list').append(listItem);
13371395 }
13381396 return validExpressions;
13391397}
13401398
13411399/**
13421400 * Renders a list item template for the expressions list.
13431401 * @param {string} itemexpression Expression name
13441402 * @param {stringobject} imageSrc Pathargs toArguments imageobject
1345- * @param {'success' | 'failure'} textClass 'success' or 'failure'
1403+ * @param {ExpressionImage[]} [args.images] Array of image objects
13461404 * @param {boolean} [args.isCustom=false] If expression is added by user
13471405 * @returns {Promise<string>} Rendered list item template
13481406 */
13491407async function getListItem(itemexpression, imageSrc,{ textClassimages, isCustom = false } = {}) {
13501408 return renderExtensionTemplateAsync(MODULE_NAME, 'list-item', { itemexpression, imageSrcimages, textClass,isCustom: isCustom ?? false });
13511409}
13521410
13531411async function getSpritesList(name) {
public/scripts/extensions/expressions/list-item.html+9 -5
@@ -1,4 +1,5 @@
1-<div id="{{item}}" class="expression_list_item">
1+{{#each images}}
2+<div class="expression_list_item" data-epression="{{../expression}}" data-expression-type="{{this.type}}" data-filename="{{this.fileName}}">
23 <div class="expression_list_buttons">
34 <div class="menu_button expression_list_upload" title="Upload image">
45 <i class="fa-solid fa-upload"></i>
@@ -7,11 +8,14 @@
78 <i class="fa-solid fa-trash"></i>
89 </div>
910 </div>
1011 <div class="expression_list_title {{textClass}}">
1112 <span>{{item../expression}}</span>
1213 {{#if ../isCustom}}
1314 <small class="expression_list_custom">(custom)</small>
1415 {{/if}}
1516 </div>
1617 <imgdiv class="expression_list_imageexpression_list_image_container" srctitle="{{imageSrcthis.title}}" />
18+ <img class="expression_list_image" src="{{this.imageSrc}}" alt="{{this.title}}" data-epression="{{../expression}}" />
19+ </div>
1720</div>
21+{{/each}}
public/scripts/extensions/expressions/style.css+18 -3
@@ -126,6 +126,9 @@ img.expression.default {
126126 flex-direction: column;
127127 line-height: 1;
128128}
129+.expression_list_custom {
130+ font-size: 0.66rem;
131+}
129132
130133.expression_list_buttons {
131134 position: absolute;
@@ -162,11 +165,24 @@ img.expression.default {
162165 row-gap: 1rem;
163166}
164167
165168#image_list .expression_list_item[data-expression-type="success"] .expression_list_title {
166169 color: green;
167170}
168171
169-#image_list .failure {
172+#image_list .expression_list_item[data-expression-type="additional"] .expression_list_title {
173+ color: darkolivegreen;
174+}
175+#image_list .expression_list_item[data-expression-type="additional"] .expression_list_title::before {
176+ content: '➕';
177+ position: absolute;
178+ top: -7px;
179+ left: -9px;
180+ font-size: 14px;
181+ color: transparent;
182+ text-shadow: 0 0 0 darkolivegreen;
183+}
184+
185+#image_list .expression_list_item[data-expression-type="failure"] .expression_list_title {
170186 color: red;
171187}
172188
@@ -188,4 +204,3 @@ img.expression.default {
188204 align-items: baseline;
189205 flex-direction: row;
190206}
191-
src/endpoints/sprites.js+7 -1
@@ -125,8 +125,14 @@ router.get('/get', jsonParser, function (request, response) {
125125 .map((file) => {
126126 const pathToSprite = path.join(spritesPath, file);
127127 const mtime = fs.statSync(pathToSprite).mtime?.toISOString().replace(/[^0-9]/g, '').slice(0, 14);
128+
129+ const fileName = path.parse(pathToSprite).name.toLowerCase();
130+ // Extract the label from the filename via regex, which can be suffixed with a sub-name, either connected with a dash or a dot.
131+ // Examples: joy.png, joy-1.png, joy.expressive.png, 美しい-17.png
132+ const label = fileName.match(/^(.+?)(?:[-\\.].*?)?$/)?.[1] ?? fileName;
133+
128134 return {
129- label: path.parse(pathToSprite).name.toLowerCase(),
135+ label: label,
130136 path: `/characters/${name}/${file}` + (mtime ? `?t=${mtime}` : ''),
131137 };
132138 });