@joewinke/jatui 0.26.0 → 0.27.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.
Files changed (73) hide show
  1. package/CLAUDE.md +106 -1
  2. package/dist/actions/keyboardScope.d.ts +58 -0
  3. package/dist/actions/keyboardScope.js +110 -0
  4. package/dist/actions/listNav.d.ts +48 -3
  5. package/dist/actions/listNav.js +267 -77
  6. package/dist/actions/longpress.d.ts +37 -0
  7. package/dist/actions/longpress.js +105 -0
  8. package/dist/actions/stickToBottom.d.ts +66 -0
  9. package/dist/actions/stickToBottom.js +155 -0
  10. package/dist/actions/swipe.d.ts +64 -0
  11. package/dist/actions/swipe.js +179 -0
  12. package/dist/components/BottomSheet.svelte +280 -0
  13. package/dist/components/BottomSheet.svelte.d.ts +27 -0
  14. package/dist/components/ConfirmDialog.svelte +21 -0
  15. package/dist/components/ConfirmDialog.svelte.d.ts +11 -1
  16. package/dist/components/ConfirmModal.svelte +15 -0
  17. package/dist/components/ConfirmModal.svelte.d.ts +5 -0
  18. package/dist/components/Drawer.svelte +18 -1
  19. package/dist/components/Drawer.svelte.d.ts +9 -1
  20. package/dist/components/InputDialog.svelte +21 -0
  21. package/dist/components/InputDialog.svelte.d.ts +11 -1
  22. package/dist/components/Modal.svelte +18 -1
  23. package/dist/components/Modal.svelte.d.ts +8 -0
  24. package/dist/components/basic/Empty.svelte +173 -9
  25. package/dist/components/basic/Empty.svelte.d.ts +50 -1
  26. package/dist/components/basic/ErrorState.svelte +128 -0
  27. package/dist/components/basic/ErrorState.svelte.d.ts +46 -0
  28. package/dist/components/basic/JumpToLatest.svelte +60 -0
  29. package/dist/components/basic/JumpToLatest.svelte.d.ts +9 -0
  30. package/dist/components/basic/PageHeader.svelte +216 -0
  31. package/dist/components/basic/PageHeader.svelte.d.ts +52 -0
  32. package/dist/components/basic/ProgressButton.svelte +221 -0
  33. package/dist/components/basic/ProgressButton.svelte.d.ts +63 -0
  34. package/dist/components/graph/GraphApiBridge.svelte +36 -0
  35. package/dist/components/graph/GraphApiBridge.svelte.d.ts +9 -0
  36. package/dist/components/graph/GraphCanvas.svelte +212 -27
  37. package/dist/components/graph/GraphCanvas.svelte.d.ts +113 -19
  38. package/dist/components/graph/api.d.ts +52 -0
  39. package/dist/components/graph/api.js +66 -0
  40. package/dist/components/graph/index.d.ts +2 -0
  41. package/dist/components/graph/index.js +1 -0
  42. package/dist/components/graph/theme.js +13 -0
  43. package/dist/components/messaging/MessageItem.svelte +5 -0
  44. package/dist/components/messaging/MessageItem.svelte.d.ts +7 -0
  45. package/dist/components/table/DataTable.svelte +452 -78
  46. package/dist/components/table/DataTable.svelte.d.ts +2 -2
  47. package/dist/components/table/ManageColumnsDropdown.svelte +466 -0
  48. package/dist/components/table/ManageColumnsDropdown.svelte.d.ts +26 -0
  49. package/dist/components/table/columnStorage.d.ts +29 -0
  50. package/dist/components/table/columnStorage.js +61 -0
  51. package/dist/components/table/groupRows.d.ts +72 -0
  52. package/dist/components/table/groupRows.js +118 -0
  53. package/dist/components/table/index.d.ts +7 -1
  54. package/dist/components/table/index.js +3 -0
  55. package/dist/components/table/types.d.ts +121 -20
  56. package/dist/index.d.ts +25 -2
  57. package/dist/index.js +32 -1
  58. package/dist/utils/ansiToHtml.d.ts +83 -0
  59. package/dist/utils/ansiToHtml.js +543 -0
  60. package/dist/utils/keyboardShortcutRegistry.d.ts +332 -11
  61. package/dist/utils/keyboardShortcutRegistry.js +1070 -76
  62. package/dist/utils/pollBackoff.d.ts +83 -0
  63. package/dist/utils/pollBackoff.js +151 -0
  64. package/dist/utils/snapFlick.d.ts +59 -0
  65. package/dist/utils/snapFlick.js +66 -0
  66. package/dist/video/advisors/qr.d.ts +1 -1
  67. package/dist/virtual/createRowWindow.d.ts +75 -0
  68. package/dist/virtual/createRowWindow.js +129 -0
  69. package/dist/virtual/index.d.ts +4 -0
  70. package/dist/virtual/index.js +5 -0
  71. package/dist/virtual/rowWindowCore.d.ts +202 -0
  72. package/dist/virtual/rowWindowCore.js +510 -0
  73. package/package.json +11 -1
