mirror of
https://github.com/SillyTavern/SillyTavern.git
synced 2025-06-05 21:59:27 +02:00
towards generic autocomplete
This commit is contained in:
@ -2608,7 +2608,13 @@ 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 ac = new SlashCommandAutoComplete(textarea, isFloating);
|
const parser = new SlashCommandParser();
|
||||||
|
const ac = new SlashCommandAutoComplete(
|
||||||
|
textarea,
|
||||||
|
() => ac.text[0] == '/',
|
||||||
|
async(text, index) => await parser.getNameAt(text, index),
|
||||||
|
isFloating,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
/**@type {HTMLTextAreaElement} */
|
/**@type {HTMLTextAreaElement} */
|
||||||
const sendTextarea = document.querySelector('#send_textarea');
|
const sendTextarea = document.querySelector('#send_textarea');
|
||||||
|
@ -1,15 +1,14 @@
|
|||||||
import { power_user } from '../power-user.js';
|
import { power_user } from '../power-user.js';
|
||||||
import { debounce, escapeRegex } from '../utils.js';
|
import { debounce, escapeRegex } from '../utils.js';
|
||||||
import { OPTION_TYPE, SlashCommandAutoCompleteOption, SlashCommandFuzzyScore } from './SlashCommandAutoCompleteOption.js';
|
import { OPTION_TYPE, SlashCommandAutoCompleteOption, SlashCommandFuzzyScore } from './SlashCommandAutoCompleteOption.js';
|
||||||
import { SlashCommandParser } from './SlashCommandParser.js';
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
// eslint-disable-next-line no-unused-vars
|
||||||
import { NAME_RESULT_TYPE, SlashCommandParserNameResult } from './SlashCommandParserNameResult.js';
|
import { SlashCommandParserNameResult } from './SlashCommandParserNameResult.js';
|
||||||
|
|
||||||
export class SlashCommandAutoComplete {
|
export class SlashCommandAutoComplete {
|
||||||
/**@type {HTMLTextAreaElement}*/ textarea;
|
/**@type {HTMLTextAreaElement}*/ textarea;
|
||||||
/**@type {boolean}*/ isFloating = false;
|
/**@type {boolean}*/ isFloating = false;
|
||||||
|
/**@type {()=>boolean}*/ checkIfActivate;
|
||||||
/**@type {SlashCommandParser}*/ parser;
|
/**@type {(text:string, index:number) => Promise<SlashCommandParserNameResult>}*/ getNameAt;
|
||||||
|
|
||||||
/**@type {boolean}*/ isActive = false;
|
/**@type {boolean}*/ isActive = false;
|
||||||
/**@type {boolean}*/ isReplaceable = false;
|
/**@type {boolean}*/ isReplaceable = false;
|
||||||
@ -17,7 +16,7 @@ export class SlashCommandAutoComplete {
|
|||||||
|
|
||||||
/**@type {string}*/ text;
|
/**@type {string}*/ text;
|
||||||
/**@type {SlashCommandParserNameResult}*/ parserResult;
|
/**@type {SlashCommandParserNameResult}*/ parserResult;
|
||||||
/**@type {string}*/ slashCommand;
|
/**@type {string}*/ name;
|
||||||
|
|
||||||
/**@type {boolean}*/ startQuote;
|
/**@type {boolean}*/ startQuote;
|
||||||
/**@type {boolean}*/ endQuote;
|
/**@type {boolean}*/ endQuote;
|
||||||
@ -55,14 +54,16 @@ export class SlashCommandAutoComplete {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {HTMLTextAreaElement} textarea The textarea to receive autocomplete.
|
* @param {HTMLTextAreaElement} textarea The textarea to receive autocomplete.
|
||||||
|
* @param {() => boolean} checkIfActivate
|
||||||
|
* @param {(text: string, index: number) => Promise<SlashCommandParserNameResult>} getNameAt
|
||||||
* @param {boolean} isFloating Whether autocomplete should float at the keyboard cursor.
|
* @param {boolean} isFloating Whether autocomplete should float at the keyboard cursor.
|
||||||
*/
|
*/
|
||||||
constructor(textarea, isFloating = false) {
|
constructor(textarea, checkIfActivate, getNameAt, isFloating = false) {
|
||||||
this.textarea = textarea;
|
this.textarea = textarea;
|
||||||
|
this.checkIfActivate = checkIfActivate;
|
||||||
|
this.getNameAt = getNameAt;
|
||||||
this.isFloating = isFloating;
|
this.isFloating = isFloating;
|
||||||
|
|
||||||
this.parser = new SlashCommandParser();
|
|
||||||
|
|
||||||
this.domWrap = document.createElement('div'); {
|
this.domWrap = document.createElement('div'); {
|
||||||
this.domWrap.classList.add('slashCommandAutoComplete-wrap');
|
this.domWrap.classList.add('slashCommandAutoComplete-wrap');
|
||||||
if (isFloating) this.domWrap.classList.add('isFloating');
|
if (isFloating) this.domWrap.classList.add('isFloating');
|
||||||
@ -97,21 +98,6 @@ export class SlashCommandAutoComplete {
|
|||||||
window.addEventListener('resize', ()=>this.updatePositionDebounced());
|
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(new SlashCommandAutoCompleteOption(OPTION_TYPE.COMMAND, cmd, key));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @param {SlashCommandAutoCompleteOption} option
|
* @param {SlashCommandAutoCompleteOption} option
|
||||||
@ -154,7 +140,7 @@ export class SlashCommandAutoComplete {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'includes': {
|
case 'includes': {
|
||||||
const start = item.name.toLowerCase().search(this.slashCommand);
|
const start = item.name.toLowerCase().search(this.name);
|
||||||
chars.forEach((it, idx)=>{
|
chars.forEach((it, idx)=>{
|
||||||
if (idx < start) {
|
if (idx < start) {
|
||||||
it.classList.remove('matched');
|
it.classList.remove('matched');
|
||||||
@ -248,129 +234,79 @@ export class SlashCommandAutoComplete {
|
|||||||
this.text = this.textarea.value;
|
this.text = this.textarea.value;
|
||||||
// only show with textarea in focus
|
// only show with textarea in focus
|
||||||
if (document.activeElement != this.textarea) return this.hide();
|
if (document.activeElement != this.textarea) return this.hide();
|
||||||
// only show for slash commands
|
// only show if provider wants to
|
||||||
//TODO activation-requirements could be provided as a function
|
if (!this.checkIfActivate()) return this.hide();
|
||||||
if (this.text[0] != '/') return this.hide();
|
|
||||||
|
|
||||||
this.buildCache();
|
// request provider to get name result (potentially "incomplete", i.e. not an actual existing name) for
|
||||||
|
|
||||||
// request parser to get command executor (potentially "incomplete", i.e. not an actual existing command) for
|
|
||||||
// cursor position
|
// cursor position
|
||||||
//TODO nameProvider function provided when instantiating?
|
this.parserResult = await this.getNameAt(this.text, this.textarea.selectionStart);
|
||||||
this.parserResult = this.parser.getNameAt(this.text, this.textarea.selectionStart);
|
|
||||||
|
|
||||||
//TODO options could be fully provided by the name source
|
|
||||||
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
|
|
||||||
//TODO whether the input is quotable could be an option (given by the parserResult?)
|
|
||||||
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) {
|
|
||||||
//TODO input quotable (see above)
|
|
||||||
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
|
// don't show if no executor found, i.e. cursor's area is not a command
|
||||||
if (!this.parserResult) return this.hide();
|
if (!this.parserResult) return this.hide();
|
||||||
else {
|
|
||||||
let matchingOptions = this.parserResult.optionList
|
// need to know if name *can* be inside quotes, and then check if quotes are already there
|
||||||
.filter(it => this.isReplaceable || it.name == '' ? matchers[this.matchType](it.name) : it.name.toLowerCase() == this.slashCommand) // Filter by the input
|
if (this.parserResult.canBeQuoted) {
|
||||||
.filter((it,idx,list) => list.findIndex(opt=>opt.value == it.value) == idx)
|
this.startQuote = this.text[this.parserResult.start] == '"';
|
||||||
;
|
this.endQuote = this.startQuote && this.text[this.parserResult.start + this.parserResult.name.length + 1] == '"';
|
||||||
this.result = matchingOptions
|
} else {
|
||||||
.filter((it,idx) => matchingOptions.indexOf(it) == idx)
|
this.startQuote = false;
|
||||||
.map(option => {
|
this.endQuote = false;
|
||||||
let li;
|
|
||||||
//TODO makeItem should be handled in the option class
|
|
||||||
switch (option.type) {
|
|
||||||
case OPTION_TYPE.QUICK_REPLY: {
|
|
||||||
li = this.makeItem(option);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case OPTION_TYPE.VARIABLE_NAME: {
|
|
||||||
li = this.makeItem(option);
|
|
||||||
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
|
|
||||||
;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// use lowercase name for matching
|
||||||
|
this.name = 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
|
||||||
|
this.isReplaceable = isInput && (!this.parserResult ? true : this.textarea.selectionStart == this.parserResult.start + this.parserResult.name.length + (this.startQuote ? 1 : 0));
|
||||||
|
|
||||||
|
// if [forced (ctrl+space) or user input] and cursor is in the middle of the name part (not at the end)
|
||||||
|
if (isForced || isInput) {
|
||||||
|
if (this.textarea.selectionStart >= this.parserResult.start && this.textarea.selectionStart <= this.parserResult.start + this.parserResult.name.length + (this.startQuote ? 1 : 0)) {
|
||||||
|
this.name = this.name.slice(0, this.textarea.selectionStart - (this.parserResult.start) - (this.startQuote ? 1 : 0));
|
||||||
|
this.parserResult.name = this.name;
|
||||||
|
this.isReplaceable = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// only build the fuzzy regex if match type is set to fuzzy
|
||||||
|
if (this.matchType == 'fuzzy') {
|
||||||
|
this.fuzzyRegex = new RegExp(`^(.*?)${this.name.split('').map(char=>`(${escapeRegex(char)})`).join('(.*?)')}(.*?)$`, 'i');
|
||||||
|
}
|
||||||
|
|
||||||
|
//TODO maybe move the matchers somewhere else; a single match function? matchType is available as property
|
||||||
|
const matchers = {
|
||||||
|
'strict': (name) => name.toLowerCase().startsWith(this.name),
|
||||||
|
'includes': (name) => name.toLowerCase().includes(this.name),
|
||||||
|
'fuzzy': (name) => this.fuzzyRegex.test(name),
|
||||||
|
};
|
||||||
|
|
||||||
|
this.result = this.parserResult.optionList
|
||||||
|
// filter the list of options by the partial name according to the matching type
|
||||||
|
.filter(it => this.isReplaceable || it.name == '' ? matchers[this.matchType](it.name) : it.name.toLowerCase() == this.name)
|
||||||
|
// remove aliases
|
||||||
|
.filter((it,idx,list) => list.findIndex(opt=>opt.value == it.value) == idx)
|
||||||
|
// update remaining options
|
||||||
|
.map(option => {
|
||||||
|
// build element
|
||||||
|
option.dom = this.makeItem(option);
|
||||||
|
// update replacer and add quotes if necessary
|
||||||
|
if (this.parserResult.canBeQuoted) {
|
||||||
|
option.replacer = option.name.includes(' ') || this.startQuote || this.endQuote ? `"${option.name}"` : `${option.name}`;
|
||||||
|
} else {
|
||||||
|
option.replacer = option.name;
|
||||||
|
}
|
||||||
|
// calculate fuzzy score if matching is fuzzy
|
||||||
|
if (this.matchType == 'fuzzy') this.fuzzyScore(option);
|
||||||
|
// update the name to highlight the matched chars
|
||||||
|
this.updateName(option);
|
||||||
|
return option;
|
||||||
|
})
|
||||||
|
// sort by fuzzy score or alphabetical
|
||||||
|
.toSorted(this.matchType == 'fuzzy' ? this.fuzzyScoreCompare : (a, b) => a.name.localeCompare(b.name))
|
||||||
|
;
|
||||||
|
|
||||||
|
|
||||||
if (this.result.length == 0) {
|
if (this.result.length == 0) {
|
||||||
// no result and no input? hide autocomplete
|
// no result and no input? hide autocomplete
|
||||||
if (!isInput) {
|
if (!isInput) {
|
||||||
@ -382,27 +318,13 @@ export class SlashCommandAutoComplete {
|
|||||||
null,
|
null,
|
||||||
'',
|
'',
|
||||||
);
|
);
|
||||||
switch (this.parserResult?.type) {
|
const li = document.createElement('li'); {
|
||||||
//TODO "no-match" text should be an option (in parserResult?)
|
li.textContent = this.name.length ?
|
||||||
case NAME_RESULT_TYPE.CLOSURE: {
|
this.parserResult.makeNoMatchText()
|
||||||
const li = document.createElement('li'); {
|
: this.parserResult.makeNoOptionstext();
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
option.dom = li;
|
||||||
|
this.result.push(option);
|
||||||
} else if (this.result.length == 1 && this.parserResult && this.result[0].name == this.parserResult.name) {
|
} 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
|
// only one result that is exactly the current value? just show hint, no autocomplete
|
||||||
this.isReplaceable = false;
|
this.isReplaceable = false;
|
||||||
@ -637,10 +559,10 @@ export class SlashCommandAutoComplete {
|
|||||||
*/
|
*/
|
||||||
async select() {
|
async select() {
|
||||||
if (this.isReplaceable && this.selectedItem.value !== null) {
|
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))}`;
|
this.textarea.value = `${this.text.slice(0, this.parserResult.start)}${this.selectedItem.replacer}${this.text.slice(this.parserResult.start + this.parserResult.name.length + (this.startQuote ? 1 : 0) + (this.endQuote ? 1 : 0))}`;
|
||||||
await this.pointerup;
|
await this.pointerup;
|
||||||
this.textarea.focus();
|
this.textarea.focus();
|
||||||
this.textarea.selectionStart = this.parserResult.start - 2 + this.selectedItem.replacer.length;
|
this.textarea.selectionStart = this.parserResult.start + this.selectedItem.replacer.length;
|
||||||
this.textarea.selectionEnd = this.textarea.selectionStart;
|
this.textarea.selectionEnd = this.textarea.selectionStart;
|
||||||
this.show();
|
this.show();
|
||||||
} else {
|
} else {
|
||||||
@ -704,7 +626,7 @@ export class SlashCommandAutoComplete {
|
|||||||
case 'Enter': {
|
case 'Enter': {
|
||||||
// pick the selected item to autocomplete
|
// pick the selected item to autocomplete
|
||||||
if (evt.ctrlKey || evt.altKey || evt.shiftKey || this.selectedItem.type == OPTION_TYPE.BLANK) break;
|
if (evt.ctrlKey || evt.altKey || evt.shiftKey || this.selectedItem.type == OPTION_TYPE.BLANK) break;
|
||||||
if (this.selectedItem.name == this.slashCommand) break;
|
if (this.selectedItem.name == this.name) break;
|
||||||
evt.preventDefault();
|
evt.preventDefault();
|
||||||
evt.stopImmediatePropagation();
|
evt.stopImmediatePropagation();
|
||||||
this.select();
|
this.select();
|
||||||
|
@ -2,15 +2,6 @@ import { SlashCommand } from './SlashCommand.js';
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
/**@readonly*/
|
|
||||||
/**@enum {Number}*/
|
|
||||||
export const OPTION_TYPE = {
|
|
||||||
'COMMAND': 1,
|
|
||||||
'QUICK_REPLY': 2,
|
|
||||||
'VARIABLE_NAME': 3,
|
|
||||||
'BLANK': 4,
|
|
||||||
};
|
|
||||||
|
|
||||||
export class SlashCommandFuzzyScore {
|
export class SlashCommandFuzzyScore {
|
||||||
/**@type {number}*/ start;
|
/**@type {number}*/ start;
|
||||||
/**@type {number}*/ longestConsecutive;
|
/**@type {number}*/ longestConsecutive;
|
||||||
@ -27,7 +18,6 @@ export class SlashCommandFuzzyScore {
|
|||||||
|
|
||||||
|
|
||||||
export class SlashCommandAutoCompleteOption {
|
export class SlashCommandAutoCompleteOption {
|
||||||
/**@type {OPTION_TYPE}*/ type;
|
|
||||||
/**@type {string|SlashCommand}*/ value;
|
/**@type {string|SlashCommand}*/ value;
|
||||||
/**@type {string}*/ name;
|
/**@type {string}*/ name;
|
||||||
/**@type {SlashCommandFuzzyScore}*/ score;
|
/**@type {SlashCommandFuzzyScore}*/ score;
|
||||||
@ -36,39 +26,15 @@ export class SlashCommandAutoCompleteOption {
|
|||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {OPTION_TYPE} type
|
|
||||||
* @param {string|SlashCommand} value
|
* @param {string|SlashCommand} value
|
||||||
* @param {string} name
|
* @param {string} name
|
||||||
*/
|
*/
|
||||||
constructor(type, value, name) {
|
constructor(value, name) {
|
||||||
this.type = type;
|
|
||||||
this.value = value;
|
this.value = value;
|
||||||
this.name = name;
|
this.name = name;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
renderItem() {
|
|
||||||
let li;
|
|
||||||
switch (this.type) {
|
|
||||||
case OPTION_TYPE.COMMAND: {
|
|
||||||
/**@type {SlashCommand}*/
|
|
||||||
// @ts-ignore
|
|
||||||
const cmd = this.value;
|
|
||||||
li = cmd.renderHelpItem(this.name);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case OPTION_TYPE.QUICK_REPLY: {
|
|
||||||
li = this.makeItem(this.name, 'QR', true);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case OPTION_TYPE.VARIABLE_NAME: {
|
|
||||||
li = this.makeItem(this.name, '𝑥', true);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
li.setAttribute('data-name', this.name);
|
|
||||||
return li;
|
|
||||||
}
|
|
||||||
makeItem(key, typeIcon, noSlash, namedArguments = [], unnamedArguments = [], returnType = 'void', helpString = '', aliasList = []) {
|
makeItem(key, typeIcon, noSlash, namedArguments = [], unnamedArguments = [], returnType = 'void', helpString = '', aliasList = []) {
|
||||||
const li = document.createElement('li'); {
|
const li = document.createElement('li'); {
|
||||||
li.classList.add('item');
|
li.classList.add('item');
|
||||||
@ -174,7 +140,7 @@ export class SlashCommandAutoCompleteOption {
|
|||||||
const returns = document.createElement('span'); {
|
const returns = document.createElement('span'); {
|
||||||
returns.classList.add('returns');
|
returns.classList.add('returns');
|
||||||
returns.textContent = returnType ?? 'void';
|
returns.textContent = returnType ?? 'void';
|
||||||
body.append(returns);
|
// body.append(returns);
|
||||||
}
|
}
|
||||||
specs.append(body);
|
specs.append(body);
|
||||||
}
|
}
|
||||||
@ -207,85 +173,17 @@ export class SlashCommandAutoCompleteOption {
|
|||||||
// li.append(aliases);
|
// 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;
|
return li;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
renderItem() {
|
||||||
|
throw new Error(`${this.constructor.name}.renderItem() is not implemented`);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
renderDetails() {
|
renderDetails() {
|
||||||
switch (this.type) {
|
throw new Error(`${this.constructor.name}.renderDetails() is not implemented`);
|
||||||
case OPTION_TYPE.COMMAND: {
|
|
||||||
return this.renderCommandDetails();
|
|
||||||
}
|
|
||||||
case OPTION_TYPE.QUICK_REPLY: {
|
|
||||||
return this.renderQuickReplyDetails();
|
|
||||||
}
|
|
||||||
case OPTION_TYPE.VARIABLE_NAME: {
|
|
||||||
return this.renderVariableDetails();
|
|
||||||
}
|
|
||||||
default: {
|
|
||||||
return this.renderBlankDetails();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
renderBlankDetails() {
|
|
||||||
return 'BLANK';
|
|
||||||
}
|
|
||||||
renderQuickReplyDetails() {
|
|
||||||
const frag = document.createDocumentFragment();
|
|
||||||
const specs = document.createElement('div'); {
|
|
||||||
specs.classList.add('specs');
|
|
||||||
const name = document.createElement('div'); {
|
|
||||||
name.classList.add('name');
|
|
||||||
name.classList.add('monospace');
|
|
||||||
name.textContent = this.value.toString();
|
|
||||||
specs.append(name);
|
|
||||||
}
|
|
||||||
frag.append(specs);
|
|
||||||
}
|
|
||||||
const help = document.createElement('span'); {
|
|
||||||
help.classList.add('help');
|
|
||||||
help.textContent = 'Quick Reply';
|
|
||||||
frag.append(help);
|
|
||||||
}
|
|
||||||
return frag;
|
|
||||||
}
|
|
||||||
renderVariableDetails() {
|
|
||||||
const frag = document.createDocumentFragment();
|
|
||||||
const specs = document.createElement('div'); {
|
|
||||||
specs.classList.add('specs');
|
|
||||||
const name = document.createElement('div'); {
|
|
||||||
name.classList.add('name');
|
|
||||||
name.classList.add('monospace');
|
|
||||||
name.textContent = this.value.toString();
|
|
||||||
specs.append(name);
|
|
||||||
}
|
|
||||||
frag.append(specs);
|
|
||||||
}
|
|
||||||
const help = document.createElement('span'); {
|
|
||||||
help.classList.add('help');
|
|
||||||
help.textContent = 'scoped variable';
|
|
||||||
frag.append(help);
|
|
||||||
}
|
|
||||||
return frag;
|
|
||||||
}
|
|
||||||
renderCommandDetails() {
|
|
||||||
const key = this.name;
|
|
||||||
/**@type {SlashCommand} */
|
|
||||||
// @ts-ignore
|
|
||||||
const cmd = this.value;
|
|
||||||
return cmd.renderHelpDetails(key);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,30 @@
|
|||||||
|
import { SlashCommand } from './SlashCommand.js';
|
||||||
|
import { SlashCommandAutoCompleteOption } from './SlashCommandAutoCompleteOption.js';
|
||||||
|
|
||||||
|
export class SlashCommandCommandAutoCompleteOption extends SlashCommandAutoCompleteOption {
|
||||||
|
/**@type {SlashCommand} */
|
||||||
|
get cmd() {
|
||||||
|
// @ts-ignore
|
||||||
|
return this.value;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* @param {SlashCommand} value
|
||||||
|
* @param {string} name
|
||||||
|
*/
|
||||||
|
constructor(value, name) {
|
||||||
|
super(value, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
renderItem() {
|
||||||
|
let li;
|
||||||
|
li = this.cmd.renderHelpItem(this.name);
|
||||||
|
li.setAttribute('data-name', this.name);
|
||||||
|
return li;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
renderDetails() {
|
||||||
|
return this.cmd.renderHelpDetails(this.name);
|
||||||
|
}
|
||||||
|
}
|
@ -1,14 +1,16 @@
|
|||||||
import { power_user } from '../power-user.js';
|
import { power_user } from '../power-user.js';
|
||||||
import { isTrueBoolean, uuidv4 } from '../utils.js';
|
import { isTrueBoolean, uuidv4 } from '../utils.js';
|
||||||
import { SlashCommand } from './SlashCommand.js';
|
import { SlashCommand } from './SlashCommand.js';
|
||||||
import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from './SlashCommandArgument.js';
|
import { ARGUMENT_TYPE, SlashCommandArgument } from './SlashCommandArgument.js';
|
||||||
import { OPTION_TYPE, SlashCommandAutoCompleteOption } from './SlashCommandAutoCompleteOption.js';
|
|
||||||
import { SlashCommandClosure } from './SlashCommandClosure.js';
|
import { SlashCommandClosure } from './SlashCommandClosure.js';
|
||||||
|
import { SlashCommandCommandAutoCompleteOption } from './SlashCommandCommandAutoCompleteOption.js';
|
||||||
import { SlashCommandExecutor } from './SlashCommandExecutor.js';
|
import { SlashCommandExecutor } from './SlashCommandExecutor.js';
|
||||||
import { SlashCommandParserError } from './SlashCommandParserError.js';
|
import { SlashCommandParserError } from './SlashCommandParserError.js';
|
||||||
import { NAME_RESULT_TYPE, SlashCommandParserNameResult } from './SlashCommandParserNameResult.js';
|
import { NAME_RESULT_TYPE, SlashCommandParserNameResult } from './SlashCommandParserNameResult.js';
|
||||||
|
import { SlashCommandQuickReplyAutoCompleteOption } from './SlashCommandQuickReplyAutoCompleteOption.js';
|
||||||
// eslint-disable-next-line no-unused-vars
|
// eslint-disable-next-line no-unused-vars
|
||||||
import { SlashCommandScope } from './SlashCommandScope.js';
|
import { SlashCommandScope } from './SlashCommandScope.js';
|
||||||
|
import { SlashCommandVariableAutoCompleteOption } from './SlashCommandVariableAutoCompleteOption.js';
|
||||||
|
|
||||||
/**@readonly*/
|
/**@readonly*/
|
||||||
/**@enum {Number}*/
|
/**@enum {Number}*/
|
||||||
@ -345,7 +347,7 @@ export class SlashCommandParser {
|
|||||||
* @param {*} text The text to parse.
|
* @param {*} text The text to parse.
|
||||||
* @param {*} index Index to check for names (cursor position).
|
* @param {*} index Index to check for names (cursor position).
|
||||||
*/
|
*/
|
||||||
getNameAt(text, index) {
|
async getNameAt(text, index) {
|
||||||
if (this.text != `{:${text}:}`) {
|
if (this.text != `{:${text}:}`) {
|
||||||
try {
|
try {
|
||||||
this.parse(text, false);
|
this.parse(text, false);
|
||||||
@ -368,22 +370,43 @@ export class SlashCommandParser {
|
|||||||
;
|
;
|
||||||
if (childClosure !== null) return null;
|
if (childClosure !== null) return null;
|
||||||
if (executor.name == ':') {
|
if (executor.name == ':') {
|
||||||
return new SlashCommandParserNameResult(
|
const options = this.scopeIndex[this.commandIndex.indexOf(executor)]
|
||||||
|
?.allVariableNames
|
||||||
|
?.map(it=>new SlashCommandVariableAutoCompleteOption(it))
|
||||||
|
?? []
|
||||||
|
;
|
||||||
|
try {
|
||||||
|
const qrApi = (await import('../extensions/quick-reply/index.js')).quickReplyApi;
|
||||||
|
options.push(...qrApi.listSets()
|
||||||
|
.map(set=>qrApi.listQuickReplies(set).map(qr=>`${set}.${qr}`))
|
||||||
|
.flat()
|
||||||
|
.map(qr=>new SlashCommandQuickReplyAutoCompleteOption(qr)),
|
||||||
|
);
|
||||||
|
} catch { /* empty */ }
|
||||||
|
const result = new SlashCommandParserNameResult(
|
||||||
NAME_RESULT_TYPE.CLOSURE,
|
NAME_RESULT_TYPE.CLOSURE,
|
||||||
executor.value.toString(),
|
executor.value.toString(),
|
||||||
executor.start,
|
executor.start - 2,
|
||||||
this.scopeIndex[this.commandIndex.indexOf(executor)]
|
options,
|
||||||
?.allVariableNames
|
true,
|
||||||
?.map(it=>new SlashCommandAutoCompleteOption(OPTION_TYPE.VARIABLE_NAME, it, it))
|
()=>`No matching variables in scope and no matching Quick Replies for "${result.name}"`,
|
||||||
?? []
|
()=>'No variables in scope and no Quick Replies found.',
|
||||||
,
|
|
||||||
);
|
);
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
return new SlashCommandParserNameResult(
|
const result = new SlashCommandParserNameResult(
|
||||||
NAME_RESULT_TYPE.COMMAND,
|
NAME_RESULT_TYPE.COMMAND,
|
||||||
executor.name,
|
executor.name,
|
||||||
executor.start,
|
executor.start - 2,
|
||||||
|
Object
|
||||||
|
.keys(this.commands)
|
||||||
|
.map(key=>new SlashCommandCommandAutoCompleteOption(this.commands[key], key))
|
||||||
|
,
|
||||||
|
false,
|
||||||
|
()=>`No matching slash commands for "/${result.name}"`,
|
||||||
|
()=>'No slash commands found!',
|
||||||
);
|
);
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
@ -15,6 +15,9 @@ export class SlashCommandParserNameResult {
|
|||||||
/**@type {string} */ name;
|
/**@type {string} */ name;
|
||||||
/**@type {number} */ start;
|
/**@type {number} */ start;
|
||||||
/**@type {SlashCommandAutoCompleteOption[]} */ optionList = [];
|
/**@type {SlashCommandAutoCompleteOption[]} */ optionList = [];
|
||||||
|
/**@type {boolean} */ canBeQuoted = false;
|
||||||
|
/**@type {()=>string} */ makeNoMatchText = ()=>`No matches found for "${this.name}"`;
|
||||||
|
/**@type {()=>string} */ makeNoOptionstext = ()=>'No options';
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -22,11 +25,17 @@ export class SlashCommandParserNameResult {
|
|||||||
* @param {string} name Name (potentially partial) of the name at the requested index.
|
* @param {string} name Name (potentially partial) of the name at the requested index.
|
||||||
* @param {number} start Index where the name starts.
|
* @param {number} start Index where the name starts.
|
||||||
* @param {SlashCommandAutoCompleteOption[]} optionList A list of autocomplete options found in the current scope.
|
* @param {SlashCommandAutoCompleteOption[]} optionList A list of autocomplete options found in the current scope.
|
||||||
|
* @param {boolean} canBeQuoted Whether the name can be inside quotes.
|
||||||
|
* @param {()=>string} makeNoMatchText Function that returns text to show when no matches where found.
|
||||||
|
* @param {()=>string} makeNoOptionsText Function that returns text to show when no options are available to match against.
|
||||||
*/
|
*/
|
||||||
constructor(type, name, start, optionList = []) {
|
constructor(type, name, start, optionList = [], canBeQuoted = false, makeNoMatchText = null, makeNoOptionsText = null) {
|
||||||
this.type = type;
|
this.type = type;
|
||||||
this.name = name;
|
this.name = name;
|
||||||
this.start = start;
|
this.start = start;
|
||||||
this.optionList = optionList;
|
this.optionList = optionList;
|
||||||
|
this.canBeQuoted = canBeQuoted;
|
||||||
|
this.noMatchText = makeNoMatchText ?? this.makeNoMatchText;
|
||||||
|
this.noOptionstext = makeNoOptionsText ?? this.makeNoOptionstext;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,39 @@
|
|||||||
|
import { SlashCommandAutoCompleteOption } from './SlashCommandAutoCompleteOption.js';
|
||||||
|
|
||||||
|
export class SlashCommandQuickReplyAutoCompleteOption extends SlashCommandAutoCompleteOption {
|
||||||
|
/**
|
||||||
|
* @param {string} value
|
||||||
|
*/
|
||||||
|
constructor(value) {
|
||||||
|
super(value, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
renderItem() {
|
||||||
|
let li;
|
||||||
|
li = this.makeItem(this.name, 'QR', true);
|
||||||
|
li.setAttribute('data-name', this.name);
|
||||||
|
return li;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
renderDetails() {
|
||||||
|
const frag = document.createDocumentFragment();
|
||||||
|
const specs = document.createElement('div'); {
|
||||||
|
specs.classList.add('specs');
|
||||||
|
const name = document.createElement('div'); {
|
||||||
|
name.classList.add('name');
|
||||||
|
name.classList.add('monospace');
|
||||||
|
name.textContent = this.value.toString();
|
||||||
|
specs.append(name);
|
||||||
|
}
|
||||||
|
frag.append(specs);
|
||||||
|
}
|
||||||
|
const help = document.createElement('span'); {
|
||||||
|
help.classList.add('help');
|
||||||
|
help.textContent = 'Quick Reply';
|
||||||
|
frag.append(help);
|
||||||
|
}
|
||||||
|
return frag;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,39 @@
|
|||||||
|
import { SlashCommandAutoCompleteOption } from './SlashCommandAutoCompleteOption.js';
|
||||||
|
|
||||||
|
export class SlashCommandVariableAutoCompleteOption extends SlashCommandAutoCompleteOption {
|
||||||
|
/**
|
||||||
|
* @param {string} value
|
||||||
|
*/
|
||||||
|
constructor(value) {
|
||||||
|
super(value, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
renderItem() {
|
||||||
|
let li;
|
||||||
|
li = this.makeItem(this.name, '𝑥', true);
|
||||||
|
li.setAttribute('data-name', this.name);
|
||||||
|
return li;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
renderDetails() {
|
||||||
|
const frag = document.createDocumentFragment();
|
||||||
|
const specs = document.createElement('div'); {
|
||||||
|
specs.classList.add('specs');
|
||||||
|
const name = document.createElement('div'); {
|
||||||
|
name.classList.add('name');
|
||||||
|
name.classList.add('monospace');
|
||||||
|
name.textContent = this.value.toString();
|
||||||
|
specs.append(name);
|
||||||
|
}
|
||||||
|
frag.append(specs);
|
||||||
|
}
|
||||||
|
const help = document.createElement('span'); {
|
||||||
|
help.classList.add('help');
|
||||||
|
help.textContent = 'scoped variable';
|
||||||
|
frag.append(help);
|
||||||
|
}
|
||||||
|
return frag;
|
||||||
|
}
|
||||||
|
}
|
Reference in New Issue
Block a user