@gusnips/react 0.9.25 → 0.9.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +137 -0
- package/dist/commands.d.ts +80 -0
- package/dist/commands.d.ts.map +1 -0
- package/dist/commands.js +192 -0
- package/dist/commands.js.map +1 -0
- package/dist/ui/menu.d.ts +1 -1
- package/dist/ui/menu.d.ts.map +1 -1
- package/package.json +5 -1
- package/src/commands-hook.test.ts +444 -0
- package/src/commands.test.ts +245 -0
- package/src/commands.ts +268 -0
package/src/commands.ts
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { useId, useState } from "react";
|
|
2
|
+
|
|
3
|
+
export interface SlashCommand {
|
|
4
|
+
/** One token, without the leading slash. */
|
|
5
|
+
name: string;
|
|
6
|
+
description: string;
|
|
7
|
+
/** Other executable names. Tags only help search. */
|
|
8
|
+
aliases: readonly string[];
|
|
9
|
+
tags: readonly string[];
|
|
10
|
+
takesArg?: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface ParsedCommand {
|
|
14
|
+
name: string;
|
|
15
|
+
/** Undefined before a space; empty after a space with no argument yet. */
|
|
16
|
+
arg?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Multiline text and a slash inside a message are not commands. */
|
|
20
|
+
export function parseCommand(text: string): ParsedCommand | null {
|
|
21
|
+
if (/[\r\n\u{2028}\u{2029}]/u.test(text)) return null;
|
|
22
|
+
const match = /^\s*\/(\S*)(?:[^\S\r\n]+(.*))?$/u.exec(text);
|
|
23
|
+
if (!match) return null;
|
|
24
|
+
const name = (match[1] ?? "").toLowerCase();
|
|
25
|
+
return match[2] === undefined ? { name } : { name, arg: match[2] };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type CommandResolution<T extends SlashCommand = SlashCommand> =
|
|
29
|
+
| { kind: "matched"; command: T }
|
|
30
|
+
| { kind: "unknown"; name: string }
|
|
31
|
+
| { kind: "ambiguous"; name: string; commands: readonly T[] };
|
|
32
|
+
|
|
33
|
+
/** Exact names win over aliases. Search tags never execute an action. */
|
|
34
|
+
export function resolveCommand<T extends SlashCommand>(
|
|
35
|
+
commands: readonly T[],
|
|
36
|
+
name: string,
|
|
37
|
+
): CommandResolution<T> {
|
|
38
|
+
const token = name.toLowerCase();
|
|
39
|
+
if (!token) return { kind: "unknown", name };
|
|
40
|
+
let matches = commands.filter((command) => command.name.toLowerCase() === token);
|
|
41
|
+
if (matches.length === 0) {
|
|
42
|
+
matches = commands.filter((command) =>
|
|
43
|
+
command.aliases.some((alias) => alias.toLowerCase() === token),
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
if (matches.length > 1) return { kind: "ambiguous", name, commands: matches };
|
|
47
|
+
const command = matches[0];
|
|
48
|
+
return command ? { kind: "matched", command } : { kind: "unknown", name };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function searchText(text: string): string {
|
|
52
|
+
return text.normalize("NFD").replace(/\p{M}/gu, "").toLowerCase();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function rank(command: SlashCommand, query: string): number {
|
|
56
|
+
if (!query) return 0;
|
|
57
|
+
const name = searchText(command.name);
|
|
58
|
+
const aliases = command.aliases.map(searchText);
|
|
59
|
+
if (name === query) return 0;
|
|
60
|
+
if (aliases.includes(query)) return 1;
|
|
61
|
+
if (name.startsWith(query)) return 2;
|
|
62
|
+
if (aliases.some((alias) => alias.startsWith(query))) return 3;
|
|
63
|
+
if (command.tags.some((tag) => searchText(tag).includes(query))) return 4;
|
|
64
|
+
if (searchText(command.description).includes(query)) return 5;
|
|
65
|
+
return Infinity;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** One row per name, in registry order when two results have the same rank. */
|
|
69
|
+
export function matchCommands<T extends SlashCommand>(commands: readonly T[], query: string): T[] {
|
|
70
|
+
const token = searchText(query.trim());
|
|
71
|
+
const names = new Set<string>();
|
|
72
|
+
return commands
|
|
73
|
+
.filter((command) => {
|
|
74
|
+
const name = command.name.toLowerCase();
|
|
75
|
+
if (names.has(name)) return false;
|
|
76
|
+
names.add(name);
|
|
77
|
+
return true;
|
|
78
|
+
})
|
|
79
|
+
.map((command) => ({ command, rank: rank(command, token) }))
|
|
80
|
+
.filter((match) => Number.isFinite(match.rank))
|
|
81
|
+
.sort((a, b) => a.rank - b.rank)
|
|
82
|
+
.map((match) => match.command);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Replace only the name, keeping the argument already typed. */
|
|
86
|
+
export function completeCommand(command: SlashCommand, text: string): string {
|
|
87
|
+
const arg = parseCommand(text)?.arg;
|
|
88
|
+
if (arg !== undefined) return `/${command.name} ${arg}`;
|
|
89
|
+
return `/${command.name}${command.takesArg ? " " : ""}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export type CommandProblem<T extends SlashCommand = SlashCommand> = Exclude<
|
|
93
|
+
CommandResolution<T>,
|
|
94
|
+
{ kind: "matched" }
|
|
95
|
+
>;
|
|
96
|
+
|
|
97
|
+
export interface CommandOptions<T extends SlashCommand> {
|
|
98
|
+
text: string;
|
|
99
|
+
/** Supply only commands allowed here, with current translated descriptions and tags. */
|
|
100
|
+
commands: readonly T[];
|
|
101
|
+
onChange: (text: string) => void;
|
|
102
|
+
/** The app owns authorization, errors, draft cleanup and attachments. */
|
|
103
|
+
onExecute: (command: T, arg: string | undefined, altKey: boolean) => void;
|
|
104
|
+
/** Required: show a way back to the available commands, never send this text to the model. */
|
|
105
|
+
onError: (problem: CommandProblem<T>) => void;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Structural so React events and small test events both fit. */
|
|
109
|
+
export interface CommandKeyEvent {
|
|
110
|
+
key: string;
|
|
111
|
+
shiftKey?: boolean;
|
|
112
|
+
altKey?: boolean;
|
|
113
|
+
ctrlKey?: boolean;
|
|
114
|
+
metaKey?: boolean;
|
|
115
|
+
isComposing?: boolean;
|
|
116
|
+
keyCode?: number;
|
|
117
|
+
nativeEvent?: { isComposing?: boolean; keyCode?: number };
|
|
118
|
+
preventDefault: () => void;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export interface SlashCommandMenu<T extends SlashCommand = SlashCommand> {
|
|
122
|
+
parsed: ParsedCommand | null;
|
|
123
|
+
open: boolean;
|
|
124
|
+
items: readonly T[];
|
|
125
|
+
index: number;
|
|
126
|
+
active: T | undefined;
|
|
127
|
+
listId: string;
|
|
128
|
+
itemId: (command: T) => string;
|
|
129
|
+
activeId: string | undefined;
|
|
130
|
+
highlight: (index: number) => void;
|
|
131
|
+
close: () => void;
|
|
132
|
+
showAll: () => void;
|
|
133
|
+
pick: (command: T, altKey?: boolean) => void;
|
|
134
|
+
submit: (altKey?: boolean) => boolean;
|
|
135
|
+
handleKeyDown: (event: CommandKeyEvent) => boolean;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** A controlled textarea menu. No UI, browser globals, timers or attachment state. */
|
|
139
|
+
export function useSlashCommands<T extends SlashCommand>({
|
|
140
|
+
text,
|
|
141
|
+
commands,
|
|
142
|
+
onChange,
|
|
143
|
+
onExecute,
|
|
144
|
+
onError,
|
|
145
|
+
}: CommandOptions<T>): SlashCommandMenu<T> {
|
|
146
|
+
const parsed = parseCommand(text);
|
|
147
|
+
const resolution = parsed ? resolveCommand(commands, parsed.name) : null;
|
|
148
|
+
const editingName =
|
|
149
|
+
parsed !== null && (parsed.arg === undefined || resolution?.kind !== "matched");
|
|
150
|
+
const items = editingName ? matchCommands(commands, parsed.name) : [];
|
|
151
|
+
const key = JSON.stringify([text, items.map((command) => command.name)]);
|
|
152
|
+
const [state, setState] = useState({ key, text, index: 0, deliberate: false, dismissed: false });
|
|
153
|
+
// Reset selection on a changed result set, but keep Escape's dismissal until the draft changes.
|
|
154
|
+
if (state.key !== key) {
|
|
155
|
+
setState({
|
|
156
|
+
key,
|
|
157
|
+
text,
|
|
158
|
+
index: 0,
|
|
159
|
+
deliberate: false,
|
|
160
|
+
dismissed: state.text === text && state.dismissed,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const listId = `${useId()}-commands`;
|
|
165
|
+
const open = editingName && !state.dismissed;
|
|
166
|
+
const active = open ? items[state.index] : undefined;
|
|
167
|
+
const itemId = (command: T) => `${listId}-${encodeURIComponent(command.name)}`;
|
|
168
|
+
|
|
169
|
+
function close(): void {
|
|
170
|
+
setState((current) => ({ ...current, dismissed: true }));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function showAll(): void {
|
|
174
|
+
setState((current) => ({ ...current, index: 0, deliberate: false, dismissed: false }));
|
|
175
|
+
onChange("/");
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function highlight(index: number): void {
|
|
179
|
+
if (!items[index]) return;
|
|
180
|
+
setState((current) => ({ ...current, index, deliberate: true }));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function pick(command: T, altKey = false): void {
|
|
184
|
+
// Re-resolve against current capabilities, not a descriptor retained by a caller.
|
|
185
|
+
const selected = resolveCommand(commands, command.name);
|
|
186
|
+
if (selected.kind !== "matched") {
|
|
187
|
+
onError(selected);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
if (selected.command.takesArg || parsed?.arg) {
|
|
191
|
+
onChange(completeCommand(selected.command, text));
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
onExecute(selected.command, parsed?.arg, altKey);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Call before the normal send/queue/quota path, including button and imperative submits. */
|
|
198
|
+
function submit(altKey = false): boolean {
|
|
199
|
+
if (!parsed || !resolution) return false;
|
|
200
|
+
if (resolution.kind !== "matched") {
|
|
201
|
+
onError(resolution);
|
|
202
|
+
return true;
|
|
203
|
+
}
|
|
204
|
+
if (
|
|
205
|
+
resolution.command.takesArg &&
|
|
206
|
+
parsed.arg === undefined &&
|
|
207
|
+
parsed.name !== resolution.command.name.toLowerCase()
|
|
208
|
+
) {
|
|
209
|
+
onChange(completeCommand(resolution.command, text));
|
|
210
|
+
return true;
|
|
211
|
+
}
|
|
212
|
+
onExecute(resolution.command, parsed.arg, altKey);
|
|
213
|
+
return true;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Call first. True means skip your own key handler; IME still gets the browser's default. */
|
|
217
|
+
function handleKeyDown(event: CommandKeyEvent): boolean {
|
|
218
|
+
const native = event.nativeEvent ?? event;
|
|
219
|
+
if (native.isComposing || native.keyCode === 229) return true;
|
|
220
|
+
if (event.shiftKey) return false;
|
|
221
|
+
if (open && event.key === "Escape") {
|
|
222
|
+
event.preventDefault();
|
|
223
|
+
close();
|
|
224
|
+
return true;
|
|
225
|
+
}
|
|
226
|
+
if (open && items.length > 0 && !event.ctrlKey && !event.metaKey) {
|
|
227
|
+
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
228
|
+
event.preventDefault();
|
|
229
|
+
const step = event.key === "ArrowDown" ? 1 : -1;
|
|
230
|
+
highlight((state.index + step + items.length) % items.length);
|
|
231
|
+
return true;
|
|
232
|
+
}
|
|
233
|
+
if (active && (event.key === "Enter" || event.key === "Tab")) {
|
|
234
|
+
event.preventDefault();
|
|
235
|
+
if (event.key === "Tab") {
|
|
236
|
+
onChange(completeCommand(active, text));
|
|
237
|
+
} else if (resolution?.kind === "ambiguous" && !state.deliberate) {
|
|
238
|
+
onError(resolution);
|
|
239
|
+
} else {
|
|
240
|
+
pick(active, event.altKey);
|
|
241
|
+
}
|
|
242
|
+
return true;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
if (parsed && event.key === "Enter") {
|
|
246
|
+
event.preventDefault();
|
|
247
|
+
return submit(event.altKey);
|
|
248
|
+
}
|
|
249
|
+
return false;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return {
|
|
253
|
+
parsed,
|
|
254
|
+
open,
|
|
255
|
+
items,
|
|
256
|
+
index: state.index,
|
|
257
|
+
active,
|
|
258
|
+
listId,
|
|
259
|
+
itemId,
|
|
260
|
+
activeId: active ? itemId(active) : undefined,
|
|
261
|
+
highlight,
|
|
262
|
+
close,
|
|
263
|
+
showAll,
|
|
264
|
+
pick,
|
|
265
|
+
submit,
|
|
266
|
+
handleKeyDown,
|
|
267
|
+
};
|
|
268
|
+
}
|