diff --git a/public/scripts/extensions/quick-reply/src/QuickReply.js b/public/scripts/extensions/quick-reply/src/QuickReply.js index fd3e71810..e71fc9c71 100644 --- a/public/scripts/extensions/quick-reply/src/QuickReply.js +++ b/public/scripts/extensions/quick-reply/src/QuickReply.js @@ -1,5 +1,5 @@ import { callPopup } from '../../../../script.js'; -import { setNewSlashCommandAutoComplete } from '../../../slash-commands.js'; +import { setSlashCommandAutoComplete } from '../../../slash-commands.js'; import { getSortableDelay } from '../../../utils.js'; import { log, warn } from '../index.js'; import { QuickReplyContextLink } from './QuickReplyContextLink.js'; @@ -229,7 +229,7 @@ export class QuickReply { message.addEventListener('input', () => { this.updateMessage(message.value); }); - setNewSlashCommandAutoComplete(message, true); + setSlashCommandAutoComplete(message, true); //TODO move tab support for textarea into its own helper(?) and use for both this and .editor_maximize message.addEventListener('keydown', (evt) => { if (evt.key == 'Tab' && !evt.shiftKey && !evt.ctrlKey && !evt.altKey) { diff --git a/public/scripts/slash-commands.js b/public/scripts/slash-commands.js index b635c6291..1a8a331d8 100644 --- a/public/scripts/slash-commands.js +++ b/public/scripts/slash-commands.js @@ -54,6 +54,7 @@ import { debounce, delay, escapeRegex, isFalseBoolean, isTrueBoolean, stringToRa import { registerVariableCommands, resolveVariable } from './variables.js'; import { SlashCommandScope } from './slash-commands/SlashCommandScope.js'; import { SlashCommandClosure } from './slash-commands/SlashCommandClosure.js'; +import { SlashCommandClosureResult } from './slash-commands/SlashCommandClosureResult.js'; export { executeSlashCommands, getSlashCommandsHelp, registerSlashCommand, }; @@ -1749,12 +1750,13 @@ function modelCallback(_, model) { /** * Executes slash commands in the provided text * @param {string} text Slash command text - * @param {boolean} unescape Whether to unescape the batch separator - * @returns {Promise<{interrupt: boolean, newText: string, pipe: string} | boolean>} + * @param {boolean} handleParserErrors Whether to handle parser errors (show toast on error) or throw + * @param {SlashCommandScope} scope The scope to be used when executing the commands. + * @returns {Promise} */ -async function executeSlashCommands(text, unescape = false, handleParserErrors = true, scope = null) { +async function executeSlashCommands(text, handleParserErrors = true, scope = null) { if (!text) { - return false; + return null; } let closure; @@ -1781,218 +1783,14 @@ async function executeSlashCommands(text, unescape = false, handleParserErrors = } return await closure.execute(); - - // Unescape the pipe character and macro braces - if (unescape) { - text = text.replace(/\\\|/g, '|'); - text = text.replace(/\\\{/g, '{'); - text = text.replace(/\\\}/g, '}'); - } - - // Hack to allow multi-line slash commands - // All slash command messages should begin with a slash - const placeholder = '\u200B'; // Use a zero-width space as a placeholder - const chars = text.split(''); - for (let i = 1; i < chars.length; i++) { - if (chars[i] === '|' && chars[i - 1] !== '\\') { - chars[i] = placeholder; - } - } - const lines = chars.join('').split(placeholder).map(line => line.trim()); - const linesToRemove = []; - - let interrupt = false; - let pipeResult = ''; - - for (let index = 0; index < lines.length; index++) { - const trimmedLine = lines[index].trim(); - - if (!trimmedLine.startsWith('/')) { - continue; - } - - const result = parser.parse(trimmedLine); - - if (!result) { - continue; - } - - // Skip comment commands. They don't run macros or interrupt pipes. - if (SlashCommandParser.COMMENT_KEYWORDS.includes(result.command)) { - continue; - } - - if (result.value && typeof result.value === 'string') { - result.value = substituteParams(result.value.trim()); - } - - console.debug('Slash command executing:', result); - let unnamedArg = result.value || pipeResult; - - if (typeof result.args === 'object') { - for (let [key, value] of Object.entries(result.args)) { - if (typeof value === 'string') { - value = substituteParams(value.trim()); - - if (/{{pipe}}/i.test(value)) { - value = value.replace(/{{pipe}}/i, pipeResult ?? ''); - } - - result.args[key] = value; - } - } - } - - if (typeof unnamedArg === 'string') { - if (/{{pipe}}/i.test(unnamedArg)) { - unnamedArg = unnamedArg.replace(/{{pipe}}/i, pipeResult ?? ''); - } - - unnamedArg = unnamedArg - ?.replace(/\\\|/g, '|') - ?.replace(/\\\{/g, '{') - ?.replace(/\\\}/g, '}'); - } - - for (const [key, value] of Object.entries(result.args)) { - if (typeof value === 'string') { - result.args[key] = value - .replace(/\\\|/g, '|') - .replace(/\\\{/g, '{') - .replace(/\\\}/g, '}'); - } - } - - pipeResult = await result.command.callback(result.args, unnamedArg); - - if (result.command.interruptsGeneration) { - interrupt = true; - } - - if (result.command.purgeFromMessage) { - linesToRemove.push(lines[index]); - } - } - - const newText = lines.filter(x => linesToRemove.indexOf(x) === -1).join('\n'); - - return { interrupt, newText, pipe: pipeResult }; } -function setSlashCommandAutocomplete(textarea) { - /**@type {Number}*/ - let width; - /**@type {HTMLTextAreaElement}*/ - let element; - /**@type {String}*/ - let text; - /**@type {SlashCommandExecutor}*/ - let executor; - /**@type {Boolean}*/ - let isReplacable; - textarea[0].addEventListener('keyup', ()=>isReplacable ? null : textarea.autocomplete('search')); - textarea.autocomplete({ - source: (input, output) => { - // Only show for slash commands - if (!input.term.startsWith('/')) { - output([]); - return; - } - - element = textarea[0]; - text = input.term; - executor = parser.getCommandAt(text, element.selectionStart); - const slashCommand = executor?.name?.toLowerCase() ?? ''; - isReplacable = !executor ? true : element.selectionStart + 1 == executor.start + executor.name.length; - - window.parser = parser; - const helpStrings = Object - .keys(parser.commands) // Get all slash commands - .filter(x => x.startsWith(slashCommand)) // Filter by the input - .sort((a, b) => a.localeCompare(b)) // Sort alphabetically - ; - const result = helpStrings - .filter((it,idx)=>helpStrings.indexOf(it) == idx) // remove duplicates - .map(x => ({ label: parser.commands[x].helpStringFormatted, value: `/${x} ` })) // Map to the help string - ; - - // add notice if no match found - if (result.length == 0) { - result.push({ label:`No matching commands for "/${slashCommand}"`, value:'' }); - } - console.log(result); - - // determine textarea width *once* before generating output - width = element.getBoundingClientRect().width; - output(result); // Return the results - }, - select: (e, u) => { - e.preventDefault(); - // only update value if no space after command name - if (isReplacable) { - element.value = `${text.slice(0, executor.start - 2)}${u.item.value}${text.slice(executor.start + executor.name.length)}`; - element.selectionStart = executor.start + u.item.value.length - 2; - element.selectionEnd = element.selectionStart; - } else { - console.log('[AUTOCOMPLETE]', '[SELECT]', { e, u }); - } - }, - focus: (e, u) => { - e.preventDefault(); - // only update value if no space after command name - if (isReplacable) { - element.value = `${text.slice(0, executor.start - 2)}${u.item.value}${text.slice(executor.start + executor.name.length)}`; - element.selectionStart = executor.start + u.item.value.length - 2; - element.selectionEnd = element.selectionStart; - } else { - switch (e.key) { - case 'ArrowUp': { - const line = text.slice(0, element.selectionStart).replace(/[^\n]/g, '').length; - if (line == 0) { - element.selectionStart = 0; - } else { - const lines = text.slice(0, element.selectionStart).split('\n'); - console.log(lines.slice(-2)[0]); - element.selectionStart -= Math.max(lines.slice(-1)[0].length + 1, lines.slice(-2)[0].length + 1); - } - element.selectionEnd = element.selectionStart; - break; - } - case 'ArrowDown': { - const line = text.slice(0, element.selectionStart).replace(/[^\n]/g, '').length; - const lines = text.split('\n'); - if (line + 1 == lines.length) { - element.selectionStart = text.length; - } else { - element.selectionStart += lines[line].length + 1; - } - element.selectionEnd = element.selectionStart; - break; - } - } - } - }, - minLength: 1, - position: { my: 'left bottom', at: 'left top', collision: 'none' }, - }); - - textarea.autocomplete('instance')._renderItem = function (ul, item) { - const li = document.createElement('li'); { - li.style.width = `${width}px`; - const div = document.createElement('div'); { - div.innerHTML = item.label; - li.append(div); - } - ul.append(li); - } - return $(li); - }; -} /** * - * @param {HTMLTextAreaElement} textarea + * @param {HTMLTextAreaElement} textarea The textarea to receive autocomplete + * @param {Boolean} isFloating Whether to show the auto complete as a floating window (e.g., large QR editor) */ -export function setNewSlashCommandAutoComplete(textarea, isFloating = false) { +export function setSlashCommandAutoComplete(textarea, isFloating = false) { const dom = document.createElement('ul'); { dom.classList.add('slashCommandAutoComplete'); dom.classList.add('defaultThemed'); @@ -2004,7 +1802,6 @@ export function setNewSlashCommandAutoComplete(textarea, isFloating = false) { let text; let executor; let clone; - let hasFocus = false; const hide = () => { dom?.remove(); isActive = false; @@ -2337,5 +2134,5 @@ export function setNewSlashCommandAutoComplete(textarea, isFloating = false) { jQuery(function () { const textarea = $('#send_textarea'); // setSlashCommandAutocomplete(textarea); - setNewSlashCommandAutoComplete(document.querySelector('#send_textarea')); + setSlashCommandAutoComplete(document.querySelector('#send_textarea')); }); diff --git a/public/scripts/variables.js b/public/scripts/variables.js index 21550714e..cc56358ff 100644 --- a/public/scripts/variables.js +++ b/public/scripts/variables.js @@ -518,8 +518,7 @@ async function executeSubCommands(command, scope = null) { command = command.slice(0, -1); } - const unescape = false; - const result = await executeSlashCommands(command, unescape, true, scope); + const result = await executeSlashCommands(command, true, scope); if (!result || typeof result !== 'object') { return '';