diff --git a/public/scripts/extensions/quick-reply/api/QuickReplyApi.js b/public/scripts/extensions/quick-reply/api/QuickReplyApi.js index 15ff1d4da..85ff2da73 100644 --- a/public/scripts/extensions/quick-reply/api/QuickReplyApi.js +++ b/public/scripts/extensions/quick-reply/api/QuickReplyApi.js @@ -73,13 +73,14 @@ export class QuickReplyApi { * @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 {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); } diff --git a/public/scripts/extensions/quick-reply/index.js b/public/scripts/extensions/quick-reply/index.js index b0f496126..5c5cf7d7e 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/src/QuickReply.js b/public/scripts/extensions/quick-reply/src/QuickReply.js index d169f361d..4388d2790 100644 --- a/public/scripts/extensions/quick-reply/src/QuickReply.js +++ b/public/scripts/extensions/quick-reply/src/QuickReply.js @@ -58,6 +58,8 @@ export class QuickReply { /**@type {Popup}*/ editorPopup; /**@type {HTMLElement}*/ editorDom; + /**@type {HTMLTextAreaElement}*/ editorMessage; + /**@type {HTMLElement}*/ editorSyntax; /**@type {HTMLElement}*/ editorExecuteBtn; /**@type {HTMLElement}*/ editorExecuteBtnPause; /**@type {HTMLElement}*/ editorExecuteBtnStop; @@ -500,6 +502,7 @@ export class QuickReply { message.style.setProperty('text-shadow', 'none', 'important'); /**@type {HTMLElement}*/ const messageSyntaxInner = dom.querySelector('#qr--modal-messageSyntaxInner'); + this.editorSyntax = messageSyntaxInner; updateSyntax(); updateWrap(); updateTabSize(); @@ -720,7 +723,7 @@ export class QuickReply { } } - getEditorPosition(start, end) { + getEditorPosition(start, end, message = null) { const inputRect = this.editorMessage.getBoundingClientRect(); const style = window.getComputedStyle(this.editorMessage); if (!this.clone) { @@ -745,21 +748,22 @@ export class QuickReply { this.clone.style.top = `${inputRect.top}px`; this.clone.style.whiteSpace = style.whiteSpace; this.clone.style.tabSize = style.tabSize; - const text = this.editorMessage.value; + const text = message ?? this.editorMessage.value; const before = text.slice(0, start); this.clone.textContent = before; const locator = document.createElement('span'); locator.textContent = text.slice(start, end); this.clone.append(locator); this.clone.append(text.slice(end)); - this.clone.scrollTop = this.editorMessage.scrollTop; - this.clone.scrollLeft = this.editorMessage.scrollLeft; + this.clone.scrollTop = this.editorSyntax.scrollTop; + this.clone.scrollLeft = this.editorSyntax.scrollLeft; const locatorRect = locator.getBoundingClientRect(); + const bodyRect = document.body.getBoundingClientRect(); const location = { - left: locatorRect.left, - right: locatorRect.right, - top: locatorRect.top, - bottom: locatorRect.bottom, + left: locatorRect.left - bodyRect.left, + right: locatorRect.right - bodyRect.left, + top: locatorRect.top - bodyRect.top, + bottom: locatorRect.bottom - bodyRect.top, }; // this.clone.remove(); return location; @@ -821,8 +825,10 @@ export class QuickReply { //TODO populate debug code from closure.fullText and get locations, highlights, etc. from that //TODO keep some kind of reference (human identifier) *where* the closure code comes from? //TODO QR name, chat input, deserialized closure, ... ? - this.editorMessage.value = closure.fullText; - this.editorMessage.dispatchEvent(new Event('input', { bubbles:true })); + // this.editorMessage.value = closure.fullText; + // this.editorMessage.dispatchEvent(new Event('input', { bubbles:true })); + syntax.innerHTML = hljs.highlight(`${closure.fullText}${closure.fullText.slice(-1) == '\n' ? ' ' : ''}`, { language:'stscript', ignoreIllegals:true })?.value; + const source = closure.source; this.editorDebugState.innerHTML = ''; let ci = -1; const varNames = []; @@ -996,19 +1002,21 @@ export class QuickReply { const title = document.createElement('div'); { title.classList.add('qr--title'); title.textContent = isCurrent ? 'Current Scope' : 'Parent Scope'; - let hi; - title.addEventListener('pointerenter', ()=>{ - const loc = this.getEditorPosition(Math.max(0, c.executorList[0].start - 1), c.executorList.slice(-1)[0].end); - const layer = syntax.getBoundingClientRect(); - hi = document.createElement('div'); - hi.classList.add('qr--highlight-secondary'); - hi.style.left = `${loc.left - layer.left}px`; - hi.style.width = `${loc.right - loc.left}px`; - hi.style.top = `${loc.top - layer.top}px`; - hi.style.height = `${loc.bottom - loc.top}px`; - syntax.append(hi); - }); - title.addEventListener('pointerleave', ()=>hi?.remove()); + if (c.source == source) { + let hi; + title.addEventListener('pointerenter', ()=>{ + const loc = this.getEditorPosition(Math.max(0, c.executorList[0].start - 1), c.executorList.slice(-1)[0].end, c.fullText); + const layer = syntax.getBoundingClientRect(); + hi = document.createElement('div'); + hi.classList.add('qr--highlight-secondary'); + hi.style.left = `${loc.left - layer.left}px`; + hi.style.width = `${loc.right - loc.left}px`; + hi.style.top = `${loc.top - layer.top}px`; + hi.style.height = `${loc.bottom - loc.top}px`; + syntax.append(hi); + }); + title.addEventListener('pointerleave', ()=>hi?.remove()); + } wrap.append(title); } for (const key of Object.keys(scope.variables)) { @@ -1128,26 +1136,41 @@ export class QuickReply { title.textContent = 'Call Stack'; wrap.append(title); } + let ei = -1; for (const executor of this.debugController.cmdStack.toReversed()) { + ei++; + const c = this.debugController.stack.toReversed()[ei]; const item = document.createElement('div'); { item.classList.add('qr--item'); - item.textContent = `/${executor.name}`; - if (executor.command.name == 'run') { - item.textContent += `${(executor.name == ':' ? '' : ' ')}${executor.unnamedArgumentList[0]?.value}`; + if (executor.source == source) { + let hi; + item.addEventListener('pointerenter', ()=>{ + const loc = this.getEditorPosition(Math.max(0, executor.start - 1), executor.end, c.fullText); + const layer = syntax.getBoundingClientRect(); + hi = document.createElement('div'); + hi.classList.add('qr--highlight-secondary'); + hi.style.left = `${loc.left - layer.left}px`; + hi.style.width = `${loc.right - loc.left}px`; + hi.style.top = `${loc.top - layer.top}px`; + hi.style.height = `${loc.bottom - loc.top}px`; + syntax.append(hi); + }); + item.addEventListener('pointerleave', ()=>hi?.remove()); + } + const cmd = document.createElement('div'); { + cmd.classList.add('qr--cmd'); + cmd.textContent = `/${executor.name}`; + if (executor.command.name == 'run') { + cmd.textContent += `${(executor.name == ':' ? '' : ' ')}${executor.unnamedArgumentList[0]?.value}`; + } + item.append(cmd); + } + const src = document.createElement('div'); { + src.classList.add('qr--source'); + const line = closure.fullText.slice(0, executor.start).split('\n').length; + src.textContent = `${executor.source}:${line}`; + item.append(src); } - let hi; - item.addEventListener('pointerenter', ()=>{ - const loc = this.getEditorPosition(Math.max(0, executor.start - 1), executor.end); - const layer = syntax.getBoundingClientRect(); - hi = document.createElement('div'); - hi.classList.add('qr--highlight-secondary'); - hi.style.left = `${loc.left - layer.left}px`; - hi.style.width = `${loc.right - loc.left}px`; - hi.style.top = `${loc.top - layer.top}px`; - hi.style.height = `${loc.bottom - loc.top}px`; - syntax.append(hi); - }); - item.addEventListener('pointerleave', ()=>hi?.remove()); wrap.append(item); } } @@ -1157,7 +1180,7 @@ export class QuickReply { this.editorDebugState.append(buildVars(closure.scope, true)); this.editorDebugState.append(buildStack()); this.editorDebugState.classList.add('qr--active'); - const loc = this.getEditorPosition(Math.max(0, executor.start - 1), executor.end); + const loc = this.getEditorPosition(Math.max(0, executor.start - 1), executor.end, closure.fullText); const layer = syntax.getBoundingClientRect(); const hi = document.createElement('div'); hi.classList.add('qr--highlight'); @@ -1291,7 +1314,7 @@ export class QuickReply { } - async execute(args = {}, isEditor = false, isRun = false) { + async execute(args = {}, isEditor = false, isRun = false, options = {}) { if (this.message?.length > 0 && this.onExecute) { const scope = new SlashCommandScope(); for (const key of Object.keys(args)) { @@ -1303,11 +1326,12 @@ export class QuickReply { this.abortController = new SlashCommandAbortController(); } return await this.onExecute(this, { - message:this.message, + message: this.message, isAutoExecute: args.isAutoExecute ?? false, isEditor, isRun, scope, + executionOptions: options, }); } } diff --git a/public/scripts/extensions/quick-reply/src/QuickReplySet.js b/public/scripts/extensions/quick-reply/src/QuickReplySet.js index 9e12f233a..32b3761c0 100644 --- a/public/scripts/extensions/quick-reply/src/QuickReplySet.js +++ b/public/scripts/extensions/quick-reply/src/QuickReplySet.js @@ -108,18 +108,9 @@ export class QuickReplySet { async debug(qr) { const parser = new SlashCommandParser(); const closure = parser.parse(qr.message, true, [], qr.abortController, qr.debugController); + closure.source = `${this.name}.${qr.label}`; closure.onProgress = (done, total) => qr.updateEditorProgress(done, total); closure.scope.setMacro('arg::*', ''); - // closure.abortController = qr.abortController; - // closure.debugController = qr.debugController; - // const stepper = closure.executeGenerator(); - // let step; - // let isStepping = false; - // while (!step?.done) { - // step = await stepper.next(isStepping); - // isStepping = yield(step.value); - // } - // return step.value; return (await closure.execute())?.pipe; } /** @@ -131,6 +122,7 @@ export class QuickReplySet { * @param {boolean} [options.isEditor] (false) whether the execution is triggered by the QR editor * @param {boolean} [options.isRun] (false) whether the execution is triggered by /run or /: (window.executeQuickReplyByName) * @param {SlashCommandScope} [options.scope] (null) scope to be used when running the command + * @param {import('../../../slash-commands.js').ExecuteSlashCommandsOptions} [options.executionOptions] ({}) further execution options * @returns */ async executeWithOptions(qr, options = {}) { @@ -140,7 +132,9 @@ export class QuickReplySet { isEditor:false, isRun:false, scope:null, + executionOptions:{}, }, options); + const execOptions = options.executionOptions; /**@type {HTMLTextAreaElement}*/ const ta = document.querySelector('#send_textarea'); const finalMessage = options.message ?? qr.message; @@ -158,21 +152,24 @@ export class QuickReplySet { if (input[0] == '/' && !this.disableSend) { let result; if (options.isAutoExecute || options.isRun) { - result = await executeSlashCommandsWithOptions(input, { + result = await executeSlashCommandsWithOptions(input, Object.assign(execOptions, { handleParserErrors: true, scope: options.scope, - }); + source: `${this.name}.${qr.label}`, + })); } else if (options.isEditor) { - result = await executeSlashCommandsWithOptions(input, { + result = await executeSlashCommandsWithOptions(input, Object.assign(execOptions, { handleParserErrors: false, scope: options.scope, abortController: qr.abortController, + source: `${this.name}.${qr.label}`, onProgress: (done, total) => qr.updateEditorProgress(done, total), - }); + })); } else { - result = await executeSlashCommandsOnChatInput(input, { + result = await executeSlashCommandsOnChatInput(input, Object.assign(execOptions, { scope: options.scope, - }); + source: `${this.name}.${qr.label}`, + })); } return typeof result === 'object' ? result?.pipe : ''; } diff --git a/public/scripts/extensions/quick-reply/style.css b/public/scripts/extensions/quick-reply/style.css index 7d16db9f3..b0ed59cd0 100644 --- a/public/scripts/extensions/quick-reply/style.css +++ b/public/scripts/extensions/quick-reply/style.css @@ -667,6 +667,10 @@ .popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-debugState .qr--scope .qr--scope .qr--pipe .qr--val { opacity: 0.5; } +.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-debugState .qr--stack { + display: grid; + grid-template-columns: 1fr 0fr; +} .popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-debugState .qr--stack .qr--title { grid-column: 1 / 3; font-weight: bold; @@ -676,10 +680,17 @@ margin-top: 1em; } .popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-debugState .qr--stack .qr--item { + display: contents; +} +.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-debugState .qr--stack .qr--item:nth-child(2n + 1) .qr--name, +.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-debugState .qr--stack .qr--item:nth-child(2n + 1) .qr--source { + background-color: rgb(from var(--SmartThemeEmColor) r g b / 0.25); +} +.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-debugState .qr--stack .qr--item .qr--name { margin-left: 0.5em; } -.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-debugState .qr--stack .qr--item:nth-child(2n + 1) { - background-color: rgb(from var(--SmartThemeEmColor) r g b / 0.25); +.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-debugState .qr--stack .qr--item .qr--source { + opacity: 0.5; } @keyframes qr--progressPulse { 0%, diff --git a/public/scripts/extensions/quick-reply/style.less b/public/scripts/extensions/quick-reply/style.less index a7e0adae5..cbb8605f6 100644 --- a/public/scripts/extensions/quick-reply/style.less +++ b/public/scripts/extensions/quick-reply/style.less @@ -748,6 +748,8 @@ } .qr--stack { + display: grid; + grid-template-columns: 1fr 0fr; .qr--title { grid-column: 1 / 3; font-weight: bold; @@ -757,10 +759,18 @@ margin-top: 1em; } .qr--item { - margin-left: 0.5em; + display: contents; &:nth-child(2n + 1) { - background-color: rgb(from var(--SmartThemeEmColor) r g b / 0.25); + .qr--name, .qr--source { + background-color: rgb(from var(--SmartThemeEmColor) r g b / 0.25); + } } + .qr--name { + margin-left: 0.5em; + } + .qr--source { + opacity: 0.5; + } } } } diff --git a/public/scripts/slash-commands.js b/public/scripts/slash-commands.js index 988f0a62a..c39b2d9a2 100644 --- a/public/scripts/slash-commands.js +++ b/public/scripts/slash-commands.js @@ -1824,7 +1824,12 @@ async function runCallback(args, name) { try { name = name.trim(); - return await window['executeQuickReplyByName'](name, args); + /**@type {ExecuteSlashCommandsOptions} */ + const options = { + abortController: args._abortController, + debugController: args._debugController, + }; + return await window['executeQuickReplyByName'](name, args, options); } catch (error) { throw new Error(`Error running Quick Reply "${name}": ${error.message}`); } @@ -3372,6 +3377,7 @@ const clearCommandProgressDebounced = debounce(clearCommandProgress); * @prop {SlashCommandAbortController} [abortController] (null) Controller used to abort or pause command execution * @prop {SlashCommandDebugController} [debugController] (null) Controller used to control debug execution * @prop {(done:number, total:number)=>void} [onProgress] (null) Callback to handle progress events + * @prop {string} [source] (null) String indicating where the code come from (e.g., QR name) */ /** @@ -3379,6 +3385,7 @@ const clearCommandProgressDebounced = debounce(clearCommandProgress); * @prop {SlashCommandScope} [scope] (null) The scope to be used when executing the commands. * @prop {{[id:PARSER_FLAG]:boolean}} [parserFlags] (null) Parser flags to apply * @prop {boolean} [clearChatInput] (false) Whether to clear the chat input textarea + * @prop {string} [source] (null) String indicating where the code come from (e.g., QR name) */ /** @@ -3394,6 +3401,7 @@ export async function executeSlashCommandsOnChatInput(text, options = {}) { scope: null, parserFlags: null, clearChatInput: false, + source: null, }, options); isExecutingCommandsFromChatInput = true; @@ -3423,6 +3431,7 @@ export async function executeSlashCommandsOnChatInput(text, options = {}) { onProgress: (done, total) => ta.style.setProperty('--prog', `${done / total * 100}%`), parserFlags: options.parserFlags, scope: options.scope, + source: options.source, }); if (commandsFromChatInputAbortController.signal.aborted) { document.querySelector('#form_sheld').classList.add('script_aborted'); @@ -3481,6 +3490,7 @@ async function executeSlashCommandsWithOptions(text, options = {}) { abortController: null, debugController: null, onProgress: null, + source: null, }, options); let closure; @@ -3489,6 +3499,7 @@ async function executeSlashCommandsWithOptions(text, options = {}) { closure.scope.parent = options.scope; closure.onProgress = options.onProgress; closure.debugController = options.debugController; + closure.source = options.source; } catch (e) { if (options.handleParserErrors && e instanceof SlashCommandParserError) { /**@type {SlashCommandParserError}*/ diff --git a/public/scripts/slash-commands/SlashCommandClosure.js b/public/scripts/slash-commands/SlashCommandClosure.js index f7056bfc4..663776acb 100644 --- a/public/scripts/slash-commands/SlashCommandClosure.js +++ b/public/scripts/slash-commands/SlashCommandClosure.js @@ -1,5 +1,5 @@ import { substituteParams } from '../../script.js'; -import { delay, escapeRegex } from '../utils.js'; +import { delay, escapeRegex, uuidv4 } from '../utils.js'; import { SlashCommand } from './SlashCommand.js'; import { SlashCommandAbortController } from './SlashCommandAbortController.js'; import { SlashCommandBreak } from './SlashCommandBreak.js'; @@ -27,6 +27,14 @@ export class SlashCommandClosure { /**@type {string}*/ rawText; /**@type {string}*/ fullText; /**@type {string}*/ parserContext; + /**@type {string}*/ #source = uuidv4(); + get source() { return this.#source; } + set source(value) { + this.#source = value; + for (const executor of this.executorList) { + executor.source = value; + } + } /**@type {number}*/ get commandCount() { @@ -114,6 +122,7 @@ export class SlashCommandClosure { closure.rawText = this.rawText; closure.fullText = this.fullText; closure.parserContext = this.parserContext; + closure.source = this.source; closure.onProgress = this.onProgress; return closure; } diff --git a/public/scripts/slash-commands/SlashCommandExecutor.js b/public/scripts/slash-commands/SlashCommandExecutor.js index f64c37aff..cfbcf6cf1 100644 --- a/public/scripts/slash-commands/SlashCommandExecutor.js +++ b/public/scripts/slash-commands/SlashCommandExecutor.js @@ -1,4 +1,5 @@ // eslint-disable-next-line no-unused-vars +import { uuidv4 } from '../utils.js'; import { SlashCommand } from './SlashCommand.js'; // eslint-disable-next-line no-unused-vars import { SlashCommandClosure } from './SlashCommandClosure.js'; @@ -16,6 +17,17 @@ export class SlashCommandExecutor { /**@type {Number}*/ startUnnamedArgs; /**@type {Number}*/ endUnnamedArgs; /**@type {String}*/ name = ''; + /**@type {String}*/ #source = uuidv4(); + get source() { return this.#source; } + set source(value) { + this.#source = value; + for (const arg of this.namedArgumentList.filter(it=>it.value instanceof SlashCommandClosure)) { + arg.value.source = value; + } + for (const arg of this.unnamedArgumentList.filter(it=>it.value instanceof SlashCommandClosure)) { + arg.value.source = value; + } + } /**@type {SlashCommand}*/ command; // @ts-ignore /**@type {SlashCommandNamedArgumentAssignment[]}*/ namedArgumentList = [];