diff --git a/public/scripts/extensions.js b/public/scripts/extensions.js index 5e633f17b..69e59e69d 100644 --- a/public/scripts/extensions.js +++ b/public/scripts/extensions.js @@ -134,7 +134,10 @@ const extension_settings = { caption: { refine_mode: false, }, - expressions: {}, + expressions: { + /** @type {string[]} */ + custom: [], + }, dice: {}, regex: [], tts: {}, diff --git a/public/scripts/extensions/expressions/add-custom-expression.html b/public/scripts/extensions/expressions/add-custom-expression.html new file mode 100644 index 000000000..cc246bc39 --- /dev/null +++ b/public/scripts/extensions/expressions/add-custom-expression.html @@ -0,0 +1,14 @@ +

+ Enter a name for the custom expression: +

+

+ Requirements: +

+
    +
  1. + The name must be unique and not already in use by the default expression. +
  2. +
  3. + The name must contain only letters, numbers, dashes and underscores. Don't include any file extensions. +
  4. +
diff --git a/public/scripts/extensions/expressions/index.js b/public/scripts/extensions/expressions/index.js index 0363a3ae4..1b8ca081f 100644 --- a/public/scripts/extensions/expressions/index.js +++ b/public/scripts/extensions/expressions/index.js @@ -887,20 +887,29 @@ function drawSpritesList(character, labels, sprites) { labels.sort().forEach((item) => { const sprite = sprites.find(x => x.label == item); + const isCustom = extension_settings.expressions.custom.includes(item); if (sprite) { validExpressions.push(sprite); - $('#image_list').append(getListItem(item, sprite.path, 'success')); + $('#image_list').append(getListItem(item, sprite.path, 'success', isCustom)); } else { - $('#image_list').append(getListItem(item, '/img/No-Image-Placeholder.svg', 'failure')); + $('#image_list').append(getListItem(item, '/img/No-Image-Placeholder.svg', 'failure', isCustom)); } }); return validExpressions; } -function getListItem(item, imageSrc, textClass) { - return renderExtensionTemplate(MODULE_NAME, 'list-item', { item, imageSrc, textClass }); +/** + * Renders a list item template for the expressions list. + * @param {string} item Expression name + * @param {string} imageSrc Path to image + * @param {'success' | 'failure'} textClass 'success' or 'failure' + * @param {boolean} isCustom If expression is added by user + * @returns {string} Rendered list item template + */ +function getListItem(item, imageSrc, textClass, isCustom) { + return renderExtensionTemplate(MODULE_NAME, 'list-item', { item, imageSrc, textClass, isCustom }); } async function getSpritesList(name) { @@ -917,50 +926,76 @@ async function getSpritesList(name) { } } -async function getExpressionsList() { - // get something for offline mode (default images) - if (!modules.includes('classify') && !extension_settings.expressions.local) { - return DEFAULT_EXPRESSIONS; +function renderCustomExpressions() { + if (!Array.isArray(extension_settings.expressions.custom)) { + extension_settings.expressions.custom = []; } + const customExpressions = extension_settings.expressions.custom.sort((a, b) => a.localeCompare(b)); + $('#expression_custom').empty(); + + for (const expression of customExpressions) { + const option = document.createElement('option'); + option.value = expression; + option.text = expression; + $('#expression_custom').append(option); + } +} + +async function getExpressionsList() { + // Return cached list if available if (Array.isArray(expressionsList)) { return expressionsList; } + /** + * Returns the list of expressions from the API or fallback in offline mode. + * @returns {Promise} + */ + async function resolveExpressionsList() { + // get something for offline mode (default images) + if (!modules.includes('classify') && !extension_settings.expressions.local) { + return DEFAULT_EXPRESSIONS; + } - try { - if (extension_settings.expressions.local) { - const apiResult = await fetch('/api/extra/classify/labels', { - method: 'POST', - headers: getRequestHeaders(), - }); + try { + if (extension_settings.expressions.local) { + const apiResult = await fetch('/api/extra/classify/labels', { + method: 'POST', + headers: getRequestHeaders(), + }); - if (apiResult.ok) { - const data = await apiResult.json(); - expressionsList = data.labels; - return expressionsList; - } - } else { - const url = new URL(getApiUrl()); - url.pathname = '/api/classify/labels'; + if (apiResult.ok) { + const data = await apiResult.json(); + expressionsList = data.labels; + return expressionsList; + } + } else { + const url = new URL(getApiUrl()); + url.pathname = '/api/classify/labels'; - const apiResult = await doExtrasFetch(url, { - method: 'GET', - headers: { 'Bypass-Tunnel-Reminder': 'bypass' }, - }); + const apiResult = await doExtrasFetch(url, { + method: 'GET', + headers: { 'Bypass-Tunnel-Reminder': 'bypass' }, + }); - if (apiResult.ok) { + if (apiResult.ok) { - const data = await apiResult.json(); - expressionsList = data.labels; - return expressionsList; + const data = await apiResult.json(); + expressionsList = data.labels; + return expressionsList; + } } } + catch (error) { + console.log(error); + return []; + } } - catch (error) { - console.log(error); - return []; - } + + const result = await resolveExpressionsList(); + result.push(...extension_settings.expressions.custom); + return result; } async function setExpression(character, expression, force) { @@ -1101,6 +1136,72 @@ function onClickExpressionImage() { setSpriteSlashCommand({}, expression); } +async function onClickExpressionAddCustom() { + let expressionName = await callPopup(renderExtensionTemplate(MODULE_NAME, 'add-custom-expression'), 'input'); + + if (!expressionName) { + console.debug('No custom expression name provided'); + return; + } + + expressionName = expressionName.trim().toLowerCase(); + + // a-z, 0-9, dashes and underscores only + if (!/^[a-z0-9-_]+$/.test(expressionName)) { + toastr.info('Invalid custom expression name provided'); + return; + } + + // Check if expression name already exists in default expressions + if (DEFAULT_EXPRESSIONS.includes(expressionName)) { + toastr.info('Expression name already exists'); + return; + } + + // Check if expression name already exists in custom expressions + if (extension_settings.expressions.custom.includes(expressionName)) { + toastr.info('Custom expression already exists'); + return; + } + + // Add custom expression into settings + extension_settings.expressions.custom.push(expressionName); + renderCustomExpressions(); + saveSettingsDebounced(); + + // Force refresh sprites list + expressionsList = null; + spriteCache = {}; + moduleWorker(); +} + +async function onClickExpressionRemoveCustom() { + const selectedExpression = $('#expression_custom').val(); + + if (!selectedExpression) { + console.debug('No custom expression selected'); + return; + } + + const confirmation = await callPopup(renderExtensionTemplate(MODULE_NAME, 'remove-custom-expression', { expression: selectedExpression }), 'confirm'); + + if (!confirmation) { + console.debug('Custom expression removal cancelled'); + return; + } + + // Remove custom expression from settings + const index = extension_settings.expressions.custom.indexOf(selectedExpression); + extension_settings.expressions.custom.splice(index, 1); + renderCustomExpressions(); + saveSettingsDebounced(); + + // Force refresh sprites list + expressionsList = null; + spriteCache = {}; + moduleWorker(); +} + async function handleFileUpload(url, formData) { try { const data = await jQuery.ajax({ @@ -1359,6 +1460,11 @@ function setExpressionOverrideHtml(forceClear = false) { setTalkingHeadState(this.checked); } }); + + renderCustomExpressions(); + + $('#expression_custom_add').on('click', onClickExpressionAddCustom); + $('#expression_custom_remove').on('click', onClickExpressionRemoveCustom); } addExpressionImage(); diff --git a/public/scripts/extensions/expressions/list-item.html b/public/scripts/extensions/expressions/list-item.html index ecd2d14b8..aaeec5cec 100644 --- a/public/scripts/extensions/expressions/list-item.html +++ b/public/scripts/extensions/expressions/list-item.html @@ -7,6 +7,11 @@ - {{item}} +
+ {{item}} + {{#if isCustom}} + (custom) + {{/if}} +
diff --git a/public/scripts/extensions/expressions/remove-custom-expression.html b/public/scripts/extensions/expressions/remove-custom-expression.html new file mode 100644 index 000000000..73841cce3 --- /dev/null +++ b/public/scripts/extensions/expressions/remove-custom-expression.html @@ -0,0 +1,7 @@ +

+ Are you sure you want to remove the expression "{{expression}}"? +

+
+ Uploaded images will not be deleted, but will no longer be used by the extension. +
+
diff --git a/public/scripts/extensions/expressions/settings.html b/public/scripts/extensions/expressions/settings.html index f52f415fc..31d395f99 100644 --- a/public/scripts/extensions/expressions/settings.html +++ b/public/scripts/extensions/expressions/settings.html @@ -18,6 +18,15 @@ Image Type - talkinghead (extras) +
+ + Can be set manually or with an /emote slash command. +
+ + + +
+
Open a chat to see the character expressions.
@@ -25,6 +34,8 @@
You are in offline mode. Click on the image below to set the expression.
+ + Use a forward slash to specify a subfolder. Example: Bob/formal
diff --git a/public/scripts/extensions/expressions/style.css b/public/scripts/extensions/expressions/style.css index d29ec2767..27ed3839e 100644 --- a/public/scripts/extensions/expressions/style.css +++ b/public/scripts/extensions/expressions/style.css @@ -123,6 +123,8 @@ img.expression.default { display: flex; justify-content: center; align-items: center; + flex-direction: column; + line-height: 1; } .expression_list_buttons {