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 -25Showing whitespace changes
public/scripts/extensions/expressions/index.js+74 -16
@@ -17,6 +17,22 @@ import { slashCommandReturnHelper } from '../../slash-commands/SlashCommandRetur
17import { generateWebLlmChatPrompt, isWebLlmSupported } from '../shared.js';17import { generateWebLlmChatPrompt, isWebLlmSupported } from '../shared.js';
18export { MODULE_NAME };18export { 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
20const MODULE_NAME = 'expressions';36const MODULE_NAME = 'expressions';
21const UPDATE_INTERVAL = 2000;37const UPDATE_INTERVAL = 2000;
22const STREAMING_UPDATE_INTERVAL = 10000;38const STREAMING_UPDATE_INTERVAL = 10000;
@@ -62,6 +78,9 @@ const EXPRESSION_API = {
62 webllm: 3,78 webllm: 3,
63};79};
6480
81/** @type {ExpressionImage} */
82const NO_IMAGE_PLACEHOLDER = { title: 'No Image', type: 'failure', fileName: 'No-Image-Placeholder.svg', imageSrc: '/img/No-Image-Placeholder.svg' };
83
65let expressionsList = null;84let expressionsList = null;
66let lastCharacter = undefined;85let lastCharacter = undefined;
67let lastMessage = null;86let lastMessage = null;
@@ -1309,8 +1328,35 @@ async function validateImages(character, forceRedrawCached) {
1309 spriteCache[character] = validExpressions;1328 spriteCache[character] = validExpressions;
1310}1329}
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 */
1336function 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 */
1312async function drawSpritesList(character, labels, sprites) {1356async function drawSpritesList(character, labels, sprites) {
1357 /** @type {Expression[]} */
1313 let validExpressions = [];1358 let validExpressions = [];
1359
1314 $('#no_chat_expressions').hide();1360 $('#no_chat_expressions').hide();
1315 $('#open_chat_expressions').show();1361 $('#open_chat_expressions').show();
1316 $('#image_list').empty();1362 $('#image_list').empty();
@@ -1321,33 +1367,45 @@ async function drawSpritesList(character, labels, sprites) {
1321 return [];1367 return [];
1322 }1368 }
13231369
1324 for (const item of labels.sort()) {1370 for (const expression 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
13271373 .filter(s => s.label === expression)
1328 if (sprite) {1374 .map(getExpressionImageData)
1329 validExpressions.push(sprite);1375 .sort((a, b) => a.title.localeCompare(b.title));
1330 const listItem = await getListItem(item, sprite.path, 'success', isCustom);1376
1377 if (images.length === 0) {
1378 const listItem = await getListItem(expression, {
1379 isCustom,
1380 images: [{ expression, isCustom, ...NO_IMAGE_PLACEHOLDER }],
1381 });
1331 $('#image_list').append(listItem);1382 $('#image_list').append(listItem);
1383 continue;
1332 }1384 }
1333 else {1385
1334 const listItem = await getListItem(item, '/img/No-Image-Placeholder.svg', 'failure', isCustom);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 });
1335 $('#image_list').append(listItem);1394 $('#image_list').append(listItem);
1336 }1395 }
1337 }
1338 return validExpressions;1396 return validExpressions;
1339}1397}
13401398
1341/**1399/**
1342 * Renders a list item template for the expressions list.1400 * Renders a list item template for the expressions list.
1343 * @param {string} item Expression name1401 * @param {string} expression Expression name
1344 * @param {string} imageSrc Path to image1402 * @param {object} args Arguments object
1345 * @param {'success' | 'failure'} textClass 'success' or 'failure'1403 * @param {ExpressionImage[]} [args.images] Array of image objects
1346 * @param {boolean} isCustom If expression is added by user1404 * @param {boolean} [args.isCustom=false] If expression is added by user
1347 * @returns {Promise<string>} Rendered list item template1405 * @returns {Promise<string>} Rendered list item template
1348 */1406 */
1349async function getListItem(item, imageSrc, textClass, isCustom) {1407async function getListItem(expression, { images, isCustom = false } = {}) {
1350 return renderExtensionTemplateAsync(MODULE_NAME, 'list-item', { item, imageSrc, textClass, isCustom });1408 return renderExtensionTemplateAsync(MODULE_NAME, 'list-item', { expression, images, isCustom: isCustom ?? false });
1351}1409}
13521410
1353async function getSpritesList(name) {1411async 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}}">
2 <div class="expression_list_buttons">3 <div class="expression_list_buttons">
3 <div class="menu_button expression_list_upload" title="Upload image">4 <div class="menu_button expression_list_upload" title="Upload image">
4 <i class="fa-solid fa-upload"></i>5 <i class="fa-solid fa-upload"></i>
@@ -7,11 +8,14 @@
7 <i class="fa-solid fa-trash"></i>8 <i class="fa-solid fa-trash"></i>
8 </div>9 </div>
9 </div>10 </div>
10 <div class="expression_list_title {{textClass}}">11 <div class="expression_list_title">
11 <span>{{item}}</span>12 <span>{{../expression}}</span>
12 {{#if isCustom}}13 {{#if ../isCustom}}
13 <small class="expression_list_custom">(custom)</small>14 <small class="expression_list_custom">(custom)</small>
14 {{/if}}15 {{/if}}
15 </div>16 </div>
16 <img class="expression_list_image" src="{{imageSrc}}" />17 <div class="expression_list_image_container" title="{{this.title}}">
18 <img class="expression_list_image" src="{{this.imageSrc}}" alt="{{this.title}}" data-epression="{{../expression}}" />
17 </div>19 </div>
20</div>
21{{/each}}
public/scripts/extensions/expressions/style.css+18 -3
@@ -126,6 +126,9 @@ img.expression.default {
126 flex-direction: column;126 flex-direction: column;
127 line-height: 1;127 line-height: 1;
128}128}
129.expression_list_custom {
130 font-size: 0.66rem;
131}
129132
130.expression_list_buttons {133.expression_list_buttons {
131 position: absolute;134 position: absolute;
@@ -162,11 +165,24 @@ img.expression.default {
162 row-gap: 1rem;165 row-gap: 1rem;
163}166}
164167
165#image_list .success {168#image_list .expression_list_item[data-expression-type="success"] .expression_list_title {
166 color: green;169 color: green;
167}170}
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 {
170 color: red;186 color: red;
171}187}
172188
@@ -188,4 +204,3 @@ img.expression.default {
188 align-items: baseline;204 align-items: baseline;
189 flex-direction: row;205 flex-direction: row;
190}206}
191
src/endpoints/sprites.js+7 -1
@@ -125,8 +125,14 @@ router.get('/get', jsonParser, function (request, response) {
125 .map((file) => {125 .map((file) => {
126 const pathToSprite = path.join(spritesPath, file);126 const pathToSprite = path.join(spritesPath, file);
127 const mtime = fs.statSync(pathToSprite).mtime?.toISOString().replace(/[^0-9]/g, '').slice(0, 14);127 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
128 return {134 return {
129 label: path.parse(pathToSprite).name.toLowerCase(),135 label: label,
130 path: `/characters/${name}/${file}` + (mtime ? `?t=${mtime}` : ''),136 path: `/characters/${name}/${file}` + (mtime ? `?t=${mtime}` : ''),
131 };137 };
132 });138 });