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 | import { generateWebLlmChatPrompt, isWebLlmSupported } from '../shared.js'; | 17 | import { generateWebLlmChatPrompt, isWebLlmSupported } from '../shared.js'; |
| 18 | export { MODULE_NAME }; | 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 | const MODULE_NAME = 'expressions'; | 36 | const MODULE_NAME = 'expressions'; |
| 21 | const UPDATE_INTERVAL = 2000; | 37 | const UPDATE_INTERVAL = 2000; |
| 22 | const STREAMING_UPDATE_INTERVAL = 10000; | 38 | const STREAMING_UPDATE_INTERVAL = 10000; |
| @@ -62,6 +78,9 @@ const EXPRESSION_API = { | |||
| 62 | webllm: 3, | 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 | let expressionsList = null; | 84 | let expressionsList = null; |
| 66 | let lastCharacter = undefined; | 85 | let lastCharacter = undefined; |
| 67 | let lastMessage = null; | 86 | let lastMessage = null; |
| @@ -1309,8 +1328,35 @@ async function validateImages(character, forceRedrawCached) { | |||
| 1309 | spriteCache[character] = validExpressions; | 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 | async function drawSpritesList(character, labels, sprites) { | 1356 | async 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 | } |
| 1323 | 1369 | ||
| 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 |
| 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 | $('#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 | } |
| 1340 | 1398 | ||
| 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 name | 1401 | * @param {string} expression Expression name |
| 1344 | * @param {string} imageSrc Path to image | 1402 | * @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 user | 1404 | * @param {boolean} [args.isCustom=false] If expression is added by user |
| 1347 | * @returns {Promise<string>} Rendered list item template | 1405 | * @returns {Promise<string>} Rendered list item template |
| 1348 | */ | 1406 | */ |
| 1349 | async function getListItem(item, imageSrc, textClass, isCustom) { | 1407 | async 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 | } |
| 1352 | 1410 | ||
| 1353 | async function getSpritesList(name) { | 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 | <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}} | ||
| @@ -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 | } | ||
| 129 | 132 | ||
| 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 | } |
| 164 | 167 | ||
| 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 | } |
| 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 | color: red; | 186 | color: red; |
| 171 | } | 187 | } |
| 172 | 188 | ||
| @@ -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 | |||
| @@ -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 | }); |