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
This commit is contained in:
Wolfsblvt
2025-01-08 01:20:25 +01:00
parent d7b3a56c3d
commit 126e4fa698
4 changed files with 108 additions and 25 deletions

View File

@ -17,6 +17,22 @@ import { slashCommandReturnHelper } from '../../slash-commands/SlashCommandRetur
import { generateWebLlmChatPrompt, isWebLlmSupported } from '../shared.js'; import { generateWebLlmChatPrompt, isWebLlmSupported } from '../shared.js';
export { MODULE_NAME }; export { MODULE_NAME };
/**
* @typedef {object} Expression Expression definition with label and file path
* @property {string} label The label of the expression
* @property {string} path The path to the expression image
*/
/**
* @typedef {object} ExpressionImage An expression image
* @property {string?} [expression=null] - The expression
* @property {boolean?} [isCustom=null] - If the expression is added by user
* @property {string} fileName - The filename with extension
* @property {string} title - The title for the image
* @property {string} imageSrc - The image source / full path
* @property {'success' | 'additional' | 'failure'} type - The type of the image
*/
const MODULE_NAME = 'expressions'; const MODULE_NAME = 'expressions';
const UPDATE_INTERVAL = 2000; const UPDATE_INTERVAL = 2000;
const STREAMING_UPDATE_INTERVAL = 10000; const STREAMING_UPDATE_INTERVAL = 10000;
@ -62,6 +78,9 @@ const EXPRESSION_API = {
webllm: 3, webllm: 3,
}; };
/** @type {ExpressionImage} */
const NO_IMAGE_PLACEHOLDER = { title: 'No Image', type: 'failure', fileName: 'No-Image-Placeholder.svg', imageSrc: '/img/No-Image-Placeholder.svg' };
let expressionsList = null; let expressionsList = null;
let lastCharacter = undefined; let lastCharacter = undefined;
let lastMessage = null; let lastMessage = null;
@ -1309,8 +1328,35 @@ async function validateImages(character, forceRedrawCached) {
spriteCache[character] = validExpressions; spriteCache[character] = validExpressions;
} }
/**
* Takes a given sprite as returned from the server, and enriches it with additional data for display/sorting
* @param {Expression} sprite
* @returns {ExpressionImage}
*/
function getExpressionImageData(sprite) {
const fileName = sprite.path.split('/').pop().split('?')[0];
const fileNameWithoutExtension = fileName.replace(/\.[^/.]+$/, '');
return {
expression: sprite.label,
fileName: fileName,
title: fileNameWithoutExtension,
imageSrc: sprite.path,
type: fileNameWithoutExtension == sprite.label ? 'success' : 'additional',
isCustom: extension_settings.expressions.custom?.includes(sprite.label),
};
}
/**
* Populate the character expression list with sprites for the given character.
* @param {string} character - The name of the character to populate the list for
* @param {string[]} labels - An array of expression labels that are valid
* @param {Expression[]} sprites - An array of sprites
* @returns {Promise<Expression[]>} An array of valid expression labels
*/
async function drawSpritesList(character, labels, sprites) { async function drawSpritesList(character, labels, sprites) {
/** @type {Expression[]} */
let validExpressions = []; let validExpressions = [];
$('#no_chat_expressions').hide(); $('#no_chat_expressions').hide();
$('#open_chat_expressions').show(); $('#open_chat_expressions').show();
$('#image_list').empty(); $('#image_list').empty();
@ -1321,33 +1367,45 @@ async function drawSpritesList(character, labels, sprites) {
return []; return [];
} }
for (const item of labels.sort()) { for (const expression of labels.sort()) {
const sprite = sprites.find(x => x.label == item); const isCustom = extension_settings.expressions.custom?.includes(expression);
const isCustom = extension_settings.expressions.custom.includes(item); const images = sprites
.filter(s => s.label === expression)
.map(getExpressionImageData)
.sort((a, b) => a.title.localeCompare(b.title));
if (sprite) { if (images.length === 0) {
validExpressions.push(sprite); const listItem = await getListItem(expression, {
const listItem = await getListItem(item, sprite.path, 'success', isCustom); isCustom,
images: [{ expression, isCustom, ...NO_IMAGE_PLACEHOLDER }],
});
$('#image_list').append(listItem); $('#image_list').append(listItem);
continue;
} }
else {
const listItem = await getListItem(item, '/img/No-Image-Placeholder.svg', 'failure', isCustom); // TODO: Fix valid expression lists/caching and group them correctly
validExpressions.push({ label: expression, paths: images });
// Render main = first file, additional = rest
let listItem = await getListItem(expression, {
isCustom,
images,
});
$('#image_list').append(listItem); $('#image_list').append(listItem);
} }
}
return validExpressions; return validExpressions;
} }
/** /**
* Renders a list item template for the expressions list. * Renders a list item template for the expressions list.
* @param {string} item Expression name * @param {string} expression Expression name
* @param {string} imageSrc Path to image * @param {object} args Arguments object
* @param {'success' | 'failure'} textClass 'success' or 'failure' * @param {ExpressionImage[]} [args.images] Array of image objects
* @param {boolean} isCustom If expression is added by user * @param {boolean} [args.isCustom=false] If expression is added by user
* @returns {Promise<string>} Rendered list item template * @returns {Promise<string>} Rendered list item template
*/ */
async function getListItem(item, imageSrc, textClass, isCustom) { async function getListItem(expression, { images, isCustom = false } = {}) {
return renderExtensionTemplateAsync(MODULE_NAME, 'list-item', { item, imageSrc, textClass, isCustom }); return renderExtensionTemplateAsync(MODULE_NAME, 'list-item', { expression, images, isCustom: isCustom ?? false });
} }
async function getSpritesList(name) { async function getSpritesList(name) {

View File

@ -1,4 +1,5 @@
<div id="{{item}}" class="expression_list_item"> {{#each images}}
<div class="expression_list_item" data-epression="{{../expression}}" data-expression-type="{{this.type}}" data-filename="{{this.fileName}}">
<div class="expression_list_buttons"> <div class="expression_list_buttons">
<div class="menu_button expression_list_upload" title="Upload image"> <div class="menu_button expression_list_upload" title="Upload image">
<i class="fa-solid fa-upload"></i> <i class="fa-solid fa-upload"></i>
@ -7,11 +8,14 @@
<i class="fa-solid fa-trash"></i> <i class="fa-solid fa-trash"></i>
</div> </div>
</div> </div>
<div class="expression_list_title {{textClass}}"> <div class="expression_list_title">
<span>{{item}}</span> <span>{{../expression}}</span>
{{#if isCustom}} {{#if ../isCustom}}
<small class="expression_list_custom">(custom)</small> <small class="expression_list_custom">(custom)</small>
{{/if}} {{/if}}
</div> </div>
<img class="expression_list_image" src="{{imageSrc}}" /> <div class="expression_list_image_container" title="{{this.title}}">
<img class="expression_list_image" src="{{this.imageSrc}}" alt="{{this.title}}" data-epression="{{../expression}}" />
</div>
</div> </div>
{{/each}}

View File

@ -126,6 +126,9 @@ img.expression.default {
flex-direction: column; flex-direction: column;
line-height: 1; line-height: 1;
} }
.expression_list_custom {
font-size: 0.66rem;
}
.expression_list_buttons { .expression_list_buttons {
position: absolute; position: absolute;
@ -162,11 +165,24 @@ img.expression.default {
row-gap: 1rem; row-gap: 1rem;
} }
#image_list .success { #image_list .expression_list_item[data-expression-type="success"] .expression_list_title {
color: green; color: green;
} }
#image_list .failure { #image_list .expression_list_item[data-expression-type="additional"] .expression_list_title {
color: darkolivegreen;
}
#image_list .expression_list_item[data-expression-type="additional"] .expression_list_title::before {
content: '';
position: absolute;
top: -7px;
left: -9px;
font-size: 14px;
color: transparent;
text-shadow: 0 0 0 darkolivegreen;
}
#image_list .expression_list_item[data-expression-type="failure"] .expression_list_title {
color: red; color: red;
} }
@ -188,4 +204,3 @@ img.expression.default {
align-items: baseline; align-items: baseline;
flex-direction: row; flex-direction: row;
} }

View File

@ -125,8 +125,14 @@ router.get('/get', jsonParser, function (request, response) {
.map((file) => { .map((file) => {
const pathToSprite = path.join(spritesPath, file); const pathToSprite = path.join(spritesPath, file);
const mtime = fs.statSync(pathToSprite).mtime?.toISOString().replace(/[^0-9]/g, '').slice(0, 14); const mtime = fs.statSync(pathToSprite).mtime?.toISOString().replace(/[^0-9]/g, '').slice(0, 14);
const fileName = path.parse(pathToSprite).name.toLowerCase();
// Extract the label from the filename via regex, which can be suffixed with a sub-name, either connected with a dash or a dot.
// Examples: joy.png, joy-1.png, joy.expressive.png, 美しい-17.png
const label = fileName.match(/^(.+?)(?:[-\\.].*?)?$/)?.[1] ?? fileName;
return { return {
label: path.parse(pathToSprite).name.toLowerCase(), label: label,
path: `/characters/${name}/${file}` + (mtime ? `?t=${mtime}` : ''), path: `/characters/${name}/${file}` + (mtime ? `?t=${mtime}` : ''),
}; };
}); });