mirror of
https://github.com/SillyTavern/SillyTavern.git
synced 2025-06-05 21:59:27 +02:00
Merge branch 'SillyTavern:staging' into staging
This commit is contained in:
@@ -10,6 +10,7 @@ export { MODULE_NAME };
|
||||
const MODULE_NAME = 'expressions';
|
||||
const UPDATE_INTERVAL = 2000;
|
||||
const STREAMING_UPDATE_INTERVAL = 6000;
|
||||
const TALKINGCHECK_UPDATE_INTERVAL = 500;
|
||||
const FALLBACK_EXPRESSION = 'joy';
|
||||
const DEFAULT_EXPRESSIONS = [
|
||||
'talkinghead',
|
||||
@@ -46,11 +47,17 @@ const DEFAULT_EXPRESSIONS = [
|
||||
let expressionsList = null;
|
||||
let lastCharacter = undefined;
|
||||
let lastMessage = null;
|
||||
let lastTalkingState = false;
|
||||
let lastTalkingStateMessage = null; // last message as seen by `updateTalkingState` (tracked separately, different timer)
|
||||
let spriteCache = {};
|
||||
let inApiCall = false;
|
||||
let lastServerResponseTime = 0;
|
||||
export let lastExpression = {};
|
||||
|
||||
function isTalkingHeadEnabled() {
|
||||
return extension_settings.expressions.talkinghead && !extension_settings.expressions.local;
|
||||
}
|
||||
|
||||
function isVisualNovelMode() {
|
||||
return Boolean(!isMobile() && power_user.waifuMode && getContext().groupId);
|
||||
}
|
||||
@@ -381,7 +388,10 @@ function onExpressionsShowDefaultInput() {
|
||||
}
|
||||
}
|
||||
|
||||
async function unloadLiveChar() {
|
||||
/**
|
||||
* Stops animating a talkinghead.
|
||||
*/
|
||||
async function unloadTalkingHead() {
|
||||
if (!modules.includes('talkinghead')) {
|
||||
console.debug('talkinghead module is disabled');
|
||||
return;
|
||||
@@ -400,7 +410,10 @@ async function unloadLiveChar() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLiveChar() {
|
||||
/**
|
||||
* Posts `talkinghead.png` of the current character to the talkinghead module in SillyTavern-extras, to start animating it.
|
||||
*/
|
||||
async function loadTalkingHead() {
|
||||
if (!modules.includes('talkinghead')) {
|
||||
console.debug('talkinghead module is disabled');
|
||||
return;
|
||||
@@ -409,6 +422,8 @@ async function loadLiveChar() {
|
||||
const spriteFolderName = getSpriteFolderName();
|
||||
|
||||
const talkingheadPath = `/characters/${encodeURIComponent(spriteFolderName)}/talkinghead.png`;
|
||||
const emotionsSettingsPath = `/characters/${encodeURIComponent(spriteFolderName)}/_emotions.json`;
|
||||
const animatorSettingsPath = `/characters/${encodeURIComponent(spriteFolderName)}/_animator.json`;
|
||||
|
||||
try {
|
||||
const spriteResponse = await fetch(talkingheadPath);
|
||||
@@ -437,6 +452,69 @@ async function loadLiveChar() {
|
||||
const loadResponseText = await loadResponse.text();
|
||||
console.log(`Load talkinghead response: ${loadResponseText}`);
|
||||
|
||||
// Optional: per-character emotion templates
|
||||
let emotionsSettings;
|
||||
try {
|
||||
const emotionsResponse = await fetch(emotionsSettingsPath);
|
||||
if (emotionsResponse.ok) {
|
||||
emotionsSettings = await emotionsResponse.json();
|
||||
console.log(`Loaded ${emotionsSettingsPath}`);
|
||||
} else {
|
||||
throw new Error();
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
emotionsSettings = {}; // blank -> use server defaults (to unload the previous character's customizations)
|
||||
console.log(`No valid config at ${emotionsSettingsPath}, using server defaults`);
|
||||
}
|
||||
try {
|
||||
const url = new URL(getApiUrl());
|
||||
url.pathname = '/api/talkinghead/load_emotion_templates';
|
||||
const apiResult = await doExtrasFetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Bypass-Tunnel-Reminder': 'bypass',
|
||||
},
|
||||
body: JSON.stringify(emotionsSettings),
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
// it's ok if not supported
|
||||
console.log('Failed to send _emotions.json (backend too old?), ignoring');
|
||||
}
|
||||
|
||||
// Optional: per-character animator and postprocessor config
|
||||
let animatorSettings;
|
||||
try {
|
||||
const animatorResponse = await fetch(animatorSettingsPath);
|
||||
if (animatorResponse.ok) {
|
||||
animatorSettings = await animatorResponse.json();
|
||||
console.log(`Loaded ${animatorSettingsPath}`);
|
||||
} else {
|
||||
throw new Error();
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
animatorSettings = {}; // blank -> use server defaults (to unload the previous character's customizations)
|
||||
console.log(`No valid config at ${animatorSettingsPath}, using server defaults`);
|
||||
}
|
||||
try {
|
||||
const url = new URL(getApiUrl());
|
||||
url.pathname = '/api/talkinghead/load_animator_settings';
|
||||
const apiResult = await doExtrasFetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Bypass-Tunnel-Reminder': 'bypass',
|
||||
},
|
||||
body: JSON.stringify(animatorSettings),
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
// it's ok if not supported
|
||||
console.log('Failed to send _animator.json (backend too old?), ignoring');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error loading talkinghead image: ${talkingheadPath} - ${error}`);
|
||||
}
|
||||
@@ -450,7 +528,7 @@ function handleImageChange() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (extension_settings.expressions.talkinghead && !extension_settings.expressions.local) {
|
||||
if (isTalkingHeadEnabled()) {
|
||||
// Method get IP of endpoint
|
||||
const talkingheadResultFeedSrc = `${getApiUrl()}/api/talkinghead/result_feed`;
|
||||
$('#expression-holder').css({ display: '' });
|
||||
@@ -559,9 +637,10 @@ async function moduleWorker() {
|
||||
return;
|
||||
}
|
||||
|
||||
const lastMessageChanged = !((lastCharacter === context.characterId || lastCharacter === context.groupId) && lastMessage === currentLastMessage.mes);
|
||||
|
||||
// check if last message changed
|
||||
if ((lastCharacter === context.characterId || lastCharacter === context.groupId)
|
||||
&& lastMessage === currentLastMessage.mes) {
|
||||
if (!lastMessageChanged) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -611,21 +690,81 @@ async function moduleWorker() {
|
||||
}
|
||||
}
|
||||
|
||||
async function talkingHeadCheck() {
|
||||
/**
|
||||
* Starts/stops talkinghead talking animation.
|
||||
*
|
||||
* Talking starts only when all the following conditions are met:
|
||||
* - The LLM is currently streaming its output.
|
||||
* - The AI's current last message is non-empty, and also not just '...' (as produced by a swipe).
|
||||
* - The AI's current last message has changed from what we saw during the previous call.
|
||||
*
|
||||
* In all other cases, talking stops.
|
||||
*
|
||||
* A talkinghead API call is made only when the talking state changes.
|
||||
*/
|
||||
async function updateTalkingState() {
|
||||
// Don't bother if talkinghead is disabled or not loaded.
|
||||
if (!isTalkingHeadEnabled() || !modules.includes('talkinghead')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const context = getContext();
|
||||
const currentLastMessage = getLastCharacterMessage();
|
||||
|
||||
try {
|
||||
// TODO: Not sure if we need also "&& !context.groupId" here - the classify check in `moduleWorker`
|
||||
// (that similarly checks the streaming processor state) does that for some reason.
|
||||
// Talkinghead isn't currently designed to work with groups.
|
||||
const lastMessageChanged = !((lastCharacter === context.characterId || lastCharacter === context.groupId) && lastTalkingStateMessage === currentLastMessage.mes);
|
||||
const url = new URL(getApiUrl());
|
||||
let newTalkingState;
|
||||
if (context.streamingProcessor && !context.streamingProcessor.isFinished &&
|
||||
currentLastMessage.mes.length !== 0 && currentLastMessage.mes !== '...' && lastMessageChanged) {
|
||||
url.pathname = '/api/talkinghead/start_talking';
|
||||
newTalkingState = true;
|
||||
} else {
|
||||
url.pathname = '/api/talkinghead/stop_talking';
|
||||
newTalkingState = false;
|
||||
}
|
||||
try {
|
||||
// Call the talkinghead API only if the talking state changed.
|
||||
if (newTalkingState !== lastTalkingState) {
|
||||
console.debug(`updateTalkingState: calling ${url.pathname}`);
|
||||
await doExtrasFetch(url);
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
// it's ok if not supported
|
||||
}
|
||||
finally {
|
||||
lastTalkingState = newTalkingState;
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
// console.log(error);
|
||||
}
|
||||
finally {
|
||||
lastTalkingStateMessage = currentLastMessage.mes;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether the current character has a talkinghead image available.
|
||||
* @returns {Promise<boolean>} True if the character has a talkinghead image available, false otherwise.
|
||||
*/
|
||||
async function isTalkingHeadAvailable() {
|
||||
let spriteFolderName = getSpriteFolderName();
|
||||
|
||||
try {
|
||||
await validateImages(spriteFolderName);
|
||||
|
||||
let talkingheadObj = spriteCache[spriteFolderName].find(obj => obj.label === 'talkinghead');
|
||||
let talkingheadPath_f = talkingheadObj ? talkingheadObj.path : null;
|
||||
let talkingheadPath = talkingheadObj ? talkingheadObj.path : null;
|
||||
|
||||
if (talkingheadPath_f != null) {
|
||||
//console.log("talkingheadPath_f " + talkingheadPath_f);
|
||||
if (talkingheadPath != null) {
|
||||
return true;
|
||||
} else {
|
||||
//console.log("talkingheadPath_f is null");
|
||||
unloadLiveChar();
|
||||
await unloadTalkingHead();
|
||||
return false;
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -647,22 +786,22 @@ function getSpriteFolderName(characterMessage = null, characterName = null) {
|
||||
return spriteFolderName;
|
||||
}
|
||||
|
||||
function setTalkingHeadState(switch_var) {
|
||||
extension_settings.expressions.talkinghead = switch_var; // Store setting
|
||||
function setTalkingHeadState(newState) {
|
||||
extension_settings.expressions.talkinghead = newState; // Store setting
|
||||
saveSettingsDebounced();
|
||||
|
||||
if (extension_settings.expressions.local) {
|
||||
return;
|
||||
}
|
||||
|
||||
talkingHeadCheck().then(result => {
|
||||
isTalkingHeadAvailable().then(result => {
|
||||
if (result) {
|
||||
//console.log("talkinghead exists!");
|
||||
|
||||
if (extension_settings.expressions.talkinghead) {
|
||||
loadLiveChar();
|
||||
loadTalkingHead();
|
||||
} else {
|
||||
unloadLiveChar();
|
||||
unloadTalkingHead();
|
||||
}
|
||||
handleImageChange(); // Change image as needed
|
||||
|
||||
@@ -732,22 +871,29 @@ async function setSpriteSlashCommand(_, spriteId) {
|
||||
|
||||
spriteId = spriteId.trim().toLowerCase();
|
||||
|
||||
// In talkinghead mode, don't check for the existence of the sprite
|
||||
// (emotion names are the same as for sprites, but it only needs "talkinghead.png").
|
||||
const currentLastMessage = getLastCharacterMessage();
|
||||
const spriteFolderName = getSpriteFolderName(currentLastMessage, currentLastMessage.name);
|
||||
await validateImages(spriteFolderName);
|
||||
let label = spriteId;
|
||||
if (!isTalkingHeadEnabled()) {
|
||||
await validateImages(spriteFolderName);
|
||||
|
||||
// Fuzzy search for sprite
|
||||
const fuse = new Fuse(spriteCache[spriteFolderName], { keys: ['label'] });
|
||||
const results = fuse.search(spriteId);
|
||||
const spriteItem = results[0]?.item;
|
||||
// Fuzzy search for sprite
|
||||
const fuse = new Fuse(spriteCache[spriteFolderName], { keys: ['label'] });
|
||||
const results = fuse.search(spriteId);
|
||||
const spriteItem = results[0]?.item;
|
||||
|
||||
if (!spriteItem) {
|
||||
console.log('No sprite found for search term ' + spriteId);
|
||||
return;
|
||||
if (!spriteItem) {
|
||||
console.log('No sprite found for search term ' + spriteId);
|
||||
return;
|
||||
}
|
||||
|
||||
label = spriteItem.label;
|
||||
}
|
||||
|
||||
const vnMode = isVisualNovelMode();
|
||||
await sendExpressionCall(spriteFolderName, spriteItem.label, true, vnMode);
|
||||
await sendExpressionCall(spriteFolderName, label, true, vnMode);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -998,7 +1144,7 @@ async function getExpressionsList() {
|
||||
}
|
||||
|
||||
async function setExpression(character, expression, force) {
|
||||
if (extension_settings.expressions.local || !extension_settings.expressions.talkinghead) {
|
||||
if (!isTalkingHeadEnabled()) {
|
||||
console.debug('entered setExpressions');
|
||||
await validateImages(character);
|
||||
const img = $('img.expression');
|
||||
@@ -1109,24 +1255,38 @@ async function setExpression(character, expression, force) {
|
||||
document.getElementById('expression-holder').style.display = '';
|
||||
|
||||
} else {
|
||||
|
||||
|
||||
talkingHeadCheck().then(result => {
|
||||
// Set the talkinghead emotion to the specified expression
|
||||
// TODO: For now, talkinghead emote only supported when VN mode is off; see also updateVisualNovelMode.
|
||||
try {
|
||||
let result = await isTalkingHeadAvailable();
|
||||
if (result) {
|
||||
// Find the <img> element with id="expression-image" and class="expression"
|
||||
const imgElement = document.querySelector('img#expression-image.expression');
|
||||
//console.log("searching");
|
||||
if (imgElement && imgElement instanceof HTMLImageElement) {
|
||||
//console.log("setting value");
|
||||
imgElement.src = getApiUrl() + '/api/talkinghead/result_feed';
|
||||
}
|
||||
|
||||
} else {
|
||||
//console.log("The fetch failed!");
|
||||
const url = new URL(getApiUrl());
|
||||
url.pathname = '/api/talkinghead/set_emotion';
|
||||
await doExtrasFetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ emotion_name: expression }),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
catch (error) {
|
||||
// `set_emotion` is not present in old versions, so let it 404.
|
||||
}
|
||||
|
||||
try {
|
||||
// Find the <img> element with id="expression-image" and class="expression"
|
||||
const imgElement = document.querySelector('img#expression-image.expression');
|
||||
//console.log("searching");
|
||||
if (imgElement && imgElement instanceof HTMLImageElement) {
|
||||
//console.log("setting value");
|
||||
imgElement.src = getApiUrl() + '/api/talkinghead/result_feed';
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
//console.log("The fetch failed!");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1247,6 +1407,11 @@ async function onClickExpressionUpload(event) {
|
||||
|
||||
// Reset the input
|
||||
e.target.form.reset();
|
||||
|
||||
// In talkinghead mode, when a new talkinghead image is uploaded, refresh the live char.
|
||||
if (isTalkingHeadEnabled() && id === 'talkinghead') {
|
||||
await loadTalkingHead();
|
||||
}
|
||||
};
|
||||
|
||||
$('#expression_upload')
|
||||
@@ -1473,6 +1638,11 @@ function setExpressionOverrideHtml(forceClear = false) {
|
||||
const updateFunction = wrapper.update.bind(wrapper);
|
||||
setInterval(updateFunction, UPDATE_INTERVAL);
|
||||
moduleWorker();
|
||||
// For setting the talkinghead talking animation on/off quickly enough for realtime use, we need another timer on a shorter schedule.
|
||||
const wrapperTalkingState = new ModuleWorkerWrapper(updateTalkingState);
|
||||
const updateTalkingStateFunction = wrapperTalkingState.update.bind(wrapperTalkingState);
|
||||
setInterval(updateTalkingStateFunction, TALKINGCHECK_UPDATE_INTERVAL);
|
||||
updateTalkingState();
|
||||
dragElement($('#expression-holder'));
|
||||
eventSource.on(event_types.CHAT_CHANGED, () => {
|
||||
// character changed
|
||||
|
@@ -4,7 +4,7 @@ import {
|
||||
characters,
|
||||
getRequestHeaders,
|
||||
} from '../../../script.js';
|
||||
import { selected_group } from '../../group-chats.js';
|
||||
import { groups, selected_group } from '../../group-chats.js';
|
||||
import { loadFileToDocument, delay } from '../../utils.js';
|
||||
import { loadMovingUIState } from '../../power-user.js';
|
||||
import { dragElement } from '../../RossAscends-mods.js';
|
||||
@@ -416,7 +416,26 @@ function viewWithDragbox(items) {
|
||||
|
||||
// Registers a simple command for opening the char gallery.
|
||||
registerSlashCommand('show-gallery', showGalleryCommand, ['sg'], '– shows the gallery', true, true);
|
||||
registerSlashCommand('list-gallery', listGalleryCommand, ['lg'], '<span class="monospace">[optional char=charName] [optional group=groupName]</span> – list images in the gallery of the current char / group or a specified char / group', true, true);
|
||||
|
||||
function showGalleryCommand(args) {
|
||||
showCharGallery();
|
||||
}
|
||||
|
||||
async function listGalleryCommand(args) {
|
||||
try {
|
||||
let url = args.char ?? (args.group ? groups.find(it=>it.name == args.group)?.id : null) ?? (selected_group || this_chid);
|
||||
if (!args.char && !args.group && !selected_group && this_chid) {
|
||||
const char = characters[this_chid];
|
||||
url = char.avatar.replace('.png', '');
|
||||
}
|
||||
|
||||
const items = await getGalleryItems(url);
|
||||
return JSON.stringify(items.map(it=>it.src));
|
||||
|
||||
} catch (err) {
|
||||
console.trace();
|
||||
console.error(err);
|
||||
}
|
||||
return JSON.stringify([]);
|
||||
}
|
||||
|
451
public/scripts/extensions/quick-reply/api/QuickReplyApi.js
Normal file
451
public/scripts/extensions/quick-reply/api/QuickReplyApi.js
Normal 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);
|
||||
}
|
||||
}
|
@@ -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>
|
83
public/scripts/extensions/quick-reply/html/qrEditor.html
Normal file
83
public/scripts/extensions/quick-reply/html/qrEditor.html
Normal 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>
|
71
public/scripts/extensions/quick-reply/html/settings.html
Normal file
71
public/scripts/extensions/quick-reply/html/settings.html
Normal 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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
@@ -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);
|
||||
}
|
||||
}
|
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
489
public/scripts/extensions/quick-reply/src/QuickReply.js
Normal file
489
public/scripts/extensions/quick-reply/src/QuickReply.js
Normal file
@@ -0,0 +1,489 @@
|
||||
import { callPopup } from '../../../../script.js';
|
||||
import { getSortableDelay } from '../../../utils.js';
|
||||
import { log, warn } from '../index.js';
|
||||
import { QuickReplyContextLink } from './QuickReplyContextLink.js';
|
||||
import { QuickReplySet } from './QuickReplySet.js';
|
||||
import { ContextMenu } from './ui/ctx/ContextMenu.js';
|
||||
|
||||
export class QuickReply {
|
||||
/**
|
||||
* @param {{ id?: number; contextList?: any; }} props
|
||||
*/
|
||||
static from(props) {
|
||||
props.contextList = (props.contextList ?? []).map((/** @type {any} */ it)=>QuickReplyContextLink.from(it));
|
||||
return Object.assign(new this(), props);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/**@type {Number}*/ id;
|
||||
/**@type {String}*/ label = '';
|
||||
/**@type {String}*/ title = '';
|
||||
/**@type {String}*/ message = '';
|
||||
|
||||
/**@type {QuickReplyContextLink[]}*/ contextList;
|
||||
|
||||
/**@type {Boolean}*/ preventAutoExecute = true;
|
||||
/**@type {Boolean}*/ isHidden = false;
|
||||
/**@type {Boolean}*/ executeOnStartup = false;
|
||||
/**@type {Boolean}*/ executeOnUser = false;
|
||||
/**@type {Boolean}*/ executeOnAi = false;
|
||||
/**@type {Boolean}*/ executeOnChatChange = false;
|
||||
|
||||
/**@type {Function}*/ onExecute;
|
||||
/**@type {Function}*/ onDelete;
|
||||
/**@type {Function}*/ onUpdate;
|
||||
|
||||
|
||||
/**@type {HTMLElement}*/ dom;
|
||||
/**@type {HTMLElement}*/ domLabel;
|
||||
/**@type {HTMLElement}*/ settingsDom;
|
||||
/**@type {HTMLInputElement}*/ settingsDomLabel;
|
||||
/**@type {HTMLTextAreaElement}*/ settingsDomMessage;
|
||||
|
||||
|
||||
get hasContext() {
|
||||
return this.contextList && this.contextList.length > 0;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
unrender() {
|
||||
this.dom?.remove();
|
||||
this.dom = null;
|
||||
}
|
||||
updateRender() {
|
||||
if (!this.dom) return;
|
||||
this.dom.title = this.title || this.message;
|
||||
this.domLabel.textContent = this.label;
|
||||
this.dom.classList[this.hasContext ? 'add' : 'remove']('qr--hasCtx');
|
||||
}
|
||||
render() {
|
||||
this.unrender();
|
||||
if (!this.dom) {
|
||||
const root = document.createElement('div'); {
|
||||
this.dom = root;
|
||||
root.classList.add('qr--button');
|
||||
root.classList.add('menu_button');
|
||||
if (this.hasContext) {
|
||||
root.classList.add('qr--hasCtx');
|
||||
}
|
||||
root.title = this.title || this.message;
|
||||
root.addEventListener('contextmenu', (evt) => {
|
||||
log('contextmenu', this, this.hasContext);
|
||||
if (this.hasContext) {
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
const menu = new ContextMenu(this);
|
||||
menu.show(evt);
|
||||
}
|
||||
});
|
||||
root.addEventListener('click', (evt)=>{
|
||||
if (evt.ctrlKey) {
|
||||
this.showEditor();
|
||||
return;
|
||||
}
|
||||
this.execute();
|
||||
});
|
||||
const lbl = document.createElement('div'); {
|
||||
this.domLabel = lbl;
|
||||
lbl.classList.add('qr--button-label');
|
||||
lbl.textContent = this.label;
|
||||
root.append(lbl);
|
||||
}
|
||||
const expander = document.createElement('div'); {
|
||||
expander.classList.add('qr--button-expander');
|
||||
expander.textContent = '⋮';
|
||||
expander.title = 'Open context menu';
|
||||
expander.addEventListener('click', (evt) => {
|
||||
evt.stopPropagation();
|
||||
evt.preventDefault();
|
||||
const menu = new ContextMenu(this);
|
||||
menu.show(evt);
|
||||
});
|
||||
root.append(expander);
|
||||
}
|
||||
}
|
||||
}
|
||||
return this.dom;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
renderSettings(idx) {
|
||||
if (!this.settingsDom) {
|
||||
const item = document.createElement('div'); {
|
||||
this.settingsDom = item;
|
||||
item.classList.add('qr--set-item');
|
||||
item.setAttribute('data-order', String(idx));
|
||||
item.setAttribute('data-id', String(this.id));
|
||||
const drag = document.createElement('div'); {
|
||||
drag.classList.add('drag-handle');
|
||||
drag.classList.add('ui-sortable-handle');
|
||||
drag.textContent = '☰';
|
||||
item.append(drag);
|
||||
}
|
||||
const lblContainer = document.createElement('div'); {
|
||||
lblContainer.classList.add('qr--set-itemLabelContainer');
|
||||
const lbl = document.createElement('input'); {
|
||||
this.settingsDomLabel = lbl;
|
||||
lbl.classList.add('qr--set-itemLabel');
|
||||
lbl.classList.add('text_pole');
|
||||
lbl.value = this.label;
|
||||
lbl.addEventListener('input', ()=>this.updateLabel(lbl.value));
|
||||
lblContainer.append(lbl);
|
||||
}
|
||||
item.append(lblContainer);
|
||||
}
|
||||
const optContainer = document.createElement('div'); {
|
||||
optContainer.classList.add('qr--set-optionsContainer');
|
||||
const opt = document.createElement('div'); {
|
||||
opt.classList.add('qr--action');
|
||||
opt.classList.add('menu_button');
|
||||
opt.classList.add('fa-solid');
|
||||
opt.textContent = '⁝';
|
||||
opt.title = 'Additional options:\n - large editor\n - context menu\n - auto-execution\n - tooltip';
|
||||
opt.addEventListener('click', ()=>this.showEditor());
|
||||
optContainer.append(opt);
|
||||
}
|
||||
item.append(optContainer);
|
||||
}
|
||||
const mes = document.createElement('textarea'); {
|
||||
this.settingsDomMessage = mes;
|
||||
mes.id = `qr--set--item${this.id}`;
|
||||
mes.classList.add('qr--set-itemMessage');
|
||||
mes.value = this.message;
|
||||
//HACK need to use jQuery to catch the triggered event from the expanded editor
|
||||
$(mes).on('input', ()=>this.updateMessage(mes.value));
|
||||
item.append(mes);
|
||||
}
|
||||
const actions = document.createElement('div'); {
|
||||
actions.classList.add('qr--actions');
|
||||
const del = document.createElement('div'); {
|
||||
del.classList.add('qr--action');
|
||||
del.classList.add('menu_button');
|
||||
del.classList.add('menu_button_icon');
|
||||
del.classList.add('fa-solid');
|
||||
del.classList.add('fa-trash-can');
|
||||
del.classList.add('redWarningBG');
|
||||
del.title = 'Remove quick reply';
|
||||
del.addEventListener('click', ()=>this.delete());
|
||||
actions.append(del);
|
||||
}
|
||||
item.append(actions);
|
||||
}
|
||||
}
|
||||
}
|
||||
return this.settingsDom;
|
||||
}
|
||||
unrenderSettings() {
|
||||
this.settingsDom?.remove();
|
||||
}
|
||||
|
||||
async showEditor() {
|
||||
const response = await fetch('/scripts/extensions/quick-reply/html/qrEditor.html', { cache: 'no-store' });
|
||||
if (response.ok) {
|
||||
this.template = document.createRange().createContextualFragment(await response.text()).querySelector('#qr--modalEditor');
|
||||
/**@type {HTMLElement} */
|
||||
// @ts-ignore
|
||||
const dom = this.template.cloneNode(true);
|
||||
const popupResult = callPopup(dom, 'text', undefined, { okButton: 'OK', wide: true, large: true, rows: 1 });
|
||||
|
||||
// basics
|
||||
/**@type {HTMLInputElement}*/
|
||||
const label = dom.querySelector('#qr--modal-label');
|
||||
label.value = this.label;
|
||||
label.addEventListener('input', ()=>{
|
||||
this.updateLabel(label.value);
|
||||
});
|
||||
/**@type {HTMLInputElement}*/
|
||||
const title = dom.querySelector('#qr--modal-title');
|
||||
title.value = this.title;
|
||||
title.addEventListener('input', () => {
|
||||
this.updateTitle(title.value);
|
||||
});
|
||||
/**@type {HTMLTextAreaElement}*/
|
||||
const message = dom.querySelector('#qr--modal-message');
|
||||
message.value = this.message;
|
||||
message.addEventListener('input', () => {
|
||||
this.updateMessage(message.value);
|
||||
});
|
||||
//TODO move tab support for textarea into its own helper(?) and use for both this and .editor_maximize
|
||||
message.addEventListener('keydown', (evt) => {
|
||||
if (evt.key == 'Tab' && !evt.shiftKey && !evt.ctrlKey && !evt.altKey) {
|
||||
evt.preventDefault();
|
||||
const start = message.selectionStart;
|
||||
const end = message.selectionEnd;
|
||||
if (end - start > 0 && message.value.substring(start, end).includes('\n')) {
|
||||
const lineStart = message.value.lastIndexOf('\n', start);
|
||||
const count = message.value.substring(lineStart, end).split('\n').length - 1;
|
||||
message.value = `${message.value.substring(0, lineStart)}${message.value.substring(lineStart, end).replace(/\n/g, '\n\t')}${message.value.substring(end)}`;
|
||||
message.selectionStart = start + 1;
|
||||
message.selectionEnd = end + count;
|
||||
this.updateMessage(message.value);
|
||||
} else {
|
||||
message.value = `${message.value.substring(0, start)}\t${message.value.substring(end)}`;
|
||||
message.selectionStart = start + 1;
|
||||
message.selectionEnd = end + 1;
|
||||
this.updateMessage(message.value);
|
||||
}
|
||||
} else if (evt.key == 'Tab' && evt.shiftKey && !evt.ctrlKey && !evt.altKey) {
|
||||
evt.preventDefault();
|
||||
const start = message.selectionStart;
|
||||
const end = message.selectionEnd;
|
||||
const lineStart = message.value.lastIndexOf('\n', start);
|
||||
const count = message.value.substring(lineStart, end).split('\n\t').length - 1;
|
||||
message.value = `${message.value.substring(0, lineStart)}${message.value.substring(lineStart, end).replace(/\n\t/g, '\n')}${message.value.substring(end)}`;
|
||||
message.selectionStart = start - 1;
|
||||
message.selectionEnd = end - count;
|
||||
this.updateMessage(message.value);
|
||||
}
|
||||
});
|
||||
|
||||
// context menu
|
||||
/**@type {HTMLTemplateElement}*/
|
||||
const tpl = dom.querySelector('#qr--ctxItem');
|
||||
const linkList = dom.querySelector('#qr--ctxEditor');
|
||||
const fillQrSetSelect = (/**@type {HTMLSelectElement}*/select, /**@type {QuickReplyContextLink}*/ link) => {
|
||||
[{ name: 'Select a QR set' }, ...QuickReplySet.list].forEach(qrs => {
|
||||
const opt = document.createElement('option'); {
|
||||
opt.value = qrs.name;
|
||||
opt.textContent = qrs.name;
|
||||
opt.selected = qrs.name == link.set?.name;
|
||||
select.append(opt);
|
||||
}
|
||||
});
|
||||
};
|
||||
const addCtxItem = (/**@type {QuickReplyContextLink}*/link, /**@type {Number}*/idx) => {
|
||||
/**@type {HTMLElement} */
|
||||
// @ts-ignore
|
||||
const itemDom = tpl.content.querySelector('.qr--ctxItem').cloneNode(true); {
|
||||
itemDom.setAttribute('data-order', String(idx));
|
||||
|
||||
/**@type {HTMLSelectElement} */
|
||||
const select = itemDom.querySelector('.qr--set');
|
||||
fillQrSetSelect(select, link);
|
||||
select.addEventListener('change', () => {
|
||||
link.set = QuickReplySet.get(select.value);
|
||||
this.updateContext();
|
||||
});
|
||||
|
||||
/**@type {HTMLInputElement} */
|
||||
const chain = itemDom.querySelector('.qr--isChained');
|
||||
chain.checked = link.isChained;
|
||||
chain.addEventListener('click', () => {
|
||||
link.isChained = chain.checked;
|
||||
this.updateContext();
|
||||
});
|
||||
|
||||
itemDom.querySelector('.qr--delete').addEventListener('click', () => {
|
||||
itemDom.remove();
|
||||
this.contextList.splice(this.contextList.indexOf(link), 1);
|
||||
this.updateContext();
|
||||
});
|
||||
|
||||
linkList.append(itemDom);
|
||||
}
|
||||
};
|
||||
[...this.contextList].forEach((link, idx) => addCtxItem(link, idx));
|
||||
dom.querySelector('#qr--ctxAdd').addEventListener('click', () => {
|
||||
const link = new QuickReplyContextLink();
|
||||
this.contextList.push(link);
|
||||
addCtxItem(link, this.contextList.length - 1);
|
||||
});
|
||||
const onContextSort = () => {
|
||||
this.contextList = Array.from(linkList.querySelectorAll('.qr--ctxItem')).map((it,idx) => {
|
||||
const link = this.contextList[Number(it.getAttribute('data-order'))];
|
||||
it.setAttribute('data-order', String(idx));
|
||||
return link;
|
||||
});
|
||||
this.updateContext();
|
||||
};
|
||||
// @ts-ignore
|
||||
$(linkList).sortable({
|
||||
delay: getSortableDelay(),
|
||||
stop: () => onContextSort(),
|
||||
});
|
||||
|
||||
// auto-exec
|
||||
/**@type {HTMLInputElement}*/
|
||||
const preventAutoExecute = dom.querySelector('#qr--preventAutoExecute');
|
||||
preventAutoExecute.checked = this.preventAutoExecute;
|
||||
preventAutoExecute.addEventListener('click', ()=>{
|
||||
this.preventAutoExecute = preventAutoExecute.checked;
|
||||
this.updateContext();
|
||||
});
|
||||
/**@type {HTMLInputElement}*/
|
||||
const isHidden = dom.querySelector('#qr--isHidden');
|
||||
isHidden.checked = this.isHidden;
|
||||
isHidden.addEventListener('click', ()=>{
|
||||
this.isHidden = isHidden.checked;
|
||||
this.updateContext();
|
||||
});
|
||||
/**@type {HTMLInputElement}*/
|
||||
const executeOnStartup = dom.querySelector('#qr--executeOnStartup');
|
||||
executeOnStartup.checked = this.executeOnStartup;
|
||||
executeOnStartup.addEventListener('click', ()=>{
|
||||
this.executeOnStartup = executeOnStartup.checked;
|
||||
this.updateContext();
|
||||
});
|
||||
/**@type {HTMLInputElement}*/
|
||||
const executeOnUser = dom.querySelector('#qr--executeOnUser');
|
||||
executeOnUser.checked = this.executeOnUser;
|
||||
executeOnUser.addEventListener('click', ()=>{
|
||||
this.executeOnUser = executeOnUser.checked;
|
||||
this.updateContext();
|
||||
});
|
||||
/**@type {HTMLInputElement}*/
|
||||
const executeOnAi = dom.querySelector('#qr--executeOnAi');
|
||||
executeOnAi.checked = this.executeOnAi;
|
||||
executeOnAi.addEventListener('click', ()=>{
|
||||
this.executeOnAi = executeOnAi.checked;
|
||||
this.updateContext();
|
||||
});
|
||||
/**@type {HTMLInputElement}*/
|
||||
const executeOnChatChange = dom.querySelector('#qr--executeOnChatChange');
|
||||
executeOnChatChange.checked = this.executeOnChatChange;
|
||||
executeOnChatChange.addEventListener('click', ()=>{
|
||||
this.executeOnChatChange = executeOnChatChange.checked;
|
||||
this.updateContext();
|
||||
});
|
||||
|
||||
|
||||
/**@type {HTMLElement}*/
|
||||
const executeErrors = dom.querySelector('#qr--modal-executeErrors');
|
||||
/**@type {HTMLInputElement}*/
|
||||
const executeHide = dom.querySelector('#qr--modal-executeHide');
|
||||
let executePromise;
|
||||
/**@type {HTMLElement}*/
|
||||
const executeBtn = dom.querySelector('#qr--modal-execute');
|
||||
executeBtn.addEventListener('click', async()=>{
|
||||
if (executePromise) return;
|
||||
executeBtn.classList.add('qr--busy');
|
||||
executeErrors.innerHTML = '';
|
||||
if (executeHide.checked) {
|
||||
document.querySelector('#shadow_popup').classList.add('qr--hide');
|
||||
}
|
||||
try {
|
||||
executePromise = this.execute();
|
||||
await executePromise;
|
||||
} catch (ex) {
|
||||
executeErrors.textContent = ex.message;
|
||||
}
|
||||
executePromise = null;
|
||||
executeBtn.classList.remove('qr--busy');
|
||||
document.querySelector('#shadow_popup').classList.remove('qr--hide');
|
||||
});
|
||||
|
||||
await popupResult;
|
||||
} else {
|
||||
warn('failed to fetch qrEditor template');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
delete() {
|
||||
if (this.onDelete) {
|
||||
this.unrender();
|
||||
this.unrenderSettings();
|
||||
this.onDelete(this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} value
|
||||
*/
|
||||
updateMessage(value) {
|
||||
if (this.onUpdate) {
|
||||
if (this.settingsDomMessage && this.settingsDomMessage.value != value) {
|
||||
this.settingsDomMessage.value = value;
|
||||
}
|
||||
this.message = value;
|
||||
this.updateRender();
|
||||
this.onUpdate(this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} value
|
||||
*/
|
||||
updateLabel(value) {
|
||||
if (this.onUpdate) {
|
||||
if (this.settingsDomLabel && this.settingsDomLabel.value != value) {
|
||||
this.settingsDomLabel.value = value;
|
||||
}
|
||||
this.label = value;
|
||||
this.updateRender();
|
||||
this.onUpdate(this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} value
|
||||
*/
|
||||
updateTitle(value) {
|
||||
if (this.onUpdate) {
|
||||
this.title = value;
|
||||
this.updateRender();
|
||||
this.onUpdate(this);
|
||||
}
|
||||
}
|
||||
|
||||
updateContext() {
|
||||
if (this.onUpdate) {
|
||||
this.updateRender();
|
||||
this.onUpdate(this);
|
||||
}
|
||||
}
|
||||
addContextLink(cl) {
|
||||
this.contextList.push(cl);
|
||||
this.updateContext();
|
||||
}
|
||||
removeContextLink(setName) {
|
||||
const idx = this.contextList.findIndex(it=>it.set.name == setName);
|
||||
if (idx > -1) {
|
||||
this.contextList.splice(idx, 1);
|
||||
this.updateContext();
|
||||
}
|
||||
}
|
||||
clearContextLinks() {
|
||||
if (this.contextList.length) {
|
||||
this.contextList.splice(0, this.contextList.length);
|
||||
this.updateContext();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async execute(args = {}) {
|
||||
if (this.message?.length > 0 && this.onExecute) {
|
||||
const message = this.message.replace(/\{\{arg::([^}]+)\}\}/g, (_, key) => {
|
||||
return args[key] ?? '';
|
||||
});
|
||||
return await this.onExecute(this, message, args.isAutoExecute ?? false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
id: this.id,
|
||||
label: this.label,
|
||||
title: this.title,
|
||||
message: this.message,
|
||||
contextList: this.contextList,
|
||||
preventAutoExecute: this.preventAutoExecute,
|
||||
isHidden: this.isHidden,
|
||||
executeOnStartup: this.executeOnStartup,
|
||||
executeOnUser: this.executeOnUser,
|
||||
executeOnAi: this.executeOnAi,
|
||||
executeOnChatChange: this.executeOnChatChange,
|
||||
};
|
||||
}
|
||||
}
|
122
public/scripts/extensions/quick-reply/src/QuickReplyConfig.js
Normal file
122
public/scripts/extensions/quick-reply/src/QuickReplyConfig.js
Normal file
@@ -0,0 +1,122 @@
|
||||
import { getSortableDelay } from '../../../utils.js';
|
||||
import { QuickReplySetLink } from './QuickReplySetLink.js';
|
||||
import { QuickReplySet } from './QuickReplySet.js';
|
||||
|
||||
export class QuickReplyConfig {
|
||||
/**@type {QuickReplySetLink[]}*/ setList = [];
|
||||
/**@type {Boolean}*/ isGlobal;
|
||||
|
||||
/**@type {Function}*/ onUpdate;
|
||||
/**@type {Function}*/ onRequestEditSet;
|
||||
|
||||
/**@type {HTMLElement}*/ dom;
|
||||
/**@type {HTMLElement}*/ setListDom;
|
||||
|
||||
|
||||
|
||||
|
||||
static from(props) {
|
||||
props.setList = props.setList?.map(it=>QuickReplySetLink.from(it))?.filter(it=>it.set) ?? [];
|
||||
const instance = Object.assign(new this(), props);
|
||||
instance.init();
|
||||
return instance;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
init() {
|
||||
this.setList.forEach(it=>this.hookQuickReplyLink(it));
|
||||
}
|
||||
|
||||
|
||||
hasSet(qrs) {
|
||||
return this.setList.find(it=>it.set == qrs) != null;
|
||||
}
|
||||
addSet(qrs, isVisible = true) {
|
||||
if (!this.hasSet(qrs)) {
|
||||
const qrl = new QuickReplySetLink();
|
||||
qrl.set = qrs;
|
||||
qrl.isVisible = isVisible;
|
||||
this.hookQuickReplyLink(qrl);
|
||||
this.setList.push(qrl);
|
||||
this.setListDom.append(qrl.renderSettings(this.setList.length - 1));
|
||||
this.update();
|
||||
}
|
||||
}
|
||||
removeSet(qrs) {
|
||||
const idx = this.setList.findIndex(it=>it.set == qrs);
|
||||
if (idx > -1) {
|
||||
this.setList.splice(idx, 1);
|
||||
this.update();
|
||||
this.updateSetListDom();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
renderSettingsInto(/**@type {HTMLElement}*/root) {
|
||||
/**@type {HTMLElement}*/
|
||||
this.setListDom = root.querySelector('.qr--setList');
|
||||
root.querySelector('.qr--setListAdd').addEventListener('click', ()=>{
|
||||
this.addSet(QuickReplySet.list[0]);
|
||||
});
|
||||
this.updateSetListDom();
|
||||
}
|
||||
updateSetListDom() {
|
||||
this.setListDom.innerHTML = '';
|
||||
// @ts-ignore
|
||||
$(this.setListDom).sortable({
|
||||
delay: getSortableDelay(),
|
||||
stop: ()=>this.onSetListSort(),
|
||||
});
|
||||
this.setList.filter(it=>!it.set.isDeleted).forEach((qrl,idx)=>this.setListDom.append(qrl.renderSettings(idx)));
|
||||
}
|
||||
|
||||
|
||||
onSetListSort() {
|
||||
this.setList = Array.from(this.setListDom.children).map((it,idx)=>{
|
||||
const qrl = this.setList[Number(it.getAttribute('data-order'))];
|
||||
qrl.index = idx;
|
||||
it.setAttribute('data-order', String(idx));
|
||||
return qrl;
|
||||
});
|
||||
this.update();
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* @param {QuickReplySetLink} qrl
|
||||
*/
|
||||
hookQuickReplyLink(qrl) {
|
||||
qrl.onDelete = ()=>this.deleteQuickReplyLink(qrl);
|
||||
qrl.onUpdate = ()=>this.update();
|
||||
qrl.onRequestEditSet = ()=>this.requestEditSet(qrl.set);
|
||||
}
|
||||
|
||||
deleteQuickReplyLink(qrl) {
|
||||
this.setList.splice(this.setList.indexOf(qrl), 1);
|
||||
this.update();
|
||||
}
|
||||
|
||||
update() {
|
||||
if (this.onUpdate) {
|
||||
this.onUpdate(this);
|
||||
}
|
||||
}
|
||||
|
||||
requestEditSet(qrs) {
|
||||
if (this.onRequestEditSet) {
|
||||
this.onRequestEditSet(qrs);
|
||||
}
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
setList: this.setList,
|
||||
};
|
||||
}
|
||||
}
|
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
209
public/scripts/extensions/quick-reply/src/QuickReplySet.js
Normal file
209
public/scripts/extensions/quick-reply/src/QuickReplySet.js
Normal file
@@ -0,0 +1,209 @@
|
||||
import { getRequestHeaders, substituteParams } from '../../../../script.js';
|
||||
import { executeSlashCommands } from '../../../slash-commands.js';
|
||||
import { debounceAsync, warn } from '../index.js';
|
||||
import { QuickReply } from './QuickReply.js';
|
||||
|
||||
export class QuickReplySet {
|
||||
/**@type {QuickReplySet[]}*/ static list = [];
|
||||
|
||||
|
||||
static from(props) {
|
||||
props.qrList = []; //props.qrList?.map(it=>QuickReply.from(it));
|
||||
const instance = Object.assign(new this(), props);
|
||||
// instance.init();
|
||||
return instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {String} name - name of the QuickReplySet
|
||||
*/
|
||||
static get(name) {
|
||||
return this.list.find(it=>it.name == name);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/**@type {String}*/ name;
|
||||
/**@type {Boolean}*/ disableSend = false;
|
||||
/**@type {Boolean}*/ placeBeforeInput = false;
|
||||
/**@type {Boolean}*/ injectInput = false;
|
||||
/**@type {QuickReply[]}*/ qrList = [];
|
||||
|
||||
/**@type {Number}*/ idIndex = 0;
|
||||
|
||||
/**@type {Boolean}*/ isDeleted = false;
|
||||
|
||||
/**@type {Function}*/ save;
|
||||
|
||||
/**@type {HTMLElement}*/ dom;
|
||||
/**@type {HTMLElement}*/ settingsDom;
|
||||
|
||||
|
||||
|
||||
|
||||
constructor() {
|
||||
this.save = debounceAsync(()=>this.performSave(), 200);
|
||||
}
|
||||
|
||||
init() {
|
||||
this.qrList.forEach(qr=>this.hookQuickReply(qr));
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
unrender() {
|
||||
this.dom?.remove();
|
||||
this.dom = null;
|
||||
}
|
||||
render() {
|
||||
this.unrender();
|
||||
if (!this.dom) {
|
||||
const root = document.createElement('div'); {
|
||||
this.dom = root;
|
||||
root.classList.add('qr--buttons');
|
||||
this.qrList.filter(qr=>!qr.isHidden).forEach(qr=>{
|
||||
root.append(qr.render());
|
||||
});
|
||||
}
|
||||
}
|
||||
return this.dom;
|
||||
}
|
||||
rerender() {
|
||||
if (!this.dom) return;
|
||||
this.dom.innerHTML = '';
|
||||
this.qrList.filter(qr=>!qr.isHidden).forEach(qr=>{
|
||||
this.dom.append(qr.render());
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
renderSettings() {
|
||||
if (!this.settingsDom) {
|
||||
this.settingsDom = document.createElement('div'); {
|
||||
this.settingsDom.classList.add('qr--set-qrListContents');
|
||||
this.qrList.forEach((qr,idx)=>{
|
||||
this.renderSettingsItem(qr, idx);
|
||||
});
|
||||
}
|
||||
}
|
||||
return this.settingsDom;
|
||||
}
|
||||
renderSettingsItem(qr, idx) {
|
||||
this.settingsDom.append(qr.renderSettings(idx));
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* @param {QuickReply} qr
|
||||
* @param {String} [message] - optional altered message to be used
|
||||
*/
|
||||
async execute(qr, message = null, isAutoExecute = false) {
|
||||
/**@type {HTMLTextAreaElement}*/
|
||||
const ta = document.querySelector('#send_textarea');
|
||||
const finalMessage = message ?? qr.message;
|
||||
let input = ta.value;
|
||||
if (!isAutoExecute && this.injectInput && input.length > 0) {
|
||||
if (this.placeBeforeInput) {
|
||||
input = `${finalMessage} ${input}`;
|
||||
} else {
|
||||
input = `${input} ${finalMessage}`;
|
||||
}
|
||||
} else {
|
||||
input = `${finalMessage} `;
|
||||
}
|
||||
|
||||
if (input[0] == '/' && !this.disableSend) {
|
||||
const result = await executeSlashCommands(input);
|
||||
return typeof result === 'object' ? result?.pipe : '';
|
||||
}
|
||||
|
||||
ta.value = substituteParams(input);
|
||||
ta.focus();
|
||||
|
||||
if (!this.disableSend) {
|
||||
// @ts-ignore
|
||||
document.querySelector('#send_but').click();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
addQuickReply() {
|
||||
const id = Math.max(this.idIndex, this.qrList.reduce((max,qr)=>Math.max(max,qr.id),0)) + 1;
|
||||
this.idIndex = id + 1;
|
||||
const qr = QuickReply.from({ id });
|
||||
this.qrList.push(qr);
|
||||
this.hookQuickReply(qr);
|
||||
if (this.settingsDom) {
|
||||
this.renderSettingsItem(qr, this.qrList.length - 1);
|
||||
}
|
||||
if (this.dom) {
|
||||
this.dom.append(qr.render());
|
||||
}
|
||||
this.save();
|
||||
return qr;
|
||||
}
|
||||
|
||||
hookQuickReply(qr) {
|
||||
qr.onExecute = (_, message, isAutoExecute)=>this.execute(qr, message, isAutoExecute);
|
||||
qr.onDelete = ()=>this.removeQuickReply(qr);
|
||||
qr.onUpdate = ()=>this.save();
|
||||
}
|
||||
|
||||
removeQuickReply(qr) {
|
||||
this.qrList.splice(this.qrList.indexOf(qr), 1);
|
||||
this.save();
|
||||
}
|
||||
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
version: 2,
|
||||
name: this.name,
|
||||
disableSend: this.disableSend,
|
||||
placeBeforeInput: this.placeBeforeInput,
|
||||
injectInput: this.injectInput,
|
||||
qrList: this.qrList,
|
||||
idIndex: this.idIndex,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
async performSave() {
|
||||
const response = await fetch('/savequickreply', {
|
||||
method: 'POST',
|
||||
headers: getRequestHeaders(),
|
||||
body: JSON.stringify(this),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
this.rerender();
|
||||
} else {
|
||||
warn(`Failed to save Quick Reply Set: ${this.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
async delete() {
|
||||
const response = await fetch('/deletequickreply', {
|
||||
method: 'POST',
|
||||
headers: getRequestHeaders(),
|
||||
body: JSON.stringify(this),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
this.unrender();
|
||||
const idx = QuickReplySet.list.indexOf(this);
|
||||
QuickReplySet.list.splice(idx, 1);
|
||||
this.isDeleted = true;
|
||||
} else {
|
||||
warn(`Failed to delete Quick Reply Set: ${this.name}`);
|
||||
}
|
||||
}
|
||||
}
|
129
public/scripts/extensions/quick-reply/src/QuickReplySetLink.js
Normal file
129
public/scripts/extensions/quick-reply/src/QuickReplySetLink.js
Normal file
@@ -0,0 +1,129 @@
|
||||
import { QuickReplySet } from './QuickReplySet.js';
|
||||
|
||||
export class QuickReplySetLink {
|
||||
static from(props) {
|
||||
props.set = QuickReplySet.get(props.set);
|
||||
/**@type {QuickReplySetLink}*/
|
||||
const instance = Object.assign(new this(), props);
|
||||
return instance;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/**@type {QuickReplySet}*/ set;
|
||||
/**@type {Boolean}*/ isVisible = true;
|
||||
|
||||
/**@type {Number}*/ index;
|
||||
|
||||
/**@type {Function}*/ onUpdate;
|
||||
/**@type {Function}*/ onRequestEditSet;
|
||||
/**@type {Function}*/ onDelete;
|
||||
|
||||
/**@type {HTMLElement}*/ settingsDom;
|
||||
|
||||
|
||||
|
||||
|
||||
renderSettings(idx) {
|
||||
this.index = idx;
|
||||
const item = document.createElement('div'); {
|
||||
this.settingsDom = item;
|
||||
item.classList.add('qr--item');
|
||||
item.setAttribute('data-order', String(this.index));
|
||||
const drag = document.createElement('div'); {
|
||||
drag.classList.add('drag-handle');
|
||||
drag.classList.add('ui-sortable-handle');
|
||||
drag.textContent = '☰';
|
||||
item.append(drag);
|
||||
}
|
||||
const set = document.createElement('select'); {
|
||||
set.classList.add('qr--set');
|
||||
// fix for jQuery sortable breaking childrens' touch events
|
||||
set.addEventListener('touchstart', (evt)=>evt.stopPropagation());
|
||||
set.addEventListener('change', ()=>{
|
||||
this.set = QuickReplySet.get(set.value);
|
||||
this.update();
|
||||
});
|
||||
QuickReplySet.list.forEach(qrs=>{
|
||||
const opt = document.createElement('option'); {
|
||||
opt.value = qrs.name;
|
||||
opt.textContent = qrs.name;
|
||||
opt.selected = qrs == this.set;
|
||||
set.append(opt);
|
||||
}
|
||||
});
|
||||
item.append(set);
|
||||
}
|
||||
const visible = document.createElement('label'); {
|
||||
visible.classList.add('qr--visible');
|
||||
visible.title = 'Show buttons';
|
||||
const cb = document.createElement('input'); {
|
||||
cb.type = 'checkbox';
|
||||
cb.checked = this.isVisible;
|
||||
cb.addEventListener('click', ()=>{
|
||||
this.isVisible = cb.checked;
|
||||
this.update();
|
||||
});
|
||||
visible.append(cb);
|
||||
}
|
||||
visible.append('Buttons');
|
||||
item.append(visible);
|
||||
}
|
||||
const edit = document.createElement('div'); {
|
||||
edit.classList.add('menu_button');
|
||||
edit.classList.add('menu_button_icon');
|
||||
edit.classList.add('fa-solid');
|
||||
edit.classList.add('fa-pencil');
|
||||
edit.title = 'Edit quick reply set';
|
||||
edit.addEventListener('click', ()=>this.requestEditSet());
|
||||
item.append(edit);
|
||||
}
|
||||
const del = document.createElement('div'); {
|
||||
del.classList.add('qr--del');
|
||||
del.classList.add('menu_button');
|
||||
del.classList.add('menu_button_icon');
|
||||
del.classList.add('fa-solid');
|
||||
del.classList.add('fa-trash-can');
|
||||
del.title = 'Remove quick reply set';
|
||||
del.addEventListener('click', ()=>this.delete());
|
||||
item.append(del);
|
||||
}
|
||||
}
|
||||
return this.settingsDom;
|
||||
}
|
||||
unrenderSettings() {
|
||||
this.settingsDom?.remove();
|
||||
this.settingsDom = null;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
update() {
|
||||
if (this.onUpdate) {
|
||||
this.onUpdate(this);
|
||||
}
|
||||
}
|
||||
requestEditSet() {
|
||||
if (this.onRequestEditSet) {
|
||||
this.onRequestEditSet(this.set);
|
||||
}
|
||||
}
|
||||
delete() {
|
||||
this.unrenderSettings();
|
||||
if (this.onDelete) {
|
||||
this.onDelete();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
set: this.set.name,
|
||||
isVisible: this.isVisible,
|
||||
};
|
||||
}
|
||||
}
|
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
270
public/scripts/extensions/quick-reply/src/SlashCommandHandler.js
Normal file
270
public/scripts/extensions/quick-reply/src/SlashCommandHandler.js
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
161
public/scripts/extensions/quick-reply/src/ui/ButtonUi.js
Normal file
161
public/scripts/extensions/quick-reply/src/ui/ButtonUi.js
Normal 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;
|
||||
}
|
||||
}
|
366
public/scripts/extensions/quick-reply/src/ui/SettingsUi.js
Normal file
366
public/scripts/extensions/quick-reply/src/ui/SettingsUi.js
Normal file
@@ -0,0 +1,366 @@
|
||||
import { callPopup } from '../../../../../script.js';
|
||||
import { getSortableDelay } from '../../../../utils.js';
|
||||
import { log, warn } from '../../index.js';
|
||||
import { QuickReply } from '../QuickReply.js';
|
||||
import { QuickReplySet } from '../QuickReplySet.js';
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
import { QuickReplySettings } from '../QuickReplySettings.js';
|
||||
|
||||
export class SettingsUi {
|
||||
/**@type {QuickReplySettings}*/ settings;
|
||||
|
||||
/**@type {HTMLElement}*/ template;
|
||||
/**@type {HTMLElement}*/ dom;
|
||||
|
||||
/**@type {HTMLInputElement}*/ isEnabled;
|
||||
/**@type {HTMLInputElement}*/ isCombined;
|
||||
|
||||
/**@type {HTMLElement}*/ globalSetList;
|
||||
|
||||
/**@type {HTMLElement}*/ chatSetList;
|
||||
|
||||
/**@type {QuickReplySet}*/ currentQrSet;
|
||||
/**@type {HTMLInputElement}*/ disableSend;
|
||||
/**@type {HTMLInputElement}*/ placeBeforeInput;
|
||||
/**@type {HTMLInputElement}*/ injectInput;
|
||||
/**@type {HTMLSelectElement}*/ currentSet;
|
||||
|
||||
|
||||
|
||||
|
||||
constructor(/**@type {QuickReplySettings}*/settings) {
|
||||
this.settings = settings;
|
||||
settings.onRequestEditSet = (qrs) => this.selectQrSet(qrs);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
rerender() {
|
||||
if (!this.dom) return;
|
||||
const content = this.dom.querySelector('.inline-drawer-content');
|
||||
content.innerHTML = '';
|
||||
// @ts-ignore
|
||||
Array.from(this.template.querySelector('.inline-drawer-content').cloneNode(true).children).forEach(el=>{
|
||||
content.append(el);
|
||||
});
|
||||
this.prepareDom();
|
||||
}
|
||||
unrender() {
|
||||
this.dom?.remove();
|
||||
this.dom = null;
|
||||
}
|
||||
async render() {
|
||||
if (!this.dom) {
|
||||
const response = await fetch('/scripts/extensions/quick-reply/html/settings.html', { cache: 'no-store' });
|
||||
if (response.ok) {
|
||||
this.template = document.createRange().createContextualFragment(await response.text()).querySelector('#qr--settings');
|
||||
// @ts-ignore
|
||||
this.dom = this.template.cloneNode(true);
|
||||
this.prepareDom();
|
||||
} else {
|
||||
warn('failed to fetch settings template');
|
||||
}
|
||||
}
|
||||
return this.dom;
|
||||
}
|
||||
|
||||
|
||||
prepareGeneralSettings() {
|
||||
// general settings
|
||||
this.isEnabled = this.dom.querySelector('#qr--isEnabled');
|
||||
this.isEnabled.checked = this.settings.isEnabled;
|
||||
this.isEnabled.addEventListener('click', ()=>this.onIsEnabled());
|
||||
|
||||
this.isCombined = this.dom.querySelector('#qr--isCombined');
|
||||
this.isCombined.checked = this.settings.isCombined;
|
||||
this.isCombined.addEventListener('click', ()=>this.onIsCombined());
|
||||
}
|
||||
|
||||
prepareGlobalSetList() {
|
||||
const dom = this.template.querySelector('#qr--global');
|
||||
const clone = dom.cloneNode(true);
|
||||
// @ts-ignore
|
||||
this.settings.config.renderSettingsInto(clone);
|
||||
this.dom.querySelector('#qr--global').replaceWith(clone);
|
||||
}
|
||||
prepareChatSetList() {
|
||||
const dom = this.template.querySelector('#qr--chat');
|
||||
const clone = dom.cloneNode(true);
|
||||
if (this.settings.chatConfig) {
|
||||
// @ts-ignore
|
||||
this.settings.chatConfig.renderSettingsInto(clone);
|
||||
} else {
|
||||
const info = document.createElement('div'); {
|
||||
info.textContent = 'No active chat.';
|
||||
// @ts-ignore
|
||||
clone.append(info);
|
||||
}
|
||||
}
|
||||
this.dom.querySelector('#qr--chat').replaceWith(clone);
|
||||
}
|
||||
|
||||
prepareQrEditor() {
|
||||
// qr editor
|
||||
this.dom.querySelector('#qr--set-new').addEventListener('click', async()=>this.addQrSet());
|
||||
/**@type {HTMLInputElement}*/
|
||||
const importFile = this.dom.querySelector('#qr--set-importFile');
|
||||
importFile.addEventListener('change', async()=>{
|
||||
await this.importQrSet(importFile.files);
|
||||
importFile.value = null;
|
||||
});
|
||||
this.dom.querySelector('#qr--set-import').addEventListener('click', ()=>importFile.click());
|
||||
this.dom.querySelector('#qr--set-export').addEventListener('click', async()=>this.exportQrSet());
|
||||
this.dom.querySelector('#qr--set-delete').addEventListener('click', async()=>this.deleteQrSet());
|
||||
this.dom.querySelector('#qr--set-add').addEventListener('click', async()=>{
|
||||
this.currentQrSet.addQuickReply();
|
||||
});
|
||||
this.qrList = this.dom.querySelector('#qr--set-qrList');
|
||||
this.currentSet = this.dom.querySelector('#qr--set');
|
||||
this.currentSet.addEventListener('change', ()=>this.onQrSetChange());
|
||||
QuickReplySet.list.forEach(qrs=>{
|
||||
const opt = document.createElement('option'); {
|
||||
opt.value = qrs.name;
|
||||
opt.textContent = qrs.name;
|
||||
this.currentSet.append(opt);
|
||||
}
|
||||
});
|
||||
this.disableSend = this.dom.querySelector('#qr--disableSend');
|
||||
this.disableSend.addEventListener('click', ()=>{
|
||||
const qrs = this.currentQrSet;
|
||||
qrs.disableSend = this.disableSend.checked;
|
||||
qrs.save();
|
||||
});
|
||||
this.placeBeforeInput = this.dom.querySelector('#qr--placeBeforeInput');
|
||||
this.placeBeforeInput.addEventListener('click', ()=>{
|
||||
const qrs = this.currentQrSet;
|
||||
qrs.placeBeforeInput = this.placeBeforeInput.checked;
|
||||
qrs.save();
|
||||
});
|
||||
this.injectInput = this.dom.querySelector('#qr--injectInput');
|
||||
this.injectInput.addEventListener('click', ()=>{
|
||||
const qrs = this.currentQrSet;
|
||||
qrs.injectInput = this.injectInput.checked;
|
||||
qrs.save();
|
||||
});
|
||||
this.onQrSetChange();
|
||||
}
|
||||
onQrSetChange() {
|
||||
this.currentQrSet = QuickReplySet.get(this.currentSet.value);
|
||||
this.disableSend.checked = this.currentQrSet.disableSend;
|
||||
this.placeBeforeInput.checked = this.currentQrSet.placeBeforeInput;
|
||||
this.injectInput.checked = this.currentQrSet.injectInput;
|
||||
this.qrList.innerHTML = '';
|
||||
const qrsDom = this.currentQrSet.renderSettings();
|
||||
this.qrList.append(qrsDom);
|
||||
// @ts-ignore
|
||||
$(qrsDom).sortable({
|
||||
delay: getSortableDelay(),
|
||||
handle: '.drag-handle',
|
||||
stop: ()=>this.onQrListSort(),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
prepareDom() {
|
||||
this.prepareGeneralSettings();
|
||||
this.prepareGlobalSetList();
|
||||
this.prepareChatSetList();
|
||||
this.prepareQrEditor();
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
async onIsEnabled() {
|
||||
this.settings.isEnabled = this.isEnabled.checked;
|
||||
this.settings.save();
|
||||
}
|
||||
|
||||
async onIsCombined() {
|
||||
this.settings.isCombined = this.isCombined.checked;
|
||||
this.settings.save();
|
||||
}
|
||||
|
||||
async onGlobalSetListSort() {
|
||||
this.settings.config.setList = Array.from(this.globalSetList.children).map((it,idx)=>{
|
||||
const set = this.settings.config.setList[Number(it.getAttribute('data-order'))];
|
||||
it.setAttribute('data-order', String(idx));
|
||||
return set;
|
||||
});
|
||||
this.settings.save();
|
||||
}
|
||||
|
||||
async onChatSetListSort() {
|
||||
this.settings.chatConfig.setList = Array.from(this.chatSetList.children).map((it,idx)=>{
|
||||
const set = this.settings.chatConfig.setList[Number(it.getAttribute('data-order'))];
|
||||
it.setAttribute('data-order', String(idx));
|
||||
return set;
|
||||
});
|
||||
this.settings.save();
|
||||
}
|
||||
|
||||
updateOrder(list) {
|
||||
Array.from(list.children).forEach((it,idx)=>{
|
||||
it.setAttribute('data-order', idx);
|
||||
});
|
||||
}
|
||||
|
||||
async onQrListSort() {
|
||||
this.currentQrSet.qrList = Array.from(this.qrList.querySelectorAll('.qr--set-item')).map((it,idx)=>{
|
||||
const qr = this.currentQrSet.qrList.find(qr=>qr.id == Number(it.getAttribute('data-id')));
|
||||
it.setAttribute('data-order', String(idx));
|
||||
return qr;
|
||||
});
|
||||
this.currentQrSet.save();
|
||||
}
|
||||
|
||||
async deleteQrSet() {
|
||||
const confirmed = await callPopup(`Are you sure you want to delete the Quick Reply Set "${this.currentQrSet.name}"?<br>This cannot be undone.`, 'confirm');
|
||||
if (confirmed) {
|
||||
await this.doDeleteQrSet(this.currentQrSet);
|
||||
this.rerender();
|
||||
}
|
||||
}
|
||||
async doDeleteQrSet(qrs) {
|
||||
await qrs.delete();
|
||||
//TODO (HACK) should just bubble up from QuickReplySet.delete() but that would require proper or at least more comples onDelete listeners
|
||||
for (let i = this.settings.config.setList.length - 1; i >= 0; i--) {
|
||||
if (this.settings.config.setList[i].set == qrs) {
|
||||
this.settings.config.setList.splice(i, 1);
|
||||
}
|
||||
}
|
||||
if (this.settings.chatConfig) {
|
||||
for (let i = this.settings.chatConfig.setList.length - 1; i >= 0; i--) {
|
||||
if (this.settings.chatConfig.setList[i].set == qrs) {
|
||||
this.settings.chatConfig.setList.splice(i, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
this.settings.save();
|
||||
}
|
||||
|
||||
async addQrSet() {
|
||||
const name = await callPopup('Quick Reply Set Name:', 'input');
|
||||
if (name && name.length > 0) {
|
||||
const oldQrs = QuickReplySet.get(name);
|
||||
if (oldQrs) {
|
||||
const replace = await callPopup(`A Quick Reply Set named "${name}" already exists.<br>Do you want to overwrite the existing Quick Reply Set?<br>The existing set will be deleted. This cannot be undone.`, 'confirm');
|
||||
if (replace) {
|
||||
const idx = QuickReplySet.list.indexOf(oldQrs);
|
||||
await this.doDeleteQrSet(oldQrs);
|
||||
const qrs = new QuickReplySet();
|
||||
qrs.name = name;
|
||||
qrs.addQuickReply();
|
||||
QuickReplySet.list.splice(idx, 0, qrs);
|
||||
this.rerender();
|
||||
this.currentSet.value = name;
|
||||
this.onQrSetChange();
|
||||
this.prepareGlobalSetList();
|
||||
this.prepareChatSetList();
|
||||
}
|
||||
} else {
|
||||
const qrs = new QuickReplySet();
|
||||
qrs.name = name;
|
||||
qrs.addQuickReply();
|
||||
const idx = QuickReplySet.list.findIndex(it=>it.name.localeCompare(name) == 1);
|
||||
if (idx > -1) {
|
||||
QuickReplySet.list.splice(idx, 0, qrs);
|
||||
} else {
|
||||
QuickReplySet.list.push(qrs);
|
||||
}
|
||||
const opt = document.createElement('option'); {
|
||||
opt.value = qrs.name;
|
||||
opt.textContent = qrs.name;
|
||||
if (idx > -1) {
|
||||
this.currentSet.children[idx].insertAdjacentElement('beforebegin', opt);
|
||||
} else {
|
||||
this.currentSet.append(opt);
|
||||
}
|
||||
}
|
||||
this.currentSet.value = name;
|
||||
this.onQrSetChange();
|
||||
this.prepareGlobalSetList();
|
||||
this.prepareChatSetList();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async importQrSet(/**@type {FileList}*/files) {
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
await this.importSingleQrSet(files.item(i));
|
||||
}
|
||||
}
|
||||
async importSingleQrSet(/**@type {File}*/file) {
|
||||
log('FILE', file);
|
||||
try {
|
||||
const text = await file.text();
|
||||
const props = JSON.parse(text);
|
||||
if (!Number.isInteger(props.version) || typeof props.name != 'string') {
|
||||
toastr.error(`The file "${file.name}" does not appear to be a valid quick reply set.`);
|
||||
warn(`The file "${file.name}" does not appear to be a valid quick reply set.`);
|
||||
} else {
|
||||
/**@type {QuickReplySet}*/
|
||||
const qrs = QuickReplySet.from(JSON.parse(JSON.stringify(props)));
|
||||
qrs.qrList = props.qrList.map(it=>QuickReply.from(it));
|
||||
qrs.init();
|
||||
const oldQrs = QuickReplySet.get(props.name);
|
||||
if (oldQrs) {
|
||||
const replace = await callPopup(`A Quick Reply Set named "${qrs.name}" already exists.<br>Do you want to overwrite the existing Quick Reply Set?<br>The existing set will be deleted. This cannot be undone.`, 'confirm');
|
||||
if (replace) {
|
||||
const idx = QuickReplySet.list.indexOf(oldQrs);
|
||||
await this.doDeleteQrSet(oldQrs);
|
||||
QuickReplySet.list.splice(idx, 0, qrs);
|
||||
await qrs.save();
|
||||
this.rerender();
|
||||
this.currentSet.value = qrs.name;
|
||||
this.onQrSetChange();
|
||||
this.prepareGlobalSetList();
|
||||
this.prepareChatSetList();
|
||||
}
|
||||
} else {
|
||||
const idx = QuickReplySet.list.findIndex(it=>it.name.localeCompare(qrs.name) == 1);
|
||||
if (idx > -1) {
|
||||
QuickReplySet.list.splice(idx, 0, qrs);
|
||||
} else {
|
||||
QuickReplySet.list.push(qrs);
|
||||
}
|
||||
await qrs.save();
|
||||
const opt = document.createElement('option'); {
|
||||
opt.value = qrs.name;
|
||||
opt.textContent = qrs.name;
|
||||
if (idx > -1) {
|
||||
this.currentSet.children[idx].insertAdjacentElement('beforebegin', opt);
|
||||
} else {
|
||||
this.currentSet.append(opt);
|
||||
}
|
||||
}
|
||||
this.currentSet.value = qrs.name;
|
||||
this.onQrSetChange();
|
||||
this.prepareGlobalSetList();
|
||||
this.prepareChatSetList();
|
||||
}
|
||||
}
|
||||
} catch (ex) {
|
||||
warn(ex);
|
||||
toastr.error(`Failed to import "${file.name}":\n\n${ex.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
exportQrSet() {
|
||||
const blob = new Blob([JSON.stringify(this.currentQrSet)], { type:'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a'); {
|
||||
a.href = url;
|
||||
a.download = `${this.currentQrSet.name}.json`;
|
||||
a.click();
|
||||
}
|
||||
}
|
||||
|
||||
selectQrSet(qrs) {
|
||||
this.currentSet.value = qrs.name;
|
||||
this.onQrSetChange();
|
||||
}
|
||||
}
|
108
public/scripts/extensions/quick-reply/src/ui/ctx/ContextMenu.js
Normal file
108
public/scripts/extensions/quick-reply/src/ui/ctx/ContextMenu.js
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,114 +1,286 @@
|
||||
#quickReplyBar {
|
||||
outline: none;
|
||||
/*
|
||||
padding: 5px 0;
|
||||
border-bottom: 1px solid var(--SmartThemeBorderColor);
|
||||
*/
|
||||
margin: 0;
|
||||
transition: 0.3s;
|
||||
opacity: 0.7;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
display: none;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
order: 1;
|
||||
position: relative;
|
||||
#qr--bar {
|
||||
outline: none;
|
||||
margin: 0;
|
||||
transition: 0.3s;
|
||||
opacity: 0.7;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
order: 1;
|
||||
padding-right: 2.5em;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#quickReplies {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
width: 100%;
|
||||
#qr--bar > #qr--popoutTrigger {
|
||||
position: absolute;
|
||||
right: 0.25em;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
#quickReplyPopoutButton {
|
||||
position: absolute;
|
||||
right: 5px;
|
||||
top: 0px;
|
||||
#qr--popout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0;
|
||||
z-index: 31;
|
||||
}
|
||||
|
||||
#quickReplies div {
|
||||
color: var(--SmartThemeBodyColor);
|
||||
background-color: var(--black50a);
|
||||
border: 1px solid var(--SmartThemeBorderColor);
|
||||
border-radius: 10px;
|
||||
padding: 3px 5px;
|
||||
margin: 3px 0;
|
||||
/* width: min-content; */
|
||||
cursor: pointer;
|
||||
transition: 0.3s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
#qr--popout > .qr--header {
|
||||
flex: 0 0 auto;
|
||||
height: 2em;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#quickReplies div:hover {
|
||||
opacity: 1;
|
||||
filter: brightness(1.2);
|
||||
cursor: pointer;
|
||||
#qr--popout > .qr--header > .qr--controls > .qr--close {
|
||||
height: 15px;
|
||||
aspect-ratio: 1 / 1;
|
||||
font-size: 20px;
|
||||
opacity: 0.5;
|
||||
transition: all 250ms;
|
||||
}
|
||||
#qr--popout > .qr--body {
|
||||
overflow-y: auto;
|
||||
}
|
||||
#qr--bar > .qr--buttons,
|
||||
#qr--popout > .qr--body > .qr--buttons {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
width: 100%;
|
||||
}
|
||||
#qr--bar > .qr--buttons > .qr--buttons,
|
||||
#qr--popout > .qr--body > .qr--buttons > .qr--buttons {
|
||||
display: contents;
|
||||
}
|
||||
#qr--bar > .qr--buttons .qr--button,
|
||||
#qr--popout > .qr--body > .qr--buttons .qr--button {
|
||||
color: var(--SmartThemeBodyColor);
|
||||
border: 1px solid var(--SmartThemeBorderColor);
|
||||
border-radius: 10px;
|
||||
padding: 3px 5px;
|
||||
margin: 3px 0;
|
||||
cursor: pointer;
|
||||
transition: 0.3s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
#qr--bar > .qr--buttons .qr--button:hover,
|
||||
#qr--popout > .qr--body > .qr--buttons .qr--button:hover {
|
||||
opacity: 1;
|
||||
filter: brightness(1.2);
|
||||
}
|
||||
#qr--bar > .qr--buttons .qr--button > .qr--button-expander,
|
||||
#qr--popout > .qr--body > .qr--buttons .qr--button > .qr--button-expander {
|
||||
display: none;
|
||||
}
|
||||
#qr--bar > .qr--buttons .qr--button.qr--hasCtx > .qr--button-expander,
|
||||
#qr--popout > .qr--body > .qr--buttons .qr--button.qr--hasCtx > .qr--button-expander {
|
||||
display: block;
|
||||
}
|
||||
.qr--button-expander {
|
||||
border-left: 1px solid;
|
||||
margin-left: 1em;
|
||||
text-align: center;
|
||||
width: 2em;
|
||||
}
|
||||
.qr--button-expander:hover {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
|
||||
.ctx-blocker {
|
||||
/* backdrop-filter: blur(1px); */
|
||||
/* background-color: rgba(0 0 0 / 10%); */
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
position: fixed;
|
||||
right: 0;
|
||||
top: 0;
|
||||
z-index: 999;
|
||||
/* backdrop-filter: blur(1px); */
|
||||
/* background-color: rgba(0 0 0 / 10%); */
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
position: fixed;
|
||||
right: 0;
|
||||
top: 0;
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
.ctx-menu {
|
||||
position: absolute;
|
||||
overflow: visible;
|
||||
position: absolute;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.list-group .list-group-item.ctx-header {
|
||||
font-weight: bold;
|
||||
cursor: default;
|
||||
font-weight: bold;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.ctx-item+.ctx-header {
|
||||
border-top: 1px solid;
|
||||
.ctx-item + .ctx-header {
|
||||
border-top: 1px solid;
|
||||
}
|
||||
|
||||
.ctx-item {
|
||||
position: relative;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.ctx-expander {
|
||||
border-left: 1px solid;
|
||||
margin-left: 1em;
|
||||
text-align: center;
|
||||
width: 2em;
|
||||
border-left: 1px solid;
|
||||
margin-left: 1em;
|
||||
text-align: center;
|
||||
width: 2em;
|
||||
}
|
||||
|
||||
.ctx-expander:hover {
|
||||
font-weight: bold;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.ctx-sub-menu {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 100%;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 100%;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1000px) {
|
||||
.ctx-blocker {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.list-group .list-group-item.ctx-item {
|
||||
padding: 1em;
|
||||
}
|
||||
.ctx-blocker {
|
||||
position: absolute;
|
||||
}
|
||||
.list-group .list-group-item.ctx-item {
|
||||
padding: 1em;
|
||||
}
|
||||
}
|
||||
#qr--settings .qr--head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 1em;
|
||||
}
|
||||
#qr--settings .qr--head > .qr--title {
|
||||
font-weight: bold;
|
||||
}
|
||||
#qr--settings .qr--head > .qr--actions {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: baseline;
|
||||
gap: 0.5em;
|
||||
}
|
||||
#qr--settings .qr--setList > .qr--item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 0.5em;
|
||||
align-items: baseline;
|
||||
padding: 0 0.5em;
|
||||
}
|
||||
#qr--settings .qr--setList > .qr--item > .drag-handle {
|
||||
padding: 0.75em;
|
||||
}
|
||||
#qr--settings .qr--setList > .qr--item > .qr--visible {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
#qr--settings #qr--set-settings #qr--injectInputContainer {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
#qr--settings #qr--set-qrList .qr--set-qrListContents {
|
||||
padding: 0 0.5em;
|
||||
}
|
||||
#qr--settings #qr--set-qrList .qr--set-qrListContents > .qr--set-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 0.5em;
|
||||
align-items: baseline;
|
||||
padding: 0.25em 0;
|
||||
}
|
||||
#qr--settings #qr--set-qrList .qr--set-qrListContents > .qr--set-item > :nth-child(1) {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
#qr--settings #qr--set-qrList .qr--set-qrListContents > .qr--set-item > :nth-child(2) {
|
||||
flex: 1 1 25%;
|
||||
}
|
||||
#qr--settings #qr--set-qrList .qr--set-qrListContents > .qr--set-item > :nth-child(3) {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
#qr--settings #qr--set-qrList .qr--set-qrListContents > .qr--set-item > :nth-child(4) {
|
||||
flex: 1 1 75%;
|
||||
}
|
||||
#qr--settings #qr--set-qrList .qr--set-qrListContents > .qr--set-item > :nth-child(5) {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
#qr--settings #qr--set-qrList .qr--set-qrListContents > .qr--set-item > .drag-handle {
|
||||
padding: 0.75em;
|
||||
}
|
||||
#qr--settings #qr--set-qrList .qr--set-qrListContents > .qr--set-item .qr--set-itemLabel,
|
||||
#qr--settings #qr--set-qrList .qr--set-qrListContents > .qr--set-item .qr--action {
|
||||
margin: 0;
|
||||
}
|
||||
#qr--settings #qr--set-qrList .qr--set-qrListContents > .qr--set-item .qr--set-itemMessage {
|
||||
font-size: smaller;
|
||||
}
|
||||
#qr--settings .qr--set-qrListActions {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 0.5em;
|
||||
justify-content: center;
|
||||
padding-bottom: 0.5em;
|
||||
}
|
||||
#qr--qrOptions > #qr--ctxEditor .qr--ctxItem {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 0.5em;
|
||||
align-items: baseline;
|
||||
}
|
||||
@media screen and (max-width: 750px) {
|
||||
body #dialogue_popup:has(#qr--modalEditor) #dialogue_popup_text > #qr--modalEditor {
|
||||
flex-direction: column;
|
||||
}
|
||||
body #dialogue_popup:has(#qr--modalEditor) #dialogue_popup_text > #qr--modalEditor > #qr--main > .qr--labels {
|
||||
flex-direction: column;
|
||||
}
|
||||
body #dialogue_popup:has(#qr--modalEditor) #dialogue_popup_text > #qr--modalEditor > #qr--main > .qr--modal-messageContainer > #qr--modal-message {
|
||||
min-height: 90svh;
|
||||
}
|
||||
}
|
||||
#dialogue_popup:has(#qr--modalEditor) {
|
||||
aspect-ratio: unset;
|
||||
}
|
||||
#dialogue_popup:has(#qr--modalEditor) #dialogue_popup_text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
#dialogue_popup:has(#qr--modalEditor) #dialogue_popup_text > #qr--modalEditor {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 1em;
|
||||
}
|
||||
#dialogue_popup:has(#qr--modalEditor) #dialogue_popup_text > #qr--modalEditor > #qr--main {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
#dialogue_popup:has(#qr--modalEditor) #dialogue_popup_text > #qr--modalEditor > #qr--main > .qr--labels {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 0.5em;
|
||||
}
|
||||
#dialogue_popup:has(#qr--modalEditor) #dialogue_popup_text > #qr--modalEditor > #qr--main > .qr--labels > label {
|
||||
flex: 1 1 1px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
#dialogue_popup:has(#qr--modalEditor) #dialogue_popup_text > #qr--modalEditor > #qr--main > .qr--labels > label > .qr--labelText {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
#dialogue_popup:has(#qr--modalEditor) #dialogue_popup_text > #qr--modalEditor > #qr--main > .qr--labels > label > .qr--labelHint {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
#dialogue_popup:has(#qr--modalEditor) #dialogue_popup_text > #qr--modalEditor > #qr--main > .qr--labels > label > input {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
#dialogue_popup:has(#qr--modalEditor) #dialogue_popup_text > #qr--modalEditor > #qr--main > .qr--modal-messageContainer {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
#dialogue_popup:has(#qr--modalEditor) #dialogue_popup_text > #qr--modalEditor > #qr--main > .qr--modal-messageContainer > #qr--modal-message {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
#dialogue_popup:has(#qr--modalEditor) #dialogue_popup_text > #qr--modalEditor #qr--modal-execute {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 0.5em;
|
||||
}
|
||||
#dialogue_popup:has(#qr--modalEditor) #dialogue_popup_text > #qr--modalEditor #qr--modal-execute.qr--busy {
|
||||
opacity: 0.5;
|
||||
cursor: wait;
|
||||
}
|
||||
#shadow_popup.qr--hide {
|
||||
opacity: 0 !important;
|
||||
}
|
||||
|
317
public/scripts/extensions/quick-reply/style.less
Normal file
317
public/scripts/extensions/quick-reply/style.less
Normal file
@@ -0,0 +1,317 @@
|
||||
#qr--bar {
|
||||
outline: none;
|
||||
margin: 0;
|
||||
transition: 0.3s;
|
||||
opacity: 0.7;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
order: 1;
|
||||
padding-right: 2.5em;
|
||||
position: relative;
|
||||
> #qr--popoutTrigger {
|
||||
position: absolute;
|
||||
right: 0.25em;
|
||||
top: 0;
|
||||
}
|
||||
}
|
||||
#qr--popout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0;
|
||||
z-index: 31;
|
||||
> .qr--header {
|
||||
flex: 0 0 auto;
|
||||
height: 2em;
|
||||
position: relative;
|
||||
> .qr--controls {
|
||||
> .qr--close {
|
||||
height: 15px;
|
||||
aspect-ratio: 1 / 1;
|
||||
font-size: 20px;
|
||||
opacity: 0.5;
|
||||
transition: all 250ms;
|
||||
}
|
||||
}
|
||||
}
|
||||
> .qr--body {
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
#qr--bar, #qr--popout > .qr--body {
|
||||
> .qr--buttons {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
width: 100%;
|
||||
|
||||
> .qr--buttons {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.qr--button {
|
||||
color: var(--SmartThemeBodyColor);
|
||||
// background-color: var(--black50a);
|
||||
border: 1px solid var(--SmartThemeBorderColor);
|
||||
border-radius: 10px;
|
||||
padding: 3px 5px;
|
||||
margin: 3px 0;
|
||||
cursor: pointer;
|
||||
transition: 0.3s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
filter: brightness(1.2);
|
||||
}
|
||||
> .qr--button-expander {
|
||||
display: none;
|
||||
}
|
||||
&.qr--hasCtx {
|
||||
> .qr--button-expander {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.qr--button-expander {
|
||||
border-left: 1px solid;
|
||||
margin-left: 1em;
|
||||
text-align: center;
|
||||
width: 2em;
|
||||
&:hover {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.ctx-blocker {
|
||||
/* backdrop-filter: blur(1px); */
|
||||
/* background-color: rgba(0 0 0 / 10%); */
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
position: fixed;
|
||||
right: 0;
|
||||
top: 0;
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
.ctx-menu {
|
||||
position: absolute;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.list-group .list-group-item.ctx-header {
|
||||
font-weight: bold;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.ctx-item+.ctx-header {
|
||||
border-top: 1px solid;
|
||||
}
|
||||
|
||||
.ctx-item {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.ctx-expander {
|
||||
border-left: 1px solid;
|
||||
margin-left: 1em;
|
||||
text-align: center;
|
||||
width: 2em;
|
||||
}
|
||||
|
||||
.ctx-expander:hover {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.ctx-sub-menu {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 100%;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1000px) {
|
||||
.ctx-blocker {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.list-group .list-group-item.ctx-item {
|
||||
padding: 1em;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
#qr--settings {
|
||||
.qr--head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 1em;
|
||||
> .qr--title {
|
||||
font-weight: bold;
|
||||
}
|
||||
> .qr--actions {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: baseline;
|
||||
gap: 0.5em;
|
||||
}
|
||||
}
|
||||
.qr--setList {
|
||||
> .qr--item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 0.5em;
|
||||
align-items: baseline;
|
||||
padding: 0 0.5em;
|
||||
> .drag-handle {
|
||||
padding: 0.75em;
|
||||
}
|
||||
> .qr--visible {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
}
|
||||
#qr--set-settings {
|
||||
#qr--injectInputContainer {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
}
|
||||
#qr--set-qrList {
|
||||
.qr--set-qrListContents > {
|
||||
padding: 0 0.5em;
|
||||
> .qr--set-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 0.5em;
|
||||
align-items: baseline;
|
||||
padding: 0.25em 0;
|
||||
> :nth-child(1) { flex: 0 0 auto; }
|
||||
> :nth-child(2) { flex: 1 1 25%; }
|
||||
> :nth-child(3) { flex: 0 0 auto; }
|
||||
> :nth-child(4) { flex: 1 1 75%; }
|
||||
> :nth-child(5) { flex: 0 0 auto; }
|
||||
> .drag-handle {
|
||||
padding: 0.75em;
|
||||
}
|
||||
.qr--set-itemLabel, .qr--action {
|
||||
margin: 0;
|
||||
}
|
||||
.qr--set-itemMessage {
|
||||
font-size: smaller;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.qr--set-qrListActions {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 0.5em;
|
||||
justify-content: center;
|
||||
padding-bottom: 0.5em;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
#qr--qrOptions {
|
||||
> #qr--ctxEditor {
|
||||
.qr--ctxItem {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 0.5em;
|
||||
align-items: baseline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@media screen and (max-width: 750px) {
|
||||
body #dialogue_popup:has(#qr--modalEditor) #dialogue_popup_text > #qr--modalEditor {
|
||||
flex-direction: column;
|
||||
> #qr--main > .qr--labels {
|
||||
flex-direction: column;
|
||||
}
|
||||
> #qr--main > .qr--modal-messageContainer > #qr--modal-message {
|
||||
min-height: 90svh;
|
||||
}
|
||||
}
|
||||
}
|
||||
#dialogue_popup:has(#qr--modalEditor) {
|
||||
aspect-ratio: unset;
|
||||
|
||||
#dialogue_popup_text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
> #qr--modalEditor {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 1em;
|
||||
|
||||
> #qr--main {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
> .qr--labels {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 0.5em;
|
||||
> label {
|
||||
flex: 1 1 1px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
> .qr--labelText {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
> .qr--labelHint {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
> input {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
> .qr--modal-messageContainer {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
> #qr--modal-message {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#qr--modal-execute {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 0.5em;
|
||||
&.qr--busy {
|
||||
opacity: 0.5;
|
||||
cursor: wait;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#shadow_popup.qr--hide {
|
||||
opacity: 0 !important;
|
||||
}
|
@@ -56,7 +56,7 @@
|
||||
<div>
|
||||
<textarea
|
||||
class="regex_replace_string text_pole wide100p textarea_compact"
|
||||
placeholder="Use {{match}} to include the matched text from the Find Regex"
|
||||
placeholder="Use {{match}} to include the matched text from the Find Regex or $1, $2, etc. for capture groups."
|
||||
rows="2"
|
||||
></textarea>
|
||||
</div>
|
||||
@@ -115,16 +115,16 @@
|
||||
<input type="checkbox" name="run_on_edit" />
|
||||
<span data-i18n="Run On Edit">Run On Edit</span>
|
||||
</label>
|
||||
<label class="checkbox flex-container">
|
||||
<label class="checkbox flex-container" title="Substitute {{macros}} in Find Regex before running it">
|
||||
<input type="checkbox" name="substitute_regex" />
|
||||
<span data-i18n="Substitute Regex">Substitute Regex</span>
|
||||
<span data-i18n="Substitute Regex">Substitute Regex (?)</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex-container flexFlowColumn alignitemsstart">
|
||||
<small>Replacement Strategy</small>
|
||||
<select name="replace_strategy_select" class="margin0">
|
||||
<option value="0">Replace</option>
|
||||
<option value="1">Overlay</option>
|
||||
<option value="1">Overlay (currently broken)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -109,19 +109,29 @@ function runRegexScript(regexScript, rawString, { characterOverride } = {}) {
|
||||
return newString;
|
||||
}
|
||||
|
||||
newString = rawString.replace(findRegex, (fencedMatch) => {
|
||||
let trimFencedMatch = filterString(fencedMatch, regexScript.trimStrings, { characterOverride });
|
||||
// Run replacement. Currently does not support the Overlay strategy
|
||||
newString = rawString.replace(findRegex, function(match) {
|
||||
const args = [...arguments];
|
||||
const replaceString = regexScript.replaceString.replace(/{{match}}/gi, '$0');
|
||||
const replaceWithGroups = replaceString.replaceAll(/\$(\d)+/g, (_, num) => {
|
||||
// Get a full match or a capture group
|
||||
const match = args[Number(num)];
|
||||
|
||||
const subReplaceString = substituteRegexParams(
|
||||
regexScript.replaceString,
|
||||
trimFencedMatch,
|
||||
{
|
||||
characterOverride,
|
||||
replaceStrategy: regexScript.replaceStrategy ?? regex_replace_strategy.REPLACE,
|
||||
},
|
||||
);
|
||||
// No match found - return the empty string
|
||||
if (!match) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return subReplaceString;
|
||||
// Remove trim strings from the match
|
||||
const filteredMatch = filterString(match, regexScript.trimStrings, { characterOverride });
|
||||
|
||||
// TODO: Handle overlay here
|
||||
|
||||
return filteredMatch;
|
||||
});
|
||||
|
||||
// Substitute at the end
|
||||
return substituteParams(replaceWithGroups);
|
||||
});
|
||||
|
||||
return newString;
|
||||
|
Reference in New Issue
Block a user