Merge branch 'SillyTavern:staging' into staging

This commit is contained in:
Tony Ribeiro
2024-01-16 00:15:13 +01:00
committed by GitHub
58 changed files with 4590 additions and 1816 deletions

View File

@@ -10,6 +10,7 @@ export { MODULE_NAME };
const MODULE_NAME = 'expressions';
const UPDATE_INTERVAL = 2000;
const STREAMING_UPDATE_INTERVAL = 6000;
const TALKINGCHECK_UPDATE_INTERVAL = 500;
const FALLBACK_EXPRESSION = 'joy';
const DEFAULT_EXPRESSIONS = [
'talkinghead',
@@ -46,11 +47,17 @@ const DEFAULT_EXPRESSIONS = [
let expressionsList = null;
let lastCharacter = undefined;
let lastMessage = null;
let lastTalkingState = false;
let lastTalkingStateMessage = null; // last message as seen by `updateTalkingState` (tracked separately, different timer)
let spriteCache = {};
let inApiCall = false;
let lastServerResponseTime = 0;
export let lastExpression = {};
function isTalkingHeadEnabled() {
return extension_settings.expressions.talkinghead && !extension_settings.expressions.local;
}
function isVisualNovelMode() {
return Boolean(!isMobile() && power_user.waifuMode && getContext().groupId);
}
@@ -381,7 +388,10 @@ function onExpressionsShowDefaultInput() {
}
}
async function unloadLiveChar() {
/**
* Stops animating a talkinghead.
*/
async function unloadTalkingHead() {
if (!modules.includes('talkinghead')) {
console.debug('talkinghead module is disabled');
return;
@@ -400,7 +410,10 @@ async function unloadLiveChar() {
}
}
async function loadLiveChar() {
/**
* Posts `talkinghead.png` of the current character to the talkinghead module in SillyTavern-extras, to start animating it.
*/
async function loadTalkingHead() {
if (!modules.includes('talkinghead')) {
console.debug('talkinghead module is disabled');
return;
@@ -409,6 +422,8 @@ async function loadLiveChar() {
const spriteFolderName = getSpriteFolderName();
const talkingheadPath = `/characters/${encodeURIComponent(spriteFolderName)}/talkinghead.png`;
const emotionsSettingsPath = `/characters/${encodeURIComponent(spriteFolderName)}/_emotions.json`;
const animatorSettingsPath = `/characters/${encodeURIComponent(spriteFolderName)}/_animator.json`;
try {
const spriteResponse = await fetch(talkingheadPath);
@@ -437,6 +452,69 @@ async function loadLiveChar() {
const loadResponseText = await loadResponse.text();
console.log(`Load talkinghead response: ${loadResponseText}`);
// Optional: per-character emotion templates
let emotionsSettings;
try {
const emotionsResponse = await fetch(emotionsSettingsPath);
if (emotionsResponse.ok) {
emotionsSettings = await emotionsResponse.json();
console.log(`Loaded ${emotionsSettingsPath}`);
} else {
throw new Error();
}
}
catch (error) {
emotionsSettings = {}; // blank -> use server defaults (to unload the previous character's customizations)
console.log(`No valid config at ${emotionsSettingsPath}, using server defaults`);
}
try {
const url = new URL(getApiUrl());
url.pathname = '/api/talkinghead/load_emotion_templates';
const apiResult = await doExtrasFetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Bypass-Tunnel-Reminder': 'bypass',
},
body: JSON.stringify(emotionsSettings),
});
}
catch (error) {
// it's ok if not supported
console.log('Failed to send _emotions.json (backend too old?), ignoring');
}
// Optional: per-character animator and postprocessor config
let animatorSettings;
try {
const animatorResponse = await fetch(animatorSettingsPath);
if (animatorResponse.ok) {
animatorSettings = await animatorResponse.json();
console.log(`Loaded ${animatorSettingsPath}`);
} else {
throw new Error();
}
}
catch (error) {
animatorSettings = {}; // blank -> use server defaults (to unload the previous character's customizations)
console.log(`No valid config at ${animatorSettingsPath}, using server defaults`);
}
try {
const url = new URL(getApiUrl());
url.pathname = '/api/talkinghead/load_animator_settings';
const apiResult = await doExtrasFetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Bypass-Tunnel-Reminder': 'bypass',
},
body: JSON.stringify(animatorSettings),
});
}
catch (error) {
// it's ok if not supported
console.log('Failed to send _animator.json (backend too old?), ignoring');
}
} catch (error) {
console.error(`Error loading talkinghead image: ${talkingheadPath} - ${error}`);
}
@@ -450,7 +528,7 @@ function handleImageChange() {
return;
}
if (extension_settings.expressions.talkinghead && !extension_settings.expressions.local) {
if (isTalkingHeadEnabled()) {
// Method get IP of endpoint
const talkingheadResultFeedSrc = `${getApiUrl()}/api/talkinghead/result_feed`;
$('#expression-holder').css({ display: '' });
@@ -559,9 +637,10 @@ async function moduleWorker() {
return;
}
const lastMessageChanged = !((lastCharacter === context.characterId || lastCharacter === context.groupId) && lastMessage === currentLastMessage.mes);
// check if last message changed
if ((lastCharacter === context.characterId || lastCharacter === context.groupId)
&& lastMessage === currentLastMessage.mes) {
if (!lastMessageChanged) {
return;
}
@@ -611,21 +690,81 @@ async function moduleWorker() {
}
}
async function talkingHeadCheck() {
/**
* Starts/stops talkinghead talking animation.
*
* Talking starts only when all the following conditions are met:
* - The LLM is currently streaming its output.
* - The AI's current last message is non-empty, and also not just '...' (as produced by a swipe).
* - The AI's current last message has changed from what we saw during the previous call.
*
* In all other cases, talking stops.
*
* A talkinghead API call is made only when the talking state changes.
*/
async function updateTalkingState() {
// Don't bother if talkinghead is disabled or not loaded.
if (!isTalkingHeadEnabled() || !modules.includes('talkinghead')) {
return;
}
const context = getContext();
const currentLastMessage = getLastCharacterMessage();
try {
// TODO: Not sure if we need also "&& !context.groupId" here - the classify check in `moduleWorker`
// (that similarly checks the streaming processor state) does that for some reason.
// Talkinghead isn't currently designed to work with groups.
const lastMessageChanged = !((lastCharacter === context.characterId || lastCharacter === context.groupId) && lastTalkingStateMessage === currentLastMessage.mes);
const url = new URL(getApiUrl());
let newTalkingState;
if (context.streamingProcessor && !context.streamingProcessor.isFinished &&
currentLastMessage.mes.length !== 0 && currentLastMessage.mes !== '...' && lastMessageChanged) {
url.pathname = '/api/talkinghead/start_talking';
newTalkingState = true;
} else {
url.pathname = '/api/talkinghead/stop_talking';
newTalkingState = false;
}
try {
// Call the talkinghead API only if the talking state changed.
if (newTalkingState !== lastTalkingState) {
console.debug(`updateTalkingState: calling ${url.pathname}`);
await doExtrasFetch(url);
}
}
catch (error) {
// it's ok if not supported
}
finally {
lastTalkingState = newTalkingState;
}
}
catch (error) {
// console.log(error);
}
finally {
lastTalkingStateMessage = currentLastMessage.mes;
}
}
/**
* Checks whether the current character has a talkinghead image available.
* @returns {Promise<boolean>} True if the character has a talkinghead image available, false otherwise.
*/
async function isTalkingHeadAvailable() {
let spriteFolderName = getSpriteFolderName();
try {
await validateImages(spriteFolderName);
let talkingheadObj = spriteCache[spriteFolderName].find(obj => obj.label === 'talkinghead');
let talkingheadPath_f = talkingheadObj ? talkingheadObj.path : null;
let talkingheadPath = talkingheadObj ? talkingheadObj.path : null;
if (talkingheadPath_f != null) {
//console.log("talkingheadPath_f " + talkingheadPath_f);
if (talkingheadPath != null) {
return true;
} else {
//console.log("talkingheadPath_f is null");
unloadLiveChar();
await unloadTalkingHead();
return false;
}
} catch (err) {
@@ -647,22 +786,22 @@ function getSpriteFolderName(characterMessage = null, characterName = null) {
return spriteFolderName;
}
function setTalkingHeadState(switch_var) {
extension_settings.expressions.talkinghead = switch_var; // Store setting
function setTalkingHeadState(newState) {
extension_settings.expressions.talkinghead = newState; // Store setting
saveSettingsDebounced();
if (extension_settings.expressions.local) {
return;
}
talkingHeadCheck().then(result => {
isTalkingHeadAvailable().then(result => {
if (result) {
//console.log("talkinghead exists!");
if (extension_settings.expressions.talkinghead) {
loadLiveChar();
loadTalkingHead();
} else {
unloadLiveChar();
unloadTalkingHead();
}
handleImageChange(); // Change image as needed
@@ -732,22 +871,29 @@ async function setSpriteSlashCommand(_, spriteId) {
spriteId = spriteId.trim().toLowerCase();
// In talkinghead mode, don't check for the existence of the sprite
// (emotion names are the same as for sprites, but it only needs "talkinghead.png").
const currentLastMessage = getLastCharacterMessage();
const spriteFolderName = getSpriteFolderName(currentLastMessage, currentLastMessage.name);
await validateImages(spriteFolderName);
let label = spriteId;
if (!isTalkingHeadEnabled()) {
await validateImages(spriteFolderName);
// Fuzzy search for sprite
const fuse = new Fuse(spriteCache[spriteFolderName], { keys: ['label'] });
const results = fuse.search(spriteId);
const spriteItem = results[0]?.item;
// Fuzzy search for sprite
const fuse = new Fuse(spriteCache[spriteFolderName], { keys: ['label'] });
const results = fuse.search(spriteId);
const spriteItem = results[0]?.item;
if (!spriteItem) {
console.log('No sprite found for search term ' + spriteId);
return;
if (!spriteItem) {
console.log('No sprite found for search term ' + spriteId);
return;
}
label = spriteItem.label;
}
const vnMode = isVisualNovelMode();
await sendExpressionCall(spriteFolderName, spriteItem.label, true, vnMode);
await sendExpressionCall(spriteFolderName, label, true, vnMode);
}
/**
@@ -998,7 +1144,7 @@ async function getExpressionsList() {
}
async function setExpression(character, expression, force) {
if (extension_settings.expressions.local || !extension_settings.expressions.talkinghead) {
if (!isTalkingHeadEnabled()) {
console.debug('entered setExpressions');
await validateImages(character);
const img = $('img.expression');
@@ -1109,24 +1255,38 @@ async function setExpression(character, expression, force) {
document.getElementById('expression-holder').style.display = '';
} else {
talkingHeadCheck().then(result => {
// Set the talkinghead emotion to the specified expression
// TODO: For now, talkinghead emote only supported when VN mode is off; see also updateVisualNovelMode.
try {
let result = await isTalkingHeadAvailable();
if (result) {
// Find the <img> element with id="expression-image" and class="expression"
const imgElement = document.querySelector('img#expression-image.expression');
//console.log("searching");
if (imgElement && imgElement instanceof HTMLImageElement) {
//console.log("setting value");
imgElement.src = getApiUrl() + '/api/talkinghead/result_feed';
}
} else {
//console.log("The fetch failed!");
const url = new URL(getApiUrl());
url.pathname = '/api/talkinghead/set_emotion';
await doExtrasFetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ emotion_name: expression }),
});
}
});
}
catch (error) {
// `set_emotion` is not present in old versions, so let it 404.
}
try {
// Find the <img> element with id="expression-image" and class="expression"
const imgElement = document.querySelector('img#expression-image.expression');
//console.log("searching");
if (imgElement && imgElement instanceof HTMLImageElement) {
//console.log("setting value");
imgElement.src = getApiUrl() + '/api/talkinghead/result_feed';
}
}
catch (error) {
//console.log("The fetch failed!");
}
}
}
@@ -1247,6 +1407,11 @@ async function onClickExpressionUpload(event) {
// Reset the input
e.target.form.reset();
// In talkinghead mode, when a new talkinghead image is uploaded, refresh the live char.
if (isTalkingHeadEnabled() && id === 'talkinghead') {
await loadTalkingHead();
}
};
$('#expression_upload')
@@ -1473,6 +1638,11 @@ function setExpressionOverrideHtml(forceClear = false) {
const updateFunction = wrapper.update.bind(wrapper);
setInterval(updateFunction, UPDATE_INTERVAL);
moduleWorker();
// For setting the talkinghead talking animation on/off quickly enough for realtime use, we need another timer on a shorter schedule.
const wrapperTalkingState = new ModuleWorkerWrapper(updateTalkingState);
const updateTalkingStateFunction = wrapperTalkingState.update.bind(wrapperTalkingState);
setInterval(updateTalkingStateFunction, TALKINGCHECK_UPDATE_INTERVAL);
updateTalkingState();
dragElement($('#expression-holder'));
eventSource.on(event_types.CHAT_CHANGED, () => {
// character changed

View File

@@ -4,7 +4,7 @@ import {
characters,
getRequestHeaders,
} from '../../../script.js';
import { selected_group } from '../../group-chats.js';
import { groups, selected_group } from '../../group-chats.js';
import { loadFileToDocument, delay } from '../../utils.js';
import { loadMovingUIState } from '../../power-user.js';
import { dragElement } from '../../RossAscends-mods.js';
@@ -416,7 +416,26 @@ function viewWithDragbox(items) {
// Registers a simple command for opening the char gallery.
registerSlashCommand('show-gallery', showGalleryCommand, ['sg'], ' shows the gallery', true, true);
registerSlashCommand('list-gallery', listGalleryCommand, ['lg'], '<span class="monospace">[optional char=charName] [optional group=groupName]</span> list images in the gallery of the current char / group or a specified char / group', true, true);
function showGalleryCommand(args) {
showCharGallery();
}
async function listGalleryCommand(args) {
try {
let url = args.char ?? (args.group ? groups.find(it=>it.name == args.group)?.id : null) ?? (selected_group || this_chid);
if (!args.char && !args.group && !selected_group && this_chid) {
const char = characters[this_chid];
url = char.avatar.replace('.png', '');
}
const items = await getGalleryItems(url);
return JSON.stringify(items.map(it=>it.src));
} catch (err) {
console.trace();
console.error(err);
}
return JSON.stringify([]);
}

View File

@@ -0,0 +1,451 @@
// eslint-disable-next-line no-unused-vars
import { QuickReply } from '../src/QuickReply.js';
import { QuickReplyContextLink } from '../src/QuickReplyContextLink.js';
import { QuickReplySet } from '../src/QuickReplySet.js';
// eslint-disable-next-line no-unused-vars
import { QuickReplySettings } from '../src/QuickReplySettings.js';
// eslint-disable-next-line no-unused-vars
import { SettingsUi } from '../src/ui/SettingsUi.js';
export class QuickReplyApi {
/**@type {QuickReplySettings}*/ settings;
/**@type {SettingsUi}*/ settingsUi;
constructor(/**@type {QuickReplySettings}*/settings, /**@type {SettingsUi}*/settingsUi) {
this.settings = settings;
this.settingsUi = settingsUi;
}
/**
* Finds and returns an existing Quick Reply Set by its name.
*
* @param {String} name name of the quick reply set
* @returns the quick reply set, or undefined if not found
*/
getSetByName(name) {
return QuickReplySet.get(name);
}
/**
* Finds and returns an existing Quick Reply by its set's name and its label.
*
* @param {String} setName name of the quick reply set
* @param {String} label label of the quick reply
* @returns the quick reply, or undefined if not found
*/
getQrByLabel(setName, label) {
const set = this.getSetByName(setName);
if (!set) return;
return set.qrList.find(it=>it.label == label);
}
/**
* Executes a quick reply by its index and returns the result.
*
* @param {Number} idx the index (zero-based) of the quick reply to execute
* @returns the return value of the quick reply, or undefined if not found
*/
async executeQuickReplyByIndex(idx) {
const qr = [...this.settings.config.setList, ...(this.settings.chatConfig?.setList ?? [])]
.map(it=>it.set.qrList)
.flat()[idx]
;
if (qr) {
return await qr.onExecute();
} else {
throw new Error(`No quick reply at index "${idx}"`);
}
}
/**
* Executes an existing quick reply.
*
* @param {String} setName name of the existing quick reply set
* @param {String} label label of the existing quick reply (text on the button)
* @param {Object} [args] optional arguments
*/
async executeQuickReply(setName, label, args = {}) {
const qr = this.getQrByLabel(setName, label);
if (!qr) {
throw new Error(`No quick reply with label "${label}" in set "${setName}" found.`);
}
return await qr.execute(args);
}
/**
* Adds or removes a quick reply set to the list of globally active quick reply sets.
*
* @param {String} name the name of the set
* @param {Boolean} isVisible whether to show the set's buttons or not
*/
toggleGlobalSet(name, isVisible = true) {
const set = this.getSetByName(name);
if (!set) {
throw new Error(`No quick reply set with name "${name}" found.`);
}
if (this.settings.config.hasSet(set)) {
this.settings.config.removeSet(set);
} else {
this.settings.config.addSet(set, isVisible);
}
}
/**
* Adds a quick reply set to the list of globally active quick reply sets.
*
* @param {String} name the name of the set
* @param {Boolean} isVisible whether to show the set's buttons or not
*/
addGlobalSet(name, isVisible = true) {
const set = this.getSetByName(name);
if (!set) {
throw new Error(`No quick reply set with name "${name}" found.`);
}
this.settings.config.addSet(set, isVisible);
}
/**
* Removes a quick reply set from the list of globally active quick reply sets.
*
* @param {String} name the name of the set
*/
removeGlobalSet(name) {
const set = this.getSetByName(name);
if (!set) {
throw new Error(`No quick reply set with name "${name}" found.`);
}
this.settings.config.removeSet(set);
}
/**
* Adds or removes a quick reply set to the list of the current chat's active quick reply sets.
*
* @param {String} name the name of the set
* @param {Boolean} isVisible whether to show the set's buttons or not
*/
toggleChatSet(name, isVisible = true) {
if (!this.settings.chatConfig) return;
const set = this.getSetByName(name);
if (!set) {
throw new Error(`No quick reply set with name "${name}" found.`);
}
if (this.settings.chatConfig.hasSet(set)) {
this.settings.chatConfig.removeSet(set);
} else {
this.settings.chatConfig.addSet(set, isVisible);
}
}
/**
* Adds a quick reply set to the list of the current chat's active quick reply sets.
*
* @param {String} name the name of the set
* @param {Boolean} isVisible whether to show the set's buttons or not
*/
addChatSet(name, isVisible = true) {
if (!this.settings.chatConfig) return;
const set = this.getSetByName(name);
if (!set) {
throw new Error(`No quick reply set with name "${name}" found.`);
}
this.settings.chatConfig.addSet(set, isVisible);
}
/**
* Removes a quick reply set from the list of the current chat's active quick reply sets.
*
* @param {String} name the name of the set
*/
removeChatSet(name) {
if (!this.settings.chatConfig) return;
const set = this.getSetByName(name);
if (!set) {
throw new Error(`No quick reply set with name "${name}" found.`);
}
this.settings.chatConfig.removeSet(set);
}
/**
* Creates a new quick reply in an existing quick reply set.
*
* @param {String} setName name of the quick reply set to insert the new quick reply into
* @param {String} label label for the new quick reply (text on the button)
* @param {Object} [props]
* @param {String} [props.message] the message to be sent or slash command to be executed by the new quick reply
* @param {String} [props.title] the title / tooltip to be shown on the quick reply button
* @param {Boolean} [props.isHidden] whether to hide or show the button
* @param {Boolean} [props.executeOnStartup] whether to execute the quick reply when SillyTavern starts
* @param {Boolean} [props.executeOnUser] whether to execute the quick reply after a user has sent a message
* @param {Boolean} [props.executeOnAi] whether to execute the quick reply after the AI has sent a message
* @param {Boolean} [props.executeOnChatChange] whether to execute the quick reply when a new chat is loaded
* @returns {QuickReply} the new quick reply
*/
createQuickReply(setName, label, {
message,
title,
isHidden,
executeOnStartup,
executeOnUser,
executeOnAi,
executeOnChatChange,
} = {}) {
const set = this.getSetByName(setName);
if (!set) {
throw new Error(`No quick reply set with named "${setName}" found.`);
}
const qr = set.addQuickReply();
qr.label = label ?? '';
qr.message = message ?? '';
qr.title = title ?? '';
qr.isHidden = isHidden ?? false;
qr.executeOnStartup = executeOnStartup ?? false;
qr.executeOnUser = executeOnUser ?? false;
qr.executeOnAi = executeOnAi ?? false;
qr.executeOnChatChange = executeOnChatChange ?? false;
qr.onUpdate();
return qr;
}
/**
* Updates an existing quick reply.
*
* @param {String} setName name of the existing quick reply set
* @param {String} label label of the existing quick reply (text on the button)
* @param {Object} [props]
* @param {String} [props.newLabel] new label for quick reply (text on the button)
* @param {String} [props.message] the message to be sent or slash command to be executed by the quick reply
* @param {String} [props.title] the title / tooltip to be shown on the quick reply button
* @param {Boolean} [props.isHidden] whether to hide or show the button
* @param {Boolean} [props.executeOnStartup] whether to execute the quick reply when SillyTavern starts
* @param {Boolean} [props.executeOnUser] whether to execute the quick reply after a user has sent a message
* @param {Boolean} [props.executeOnAi] whether to execute the quick reply after the AI has sent a message
* @param {Boolean} [props.executeOnChatChange] whether to execute the quick reply when a new chat is loaded
* @returns {QuickReply} the altered quick reply
*/
updateQuickReply(setName, label, {
newLabel,
message,
title,
isHidden,
executeOnStartup,
executeOnUser,
executeOnAi,
executeOnChatChange,
} = {}) {
const qr = this.getQrByLabel(setName, label);
if (!qr) {
throw new Error(`No quick reply with label "${label}" in set "${setName}" found.`);
}
qr.label = newLabel ?? qr.label;
qr.message = message ?? qr.message;
qr.title = title ?? qr.title;
qr.isHidden = isHidden ?? qr.isHidden;
qr.executeOnStartup = executeOnStartup ?? qr.executeOnStartup;
qr.executeOnUser = executeOnUser ?? qr.executeOnUser;
qr.executeOnAi = executeOnAi ?? qr.executeOnAi;
qr.executeOnChatChange = executeOnChatChange ?? qr.executeOnChatChange;
qr.onUpdate();
return qr;
}
/**
* Deletes an existing quick reply.
*
* @param {String} setName name of the existing quick reply set
* @param {String} label label of the existing quick reply (text on the button)
*/
deleteQuickReply(setName, label) {
const qr = this.getQrByLabel(setName, label);
if (!qr) {
throw new Error(`No quick reply with label "${label}" in set "${setName}" found.`);
}
qr.delete();
}
/**
* Adds an existing quick reply set as a context menu to an existing quick reply.
*
* @param {String} setName name of the existing quick reply set containing the quick reply
* @param {String} label label of the existing quick reply
* @param {String} contextSetName name of the existing quick reply set to be used as a context menu
* @param {Boolean} isChained whether or not to chain the context menu quick replies
*/
createContextItem(setName, label, contextSetName, isChained = false) {
const qr = this.getQrByLabel(setName, label);
const set = this.getSetByName(contextSetName);
if (!qr) {
throw new Error(`No quick reply with label "${label}" in set "${setName}" found.`);
}
if (!set) {
throw new Error(`No quick reply set with name "${contextSetName}" found.`);
}
const cl = new QuickReplyContextLink();
cl.set = set;
cl.isChained = isChained;
qr.addContextLink(cl);
}
/**
* Removes a quick reply set from a quick reply's context menu.
*
* @param {String} setName name of the existing quick reply set containing the quick reply
* @param {String} label label of the existing quick reply
* @param {String} contextSetName name of the existing quick reply set to be used as a context menu
*/
deleteContextItem(setName, label, contextSetName) {
const qr = this.getQrByLabel(setName, label);
const set = this.getSetByName(contextSetName);
if (!qr) {
throw new Error(`No quick reply with label "${label}" in set "${setName}" found.`);
}
if (!set) {
throw new Error(`No quick reply set with name "${contextSetName}" found.`);
}
qr.removeContextLink(set.name);
}
/**
* Removes all entries from a quick reply's context menu.
*
* @param {String} setName name of the existing quick reply set containing the quick reply
* @param {String} label label of the existing quick reply
*/
clearContextMenu(setName, label) {
const qr = this.getQrByLabel(setName, label);
if (!qr) {
throw new Error(`No quick reply with label "${label}" in set "${setName}" found.`);
}
qr.clearContextLinks();
}
/**
* Create a new quick reply set.
*
* @param {String} name name of the new quick reply set
* @param {Object} [props]
* @param {Boolean} [props.disableSend] whether or not to send the quick replies or put the message or slash command into the char input box
* @param {Boolean} [props.placeBeforeInput] whether or not to place the quick reply contents before the existing user input
* @param {Boolean} [props.injectInput] whether or not to automatically inject the user input at the end of the quick reply
* @returns {Promise<QuickReplySet>} the new quick reply set
*/
async createSet(name, {
disableSend,
placeBeforeInput,
injectInput,
} = {}) {
const set = new QuickReplySet();
set.name = name;
set.disableSend = disableSend ?? false;
set.placeBeforeInput = placeBeforeInput ?? false;
set.injectInput = injectInput ?? false;
const oldSet = this.getSetByName(name);
if (oldSet) {
QuickReplySet.list.splice(QuickReplySet.list.indexOf(oldSet), 1, set);
} else {
const idx = QuickReplySet.list.findIndex(it=>it.name.localeCompare(name) == 1);
if (idx > -1) {
QuickReplySet.list.splice(idx, 0, set);
} else {
QuickReplySet.list.push(set);
}
}
await set.save();
this.settingsUi.rerender();
return set;
}
/**
* Update an existing quick reply set.
*
* @param {String} name name of the existing quick reply set
* @param {Object} [props]
* @param {Boolean} [props.disableSend] whether or not to send the quick replies or put the message or slash command into the char input box
* @param {Boolean} [props.placeBeforeInput] whether or not to place the quick reply contents before the existing user input
* @param {Boolean} [props.injectInput] whether or not to automatically inject the user input at the end of the quick reply
* @returns {Promise<QuickReplySet>} the altered quick reply set
*/
async updateSet(name, {
disableSend,
placeBeforeInput,
injectInput,
} = {}) {
const set = this.getSetByName(name);
if (!set) {
throw new Error(`No quick reply set with name "${name}" found.`);
}
set.disableSend = disableSend ?? false;
set.placeBeforeInput = placeBeforeInput ?? false;
set.injectInput = injectInput ?? false;
await set.save();
this.settingsUi.rerender();
return set;
}
/**
* Delete an existing quick reply set.
*
* @param {String} name name of the existing quick reply set
*/
async deleteSet(name) {
const set = this.getSetByName(name);
if (!set) {
throw new Error(`No quick reply set with name "${name}" found.`);
}
await set.delete();
this.settingsUi.rerender();
}
/**
* Gets a list of all quick reply sets.
*
* @returns array with the names of all quick reply sets
*/
listSets() {
return QuickReplySet.list.map(it=>it.name);
}
/**
* Gets a list of all globally active quick reply sets.
*
* @returns array with the names of all quick reply sets
*/
listGlobalSets() {
return this.settings.config.setList.map(it=>it.set.name);
}
/**
* Gets a list of all quick reply sets activated by the current chat.
*
* @returns array with the names of all quick reply sets
*/
listChatSets() {
return this.settings.chatConfig?.setList?.flatMap(it=>it.set.name) ?? [];
}
/**
* Gets a list of all quick replies in the quick reply set.
*
* @param {String} setName name of the existing quick reply set
* @returns array with the labels of this set's quick replies
*/
listQuickReplies(setName) {
const set = this.getSetByName(setName);
if (!set) {
throw new Error(`No quick reply set with name "${name}" found.`);
}
return set.qrList.map(it=>it.label);
}
}

View File

@@ -1,49 +0,0 @@
<div id="quickReply_contextMenuEditor_template">
<div class="quickReply_contextMenuEditor">
<h3><strong>Context Menu Editor</strong></h3>
<div id="quickReply_contextMenuEditor_content">
<template id="quickReply_contextMenuEditor_itemTemplate">
<div class="quickReplyContextMenuEditor_item flex-container alignitemscenter" data-order="0">
<span class="drag-handle ui-sortable-handle"></span>
<select class="quickReply_contextMenuEditor_preset"></select>
<label class="flex-container" title="When enabled, the current Quick Reply will be sent together with (before) the clicked QR from the context menu.">
Chaining:
<input type="checkbox" class="quickReply_contextMenuEditor_chaining">
</label>
<span class="quickReply_contextMenuEditor_remove menu_button menu_button_icon fa-solid fa-trash-can" title="Remove entry"></span>
</div>
</template>
</div>
<div class="quickReply_contextMenuEditor_actions">
<span id="quickReply_contextMenuEditor_addPreset" class="menu_button menu_button_icon fa-solid fa-plus" title="Add preset to context menu"></span>
</div>
<h3><strong>Auto-Execute</strong></h3>
<div class="flex-container flexFlowColumn">
<label class="checkbox_label" for="quickReply_hidden">
<input type="checkbox" id="quickReply_hidden" >
<span><i class="fa-solid fa-fw fa-eye-slash"></i> Invisible (auto-execute only)</span>
</label>
<label class="checkbox_label" for="quickReply_autoExecute_appStartup">
<input type="checkbox" id="quickReply_autoExecute_appStartup" >
<span><i class="fa-solid fa-fw fa-rocket"></i> Execute on app startup</span>
</label>
<label class="checkbox_label" for="quickReply_autoExecute_userMessage">
<input type="checkbox" id="quickReply_autoExecute_userMessage" >
<span><i class="fa-solid fa-fw fa-user"></i> Execute on user message</span>
</label>
<label class="checkbox_label" for="quickReply_autoExecute_botMessage">
<input type="checkbox" id="quickReply_autoExecute_botMessage" >
<span><i class="fa-solid fa-fw fa-robot"></i> Execute on AI message</span>
</label>
<label class="checkbox_label" for="quickReply_autoExecute_chatLoad">
<input type="checkbox" id="quickReply_autoExecute_chatLoad" >
<span><i class="fa-solid fa-fw fa-message"></i> Execute on opening chat</span>
</label>
</div>
<h3><strong>UI Options</strong></h3>
<div class="flex-container flexFlowColumn">
<label for="quickReply_ui_title">Title (tooltip, leave empty to show the message or /command)</label>
<input type="text" class="text_pole" id="quickReply_ui_title">
</div>
</div>
</div>

View File

@@ -0,0 +1,83 @@
<div id="qr--modalEditor">
<div id="qr--main">
<h3>Labels and Message</h3>
<div class="qr--labels">
<label>
<span class="qr--labelText">Label</span>
<input type="text" class="text_pole" id="qr--modal-label">
</label>
<label>
<span class="qr--labelText">Title</span>
<small class="qr--labelHint">(tooltip, leave empty to show message or /command)</small>
<input type="text" class="text_pole" id="qr--modal-title">
</label>
</div>
<div class="qr--modal-messageContainer">
<label for="qr--modal-message">Message / Command:</label>
<textarea class="monospace" id="qr--modal-message"></textarea>
</div>
</div>
<div id="qr--qrOptions">
<h3>Context Menu</h3>
<div id="qr--ctxEditor">
<template id="qr--ctxItem">
<div class="qr--ctxItem" data-order="0">
<div class="drag-handle ui-sortable-handle"></div>
<select class="qr--set"></select>
<label class="qr--isChainedLabel checkbox_label" title="When enabled, the current Quick Reply will be sent together with (before) the clicked QR from the context menu.">
Chaining:
<input type="checkbox" class="qr--isChained">
</label>
<div class="qr--delete menu_button menu_button_icon fa-solid fa-trash-can" title="Remove entry"></div>
</div>
</template>
</div>
<div class="qr--ctxEditorActions">
<span id="qr--ctxAdd" class="menu_button menu_button_icon fa-solid fa-plus" title="Add quick reply set to context menu"></span>
</div>
<h3>Auto-Execute</h3>
<div class="flex-container flexFlowColumn">
<label class="checkbox_label" title="Prevent this quick reply from triggering other auto-executed quick replies while auto-executing (i.e., prevent recursive auto-execution)">
<input type="checkbox" id="qr--preventAutoExecute" >
<span><i class="fa-solid fa-fw fa-plane-slash"></i> Don't trigger auto-execute</span>
</label>
<label class="checkbox_label">
<input type="checkbox" id="qr--isHidden" >
<span><i class="fa-solid fa-fw fa-eye-slash"></i> Invisible (auto-execute only)</span>
</label>
<label class="checkbox_label">
<input type="checkbox" id="qr--executeOnStartup" >
<span><i class="fa-solid fa-fw fa-rocket"></i> Execute on app startup</span>
</label>
<label class="checkbox_label">
<input type="checkbox" id="qr--executeOnUser" >
<span><i class="fa-solid fa-fw fa-user"></i> Execute on user message</span>
</label>
<label class="checkbox_label">
<input type="checkbox" id="qr--executeOnAi" >
<span><i class="fa-solid fa-fw fa-robot"></i> Execute on AI message</span>
</label>
<label class="checkbox_label">
<input type="checkbox" id="qr--executeOnChatChange" >
<span><i class="fa-solid fa-fw fa-message"></i> Execute on opening chat</span>
</label>
</div>
<h3>Testing</h3>
<div id="qr--modal-execute" class="menu_button" title="Execute the quick reply now">
<i class="fa-solid fa-play"></i>
Execute
</div>
<label class="checkbox_label">
<input type="checkbox" id="qr--modal-executeHide">
<span> Hide editor while executing</span>
</label>
<div id="qr--modal-executeErrors"></div>
</div>
</div>

View File

@@ -0,0 +1,71 @@
<div id="qr--settings">
<div class="inline-drawer">
<div class="inline-drawer-toggle inline-drawer-header">
<strong>Quick Reply</strong>
<div class="inline-drawer-icon fa-solid fa-circle-chevron-down down"></div>
</div>
<div class="inline-drawer-content">
<label class="flex-container">
<input type="checkbox" id="qr--isEnabled"> Enable Quick Replies
</label>
<label class="flex-container">
<input type="checkbox" id="qr--isCombined"> Combine buttons from all active sets
</label>
<hr>
<div id="qr--global">
<div class="qr--head">
<div class="qr--title">Global Quick Reply Sets</div>
<div class="qr--actions">
<div class="qr--setListAdd menu_button menu_button_icon fa-solid fa-plus" id="qr--global-setListAdd" title="Add quick reply set"></div>
</div>
</div>
<div id="qr--global-setList" class="qr--setList"></div>
</div>
<hr>
<div id="qr--chat">
<div class="qr--head">
<div class="qr--title">Chat Quick Reply Sets</div>
<div class="qr--actions">
<div class="qr--setListAdd menu_button menu_button_icon fa-solid fa-plus" id="qr--chat-setListAdd" title="Add quick reply set"></div>
</div>
</div>
<div id="qr--chat-setList" class="qr--setList"></div>
</div>
<hr>
<div id="qr--editor">
<div class="qr--head">
<div class="qr--title">Edit Quick Replies</div>
<div class="qr--actions">
<select id="qr--set" class="text_pole"></select>
<div class="qr--add menu_button menu_button_icon fa-solid fa-plus" id="qr--set-new" title="Create new quick reply set"></div>
<div class="qr--add menu_button menu_button_icon fa-solid fa-file-import" id="qr--set-import" title="Import quick reply set"></div>
<input type="file" id="qr--set-importFile" accept=".json" hidden>
<div class="qr--add menu_button menu_button_icon fa-solid fa-file-export" id="qr--set-export" title="Export quick reply set"></div>
<div class="qr--del menu_button menu_button_icon fa-solid fa-trash redWarningBG" id="qr--set-delete" title="Delete quick reply set"></div>
</div>
</div>
<div id="qr--set-settings">
<label class="flex-container">
<input type="checkbox" id="qr--disableSend"> <span>Disable send (insert into input field)</span>
</label>
<label class="flex-container">
<input type="checkbox" id="qr--placeBeforeInput"> <span>Place quick reply before input</span>
</label>
<label class="flex-container" id="qr--injectInputContainer">
<input type="checkbox" id="qr--injectInput"> <span>Inject user input automatically <small>(if disabled, use <code>{{input}}</code> macro for manual injection)</small></span>
</label>
</div>
<div id="qr--set-qrList" class="qr--qrList"></div>
<div class="qr--set-qrListActions">
<div class="qr--add menu_button menu_button_icon fa-solid fa-plus" id="qr--set-add" title="Add quick reply"></div>
</div>
</div>
</div>
</div>
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -6,6 +6,6 @@
"js": "index.js",
"css": "style.css",
"author": "RossAscends#1779",
"version": "1.0.0",
"version": "2.0.0",
"homePage": "https://github.com/SillyTavern/SillyTavern"
}
}

View File

@@ -0,0 +1,76 @@
import { warn } from '../index.js';
// eslint-disable-next-line no-unused-vars
import { QuickReply } from './QuickReply.js';
// eslint-disable-next-line no-unused-vars
import { QuickReplySettings } from './QuickReplySettings.js';
export class AutoExecuteHandler {
/**@type {QuickReplySettings}*/ settings;
/**@type {Boolean[]}*/ preventAutoExecuteStack = [];
constructor(/**@type {QuickReplySettings}*/settings) {
this.settings = settings;
}
checkExecute() {
return this.settings.isEnabled && !this.preventAutoExecuteStack.slice(-1)[0];
}
async performAutoExecute(/**@type {QuickReply[]}*/qrList) {
for (const qr of qrList) {
this.preventAutoExecuteStack.push(qr.preventAutoExecute);
try {
await qr.execute({ isAutoExecute:true });
} catch (ex) {
warn(ex);
} finally {
this.preventAutoExecuteStack.pop();
}
}
}
async handleStartup() {
if (!this.checkExecute()) return;
const qrList = [
...this.settings.config.setList.map(link=>link.set.qrList.filter(qr=>qr.executeOnStartup)).flat(),
...(this.settings.chatConfig?.setList?.map(link=>link.set.qrList.filter(qr=>qr.executeOnStartup))?.flat() ?? []),
];
await this.performAutoExecute(qrList);
}
async handleUser() {
if (!this.checkExecute()) return;
const qrList = [
...this.settings.config.setList.map(link=>link.set.qrList.filter(qr=>qr.executeOnUser)).flat(),
...(this.settings.chatConfig?.setList?.map(link=>link.set.qrList.filter(qr=>qr.executeOnUser))?.flat() ?? []),
];
await this.performAutoExecute(qrList);
}
async handleAi() {
if (!this.checkExecute()) return;
const qrList = [
...this.settings.config.setList.map(link=>link.set.qrList.filter(qr=>qr.executeOnAi)).flat(),
...(this.settings.chatConfig?.setList?.map(link=>link.set.qrList.filter(qr=>qr.executeOnAi))?.flat() ?? []),
];
await this.performAutoExecute(qrList);
}
async handleChatChanged() {
if (!this.checkExecute()) return;
const qrList = [
...this.settings.config.setList.map(link=>link.set.qrList.filter(qr=>qr.executeOnChatChange)).flat(),
...(this.settings.chatConfig?.setList?.map(link=>link.set.qrList.filter(qr=>qr.executeOnChatChange))?.flat() ?? []),
];
await this.performAutoExecute(qrList);
}
}

View File

@@ -1,67 +0,0 @@
/**
* @typedef {import('./MenuItem.js').MenuItem} MenuItem
*/
export class ContextMenu {
/**@type {MenuItem[]}*/ itemList = [];
/**@type {Boolean}*/ isActive = false;
/**@type {HTMLElement}*/ root;
/**@type {HTMLElement}*/ menu;
constructor(/**@type {MenuItem[]}*/items) {
this.itemList = items;
items.forEach(item => {
item.onExpand = () => {
items.filter(it => it != item)
.forEach(it => it.collapse());
};
});
}
render() {
if (!this.root) {
const blocker = document.createElement('div'); {
this.root = blocker;
blocker.classList.add('ctx-blocker');
blocker.addEventListener('click', () => this.hide());
const menu = document.createElement('ul'); {
this.menu = menu;
menu.classList.add('list-group');
menu.classList.add('ctx-menu');
this.itemList.forEach(it => menu.append(it.render()));
blocker.append(menu);
}
}
}
return this.root;
}
show({ clientX, clientY }) {
if (this.isActive) return;
this.isActive = true;
this.render();
this.menu.style.bottom = `${window.innerHeight - clientY}px`;
this.menu.style.left = `${clientX}px`;
document.body.append(this.root);
}
hide() {
if (this.root) {
this.root.remove();
}
this.isActive = false;
}
toggle(/**@type {PointerEvent}*/evt) {
if (this.isActive) {
this.hide();
} else {
this.show(evt);
}
}
}

View File

@@ -0,0 +1,489 @@
import { callPopup } from '../../../../script.js';
import { getSortableDelay } from '../../../utils.js';
import { log, warn } from '../index.js';
import { QuickReplyContextLink } from './QuickReplyContextLink.js';
import { QuickReplySet } from './QuickReplySet.js';
import { ContextMenu } from './ui/ctx/ContextMenu.js';
export class QuickReply {
/**
* @param {{ id?: number; contextList?: any; }} props
*/
static from(props) {
props.contextList = (props.contextList ?? []).map((/** @type {any} */ it)=>QuickReplyContextLink.from(it));
return Object.assign(new this(), props);
}
/**@type {Number}*/ id;
/**@type {String}*/ label = '';
/**@type {String}*/ title = '';
/**@type {String}*/ message = '';
/**@type {QuickReplyContextLink[]}*/ contextList;
/**@type {Boolean}*/ preventAutoExecute = true;
/**@type {Boolean}*/ isHidden = false;
/**@type {Boolean}*/ executeOnStartup = false;
/**@type {Boolean}*/ executeOnUser = false;
/**@type {Boolean}*/ executeOnAi = false;
/**@type {Boolean}*/ executeOnChatChange = false;
/**@type {Function}*/ onExecute;
/**@type {Function}*/ onDelete;
/**@type {Function}*/ onUpdate;
/**@type {HTMLElement}*/ dom;
/**@type {HTMLElement}*/ domLabel;
/**@type {HTMLElement}*/ settingsDom;
/**@type {HTMLInputElement}*/ settingsDomLabel;
/**@type {HTMLTextAreaElement}*/ settingsDomMessage;
get hasContext() {
return this.contextList && this.contextList.length > 0;
}
unrender() {
this.dom?.remove();
this.dom = null;
}
updateRender() {
if (!this.dom) return;
this.dom.title = this.title || this.message;
this.domLabel.textContent = this.label;
this.dom.classList[this.hasContext ? 'add' : 'remove']('qr--hasCtx');
}
render() {
this.unrender();
if (!this.dom) {
const root = document.createElement('div'); {
this.dom = root;
root.classList.add('qr--button');
root.classList.add('menu_button');
if (this.hasContext) {
root.classList.add('qr--hasCtx');
}
root.title = this.title || this.message;
root.addEventListener('contextmenu', (evt) => {
log('contextmenu', this, this.hasContext);
if (this.hasContext) {
evt.preventDefault();
evt.stopPropagation();
const menu = new ContextMenu(this);
menu.show(evt);
}
});
root.addEventListener('click', (evt)=>{
if (evt.ctrlKey) {
this.showEditor();
return;
}
this.execute();
});
const lbl = document.createElement('div'); {
this.domLabel = lbl;
lbl.classList.add('qr--button-label');
lbl.textContent = this.label;
root.append(lbl);
}
const expander = document.createElement('div'); {
expander.classList.add('qr--button-expander');
expander.textContent = '⋮';
expander.title = 'Open context menu';
expander.addEventListener('click', (evt) => {
evt.stopPropagation();
evt.preventDefault();
const menu = new ContextMenu(this);
menu.show(evt);
});
root.append(expander);
}
}
}
return this.dom;
}
renderSettings(idx) {
if (!this.settingsDom) {
const item = document.createElement('div'); {
this.settingsDom = item;
item.classList.add('qr--set-item');
item.setAttribute('data-order', String(idx));
item.setAttribute('data-id', String(this.id));
const drag = document.createElement('div'); {
drag.classList.add('drag-handle');
drag.classList.add('ui-sortable-handle');
drag.textContent = '☰';
item.append(drag);
}
const lblContainer = document.createElement('div'); {
lblContainer.classList.add('qr--set-itemLabelContainer');
const lbl = document.createElement('input'); {
this.settingsDomLabel = lbl;
lbl.classList.add('qr--set-itemLabel');
lbl.classList.add('text_pole');
lbl.value = this.label;
lbl.addEventListener('input', ()=>this.updateLabel(lbl.value));
lblContainer.append(lbl);
}
item.append(lblContainer);
}
const optContainer = document.createElement('div'); {
optContainer.classList.add('qr--set-optionsContainer');
const opt = document.createElement('div'); {
opt.classList.add('qr--action');
opt.classList.add('menu_button');
opt.classList.add('fa-solid');
opt.textContent = '⁝';
opt.title = 'Additional options:\n - large editor\n - context menu\n - auto-execution\n - tooltip';
opt.addEventListener('click', ()=>this.showEditor());
optContainer.append(opt);
}
item.append(optContainer);
}
const mes = document.createElement('textarea'); {
this.settingsDomMessage = mes;
mes.id = `qr--set--item${this.id}`;
mes.classList.add('qr--set-itemMessage');
mes.value = this.message;
//HACK need to use jQuery to catch the triggered event from the expanded editor
$(mes).on('input', ()=>this.updateMessage(mes.value));
item.append(mes);
}
const actions = document.createElement('div'); {
actions.classList.add('qr--actions');
const del = document.createElement('div'); {
del.classList.add('qr--action');
del.classList.add('menu_button');
del.classList.add('menu_button_icon');
del.classList.add('fa-solid');
del.classList.add('fa-trash-can');
del.classList.add('redWarningBG');
del.title = 'Remove quick reply';
del.addEventListener('click', ()=>this.delete());
actions.append(del);
}
item.append(actions);
}
}
}
return this.settingsDom;
}
unrenderSettings() {
this.settingsDom?.remove();
}
async showEditor() {
const response = await fetch('/scripts/extensions/quick-reply/html/qrEditor.html', { cache: 'no-store' });
if (response.ok) {
this.template = document.createRange().createContextualFragment(await response.text()).querySelector('#qr--modalEditor');
/**@type {HTMLElement} */
// @ts-ignore
const dom = this.template.cloneNode(true);
const popupResult = callPopup(dom, 'text', undefined, { okButton: 'OK', wide: true, large: true, rows: 1 });
// basics
/**@type {HTMLInputElement}*/
const label = dom.querySelector('#qr--modal-label');
label.value = this.label;
label.addEventListener('input', ()=>{
this.updateLabel(label.value);
});
/**@type {HTMLInputElement}*/
const title = dom.querySelector('#qr--modal-title');
title.value = this.title;
title.addEventListener('input', () => {
this.updateTitle(title.value);
});
/**@type {HTMLTextAreaElement}*/
const message = dom.querySelector('#qr--modal-message');
message.value = this.message;
message.addEventListener('input', () => {
this.updateMessage(message.value);
});
//TODO move tab support for textarea into its own helper(?) and use for both this and .editor_maximize
message.addEventListener('keydown', (evt) => {
if (evt.key == 'Tab' && !evt.shiftKey && !evt.ctrlKey && !evt.altKey) {
evt.preventDefault();
const start = message.selectionStart;
const end = message.selectionEnd;
if (end - start > 0 && message.value.substring(start, end).includes('\n')) {
const lineStart = message.value.lastIndexOf('\n', start);
const count = message.value.substring(lineStart, end).split('\n').length - 1;
message.value = `${message.value.substring(0, lineStart)}${message.value.substring(lineStart, end).replace(/\n/g, '\n\t')}${message.value.substring(end)}`;
message.selectionStart = start + 1;
message.selectionEnd = end + count;
this.updateMessage(message.value);
} else {
message.value = `${message.value.substring(0, start)}\t${message.value.substring(end)}`;
message.selectionStart = start + 1;
message.selectionEnd = end + 1;
this.updateMessage(message.value);
}
} else if (evt.key == 'Tab' && evt.shiftKey && !evt.ctrlKey && !evt.altKey) {
evt.preventDefault();
const start = message.selectionStart;
const end = message.selectionEnd;
const lineStart = message.value.lastIndexOf('\n', start);
const count = message.value.substring(lineStart, end).split('\n\t').length - 1;
message.value = `${message.value.substring(0, lineStart)}${message.value.substring(lineStart, end).replace(/\n\t/g, '\n')}${message.value.substring(end)}`;
message.selectionStart = start - 1;
message.selectionEnd = end - count;
this.updateMessage(message.value);
}
});
// context menu
/**@type {HTMLTemplateElement}*/
const tpl = dom.querySelector('#qr--ctxItem');
const linkList = dom.querySelector('#qr--ctxEditor');
const fillQrSetSelect = (/**@type {HTMLSelectElement}*/select, /**@type {QuickReplyContextLink}*/ link) => {
[{ name: 'Select a QR set' }, ...QuickReplySet.list].forEach(qrs => {
const opt = document.createElement('option'); {
opt.value = qrs.name;
opt.textContent = qrs.name;
opt.selected = qrs.name == link.set?.name;
select.append(opt);
}
});
};
const addCtxItem = (/**@type {QuickReplyContextLink}*/link, /**@type {Number}*/idx) => {
/**@type {HTMLElement} */
// @ts-ignore
const itemDom = tpl.content.querySelector('.qr--ctxItem').cloneNode(true); {
itemDom.setAttribute('data-order', String(idx));
/**@type {HTMLSelectElement} */
const select = itemDom.querySelector('.qr--set');
fillQrSetSelect(select, link);
select.addEventListener('change', () => {
link.set = QuickReplySet.get(select.value);
this.updateContext();
});
/**@type {HTMLInputElement} */
const chain = itemDom.querySelector('.qr--isChained');
chain.checked = link.isChained;
chain.addEventListener('click', () => {
link.isChained = chain.checked;
this.updateContext();
});
itemDom.querySelector('.qr--delete').addEventListener('click', () => {
itemDom.remove();
this.contextList.splice(this.contextList.indexOf(link), 1);
this.updateContext();
});
linkList.append(itemDom);
}
};
[...this.contextList].forEach((link, idx) => addCtxItem(link, idx));
dom.querySelector('#qr--ctxAdd').addEventListener('click', () => {
const link = new QuickReplyContextLink();
this.contextList.push(link);
addCtxItem(link, this.contextList.length - 1);
});
const onContextSort = () => {
this.contextList = Array.from(linkList.querySelectorAll('.qr--ctxItem')).map((it,idx) => {
const link = this.contextList[Number(it.getAttribute('data-order'))];
it.setAttribute('data-order', String(idx));
return link;
});
this.updateContext();
};
// @ts-ignore
$(linkList).sortable({
delay: getSortableDelay(),
stop: () => onContextSort(),
});
// auto-exec
/**@type {HTMLInputElement}*/
const preventAutoExecute = dom.querySelector('#qr--preventAutoExecute');
preventAutoExecute.checked = this.preventAutoExecute;
preventAutoExecute.addEventListener('click', ()=>{
this.preventAutoExecute = preventAutoExecute.checked;
this.updateContext();
});
/**@type {HTMLInputElement}*/
const isHidden = dom.querySelector('#qr--isHidden');
isHidden.checked = this.isHidden;
isHidden.addEventListener('click', ()=>{
this.isHidden = isHidden.checked;
this.updateContext();
});
/**@type {HTMLInputElement}*/
const executeOnStartup = dom.querySelector('#qr--executeOnStartup');
executeOnStartup.checked = this.executeOnStartup;
executeOnStartup.addEventListener('click', ()=>{
this.executeOnStartup = executeOnStartup.checked;
this.updateContext();
});
/**@type {HTMLInputElement}*/
const executeOnUser = dom.querySelector('#qr--executeOnUser');
executeOnUser.checked = this.executeOnUser;
executeOnUser.addEventListener('click', ()=>{
this.executeOnUser = executeOnUser.checked;
this.updateContext();
});
/**@type {HTMLInputElement}*/
const executeOnAi = dom.querySelector('#qr--executeOnAi');
executeOnAi.checked = this.executeOnAi;
executeOnAi.addEventListener('click', ()=>{
this.executeOnAi = executeOnAi.checked;
this.updateContext();
});
/**@type {HTMLInputElement}*/
const executeOnChatChange = dom.querySelector('#qr--executeOnChatChange');
executeOnChatChange.checked = this.executeOnChatChange;
executeOnChatChange.addEventListener('click', ()=>{
this.executeOnChatChange = executeOnChatChange.checked;
this.updateContext();
});
/**@type {HTMLElement}*/
const executeErrors = dom.querySelector('#qr--modal-executeErrors');
/**@type {HTMLInputElement}*/
const executeHide = dom.querySelector('#qr--modal-executeHide');
let executePromise;
/**@type {HTMLElement}*/
const executeBtn = dom.querySelector('#qr--modal-execute');
executeBtn.addEventListener('click', async()=>{
if (executePromise) return;
executeBtn.classList.add('qr--busy');
executeErrors.innerHTML = '';
if (executeHide.checked) {
document.querySelector('#shadow_popup').classList.add('qr--hide');
}
try {
executePromise = this.execute();
await executePromise;
} catch (ex) {
executeErrors.textContent = ex.message;
}
executePromise = null;
executeBtn.classList.remove('qr--busy');
document.querySelector('#shadow_popup').classList.remove('qr--hide');
});
await popupResult;
} else {
warn('failed to fetch qrEditor template');
}
}
delete() {
if (this.onDelete) {
this.unrender();
this.unrenderSettings();
this.onDelete(this);
}
}
/**
* @param {string} value
*/
updateMessage(value) {
if (this.onUpdate) {
if (this.settingsDomMessage && this.settingsDomMessage.value != value) {
this.settingsDomMessage.value = value;
}
this.message = value;
this.updateRender();
this.onUpdate(this);
}
}
/**
* @param {string} value
*/
updateLabel(value) {
if (this.onUpdate) {
if (this.settingsDomLabel && this.settingsDomLabel.value != value) {
this.settingsDomLabel.value = value;
}
this.label = value;
this.updateRender();
this.onUpdate(this);
}
}
/**
* @param {string} value
*/
updateTitle(value) {
if (this.onUpdate) {
this.title = value;
this.updateRender();
this.onUpdate(this);
}
}
updateContext() {
if (this.onUpdate) {
this.updateRender();
this.onUpdate(this);
}
}
addContextLink(cl) {
this.contextList.push(cl);
this.updateContext();
}
removeContextLink(setName) {
const idx = this.contextList.findIndex(it=>it.set.name == setName);
if (idx > -1) {
this.contextList.splice(idx, 1);
this.updateContext();
}
}
clearContextLinks() {
if (this.contextList.length) {
this.contextList.splice(0, this.contextList.length);
this.updateContext();
}
}
async execute(args = {}) {
if (this.message?.length > 0 && this.onExecute) {
const message = this.message.replace(/\{\{arg::([^}]+)\}\}/g, (_, key) => {
return args[key] ?? '';
});
return await this.onExecute(this, message, args.isAutoExecute ?? false);
}
}
toJSON() {
return {
id: this.id,
label: this.label,
title: this.title,
message: this.message,
contextList: this.contextList,
preventAutoExecute: this.preventAutoExecute,
isHidden: this.isHidden,
executeOnStartup: this.executeOnStartup,
executeOnUser: this.executeOnUser,
executeOnAi: this.executeOnAi,
executeOnChatChange: this.executeOnChatChange,
};
}
}

View File

@@ -0,0 +1,122 @@
import { getSortableDelay } from '../../../utils.js';
import { QuickReplySetLink } from './QuickReplySetLink.js';
import { QuickReplySet } from './QuickReplySet.js';
export class QuickReplyConfig {
/**@type {QuickReplySetLink[]}*/ setList = [];
/**@type {Boolean}*/ isGlobal;
/**@type {Function}*/ onUpdate;
/**@type {Function}*/ onRequestEditSet;
/**@type {HTMLElement}*/ dom;
/**@type {HTMLElement}*/ setListDom;
static from(props) {
props.setList = props.setList?.map(it=>QuickReplySetLink.from(it))?.filter(it=>it.set) ?? [];
const instance = Object.assign(new this(), props);
instance.init();
return instance;
}
init() {
this.setList.forEach(it=>this.hookQuickReplyLink(it));
}
hasSet(qrs) {
return this.setList.find(it=>it.set == qrs) != null;
}
addSet(qrs, isVisible = true) {
if (!this.hasSet(qrs)) {
const qrl = new QuickReplySetLink();
qrl.set = qrs;
qrl.isVisible = isVisible;
this.hookQuickReplyLink(qrl);
this.setList.push(qrl);
this.setListDom.append(qrl.renderSettings(this.setList.length - 1));
this.update();
}
}
removeSet(qrs) {
const idx = this.setList.findIndex(it=>it.set == qrs);
if (idx > -1) {
this.setList.splice(idx, 1);
this.update();
this.updateSetListDom();
}
}
renderSettingsInto(/**@type {HTMLElement}*/root) {
/**@type {HTMLElement}*/
this.setListDom = root.querySelector('.qr--setList');
root.querySelector('.qr--setListAdd').addEventListener('click', ()=>{
this.addSet(QuickReplySet.list[0]);
});
this.updateSetListDom();
}
updateSetListDom() {
this.setListDom.innerHTML = '';
// @ts-ignore
$(this.setListDom).sortable({
delay: getSortableDelay(),
stop: ()=>this.onSetListSort(),
});
this.setList.filter(it=>!it.set.isDeleted).forEach((qrl,idx)=>this.setListDom.append(qrl.renderSettings(idx)));
}
onSetListSort() {
this.setList = Array.from(this.setListDom.children).map((it,idx)=>{
const qrl = this.setList[Number(it.getAttribute('data-order'))];
qrl.index = idx;
it.setAttribute('data-order', String(idx));
return qrl;
});
this.update();
}
/**
* @param {QuickReplySetLink} qrl
*/
hookQuickReplyLink(qrl) {
qrl.onDelete = ()=>this.deleteQuickReplyLink(qrl);
qrl.onUpdate = ()=>this.update();
qrl.onRequestEditSet = ()=>this.requestEditSet(qrl.set);
}
deleteQuickReplyLink(qrl) {
this.setList.splice(this.setList.indexOf(qrl), 1);
this.update();
}
update() {
if (this.onUpdate) {
this.onUpdate(this);
}
}
requestEditSet(qrs) {
if (this.onRequestEditSet) {
this.onRequestEditSet(qrs);
}
}
toJSON() {
return {
setList: this.setList,
};
}
}

View File

@@ -0,0 +1,22 @@
import { QuickReplySet } from './QuickReplySet.js';
export class QuickReplyContextLink {
static from(props) {
props.set = QuickReplySet.get(props.set);
const x = Object.assign(new this(), props);
return x;
}
/**@type {QuickReplySet}*/ set;
/**@type {Boolean}*/ isChained = false;
toJSON() {
return {
set: this.set?.name,
isChained: this.isChained,
};
}
}

View File

@@ -0,0 +1,209 @@
import { getRequestHeaders, substituteParams } from '../../../../script.js';
import { executeSlashCommands } from '../../../slash-commands.js';
import { debounceAsync, warn } from '../index.js';
import { QuickReply } from './QuickReply.js';
export class QuickReplySet {
/**@type {QuickReplySet[]}*/ static list = [];
static from(props) {
props.qrList = []; //props.qrList?.map(it=>QuickReply.from(it));
const instance = Object.assign(new this(), props);
// instance.init();
return instance;
}
/**
* @param {String} name - name of the QuickReplySet
*/
static get(name) {
return this.list.find(it=>it.name == name);
}
/**@type {String}*/ name;
/**@type {Boolean}*/ disableSend = false;
/**@type {Boolean}*/ placeBeforeInput = false;
/**@type {Boolean}*/ injectInput = false;
/**@type {QuickReply[]}*/ qrList = [];
/**@type {Number}*/ idIndex = 0;
/**@type {Boolean}*/ isDeleted = false;
/**@type {Function}*/ save;
/**@type {HTMLElement}*/ dom;
/**@type {HTMLElement}*/ settingsDom;
constructor() {
this.save = debounceAsync(()=>this.performSave(), 200);
}
init() {
this.qrList.forEach(qr=>this.hookQuickReply(qr));
}
unrender() {
this.dom?.remove();
this.dom = null;
}
render() {
this.unrender();
if (!this.dom) {
const root = document.createElement('div'); {
this.dom = root;
root.classList.add('qr--buttons');
this.qrList.filter(qr=>!qr.isHidden).forEach(qr=>{
root.append(qr.render());
});
}
}
return this.dom;
}
rerender() {
if (!this.dom) return;
this.dom.innerHTML = '';
this.qrList.filter(qr=>!qr.isHidden).forEach(qr=>{
this.dom.append(qr.render());
});
}
renderSettings() {
if (!this.settingsDom) {
this.settingsDom = document.createElement('div'); {
this.settingsDom.classList.add('qr--set-qrListContents');
this.qrList.forEach((qr,idx)=>{
this.renderSettingsItem(qr, idx);
});
}
}
return this.settingsDom;
}
renderSettingsItem(qr, idx) {
this.settingsDom.append(qr.renderSettings(idx));
}
/**
* @param {QuickReply} qr
* @param {String} [message] - optional altered message to be used
*/
async execute(qr, message = null, isAutoExecute = false) {
/**@type {HTMLTextAreaElement}*/
const ta = document.querySelector('#send_textarea');
const finalMessage = message ?? qr.message;
let input = ta.value;
if (!isAutoExecute && this.injectInput && input.length > 0) {
if (this.placeBeforeInput) {
input = `${finalMessage} ${input}`;
} else {
input = `${input} ${finalMessage}`;
}
} else {
input = `${finalMessage} `;
}
if (input[0] == '/' && !this.disableSend) {
const result = await executeSlashCommands(input);
return typeof result === 'object' ? result?.pipe : '';
}
ta.value = substituteParams(input);
ta.focus();
if (!this.disableSend) {
// @ts-ignore
document.querySelector('#send_but').click();
}
}
addQuickReply() {
const id = Math.max(this.idIndex, this.qrList.reduce((max,qr)=>Math.max(max,qr.id),0)) + 1;
this.idIndex = id + 1;
const qr = QuickReply.from({ id });
this.qrList.push(qr);
this.hookQuickReply(qr);
if (this.settingsDom) {
this.renderSettingsItem(qr, this.qrList.length - 1);
}
if (this.dom) {
this.dom.append(qr.render());
}
this.save();
return qr;
}
hookQuickReply(qr) {
qr.onExecute = (_, message, isAutoExecute)=>this.execute(qr, message, isAutoExecute);
qr.onDelete = ()=>this.removeQuickReply(qr);
qr.onUpdate = ()=>this.save();
}
removeQuickReply(qr) {
this.qrList.splice(this.qrList.indexOf(qr), 1);
this.save();
}
toJSON() {
return {
version: 2,
name: this.name,
disableSend: this.disableSend,
placeBeforeInput: this.placeBeforeInput,
injectInput: this.injectInput,
qrList: this.qrList,
idIndex: this.idIndex,
};
}
async performSave() {
const response = await fetch('/savequickreply', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify(this),
});
if (response.ok) {
this.rerender();
} else {
warn(`Failed to save Quick Reply Set: ${this.name}`);
}
}
async delete() {
const response = await fetch('/deletequickreply', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify(this),
});
if (response.ok) {
this.unrender();
const idx = QuickReplySet.list.indexOf(this);
QuickReplySet.list.splice(idx, 1);
this.isDeleted = true;
} else {
warn(`Failed to delete Quick Reply Set: ${this.name}`);
}
}
}

View File

@@ -0,0 +1,129 @@
import { QuickReplySet } from './QuickReplySet.js';
export class QuickReplySetLink {
static from(props) {
props.set = QuickReplySet.get(props.set);
/**@type {QuickReplySetLink}*/
const instance = Object.assign(new this(), props);
return instance;
}
/**@type {QuickReplySet}*/ set;
/**@type {Boolean}*/ isVisible = true;
/**@type {Number}*/ index;
/**@type {Function}*/ onUpdate;
/**@type {Function}*/ onRequestEditSet;
/**@type {Function}*/ onDelete;
/**@type {HTMLElement}*/ settingsDom;
renderSettings(idx) {
this.index = idx;
const item = document.createElement('div'); {
this.settingsDom = item;
item.classList.add('qr--item');
item.setAttribute('data-order', String(this.index));
const drag = document.createElement('div'); {
drag.classList.add('drag-handle');
drag.classList.add('ui-sortable-handle');
drag.textContent = '☰';
item.append(drag);
}
const set = document.createElement('select'); {
set.classList.add('qr--set');
// fix for jQuery sortable breaking childrens' touch events
set.addEventListener('touchstart', (evt)=>evt.stopPropagation());
set.addEventListener('change', ()=>{
this.set = QuickReplySet.get(set.value);
this.update();
});
QuickReplySet.list.forEach(qrs=>{
const opt = document.createElement('option'); {
opt.value = qrs.name;
opt.textContent = qrs.name;
opt.selected = qrs == this.set;
set.append(opt);
}
});
item.append(set);
}
const visible = document.createElement('label'); {
visible.classList.add('qr--visible');
visible.title = 'Show buttons';
const cb = document.createElement('input'); {
cb.type = 'checkbox';
cb.checked = this.isVisible;
cb.addEventListener('click', ()=>{
this.isVisible = cb.checked;
this.update();
});
visible.append(cb);
}
visible.append('Buttons');
item.append(visible);
}
const edit = document.createElement('div'); {
edit.classList.add('menu_button');
edit.classList.add('menu_button_icon');
edit.classList.add('fa-solid');
edit.classList.add('fa-pencil');
edit.title = 'Edit quick reply set';
edit.addEventListener('click', ()=>this.requestEditSet());
item.append(edit);
}
const del = document.createElement('div'); {
del.classList.add('qr--del');
del.classList.add('menu_button');
del.classList.add('menu_button_icon');
del.classList.add('fa-solid');
del.classList.add('fa-trash-can');
del.title = 'Remove quick reply set';
del.addEventListener('click', ()=>this.delete());
item.append(del);
}
}
return this.settingsDom;
}
unrenderSettings() {
this.settingsDom?.remove();
this.settingsDom = null;
}
update() {
if (this.onUpdate) {
this.onUpdate(this);
}
}
requestEditSet() {
if (this.onRequestEditSet) {
this.onRequestEditSet(this.set);
}
}
delete() {
this.unrenderSettings();
if (this.onDelete) {
this.onDelete();
}
}
toJSON() {
return {
set: this.set.name,
isVisible: this.isVisible,
};
}
}

View File

@@ -0,0 +1,85 @@
import { chat_metadata, saveChatDebounced, saveSettingsDebounced } from '../../../../script.js';
import { extension_settings } from '../../../extensions.js';
import { QuickReplyConfig } from './QuickReplyConfig.js';
export class QuickReplySettings {
static from(props) {
props.config = QuickReplyConfig.from(props.config);
const instance = Object.assign(new this(), props);
instance.init();
return instance;
}
/**@type {Boolean}*/ isEnabled = false;
/**@type {Boolean}*/ isCombined = false;
/**@type {Boolean}*/ isPopout = false;
/**@type {QuickReplyConfig}*/ config;
/**@type {QuickReplyConfig}*/ _chatConfig;
get chatConfig() {
return this._chatConfig;
}
set chatConfig(value) {
if (this._chatConfig != value) {
this.unhookConfig(this._chatConfig);
this._chatConfig = value;
this.hookConfig(this._chatConfig);
}
}
/**@type {Function}*/ onSave;
/**@type {Function}*/ onRequestEditSet;
init() {
this.hookConfig(this.config);
this.hookConfig(this.chatConfig);
}
hookConfig(config) {
if (config) {
config.onUpdate = ()=>this.save();
config.onRequestEditSet = (qrs)=>this.requestEditSet(qrs);
}
}
unhookConfig(config) {
if (config) {
config.onUpdate = null;
config.onRequestEditSet = null;
}
}
save() {
extension_settings.quickReplyV2 = this.toJSON();
saveSettingsDebounced();
if (this.chatConfig) {
chat_metadata.quickReply = this.chatConfig.toJSON();
saveChatDebounced();
}
if (this.onSave) {
this.onSave();
}
}
requestEditSet(qrs) {
if (this.onRequestEditSet) {
this.onRequestEditSet(qrs);
}
}
toJSON() {
return {
isEnabled: this.isEnabled,
isCombined: this.isCombined,
isPopout: this.isPopout,
config: this.config,
};
}
}

View File

@@ -0,0 +1,270 @@
import { registerSlashCommand } from '../../../slash-commands.js';
// eslint-disable-next-line no-unused-vars
import { QuickReplyApi } from '../api/QuickReplyApi.js';
export class SlashCommandHandler {
/**@type {QuickReplyApi}*/ api;
constructor(/**@type {QuickReplyApi}*/api) {
this.api = api;
}
init() {
registerSlashCommand('qr', (_, value) => this.executeQuickReplyByIndex(Number(value)), [], '<span class="monospace">(number)</span> activates the specified Quick Reply', true, true);
registerSlashCommand('qrset', ()=>toastr.warning('The command /qrset has been deprecated. Use /qr-set, /qr-set-on, and /qr-set-off instead.'), [], '<strong>DEPRECATED</strong> The command /qrset has been deprecated. Use /qr-set, /qr-set-on, and /qr-set-off instead.', true, true);
registerSlashCommand('qr-set', (args, value)=>this.toggleGlobalSet(value, args), [], '<span class="monospace">[visible=true] (number)</span> toggle global QR set', true, true);
registerSlashCommand('qr-set-on', (args, value)=>this.addGlobalSet(value, args), [], '<span class="monospace">[visible=true] (number)</span> activate global QR set', true, true);
registerSlashCommand('qr-set-off', (_, value)=>this.removeGlobalSet(value), [], '<span class="monospace">(number)</span> deactivate global QR set', true, true);
registerSlashCommand('qr-chat-set', (args, value)=>this.toggleChatSet(value, args), [], '<span class="monospace">[visible=true] (number)</span> toggle chat QR set', true, true);
registerSlashCommand('qr-chat-set-on', (args, value)=>this.addChatSet(value, args), [], '<span class="monospace">[visible=true] (number)</span> activate chat QR set', true, true);
registerSlashCommand('qr-chat-set-off', (_, value)=>this.removeChatSet(value), [], '<span class="monospace">(number)</span> deactivate chat QR set', true, true);
registerSlashCommand('qr-set-list', (_, value)=>this.listSets(value ?? 'all'), [], '(all|global|chat) gets a list of the names of all quick reply sets', true, true);
registerSlashCommand('qr-list', (_, value)=>this.listQuickReplies(value), [], '(set name) gets a list of the names of all quick replies in this quick reply set', true, true);
const qrArgs = `
label - string - text on the button, e.g., label=MyButton
set - string - name of the QR set, e.g., set=PresetName1
hidden - bool - whether the button should be hidden, e.g., hidden=true
startup - bool - auto execute on app startup, e.g., startup=true
user - bool - auto execute on user message, e.g., user=true
bot - bool - auto execute on AI message, e.g., bot=true
load - bool - auto execute on chat load, e.g., load=true
title - bool - title / tooltip to be shown on button, e.g., title="My Fancy Button"
`.trim();
const qrUpdateArgs = `
newlabel - string - new text for the button, e.g. newlabel=MyRenamedButton
${qrArgs}
`.trim();
registerSlashCommand('qr-create', (args, message)=>this.createQuickReply(args, message), [], `<span class="monospace" style="white-space:pre-line;">[arguments] (message)\n arguments:\n ${qrArgs}</span> creates a new Quick Reply, example: <tt>/qr-create set=MyPreset label=MyButton /echo 123</tt>`, true, true);
registerSlashCommand('qr-update', (args, message)=>this.updateQuickReply(args, message), [], `<span class="monospace" style="white-space:pre-line;">[arguments] (message)\n arguments:\n ${qrUpdateArgs}</span> updates Quick Reply, example: <tt>/qr-update set=MyPreset label=MyButton newlabel=MyRenamedButton /echo 123</tt>`, true, true);
registerSlashCommand('qr-delete', (args, name)=>this.deleteQuickReply(args, name), [], '<span class="monospace">set=string [label]</span> deletes Quick Reply', true, true);
registerSlashCommand('qr-contextadd', (args, name)=>this.createContextItem(args, name), [], '<span class="monospace">set=string label=string [chain=false] (preset name)</span> add context menu preset to a QR, example: <tt>/qr-contextadd set=MyPreset label=MyButton chain=true MyOtherPreset</tt>', true, true);
registerSlashCommand('qr-contextdel', (args, name)=>this.deleteContextItem(args, name), [], '<span class="monospace">set=string label=string (preset name)</span> remove context menu preset from a QR, example: <tt>/qr-contextdel set=MyPreset label=MyButton MyOtherPreset</tt>', true, true);
registerSlashCommand('qr-contextclear', (args, label)=>this.clearContextMenu(args, label), [], '<span class="monospace">set=string (label)</span> remove all context menu presets from a QR, example: <tt>/qr-contextclear set=MyPreset MyButton</tt>', true, true);
const presetArgs = `
nosend - bool - disable send / insert in user input (invalid for slash commands)
before - bool - place QR before user input
inject - bool - inject user input automatically (if disabled use {{input}})
`.trim();
registerSlashCommand('qr-set-create', (args, name)=>this.createSet(name, args), ['qr-presetadd'], `<span class="monospace" style="white-space:pre-line;">[arguments] (name)\n arguments:\n ${presetArgs}</span> create a new preset (overrides existing ones), example: <tt>/qr-set-add MyNewPreset</tt>`, true, true);
registerSlashCommand('qr-set-update', (args, name)=>this.updateSet(name, args), ['qr-presetupdate'], `<span class="monospace" style="white-space:pre-line;">[arguments] (name)\n arguments:\n ${presetArgs}</span> update an existing preset, example: <tt>/qr-set-update enabled=false MyPreset</tt>`, true, true);
registerSlashCommand('qr-set-delete', (args, name)=>this.deleteSet(name), ['qr-presetdelete'], `<span class="monospace" style="white-space:pre-line;">(name)\n arguments:\n ${presetArgs}</span> delete an existing preset, example: <tt>/qr-set-delete MyPreset</tt>`, true, true);
}
getSetByName(name) {
const set = this.api.getSetByName(name);
if (!set) {
toastr.error(`No Quick Reply Set with the name "${name}" could be found.`);
}
return set;
}
getQrByLabel(setName, label) {
const qr = this.api.getQrByLabel(setName, label);
if (!qr) {
toastr.error(`No Quick Reply with the label "${label}" could be found in the set "${setName}"`);
}
return qr;
}
async executeQuickReplyByIndex(idx) {
try {
return await this.api.executeQuickReplyByIndex(idx);
} catch (ex) {
toastr.error(ex.message);
}
}
toggleGlobalSet(name, args = {}) {
try {
this.api.toggleGlobalSet(name, JSON.parse(args.visible ?? 'true') === true);
} catch (ex) {
toastr.error(ex.message);
}
}
addGlobalSet(name, args = {}) {
try {
this.api.addGlobalSet(name, JSON.parse(args.visible ?? 'true') === true);
} catch (ex) {
toastr.error(ex.message);
}
}
removeGlobalSet(name) {
try {
this.api.removeGlobalSet(name);
} catch (ex) {
toastr.error(ex.message);
}
}
toggleChatSet(name, args = {}) {
try {
this.api.toggleChatSet(name, JSON.parse(args.visible ?? 'true') === true);
} catch (ex) {
toastr.error(ex.message);
}
}
addChatSet(name, args = {}) {
try {
this.api.addChatSet(name, JSON.parse(args.visible ?? 'true') === true);
} catch (ex) {
toastr.error(ex.message);
}
}
removeChatSet(name) {
try {
this.api.removeChatSet(name);
} catch (ex) {
toastr.error(ex.message);
}
}
createQuickReply(args, message) {
try {
this.api.createQuickReply(
args.set ?? '',
args.label ?? '',
{
message: message ?? '',
title: args.title,
isHidden: JSON.parse(args.hidden ?? 'false') === true,
executeOnStartup: JSON.parse(args.startup ?? 'false') === true,
executeOnUser: JSON.parse(args.user ?? 'false') === true,
executeOnAi: JSON.parse(args.bot ?? 'false') === true,
executeOnChatChange: JSON.parse(args.load ?? 'false') === true,
},
);
} catch (ex) {
toastr.error(ex.message);
}
}
updateQuickReply(args, message) {
try {
this.api.updateQuickReply(
args.set ?? '',
args.label ?? '',
{
newLabel: args.newlabel,
message: (message ?? '').trim().length > 0 ? message : undefined,
title: args.title,
isHidden: args.hidden,
executeOnStartup: args.startup,
executeOnUser: args.user,
executeOnAi: args.bot,
executeOnChatChange: args.load,
},
);
} catch (ex) {
toastr.error(ex.message);
}
}
deleteQuickReply(args, label) {
try {
this.api.deleteQuickReply(args.set, label);
} catch (ex) {
toastr.error(ex.message);
}
}
createContextItem(args, name) {
try {
this.api.createContextItem(
args.set,
args.label,
name,
JSON.parse(args.chain ?? 'false') === true,
);
} catch (ex) {
toastr.error(ex.message);
}
}
deleteContextItem(args, name) {
try {
this.api.deleteContextItem(args.set, args.label, name);
} catch (ex) {
toastr.error(ex.message);
}
}
clearContextMenu(args, label) {
try {
this.api.clearContextMenu(args.set, args.label ?? label);
} catch (ex) {
toastr.error(ex.message);
}
}
createSet(name, args) {
try {
this.api.createSet(
args.name ?? name ?? '',
{
disableSend: JSON.parse(args.nosend ?? 'false') === true,
placeBeforeInput: JSON.parse(args.before ?? 'false') === true,
injectInput: JSON.parse(args.inject ?? 'false') === true,
},
);
} catch (ex) {
toastr.error(ex.message);
}
}
updateSet(name, args) {
try {
this.api.updateSet(
args.name ?? name ?? '',
{
disableSend: args.nosend !== undefined ? JSON.parse(args.nosend ?? 'false') === true : undefined,
placeBeforeInput: args.before !== undefined ? JSON.parse(args.before ?? 'false') === true : undefined,
injectInput: args.inject !== undefined ? JSON.parse(args.inject ?? 'false') === true : undefined,
},
);
} catch (ex) {
toastr.error(ex.message);
}
}
deleteSet(name) {
try {
this.api.deleteSet(name ?? '');
} catch (ex) {
toastr.error(ex.message);
}
}
listSets(source) {
try {
switch (source) {
case 'global':
return this.api.listGlobalSets();
case 'chat':
return this.api.listChatSets();
default:
return this.api.listSets();
}
} catch (ex) {
toastr.error(ex.message);
}
}
listQuickReplies(name) {
try {
return this.api.listQuickReplies(name);
} catch (ex) {
toastr.error(ex.message);
}
}
}

View File

@@ -0,0 +1,161 @@
import { animation_duration } from '../../../../../script.js';
import { dragElement } from '../../../../RossAscends-mods.js';
import { loadMovingUIState } from '../../../../power-user.js';
// eslint-disable-next-line no-unused-vars
import { QuickReplySettings } from '../QuickReplySettings.js';
export class ButtonUi {
/**@type {QuickReplySettings}*/ settings;
/**@type {HTMLElement}*/ dom;
/**@type {HTMLElement}*/ popoutDom;
constructor(/**@type {QuickReplySettings}*/settings) {
this.settings = settings;
}
render() {
if (this.settings.isPopout) {
return this.renderPopout();
}
return this.renderBar();
}
unrender() {
this.dom?.remove();
this.dom = null;
this.popoutDom?.remove();
this.popoutDom = null;
}
show() {
if (!this.settings.isEnabled) return;
if (this.settings.isPopout) {
document.body.append(this.render());
loadMovingUIState();
$(this.render()).fadeIn(animation_duration);
dragElement($(this.render()));
} else {
const sendForm = document.querySelector('#send_form');
if (sendForm.children.length > 0) {
sendForm.children[0].insertAdjacentElement('beforebegin', this.render());
} else {
sendForm.append(this.render());
}
}
}
hide() {
this.unrender();
}
refresh() {
this.hide();
this.show();
}
renderBar() {
if (!this.dom) {
let buttonHolder;
const root = document.createElement('div'); {
this.dom = root;
buttonHolder = root;
root.id = 'qr--bar';
root.classList.add('flex-container');
root.classList.add('flexGap5');
const popout = document.createElement('div'); {
popout.id = 'qr--popoutTrigger';
popout.classList.add('menu_button');
popout.classList.add('fa-solid');
popout.classList.add('fa-window-restore');
popout.addEventListener('click', ()=>{
this.settings.isPopout = true;
this.refresh();
this.settings.save();
});
root.append(popout);
}
if (this.settings.isCombined) {
const buttons = document.createElement('div'); {
buttonHolder = buttons;
buttons.classList.add('qr--buttons');
root.append(buttons);
}
}
[...this.settings.config.setList, ...(this.settings.chatConfig?.setList ?? [])]
.filter(link=>link.isVisible)
.forEach(link=>buttonHolder.append(link.set.render()))
;
}
}
return this.dom;
}
renderPopout() {
if (!this.popoutDom) {
let buttonHolder;
const root = document.createElement('div'); {
this.popoutDom = root;
root.id = 'qr--popout';
root.classList.add('qr--popout');
root.classList.add('draggable');
const head = document.createElement('div'); {
head.classList.add('qr--header');
root.append(head);
const controls = document.createElement('div'); {
controls.classList.add('qr--controls');
controls.classList.add('panelControlBar');
controls.classList.add('flex-container');
const drag = document.createElement('div'); {
drag.id = 'qr--popoutheader';
drag.classList.add('fa-solid');
drag.classList.add('fa-grip');
drag.classList.add('drag-grabber');
drag.classList.add('hoverglow');
controls.append(drag);
}
const close = document.createElement('div'); {
close.classList.add('qr--close');
close.classList.add('fa-solid');
close.classList.add('fa-circle-xmark');
close.classList.add('hoverglow');
close.addEventListener('click', ()=>{
this.settings.isPopout = false;
this.refresh();
this.settings.save();
});
controls.append(close);
}
head.append(controls);
}
}
const body = document.createElement('div'); {
buttonHolder = body;
body.classList.add('qr--body');
if (this.settings.isCombined) {
const buttons = document.createElement('div'); {
buttonHolder = buttons;
buttons.classList.add('qr--buttons');
body.append(buttons);
}
}
[...this.settings.config.setList, ...(this.settings.chatConfig?.setList ?? [])]
.filter(link=>link.isVisible)
.forEach(link=>buttonHolder.append(link.set.render()))
;
root.append(body);
}
}
}
return this.popoutDom;
}
}

View File

@@ -0,0 +1,366 @@
import { callPopup } from '../../../../../script.js';
import { getSortableDelay } from '../../../../utils.js';
import { log, warn } from '../../index.js';
import { QuickReply } from '../QuickReply.js';
import { QuickReplySet } from '../QuickReplySet.js';
// eslint-disable-next-line no-unused-vars
import { QuickReplySettings } from '../QuickReplySettings.js';
export class SettingsUi {
/**@type {QuickReplySettings}*/ settings;
/**@type {HTMLElement}*/ template;
/**@type {HTMLElement}*/ dom;
/**@type {HTMLInputElement}*/ isEnabled;
/**@type {HTMLInputElement}*/ isCombined;
/**@type {HTMLElement}*/ globalSetList;
/**@type {HTMLElement}*/ chatSetList;
/**@type {QuickReplySet}*/ currentQrSet;
/**@type {HTMLInputElement}*/ disableSend;
/**@type {HTMLInputElement}*/ placeBeforeInput;
/**@type {HTMLInputElement}*/ injectInput;
/**@type {HTMLSelectElement}*/ currentSet;
constructor(/**@type {QuickReplySettings}*/settings) {
this.settings = settings;
settings.onRequestEditSet = (qrs) => this.selectQrSet(qrs);
}
rerender() {
if (!this.dom) return;
const content = this.dom.querySelector('.inline-drawer-content');
content.innerHTML = '';
// @ts-ignore
Array.from(this.template.querySelector('.inline-drawer-content').cloneNode(true).children).forEach(el=>{
content.append(el);
});
this.prepareDom();
}
unrender() {
this.dom?.remove();
this.dom = null;
}
async render() {
if (!this.dom) {
const response = await fetch('/scripts/extensions/quick-reply/html/settings.html', { cache: 'no-store' });
if (response.ok) {
this.template = document.createRange().createContextualFragment(await response.text()).querySelector('#qr--settings');
// @ts-ignore
this.dom = this.template.cloneNode(true);
this.prepareDom();
} else {
warn('failed to fetch settings template');
}
}
return this.dom;
}
prepareGeneralSettings() {
// general settings
this.isEnabled = this.dom.querySelector('#qr--isEnabled');
this.isEnabled.checked = this.settings.isEnabled;
this.isEnabled.addEventListener('click', ()=>this.onIsEnabled());
this.isCombined = this.dom.querySelector('#qr--isCombined');
this.isCombined.checked = this.settings.isCombined;
this.isCombined.addEventListener('click', ()=>this.onIsCombined());
}
prepareGlobalSetList() {
const dom = this.template.querySelector('#qr--global');
const clone = dom.cloneNode(true);
// @ts-ignore
this.settings.config.renderSettingsInto(clone);
this.dom.querySelector('#qr--global').replaceWith(clone);
}
prepareChatSetList() {
const dom = this.template.querySelector('#qr--chat');
const clone = dom.cloneNode(true);
if (this.settings.chatConfig) {
// @ts-ignore
this.settings.chatConfig.renderSettingsInto(clone);
} else {
const info = document.createElement('div'); {
info.textContent = 'No active chat.';
// @ts-ignore
clone.append(info);
}
}
this.dom.querySelector('#qr--chat').replaceWith(clone);
}
prepareQrEditor() {
// qr editor
this.dom.querySelector('#qr--set-new').addEventListener('click', async()=>this.addQrSet());
/**@type {HTMLInputElement}*/
const importFile = this.dom.querySelector('#qr--set-importFile');
importFile.addEventListener('change', async()=>{
await this.importQrSet(importFile.files);
importFile.value = null;
});
this.dom.querySelector('#qr--set-import').addEventListener('click', ()=>importFile.click());
this.dom.querySelector('#qr--set-export').addEventListener('click', async()=>this.exportQrSet());
this.dom.querySelector('#qr--set-delete').addEventListener('click', async()=>this.deleteQrSet());
this.dom.querySelector('#qr--set-add').addEventListener('click', async()=>{
this.currentQrSet.addQuickReply();
});
this.qrList = this.dom.querySelector('#qr--set-qrList');
this.currentSet = this.dom.querySelector('#qr--set');
this.currentSet.addEventListener('change', ()=>this.onQrSetChange());
QuickReplySet.list.forEach(qrs=>{
const opt = document.createElement('option'); {
opt.value = qrs.name;
opt.textContent = qrs.name;
this.currentSet.append(opt);
}
});
this.disableSend = this.dom.querySelector('#qr--disableSend');
this.disableSend.addEventListener('click', ()=>{
const qrs = this.currentQrSet;
qrs.disableSend = this.disableSend.checked;
qrs.save();
});
this.placeBeforeInput = this.dom.querySelector('#qr--placeBeforeInput');
this.placeBeforeInput.addEventListener('click', ()=>{
const qrs = this.currentQrSet;
qrs.placeBeforeInput = this.placeBeforeInput.checked;
qrs.save();
});
this.injectInput = this.dom.querySelector('#qr--injectInput');
this.injectInput.addEventListener('click', ()=>{
const qrs = this.currentQrSet;
qrs.injectInput = this.injectInput.checked;
qrs.save();
});
this.onQrSetChange();
}
onQrSetChange() {
this.currentQrSet = QuickReplySet.get(this.currentSet.value);
this.disableSend.checked = this.currentQrSet.disableSend;
this.placeBeforeInput.checked = this.currentQrSet.placeBeforeInput;
this.injectInput.checked = this.currentQrSet.injectInput;
this.qrList.innerHTML = '';
const qrsDom = this.currentQrSet.renderSettings();
this.qrList.append(qrsDom);
// @ts-ignore
$(qrsDom).sortable({
delay: getSortableDelay(),
handle: '.drag-handle',
stop: ()=>this.onQrListSort(),
});
}
prepareDom() {
this.prepareGeneralSettings();
this.prepareGlobalSetList();
this.prepareChatSetList();
this.prepareQrEditor();
}
async onIsEnabled() {
this.settings.isEnabled = this.isEnabled.checked;
this.settings.save();
}
async onIsCombined() {
this.settings.isCombined = this.isCombined.checked;
this.settings.save();
}
async onGlobalSetListSort() {
this.settings.config.setList = Array.from(this.globalSetList.children).map((it,idx)=>{
const set = this.settings.config.setList[Number(it.getAttribute('data-order'))];
it.setAttribute('data-order', String(idx));
return set;
});
this.settings.save();
}
async onChatSetListSort() {
this.settings.chatConfig.setList = Array.from(this.chatSetList.children).map((it,idx)=>{
const set = this.settings.chatConfig.setList[Number(it.getAttribute('data-order'))];
it.setAttribute('data-order', String(idx));
return set;
});
this.settings.save();
}
updateOrder(list) {
Array.from(list.children).forEach((it,idx)=>{
it.setAttribute('data-order', idx);
});
}
async onQrListSort() {
this.currentQrSet.qrList = Array.from(this.qrList.querySelectorAll('.qr--set-item')).map((it,idx)=>{
const qr = this.currentQrSet.qrList.find(qr=>qr.id == Number(it.getAttribute('data-id')));
it.setAttribute('data-order', String(idx));
return qr;
});
this.currentQrSet.save();
}
async deleteQrSet() {
const confirmed = await callPopup(`Are you sure you want to delete the Quick Reply Set "${this.currentQrSet.name}"?<br>This cannot be undone.`, 'confirm');
if (confirmed) {
await this.doDeleteQrSet(this.currentQrSet);
this.rerender();
}
}
async doDeleteQrSet(qrs) {
await qrs.delete();
//TODO (HACK) should just bubble up from QuickReplySet.delete() but that would require proper or at least more comples onDelete listeners
for (let i = this.settings.config.setList.length - 1; i >= 0; i--) {
if (this.settings.config.setList[i].set == qrs) {
this.settings.config.setList.splice(i, 1);
}
}
if (this.settings.chatConfig) {
for (let i = this.settings.chatConfig.setList.length - 1; i >= 0; i--) {
if (this.settings.chatConfig.setList[i].set == qrs) {
this.settings.chatConfig.setList.splice(i, 1);
}
}
}
this.settings.save();
}
async addQrSet() {
const name = await callPopup('Quick Reply Set Name:', 'input');
if (name && name.length > 0) {
const oldQrs = QuickReplySet.get(name);
if (oldQrs) {
const replace = await callPopup(`A Quick Reply Set named "${name}" already exists.<br>Do you want to overwrite the existing Quick Reply Set?<br>The existing set will be deleted. This cannot be undone.`, 'confirm');
if (replace) {
const idx = QuickReplySet.list.indexOf(oldQrs);
await this.doDeleteQrSet(oldQrs);
const qrs = new QuickReplySet();
qrs.name = name;
qrs.addQuickReply();
QuickReplySet.list.splice(idx, 0, qrs);
this.rerender();
this.currentSet.value = name;
this.onQrSetChange();
this.prepareGlobalSetList();
this.prepareChatSetList();
}
} else {
const qrs = new QuickReplySet();
qrs.name = name;
qrs.addQuickReply();
const idx = QuickReplySet.list.findIndex(it=>it.name.localeCompare(name) == 1);
if (idx > -1) {
QuickReplySet.list.splice(idx, 0, qrs);
} else {
QuickReplySet.list.push(qrs);
}
const opt = document.createElement('option'); {
opt.value = qrs.name;
opt.textContent = qrs.name;
if (idx > -1) {
this.currentSet.children[idx].insertAdjacentElement('beforebegin', opt);
} else {
this.currentSet.append(opt);
}
}
this.currentSet.value = name;
this.onQrSetChange();
this.prepareGlobalSetList();
this.prepareChatSetList();
}
}
}
async importQrSet(/**@type {FileList}*/files) {
for (let i = 0; i < files.length; i++) {
await this.importSingleQrSet(files.item(i));
}
}
async importSingleQrSet(/**@type {File}*/file) {
log('FILE', file);
try {
const text = await file.text();
const props = JSON.parse(text);
if (!Number.isInteger(props.version) || typeof props.name != 'string') {
toastr.error(`The file "${file.name}" does not appear to be a valid quick reply set.`);
warn(`The file "${file.name}" does not appear to be a valid quick reply set.`);
} else {
/**@type {QuickReplySet}*/
const qrs = QuickReplySet.from(JSON.parse(JSON.stringify(props)));
qrs.qrList = props.qrList.map(it=>QuickReply.from(it));
qrs.init();
const oldQrs = QuickReplySet.get(props.name);
if (oldQrs) {
const replace = await callPopup(`A Quick Reply Set named "${qrs.name}" already exists.<br>Do you want to overwrite the existing Quick Reply Set?<br>The existing set will be deleted. This cannot be undone.`, 'confirm');
if (replace) {
const idx = QuickReplySet.list.indexOf(oldQrs);
await this.doDeleteQrSet(oldQrs);
QuickReplySet.list.splice(idx, 0, qrs);
await qrs.save();
this.rerender();
this.currentSet.value = qrs.name;
this.onQrSetChange();
this.prepareGlobalSetList();
this.prepareChatSetList();
}
} else {
const idx = QuickReplySet.list.findIndex(it=>it.name.localeCompare(qrs.name) == 1);
if (idx > -1) {
QuickReplySet.list.splice(idx, 0, qrs);
} else {
QuickReplySet.list.push(qrs);
}
await qrs.save();
const opt = document.createElement('option'); {
opt.value = qrs.name;
opt.textContent = qrs.name;
if (idx > -1) {
this.currentSet.children[idx].insertAdjacentElement('beforebegin', opt);
} else {
this.currentSet.append(opt);
}
}
this.currentSet.value = qrs.name;
this.onQrSetChange();
this.prepareGlobalSetList();
this.prepareChatSetList();
}
}
} catch (ex) {
warn(ex);
toastr.error(`Failed to import "${file.name}":\n\n${ex.message}`);
}
}
exportQrSet() {
const blob = new Blob([JSON.stringify(this.currentQrSet)], { type:'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a'); {
a.href = url;
a.download = `${this.currentQrSet.name}.json`;
a.click();
}
}
selectQrSet(qrs) {
this.currentSet.value = qrs.name;
this.onQrSetChange();
}
}

View File

@@ -0,0 +1,108 @@
import { QuickReply } from '../../QuickReply.js';
// eslint-disable-next-line no-unused-vars
import { QuickReplySet } from '../../QuickReplySet.js';
import { MenuHeader } from './MenuHeader.js';
import { MenuItem } from './MenuItem.js';
export class ContextMenu {
/**@type {MenuItem[]}*/ itemList = [];
/**@type {Boolean}*/ isActive = false;
/**@type {HTMLElement}*/ root;
/**@type {HTMLElement}*/ menu;
constructor(/**@type {QuickReply}*/qr) {
// this.itemList = items;
this.itemList = this.build(qr).children;
this.itemList.forEach(item => {
item.onExpand = () => {
this.itemList.filter(it => it != item)
.forEach(it => it.collapse());
};
});
}
/**
* @param {QuickReply} qr
* @param {String} chainedMessage
* @param {QuickReplySet[]} hierarchy
* @param {String[]} labelHierarchy
*/
build(qr, chainedMessage = null, hierarchy = [], labelHierarchy = []) {
const tree = {
label: qr.label,
message: (chainedMessage && qr.message ? `${chainedMessage} | ` : '') + qr.message,
children: [],
};
qr.contextList.forEach((cl) => {
if (!hierarchy.includes(cl.set)) {
const nextHierarchy = [...hierarchy, cl.set];
const nextLabelHierarchy = [...labelHierarchy, tree.label];
tree.children.push(new MenuHeader(cl.set.name));
cl.set.qrList.forEach(subQr => {
const subTree = this.build(subQr, cl.isChained ? tree.message : null, nextHierarchy, nextLabelHierarchy);
tree.children.push(new MenuItem(
subTree.label,
subTree.message,
(evt) => {
evt.stopPropagation();
const finalQr = Object.assign(new QuickReply(), subQr);
finalQr.message = subTree.message.replace(/%%parent(-\d+)?%%/g, (_, index) => {
return nextLabelHierarchy.slice(parseInt(index ?? '-1'))[0];
});
cl.set.execute(finalQr);
},
subTree.children,
));
});
}
});
return tree;
}
render() {
if (!this.root) {
const blocker = document.createElement('div'); {
this.root = blocker;
blocker.classList.add('ctx-blocker');
blocker.addEventListener('click', () => this.hide());
const menu = document.createElement('ul'); {
this.menu = menu;
menu.classList.add('list-group');
menu.classList.add('ctx-menu');
this.itemList.forEach(it => menu.append(it.render()));
blocker.append(menu);
}
}
}
return this.root;
}
show({ clientX, clientY }) {
if (this.isActive) return;
this.isActive = true;
this.render();
this.menu.style.bottom = `${window.innerHeight - clientY}px`;
this.menu.style.left = `${clientX}px`;
document.body.append(this.root);
}
hide() {
if (this.root) {
this.root.remove();
}
this.isActive = false;
}
toggle(/**@type {PointerEvent}*/evt) {
if (this.isActive) {
this.hide();
} else {
this.show(evt);
}
}
}

View File

@@ -1,114 +1,286 @@
#quickReplyBar {
outline: none;
/*
padding: 5px 0;
border-bottom: 1px solid var(--SmartThemeBorderColor);
*/
margin: 0;
transition: 0.3s;
opacity: 0.7;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
display: none;
max-width: 100%;
overflow-x: auto;
order: 1;
position: relative;
#qr--bar {
outline: none;
margin: 0;
transition: 0.3s;
opacity: 0.7;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
max-width: 100%;
overflow-x: auto;
order: 1;
padding-right: 2.5em;
position: relative;
}
#quickReplies {
margin: 0;
padding: 0;
display: flex;
justify-content: center;
flex-wrap: wrap;
gap: 5px;
width: 100%;
#qr--bar > #qr--popoutTrigger {
position: absolute;
right: 0.25em;
top: 0;
}
#quickReplyPopoutButton {
position: absolute;
right: 5px;
top: 0px;
#qr--popout {
display: flex;
flex-direction: column;
padding: 0;
z-index: 31;
}
#quickReplies div {
color: var(--SmartThemeBodyColor);
background-color: var(--black50a);
border: 1px solid var(--SmartThemeBorderColor);
border-radius: 10px;
padding: 3px 5px;
margin: 3px 0;
/* width: min-content; */
cursor: pointer;
transition: 0.3s;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
#qr--popout > .qr--header {
flex: 0 0 auto;
height: 2em;
position: relative;
}
#quickReplies div:hover {
opacity: 1;
filter: brightness(1.2);
cursor: pointer;
#qr--popout > .qr--header > .qr--controls > .qr--close {
height: 15px;
aspect-ratio: 1 / 1;
font-size: 20px;
opacity: 0.5;
transition: all 250ms;
}
#qr--popout > .qr--body {
overflow-y: auto;
}
#qr--bar > .qr--buttons,
#qr--popout > .qr--body > .qr--buttons {
margin: 0;
padding: 0;
display: flex;
justify-content: center;
flex-wrap: wrap;
gap: 5px;
width: 100%;
}
#qr--bar > .qr--buttons > .qr--buttons,
#qr--popout > .qr--body > .qr--buttons > .qr--buttons {
display: contents;
}
#qr--bar > .qr--buttons .qr--button,
#qr--popout > .qr--body > .qr--buttons .qr--button {
color: var(--SmartThemeBodyColor);
border: 1px solid var(--SmartThemeBorderColor);
border-radius: 10px;
padding: 3px 5px;
margin: 3px 0;
cursor: pointer;
transition: 0.3s;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
}
#qr--bar > .qr--buttons .qr--button:hover,
#qr--popout > .qr--body > .qr--buttons .qr--button:hover {
opacity: 1;
filter: brightness(1.2);
}
#qr--bar > .qr--buttons .qr--button > .qr--button-expander,
#qr--popout > .qr--body > .qr--buttons .qr--button > .qr--button-expander {
display: none;
}
#qr--bar > .qr--buttons .qr--button.qr--hasCtx > .qr--button-expander,
#qr--popout > .qr--body > .qr--buttons .qr--button.qr--hasCtx > .qr--button-expander {
display: block;
}
.qr--button-expander {
border-left: 1px solid;
margin-left: 1em;
text-align: center;
width: 2em;
}
.qr--button-expander:hover {
font-weight: bold;
}
.ctx-blocker {
/* backdrop-filter: blur(1px); */
/* background-color: rgba(0 0 0 / 10%); */
bottom: 0;
left: 0;
position: fixed;
right: 0;
top: 0;
z-index: 999;
/* backdrop-filter: blur(1px); */
/* background-color: rgba(0 0 0 / 10%); */
bottom: 0;
left: 0;
position: fixed;
right: 0;
top: 0;
z-index: 999;
}
.ctx-menu {
position: absolute;
overflow: visible;
position: absolute;
overflow: visible;
}
.list-group .list-group-item.ctx-header {
font-weight: bold;
cursor: default;
font-weight: bold;
cursor: default;
}
.ctx-item+.ctx-header {
border-top: 1px solid;
.ctx-item + .ctx-header {
border-top: 1px solid;
}
.ctx-item {
position: relative;
position: relative;
}
.ctx-expander {
border-left: 1px solid;
margin-left: 1em;
text-align: center;
width: 2em;
border-left: 1px solid;
margin-left: 1em;
text-align: center;
width: 2em;
}
.ctx-expander:hover {
font-weight: bold;
font-weight: bold;
}
.ctx-sub-menu {
position: absolute;
top: 0;
left: 100%;
position: absolute;
top: 0;
left: 100%;
}
@media screen and (max-width: 1000px) {
.ctx-blocker {
position: absolute;
}
.list-group .list-group-item.ctx-item {
padding: 1em;
}
.ctx-blocker {
position: absolute;
}
.list-group .list-group-item.ctx-item {
padding: 1em;
}
}
#qr--settings .qr--head {
display: flex;
align-items: baseline;
gap: 1em;
}
#qr--settings .qr--head > .qr--title {
font-weight: bold;
}
#qr--settings .qr--head > .qr--actions {
display: flex;
flex-direction: row;
align-items: baseline;
gap: 0.5em;
}
#qr--settings .qr--setList > .qr--item {
display: flex;
flex-direction: row;
gap: 0.5em;
align-items: baseline;
padding: 0 0.5em;
}
#qr--settings .qr--setList > .qr--item > .drag-handle {
padding: 0.75em;
}
#qr--settings .qr--setList > .qr--item > .qr--visible {
flex: 0 0 auto;
display: flex;
flex-direction: row;
}
#qr--settings #qr--set-settings #qr--injectInputContainer {
flex-wrap: nowrap;
}
#qr--settings #qr--set-qrList .qr--set-qrListContents {
padding: 0 0.5em;
}
#qr--settings #qr--set-qrList .qr--set-qrListContents > .qr--set-item {
display: flex;
flex-direction: row;
gap: 0.5em;
align-items: baseline;
padding: 0.25em 0;
}
#qr--settings #qr--set-qrList .qr--set-qrListContents > .qr--set-item > :nth-child(1) {
flex: 0 0 auto;
}
#qr--settings #qr--set-qrList .qr--set-qrListContents > .qr--set-item > :nth-child(2) {
flex: 1 1 25%;
}
#qr--settings #qr--set-qrList .qr--set-qrListContents > .qr--set-item > :nth-child(3) {
flex: 0 0 auto;
}
#qr--settings #qr--set-qrList .qr--set-qrListContents > .qr--set-item > :nth-child(4) {
flex: 1 1 75%;
}
#qr--settings #qr--set-qrList .qr--set-qrListContents > .qr--set-item > :nth-child(5) {
flex: 0 0 auto;
}
#qr--settings #qr--set-qrList .qr--set-qrListContents > .qr--set-item > .drag-handle {
padding: 0.75em;
}
#qr--settings #qr--set-qrList .qr--set-qrListContents > .qr--set-item .qr--set-itemLabel,
#qr--settings #qr--set-qrList .qr--set-qrListContents > .qr--set-item .qr--action {
margin: 0;
}
#qr--settings #qr--set-qrList .qr--set-qrListContents > .qr--set-item .qr--set-itemMessage {
font-size: smaller;
}
#qr--settings .qr--set-qrListActions {
display: flex;
flex-direction: row;
gap: 0.5em;
justify-content: center;
padding-bottom: 0.5em;
}
#qr--qrOptions > #qr--ctxEditor .qr--ctxItem {
display: flex;
flex-direction: row;
gap: 0.5em;
align-items: baseline;
}
@media screen and (max-width: 750px) {
body #dialogue_popup:has(#qr--modalEditor) #dialogue_popup_text > #qr--modalEditor {
flex-direction: column;
}
body #dialogue_popup:has(#qr--modalEditor) #dialogue_popup_text > #qr--modalEditor > #qr--main > .qr--labels {
flex-direction: column;
}
body #dialogue_popup:has(#qr--modalEditor) #dialogue_popup_text > #qr--modalEditor > #qr--main > .qr--modal-messageContainer > #qr--modal-message {
min-height: 90svh;
}
}
#dialogue_popup:has(#qr--modalEditor) {
aspect-ratio: unset;
}
#dialogue_popup:has(#qr--modalEditor) #dialogue_popup_text {
display: flex;
flex-direction: column;
}
#dialogue_popup:has(#qr--modalEditor) #dialogue_popup_text > #qr--modalEditor {
flex: 1 1 auto;
display: flex;
flex-direction: row;
gap: 1em;
}
#dialogue_popup:has(#qr--modalEditor) #dialogue_popup_text > #qr--modalEditor > #qr--main {
flex: 1 1 auto;
display: flex;
flex-direction: column;
}
#dialogue_popup:has(#qr--modalEditor) #dialogue_popup_text > #qr--modalEditor > #qr--main > .qr--labels {
flex: 0 0 auto;
display: flex;
flex-direction: row;
gap: 0.5em;
}
#dialogue_popup:has(#qr--modalEditor) #dialogue_popup_text > #qr--modalEditor > #qr--main > .qr--labels > label {
flex: 1 1 1px;
display: flex;
flex-direction: column;
}
#dialogue_popup:has(#qr--modalEditor) #dialogue_popup_text > #qr--modalEditor > #qr--main > .qr--labels > label > .qr--labelText {
flex: 1 1 auto;
}
#dialogue_popup:has(#qr--modalEditor) #dialogue_popup_text > #qr--modalEditor > #qr--main > .qr--labels > label > .qr--labelHint {
flex: 1 1 auto;
}
#dialogue_popup:has(#qr--modalEditor) #dialogue_popup_text > #qr--modalEditor > #qr--main > .qr--labels > label > input {
flex: 0 0 auto;
}
#dialogue_popup:has(#qr--modalEditor) #dialogue_popup_text > #qr--modalEditor > #qr--main > .qr--modal-messageContainer {
flex: 1 1 auto;
display: flex;
flex-direction: column;
}
#dialogue_popup:has(#qr--modalEditor) #dialogue_popup_text > #qr--modalEditor > #qr--main > .qr--modal-messageContainer > #qr--modal-message {
flex: 1 1 auto;
}
#dialogue_popup:has(#qr--modalEditor) #dialogue_popup_text > #qr--modalEditor #qr--modal-execute {
display: flex;
flex-direction: row;
gap: 0.5em;
}
#dialogue_popup:has(#qr--modalEditor) #dialogue_popup_text > #qr--modalEditor #qr--modal-execute.qr--busy {
opacity: 0.5;
cursor: wait;
}
#shadow_popup.qr--hide {
opacity: 0 !important;
}

