mirror of
https://github.com/SillyTavern/SillyTavern.git
synced 2025-06-05 21:59:27 +02:00
move autocomplete into class and floating with details
This commit is contained in:
@ -59,6 +59,7 @@ import { SlashCommandClosureResult } from './slash-commands/SlashCommandClosureR
|
|||||||
import { NAME_RESULT_TYPE, SlashCommandParserNameResult } from './slash-commands/SlashCommandParserNameResult.js';
|
import { NAME_RESULT_TYPE, SlashCommandParserNameResult } from './slash-commands/SlashCommandParserNameResult.js';
|
||||||
import { OPTION_TYPE, SlashCommandAutoCompleteOption, SlashCommandFuzzyScore } from './slash-commands/SlashCommandAutoCompleteOption.js';
|
import { OPTION_TYPE, SlashCommandAutoCompleteOption, SlashCommandFuzzyScore } from './slash-commands/SlashCommandAutoCompleteOption.js';
|
||||||
import { SlashCommandArgument, SlashCommandNamedArgument } from './slash-commands/SlashCommandArgument.js';
|
import { SlashCommandArgument, SlashCommandNamedArgument } from './slash-commands/SlashCommandArgument.js';
|
||||||
|
import { SlashCommandAutoComplete } from './slash-commands/SlashCommandAutoComplete.js';
|
||||||
export {
|
export {
|
||||||
executeSlashCommands, getSlashCommandsHelp, registerSlashCommand,
|
executeSlashCommands, getSlashCommandsHelp, registerSlashCommand,
|
||||||
};
|
};
|
||||||
@ -1652,790 +1653,7 @@ async function executeSlashCommands(text, handleParserErrors = true, scope = nul
|
|||||||
* @param {Boolean} isFloating Whether to show the auto complete as a floating window (e.g., large QR editor)
|
* @param {Boolean} isFloating Whether to show the auto complete as a floating window (e.g., large QR editor)
|
||||||
*/
|
*/
|
||||||
export async function setSlashCommandAutoComplete(textarea, isFloating = false) {
|
export async function setSlashCommandAutoComplete(textarea, isFloating = false) {
|
||||||
const dom = document.createElement('ul'); {
|
const ac = new SlashCommandAutoComplete(textarea, isFloating);
|
||||||
dom.classList.add('slashCommandAutoComplete');
|
|
||||||
}
|
|
||||||
const detailsWrap = document.createElement('div'); {
|
|
||||||
detailsWrap.classList.add('slashCommandAutoComplete-detailsWrap');
|
|
||||||
}
|
|
||||||
const detailsDom = document.createElement('div'); {
|
|
||||||
detailsDom.classList.add('slashCommandAutoComplete-details');
|
|
||||||
detailsWrap.append(detailsDom);
|
|
||||||
}
|
|
||||||
let isReplacable = false;
|
|
||||||
/**@type {SlashCommandAutoCompleteOption[]} */
|
|
||||||
let result = [];
|
|
||||||
/**@type {SlashCommandAutoCompleteOption} */
|
|
||||||
let selectedItem = null;
|
|
||||||
let isActive = false;
|
|
||||||
let isShowingDetails = false;
|
|
||||||
let text;
|
|
||||||
/**@type {SlashCommandParserNameResult}*/
|
|
||||||
let parserResult;
|
|
||||||
let clone;
|
|
||||||
let startQuote;
|
|
||||||
let endQuote;
|
|
||||||
/**@type {Object.<string,HTMLElement>} */
|
|
||||||
const items = {};
|
|
||||||
let hasCache = false;
|
|
||||||
let selectionStart;
|
|
||||||
/**
|
|
||||||
*
|
|
||||||
* @param {string} key
|
|
||||||
* @param {string} typeIcon
|
|
||||||
* @param {boolean} noSlash
|
|
||||||
* @param {SlashCommandNamedArgument[]} namedArguments
|
|
||||||
* @param {SlashCommandArgument[]} unnamedArguments
|
|
||||||
* @param {string} helpString
|
|
||||||
* @param {string[]} aliasList
|
|
||||||
* @returns
|
|
||||||
*/
|
|
||||||
const makeItem = (key, typeIcon, noSlash, namedArguments = [], unnamedArguments = [], returnType = 'void', 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 specs = document.createElement('span'); {
|
|
||||||
specs.classList.add('specs');
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
specs.append(name);
|
|
||||||
}
|
|
||||||
const body = document.createElement('span'); {
|
|
||||||
body.classList.add('body');
|
|
||||||
const args = document.createElement('span'); {
|
|
||||||
args.classList.add('arguments');
|
|
||||||
for (const arg of namedArguments) {
|
|
||||||
const argItem = document.createElement('span'); {
|
|
||||||
argItem.classList.add('argument');
|
|
||||||
argItem.classList.add('namedArgument');
|
|
||||||
if (!arg.isRequired || (arg.defaultValue ?? false)) argItem.classList.add('optional');
|
|
||||||
if (arg.acceptsMultiple) argItem.classList.add('multiple');
|
|
||||||
const name = document.createElement('span'); {
|
|
||||||
name.classList.add('argument-name');
|
|
||||||
name.textContent = arg.name;
|
|
||||||
argItem.append(name);
|
|
||||||
}
|
|
||||||
if (arg.enumList.length > 0) {
|
|
||||||
const enums = document.createElement('span'); {
|
|
||||||
enums.classList.add('argument-enums');
|
|
||||||
for (const e of arg.enumList) {
|
|
||||||
const enumItem = document.createElement('span'); {
|
|
||||||
enumItem.classList.add('argument-enum');
|
|
||||||
enumItem.textContent = e;
|
|
||||||
enums.append(enumItem);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
argItem.append(enums);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const types = document.createElement('span'); {
|
|
||||||
types.classList.add('argument-types');
|
|
||||||
for (const t of arg.typeList) {
|
|
||||||
const type = document.createElement('span'); {
|
|
||||||
type.classList.add('argument-type');
|
|
||||||
type.textContent = t;
|
|
||||||
types.append(type);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
argItem.append(types);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
args.append(argItem);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const arg of unnamedArguments) {
|
|
||||||
const argItem = document.createElement('span'); {
|
|
||||||
argItem.classList.add('argument');
|
|
||||||
argItem.classList.add('unnamedArgument');
|
|
||||||
if (!arg.isRequired || (arg.defaultValue ?? false)) argItem.classList.add('optional');
|
|
||||||
if (arg.acceptsMultiple) argItem.classList.add('multiple');
|
|
||||||
if (arg.enumList.length > 0) {
|
|
||||||
const enums = document.createElement('span'); {
|
|
||||||
enums.classList.add('argument-enums');
|
|
||||||
for (const e of arg.enumList) {
|
|
||||||
const enumItem = document.createElement('span'); {
|
|
||||||
enumItem.classList.add('argument-enum');
|
|
||||||
enumItem.textContent = e;
|
|
||||||
enums.append(enumItem);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
argItem.append(enums);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const types = document.createElement('span'); {
|
|
||||||
types.classList.add('argument-types');
|
|
||||||
for (const t of arg.typeList) {
|
|
||||||
const type = document.createElement('span'); {
|
|
||||||
type.classList.add('argument-type');
|
|
||||||
type.textContent = t;
|
|
||||||
types.append(type);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
argItem.append(types);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
args.append(argItem);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
body.append(args);
|
|
||||||
}
|
|
||||||
const returns = document.createElement('span'); {
|
|
||||||
returns.classList.add('returns');
|
|
||||||
returns.textContent = returnType ?? 'void';
|
|
||||||
body.append(returns);
|
|
||||||
}
|
|
||||||
specs.append(body);
|
|
||||||
}
|
|
||||||
li.append(specs);
|
|
||||||
}
|
|
||||||
const help = document.createElement('span'); {
|
|
||||||
help.classList.add('help');
|
|
||||||
const content = document.createElement('span'); {
|
|
||||||
content.classList.add('helpContent');
|
|
||||||
content.innerHTML = helpString;
|
|
||||||
const text = content.textContent;
|
|
||||||
content.innerHTML = '';
|
|
||||||
content.textContent = text;
|
|
||||||
help.append(content);
|
|
||||||
}
|
|
||||||
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();
|
|
||||||
detailsWrap?.remove();
|
|
||||||
isActive = false;
|
|
||||||
isShowingDetails = false;
|
|
||||||
};
|
|
||||||
const show = async(isInput = false, isForced = false) => {
|
|
||||||
//TODO check if isInput and isForced are both required
|
|
||||||
text = textarea.value;
|
|
||||||
// only show with textarea in focus
|
|
||||||
if (document.activeElement != textarea) return hide();
|
|
||||||
// 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.namedArgumentList,
|
|
||||||
cmd.unnamedArgumentList,
|
|
||||||
cmd.returns,
|
|
||||||
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);
|
|
||||||
switch (parserResult?.type) {
|
|
||||||
case NAME_RESULT_TYPE.CLOSURE: {
|
|
||||||
startQuote = text[parserResult.start - 2] == '"';
|
|
||||||
endQuote = startQuote && text[parserResult.start - 2 + parserResult.name.length + 1] == '"';
|
|
||||||
try {
|
|
||||||
const qrApi = (await import('./extensions/quick-reply/index.js')).quickReplyApi;
|
|
||||||
parserResult.optionList.push(...qrApi.listSets()
|
|
||||||
.map(set=>qrApi.listQuickReplies(set).map(qr=>`${set}.${qr}`))
|
|
||||||
.flat()
|
|
||||||
.map(qr=>new SlashCommandAutoCompleteOption(OPTION_TYPE.QUICK_REPLY, qr, qr)),
|
|
||||||
);
|
|
||||||
} catch { /* empty */ }
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
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
|
|
||||||
// of the name part of the command
|
|
||||||
switch (parserResult?.type) {
|
|
||||||
case NAME_RESULT_TYPE.CLOSURE: {
|
|
||||||
isReplacable = isInput && (!parserResult ? true : textarea.selectionStart == parserResult.start - 2 + parserResult.name.length + (startQuote ? 1 : 0));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
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 (isForced || isInput) {
|
|
||||||
switch (parserResult?.type) {
|
|
||||||
case NAME_RESULT_TYPE.CLOSURE: {
|
|
||||||
if (textarea.selectionStart >= parserResult.start - 2 && textarea.selectionStart <= parserResult.start - 2 + parserResult.name.length + (startQuote ? 1 : 0)) {
|
|
||||||
slashCommand = slashCommand.slice(0, textarea.selectionStart - (parserResult.start - 2) - (startQuote ? 1 : 0));
|
|
||||||
parserResult.name = slashCommand;
|
|
||||||
isReplacable = true;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
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));
|
|
||||||
parserResult.name = slashCommand;
|
|
||||||
isReplacable = true;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
default: {
|
|
||||||
// no result
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const matchType = power_user.stscript?.matching ?? 'strict';
|
|
||||||
const fuzzyRegex = new RegExp(`^(.*?)${slashCommand.split('').map(char=>`(${escapeRegex(char)})`).join('(.*?)')}(.*?)$`, 'i');
|
|
||||||
const matchers = {
|
|
||||||
'strict': (name) => name.toLowerCase().startsWith(slashCommand),
|
|
||||||
'includes': (name) => name.toLowerCase().includes(slashCommand),
|
|
||||||
'fuzzy': (name) => fuzzyRegex.test(name),
|
|
||||||
};
|
|
||||||
/**
|
|
||||||
*
|
|
||||||
* @param {SlashCommandAutoCompleteOption} option
|
|
||||||
* @returns
|
|
||||||
*/
|
|
||||||
const fuzzyScore = (option) => {
|
|
||||||
const parts = fuzzyRegex.exec(option.name).slice(1, -1);
|
|
||||||
let start = null;
|
|
||||||
let consecutive = [];
|
|
||||||
let current = '';
|
|
||||||
let offset = 0;
|
|
||||||
parts.forEach((part, idx) => {
|
|
||||||
if (idx % 2 == 0) {
|
|
||||||
if (part.length > 0) {
|
|
||||||
if (current.length > 0) {
|
|
||||||
consecutive.push(current);
|
|
||||||
}
|
|
||||||
current = '';
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (start === null) {
|
|
||||||
start = offset;
|
|
||||||
}
|
|
||||||
current += part;
|
|
||||||
}
|
|
||||||
offset += part.length;
|
|
||||||
});
|
|
||||||
if (current.length > 0) {
|
|
||||||
consecutive.push(current);
|
|
||||||
}
|
|
||||||
consecutive.sort((a,b)=>b.length - a.length);
|
|
||||||
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;
|
|
||||||
if (a.score.longestConsecutive > b.score.longestConsecutive) return -1;
|
|
||||||
if (a.score.longestConsecutive < b.score.longestConsecutive) return 1;
|
|
||||||
return a.name.localeCompare(b.name);
|
|
||||||
};
|
|
||||||
/**
|
|
||||||
*
|
|
||||||
* @param {SlashCommandAutoCompleteOption} item
|
|
||||||
*/
|
|
||||||
const updateName = (item) => {
|
|
||||||
const chars = Array.from(item.dom.querySelector('.name').children);
|
|
||||||
switch (matchType) {
|
|
||||||
case 'strict': {
|
|
||||||
chars.forEach((it, idx)=>{
|
|
||||||
if (idx < item.name.length) {
|
|
||||||
it.classList.add('matched');
|
|
||||||
} else {
|
|
||||||
it.classList.remove('matched');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'includes': {
|
|
||||||
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': {
|
|
||||||
item.name.replace(fuzzyRegex, (_, ...parts)=>{
|
|
||||||
parts.splice(-2, 2);
|
|
||||||
if (parts.length == 2) {
|
|
||||||
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 '';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return item;
|
|
||||||
};
|
|
||||||
|
|
||||||
// don't show if no executor found, i.e. cursor's area is not a command
|
|
||||||
if (!parserResult) return hide();
|
|
||||||
else {
|
|
||||||
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.findIndex(opt=>opt.value == it.value) == idx)
|
|
||||||
;
|
|
||||||
result = matchingOptions
|
|
||||||
.filter((it,idx) => matchingOptions.indexOf(it) == idx)
|
|
||||||
.map(option => {
|
|
||||||
let li;
|
|
||||||
switch (option.type) {
|
|
||||||
case OPTION_TYPE.QUICK_REPLY: {
|
|
||||||
li = makeItem(option.name, 'QR', true);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case OPTION_TYPE.VARIABLE_NAME: {
|
|
||||||
li = makeItem(option.name, '𝑥', true);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case OPTION_TYPE.COMMAND: {
|
|
||||||
li = items[option.name];
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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
|
|
||||||
;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.length == 0) {
|
|
||||||
// no result and no input? hide autocomplete
|
|
||||||
if (!isInput) {
|
|
||||||
return hide();
|
|
||||||
}
|
|
||||||
// otherwise add "no match" notice
|
|
||||||
const option = new SlashCommandAutoCompleteOption(
|
|
||||||
OPTION_TYPE.BLANK,
|
|
||||||
null,
|
|
||||||
'',
|
|
||||||
);
|
|
||||||
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.';
|
|
||||||
}
|
|
||||||
option.dom = li;
|
|
||||||
result.push(option);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case NAME_RESULT_TYPE.COMMAND: {
|
|
||||||
const li = document.createElement('li'); {
|
|
||||||
li.textContent = `No matching commands for "/${slashCommand}"`;
|
|
||||||
}
|
|
||||||
option.dom = li;
|
|
||||||
result.push(option);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (result.length == 1 && parserResult && result[0].name == parserResult.name) {
|
|
||||||
// only one result that is exactly the current value? just show hint, no autocomplete
|
|
||||||
isReplacable = false;
|
|
||||||
isShowingDetails = false;
|
|
||||||
} else if (!isReplacable && result.length > 1) {
|
|
||||||
return hide();
|
|
||||||
}
|
|
||||||
selectedItem = result[0];
|
|
||||||
isActive = true;
|
|
||||||
renderDebounced();
|
|
||||||
};
|
|
||||||
const render = ()=>{
|
|
||||||
// render autocomplete list
|
|
||||||
if (isReplacable) {
|
|
||||||
dom.innerHTML = '';
|
|
||||||
dom.classList.remove('defaultDark');
|
|
||||||
dom.classList.remove('defaultLight');
|
|
||||||
dom.classList.remove('defaultThemed');
|
|
||||||
detailsDom.classList.remove('defaultDark');
|
|
||||||
detailsDom.classList.remove('defaultLight');
|
|
||||||
detailsDom.classList.remove('defaultThemed');
|
|
||||||
switch (power_user.stscript.autocomplete_style ?? 'theme') {
|
|
||||||
case 'dark': {
|
|
||||||
dom.classList.add('defaultDark');
|
|
||||||
detailsDom.classList.add('defaultDark');
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'light': {
|
|
||||||
dom.classList.add('defaultLight');
|
|
||||||
detailsDom.classList.add('defaultLight');
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'theme':
|
|
||||||
default: {
|
|
||||||
dom.classList.add('defaultThemed');
|
|
||||||
detailsDom.classList.add('defaultThemed');
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const frag = document.createDocumentFragment();
|
|
||||||
for (const item of result) {
|
|
||||||
if (item == selectedItem) {
|
|
||||||
item.dom.classList.add('selected');
|
|
||||||
} else {
|
|
||||||
item.dom.classList.remove('selected');
|
|
||||||
}
|
|
||||||
frag.append(item.dom);
|
|
||||||
}
|
|
||||||
dom.append(frag);
|
|
||||||
updatePosition();
|
|
||||||
document.body.append(dom);
|
|
||||||
} else {
|
|
||||||
dom.remove();
|
|
||||||
}
|
|
||||||
renderDetailsDebounced();
|
|
||||||
};
|
|
||||||
const renderDetails = ()=>{
|
|
||||||
if (!isShowingDetails && isReplacable) return detailsWrap.remove();
|
|
||||||
detailsDom.innerHTML = '';
|
|
||||||
detailsDom.append(selectedItem?.renderDetails() ?? 'NO ITEM');
|
|
||||||
document.body.append(detailsWrap);
|
|
||||||
updateDetailsPositionDebounced();
|
|
||||||
};
|
|
||||||
const renderDebounced = debounce(render, 10);
|
|
||||||
const renderDetailsDebounced = debounce(renderDetails, 10);
|
|
||||||
const updatePosition = () => {
|
|
||||||
if (isFloating) {
|
|
||||||
updateFloatingPosition();
|
|
||||||
} else {
|
|
||||||
const rect = textarea.getBoundingClientRect();
|
|
||||||
dom.style.setProperty('--bottom', `${window.innerHeight - rect.top}px`);
|
|
||||||
dom.style.bottom = `${window.innerHeight - rect.top}px`;
|
|
||||||
if (isShowingDetails) {
|
|
||||||
dom.style.left = '1vw';
|
|
||||||
} else {
|
|
||||||
dom.style.left = `${rect.left}px`;
|
|
||||||
}
|
|
||||||
dom.style.right = `calc(1vw + ${isShowingDetails ? 25 : 0}vw)`;
|
|
||||||
updateDetailsPosition();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const updateDetailsPosition = () => {
|
|
||||||
if (isShowingDetails || !isReplacable) {
|
|
||||||
const rect = textarea.getBoundingClientRect();
|
|
||||||
if (isReplacable) {
|
|
||||||
const selRect = selectedItem.dom.children[0].getBoundingClientRect();
|
|
||||||
detailsWrap.style.setProperty('--targetTop', `${selRect.top}`);
|
|
||||||
detailsWrap.style.bottom = dom.style.bottom;
|
|
||||||
detailsWrap.style.left = `calc(100vw - ${dom.style.right})`;
|
|
||||||
detailsWrap.style.right = '1vw';
|
|
||||||
detailsWrap.style.top = '5vh';
|
|
||||||
} else {
|
|
||||||
detailsWrap.style.setProperty('--targetTop', `${rect.top}`);
|
|
||||||
detailsWrap.style.bottom = dom.style.bottom;
|
|
||||||
detailsWrap.style.left = `${rect.left}px`;
|
|
||||||
detailsWrap.style.right = `calc(100vw - ${rect.right}px)`;
|
|
||||||
detailsWrap.style.top = '5vh';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const updateDetailsPositionDebounced = debounce(updateDetailsPosition, 10);
|
|
||||||
const updateFloatingPosition = () => {
|
|
||||||
const location = getCursorPosition();
|
|
||||||
const rect = textarea.getBoundingClientRect();
|
|
||||||
if (location.bottom < rect.top || location.top > rect.bottom || location.left < rect.left || location.left > rect.right) return hide();
|
|
||||||
const left = Math.max(rect.left, location.left);
|
|
||||||
if (location.top <= window.innerHeight / 2) {
|
|
||||||
// if cursor is in lower half of window, show list above line
|
|
||||||
dom.style.top = `${location.bottom}px`;
|
|
||||||
dom.style.bottom = 'auto';
|
|
||||||
dom.style.left = `${left}px`;
|
|
||||||
dom.style.right = 'auto';
|
|
||||||
dom.style.maxWidth = `calc(99vw - ${left}px)`;
|
|
||||||
dom.style.maxHeight = `calc(${location.bottom}px - 1vh)`;
|
|
||||||
} else {
|
|
||||||
// if cursor is in upper half of window, show list below line
|
|
||||||
dom.style.top = 'auto';
|
|
||||||
dom.style.bottom = `calc(100vh - ${location.top}px)`;
|
|
||||||
dom.style.left = `${left}px`;
|
|
||||||
dom.style.right = 'auto';
|
|
||||||
dom.style.maxWidth = `calc(99vw - ${left}px)`;
|
|
||||||
dom.style.maxHeight = `calc(${location.top}px - 1vh)`;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
/**
|
|
||||||
* Creates a temporary invisible clone of the textarea to determine cursor coordinates.
|
|
||||||
* @returns {{left:Number, top:Number, bottom:Number}} cursor coordinates
|
|
||||||
*/
|
|
||||||
const getCursorPosition = () => {
|
|
||||||
const inputRect = textarea.getBoundingClientRect();
|
|
||||||
// clone?.remove();
|
|
||||||
const style = window.getComputedStyle(textarea);
|
|
||||||
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.whiteSpace = style.whiteSpace;
|
|
||||||
clone.style.tabSize = style.tabSize;
|
|
||||||
const text = textarea.value;
|
|
||||||
const before = text.slice(0, textarea.selectionStart);
|
|
||||||
clone.textContent = before;
|
|
||||||
const locator = document.createElement('span');
|
|
||||||
locator.textContent = text[textarea.selectionStart];
|
|
||||||
clone.append(locator);
|
|
||||||
clone.append(text.slice(textarea.selectionStart + 1));
|
|
||||||
clone.scrollTop = textarea.scrollTop;
|
|
||||||
clone.scrollLeft = textarea.scrollLeft;
|
|
||||||
const locatorRect = locator.getBoundingClientRect();
|
|
||||||
const location = {
|
|
||||||
left: locatorRect.left,
|
|
||||||
top: locatorRect.top,
|
|
||||||
bottom: locatorRect.bottom,
|
|
||||||
};
|
|
||||||
// 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.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.replacer.length;
|
|
||||||
textarea.selectionEnd = textarea.selectionStart;
|
|
||||||
show();
|
|
||||||
} else {
|
|
||||||
const selectionStart = textarea.selectionStart;
|
|
||||||
const selectionEnd = textarea.selectionDirection;
|
|
||||||
await pointerup;
|
|
||||||
textarea.focus();
|
|
||||||
textarea.selectionStart = selectionStart;
|
|
||||||
textarea.selectionDirection = selectionEnd;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const toggleDetails = async() => {
|
|
||||||
isShowingDetails = !isShowingDetails;
|
|
||||||
renderDetailsDebounced();
|
|
||||||
updatePosition();
|
|
||||||
};
|
|
||||||
const showAutoCompleteDebounced = show;
|
|
||||||
textarea.addEventListener('input', ()=>showAutoCompleteDebounced(true));
|
|
||||||
textarea.addEventListener('click', ()=>showAutoCompleteDebounced());
|
|
||||||
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) {
|
|
||||||
case 'ArrowUp': {
|
|
||||||
// select previous item
|
|
||||||
if (evt.ctrlKey || evt.altKey || evt.shiftKey) return;
|
|
||||||
evt.preventDefault();
|
|
||||||
evt.stopPropagation();
|
|
||||||
const idx = result.indexOf(selectedItem);
|
|
||||||
let newIdx;
|
|
||||||
if (idx == 0) newIdx = result.length - 1;
|
|
||||||
else newIdx = idx - 1;
|
|
||||||
selectedItem.dom.classList.remove('selected');
|
|
||||||
selectedItem = result[newIdx];
|
|
||||||
selectedItem.dom.classList.add('selected');
|
|
||||||
const rect = selectedItem.dom.children[0].getBoundingClientRect();
|
|
||||||
const rectParent = dom.getBoundingClientRect();
|
|
||||||
if (rect.top < rectParent.top || rect.bottom > rectParent.bottom ) {
|
|
||||||
// selectedItem.dom.children[0].scrollIntoView();
|
|
||||||
dom.scrollTop += rect.top < rectParent.top ? rect.top - rectParent.top : rect.bottom - rectParent.bottom;
|
|
||||||
}
|
|
||||||
renderDetailsDebounced();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
case 'ArrowDown': {
|
|
||||||
// select next item
|
|
||||||
if (evt.ctrlKey || evt.altKey || evt.shiftKey) return;
|
|
||||||
evt.preventDefault();
|
|
||||||
evt.stopPropagation();
|
|
||||||
const idx = result.indexOf(selectedItem);
|
|
||||||
const newIdx = (idx + 1) % result.length;
|
|
||||||
selectedItem.dom.classList.remove('selected');
|
|
||||||
selectedItem = result[newIdx];
|
|
||||||
selectedItem.dom.classList.add('selected');
|
|
||||||
const rect = selectedItem.dom.children[0].getBoundingClientRect();
|
|
||||||
const rectParent = dom.getBoundingClientRect();
|
|
||||||
if (rect.top < rectParent.top || rect.bottom > rectParent.bottom ) {
|
|
||||||
// selectedItem.dom.children[0].scrollIntoView();
|
|
||||||
dom.scrollTop += rect.top < rectParent.top ? rect.top - rectParent.top : rect.bottom - rectParent.bottom;
|
|
||||||
}
|
|
||||||
renderDetailsDebounced();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
case 'Enter':
|
|
||||||
case 'Tab': {
|
|
||||||
// pick the selected item to autocomplete
|
|
||||||
if (evt.ctrlKey || evt.altKey || evt.shiftKey || selectedItem.type == OPTION_TYPE.BLANK) break;
|
|
||||||
evt.preventDefault();
|
|
||||||
evt.stopImmediatePropagation();
|
|
||||||
select();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// autocomplete is shown, cursor can be anywhere
|
|
||||||
if (isActive) {
|
|
||||||
switch (evt.key) {
|
|
||||||
case 'Escape': {
|
|
||||||
// close autocomplete
|
|
||||||
if (evt.ctrlKey || evt.altKey || evt.shiftKey) return;
|
|
||||||
evt.preventDefault();
|
|
||||||
evt.stopPropagation();
|
|
||||||
hide();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
case 'Enter': {
|
|
||||||
// hide autocomplete on enter (send, execute, ...)
|
|
||||||
if (!evt.shiftKey) {
|
|
||||||
hide();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// autocomplete shown or not, cursor anywhere
|
|
||||||
switch (evt.key) {
|
|
||||||
case ' ': {
|
|
||||||
if (evt.ctrlKey) {
|
|
||||||
if (isActive) {
|
|
||||||
// ctrl-space to toggle details for selected item
|
|
||||||
toggleDetails();
|
|
||||||
} else {
|
|
||||||
// ctrl-space to force show autocomplete
|
|
||||||
showAutoCompleteDebounced(true, true);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (['Control', 'Shift', 'Alt'].includes(evt.key)) {
|
|
||||||
// ignore keydown on modifier keys
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
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());
|
|
||||||
if (isFloating) {
|
|
||||||
textarea.addEventListener('scroll', debounce(updateFloatingPosition, 100));
|
|
||||||
}
|
|
||||||
window.addEventListener('resize', debounce(updatePosition, 100));
|
|
||||||
}
|
}
|
||||||
/**@type {HTMLTextAreaElement} */
|
/**@type {HTMLTextAreaElement} */
|
||||||
const sendTextarea = document.querySelector('#send_textarea');
|
const sendTextarea = document.querySelector('#send_textarea');
|
||||||
|
927
public/scripts/slash-commands/SlashCommandAutoComplete.js
Normal file
927
public/scripts/slash-commands/SlashCommandAutoComplete.js
Normal file
@ -0,0 +1,927 @@
|
|||||||
|
import { power_user } from '../power-user.js';
|
||||||
|
import { debounce, escapeRegex } from '../utils.js';
|
||||||
|
import { OPTION_TYPE, SlashCommandAutoCompleteOption, SlashCommandFuzzyScore } from './SlashCommandAutoCompleteOption.js';
|
||||||
|
import { SlashCommandParser } from './SlashCommandParser.js';
|
||||||
|
// eslint-disable-next-line no-unused-vars
|
||||||
|
import { NAME_RESULT_TYPE, SlashCommandParserNameResult } from './SlashCommandParserNameResult.js';
|
||||||
|
|
||||||
|
export class SlashCommandAutoComplete {
|
||||||
|
/**@type {HTMLTextAreaElement}*/ textarea;
|
||||||
|
/**@type {boolean}*/ isFloating = false;
|
||||||
|
|
||||||
|
/**@type {SlashCommandParser}*/ parser;
|
||||||
|
|
||||||
|
/**@type {boolean}*/ isActive = false;
|
||||||
|
/**@type {boolean}*/ isReplaceable = false;
|
||||||
|
/**@type {boolean}*/ isShowingDetails = false;
|
||||||
|
|
||||||
|
/**@type {string}*/ text;
|
||||||
|
/**@type {SlashCommandParserNameResult}*/ parserResult;
|
||||||
|
/**@type {string}*/ slashCommand;
|
||||||
|
|
||||||
|
/**@type {boolean}*/ startQuote;
|
||||||
|
/**@type {boolean}*/ endQuote;
|
||||||
|
/**@type {number}*/ selectionStart;
|
||||||
|
|
||||||
|
/**@type {RegExp}*/ fuzzyRegex;
|
||||||
|
|
||||||
|
|
||||||
|
/**@type {Object.<string,HTMLElement>}*/ items = {};
|
||||||
|
/**@type {boolean}*/ hasCache = false;
|
||||||
|
|
||||||
|
/**@type {SlashCommandAutoCompleteOption[]}*/ result = [];
|
||||||
|
/**@type {SlashCommandAutoCompleteOption}*/ selectedItem = null;
|
||||||
|
|
||||||
|
/**@type {Promise}*/ pointerup = Promise.resolve();
|
||||||
|
|
||||||
|
/**@type {HTMLElement}*/ clone;
|
||||||
|
/**@type {HTMLElement}*/ domWrap;
|
||||||
|
/**@type {HTMLElement}*/ dom;
|
||||||
|
/**@type {HTMLElement}*/ detailsWrap;
|
||||||
|
/**@type {HTMLElement}*/ detailsDom;
|
||||||
|
|
||||||
|
/**@type {function}*/ renderDebounced;
|
||||||
|
/**@type {function}*/ renderDetailsDebounced;
|
||||||
|
/**@type {function}*/ updatePositionDebounced;
|
||||||
|
/**@type {function}*/ updateDetailsPositionDebounced;
|
||||||
|
/**@type {function}*/ updateFloatingPositionDebounced;
|
||||||
|
|
||||||
|
get matchType() {
|
||||||
|
return power_user.stscript.matching ?? 'fuzzy';
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {HTMLTextAreaElement} textarea The textarea to receive autocomplete.
|
||||||
|
* @param {boolean} isFloating Whether autocomplete should float at the keyboard cursor.
|
||||||
|
*/
|
||||||
|
constructor(textarea, isFloating = false) {
|
||||||
|
this.textarea = textarea;
|
||||||
|
this.isFloating = isFloating;
|
||||||
|
|
||||||
|
this.parser = new SlashCommandParser();
|
||||||
|
|
||||||
|
this.domWrap = document.createElement('div'); {
|
||||||
|
this.domWrap.classList.add('slashCommandAutoComplete-wrap');
|
||||||
|
if (isFloating) this.domWrap.classList.add('isFloating');
|
||||||
|
}
|
||||||
|
this.dom = document.createElement('ul'); {
|
||||||
|
this.dom.classList.add('slashCommandAutoComplete');
|
||||||
|
this.domWrap.append(this.dom);
|
||||||
|
}
|
||||||
|
this.detailsWrap = document.createElement('div'); {
|
||||||
|
this.detailsWrap.classList.add('slashCommandAutoComplete-detailsWrap');
|
||||||
|
if (isFloating) this.detailsWrap.classList.add('isFloating');
|
||||||
|
}
|
||||||
|
this.detailsDom = document.createElement('div'); {
|
||||||
|
this.detailsDom.classList.add('slashCommandAutoComplete-details');
|
||||||
|
this.detailsWrap.append(this.detailsDom);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.renderDebounced = debounce(this.render.bind(this), 10);
|
||||||
|
this.renderDetailsDebounced = debounce(this.renderDetails.bind(this), 10);
|
||||||
|
this.updatePositionDebounced = debounce(this.updatePosition.bind(this), 10);
|
||||||
|
this.updateDetailsPositionDebounced = debounce(this.updateDetailsPosition.bind(this), 10);
|
||||||
|
this.updateFloatingPositionDebounced = debounce(this.updateFloatingPosition.bind(this), 10);
|
||||||
|
|
||||||
|
textarea.addEventListener('input', ()=>this.show(true));
|
||||||
|
textarea.addEventListener('keydown', (evt)=>this.handleKeyDown(evt));
|
||||||
|
textarea.addEventListener('click', ()=>this.isActive ? this.show() : null);
|
||||||
|
textarea.addEventListener('selectionchange', ()=>this.show());
|
||||||
|
// textarea.addEventListener('blur', ()=>this.hide());
|
||||||
|
if (isFloating) {
|
||||||
|
textarea.addEventListener('scroll', ()=>this.updateFloatingPositionDebounced());
|
||||||
|
}
|
||||||
|
window.addEventListener('resize', ()=>this.updatePositionDebounced());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a cache of DOM list items for autocomplete of slash commands.
|
||||||
|
*/
|
||||||
|
buildCache() {
|
||||||
|
if (!this.hasCache) {
|
||||||
|
this.hasCache = true;
|
||||||
|
// init by appending all command options
|
||||||
|
Object.keys(this.parser.commands).forEach(key=>{
|
||||||
|
const cmd = this.parser.commands[key];
|
||||||
|
this.items[key] = this.makeItem(
|
||||||
|
key,
|
||||||
|
'/',
|
||||||
|
false,
|
||||||
|
cmd.namedArgumentList,
|
||||||
|
cmd.unnamedArgumentList,
|
||||||
|
cmd.returns,
|
||||||
|
cmd.helpString,
|
||||||
|
[cmd.name, ...(cmd.aliases ?? [])].filter(it=>it != key),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
makeItem(key, typeIcon, noSlash, namedArguments = [], unnamedArguments = [], returnType = 'void', 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 specs = document.createElement('span'); {
|
||||||
|
specs.classList.add('specs');
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
specs.append(name);
|
||||||
|
}
|
||||||
|
const body = document.createElement('span'); {
|
||||||
|
body.classList.add('body');
|
||||||
|
const args = document.createElement('span'); {
|
||||||
|
args.classList.add('arguments');
|
||||||
|
for (const arg of namedArguments) {
|
||||||
|
const argItem = document.createElement('span'); {
|
||||||
|
argItem.classList.add('argument');
|
||||||
|
argItem.classList.add('namedArgument');
|
||||||
|
if (!arg.isRequired || (arg.defaultValue ?? false)) argItem.classList.add('optional');
|
||||||
|
if (arg.acceptsMultiple) argItem.classList.add('multiple');
|
||||||
|
const name = document.createElement('span'); {
|
||||||
|
name.classList.add('argument-name');
|
||||||
|
name.textContent = arg.name;
|
||||||
|
argItem.append(name);
|
||||||
|
}
|
||||||
|
if (arg.enumList.length > 0) {
|
||||||
|
const enums = document.createElement('span'); {
|
||||||
|
enums.classList.add('argument-enums');
|
||||||
|
for (const e of arg.enumList) {
|
||||||
|
const enumItem = document.createElement('span'); {
|
||||||
|
enumItem.classList.add('argument-enum');
|
||||||
|
enumItem.textContent = e;
|
||||||
|
enums.append(enumItem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
argItem.append(enums);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const types = document.createElement('span'); {
|
||||||
|
types.classList.add('argument-types');
|
||||||
|
for (const t of arg.typeList) {
|
||||||
|
const type = document.createElement('span'); {
|
||||||
|
type.classList.add('argument-type');
|
||||||
|
type.textContent = t;
|
||||||
|
types.append(type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
argItem.append(types);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
args.append(argItem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const arg of unnamedArguments) {
|
||||||
|
const argItem = document.createElement('span'); {
|
||||||
|
argItem.classList.add('argument');
|
||||||
|
argItem.classList.add('unnamedArgument');
|
||||||
|
if (!arg.isRequired || (arg.defaultValue ?? false)) argItem.classList.add('optional');
|
||||||
|
if (arg.acceptsMultiple) argItem.classList.add('multiple');
|
||||||
|
if (arg.enumList.length > 0) {
|
||||||
|
const enums = document.createElement('span'); {
|
||||||
|
enums.classList.add('argument-enums');
|
||||||
|
for (const e of arg.enumList) {
|
||||||
|
const enumItem = document.createElement('span'); {
|
||||||
|
enumItem.classList.add('argument-enum');
|
||||||
|
enumItem.textContent = e;
|
||||||
|
enums.append(enumItem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
argItem.append(enums);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const types = document.createElement('span'); {
|
||||||
|
types.classList.add('argument-types');
|
||||||
|
for (const t of arg.typeList) {
|
||||||
|
const type = document.createElement('span'); {
|
||||||
|
type.classList.add('argument-type');
|
||||||
|
type.textContent = t;
|
||||||
|
types.append(type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
argItem.append(types);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
args.append(argItem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
body.append(args);
|
||||||
|
}
|
||||||
|
const returns = document.createElement('span'); {
|
||||||
|
returns.classList.add('returns');
|
||||||
|
returns.textContent = returnType ?? 'void';
|
||||||
|
body.append(returns);
|
||||||
|
}
|
||||||
|
specs.append(body);
|
||||||
|
}
|
||||||
|
li.append(specs);
|
||||||
|
}
|
||||||
|
const help = document.createElement('span'); {
|
||||||
|
help.classList.add('help');
|
||||||
|
const content = document.createElement('span'); {
|
||||||
|
content.classList.add('helpContent');
|
||||||
|
content.innerHTML = helpString;
|
||||||
|
const text = content.textContent;
|
||||||
|
content.innerHTML = '';
|
||||||
|
content.textContent = text;
|
||||||
|
help.append(content);
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
this.pointerup = new Promise(resolve=>{
|
||||||
|
const resolver = ()=>{
|
||||||
|
window.removeEventListener('pointerup', resolver);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
window.addEventListener('pointerup', resolver);
|
||||||
|
});
|
||||||
|
this.select();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return li;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {SlashCommandAutoCompleteOption} item
|
||||||
|
*/
|
||||||
|
updateName(item) {
|
||||||
|
const chars = Array.from(item.dom.querySelector('.name').children);
|
||||||
|
switch (this.matchType) {
|
||||||
|
case 'strict': {
|
||||||
|
chars.forEach((it, idx)=>{
|
||||||
|
if (idx < item.name.length) {
|
||||||
|
it.classList.add('matched');
|
||||||
|
} else {
|
||||||
|
it.classList.remove('matched');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'includes': {
|
||||||
|
const start = item.name.toLowerCase().search(this.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': {
|
||||||
|
item.name.replace(this.fuzzyRegex, (_, ...parts)=>{
|
||||||
|
parts.splice(-2, 2);
|
||||||
|
if (parts.length == 2) {
|
||||||
|
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 '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate score for the fuzzy match.
|
||||||
|
* @param {SlashCommandAutoCompleteOption} option
|
||||||
|
* @returns The option.
|
||||||
|
*/
|
||||||
|
fuzzyScore(option) {
|
||||||
|
const parts = this.fuzzyRegex.exec(option.name).slice(1, -1);
|
||||||
|
let start = null;
|
||||||
|
let consecutive = [];
|
||||||
|
let current = '';
|
||||||
|
let offset = 0;
|
||||||
|
parts.forEach((part, idx) => {
|
||||||
|
if (idx % 2 == 0) {
|
||||||
|
if (part.length > 0) {
|
||||||
|
if (current.length > 0) {
|
||||||
|
consecutive.push(current);
|
||||||
|
}
|
||||||
|
current = '';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (start === null) {
|
||||||
|
start = offset;
|
||||||
|
}
|
||||||
|
current += part;
|
||||||
|
}
|
||||||
|
offset += part.length;
|
||||||
|
});
|
||||||
|
if (current.length > 0) {
|
||||||
|
consecutive.push(current);
|
||||||
|
}
|
||||||
|
consecutive.sort((a,b)=>b.length - a.length);
|
||||||
|
option.score = new SlashCommandFuzzyScore(start, consecutive[0]?.length ?? 0);
|
||||||
|
return option;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compare two auto complete options by their fuzzy score.
|
||||||
|
* @param {SlashCommandAutoCompleteOption} a
|
||||||
|
* @param {SlashCommandAutoCompleteOption} b
|
||||||
|
*/
|
||||||
|
fuzzyScoreCompare(a, b) {
|
||||||
|
if (a.score.start < b.score.start) return -1;
|
||||||
|
if (a.score.start > b.score.start) return 1;
|
||||||
|
if (a.score.longestConsecutive > b.score.longestConsecutive) return -1;
|
||||||
|
if (a.score.longestConsecutive < b.score.longestConsecutive) return 1;
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the autocomplete.
|
||||||
|
* @param {boolean} isInput Whether triggered by input.
|
||||||
|
* @param {boolean} isForced Whether force-showing (ctrl+space).
|
||||||
|
*/
|
||||||
|
async show(isInput = false, isForced = false) {
|
||||||
|
//TODO check if isInput and isForced are both required
|
||||||
|
this.text = this.textarea.value;
|
||||||
|
// only show with textarea in focus
|
||||||
|
if (document.activeElement != this.textarea) return this.hide();
|
||||||
|
// only show for slash commands
|
||||||
|
if (this.text[0] != '/') return this.hide();
|
||||||
|
|
||||||
|
this.buildCache();
|
||||||
|
|
||||||
|
// request parser to get command executor (potentially "incomplete", i.e. not an actual existing command) for
|
||||||
|
// cursor position
|
||||||
|
this.parserResult = this.parser.getNameAt(this.text, this.textarea.selectionStart);
|
||||||
|
switch (this.parserResult?.type) {
|
||||||
|
case NAME_RESULT_TYPE.CLOSURE: {
|
||||||
|
this.startQuote = this.text[this.parserResult.start - 2] == '"';
|
||||||
|
this.endQuote = this.startQuote && this.text[this.parserResult.start - 2 + this.parserResult.name.length + 1] == '"';
|
||||||
|
try {
|
||||||
|
const qrApi = (await import('../extensions/quick-reply/index.js')).quickReplyApi;
|
||||||
|
this.parserResult.optionList.push(...qrApi.listSets()
|
||||||
|
.map(set=>qrApi.listQuickReplies(set).map(qr=>`${set}.${qr}`))
|
||||||
|
.flat()
|
||||||
|
.map(qr=>new SlashCommandAutoCompleteOption(OPTION_TYPE.QUICK_REPLY, qr, qr)),
|
||||||
|
);
|
||||||
|
} catch { /* empty */ }
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case NAME_RESULT_TYPE.COMMAND: {
|
||||||
|
this.parserResult.optionList.push(...Object.keys(this.parser.commands)
|
||||||
|
.map(key=>new SlashCommandAutoCompleteOption(OPTION_TYPE.COMMAND, this.parser.commands[key], key)),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
// no result
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.slashCommand = this.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
|
||||||
|
// of the name part of the command
|
||||||
|
switch (this.parserResult?.type) {
|
||||||
|
case NAME_RESULT_TYPE.CLOSURE: {
|
||||||
|
this.isReplaceable = isInput && (!this.parserResult ? true : this.textarea.selectionStart == this.parserResult.start - 2 + this.parserResult.name.length + (this.startQuote ? 1 : 0));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default: // no result
|
||||||
|
case NAME_RESULT_TYPE.COMMAND: {
|
||||||
|
this.isReplaceable = isInput && (!this.parserResult ? true : this.textarea.selectionStart == this.parserResult.start - 2 + this.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 (isForced || isInput) {
|
||||||
|
switch (this.parserResult?.type) {
|
||||||
|
case NAME_RESULT_TYPE.CLOSURE: {
|
||||||
|
if (this.textarea.selectionStart >= this.parserResult.start - 2 && this.textarea.selectionStart <= this.parserResult.start - 2 + this.parserResult.name.length + (this.startQuote ? 1 : 0)) {
|
||||||
|
this.slashCommand = this.slashCommand.slice(0, this.textarea.selectionStart - (this.parserResult.start - 2) - (this.startQuote ? 1 : 0));
|
||||||
|
this.parserResult.name = this.slashCommand;
|
||||||
|
this.isReplaceable = true;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case NAME_RESULT_TYPE.COMMAND: {
|
||||||
|
if (this.textarea.selectionStart >= this.parserResult.start - 2 && this.textarea.selectionStart <= this.parserResult.start - 2 + this.parserResult.name.length) {
|
||||||
|
this.slashCommand = this.slashCommand.slice(0, this.textarea.selectionStart - (this.parserResult.start - 2));
|
||||||
|
this.parserResult.name = this.slashCommand;
|
||||||
|
this.isReplaceable = true;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
// no result
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.fuzzyRegex = new RegExp(`^(.*?)${this.slashCommand.split('').map(char=>`(${escapeRegex(char)})`).join('(.*?)')}(.*?)$`, 'i');
|
||||||
|
const matchers = {
|
||||||
|
'strict': (name) => name.toLowerCase().startsWith(this.slashCommand),
|
||||||
|
'includes': (name) => name.toLowerCase().includes(this.slashCommand),
|
||||||
|
'fuzzy': (name) => this.fuzzyRegex.test(name),
|
||||||
|
};
|
||||||
|
|
||||||
|
// don't show if no executor found, i.e. cursor's area is not a command
|
||||||
|
if (!this.parserResult) return this.hide();
|
||||||
|
else {
|
||||||
|
let matchingOptions = this.parserResult.optionList
|
||||||
|
.filter(it => this.isReplaceable || it.name == '' ? matchers[this.matchType](it.name) : it.name.toLowerCase() == this.slashCommand) // Filter by the input
|
||||||
|
.filter((it,idx,list) => list.findIndex(opt=>opt.value == it.value) == idx)
|
||||||
|
;
|
||||||
|
this.result = matchingOptions
|
||||||
|
.filter((it,idx) => matchingOptions.indexOf(it) == idx)
|
||||||
|
.map(option => {
|
||||||
|
let li;
|
||||||
|
switch (option.type) {
|
||||||
|
case OPTION_TYPE.QUICK_REPLY: {
|
||||||
|
li = this.makeItem(option.name, 'QR', true);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case OPTION_TYPE.VARIABLE_NAME: {
|
||||||
|
li = this.makeItem(option.name, '𝑥', true);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case OPTION_TYPE.COMMAND: {
|
||||||
|
li = this.items[option.name];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
option.replacer = option.name.includes(' ') || this.startQuote || this.endQuote ? `"${option.name}"` : `${option.name}`;
|
||||||
|
option.dom = li;
|
||||||
|
if (this.matchType == 'fuzzy') this.fuzzyScore(option);
|
||||||
|
this.updateName(option);
|
||||||
|
return option;
|
||||||
|
}) // Map to the help string and score
|
||||||
|
.toSorted(this.matchType == 'fuzzy' ? this.fuzzyScoreCompare : (a, b) => a.name.localeCompare(b.name)) // sort by score (if fuzzy) or name
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.result.length == 0) {
|
||||||
|
// no result and no input? hide autocomplete
|
||||||
|
if (!isInput) {
|
||||||
|
return this.hide();
|
||||||
|
}
|
||||||
|
// otherwise add "no match" notice
|
||||||
|
const option = new SlashCommandAutoCompleteOption(
|
||||||
|
OPTION_TYPE.BLANK,
|
||||||
|
null,
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
switch (this.parserResult?.type) {
|
||||||
|
case NAME_RESULT_TYPE.CLOSURE: {
|
||||||
|
const li = document.createElement('li'); {
|
||||||
|
li.textContent = this.slashCommand.length ?
|
||||||
|
`No matching variables in scope and no matching Quick Replies for "${this.slashCommand}"`
|
||||||
|
: 'No variables in scope and no Quick Replies found.';
|
||||||
|
}
|
||||||
|
option.dom = li;
|
||||||
|
this.result.push(option);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case NAME_RESULT_TYPE.COMMAND: {
|
||||||
|
const li = document.createElement('li'); {
|
||||||
|
li.textContent = `No matching commands for "/${this.slashCommand}"`;
|
||||||
|
}
|
||||||
|
option.dom = li;
|
||||||
|
this.result.push(option);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (this.result.length == 1 && this.parserResult && this.result[0].name == this.parserResult.name) {
|
||||||
|
// only one result that is exactly the current value? just show hint, no autocomplete
|
||||||
|
this.isReplaceable = false;
|
||||||
|
this.isShowingDetails = false;
|
||||||
|
} else if (!this.isReplaceable && this.result.length > 1) {
|
||||||
|
return this.hide();
|
||||||
|
}
|
||||||
|
this.selectedItem = this.result[0];
|
||||||
|
this.isActive = true;
|
||||||
|
this.renderDebounced();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hide autocomplete.
|
||||||
|
*/
|
||||||
|
hide() {
|
||||||
|
this.domWrap?.remove();
|
||||||
|
this.detailsWrap?.remove();
|
||||||
|
this.isActive = false;
|
||||||
|
this.isShowingDetails = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create updated DOM.
|
||||||
|
*/
|
||||||
|
render() {
|
||||||
|
// render autocomplete list
|
||||||
|
if (this.isReplaceable) {
|
||||||
|
this.dom.innerHTML = '';
|
||||||
|
this.dom.classList.remove('defaultDark');
|
||||||
|
this.dom.classList.remove('defaultLight');
|
||||||
|
this.dom.classList.remove('defaultThemed');
|
||||||
|
this.detailsDom.classList.remove('defaultDark');
|
||||||
|
this.detailsDom.classList.remove('defaultLight');
|
||||||
|
this.detailsDom.classList.remove('defaultThemed');
|
||||||
|
switch (power_user.stscript.autocomplete_style ?? 'theme') {
|
||||||
|
case 'dark': {
|
||||||
|
this.dom.classList.add('defaultDark');
|
||||||
|
this.detailsDom.classList.add('defaultDark');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'light': {
|
||||||
|
this.dom.classList.add('defaultLight');
|
||||||
|
this.detailsDom.classList.add('defaultLight');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'theme':
|
||||||
|
default: {
|
||||||
|
this.dom.classList.add('defaultThemed');
|
||||||
|
this.detailsDom.classList.add('defaultThemed');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const frag = document.createDocumentFragment();
|
||||||
|
for (const item of this.result) {
|
||||||
|
if (item == this.selectedItem) {
|
||||||
|
item.dom.classList.add('selected');
|
||||||
|
} else {
|
||||||
|
item.dom.classList.remove('selected');
|
||||||
|
}
|
||||||
|
frag.append(item.dom);
|
||||||
|
}
|
||||||
|
this.dom.append(frag);
|
||||||
|
this.updatePosition();
|
||||||
|
document.body.append(this.domWrap);
|
||||||
|
} else {
|
||||||
|
this.domWrap.remove();
|
||||||
|
}
|
||||||
|
this.renderDetailsDebounced();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create updated DOM for details.
|
||||||
|
*/
|
||||||
|
renderDetails() {
|
||||||
|
if (!this.isShowingDetails && this.isReplaceable) return this.detailsWrap.remove();
|
||||||
|
this.detailsDom.innerHTML = '';
|
||||||
|
this.detailsDom.append(this.selectedItem?.renderDetails() ?? 'NO ITEM');
|
||||||
|
document.body.append(this.detailsWrap);
|
||||||
|
this.updateDetailsPositionDebounced();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update position of DOM.
|
||||||
|
*/
|
||||||
|
updatePosition() {
|
||||||
|
if (this.isFloating) {
|
||||||
|
this.updateFloatingPosition();
|
||||||
|
} else {
|
||||||
|
const rect = this.textarea.getBoundingClientRect();
|
||||||
|
this.domWrap.style.setProperty('--bottom', `${window.innerHeight - rect.top}px`);
|
||||||
|
this.domWrap.style.bottom = `${window.innerHeight - rect.top}px`;
|
||||||
|
if (this.isShowingDetails) {
|
||||||
|
this.domWrap.style.left = '1vw';
|
||||||
|
} else {
|
||||||
|
this.domWrap.style.left = `${rect.left}px`;
|
||||||
|
}
|
||||||
|
this.domWrap.style.right = `calc(1vw + ${this.isShowingDetails ? 25 : 0}vw)`;
|
||||||
|
this.updateDetailsPosition();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update position of details DOM.
|
||||||
|
*/
|
||||||
|
updateDetailsPosition() {
|
||||||
|
if (this.isShowingDetails || !this.isReplaceable) {
|
||||||
|
if (this.isFloating) {
|
||||||
|
this.updateFloatingDetailsPosition();
|
||||||
|
} else {
|
||||||
|
const rect = this.textarea.getBoundingClientRect();
|
||||||
|
if (this.isReplaceable) {
|
||||||
|
const selRect = this.selectedItem.dom.children[0].getBoundingClientRect();
|
||||||
|
this.detailsWrap.style.setProperty('--targetOffset', `${selRect.top}`);
|
||||||
|
this.detailsWrap.style.bottom = this.domWrap.style.bottom;
|
||||||
|
this.detailsWrap.style.left = `calc(100vw - ${this.domWrap.style.right})`;
|
||||||
|
this.detailsWrap.style.right = '1vw';
|
||||||
|
this.detailsWrap.style.top = '5vh';
|
||||||
|
} else {
|
||||||
|
this.detailsWrap.style.setProperty('--targetOffset', `${rect.top}`);
|
||||||
|
this.detailsWrap.style.bottom = this.domWrap.style.bottom;
|
||||||
|
this.detailsWrap.style.left = `${rect.left}px`;
|
||||||
|
this.detailsWrap.style.right = `calc(100vw - ${rect.right}px)`;
|
||||||
|
this.detailsWrap.style.top = '5vh';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update position of floating autocomplete.
|
||||||
|
*/
|
||||||
|
updateFloatingPosition() {
|
||||||
|
const location = this.getCursorPosition();
|
||||||
|
const rect = this.textarea.getBoundingClientRect();
|
||||||
|
// cursor is out of view -> hide
|
||||||
|
if (location.bottom < rect.top || location.top > rect.bottom || location.left < rect.left || location.left > rect.right) {
|
||||||
|
return this.hide();
|
||||||
|
}
|
||||||
|
const left = Math.max(rect.left, location.left);
|
||||||
|
this.domWrap.style.setProperty('--targetOffset', `${left}`);
|
||||||
|
if (location.top <= window.innerHeight / 2) {
|
||||||
|
// if cursor is in lower half of window, show list above line
|
||||||
|
this.domWrap.style.top = `${location.bottom}px`;
|
||||||
|
this.domWrap.style.bottom = 'auto';
|
||||||
|
this.domWrap.style.maxHeight = `calc(${location.bottom}px - 1vh)`;
|
||||||
|
} else {
|
||||||
|
// if cursor is in upper half of window, show list below line
|
||||||
|
this.domWrap.style.top = 'auto';
|
||||||
|
this.domWrap.style.bottom = `calc(100vh - ${location.top}px)`;
|
||||||
|
this.domWrap.style.maxHeight = `calc(${location.top}px - 1vh)`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFloatingDetailsPosition(location = null) {
|
||||||
|
if (!location) location = this.getCursorPosition();
|
||||||
|
const rect = this.textarea.getBoundingClientRect();
|
||||||
|
if (location.bottom < rect.top || location.top > rect.bottom || location.left < rect.left || location.left > rect.right) {
|
||||||
|
return this.hide();
|
||||||
|
}
|
||||||
|
const left = Math.max(rect.left, location.left);
|
||||||
|
this.detailsWrap.style.setProperty('--targetOffset', `${left}`);
|
||||||
|
if (this.isReplaceable) {
|
||||||
|
this.detailsWrap.classList.remove('full');
|
||||||
|
if (left < window.innerWidth / 4) {
|
||||||
|
// if cursor is in left part of screen, show details on right of list
|
||||||
|
this.detailsWrap.classList.add('right');
|
||||||
|
} else {
|
||||||
|
// if cursor is in right part of screen, show details on left of list
|
||||||
|
this.detailsWrap.classList.remove('right');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.detailsWrap.classList.remove('right');
|
||||||
|
this.detailsWrap.classList.add('full');
|
||||||
|
}
|
||||||
|
if (location.top <= window.innerHeight / 2) {
|
||||||
|
// if cursor is in lower half of window, show list above line
|
||||||
|
this.detailsWrap.style.top = `${location.bottom}px`;
|
||||||
|
this.detailsWrap.style.bottom = 'auto';
|
||||||
|
this.detailsWrap.style.maxHeight = `calc(${location.bottom}px - 1vh)`;
|
||||||
|
} else {
|
||||||
|
// if cursor is in upper half of window, show list below line
|
||||||
|
this.detailsWrap.style.top = 'auto';
|
||||||
|
this.detailsWrap.style.bottom = `calc(100vh - ${location.top}px)`;
|
||||||
|
this.detailsWrap.style.maxHeight = `calc(${location.top}px - 1vh)`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate (keyboard) cursor coordinates within textarea.
|
||||||
|
* @returns {{left:number, top:number, bottom:number}}
|
||||||
|
*/
|
||||||
|
getCursorPosition() {
|
||||||
|
const inputRect = this.textarea.getBoundingClientRect();
|
||||||
|
const style = window.getComputedStyle(this.textarea);
|
||||||
|
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';
|
||||||
|
document.body.append(this.clone);
|
||||||
|
const mo = new MutationObserver(muts=>{
|
||||||
|
if (muts.find(it=>Array.from(it.removedNodes).includes(this.textarea))) {
|
||||||
|
this.clone.remove();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
mo.observe(this.textarea.parentElement, { childList:true });
|
||||||
|
}
|
||||||
|
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 = this.textarea.value;
|
||||||
|
const before = text.slice(0, this.textarea.selectionStart);
|
||||||
|
this.clone.textContent = before;
|
||||||
|
const locator = document.createElement('span');
|
||||||
|
locator.textContent = text[this.textarea.selectionStart];
|
||||||
|
this.clone.append(locator);
|
||||||
|
this.clone.append(text.slice(this.textarea.selectionStart + 1));
|
||||||
|
this.clone.scrollTop = this.textarea.scrollTop;
|
||||||
|
this.clone.scrollLeft = this.textarea.scrollLeft;
|
||||||
|
const locatorRect = locator.getBoundingClientRect();
|
||||||
|
const location = {
|
||||||
|
left: locatorRect.left,
|
||||||
|
top: locatorRect.top,
|
||||||
|
bottom: locatorRect.bottom,
|
||||||
|
};
|
||||||
|
return location;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle details view alongside autocomplete list.
|
||||||
|
*/
|
||||||
|
toggleDetails() {
|
||||||
|
this.isShowingDetails = !this.isShowingDetails;
|
||||||
|
this.renderDetailsDebounced();
|
||||||
|
this.updatePosition();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select an item for autocomplete and put text into textarea.
|
||||||
|
*/
|
||||||
|
async select() {
|
||||||
|
if (this.isReplaceable && this.selectedItem.value !== null) {
|
||||||
|
this.textarea.value = `${this.text.slice(0, this.parserResult.start - 2)}${this.selectedItem.replacer}${this.text.slice(this.parserResult.start - 2 + this.parserResult.name.length + (this.startQuote ? 1 : 0) + (this.endQuote ? 1 : 0))}`;
|
||||||
|
await this.pointerup;
|
||||||
|
this.textarea.focus();
|
||||||
|
this.textarea.selectionStart = this.parserResult.start - 2 + this.selectedItem.replacer.length;
|
||||||
|
this.textarea.selectionEnd = this.textarea.selectionStart;
|
||||||
|
this.show();
|
||||||
|
} else {
|
||||||
|
const selectionStart = this.textarea.selectionStart;
|
||||||
|
const selectionEnd = this.textarea.selectionDirection;
|
||||||
|
await this.pointerup;
|
||||||
|
this.textarea.focus();
|
||||||
|
this.textarea.selectionStart = selectionStart;
|
||||||
|
this.textarea.selectionDirection = selectionEnd;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark the item at newIdx in the autocomplete list as selected.
|
||||||
|
* @param {number} newIdx
|
||||||
|
*/
|
||||||
|
selectItemAtIndex(newIdx) {
|
||||||
|
this.selectedItem.dom.classList.remove('selected');
|
||||||
|
this.selectedItem = this.result[newIdx];
|
||||||
|
this.selectedItem.dom.classList.add('selected');
|
||||||
|
const rect = this.selectedItem.dom.children[0].getBoundingClientRect();
|
||||||
|
const rectParent = this.dom.getBoundingClientRect();
|
||||||
|
if (rect.top < rectParent.top || rect.bottom > rectParent.bottom ) {
|
||||||
|
this.dom.scrollTop += rect.top < rectParent.top ? rect.top - rectParent.top : rect.bottom - rectParent.bottom;
|
||||||
|
}
|
||||||
|
this.renderDetailsDebounced();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle keyboard events.
|
||||||
|
* @param {KeyboardEvent} evt The event.
|
||||||
|
*/
|
||||||
|
async handleKeyDown(evt) {
|
||||||
|
// autocomplete is shown and cursor at end of current command name (or inside name and typed or forced)
|
||||||
|
if (this.isActive && this.isReplaceable) {
|
||||||
|
switch (evt.key) {
|
||||||
|
case 'ArrowUp': {
|
||||||
|
// select previous item
|
||||||
|
if (evt.ctrlKey || evt.altKey || evt.shiftKey) return;
|
||||||
|
evt.preventDefault();
|
||||||
|
evt.stopPropagation();
|
||||||
|
const idx = this.result.indexOf(this.selectedItem);
|
||||||
|
let newIdx;
|
||||||
|
if (idx == 0) newIdx = this.result.length - 1;
|
||||||
|
else newIdx = idx - 1;
|
||||||
|
this.selectItemAtIndex(newIdx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'ArrowDown': {
|
||||||
|
// select next item
|
||||||
|
if (evt.ctrlKey || evt.altKey || evt.shiftKey) return;
|
||||||
|
evt.preventDefault();
|
||||||
|
evt.stopPropagation();
|
||||||
|
const idx = this.result.indexOf(this.selectedItem);
|
||||||
|
const newIdx = (idx + 1) % this.result.length;
|
||||||
|
this.selectItemAtIndex(newIdx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'Enter':
|
||||||
|
case 'Tab': {
|
||||||
|
// pick the selected item to autocomplete
|
||||||
|
if (evt.ctrlKey || evt.altKey || evt.shiftKey || this.selectedItem.type == OPTION_TYPE.BLANK) break;
|
||||||
|
evt.preventDefault();
|
||||||
|
evt.stopImmediatePropagation();
|
||||||
|
this.select();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// autocomplete is shown, cursor can be anywhere
|
||||||
|
if (this.isActive) {
|
||||||
|
switch (evt.key) {
|
||||||
|
case 'Escape': {
|
||||||
|
// close autocomplete
|
||||||
|
if (evt.ctrlKey || evt.altKey || evt.shiftKey) return;
|
||||||
|
evt.preventDefault();
|
||||||
|
evt.stopPropagation();
|
||||||
|
this.hide();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'Enter': {
|
||||||
|
// hide autocomplete on enter (send, execute, ...)
|
||||||
|
if (!evt.shiftKey) {
|
||||||
|
this.hide();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// autocomplete shown or not, cursor anywhere
|
||||||
|
switch (evt.key) {
|
||||||
|
case ' ': {
|
||||||
|
if (evt.ctrlKey) {
|
||||||
|
if (this.isActive) {
|
||||||
|
// ctrl-space to toggle details for selected item
|
||||||
|
this.toggleDetails();
|
||||||
|
} else {
|
||||||
|
// ctrl-space to force show autocomplete
|
||||||
|
this.show(true, true);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (['Control', 'Shift', 'Alt'].includes(evt.key)) {
|
||||||
|
// ignore keydown on modifier keys
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
switch (evt.key) {
|
||||||
|
default:
|
||||||
|
case 'ArrowUp':
|
||||||
|
case 'ArrowDown':
|
||||||
|
case 'ArrowRight':
|
||||||
|
case 'ArrowLeft': {
|
||||||
|
if (this.isActive) {
|
||||||
|
// keyboard navigation, wait for keyup to complete cursor move
|
||||||
|
const oldText = this.textarea.value;
|
||||||
|
await new Promise(resolve=>{
|
||||||
|
window.addEventListener('keyup', resolve, { once:true });
|
||||||
|
});
|
||||||
|
if (this.selectionStart != this.textarea.selectionStart) {
|
||||||
|
this.selectionStart = this.textarea.selectionStart;
|
||||||
|
this.show(oldText != this.textarea.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1097,8 +1097,39 @@ select {
|
|||||||
--ac-color-hovered-background: color-mix(in srgb, rgb(128 128 128) 30%, var(--SmartThemeChatTintColor));
|
--ac-color-hovered-background: color-mix(in srgb, rgb(128 128 128) 30%, var(--SmartThemeChatTintColor));
|
||||||
--ac-color-hovered-text: var(--SmartThemeEmColor);
|
--ac-color-hovered-text: var(--SmartThemeEmColor);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.slashCommandAutoComplete-wrap {
|
||||||
|
--targetOffset: 0;
|
||||||
|
--direction: column;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: var(--direction);
|
||||||
|
position: absolute;
|
||||||
|
z-index: 10000;
|
||||||
|
|
||||||
|
|
||||||
|
&.isFloating {
|
||||||
|
--direction: row;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
&:before {
|
||||||
|
content: "";
|
||||||
|
flex: 0 1 calc(var(--targetOffset) * 1px);
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.slashCommandAutoComplete {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 50vw;
|
||||||
|
}
|
||||||
|
&:after {
|
||||||
|
content: "";
|
||||||
|
flex: 1 1 0;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.slashCommandAutoComplete-detailsWrap {
|
.slashCommandAutoComplete-detailsWrap {
|
||||||
--targetTop: 0;
|
--targetOffset: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@ -1106,7 +1137,7 @@ select {
|
|||||||
|
|
||||||
&:before {
|
&:before {
|
||||||
content: "";
|
content: "";
|
||||||
flex: 0 1 calc(var(--targetTop) * 1px - 5vh);
|
flex: 0 1 calc(var(--targetOffset) * 1px - 5vh);
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
.slashCommandAutoComplete-details {
|
.slashCommandAutoComplete-details {
|
||||||
@ -1118,6 +1149,38 @@ select {
|
|||||||
flex: 1 1 0;
|
flex: 1 1 0;
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.isFloating {
|
||||||
|
flex-direction: row;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
&.right:before {
|
||||||
|
flex: 0 0 calc(var(--targetOffset) * 1px + 50vw);
|
||||||
|
}
|
||||||
|
&:before {
|
||||||
|
flex: 0 0 calc(var(--targetOffset) * 1px - 25vw);
|
||||||
|
}
|
||||||
|
.slashCommandAutoComplete-details {
|
||||||
|
max-height: unset;
|
||||||
|
width: 25vw;
|
||||||
|
}
|
||||||
|
&.full {
|
||||||
|
&:before {
|
||||||
|
content: "";
|
||||||
|
flex: 0 1 calc(var(--targetOffset) * 1px);
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.slashCommandAutoComplete-details {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 50vw;
|
||||||
|
}
|
||||||
|
&:after {
|
||||||
|
content: "";
|
||||||
|
flex: 1 1 0;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.slashCommandAutoComplete, .slashCommandAutoComplete-details {
|
.slashCommandAutoComplete, .slashCommandAutoComplete-details {
|
||||||
--ac-color-border: rgb(69 69 69);
|
--ac-color-border: rgb(69 69 69);
|
||||||
@ -1151,7 +1214,7 @@ select {
|
|||||||
}
|
}
|
||||||
.slashCommandAutoComplete {
|
.slashCommandAutoComplete {
|
||||||
padding-bottom: 1px;
|
padding-bottom: 1px;
|
||||||
position: absolute;
|
/* position: absolute; */
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 0fr auto minmax(50%, 1fr);
|
grid-template-columns: 0fr auto minmax(50%, 1fr);
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
@ -1372,7 +1435,6 @@ select {
|
|||||||
}
|
}
|
||||||
> .returns {
|
> .returns {
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
white-space: nowrap;
|
|
||||||
color: var(--ac-color-text);
|
color: var(--ac-color-text);
|
||||||
&:before {
|
&:before {
|
||||||
content: "=> ";
|
content: "=> ";
|
||||||
|
Reference in New Issue
Block a user