@inklu/keys 2.0.2 → 2.2.0

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.
@@ -0,0 +1,1797 @@
1
+ //#region src/chord.ts
2
+ /** The platform, as `navigator` reports it. `"other"` where there is none. */
3
+ function detectPlatform() {
4
+ if (typeof navigator === "undefined") return "other";
5
+ return /mac/i.test(`${navigator.platform} ${navigator.userAgent}`) ? "mac" : "other";
6
+ }
7
+ /** Named keys in the `Key` type, as `KeyboardEvent.key` spells them. Other names still work. */
8
+ const NAMED_KEYS = [
9
+ "ArrowUp",
10
+ "ArrowDown",
11
+ "ArrowLeft",
12
+ "ArrowRight",
13
+ "Home",
14
+ "End",
15
+ "PageUp",
16
+ "PageDown",
17
+ "Enter",
18
+ "Escape",
19
+ "Space",
20
+ "Tab",
21
+ "Backspace",
22
+ "Delete",
23
+ "Insert",
24
+ "CapsLock",
25
+ "ContextMenu",
26
+ "PrintScreen",
27
+ "ScrollLock",
28
+ "Pause",
29
+ "NumLock",
30
+ "Help",
31
+ "Clear",
32
+ "MediaPlayPause",
33
+ "MediaStop",
34
+ "MediaTrackNext",
35
+ "MediaTrackPrevious",
36
+ "AudioVolumeUp",
37
+ "AudioVolumeDown",
38
+ "AudioVolumeMute",
39
+ "BrowserBack",
40
+ "BrowserForward",
41
+ "BrowserRefresh",
42
+ "BrowserSearch",
43
+ "BrowserHome"
44
+ ];
45
+ const MODIFIERS = [
46
+ "Control",
47
+ "Alt",
48
+ "Shift",
49
+ "Meta"
50
+ ];
51
+ const PHYSICAL = /^\[([A-Za-z][A-Za-z0-9]*)\]$/;
52
+ const FUNCTION_KEY = /^F([1-9]|1\d|2[0-4])$/;
53
+ const LETTER = /^\p{Letter}$/u;
54
+ const LATIN_LETTER = /^\p{Script_Extensions=Latin}$/u;
55
+ /** Splits on `+`. The plus key itself is written last: `+` alone, or `Control++`. */
56
+ function split(chord) {
57
+ const plusKey = chord === "+" || chord.endsWith("++");
58
+ const parts = (plusKey ? chord.slice(0, -1) : chord).split("+");
59
+ if (plusKey) parts[parts.length - 1] = "+";
60
+ return parts.map((part) => part.trim());
61
+ }
62
+ /** One spelling per key: letters upper-cased, the space bar by name. */
63
+ function keyName$1(key) {
64
+ if (key === " ") return "Space";
65
+ if (!LETTER.test(key)) return key;
66
+ const upper = key.toUpperCase();
67
+ return upper.length === 1 ? upper : key;
68
+ }
69
+ /** The key a keypress produced. Empty for the keyless events browser autofill dispatches. */
70
+ function pressedKey(event) {
71
+ return keyName$1(String(event.key ?? "").normalize("NFC"));
72
+ }
73
+ function isOneCharacter(key) {
74
+ return Array.from(key).length === 1;
75
+ }
76
+ /** Reads a chord, or says why it cannot be read. */
77
+ function read(chord, platform) {
78
+ if (chord.trim() === "") return "Chord cannot be empty";
79
+ const parts = split(chord);
80
+ const key = parts.pop() ?? "";
81
+ if (key === "" || parts.includes("")) return "Invalid chord: empty part";
82
+ if (parts.includes("Mod") && (parts.includes("Control") || parts.includes("Meta"))) return "Mod already stands for Control or Meta: write one or the other";
83
+ const held = /* @__PURE__ */ new Set();
84
+ for (const part of parts) {
85
+ const name = part === "Mod" ? platform === "mac" ? "Meta" : "Control" : part;
86
+ const modifier = MODIFIERS.find((candidate) => candidate === name);
87
+ if (!modifier) return `Unknown modifier: '${part}'`;
88
+ if (held.has(modifier)) return `Repeated modifier: '${part}'`;
89
+ held.add(modifier);
90
+ }
91
+ const code = PHYSICAL.exec(key)?.[1];
92
+ if (code === void 0 && key.length > 1 && (key.startsWith("[") || key.endsWith("]"))) return "Invalid physical key: use a bracketed KeyboardEvent.code, such as [KeyS]";
93
+ return {
94
+ ctrl: held.has("Control"),
95
+ alt: held.has("Alt"),
96
+ shift: held.has("Shift"),
97
+ meta: held.has("Meta"),
98
+ ...code === void 0 ? { key: keyName$1(key) } : { code }
99
+ };
100
+ }
101
+ /** Parses a chord for a platform, or returns `null` for one that cannot be read. */
102
+ function parseChord(chord, platform) {
103
+ const result = read(chord, platform);
104
+ return typeof result === "string" ? null : result;
105
+ }
106
+ /** Whether a string is a chord this package can read. */
107
+ function isChord(text) {
108
+ return parseChord(text, "mac") !== null;
109
+ }
110
+ /** A chord's `errors` (it will never fire) and `warnings` (a key name this package does not know). */
111
+ function validateChord(chord) {
112
+ const result = read(chord, "mac");
113
+ if (typeof result === "string") return {
114
+ errors: [result],
115
+ warnings: []
116
+ };
117
+ const key = result.key;
118
+ return {
119
+ errors: [],
120
+ warnings: key === void 0 || isOneCharacter(key) || FUNCTION_KEY.test(key) || NAMED_KEYS.some((name) => name === key) ? [] : [`Unknown key: '${key}'. Key names are KeyboardEvent.key values, such as Escape.`]
121
+ };
122
+ }
123
+ /** Writes a parsed chord as text. Given a platform, its primary modifier is written `Mod`. */
124
+ function writeChord(chord, mod) {
125
+ const portable = mod !== void 0 && (mod === "mac" ? chord.meta && !chord.ctrl : chord.ctrl && !chord.meta);
126
+ const parts = portable ? ["Mod"] : [];
127
+ if (chord.ctrl && !portable) parts.push("Control");
128
+ if (chord.alt) parts.push("Alt");
129
+ if (chord.shift) parts.push("Shift");
130
+ if (chord.meta && !portable) parts.push("Meta");
131
+ parts.push(chord.code === void 0 ? chord.key ?? "" : `[${chord.code}]`);
132
+ return parts.join("+");
133
+ }
134
+ /** Whether a chord types a character: a one-character key with no Control, Alt or Meta. */
135
+ function typesCharacter(chord) {
136
+ if (chord.ctrl || chord.alt || chord.meta) return false;
137
+ return isOneCharacter(chord.key ?? keyAtCode(chord.code ?? "") ?? "");
138
+ }
139
+ const MODIFIER_KEYS = /* @__PURE__ */ new Set([
140
+ ...MODIFIERS,
141
+ "AltGraph",
142
+ "OS",
143
+ "Fn",
144
+ "Hyper",
145
+ "Super",
146
+ "Symbol"
147
+ ]);
148
+ /** Whether a `KeyboardEvent.key` is a modifier on its own. */
149
+ function isModifierKey(key) {
150
+ return MODIFIER_KEYS.has(key);
151
+ }
152
+ /** Whether AltGr is down. Windows reports it as Control+Alt, though a character is being typed. */
153
+ function hasAltGraph(event, platform) {
154
+ return platform !== "mac" && event.getModifierState?.("AltGraph") === true;
155
+ }
156
+ /** The punctuation keys of the conventional (US) layout, by `KeyboardEvent.code`. */
157
+ const PUNCTUATION_CODES = {
158
+ Backquote: "`",
159
+ Backslash: "\\",
160
+ BracketLeft: "[",
161
+ BracketRight: "]",
162
+ Comma: ",",
163
+ Equal: "=",
164
+ Minus: "-",
165
+ Period: ".",
166
+ Quote: "'",
167
+ Semicolon: ";",
168
+ Slash: "/"
169
+ };
170
+ /** The key a `KeyboardEvent.code` sits on in the conventional (US) layout. */
171
+ function keyAtCode(code) {
172
+ if (/^Key[A-Z]$/.test(code)) return code.slice(3);
173
+ if (/^Digit[0-9]$/.test(code)) return code.slice(5);
174
+ return code === "Space" ? code : PUNCTUATION_CODES[code];
175
+ }
176
+ /** A chord with its key by name: a physical key becomes the key at its position, or its `code`. */
177
+ function namedChord(chord) {
178
+ const key = chord.key ?? keyAtCode(chord.code ?? "") ?? chord.code ?? "";
179
+ return {
180
+ ...chord,
181
+ key,
182
+ code: void 0
183
+ };
184
+ }
185
+ /** `KeyboardEvent.key` values that name neither a character nor a key. */
186
+ const NO_KEY = /* @__PURE__ */ new Set([
187
+ "",
188
+ "Dead",
189
+ "Unidentified",
190
+ "Process"
191
+ ]);
192
+ const isAscii = (key) => /^[\x21-\x7e]$/.test(key);
193
+ /** A one-character key that is not a letter: a digit or a symbol. */
194
+ const isSymbol = (key) => isOneCharacter(key) && !LETTER.test(key);
195
+ /** A key known by name rather than by a character, such as `Enter`. */
196
+ const isNamed = (key) => !isOneCharacter(key) && !NO_KEY.has(key);
197
+ /**
198
+ * The key at a keypress's position on the US layout, where position may stand
199
+ * in for what the layout produced:
200
+ *
201
+ * - on the digit row, always (`Mod+1` on AZERTY, `Shift+2` on a US keyboard);
202
+ * - on a letter key that typed another script, or that macOS Option altered;
203
+ * - on a punctuation key likewise, and while Shift or Alt is held.
204
+ *
205
+ * Never for a Latin letter, a named key, or under AltGr.
206
+ */
207
+ function positionKey(event, key, altGraph) {
208
+ if (altGraph || /^[A-Z]$/.test(key) || isNamed(key)) return void 0;
209
+ const code = event.code;
210
+ if (/^Digit[0-9]$/.test(code)) return code.slice(5);
211
+ if (LATIN_LETTER.test(key) && !event.altKey) return void 0;
212
+ if (/^Key[A-Z]$/.test(code)) return isAscii(key) ? void 0 : code.slice(3);
213
+ return isAscii(key) && !event.shiftKey && !event.altKey ? void 0 : PUNCTUATION_CODES[code];
214
+ }
215
+ /**
216
+ * Whether a keypress is this chord. A physical chord (`[KeyK]`) compares
217
+ * position only. A logical one matches the key produced, where modifiers that
218
+ * only typed a symbol need not be named (`?` is Shift+/ on a US keyboard), or
219
+ * else the key's position where `positionKey` allows.
220
+ *
221
+ * Follows the matcher in TanStack Hotkeys (MIT).
222
+ */
223
+ function matchesChord(event, chord, platform) {
224
+ const altGraph = hasAltGraph(event, platform);
225
+ if (altGraph && (chord.ctrl || chord.alt)) return false;
226
+ const alt = !altGraph && event.altKey;
227
+ const modifiersMatch = (typing) => (!altGraph && event.ctrlKey) === chord.ctrl && event.metaKey === chord.meta && (alt === chord.alt || typing && alt && platform === "mac") && (event.shiftKey === chord.shift || typing && event.shiftKey);
228
+ if (chord.code !== void 0) return event.code === chord.code && modifiersMatch(false);
229
+ const key = pressedKey(event);
230
+ if (key === chord.key) return modifiersMatch(isSymbol(key));
231
+ return positionKey(event, key, altGraph) === chord.key && modifiersMatch(false);
232
+ }
233
+ /**
234
+ * The chord a keypress expresses, for recording a shortcut, or `null` for a
235
+ * modifier on its own or a key with no name. It records what `matchesChord`
236
+ * then matches: the character typed (`?`, not `Shift+/`), else the key's
237
+ * position, with the primary modifier written `Mod`.
238
+ */
239
+ function readChord(event, platform) {
240
+ if (isModifierKey(event.key)) return null;
241
+ const pressed = pressedKey(event);
242
+ const altGraph = hasAltGraph(event, platform);
243
+ const position = positionKey(event, pressed, altGraph);
244
+ const key = altGraph || isNamed(pressed) || isAscii(pressed) || LATIN_LETTER.test(pressed) && !event.altKey || position === void 0 ? pressed : position;
245
+ if (NO_KEY.has(key)) return null;
246
+ const typed = key === pressed && isSymbol(key);
247
+ const alt = !altGraph && event.altKey;
248
+ const chord = writeChord({
249
+ ctrl: !altGraph && event.ctrlKey,
250
+ alt: alt && !(typed && platform === "mac"),
251
+ shift: event.shiftKey && !typed,
252
+ meta: event.metaKey,
253
+ key
254
+ }, platform);
255
+ return isChord(chord) ? chord : null;
256
+ }
257
+ //#endregion
258
+ //#region src/bindings.ts
259
+ /** Bindings: a chord or a sequence of them. Comparing, validating and serializing. */
260
+ /**
261
+ * Creates a sequence binding from ordered chords.
262
+ *
263
+ * @example
264
+ * ```ts
265
+ * keys: seq("G", "I")
266
+ * ```
267
+ */
268
+ function seq(...steps) {
269
+ return { sequence: steps };
270
+ }
271
+ /** Type guard checking if a binding is a sequence. */
272
+ function isSequence(binding) {
273
+ return typeof binding === "object" && binding !== null && "sequence" in binding;
274
+ }
275
+ /** The chords of a binding, in order: one for a chord, several for a sequence. */
276
+ function stepsOf(binding) {
277
+ return isSequence(binding) ? binding.sequence : [binding];
278
+ }
279
+ /** Normalizes a binding input into an array of bindings. A one-step sequence becomes a chord. */
280
+ function toBindings(input) {
281
+ if (input == null) return [];
282
+ const candidates = isList(input) ? input : [input];
283
+ const bindings = [];
284
+ for (const candidate of candidates) {
285
+ if (candidate == null) continue;
286
+ const [first, ...rest] = stepsOf(candidate);
287
+ if (first === void 0) continue;
288
+ if (rest.length > 0) bindings.push({ sequence: [first, ...rest] });
289
+ else if (first.trim() !== "") bindings.push(first);
290
+ }
291
+ return bindings;
292
+ }
293
+ function isList(input) {
294
+ return Array.isArray(input);
295
+ }
296
+ /** The side of `{ mac, other }` keys that applies on a platform. Keys that do not differ pass through. */
297
+ function keysFor(keys, platform) {
298
+ if (keys == null || !isPerPlatform(keys)) return keys;
299
+ return platform === "mac" ? keys.mac : keys.other;
300
+ }
301
+ function isPerPlatform(keys) {
302
+ return typeof keys === "object" && !Array.isArray(keys) && !("sequence" in keys);
303
+ }
304
+ /** Generates a normalized signature for binding comparison and conflict detection. */
305
+ function bindingSignature(binding, platform) {
306
+ const steps = canonicalSteps(binding, platform);
307
+ return isSequence(binding) ? `seq:${steps.join(" ")}` : `chord:${steps.join(" ")}`;
308
+ }
309
+ /** Whether one binding is a strict prefix of the other, like chord `G` against `G then I`. */
310
+ function isPrefixCollision(a, b, platform) {
311
+ const left = canonicalSteps(a, platform);
312
+ const right = canonicalSteps(b, platform);
313
+ if (left.length === right.length) return false;
314
+ const [short, long] = left.length < right.length ? [left, right] : [right, left];
315
+ return short.every((step, index) => step === long[index]);
316
+ }
317
+ /** Every step parsed for a platform, or `null` if any of them cannot be read. */
318
+ function parseBinding(binding, platform) {
319
+ const steps = [];
320
+ for (const step of stepsOf(binding)) {
321
+ const parsed = parseChord(step, platform);
322
+ if (!parsed) return null;
323
+ steps.push(parsed);
324
+ }
325
+ return steps;
326
+ }
327
+ /** Each step in one spelling, so equivalent chords compare equal. One that cannot be read stays as written. */
328
+ function canonicalSteps(binding, platform = detectPlatform()) {
329
+ return stepsOf(binding).map((step) => {
330
+ const parsed = parseChord(step, platform);
331
+ return parsed ? writeChord(parsed) : step;
332
+ });
333
+ }
334
+ /**
335
+ * Whether every step is a printable character with no Control, Alt or Meta:
336
+ * the "character key shortcuts" WCAG 2.1.4 lets users turn off.
337
+ */
338
+ function isSingleKeyBinding(binding) {
339
+ return parseBinding(binding, "mac")?.every(typesCharacter) === true;
340
+ }
341
+ /** A binding's syntax `errors` (it will never fire) and `warnings`, for the development audit. */
342
+ function validateBinding(binding) {
343
+ const results = stepsOf(binding).map(validateChord);
344
+ return {
345
+ errors: results.flatMap((result) => result.errors),
346
+ warnings: results.flatMap((result) => result.warnings)
347
+ };
348
+ }
349
+ //#endregion
350
+ //#region src/display.ts
351
+ /** Writing a binding for people: on screen, in `aria-keyshortcuts`, and read aloud. */
352
+ const MODIFIER_LABELS = {
353
+ symbols: [
354
+ "⌃",
355
+ "⌥",
356
+ "⇧",
357
+ "⌘"
358
+ ],
359
+ mac: [
360
+ "Control",
361
+ "Option",
362
+ "Shift",
363
+ "Cmd"
364
+ ],
365
+ other: [
366
+ "Ctrl",
367
+ "Alt",
368
+ "Shift",
369
+ "Meta"
370
+ ]
371
+ };
372
+ /** The abbreviated modifier labels, in full, for reading aloud. */
373
+ const SPOKEN_MODIFIERS = {
374
+ Ctrl: "Control",
375
+ Cmd: "Command"
376
+ };
377
+ const KEY_LABELS = {
378
+ Enter: [
379
+ "↩",
380
+ "Enter",
381
+ "Return",
382
+ "Enter"
383
+ ],
384
+ Backspace: [
385
+ "⌫",
386
+ "Backspace",
387
+ "Delete",
388
+ "Backspace"
389
+ ],
390
+ Delete: [
391
+ "⌦",
392
+ "Del",
393
+ "Forward Delete",
394
+ "Delete"
395
+ ],
396
+ Tab: [
397
+ "⇥",
398
+ "Tab",
399
+ "Tab"
400
+ ],
401
+ Escape: [
402
+ "⎋",
403
+ "Esc",
404
+ "Escape"
405
+ ],
406
+ ArrowUp: [
407
+ "↑",
408
+ "↑",
409
+ "Up Arrow"
410
+ ],
411
+ ArrowDown: [
412
+ "↓",
413
+ "↓",
414
+ "Down Arrow"
415
+ ],
416
+ ArrowLeft: [
417
+ "←",
418
+ "←",
419
+ "Left Arrow"
420
+ ],
421
+ ArrowRight: [
422
+ "→",
423
+ "→",
424
+ "Right Arrow"
425
+ ],
426
+ Home: [
427
+ "↖",
428
+ "Home",
429
+ "Home"
430
+ ],
431
+ End: [
432
+ "↘",
433
+ "End",
434
+ "End"
435
+ ],
436
+ PageUp: [
437
+ "⇞",
438
+ "PgUp",
439
+ "Page Up"
440
+ ],
441
+ PageDown: [
442
+ "⇟",
443
+ "PgDn",
444
+ "Page Down"
445
+ ],
446
+ "+": [
447
+ "+",
448
+ "Plus",
449
+ "Plus"
450
+ ]
451
+ };
452
+ /** Names for the keys whose character is hard to tell apart in running text, or to hear. */
453
+ const PUNCTUATION_NAMES = {
454
+ "`": "Backquote",
455
+ "\\": "Backslash",
456
+ "[": "Left Bracket",
457
+ "]": "Right Bracket",
458
+ ",": "Comma",
459
+ "=": "Equal",
460
+ "-": "Minus",
461
+ ".": "Period",
462
+ ";": "Semicolon",
463
+ "/": "Slash",
464
+ "?": "Question Mark"
465
+ };
466
+ const spaced = (name) => name.replace(/([a-z])([A-Z])/g, "$1 $2");
467
+ /**
468
+ * Formats a binding the way its platform writes shortcuts: `⇧⌘S` on a Mac,
469
+ * `Ctrl+Shift+S` elsewhere. The steps of a sequence are set apart by a space.
470
+ */
471
+ function formatBinding(binding, options) {
472
+ return stepsOf(binding).map((step) => formatChord(step, options)).join(" ");
473
+ }
474
+ /**
475
+ * Splits a binding into display tokens for rendering `<kbd>` elements: one
476
+ * per key of a chord, one per step of a sequence.
477
+ */
478
+ function bindingTokens(binding, options) {
479
+ if (isSequence(binding)) return binding.sequence.map((step) => formatChord(step, options));
480
+ return chordTokens(binding, options);
481
+ }
482
+ function formatChord(chord, options) {
483
+ return chordTokens(chord, options).join(usesModifierSymbols(options) ? "" : "+");
484
+ }
485
+ function usesModifierSymbols(options) {
486
+ return (options?.platform ?? detectPlatform()) === "mac" && options?.useSymbols !== false;
487
+ }
488
+ function chordTokens(chord, options) {
489
+ const platform = options?.platform ?? detectPlatform();
490
+ const parsed = parseChord(chord, platform);
491
+ if (!parsed) return [chord];
492
+ const labels = MODIFIER_LABELS[usesModifierSymbols(options) ? "symbols" : platform];
493
+ return [...heldLabels(parsed, labels), keyLabel(parsed, platform, options?.useSymbols !== false)];
494
+ }
495
+ function heldLabels(chord, labels) {
496
+ const held = [
497
+ chord.ctrl,
498
+ chord.alt,
499
+ chord.shift,
500
+ chord.meta
501
+ ];
502
+ return labels.filter((_, index) => held[index]);
503
+ }
504
+ function keyLabel(chord, platform, symbols) {
505
+ const mac = platform === "mac";
506
+ const label = (key) => symbols ? KEY_LABELS[key]?.[mac ? 0 : 1] ?? key : keyName(key, mac);
507
+ if (chord.code?.startsWith("Numpad")) return `Numpad ${spaced(label(chord.code.slice(6)))}`;
508
+ return label(namedChord(chord).key);
509
+ }
510
+ /** A key in words: `"Return"`, `"Up Arrow"`, `"Comma"`, `"Caps Lock"`. */
511
+ function keyName(key, mac) {
512
+ const label = KEY_LABELS[key];
513
+ if (label) return (mac ? void 0 : label[3]) ?? label[2];
514
+ return PUNCTUATION_NAMES[key] ?? spaced(key);
515
+ }
516
+ /**
517
+ * Phrases a binding for a screen reader: `"Command K"` for a chord,
518
+ * `"G then I"` for a sequence, which `aria-keyshortcuts` cannot express.
519
+ */
520
+ function spokenBinding(binding, platform = detectPlatform()) {
521
+ return stepsOf(binding).map((step) => {
522
+ const parsed = parseChord(step, platform);
523
+ if (!parsed) return step;
524
+ return [...heldLabels(parsed, MODIFIER_LABELS[platform]).map((label) => SPOKEN_MODIFIERS[label] ?? label), keyName(namedChord(parsed).key, platform === "mac")].join(" ");
525
+ }).join(" then ");
526
+ }
527
+ /**
528
+ * The `aria-keyshortcuts` value for a chord, as in `"Meta+K"`. Empty for a
529
+ * sequence and for a chord that cannot be read.
530
+ */
531
+ function ariaKeyShortcuts(binding, platform = detectPlatform()) {
532
+ const parsed = isSequence(binding) ? null : parseChord(binding, platform);
533
+ if (!parsed) return "";
534
+ const chord = namedChord(parsed);
535
+ return writeChord(chord.key === "+" ? {
536
+ ...chord,
537
+ key: "Plus"
538
+ } : chord);
539
+ }
540
+ //#endregion
541
+ //#region src/keymap.ts
542
+ /** Keymap: default bindings, and the user's overrides on top of them. */
543
+ /** A signature that holds on every platform, so `Mod+S` and `Control+S` stay distinct. */
544
+ function portableSignature(binding) {
545
+ return `${bindingSignature(binding, "mac")} ${bindingSignature(binding, "other")}`;
546
+ }
547
+ var Keymap = class {
548
+ #defaults = /* @__PURE__ */ new Map();
549
+ #overrides = /* @__PURE__ */ new Map();
550
+ #listeners = /* @__PURE__ */ new Set();
551
+ #store;
552
+ #loaded = false;
553
+ constructor(store) {
554
+ this.#store = store;
555
+ }
556
+ /** Sets a command's default bindings. `null` leaves the command unbound. */
557
+ setDefault(id, input) {
558
+ const bindings = toBindings(input);
559
+ if (bindings.length === 0) this.#defaults.delete(id);
560
+ else this.#defaults.set(id, bindings);
561
+ }
562
+ /** Checks whether a command has an active user override. */
563
+ isOverridden(id) {
564
+ return this.#overrides.has(id);
565
+ }
566
+ /** The user's overrides, for a store or an export to write out. */
567
+ get overrides() {
568
+ return this.#overrides;
569
+ }
570
+ get(id) {
571
+ const defaults = this.#defaults.get(id) ?? [];
572
+ const override = this.#overrides.get(id);
573
+ if (!override) return defaults;
574
+ if (override.kind === "replace") return override.bindings;
575
+ return [...defaults, ...without(override.bindings, defaults)];
576
+ }
577
+ set(id, input) {
578
+ this.#load();
579
+ const bindings = input === null ? [] : toBindings(input);
580
+ if (sameBindings(bindings, this.#defaults.get(id) ?? [])) {
581
+ if (!this.#overrides.delete(id)) return;
582
+ } else this.#overrides.set(id, {
583
+ kind: "replace",
584
+ bindings
585
+ });
586
+ this.#changed();
587
+ }
588
+ add(id, input) {
589
+ this.#load();
590
+ const added = without(toBindings(input), this.get(id));
591
+ if (added.length === 0) return;
592
+ const override = this.#overrides.get(id);
593
+ this.#overrides.set(id, {
594
+ kind: override?.kind ?? "add",
595
+ bindings: [...override?.bindings ?? [], ...added]
596
+ });
597
+ this.#changed();
598
+ }
599
+ reset(id) {
600
+ this.#load();
601
+ if (id === void 0) {
602
+ if (this.#overrides.size === 0) return;
603
+ this.#overrides.clear();
604
+ } else if (!this.#overrides.delete(id)) return;
605
+ this.#changed();
606
+ }
607
+ /** Replaces every override and saves, as an import does. */
608
+ replace(overrides) {
609
+ this.#loaded = true;
610
+ this.#overrides = overrides;
611
+ this.#changed();
612
+ }
613
+ /**
614
+ * Takes the stored overrides, telling subscribers if they differ from the
615
+ * ones in memory, and returns whether they did. Each mount starts with it,
616
+ * which catches up on what other tabs stored while nothing was listening.
617
+ */
618
+ sync() {
619
+ this.#loaded = true;
620
+ const stored = this.#store?.read();
621
+ if (!stored) return false;
622
+ const before = JSON.stringify([...this.#overrides]);
623
+ this.#overrides = stored;
624
+ if (JSON.stringify([...stored]) === before) return false;
625
+ this.#notify();
626
+ return true;
627
+ }
628
+ /** Follows edits other tabs make to the stored overrides. Returns a function that stops. */
629
+ watch() {
630
+ return this.#store?.watch(() => this.sync()) ?? (() => {});
631
+ }
632
+ /** Subscribes to keymap changes. Returns an unsubscribe function. */
633
+ subscribe(listener) {
634
+ this.#listeners.add(listener);
635
+ return () => {
636
+ this.#listeners.delete(listener);
637
+ };
638
+ }
639
+ /** Before an edit: stored overrides load first, once, so the edit lands on top of them. */
640
+ #load() {
641
+ if (!this.#loaded) this.sync();
642
+ }
643
+ #changed() {
644
+ this.#store?.write(this.#overrides);
645
+ this.#notify();
646
+ }
647
+ #notify() {
648
+ for (const listener of this.#listeners) listener();
649
+ }
650
+ };
651
+ /** `bindings` minus any already in `existing` or repeated, compared across platforms. */
652
+ function without(bindings, existing) {
653
+ const known = new Set(existing.map(portableSignature));
654
+ return bindings.filter((binding) => {
655
+ const signature = portableSignature(binding);
656
+ if (known.has(signature)) return false;
657
+ known.add(signature);
658
+ return true;
659
+ });
660
+ }
661
+ /** Same bindings in the same order; the first is the one shortcuts display. */
662
+ function sameBindings(a, b) {
663
+ return a.length === b.length && a.every((binding, index) => {
664
+ const other = b[index];
665
+ return other !== void 0 && portableSignature(binding) === portableSignature(other);
666
+ });
667
+ }
668
+ //#endregion
669
+ //#region src/persist.ts
670
+ /** Keeping a keymap between visits: Web Storage, and export and import as JSON. */
671
+ const VERSION = 1;
672
+ /**
673
+ * Keeps user overrides in Web Storage under `key`, and follows edits made in
674
+ * other tabs. `storage` is only called in the browser; return
675
+ * `sessionStorage` or an object of your own to keep them somewhere else.
676
+ *
677
+ * @example
678
+ * ```ts
679
+ * createCommands(definitions, { persist: webStorage("my-app.keys") });
680
+ * ```
681
+ */
682
+ function webStorage(key = "inklu.keys", storage = () => localStorage) {
683
+ const open = () => {
684
+ try {
685
+ return storage();
686
+ } catch {
687
+ return null;
688
+ }
689
+ };
690
+ return {
691
+ read() {
692
+ const area = open();
693
+ if (!area) return null;
694
+ try {
695
+ const raw = area.getItem(key);
696
+ return raw ? parseKeymap(JSON.parse(raw)) : /* @__PURE__ */ new Map();
697
+ } catch {
698
+ return null;
699
+ }
700
+ },
701
+ write(overrides) {
702
+ try {
703
+ if (overrides.size === 0) open()?.removeItem(key);
704
+ else open()?.setItem(key, JSON.stringify(serializeKeymap(overrides)));
705
+ } catch {}
706
+ },
707
+ watch(onChange) {
708
+ if (typeof window === "undefined") return () => {};
709
+ const onStorage = (event) => {
710
+ if (event.key === null || event.key === key) onChange();
711
+ };
712
+ window.addEventListener("storage", onStorage);
713
+ return () => window.removeEventListener("storage", onStorage);
714
+ }
715
+ };
716
+ }
717
+ /** A registry's user overrides as JSON-safe data, for your own storage or another device. */
718
+ function exportKeymap(commands) {
719
+ return serializeKeymap(keymapOf(commands).overrides);
720
+ }
721
+ /**
722
+ * Replaces a registry's user overrides with exported data, and saves them.
723
+ * Data in another format changes nothing.
724
+ */
725
+ function importKeymap(commands, data) {
726
+ const overrides = parseKeymap(data);
727
+ if (overrides) keymapOf(commands).replace(overrides);
728
+ }
729
+ function keymapOf({ keymap }) {
730
+ if (keymap instanceof Keymap) return keymap;
731
+ throw new TypeError("[@inklu/keys] not a registry made by createCommands");
732
+ }
733
+ function serializeKeymap(overrides) {
734
+ return {
735
+ version: VERSION,
736
+ overrides: Object.fromEntries(Array.from(overrides, ([id, { kind, bindings }]) => {
737
+ const list = bindings.map((binding) => isSequence(binding) ? [...binding.sequence] : binding);
738
+ return [id, kind === "replace" ? list : { add: list }];
739
+ }))
740
+ };
741
+ }
742
+ /** The overrides in stored data, or `null` for data in another format. */
743
+ function parseKeymap(data) {
744
+ const stored = isObject(data) && data.version === VERSION ? data.overrides : void 0;
745
+ if (stored === void 0 || !isObject(stored)) return null;
746
+ const overrides = /* @__PURE__ */ new Map();
747
+ for (const [id, value] of Object.entries(stored)) {
748
+ const override = parseOverride(value);
749
+ if (override) overrides.set(id, override);
750
+ }
751
+ return overrides;
752
+ }
753
+ function parseOverride(value) {
754
+ if (Array.isArray(value)) {
755
+ const bindings = parseBindings(value);
756
+ return bindings && {
757
+ kind: "replace",
758
+ bindings
759
+ };
760
+ }
761
+ if (isObject(value) && Array.isArray(value.add)) {
762
+ const bindings = parseBindings(value.add);
763
+ return bindings && bindings.length > 0 ? {
764
+ kind: "add",
765
+ bindings
766
+ } : null;
767
+ }
768
+ return null;
769
+ }
770
+ /**
771
+ * Reads a stored list. `[]` is an unbound command; a non-empty list with
772
+ * nothing usable in it is damage, and reads as `null`.
773
+ */
774
+ function parseBindings(values) {
775
+ const bindings = values.map(parseStoredBinding).filter((binding) => binding !== null);
776
+ return bindings.length === 0 && values.length > 0 ? null : bindings;
777
+ }
778
+ /** A stored chord (a string) or sequence (a list of them), or `null` if it cannot be read. */
779
+ function parseStoredBinding(value) {
780
+ if (typeof value === "string") return isChord(value) ? value : null;
781
+ if (!Array.isArray(value)) return null;
782
+ const steps = [];
783
+ for (const step of value) {
784
+ if (typeof step !== "string" || !isChord(step)) return null;
785
+ steps.push(step);
786
+ }
787
+ const [first, ...rest] = steps;
788
+ if (first === void 0) return null;
789
+ return rest.length === 0 ? first : { sequence: steps };
790
+ }
791
+ function isObject(value) {
792
+ return typeof value === "object" && value !== null && !Array.isArray(value);
793
+ }
794
+ //#endregion
795
+ //#region src/keys.ts
796
+ /** The one keyboard listener: matches a keypress to bindings and runs the command that wins it. */
797
+ const DEFAULT_SEQUENCE_TIMEOUT = 1e3;
798
+ /** The longest delay `setTimeout` honours; a longer one fires at once. */
799
+ const MAX_TIMER = 2 ** 31 - 1;
800
+ const NO_PROGRESS = [];
801
+ var KeyListener = class {
802
+ #host;
803
+ #target;
804
+ #attached = null;
805
+ #bound = null;
806
+ /** Whoever has taken the keyboard, the most recent last. See `capture`. */
807
+ #captures = [];
808
+ #spent = null;
809
+ #watchers = /* @__PURE__ */ new Set();
810
+ #progress = NO_PROGRESS;
811
+ #timer = null;
812
+ constructor(host, target) {
813
+ this.#host = host;
814
+ this.#target = target;
815
+ }
816
+ /** Starts listening. A no-op on the server. */
817
+ attach() {
818
+ if (this.#attached || typeof document === "undefined") return;
819
+ this.#attached = this.#target ?? document;
820
+ this.#attached.addEventListener("keydown", this.#onKeyDown);
821
+ }
822
+ /** Stops listening and abandons half-typed sequences. */
823
+ detach() {
824
+ this.#attached?.removeEventListener("keydown", this.#onKeyDown);
825
+ this.#attached = null;
826
+ this.refresh();
827
+ }
828
+ /** Stops listening for good, dropping the watchers and captures too. */
829
+ destroy() {
830
+ this.#watchers.clear();
831
+ for (let onKey = this.#captures.at(-1); onKey; onKey = this.#captures.at(-1)) this.#release(onKey);
832
+ this.detach();
833
+ }
834
+ /** Whether something has taken the keyboard, so no command's keys are live. */
835
+ get captured() {
836
+ return this.#captures.length > 0;
837
+ }
838
+ /** Hands every keypress to `onKey` until released, in the capture phase on the document. */
839
+ capture(onKey) {
840
+ if (typeof document === "undefined") return () => {};
841
+ this.#captures.push(onKey);
842
+ if (this.#captures.length === 1) document.addEventListener("keydown", this.#onCapture, true);
843
+ return () => this.#release(onKey);
844
+ }
845
+ #release(onKey) {
846
+ const index = this.#captures.lastIndexOf(onKey);
847
+ if (index === -1) return;
848
+ this.#captures.splice(index, 1);
849
+ if (this.#captures.length > 0) return;
850
+ document.removeEventListener("keydown", this.#onCapture, true);
851
+ }
852
+ /** Bindings or the platform changed: re-read them. Half-typed sequences start over. */
853
+ refresh() {
854
+ this.#bound = null;
855
+ this.#publish();
856
+ }
857
+ /** Reachability changed: a half-typed sequence that is no longer reachable starts over. */
858
+ prune() {
859
+ let changed = false;
860
+ for (const bound of this.#bound ?? []) {
861
+ if (bound.matched === 0 || this.#host.reachable(bound.id)) continue;
862
+ bound.matched = 0;
863
+ changed = true;
864
+ }
865
+ if (changed) this.#publish();
866
+ }
867
+ /** Half-typed sequences. Tracked only while something is watching. */
868
+ progress() {
869
+ return this.#progress;
870
+ }
871
+ /** Watches sequence progress. It is tracked, and timed, only while someone is watching. */
872
+ watch(listener) {
873
+ this.#watchers.add(listener);
874
+ if (this.#watchers.size === 1) this.#publish();
875
+ return () => {
876
+ this.#watchers.delete(listener);
877
+ if (this.#watchers.size === 0) this.#publish();
878
+ };
879
+ }
880
+ #bindings() {
881
+ if (this.#bound) return this.#bound;
882
+ const platform = this.#host.platform();
883
+ const bound = [];
884
+ this.#host.ids().forEach((id, order) => {
885
+ for (const binding of this.#host.bindings(id)) {
886
+ const steps = parseBinding(binding, platform);
887
+ if (!steps) continue;
888
+ const timeout = this.#host.definition(id)?.sequenceTimeout ?? DEFAULT_SEQUENCE_TIMEOUT;
889
+ bound.push({
890
+ id,
891
+ order,
892
+ binding,
893
+ steps,
894
+ timeout,
895
+ matched: 0,
896
+ at: 0
897
+ });
898
+ }
899
+ });
900
+ this.#bound = bound;
901
+ return bound;
902
+ }
903
+ #onCapture = (event) => {
904
+ const onKey = this.#captures.at(-1);
905
+ if (!onKey || isComposing(event)) return;
906
+ this.#spent = event;
907
+ onKey(event, readChord(event, this.#host.platform()));
908
+ };
909
+ #onKeyDown = (event) => {
910
+ if (isComposing(event) || this.captured) return;
911
+ if (this.#spent) {
912
+ if (event === this.#spent || event.repeat) return;
913
+ this.#spent = null;
914
+ }
915
+ if (isModifierKey(event.key)) return;
916
+ const platform = this.#host.platform();
917
+ const typing = isTyping(event, this.#target);
918
+ const now = Date.now();
919
+ const chords = [];
920
+ const sequences = [];
921
+ let continued = false;
922
+ let prevent = false;
923
+ let progressed = false;
924
+ for (const bound of this.#bindings()) {
925
+ const definition = this.#host.definition(bound.id);
926
+ const first = bound.steps[0];
927
+ if (!definition || !first || !this.#host.reachable(bound.id)) continue;
928
+ if (typing && (definition.ignoreInputs ?? ignoresInputs(first, platform))) continue;
929
+ if (bound.steps.length === 1) {
930
+ if (matchesChord(event, first, platform)) chords.push(bound);
931
+ continue;
932
+ }
933
+ if (event.repeat) continue;
934
+ const matched = now - bound.at > bound.timeout ? 0 : bound.matched;
935
+ const expected = bound.steps[matched];
936
+ let reached = 0;
937
+ if (expected && matchesChord(event, expected, platform)) reached = matched + 1;
938
+ else if (matched > 0 && matchesChord(event, first, platform)) reached = 1;
939
+ const next = reached === bound.steps.length ? 0 : reached;
940
+ if (next !== bound.matched) progressed = true;
941
+ bound.matched = next;
942
+ bound.at = now;
943
+ if (reached === 0) continue;
944
+ if (reached > 1) continued = true;
945
+ if (reached === bound.steps.length) {
946
+ sequences.push(bound);
947
+ continue;
948
+ }
949
+ const step = bound.steps[reached - 1];
950
+ if (!(typing && !step?.ctrl && !step?.meta) && definition.preventDefault !== false) prevent = true;
951
+ }
952
+ if (progressed) this.#publish();
953
+ if (continued) this.#spent = event;
954
+ const candidates = sequences.length > 0 ? sequences : continued ? [] : chords;
955
+ let winner;
956
+ for (const bound of candidates) if (!winner || this.#outranks(bound, winner)) winner = bound;
957
+ const definition = winner && this.#host.definition(winner.id);
958
+ if (!winner || !definition) {
959
+ if (prevent) event.preventDefault();
960
+ return;
961
+ }
962
+ if (prevent || definition.preventDefault !== false) event.preventDefault();
963
+ if (event.repeat && definition.repeat === false) return;
964
+ this.#host.run(winner.id, event, winner.binding);
965
+ };
966
+ #outranks(a, b) {
967
+ const depth = this.#host.depth(a.id) - this.#host.depth(b.id);
968
+ return depth === 0 ? a.order > b.order : depth > 0;
969
+ }
970
+ #publish = () => {
971
+ if (this.#timer !== null) clearTimeout(this.#timer);
972
+ this.#timer = null;
973
+ const progress = [];
974
+ let expires = null;
975
+ if (this.#watchers.size > 0) {
976
+ const now = Date.now();
977
+ for (const bound of this.#bound ?? []) {
978
+ if (bound.matched === 0 || !isSequence(bound.binding)) continue;
979
+ const deadline = bound.at + bound.timeout;
980
+ if (now > deadline) continue;
981
+ expires = expires === null ? deadline : Math.min(expires, deadline);
982
+ progress.push({
983
+ id: bound.id,
984
+ binding: bound.binding,
985
+ matched: bound.matched
986
+ });
987
+ }
988
+ }
989
+ const wait = expires === null ? Infinity : Math.max(0, expires - Date.now()) + 1;
990
+ if (wait < MAX_TIMER) this.#timer = setTimeout(this.#publish, wait);
991
+ if (progress.length === this.#progress.length && progress.every((entry, index) => {
992
+ const other = this.#progress[index];
993
+ return other?.binding === entry.binding && other.matched === entry.matched;
994
+ })) return;
995
+ this.#progress = progress.length === 0 ? NO_PROGRESS : progress;
996
+ for (const watcher of this.#watchers) watcher();
997
+ };
998
+ };
999
+ /**
1000
+ * Whether a keydown belongs to an IME composition. Some engines report it
1001
+ * only as `keyCode` 229 or the key `Process`.
1002
+ */
1003
+ function isComposing(event) {
1004
+ return event.isComposing || event.keyCode === 229 || event.key === "Process";
1005
+ }
1006
+ /**
1007
+ * Whether a binding stays quiet in a text field when its command does not
1008
+ * say: everything but Control/Meta chords and bare `Escape`, and also the
1009
+ * chords a field edits its own text with.
1010
+ */
1011
+ function ignoresInputs(step, platform) {
1012
+ if (step.key === "Escape" || step.code === "Escape") return false;
1013
+ return !(step.ctrl || step.meta) || editsText(step, platform);
1014
+ }
1015
+ /** Keys that, with the primary modifier, move the caret or delete by word or by line. */
1016
+ const CARET_KEYS = /^(Backspace|Delete|Home|End|Arrow\w+)$/;
1017
+ /**
1018
+ * Whether a text field acts on a chord itself, on the platform's primary
1019
+ * modifier: select all, copy, cut, paste, undo and redo, and moving or
1020
+ * deleting by word or line.
1021
+ */
1022
+ function editsText(step, platform) {
1023
+ const mac = platform === "mac";
1024
+ if (step.alt || step.ctrl === mac || step.meta !== mac) return false;
1025
+ const key = namedChord(step).key;
1026
+ if (key.length > 1) return CARET_KEYS.test(key);
1027
+ return (step.shift ? "VZ" : mac ? "ACVXZ" : "ACVXYZ").includes(key);
1028
+ }
1029
+ /**
1030
+ * Whether a keypress lands in a field that takes text. `host`, the element
1031
+ * the listener sits on, does not count.
1032
+ */
1033
+ function isTyping(event, host) {
1034
+ return event.composedPath().some((node) => node !== host && isTextEntry(node));
1035
+ }
1036
+ /** ARIA roles of widgets that take typed keys the way their native counterparts do. */
1037
+ const TEXT_ENTRY_ROLES = /* @__PURE__ */ new Set([
1038
+ "textbox",
1039
+ "searchbox",
1040
+ "combobox",
1041
+ "spinbutton",
1042
+ "slider"
1043
+ ]);
1044
+ function isTextEntry(node) {
1045
+ if (!(node instanceof HTMLElement)) return false;
1046
+ if (node instanceof HTMLInputElement) {
1047
+ const type = node.type.toLowerCase();
1048
+ return type !== "button" && type !== "submit" && type !== "reset";
1049
+ }
1050
+ return node instanceof HTMLTextAreaElement || node instanceof HTMLSelectElement || !["false", null].includes(node.getAttribute("contenteditable")) || TEXT_ENTRY_ROLES.has(node.getAttribute("role") ?? "");
1051
+ }
1052
+ //#endregion
1053
+ //#region src/rebind.ts
1054
+ /** Controller for recording and applying hotkey rebindings. */
1055
+ const NO_STEPS = [];
1056
+ /**
1057
+ * Creates a rebinder for recording a command's keys. While it records, no
1058
+ * command runs. Escape cancels, Backspace removes the shortcut (or the last
1059
+ * step of a sequence), and Enter finishes a sequence.
1060
+ *
1061
+ * @example
1062
+ * ```ts
1063
+ * const rebinder = createRebinder(commands, "palette.open", {
1064
+ * onRecord: (binding, conflicts) => showConflicts(conflicts),
1065
+ * });
1066
+ * rebinder.start();
1067
+ * ```
1068
+ */
1069
+ function createRebinder(registry, id, options = {}) {
1070
+ const sequence = options.mode === "sequence";
1071
+ const listeners = /* @__PURE__ */ new Set();
1072
+ let state = {
1073
+ recording: false,
1074
+ binding: null,
1075
+ steps: NO_STEPS,
1076
+ conflicts: []
1077
+ };
1078
+ let release = null;
1079
+ let idle = null;
1080
+ const set = (next) => {
1081
+ state = next;
1082
+ for (const listener of listeners) listener();
1083
+ };
1084
+ /** Gives the keyboard back. */
1085
+ const end = () => {
1086
+ release?.();
1087
+ release = null;
1088
+ if (idle !== null) clearTimeout(idle);
1089
+ idle = null;
1090
+ };
1091
+ const record = (binding) => {
1092
+ end();
1093
+ const conflicts = registry.conflicts(binding, {
1094
+ ignore: id,
1095
+ scope: registry.get(id)?.scope
1096
+ });
1097
+ const blocked = conflicts.some((conflict) => conflict.kind === "reserved") || options.rejectConflicts === true && conflicts.length > 0;
1098
+ if (options.apply !== false && !blocked) {
1099
+ if (options.add) registry.keymap.add(id, binding);
1100
+ else registry.keymap.set(id, binding);
1101
+ }
1102
+ set({
1103
+ recording: false,
1104
+ binding,
1105
+ steps: NO_STEPS,
1106
+ conflicts
1107
+ });
1108
+ options.onRecord?.(binding, conflicts);
1109
+ };
1110
+ const cancel = () => {
1111
+ end();
1112
+ set({
1113
+ ...state,
1114
+ recording: false,
1115
+ steps: NO_STEPS
1116
+ });
1117
+ options.onCancel?.();
1118
+ };
1119
+ const clear = () => {
1120
+ end();
1121
+ if (options.apply !== false) registry.keymap.set(id, null);
1122
+ set({
1123
+ recording: false,
1124
+ binding: null,
1125
+ steps: NO_STEPS,
1126
+ conflicts: []
1127
+ });
1128
+ options.onClear?.();
1129
+ };
1130
+ const commit = () => {
1131
+ const [first, ...rest] = state.steps;
1132
+ if (first === void 0) return;
1133
+ record(rest.length === 0 ? first : { sequence: state.steps });
1134
+ };
1135
+ /** Sets the steps typed so far, and restarts the wait for the next one. */
1136
+ const step = (steps) => {
1137
+ if (idle !== null) clearTimeout(idle);
1138
+ const wait = options.idleTimeout ?? 0;
1139
+ idle = wait > 0 && steps.length > 0 ? setTimeout(commit, wait) : null;
1140
+ set({
1141
+ ...state,
1142
+ steps
1143
+ });
1144
+ };
1145
+ const stop = () => {
1146
+ if (!state.recording) return;
1147
+ end();
1148
+ set({
1149
+ ...state,
1150
+ recording: false,
1151
+ steps: NO_STEPS
1152
+ });
1153
+ };
1154
+ const onKey = (event, chord) => {
1155
+ if (options.ignoreInputs !== false && event.key !== "Escape" && isTyping(event)) return;
1156
+ event.preventDefault();
1157
+ event.stopPropagation();
1158
+ if (event.repeat) return;
1159
+ const bare = !event.ctrlKey && !event.altKey && !event.shiftKey && !event.metaKey;
1160
+ if (bare && event.key === "Escape") return cancel();
1161
+ if (bare && (event.key === "Backspace" || event.key === "Delete")) return state.steps.length === 0 ? clear() : step(state.steps.slice(0, -1));
1162
+ if (sequence && bare && event.key === "Enter") return commit();
1163
+ if (chord === null) return;
1164
+ if (sequence) step([...state.steps, chord]);
1165
+ else record(chord);
1166
+ };
1167
+ return {
1168
+ start: () => {
1169
+ if (state.recording) return;
1170
+ release = registry.capture(onKey);
1171
+ set({
1172
+ recording: true,
1173
+ binding: null,
1174
+ steps: NO_STEPS,
1175
+ conflicts: []
1176
+ });
1177
+ },
1178
+ stop,
1179
+ cancel: () => {
1180
+ if (state.recording) cancel();
1181
+ },
1182
+ commit: () => {
1183
+ if (state.recording && sequence) commit();
1184
+ },
1185
+ get state() {
1186
+ return state;
1187
+ },
1188
+ subscribe: (listener) => {
1189
+ listeners.add(listener);
1190
+ return () => {
1191
+ listeners.delete(listener);
1192
+ };
1193
+ },
1194
+ destroy: () => {
1195
+ stop();
1196
+ listeners.clear();
1197
+ }
1198
+ };
1199
+ }
1200
+ //#endregion
1201
+ //#region src/reserved.ts
1202
+ /** Chords the browser or the operating system keeps for itself. */
1203
+ const chords = (list) => new Set(list.split(" "));
1204
+ const RESERVED = {
1205
+ mac: chords("Meta+N Meta+T Meta+W Meta+Q Meta+H Meta+M Meta+Tab Meta+Space Shift+Meta+N Shift+Meta+T Shift+Meta+W Shift+Meta+Q Shift+Meta+Tab Shift+Meta+3 Shift+Meta+4 Shift+Meta+5 Control+Tab Control+Shift+Tab Control+Meta+Q"),
1206
+ other: chords("Control+N Control+T Control+W Control+Shift+N Control+Shift+T Control+Shift+W Control+Tab Control+Shift+Tab Control+PageUp Control+PageDown Control+F4 Alt+F4 Alt+Tab Alt+Shift+Tab Alt+Space Control+Alt+Tab Control+Alt+Delete")
1207
+ };
1208
+ /** Whether any step of a binding is a chord the browser or OS reserves on a platform. */
1209
+ function isReserved(binding, platform) {
1210
+ return (parseBinding(binding, platform) ?? []).some((chord) => {
1211
+ const written = writeChord(namedChord(chord));
1212
+ return platform === "mac" ? RESERVED.mac.has(written) : chord.meta || RESERVED.other.has(written);
1213
+ });
1214
+ }
1215
+ /**
1216
+ * Whether commands in two scopes can be live together, so their bindings can
1217
+ * collide: a global or `"*"` command overlaps every scope, and two named
1218
+ * scopes only when they are the same.
1219
+ */
1220
+ function scopesOverlap(a, b) {
1221
+ if (a === void 0 || b === void 0) return true;
1222
+ if (a === "*" || b === "*") return true;
1223
+ return a === b;
1224
+ }
1225
+ /** Stack managing active scopes and command reachability. */
1226
+ var ScopeStack = class {
1227
+ #entries = [];
1228
+ #listeners = /* @__PURE__ */ new Set();
1229
+ /** Activates a scope until the returned disposal function is called. */
1230
+ push(name, options = {}) {
1231
+ const entry = {
1232
+ name,
1233
+ exclusive: options.exclusive === true
1234
+ };
1235
+ this.#entries.push(entry);
1236
+ this.#notify();
1237
+ return () => {
1238
+ const index = this.#entries.indexOf(entry);
1239
+ if (index === -1) return;
1240
+ this.#entries.splice(index, 1);
1241
+ this.#notify();
1242
+ };
1243
+ }
1244
+ /**
1245
+ * Whether the keys of commands in `scope` are live: the scope is open and
1246
+ * no `exclusive` scope above it has claimed the keyboard.
1247
+ */
1248
+ isActive(scope) {
1249
+ if (scope === "*") return true;
1250
+ const barrier = this.#barrierIndex();
1251
+ if (barrier === -1) return this.isOpen(scope);
1252
+ return scope !== void 0 && this.depth(scope) >= barrier;
1253
+ }
1254
+ /** Whether `scope` is open at all, barriers aside. This is what a direct `run()` checks. */
1255
+ isOpen(scope) {
1256
+ return scope === void 0 || scope === "*" || this.depth(scope) !== -1;
1257
+ }
1258
+ /** How far up the stack `scope` was last opened, or `-1` for a global or closed one. */
1259
+ depth(scope) {
1260
+ if (scope === void 0 || scope === "*") return -1;
1261
+ for (let index = this.#entries.length - 1; index >= 0; index--) if (this.#entries[index]?.name === scope) return index;
1262
+ return -1;
1263
+ }
1264
+ /** Clears all active scopes. */
1265
+ clear() {
1266
+ if (this.#entries.length === 0) return;
1267
+ this.#entries = [];
1268
+ this.#notify();
1269
+ }
1270
+ /** Subscribes to scope stack changes. Returns an unsubscribe function. */
1271
+ subscribe(listener) {
1272
+ this.#listeners.add(listener);
1273
+ return () => {
1274
+ this.#listeners.delete(listener);
1275
+ };
1276
+ }
1277
+ #barrierIndex() {
1278
+ for (let index = this.#entries.length - 1; index >= 0; index--) if (this.#entries[index]?.exclusive) return index;
1279
+ return -1;
1280
+ }
1281
+ #notify() {
1282
+ for (const listener of this.#listeners) listener();
1283
+ }
1284
+ };
1285
+ //#endregion
1286
+ //#region src/audit.ts
1287
+ /** Development-only warnings about default bindings that cannot work as written. */
1288
+ const platformName = (platform) => platform === "mac" ? "macOS" : "Windows and Linux";
1289
+ /** A binding as the app wrote it, which is what there is to search the code for. */
1290
+ const written = (binding) => stepsOf(binding).join(" ");
1291
+ /**
1292
+ * Warns about bad syntax, keys the platform keeps or types with, unknown
1293
+ * overrides and collisions. Called behind a `NODE_ENV` check, so a production
1294
+ * build drops this module.
1295
+ */
1296
+ function auditRegistry(source) {
1297
+ const said = /* @__PURE__ */ new Set();
1298
+ const warn = (message) => {
1299
+ if (said.has(message)) return;
1300
+ said.add(message);
1301
+ console.warn(`[@inklu/keys] ${message}`);
1302
+ };
1303
+ /** Reserved bindings, and the platforms each is reserved on. */
1304
+ const reserved = /* @__PURE__ */ new Map();
1305
+ for (const platform of source.platforms) {
1306
+ for (const id of source.ids) for (const binding of source.bindings(id, platform)) {
1307
+ warnAboutBinding(id, binding, platform, warn);
1308
+ if (!isReserved(binding, platform)) continue;
1309
+ const subject = `"${id}" binds "${written(binding)}"`;
1310
+ reserved.set(subject, (reserved.get(subject) ?? /* @__PURE__ */ new Set()).add(platformName(platform)));
1311
+ }
1312
+ warnAboutConflicts(source, platform, warn);
1313
+ }
1314
+ for (const [subject, platforms] of reserved) warn(`${subject}, which the browser or operating system keeps for itself on ${[...platforms].join(" and ")}, so it may never reach the page.`);
1315
+ warnAboutUnknownOverrides(source, warn);
1316
+ }
1317
+ function warnAboutBinding(id, binding, platform, warn) {
1318
+ const { errors, warnings } = validateBinding(binding);
1319
+ if (errors.length > 0) {
1320
+ warn(`"${id}" has an unrecognized binding "${written(binding)}", so it never fires. ${errors.join("; ")}.`);
1321
+ return;
1322
+ }
1323
+ if (warnings.length > 0) warn(`"${id}" binds "${written(binding)}". ${warnings.join(" ")}`);
1324
+ const held = (parseBinding(binding, platform) ?? []).filter((step) => step.ctrl && step.alt);
1325
+ if (held.length === 0) return;
1326
+ if (platform === "mac") warn(`"${id}" binds "${written(binding)}". Control+Option is the VoiceOver modifier on macOS, so screen reader users cannot press it.`);
1327
+ else if (held.some((step) => typesCharacter({
1328
+ ...namedChord(step),
1329
+ ctrl: false,
1330
+ alt: false
1331
+ }))) warn(`"${id}" binds "${written(binding)}". Control+Alt is AltGr on many Windows and Linux keyboard layouts, where it types a character instead. Give those platforms other keys with { mac, other }.`);
1332
+ }
1333
+ function warnAboutConflicts(source, platform, warn) {
1334
+ const seen = /* @__PURE__ */ new Map();
1335
+ const visited = [];
1336
+ for (const id of source.ids) {
1337
+ const scope = source.scope(id);
1338
+ for (const binding of source.bindings(id, platform)) {
1339
+ for (const other of visited) {
1340
+ if (other.id === id || !scopesOverlap(other.scope, scope)) continue;
1341
+ if (!isPrefixCollision(other.binding, binding, platform)) continue;
1342
+ const [short, long] = stepsOf(other.binding).length < stepsOf(binding).length ? [other, {
1343
+ id,
1344
+ binding
1345
+ }] : [{
1346
+ id,
1347
+ binding
1348
+ }, other];
1349
+ warn(`prefix collision: ${written(short.binding)} ("${short.id}") is a prefix of ${written(long.binding)} ("${long.id}"), so the shorter binding fires on the way to the longer one.`);
1350
+ }
1351
+ visited.push({
1352
+ id,
1353
+ scope,
1354
+ binding
1355
+ });
1356
+ const key = `${scope ?? ""}::${bindingSignature(binding, platform)}`;
1357
+ const owner = seen.get(key);
1358
+ if (owner === void 0) {
1359
+ seen.set(key, id);
1360
+ continue;
1361
+ }
1362
+ warn(`shadowed binding: "${id}" and "${owner}" share ${written(binding)} in the same scope, so "${id}" takes the key whenever both have a handler.`);
1363
+ }
1364
+ }
1365
+ }
1366
+ /** Catches ids in `options.bindings` that TypeScript could not, as in plain JS. */
1367
+ function warnAboutUnknownOverrides(source, warn) {
1368
+ if (!source.overrides) return;
1369
+ const known = new Set(source.ids);
1370
+ const unknown = Object.keys(source.overrides).filter((id) => !known.has(id));
1371
+ if (unknown.length > 0) warn(`bindings override ${unknown.map((id) => `"${id}"`).join(", ")}, which ${unknown.length === 1 ? "is not a registered command" : "are not registered commands"}.`);
1372
+ }
1373
+ //#endregion
1374
+ //#region src/registry.ts
1375
+ /** Command registry: what an app can do, which keys do it, and where each is live. */
1376
+ const SSR_PLATFORM = "other";
1377
+ var CommandRegistry = class {
1378
+ #definitions;
1379
+ /** Handlers attached per command, the most recent last. */
1380
+ #handlers = /* @__PURE__ */ new Map();
1381
+ #keymap;
1382
+ #scopes = new ScopeStack();
1383
+ #keys;
1384
+ #options;
1385
+ #listeners = /* @__PURE__ */ new Set();
1386
+ #snapshot = null;
1387
+ /** What the snapshot is behind on: who is reachable, or the bindings too. */
1388
+ #stale = "bindings";
1389
+ #serverSnapshot = null;
1390
+ #pending = null;
1391
+ /** Stops following other tabs' keymap edits; set while mounted. */
1392
+ #unwatch = null;
1393
+ #mounts = 0;
1394
+ #hydrated = false;
1395
+ #singleKeys;
1396
+ constructor(definitions, options = {}) {
1397
+ this.#options = options;
1398
+ this.#singleKeys = options.singleKeyShortcuts !== false;
1399
+ this.#keymap = new Keymap(options.persist);
1400
+ this.#definitions = /* @__PURE__ */ new Map();
1401
+ for (const id in definitions) this.#definitions.set(id, definitions[id]);
1402
+ this.#setDefaults();
1403
+ this.#keys = new KeyListener({
1404
+ ids: () => [...this.#definitions.keys()],
1405
+ definition: (id) => this.#definitions.get(id),
1406
+ bindings: (id) => this.#bindings(id),
1407
+ reachable: (id) => this.#reachable(id),
1408
+ depth: (id) => this.#scopes.depth(this.#definitions.get(id)?.scope),
1409
+ platform: () => this.#platform(),
1410
+ run: (id, event, binding) => this.#execute(id, {
1411
+ source: "keyboard",
1412
+ event,
1413
+ binding
1414
+ })
1415
+ }, options.target);
1416
+ this.#keymap.subscribe(() => this.#bindingsChanged());
1417
+ this.#scopes.subscribe(() => this.#reachabilityChanged());
1418
+ if (process.env.NODE_ENV !== "production") auditRegistry({
1419
+ ids: [...this.#definitions.keys()],
1420
+ scope: (id) => this.#definitions.get(id)?.scope,
1421
+ bindings: (id, platform) => this.#defaultBindings(id, platform),
1422
+ platforms: options.platform ? [options.platform] : ["mac", "other"],
1423
+ overrides: options.bindings
1424
+ });
1425
+ }
1426
+ get keymap() {
1427
+ return this.#keymap;
1428
+ }
1429
+ get(id) {
1430
+ return this.#snapshots().index.get(id);
1431
+ }
1432
+ list() {
1433
+ return this.#snapshots().list;
1434
+ }
1435
+ serverList() {
1436
+ if (this.#serverSnapshot) return this.#serverSnapshot;
1437
+ const platform = this.#options.platform ?? SSR_PLATFORM;
1438
+ const options = {
1439
+ platform,
1440
+ ...this.#options.display
1441
+ };
1442
+ const singleKeys = this.#options.singleKeyShortcuts !== false;
1443
+ this.#serverSnapshot = Array.from(this.#definitions, ([id, definition]) => {
1444
+ const bindings = this.#defaultBindings(id, platform).filter((binding) => singleKeys || !isSingleKeyBinding(binding));
1445
+ const global = definition.scope === void 0 || definition.scope === "*";
1446
+ return describeCommand(id, definition, bindings, platform, options, {
1447
+ reachable: definition.run !== void 0 && global,
1448
+ overridden: false
1449
+ });
1450
+ });
1451
+ return this.#serverSnapshot;
1452
+ }
1453
+ run(id, options = {}) {
1454
+ if (this.whyNotRun(id) !== null) return false;
1455
+ this.#execute(id, {
1456
+ source: options.source ?? "api",
1457
+ event: options.event
1458
+ });
1459
+ return true;
1460
+ }
1461
+ canRun(id) {
1462
+ return this.whyNotRun(id) === null;
1463
+ }
1464
+ whyNotRun(id) {
1465
+ const definition = this.#definitions.get(id);
1466
+ if (!definition) return "unknown";
1467
+ if (!this.#handler(id)) return "unhandled";
1468
+ return this.#scopes.isOpen(definition.scope) ? null : "out-of-scope";
1469
+ }
1470
+ handle(id, run) {
1471
+ if (!this.#definitions.has(id)) return () => {};
1472
+ let handlers = this.#handlers.get(id);
1473
+ if (!handlers) {
1474
+ handlers = [];
1475
+ this.#handlers.set(id, handlers);
1476
+ }
1477
+ const had = this.#handler(id) !== void 0;
1478
+ handlers.push(run);
1479
+ if (!had) this.#reachabilityChanged();
1480
+ let disposed = false;
1481
+ return () => {
1482
+ if (disposed) return;
1483
+ disposed = true;
1484
+ const index = handlers.lastIndexOf(run);
1485
+ if (index !== -1) handlers.splice(index, 1);
1486
+ if (this.#handler(id) === void 0) this.#reachabilityChanged();
1487
+ };
1488
+ }
1489
+ pushScope(name, options) {
1490
+ return this.#scopes.push(name, options);
1491
+ }
1492
+ capture(onKey) {
1493
+ const release = this.#keys.capture(onKey);
1494
+ this.#reachabilityChanged();
1495
+ let released = false;
1496
+ return () => {
1497
+ if (released) return;
1498
+ released = true;
1499
+ release();
1500
+ this.#reachabilityChanged();
1501
+ };
1502
+ }
1503
+ conflicts(binding, options = {}) {
1504
+ const platform = this.#platform();
1505
+ const display = (entry) => formatBinding(entry, this.#displayOptions());
1506
+ const candidates = toBindings(binding);
1507
+ const signatures = new Set(candidates.map((entry) => bindingSignature(entry, platform)));
1508
+ const conflicts = [];
1509
+ for (const candidate of candidates) {
1510
+ if (!isReserved(candidate, platform)) continue;
1511
+ conflicts.push({
1512
+ kind: "reserved",
1513
+ binding: candidate,
1514
+ display: display(candidate)
1515
+ });
1516
+ }
1517
+ for (const [id, { scope }] of this.#definitions) {
1518
+ if (id === options.ignore || !scopesOverlap(scope, options.scope)) continue;
1519
+ for (const existing of this.#bindings(id)) {
1520
+ const kind = signatures.has(bindingSignature(existing, platform)) ? "exact" : candidates.some((candidate) => isPrefixCollision(candidate, existing, platform)) ? "prefix" : null;
1521
+ if (kind === null) continue;
1522
+ conflicts.push({
1523
+ kind,
1524
+ id,
1525
+ binding: existing,
1526
+ display: display(existing),
1527
+ scope
1528
+ });
1529
+ }
1530
+ }
1531
+ return conflicts;
1532
+ }
1533
+ pending() {
1534
+ const from = this.#keys.progress();
1535
+ if (this.#pending?.from !== from) {
1536
+ const options = this.#displayOptions();
1537
+ this.#pending = {
1538
+ from,
1539
+ list: from.map(({ id, binding, matched }) => ({
1540
+ id,
1541
+ binding,
1542
+ matched,
1543
+ total: binding.sequence.length,
1544
+ remaining: binding.sequence.slice(matched).map((step) => formatBinding(step, options))
1545
+ }))
1546
+ };
1547
+ }
1548
+ return this.#pending.list;
1549
+ }
1550
+ subscribePending(listener) {
1551
+ return this.#keys.watch(listener);
1552
+ }
1553
+ get singleKeyShortcuts() {
1554
+ return this.#singleKeys;
1555
+ }
1556
+ setSingleKeyShortcuts(enabled) {
1557
+ if (this.#singleKeys === enabled) return;
1558
+ this.#singleKeys = enabled;
1559
+ this.#bindingsChanged();
1560
+ }
1561
+ mount() {
1562
+ this.#mounts++;
1563
+ if (this.#mounts === 1) {
1564
+ const rendered = this.#platform();
1565
+ this.#hydrated = true;
1566
+ const moved = this.#platform() !== rendered;
1567
+ if (moved) this.#setDefaults();
1568
+ this.#unwatch = this.#keymap.watch();
1569
+ this.#keys.attach();
1570
+ if (!this.#keymap.sync() && moved) this.#bindingsChanged();
1571
+ }
1572
+ let released = false;
1573
+ return () => {
1574
+ if (released) return;
1575
+ released = true;
1576
+ if (this.#mounts === 0) return;
1577
+ this.#mounts--;
1578
+ if (this.#mounts === 0) this.#unmount();
1579
+ };
1580
+ }
1581
+ subscribe(listener) {
1582
+ this.#listeners.add(listener);
1583
+ return () => {
1584
+ this.#listeners.delete(listener);
1585
+ };
1586
+ }
1587
+ destroy() {
1588
+ this.#mounts = 0;
1589
+ this.#unwatch?.();
1590
+ this.#unwatch = null;
1591
+ this.#keys.destroy();
1592
+ this.#definitions.clear();
1593
+ this.#handlers.clear();
1594
+ this.#serverSnapshot = null;
1595
+ this.#invalidate("bindings");
1596
+ this.#listeners.clear();
1597
+ this.#scopes.clear();
1598
+ }
1599
+ #unmount() {
1600
+ this.#keys.detach();
1601
+ this.#unwatch?.();
1602
+ this.#unwatch = null;
1603
+ }
1604
+ /** The handler that runs: the one attached last, else the definition's own. */
1605
+ #handler(id) {
1606
+ return this.#handlers.get(id)?.at(-1) ?? this.#definitions.get(id)?.run;
1607
+ }
1608
+ #reachable(id) {
1609
+ if (this.#keys.captured || !this.#handler(id)) return false;
1610
+ return this.#scopes.isActive(this.#definitions.get(id)?.scope);
1611
+ }
1612
+ /** Bindings in effect: the keymap's, minus single-key ones while those are switched off. */
1613
+ #bindings(id) {
1614
+ const bindings = this.#keymap.get(id);
1615
+ if (this.#singleKeys) return bindings;
1616
+ return bindings.filter((binding) => !isSingleKeyBinding(binding));
1617
+ }
1618
+ /**
1619
+ * A command's default bindings on a platform. An `options.bindings` entry
1620
+ * wins over the definition's `keys`, and a `null` one means unbound.
1621
+ */
1622
+ #defaultBindings(id, platform) {
1623
+ const overrides = this.#options.bindings;
1624
+ return toBindings(keysFor(overrides && Object.hasOwn(overrides, id) ? overrides[id] : this.#definitions.get(id)?.keys, platform));
1625
+ }
1626
+ /** Hands the keymap every command's defaults for the platform in effect. */
1627
+ #setDefaults() {
1628
+ const platform = this.#platform();
1629
+ for (const id of this.#definitions.keys()) this.#keymap.setDefault(id, this.#defaultBindings(id, platform));
1630
+ }
1631
+ #execute(id, context) {
1632
+ const run = this.#handler(id);
1633
+ if (!run) return;
1634
+ const full = {
1635
+ id,
1636
+ ...context,
1637
+ commands: this
1638
+ };
1639
+ if (this.#options.onRun) try {
1640
+ this.#options.onRun(full);
1641
+ } catch (error) {
1642
+ this.#reportError(toError(error), full);
1643
+ }
1644
+ try {
1645
+ const result = run(full);
1646
+ if (result instanceof Promise) result.catch((cause) => this.#reportError(toError(cause), full));
1647
+ } catch (error) {
1648
+ this.#reportError(toError(error), full);
1649
+ }
1650
+ }
1651
+ #reportError(error, context) {
1652
+ if (this.#options.onError) {
1653
+ this.#options.onError(error, context);
1654
+ return;
1655
+ }
1656
+ console.error(`[@inklu/keys] command "${context.id}" failed`, error);
1657
+ }
1658
+ #bindingsChanged() {
1659
+ this.#keys.refresh();
1660
+ this.#invalidate("bindings");
1661
+ }
1662
+ #reachabilityChanged() {
1663
+ this.#keys.prune();
1664
+ this.#invalidate("reachable");
1665
+ }
1666
+ #platform() {
1667
+ return this.#options.platform ?? (this.#hydrated ? detectPlatform() : SSR_PLATFORM);
1668
+ }
1669
+ #displayOptions() {
1670
+ return {
1671
+ platform: this.#platform(),
1672
+ ...this.#options.display
1673
+ };
1674
+ }
1675
+ /**
1676
+ * The commands as they are now. A command that did not change keeps its
1677
+ * object, and the list keeps its own while none did, so a subscriber can
1678
+ * tell by identity whether it has anything to do.
1679
+ */
1680
+ #snapshots() {
1681
+ const previous = this.#snapshot;
1682
+ if (previous && !this.#stale) return previous;
1683
+ const kept = this.#stale === "reachable" ? previous?.index : void 0;
1684
+ const platform = this.#platform();
1685
+ const options = this.#displayOptions();
1686
+ const list = [];
1687
+ const index = /* @__PURE__ */ new Map();
1688
+ let changed = !kept;
1689
+ for (const [id, definition] of this.#definitions) {
1690
+ const reachable = this.#reachable(id);
1691
+ let command = kept?.get(id);
1692
+ if (!command) command = describeCommand(id, definition, this.#bindings(id), platform, options, {
1693
+ reachable,
1694
+ overridden: this.#keymap.isOverridden(id)
1695
+ });
1696
+ else if (command.reachable !== reachable) {
1697
+ command = {
1698
+ ...command,
1699
+ reachable
1700
+ };
1701
+ changed = true;
1702
+ }
1703
+ list.push(command);
1704
+ index.set(id, command);
1705
+ }
1706
+ this.#stale = null;
1707
+ this.#snapshot = changed || !previous ? {
1708
+ list,
1709
+ index
1710
+ } : previous;
1711
+ return this.#snapshot;
1712
+ }
1713
+ #invalidate(stale) {
1714
+ if (this.#stale !== "bindings") this.#stale = stale;
1715
+ for (const listener of this.#listeners) listener();
1716
+ }
1717
+ };
1718
+ function describeCommand(id, definition, source, platform, options, state) {
1719
+ const bindings = [...source];
1720
+ return {
1721
+ id,
1722
+ title: definition.title,
1723
+ description: definition.description,
1724
+ group: definition.group,
1725
+ keywords: definition.keywords ?? [],
1726
+ hidden: definition.hidden === true,
1727
+ scope: definition.scope,
1728
+ bindings,
1729
+ display: bindings.map((binding) => formatBinding(binding, options)),
1730
+ tokens: bindings.map((binding) => bindingTokens(binding, options)),
1731
+ aria: bindings.map((binding) => ariaKeyShortcuts(binding, platform)).filter((value) => value.length > 0).join(" "),
1732
+ spoken: bindings.map((binding) => spokenBinding(binding, platform)).join(" or "),
1733
+ ...state
1734
+ };
1735
+ }
1736
+ /** Normalizes an arbitrary thrown value into a real `Error` at the catch boundary. */
1737
+ function toError(cause) {
1738
+ return cause instanceof Error ? cause : new Error(String(cause));
1739
+ }
1740
+ /**
1741
+ * Creates a command registry.
1742
+ *
1743
+ * @example
1744
+ * ```ts
1745
+ * export const commands = createCommands({
1746
+ * "palette.open": { title: "Open command palette", keys: "Mod+K" },
1747
+ * "nav.inbox": {
1748
+ * title: "Go to inbox",
1749
+ * keys: seq("G", "I"),
1750
+ * run: () => router.navigate("/inbox"),
1751
+ * },
1752
+ * });
1753
+ * ```
1754
+ */
1755
+ function createCommands(definitions, options) {
1756
+ return new CommandRegistry(definitions, options);
1757
+ }
1758
+ //#endregion
1759
+ //#region src/search.ts
1760
+ /**
1761
+ * Filters commands for a search query, leaving out `hidden` ones. Every word
1762
+ * must appear in the title, description, group, keywords, id or displayed
1763
+ * shortcut. Titles starting with the query come first, then titles containing
1764
+ * every word, then the rest. An empty query returns every visible command.
1765
+ *
1766
+ * @example
1767
+ * ```ts
1768
+ * const results = matchCommands(commands.list(), "go inbox");
1769
+ * ```
1770
+ */
1771
+ function matchCommands(commands, query) {
1772
+ const visible = commands.filter((command) => !command.hidden);
1773
+ const needle = query.trim().toLowerCase();
1774
+ if (!needle) return visible;
1775
+ const terms = needle.split(/\s+/);
1776
+ const ranked = [];
1777
+ for (const command of visible) {
1778
+ const title = command.title.toLowerCase();
1779
+ const text = [
1780
+ title,
1781
+ command.description,
1782
+ command.group,
1783
+ command.id,
1784
+ ...command.keywords,
1785
+ ...command.display
1786
+ ].join("\n").toLowerCase();
1787
+ if (!terms.every((term) => text.includes(term))) continue;
1788
+ const rank = title.startsWith(needle) ? 0 : terms.every((term) => title.includes(term)) ? 1 : 2;
1789
+ ranked.push({
1790
+ command,
1791
+ rank
1792
+ });
1793
+ }
1794
+ return ranked.sort((a, b) => a.rank - b.rank).map(({ command }) => command);
1795
+ }
1796
+ //#endregion
1797
+ export { importKeymap as a, bindingTokens as c, isSingleKeyBinding as d, seq as f, exportKeymap as i, formatBinding as l, keyName$1 as m, createCommands as n, webStorage as o, isModifierKey as p, createRebinder as r, ariaKeyShortcuts as s, matchCommands as t, spokenBinding as u };