View File

@@ -0,0 +1,317 @@
#qr--bar {
outline: none;
margin: 0;
transition: 0.3s;
opacity: 0.7;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
max-width: 100%;
overflow-x: auto;
order: 1;
padding-right: 2.5em;
position: relative;
> #qr--popoutTrigger {
position: absolute;
right: 0.25em;
top: 0;
}
}
#qr--popout {
display: flex;
flex-direction: column;
padding: 0;
z-index: 31;
> .qr--header {
flex: 0 0 auto;
height: 2em;
position: relative;
> .qr--controls {
> .qr--close {
height: 15px;
aspect-ratio: 1 / 1;
font-size: 20px;
opacity: 0.5;
transition: all 250ms;
}
}
}
> .qr--body {
overflow-y: auto;
}
}
#qr--bar, #qr--popout > .qr--body {
> .qr--buttons {
margin: 0;
padding: 0;
display: flex;
justify-content: center;
flex-wrap: wrap;
gap: 5px;
width: 100%;
> .qr--buttons {
display: contents;
}
.qr--button {
color: var(--SmartThemeBodyColor);
// background-color: var(--black50a);
border: 1px solid var(--SmartThemeBorderColor);
border-radius: 10px;
padding: 3px 5px;
margin: 3px 0;
cursor: pointer;
transition: 0.3s;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
&:hover {
opacity: 1;
filter: brightness(1.2);
}
> .qr--button-expander {
display: none;
}
&.qr--hasCtx {
> .qr--button-expander {
display: block;
}
}
}
}
}
.qr--button-expander {
border-left: 1px solid;
margin-left: 1em;
text-align: center;
width: 2em;
&:hover {
font-weight: bold;
}
}
.ctx-blocker {
/* backdrop-filter: blur(1px); */
/* background-color: rgba(0 0 0 / 10%); */
bottom: 0;
left: 0;
position: fixed;
right: 0;
top: 0;
z-index: 999;
}
.ctx-menu {
position: absolute;
overflow: visible;
}
.list-group .list-group-item.ctx-header {
font-weight: bold;
cursor: default;
}
.ctx-item+.ctx-header {
border-top: 1px solid;
}
.ctx-item {
position: relative;
}
.ctx-expander {
border-left: 1px solid;
margin-left: 1em;
text-align: center;
width: 2em;
}
.ctx-expander:hover {
font-weight: bold;
}
.ctx-sub-menu {
position: absolute;
top: 0;
left: 100%;
}
@media screen and (max-width: 1000px) {
.ctx-blocker {
position: absolute;
}
.list-group .list-group-item.ctx-item {
padding: 1em;
}
}
#qr--settings {
.qr--head {
display: flex;
align-items: baseline;
gap: 1em;
> .qr--title {
font-weight: bold;
}
> .qr--actions {
display: flex;
flex-direction: row;
align-items: baseline;
gap: 0.5em;
}
}
.qr--setList {
> .qr--item {
display: flex;
flex-direction: row;
gap: 0.5em;
align-items: baseline;
padding: 0 0.5em;
> .drag-handle {
padding: 0.75em;
}
> .qr--visible {
flex: 0 0 auto;
display: flex;
flex-direction: row;
}
}
}
#qr--set-settings {
#qr--injectInputContainer {
flex-wrap: nowrap;
}
}
#qr--set-qrList {
.qr--set-qrListContents > {
padding: 0 0.5em;
> .qr--set-item {
display: flex;
flex-direction: row;
gap: 0.5em;
align-items: baseline;
padding: 0.25em 0;
> :nth-child(1) { flex: 0 0 auto; }
> :nth-child(2) { flex: 1 1 25%; }
> :nth-child(3) { flex: 0 0 auto; }
> :nth-child(4) { flex: 1 1 75%; }
> :nth-child(5) { flex: 0 0 auto; }
> .drag-handle {
padding: 0.75em;
}
.qr--set-itemLabel, .qr--action {
margin: 0;
}
.qr--set-itemMessage {
font-size: smaller;
}
}
}
}
.qr--set-qrListActions {
display: flex;
flex-direction: row;
gap: 0.5em;
justify-content: center;
padding-bottom: 0.5em;
}
}
#qr--qrOptions {
> #qr--ctxEditor {
.qr--ctxItem {
display: flex;
flex-direction: row;
gap: 0.5em;
align-items: baseline;
}
}
}
@media screen and (max-width: 750px) {
body #dialogue_popup:has(#qr--modalEditor) #dialogue_popup_text > #qr--modalEditor {
flex-direction: column;
> #qr--main > .qr--labels {
flex-direction: column;
}
> #qr--main > .qr--modal-messageContainer > #qr--modal-message {
min-height: 90svh;
}
}
}
#dialogue_popup:has(#qr--modalEditor) {
aspect-ratio: unset;
#dialogue_popup_text {
display: flex;
flex-direction: column;
> #qr--modalEditor {
flex: 1 1 auto;
display: flex;
flex-direction: row;
gap: 1em;
> #qr--main {
flex: 1 1 auto;
display: flex;
flex-direction: column;
> .qr--labels {
flex: 0 0 auto;
display: flex;
flex-direction: row;
gap: 0.5em;
> label {
flex: 1 1 1px;
display: flex;
flex-direction: column;
> .qr--labelText {
flex: 1 1 auto;
}
> .qr--labelHint {
flex: 1 1 auto;
}
> input {
flex: 0 0 auto;
}
}
}
> .qr--modal-messageContainer {
flex: 1 1 auto;
display: flex;
flex-direction: column;
> #qr--modal-message {
flex: 1 1 auto;
}
}
}
#qr--modal-execute {
display: flex;
flex-direction: row;
gap: 0.5em;
&.qr--busy {
opacity: 0.5;
cursor: wait;
}
}
}
}
}
#shadow_popup.qr--hide {
opacity: 0 !important;
}

