Preact: Add tab autocomplete (#2667)
Some checks are pending
Node.js CI / build (22.x) (push) Waiting to run

This commit is contained in:
Daniel Chen 2026-05-01 23:29:59 -04:00 committed by GitHub
parent b127c965a4
commit 075ff28166
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 59 additions and 13 deletions

View File

@ -824,6 +824,19 @@ export class CopyableURLBox extends preact.Component<{ url: string }> {
}
}
interface UserAutoCompleteCandidate {
type: "user";
userid: string;
prefixIndex: number;
}
interface CmdAutoCompleteCandidate {
type: "command";
command: string;
}
export type AutoCompleteCandidate = UserAutoCompleteCandidate | CmdAutoCompleteCandidate;
export class ChatTextEntry extends preact.Component<{
room: ChatRoom, onMessage: (msg: string, elem: HTMLElement) => void, onKey: (e: KeyboardEvent) => boolean,
left?: number, tinyLayout?: boolean,
@ -834,7 +847,7 @@ export class ChatTextEntry extends preact.Component<{
history: string[] = [];
historyIndex = 0;
tabComplete: {
candidates: { userid: string, prefixIndex: number }[],
candidates: AutoCompleteCandidate[],
candidateIndex: number,
/** the text left of the cursor before tab completing */
prefix: string,
@ -1099,7 +1112,7 @@ export class ChatTextEntry extends preact.Component<{
return false;
}
// TODO - add support for commands tabcomplete
handleTabComplete(reverse: boolean) {
handleTabComplete(reverse: boolean): boolean {
// Don't tab complete at the start of the text box.
let { value, start, end } = this.getSelection();
if (start !== end || end === 0) return false;
@ -1134,23 +1147,25 @@ export class ChatTextEntry extends preact.Component<{
const match2 = /^([\s\S!/]*?)([A-Za-z0-9][^, \n]* [^, ]*)$/.exec(prefix);
if (!match1 && !match2) return true;
const candidates: AutoCompleteCandidate[] = [];
const idprefix = (match1 ? toID(match1[2]) : '');
let spaceprefix = (match2 ? match2[2].replace(/[^A-Za-z0-9 ]+/g, '').toLowerCase() : '');
const candidates: { userid: string, prefixIndex: number }[] = [];
if (match2 && (match2[0] === '/' || match2[0] === '!')) spaceprefix = '';
for (const userid in users) {
if (spaceprefix && users[userid].slice(1).replace(/[^A-Za-z0-9 ]+/g, '')
.toLowerCase()
.startsWith(spaceprefix)) {
if (match2) candidates.push({ userid, prefixIndex: match2[1].length });
if (match2) candidates.push({ type: "user", userid, prefixIndex: match2[1].length });
} else if (idprefix && userid.startsWith(idprefix)) {
if (match1) candidates.push({ userid, prefixIndex: match1[1].length });
if (match1) candidates.push({ type: "user", userid, prefixIndex: match1[1].length });
}
}
// Sort by most recent to speak in the chat, or, in the case of a tie,
// in alphabetical order.
const userActivity = this.props.room.userActivity;
candidates.sort((a, b) => {
// command autocomplete options aren't added until after the user autocomplete options are sorted.
if (a.type !== "user" || b.type !== "user") return 0;
if (a.prefixIndex !== b.prefixIndex) {
// shorter prefix length comes first
return a.prefixIndex - b.prefixIndex;
@ -1163,6 +1178,28 @@ export class ChatTextEntry extends preact.Component<{
return (a.userid < b.userid) ? -1 : 1; // alphabetical order
});
const currentLine = prefix.substring(prefix.lastIndexOf('\n') + 1);
const isCommandSearch = (currentLine.startsWith('/') && !currentLine.startsWith('//')) || currentLine.startsWith('!');
if (isCommandSearch) {
PS.mainmenu.makeQuery('cmdsearch', currentLine, true).then((data: string[]) => {
const cmds = data.sort((a, b) => a.length < b.length ? 1 : -1);
const nextCmd = cmds[cmds.length - 1];
const newValue = nextCmd + value.substring(end);
this.setValue(newValue, nextCmd.length, nextCmd.length);
const currentCandidates = this.tabComplete?.candidates ?? [];
for (const cmd of cmds) {
currentCandidates.unshift({ type: "command", command: cmd });
}
this.tabComplete = {
candidates: currentCandidates,
candidateIndex: 0,
prefix: nextCmd,
cursor: nextCmd,
};
});
return true;
}
if (!candidates.length) {
this.tabComplete = null;
return true;
@ -1176,13 +1213,22 @@ export class ChatTextEntry extends preact.Component<{
}
// Substitute in the tab-completed name
const candidate = this.tabComplete.candidates[this.tabComplete.candidateIndex];
let name = users[candidate.userid];
if (!name) return true;
if (candidate.type === "user") {
let name = users[candidate.userid];
if (!name) return true;
name = Dex.getShortName(name.slice(1)); // Remove rank and busy characters
const cursor = this.tabComplete.prefix.slice(0, candidate.prefixIndex) + name;
this.setValue(cursor + value.slice(end), cursor.length);
this.tabComplete.cursor = cursor;
name = Dex.getShortName(name.slice(1)); // Remove rank and busy characters
const cursor = this.tabComplete.prefix.slice(0, candidate.prefixIndex) + name;
this.setValue(cursor + value.slice(end), cursor.length);
this.tabComplete.cursor = cursor;
} else {
const prefixIndex = prefix.lastIndexOf('\n') + 1;
const fullPrefix = prefix.substring(0, prefixIndex) + Dex.getShortName(candidate.command);
const newValue = fullPrefix + value.substring(end);
this.setValue(newValue, fullPrefix.length, fullPrefix.length);
this.tabComplete.cursor = fullPrefix;
this.tabComplete.prefix = fullPrefix;
}
return true;
}
undoTabComplete() {

View File

@ -381,9 +381,9 @@ export class MainMenuRoom extends PSRoom {
* Most queries are still handled hardcoded, so this is only for certain
* special queries that need a Promise.
*/
makeQuery(id: string, param?: string) {
makeQuery(id: string, param?: string, excludeParamFromListener?: boolean) {
let fullid = id;
if (param) fullid += ` ${toID(param)}`;
if (param && !excludeParamFromListener) fullid += ` ${toID(param)}`;
return new Promise<any>(resolve => {
if (!this.listeners[fullid]) {
this.listeners[fullid] = [];