mirror of
https://github.com/SillyTavern/SillyTavern.git
synced 2025-06-05 21:59:27 +02:00
Absolute positioning was causing an issue on both mobile and PC in terms of expression image sizing. Dynamically set image width and height along with fixing absolute position anchors. This is not a fix for groups. Signed-off-by: kingbri <bdashore3@proton.me>
1109 lines
38 KiB
JavaScript
1109 lines
38 KiB
JavaScript
import { callPopup, eventSource, event_types, getRequestHeaders, saveSettingsDebounced } from "../../../script.js";
|
|
import { dragElement, isMobile } from "../../RossAscends-mods.js";
|
|
import { getContext, getApiUrl, modules, extension_settings, ModuleWorkerWrapper, doExtrasFetch } from "../../extensions.js";
|
|
import { loadMovingUIState, power_user } from "../../power-user.js";
|
|
import { onlyUnique, debounce, getCharaFilename } from "../../utils.js";
|
|
export { MODULE_NAME };
|
|
|
|
const MODULE_NAME = 'expressions';
|
|
const UPDATE_INTERVAL = 2000;
|
|
const FALLBACK_EXPRESSION = 'joy';
|
|
const DEFAULT_EXPRESSIONS = [
|
|
"admiration",
|
|
"amusement",
|
|
"anger",
|
|
"annoyance",
|
|
"approval",
|
|
"caring",
|
|
"confusion",
|
|
"curiosity",
|
|
"desire",
|
|
"disappointment",
|
|
"disapproval",
|
|
"disgust",
|
|
"embarrassment",
|
|
"excitement",
|
|
"fear",
|
|
"gratitude",
|
|
"grief",
|
|
"joy",
|
|
"love",
|
|
"nervousness",
|
|
"optimism",
|
|
"pride",
|
|
"realization",
|
|
"relief",
|
|
"remorse",
|
|
"sadness",
|
|
"surprise",
|
|
"neutral"
|
|
];
|
|
|
|
let expressionsList = null;
|
|
let lastCharacter = undefined;
|
|
let lastMessage = null;
|
|
let spriteCache = {};
|
|
let inApiCall = false;
|
|
|
|
function isVisualNovelMode() {
|
|
return Boolean(!isMobile() && power_user.waifuMode && getContext().groupId);
|
|
}
|
|
|
|
async function forceUpdateVisualNovelMode() {
|
|
if (isVisualNovelMode()) {
|
|
await updateVisualNovelMode();
|
|
}
|
|
}
|
|
|
|
const updateVisualNovelModeDebounced = debounce(forceUpdateVisualNovelMode, 100);
|
|
|
|
async function updateVisualNovelMode(name, expression) {
|
|
const container = $('#visual-novel-wrapper');
|
|
|
|
await visualNovelRemoveInactive(container);
|
|
|
|
const setSpritePromises = await visualNovelSetCharacterSprites(container, name, expression);
|
|
|
|
// calculate layer indices based on recent messages
|
|
await visualNovelUpdateLayers(container);
|
|
|
|
await Promise.allSettled(setSpritePromises);
|
|
|
|
// update again based on new sprites
|
|
if (setSpritePromises.length > 0) {
|
|
await visualNovelUpdateLayers(container);
|
|
}
|
|
}
|
|
|
|
async function visualNovelRemoveInactive(container) {
|
|
const context = getContext();
|
|
const group = context.groups.find(x => x.id == context.groupId);
|
|
const removeInactiveCharactersPromises = [];
|
|
|
|
// remove inactive characters after 1 second
|
|
container.find('.expression-holder').each((_, current) => {
|
|
const promise = new Promise(resolve => {
|
|
const element = $(current);
|
|
const avatar = element.data('avatar');
|
|
|
|
if (!group.members.includes(avatar) || group.disabled_members.includes(avatar)) {
|
|
element.fadeOut(250, () => {
|
|
element.remove();
|
|
resolve();
|
|
});
|
|
} else {
|
|
resolve();
|
|
}
|
|
});
|
|
|
|
removeInactiveCharactersPromises.push(promise);
|
|
});
|
|
|
|
await Promise.allSettled(removeInactiveCharactersPromises);
|
|
}
|
|
|
|
async function visualNovelSetCharacterSprites(container, name, expression) {
|
|
const context = getContext();
|
|
const group = context.groups.find(x => x.id == context.groupId);
|
|
const labels = await getExpressionsList();
|
|
|
|
const createCharacterPromises = [];
|
|
const setSpritePromises = [];
|
|
|
|
for (const avatar of group.members) {
|
|
const isDisabled = group.disabled_members.includes(avatar);
|
|
|
|
// skip disabled characters
|
|
if (isDisabled) {
|
|
continue;
|
|
}
|
|
|
|
const character = context.characters.find(x => x.avatar == avatar);
|
|
|
|
if (!character) {
|
|
continue;
|
|
}
|
|
|
|
let spriteFolderName = character.name;
|
|
const avatarFileName = getSpriteFolderName({ original_avatar: character.avatar });
|
|
const expressionOverride = extension_settings.expressionOverrides.find((e) =>
|
|
e.name == avatarFileName
|
|
);
|
|
|
|
if (expressionOverride && expressionOverride.path) {
|
|
spriteFolderName = expressionOverride.path;
|
|
}
|
|
|
|
// download images if not downloaded yet
|
|
if (spriteCache[spriteFolderName] === undefined) {
|
|
spriteCache[spriteFolderName] = await getSpritesList(spriteFolderName);
|
|
}
|
|
|
|
const sprites = spriteCache[spriteFolderName];
|
|
const expressionImage = container.find(`.expression-holder[data-avatar="${avatar}"]`);
|
|
const defaultSpritePath = sprites.find(x => x.label === FALLBACK_EXPRESSION)?.path;
|
|
const noSprites = sprites.length === 0;
|
|
|
|
if (expressionImage.length > 0) {
|
|
if (name == spriteFolderName) {
|
|
await validateImages(spriteFolderName, true);
|
|
setExpressionOverrideHtml(true); // <= force clear expression override input
|
|
const currentSpritePath = labels.includes(expression) ? sprites.find(x => x.label === expression)?.path : '';
|
|
|
|
const path = currentSpritePath || defaultSpritePath || '';
|
|
const img = expressionImage.find('img');
|
|
await setImage(img, path);
|
|
}
|
|
expressionImage.toggleClass('hidden', noSprites);
|
|
} else {
|
|
const template = $('#expression-holder').clone();
|
|
template.attr('id', `expression-${avatar}`);
|
|
template.attr('data-avatar', avatar);
|
|
template.find('.drag-grabber').attr('id', `expression-${avatar}header`);
|
|
$('#visual-novel-wrapper').append(template);
|
|
dragElement($(template[0]));
|
|
template.toggleClass('hidden', noSprites);
|
|
await setImage(template.find('img'), defaultSpritePath || '');
|
|
const fadeInPromise = new Promise(resolve => {
|
|
template.fadeIn(250, () => resolve());
|
|
});
|
|
createCharacterPromises.push(fadeInPromise);
|
|
const setSpritePromise = setLastMessageSprite(template.find('img'), avatar, labels);
|
|
setSpritePromises.push(setSpritePromise);
|
|
}
|
|
}
|
|
|
|
await Promise.allSettled(createCharacterPromises);
|
|
return setSpritePromises;
|
|
}
|
|
|
|
async function visualNovelUpdateLayers(container) {
|
|
const context = getContext();
|
|
const group = context.groups.find(x => x.id == context.groupId);
|
|
const recentMessages = context.chat.map(x => x.original_avatar).filter(x => x).reverse().filter(onlyUnique);
|
|
const filteredMembers = group.members.filter(x => !group.disabled_members.includes(x));
|
|
const layerIndices = filteredMembers.slice().sort((a, b) => {
|
|
const aRecentIndex = recentMessages.indexOf(a);
|
|
const bRecentIndex = recentMessages.indexOf(b);
|
|
const aFilteredIndex = filteredMembers.indexOf(a);
|
|
const bFilteredIndex = filteredMembers.indexOf(b);
|
|
|
|
if (aRecentIndex !== -1 && bRecentIndex !== -1) {
|
|
return bRecentIndex - aRecentIndex;
|
|
} else if (aRecentIndex !== -1) {
|
|
return 1;
|
|
} else if (bRecentIndex !== -1) {
|
|
return -1;
|
|
} else {
|
|
return aFilteredIndex - bFilteredIndex;
|
|
}
|
|
});
|
|
|
|
const setLayerIndicesPromises = [];
|
|
|
|
const sortFunction = (a, b) => {
|
|
const avatarA = $(a).data('avatar');
|
|
const avatarB = $(b).data('avatar');
|
|
const indexA = filteredMembers.indexOf(avatarA);
|
|
const indexB = filteredMembers.indexOf(avatarB);
|
|
return indexA - indexB;
|
|
};
|
|
|
|
const containerWidth = container.width();
|
|
const pivotalPoint = containerWidth * 0.5;
|
|
|
|
let images = $('.expression-holder');
|
|
let imagesWidth = [];
|
|
|
|
images.sort(sortFunction).each(function () {
|
|
imagesWidth.push($(this).width());
|
|
});
|
|
|
|
let totalWidth = imagesWidth.reduce((a, b) => a + b, 0);
|
|
let currentPosition = pivotalPoint - (totalWidth / 2);
|
|
|
|
if (totalWidth > containerWidth) {
|
|
let totalOverlap = totalWidth - containerWidth;
|
|
let totalWidthWithoutWidest = imagesWidth.reduce((a, b) => a + b, 0) - Math.max(...imagesWidth);
|
|
let overlaps = imagesWidth.map(width => (width / totalWidthWithoutWidest) * totalOverlap);
|
|
imagesWidth = imagesWidth.map((width, index) => width - overlaps[index]);
|
|
currentPosition = 0; // Reset the initial position to 0
|
|
}
|
|
|
|
images.sort(sortFunction).each((index, current) => {
|
|
const element = $(current);
|
|
|
|
// skip repositioning of dragged elements
|
|
if (element.data('dragged')) {
|
|
currentPosition += imagesWidth[index];
|
|
return;
|
|
}
|
|
|
|
const avatar = element.data('avatar');
|
|
const layerIndex = layerIndices.indexOf(avatar);
|
|
element.css('z-index', layerIndex);
|
|
element.show();
|
|
|
|
const promise = new Promise(resolve => {
|
|
element.animate({ left: currentPosition + 'px' }, 500, () => {
|
|
resolve();
|
|
});
|
|
});
|
|
|
|
currentPosition += imagesWidth[index];
|
|
|
|
setLayerIndicesPromises.push(promise);
|
|
});
|
|
|
|
await Promise.allSettled(setLayerIndicesPromises);
|
|
}
|
|
|
|
async function setLastMessageSprite(img, avatar, labels) {
|
|
const context = getContext();
|
|
const lastMessage = context.chat.slice().reverse().find(x => x.original_avatar == avatar || (x.force_avatar && x.force_avatar.includes(encodeURIComponent(avatar))));
|
|
|
|
if (lastMessage) {
|
|
const text = lastMessage.mes || '';
|
|
let spriteFolderName = lastMessage.name;
|
|
const avatarFileName = getSpriteFolderName(lastMessage);
|
|
const expressionOverride = extension_settings.expressionOverrides.find((e) =>
|
|
e.name == avatarFileName
|
|
);
|
|
|
|
if (expressionOverride && expressionOverride.path) {
|
|
spriteFolderName = expressionOverride.path;
|
|
}
|
|
|
|
const sprites = spriteCache[spriteFolderName] || [];
|
|
const label = await getExpressionLabel(text);
|
|
const path = labels.includes(label) ? sprites.find(x => x.label === label)?.path : '';
|
|
|
|
if (path) {
|
|
setImage(img, path);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function setImage(img, path) {
|
|
// Cohee: If something goes wrong, uncomment this to return to the old behavior
|
|
/*
|
|
img.attr('src', path);
|
|
img.removeClass('default');
|
|
img.off('error');
|
|
img.on('error', function () {
|
|
console.debug('Error loading image', path);
|
|
$(this).off('error');
|
|
$(this).attr('src', '');
|
|
});
|
|
*/
|
|
|
|
return new Promise(resolve => {
|
|
const prevExpressionSrc = img.attr('src');
|
|
const expressionClone = img.clone();
|
|
const originalId = img.attr('id');
|
|
|
|
//only swap expressions when necessary
|
|
if (prevExpressionSrc !== path && !img.hasClass('expression-animating')) {
|
|
//clone expression
|
|
expressionClone.addClass('expression-clone')
|
|
//make invisible and remove id to prevent double ids
|
|
//must be made invisible to start because they share the same Z-index
|
|
expressionClone.attr('id', '').css({ opacity: 0 });
|
|
//add new sprite path to clone src
|
|
expressionClone.attr('src', path);
|
|
//add invisible clone to html
|
|
expressionClone.appendTo(img.parent());
|
|
|
|
const duration = 200;
|
|
|
|
//add animation flags to both images
|
|
//to prevent multiple expression changes happening simultaneously
|
|
img.addClass('expression-animating')
|
|
//position absolute prevent the original from jumping around during transition
|
|
img.css('position', 'absolute')
|
|
expressionClone.addClass('expression-animating')
|
|
//fade the clone in
|
|
expressionClone.css({
|
|
opacity: 0
|
|
}).animate({
|
|
opacity: 1
|
|
}, duration)
|
|
//when finshed fading in clone, fade out the original
|
|
.promise().done(function () {
|
|
img.animate({
|
|
opacity: 0
|
|
}, duration);
|
|
//remove old expression
|
|
img.remove();
|
|
//replace ID so it becomes the new 'original' expression for next change
|
|
expressionClone.attr('id', originalId);
|
|
expressionClone.removeClass('expression-animating');
|
|
resolve();
|
|
});
|
|
|
|
expressionClone.removeClass('expression-clone');
|
|
|
|
expressionClone.removeClass('default');
|
|
expressionClone.off('error');
|
|
expressionClone.on('error', function () {
|
|
console.debug('Expression image error', sprite.path);
|
|
$(this).attr('src', '');
|
|
$(this).off('error');
|
|
resolve();
|
|
});
|
|
} else {
|
|
resolve();
|
|
}
|
|
});
|
|
}
|
|
|
|
function onExpressionsShowDefaultInput() {
|
|
const value = $(this).prop('checked');
|
|
extension_settings.expressions.showDefault = value;
|
|
saveSettingsDebounced();
|
|
|
|
const existingImageSrc = $('img.expression').prop('src');
|
|
if (existingImageSrc !== undefined) { //if we have an image in src
|
|
if (!value && existingImageSrc.includes('/img/default-expressions/')) { //and that image is from /img/ (default)
|
|
$('img.expression').prop('src', ''); //remove it
|
|
lastMessage = null;
|
|
}
|
|
if (value) {
|
|
lastMessage = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
async function moduleWorker() {
|
|
const context = getContext();
|
|
|
|
// non-characters not supported
|
|
if (!context.groupId && context.characterId === undefined) {
|
|
removeExpression();
|
|
return;
|
|
}
|
|
|
|
// character changed
|
|
if (context.groupId !== lastCharacter && context.characterId !== lastCharacter) {
|
|
removeExpression();
|
|
spriteCache = {};
|
|
}
|
|
|
|
const vnMode = isVisualNovelMode();
|
|
const vnWrapperVisible = $('#visual-novel-wrapper').is(':visible');
|
|
|
|
if (vnMode) {
|
|
$('#expression-wrapper').hide();
|
|
$('#visual-novel-wrapper').show();
|
|
} else {
|
|
$('#expression-wrapper').show();
|
|
$('#visual-novel-wrapper').hide();
|
|
}
|
|
|
|
const vnStateChanged = vnMode !== vnWrapperVisible;
|
|
|
|
if (vnStateChanged) {
|
|
lastMessage = null;
|
|
$('#visual-novel-wrapper').empty();
|
|
$("#expression-holder").css({ top: '', left: '', right: '', bottom: '', height: '', width: '', margin: '' });
|
|
}
|
|
|
|
const currentLastMessage = getLastCharacterMessage();
|
|
let spriteFolderName = currentLastMessage.name;
|
|
const avatarFileName = getSpriteFolderName(currentLastMessage);
|
|
const expressionOverride = extension_settings.expressionOverrides.find((e) =>
|
|
e.name == avatarFileName
|
|
);
|
|
|
|
if (expressionOverride && expressionOverride.path) {
|
|
spriteFolderName = expressionOverride.path;
|
|
}
|
|
|
|
// character has no expressions or it is not loaded
|
|
if (Object.keys(spriteCache).length === 0) {
|
|
await validateImages(spriteFolderName);
|
|
lastCharacter = context.groupId || context.characterId;
|
|
}
|
|
|
|
const offlineMode = $('.expression_settings .offline_mode');
|
|
if (!modules.includes('classify')) {
|
|
$('.expression_settings').show();
|
|
offlineMode.css('display', 'block');
|
|
lastCharacter = context.groupId || context.characterId;
|
|
|
|
if (context.groupId) {
|
|
await validateImages(spriteFolderName, true);
|
|
await forceUpdateVisualNovelMode();
|
|
}
|
|
|
|
return;
|
|
}
|
|
else {
|
|
// force reload expressions list on connect to API
|
|
if (offlineMode.is(':visible')) {
|
|
expressionsList = null;
|
|
spriteCache = {};
|
|
expressionsList = await getExpressionsList();
|
|
await validateImages(spriteFolderName, true);
|
|
await forceUpdateVisualNovelMode();
|
|
}
|
|
|
|
offlineMode.css('display', 'none');
|
|
}
|
|
|
|
// check if last message changed
|
|
if ((lastCharacter === context.characterId || lastCharacter === context.groupId)
|
|
&& lastMessage === currentLastMessage.mes) {
|
|
return;
|
|
}
|
|
|
|
// API is busy
|
|
if (inApiCall) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
inApiCall = true;
|
|
let expression = await getExpressionLabel(currentLastMessage.mes);
|
|
|
|
// If we're not already overriding the folder name, account for group chats.
|
|
if (spriteFolderName === currentLastMessage.name && !context.groupId) {
|
|
spriteFolderName = context.name2;
|
|
}
|
|
|
|
const force = !!context.groupId;
|
|
|
|
// Character won't be angry on you for swiping
|
|
if (currentLastMessage.mes == '...' && expressionsList.includes(FALLBACK_EXPRESSION)) {
|
|
expression = FALLBACK_EXPRESSION;
|
|
}
|
|
|
|
await sendExpressionCall(spriteFolderName, expression, force, vnMode);
|
|
|
|
}
|
|
catch (error) {
|
|
console.log(error);
|
|
}
|
|
finally {
|
|
inApiCall = false;
|
|
lastCharacter = context.groupId || context.characterId;
|
|
lastMessage = currentLastMessage.mes;
|
|
}
|
|
}
|
|
|
|
function getSpriteFolderName(message) {
|
|
const context = getContext();
|
|
let avatarPath = '';
|
|
|
|
if (context.groupId) {
|
|
avatarPath = message.original_avatar || context.characters.find(x => message.force_avatar && message.force_avatar.includes(encodeURIComponent(x.avatar)))?.avatar;
|
|
}
|
|
else if (context.characterId) {
|
|
avatarPath = getCharaFilename();
|
|
}
|
|
|
|
if (!avatarPath) {
|
|
return '';
|
|
}
|
|
|
|
const folderName = avatarPath.replace(/\.[^/.]+$/, "");
|
|
return folderName;
|
|
}
|
|
|
|
async function sendExpressionCall(name, expression, force, vnMode) {
|
|
if (!vnMode) {
|
|
vnMode = isVisualNovelMode();
|
|
}
|
|
|
|
if (vnMode) {
|
|
await updateVisualNovelMode(name, expression);
|
|
} else {
|
|
setExpression(name, expression, force);
|
|
}
|
|
}
|
|
|
|
async function getExpressionLabel(text) {
|
|
// Return if text is undefined, saving a costly fetch request
|
|
if (!modules.includes('classify') || !text) {
|
|
return FALLBACK_EXPRESSION;
|
|
}
|
|
|
|
const url = new URL(getApiUrl());
|
|
url.pathname = '/api/classify';
|
|
|
|
const apiResult = await doExtrasFetch(url, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Bypass-Tunnel-Reminder': 'bypass',
|
|
},
|
|
body: JSON.stringify({ text: text }),
|
|
});
|
|
|
|
if (apiResult.ok) {
|
|
const data = await apiResult.json();
|
|
return data.classification[0].label;
|
|
}
|
|
}
|
|
|
|
function getLastCharacterMessage() {
|
|
const context = getContext();
|
|
const reversedChat = context.chat.slice().reverse();
|
|
|
|
for (let mes of reversedChat) {
|
|
if (mes.is_user || mes.is_system) {
|
|
continue;
|
|
}
|
|
|
|
return { mes: mes.mes, name: mes.name, original_avatar: mes.original_avatar, force_avatar: mes.force_avatar };
|
|
}
|
|
|
|
return { mes: '', name: null, original_avatar: null, force_avatar: null };
|
|
}
|
|
|
|
function removeExpression() {
|
|
lastMessage = null;
|
|
$('img.expression').off('error');
|
|
$('img.expression').prop('src', '');
|
|
$('img.expression').removeClass('default');
|
|
$('.expression_settings').hide();
|
|
}
|
|
|
|
async function validateImages(character, forceRedrawCached) {
|
|
if (!character) {
|
|
return;
|
|
}
|
|
|
|
const labels = await getExpressionsList();
|
|
|
|
if (spriteCache[character]) {
|
|
if (forceRedrawCached && $('#image_list').data('name') !== character) {
|
|
console.debug('force redrawing character sprites list')
|
|
drawSpritesList(character, labels, spriteCache[character]);
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
const sprites = await getSpritesList(character);
|
|
let validExpressions = drawSpritesList(character, labels, sprites);
|
|
spriteCache[character] = validExpressions;
|
|
}
|
|
|
|
function drawSpritesList(character, labels, sprites) {
|
|
let validExpressions = [];
|
|
$('.expression_settings').show();
|
|
$('#image_list').empty();
|
|
$('#image_list').data('name', character);
|
|
|
|
if (!Array.isArray(labels)) {
|
|
return [];
|
|
}
|
|
|
|
labels.sort().forEach((item) => {
|
|
const sprite = sprites.find(x => x.label == item);
|
|
|
|
if (sprite) {
|
|
validExpressions.push(sprite);
|
|
$('#image_list').append(getListItem(item, sprite.path, 'success'));
|
|
}
|
|
else {
|
|
$('#image_list').append(getListItem(item, '/img/No-Image-Placeholder.svg', 'failure'));
|
|
}
|
|
});
|
|
return validExpressions;
|
|
}
|
|
|
|
function getListItem(item, imageSrc, textClass) {
|
|
return `
|
|
<div id="${item}" class="expression_list_item">
|
|
<div class="expression_list_buttons">
|
|
<div class="menu_button expression_list_upload" title="Upload image">
|
|
<i class="fa-solid fa-upload"></i>
|
|
</div>
|
|
<div class="menu_button expression_list_delete" title="Delete image">
|
|
<i class="fa-solid fa-trash"></i>
|
|
</div>
|
|
</div>
|
|
<span class="expression_list_title ${textClass}">${item}</span>
|
|
<img class="expression_list_image" src="${imageSrc}" />
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
async function getSpritesList(name) {
|
|
console.debug('getting sprites list');
|
|
|
|
try {
|
|
const result = await fetch(`/get_sprites?name=${encodeURIComponent(name)}`);
|
|
|
|
let sprites = result.ok ? (await result.json()) : [];
|
|
return sprites;
|
|
}
|
|
catch (err) {
|
|
console.log(err);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
async function getExpressionsList() {
|
|
// get something for offline mode (default images)
|
|
if (!modules.includes('classify')) {
|
|
return DEFAULT_EXPRESSIONS;
|
|
}
|
|
|
|
if (Array.isArray(expressionsList)) {
|
|
return expressionsList;
|
|
}
|
|
|
|
const url = new URL(getApiUrl());
|
|
url.pathname = '/api/classify/labels';
|
|
|
|
try {
|
|
const apiResult = await doExtrasFetch(url, {
|
|
method: 'GET',
|
|
headers: { 'Bypass-Tunnel-Reminder': 'bypass' },
|
|
});
|
|
|
|
if (apiResult.ok) {
|
|
|
|
const data = await apiResult.json();
|
|
expressionsList = data.labels;
|
|
return expressionsList;
|
|
}
|
|
}
|
|
catch (error) {
|
|
console.log(error);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
async function setExpression(character, expression, force) {
|
|
console.debug('entered setExpressions');
|
|
await validateImages(character);
|
|
const img = $('img.expression');
|
|
const prevExpressionSrc = img.attr('src');
|
|
const expressionClone = img.clone()
|
|
|
|
const sprite = (spriteCache[character] && spriteCache[character].find(x => x.label === expression));
|
|
console.debug('checking for expression images to show..');
|
|
if (sprite) {
|
|
console.debug('setting expression from character images folder');
|
|
|
|
if (force && isVisualNovelMode()) {
|
|
const context = getContext();
|
|
const group = context.groups.find(x => x.id === context.groupId);
|
|
|
|
for (const member of group.members) {
|
|
const groupMember = context.characters.find(x => x.avatar === member);
|
|
|
|
if (!groupMember) {
|
|
continue;
|
|
}
|
|
|
|
if (groupMember.name == character) {
|
|
await setImage($(`.expression-holder[data-avatar="${member}"] img`), sprite.path);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
//only swap expressions when necessary
|
|
if (prevExpressionSrc !== sprite.path
|
|
&& !img.hasClass('expression-animating')) {
|
|
//clone expression
|
|
expressionClone.addClass('expression-clone')
|
|
//make invisible and remove id to prevent double ids
|
|
//must be made invisible to start because they share the same Z-index
|
|
expressionClone.attr('id', '').css({ opacity: 0 });
|
|
//add new sprite path to clone src
|
|
expressionClone.attr('src', sprite.path);
|
|
//add invisible clone to html
|
|
expressionClone.appendTo($("#expression-holder"))
|
|
|
|
const duration = 200;
|
|
|
|
//add animation flags to both images
|
|
//to prevent multiple expression changes happening simultaneously
|
|
img.addClass('expression-animating');
|
|
|
|
// Set the parent container's min width and height before running the transition
|
|
const imgWidth = img.width();
|
|
const imgHeight = img.height();
|
|
const expressionHolder = img.parent();
|
|
expressionHolder.css('min-width', imgWidth > 100 ? imgWidth : 100);
|
|
expressionHolder.css('min-height', imgHeight > 100 ? imgHeight : 100);
|
|
|
|
//position absolute prevent the original from jumping around during transition
|
|
img.css('position', 'absolute');
|
|
expressionClone.addClass('expression-animating');
|
|
//fade the clone in
|
|
expressionClone.css({
|
|
opacity: 0
|
|
}).animate({
|
|
opacity: 1
|
|
}, duration)
|
|
//when finshed fading in clone, fade out the original
|
|
.promise().done(function () {
|
|
img.animate({
|
|
opacity: 0
|
|
}, duration);
|
|
//remove old expression
|
|
img.remove();
|
|
//replace ID so it becomes the new 'original' expression for next change
|
|
expressionClone.attr('id', 'expression-image');
|
|
expressionClone.removeClass('expression-animating');
|
|
|
|
// Reset the expression holder min height and width
|
|
expressionHolder.css('min-width', 100);
|
|
expressionHolder.css('min-height', 100);
|
|
});
|
|
|
|
|
|
expressionClone.removeClass('expression-clone');
|
|
|
|
expressionClone.removeClass('default');
|
|
expressionClone.off('error');
|
|
expressionClone.on('error', function () {
|
|
console.debug('Expression image error', sprite.path);
|
|
$(this).attr('src', '');
|
|
$(this).off('error');
|
|
if (force && extension_settings.expressions.showDefault) {
|
|
setDefault();
|
|
}
|
|
});
|
|
} else {
|
|
if (extension_settings.expressions.showDefault) {
|
|
setDefault();
|
|
}
|
|
}
|
|
}
|
|
|
|
function setDefault() {
|
|
console.debug('setting default');
|
|
const defImgUrl = `/img/default-expressions/${expression}.png`;
|
|
//console.log(defImgUrl);
|
|
expressionClone.attr('src', defImgUrl);
|
|
expressionClone.addClass('default');
|
|
}
|
|
document.getElementById("expression-holder").style.display = '';
|
|
}
|
|
|
|
function onClickExpressionImage() {
|
|
// online mode doesn't need force set
|
|
if (modules.includes('classify')) {
|
|
return;
|
|
}
|
|
|
|
const expression = $(this).attr('id');
|
|
const name = getLastCharacterMessage().name;
|
|
|
|
if ($(this).find('.failure').length === 0) {
|
|
setExpression(name, expression, true);
|
|
}
|
|
}
|
|
async function handleFileUpload(url, formData) {
|
|
try {
|
|
const data = await jQuery.ajax({
|
|
type: "POST",
|
|
url: url,
|
|
data: formData,
|
|
beforeSend: function () { },
|
|
cache: false,
|
|
contentType: false,
|
|
processData: false,
|
|
});
|
|
|
|
// Refresh sprites list
|
|
const name = formData.get('name');
|
|
delete spriteCache[name];
|
|
await validateImages(name);
|
|
|
|
return data;
|
|
} catch (error) {
|
|
toastr.error('Failed to upload image');
|
|
}
|
|
}
|
|
|
|
async function onClickExpressionUpload(event) {
|
|
// Prevents the expression from being set
|
|
event.stopPropagation();
|
|
|
|
const id = $(this).closest('.expression_list_item').attr('id');
|
|
const name = $('#image_list').data('name');
|
|
|
|
const handleExpressionUploadChange = async (e) => {
|
|
const file = e.target.files[0];
|
|
|
|
if (!file) {
|
|
return;
|
|
}
|
|
|
|
const formData = new FormData();
|
|
formData.append('name', name);
|
|
formData.append('label', id);
|
|
formData.append('avatar', file);
|
|
|
|
await handleFileUpload('/upload_sprite', formData);
|
|
|
|
// Reset the input
|
|
e.target.form.reset();
|
|
};
|
|
|
|
$('#expression_upload')
|
|
.off('change')
|
|
.on('change', handleExpressionUploadChange)
|
|
.trigger('click');
|
|
}
|
|
|
|
async function onClickExpressionOverrideButton() {
|
|
const context = getContext();
|
|
const currentLastMessage = getLastCharacterMessage();
|
|
const avatarFileName = getSpriteFolderName(currentLastMessage);
|
|
|
|
// If the avatar name couldn't be found, abort.
|
|
if (!avatarFileName) {
|
|
console.debug(`Could not find filename for character with name ${currentLastMessage.name} and ID ${context.characterId}`);
|
|
|
|
return;
|
|
}
|
|
|
|
const overridePath = $("#expression_override").val();
|
|
const existingOverrideIndex = extension_settings.expressionOverrides.findIndex((e) =>
|
|
e.name == avatarFileName
|
|
);
|
|
|
|
// If the path is empty, delete the entry from overrides
|
|
if (overridePath === undefined || overridePath.length === 0) {
|
|
if (existingOverrideIndex === -1) {
|
|
return;
|
|
}
|
|
|
|
extension_settings.expressionOverrides.splice(existingOverrideIndex, 1);
|
|
console.debug(`Removed existing override for ${avatarFileName}`);
|
|
} else {
|
|
// Properly override objects and clear the sprite cache of the previously set names
|
|
const existingOverride = extension_settings.expressionOverrides[existingOverrideIndex];
|
|
if (existingOverride) {
|
|
Object.assign(existingOverride, { path: overridePath });
|
|
delete spriteCache[existingOverride.name];
|
|
} else {
|
|
const characterOverride = { name: avatarFileName, path: overridePath };
|
|
extension_settings.expressionOverrides.push(characterOverride);
|
|
delete spriteCache[currentLastMessage.name];
|
|
}
|
|
|
|
console.debug(`Added/edited expression override for character with filename ${avatarFileName} to folder ${overridePath}`);
|
|
}
|
|
|
|
saveSettingsDebounced();
|
|
|
|
// Refresh sprites list. Assume the override path has been properly handled.
|
|
try {
|
|
$('#visual-novel-wrapper').empty();
|
|
await validateImages(overridePath.length === 0 ? currentLastMessage.name : overridePath, true);
|
|
const expression = await getExpressionLabel(currentLastMessage.mes);
|
|
await sendExpressionCall(overridePath.length === 0 ? currentLastMessage.name : overridePath, expression, true);
|
|
forceUpdateVisualNovelMode();
|
|
} catch (error) {
|
|
console.debug(`Setting expression override for ${avatarFileName} failed with error: ${error}`);
|
|
}
|
|
}
|
|
|
|
async function onClickExpressionOverrideRemoveAllButton() {
|
|
// Remove all the overrided entries from sprite cache
|
|
for (const element of extension_settings.expressionOverrides) {
|
|
delete spriteCache[element.name];
|
|
}
|
|
|
|
extension_settings.expressionOverrides = [];
|
|
saveSettingsDebounced();
|
|
|
|
console.debug("All expression image overrides have been cleared.");
|
|
|
|
// Refresh sprites list to use the default name if applicable
|
|
try {
|
|
$('#visual-novel-wrapper').empty();
|
|
const currentLastMessage = getLastCharacterMessage();
|
|
await validateImages(currentLastMessage.name, true);
|
|
const expression = await getExpressionLabel(currentLastMessage.mes);
|
|
await sendExpressionCall(currentLastMessage.name, expression, true);
|
|
forceUpdateVisualNovelMode();
|
|
|
|
console.debug(extension_settings.expressionOverrides);
|
|
} catch (error) {
|
|
console.debug(`The current expression could not be set because of error: ${error}`);
|
|
}
|
|
}
|
|
|
|
async function onClickExpressionUploadPackButton() {
|
|
const name = $('#image_list').data('name');
|
|
|
|
const handleFileUploadChange = async (e) => {
|
|
const file = e.target.files[0];
|
|
|
|
if (!file) {
|
|
return;
|
|
}
|
|
|
|
const formData = new FormData();
|
|
formData.append('name', name);
|
|
formData.append('avatar', file);
|
|
|
|
const { count } = await handleFileUpload('/upload_sprite_pack', formData);
|
|
toastr.success(`Uploaded ${count} image(s) for ${name}`);
|
|
|
|
// Reset the input
|
|
e.target.form.reset();
|
|
};
|
|
|
|
$('#expression_upload_pack')
|
|
.off('change')
|
|
.on('change', handleFileUploadChange)
|
|
.trigger('click');
|
|
}
|
|
|
|
async function onClickExpressionDelete(event) {
|
|
// Prevents the expression from being set
|
|
event.stopPropagation();
|
|
|
|
const confirmation = await callPopup("<h3>Are you sure?</h3>Once deleted, it's gone forever!", 'confirm');
|
|
|
|
if (!confirmation) {
|
|
return;
|
|
}
|
|
|
|
const id = $(this).closest('.expression_list_item').attr('id');
|
|
const name = $('#image_list').data('name');
|
|
|
|
try {
|
|
await fetch('/delete_sprite', {
|
|
method: 'POST',
|
|
headers: getRequestHeaders(),
|
|
body: JSON.stringify({ name, label: id }),
|
|
});
|
|
} catch (error) {
|
|
toastr.error('Failed to delete image. Try again later.');
|
|
}
|
|
|
|
// Refresh sprites list
|
|
delete spriteCache[name];
|
|
await validateImages(name);
|
|
}
|
|
|
|
function setExpressionOverrideHtml(forceClear = false) {
|
|
const currentLastMessage = getLastCharacterMessage();
|
|
const avatarFileName = getSpriteFolderName(currentLastMessage);
|
|
if (!avatarFileName) {
|
|
return;
|
|
}
|
|
|
|
const expressionOverride = extension_settings.expressionOverrides.find((e) =>
|
|
e.name == avatarFileName
|
|
);
|
|
|
|
if (expressionOverride && expressionOverride.path) {
|
|
$("#expression_override").val(expressionOverride.path);
|
|
} else if (expressionOverride) {
|
|
delete extension_settings.expressionOverrides[expressionOverride.name];
|
|
}
|
|
|
|
if (forceClear && !expressionOverride) {
|
|
$("#expression_override").val("");
|
|
}
|
|
}
|
|
|
|
(function () {
|
|
function addExpressionImage() {
|
|
const html = `
|
|
<div id="expression-wrapper">
|
|
<div id="expression-holder" class="expression-holder" style="display:none;">
|
|
<div id="expression-holderheader" class="fa-solid fa-grip drag-grabber"></div>
|
|
<img id="expression-image" class="expression">
|
|
</div>
|
|
</div>`;
|
|
$('body').append(html);
|
|
loadMovingUIState();
|
|
}
|
|
function addVisualNovelMode() {
|
|
const html = `
|
|
<div id="visual-novel-wrapper">
|
|
</div>`
|
|
const element = $(html);
|
|
element.hide();
|
|
$('body').append(element);
|
|
}
|
|
function addSettings() {
|
|
|
|
const html = `
|
|
<div class="expression_settings">
|
|
<div class="inline-drawer">
|
|
<div class="inline-drawer-toggle inline-drawer-header">
|
|
<b>Character Expressions</b>
|
|
<div class="inline-drawer-icon fa-solid fa-circle-chevron-down down"></div>
|
|
</div>
|
|
<div class="inline-drawer-content">
|
|
<div class="offline_mode">
|
|
<small>You are in offline mode. Click on the image below to set the expression.</small>
|
|
</div>
|
|
<div class="flex-container flexnowrap">
|
|
<input id="expression_override" type="text" class="text_pole" placeholder="Override folder name" />
|
|
<input id="expression_override_button" class="menu_button" type="submit" value="Submit" />
|
|
</div>
|
|
<div id="image_list"></div>
|
|
<div class="expression_buttons flex-container spaceEvenly">
|
|
<div id="expression_upload_pack_button" class="menu_button">
|
|
<i class="fa-solid fa-file-zipper"></i>
|
|
<span>Upload sprite pack (ZIP)</span>
|
|
</div>
|
|
<div id="expression_override_cleanup_button" class="menu_button">
|
|
<i class="fa-solid fa-trash-can"></i>
|
|
<span>Remove all image overrides</span>
|
|
</div>
|
|
</div>
|
|
<p class="hint"><b>Hint:</b> <i>Create new folder in the <b>public/characters/</b> folder and name it as the name of the character.
|
|
Put images with expressions there. File names should follow the pattern: <tt>[expression_label].[image_format]</tt></i></p>
|
|
<label for="expressions_show_default"><input id="expressions_show_default" type="checkbox">Show default images (emojis) if missing</label>
|
|
</div>
|
|
</div>
|
|
<form>
|
|
<input type="file" id="expression_upload_pack" name="expression_upload_pack" accept="application/zip" hidden>
|
|
<input type="file" id="expression_upload" name="expression_upload" accept="image/*" hidden>
|
|
</form>
|
|
</div>
|
|
`;
|
|
$('#extensions_settings').append(html);
|
|
$('#expression_override_button').on('click', onClickExpressionOverrideButton);
|
|
$('#expressions_show_default').on('input', onExpressionsShowDefaultInput);
|
|
$('#expression_upload_pack_button').on('click', onClickExpressionUploadPackButton);
|
|
$('#expressions_show_default').prop('checked', extension_settings.expressions.showDefault).trigger('input');
|
|
$('#expression_override_cleanup_button').on('click', onClickExpressionOverrideRemoveAllButton);
|
|
$(document).on('dragstart', '.expression', (e) => {
|
|
e.preventDefault()
|
|
return false
|
|
})
|
|
$(document).on('click', '.expression_list_item', onClickExpressionImage);
|
|
$(document).on('click', '.expression_list_upload', onClickExpressionUpload);
|
|
$(document).on('click', '.expression_list_delete', onClickExpressionDelete);
|
|
$(window).on("resize", updateVisualNovelModeDebounced);
|
|
$('.expression_settings').hide();
|
|
}
|
|
|
|
addExpressionImage();
|
|
addVisualNovelMode();
|
|
addSettings();
|
|
const wrapper = new ModuleWorkerWrapper(moduleWorker);
|
|
const updateFunction = wrapper.update.bind(wrapper);
|
|
setInterval(updateFunction, UPDATE_INTERVAL);
|
|
moduleWorker();
|
|
dragElement($("#expression-holder"))
|
|
eventSource.on(event_types.CHAT_CHANGED, () => {
|
|
setExpressionOverrideHtml();
|
|
|
|
if (isVisualNovelMode()) {
|
|
$('#visual-novel-wrapper').empty();
|
|
}
|
|
});
|
|
eventSource.on(event_types.MOVABLE_PANELS_RESET, updateVisualNovelModeDebounced);
|
|
eventSource.on(event_types.GROUP_UPDATED, updateVisualNovelModeDebounced);
|
|
})();
|