View File

@@ -56,7 +56,7 @@
<div>
<textarea
class="regex_replace_string text_pole wide100p textarea_compact"
placeholder="Use {{match}} to include the matched text from the Find Regex"
placeholder="Use {{match}} to include the matched text from the Find Regex or $1, $2, etc. for capture groups."
rows="2"
></textarea>
</div>
@@ -115,16 +115,16 @@
<input type="checkbox" name="run_on_edit" />
<span data-i18n="Run On Edit">Run On Edit</span>
</label>
<label class="checkbox flex-container">
<label class="checkbox flex-container" title="Substitute {{macros}} in Find Regex before running it">
<input type="checkbox" name="substitute_regex" />
<span data-i18n="Substitute Regex">Substitute Regex</span>
<span data-i18n="Substitute Regex">Substitute Regex (?)</span>
</label>
</div>
<div class="flex-container flexFlowColumn alignitemsstart">
<small>Replacement Strategy</small>
<select name="replace_strategy_select" class="margin0">
<option value="0">Replace</option>
<option value="1">Overlay</option>
<option value="1">Overlay (currently broken)</option>
</select>
</div>
</div>

View File

@@ -109,19 +109,29 @@ function runRegexScript(regexScript, rawString, { characterOverride } = {}) {
return newString;
}
newString = rawString.replace(findRegex, (fencedMatch) => {
let trimFencedMatch = filterString(fencedMatch, regexScript.trimStrings, { characterOverride });
// Run replacement. Currently does not support the Overlay strategy
newString = rawString.replace(findRegex, function(match) {
const args = [...arguments];
const replaceString = regexScript.replaceString.replace(/{{match}}/gi, '$0');
const replaceWithGroups = replaceString.replaceAll(/\$(\d)+/g, (_, num) => {
// Get a full match or a capture group
const match = args[Number(num)];
const subReplaceString = substituteRegexParams(
regexScript.replaceString,
trimFencedMatch,
{
characterOverride,
replaceStrategy: regexScript.replaceStrategy ?? regex_replace_strategy.REPLACE,
},
);
// No match found - return the empty string
if (!match) {
return '';
}
return subReplaceString;
// Remove trim strings from the match
const filteredMatch = filterString(match, regexScript.trimStrings, { characterOverride });
// TODO: Handle overlay here
return filteredMatch;
});
// Substitute at the end
return substituteParams(replaceWithGroups);
});
return newString;