@inklu/keys 2.2.0 → 3.0.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.
@@ -48,7 +48,6 @@ const MODIFIERS = [
48
48
  "Shift",
49
49
  "Meta"
50
50
  ];
51
- const PHYSICAL = /^\[([A-Za-z][A-Za-z0-9]*)\]$/;
52
51
  const FUNCTION_KEY = /^F([1-9]|1\d|2[0-4])$/;
53
52
  const LETTER = /^\p{Letter}$/u;
54
53
  const LATIN_LETTER = /^\p{Script_Extensions=Latin}$/u;
@@ -60,7 +59,7 @@ function split(chord) {
60
59
  return parts.map((part) => part.trim());
61
60
  }
62
61
  /** One spelling per key: letters upper-cased, the space bar by name. */
63
- function keyName$1(key) {
62
+ function keyName(key) {
64
63
  if (key === " ") return "Space";
65
64
  if (!LETTER.test(key)) return key;
66
65
  const upper = key.toUpperCase();
@@ -68,11 +67,21 @@ function keyName$1(key) {
68
67
  }
69
68
  /** The key a keypress produced. Empty for the keyless events browser autofill dispatches. */
70
69
  function pressedKey(event) {
71
- return keyName$1(String(event.key ?? "").normalize("NFC"));
72
- }
73
- function isOneCharacter(key) {
74
- return Array.from(key).length === 1;
70
+ return keyName(String(event.key ?? "").normalize("NFC"));
75
71
  }
72
+ const isOneCharacter = (key) => Array.from(key).length === 1;
73
+ const isAscii = (key) => /^[\x21-\x7e]$/.test(key);
74
+ /** A one-character key that is not a letter: a digit or a symbol. */
75
+ const isSymbol = (key) => isOneCharacter(key) && !LETTER.test(key);
76
+ /** `KeyboardEvent.key` values that name neither a character nor a key. */
77
+ const NO_KEY = /* @__PURE__ */ new Set([
78
+ "",
79
+ "Dead",
80
+ "Unidentified",
81
+ "Process"
82
+ ]);
83
+ /** A key known by name rather than by a character, such as `Enter`. */
84
+ const isNamed = (key) => !isOneCharacter(key) && !NO_KEY.has(key);
76
85
  /** Reads a chord, or says why it cannot be read. */
77
86
  function read(chord, platform) {
78
87
  if (chord.trim() === "") return "Chord cannot be empty";
@@ -88,14 +97,12 @@ function read(chord, platform) {
88
97
  if (held.has(modifier)) return `Repeated modifier: '${part}'`;
89
98
  held.add(modifier);
90
99
  }
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
100
  return {
94
101
  ctrl: held.has("Control"),
95
102
  alt: held.has("Alt"),
96
103
  shift: held.has("Shift"),
97
104
  meta: held.has("Meta"),
98
- ...code === void 0 ? { key: keyName$1(key) } : { code }
105
+ key: keyName(key)
99
106
  };
100
107
  }
101
108
  /** Parses a chord for a platform, or returns `null` for one that cannot be read. */
@@ -114,10 +121,14 @@ function validateChord(chord) {
114
121
  errors: [result],
115
122
  warnings: []
116
123
  };
117
- const key = result.key;
124
+ const { key } = result;
125
+ const known = isOneCharacter(key) || FUNCTION_KEY.test(key) || NAMED_KEYS.some((name) => name === key);
126
+ const warnings = [];
127
+ if (!known) warnings.push(`Unknown key: '${key}'. Key names are KeyboardEvent.key values, such as Escape.`);
128
+ if (result.shift && isSymbol(key) && !/\d/.test(key)) warnings.push(`Shift+${key} never fires: write the symbol the key types with Shift held, such as '?'.`);
118
129
  return {
119
130
  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.`]
131
+ warnings
121
132
  };
122
133
  }
123
134
  /** Writes a parsed chord as text. Given a platform, its primary modifier is written `Mod`. */
@@ -128,13 +139,12 @@ function writeChord(chord, mod) {
128
139
  if (chord.alt) parts.push("Alt");
129
140
  if (chord.shift) parts.push("Shift");
130
141
  if (chord.meta && !portable) parts.push("Meta");
131
- parts.push(chord.code === void 0 ? chord.key ?? "" : `[${chord.code}]`);
142
+ parts.push(chord.key);
132
143
  return parts.join("+");
133
144
  }
134
145
  /** Whether a chord types a character: a one-character key with no Control, Alt or Meta. */
135
146
  function typesCharacter(chord) {
136
- if (chord.ctrl || chord.alt || chord.meta) return false;
137
- return isOneCharacter(chord.key ?? keyAtCode(chord.code ?? "") ?? "");
147
+ return !chord.ctrl && !chord.alt && !chord.meta && isOneCharacter(chord.key);
138
148
  }
139
149
  const MODIFIER_KEYS = /* @__PURE__ */ new Set([
140
150
  ...MODIFIERS,
@@ -167,68 +177,30 @@ const PUNCTUATION_CODES = {
167
177
  Semicolon: ";",
168
178
  Slash: "/"
169
179
  };
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
180
  /**
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.
181
+ * The key at a keypress's position on the US layout, where that stands in for
182
+ * what the layout typed: on the digit row always (`Mod+1` on AZERTY, whose
183
+ * digit row types symbols), and on a letter or punctuation key when the layout
184
+ * typed no Latin character there (Cyrillic or Greek, or a key macOS Option altered).
206
185
  */
207
- function positionKey(event, key, altGraph) {
208
- if (altGraph || /^[A-Z]$/.test(key) || isNamed(key)) return void 0;
186
+ function positionKey(event, key) {
209
187
  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];
188
+ if (/^Digit\d$/.test(code)) return code.slice(5);
189
+ if (isAscii(key) || LATIN_LETTER.test(key) && !event.altKey) return void 0;
190
+ return /^Key[A-Z]$/.test(code) ? code.slice(3) : PUNCTUATION_CODES[code];
214
191
  }
215
192
  /**
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).
193
+ * Whether a keypress is this chord: the key it names was typed, or sits at
194
+ * that position where `positionKey` allows, with the same modifiers held. A
195
+ * symbol carries the Shift it took to type (`?` is Shift+/ on a US keyboard),
196
+ * so that Shift need not be named.
222
197
  */
223
198
  function matchesChord(event, chord, platform) {
224
199
  const altGraph = hasAltGraph(event, platform);
225
200
  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
201
  const key = pressedKey(event);
230
- if (key === chord.key) return modifiersMatch(isSymbol(key));
231
- return positionKey(event, key, altGraph) === chord.key && modifiersMatch(false);
202
+ const typed = key === chord.key && isSymbol(key);
203
+ return (key === chord.key || !altGraph && positionKey(event, key) === chord.key) && (!altGraph && event.ctrlKey) === chord.ctrl && ((!altGraph && event.altKey) === chord.alt || typed && event.altKey && platform === "mac") && event.metaKey === chord.meta && (event.shiftKey === chord.shift || typed && event.shiftKey);
232
204
  }
233
205
  /**
234
206
  * The chord a keypress expresses, for recording a shortcut, or `null` for a
@@ -240,14 +212,12 @@ function readChord(event, platform) {
240
212
  if (isModifierKey(event.key)) return null;
241
213
  const pressed = pressedKey(event);
242
214
  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;
215
+ const key = altGraph || isAscii(pressed) || isNamed(pressed) ? pressed : positionKey(event, pressed) ?? pressed;
245
216
  if (NO_KEY.has(key)) return null;
246
217
  const typed = key === pressed && isSymbol(key);
247
- const alt = !altGraph && event.altKey;
248
218
  const chord = writeChord({
249
219
  ctrl: !altGraph && event.ctrlKey,
250
- alt: alt && !(typed && platform === "mac"),
220
+ alt: !altGraph && event.altKey && !(typed && platform === "mac"),
251
221
  shift: event.shiftKey && !typed,
252
222
  meta: event.metaKey,
253
223
  key
@@ -256,7 +226,7 @@ function readChord(event, platform) {
256
226
  }
257
227
  //#endregion
258
228
  //#region src/bindings.ts
259
- /** Bindings: a chord or a sequence of them. Comparing, validating and serializing. */
229
+ /** Bindings: a chord or a sequence of them. Comparing, validating and reading per platform. */
260
230
  /**
261
231
  * Creates a sequence binding from ordered chords.
262
232
  *
@@ -279,7 +249,7 @@ function stepsOf(binding) {
279
249
  /** Normalizes a binding input into an array of bindings. A one-step sequence becomes a chord. */
280
250
  function toBindings(input) {
281
251
  if (input == null) return [];
282
- const candidates = isList(input) ? input : [input];
252
+ const candidates = Array.isArray(input) ? input : [input];
283
253
  const bindings = [];
284
254
  for (const candidate of candidates) {
285
255
  if (candidate == null) continue;
@@ -290,9 +260,6 @@ function toBindings(input) {
290
260
  }
291
261
  return bindings;
292
262
  }
293
- function isList(input) {
294
- return Array.isArray(input);
295
- }
296
263
  /** The side of `{ mac, other }` keys that applies on a platform. Keys that do not differ pass through. */
297
264
  function keysFor(keys, platform) {
298
265
  if (keys == null || !isPerPlatform(keys)) return keys;
@@ -301,10 +268,9 @@ function keysFor(keys, platform) {
301
268
  function isPerPlatform(keys) {
302
269
  return typeof keys === "object" && !Array.isArray(keys) && !("sequence" in keys);
303
270
  }
304
- /** Generates a normalized signature for binding comparison and conflict detection. */
271
+ /** One spelling of a binding on a platform, so equivalent bindings compare equal. */
305
272
  function bindingSignature(binding, platform) {
306
- const steps = canonicalSteps(binding, platform);
307
- return isSequence(binding) ? `seq:${steps.join(" ")}` : `chord:${steps.join(" ")}`;
273
+ return canonicalSteps(binding, platform).join(" ");
308
274
  }
309
275
  /** Whether one binding is a strict prefix of the other, like chord `G` against `G then I`. */
310
276
  function isPrefixCollision(a, b, platform) {
@@ -324,8 +290,8 @@ function parseBinding(binding, platform) {
324
290
  }
325
291
  return steps;
326
292
  }
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()) {
293
+ /** Each step in one spelling. One that cannot be read stays as written. */
294
+ function canonicalSteps(binding, platform) {
329
295
  return stepsOf(binding).map((step) => {
330
296
  const parsed = parseChord(step, platform);
331
297
  return parsed ? writeChord(parsed) : step;
@@ -348,20 +314,14 @@ function validateBinding(binding) {
348
314
  }
349
315
  //#endregion
350
316
  //#region src/display.ts
351
- /** Writing a binding for people: on screen, in `aria-keyshortcuts`, and read aloud. */
317
+ /** Writing a binding for people: on screen, and in `aria-keyshortcuts`. */
352
318
  const MODIFIER_LABELS = {
353
- symbols: [
319
+ mac: [
354
320
  "⌃",
355
321
  "⌥",
356
322
  "⇧",
357
323
  "⌘"
358
324
  ],
359
- mac: [
360
- "Control",
361
- "Option",
362
- "Shift",
363
- "Cmd"
364
- ],
365
325
  other: [
366
326
  "Ctrl",
367
327
  "Alt",
@@ -369,181 +329,69 @@ const MODIFIER_LABELS = {
369
329
  "Meta"
370
330
  ]
371
331
  };
372
- /** The abbreviated modifier labels, in full, for reading aloud. */
373
- const SPOKEN_MODIFIERS = {
374
- Ctrl: "Control",
375
- Cmd: "Command"
376
- };
332
+ /** Named keys as each platform's keyboard prints them: on a Mac, then on Windows and Linux. */
377
333
  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
- ]
334
+ Enter: ["↩", "Enter"],
335
+ Backspace: ["⌫", "Backspace"],
336
+ Delete: ["⌦", "Del"],
337
+ Tab: ["⇥", "Tab"],
338
+ Escape: ["⎋", "Esc"],
339
+ ArrowUp: ["↑", "↑"],
340
+ ArrowDown: ["↓", "↓"],
341
+ ArrowLeft: ["←", "←"],
342
+ ArrowRight: ["→", "→"],
343
+ Home: ["↖", "Home"],
344
+ End: ["↘", "End"],
345
+ PageUp: ["⇞", "PgUp"],
346
+ PageDown: ["⇟", "PgDn"],
347
+ "+": ["+", "Plus"]
451
348
  };
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
349
  /**
468
350
  * Formats a binding the way its platform writes shortcuts: `⇧⌘S` on a Mac,
469
351
  * `Ctrl+Shift+S` elsewhere. The steps of a sequence are set apart by a space.
470
352
  */
471
- function formatBinding(binding, options) {
472
- return stepsOf(binding).map((step) => formatChord(step, options)).join(" ");
353
+ function formatBinding(binding, platform = detectPlatform()) {
354
+ return stepsOf(binding).map((step) => formatChord(step, platform)).join(" ");
473
355
  }
474
356
  /**
475
357
  * Splits a binding into display tokens for rendering `<kbd>` elements: one
476
358
  * per key of a chord, one per step of a sequence.
477
359
  */
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) ? "" : "+");
360
+ function bindingTokens(binding, platform = detectPlatform()) {
361
+ if (isSequence(binding)) return binding.sequence.map((step) => formatChord(step, platform));
362
+ return chordTokens(binding, platform);
484
363
  }
485
- function usesModifierSymbols(options) {
486
- return (options?.platform ?? detectPlatform()) === "mac" && options?.useSymbols !== false;
364
+ function formatChord(chord, platform) {
365
+ return chordTokens(chord, platform).join(platform === "mac" ? "" : "+");
487
366
  }
488
- function chordTokens(chord, options) {
489
- const platform = options?.platform ?? detectPlatform();
367
+ function chordTokens(chord, platform) {
490
368
  const parsed = parseChord(chord, platform);
491
369
  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
370
  const held = [
497
- chord.ctrl,
498
- chord.alt,
499
- chord.shift,
500
- chord.meta
371
+ parsed.ctrl,
372
+ parsed.alt,
373
+ parsed.shift,
374
+ parsed.meta
501
375
  ];
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 ");
376
+ return [...MODIFIER_LABELS[platform].filter((_, index) => held[index]), KEY_LABELS[parsed.key]?.[platform === "mac" ? 0 : 1] ?? parsed.key];
526
377
  }
527
378
  /**
528
379
  * The `aria-keyshortcuts` value for a chord, as in `"Meta+K"`. Empty for a
529
- * sequence and for a chord that cannot be read.
380
+ * sequence, which the attribute cannot express, and for a chord that cannot be read.
530
381
  */
531
382
  function ariaKeyShortcuts(binding, platform = detectPlatform()) {
532
383
  const parsed = isSequence(binding) ? null : parseChord(binding, platform);
533
384
  if (!parsed) return "";
534
- const chord = namedChord(parsed);
535
- return writeChord(chord.key === "+" ? {
536
- ...chord,
385
+ return writeChord(parsed.key === "+" ? {
386
+ ...parsed,
537
387
  key: "Plus"
538
- } : chord);
388
+ } : parsed);
539
389
  }
540
390
  //#endregion
541
391
  //#region src/keymap.ts
542
392
  /** Keymap: default bindings, and the user's overrides on top of them. */
543
393
  /** 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
- }
394
+ const portable = (binding) => `${bindingSignature(binding, "mac")} ${bindingSignature(binding, "other")}`;
547
395
  var Keymap = class {
548
396
  #defaults = /* @__PURE__ */ new Map();
549
397
  #overrides = /* @__PURE__ */ new Map();
@@ -568,36 +416,18 @@ var Keymap = class {
568
416
  return this.#overrides;
569
417
  }
570
418
  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)];
419
+ return this.#overrides.get(id) ?? this.#defaults.get(id) ?? [];
576
420
  }
577
421
  set(id, input) {
578
- this.#load();
422
+ this.load();
579
423
  const bindings = input === null ? [] : toBindings(input);
580
424
  if (sameBindings(bindings, this.#defaults.get(id) ?? [])) {
581
425
  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
- });
426
+ } else this.#overrides.set(id, bindings);
597
427
  this.#changed();
598
428
  }
599
429
  reset(id) {
600
- this.#load();
430
+ this.load();
601
431
  if (id === void 0) {
602
432
  if (this.#overrides.size === 0) return;
603
433
  this.#overrides.clear();
@@ -611,23 +441,19 @@ var Keymap = class {
611
441
  this.#changed();
612
442
  }
613
443
  /**
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.
444
+ * Takes the stored overrides, once: on the first mount, or before an edit
445
+ * made earlier, so the edit lands on top of them. Tells subscribers if they
446
+ * differ from the ones in memory, and returns whether they did.
617
447
  */
618
- sync() {
448
+ load() {
449
+ if (this.#loaded) return false;
619
450
  this.#loaded = true;
620
451
  const stored = this.#store?.read();
621
452
  if (!stored) return false;
622
- const before = JSON.stringify([...this.#overrides]);
453
+ const changed = JSON.stringify([...stored]) !== JSON.stringify([...this.#overrides]);
623
454
  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()) ?? (() => {});
455
+ if (changed) this.#notify();
456
+ return changed;
631
457
  }
632
458
  /** Subscribes to keymap changes. Returns an unsubscribe function. */
633
459
  subscribe(listener) {
@@ -636,10 +462,6 @@ var Keymap = class {
636
462
  this.#listeners.delete(listener);
637
463
  };
638
464
  }
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
465
  #changed() {
644
466
  this.#store?.write(this.#overrides);
645
467
  this.#notify();
@@ -648,31 +470,18 @@ var Keymap = class {
648
470
  for (const listener of this.#listeners) listener();
649
471
  }
650
472
  };
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
473
  /** Same bindings in the same order; the first is the one shortcuts display. */
662
474
  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
- });
475
+ return a.length === b.length && a.every((binding, index) => portable(binding) === portable(b[index]));
667
476
  }
668
477
  //#endregion
669
478
  //#region src/persist.ts
670
479
  /** Keeping a keymap between visits: Web Storage, and export and import as JSON. */
671
480
  const VERSION = 1;
672
481
  /**
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.
482
+ * Keeps user overrides in Web Storage under `key`. `storage` is only called in
483
+ * the browser; return `sessionStorage` or an object of your own to keep them
484
+ * somewhere else.
676
485
  *
677
486
  * @example
678
487
  * ```ts
@@ -703,14 +512,6 @@ function webStorage(key = "inklu.keys", storage = () => localStorage) {
703
512
  if (overrides.size === 0) open()?.removeItem(key);
704
513
  else open()?.setItem(key, JSON.stringify(serializeKeymap(overrides)));
705
514
  } 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
515
  }
715
516
  };
716
517
  }
