import { POPUP_TYPE, Popup } from '../../../popup.js'; import { setSlashCommandAutoComplete } from '../../../slash-commands.js'; import { SlashCommandAbortController } from '../../../slash-commands/SlashCommandAbortController.js'; import { SlashCommandBreakPoint } from '../../../slash-commands/SlashCommandBreakPoint.js'; import { SlashCommandClosure } from '../../../slash-commands/SlashCommandClosure.js'; import { SlashCommandClosureResult } from '../../../slash-commands/SlashCommandClosureResult.js'; import { SlashCommandDebugController } from '../../../slash-commands/SlashCommandDebugController.js'; import { SlashCommandExecutor } from '../../../slash-commands/SlashCommandExecutor.js'; import { SlashCommandParser } from '../../../slash-commands/SlashCommandParser.js'; import { SlashCommandParserError } from '../../../slash-commands/SlashCommandParserError.js'; import { SlashCommandScope } from '../../../slash-commands/SlashCommandScope.js'; import { debounce, delay, getSortableDelay, showFontAwesomePicker } from '../../../utils.js'; import { log, quickReplyApi, 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}*/ icon; /**@type {string}*/ label = ''; /**@type {boolean}*/ showLabel = false; /**@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 {boolean}*/ executeOnGroupMemberDraft = false; /**@type {string}*/ automationId = ''; /**@type {function}*/ onExecute; /**@type {(qr:QuickReply)=>AsyncGenerator}*/ onDebug; /**@type {function}*/ onDelete; /**@type {function}*/ onUpdate; /**@type {function}*/ onInsertBefore; /**@type {function}*/ onTransfer; /**@type {HTMLElement}*/ dom; /**@type {HTMLElement}*/ domIcon; /**@type {HTMLElement}*/ domLabel; /**@type {HTMLElement}*/ settingsDom; /**@type {HTMLElement}*/ settingsDomIcon; /**@type {HTMLInputElement}*/ settingsDomLabel; /**@type {HTMLTextAreaElement}*/ settingsDomMessage; /**@type {Popup}*/ editorPopup; /**@type {HTMLElement}*/ editorDom; /**@type {HTMLTextAreaElement}*/ editorMessage; /**@type {HTMLTextAreaElement}*/ editorMessageLabel; /**@type {HTMLElement}*/ editorSyntax; /**@type {HTMLElement}*/ editorExecuteBtn; /**@type {HTMLElement}*/ editorExecuteBtnPause; /**@type {HTMLElement}*/ editorExecuteBtnStop; /**@type {HTMLElement}*/ editorExecuteProgress; /**@type {HTMLElement}*/ editorExecuteErrors; /**@type {HTMLElement}*/ editorExecuteResult; /**@type {HTMLElement}*/ editorDebugState; /**@type {Promise}*/ editorExecutePromise; /**@type {boolean}*/ isExecuting; /**@type {SlashCommandAbortController}*/ abortController; /**@type {SlashCommandDebugController}*/ debugController; 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; if (this.icon) { this.domIcon.classList.remove('qr--hidden'); if (this.showLabel) this.domLabel.classList.remove('qr--hidden'); else this.domLabel.classList.add('qr--hidden'); } else { this.domIcon.classList.add('qr--hidden'); this.domLabel.classList.remove('qr--hidden'); } 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 icon = document.createElement('div'); { this.domIcon = icon; icon.classList.add('qr--button-icon'); icon.classList.add('fa-solid'); if (!this.icon) icon.classList.add('qr--hidden'); else icon.classList.add(this.icon); root.append(icon); } const lbl = document.createElement('div'); { this.domLabel = lbl; lbl.classList.add('qr--button-label'); if (this.icon && !this.showLabel) lbl.classList.add('qr--hidden'); 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 adder = document.createElement('div'); { adder.classList.add('qr--set-itemAdder'); const actions = document.createElement('div'); { actions.classList.add('qr--actions'); const addNew = document.createElement('div'); { addNew.classList.add('qr--action'); addNew.classList.add('qr--add'); addNew.classList.add('menu_button'); addNew.classList.add('menu_button_icon'); addNew.classList.add('fa-solid'); addNew.classList.add('fa-plus'); addNew.title = 'Add quick reply'; addNew.addEventListener('click', ()=>this.onInsertBefore()); actions.append(addNew); } const paste = document.createElement('div'); { paste.classList.add('qr--action'); paste.classList.add('qr--paste'); paste.classList.add('menu_button'); paste.classList.add('menu_button_icon'); paste.classList.add('fa-solid'); paste.classList.add('fa-paste'); paste.title = 'Add quick reply from clipboard'; paste.addEventListener('click', async()=>{ const text = await navigator.clipboard.readText(); this.onInsertBefore(text); }); actions.append(paste); } const importFile = document.createElement('div'); { importFile.classList.add('qr--action'); importFile.classList.add('qr--importFile'); importFile.classList.add('menu_button'); importFile.classList.add('menu_button_icon'); importFile.classList.add('fa-solid'); importFile.classList.add('fa-file-import'); importFile.title = 'Add quick reply from JSON file'; importFile.addEventListener('click', async()=>{ const inp = document.createElement('input'); { inp.type = 'file'; inp.accept = '.json'; inp.addEventListener('change', async()=>{ if (inp.files.length > 0) { for (const file of inp.files) { const text = await file.text(); this.onInsertBefore(text); } } }); inp.click(); } }); actions.append(importFile); } adder.append(actions); } item.append(adder); } const itemContent = document.createElement('div'); { itemContent.classList.add('qr--content'); const drag = document.createElement('div'); { drag.classList.add('drag-handle'); drag.classList.add('ui-sortable-handle'); drag.textContent = '☰'; itemContent.append(drag); } const lblContainer = document.createElement('div'); { lblContainer.classList.add('qr--set-itemLabelContainer'); const icon = document.createElement('div'); { this.settingsDomIcon = icon; icon.title = 'Click to change icon'; icon.classList.add('qr--set-itemIcon'); icon.classList.add('menu_button'); if (this.icon) { icon.classList.add('fa-solid'); icon.classList.add(this.icon); } icon.addEventListener('click', async()=>{ let value = await showFontAwesomePicker(); this.updateIcon(value); }); lblContainer.append(icon); } 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); } itemContent.append(lblContainer); } item.append(itemContent); } 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); } itemContent.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)); itemContent.append(mes); } const actions = document.createElement('div'); { actions.classList.add('qr--actions'); const move = document.createElement('div'); { move.classList.add('qr--action'); move.classList.add('menu_button'); move.classList.add('menu_button_icon'); move.classList.add('fa-solid'); move.classList.add('fa-truck-arrow-right'); move.title = 'Move quick reply to other set'; move.addEventListener('click', ()=>this.onTransfer(this)); actions.append(move); } const copy = document.createElement('div'); { copy.classList.add('qr--action'); copy.classList.add('menu_button'); copy.classList.add('menu_button_icon'); copy.classList.add('fa-solid'); copy.classList.add('fa-copy'); copy.title = 'Copy quick reply to clipboard'; copy.addEventListener('click', async()=>{ await navigator.clipboard.writeText(JSON.stringify(this)); copy.classList.add('qr--success'); await delay(3010); copy.classList.remove('qr--success'); }); actions.append(copy); } const cut = document.createElement('div'); { cut.classList.add('qr--action'); cut.classList.add('menu_button'); cut.classList.add('menu_button_icon'); cut.classList.add('fa-solid'); cut.classList.add('fa-cut'); cut.title = 'Cut quick reply to clipboard (copy and remove)'; cut.addEventListener('click', async()=>{ await navigator.clipboard.writeText(JSON.stringify(this)); this.delete(); }); actions.append(cut); } const exp = document.createElement('div'); { exp.classList.add('qr--action'); exp.classList.add('menu_button'); exp.classList.add('menu_button_icon'); exp.classList.add('fa-solid'); exp.classList.add('fa-file-export'); exp.title = 'Export quick reply as file'; exp.addEventListener('click', ()=>{ const blob = new Blob([JSON.stringify(this)], { type:'text' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); { a.href = url; a.download = `${this.label}.qr.json`; a.click(); } }); actions.append(exp); } 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); } itemContent.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); this.editorDom = dom; this.editorPopup = new Popup(dom, POPUP_TYPE.TEXT, undefined, { okButton: 'OK', wide: true, large: true, rows: 1 }); const popupResult = this.editorPopup.show(); // basics /**@type {HTMLElement}*/ const icon = dom.querySelector('#qr--modal-icon'); if (this.icon) { icon.classList.add('fa-solid'); icon.classList.add(this.icon); } else { icon.textContent = '…'; } icon.addEventListener('click', async()=>{ let value = await showFontAwesomePicker(); if (value === null) return; if (this.icon) icon.classList.remove(this.icon); if (value == '') { icon.classList.remove('fa-solid'); icon.textContent = '…'; } else { icon.textContent = ''; icon.classList.add('fa-solid'); icon.classList.add(value); } this.updateIcon(value); }); /**@type {HTMLInputElement}*/ const showLabel = dom.querySelector('#qr--modal-showLabel'); showLabel.checked = this.showLabel; showLabel.addEventListener('click', ()=>{ this.updateShowLabel(showLabel.checked); }); /**@type {HTMLInputElement}*/ const label = dom.querySelector('#qr--modal-label'); label.value = this.label; label.addEventListener('input', ()=>{ this.updateLabel(label.value); }); let switcherList; dom.querySelector('#qr--modal-switcher').addEventListener('click', (evt)=>{ if (switcherList) { switcherList.remove(); switcherList = null; return; } const list = document.createElement('ul'); { switcherList = list; list.classList.add('qr--modal-switcherList'); const makeList = (qrs)=>{ const setItem = document.createElement('li'); { setItem.classList.add('qr--modal-switcherItem'); setItem.addEventListener('click', ()=>{ list.innerHTML = ''; for (const qrs of quickReplyApi.listSets()) { const item = document.createElement('li'); { item.classList.add('qr--modal-switcherItem'); item.addEventListener('click', ()=>{ list.innerHTML = ''; makeList(quickReplyApi.getSetByName(qrs)); }); const lbl = document.createElement('div'); { lbl.classList.add('qr--label'); lbl.textContent = qrs; item.append(lbl); } list.append(item); } } }); const lbl = document.createElement('div'); { lbl.classList.add('qr--label'); lbl.textContent = 'Switch QR Sets...'; setItem.append(lbl); } list.append(setItem); } for (const qr of qrs.qrList.toSorted((a,b)=>a.label.toLowerCase().localeCompare(b.label.toLowerCase()))) { const item = document.createElement('li'); { item.classList.add('qr--modal-switcherItem'); if (qr == this) item.classList.add('qr--current'); else item.addEventListener('click', ()=>{ this.editorPopup.completeAffirmative(); qr.showEditor(); }); const lbl = document.createElement('div'); { lbl.classList.add('qr--label'); lbl.textContent = qr.label; item.append(lbl); } const id = document.createElement('div'); { id.classList.add('qr--id'); id.textContent = qr.id.toString(); item.append(id); } const mes = document.createElement('div'); { mes.classList.add('qr--message'); mes.textContent = qr.message; item.append(mes); } list.append(item); } } }; makeList(quickReplyApi.getSetByQr(this)); } label.parentElement.append(list); }); /**@type {HTMLInputElement}*/ const title = dom.querySelector('#qr--modal-title'); title.value = this.title; title.addEventListener('input', () => { this.updateTitle(title.value); }); /**@type {HTMLInputElement}*/ const wrap = dom.querySelector('#qr--modal-wrap'); wrap.checked = JSON.parse(localStorage.getItem('qr--wrap') ?? 'false'); wrap.addEventListener('click', () => { localStorage.setItem('qr--wrap', JSON.stringify(wrap.checked)); updateWrap(); }); const updateWrap = () => { if (wrap.checked) { message.style.whiteSpace = 'pre-wrap'; messageSyntaxInner.style.whiteSpace = 'pre-wrap'; if (this.clone) { this.clone.style.whiteSpace = 'pre-wrap'; } } else { message.style.whiteSpace = 'pre'; messageSyntaxInner.style.whiteSpace = 'pre'; if (this.clone) { this.clone.style.whiteSpace = 'pre'; } } updateScrollDebounced(); }; const updateScroll = (evt) => { let left = message.scrollLeft; let top = message.scrollTop; if (evt) { evt.preventDefault(); left = message.scrollLeft + evt.deltaX; top = message.scrollTop + evt.deltaY; message.scrollTo({ behavior: 'instant', left, top, }); } messageSyntaxInner.scrollTo({ behavior: 'instant', left, top, }); }; const updateScrollDebounced = updateScroll; const updateSyntax = ()=>{ messageSyntaxInner.innerHTML = hljs.highlight(`${message.value}${message.value.slice(-1) == '\n' ? ' ' : ''}`, { language:'stscript', ignoreIllegals:true })?.value; }; const updateSyntaxEnabled = ()=>{ if (JSON.parse(localStorage.getItem('qr--syntax'))) { dom.querySelector('#qr--modal-messageHolder').classList.remove('qr--noSyntax'); } else { dom.querySelector('#qr--modal-messageHolder').classList.add('qr--noSyntax'); } }; /**@type {HTMLInputElement}*/ const tabSize = dom.querySelector('#qr--modal-tabSize'); tabSize.value = JSON.parse(localStorage.getItem('qr--tabSize') ?? '4'); const updateTabSize = () => { message.style.tabSize = tabSize.value; messageSyntaxInner.style.tabSize = tabSize.value; updateScrollDebounced(); }; tabSize.addEventListener('change', () => { localStorage.setItem('qr--tabSize', JSON.stringify(Number(tabSize.value))); updateTabSize(); }); /**@type {HTMLInputElement}*/ const executeShortcut = dom.querySelector('#qr--modal-executeShortcut'); executeShortcut.checked = JSON.parse(localStorage.getItem('qr--executeShortcut') ?? 'true'); executeShortcut.addEventListener('click', () => { localStorage.setItem('qr--executeShortcut', JSON.stringify(executeShortcut.checked)); }); /**@type {HTMLInputElement}*/ const syntax = dom.querySelector('#qr--modal-syntax'); syntax.checked = JSON.parse(localStorage.getItem('qr--syntax') ?? 'true'); syntax.addEventListener('click', () => { localStorage.setItem('qr--syntax', JSON.stringify(syntax.checked)); updateSyntaxEnabled(); }); navigator.keyboard.getLayoutMap().then(it=>dom.querySelector('#qr--modal-commentKey').textContent = it.get('Backslash')); this.editorMessageLabel = dom.querySelector('label[for="qr--modal-message"]'); /**@type {HTMLTextAreaElement}*/ const message = dom.querySelector('#qr--modal-message'); this.editorMessage = message; message.value = this.message; message.addEventListener('input', () => { updateSyntax(); this.updateMessage(message.value); updateScrollDebounced(); }); //TODO move tab support for textarea into its own helper(?) and use for both this and .editor_maximize message.addEventListener('keydown', async(evt) => { if (this.isExecuting) return; if (evt.key == 'Tab' && !evt.shiftKey && !evt.ctrlKey && !evt.altKey) { // increase indent evt.preventDefault(); const start = message.selectionStart; const end = message.selectionEnd; if (end - start > 0 && message.value.substring(start, end).includes('\n')) { evt.stopImmediatePropagation(); evt.stopPropagation(); const lineStart = message.value.lastIndexOf('\n', start - 1); 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; message.dispatchEvent(new Event('input', { bubbles:true })); } else if (!(ac.isReplaceable && ac.isActive)) { evt.stopImmediatePropagation(); evt.stopPropagation(); message.value = `${message.value.substring(0, start)}\t${message.value.substring(end)}`; message.selectionStart = start + 1; message.selectionEnd = end + 1; message.dispatchEvent(new Event('input', { bubbles:true })); } } else if (evt.key == 'Tab' && evt.shiftKey && !evt.ctrlKey && !evt.altKey) { // decrease indent evt.preventDefault(); evt.stopImmediatePropagation(); evt.stopPropagation(); const start = message.selectionStart; const end = message.selectionEnd; const lineStart = message.value.lastIndexOf('\n', start - 1); 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; message.dispatchEvent(new Event('input', { bubbles:true })); } else if (evt.key == 'Enter' && !evt.ctrlKey && !evt.shiftKey && !evt.altKey && !(ac.isReplaceable && ac.isActive)) { // new line, keep indent evt.stopImmediatePropagation(); evt.stopPropagation(); evt.preventDefault(); const start = message.selectionStart; const end = message.selectionEnd; const lineStart = message.value.lastIndexOf('\n', start - 1); const indent = /^(\s*)/.exec(message.value.slice(lineStart).replace(/^\n*/, ''))[1] ?? ''; message.value = `${message.value.slice(0, start)}\n${indent}${message.value.slice(end)}`; message.selectionStart = start + 1 + indent.length; message.selectionEnd = message.selectionStart; message.dispatchEvent(new Event('input', { bubbles:true })); } else if (evt.key == 'Enter' && evt.ctrlKey && !evt.shiftKey && !evt.altKey) { if (executeShortcut.checked) { // execute QR evt.stopImmediatePropagation(); evt.stopPropagation(); evt.preventDefault(); const selectionStart = message.selectionStart; const selectionEnd = message.selectionEnd; message.blur(); await this.executeFromEditor(); if (document.activeElement != message) { message.focus(); message.selectionStart = selectionStart; message.selectionEnd = selectionEnd; } } } else if (evt.key == 'F9' && !evt.ctrlKey && !evt.shiftKey && !evt.altKey) { // toggle breakpoint evt.stopImmediatePropagation(); evt.stopPropagation(); evt.preventDefault(); preBreakPointStart = message.selectionStart; preBreakPointEnd = message.selectionEnd; toggleBreakpoint(); } else if (evt.code == 'Backslash' && evt.ctrlKey && !evt.shiftKey && !evt.altKey) { // toggle comment evt.stopImmediatePropagation(); evt.stopPropagation(); evt.preventDefault(); // check if we are inside a comment -> uncomment const parser = new SlashCommandParser(); parser.parse(message.value, false); const start = message.selectionStart; const end = message.selectionEnd; const comment = parser.commandIndex.findLast(it=>it.name == '*' && (it.start <= start && it.end >= start || it.start <= end && it.end >= end)); if (comment) { let content = message.value.slice(comment.start + 1, comment.end - 1); let len = content.length; content = content.replace(/^ /, ''); const offsetStart = len - content.length; len = content.length; content = content.replace(/ $/, ''); const offsetEnd = len - content.length; message.value = `${message.value.slice(0, comment.start - 1)}${content}${message.value.slice(comment.end + 1)}`; message.selectionStart = start - (start >= comment.start ? 2 + offsetStart : 0); message.selectionEnd = end - 2 - offsetStart - (end >= comment.end ? 2 + offsetEnd : 0); } else { const lineStart = message.value.lastIndexOf('\n', start - 1) + 1; const lineEnd = message.value.indexOf('\n', end); const lines = message.value.slice(lineStart, lineEnd).split('\n'); message.value = `${message.value.slice(0, lineStart)}/* ${message.value.slice(lineStart, lineEnd)} *|${message.value.slice(lineEnd)}`; message.selectionStart = start + 3; message.selectionEnd = end + 3; } message.dispatchEvent(new Event('input', { bubbles:true })); } }); const ac = await setSlashCommandAutoComplete(message, true); message.addEventListener('wheel', (evt)=>{ updateScrollDebounced(evt); }); message.addEventListener('scroll', (evt)=>{ updateScrollDebounced(); }); let preBreakPointStart; let preBreakPointEnd; /** * @param {SlashCommandBreakPoint} bp */ const removeBreakpoint = (bp)=>{ // start at -1 because "/" is not included in start-end let start = bp.start - 1; // step left until forward slash "/" while (message.value[start] != '/') start--; // step left while whitespace (except newline) before start while (/[^\S\n]/.test(message.value[start - 1])) start--; // if newline before indent, include the newline for removal if (message.value[start - 1] == '\n') start--; let end = bp.end; // step right while whitespace while (/\s/.test(message.value[end])) end++; // if pipe after whitepace, include pipe for removal if (message.value[end] == '|') end++; const v = `${message.value.slice(0, start)}${message.value.slice(end)}`; message.value = v; message.dispatchEvent(new Event('input', { bubbles:true })); let postStart = preBreakPointStart; let postEnd = preBreakPointEnd; // set caret back to where it was if (preBreakPointStart <= start) { // do nothing } else if (preBreakPointStart > start && preBreakPointEnd < end) { // selection start was inside breakpoint: move to index before breakpoint postStart = start; } else if (preBreakPointStart >= end) { // selection was behind breakpoint: move back by length of removed string postStart = preBreakPointStart - (end - start); } if (preBreakPointEnd <= start) { // do nothing } else if (preBreakPointEnd > start && preBreakPointEnd < end) { // selection start was inside breakpoint: move to index before breakpoint postEnd = start; } else if (preBreakPointEnd >= end) { // selection was behind breakpoint: move back by length of removed string postEnd = preBreakPointEnd - (end - start); } return { start:postStart, end:postEnd }; }; /** * @param {SlashCommandExecutor} cmd */ const addBreakpoint = (cmd)=>{ // start at -1 because "/" is not included in start-end let start = cmd.start - 1; let indent = ''; // step left until forward slash "/" while (message.value[start] != '/') start--; // step left while whitespace (except newline) before start, collect the whitespace to help build indentation while (/[^\S\n]/.test(message.value[start - 1])) { start--; indent += message.value[start]; } // if newline before indent, include the newline if (message.value[start - 1] == '\n') { start--; indent = `\n${indent}`; } const breakpointText = `${indent}/breakpoint |`; const v = `${message.value.slice(0, start)}${breakpointText}${message.value.slice(start)}`; message.value = v; message.dispatchEvent(new Event('input', { bubbles:true })); return breakpointText.length; }; const toggleBreakpoint = ()=>{ const idx = message.selectionStart; let postStart = preBreakPointStart; let postEnd = preBreakPointEnd; const parser = new SlashCommandParser(); parser.parse(message.value, false); const cmdIdx = parser.commandIndex.findLastIndex(it=>it.start <= idx); if (cmdIdx > -1) { const cmd = parser.commandIndex[cmdIdx]; if (cmd instanceof SlashCommandBreakPoint) { const bp = cmd; const { start, end } = removeBreakpoint(bp); postStart = start; postEnd = end; } else if (parser.commandIndex[cmdIdx - 1] instanceof SlashCommandBreakPoint) { const bp = parser.commandIndex[cmdIdx - 1]; const { start, end } = removeBreakpoint(bp); postStart = start; postEnd = end; } else { const len = addBreakpoint(cmd); postStart += len; postEnd += len; } message.selectionStart = postStart; message.selectionEnd = postEnd; } }; message.addEventListener('pointerdown', (evt)=>{ if (!evt.ctrlKey || !evt.altKey) return; preBreakPointStart = message.selectionStart; preBreakPointEnd = message.selectionEnd; }); message.addEventListener('pointerup', async(evt)=>{ if (!evt.ctrlKey || !evt.altKey || message.selectionStart != message.selectionEnd) return; toggleBreakpoint(); }); /** @type {any} */ const resizeListener = debounce((evt) => { updateSyntax(); updateScrollDebounced(evt); if (document.activeElement == message) { message.blur(); message.focus(); } }); window.addEventListener('resize', resizeListener); updateSyntaxEnabled(); message.style.setProperty('text-shadow', 'none', 'important'); /**@type {HTMLElement}*/ const messageSyntaxInner = dom.querySelector('#qr--modal-messageSyntaxInner'); this.editorSyntax = messageSyntaxInner; updateSyntax(); updateWrap(); updateTabSize(); // 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.toSorted((a,b)=>a.name.toLowerCase().localeCompare(b.name.toLowerCase()))].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 {HTMLInputElement}*/ const executeOnGroupMemberDraft = dom.querySelector('#qr--executeOnGroupMemberDraft'); executeOnGroupMemberDraft.checked = this.executeOnGroupMemberDraft; executeOnGroupMemberDraft.addEventListener('click', ()=>{ this.executeOnGroupMemberDraft = executeOnGroupMemberDraft.checked; this.updateContext(); }); /**@type {HTMLInputElement}*/ const automationId = dom.querySelector('#qr--automationId'); automationId.value = this.automationId; automationId.addEventListener('input', () => { this.automationId = automationId.value; this.updateContext(); }); /**@type {HTMLElement}*/ const executeProgress = dom.querySelector('#qr--modal-executeProgress'); this.editorExecuteProgress = executeProgress; /**@type {HTMLElement}*/ const executeErrors = dom.querySelector('#qr--modal-executeErrors'); this.editorExecuteErrors = executeErrors; /**@type {HTMLElement}*/ const executeResult = dom.querySelector('#qr--modal-executeResult'); this.editorExecuteResult = executeResult; /**@type {HTMLElement}*/ const debugState = dom.querySelector('#qr--modal-debugState'); this.editorDebugState = debugState; /**@type {HTMLElement}*/ const executeBtn = dom.querySelector('#qr--modal-execute'); this.editorExecuteBtn = executeBtn; executeBtn.addEventListener('click', async()=>{ await this.executeFromEditor(); }); /**@type {HTMLElement}*/ const executeBtnPause = dom.querySelector('#qr--modal-pause'); this.editorExecuteBtnPause = executeBtnPause; executeBtnPause.addEventListener('click', async()=>{ if (this.abortController) { if (this.abortController.signal.paused) { this.abortController.continue('Continue button clicked'); this.editorExecuteProgress.classList.remove('qr--paused'); } else { this.abortController.pause('Pause button clicked'); this.editorExecuteProgress.classList.add('qr--paused'); } } }); /**@type {HTMLElement}*/ const executeBtnStop = dom.querySelector('#qr--modal-stop'); this.editorExecuteBtnStop = executeBtnStop; executeBtnStop.addEventListener('click', async()=>{ this.abortController?.abort('Stop button clicked'); }); /**@type {HTMLElement}*/ const resumeBtn = dom.querySelector('#qr--modal-resume'); resumeBtn.addEventListener('click', ()=>{ this.debugController?.resume(); }); /**@type {HTMLElement}*/ const stepBtn = dom.querySelector('#qr--modal-step'); stepBtn.addEventListener('click', ()=>{ this.debugController?.step(); }); /**@type {HTMLElement}*/ const stepIntoBtn = dom.querySelector('#qr--modal-stepInto'); stepIntoBtn.addEventListener('click', ()=>{ this.debugController?.stepInto(); }); /**@type {HTMLElement}*/ const stepOutBtn = dom.querySelector('#qr--modal-stepOut'); stepOutBtn.addEventListener('click', ()=>{ this.debugController?.stepOut(); }); /**@type {HTMLElement}*/ const minimizeBtn = dom.querySelector('#qr--modal-minimize'); minimizeBtn.addEventListener('click', ()=>{ this.editorDom.classList.add('qr--minimized'); }); const maximizeBtn = dom.querySelector('#qr--modal-maximize'); maximizeBtn.addEventListener('click', ()=>{ this.editorDom.classList.remove('qr--minimized'); }); /**@type {boolean}*/ let isResizing = false; let resizeStart; let wStart; /**@type {HTMLElement}*/ const resizeHandle = dom.querySelector('#qr--resizeHandle'); resizeHandle.addEventListener('pointerdown', (evt)=>{ if (isResizing) return; isResizing = true; evt.preventDefault(); resizeStart = evt.x; wStart = dom.querySelector('#qr--qrOptions').offsetWidth; const dragListener = debounce((evt)=>{ const w = wStart + resizeStart - evt.x; dom.querySelector('#qr--qrOptions').style.setProperty('--width', `${w}px`); }, 5); window.addEventListener('pointerup', ()=>{ window.removeEventListener('pointermove', dragListener); isResizing = false; }, { once:true }); window.addEventListener('pointermove', dragListener); }); await popupResult; window.removeEventListener('resize', resizeListener); } else { warn('failed to fetch qrEditor template'); } } getEditorPosition(start, end, message = null) { const inputRect = this.editorMessage.getBoundingClientRect(); const style = window.getComputedStyle(this.editorMessage); if (!this.clone) { this.clone = document.createElement('div'); for (const key of style) { this.clone.style[key] = style[key]; } this.clone.style.position = 'fixed'; this.clone.style.visibility = 'hidden'; const mo = new MutationObserver(muts=>{ if (muts.find(it=>[...it.removedNodes].includes(this.editorMessage) || [...it.removedNodes].find(n=>n.contains(this.editorMessage)))) { this.clone?.remove(); this.clone = null; } }); mo.observe(document.body, { childList:true }); } document.body.append(this.clone); this.clone.style.width = `${inputRect.width}px`; this.clone.style.height = `${inputRect.height}px`; this.clone.style.left = `${inputRect.left}px`; this.clone.style.top = `${inputRect.top}px`; this.clone.style.whiteSpace = style.whiteSpace; this.clone.style.tabSize = style.tabSize; 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.editorSyntax.scrollTop; this.clone.scrollLeft = this.editorSyntax.scrollLeft; const locatorRect = locator.getBoundingClientRect(); const bodyRect = document.body.getBoundingClientRect(); const location = { 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; } async executeFromEditor() { if (this.isExecuting) return; this.editorPopup.onClosing = ()=>false; const uuidCheck = /^[0-9a-z]{8}(-[0-9a-z]{4}){3}-[0-9a-z]{12}$/; const oText = this.message; this.isExecuting = true; this.editorDom.classList.add('qr--isExecuting'); const noSyntax = this.editorDom.querySelector('#qr--modal-messageHolder').classList.contains('qr--noSyntax'); if (noSyntax) { this.editorDom.querySelector('#qr--modal-messageHolder').classList.remove('qr--noSyntax'); } this.editorExecuteBtn.classList.add('qr--busy'); this.editorExecuteProgress.style.setProperty('--prog', '0'); this.editorExecuteErrors.classList.remove('qr--hasErrors'); this.editorExecuteResult.classList.remove('qr--hasResult'); this.editorExecuteProgress.classList.remove('qr--error'); this.editorExecuteProgress.classList.remove('qr--success'); this.editorExecuteProgress.classList.remove('qr--paused'); this.editorExecuteProgress.classList.remove('qr--aborted'); this.editorExecuteErrors.innerHTML = ''; this.editorExecuteResult.innerHTML = ''; const syntax = this.editorDom.querySelector('#qr--modal-messageSyntaxInner'); const updateScroll = (evt) => { let left = syntax.scrollLeft; let top = syntax.scrollTop; if (evt) { evt.preventDefault(); left = syntax.scrollLeft + evt.deltaX; top = syntax.scrollTop + evt.deltaY; syntax.scrollTo({ behavior: 'instant', left, top, }); } this.editorMessage.scrollTo({ behavior: 'instant', left, top, }); }; const updateScrollDebounced = updateScroll; syntax.addEventListener('wheel', (evt)=>{ updateScrollDebounced(evt); }); syntax.addEventListener('scroll', (evt)=>{ updateScrollDebounced(); }); try { this.abortController = new SlashCommandAbortController(); this.debugController = new SlashCommandDebugController(); this.debugController.onBreakPoint = async(closure, executor)=>{ //TODO move debug code into its own element, separate from the QR //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 })); syntax.innerHTML = hljs.highlight(`${closure.fullText}${closure.fullText.slice(-1) == '\n' ? ' ' : ''}`, { language:'stscript', ignoreIllegals:true })?.value; this.editorMessageLabel.innerHTML = ''; if (uuidCheck.test(closure.source)) { const p0 = document.createElement('span'); { p0.textContent = 'anonymous: '; this.editorMessageLabel.append(p0); } const p1 = document.createElement('strong'); { p1.textContent = executor.source.slice(0,5); this.editorMessageLabel.append(p1); } const p2 = document.createElement('span'); { p2.textContent = executor.source.slice(5, -5); this.editorMessageLabel.append(p2); } const p3 = document.createElement('strong'); { p3.textContent = executor.source.slice(-5); this.editorMessageLabel.append(p3); } } else { this.editorMessageLabel.textContent = executor.source; } const source = closure.source; this.editorDebugState.innerHTML = ''; let ci = -1; const varNames = []; const macroNames = []; /** * @param {SlashCommandScope} scope */ const buildVars = (scope, isCurrent = false)=>{ if (!isCurrent) { ci--; } const c = this.debugController.stack.slice(ci)[0]; const wrap = document.createElement('div'); { wrap.classList.add('qr--scope'); if (isCurrent) { const executor = this.debugController.cmdStack.slice(-1)[0]; { // named args const namedTitle = document.createElement('div'); { namedTitle.classList.add('qr--title'); namedTitle.textContent = `Named Args - /${executor.name}`; if (executor.command.name == 'run') { namedTitle.textContent += `${(executor.name == ':' ? '' : ' ')}${executor.unnamedArgumentList[0]?.value}`; } wrap.append(namedTitle); } const keys = new Set([...Object.keys(this.debugController.namedArguments ?? {}), ...(executor.namedArgumentList ?? []).map(it=>it.name)]); for (const key of keys) { if (key[0] == '_') continue; const item = document.createElement('div'); { item.classList.add('qr--var'); const k = document.createElement('div'); { k.classList.add('qr--key'); k.textContent = key; item.append(k); } const vUnresolved = document.createElement('div'); { vUnresolved.classList.add('qr--val'); vUnresolved.classList.add('qr--singleCol'); const val = executor.namedArgumentList.find(it=>it.name == key)?.value; if (val instanceof SlashCommandClosure) { vUnresolved.classList.add('qr--closure'); vUnresolved.title = val.rawText; vUnresolved.textContent = val.toString(); } else if (val === undefined) { vUnresolved.classList.add('qr--undefined'); vUnresolved.textContent = 'undefined'; } else { let jsonVal; try { jsonVal = JSON.parse(val); } catch { /* empty */ } if (jsonVal && typeof jsonVal == 'object') { vUnresolved.textContent = JSON.stringify(jsonVal, null, 2); } else { vUnresolved.textContent = val; vUnresolved.classList.add('qr--simple'); } } item.append(vUnresolved); } const vResolved = document.createElement('div'); { vResolved.classList.add('qr--val'); vResolved.classList.add('qr--singleCol'); if (this.debugController.namedArguments === undefined) { vResolved.classList.add('qr--unresolved'); } else { const val = this.debugController.namedArguments?.[key]; if (val instanceof SlashCommandClosure) { vResolved.classList.add('qr--closure'); vResolved.title = val.rawText; vResolved.textContent = val.toString(); } else if (val === undefined) { vResolved.classList.add('qr--undefined'); vResolved.textContent = 'undefined'; } else { let jsonVal; try { jsonVal = JSON.parse(val); } catch { /* empty */ } if (jsonVal && typeof jsonVal == 'object') { vResolved.textContent = JSON.stringify(jsonVal, null, 2); } else { vResolved.textContent = val; vResolved.classList.add('qr--simple'); } } } item.append(vResolved); } wrap.append(item); } } } { // unnamed args const unnamedTitle = document.createElement('div'); { unnamedTitle.classList.add('qr--title'); unnamedTitle.textContent = `Unnamed Args - /${executor.name}`; if (executor.command.name == 'run') { unnamedTitle.textContent += `${(executor.name == ':' ? '' : ' ')}${executor.unnamedArgumentList[0]?.value}`; } wrap.append(unnamedTitle); } let i = 0; let unnamed = this.debugController.unnamedArguments ?? []; if (!Array.isArray(unnamed)) unnamed = [unnamed]; while (unnamed.length < executor.unnamedArgumentList?.length ?? 0) unnamed.push(undefined); unnamed = unnamed.map((it,idx)=>[executor.unnamedArgumentList?.[idx], it]); for (const arg of unnamed) { i++; const item = document.createElement('div'); { item.classList.add('qr--var'); const k = document.createElement('div'); { k.classList.add('qr--key'); k.textContent = i.toString(); item.append(k); } const vUnresolved = document.createElement('div'); { vUnresolved.classList.add('qr--val'); vUnresolved.classList.add('qr--singleCol'); const val = arg[0]?.value; if (val instanceof SlashCommandClosure) { vUnresolved.classList.add('qr--closure'); vUnresolved.title = val.rawText; vUnresolved.textContent = val.toString(); } else if (val === undefined) { vUnresolved.classList.add('qr--undefined'); vUnresolved.textContent = 'undefined'; } else { let jsonVal; try { jsonVal = JSON.parse(val); } catch { /* empty */ } if (jsonVal && typeof jsonVal == 'object') { vUnresolved.textContent = JSON.stringify(jsonVal, null, 2); } else { vUnresolved.textContent = val; vUnresolved.classList.add('qr--simple'); } } item.append(vUnresolved); } const vResolved = document.createElement('div'); { vResolved.classList.add('qr--val'); vResolved.classList.add('qr--singleCol'); if (this.debugController.unnamedArguments === undefined) { vResolved.classList.add('qr--unresolved'); } else if ((Array.isArray(this.debugController.unnamedArguments) ? this.debugController.unnamedArguments : [this.debugController.unnamedArguments]).length < i) { // do nothing } else { const val = arg[1]; if (val instanceof SlashCommandClosure) { vResolved.classList.add('qr--closure'); vResolved.title = val.rawText; vResolved.textContent = val.toString(); } else if (val === undefined) { vResolved.classList.add('qr--undefined'); vResolved.textContent = 'undefined'; } else { let jsonVal; try { jsonVal = JSON.parse(val); } catch { /* empty */ } if (jsonVal && typeof jsonVal == 'object') { vResolved.textContent = JSON.stringify(jsonVal, null, 2); } else { vResolved.textContent = val; vResolved.classList.add('qr--simple'); } } } item.append(vResolved); } wrap.append(item); } } } } // current scope const title = document.createElement('div'); { title.classList.add('qr--title'); title.textContent = isCurrent ? 'Current Scope' : 'Parent Scope'; 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)) { const isHidden = varNames.includes(key); if (!isHidden) varNames.push(key); const item = document.createElement('div'); { item.classList.add('qr--var'); if (isHidden) item.classList.add('qr--isHidden'); const k = document.createElement('div'); { k.classList.add('qr--key'); k.textContent = key; item.append(k); } const v = document.createElement('div'); { v.classList.add('qr--val'); const val = scope.variables[key]; if (val instanceof SlashCommandClosure) { v.classList.add('qr--closure'); v.title = val.rawText; v.textContent = val.toString(); } else if (val === undefined) { v.classList.add('qr--undefined'); v.textContent = 'undefined'; } else { let jsonVal; try { jsonVal = JSON.parse(val); } catch { /* empty */ } if (jsonVal && typeof jsonVal == 'object') { v.textContent = JSON.stringify(jsonVal, null, 2); } else { v.textContent = val; v.classList.add('qr--simple'); } } item.append(v); } wrap.append(item); } } for (const key of Object.keys(scope.macros)) { const isHidden = macroNames.includes(key); if (!isHidden) macroNames.push(key); const item = document.createElement('div'); { item.classList.add('qr--macro'); if (isHidden) item.classList.add('qr--isHidden'); const k = document.createElement('div'); { k.classList.add('qr--key'); k.textContent = key; item.append(k); } const v = document.createElement('div'); { v.classList.add('qr--val'); const val = scope.macros[key]; if (val instanceof SlashCommandClosure) { v.classList.add('qr--closure'); v.title = val.rawText; v.textContent = val.toString(); } else if (val === undefined) { v.classList.add('qr--undefined'); v.textContent = 'undefined'; } else { let jsonVal; try { jsonVal = JSON.parse(val); } catch { /* empty */ } if (jsonVal && typeof jsonVal == 'object') { v.textContent = JSON.stringify(jsonVal, null, 2); } else { v.textContent = val; v.classList.add('qr--simple'); } } item.append(v); } wrap.append(item); } } const pipeItem = document.createElement('div'); { pipeItem.classList.add('qr--pipe'); const k = document.createElement('div'); { k.classList.add('qr--key'); k.textContent = 'pipe'; pipeItem.append(k); } const v = document.createElement('div'); { v.classList.add('qr--val'); const val = scope.pipe; if (val instanceof SlashCommandClosure) { v.classList.add('qr--closure'); v.title = val.rawText; v.textContent = val.toString(); } else if (val === undefined) { v.classList.add('qr--undefined'); v.textContent = 'undefined'; } else { let jsonVal; try { jsonVal = JSON.parse(val); } catch { /* empty */ } if (jsonVal && typeof jsonVal == 'object') { v.textContent = JSON.stringify(jsonVal, null, 2); } else { v.textContent = val; v.classList.add('qr--simple'); } } pipeItem.append(v); } wrap.append(pipeItem); } if (scope.parent) { wrap.append(buildVars(scope.parent)); } } return wrap; }; const buildStack = ()=>{ const wrap = document.createElement('div'); { wrap.classList.add('qr--stack'); const title = document.createElement('div'); { title.classList.add('qr--title'); 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'); 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; if (uuidCheck.test(executor.source)) { const p1 = document.createElement('span'); { p1.classList.add('qr--fixed'); p1.textContent = executor.source.slice(0,5); src.append(p1); } const p2 = document.createElement('span'); { p2.classList.add('qr--truncated'); p2.textContent = '…'; src.append(p2); } const p3 = document.createElement('span'); { p3.classList.add('qr--fixed'); p3.textContent = `${executor.source.slice(-5)}:${line}`; src.append(p3); } src.title = `anonymous: ${executor.source}`; } else { src.textContent = `${executor.source}:${line}`; } item.append(src); } wrap.append(item); } } } return wrap; }; 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, closure.fullText); const layer = syntax.getBoundingClientRect(); const hi = document.createElement('div'); hi.classList.add('qr--highlight'); if (this.debugController.namedArguments === undefined) { hi.classList.add('qr--unresolved'); } 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); const isStepping = await this.debugController.awaitContinue(); hi.remove(); this.editorDebugState.textContent = ''; this.editorDebugState.classList.remove('qr--active'); return isStepping; }; const result = await this.onDebug(this); if (this.abortController?.signal?.aborted) { this.editorExecuteProgress.classList.add('qr--aborted'); } else { this.editorExecuteResult.textContent = result?.toString(); this.editorExecuteResult.classList.add('qr--hasResult'); this.editorExecuteProgress.classList.add('qr--success'); } this.editorExecuteProgress.classList.remove('qr--paused'); } catch (ex) { this.editorExecuteErrors.classList.add('qr--hasErrors'); this.editorExecuteProgress.classList.add('qr--error'); this.editorExecuteProgress.classList.remove('qr--paused'); if (ex instanceof SlashCommandParserError) { this.editorExecuteErrors.innerHTML = `
${ex.message}
Line: ${ex.line} Column: ${ex.column}
${ex.hint}
`; } else { this.editorExecuteErrors.innerHTML = `
${ex.message}
`; } } if (noSyntax) { this.editorDom.querySelector('#qr--modal-messageHolder').classList.add('qr--noSyntax'); } this.editorMessageLabel.innerHTML = ''; this.editorMessageLabel.textContent = 'Message / Command: '; this.editorMessage.value = oText; this.editorMessage.dispatchEvent(new Event('input', { bubbles:true })); this.editorExecutePromise = null; this.editorExecuteBtn.classList.remove('qr--busy'); this.editorDom.classList.remove('qr--isExecuting'); this.isExecuting = false; this.editorPopup.onClosing = null; } updateEditorProgress(done, total) { this.editorExecuteProgress.style.setProperty('--prog', `${done / total * 100}`); } 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 */ updateIcon(value) { if (this.onUpdate) { if (value === null) return; if (this.settingsDomIcon) { if (this.icon != value) { if (value == '') { if (this.icon) { this.settingsDomIcon.classList.remove(this.icon); } this.settingsDomIcon.textContent = '…'; this.settingsDomIcon.classList.remove('fa-solid'); } else { if (this.icon) { this.settingsDomIcon.classList.remove(this.icon); } else { this.settingsDomIcon.classList.add('fa-solid'); } this.settingsDomIcon.classList.add(value); } } } this.icon = value; this.updateRender(); this.onUpdate(this); } } /** * @param {boolean} value */ updateShowLabel(value) { if (this.onUpdate) { this.showLabel = 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 = {}, isEditor = false, isRun = false, options = {}) { if (this.message?.length > 0 && this.onExecute) { const scope = new SlashCommandScope(); for (const key of Object.keys(args)) { if (key[0] == '_') continue; if (key == 'isAutoExecute') continue; scope.setMacro(`arg::${key}`, args[key]); } scope.setMacro('arg::*', ''); if (isEditor) { this.abortController = new SlashCommandAbortController(); } return await this.onExecute(this, { message: this.message, isAutoExecute: args.isAutoExecute ?? false, isEditor, isRun, scope, executionOptions: options, }); } } toJSON() { return { id: this.id, icon: this.icon, showLabel: this.showLabel, 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, executeOnGroupMemberDraft: this.executeOnGroupMemberDraft, automationId: this.automationId, }; } }