@inklu/keys 2.1.0 → 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.
@@ -1,15 +1,10 @@
1
1
  //#region src/chord.ts
2
- /** The platform this code runs on, as `navigator` reports it. `"linux"` where there is none. */
2
+ /** The platform, as `navigator` reports it. `"other"` where there is none. */
3
3
  function detectPlatform() {
4
- if (typeof navigator === "undefined") return "linux";
5
- const platform = `${navigator.platform} ${navigator.userAgent}`.toLowerCase();
6
- if (platform.includes("mac")) return "mac";
7
- return platform.includes("win") ? "windows" : "linux";
4
+ if (typeof navigator === "undefined") return "other";
5
+ return /mac/i.test(`${navigator.platform} ${navigator.userAgent}`) ? "mac" : "other";
8
6
  }
9
- /**
10
- * Named keys with a place in the `Key` type, as `KeyboardEvent.key` spells
11
- * them. Any other name still works as a key; `validateChord` only remarks on it.
12
- */
7
+ /** Named keys in the `Key` type, as `KeyboardEvent.key` spells them. Other names still work. */
13
8
  const NAMED_KEYS = [
14
9
  "ArrowUp",
15
10
  "ArrowDown",
@@ -65,7 +60,7 @@ function split(chord) {
65
60
  return parts.map((part) => part.trim());
66
61
  }
67
62
  /** One spelling per key: letters upper-cased, the space bar by name. */
68
- function keyName(key) {
63
+ function keyName$1(key) {
69
64
  if (key === " ") return "Space";
70
65
  if (!LETTER.test(key)) return key;
71
66
  const upper = key.toUpperCase();
@@ -73,7 +68,7 @@ function keyName(key) {
73
68
  }
74
69
  /** The key a keypress produced. Empty for the keyless events browser autofill dispatches. */
75
70
  function pressedKey(event) {
76
- return keyName(String(event.key ?? "").normalize("NFC"));
71
+ return keyName$1(String(event.key ?? "").normalize("NFC"));
77
72
  }
78
73
  function isOneCharacter(key) {
79
74
  return Array.from(key).length === 1;
@@ -100,7 +95,7 @@ function read(chord, platform) {
100
95
  alt: held.has("Alt"),
101
96
  shift: held.has("Shift"),
102
97
  meta: held.has("Meta"),
103
- ...code === void 0 ? { key: keyName(key) } : { code }
98
+ ...code === void 0 ? { key: keyName$1(key) } : { code }
104
99
  };
105
100
  }
106
101
  /** Parses a chord for a platform, or returns `null` for one that cannot be read. */
@@ -112,10 +107,7 @@ function parseChord(chord, platform) {
112
107
  function isChord(text) {
113
108
  return parseChord(text, "mac") !== null;
114
109
  }
115
- /**
116
- * What is wrong with a chord (`errors`: it will never fire) and what merely
117
- * looks off (`warnings`: a key name this package does not know).
118
- */
110
+ /** A chord's `errors` (it will never fire) and `warnings` (a key name this package does not know). */
119
111
  function validateChord(chord) {
120
112
  const result = read(chord, "mac");
121
113
  if (typeof result === "string") return {
@@ -128,11 +120,7 @@ function validateChord(chord) {
128
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.`]
129
121
  };
130
122
  }
131
- /**
132
- * Writes a parsed chord back as text, modifiers in canonical order. Given a
133
- * platform it prefers `Mod`, which is how a chord stays portable when it is
134
- * stored and read back on another machine.
135
- */
123
+ /** Writes a parsed chord as text. Given a platform, its primary modifier is written `Mod`. */
136
124
  function writeChord(chord, mod) {
137
125
  const portable = mod !== void 0 && (mod === "mac" ? chord.meta && !chord.ctrl : chord.ctrl && !chord.meta);
138
126
  const parts = portable ? ["Mod"] : [];
@@ -143,11 +131,7 @@ function writeChord(chord, mod) {
143
131
  parts.push(chord.code === void 0 ? chord.key ?? "" : `[${chord.code}]`);
144
132
  return parts.join("+");
145
133
  }
146
- /**
147
- * Whether a chord types a character when pressed: one character, or a letter,
148
- * digit or punctuation key by position, with no Control, Alt or Meta. Shift
149
- * does not count as a modifier here: `Shift+A` still types a character.
150
- */
134
+ /** Whether a chord types a character: a one-character key with no Control, Alt or Meta. */
151
135
  function typesCharacter(chord) {
152
136
  if (chord.ctrl || chord.alt || chord.meta) return false;
153
137
  return isOneCharacter(chord.key ?? keyAtCode(chord.code ?? "") ?? "");
@@ -165,10 +149,7 @@ const MODIFIER_KEYS = /* @__PURE__ */ new Set([
165
149
  function isModifierKey(key) {
166
150
  return MODIFIER_KEYS.has(key);
167
151
  }
168
- /**
169
- * AltGr types a third character on many layouts, and Windows reports it as
170
- * Control+Alt. While it is down those two flags say nothing about intent.
171
- */
152
+ /** Whether AltGr is down. Windows reports it as Control+Alt, though a character is being typed. */
172
153
  function hasAltGraph(event, platform) {
173
154
  return platform !== "mac" && event.getModifierState?.("AltGraph") === true;
174
155
  }
@@ -192,6 +173,15 @@ function keyAtCode(code) {
192
173
  if (/^Digit[0-9]$/.test(code)) return code.slice(5);
193
174
  return code === "Space" ? code : PUNCTUATION_CODES[code];
194
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
+ }
195
185
  /** `KeyboardEvent.key` values that name neither a character nor a key. */
196
186
  const NO_KEY = /* @__PURE__ */ new Set([
197
187
  "",
@@ -205,20 +195,14 @@ const isSymbol = (key) => isOneCharacter(key) && !LETTER.test(key);
205
195
  /** A key known by name rather than by a character, such as `Enter`. */
206
196
  const isNamed = (key) => !isOneCharacter(key) && !NO_KEY.has(key);
207
197
  /**
208
- * The key at a keypress's position on the conventional layout, when position
209
- * may stand in for what the layout produced. That is when the layout produced
210
- * nothing a chord would name:
198
+ * The key at a keypress's position on the US layout, where position may stand
199
+ * in for what the layout produced:
211
200
  *
212
- * - On the digit row, always: `Mod+1` is the first key of that row on AZERTY,
213
- * which types `&` there, and `Shift+2` is `@` on a US keyboard.
214
- * - On a letter key, when it typed a character from another script (`ы` on a
215
- * Russian keyboard still reaches `Mod+S`), or macOS Option turned the letter
216
- * into something else.
217
- * - On a punctuation key, the same, and also while Shift or Alt is held, so
218
- * `Shift+/` can be written by its base key.
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.
219
204
  *
220
- * Never when the layout typed a Latin letter or the key has a name (that is
221
- * the key pressed), nor under AltGr (the user is typing a character).
205
+ * Never for a Latin letter, a named key, or under AltGr.
222
206
  */
223
207
  function positionKey(event, key, altGraph) {
224
208
  if (altGraph || /^[A-Z]$/.test(key) || isNamed(key)) return void 0;
@@ -229,17 +213,12 @@ function positionKey(event, key, altGraph) {
229
213
  return isAscii(key) && !event.shiftKey && !event.altKey ? void 0 : PUNCTUATION_CODES[code];
230
214
  }
231
215
  /**
232
- * Whether a keypress is this chord.
233
- *
234
- * A physical chord (`[KeyK]`) compares the key's position and nothing else.
235
- * A logical one names what the key produced, and matches in one of two ways:
236
- *
237
- * 1. The key produced is the chord's key. For a symbol, the modifiers that
238
- * were only there to type it need not be named: `?` is Shift+/ on a US
239
- * keyboard and unshifted elsewhere, and `[` is Option+5 on a German Mac.
240
- * 2. The key's position stands in for it, where `positionKey` allows.
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.
241
220
  *
242
- * The approach follows the matcher in TanStack Hotkeys (MIT).
221
+ * Follows the matcher in TanStack Hotkeys (MIT).
243
222
  */
244
223
  function matchesChord(event, chord, platform) {
245
224
  const altGraph = hasAltGraph(event, platform);
@@ -252,14 +231,10 @@ function matchesChord(event, chord, platform) {
252
231
  return positionKey(event, key, altGraph) === chord.key && modifiersMatch(false);
253
232
  }
254
233
  /**
255
- * The chord a keypress expresses, for recording a new shortcut, or `null` for
256
- * a keypress that is not one: a modifier on its own, or a key with no name.
257
- *
258
- * It is the inverse of `matchesChord`: what it records, the same keypress
259
- * matches. It writes the character the layout typed where a chord can name it
260
- * (`?`, not `Shift+/`), the key's position otherwise (`Mod+S` for Ctrl+ы), and
261
- * `Mod` for the primary modifier, so the chord works on the user's other
262
- * machines too.
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`.
263
238
  */
264
239
  function readChord(event, platform) {
265
240
  if (isModifierKey(event.key)) return null;
@@ -301,12 +276,7 @@ function isSequence(binding) {
301
276
  function stepsOf(binding) {
302
277
  return isSequence(binding) ? binding.sequence : [binding];
303
278
  }
304
- /**
305
- * Normalizes a binding input into an array of bindings. A one-step sequence
306
- * becomes a plain chord: it fires on exactly the same keypress, and keeping it
307
- * a sequence gave it a different signature from the chord, so the two were
308
- * neither arbitrated nor reported as conflicting — both ran.
309
- */
279
+ /** Normalizes a binding input into an array of bindings. A one-step sequence becomes a chord. */
310
280
  function toBindings(input) {
311
281
  if (input == null) return [];
312
282
  const candidates = isList(input) ? input : [input];
@@ -323,16 +293,20 @@ function toBindings(input) {
323
293
  function isList(input) {
324
294
  return Array.isArray(input);
325
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
+ }
326
304
  /** Generates a normalized signature for binding comparison and conflict detection. */
327
305
  function bindingSignature(binding, platform) {
328
306
  const steps = canonicalSteps(binding, platform);
329
307
  return isSequence(binding) ? `seq:${steps.join(" ")}` : `chord:${steps.join(" ")}`;
330
308
  }
331
- /**
332
- * Whether one binding is a strict prefix of the other — chord `G` against
333
- * `G then I`, or `G then G` against `G then G then I`. The shorter binding
334
- * fires on the way to the longer one, so both run for a single intent.
335
- */
309
+ /** Whether one binding is a strict prefix of the other, like chord `G` against `G then I`. */
336
310
  function isPrefixCollision(a, b, platform) {
337
311
  const left = canonicalSteps(a, platform);
338
312
  const right = canonicalSteps(b, platform);
@@ -358,53 +332,23 @@ function canonicalSteps(binding, platform = detectPlatform()) {
358
332
  });
359
333
  }
360
334
  /**
361
- * Whether every step of a binding is a single printable character with no
362
- * Control, Alt or Meta — the "character key shortcuts" WCAG 2.1.4 lets users
363
- * turn off, since speech input and stray keypresses trigger them by accident.
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.
364
337
  */
365
338
  function isSingleKeyBinding(binding) {
366
339
  return parseBinding(binding, "mac")?.every(typesCharacter) === true;
367
340
  }
368
- /** Converts a binding to a JSON-serializable format. */
369
- function serializeBinding(binding) {
370
- return isSequence(binding) ? [...binding.sequence] : binding;
371
- }
372
- /** Deserializes a stored value into a valid binding, or `null` if invalid. */
373
- function deserializeBinding(value) {
374
- if (typeof value === "string") {
375
- const chord = value.trim();
376
- return isChord(chord) ? chord : null;
377
- }
378
- if (!Array.isArray(value)) return null;
379
- const steps = [];
380
- for (const step of value) {
381
- if (step === "") continue;
382
- if (typeof step !== "string" || !isChord(step)) return null;
383
- steps.push(step);
384
- }
385
- const [first, ...rest] = steps;
386
- if (first === void 0) return null;
387
- return rest.length === 0 ? first : { sequence: steps };
388
- }
389
- /** Validates a binding's syntax without logging warnings or errors. */
341
+ /** A binding's syntax `errors` (it will never fire) and `warnings`, for the development audit. */
390
342
  function validateBinding(binding) {
391
- const steps = stepsOf(binding);
392
- if (steps.length === 0) return {
393
- valid: false,
394
- errors: ["Sequence has no steps"],
395
- warnings: []
396
- };
397
- const results = steps.map(validateChord);
398
- const errors = results.flatMap((result) => result.errors);
343
+ const results = stepsOf(binding).map(validateChord);
399
344
  return {
400
- valid: errors.length === 0,
401
- errors,
402
- warnings: results.flatMap((r) => r.warnings)
345
+ errors: results.flatMap((result) => result.errors),
346
+ warnings: results.flatMap((result) => result.warnings)
403
347
  };
404
348
  }
405
349
  //#endregion
406
350
  //#region src/display.ts
407
- /** Platform-aware hotkey display formatting and accessibility attributes. */
351
+ /** Writing a binding for people: on screen, in `aria-keyshortcuts`, and read aloud. */
408
352
  const MODIFIER_LABELS = {
409
353
  symbols: [
410
354
  "⌃",
@@ -418,32 +362,94 @@ const MODIFIER_LABELS = {
418
362
  "Shift",
419
363
  "Cmd"
420
364
  ],
421
- windows: [
365
+ other: [
422
366
  "Ctrl",
423
367
  "Alt",
424
368
  "Shift",
425
- "Win"
426
- ],
427
- linux: [
428
- "Ctrl",
429
- "Alt",
430
- "Shift",
431
- "Super"
369
+ "Meta"
432
370
  ]
433
371
  };
434
- const KEY_SYMBOLS = {
435
- ArrowUp: "↑",
436
- ArrowDown: "↓",
437
- ArrowLeft: "←",
438
- ArrowRight: "→",
439
- Enter: "↵",
440
- Escape: "Esc",
441
- Backspace: "⌫",
442
- Delete: "⌦",
443
- Tab: "⇥",
444
- Space: "␣"
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
+ ]
445
451
  };
446
- /** Names for the keys whose character is hard to tell apart in running text. */
452
+ /** Names for the keys whose character is hard to tell apart in running text, or to hear. */
447
453
  const PUNCTUATION_NAMES = {
448
454
  "`": "Backquote",
449
455
  "\\": "Backslash",
@@ -453,21 +459,28 @@ const PUNCTUATION_NAMES = {
453
459
  "=": "Equal",
454
460
  "-": "Minus",
455
461
  ".": "Period",
456
- ";": "Semicolon"
462
+ ";": "Semicolon",
463
+ "/": "Slash",
464
+ "?": "Question Mark"
457
465
  };
458
- /** Formats a binding for display on the target platform (e.g. `⌘ K` or `Ctrl+K`). */
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
+ */
459
471
  function formatBinding(binding, options) {
460
- if (isSequence(binding)) return binding.sequence.map((step) => formatChord(step, options)).join(" ");
461
- return formatChord(binding, options);
472
+ return stepsOf(binding).map((step) => formatChord(step, options)).join(" ");
462
473
  }
463
- /** Splits a binding into display tokens for rendering `<kbd>` elements. */
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
+ */
464
478
  function bindingTokens(binding, options) {
465
479
  if (isSequence(binding)) return binding.sequence.map((step) => formatChord(step, options));
466
480
  return chordTokens(binding, options);
467
481
  }
468
482
  function formatChord(chord, options) {
469
- const spaced = usesModifierSymbols(options);
470
- return chordTokens(chord, options).join(spaced ? " " : "+");
483
+ return chordTokens(chord, options).join(usesModifierSymbols(options) ? "" : "+");
471
484
  }
472
485
  function usesModifierSymbols(options) {
473
486
  return (options?.platform ?? detectPlatform()) === "mac" && options?.useSymbols !== false;
@@ -477,46 +490,308 @@ function chordTokens(chord, options) {
477
490
  const parsed = parseChord(chord, platform);
478
491
  if (!parsed) return [chord];
479
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) {
480
496
  const held = [
481
- parsed.ctrl,
482
- parsed.alt,
483
- parsed.shift,
484
- parsed.meta
497
+ chord.ctrl,
498
+ chord.alt,
499
+ chord.shift,
500
+ chord.meta
485
501
  ];
486
- return [...labels.filter((_, index) => held[index]), keyLabel(parsed, options)];
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);
487
509
  }
488
- function keyLabel(chord, options) {
489
- const names = options?.useSymbols === false ? PUNCTUATION_NAMES : KEY_SYMBOLS;
490
- const label = (key) => names[key] ?? key;
491
- if (chord.code === void 0) return label(chord.key ?? "");
492
- if (chord.code.startsWith("Numpad")) return `Numpad ${label(chord.code.slice(6)).replace(/([a-z])([A-Z])/g, "$1 $2")}`;
493
- return label(keyAtCode(chord.code) ?? chord.code);
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);
494
515
  }
495
516
  /**
496
- * Phrases a binding for a screen reader: `"Control+K"` for a chord,
497
- * `"G then I"` for a sequence. `aria-keyshortcuts` cannot express a sequence
498
- * (a space there means "or"), so this is how a sequence gets announced — put it
499
- * in visually hidden text or `aria-description`.
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.
500
519
  */
501
- function spokenBinding(binding, platform) {
502
- return stepsOf(binding).map((step) => keyNames(step, platform) || step).join(" then ");
503
- }
504
- /** Generates an `aria-keyshortcuts` attribute string for a binding. */
505
- function ariaKeyShortcuts(binding, platform) {
506
- return isSequence(binding) ? "" : keyNames(binding, platform);
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 ");
507
526
  }
508
- /** A chord in key names, `Mod` resolved: `"Meta+K"`. Empty for one that cannot be read. */
509
- function keyNames(chord, platform = detectPlatform()) {
510
- const parsed = parseChord(chord, platform);
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);
511
533
  if (!parsed) return "";
512
- const key = parsed.key ?? keyAtCode(parsed.code ?? "") ?? parsed.code;
513
- return writeChord({
514
- ...parsed,
515
- key,
516
- code: void 0
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);
517
666
  });
518
667
  }
519
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
520
795
  //#region src/keys.ts
521
796
  /** The one keyboard listener: matches a keypress to bindings and runs the command that wins it. */
522
797
  const DEFAULT_SEQUENCE_TIMEOUT = 1e3;
@@ -560,11 +835,7 @@ var KeyListener = class {
560
835
  get captured() {
561
836
  return this.#captures.length > 0;
562
837
  }
563
- /**
564
- * Hands every keypress to `onKey` until released. See `Commands.capture`.
565
- * A capture listens first, in the capture phase and on the whole document,
566
- * and only for as long as there is one.
567
- */
838
+ /** Hands every keypress to `onKey` until released, in the capture phase on the document. */
568
839
  capture(onKey) {
569
840
  if (typeof document === "undefined") return () => {};
570
841
  this.#captures.push(onKey);
@@ -583,12 +854,7 @@ var KeyListener = class {
583
854
  this.#bound = null;
584
855
  this.#publish();
585
856
  }
586
- /**
587
- * Reachability changed. A sequence that became unreachable halfway through
588
- * loses its progress: otherwise a scope that closed and reopened within
589
- * the timeout would let the next key complete a sequence begun in a
590
- * different context.
591
- */
857
+ /** Reachability changed: a half-typed sequence that is no longer reachable starts over. */
592
858
  prune() {
593
859
  let changed = false;
594
860
  for (const bound of this.#bound ?? []) {
@@ -659,7 +925,7 @@ var KeyListener = class {
659
925
  const definition = this.#host.definition(bound.id);
660
926
  const first = bound.steps[0];
661
927
  if (!definition || !first || !this.#host.reachable(bound.id)) continue;
662
- if (typing && (definition.ignoreInputs ?? ignoresInputs(first))) continue;
928
+ if (typing && (definition.ignoreInputs ?? ignoresInputs(first, platform))) continue;
663
929
  if (bound.steps.length === 1) {
664
930
  if (matchesChord(event, first, platform)) chords.push(bound);
665
931
  continue;
@@ -688,9 +954,14 @@ var KeyListener = class {
688
954
  const candidates = sequences.length > 0 ? sequences : continued ? [] : chords;
689
955
  let winner;
690
956
  for (const bound of candidates) if (!winner || this.#outranks(bound, winner)) winner = bound;
691
- if (winner && this.#host.definition(winner.id)?.preventDefault !== false) prevent = true;
692
- if (prevent) event.preventDefault();
693
- if (winner) this.#host.run(winner.id, event, winner.binding);
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);
694
965
  };
695
966
  #outranks(a, b) {
696
967
  const depth = this.#host.depth(a.id) - this.#host.depth(b.id);
@@ -726,53 +997,71 @@ var KeyListener = class {
726
997
  };
727
998
  };
728
999
  /**
729
- * A keydown mid-composition belongs to the IME — confirming a kana or pinyin
730
- * candidate must not also fire whatever the raw key is bound to. Older
731
- * engines report composition only as `keyCode` 229 or the key `Process`.
1000
+ * Whether a keydown belongs to an IME composition. Some engines report it
1001
+ * only as `keyCode` 229 or the key `Process`.
732
1002
  */
733
1003
  function isComposing(event) {
734
1004
  return event.isComposing || event.keyCode === 229 || event.key === "Process";
735
1005
  }
736
1006
  /**
737
- * Whether a binding stays quiet while the user is typing, when its command
738
- * does not say: chords holding Control or Meta and bare `Escape` still fire
739
- * inside a text field, everything else would hijack a typed character.
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.
740
1010
  */
741
- function ignoresInputs(step) {
742
- return !(step.ctrl || step.meta || step.key === "Escape" || step.code === "Escape");
1011
+ function ignoresInputs(step, platform) {
1012
+ if (step.key === "Escape" || step.code === "Escape") return false;
1013
+ return !(step.ctrl || step.meta) || editsText(step, platform);
743
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+)$/;
744
1017
  /**
745
- * Whether a keypress lands in a field that takes text. `host` is the element
746
- * the listener itself sits on, which never counts: keys bound on an editor
747
- * are meant for that editor.
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.
748
1032
  */
749
1033
  function isTyping(event, host) {
750
1034
  return event.composedPath().some((node) => node !== host && isTextEntry(node));
751
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
+ ]);
752
1044
  function isTextEntry(node) {
753
- if (typeof HTMLElement === "undefined" || !(node instanceof HTMLElement)) return false;
1045
+ if (!(node instanceof HTMLElement)) return false;
754
1046
  if (node instanceof HTMLInputElement) {
755
1047
  const type = node.type.toLowerCase();
756
1048
  return type !== "button" && type !== "submit" && type !== "reset";
757
1049
  }
758
- return node instanceof HTMLTextAreaElement || node instanceof HTMLSelectElement || !["false", null].includes(node.getAttribute("contenteditable"));
1050
+ return node instanceof HTMLTextAreaElement || node instanceof HTMLSelectElement || !["false", null].includes(node.getAttribute("contenteditable")) || TEXT_ENTRY_ROLES.has(node.getAttribute("role") ?? "");
759
1051
  }
760
1052
  //#endregion
761
1053
  //#region src/rebind.ts
762
1054
  /** Controller for recording and applying hotkey rebindings. */
763
1055
  const NO_STEPS = [];
764
1056
  /**
765
- * Creates a rebinder instance for capturing and remapping a command's keys.
766
- * While it records it holds the whole keyboard, so no command runs on the
767
- * keys being pressed. Escape cancels, Backspace removes the shortcut (or the
768
- * last step of a sequence), and Enter finishes a sequence.
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.
769
1060
  *
770
1061
  * @example
771
1062
  * ```ts
772
1063
  * const rebinder = createRebinder(commands, "palette.open", {
773
- * onRecord: (binding, conflicts) => {
774
- * if (conflicts.length) console.warn("Conflict with", conflicts[0].id);
775
- * },
1064
+ * onRecord: (binding, conflicts) => showConflicts(conflicts),
776
1065
  * });
777
1066
  * rebinder.start();
778
1067
  * ```
@@ -805,7 +1094,7 @@ function createRebinder(registry, id, options = {}) {
805
1094
  ignore: id,
806
1095
  scope: registry.get(id)?.scope
807
1096
  });
808
- const blocked = options.rejectConflicts === true && conflicts.length > 0;
1097
+ const blocked = conflicts.some((conflict) => conflict.kind === "reserved") || options.rejectConflicts === true && conflicts.length > 0;
809
1098
  if (options.apply !== false && !blocked) {
810
1099
  if (options.add) registry.keymap.add(id, binding);
811
1100
  else registry.keymap.set(id, binding);
@@ -908,11 +1197,25 @@ function createRebinder(registry, id, options = {}) {
908
1197
  }
909
1198
  };
910
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
+ }
911
1215
  /**
912
- * Whether commands in two scopes are live together whenever either is, so
913
- * their bindings can collide. A global or `"*"` command is live alongside
914
- * every scope; two different named scopes are separate surfaces, told apart
915
- * by which was opened last.
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.
916
1219
  */
917
1220
  function scopesOverlap(a, b) {
918
1221
  if (a === void 0 || b === void 0) return true;
@@ -948,17 +1251,11 @@ var ScopeStack = class {
948
1251
  if (barrier === -1) return this.isOpen(scope);
949
1252
  return scope !== void 0 && this.depth(scope) >= barrier;
950
1253
  }
951
- /**
952
- * Whether `scope` is open at all, barriers aside. This is what a direct
953
- * `run()` checks: a barrier claims the keyboard, not the command.
954
- */
1254
+ /** Whether `scope` is open at all, barriers aside. This is what a direct `run()` checks. */
955
1255
  isOpen(scope) {
956
1256
  return scope === void 0 || scope === "*" || this.depth(scope) !== -1;
957
1257
  }
958
- /**
959
- * How far up the stack `scope` was last opened, or `-1` for a global or
960
- * closed one. The surface opened last sits highest, and wins a shared key.
961
- */
1258
+ /** How far up the stack `scope` was last opened, or `-1` for a global or closed one. */
962
1259
  depth(scope) {
963
1260
  if (scope === void 0 || scope === "*") return -1;
964
1261
  for (let index = this.#entries.length - 1; index >= 0; index--) if (this.#entries[index]?.name === scope) return index;
@@ -987,26 +1284,58 @@ var ScopeStack = class {
987
1284
  };
988
1285
  //#endregion
989
1286
  //#region src/audit.ts
990
- /** Development-only warnings about bindings that cannot work as written. */
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(" ");
991
1291
  /**
992
- * Warns about every command: bad syntax, unknown overrides, collisions. The
993
- * registry calls this behind a `NODE_ENV` check, so a production build drops
994
- * this whole module.
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.
995
1295
  */
996
1296
  function auditRegistry(source) {
997
- for (const id of source.ids) warnAboutBindings(source, id);
998
- warnAboutUnknownOverrides(source);
999
- warnAboutConflicts(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 }.`);
1000
1332
  }
1001
- function warnAboutConflicts(source) {
1002
- const { platform, display } = source;
1333
+ function warnAboutConflicts(source, platform, warn) {
1003
1334
  const seen = /* @__PURE__ */ new Map();
1004
- const collisions = [];
1005
- const prefixes = [];
1006
1335
  const visited = [];
1007
1336
  for (const id of source.ids) {
1008
1337
  const scope = source.scope(id);
1009
- for (const binding of source.bindings(id)) {
1338
+ for (const binding of source.bindings(id, platform)) {
1010
1339
  for (const other of visited) {
1011
1340
  if (other.id === id || !scopesOverlap(other.scope, scope)) continue;
1012
1341
  if (!isPrefixCollision(other.binding, binding, platform)) continue;
@@ -1017,7 +1346,7 @@ function warnAboutConflicts(source) {
1017
1346
  id,
1018
1347
  binding
1019
1348
  }, other];
1020
- prefixes.push(`${formatBinding(short.binding, display)} ("${short.id}") is a prefix of ${formatBinding(long.binding, display)} ("${long.id}")`);
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.`);
1021
1350
  }
1022
1351
  visited.push({
1023
1352
  id,
@@ -1030,263 +1359,21 @@ function warnAboutConflicts(source) {
1030
1359
  seen.set(key, id);
1031
1360
  continue;
1032
1361
  }
1033
- collisions.push(`${formatBinding(binding, display)} — "${id}" shadows "${owner}"`);
1362
+ warn(`shadowed binding: "${id}" and "${owner}" share ${written(binding)} in the same scope, so "${id}" takes the key whenever both have a handler.`);
1034
1363
  }
1035
1364
  }
1036
- if (collisions.length > 0) console.warn(`[@inklu/keys] ${collisions.length} shadowed binding(s) — same chord, same scope, so the later command takes the key whenever both have a handler:\n ${collisions.join("\n ")}`);
1037
- if (prefixes.length > 0) console.warn(`[@inklu/keys] ${prefixes.length} prefix collision(s) — the shorter binding fires on the way to the longer one:\n ${prefixes.join("\n ")}`);
1038
- }
1039
- function warnAboutBindings(source, id) {
1040
- for (const binding of source.declared(id)) {
1041
- const { valid, errors, warnings } = validateBinding(binding);
1042
- if (!valid) console.warn(`[@inklu/keys] "${id}" has an unrecognized binding ${describeBinding(binding)}, so it never fires. ${errors.join("; ")}.`);
1043
- else if (warnings.length > 0) console.warn(`[@inklu/keys] "${id}" binds ${describeBinding(binding)}. ${warnings.join(" ")}`);
1044
- }
1045
1365
  }
1046
1366
  /** Catches ids in `options.bindings` that TypeScript could not, as in plain JS. */
1047
- function warnAboutUnknownOverrides(source) {
1367
+ function warnAboutUnknownOverrides(source, warn) {
1048
1368
  if (!source.overrides) return;
1049
1369
  const known = new Set(source.ids);
1050
1370
  const unknown = Object.keys(source.overrides).filter((id) => !known.has(id));
1051
- if (unknown.length > 0) console.warn(`[@inklu/keys] bindings override ${unknown.map((id) => `"${id}"`).join(", ")}, which ${unknown.length === 1 ? "is not a registered command" : "are not registered commands"}.`);
1052
- }
1053
- function describeBinding(binding) {
1054
- return isSequence(binding) ? `"${binding.sequence.join(" ")}"` : `"${binding}"`;
1055
- }
1056
- //#endregion
1057
- //#region src/keymap.ts
1058
- /** Keymap managing default bindings and user overrides. */
1059
- const DEFAULT_STORAGE_KEY = "inklu.keys";
1060
- const KEYMAP_VERSION = 1;
1061
- /** Type guard narrowing a JSON value to a plain object (excludes arrays and `null`). */
1062
- function isJsonObject(value) {
1063
- return typeof value === "object" && value !== null && !Array.isArray(value);
1064
- }
1065
- /**
1066
- * Same keys on every platform. The keymap is user data that may travel
1067
- * between machines, so `Mod+S` and `Control+S` stay distinct here even on
1068
- * Windows, where they happen to coincide.
1069
- */
1070
- function portableSignature(binding) {
1071
- return `${bindingSignature(binding, "mac")} ${bindingSignature(binding, "windows")}`;
1072
- }
1073
- var Keymap = class {
1074
- #defaults = /* @__PURE__ */ new Map();
1075
- #overrides = /* @__PURE__ */ new Map();
1076
- #listeners = /* @__PURE__ */ new Set();
1077
- #persistence;
1078
- #loaded = false;
1079
- constructor(persistence) {
1080
- this.#persistence = persistence;
1081
- }
1082
- /** Sets a command's default bindings. `null` leaves the command unbound. */
1083
- setDefault(id, input) {
1084
- const bindings = toBindings(input);
1085
- if (bindings.length === 0) this.#defaults.delete(id);
1086
- else this.#defaults.set(id, bindings);
1087
- }
1088
- /** Checks whether a command has an active user override. */
1089
- isOverridden(id) {
1090
- return this.#overrides.has(id);
1091
- }
1092
- get(id) {
1093
- const defaults = this.#defaults.get(id) ?? [];
1094
- const override = this.#overrides.get(id);
1095
- if (!override) return defaults;
1096
- if (override.kind === "replace") return override.bindings;
1097
- return [...defaults, ...without(override.bindings, defaults)];
1098
- }
1099
- /** Replaces a command's bindings. Passing `null` unbinds it. */
1100
- set(id, input) {
1101
- this.load();
1102
- const bindings = input === null ? [] : toBindings(input);
1103
- if (sameBindings(bindings, this.#defaults.get(id) ?? [])) {
1104
- if (!this.#overrides.delete(id)) return;
1105
- } else this.#overrides.set(id, {
1106
- kind: "replace",
1107
- bindings
1108
- });
1109
- this.#changed();
1110
- }
1111
- /** Adds an additional binding to a command. */
1112
- add(id, input) {
1113
- this.load();
1114
- const added = without(toBindings(input), this.get(id));
1115
- if (added.length === 0) return;
1116
- const override = this.#overrides.get(id);
1117
- this.#overrides.set(id, {
1118
- kind: override?.kind ?? "add",
1119
- bindings: [...override?.bindings ?? [], ...added]
1120
- });
1121
- this.#changed();
1122
- }
1123
- /** Resets user overrides to defaults for one command or all commands. */
1124
- reset(id) {
1125
- this.load();
1126
- if (id === void 0) {
1127
- if (this.#overrides.size === 0) return;
1128
- this.#overrides.clear();
1129
- } else if (!this.#overrides.delete(id)) return;
1130
- this.#changed();
1131
- }
1132
- /** Exports user overrides as a serialized keymap object. */
1133
- export() {
1134
- return {
1135
- version: KEYMAP_VERSION,
1136
- overrides: Object.fromEntries(Array.from(this.#overrides, ([id, override]) => [id, serializeOverride(override)]))
1137
- };
1138
- }
1139
- /** Imports serialized user overrides and saves to storage. */
1140
- import(data) {
1141
- if (!this.#read(data)) return;
1142
- this.#loaded = true;
1143
- this.#changed();
1144
- }
1145
- /**
1146
- * Loads persisted overrides from storage, once; returns whether that
1147
- * changed anything (subscribers have then been told). The registry calls
1148
- * this on its first mount. `set`, `add` and `reset` call it too, so an
1149
- * edit made before mount lands on top of the stored overrides instead of
1150
- * writing over them.
1151
- */
1152
- load() {
1153
- return !this.#loaded && this.#sync();
1154
- }
1155
- /** Reads the stored overrides, telling subscribers if they differ from the ones in memory. */
1156
- #sync() {
1157
- this.#loaded = true;
1158
- const storage = this.#storage();
1159
- if (!storage) return false;
1160
- const before = JSON.stringify(this.export());
1161
- try {
1162
- const raw = storage.getItem(this.#storageKey());
1163
- if (raw) this.#read(JSON.parse(raw));
1164
- else this.#overrides.clear();
1165
- } catch {
1166
- return false;
1167
- }
1168
- if (JSON.stringify(this.export()) === before) return false;
1169
- this.#notify();
1170
- return true;
1171
- }
1172
- /**
1173
- * Follows edits other tabs make to the stored overrides, so rebinding a
1174
- * key in one tab reaches the rest instead of being overwritten by the next
1175
- * stale tab to save. Returns a function that stops watching.
1176
- */
1177
- watch() {
1178
- if (!this.#persistence || typeof window === "undefined") return () => {};
1179
- const onStorage = (event) => {
1180
- if (event.key !== null && event.key !== this.#storageKey()) return;
1181
- this.#sync();
1182
- };
1183
- window.addEventListener("storage", onStorage);
1184
- return () => window.removeEventListener("storage", onStorage);
1185
- }
1186
- /** Subscribes to keymap changes. Returns an unsubscribe function. */
1187
- subscribe(listener) {
1188
- this.#listeners.add(listener);
1189
- return () => {
1190
- this.#listeners.delete(listener);
1191
- };
1192
- }
1193
- /** Replaces the overrides with stored ones. `false` for data this release cannot read. */
1194
- #read(data) {
1195
- const entries = storedOverrides(data);
1196
- if (!entries) return false;
1197
- this.#overrides.clear();
1198
- for (const [id, value] of Object.entries(entries)) {
1199
- const override = deserializeOverride(value);
1200
- if (override) this.#overrides.set(id, override);
1201
- }
1202
- return true;
1203
- }
1204
- #changed() {
1205
- this.#persist();
1206
- this.#notify();
1207
- }
1208
- #notify() {
1209
- for (const listener of this.#listeners) listener();
1210
- }
1211
- #persist() {
1212
- const storage = this.#storage();
1213
- if (!storage) return;
1214
- try {
1215
- if (this.#overrides.size === 0) storage.removeItem(this.#storageKey());
1216
- else storage.setItem(this.#storageKey(), JSON.stringify(this.export()));
1217
- } catch {}
1218
- }
1219
- #storageKey() {
1220
- return this.#persistence?.key ?? DEFAULT_STORAGE_KEY;
1221
- }
1222
- #storage() {
1223
- try {
1224
- return this.#persistence?.storage() ?? null;
1225
- } catch {
1226
- return null;
1227
- }
1228
- }
1229
- };
1230
- /** `bindings` minus any already in `existing` or repeated, compared across platforms. */
1231
- function without(bindings, existing) {
1232
- const known = new Set(existing.map(portableSignature));
1233
- return bindings.filter((binding) => {
1234
- const signature = portableSignature(binding);
1235
- if (known.has(signature)) return false;
1236
- known.add(signature);
1237
- return true;
1238
- });
1239
- }
1240
- /** Same bindings in the same order; the first is the one shortcuts display. */
1241
- function sameBindings(a, b) {
1242
- return a.length === b.length && a.every((binding, index) => {
1243
- const other = b[index];
1244
- return other !== void 0 && portableSignature(binding) === portableSignature(other);
1245
- });
1246
- }
1247
- /** The overrides in stored data, or `null` if this release cannot read it. */
1248
- function storedOverrides(data) {
1249
- if (!isJsonObject(data)) return null;
1250
- if (typeof data.version !== "number") return data;
1251
- if (data.version !== KEYMAP_VERSION) return null;
1252
- const overrides = data.overrides;
1253
- return overrides !== void 0 && isJsonObject(overrides) ? overrides : null;
1254
- }
1255
- function serializeOverride(override) {
1256
- const bindings = override.bindings.map(serializeBinding);
1257
- return override.kind === "replace" ? bindings : { add: bindings };
1258
- }
1259
- function deserializeOverride(value) {
1260
- if (Array.isArray(value)) {
1261
- const bindings = deserializeBindings(value);
1262
- return bindings && {
1263
- kind: "replace",
1264
- bindings
1265
- };
1266
- }
1267
- if (isJsonObject(value) && Array.isArray(value.add)) {
1268
- const bindings = deserializeBindings(value.add);
1269
- return bindings && bindings.length > 0 ? {
1270
- kind: "add",
1271
- bindings
1272
- } : null;
1273
- }
1274
- return null;
1275
- }
1276
- /**
1277
- * `[]` is how an unbound command is stored. A non-empty list with nothing
1278
- * usable in it is damage — hand edits, another version's format — and
1279
- * reading it as "unbound" would silently strip the command of its default
1280
- * shortcut, so it reads as `null` instead.
1281
- */
1282
- function deserializeBindings(values) {
1283
- const bindings = values.map(deserializeBinding).filter((binding) => binding !== null);
1284
- return bindings.length === 0 && values.length > 0 ? null : bindings;
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"}.`);
1285
1372
  }
1286
1373
  //#endregion
1287
1374
  //#region src/registry.ts
1288
1375
  /** Command registry: what an app can do, which keys do it, and where each is live. */
1289
- const SSR_PLATFORM = "windows";
1376
+ const SSR_PLATFORM = "other";
1290
1377
  var CommandRegistry = class {
1291
1378
  #definitions;
1292
1379
  /** Handlers attached per command, the most recent last. */
@@ -1297,6 +1384,8 @@ var CommandRegistry = class {
1297
1384
  #options;
1298
1385
  #listeners = /* @__PURE__ */ new Set();
1299
1386
  #snapshot = null;
1387
+ /** What the snapshot is behind on: who is reachable, or the bindings too. */
1388
+ #stale = "bindings";
1300
1389
  #serverSnapshot = null;
1301
1390
  #pending = null;
1302
1391
  /** Stops following other tabs' keymap edits; set while mounted. */
@@ -1309,11 +1398,8 @@ var CommandRegistry = class {
1309
1398
  this.#singleKeys = options.singleKeyShortcuts !== false;
1310
1399
  this.#keymap = new Keymap(options.persist);
1311
1400
  this.#definitions = /* @__PURE__ */ new Map();
1312
- for (const id in definitions) {
1313
- const definition = definitions[id];
1314
- this.#definitions.set(id, definition);
1315
- this.#keymap.setDefault(id, this.#defaultKeys(id, definition));
1316
- }
1401
+ for (const id in definitions) this.#definitions.set(id, definitions[id]);
1402
+ this.#setDefaults();
1317
1403
  this.#keys = new KeyListener({
1318
1404
  ids: () => [...this.#definitions.keys()],
1319
1405
  definition: (id) => this.#definitions.get(id),
@@ -1332,11 +1418,9 @@ var CommandRegistry = class {
1332
1418
  if (process.env.NODE_ENV !== "production") auditRegistry({
1333
1419
  ids: [...this.#definitions.keys()],
1334
1420
  scope: (id) => this.#definitions.get(id)?.scope,
1335
- bindings: (id) => this.#bindings(id),
1336
- declared: (id) => this.#keymap.get(id),
1337
- overrides: options.bindings,
1338
- platform: this.#platform(),
1339
- display: this.#displayOptions()
1421
+ bindings: (id, platform) => this.#defaultBindings(id, platform),
1422
+ platforms: options.platform ? [options.platform] : ["mac", "other"],
1423
+ overrides: options.bindings
1340
1424
  });
1341
1425
  }
1342
1426
  get keymap() {
@@ -1357,7 +1441,7 @@ var CommandRegistry = class {
1357
1441
  };
1358
1442
  const singleKeys = this.#options.singleKeyShortcuts !== false;
1359
1443
  this.#serverSnapshot = Array.from(this.#definitions, ([id, definition]) => {
1360
- const bindings = toBindings(this.#defaultKeys(id, definition)).filter((binding) => singleKeys || !isSingleKeyBinding(binding));
1444
+ const bindings = this.#defaultBindings(id, platform).filter((binding) => singleKeys || !isSingleKeyBinding(binding));
1361
1445
  const global = definition.scope === void 0 || definition.scope === "*";
1362
1446
  return describeCommand(id, definition, bindings, platform, options, {
1363
1447
  reachable: definition.run !== void 0 && global,
@@ -1418,20 +1502,28 @@ var CommandRegistry = class {
1418
1502
  }
1419
1503
  conflicts(binding, options = {}) {
1420
1504
  const platform = this.#platform();
1505
+ const display = (entry) => formatBinding(entry, this.#displayOptions());
1421
1506
  const candidates = toBindings(binding);
1422
1507
  const signatures = new Set(candidates.map((entry) => bindingSignature(entry, platform)));
1423
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
+ }
1424
1517
  for (const [id, { scope }] of this.#definitions) {
1425
1518
  if (id === options.ignore || !scopesOverlap(scope, options.scope)) continue;
1426
1519
  for (const existing of this.#bindings(id)) {
1427
1520
  const kind = signatures.has(bindingSignature(existing, platform)) ? "exact" : candidates.some((candidate) => isPrefixCollision(candidate, existing, platform)) ? "prefix" : null;
1428
1521
  if (kind === null) continue;
1429
- const display = formatBinding(existing, this.#displayOptions());
1430
1522
  conflicts.push({
1523
+ kind,
1431
1524
  id,
1432
1525
  binding: existing,
1433
- kind,
1434
- display,
1526
+ display: display(existing),
1435
1527
  scope
1436
1528
  });
1437
1529
  }
@@ -1469,10 +1561,13 @@ var CommandRegistry = class {
1469
1561
  mount() {
1470
1562
  this.#mounts++;
1471
1563
  if (this.#mounts === 1) {
1564
+ const rendered = this.#platform();
1472
1565
  this.#hydrated = true;
1566
+ const moved = this.#platform() !== rendered;
1567
+ if (moved) this.#setDefaults();
1473
1568
  this.#unwatch = this.#keymap.watch();
1474
1569
  this.#keys.attach();
1475
- if (!this.#keymap.load()) this.#bindingsChanged();
1570
+ if (!this.#keymap.sync() && moved) this.#bindingsChanged();
1476
1571
  }
1477
1572
  let released = false;
1478
1573
  return () => {
@@ -1497,7 +1592,7 @@ var CommandRegistry = class {
1497
1592
  this.#definitions.clear();
1498
1593
  this.#handlers.clear();
1499
1594
  this.#serverSnapshot = null;
1500
- this.#invalidate();
1595
+ this.#invalidate("bindings");
1501
1596
  this.#listeners.clear();
1502
1597
  this.#scopes.clear();
1503
1598
  }
@@ -1514,25 +1609,24 @@ var CommandRegistry = class {
1514
1609
  if (this.#keys.captured || !this.#handler(id)) return false;
1515
1610
  return this.#scopes.isActive(this.#definitions.get(id)?.scope);
1516
1611
  }
1517
- /**
1518
- * Bindings in effect: the keymap's, minus single-key ones while those are
1519
- * switched off. Everything that binds, displays or compares keys reads
1520
- * this, so a suppressed shortcut is not advertised either.
1521
- */
1612
+ /** Bindings in effect: the keymap's, minus single-key ones while those are switched off. */
1522
1613
  #bindings(id) {
1523
1614
  const bindings = this.#keymap.get(id);
1524
1615
  if (this.#singleKeys) return bindings;
1525
1616
  return bindings.filter((binding) => !isSingleKeyBinding(binding));
1526
1617
  }
1527
1618
  /**
1528
- * A command's default keys: the app's `options.bindings` entry if it has
1529
- * one, else the definition's own. `hasOwn`, not `??`: a `null` entry
1530
- * deliberately ships the command unbound, and `??` would read that as
1531
- * "absent" and restore the very default the app asked to drop.
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.
1532
1621
  */
1533
- #defaultKeys(id, definition) {
1622
+ #defaultBindings(id, platform) {
1534
1623
  const overrides = this.#options.bindings;
1535
- return overrides && Object.hasOwn(overrides, id) ? overrides[id] : definition.keys;
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));
1536
1630
  }
1537
1631
  #execute(id, context) {
1538
1632
  const run = this.#handler(id);
@@ -1563,11 +1657,11 @@ var CommandRegistry = class {
1563
1657
  }
1564
1658
  #bindingsChanged() {
1565
1659
  this.#keys.refresh();
1566
- this.#invalidate();
1660
+ this.#invalidate("bindings");
1567
1661
  }
1568
1662
  #reachabilityChanged() {
1569
1663
  this.#keys.prune();
1570
- this.#invalidate();
1664
+ this.#invalidate("reachable");
1571
1665
  }
1572
1666
  #platform() {
1573
1667
  return this.#options.platform ?? (this.#hydrated ? detectPlatform() : SSR_PLATFORM);
@@ -1578,28 +1672,46 @@ var CommandRegistry = class {
1578
1672
  ...this.#options.display
1579
1673
  };
1580
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
+ */
1581
1680
  #snapshots() {
1582
- if (this.#snapshot) return this.#snapshot;
1681
+ const previous = this.#snapshot;
1682
+ if (previous && !this.#stale) return previous;
1683
+ const kept = this.#stale === "reachable" ? previous?.index : void 0;
1583
1684
  const platform = this.#platform();
1584
1685
  const options = this.#displayOptions();
1585
1686
  const list = [];
1586
1687
  const index = /* @__PURE__ */ new Map();
1688
+ let changed = !kept;
1587
1689
  for (const [id, definition] of this.#definitions) {
1588
- const command = describeCommand(id, definition, this.#bindings(id), platform, options, {
1589
- reachable: this.#reachable(id),
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,
1590
1694
  overridden: this.#keymap.isOverridden(id)
1591
1695
  });
1696
+ else if (command.reachable !== reachable) {
1697
+ command = {
1698
+ ...command,
1699
+ reachable
1700
+ };
1701
+ changed = true;
1702
+ }
1592
1703
  list.push(command);
1593
1704
  index.set(id, command);
1594
1705
  }
1595
- this.#snapshot = {
1706
+ this.#stale = null;
1707
+ this.#snapshot = changed || !previous ? {
1596
1708
  list,
1597
1709
  index
1598
- };
1710
+ } : previous;
1599
1711
  return this.#snapshot;
1600
1712
  }
1601
- #invalidate() {
1602
- this.#snapshot = null;
1713
+ #invalidate(stale) {
1714
+ if (this.#stale !== "bindings") this.#stale = stale;
1603
1715
  for (const listener of this.#listeners) listener();
1604
1716
  }
1605
1717
  };
@@ -1646,12 +1758,10 @@ function createCommands(definitions, options) {
1646
1758
  //#endregion
1647
1759
  //#region src/search.ts
1648
1760
  /**
1649
- * Filters commands for a search query, as a command palette does, leaving
1650
- * out `hidden` ones. Every word of the query must appear somewhere in the
1651
- * command — title, description, group, keywords, id or displayed shortcut —
1652
- * in any order. Title matches come first: titles starting with the query,
1653
- * then titles containing every word, then the rest; each tier keeps the
1654
- * list's own order. An empty query returns every visible command.
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.
1655
1765
  *
1656
1766
  * @example
1657
1767
  * ```ts
@@ -1684,4 +1794,4 @@ function matchCommands(commands, query) {
1684
1794
  return ranked.sort((a, b) => a.rank - b.rank).map(({ command }) => command);
1685
1795
  }
1686
1796
  //#endregion
1687
- export { bindingTokens as a, isSingleKeyBinding as c, isModifierKey as d, keyName as f, ariaKeyShortcuts as i, seq as l, createCommands as n, formatBinding as o, createRebinder as r, spokenBinding as s, matchCommands as t, validateBinding as u };
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 };