diff --git a/public/scripts/slash-commands.js b/public/scripts/slash-commands.js index 327d12ec5..1ce22a97d 100644 --- a/public/scripts/slash-commands.js +++ b/public/scripts/slash-commands.js @@ -57,7 +57,7 @@ import { SlashCommandScope } from './slash-commands/SlashCommandScope.js'; import { SlashCommandClosure } from './slash-commands/SlashCommandClosure.js'; import { SlashCommandClosureResult } from './slash-commands/SlashCommandClosureResult.js'; import { NAME_RESULT_TYPE, SlashCommandParserNameResult } from './slash-commands/SlashCommandParserNameResult.js'; -import { OPTION_TYPE, SlashCommandAutoCompleteOption } from './slash-commands/SlashCommandAutoCompleteOption.js'; +import { OPTION_TYPE, SlashCommandAutoCompleteOption, SlashCommandFuzzyScore } from './slash-commands/SlashCommandAutoCompleteOption.js'; export { executeSlashCommands, getSlashCommandsHelp, registerSlashCommand, }; @@ -1795,6 +1795,7 @@ export async function setSlashCommandAutoComplete(textarea, isFloating = false) dom.classList.add('slashCommandAutoComplete'); } let isReplacable = false; + /**@type {SlashCommandAutoCompleteOption[]} */ let result = []; let selectedItem = null; let isActive = false; @@ -1804,6 +1805,67 @@ export async function setSlashCommandAutoComplete(textarea, isFloating = false) let clone; let startQuote; let endQuote; + /**@type {Object.} */ + const items = {}; + let hasCache = false; + let selectionStart; + const makeItem = (key, typeIcon, noSlash, helpString = '', aliasList = []) => { + const li = document.createElement('li'); { + li.classList.add('item'); + const type = document.createElement('span'); { + type.classList.add('type'); + type.classList.add('monospace'); + type.textContent = typeIcon; + li.append(type); + } + const name = document.createElement('span'); { + name.classList.add('name'); + name.classList.add('monospace'); + name.textContent = noSlash ? '' : '/'; + key.split('').forEach(char=>{ + const span = document.createElement('span'); { + span.textContent = char; + name.append(span); + } + }); + li.append(name); + } + li.append(' '); + const help = document.createElement('span'); { + help.classList.add('help'); + help.innerHTML = helpString; + li.append(help); + } + if (aliasList.length > 0) { + const aliases = document.createElement('span'); { + aliases.classList.add('aliases'); + aliases.append(' (alias: '); + for (const aliasName of aliasList) { + const alias = document.createElement('span'); { + alias.classList.add('monospace'); + alias.textContent = `/${aliasName}`; + aliases.append(alias); + } + } + aliases.append(')'); + li.append(aliases); + } + } + // gotta listen to pointerdown (happens before textarea-blur) + li.addEventListener('pointerdown', ()=>{ + // gotta catch pointerup to restore focus to textarea (blurs after pointerdown) + pointerup = new Promise(resolve=>{ + const resolver = ()=>{ + window.removeEventListener('pointerup', resolver); + resolve(); + }; + window.addEventListener('pointerup', resolver); + }); + select(); + }); + } + return li; + }; const hide = () => { dom?.remove(); isActive = false; @@ -1816,6 +1878,15 @@ export async function setSlashCommandAutoComplete(textarea, isFloating = false) // only show for slash commands if (text[0] != '/') return hide(); + if (!hasCache) { + hasCache = true; + // init by appending all command options + Object.keys(parser.commands).forEach(key=>{ + const cmd = parser.commands[key]; + items[key] = makeItem(key, '/', false, cmd.helpString, [cmd.name, ...(cmd.aliases ?? [])].filter(it=>it != key)); + }); + } + // request parser to get command executor (potentially "incomplete", i.e. not an actual existing command) for // cursor position parserResult = parser.getNameAt(text, textarea.selectionStart); @@ -1833,13 +1904,16 @@ export async function setSlashCommandAutoComplete(textarea, isFloating = false) } catch { /* empty */ } break; } - default: // no result -> empty slash "/" -> list all commands case NAME_RESULT_TYPE.COMMAND: { parserResult.optionList.push(...Object.keys(parser.commands) .map(key=>new SlashCommandAutoCompleteOption(OPTION_TYPE.COMMAND, parser.commands[key], key)), ); break; } + default: { + // no result + break; + } } let slashCommand = parserResult?.name?.toLowerCase() ?? ''; // do autocomplete if triggered by a user input and we either don't have an executor or the cursor is at the end @@ -1849,14 +1923,14 @@ export async function setSlashCommandAutoComplete(textarea, isFloating = false) isReplacable = isInput && (!parserResult ? true : textarea.selectionStart == parserResult.start - 2 + parserResult.name.length + (startQuote ? 1 : 0)); break; } - default: // no result -> empty slash "/" -> list all commands + default: // no result case NAME_RESULT_TYPE.COMMAND: { isReplacable = isInput && (!parserResult ? true : textarea.selectionStart == parserResult.start - 2 + parserResult.name.length); break; } } - // if forced (ctrl+space) or user input and cursor is in the middle of the name part (not at the end) + // if [forced (ctrl+space) or user input] and cursor is in the middle of the name part (not at the end) if (isForced || isInput) { switch (parserResult?.type) { case NAME_RESULT_TYPE.CLOSURE: { @@ -1867,7 +1941,6 @@ export async function setSlashCommandAutoComplete(textarea, isFloating = false) } break; } - default: // no result -> empty slash "/" -> list all commands case NAME_RESULT_TYPE.COMMAND: { if (textarea.selectionStart >= parserResult.start - 2 && textarea.selectionStart <= parserResult.start - 2 + parserResult.name.length) { slashCommand = slashCommand.slice(0, textarea.selectionStart - (parserResult.start - 2)); @@ -1876,6 +1949,10 @@ export async function setSlashCommandAutoComplete(textarea, isFloating = false) } break; } + default: { + // no result + break; + } } } @@ -1886,8 +1963,13 @@ export async function setSlashCommandAutoComplete(textarea, isFloating = false) 'includes': (name) => name.toLowerCase().includes(slashCommand), 'fuzzy': (name) => fuzzyRegex.test(name), }; - const fuzzyScore = (name) => { - const parts = fuzzyRegex.exec(name).slice(1, -1); + /** + * + * @param {SlashCommandAutoCompleteOption} option + * @returns + */ + const fuzzyScore = (option) => { + const parts = fuzzyRegex.exec(option.name).slice(1, -1); let start = null; let consecutive = []; let current = ''; @@ -1912,8 +1994,13 @@ export async function setSlashCommandAutoComplete(textarea, isFloating = false) consecutive.push(current); } consecutive.sort((a,b)=>b.length - a.length); - return { name, start, longestConsecutive:consecutive[0]?.length ?? 0 }; + option.score = new SlashCommandFuzzyScore(start, consecutive[0]?.length ?? 0); + return option; }; + /** + * @param {SlashCommandAutoCompleteOption} a + * @param {SlashCommandAutoCompleteOption} b + */ const fuzzyScoreCompare = (a, b) => { if (a.score.start < b.score.start) return -1; if (a.score.start > b.score.start) return 1; @@ -1921,81 +2008,90 @@ export async function setSlashCommandAutoComplete(textarea, isFloating = false) if (a.score.longestConsecutive < b.score.longestConsecutive) return 1; return a.name.localeCompare(b.name); }; - const buildHelpStringName = (name, noSlash=false) => { + /** + * + * @param {SlashCommandAutoCompleteOption} item + */ + const updateName = (item) => { + const chars = Array.from(item.dom.querySelector('.name').children); switch (matchType) { case 'strict': { - return `${noSlash?'':'/'}${name.slice(0, slashCommand.length)}${name.slice(slashCommand.length)} `; + chars.forEach((it, idx)=>{ + if (idx < item.name.length) { + it.classList.add('matched'); + } else { + it.classList.remove('matched'); + } + }); + break; } case 'includes': { - const start = name.toLowerCase().search(slashCommand); - return `${noSlash?'':'/'}${name.slice(0, start)}${name.slice(start, start + slashCommand.length)}${name.slice(start + slashCommand.length)} `; + const start = item.name.toLowerCase().search(slashCommand); + chars.forEach((it, idx)=>{ + if (idx < start) { + it.classList.remove('matched'); + } else if (idx < start + item.name.length) { + it.classList.add('matched'); + } else { + it.classList.remove('matched'); + } + }); + break; } case 'fuzzy': { - const matched = name.replace(fuzzyRegex, (_, ...parts)=>{ + item.name.replace(fuzzyRegex, (_, ...parts)=>{ parts.splice(-2, 2); if (parts.length == 2) { - return parts.join(''); + chars.forEach(c=>c.classList.remove('matched')); + } else { + let cIdx = 0; + parts.forEach((it, idx)=>{ + if (it === null || it.length == 0) return ''; + if (idx % 2 == 1) { + chars.slice(cIdx, cIdx + it.length).forEach(c=>c.classList.add('matched')); + } else { + chars.slice(cIdx, cIdx + it.length).forEach(c=>c.classList.remove('matched')); + } + cIdx += it.length; + }); } - return parts.map((it, idx)=>{ - if (it === null || it.length == 0) return ''; - if (idx % 2 == 1) { - return `${it}`; - } - return it; - }).join(''); + return ''; }); - return `${noSlash?'':'/'}${matched} `; } } + return item; }; // don't show if no executor found, i.e. cursor's area is not a command if (!parserResult) return hide(); else { - const matchingOptions = parserResult.optionList + let matchingOptions = parserResult.optionList .filter(it => isReplacable || it.name == '' ? matchers[matchType](it.name) : it.name.toLowerCase() == slashCommand) // Filter by the input + .filter((it,idx,list) => list.indexOf(it) == idx) ; result = matchingOptions .filter((it,idx) => matchingOptions.indexOf(it) == idx) .map(option => { - let typeIcon = ''; - let noSlash = false; - let helpString = ''; - let aliases = ''; + let li; switch (option.type) { case OPTION_TYPE.QUICK_REPLY: { - typeIcon = 'QR'; - noSlash = true; + li = makeItem(option.name, 'QR', true); break; } case OPTION_TYPE.VARIABLE_NAME: { - typeIcon = '𝑥'; - noSlash = true; + li = makeItem(option.name, '𝑥', true); break; } case OPTION_TYPE.COMMAND: { - typeIcon = '/'; - noSlash = false; - helpString = option.value.helpString; - if (option.value.aliases.length > 0) { - aliases = ' (alias: '; - aliases += [option.value.name, ...option.value.aliases] - .filter(it=>it != option) - .map(it=>`/${it}`) - .join(', ') - ; - aliases += ')'; - } + li = items[option.name]; break; } } - return { - name: option.name, - label: `${typeIcon} ${buildHelpStringName(option.name, noSlash)}${helpString}${aliases}`, - value: option.name.includes(' ') || startQuote || endQuote ? `"${option.name}"` : `${option.name}`, - score: matchType == 'fuzzy' ? fuzzyScore(option.name) : null, - li: null, - }; + option.replacer = option.name.includes(' ') || startQuote || endQuote ? `"${option.name}"` : `${option.name}`; + option.dom = li; + if (matchType == 'fuzzy') fuzzyScore(option); + updateName(option); + return option; }) // Map to the help string and score .toSorted(matchType == 'fuzzy' ? fuzzyScoreCompare : (a, b) => a.name.localeCompare(b.name)) // sort by score (if fuzzy) or name ; @@ -2007,26 +2103,30 @@ export async function setSlashCommandAutoComplete(textarea, isFloating = false) return hide(); } // otherwise add "no match" notice - switch (parserResult.type) { + switch (parserResult?.type) { case NAME_RESULT_TYPE.CLOSURE: { + const li = document.createElement('li'); { + li.textContent = slashCommand.length ? + `No matching variables in scope and no matching Quick Replies for "${slashCommand}"` + : 'No variables in scope and no Quick Replies found.'; + } result.push({ name: '', - label: slashCommand.length ? - `No matching variables in scope and no matching Quick Replies for "${slashCommand}"` - : 'No variables in scope and no Quick Replies found.', value: null, score: null, - li: null, + li, }); break; } case NAME_RESULT_TYPE.COMMAND: { + const li = document.createElement('li'); { + li.textContent = `No matching commands for "/${slashCommand}"`; + } result.push({ name: '', - label: `No matching commands for "/${slashCommand}"`, value: null, score: null, - li: null, + li, }); break; } @@ -2037,7 +2137,11 @@ export async function setSlashCommandAutoComplete(textarea, isFloating = false) } else if (!isReplacable && result.length > 1) { return hide(); } - + selectedItem = result[0]; + isActive = true; + renderDebounced(); + }; + const render = ()=>{ // render autocomplete list dom.innerHTML = ''; dom.classList.remove('defaultDark'); @@ -2058,35 +2162,21 @@ export async function setSlashCommandAutoComplete(textarea, isFloating = false) break; } } + const frag = document.createDocumentFragment(); for (const item of result) { - const li = document.createElement('li'); { - li.classList.add('item'); - if (item == result[0]) { - li.classList.add('selected'); - } - li.innerHTML = item.label; - // gotta listen to pointerdown (happens before textarea-blur) - li.addEventListener('pointerdown', ()=>{ - // gotta catch pointerup to restore focus to textarea (blurs after pointerdown) - pointerup = new Promise(resolve=>{ - const resolver = ()=>{ - window.removeEventListener('pointerup', resolver); - resolve(); - }; - window.addEventListener('pointerup', resolver); - }); - selectedItem = item; - select(); - }); - item.li = li; - dom.append(li); + if (item == selectedItem) { + item.dom.classList.add('selected'); + } else { + item.dom.classList.remove('selected'); } + frag.append(item.dom); } - selectedItem = result[0]; + dom.append(frag); updatePosition(); document.body.append(dom); - isActive = true; + // prevType = parserResult.type; }; + const renderDebounced = debounce(render, 100); const updatePosition = () => { if (isFloating) { updateFloatingPosition(); @@ -2127,17 +2217,28 @@ export async function setSlashCommandAutoComplete(textarea, isFloating = false) */ const getCursorPosition = () => { const inputRect = textarea.getBoundingClientRect(); - clone?.remove(); - clone = document.createElement('div'); + // clone?.remove(); const style = window.getComputedStyle(textarea); - for (const key of style) { - clone.style[key] = style[key]; + if (!clone) { + clone = document.createElement('div'); + for (const key of style) { + clone.style[key] = style[key]; + } + clone.style.position = 'fixed'; + clone.style.visibility = 'hidden'; + document.body.append(clone); + const mo = new MutationObserver(muts=>{ + if (muts.find(it=>Array.from(it.removedNodes).includes(textarea))) { + clone.remove(); + } + }); + mo.observe(textarea.parentElement, { childList:true }); } clone.style.height = `${inputRect.height}px`; clone.style.left = `${inputRect.left}px`; clone.style.top = `${inputRect.top}px`; - clone.style.position = 'fixed'; - clone.style.visibility = 'hidden'; + clone.style.whiteSpace = style.whiteSpace; + clone.style.tabSize = style.tabSize; const text = textarea.value; const before = text.slice(0, textarea.selectionStart); clone.textContent = before; @@ -2145,7 +2246,6 @@ export async function setSlashCommandAutoComplete(textarea, isFloating = false) locator.textContent = text[textarea.selectionStart]; clone.append(locator); clone.append(text.slice(textarea.selectionStart + 1)); - document.body.append(clone); clone.scrollTop = textarea.scrollTop; clone.scrollLeft = textarea.scrollLeft; const locatorRect = locator.getBoundingClientRect(); @@ -2154,24 +2254,25 @@ export async function setSlashCommandAutoComplete(textarea, isFloating = false) top: locatorRect.top, bottom: locatorRect.bottom, }; - clone.remove(); + // clone.remove(); return location; }; let pointerup = Promise.resolve(); const select = async() => { if (isReplacable && selectedItem.value !== null) { - textarea.value = `${text.slice(0, parserResult.start - 2)}${selectedItem.value}${text.slice(parserResult.start - 2 + parserResult.name.length + (startQuote ? 1 : 0) + (endQuote ? 1 : 0))}`; + textarea.value = `${text.slice(0, parserResult.start - 2)}${selectedItem.replacer}${text.slice(parserResult.start - 2 + parserResult.name.length + (startQuote ? 1 : 0) + (endQuote ? 1 : 0))}`; await pointerup; textarea.focus(); - textarea.selectionStart = parserResult.start - 2 + selectedItem.value.length; + textarea.selectionStart = parserResult.start - 2 + selectedItem.replacer.length; textarea.selectionEnd = textarea.selectionStart; show(); } }; - const showAutoCompleteDebounced = debounce(show, 100); + const showAutoCompleteDebounced = show; textarea.addEventListener('input', ()=>showAutoCompleteDebounced(true)); textarea.addEventListener('click', ()=>showAutoCompleteDebounced()); - textarea.addEventListener('keydown', (evt)=>{ + textarea.addEventListener('selectionchange', ()=>showAutoCompleteDebounced()); + textarea.addEventListener('keydown', async(evt)=>{ // autocomplete is shown and cursor at end of current command name (or inside name and typed or forced) if (isActive && isReplacable) { switch (evt.key) { @@ -2184,13 +2285,13 @@ export async function setSlashCommandAutoComplete(textarea, isFloating = false) let newIdx; if (idx == 0) newIdx = result.length - 1; else newIdx = idx - 1; - selectedItem.li.classList.remove('selected'); + selectedItem.dom.classList.remove('selected'); selectedItem = result[newIdx]; - selectedItem.li.classList.add('selected'); - const rect = selectedItem.li.getBoundingClientRect(); + selectedItem.dom.classList.add('selected'); + const rect = selectedItem.dom.getBoundingClientRect(); const rectParent = dom.getBoundingClientRect(); if (rect.top < rectParent.top || rect.bottom > rectParent.bottom ) { - selectedItem.li.scrollIntoView(); + selectedItem.dom.scrollIntoView(); } return; } @@ -2201,13 +2302,13 @@ export async function setSlashCommandAutoComplete(textarea, isFloating = false) evt.stopPropagation(); const idx = result.indexOf(selectedItem); const newIdx = (idx + 1) % result.length; - selectedItem.li.classList.remove('selected'); + selectedItem.dom.classList.remove('selected'); selectedItem = result[newIdx]; - selectedItem.li.classList.add('selected'); - const rect = selectedItem.li.getBoundingClientRect(); + selectedItem.dom.classList.add('selected'); + const rect = selectedItem.dom.getBoundingClientRect(); const rectParent = dom.getBoundingClientRect(); if (rect.top < rectParent.top || rect.bottom > rectParent.bottom ) { - selectedItem.li.scrollIntoView(); + selectedItem.dom.scrollIntoView(); } return; } @@ -2258,9 +2359,29 @@ export async function setSlashCommandAutoComplete(textarea, isFloating = false) // ignore keydown on modifier keys return; } - showAutoCompleteDebounced(); + switch (evt.key) { + default: + case 'ArrowUp': + case 'ArrowDown': + case 'ArrowRight': + case 'ArrowLeft': { + // keyboard navigation, wait for keyup to complete cursor move + const oldText = textarea.value; + await new Promise(resolve=>{ + const keyUpListener = ()=>{ + window.removeEventListener('keyup', keyUpListener); + resolve(); + }; + window.addEventListener('keyup', keyUpListener); + }); + if (selectionStart != textarea.selectionStart) { + selectionStart = textarea.selectionStart; + showAutoCompleteDebounced(oldText != textarea.value); + } + } + } }); - textarea.addEventListener('blur', ()=>hide()); + // textarea.addEventListener('blur', ()=>hide()); if (isFloating) { textarea.addEventListener('scroll', debounce(updateFloatingPosition, 100)); } diff --git a/public/scripts/slash-commands/SlashCommandAutoCompleteOption.js b/public/scripts/slash-commands/SlashCommandAutoCompleteOption.js index 2d21bc9d6..4982f57f7 100644 --- a/public/scripts/slash-commands/SlashCommandAutoCompleteOption.js +++ b/public/scripts/slash-commands/SlashCommandAutoCompleteOption.js @@ -10,11 +10,28 @@ export const OPTION_TYPE = { 'VARIABLE_NAME': 3, }; +export class SlashCommandFuzzyScore { + /**@type {number}*/ start; + /**@type {number}*/ longestConsecutive; + + /** + * @param {number} start + * @param {number} longestConsecutive + */ + constructor(start, longestConsecutive) { + this.start = start; + this.longestConsecutive = longestConsecutive; + } +} + export class SlashCommandAutoCompleteOption { /**@type {OPTION_TYPE}*/ type; /**@type {string|SlashCommand}*/ value; /**@type {string}*/ name; + /**@type {SlashCommandFuzzyScore}*/ score; + /**@type {string}*/ replacer; + /**@type {HTMLElement}*/ dom; /**