improve performance

This commit is contained in:
LenAnderson 2024-04-16 16:44:48 -04:00
parent b36740d3a2
commit 001b22bec0
2 changed files with 241 additions and 103 deletions

View File

@ -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.<string,HTMLElement>} */
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 `<span class="monospace">${noSlash?'':'/'}<span class="matched">${name.slice(0, slashCommand.length)}</span>${name.slice(slashCommand.length)}</span> `;
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 `<span class="monospace">${noSlash?'':'/'}${name.slice(0, start)}<span class="matched">${name.slice(start, start + slashCommand.length)}</span>${name.slice(start + slashCommand.length)}</span> `;
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 `<span class="matched">${it}</span>`;
}
return it;
}).join('');
return '';
});
return `<span class="monospace">${noSlash?'':'/'}${matched}</span> `;
}
}
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=>`<span class="monospace">/${it}</span>`)
.join(', ')
;
aliases += ')';
}
li = items[option.name];
break;
}
}
return {
name: option.name,
label: `<span class="type monospace">${typeIcon}</span> ${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));
}

View File

@ -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;
/**