@@ -733,10 +534,7 @@ function keymapOf({ keymap }) {
733
534
  function serializeKeymap(overrides) {
734
535
  return {
735
536
  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
- }))
537
+ overrides: Object.fromEntries(Array.from(overrides, ([id, bindings]) => [id, bindings.map((binding) => isSequence(binding) ? [...binding.sequence] : binding)]))
740
538
  };
741
539
  }
742
540
  /** The overrides in stored data, or `null` for data in another format. */
@@ -745,36 +543,12 @@ function parseKeymap(data) {
745
543
  if (stored === void 0 || !isObject(stored)) return null;
746
544
  const overrides = /* @__PURE__ */ new Map();
747
545
  for (const [id, value] of Object.entries(stored)) {
748
- const override = parseOverride(value);
749
- if (override) overrides.set(id, override);
546
+ if (!Array.isArray(value)) continue;
547
+ const bindings = value.map(parseStoredBinding).filter((binding) => binding !== null);
548
+ if (bindings.length > 0 || value.length === 0) overrides.set(id, bindings);
750
549
  }
751
550
  return overrides;
752
551
  }
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
552
  /** A stored chord (a string) or sequence (a list of them), or `null` if it cannot be read. */
779
553
  function parseStoredBinding(value) {
780
554
  if (typeof value === "string") return isChord(value) ? value : null;
@@ -792,266 +566,7 @@ function isObject(value) {
792
566
  return typeof value === "object" && value !== null && !Array.isArray(value);
793
567
  }
794
568
  //#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
569
  //#region src/rebind.ts
1054
- /** Controller for recording and applying hotkey rebindings. */
1055
570
  const NO_STEPS = [];
1056
571
  /**
1057
572
  * Creates a rebinder for recording a command's keys. While it records, no
@@ -1067,7 +582,6 @@ const NO_STEPS = [];
1067
582
  * ```
1068
583
  */
1069
584
  function createRebinder(registry, id, options = {}) {
1070
- const sequence = options.mode === "sequence";
1071
585
  const listeners = /* @__PURE__ */ new Set();
1072
586
  let state = {
1073
587
  recording: false,
@@ -1076,7 +590,6 @@ function createRebinder(registry, id, options = {}) {
1076
590
  conflicts: []
1077
591
  };
1078
592
  let release = null;
1079
- let idle = null;
1080
593
  const set = (next) => {
1081
594
  state = next;
1082
595
  for (const listener of listeners) listener();
@@ -1085,20 +598,15 @@ function createRebinder(registry, id, options = {}) {
1085
598
  const end = () => {
1086
599
  release?.();
1087
600
  release = null;
1088
- if (idle !== null) clearTimeout(idle);
1089
- idle = null;
1090
601
  };
1091
602
  const record = (binding) => {
1092
603
  end();
1093
- const conflicts = registry.conflicts(binding, {
604
+ const conflicts = binding ? registry.conflicts(binding, {
1094
605
  ignore: id,
1095
606
  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
- }
607
+ }) : [];
608
+ const blocked = options.rejectConflicts === true && conflicts.length > 0;
609
+ if (options.apply !== false && !blocked) registry.keymap.set(id, binding);
1102
610
  set({
1103
611
  recording: false,
1104
612
  binding,
@@ -1108,6 +616,7 @@ function createRebinder(registry, id, options = {}) {
1108
616
  options.onRecord?.(binding, conflicts);
1109
617
  };
1110
618
  const cancel = () => {
619
+ if (!state.recording) return;
1111
620
  end();
1112
621
  set({
1113
622
  ...state,
@@ -1116,52 +625,27 @@ function createRebinder(registry, id, options = {}) {
1116
625
  });
1117
626
  options.onCancel?.();
1118
627
  };
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
628
  const commit = () => {
1131
629
  const [first, ...rest] = state.steps;
1132
- if (first === void 0) return;
630
+ if (!state.recording || first === void 0) return;
1133
631
  record(rest.length === 0 ? first : { sequence: state.steps });
1134
632
  };
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
633
  const onKey = (event, chord) => {
1155
- if (options.ignoreInputs !== false && event.key !== "Escape" && isTyping(event)) return;
1156
634
  event.preventDefault();
1157
635
  event.stopPropagation();
1158
636
  if (event.repeat) return;
1159
637
  const bare = !event.ctrlKey && !event.altKey && !event.shiftKey && !event.metaKey;
1160
638
  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();
639
+ if (bare && (event.key === "Backspace" || event.key === "Delete")) return state.steps.length === 0 ? record(null) : set({
640
+ ...state,
641
+ steps: state.steps.slice(0, -1)
642
+ });
643
+ if (options.sequence && bare && event.key === "Enter") return commit();
1163
644
  if (chord === null) return;
1164
- if (sequence) step([...state.steps, chord]);
645
+ if (options.sequence) set({
646
+ ...state,
647
+ steps: [...state.steps, chord]
648
+ });
1165
649
  else record(chord);
1166
650
  };
1167
651
  return {
@@ -1175,13 +659,8 @@ function createRebinder(registry, id, options = {}) {
1175
659
  conflicts: []
1176
660
  });
1177
661
  },
1178
- stop,
1179
- cancel: () => {
1180
- if (state.recording) cancel();
1181
- },
1182
- commit: () => {
1183
- if (state.recording && sequence) commit();
1184
- },
662
+ cancel,
663
+ commit,
1185
664
  get state() {
1186
665
  return state;
1187
666
  },
@@ -1192,26 +671,15 @@ function createRebinder(registry, id, options = {}) {
1192
671
  };
1193
672
  },
1194
673
  destroy: () => {
1195
- stop();
1196
- listeners.clear();
674
+ end();
675
+ if (state.recording) set({
676
+ ...state,
677
+ recording: false,
678
+ steps: NO_STEPS
679
+ });
1197
680
  }
1198
681
  };
1199
682
  }
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
683
  /**
1216
684
  * Whether commands in two scopes can be live together, so their bindings can
1217
685
  * collide: a global or `"*"` command overlaps every scope, and two named
@@ -1285,6 +753,15 @@ var ScopeStack = class {
1285
753
  //#endregion
1286
754
  //#region src/audit.ts
1287
755
  /** Development-only warnings about default bindings that cannot work as written. */
756
+ const chords = (list) => new Set(list.split(" "));
757
+ const RESERVED = {
758
+ 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"),
759
+ 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")
760
+ };
761
+ /** Whether any step of a binding is a chord the browser or OS reserves on a platform. */
762
+ function isReserved(binding, platform) {
763
+ return (parseBinding(binding, platform) ?? []).some((chord) => platform === "mac" ? RESERVED.mac.has(writeChord(chord)) : chord.meta || RESERVED.other.has(writeChord(chord)));
764
+ }
1288
765
  const platformName = (platform) => platform === "mac" ? "macOS" : "Windows and Linux";
1289
766
  /** A binding as the app wrote it, which is what there is to search the code for. */
1290
767
  const written = (binding) => stepsOf(binding).join(" ");
@@ -1325,7 +802,7 @@ function warnAboutBinding(id, binding, platform, warn) {
1325
802
  if (held.length === 0) return;
1326
803
  if (platform === "mac") warn(`"${id}" binds "${written(binding)}". Control+Option is the VoiceOver modifier on macOS, so screen reader users cannot press it.`);
1327
804
  else if (held.some((step) => typesCharacter({
1328
- ...namedChord(step),
805
+ ...step,
1329
806
  ctrl: false,
1330
807
  alt: false
1331
808
  }))) 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 }.`);
@@ -1371,11 +848,186 @@ function warnAboutUnknownOverrides(source, warn) {
1371
848
  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
849
  }
1373
850
  //#endregion
851
+ //#region src/keys.ts
852
+ /** The one keyboard listener: matches a keypress to bindings and runs the command that wins it. */
853
+ /** How long a sequence waits for its next step, in milliseconds. */
854
+ const SEQUENCE_TIMEOUT = 1e3;
855
+ var KeyListener = class {
856
+ #host;
857
+ #attached = false;
858
+ #bound = null;
859
+ /** Whoever has taken the keyboard, the most recent last. See `capture`. */
860
+ #captures = [];
861
+ /** The keypress a capture used up: released in the same event, it must not run a chord. */
862
+ #spent = null;
863
+ constructor(host) {
864
+ this.#host = host;
865
+ }
866
+ /** Starts listening. A no-op on the server. */
867
+ attach() {
868
+ if (this.#attached || typeof document === "undefined") return;
869
+ this.#attached = true;
870
+ document.addEventListener("keydown", this.#onKeyDown);
871
+ }
872
+ /** Stops listening and abandons half-typed sequences. */
873
+ detach() {
874
+ if (this.#attached) document.removeEventListener("keydown", this.#onKeyDown);
875
+ this.#attached = false;
876
+ this.refresh();
877
+ }
878
+ /** Stops listening for good, dropping the captures too. */
879
+ destroy() {
880
+ while (this.#captures.length > 0) this.#release(this.#captures[0]);
881
+ this.detach();
882
+ }
883
+ /** Whether something has taken the keyboard, so no command's keys are live. */
884
+ get captured() {
885
+ return this.#captures.length > 0;
886
+ }
887
+ /** Hands every keypress to `onKey` until released, in the capture phase on the document. */
888
+ capture(onKey) {
889
+ if (typeof document === "undefined") return () => {};
890
+ this.#captures.push(onKey);
891
+ if (this.#captures.length === 1) document.addEventListener("keydown", this.#onCapture, true);
892
+ return () => this.#release(onKey);
893
+ }
894
+ #release(onKey) {
895
+ const index = this.#captures.lastIndexOf(onKey);
896
+ if (index === -1) return;
897
+ this.#captures.splice(index, 1);
898
+ if (this.#captures.length === 0) document.removeEventListener("keydown", this.#onCapture, true);
899
+ }
900
+ /** Bindings or the platform changed: re-read them. Half-typed sequences start over. */
901
+ refresh() {
902
+ this.#bound = null;
903
+ }
904
+ #bindings() {
905
+ if (this.#bound) return this.#bound;
906
+ const platform = this.#host.platform();
907
+ const bound = [];
908
+ this.#host.ids().forEach((id, order) => {
909
+ for (const binding of this.#host.bindings(id)) {
910
+ const steps = parseBinding(binding, platform);
911
+ if (steps) bound.push({
912
+ id,
913
+ order,
914
+ binding,
915
+ steps,
916
+ matched: 0,
917
+ at: 0
918
+ });
919
+ }
920
+ });
921
+ this.#bound = bound;
922
+ return bound;
923
+ }
924
+ #onCapture = (event) => {
925
+ const onKey = this.#captures.at(-1);
926
+ if (!onKey || isComposing(event)) return;
927
+ this.#spent = event;
928
+ onKey(event, readChord(event, this.#host.platform()));
929
+ };
930
+ #onKeyDown = (event) => {
931
+ if (event === this.#spent) {
932
+ this.#spent = null;
933
+ return;
934
+ }
935
+ if (event.defaultPrevented || isComposing(event) || this.captured) return;
936
+ if (isModifierKey(event.key)) return;
937
+ const platform = this.#host.platform();
938
+ const typing = isTyping(event);
939
+ const now = Date.now();
940
+ const chords = [];
941
+ const sequences = [];
942
+ let continued = false;
943
+ let prevent = false;
944
+ for (const bound of this.#bindings()) {
945
+ const definition = this.#host.definition(bound.id);
946
+ const first = bound.steps[0];
947
+ if (!definition || !first || !this.#host.reachable(bound.id)) continue;
948
+ if (typing && (definition.ignoreInputs ?? !isBareEscape(bound))) continue;
949
+ if (bound.steps.length === 1) {
950
+ if (matchesChord(event, first, platform)) chords.push(bound);
951
+ continue;
952
+ }
953
+ if (event.repeat) continue;
954
+ const matched = now - bound.at > SEQUENCE_TIMEOUT ? 0 : bound.matched;
955
+ const expected = bound.steps[matched];
956
+ let reached = 0;
957
+ if (expected && matchesChord(event, expected, platform)) reached = matched + 1;
958
+ else if (matched > 0 && matchesChord(event, first, platform)) reached = 1;
959
+ bound.matched = reached === bound.steps.length ? 0 : reached;
960
+ bound.at = now;
961
+ if (reached === 0) continue;
962
+ if (reached === bound.steps.length) sequences.push(bound);
963
+ else {
964
+ const step = bound.steps[reached - 1];
965
+ if (!typing || step?.ctrl || step?.meta) prevent = true;
966
+ if (reached > 1) continued = true;
967
+ }
968
+ }
969
+ const candidates = sequences.length > 0 ? sequences : continued ? [] : chords;
970
+ let winner;
971
+ for (const bound of candidates) if (!winner || this.#outranks(bound, winner)) winner = bound;
972
+ if (!winner) {
973
+ if (prevent) event.preventDefault();
974
+ return;
975
+ }
976
+ event.preventDefault();
977
+ if (!event.repeat) this.#host.run(winner.id, event, winner.binding);
978
+ };
979
+ #outranks(a, b) {
980
+ const depth = this.#host.depth(a.id) - this.#host.depth(b.id);
981
+ return depth === 0 ? a.order > b.order : depth > 0;
982
+ }
983
+ };
984
+ /**
985
+ * Whether a keydown belongs to an IME composition. Some engines report it
986
+ * only as `keyCode` 229 or the key `Process`.
987
+ */
988
+ function isComposing(event) {
989
+ return event.isComposing || event.keyCode === 229 || event.key === "Process";
990
+ }
991
+ /** A chord of the Escape key alone: the one key a text field does not keep. */
992
+ function isBareEscape(bound) {
993
+ const [step] = bound.steps;
994
+ return bound.steps.length === 1 && step?.key === "Escape" && !step.ctrl && !step.alt && !step.shift && !step.meta;
995
+ }
996
+ /** Whether a keypress lands in a field that takes text. */
997
+ function isTyping(event) {
998
+ return event.composedPath().some(isTextEntry);
999
+ }
1000
+ /** ARIA roles of widgets that take typed keys the way their native counterparts do. */
1001
+ const TEXT_ENTRY_ROLES = /* @__PURE__ */ new Set([
1002
+ "textbox",
1003
+ "searchbox",
1004
+ "combobox",
1005
+ "spinbutton",
1006
+ "slider"
1007
+ ]);
1008
+ /** Input types that take no typed keys, so a focused one keeps nothing from a command. */
1009
+ const KEYLESS_INPUTS = /* @__PURE__ */ new Set([
1010
+ "button",
1011
+ "submit",
1012
+ "reset",
1013
+ "image",
1014
+ "checkbox",
1015
+ "radio",
1016
+ "color",
1017
+ "file",
1018
+ "hidden"
1019
+ ]);
1020
+ function isTextEntry(node) {
1021
+ if (!(node instanceof HTMLElement)) return false;
1022
+ if (node instanceof HTMLInputElement) return !KEYLESS_INPUTS.has(node.type.toLowerCase());
1023
+ return node instanceof HTMLTextAreaElement || node instanceof HTMLSelectElement || !["false", null].includes(node.getAttribute("contenteditable")) || TEXT_ENTRY_ROLES.has(node.getAttribute("role") ?? "");
1024
+ }
1025
+ //#endregion
1374
1026
  //#region src/registry.ts
1375
1027
  /** Command registry: what an app can do, which keys do it, and where each is live. */
1376
1028
  const SSR_PLATFORM = "other";
1377
1029
  var CommandRegistry = class {
1378
- #definitions;
1030
+ #definitions = /* @__PURE__ */ new Map();
1379
1031
  /** Handlers attached per command, the most recent last. */
1380
1032
  #handlers = /* @__PURE__ */ new Map();
1381
1033
  #keymap;
@@ -1387,9 +1039,6 @@ var CommandRegistry = class {
1387
1039
  /** What the snapshot is behind on: who is reachable, or the bindings too. */
1388
1040
  #stale = "bindings";
1389
1041
  #serverSnapshot = null;
1390
- #pending = null;
1391
- /** Stops following other tabs' keymap edits; set while mounted. */
1392
- #unwatch = null;
1393
1042
  #mounts = 0;
1394
1043
  #hydrated = false;
1395
1044
  #singleKeys;
@@ -1397,7 +1046,6 @@ var CommandRegistry = class {
1397
1046
  this.#options = options;
1398
1047
  this.#singleKeys = options.singleKeyShortcuts !== false;
1399
1048
  this.#keymap = new Keymap(options.persist);
1400
- this.#definitions = /* @__PURE__ */ new Map();
1401
1049
  for (const id in definitions) this.#definitions.set(id, definitions[id]);
1402
1050
  this.#setDefaults();
1403
1051
  this.#keys = new KeyListener({
@@ -1412,9 +1060,9 @@ var CommandRegistry = class {
1412
1060
  event,
1413
1061
  binding
1414
1062
  })
1415
- }, options.target);
1063
+ });
1416
1064
  this.#keymap.subscribe(() => this.#bindingsChanged());
1417
- this.#scopes.subscribe(() => this.#reachabilityChanged());
1065
+ this.#scopes.subscribe(() => this.#invalidate("reachable"));
1418
1066
  if (process.env.NODE_ENV !== "production") auditRegistry({
1419
1067
  ids: [...this.#definitions.keys()],
1420
1068
  scope: (id) => this.#definitions.get(id)?.scope,
@@ -1435,15 +1083,11 @@ var CommandRegistry = class {
1435
1083
  serverList() {
1436
1084
  if (this.#serverSnapshot) return this.#serverSnapshot;
1437
1085
  const platform = this.#options.platform ?? SSR_PLATFORM;
1438
- const options = {
1439
- platform,
1440
- ...this.#options.display
1441
- };
1442
1086
  const singleKeys = this.#options.singleKeyShortcuts !== false;
1443
1087
  this.#serverSnapshot = Array.from(this.#definitions, ([id, definition]) => {
1444
1088
  const bindings = this.#defaultBindings(id, platform).filter((binding) => singleKeys || !isSingleKeyBinding(binding));
1445
1089
  const global = definition.scope === void 0 || definition.scope === "*";
1446
- return describeCommand(id, definition, bindings, platform, options, {
1090
+ return describeCommand(id, definition, bindings, platform, {
1447
1091
  reachable: definition.run !== void 0 && global,
1448
1092
  overridden: false
1449
1093
  });
@@ -1458,9 +1102,6 @@ var CommandRegistry = class {
1458
1102
  });
1459
1103
  return true;
1460
1104
  }
1461
- canRun(id) {
1462
- return this.whyNotRun(id) === null;
1463
- }
1464
1105
  whyNotRun(id) {
1465
1106
  const definition = this.#definitions.get(id);
1466
1107
  if (!definition) return "unknown";
@@ -1476,14 +1117,14 @@ var CommandRegistry = class {
1476
1117
  }
1477
1118
  const had = this.#handler(id) !== void 0;
1478
1119
  handlers.push(run);
1479
- if (!had) this.#reachabilityChanged();
1120
+ if (!had) this.#invalidate("reachable");
1480
1121
  let disposed = false;
1481
1122
  return () => {
1482
1123
  if (disposed) return;
1483
1124
  disposed = true;
1484
1125
  const index = handlers.lastIndexOf(run);
1485
1126
  if (index !== -1) handlers.splice(index, 1);
1486
- if (this.#handler(id) === void 0) this.#reachabilityChanged();
1127
+ if (this.#handler(id) === void 0) this.#invalidate("reachable");
1487
1128
  };
1488
1129
  }
1489
1130
  pushScope(name, options) {
@@ -1491,29 +1132,20 @@ var CommandRegistry = class {
1491
1132
  }
1492
1133
  capture(onKey) {
1493
1134
  const release = this.#keys.capture(onKey);
1494
- this.#reachabilityChanged();
1135
+ this.#invalidate("reachable");
1495
1136
  let released = false;
1496
1137
  return () => {
1497
1138
  if (released) return;
1498
1139
  released = true;
1499
1140
  release();
1500
- this.#reachabilityChanged();
1141
+ this.#invalidate("reachable");
1501
1142
  };
1502
1143
  }
1503
1144
  conflicts(binding, options = {}) {
1504
1145
  const platform = this.#platform();
1505
- const display = (entry) => formatBinding(entry, this.#displayOptions());
1506
1146
  const candidates = toBindings(binding);
1507
1147
  const signatures = new Set(candidates.map((entry) => bindingSignature(entry, platform)));
1508
1148
  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
1149
  for (const [id, { scope }] of this.#definitions) {
1518
1150
  if (id === options.ignore || !scopesOverlap(scope, options.scope)) continue;
1519
1151
  for (const existing of this.#bindings(id)) {
@@ -1523,33 +1155,13 @@ var CommandRegistry = class {
1523
1155
  kind,
1524
1156
  id,
1525
1157
  binding: existing,
1526
- display: display(existing),
1158
+ display: formatBinding(existing, platform),
1527
1159
  scope
1528
1160
  });
1529
1161
  }
1530
1162
  }
1531
1163
  return conflicts;
1532
1164
  }
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
1165
  get singleKeyShortcuts() {
1554
1166
  return this.#singleKeys;
1555
1167
  }
@@ -1565,9 +1177,8 @@ var CommandRegistry = class {
1565
1177
  this.#hydrated = true;
1566
1178
  const moved = this.#platform() !== rendered;
1567
1179
  if (moved) this.#setDefaults();
1568
- this.#unwatch = this.#keymap.watch();
1569
1180
  this.#keys.attach();
1570
- if (!this.#keymap.sync() && moved) this.#bindingsChanged();
1181
+ if (!this.#keymap.load() && moved) this.#bindingsChanged();
1571
1182
  }
1572
1183
  let released = false;
1573
1184
  return () => {
@@ -1575,7 +1186,7 @@ var CommandRegistry = class {
1575
1186
  released = true;
1576
1187
  if (this.#mounts === 0) return;
1577
1188
  this.#mounts--;
1578
- if (this.#mounts === 0) this.#unmount();
1189
+ if (this.#mounts === 0) this.#keys.detach();
1579
1190
  };
1580
1191
  }
1581
1192
  subscribe(listener) {
@@ -1586,8 +1197,6 @@ var CommandRegistry = class {
1586
1197
  }
1587
1198
  destroy() {
1588
1199
  this.#mounts = 0;
1589
- this.#unwatch?.();
1590
- this.#unwatch = null;
1591
1200
  this.#keys.destroy();
1592
1201
  this.#definitions.clear();
1593
1202
  this.#handlers.clear();
@@ -1596,11 +1205,6 @@ var CommandRegistry = class {
1596
1205
  this.#listeners.clear();
1597
1206
  this.#scopes.clear();
1598
1207
  }
1599
- #unmount() {
1600
- this.#keys.detach();
1601
- this.#unwatch?.();
1602
- this.#unwatch = null;
1603
- }
1604
1208
  /** The handler that runs: the one attached last, else the definition's own. */
1605
1209
  #handler(id) {
1606
1210
  return this.#handlers.get(id)?.at(-1) ?? this.#definitions.get(id)?.run;
@@ -1620,8 +1224,8 @@ var CommandRegistry = class {
1620
1224
  * wins over the definition's `keys`, and a `null` one means unbound.
1621
1225
  */
1622
1226
  #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));
1227
+ const override = this.#options.bindings?.[id];
1228
+ return toBindings(keysFor(override === void 0 ? this.#definitions.get(id)?.keys : override, platform));
1625
1229
  }
1626
1230
  /** Hands the keymap every command's defaults for the platform in effect. */
1627
1231
  #setDefaults() {
@@ -1636,42 +1240,22 @@ var CommandRegistry = class {
1636
1240
  ...context,
1637
1241
  commands: this
1638
1242
  };
1639
- if (this.#options.onRun) try {
1640
- this.#options.onRun(full);
1641
- } catch (error) {
1642
- this.#reportError(toError(error), full);
1643
- }
1243
+ this.#options.onRun?.(full);
1244
+ const report = (error) => console.error(`[@inklu/keys] "${id}" failed`, error);
1644
1245
  try {
1645
1246
  const result = run(full);
1646
- if (result instanceof Promise) result.catch((cause) => this.#reportError(toError(cause), full));
1247
+ if (result instanceof Promise) result.catch(report);
1647
1248
  } catch (error) {
1648
- this.#reportError(toError(error), full);
1249
+ report(error);
1649
1250
  }
1650
1251
  }
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
1252
  #bindingsChanged() {
1659
1253
  this.#keys.refresh();
1660
1254
  this.#invalidate("bindings");
1661
1255
  }
1662
- #reachabilityChanged() {
1663
- this.#keys.prune();
1664
- this.#invalidate("reachable");
1665
- }
1666
1256
  #platform() {
1667
1257
  return this.#options.platform ?? (this.#hydrated ? detectPlatform() : SSR_PLATFORM);
1668
1258
  }
1669
- #displayOptions() {
1670
- return {
1671
- platform: this.#platform(),
1672
- ...this.#options.display
1673
- };
1674
- }
1675
1259
  /**
1676
1260
  * The commands as they are now. A command that did not change keeps its
1677
1261
  * object, and the list keeps its own while none did, so a subscriber can
@@ -1682,14 +1266,13 @@ var CommandRegistry = class {
1682
1266
  if (previous && !this.#stale) return previous;
1683
1267
  const kept = this.#stale === "reachable" ? previous?.index : void 0;
1684
1268
  const platform = this.#platform();
1685
- const options = this.#displayOptions();
1686
1269
  const list = [];
1687
1270
  const index = /* @__PURE__ */ new Map();
1688
1271
  let changed = !kept;
1689
1272
  for (const [id, definition] of this.#definitions) {
1690
1273
  const reachable = this.#reachable(id);
1691
1274
  let command = kept?.get(id);
1692
- if (!command) command = describeCommand(id, definition, this.#bindings(id), platform, options, {
1275
+ if (!command) command = describeCommand(id, definition, this.#bindings(id), platform, {
1693
1276
  reachable,
1694
1277
  overridden: this.#keymap.isOverridden(id)
1695
1278
  });
@@ -1715,28 +1298,19 @@ var CommandRegistry = class {
1715
1298
  for (const listener of this.#listeners) listener();
1716
1299
  }
1717
1300
  };
1718
- function describeCommand(id, definition, source, platform, options, state) {
1301
+ function describeCommand(id, definition, source, platform, state) {
1719
1302
  const bindings = [...source];
1720
1303
  return {
1721
1304
  id,
1722
1305
  title: definition.title,
1723
- description: definition.description,
1724
- group: definition.group,
1725
- keywords: definition.keywords ?? [],
1726
- hidden: definition.hidden === true,
1727
1306
  scope: definition.scope,
1728
1307
  bindings,
1729
- display: bindings.map((binding) => formatBinding(binding, options)),
1730
- tokens: bindings.map((binding) => bindingTokens(binding, options)),
1308
+ display: bindings.map((binding) => formatBinding(binding, platform)),
1309
+ tokens: bindings.map((binding) => bindingTokens(binding, platform)),
1731
1310
  aria: bindings.map((binding) => ariaKeyShortcuts(binding, platform)).filter((value) => value.length > 0).join(" "),
1732
- spoken: bindings.map((binding) => spokenBinding(binding, platform)).join(" or "),
1733
1311
  ...state
1734
1312
  };
1735
1313
  }
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
1314
  /**
1741
1315
  * Creates a command registry.
1742
1316
  *
@@ -1756,42 +1330,4 @@ function createCommands(definitions, options) {
1756
1330
  return new CommandRegistry(definitions, options);
1757
1331
  }
1758
1332
  //#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 };
1333
+ export { webStorage as a, formatBinding as c, importKeymap as i, isSingleKeyBinding as l, createRebinder as n, ariaKeyShortcuts as o, exportKeymap as r, bindingTokens as s, createCommands as t, seq as u };