From 7553efc30843275e728855111a3f1fb5ed37662d Mon Sep 17 00:00:00 2001
From: Cohee <18619528+Cohee1207@users.noreply.github.com>
Date: Thu, 14 Sep 2023 21:30:02 +0300
Subject: [PATCH] Custom char expressions
---
public/scripts/extensions.js | 5 +-
.../expressions/add-custom-expression.html | 14 ++
.../scripts/extensions/expressions/index.js | 174 ++++++++++++++----
.../extensions/expressions/list-item.html | 7 +-
.../expressions/remove-custom-expression.html | 7 +
.../extensions/expressions/settings.html | 11 ++
.../scripts/extensions/expressions/style.css | 2 +
7 files changed, 184 insertions(+), 36 deletions(-)
create mode 100644 public/scripts/extensions/expressions/add-custom-expression.html
create mode 100644 public/scripts/extensions/expressions/remove-custom-expression.html
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:
+
+
+ -
+ The name must be unique and not already in use by the default expression.
+
+ -
+ The name must contain only letters, numbers, dashes and underscores. Don't include any file extensions.
+
+
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 {