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
| @@ -17,6 +17,22 @@ import { slashCommandReturnHelper } from '../../slash-commands/SlashCommandRetur | ||
| 17 | 17 | import { generateWebLlmChatPrompt, isWebLlmSupported } from '../shared.js'; |
| 18 | 18 | export { MODULE_NAME }; |
| 19 | 19 | |
| 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 | + | |
| 20 | 36 | const MODULE_NAME = 'expressions'; |
| 21 | 37 | const UPDATE_INTERVAL = 2000; |
| 22 | 38 | const STREAMING_UPDATE_INTERVAL = 10000; |
| @@ -62,6 +78,9 @@ const EXPRESSION_API = { | ||
| 62 | 78 | webllm: 3, |
| 63 | 79 | }; |
| 64 | 80 | |
| 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 | + | |
| 65 | 84 | let expressionsList = null; |
| 66 | 85 | let lastCharacter = undefined; |
| 67 | 86 | let lastMessage = null; |
| @@ -1309,8 +1328,35 @@ async function validateImages(character, forceRedrawCached) { | ||
| 1309 | 1328 | spriteCache[character] = validExpressions; |
| 1310 | 1329 | } |
| 1311 | 1330 | |
| 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 | + */ | |
| 1312 | 1356 | async function drawSpritesList(character, labels, sprites) { |
| 1357 | + /** @type {Expression[]} */ | |
| 1313 | 1358 | let validExpressions = []; |
| 1359 | + | |
| 1314 | 1360 | $('#no_chat_expressions').hide(); |
| 1315 | 1361 | $('#open_chat_expressions').show(); |
| 1316 | 1362 | $('#image_list').empty(); |
| @@ -1321,33 +1367,45 @@ async function drawSpritesList(character, labels, sprites) { | ||
| 1321 | 1367 | return []; |
| 1322 | 1368 | } |
| 1323 | 1369 | |
| 1324 | 1370 | 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 | |
| 1327 | - | |
| 1373 | + .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 | 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 | 1394 | $('#image_list').append(listItem); |
| 1336 | 1395 | } |
| 1337 | - } | |
| 1338 | 1396 | return validExpressions; |
| 1339 | 1397 | } |
| 1340 | 1398 | |
| 1341 | 1399 | /** |
| 1342 | 1400 | * Renders a list item template for the expressions list. |
| 1343 | 1401 | * @param {string} itemexpression Expression name |
| 1344 | 1402 | * @param {stringobject} imageSrc Pathargs toArguments imageobject |
| 1345 | - * @param {'success' | 'failure'} textClass 'success' or 'failure' | |
| 1403 | + * @param {ExpressionImage[]} [args.images] Array of image objects | |
| 1346 | 1404 | * @param {boolean} [args.isCustom=false] If expression is added by user |
| 1347 | 1405 | * @returns {Promise<string>} Rendered list item template |
| 1348 | 1406 | */ |
| 1349 | 1407 | async function getListItem(itemexpression, imageSrc,{ textClassimages, isCustom = false } = {}) { |
| 1350 | 1408 | return renderExtensionTemplateAsync(MODULE_NAME, 'list-item', { itemexpression, imageSrcimages, textClass,isCustom: isCustom ?? false }); |
| 1351 | 1409 | } |
| 1352 | 1410 | |
| 1353 | 1411 | async function getSpritesList(name) { |
| @@ -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 | 3 | <div class="expression_list_buttons"> |
| 3 | 4 | <div class="menu_button expression_list_upload" title="Upload image"> |
| 4 | 5 | <i class="fa-solid fa-upload"></i> |
| @@ -7,11 +8,14 @@ | ||
| 7 | 8 | <i class="fa-solid fa-trash"></i> |
| 8 | 9 | </div> |
| 9 | 10 | </div> |
| 10 | 11 | <div class="expression_list_title {{textClass}}"> |
| 11 | 12 | <span>{{item../expression}}</span> |
| 12 | 13 | {{#if ../isCustom}} |
| 13 | 14 | <small class="expression_list_custom">(custom)</small> |
| 14 | 15 | {{/if}} |
| 15 | 16 | </div> |
| 16 | 17 | <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}}" /> | |
| 17 | 19 | </div> |
| 20 | +</div> | |
| 21 | +{{/each}} | |
| @@ -126,6 +126,9 @@ img.expression.default { | ||
| 126 | 126 | flex-direction: column; |
| 127 | 127 | line-height: 1; |
| 128 | 128 | } |
| 129 | +.expression_list_custom { | |
| 130 | + font-size: 0.66rem; | |
| 131 | +} | |
| 129 | 132 | |
| 130 | 133 | .expression_list_buttons { |
| 131 | 134 | position: absolute; |
| @@ -162,11 +165,24 @@ img.expression.default { | ||
| 162 | 165 | row-gap: 1rem; |
| 163 | 166 | } |
| 164 | 167 | |
| 165 | 168 | #image_list .expression_list_item[data-expression-type="success"] .expression_list_title { |
| 166 | 169 | color: green; |
| 167 | 170 | } |
| 168 | 171 | |
| 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 | 186 | color: red; |
| 171 | 187 | } |
| 172 | 188 | |
| @@ -188,4 +204,3 @@ img.expression.default { | ||
| 188 | 204 | align-items: baseline; |
| 189 | 205 | flex-direction: row; |
| 190 | 206 | } |
| 191 | - | |
| @@ -125,8 +125,14 @@ router.get('/get', jsonParser, function (request, response) { | ||
| 125 | 125 | .map((file) => { |
| 126 | 126 | const pathToSprite = path.join(spritesPath, file); |
| 127 | 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 | 134 | return { |
| 129 | - label: path.parse(pathToSprite).name.toLowerCase(), | |
| 135 | + label: label, | |
| 130 | 136 | path: `/characters/${name}/${file}` + (mtime ? `?t=${mtime}` : ''), |
| 131 | 137 | }; |
| 132 | 138 | }); |