Merge pull request #1581 from LenAnderson/qr-rewrite

Rewrite of Quick Reply extension
This commit is contained in:
Cohee 2024-01-05 19:32:40 +02:00 committed by GitHub
commit c937d88437
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 3406 additions and 1326 deletions

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] ?? '';
});
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,126 @@
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.setList.push(qrl);
this.update();
this.updateSetListDom();
}
}
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', ()=>{
const qrl = new QuickReplySetLink();
qrl.set = QuickReplySet.list[0];
this.hookQuickReplyLink(qrl);
this.setList.push(qrl);
this.setListDom.append(qrl.renderSettings(this.setList.length - 1));
this.update();
});
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 } 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 = 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,127 @@
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');
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,365 @@
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(),
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,280 @@
#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 > .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 .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,311 @@
#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;
> .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; }
.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

@ -175,7 +175,7 @@ parser.addCommand('fuzzy', fuzzyCallback, [], 'list=["a","b","c"] (search value)
parser.addCommand('pass', (_, arg) => arg, ['return'], '<span class="monospace">(text)</span> passes the text to the next command through the pipe.', true, true);
parser.addCommand('delay', delayCallback, ['wait', 'sleep'], '<span class="monospace">(milliseconds)</span> delays the next command in the pipe by the specified number of milliseconds.', true, true);
parser.addCommand('input', inputCallback, ['prompt'], '<span class="monospace">(default="string" large=on/off wide=on/off okButton="string" rows=number [text])</span> Shows a popup with the provided text and an input field. The default argument is the default value of the input field, and the text argument is the text to display.', true, true);
parser.addCommand('run', runCallback, ['call', 'exec'], '<span class="monospace">(QR label)</span> runs a Quick Reply with the specified name from the current preset.', true, true);
parser.addCommand('run', runCallback, ['call', 'exec'], '<span class="monospace">[key1=value key2=value ...] ([qrSet.]qrLabel)</span> runs a Quick Reply with the specified name from a currently active preset or from another preset, named arguments can be referenced in a QR with {{arg::key}}.', true, true);
parser.addCommand('messages', getMessagesCallback, ['message'], '<span class="monospace">(names=off/on [message index or range])</span> returns the specified message or range of messages as a string.', true, true);
parser.addCommand('setinput', setInputCallback, [], '<span class="monospace">(text)</span> sets the user input to the specified text and passes it to the next command through the pipe.', true, true);
parser.addCommand('popup', popupCallback, [], '<span class="monospace">(large=on/off wide=on/off okButton="string" text)</span> shows a blocking popup with the specified text and buttons. Returns the input value into the pipe or empty string if canceled.', true, true);
@ -445,7 +445,7 @@ function getMessagesCallback(args, value) {
return messages.join('\n\n');
}
async function runCallback(_, name) {
async function runCallback(args, name) {
if (!name) {
toastr.warning('No name provided for /run command');
return '';
@ -458,7 +458,7 @@ async function runCallback(_, name) {
try {
name = name.trim();
return await window['executeQuickReplyByName'](name);
return await window['executeQuickReplyByName'](name, args);
} catch (error) {
toastr.error(`Error running Quick Reply "${name}": ${error.message}`, 'Error');
return '';

View File

@ -294,6 +294,19 @@ app.post('/savequickreply', jsonParser, (request, response) => {
return response.sendStatus(200);
});
app.post('/deletequickreply', jsonParser, (request, response) => {
if (!request.body || !request.body.name) {
return response.sendStatus(400);
}
const filename = path.join(DIRECTORIES.quickreplies, sanitize(request.body.name) + '.json');
if (fs.existsSync(filename)) {
fs.unlinkSync(filename);
}
return response.sendStatus(200);
});
app.post('/uploaduseravatar', urlencodedParser, async (request, response) => {
if (!request.file) return response.sendStatus(400);