@@ -37,9 +37,66 @@
37
37
  * browser (Chrome won't hand some of them to the page at all). The sidebar
38
38
  * convention across JAT apps is `mod+\` — see `SIDEBAR_TOGGLE_KEY`.
39
39
  *
40
+ * Rebinding (jatui-voc3i.1): a definition with an `id` is rebindable. Its
41
+ * `key` is the DEFAULT; the effective binding is the user's override from a
42
+ * `BindingStore` (or `rebind()`), and registered entries re-resolve on every
43
+ * binding change, so components never re-register because a key moved.
44
+ * `declare()` makes ids known before their component mounts (a settings page
45
+ * can list a route's shortcuts without visiting the route). Combos are
46
+ * stored and compared in the canonical form `normalizeCombo()` produces.
47
+ * A stored entry that can't be parsed is never dropped silently: it is
48
+ * logged once (`console.warn`) and listed by `rejectedBindings()` with its
49
+ * raw value, so a settings page can show it.
50
+ *
51
+ * Conflicts (jatui-voc3i.2): `findConflicts(keys, { id | context })` and
52
+ * `allConflicts()` report collisions by how the stack resolves keys — same
53
+ * context is an error (one of them is dead), contexts that can be co-active
54
+ * (`contextGraph`; `global` is beneath everything) shadow with a warning, a
55
+ * modal upper context is info (they never compete), and `reserved`
56
+ * browser combos carry their own severity. `rebind()` rejects on any error.
57
+ *
58
+ * Beyond combos (jatui-voc3i.3): `onRelease` fires on the keyup of the
59
+ * same physical key (push-to-talk); `onModifierChange` reports held
60
+ * modifiers and is reset on blur; `registerHandler()` adds raw handlers
61
+ * (count prefixes, chords) that run inside the context walk after the
62
+ * context's combos, so modals still suppress them; `pushContext` takes a
63
+ * `layer` (frames sort by layer, then push order) and, for modal frames, a
64
+ * `passthrough` list of ids/combos that still reach the contexts beneath.
65
+ *
66
+ * Known divergences from the JAT IDE's legacy `shortcutParser` (an importer
67
+ * passed to `localStorageBindingStore({ migrate })` must account for them):
68
+ * - `cmd`: the IDE maps `Cmd+K` to META on every platform; here `cmd` is
69
+ * an alias of `mod` (Ctrl on Linux/Windows). Rewrite `cmd` → `meta`
70
+ * when importing so a stored `Cmd+K` keeps meaning ⌘/Win.
71
+ * - Physical-key fallback: the IDE falls back to `event.code` for LETTER
72
+ * keys under any modifier (non-Latin layouts: Ctrl+K on a Cyrillic
73
+ * layout). Here the `event.code` fallback applies only to alt combos and
74
+ * to `` ` `` / Space; `ctrl+k` does not fire for `ctrl+л`.
75
+ *
40
76
  * Pure TypeScript, no Svelte runes and no DOM globals at import time, so it
41
77
  * is SSR-safe and unit-testable under plain node.
42
78
  */
79
+ /**
80
+ * Browser-owned combos. `mod+t/w/n` block: Chrome
81
+ * never hands them to the page. The editing/navigation combos (and Ctrl+B,
82
+ * the bookmarks bar) are delivered but warn.
83
+ */
84
+ export const BROWSER_RESERVED_COMBOS = [
85
+ { key: 'mod+t', reason: 'Browser: new tab', severity: 'block' },
86
+ { key: 'mod+w', reason: 'Browser: close tab', severity: 'block' },
87
+ { key: 'mod+n', reason: 'Browser: new window', severity: 'block' },
88
+ { key: 'mod+b', reason: 'Browser: bookmarks bar', severity: 'warn' },
89
+ { key: 'mod+c', reason: 'Browser: copy', severity: 'warn' },
90
+ { key: 'mod+v', reason: 'Browser: paste', severity: 'warn' },
91
+ { key: 'mod+x', reason: 'Browser: cut', severity: 'warn' },
92
+ { key: 'mod+z', reason: 'Browser: undo', severity: 'warn' },
93
+ { key: 'mod+y', reason: 'Browser: redo', severity: 'warn' },
94
+ { key: 'mod+a', reason: 'Browser: select all', severity: 'warn' },
95
+ { key: 'mod+s', reason: 'Browser: save page', severity: 'warn' },
96
+ { key: 'mod+f', reason: 'Browser: find', severity: 'warn' },
97
+ { key: 'mod+p', reason: 'Browser: print', severity: 'warn' },
98
+ { key: 'mod+r', reason: 'Browser: reload', severity: 'warn' }
99
+ ];
43
100
  /** The sidebar-toggle convention. Ctrl+B is reserved by Chrome (bookmarks bar). */
44
101
  export const SIDEBAR_TOGGLE_KEY = 'mod+\\';
45
102
  const GLOBAL = 'global';
@@ -50,6 +107,33 @@ export function detectPlatform() {
50
107
  const p = nav.userAgentData?.platform || nav.platform || nav.userAgent || '';
51
108
  return /mac|iphone|ipad|ipod/i.test(p) ? 'mac' : 'other';
52
109
  }
110
+ /** Every spelling of a modifier the IDE (or a human) has ever written. */
111
+ const MODIFIER_ALIASES = {
112
+ mod: 'mod',
113
+ cmd: 'mod',
114
+ meta: 'meta',
115
+ command: 'meta',
116
+ win: 'meta',
117
+ windows: 'meta',
118
+ super: 'meta',
119
+ ctrl: 'ctrl',
120
+ control: 'ctrl',
121
+ shift: 'shift',
122
+ alt: 'alt',
123
+ option: 'alt',
124
+ opt: 'alt'
125
+ };
126
+ /** Named-key aliases (case-insensitive) → the lowercased `KeyboardEvent.key` value. */
127
+ const KEY_ALIASES = {
128
+ esc: 'escape',
129
+ space: ' ',
130
+ spacebar: ' ',
131
+ del: 'delete',
132
+ up: 'arrowup',
133
+ down: 'arrowdown',
134
+ left: 'arrowleft',
135
+ right: 'arrowright'
136
+ };
53
137
  /** Parse `'mod+shift+k'` (plus any extra modifiers) into a normalized combo. */
54
138
  export function parseCombo(key, extra) {
55
139
  const combo = { key: '', mod: false, meta: false, ctrl: false, shift: false, alt: false };
@@ -61,15 +145,12 @@ export function parseCombo(key, extra) {
61
145
  base = parts.pop() ?? '';
62
146
  for (const p of parts) {
63
147
  const m = p.trim().toLowerCase();
64
- if (m === 'mod' || m === 'cmd' || m === 'meta' || m === 'ctrl' || m === 'shift' || m === 'alt') {
65
- mods.push(m);
66
- }
67
- else if (m === 'control')
68
- mods.push('ctrl');
69
- else if (m === 'option')
70
- mods.push('alt');
71
- else if (m)
148
+ if (!m)
149
+ continue;
150
+ const alias = MODIFIER_ALIASES[m];
151
+ if (!alias)
72
152
  throw new Error(`keyboardShortcutRegistry: unknown modifier "${p}" in "${key}"`);
153
+ mods.push(alias);
73
154
  }
74
155
  }
75
156
  for (const m of mods) {
@@ -82,18 +163,42 @@ export function parseCombo(key, extra) {
82
163
  return combo;
83
164
  }
84
165
  function normalizeKey(key) {
85
- if (key === 'Esc')
86
- return 'escape';
87
- if (key === 'Space' || key === 'Spacebar')
88
- return ' ';
89
- if (key === 'Del')
90
- return 'delete';
91
- return key.toLowerCase();
166
+ const k = key.toLowerCase();
167
+ return key.length > 1 ? (KEY_ALIASES[k] ?? k) : k;
168
+ }
169
+ const MODIFIER_ORDER = ['mod', 'meta', 'ctrl', 'alt', 'shift'];
170
+ function comboToString(c) {
171
+ const parts = MODIFIER_ORDER.filter((m) => c[m]);
172
+ parts.push(c.key === ' ' ? 'space' : c.key);
173
+ return parts.join('+');
174
+ }
175
+ /**
176
+ * Canonical, order-stable form used for storage and equality:
177
+ * `"alt+shift+s"`, `"mod+k"`, `"ctrl+\`"`, `"space"`, `"mod++"`. Modifier
178
+ * order is mod, meta, ctrl, alt, shift. `mod` is never introduced: a stored
179
+ * `Ctrl+K` stays `ctrl+k`. Throws on an unknown modifier (see `validateCombo`).
180
+ */
181
+ export function normalizeCombo(key, extra) {
182
+ return comboToString(parseCombo(key, extra));
92
183
  }
93
184
  /** A single printable non-letter (`?`, `/`, `\`, `1`): its shift state is baked into the char. */
94
185
  function isSymbolKey(key) {
95
186
  return key.length === 1 && key.toLowerCase() === key.toUpperCase() && key !== ' ';
96
187
  }
188
+ const CODE_KEYS = {
189
+ Backslash: '\\',
190
+ Slash: '/',
191
+ Backquote: '`',
192
+ Space: ' ',
193
+ Minus: '-',
194
+ Equal: '=',
195
+ BracketLeft: '[',
196
+ BracketRight: ']',
197
+ Semicolon: ';',
198
+ Quote: "'",
199
+ Comma: ',',
200
+ Period: '.'
201
+ };
97
202
  function codeToKey(code) {
98
203
  if (!code)
99
204
  return null;
@@ -101,19 +206,17 @@ function codeToKey(code) {
101
206
  return code.slice(3).toLowerCase();
102
207
  if (/^Digit\d$/.test(code))
103
208
  return code.slice(5);
104
- if (code === 'Backslash')
105
- return '\\';
106
- if (code === 'Slash')
107
- return '/';
108
- return null;
209
+ return CODE_KEYS[code] ?? null;
109
210
  }
110
211
  function matches(event, combo, platform) {
111
212
  const evKey = normalizeKey(event.key ?? '');
112
213
  let keyHit = evKey === combo.key;
113
- // macOS Option (and some layouts) rewrite event.key for alt combos — fall
114
- // back to the physical key when alt is part of the shortcut.
115
- if (!keyHit && combo.alt)
214
+ // macOS Option (and some layouts) rewrite event.key for alt combos, and
215
+ // `` ` `` / Space vary by layout with any modifier — fall back to the
216
+ // physical key for those.
217
+ if (!keyHit && (combo.alt || combo.key === '`' || combo.key === ' ')) {
116
218
  keyHit = codeToKey(event.code) === combo.key;
219
+ }
117
220
  if (!keyHit)
118
221
  return false;
119
222
  const wantMeta = combo.meta || (combo.mod && platform === 'mac');
@@ -129,6 +232,76 @@ function matches(event, combo, platform) {
129
232
  return true;
130
233
  return !!event.shiftKey === combo.shift;
131
234
  }
235
+ const MODIFIER_KEY_NAMES = new Set([
236
+ 'control', 'ctrl', 'shift', 'alt', 'option', 'meta', 'os', 'super', 'hyper', 'altgraph',
237
+ 'capslock', 'fn', 'fnlock', 'numlock', 'scrolllock', 'symbol', 'symbollock', 'cmd', 'command', 'win', 'windows'
238
+ ]);
239
+ const NAMED_KEYS = new Set([
240
+ 'escape', 'enter', 'tab', 'backspace', 'delete', 'insert', 'home', 'end', 'pageup', 'pagedown',
241
+ 'arrowup', 'arrowdown', 'arrowleft', 'arrowright', 'contextmenu', 'pause', 'printscreen',
242
+ 'mediaplaypause', 'mediatracknext', 'mediatrackprevious', 'mediastop',
243
+ 'audiovolumeup', 'audiovolumedown', 'audiovolumemute'
244
+ ]);
245
+ /**
246
+ * Canonical combo for a live keydown (a settings "press the new shortcut"
247
+ * recorder). Null while only modifiers are down, or when the key can't be
248
+ * identified. Ctrl/Meta are reported literally (never `mod`). Uses the
249
+ * physical key when macOS Option (or a dead key / non-ASCII layout) hides it.
250
+ */
251
+ export function comboFromEvent(event, platform = detectPlatform()) {
252
+ const raw = event.key ?? '';
253
+ if (!raw || MODIFIER_KEY_NAMES.has(raw.toLowerCase()))
254
+ return null;
255
+ const fromCode = codeToKey(event.code);
256
+ const unusable = raw === 'Dead' || raw === 'Unidentified' || raw === 'Process';
257
+ let key = normalizeKey(raw);
258
+ let usedCode = false;
259
+ const nonAscii = key.length === 1 && !/^[\x20-\x7e]$/.test(key);
260
+ if (fromCode && (unusable || (event.altKey && (platform === 'mac' || nonAscii)))) {
261
+ key = fromCode;
262
+ usedCode = true;
263
+ }
264
+ else if (unusable) {
265
+ return null;
266
+ }
267
+ return comboToString({
268
+ key,
269
+ mod: false,
270
+ meta: !!event.metaKey,
271
+ ctrl: !!event.ctrlKey,
272
+ alt: !!event.altKey,
273
+ // `?` is already Shift+/ — keep shift implied, exactly as matching does.
274
+ shift: !!event.shiftKey && !(isSymbolKey(key) && !usedCode)
275
+ });
276
+ }
277
+ /**
278
+ * Structural validation for a user-entered binding. Null means valid,
279
+ * otherwise a human-readable reason. `requireModifier` demands Ctrl, Alt,
280
+ * Meta or mod (Shift alone doesn't count) — the IDE's rule for global ids.
281
+ */
282
+ export function validateCombo(key, options = {}) {
283
+ if (!key || !key.trim())
284
+ return 'Shortcut is empty';
285
+ let c;
286
+ try {
287
+ c = parseCombo(key);
288
+ }
289
+ catch (err) {
290
+ const msg = err instanceof Error ? err.message.replace(/^keyboardShortcutRegistry: /, '') : String(err);
291
+ return msg.charAt(0).toUpperCase() + msg.slice(1);
292
+ }
293
+ if (!c.key)
294
+ return 'Shortcut must include a key (e.g. A, 1, Enter)';
295
+ if (MODIFIER_KEY_NAMES.has(c.key))
296
+ return 'Shortcut must include a non-modifier key';
297
+ if (c.key.length > 1 && !NAMED_KEYS.has(c.key) && !/^f([1-9]|1\d|2[0-4])$/.test(c.key)) {
298
+ return `Unknown key "${key.split('+').pop()}"`;
299
+ }
300
+ if (options.requireModifier && !(c.mod || c.meta || c.ctrl || c.alt)) {
301
+ return 'Shortcut must include Ctrl, Alt or Meta';
302
+ }
303
+ return null;
304
+ }
132
305
  const NON_TEXT_INPUT_TYPES = new Set([
133
306
  'button', 'checkbox', 'color', 'file', 'hidden', 'image', 'radio', 'range', 'reset', 'submit'
134
307
  ]);
@@ -162,12 +335,7 @@ const KEY_GLYPHS = {
162
335
  home: 'Home',
163
336
  end: 'End'
164
337
  };
165
- /** Keycap label: `⌘⇧K` on macOS, `Ctrl+Shift+K` elsewhere. */
166
- export function formatShortcut(shortcut, platform = detectPlatform()) {
167
- const def = typeof shortcut === 'string' ? null : shortcut;
168
- if (def?.label)
169
- return def.label;
170
- const combo = def ? parseCombo(def.key, def.modifiers) : parseCombo(shortcut);
338
+ function formatCombo(combo, platform) {
171
339
  const k = combo.key;
172
340
  const keyLabel = KEY_GLYPHS[k] ?? (/^f\d{1,2}$/.test(k) ? k.toUpperCase() : k.length === 1 ? k.toUpperCase() : k);
173
341
  if (platform === 'mac') {
@@ -194,51 +362,682 @@ export function formatShortcut(shortcut, platform = detectPlatform()) {
194
362
  parts.push(keyLabel);
195
363
  return parts.join('+');
196
364
  }
365
+ /** Keycap label: `⌘⇧K` on macOS, `Ctrl+Shift+K` elsewhere. Several keys join with `' / '`. */
366
+ export function formatShortcut(shortcut, platform = detectPlatform()) {
367
+ if (typeof shortcut === 'string')
368
+ return formatCombo(parseCombo(shortcut), platform);
369
+ if (shortcut.label)
370
+ return shortcut.label;
371
+ return toArray(shortcut.key)
372
+ .map((k) => formatCombo(parseCombo(k, shortcut.modifiers), platform))
373
+ .join(' / ');
374
+ }
375
+ function toArray(v) {
376
+ return Array.isArray(v) ? v : [v];
377
+ }
378
+ function hasOwn(obj, key) {
379
+ return Object.prototype.hasOwnProperty.call(obj, key);
380
+ }
381
+ /**
382
+ * Canonicalize stored overrides. Anything unusable — a non-object blob, a
383
+ * non-array value, an unparsable combo — is handed to `reject`, never just
384
+ * dropped.
385
+ */
386
+ function sanitizeOverrides(raw, reject) {
387
+ const out = {};
388
+ if (raw === null || raw === undefined)
389
+ return out;
390
+ if (typeof raw !== 'object' || Array.isArray(raw)) {
391
+ reject({ id: '*', raw, reason: 'Stored bindings are not an object of id → combos' });
392
+ return out;
393
+ }
394
+ for (const [id, value] of Object.entries(raw)) {
395
+ if (!Array.isArray(value)) {
396
+ reject({ id, raw: value, reason: 'Expected an array of combos' });
397
+ continue;
398
+ }
399
+ const combos = [];
400
+ for (const k of value) {
401
+ const err = typeof k === 'string' ? validateCombo(k) : 'Combo is not a string';
402
+ if (err !== null) {
403
+ reject({ id, raw: k, reason: err });
404
+ continue;
405
+ }
406
+ const n = normalizeCombo(k);
407
+ if (!combos.includes(n))
408
+ combos.push(n);
409
+ }
410
+ // A non-empty list with nothing usable is a bad record, not "unbound".
411
+ if (value.length > 0 && combos.length === 0)
412
+ continue;
413
+ out[id] = combos;
414
+ }
415
+ return out;
416
+ }
417
+ function isThenable(v) {
418
+ return !!v && typeof v.then === 'function';
419
+ }
420
+ function defaultDev() {
421
+ try {
422
+ const env = import.meta.env;
423
+ if (env && typeof env.DEV === 'boolean')
424
+ return env.DEV;
425
+ }
426
+ catch {
427
+ // import.meta.env absent outside vite
428
+ }
429
+ const proc = globalThis.process;
430
+ return proc?.env?.NODE_ENV !== 'production';
431
+ }
432
+ function warnStore(error) {
433
+ console.warn('keyboardShortcutRegistry: binding store failed', error);
434
+ }
435
+ function isRejectedBinding(v) {
436
+ const r = v;
437
+ return !!r && typeof r === 'object' && typeof r.id === 'string' && typeof r.reason === 'string' && 'raw' in r;
438
+ }
439
+ /** A rejected-entry collector that warns once per distinct entry. */
440
+ function rejectCollector() {
441
+ let list = [];
442
+ const warned = new Set();
443
+ const keyOf = (r) => `${r.id}\u0000${JSON.stringify(r.raw) ?? String(r.raw)}`;
444
+ return {
445
+ add(r) {
446
+ const k = keyOf(r);
447
+ if (list.some((x) => keyOf(x) === k))
448
+ return;
449
+ list.push(r);
450
+ if (warned.has(k))
451
+ return;
452
+ warned.add(k);
453
+ console.warn(`keyboardShortcutRegistry: rejected stored binding "${r.id}" (${JSON.stringify(r.raw)}): ${r.reason}`);
454
+ },
455
+ reset() {
456
+ list = [];
457
+ },
458
+ list: () => [...list]
459
+ };
460
+ }
461
+ /**
462
+ * Versioned localStorage `BindingStore`: `{ v: 1, bindings, rejected? }`.
463
+ * Tolerates corrupt JSON, a foreign shape and unavailable storage (private
464
+ * mode, SSR) — all read as "no overrides". `migrate` runs only when
465
+ * `storageKey` is absent, and its result (even an empty one) is written so
466
+ * it runs once. Entries it can't parse (from storage or from `migrate`) are
467
+ * warned once, exposed via `rejected()`, and kept in the stored `rejected`
468
+ * list so a later save doesn't lose them.
469
+ * `syncTabs` (default true) forwards other tabs' writes via `storage` events.
470
+ */
471
+ export function localStorageBindingStore(storageKey, options = {}) {
472
+ function storage() {
473
+ try {
474
+ return globalThis.localStorage ?? null;
475
+ }
476
+ catch {
477
+ return null;
478
+ }
479
+ }
480
+ const rejected = rejectCollector();
481
+ /** Read a stored blob; refreshes `rejected` to what this blob carries. */
482
+ function decode(raw) {
483
+ rejected.reset();
484
+ if (raw === null)
485
+ return {};
486
+ let parsed;
487
+ try {
488
+ parsed = JSON.parse(raw);
489
+ }
490
+ catch {
491
+ rejected.add({ id: '*', raw, reason: 'Stored bindings are not valid JSON' });
492
+ return {};
493
+ }
494
+ if (!parsed || typeof parsed !== 'object' || parsed.v !== 1) {
495
+ rejected.add({ id: '*', raw, reason: 'Unrecognised bindings format (expected { v: 1, bindings })' });
496
+ return {};
497
+ }
498
+ if (Array.isArray(parsed.rejected)) {
499
+ for (const r of parsed.rejected)
500
+ if (isRejectedBinding(r))
501
+ rejected.add(r);
502
+ }
503
+ return sanitizeOverrides(parsed.bindings, rejected.add);
504
+ }
505
+ function write(overrides) {
506
+ const list = rejected.list();
507
+ try {
508
+ storage()?.setItem(storageKey, JSON.stringify({ v: 1, bindings: overrides, ...(list.length ? { rejected: list } : {}) }));
509
+ }
510
+ catch (err) {
511
+ warnStore(err);
512
+ }
513
+ }
514
+ return {
515
+ load() {
516
+ let raw = null;
517
+ try {
518
+ raw = storage()?.getItem(storageKey) ?? null;
519
+ }
520
+ catch {
521
+ return {};
522
+ }
523
+ if (raw !== null || !options.migrate)
524
+ return decode(raw);
525
+ let migrated = {};
526
+ rejected.reset();
527
+ try {
528
+ migrated = sanitizeOverrides(options.migrate(), rejected.add);
529
+ }
530
+ catch (err) {
531
+ warnStore(err);
532
+ }
533
+ write(migrated);
534
+ return migrated;
535
+ },
536
+ save: write,
537
+ subscribe(listener) {
538
+ const w = globalThis.window;
539
+ if (options.syncTabs === false || !w || typeof w.addEventListener !== 'function')
540
+ return () => { };
541
+ const onStorage = (e) => {
542
+ // key === null is localStorage.clear()
543
+ if (e.key !== storageKey && e.key !== null)
544
+ return;
545
+ listener(decode(e.key === null ? null : e.newValue));
546
+ };
547
+ w.addEventListener('storage', onStorage);
548
+ return () => w.removeEventListener('storage', onStorage);
549
+ },
550
+ rejected: rejected.list
551
+ };
552
+ }
197
553
  export function createShortcutRegistry(options = {}) {
198
554
  const platform = options.platform ?? detectPlatform();
199
555
  const labels = { [GLOBAL]: 'General', ...options.contextLabels };
200
556
  const onError = options.onError ??
201
557
  ((error) => console.error('keyboardShortcutRegistry: shortcut action failed', error));
558
+ const dev = options.dev ?? defaultDev();
559
+ const store = options.bindings;
560
+ const graph = options.contextGraph ?? {};
561
+ // Remembered for conflict reporting: a context once pushed modal stays modal.
562
+ const modalContexts = new Set(options.modalContexts ?? []);
563
+ const reservedPhysical = (options.reserved ?? BROWSER_RESERVED_COMBOS).map((r) => ({
564
+ physical: physical(normalizeCombo(r.key)),
565
+ reason: r.reason,
566
+ severity: r.severity
567
+ }));
202
568
  const entries = [];
569
+ const declarations = [];
570
+ const handlers = [];
203
571
  const stack = [];
204
572
  const listeners = new Set();
205
573
  let seq = 0;
574
+ let pushSeq = 0;
575
+ const typingTest = options.isTypingTarget ?? ((target) => isTypingTarget(target));
576
+ // Pending `onRelease` calls, keyed by physical key.
577
+ const pendingReleases = new Map();
578
+ let modifiers = { alt: false, ctrl: false, shift: false, meta: false };
579
+ let overrides = {};
580
+ // Edits made before the store finished loading are replayed on top of it.
581
+ let loaded = !store;
582
+ const preLoadEdits = new Map();
583
+ let preLoadResetAll = false;
584
+ // Entries a custom store handed back unparsed (localStorageBindingStore
585
+ // rejects its own before they get here).
586
+ const rejected = rejectCollector();
206
587
  function notify() {
207
588
  for (const l of [...listeners])
208
589
  l();
209
590
  }
591
+ function isRebindable(def) {
592
+ return !!def.id && (def.rebindable ?? true);
593
+ }
594
+ function isCustom(def) {
595
+ return isRebindable(def) && hasOwn(overrides, def.id);
596
+ }
597
+ function defaultsOf(def) {
598
+ const out = [];
599
+ for (const k of toArray(def.key)) {
600
+ const n = normalizeCombo(k, def.modifiers);
601
+ if (!out.includes(n))
602
+ out.push(n);
603
+ }
604
+ return out;
605
+ }
606
+ function currentOf(def) {
607
+ return isCustom(def) ? [...overrides[def.id]] : defaultsOf(def);
608
+ }
609
+ /** Same physical combo on this platform (`mod` resolved, implied shift dropped). */
610
+ function physical(canonical) {
611
+ const c = parseCombo(canonical);
612
+ if (c.mod) {
613
+ c.mod = false;
614
+ if (platform === 'mac')
615
+ c.meta = true;
616
+ else
617
+ c.ctrl = true;
618
+ }
619
+ if (isSymbolKey(c.key))
620
+ c.shift = false;
621
+ return comboToString(c);
622
+ }
623
+ function resolve(entry) {
624
+ entry.canon = currentOf(entry.def);
625
+ entry.combos = entry.canon.map((c) => parseCombo(c));
626
+ }
627
+ function reresolveAll() {
628
+ for (const e of entries)
629
+ if (e.def.id)
630
+ resolve(e);
631
+ }
632
+ function persist() {
633
+ if (!store || !loaded)
634
+ return;
635
+ try {
636
+ const r = store.save({ ...overrides });
637
+ if (isThenable(r))
638
+ Promise.resolve(r).catch(warnStore);
639
+ }
640
+ catch (err) {
641
+ warnStore(err);
642
+ }
643
+ }
644
+ function applyOverrides(next, { replay }) {
645
+ const hadEdits = replay && (preLoadResetAll || preLoadEdits.size > 0);
646
+ const base = replay && preLoadResetAll ? {} : next;
647
+ if (replay) {
648
+ for (const [id, v] of preLoadEdits) {
649
+ if (v === null)
650
+ delete base[id];
651
+ else
652
+ base[id] = v;
653
+ }
654
+ preLoadEdits.clear();
655
+ preLoadResetAll = false;
656
+ }
657
+ overrides = base;
658
+ loaded = true;
659
+ reresolveAll();
660
+ if (hadEdits)
661
+ persist();
662
+ notify();
663
+ }
664
+ function setOverride(id, keys, defaults) {
665
+ const same = keys.length === defaults.length && keys.every((k) => defaults.includes(k));
666
+ if (same)
667
+ delete overrides[id];
668
+ else
669
+ overrides[id] = keys;
670
+ if (!loaded)
671
+ preLoadEdits.set(id, same ? null : keys);
672
+ }
673
+ function commit() {
674
+ reresolveAll();
675
+ persist();
676
+ notify();
677
+ }
678
+ /** Latest live registration for `id`, else its latest declaration. */
679
+ function sourceOf(id) {
680
+ for (let i = entries.length - 1; i >= 0; i--) {
681
+ if (entries[i].def.id === id)
682
+ return entries[i];
683
+ }
684
+ for (let i = declarations.length - 1; i >= 0; i--) {
685
+ if (declarations[i].def.id === id)
686
+ return declarations[i];
687
+ }
688
+ return undefined;
689
+ }
690
+ function binding(id) {
691
+ const src = sourceOf(id);
692
+ if (!src)
693
+ return undefined;
694
+ const defaults = defaultsOf(src.def);
695
+ const custom = isCustom(src.def);
696
+ return {
697
+ id,
698
+ description: src.def.description,
699
+ context: src.context,
700
+ group: src.def.group,
701
+ defaults,
702
+ current: custom ? [...overrides[id]] : defaults,
703
+ isCustom: custom,
704
+ rebindable: isRebindable(src.def)
705
+ };
706
+ }
707
+ function bindings() {
708
+ const firstSeen = new Map();
709
+ for (const x of [...declarations, ...entries]) {
710
+ const id = x.def.id;
711
+ if (id && (!firstSeen.has(id) || x.seq < firstSeen.get(id)))
712
+ firstSeen.set(id, x.seq);
713
+ }
714
+ return [...firstSeen.entries()]
715
+ .sort((a, b) => a[1] - b[1])
716
+ .map(([id]) => binding(id))
717
+ .filter(Boolean);
718
+ }
719
+ /** Every live definition: registrations, plus declarations no registration covers yet. */
720
+ function units() {
721
+ const live = new Set(entries.filter((e) => e.def.id).map((e) => `${e.def.id}\u0000${e.context}`));
722
+ return [
723
+ ...entries,
724
+ ...declarations
725
+ .filter((d) => !live.has(`${d.def.id}\u0000${d.context}`))
726
+ .map((d) => ({ ...d, canon: currentOf(d.def) }))
727
+ ];
728
+ }
729
+ function contextsOf(id) {
730
+ const out = [];
731
+ for (const x of [...entries, ...declarations]) {
732
+ if (x.def.id === id && !out.includes(x.context))
733
+ out.push(x.context);
734
+ }
735
+ return out;
736
+ }
737
+ /** Can `upper` be on the stack ABOVE `lower`? (`contextGraph`, transitive; `global` is beneath all.) */
738
+ function above(upper, lower) {
739
+ if (upper === lower || upper === GLOBAL)
740
+ return false;
741
+ if (lower === GLOBAL)
742
+ return true;
743
+ const seen = new Set();
744
+ const todo = [...(graph[upper] ?? [])];
745
+ while (todo.length) {
746
+ const c = todo.pop();
747
+ if (c === lower)
748
+ return true;
749
+ if (seen.has(c))
750
+ continue;
751
+ seen.add(c);
752
+ todo.push(...(graph[c] ?? []));
753
+ }
754
+ return false;
755
+ }
756
+ function isModalContext(ctx) {
757
+ return modalContexts.has(ctx);
758
+ }
759
+ /** How a candidate in `cand` relates to a definition in `other` on the same combo. */
760
+ function classify(cand, other) {
761
+ if (cand === other)
762
+ return { kind: 'same-context', severity: 'error' };
763
+ const upper = above(cand, other) ? cand : above(other, cand) ? other : null;
764
+ if (upper === null)
765
+ return null; // never on the stack together
766
+ if (isModalContext(upper))
767
+ return { kind: 'modal-blocked', severity: 'info' };
768
+ return upper === cand ? { kind: 'shadows', severity: 'warning' } : { kind: 'shadowed-by', severity: 'warning' };
769
+ }
770
+ function reservedConflicts(combo) {
771
+ const p = physical(combo);
772
+ return reservedPhysical
773
+ .filter((r) => r.physical === p)
774
+ .map((r) => ({
775
+ combo,
776
+ description: r.reason,
777
+ context: 'browser',
778
+ kind: 'reserved',
779
+ severity: r.severity === 'block' ? 'error' : 'warning'
780
+ }));
781
+ }
782
+ function findConflicts(keys, target = {}) {
783
+ const combos = [];
784
+ for (const k of toArray(keys)) {
785
+ if (validateCombo(k) !== null)
786
+ continue;
787
+ const n = normalizeCombo(k);
788
+ if (!combos.some((c) => physical(c) === physical(n)))
789
+ combos.push(n);
790
+ }
791
+ if (!combos.length)
792
+ return [];
793
+ const known = target.id ? contextsOf(target.id) : [];
794
+ const contexts = target.context ? [target.context] : known.length ? known : [GLOBAL];
795
+ const wanted = new Map(combos.map((c) => [physical(c), c]));
796
+ const out = [];
797
+ const seen = new Set();
798
+ for (const ctx of contexts) {
799
+ for (const u of units()) {
800
+ if (target.id && u.def.id === target.id)
801
+ continue;
802
+ const rel = classify(ctx, u.context);
803
+ if (!rel)
804
+ continue;
805
+ for (const c of u.canon) {
806
+ const combo = wanted.get(physical(c));
807
+ if (combo === undefined)
808
+ continue;
809
+ const key = `${u.def.id ?? `#${u.seq}`}|${u.context}|${combo}|${rel.kind}`;
810
+ if (seen.has(key))
811
+ continue;
812
+ seen.add(key);
813
+ out.push({
814
+ combo,
815
+ ...(u.def.id ? { id: u.def.id } : {}),
816
+ description: u.def.description,
817
+ context: u.context,
818
+ ...rel
819
+ });
820
+ }
821
+ }
822
+ }
823
+ for (const c of combos)
824
+ out.push(...reservedConflicts(c));
825
+ return out;
826
+ }
827
+ const SEVERITY_RANK = { info: 0, warning: 1, error: 2 };
828
+ function allConflicts(opts = {}) {
829
+ const min = SEVERITY_RANK[opts.minSeverity ?? 'info'];
830
+ const all = units().sort((a, b) => a.seq - b.seq);
831
+ const out = [];
832
+ const sourceOfUnit = (u) => ({
833
+ ...(u.def.id ? { id: u.def.id } : {}),
834
+ description: u.def.description,
835
+ context: u.context
836
+ });
837
+ for (let i = 0; i < all.length; i++) {
838
+ const cand = all[i];
839
+ // The candidate is the NEWER of each pair, so it is compared with older units only.
840
+ for (let j = 0; j < i; j++) {
841
+ const other = all[j];
842
+ if (cand.def.id && cand.def.id === other.def.id)
843
+ continue;
844
+ const rel = classify(cand.context, other.context);
845
+ if (!rel || SEVERITY_RANK[rel.severity] < min)
846
+ continue;
847
+ const theirs = new Set(other.canon.map(physical));
848
+ for (const c of cand.canon) {
849
+ if (!theirs.has(physical(c)))
850
+ continue;
851
+ out.push({
852
+ combo: c,
853
+ ...(other.def.id ? { id: other.def.id } : {}),
854
+ description: other.def.description,
855
+ context: other.context,
856
+ ...rel,
857
+ source: sourceOfUnit(cand)
858
+ });
859
+ }
860
+ }
861
+ for (const c of cand.canon) {
862
+ for (const r of reservedConflicts(c)) {
863
+ if (SEVERITY_RANK[r.severity] >= min)
864
+ out.push({ ...r, source: sourceOfUnit(cand) });
865
+ }
866
+ }
867
+ }
868
+ return out;
869
+ }
870
+ function rebind(id, keys, opts = {}) {
871
+ const fail = (error, conflicts = []) => ({ ok: false, error, conflicts });
872
+ const src = sourceOf(id);
873
+ if (!src)
874
+ return fail(`Unknown shortcut id "${id}"`);
875
+ if (!isRebindable(src.def))
876
+ return fail(`"${src.def.description}" can't be rebound`);
877
+ const next = [];
878
+ for (const k of keys === null ? [] : toArray(keys)) {
879
+ const err = validateCombo(k);
880
+ if (err)
881
+ return fail(err);
882
+ const n = normalizeCombo(k);
883
+ if (!next.some((x) => physical(x) === physical(n)))
884
+ next.push(n);
885
+ }
886
+ const conflicts = findConflicts(next, { id });
887
+ const errors = conflicts.filter((c) => c.severity === 'error');
888
+ const mode = opts.onConflict ?? 'reject';
889
+ const displaced = [];
890
+ if (errors.length && mode !== 'allow') {
891
+ // The browser never delivers a blocked combo; no swap can fix that.
892
+ const blocked = errors.find((c) => c.kind === 'reserved');
893
+ if (blocked) {
894
+ return fail(`${formatShortcut(blocked.combo, platform)} is reserved (${blocked.description})`, conflicts);
895
+ }
896
+ const first = errors[0];
897
+ const msg = `${formatShortcut(first.combo, platform)} is already used by "${first.description}"`;
898
+ if (mode === 'reject')
899
+ return fail(msg, conflicts);
900
+ // swap: only possible when every owner is itself rebindable.
901
+ const fixed = errors.find((c) => {
902
+ const owner = c.id ? sourceOf(c.id) : undefined;
903
+ return !owner || !isRebindable(owner.def);
904
+ });
905
+ if (fixed) {
906
+ return fail(`${formatShortcut(fixed.combo, platform)} belongs to "${fixed.description}", which can't be rebound`, conflicts);
907
+ }
908
+ for (const c of errors) {
909
+ const other = binding(c.id);
910
+ const remaining = other.current.filter((k) => physical(k) !== physical(c.combo));
911
+ setOverride(c.id, remaining, other.defaults);
912
+ if (!displaced.includes(c.id))
913
+ displaced.push(c.id);
914
+ }
915
+ }
916
+ setOverride(id, next, defaultsOf(src.def));
917
+ commit();
918
+ const remaining = mode === 'allow' ? conflicts : conflicts.filter((c) => c.severity !== 'error');
919
+ return { ok: true, binding: binding(id), displaced, conflicts: remaining };
920
+ }
921
+ function resetBinding(id) {
922
+ if (loaded && !hasOwn(overrides, id))
923
+ return;
924
+ delete overrides[id];
925
+ if (!loaded)
926
+ preLoadEdits.set(id, null);
927
+ commit();
928
+ }
929
+ function resetAllBindings() {
930
+ overrides = {};
931
+ if (!loaded) {
932
+ preLoadEdits.clear();
933
+ preLoadResetAll = true;
934
+ }
935
+ commit();
936
+ }
937
+ function declare(defs) {
938
+ const added = toArray(defs)
939
+ .filter((def) => !!def.id)
940
+ .map((def) => {
941
+ defaultsOf(def); // throw on a malformed key now, not at render time
942
+ const d = { def, context: def.context ?? GLOBAL, seq: seq++ };
943
+ declarations.push(d);
944
+ return d;
945
+ });
946
+ notify();
947
+ let done = false;
948
+ return () => {
949
+ if (done)
950
+ return;
951
+ done = true;
952
+ for (const d of added) {
953
+ const i = declarations.indexOf(d);
954
+ if (i !== -1)
955
+ declarations.splice(i, 1);
956
+ }
957
+ notify();
958
+ };
959
+ }
960
+ /**
961
+ * The walk order, top first: frames sorted by layer then push order. A
962
+ * modal frame ends the walk unless it has a passthrough list, in which case
963
+ * the contexts beneath are visited with that frame as a filter. A context
964
+ * already visited higher up is not visited again.
965
+ */
966
+ function liveContexts() {
967
+ const frames = [...stack].sort((a, b) => a.layer - b.layer || a.order - b.order);
968
+ const out = [];
969
+ const seen = new Set();
970
+ const filters = [];
971
+ const visit = (name) => {
972
+ if (seen.has(name))
973
+ return;
974
+ seen.add(name);
975
+ out.push({ name, filters: [...filters] });
976
+ };
977
+ for (let i = frames.length - 1; i >= 0; i--) {
978
+ const f = frames[i];
979
+ visit(f.name);
980
+ if (f.modal) {
981
+ if (!f.passIds.size && !f.passPhysical.size)
982
+ return out;
983
+ filters.push(f);
984
+ }
985
+ }
986
+ visit(GLOBAL);
987
+ return out;
988
+ }
989
+ /** Does a definition beneath passthrough modal frames get through all of them? */
990
+ function passes(def, canon, filters) {
991
+ return filters.every((f) => (!!def.id && f.passIds.has(def.id)) || canon.some((c) => f.passPhysical.has(physical(c))));
992
+ }
210
993
  function activeContexts() {
211
- const live = [];
212
- for (let i = stack.length - 1; i >= 0; i--) {
213
- const f = stack[i];
214
- if (!live.includes(f.name))
215
- live.push(f.name);
216
- if (f.modal)
217
- return live;
218
- }
219
- if (!live.includes(GLOBAL))
220
- live.push(GLOBAL);
221
- return live;
994
+ return liveContexts()
995
+ .filter((c) => c.filters.length === 0)
996
+ .map((c) => c.name);
997
+ }
998
+ function registerHandler(handler) {
999
+ const h = { handler, context: handler.context ?? GLOBAL, seq: seq++ };
1000
+ handlers.push(h);
1001
+ notify();
1002
+ let done = false;
1003
+ return () => {
1004
+ if (done)
1005
+ return;
1006
+ done = true;
1007
+ const i = handlers.indexOf(h);
1008
+ if (i !== -1)
1009
+ handlers.splice(i, 1);
1010
+ notify();
1011
+ };
222
1012
  }
223
1013
  function register(shortcuts) {
224
- const defs = Array.isArray(shortcuts) ? shortcuts : [shortcuts];
225
- const added = defs.map((def) => {
226
- const entry = {
227
- def,
228
- combo: parseCombo(def.key, def.modifiers),
229
- context: def.context ?? GLOBAL,
230
- seq: seq++
231
- };
1014
+ const defs = toArray(shortcuts);
1015
+ const batch = [];
1016
+ for (const def of defs) {
1017
+ const entry = { def, canon: [], combos: [], context: def.context ?? GLOBAL, seq: 0 };
1018
+ resolve(entry); // throws on a malformed key before anything is added
1019
+ if (def.id) {
1020
+ const dup = [...entries, ...batch].some((e) => e.def.id === def.id && e.context === entry.context);
1021
+ if (dup) {
1022
+ const msg = `keyboardShortcutRegistry: duplicate shortcut id "${def.id}" in context "${entry.context}"`;
1023
+ if (dev)
1024
+ throw new Error(msg);
1025
+ console.warn(`${msg} — the later registration wins`);
1026
+ }
1027
+ }
1028
+ batch.push(entry);
1029
+ }
1030
+ for (const entry of batch) {
1031
+ entry.seq = seq++;
232
1032
  entries.push(entry);
233
- return entry;
234
- });
1033
+ }
235
1034
  notify();
236
1035
  let done = false;
237
1036
  return () => {
238
1037
  if (done)
239
1038
  return;
240
1039
  done = true;
241
- for (const e of added) {
1040
+ for (const e of batch) {
242
1041
  const i = entries.indexOf(e);
243
1042
  if (i !== -1)
244
1043
  entries.splice(i, 1);
@@ -247,9 +1046,27 @@ export function createShortcutRegistry(options = {}) {
247
1046
  };
248
1047
  }
249
1048
  function pushContext(name, opts = {}) {
250
- const frame = { name, modal: !!opts.modal, token: {} };
1049
+ const passIds = new Set();
1050
+ const passPhysical = new Set();
1051
+ if (opts.modal) {
1052
+ for (const p of opts.passthrough ?? []) {
1053
+ passIds.add(p);
1054
+ if (validateCombo(p) === null)
1055
+ passPhysical.add(physical(normalizeCombo(p)));
1056
+ }
1057
+ }
1058
+ const frame = {
1059
+ name,
1060
+ modal: !!opts.modal,
1061
+ layer: opts.layer ?? 0,
1062
+ order: pushSeq++,
1063
+ passIds,
1064
+ passPhysical
1065
+ };
251
1066
  if (opts.label)
252
1067
  labels[name] = opts.label;
1068
+ if (frame.modal)
1069
+ modalContexts.add(name);
253
1070
  stack.push(frame);
254
1071
  notify();
255
1072
  return () => {
@@ -262,18 +1079,48 @@ export function createShortcutRegistry(options = {}) {
262
1079
  notify();
263
1080
  };
264
1081
  }
1082
+ function trackModifiers(next) {
1083
+ const cb = options.onModifierChange;
1084
+ if (!cb)
1085
+ return;
1086
+ const prev = modifiers;
1087
+ if (prev.alt === next.alt && prev.ctrl === next.ctrl && prev.shift === next.shift && prev.meta === next.meta) {
1088
+ return;
1089
+ }
1090
+ modifiers = next;
1091
+ try {
1092
+ cb({ ...next });
1093
+ }
1094
+ catch (err) {
1095
+ console.error('keyboardShortcutRegistry: onModifierChange failed', err);
1096
+ }
1097
+ }
1098
+ function modifiersOf(event) {
1099
+ return { alt: !!event.altKey, ctrl: !!event.ctrlKey, shift: !!event.shiftKey, meta: !!event.metaKey };
1100
+ }
1101
+ /** Physical identity of a key for release matching; `event.key` when `code` is absent (synthetic events). */
1102
+ function releaseKey(event) {
1103
+ return event.code ? `code:${event.code}` : `key:${normalizeKey(event.key ?? '')}`;
1104
+ }
1105
+ /** A raw handler error is reported through `onError` with a stand-in definition. */
1106
+ function handlerAsDefinition(h) {
1107
+ return { key: [], description: 'Raw shortcut handler', context: h.context, action: () => { } };
1108
+ }
265
1109
  function handleKeydown(event) {
1110
+ trackModifiers(modifiersOf(event));
266
1111
  if (event.defaultPrevented || event.isComposing || event.key === 'Process')
267
1112
  return false;
268
1113
  const path = typeof event.composedPath === 'function' ? event.composedPath() : [];
269
- const typing = isTypingTarget(path[0] ?? event.target);
270
- for (const ctx of activeContexts()) {
1114
+ const typing = typingTest(path[0] ?? event.target ?? null, event);
1115
+ for (const { name: ctx, filters } of liveContexts()) {
271
1116
  // Later registrations shadow earlier ones within a context.
272
1117
  for (let i = entries.length - 1; i >= 0; i--) {
273
1118
  const e = entries[i];
274
1119
  if (e.context !== ctx)
275
1120
  continue;
276
- if (!matches(event, e.combo, platform))
1121
+ if (!e.combos.some((c) => matches(event, c, platform)))
1122
+ continue;
1123
+ if (filters.length && !passes(e.def, e.canon, filters))
277
1124
  continue;
278
1125
  if (typing && !e.def.allowInInput)
279
1126
  continue;
@@ -283,6 +1130,8 @@ export function createShortcutRegistry(options = {}) {
283
1130
  continue;
284
1131
  if (e.def.preventDefault !== false)
285
1132
  event.preventDefault();
1133
+ if (e.def.onRelease)
1134
+ pendingReleases.set(releaseKey(event), e.def);
286
1135
  try {
287
1136
  const result = e.def.action(event);
288
1137
  if (result && typeof result.catch === 'function') {
@@ -294,67 +1143,212 @@ export function createShortcutRegistry(options = {}) {
294
1143
  }
295
1144
  return true;
296
1145
  }
1146
+ // Raw handlers: after the context's combos, never through a modal.
1147
+ if (filters.length)
1148
+ continue;
1149
+ for (let i = handlers.length - 1; i >= 0; i--) {
1150
+ const h = handlers[i];
1151
+ if (h.context !== ctx)
1152
+ continue;
1153
+ if (typing && !h.handler.allowInInput)
1154
+ continue;
1155
+ let handled = false;
1156
+ try {
1157
+ handled = h.handler.handle(event) === true;
1158
+ }
1159
+ catch (err) {
1160
+ onError(err, handlerAsDefinition(h));
1161
+ }
1162
+ if (!handled)
1163
+ continue;
1164
+ if (h.handler.preventDefault !== false)
1165
+ event.preventDefault();
1166
+ return true;
1167
+ }
297
1168
  }
298
1169
  return false;
299
1170
  }
1171
+ function handleKeyup(event) {
1172
+ trackModifiers(modifiersOf(event));
1173
+ const k = releaseKey(event);
1174
+ const def = pendingReleases.get(k);
1175
+ if (!def)
1176
+ return false;
1177
+ pendingReleases.delete(k);
1178
+ try {
1179
+ def.onRelease?.(event);
1180
+ }
1181
+ catch (err) {
1182
+ onError(err, def);
1183
+ }
1184
+ return true;
1185
+ }
1186
+ function handleBlur() {
1187
+ pendingReleases.clear();
1188
+ trackModifiers({ alt: false, ctrl: false, shift: false, meta: false });
1189
+ }
300
1190
  function attach(target) {
301
1191
  const t = target ?? (typeof window !== 'undefined' ? window : null);
302
1192
  if (!t)
303
1193
  return () => { };
304
- const listener = (e) => {
1194
+ const onDown = (e) => {
305
1195
  handleKeydown(e);
306
1196
  };
307
- t.addEventListener('keydown', listener);
308
- return () => t.removeEventListener('keydown', listener);
1197
+ const onUp = (e) => {
1198
+ handleKeyup(e);
1199
+ };
1200
+ const onBlur = () => handleBlur();
1201
+ // keydown last: a pre-A3 custom target that ignores `type` keeps the keydown.
1202
+ t.addEventListener('keyup', onUp);
1203
+ t.addEventListener('blur', onBlur);
1204
+ t.addEventListener('keydown', onDown);
1205
+ return () => {
1206
+ t.removeEventListener('keyup', onUp);
1207
+ t.removeEventListener('blur', onBlur);
1208
+ t.removeEventListener('keydown', onDown);
1209
+ };
1210
+ }
1211
+ /** Overlay keycap for a definition's live binding; null when unbound. */
1212
+ function labelOf(def, canon) {
1213
+ if (!canon.length)
1214
+ return null;
1215
+ if (def.label && !isCustom(def))
1216
+ return def.label;
1217
+ return canon.map((c) => formatCombo(parseCombo(c), platform)).join(' / ');
309
1218
  }
310
1219
  function toSections(opts = {}) {
1220
+ // Registrations, plus (opt-in) declarations nothing has registered yet.
1221
+ const pool = opts.includeDeclared ? units().sort((a, b) => a.seq - b.seq) : entries;
311
1222
  const contexts = opts.scope === 'all'
312
- ? [...new Set(entries.map((e) => e.context))].sort((a, b) => a === GLOBAL ? 1 : b === GLOBAL ? -1 : 0)
313
- : activeContexts();
1223
+ ? [...new Set([...pool.map((e) => e.context), ...handlers.map((h) => h.context)])]
1224
+ .sort((a, b) => (a === GLOBAL ? 1 : b === GLOBAL ? -1 : 0))
1225
+ .map((name) => ({ name, filters: [] }))
1226
+ : liveContexts();
314
1227
  const sections = new Map();
1228
+ const sectionFor = (title) => {
1229
+ let section = sections.get(title);
1230
+ if (!section) {
1231
+ section = { title, shortcuts: [] };
1232
+ sections.set(title, section);
1233
+ }
1234
+ return section;
1235
+ };
315
1236
  const seen = new Set();
316
- for (const ctx of contexts) {
1237
+ for (const { name: ctx, filters } of contexts) {
317
1238
  // Walk newest-first so a shadowed combo (same context, or a lower
318
1239
  // context) is the one dropped, then restore registration order.
319
1240
  const kept = [];
320
- for (let i = entries.length - 1; i >= 0; i--) {
321
- const e = entries[i];
1241
+ for (let i = pool.length - 1; i >= 0; i--) {
1242
+ const e = pool[i];
322
1243
  if (e.context !== ctx || e.def.hidden)
323
1244
  continue;
1245
+ if (filters.length && !passes(e.def, e.canon, filters))
1246
+ continue;
324
1247
  if (opts.onlyEnabled && e.def.enabled && !e.def.enabled())
325
1248
  continue;
326
- const comboId = JSON.stringify(e.combo);
327
- if (opts.scope !== 'all' && seen.has(comboId))
1249
+ const label = labelOf(e.def, e.canon);
1250
+ if (label === null)
1251
+ continue;
1252
+ const ids = e.canon.map(physical);
1253
+ if (opts.scope !== 'all' && ids.every((id) => seen.has(id)))
328
1254
  continue;
329
- seen.add(comboId);
330
- kept.unshift(e);
1255
+ for (const id of ids)
1256
+ seen.add(id);
1257
+ kept.unshift({ e, label });
331
1258
  }
332
- for (const e of kept) {
333
- const title = e.def.group ?? labels[ctx] ?? ctx;
334
- let section = sections.get(title);
335
- if (!section) {
336
- section = { title, shortcuts: [] };
337
- sections.set(title, section);
1259
+ for (const { e, label } of kept) {
1260
+ sectionFor(e.def.group ?? labels[ctx] ?? ctx).shortcuts.push({ key: label, description: e.def.description });
1261
+ }
1262
+ if (filters.length)
1263
+ continue;
1264
+ for (const h of handlers) {
1265
+ if (h.context !== ctx || !h.handler.describe)
1266
+ continue;
1267
+ let rows = [];
1268
+ try {
1269
+ rows = h.handler.describe() ?? [];
1270
+ }
1271
+ catch (err) {
1272
+ console.error('keyboardShortcutRegistry: raw handler describe() failed', err);
1273
+ }
1274
+ for (const row of rows) {
1275
+ sectionFor(row.group ?? labels[ctx] ?? ctx).shortcuts.push({ key: row.key, description: row.description });
338
1276
  }
339
- section.shortcuts.push({ key: formatShortcut(e.def, platform), description: e.def.description });
340
1277
  }
341
1278
  }
342
1279
  return [...sections.values()];
343
1280
  }
1281
+ function format(s) {
1282
+ if (typeof s === 'string' || !s.id)
1283
+ return formatShortcut(s, platform);
1284
+ return labelOf(s, currentOf(s)) ?? '';
1285
+ }
1286
+ function formatBinding(id) {
1287
+ const src = sourceOf(id);
1288
+ if (!src)
1289
+ return null;
1290
+ return labelOf(src.def, currentOf(src.def));
1291
+ }
1292
+ // Load the store last, once everything it re-resolves exists.
1293
+ let ready = Promise.resolve();
1294
+ if (store) {
1295
+ let result;
1296
+ try {
1297
+ result = store.load();
1298
+ }
1299
+ catch (err) {
1300
+ warnStore(err);
1301
+ result = {};
1302
+ }
1303
+ if (isThenable(result)) {
1304
+ ready = Promise.resolve(result).then((o) => applyOverrides(sanitizeOverrides(o, rejected.add), { replay: true }), (err) => {
1305
+ warnStore(err);
1306
+ applyOverrides({}, { replay: true });
1307
+ });
1308
+ }
1309
+ else {
1310
+ applyOverrides(sanitizeOverrides(result, rejected.add), { replay: true });
1311
+ }
1312
+ store.subscribe?.((o) => applyOverrides(sanitizeOverrides(o, rejected.add), { replay: !loaded }));
1313
+ }
344
1314
  return {
345
1315
  register,
346
1316
  pushContext,
347
1317
  activeContexts,
348
1318
  attach,
349
1319
  handleKeydown,
1320
+ handleKeyup,
1321
+ handleBlur,
1322
+ registerHandler,
1323
+ formatBinding,
350
1324
  toSections,
351
1325
  list: () => [...entries].sort((a, b) => a.seq - b.seq).map((e) => e.def),
352
- format: (s) => formatShortcut(s, platform),
1326
+ format,
353
1327
  subscribe(listener) {
354
1328
  listeners.add(listener);
355
1329
  return () => listeners.delete(listener);
356
1330
  },
357
- platform
1331
+ platform,
1332
+ ready,
1333
+ bindings,
1334
+ binding,
1335
+ rebind,
1336
+ resetBinding,
1337
+ resetAllBindings,
1338
+ findConflicts,
1339
+ allConflicts,
1340
+ rejectedBindings() {
1341
+ const fromStore = (() => {
1342
+ try {
1343
+ return store?.rejected?.() ?? [];
1344
+ }
1345
+ catch {
1346
+ return [];
1347
+ }
1348
+ })();
1349
+ return [...fromStore, ...rejected.list()];
1350
+ },
1351
+ declare
358
1352
  };
359
1353
  }
360
1354
  let defaultRegistry = null;