diff --git a/public/img/step-into.svg b/public/img/step-into.svg new file mode 100644 index 000000000..fcfa7ef16 --- /dev/null +++ b/public/img/step-into.svg @@ -0,0 +1,149 @@ + + + + diff --git a/public/img/step-out.svg b/public/img/step-out.svg new file mode 100644 index 000000000..aa7dd3ea2 --- /dev/null +++ b/public/img/step-out.svg @@ -0,0 +1,149 @@ + + + + diff --git a/public/img/step-over.svg b/public/img/step-over.svg new file mode 100644 index 000000000..6f23ff22a --- /dev/null +++ b/public/img/step-over.svg @@ -0,0 +1,149 @@ + + + + diff --git a/public/img/step-resume.svg b/public/img/step-resume.svg new file mode 100644 index 000000000..bf3e0647f --- /dev/null +++ b/public/img/step-resume.svg @@ -0,0 +1,218 @@ + + + + diff --git a/public/index.html b/public/index.html index 1fa87231b..d3d0a6b64 100644 --- a/public/index.html +++ b/public/index.html @@ -4268,6 +4268,16 @@ +
+ + +
diff --git a/public/scripts/autocomplete/AutoComplete.js b/public/scripts/autocomplete/AutoComplete.js index df3bcf75c..ba3d427a2 100644 --- a/public/scripts/autocomplete/AutoComplete.js +++ b/public/scripts/autocomplete/AutoComplete.js @@ -16,8 +16,15 @@ export const AUTOCOMPLETE_WIDTH = { 'FULL': 2, }; +/**@readonly*/ +/**@enum {Number}*/ +export const AUTOCOMPLETE_SELECT_KEY = { + 'TAB': 1, // 2^0 + 'ENTER': 2, // 2^1 +}; + export class AutoComplete { - /**@type {HTMLTextAreaElement}*/ textarea; + /**@type {HTMLTextAreaElement|HTMLInputElement}*/ textarea; /**@type {boolean}*/ isFloating = false; /**@type {()=>boolean}*/ checkIfActivate; /**@type {(text:string, index:number) => Promise}*/ getNameAt; @@ -56,6 +63,8 @@ export class AutoComplete { /**@type {function}*/ updateDetailsPositionDebounced; /**@type {function}*/ updateFloatingPositionDebounced; + /**@type {(item:AutoCompleteOption)=>any}*/ onSelect; + get matchType() { return power_user.stscript.matching ?? 'fuzzy'; } @@ -68,7 +77,7 @@ export class AutoComplete { /** - * @param {HTMLTextAreaElement} textarea The textarea to receive autocomplete. + * @param {HTMLTextAreaElement|HTMLInputElement} textarea The textarea to receive autocomplete. * @param {() => boolean} checkIfActivate Function should return true only if under the current conditions, autocomplete should display (e.g., for slash commands: autoComplete.text[0] == '/') * @param {(text: string, index: number) => Promise} getNameAt Function should return (unfiltered, matching against input is done in AutoComplete) information about name options at index in text. * @param {boolean} isFloating Whether autocomplete should float at the keyboard cursor. @@ -102,10 +111,15 @@ export class AutoComplete { this.updateDetailsPositionDebounced = debounce(this.updateDetailsPosition.bind(this), 10); this.updateFloatingPositionDebounced = debounce(this.updateFloatingPosition.bind(this), 10); - textarea.addEventListener('input', ()=>this.text != this.textarea.value && this.show(true, this.wasForced)); + textarea.addEventListener('input', ()=>{ + this.selectionStart = this.textarea.selectionStart; + if (this.text != this.textarea.value) this.show(true, this.wasForced); + }); textarea.addEventListener('keydown', (evt)=>this.handleKeyDown(evt)); - textarea.addEventListener('click', ()=>this.isActive ? this.show() : null); - textarea.addEventListener('selectionchange', ()=>this.show()); + textarea.addEventListener('click', ()=>{ + this.selectionStart = this.textarea.selectionStart; + if (this.isActive) this.show(); + }); textarea.addEventListener('blur', ()=>this.hide()); if (isFloating) { textarea.addEventListener('scroll', ()=>this.updateFloatingPositionDebounced()); @@ -189,6 +203,11 @@ export class AutoComplete { * @returns The option. */ fuzzyScore(option) { + // might have been matched by the options matchProvider function instead + if (!this.fuzzyRegex.test(option.name)) { + option.score = new AutoCompleteFuzzyScore(Number.MAX_SAFE_INTEGER, -1); + return option; + } const parts = this.fuzzyRegex.exec(option.name).slice(1, -1); let start = null; let consecutive = []; @@ -339,7 +358,7 @@ export class AutoComplete { this.result = this.effectiveParserResult.optionList // filter the list of options by the partial name according to the matching type - .filter(it => this.isReplaceable || it.name == '' ? matchers[this.matchType](it.name) : it.name.toLowerCase() == this.name) + .filter(it => this.isReplaceable || it.name == '' ? (it.matchProvider ? it.matchProvider(this.name) : matchers[this.matchType](it.name)) : it.name.toLowerCase() == this.name) // remove aliases .filter((it,idx,list) => list.findIndex(opt=>opt.value == it.value) == idx); @@ -357,10 +376,11 @@ export class AutoComplete { // build element option.dom = this.makeItem(option); // update replacer and add quotes if necessary + const optionName = option.valueProvider ? option.valueProvider(this.name) : option.name; if (this.effectiveParserResult.canBeQuoted) { - option.replacer = option.name.includes(' ') || this.startQuote || this.endQuote ? `"${option.name}"` : `${option.name}`; + option.replacer = optionName.includes(' ') || this.startQuote || this.endQuote ? `"${optionName.replace(/"/g, '\\"')}"` : `${optionName}`; } else { - option.replacer = option.name; + option.replacer = optionName; } // calculate fuzzy score if matching is fuzzy if (this.matchType == 'fuzzy') this.fuzzyScore(option); @@ -399,7 +419,7 @@ export class AutoComplete { , ); this.result.push(option); - } else if (this.result.length == 1 && this.effectiveParserResult && this.result[0].name == this.effectiveParserResult.name) { + } else if (this.result.length == 1 && this.effectiveParserResult && this.effectiveParserResult != this.secondaryParserResult && this.result[0].name == this.effectiveParserResult.name) { // only one result that is exactly the current value? just show hint, no autocomplete this.isReplaceable = false; this.isShowingDetails = false; @@ -439,11 +459,14 @@ export class AutoComplete { } else { item.dom.classList.remove('selected'); } + if (!item.isSelectable) { + item.dom.classList.add('not-selectable'); + } frag.append(item.dom); } this.dom.append(frag); this.updatePosition(); - getTopmostModalLayer().append(this.domWrap); + this.getLayer().append(this.domWrap); } else { this.domWrap.remove(); } @@ -458,10 +481,17 @@ export class AutoComplete { if (!this.isShowingDetails && this.isReplaceable) return this.detailsWrap.remove(); this.detailsDom.innerHTML = ''; this.detailsDom.append(this.selectedItem?.renderDetails() ?? 'NO ITEM'); - getTopmostModalLayer().append(this.detailsWrap); + this.getLayer().append(this.detailsWrap); this.updateDetailsPositionDebounced(); } + /** + * @returns {HTMLElement} closest ancestor dialog or body + */ + getLayer() { + return this.textarea.closest('dialog, body'); + } + /** @@ -474,7 +504,7 @@ export class AutoComplete { const rect = {}; rect[AUTOCOMPLETE_WIDTH.INPUT] = this.textarea.getBoundingClientRect(); rect[AUTOCOMPLETE_WIDTH.CHAT] = document.querySelector('#sheld').getBoundingClientRect(); - rect[AUTOCOMPLETE_WIDTH.FULL] = getTopmostModalLayer().getBoundingClientRect(); + rect[AUTOCOMPLETE_WIDTH.FULL] = this.getLayer().getBoundingClientRect(); this.domWrap.style.setProperty('--bottom', `${window.innerHeight - rect[AUTOCOMPLETE_WIDTH.INPUT].top}px`); this.dom.style.setProperty('--bottom', `${window.innerHeight - rect[AUTOCOMPLETE_WIDTH.INPUT].top}px`); this.domWrap.style.bottom = `${window.innerHeight - rect[AUTOCOMPLETE_WIDTH.INPUT].top}px`; @@ -501,7 +531,7 @@ export class AutoComplete { const rect = {}; rect[AUTOCOMPLETE_WIDTH.INPUT] = this.textarea.getBoundingClientRect(); rect[AUTOCOMPLETE_WIDTH.CHAT] = document.querySelector('#sheld').getBoundingClientRect(); - rect[AUTOCOMPLETE_WIDTH.FULL] = getTopmostModalLayer().getBoundingClientRect(); + rect[AUTOCOMPLETE_WIDTH.FULL] = this.getLayer().getBoundingClientRect(); if (this.isReplaceable) { this.detailsWrap.classList.remove('full'); const selRect = this.selectedItem.dom.children[0].getBoundingClientRect(); @@ -527,32 +557,34 @@ export class AutoComplete { updateFloatingPosition() { const location = this.getCursorPosition(); const rect = this.textarea.getBoundingClientRect(); + const layerRect = this.getLayer().getBoundingClientRect(); // cursor is out of view -> hide if (location.bottom < rect.top || location.top > rect.bottom || location.left < rect.left || location.left > rect.right) { return this.hide(); } - const left = Math.max(rect.left, location.left); + const left = Math.max(rect.left, location.left) - layerRect.left; this.domWrap.style.setProperty('--targetOffset', `${left}`); if (location.top <= window.innerHeight / 2) { // if cursor is in lower half of window, show list above line - this.domWrap.style.top = `${location.bottom}px`; + this.domWrap.style.top = `${location.bottom - layerRect.top}px`; this.domWrap.style.bottom = 'auto'; - this.domWrap.style.maxHeight = `calc(${location.bottom}px - 1vh)`; + this.domWrap.style.maxHeight = `calc(${location.bottom - layerRect.top}px - ${this.textarea.closest('dialog') ? '0' : '1vh'})`; } else { // if cursor is in upper half of window, show list below line this.domWrap.style.top = 'auto'; - this.domWrap.style.bottom = `calc(100vh - ${location.top}px)`; - this.domWrap.style.maxHeight = `calc(${location.top}px - 1vh)`; + this.domWrap.style.bottom = `calc(${layerRect.height}px - ${location.top - layerRect.top}px)`; + this.domWrap.style.maxHeight = `calc(${location.top - layerRect.top}px - ${this.textarea.closest('dialog') ? '0' : '1vh'})`; } } updateFloatingDetailsPosition(location = null) { if (!location) location = this.getCursorPosition(); const rect = this.textarea.getBoundingClientRect(); + const layerRect = this.getLayer().getBoundingClientRect(); if (location.bottom < rect.top || location.top > rect.bottom || location.left < rect.left || location.left > rect.right) { return this.hide(); } - const left = Math.max(rect.left, location.left); + const left = Math.max(rect.left, location.left) - layerRect.left; this.detailsWrap.style.setProperty('--targetOffset', `${left}`); if (this.isReplaceable) { this.detailsWrap.classList.remove('full'); @@ -572,14 +604,14 @@ export class AutoComplete { } if (location.top <= window.innerHeight / 2) { // if cursor is in lower half of window, show list above line - this.detailsWrap.style.top = `${location.bottom}px`; + this.detailsWrap.style.top = `${location.bottom - layerRect.top}px`; this.detailsWrap.style.bottom = 'auto'; - this.detailsWrap.style.maxHeight = `calc(${location.bottom}px - 1vh)`; + this.detailsWrap.style.maxHeight = `calc(${location.bottom - layerRect.top}px - ${this.textarea.closest('dialog') ? '0' : '1vh'})`; } else { // if cursor is in upper half of window, show list below line this.detailsWrap.style.top = 'auto'; - this.detailsWrap.style.bottom = `calc(100vh - ${location.top}px)`; - this.detailsWrap.style.maxHeight = `calc(${location.top}px - 1vh)`; + this.detailsWrap.style.bottom = `calc(${layerRect.height}px - ${location.top - layerRect.top}px)`; + this.detailsWrap.style.maxHeight = `calc(${location.top - layerRect.top}px - ${this.textarea.closest('dialog') ? '0' : '1vh'})`; } } @@ -597,7 +629,7 @@ export class AutoComplete { } this.clone.style.position = 'fixed'; this.clone.style.visibility = 'hidden'; - getTopmostModalLayer().append(this.clone); + document.body.append(this.clone); const mo = new MutationObserver(muts=>{ if (muts.find(it=>Array.from(it.removedNodes).includes(this.textarea))) { this.clone.remove(); @@ -656,6 +688,7 @@ export class AutoComplete { } this.wasForced = false; this.textarea.dispatchEvent(new Event('input', { bubbles:true })); + this.onSelect?.(this.selectedItem); } @@ -708,8 +741,10 @@ export class AutoComplete { } case 'Enter': { // pick the selected item to autocomplete + if ((power_user.stscript.autocomplete.select & AUTOCOMPLETE_SELECT_KEY.ENTER) != AUTOCOMPLETE_SELECT_KEY.ENTER) break; if (evt.ctrlKey || evt.altKey || evt.shiftKey || this.selectedItem.value == '') break; if (this.selectedItem.name == this.name) break; + if (!this.selectedItem.isSelectable) break; evt.preventDefault(); evt.stopImmediatePropagation(); this.select(); @@ -717,9 +752,11 @@ export class AutoComplete { } case 'Tab': { // pick the selected item to autocomplete + if ((power_user.stscript.autocomplete.select & AUTOCOMPLETE_SELECT_KEY.TAB) != AUTOCOMPLETE_SELECT_KEY.TAB) break; if (evt.ctrlKey || evt.altKey || evt.shiftKey || this.selectedItem.value == '') break; evt.preventDefault(); evt.stopImmediatePropagation(); + if (!this.selectedItem.isSelectable) break; this.select(); return; } @@ -772,30 +809,16 @@ export class AutoComplete { // ignore keydown on modifier keys return; } - switch (evt.key) { - case 'ArrowUp': - case 'ArrowDown': - case 'ArrowRight': - case 'ArrowLeft': { - if (this.isActive) { - // keyboard navigation, wait for keyup to complete cursor move - const oldText = this.textarea.value; - await new Promise(resolve=>{ - window.addEventListener('keyup', resolve, { once:true }); - }); - if (this.selectionStart != this.textarea.selectionStart) { - this.selectionStart = this.textarea.selectionStart; - this.show(this.isReplaceable || oldText != this.textarea.value); - } - } - break; - } - default: { - if (this.isActive) { - this.text != this.textarea.value && this.show(this.isReplaceable); - } - break; - } + // await keyup to see if cursor position or text has changed + const oldText = this.textarea.value; + await new Promise(resolve=>{ + window.addEventListener('keyup', resolve, { once:true }); + }); + if (this.selectionStart != this.textarea.selectionStart) { + this.selectionStart = this.textarea.selectionStart; + this.show(this.isReplaceable || oldText != this.textarea.value); + } else if (this.isActive) { + this.text != this.textarea.value && this.show(this.isReplaceable); } } } diff --git a/public/scripts/autocomplete/AutoCompleteNameResult.js b/public/scripts/autocomplete/AutoCompleteNameResult.js index 41c19cf9f..f048d6383 100644 --- a/public/scripts/autocomplete/AutoCompleteNameResult.js +++ b/public/scripts/autocomplete/AutoCompleteNameResult.js @@ -1,36 +1,9 @@ -import { SlashCommandNamedArgumentAutoCompleteOption } from '../slash-commands/SlashCommandNamedArgumentAutoCompleteOption.js'; -import { AutoCompleteOption } from './AutoCompleteOption.js'; -// import { AutoCompleteSecondaryNameResult } from './AutoCompleteSecondaryNameResult.js'; +import { AutoCompleteNameResultBase } from './AutoCompleteNameResultBase.js'; +import { AutoCompleteSecondaryNameResult } from './AutoCompleteSecondaryNameResult.js'; -export class AutoCompleteNameResult { - /**@type {string} */ name; - /**@type {number} */ start; - /**@type {AutoCompleteOption[]} */ optionList = []; - /**@type {boolean} */ canBeQuoted = false; - /**@type {()=>string} */ makeNoMatchText = ()=>`No matches found for "${this.name}"`; - /**@type {()=>string} */ makeNoOptionsText = ()=>'No options'; - - - /** - * @param {string} name Name (potentially partial) of the name at the requested index. - * @param {number} start Index where the name starts. - * @param {AutoCompleteOption[]} optionList A list of autocomplete options found in the current scope. - * @param {boolean} canBeQuoted Whether the name can be inside quotes. - * @param {()=>string} makeNoMatchText Function that returns text to show when no matches where found. - * @param {()=>string} makeNoOptionsText Function that returns text to show when no options are available to match against. - */ - constructor(name, start, optionList = [], canBeQuoted = false, makeNoMatchText = null, makeNoOptionsText = null) { - this.name = name; - this.start = start; - this.optionList = optionList; - this.canBeQuoted = canBeQuoted; - this.noMatchText = makeNoMatchText ?? this.makeNoMatchText; - this.noOptionstext = makeNoOptionsText ?? this.makeNoOptionsText; - } - - +export class AutoCompleteNameResult extends AutoCompleteNameResultBase { /** * * @param {string} text The whole text diff --git a/public/scripts/autocomplete/AutoCompleteNameResultBase.js b/public/scripts/autocomplete/AutoCompleteNameResultBase.js new file mode 100644 index 000000000..150ee68c5 --- /dev/null +++ b/public/scripts/autocomplete/AutoCompleteNameResultBase.js @@ -0,0 +1,31 @@ +import { SlashCommandNamedArgumentAutoCompleteOption } from '../slash-commands/SlashCommandNamedArgumentAutoCompleteOption.js'; +import { AutoCompleteOption } from './AutoCompleteOption.js'; + + + +export class AutoCompleteNameResultBase { + /**@type {string} */ name; + /**@type {number} */ start; + /**@type {AutoCompleteOption[]} */ optionList = []; + /**@type {boolean} */ canBeQuoted = false; + /**@type {()=>string} */ makeNoMatchText = ()=>`No matches found for "${this.name}"`; + /**@type {()=>string} */ makeNoOptionsText = ()=>'No options'; + + + /** + * @param {string} name Name (potentially partial) of the name at the requested index. + * @param {number} start Index where the name starts. + * @param {AutoCompleteOption[]} optionList A list of autocomplete options found in the current scope. + * @param {boolean} canBeQuoted Whether the name can be inside quotes. + * @param {()=>string} makeNoMatchText Function that returns text to show when no matches where found. + * @param {()=>string} makeNoOptionsText Function that returns text to show when no options are available to match against. + */ + constructor(name, start, optionList = [], canBeQuoted = false, makeNoMatchText = null, makeNoOptionsText = null) { + this.name = name; + this.start = start; + this.optionList = optionList; + this.canBeQuoted = canBeQuoted; + this.noMatchText = makeNoMatchText ?? this.makeNoMatchText; + this.noOptionstext = makeNoOptionsText ?? this.makeNoOptionsText; + } +} diff --git a/public/scripts/autocomplete/AutoCompleteOption.js b/public/scripts/autocomplete/AutoCompleteOption.js index abfcf3ff6..24822750b 100644 --- a/public/scripts/autocomplete/AutoCompleteOption.js +++ b/public/scripts/autocomplete/AutoCompleteOption.js @@ -11,6 +11,9 @@ export class AutoCompleteOption { /**@type {AutoCompleteFuzzyScore}*/ score; /**@type {string}*/ replacer; /**@type {HTMLElement}*/ dom; + /**@type {(input:string)=>boolean}*/ matchProvider; + /**@type {(input:string)=>string}*/ valueProvider; + /**@type {boolean}*/ makeSelectable = false; /** @@ -21,14 +24,21 @@ export class AutoCompleteOption { return this.name; } + get isSelectable() { + return this.makeSelectable || !this.valueProvider; + } + /** * @param {string} name */ - constructor(name, typeIcon = ' ', type = '') { + constructor(name, typeIcon = ' ', type = '', matchProvider = null, valueProvider = null, makeSelectable = false) { this.name = name; this.typeIcon = typeIcon; this.type = type; + this.matchProvider = matchProvider; + this.valueProvider = valueProvider; + this.makeSelectable = makeSelectable; } diff --git a/public/scripts/autocomplete/AutoCompleteSecondaryNameResult.js b/public/scripts/autocomplete/AutoCompleteSecondaryNameResult.js index 63eccf99f..e0e65fc7c 100644 --- a/public/scripts/autocomplete/AutoCompleteSecondaryNameResult.js +++ b/public/scripts/autocomplete/AutoCompleteSecondaryNameResult.js @@ -1,6 +1,6 @@ -import { AutoCompleteNameResult } from './AutoCompleteNameResult.js'; +import { AutoCompleteNameResultBase } from './AutoCompleteNameResultBase.js'; -export class AutoCompleteSecondaryNameResult extends AutoCompleteNameResult { +export class AutoCompleteSecondaryNameResult extends AutoCompleteNameResultBase { /**@type {boolean}*/ isRequired = false; /**@type {boolean}*/ forceMatch = true; } diff --git a/public/scripts/extensions/quick-reply/api/QuickReplyApi.js b/public/scripts/extensions/quick-reply/api/QuickReplyApi.js index 15ff1d4da..fedb6d0e6 100644 --- a/public/scripts/extensions/quick-reply/api/QuickReplyApi.js +++ b/public/scripts/extensions/quick-reply/api/QuickReplyApi.js @@ -23,10 +23,18 @@ export class QuickReplyApi { + /** + * @param {QuickReply} qr + * @returns {QuickReplySet} + */ + getSetByQr(qr) { + return QuickReplySet.list.find(it=>it.qrList.includes(qr)); + } + /** * Finds and returns an existing Quick Reply Set by its name. * - * @param {String} name name of the quick reply set + * @param {string} name name of the quick reply set * @returns the quick reply set, or undefined if not found */ getSetByName(name) { @@ -36,13 +44,14 @@ export class QuickReplyApi { /** * 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 + * @param {string} setName name of the quick reply set + * @param {string|number} label label or numeric ID of the quick reply * @returns the quick reply, or undefined if not found */ getQrByLabel(setName, label) { const set = this.getSetByName(setName); if (!set) return; + if (Number.isInteger(label)) return set.qrList.find(it=>it.id == label); return set.qrList.find(it=>it.label == label); } @@ -70,24 +79,25 @@ export class QuickReplyApi { /** * 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 + * @param {string} setName name of the existing quick reply set + * @param {string|number} label label of the existing quick reply (text on the button) or its numeric ID + * @param {object} [args] optional arguments + * @param {import('../../../slash-commands.js').ExecuteSlashCommandsOptions} [options] optional execution options */ - async executeQuickReply(setName, label, args = {}) { + async executeQuickReply(setName, label, args = {}, options = {}) { 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); + return await qr.execute(args, false, false, options); } /** * 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 + * @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); @@ -104,8 +114,8 @@ export class QuickReplyApi { /** * 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 + * @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); @@ -118,7 +128,7 @@ export class QuickReplyApi { /** * Removes a quick reply set from the list of globally active quick reply sets. * - * @param {String} name the name of the set + * @param {string} name the name of the set */ removeGlobalSet(name) { const set = this.getSetByName(name); @@ -132,8 +142,8 @@ export class QuickReplyApi { /** * 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 + * @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; @@ -151,8 +161,8 @@ export class QuickReplyApi { /** * 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 + * @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; @@ -166,7 +176,7 @@ export class QuickReplyApi { /** * 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 + * @param {string} name the name of the set */ removeChatSet(name) { if (!this.settings.chatConfig) return; @@ -181,21 +191,25 @@ export class QuickReplyApi { /** * 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 - * @param {Boolean} [props.executeOnGroupMemberDraft] whether to execute the quick reply when a group member is selected - * @param {String} [props.automationId] when not empty, the quick reply will be executed when the WI with the given automation ID is activated + * @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.icon] the icon to show on the QR button + * @param {boolean} [props.showLabel] whether to show the label even when an icon is assigned + * @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 + * @param {boolean} [props.executeOnGroupMemberDraft] whether to execute the quick reply when a group member is selected + * @param {string} [props.automationId] when not empty, the quick reply will be executed when the WI with the given automation ID is activated * @returns {QuickReply} the new quick reply */ createQuickReply(setName, label, { + icon, + showLabel, message, title, isHidden, @@ -212,6 +226,8 @@ export class QuickReplyApi { } const qr = set.addQuickReply(); qr.label = label ?? ''; + qr.icon = icon ?? ''; + qr.showLabel = showLabel ?? false; qr.message = message ?? ''; qr.title = title ?? ''; qr.isHidden = isHidden ?? false; @@ -228,22 +244,26 @@ export class QuickReplyApi { /** * 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 - * @param {Boolean} [props.executeOnGroupMemberDraft] whether to execute the quick reply when a group member is selected - * @param {String} [props.automationId] when not empty, the quick reply will be executed when the WI with the given automation ID is activated + * @param {string} setName name of the existing quick reply set + * @param {string|number} label label of the existing quick reply (text on the button) or its numeric ID + * @param {object} [props] + * @param {string} [props.icon] the icon to show on the QR button + * @param {boolean} [props.showLabel] whether to show the label even when an icon is assigned + * @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 + * @param {boolean} [props.executeOnGroupMemberDraft] whether to execute the quick reply when a group member is selected + * @param {string} [props.automationId] when not empty, the quick reply will be executed when the WI with the given automation ID is activated * @returns {QuickReply} the altered quick reply */ updateQuickReply(setName, label, { + icon, + showLabel, newLabel, message, title, @@ -259,6 +279,8 @@ export class QuickReplyApi { if (!qr) { throw new Error(`No quick reply with label "${label}" in set "${setName}" found.`); } + qr.updateIcon(icon ?? qr.icon); + qr.updateShowLabel(showLabel ?? qr.showLabel); qr.updateLabel(newLabel ?? qr.label); qr.updateMessage(message ?? qr.message); qr.updateTitle(title ?? qr.title); @@ -276,8 +298,8 @@ export class QuickReplyApi { /** * 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) + * @param {string} setName name of the existing quick reply set + * @param {string|number} label label of the existing quick reply (text on the button) or its numeric ID */ deleteQuickReply(setName, label) { const qr = this.getQrByLabel(setName, label); @@ -291,10 +313,10 @@ export class QuickReplyApi { /** * 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 + * @param {string} setName name of the existing quick reply set containing the quick reply + * @param {string|number} label label of the existing quick reply or its numeric ID + * @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); @@ -314,9 +336,9 @@ export class QuickReplyApi { /** * 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 + * @param {string} setName name of the existing quick reply set containing the quick reply + * @param {string|number} label label of the existing quick reply or its numeric ID + * @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); @@ -333,8 +355,8 @@ export class QuickReplyApi { /** * 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 + * @param {string} setName name of the existing quick reply set containing the quick reply + * @param {string|number} label label of the existing quick reply or its numeric ID */ clearContextMenu(setName, label) { const qr = this.getQrByLabel(setName, label); @@ -348,11 +370,11 @@ export class QuickReplyApi { /** * 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 + * @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} the new quick reply set */ async createSet(name, { @@ -384,11 +406,11 @@ export class QuickReplyApi { /** * 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 + * @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} the altered quick reply set */ async updateSet(name, { @@ -411,7 +433,7 @@ export class QuickReplyApi { /** * Delete an existing quick reply set. * - * @param {String} name name of the existing quick reply set + * @param {string} name name of the existing quick reply set */ async deleteSet(name) { const set = this.getSetByName(name); @@ -451,7 +473,7 @@ export class QuickReplyApi { /** * Gets a list of all quick replies in the quick reply set. * - * @param {String} setName name of the existing 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) { diff --git a/public/scripts/extensions/quick-reply/html/qrEditor.html b/public/scripts/extensions/quick-reply/html/qrEditor.html index b9ce236d6..89766d78a 100644 --- a/public/scripts/extensions/quick-reply/html/qrEditor.html +++ b/public/scripts/extensions/quick-reply/html/qrEditor.html @@ -2,10 +2,23 @@

Labels and Message

-
@@ -43,6 +58,10 @@ +
+ + +

Context Menu

@@ -64,7 +83,7 @@

Auto-Execute

-
+
-
+ +
+ + + + + + +
+ +
diff --git a/public/scripts/extensions/quick-reply/html/settings.html b/public/scripts/extensions/quick-reply/html/settings.html index c618bddf3..6c3845ca5 100644 --- a/public/scripts/extensions/quick-reply/html/settings.html +++ b/public/scripts/extensions/quick-reply/html/settings.html @@ -60,10 +60,20 @@ +
+ + + Color +
+
+ +
diff --git a/public/scripts/extensions/quick-reply/index.js b/public/scripts/extensions/quick-reply/index.js index ab6044bf0..7bd108243 100644 --- a/public/scripts/extensions/quick-reply/index.js +++ b/public/scripts/extensions/quick-reply/index.js @@ -176,7 +176,7 @@ const init = async () => { buttons.show(); settings.onSave = ()=>buttons.refresh(); - window['executeQuickReplyByName'] = async(name, args = {}) => { + window['executeQuickReplyByName'] = async(name, args = {}, options = {}) => { let qr = [...settings.config.setList, ...(settings.chatConfig?.setList ?? [])] .map(it=>it.set.qrList) .flat() @@ -191,7 +191,7 @@ const init = async () => { } } if (qr && qr.onExecute) { - return await qr.execute(args, false, true); + return await qr.execute(args, false, true, options); } else { throw new Error(`No Quick Reply found for "${name}".`); } diff --git a/public/scripts/extensions/quick-reply/lib/morphdom-esm.js b/public/scripts/extensions/quick-reply/lib/morphdom-esm.js new file mode 100644 index 000000000..7a13a27fc --- /dev/null +++ b/public/scripts/extensions/quick-reply/lib/morphdom-esm.js @@ -0,0 +1,769 @@ +var DOCUMENT_FRAGMENT_NODE = 11; + +function morphAttrs(fromNode, toNode) { + var toNodeAttrs = toNode.attributes; + var attr; + var attrName; + var attrNamespaceURI; + var attrValue; + var fromValue; + + // document-fragments dont have attributes so lets not do anything + if (toNode.nodeType === DOCUMENT_FRAGMENT_NODE || fromNode.nodeType === DOCUMENT_FRAGMENT_NODE) { + return; + } + + // update attributes on original DOM element + for (var i = toNodeAttrs.length - 1; i >= 0; i--) { + attr = toNodeAttrs[i]; + attrName = attr.name; + attrNamespaceURI = attr.namespaceURI; + attrValue = attr.value; + + if (attrNamespaceURI) { + attrName = attr.localName || attrName; + fromValue = fromNode.getAttributeNS(attrNamespaceURI, attrName); + + if (fromValue !== attrValue) { + if (attr.prefix === 'xmlns'){ + attrName = attr.name; // It's not allowed to set an attribute with the XMLNS namespace without specifying the `xmlns` prefix + } + fromNode.setAttributeNS(attrNamespaceURI, attrName, attrValue); + } + } else { + fromValue = fromNode.getAttribute(attrName); + + if (fromValue !== attrValue) { + fromNode.setAttribute(attrName, attrValue); + } + } + } + + // Remove any extra attributes found on the original DOM element that + // weren't found on the target element. + var fromNodeAttrs = fromNode.attributes; + + for (var d = fromNodeAttrs.length - 1; d >= 0; d--) { + attr = fromNodeAttrs[d]; + attrName = attr.name; + attrNamespaceURI = attr.namespaceURI; + + if (attrNamespaceURI) { + attrName = attr.localName || attrName; + + if (!toNode.hasAttributeNS(attrNamespaceURI, attrName)) { + fromNode.removeAttributeNS(attrNamespaceURI, attrName); + } + } else { + if (!toNode.hasAttribute(attrName)) { + fromNode.removeAttribute(attrName); + } + } + } +} + +var range; // Create a range object for efficently rendering strings to elements. +var NS_XHTML = 'http://www.w3.org/1999/xhtml'; + +var doc = typeof document === 'undefined' ? undefined : document; +var HAS_TEMPLATE_SUPPORT = !!doc && 'content' in doc.createElement('template'); +var HAS_RANGE_SUPPORT = !!doc && doc.createRange && 'createContextualFragment' in doc.createRange(); + +function createFragmentFromTemplate(str) { + var template = doc.createElement('template'); + template.innerHTML = str; + return template.content.childNodes[0]; +} + +function createFragmentFromRange(str) { + if (!range) { + range = doc.createRange(); + range.selectNode(doc.body); + } + + var fragment = range.createContextualFragment(str); + return fragment.childNodes[0]; +} + +function createFragmentFromWrap(str) { + var fragment = doc.createElement('body'); + fragment.innerHTML = str; + return fragment.childNodes[0]; +} + +/** + * This is about the same + * var html = new DOMParser().parseFromString(str, 'text/html'); + * return html.body.firstChild; + * + * @method toElement + * @param {String} str + */ +function toElement(str) { + str = str.trim(); + if (HAS_TEMPLATE_SUPPORT) { + // avoid restrictions on content for things like `Hi` which + // createContextualFragment doesn't support + //