@pyreon/hotkeys 0.44.0 → 0.46.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.
package/README.md CHANGED
@@ -58,9 +58,10 @@ For non-component use (one-off registration, dynamic shortcuts read from setting
58
58
  | Function | Notes |
59
59
  |---|---|
60
60
  | `registerHotkey(shortcut, handler, options?)` | Returns an `unregister()` function — call manually |
61
- | `enableScope(scope)` / `disableScope(scope)` | Imperative scope control |
61
+ | `enableScope(scope)` / `disableScope(scope)` | Imperative scope control — reference-counted acquire/release |
62
62
  | `getActiveScopes()` | Currently active scope names |
63
- | `getRegisteredHotkeys(): HotkeyEntry[]` | All registered shortcuts — useful for help dialogs |
63
+ | `getRegisteredHotkeys()` | All registered shortcuts — useful for help dialogs |
64
+ | `getHotkeyConflicts()` | Shortcuts that collide (same combo, same scope) — useful for audits/settings UIs |
64
65
 
65
66
  ## Options
66
67
 
@@ -85,6 +86,32 @@ Modifiers: `ctrl` / `control`, `shift`, `alt`, `meta` / `cmd` / `command`, `mod`
85
86
 
86
87
  Key aliases: `esc` → `escape`, `return` → `enter`, `del` → `delete`, `ins` → `insert`, `space` / `spacebar` → ` `, `up` / `down` / `left` / `right` → `arrowup` / `arrowdown` / …, `plus` → `+`.
87
88
 
89
+ **Sequential combos** — space-separated combos fire in order, Gmail/vim-style. `'g t'` fires when the user presses `g` then `t` within one second; `'ctrl+k p'` works too (each step is a full combo). A stranded prefix times out after 1 s.
90
+
91
+ ```ts
92
+ useHotkey('g t', () => goToTop())
93
+ useHotkey('g n', () => goToNotifications())
94
+ ```
95
+
96
+ **Shifted symbols** — bind a single symbol directly. `?` fires on the real `Shift+/` keystroke (the canonical "show help" shortcut) — the produced character encodes the shift, so you never write `shift+?`, and `/` and `?` stay distinct.
97
+
98
+ ```ts
99
+ useHotkey('?', () => openHelp())
100
+ ```
101
+
102
+ ## Conflict detection
103
+
104
+ `getHotkeyConflicts()` returns registered shortcuts that would fire on the **same keystroke within the same scope**. Matching is on the *parsed* combo, so aliased duplicates (`ctrl+s` vs `control+s`, `mod+s` vs `ctrl+s` off Mac) are caught. Cross-scope overlaps are intentional layering and are not reported.
105
+
106
+ ```ts
107
+ import { getHotkeyConflicts } from '@pyreon/hotkeys'
108
+
109
+ // In a dev-only "keyboard audit" panel or a test assertion:
110
+ for (const c of getHotkeyConflicts()) {
111
+ console.warn(`Conflict in "${c.scope}": ${c.shortcuts.join(', ')}`)
112
+ }
113
+ ```
114
+
88
115
  ## Parsing utilities
89
116
 
90
117
  For building custom UIs (settings panels, help overlays):
@@ -117,11 +144,14 @@ Clears every registered hotkey and active scope. Underscore-prefixed because it'
117
144
  ## Gotchas
118
145
 
119
146
  - **Scopes are NOT hierarchical** — activating `'editor'` does not implicitly activate `'editor/code'`. A hotkey fires only when its exact scope string is active.
147
+ - **Scopes are reference-counted** — two components that both activate `'editor'` keep it active until BOTH release it. `enableScope`/`disableScope` are acquire/release; pair them evenly.
120
148
  - **`'global'` is the default scope** and is always active. A hotkey with no `scope` option fires whenever the global scope is active (which is always, unless you disable it).
121
- - **Multiple scopes can fire simultaneously** — if both `'modal'` and `'global'` are active and both have a `'mod+s'` binding, both handlers fire. Use `stopPropagation: true` or different scopes to disambiguate.
122
- - **`enableOnInputs: true` is required** to let users trigger shortcuts while typing — by default the listener checks `document.activeElement` and bails on form fields / `contenteditable`.
149
+ - **Multiple scopes can fire simultaneously** — if both `'modal'` and `'global'` are active and both have a `'mod+s'` binding, both handlers fire. Use `stopPropagation: true` or different scopes to disambiguate; `getHotkeyConflicts()` surfaces same-scope duplicates.
150
+ - **`enableOnInputs: true` is required** to let users trigger shortcuts while typing — by default the listener checks the event target and bails on `<input>` / `<textarea>` / `<select>` / `contenteditable`.
151
+ - **Bind shifted symbols directly** — write `?` (not `shift+?`) for a help shortcut; a single symbol key already implies shift, so `?` fires on the real `Shift+/` keystroke.
123
152
  - **`enabled` is re-evaluated on every dispatch** — pass a function for reactive gating; a static `false` is equivalent to never registering.
124
153
  - **The hotkey listener attaches to `window`** at first registration and detaches when the last hotkey is removed (`_resetHotkeys` or every `unregister()` called).
154
+ - **SSR-safe** — registration and scope activation are no-ops on the server (no shared-state bleed across requests). `getRegisteredHotkeys()` is client-runtime state; build server-rendered help panels from a static config.
125
155
 
126
156
  ## Documentation
127
157
 
@@ -5386,7 +5386,7 @@ var drawChart = (function (exports) {
5386
5386
  </script>
5387
5387
  <script>
5388
5388
  /*<!--*/
5389
- const data = {"version":2,"tree":{"name":"root","children":[{"name":"index.js","children":[{"uid":"e49e8376-1","name":"package.json"},{"name":"src","children":[{"uid":"e49e8376-3","name":"parse.ts"},{"uid":"e49e8376-5","name":"registry.ts"},{"uid":"e49e8376-7","name":"use-hotkey.ts"},{"uid":"e49e8376-9","name":"use-hotkey-scope.ts"},{"uid":"e49e8376-11","name":"index.ts"}]}]}],"isRoot":true},"nodeParts":{"e49e8376-1":{"renderedLength":90,"gzipLength":100,"brotliLength":0,"metaUid":"e49e8376-0"},"e49e8376-3":{"renderedLength":1941,"gzipLength":838,"brotliLength":0,"metaUid":"e49e8376-2"},"e49e8376-5":{"renderedLength":5093,"gzipLength":1657,"brotliLength":0,"metaUid":"e49e8376-4"},"e49e8376-7":{"renderedLength":496,"gzipLength":295,"brotliLength":0,"metaUid":"e49e8376-6"},"e49e8376-9":{"renderedLength":421,"gzipLength":264,"brotliLength":0,"metaUid":"e49e8376-8"},"e49e8376-11":{"renderedLength":760,"gzipLength":452,"brotliLength":0,"metaUid":"e49e8376-10"}},"nodeMetas":{"e49e8376-0":{"id":"/package.json","moduleParts":{"index.js":"e49e8376-1"},"imported":[],"importedBy":[{"uid":"e49e8376-10"}]},"e49e8376-2":{"id":"/src/parse.ts","moduleParts":{"index.js":"e49e8376-3"},"imported":[],"importedBy":[{"uid":"e49e8376-10"},{"uid":"e49e8376-4"}]},"e49e8376-4":{"id":"/src/registry.ts","moduleParts":{"index.js":"e49e8376-5"},"imported":[{"uid":"e49e8376-12"},{"uid":"e49e8376-2"}],"importedBy":[{"uid":"e49e8376-10"},{"uid":"e49e8376-6"},{"uid":"e49e8376-8"}]},"e49e8376-6":{"id":"/src/use-hotkey.ts","moduleParts":{"index.js":"e49e8376-7"},"imported":[{"uid":"e49e8376-13"},{"uid":"e49e8376-4"}],"importedBy":[{"uid":"e49e8376-10"}]},"e49e8376-8":{"id":"/src/use-hotkey-scope.ts","moduleParts":{"index.js":"e49e8376-9"},"imported":[{"uid":"e49e8376-13"},{"uid":"e49e8376-4"}],"importedBy":[{"uid":"e49e8376-10"}]},"e49e8376-10":{"id":"/src/index.ts","moduleParts":{"index.js":"e49e8376-11"},"imported":[{"uid":"e49e8376-0"},{"uid":"e49e8376-12"},{"uid":"e49e8376-6"},{"uid":"e49e8376-8"},{"uid":"e49e8376-4"},{"uid":"e49e8376-2"}],"importedBy":[],"isEntry":true},"e49e8376-12":{"id":"@pyreon/reactivity","moduleParts":{},"imported":[],"importedBy":[{"uid":"e49e8376-10"},{"uid":"e49e8376-4"}]},"e49e8376-13":{"id":"@pyreon/core","moduleParts":{},"imported":[],"importedBy":[{"uid":"e49e8376-6"},{"uid":"e49e8376-8"}]}},"env":{"rollup":"4.23.0"},"options":{"gzip":true,"brotli":false,"sourcemap":false}};
5389
+ const data = {"version":2,"tree":{"name":"root","children":[{"name":"index.js","children":[{"uid":"e6e94637-1","name":"package.json"},{"name":"src","children":[{"uid":"e6e94637-3","name":"parse.ts"},{"uid":"e6e94637-5","name":"registry.ts"},{"uid":"e6e94637-7","name":"use-hotkey.ts"},{"uid":"e6e94637-9","name":"use-hotkey-scope.ts"},{"uid":"e6e94637-11","name":"index.ts"}]}]}],"isRoot":true},"nodeParts":{"e6e94637-1":{"renderedLength":90,"gzipLength":101,"brotliLength":0,"metaUid":"e6e94637-0"},"e6e94637-3":{"renderedLength":2863,"gzipLength":1296,"brotliLength":0,"metaUid":"e6e94637-2"},"e6e94637-5":{"renderedLength":7972,"gzipLength":2759,"brotliLength":0,"metaUid":"e6e94637-4"},"e6e94637-7":{"renderedLength":496,"gzipLength":295,"brotliLength":0,"metaUid":"e6e94637-6"},"e6e94637-9":{"renderedLength":421,"gzipLength":264,"brotliLength":0,"metaUid":"e6e94637-8"},"e6e94637-11":{"renderedLength":760,"gzipLength":452,"brotliLength":0,"metaUid":"e6e94637-10"}},"nodeMetas":{"e6e94637-0":{"id":"/package.json","moduleParts":{"index.js":"e6e94637-1"},"imported":[],"importedBy":[{"uid":"e6e94637-10"}]},"e6e94637-2":{"id":"/src/parse.ts","moduleParts":{"index.js":"e6e94637-3"},"imported":[],"importedBy":[{"uid":"e6e94637-10"},{"uid":"e6e94637-4"}]},"e6e94637-4":{"id":"/src/registry.ts","moduleParts":{"index.js":"e6e94637-5"},"imported":[{"uid":"e6e94637-12"},{"uid":"e6e94637-2"}],"importedBy":[{"uid":"e6e94637-10"},{"uid":"e6e94637-6"},{"uid":"e6e94637-8"}]},"e6e94637-6":{"id":"/src/use-hotkey.ts","moduleParts":{"index.js":"e6e94637-7"},"imported":[{"uid":"e6e94637-13"},{"uid":"e6e94637-4"}],"importedBy":[{"uid":"e6e94637-10"}]},"e6e94637-8":{"id":"/src/use-hotkey-scope.ts","moduleParts":{"index.js":"e6e94637-9"},"imported":[{"uid":"e6e94637-13"},{"uid":"e6e94637-4"}],"importedBy":[{"uid":"e6e94637-10"}]},"e6e94637-10":{"id":"/src/index.ts","moduleParts":{"index.js":"e6e94637-11"},"imported":[{"uid":"e6e94637-0"},{"uid":"e6e94637-12"},{"uid":"e6e94637-6"},{"uid":"e6e94637-8"},{"uid":"e6e94637-4"},{"uid":"e6e94637-2"}],"importedBy":[],"isEntry":true},"e6e94637-12":{"id":"@pyreon/reactivity","moduleParts":{},"imported":[],"importedBy":[{"uid":"e6e94637-10"},{"uid":"e6e94637-4"}]},"e6e94637-13":{"id":"@pyreon/core","moduleParts":{},"imported":[],"importedBy":[{"uid":"e6e94637-6"},{"uid":"e6e94637-8"}]}},"env":{"rollup":"4.23.0"},"options":{"gzip":true,"brotli":false,"sourcemap":false}};
5390
5390
 
5391
5391
  const run = () => {
5392
5392
  const width = window.innerWidth;
package/lib/index.js CHANGED
@@ -3,7 +3,7 @@ import { onUnmount } from "@pyreon/core";
3
3
 
4
4
  //#region package.json
5
5
  var name = "@pyreon/hotkeys";
6
- var version = "0.44.0";
6
+ var version = "0.46.0";
7
7
 
8
8
  //#endregion
9
9
  //#region src/parse.ts
@@ -46,14 +46,36 @@ function parseShortcut(shortcut) {
46
46
  return combo;
47
47
  }
48
48
  /**
49
- * Check if a KeyboardEvent matches a KeyCombo.
49
+ * A single-character key whose typing inherently requires Shift on a standard
50
+ * layout (`?`, `!`, `@`, `+`, `<`, `:`, …). Letters, digits, and space are NOT
51
+ * symbols. `combo.key` is already lower-cased at parse time.
50
52
  */
51
- function matchesCombo(event, combo) {
53
+ function isSymbolKey(key) {
54
+ return key.length === 1 && !/[a-z0-9 ]/.test(key);
55
+ }
56
+ /**
57
+ * Match core shared by {@link matchesCombo} and the dispatch hot path, taking a
58
+ * PRE-LOWERCASED `event.key` so the per-keydown dispatch loop computes
59
+ * `toLowerCase()` once instead of once per registered entry. Key is checked
60
+ * FIRST — it's the most selective field, so a non-matching keystroke rejects
61
+ * before touching the modifier bits. Not exported from the package.
62
+ *
63
+ * @internal
64
+ */
65
+ function matchesComboWithKey(event, combo, eventKey) {
66
+ if (eventKey !== combo.key) return false;
52
67
  if (event.ctrlKey !== combo.ctrl) return false;
53
- if (event.shiftKey !== combo.shift) return false;
54
68
  if (event.altKey !== combo.alt) return false;
55
69
  if (event.metaKey !== combo.meta) return false;
56
- return event.key.toLowerCase() === combo.key;
70
+ if (isSymbolKey(combo.key)) return true;
71
+ return event.shiftKey === combo.shift;
72
+ }
73
+ /**
74
+ * Check if a KeyboardEvent matches a KeyCombo. See {@link matchesComboWithKey}
75
+ * for the Shift/symbol semantics.
76
+ */
77
+ function matchesCombo(event, combo) {
78
+ return matchesComboWithKey(event, combo, event.key.toLowerCase());
57
79
  }
58
80
  /**
59
81
  * Format a KeyCombo back to a human-readable string.
@@ -81,6 +103,7 @@ const entries = [];
81
103
  const activeScopes = signal(/* @__PURE__ */ new Set(["global"]));
82
104
  let listenerAttached = false;
83
105
  let keydownHandler = null;
106
+ const scopeRefcounts = /* @__PURE__ */ new Map();
84
107
  const SEQUENCE_TIMEOUT_MS = 1e3;
85
108
  let pending = [];
86
109
  let pendingTimer = null;
@@ -113,6 +136,8 @@ function attachListener() {
113
136
  listenerAttached = true;
114
137
  keydownHandler = (event) => {
115
138
  const scopes = activeScopes.peek();
139
+ const eventKey = event.key.toLowerCase();
140
+ const inInput = isInputFocused(event);
116
141
  if (pending.length > 0) {
117
142
  const surviving = [];
118
143
  let fired = false;
@@ -120,7 +145,7 @@ function attachListener() {
120
145
  const expected = p.entry.sequence[p.next];
121
146
  /* v8 ignore next */
122
147
  if (!expected) continue;
123
- if (!matchesCombo(event, expected)) continue;
148
+ if (!matchesComboWithKey(event, expected, eventKey)) continue;
124
149
  if (p.next + 1 === p.entry.sequence.length) {
125
150
  if (p.entry.options.preventDefault) event.preventDefault();
126
151
  if (p.entry.options.stopPropagation) event.stopPropagation();
@@ -141,10 +166,10 @@ function attachListener() {
141
166
  }
142
167
  const newPending = [];
143
168
  for (const entry of entries) {
169
+ if (!matchesComboWithKey(event, entry.combo, eventKey)) continue;
144
170
  if (!scopes.has(entry.options.scope)) continue;
171
+ if (!entry.options.enableOnInputs && inInput) continue;
145
172
  if (!(typeof entry.options.enabled === "function" ? entry.options.enabled() : entry.options.enabled)) continue;
146
- if (!entry.options.enableOnInputs && isInputFocused(event)) continue;
147
- if (!matchesCombo(event, entry.combo)) continue;
148
173
  if (entry.sequence.length === 0) {
149
174
  if (entry.options.preventDefault) event.preventDefault();
150
175
  if (entry.options.stopPropagation) event.stopPropagation();
@@ -182,6 +207,7 @@ function detachListener() {
182
207
  */
183
208
  function registerHotkey(shortcut, handler, options) {
184
209
  attachListener();
210
+ if (isServer) return () => {};
185
211
  const subShortcuts = shortcut.split(/\s+/).map((s) => s.trim()).filter(Boolean);
186
212
  if (subShortcuts.length === 0) throw new Error(`[@pyreon/hotkeys] empty shortcut: ${JSON.stringify(shortcut)}`);
187
213
  const combos = subShortcuts.map(parseShortcut);
@@ -210,23 +236,38 @@ function registerHotkey(shortcut, handler, options) {
210
236
  };
211
237
  }
212
238
  /**
213
- * Activate a hotkey scope. 'global' is always active.
239
+ * Activate a hotkey scope (acquire). Reference-counted the scope becomes
240
+ * active on the first acquire and stays active until every acquire is matched
241
+ * by a {@link disableScope} release. `'global'` is always active. No-op on the
242
+ * server (scope state is client-runtime and must not bleed across requests).
214
243
  */
215
244
  function enableScope(scope) {
216
- const current = activeScopes.peek();
217
- if (current.has(scope)) return;
218
- const next = new Set(current);
245
+ if (isServer) return;
246
+ if (scope === "global") return;
247
+ const count = scopeRefcounts.get(scope) ?? 0;
248
+ scopeRefcounts.set(scope, count + 1);
249
+ if (count > 0) return;
250
+ const next = new Set(activeScopes.peek());
219
251
  next.add(scope);
220
252
  activeScopes.set(next);
221
253
  }
222
254
  /**
223
- * Deactivate a hotkey scope. Cannot deactivate 'global'.
255
+ * Deactivate a hotkey scope (release). Reference-counted the scope only
256
+ * deactivates once every {@link enableScope} acquire has been released. Cannot
257
+ * deactivate `'global'`. Releasing an inactive scope is a no-op (the count
258
+ * clamps at zero). No-op on the server.
224
259
  */
225
260
  function disableScope(scope) {
261
+ if (isServer) return;
226
262
  if (scope === "global") return;
227
- const current = activeScopes.peek();
228
- if (!current.has(scope)) return;
229
- const next = new Set(current);
263
+ const count = scopeRefcounts.get(scope) ?? 0;
264
+ if (count === 0) return;
265
+ if (count > 1) {
266
+ scopeRefcounts.set(scope, count - 1);
267
+ return;
268
+ }
269
+ scopeRefcounts.delete(scope);
270
+ const next = new Set(activeScopes.peek());
230
271
  next.delete(scope);
231
272
  activeScopes.set(next);
232
273
  }
@@ -246,9 +287,62 @@ function getRegisteredHotkeys() {
246
287
  ...e.options.description != null ? { description: e.options.description } : {}
247
288
  }));
248
289
  }
290
+ /** Deterministic signature for one parsed combo (modifiers + key). */
291
+ function comboSignature(c) {
292
+ return `${c.ctrl ? "c" : ""}${c.alt ? "a" : ""}${c.shift ? "s" : ""}${c.meta ? "m" : ""}:${c.key}`;
293
+ }
294
+ /**
295
+ * Full-sequence signature for an entry. Two entries with the SAME signature
296
+ * resolve to the same keystroke(s) — so `'ctrl+s'` and `'control+s'`, or (on a
297
+ * non-Mac) `'mod+s'` and `'ctrl+s'`, share a signature and are flagged as
298
+ * conflicting despite different source strings.
299
+ */
300
+ function entrySignature(e) {
301
+ return [e.combo, ...e.sequence].map(comboSignature).join(" ");
302
+ }
303
+ /**
304
+ * Detect hotkeys that would fire on the SAME keystroke within the SAME scope —
305
+ * i.e. registered shortcuts whose parsed combo sequence is identical. Because
306
+ * matching happens on the parsed combo (not the source string), aliased
307
+ * duplicates (`'ctrl+s'` vs `'control+s'`, `'mod+s'` vs `'ctrl+s'` off Mac)
308
+ * are caught too.
309
+ *
310
+ * Cross-scope overlaps are NOT reported — a `'mod+s'` in `'global'` and one in
311
+ * `'editor'` is intentional scope LAYERING, not a conflict. Use this for a
312
+ * "keyboard shortcut audit" panel, a dev-time assertion, or a settings UI that
313
+ * warns on duplicate bindings.
314
+ *
315
+ * @example
316
+ * ```ts
317
+ * registerHotkey('ctrl+s', saveA)
318
+ * registerHotkey('control+s', saveB) // same combo, same (global) scope
319
+ * getHotkeyConflicts()
320
+ * // → [{ scope: 'global', shortcuts: ['ctrl+s', 'control+s'], descriptions: [undefined, undefined] }]
321
+ * ```
322
+ */
323
+ function getHotkeyConflicts() {
324
+ const groups = /* @__PURE__ */ new Map();
325
+ for (const e of entries) {
326
+ const key = `${e.options.scope}\u0000${entrySignature(e)}`;
327
+ const g = groups.get(key);
328
+ if (g) g.push(e);
329
+ else groups.set(key, [e]);
330
+ }
331
+ const conflicts = [];
332
+ for (const g of groups.values()) {
333
+ if (g.length < 2) continue;
334
+ conflicts.push({
335
+ scope: g[0].options.scope,
336
+ shortcuts: g.map((e) => e.shortcut),
337
+ descriptions: g.map((e) => e.options.description)
338
+ });
339
+ }
340
+ return conflicts;
341
+ }
249
342
  function _resetHotkeys() {
250
343
  entries.length = 0;
251
344
  activeScopes.set(/* @__PURE__ */ new Set(["global"]));
345
+ scopeRefcounts.clear();
252
346
  clearPending();
253
347
  detachListener();
254
348
  }
@@ -320,5 +414,5 @@ function useHotkeyScope(scope) {
320
414
  registerSingleton(name, version, import.meta.url);
321
415
 
322
416
  //#endregion
323
- export { _resetHotkeys, disableScope, enableScope, formatCombo, getActiveScopes, getRegisteredHotkeys, matchesCombo, parseShortcut, registerHotkey, useHotkey, useHotkeyScope };
417
+ export { _resetHotkeys, disableScope, enableScope, formatCombo, getActiveScopes, getHotkeyConflicts, getRegisteredHotkeys, matchesCombo, parseShortcut, registerHotkey, useHotkey, useHotkeyScope };
324
418
  //# sourceMappingURL=index.js.map
package/lib/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","names":["__pkgName","__pkgVersion"],"sources":["../package.json","../src/parse.ts","../src/registry.ts","../src/use-hotkey.ts","../src/use-hotkey-scope.ts","../src/index.ts"],"sourcesContent":["","import type { KeyCombo } from './types'\n\n// ─── Key aliases ─────────────────────────────────────────────────────────────\n\nconst KEY_ALIASES: Record<string, string> = {\n esc: 'escape',\n return: 'enter',\n del: 'delete',\n ins: 'insert',\n space: ' ',\n spacebar: ' ',\n up: 'arrowup',\n down: 'arrowdown',\n left: 'arrowleft',\n right: 'arrowright',\n plus: '+',\n}\n\n/**\n * Parse a shortcut string like 'ctrl+shift+s' into a KeyCombo.\n * Supports aliases (esc, del, space, etc.) and mod (ctrl on Windows/Linux, meta on Mac).\n */\nexport function parseShortcut(shortcut: string): KeyCombo {\n const parts = shortcut.toLowerCase().trim().split('+')\n const combo: KeyCombo = {\n ctrl: false,\n shift: false,\n alt: false,\n meta: false,\n key: '',\n }\n\n for (const part of parts) {\n const p = part.trim()\n if (p === 'ctrl' || p === 'control') {\n combo.ctrl = true\n } else if (p === 'shift') {\n combo.shift = true\n } else if (p === 'alt') {\n combo.alt = true\n } else if (p === 'meta' || p === 'cmd' || p === 'command') {\n combo.meta = true\n } else if (p === 'mod') {\n // mod = meta on Mac, ctrl elsewhere\n if (isMac()) {\n combo.meta = true\n } else {\n combo.ctrl = true\n }\n } else {\n combo.key = KEY_ALIASES[p] ?? p\n }\n }\n\n return combo\n}\n\n/**\n * Check if a KeyboardEvent matches a KeyCombo.\n */\nexport function matchesCombo(event: KeyboardEvent, combo: KeyCombo): boolean {\n if (event.ctrlKey !== combo.ctrl) return false\n if (event.shiftKey !== combo.shift) return false\n if (event.altKey !== combo.alt) return false\n if (event.metaKey !== combo.meta) return false\n\n const eventKey = event.key.toLowerCase()\n return eventKey === combo.key\n}\n\n/**\n * Format a KeyCombo back to a human-readable string.\n */\nexport function formatCombo(combo: KeyCombo): string {\n const parts: string[] = []\n if (combo.ctrl) parts.push('Ctrl')\n if (combo.shift) parts.push('Shift')\n if (combo.alt) parts.push('Alt')\n if (combo.meta) parts.push(isMac() ? '⌘' : 'Meta')\n parts.push(combo.key.length === 1 ? combo.key.toUpperCase() : capitalize(combo.key))\n return parts.join('+')\n}\n\nfunction capitalize(s: string): string {\n return s.charAt(0).toUpperCase() + s.slice(1)\n}\n\nfunction isMac(): boolean {\n if (typeof navigator === 'undefined') return false\n return /mac|iphone|ipad|ipod/i.test(navigator.userAgent)\n}\n","import type { Signal } from '@pyreon/reactivity'\nimport { isServer, signal } from '@pyreon/reactivity'\nimport { matchesCombo, parseShortcut } from './parse'\nimport type { HotkeyEntry, HotkeyOptions } from './types'\n\n// ─── State ───────────────────────────────────────────────────────────────────\n\n// Linear array on purpose. Per-keystroke dispatch (line 35) iterates entries\n// in registration order — O(n) where n = registered hotkeys. For real apps\n// (single-digit to low-tens) the cost is sub-microsecond and well under the\n// 16ms frame budget. Switching to a Map<comboKey, entries[]> would help only\n// past ~5,000 hotkeys, which is unrealistic. The array also preserves\n// registration order for the rare case where two hotkeys match the same\n// combo on different scopes — first-registered wins. Don't replace with a\n// Map without confirming a real app actually hits the perf wall.\nconst entries: HotkeyEntry[] = []\nconst activeScopes = signal<Set<string>>(new Set(['global']))\nlet listenerAttached = false\nlet keydownHandler: ((event: KeyboardEvent) => void) | null = null\n\n// ─── Sequence state ──────────────────────────────────────────────────────────\n\n/**\n * Active sequence-tracking state. When a user presses a key that matches the\n * FIRST combo of one-or-more sequential hotkeys (`'g t'`, `'g n'`, etc.), we\n * remember the matched prefix and wait for the next keystroke. If it matches\n * the next combo in any pending sequence, we keep narrowing. A full match\n * fires the handler; a non-match clears the pending state. The state also\n * times out after `SEQUENCE_TIMEOUT_MS` so a stranded prefix doesn't trap\n * the next single-key shortcut.\n */\ninterface PendingSequence {\n /** The entry whose remaining keys we're tracking. */\n entry: HotkeyEntry\n /** Index of the NEXT combo we expect to match in `entry.sequence`. */\n next: number\n}\n\nconst SEQUENCE_TIMEOUT_MS = 1000\nlet pending: PendingSequence[] = []\nlet pendingTimer: ReturnType<typeof setTimeout> | null = null\n\nfunction clearPending(): void {\n pending = []\n if (pendingTimer) {\n clearTimeout(pendingTimer)\n pendingTimer = null\n }\n}\n\nfunction armSequenceTimeout(): void {\n if (pendingTimer) clearTimeout(pendingTimer)\n pendingTimer = setTimeout(clearPending, SEQUENCE_TIMEOUT_MS)\n}\n\n// ─── Input detection ─────────────────────────────────────────────────────────\n\nconst INPUT_TAGS = new Set(['INPUT', 'TEXTAREA', 'SELECT'])\n\nfunction isInputFocused(event: KeyboardEvent): boolean {\n const target = event.target as HTMLElement | null\n if (!target) return false\n if (INPUT_TAGS.has(target.tagName)) return true\n if (target.isContentEditable) return true\n return false\n}\n\n// ─── Global listener ─────────────────────────────────────────────────────────\n\nfunction attachListener(): void {\n if (listenerAttached) return\n if (isServer) return\n listenerAttached = true\n\n keydownHandler = (event) => {\n const scopes = activeScopes.peek()\n\n // ─── Stage 1: advance any pending sequences ──────────────────────────\n // If we're mid-sequence, the user's next keystroke must match the\n // NEXT combo of at least one pending entry. Non-matches drop that\n // pending entry; a full match (last combo) fires the handler and\n // clears all pending state. If anything advanced, return early so\n // single-key shortcuts (like 't' alone) don't ALSO fire on this\n // keystroke — sequence ownership of the keystroke beats fresh\n // single-combo matches.\n if (pending.length > 0) {\n const surviving: PendingSequence[] = []\n let fired = false\n for (const p of pending) {\n const expected = p.entry.sequence[p.next]\n // Defensive — unreachable. A PendingSequence is only created with a\n // `next` that points at a valid index: stage-1 survivors (L101) use\n // `p.next + 1` strictly below `sequence.length`, and fresh entries\n // (L144) use `next: 0` only when `sequence.length > 0`. So\n // `sequence[next]` is always defined.\n /* v8 ignore next */\n if (!expected) continue\n if (!matchesCombo(event, expected)) continue\n // Advance\n if (p.next + 1 === p.entry.sequence.length) {\n // Full match — fire\n if (p.entry.options.preventDefault) event.preventDefault()\n if (p.entry.options.stopPropagation) event.stopPropagation()\n p.entry.handler(event)\n fired = true\n } else {\n surviving.push({ entry: p.entry, next: p.next + 1 })\n }\n }\n if (fired || surviving.length > 0) {\n pending = surviving\n if (surviving.length > 0) armSequenceTimeout()\n else clearPending()\n return\n }\n // No pending sequence matched — fall through to fresh dispatch\n // but clear the stale pending state.\n clearPending()\n }\n\n // ─── Stage 2: fresh dispatch ────────────────────────────────────────\n const newPending: PendingSequence[] = []\n for (const entry of entries) {\n // Check scope\n if (!scopes.has(entry.options.scope)) continue\n\n // Check enabled\n const enabled =\n typeof entry.options.enabled === 'function'\n ? entry.options.enabled()\n : entry.options.enabled\n if (!enabled) continue\n\n // Check input focus\n if (!entry.options.enableOnInputs && isInputFocused(event)) continue\n\n // Check FIRST combo match\n if (!matchesCombo(event, entry.combo)) continue\n\n if (entry.sequence.length === 0) {\n // Single-combo hotkey — fire immediately\n if (entry.options.preventDefault) event.preventDefault()\n if (entry.options.stopPropagation) event.stopPropagation()\n entry.handler(event)\n } else {\n // Sequential hotkey — record as pending; consume the keystroke\n // (preventDefault so 'g' doesn't trigger a browser shortcut)\n // but DON'T fire the handler yet.\n if (entry.options.preventDefault) event.preventDefault()\n newPending.push({ entry, next: 0 })\n }\n }\n\n if (newPending.length > 0) {\n pending = newPending\n armSequenceTimeout()\n }\n }\n\n window.addEventListener('keydown', keydownHandler)\n}\n\nfunction detachListener(): void {\n if (isServer) return\n if (!listenerAttached || !keydownHandler) return\n window.removeEventListener('keydown', keydownHandler)\n listenerAttached = false\n keydownHandler = null\n}\n\n// ─── Registration ────────────────────────────────────────────────────────────\n\n/**\n * Register a keyboard shortcut. Returns an unregister function.\n *\n * @example\n * ```ts\n * const unregister = registerHotkey('ctrl+s', (e) => save(), { description: 'Save' })\n * // later: unregister()\n * ```\n */\nexport function registerHotkey(\n shortcut: string,\n handler: (event: KeyboardEvent) => void,\n options?: HotkeyOptions,\n): () => void {\n attachListener()\n\n // Sequential combo support: space-separated combos like `'g t'` are\n // treated as ordered sequences — user presses `g`, then `t` within\n // `SEQUENCE_TIMEOUT_MS`, the handler fires. Each sub-combo is parsed\n // through the existing `parseShortcut` (so `'ctrl+k p'` works: `ctrl+k`\n // followed by `p`). Single-combo shortcuts have `sequence: []` and\n // behave identically to the pre-sequence code path.\n const subShortcuts = shortcut\n .split(/\\s+/)\n .map((s) => s.trim())\n .filter(Boolean)\n if (subShortcuts.length === 0) {\n throw new Error(`[@pyreon/hotkeys] empty shortcut: ${JSON.stringify(shortcut)}`)\n }\n const combos = subShortcuts.map(parseShortcut)\n const firstCombo = combos[0]\n // Unreachable given the `subShortcuts.length === 0` throw above (combos is\n // 1:1 with the non-empty subShortcuts), but the index access is typed\n // `T | undefined`, so the guard keeps TS happy.\n /* v8 ignore start */\n if (!firstCombo) {\n throw new Error(`[@pyreon/hotkeys] invalid shortcut: ${JSON.stringify(shortcut)}`)\n }\n /* v8 ignore stop */\n const rest = combos.slice(1)\n\n const entry: HotkeyEntry = {\n shortcut,\n combo: firstCombo,\n sequence: rest,\n handler,\n options: {\n scope: options?.scope ?? 'global',\n preventDefault: options?.preventDefault !== false,\n stopPropagation: options?.stopPropagation === true,\n enableOnInputs: options?.enableOnInputs === true,\n enabled: options?.enabled ?? true,\n ...(options?.description != null ? { description: options.description } : {}),\n },\n }\n\n entries.push(entry)\n\n return () => {\n const idx = entries.indexOf(entry)\n if (idx !== -1) entries.splice(idx, 1)\n\n // Detach listener if no more entries\n if (entries.length === 0) {\n detachListener()\n }\n }\n}\n\n// ─── Scope management ────────────────────────────────────────────────────────\n\n/**\n * Activate a hotkey scope. 'global' is always active.\n */\nexport function enableScope(scope: string): void {\n const current = activeScopes.peek()\n if (current.has(scope)) return\n const next = new Set(current)\n next.add(scope)\n activeScopes.set(next)\n}\n\n/**\n * Deactivate a hotkey scope. Cannot deactivate 'global'.\n */\nexport function disableScope(scope: string): void {\n if (scope === 'global') return\n const current = activeScopes.peek()\n if (!current.has(scope)) return\n const next = new Set(current)\n next.delete(scope)\n activeScopes.set(next)\n}\n\n/**\n * Get the currently active scopes as a reactive signal.\n */\nexport function getActiveScopes(): Signal<Set<string>> {\n return activeScopes\n}\n\n/**\n * Get all registered hotkeys (for building help dialogs).\n */\nexport function getRegisteredHotkeys(): ReadonlyArray<{\n shortcut: string\n scope: string\n description?: string\n}> {\n return entries.map((e) => ({\n shortcut: e.shortcut,\n scope: e.options.scope,\n ...(e.options.description != null ? { description: e.options.description } : {}),\n }))\n}\n\n// ─── Reset (for testing) ────────────────────────────────────────────────\n\nexport function _resetHotkeys(): void {\n entries.length = 0\n activeScopes.set(new Set(['global']))\n clearPending()\n detachListener()\n}\n","import { onUnmount } from '@pyreon/core'\nimport { registerHotkey } from './registry'\nimport type { HotkeyOptions } from './types'\n\n/**\n * Register a keyboard shortcut scoped to a component's lifecycle.\n * Automatically unregisters when the component unmounts.\n *\n * @example\n * ```ts\n * function Editor() {\n * useHotkey('ctrl+s', () => save(), { description: 'Save document' })\n * useHotkey('ctrl+z', () => undo())\n * useHotkey('ctrl+shift+z', () => redo())\n * // ...\n * }\n * ```\n */\nexport function useHotkey(\n shortcut: string,\n handler: (event: KeyboardEvent) => void,\n options?: HotkeyOptions,\n): void {\n const unregister = registerHotkey(shortcut, handler, options)\n onUnmount(unregister)\n}\n","import { onUnmount } from '@pyreon/core'\nimport { disableScope, enableScope } from './registry'\n\n/**\n * Activate a hotkey scope for the lifetime of a component.\n * When the component unmounts, the scope is deactivated.\n *\n * @example\n * ```tsx\n * function Modal() {\n * useHotkeyScope('modal')\n * useHotkey('escape', () => closeModal(), { scope: 'modal' })\n * // ...\n * }\n * ```\n */\nexport function useHotkeyScope(scope: string): void {\n enableScope(scope)\n onUnmount(() => disableScope(scope))\n}\n","/**\n * @pyreon/hotkeys — Reactive keyboard shortcut management for Pyreon.\n *\n * Register global or scoped keyboard shortcuts with automatic lifecycle\n * management. Supports modifier keys, aliases, input filtering, and\n * scope-based activation.\n *\n * @example\n * ```ts\n * import { useHotkey, useHotkeyScope } from '@pyreon/hotkeys'\n *\n * // Global shortcut\n * useHotkey('ctrl+s', () => save(), { description: 'Save' })\n *\n * // Scoped shortcut — only active when scope is enabled\n * useHotkeyScope('editor')\n * useHotkey('ctrl+z', () => undo(), { scope: 'editor' })\n *\n * // Platform-aware — mod = ⌘ on Mac, Ctrl elsewhere\n * useHotkey('mod+k', () => openCommandPalette())\n * ```\n */\n\nimport { name as __pkgName, version as __pkgVersion } from '../package.json' with { type: 'json' }\nimport { registerSingleton } from '@pyreon/reactivity'\n\n// Singleton sentinel — fail-loud detection of duplicate @pyreon/hotkeys\n// instances in the same heap. See @pyreon/reactivity/singleton-sentinel for\n// full rationale. Hardcoded version is acceptable here — it's a diagnostic\n// aid, not a load-bearing identity check.\nregisterSingleton(__pkgName, __pkgVersion, import.meta.url)\n\n// ─── Hooks ───────────────────────────────────────────────────────────────────\n\nexport { useHotkey } from './use-hotkey'\nexport { useHotkeyScope } from './use-hotkey-scope'\n\n// ─── Imperative API ──────────────────────────────────────────────────────────\n\nexport {\n disableScope,\n enableScope,\n getActiveScopes,\n getRegisteredHotkeys,\n registerHotkey,\n} from './registry'\n\n// ─── Utilities ───────────────────────────────────────────────────────────────\n\nexport { formatCombo, matchesCombo, parseShortcut } from './parse'\n\n// ─── Types ───────────────────────────────────────────────────────────────────\n\nexport type { HotkeyEntry, HotkeyOptions, KeyCombo } from './types'\n\n// ─── Testing ─────────────────────────────────────────────────────────────────\n\nexport { _resetHotkeys } from './registry'\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.js","names":["__pkgName","__pkgVersion"],"sources":["../package.json","../src/parse.ts","../src/registry.ts","../src/use-hotkey.ts","../src/use-hotkey-scope.ts","../src/index.ts"],"sourcesContent":["","import type { KeyCombo } from './types'\n\n// ─── Key aliases ─────────────────────────────────────────────────────────────\n\nconst KEY_ALIASES: Record<string, string> = {\n esc: 'escape',\n return: 'enter',\n del: 'delete',\n ins: 'insert',\n space: ' ',\n spacebar: ' ',\n up: 'arrowup',\n down: 'arrowdown',\n left: 'arrowleft',\n right: 'arrowright',\n plus: '+',\n}\n\n/**\n * Parse a shortcut string like 'ctrl+shift+s' into a KeyCombo.\n * Supports aliases (esc, del, space, etc.) and mod (ctrl on Windows/Linux, meta on Mac).\n */\nexport function parseShortcut(shortcut: string): KeyCombo {\n const parts = shortcut.toLowerCase().trim().split('+')\n const combo: KeyCombo = {\n ctrl: false,\n shift: false,\n alt: false,\n meta: false,\n key: '',\n }\n\n for (const part of parts) {\n const p = part.trim()\n if (p === 'ctrl' || p === 'control') {\n combo.ctrl = true\n } else if (p === 'shift') {\n combo.shift = true\n } else if (p === 'alt') {\n combo.alt = true\n } else if (p === 'meta' || p === 'cmd' || p === 'command') {\n combo.meta = true\n } else if (p === 'mod') {\n // mod = meta on Mac, ctrl elsewhere\n if (isMac()) {\n combo.meta = true\n } else {\n combo.ctrl = true\n }\n } else {\n combo.key = KEY_ALIASES[p] ?? p\n }\n }\n\n return combo\n}\n\n/**\n * A single-character key whose typing inherently requires Shift on a standard\n * layout (`?`, `!`, `@`, `+`, `<`, `:`, …). Letters, digits, and space are NOT\n * symbols. `combo.key` is already lower-cased at parse time.\n */\nfunction isSymbolKey(key: string): boolean {\n return key.length === 1 && !/[a-z0-9 ]/.test(key)\n}\n\n/**\n * Match core shared by {@link matchesCombo} and the dispatch hot path, taking a\n * PRE-LOWERCASED `event.key` so the per-keydown dispatch loop computes\n * `toLowerCase()` once instead of once per registered entry. Key is checked\n * FIRST — it's the most selective field, so a non-matching keystroke rejects\n * before touching the modifier bits. Not exported from the package.\n *\n * @internal\n */\nexport function matchesComboWithKey(\n event: KeyboardEvent,\n combo: KeyCombo,\n eventKey: string,\n): boolean {\n if (eventKey !== combo.key) return false\n if (event.ctrlKey !== combo.ctrl) return false\n if (event.altKey !== combo.alt) return false\n if (event.metaKey !== combo.meta) return false\n\n // Shift handling: for a single-character SYMBOL key (`?`, `!`, `+`, `/`, …),\n // whether Shift was needed to type it is keyboard-layout-dependent, and the\n // produced `event.key` already encodes the character. So the Shift modifier is\n // NOT enforced for symbol keys — binding `?` fires on the real `Shift+/`\n // keystroke (the canonical \"show help\" shortcut) without the author having to\n // write `shift+?`, and the distinct `event.key` values keep `/` and `?` from\n // colliding. Letters and named keys keep exact Shift-matching, so `a` never\n // matches `Shift+A` and `arrowup` never matches `Shift+ArrowUp`.\n if (isSymbolKey(combo.key)) return true\n return event.shiftKey === combo.shift\n}\n\n/**\n * Check if a KeyboardEvent matches a KeyCombo. See {@link matchesComboWithKey}\n * for the Shift/symbol semantics.\n */\nexport function matchesCombo(event: KeyboardEvent, combo: KeyCombo): boolean {\n return matchesComboWithKey(event, combo, event.key.toLowerCase())\n}\n\n/**\n * Format a KeyCombo back to a human-readable string.\n */\nexport function formatCombo(combo: KeyCombo): string {\n const parts: string[] = []\n if (combo.ctrl) parts.push('Ctrl')\n if (combo.shift) parts.push('Shift')\n if (combo.alt) parts.push('Alt')\n if (combo.meta) parts.push(isMac() ? '⌘' : 'Meta')\n parts.push(combo.key.length === 1 ? combo.key.toUpperCase() : capitalize(combo.key))\n return parts.join('+')\n}\n\nfunction capitalize(s: string): string {\n return s.charAt(0).toUpperCase() + s.slice(1)\n}\n\nfunction isMac(): boolean {\n if (typeof navigator === 'undefined') return false\n return /mac|iphone|ipad|ipod/i.test(navigator.userAgent)\n}\n","import type { Signal } from '@pyreon/reactivity'\nimport { isServer, signal } from '@pyreon/reactivity'\nimport { matchesComboWithKey, parseShortcut } from './parse'\nimport type { HotkeyEntry, HotkeyOptions, KeyCombo } from './types'\n\n// ─── State ───────────────────────────────────────────────────────────────────\n\n// Linear array on purpose. Per-keystroke dispatch (line 35) iterates entries\n// in registration order — O(n) where n = registered hotkeys. For real apps\n// (single-digit to low-tens) the cost is sub-microsecond and well under the\n// 16ms frame budget. Switching to a Map<comboKey, entries[]> would help only\n// past ~5,000 hotkeys, which is unrealistic. The array also preserves\n// registration order for the rare case where two hotkeys match the same\n// combo on different scopes — first-registered wins. Don't replace with a\n// Map without confirming a real app actually hits the perf wall.\nconst entries: HotkeyEntry[] = []\nconst activeScopes = signal<Set<string>>(new Set(['global']))\nlet listenerAttached = false\nlet keydownHandler: ((event: KeyboardEvent) => void) | null = null\n\n// Scope activation is REFERENCE-COUNTED. `enableScope`/`disableScope` (and the\n// `useHotkeyScope` hook that calls them) are acquire/release: a scope becomes\n// active on the 0→1 transition and deactivates on the 1→0 transition. This is\n// what makes STACKED components correct — two panels that both activate\n// `'editor'` keep it active until BOTH release it, instead of the first\n// unmount disabling it for the survivor (the classic leak-class-D shape).\n// `'global'` is always active and never counted.\nconst scopeRefcounts = new Map<string, number>()\n\n// ─── Sequence state ──────────────────────────────────────────────────────────\n\n/**\n * Active sequence-tracking state. When a user presses a key that matches the\n * FIRST combo of one-or-more sequential hotkeys (`'g t'`, `'g n'`, etc.), we\n * remember the matched prefix and wait for the next keystroke. If it matches\n * the next combo in any pending sequence, we keep narrowing. A full match\n * fires the handler; a non-match clears the pending state. The state also\n * times out after `SEQUENCE_TIMEOUT_MS` so a stranded prefix doesn't trap\n * the next single-key shortcut.\n */\ninterface PendingSequence {\n /** The entry whose remaining keys we're tracking. */\n entry: HotkeyEntry\n /** Index of the NEXT combo we expect to match in `entry.sequence`. */\n next: number\n}\n\nconst SEQUENCE_TIMEOUT_MS = 1000\nlet pending: PendingSequence[] = []\nlet pendingTimer: ReturnType<typeof setTimeout> | null = null\n\nfunction clearPending(): void {\n pending = []\n if (pendingTimer) {\n clearTimeout(pendingTimer)\n pendingTimer = null\n }\n}\n\nfunction armSequenceTimeout(): void {\n if (pendingTimer) clearTimeout(pendingTimer)\n pendingTimer = setTimeout(clearPending, SEQUENCE_TIMEOUT_MS)\n}\n\n// ─── Input detection ─────────────────────────────────────────────────────────\n\nconst INPUT_TAGS = new Set(['INPUT', 'TEXTAREA', 'SELECT'])\n\nfunction isInputFocused(event: KeyboardEvent): boolean {\n const target = event.target as HTMLElement | null\n if (!target) return false\n if (INPUT_TAGS.has(target.tagName)) return true\n if (target.isContentEditable) return true\n return false\n}\n\n// ─── Global listener ─────────────────────────────────────────────────────────\n\nfunction attachListener(): void {\n if (listenerAttached) return\n // SSR guard: never touch `window` on the server. (Also enforced upstream by\n // `registerHotkey`'s own isServer bail, but this keeps the window access\n // provably SSR-safe at its use site.)\n if (isServer) return\n listenerAttached = true\n\n keydownHandler = (event) => {\n const scopes = activeScopes.peek()\n // Hoist the two per-keydown-constant computations OUT of the per-entry\n // loop: `event.key` lower-cased once (not once per registered entry inside\n // matchesCombo), and the input-focus check once (its result is identical\n // for every entry on this keystroke). Both were previously recomputed for\n // every entry — pure waste that scaled with the number of hotkeys.\n const eventKey = event.key.toLowerCase()\n const inInput = isInputFocused(event)\n\n // ─── Stage 1: advance any pending sequences ──────────────────────────\n // If we're mid-sequence, the user's next keystroke must match the\n // NEXT combo of at least one pending entry. Non-matches drop that\n // pending entry; a full match (last combo) fires the handler and\n // clears all pending state. If anything advanced, return early so\n // single-key shortcuts (like 't' alone) don't ALSO fire on this\n // keystroke — sequence ownership of the keystroke beats fresh\n // single-combo matches.\n if (pending.length > 0) {\n const surviving: PendingSequence[] = []\n let fired = false\n for (const p of pending) {\n const expected = p.entry.sequence[p.next]\n // Defensive — unreachable. A PendingSequence is only created with a\n // `next` that points at a valid index: stage-1 survivors (L101) use\n // `p.next + 1` strictly below `sequence.length`, and fresh entries\n // (L144) use `next: 0` only when `sequence.length > 0`. So\n // `sequence[next]` is always defined.\n /* v8 ignore next */\n if (!expected) continue\n if (!matchesComboWithKey(event, expected, eventKey)) continue\n // Advance\n if (p.next + 1 === p.entry.sequence.length) {\n // Full match — fire\n if (p.entry.options.preventDefault) event.preventDefault()\n if (p.entry.options.stopPropagation) event.stopPropagation()\n p.entry.handler(event)\n fired = true\n } else {\n surviving.push({ entry: p.entry, next: p.next + 1 })\n }\n }\n if (fired || surviving.length > 0) {\n pending = surviving\n if (surviving.length > 0) armSequenceTimeout()\n else clearPending()\n return\n }\n // No pending sequence matched — fall through to fresh dispatch\n // but clear the stale pending state.\n clearPending()\n }\n\n // ─── Stage 2: fresh dispatch ────────────────────────────────────────\n const newPending: PendingSequence[] = []\n for (const entry of entries) {\n // Check FIRST combo match (most selective — reject non-matching keystrokes\n // before the scope / enabled / input-focus work).\n if (!matchesComboWithKey(event, entry.combo, eventKey)) continue\n\n // Check scope\n if (!scopes.has(entry.options.scope)) continue\n\n // Check input focus (hoisted once-per-keydown result)\n if (!entry.options.enableOnInputs && inInput) continue\n\n // Check enabled\n const enabled =\n typeof entry.options.enabled === 'function'\n ? entry.options.enabled()\n : entry.options.enabled\n if (!enabled) continue\n\n if (entry.sequence.length === 0) {\n // Single-combo hotkey — fire immediately\n if (entry.options.preventDefault) event.preventDefault()\n if (entry.options.stopPropagation) event.stopPropagation()\n entry.handler(event)\n } else {\n // Sequential hotkey — record as pending; consume the keystroke\n // (preventDefault so 'g' doesn't trigger a browser shortcut)\n // but DON'T fire the handler yet.\n if (entry.options.preventDefault) event.preventDefault()\n newPending.push({ entry, next: 0 })\n }\n }\n\n if (newPending.length > 0) {\n pending = newPending\n armSequenceTimeout()\n }\n }\n\n window.addEventListener('keydown', keydownHandler)\n}\n\nfunction detachListener(): void {\n if (isServer) return\n if (!listenerAttached || !keydownHandler) return\n window.removeEventListener('keydown', keydownHandler)\n listenerAttached = false\n keydownHandler = null\n}\n\n// ─── Registration ────────────────────────────────────────────────────────────\n\n/**\n * Register a keyboard shortcut. Returns an unregister function.\n *\n * @example\n * ```ts\n * const unregister = registerHotkey('ctrl+s', (e) => save(), { description: 'Save' })\n * // later: unregister()\n * ```\n */\nexport function registerHotkey(\n shortcut: string,\n handler: (event: KeyboardEvent) => void,\n options?: HotkeyOptions,\n): () => void {\n attachListener() // no-op on the server (its own isServer guard)\n\n // SSR no-op. The registry drives a `keydown` listener that only exists in a\n // browser, so registering on the server does nothing useful — but the module\n // is shared across every request, so pushing the entry would (a) leak\n // unboundedly (no unmount fires during `renderToString`) and (b) BLEED one\n // request's hotkeys into the next. Return an inert unregister and touch no\n // shared state. `getRegisteredHotkeys()` is therefore client-runtime state;\n // build SSR help panels from a static config, not the live registry.\n if (isServer) return () => {}\n\n // Sequential combo support: space-separated combos like `'g t'` are\n // treated as ordered sequences — user presses `g`, then `t` within\n // `SEQUENCE_TIMEOUT_MS`, the handler fires. Each sub-combo is parsed\n // through the existing `parseShortcut` (so `'ctrl+k p'` works: `ctrl+k`\n // followed by `p`). Single-combo shortcuts have `sequence: []` and\n // behave identically to the pre-sequence code path.\n const subShortcuts = shortcut\n .split(/\\s+/)\n .map((s) => s.trim())\n .filter(Boolean)\n if (subShortcuts.length === 0) {\n throw new Error(`[@pyreon/hotkeys] empty shortcut: ${JSON.stringify(shortcut)}`)\n }\n const combos = subShortcuts.map(parseShortcut)\n const firstCombo = combos[0]\n // Unreachable given the `subShortcuts.length === 0` throw above (combos is\n // 1:1 with the non-empty subShortcuts), but the index access is typed\n // `T | undefined`, so the guard keeps TS happy.\n /* v8 ignore start */\n if (!firstCombo) {\n throw new Error(`[@pyreon/hotkeys] invalid shortcut: ${JSON.stringify(shortcut)}`)\n }\n /* v8 ignore stop */\n const rest = combos.slice(1)\n\n const entry: HotkeyEntry = {\n shortcut,\n combo: firstCombo,\n sequence: rest,\n handler,\n options: {\n scope: options?.scope ?? 'global',\n preventDefault: options?.preventDefault !== false,\n stopPropagation: options?.stopPropagation === true,\n enableOnInputs: options?.enableOnInputs === true,\n enabled: options?.enabled ?? true,\n ...(options?.description != null ? { description: options.description } : {}),\n },\n }\n\n entries.push(entry)\n\n return () => {\n const idx = entries.indexOf(entry)\n if (idx !== -1) entries.splice(idx, 1)\n\n // Detach listener if no more entries\n if (entries.length === 0) {\n detachListener()\n }\n }\n}\n\n// ─── Scope management ────────────────────────────────────────────────────────\n\n/**\n * Activate a hotkey scope (acquire). Reference-counted — the scope becomes\n * active on the first acquire and stays active until every acquire is matched\n * by a {@link disableScope} release. `'global'` is always active. No-op on the\n * server (scope state is client-runtime and must not bleed across requests).\n */\nexport function enableScope(scope: string): void {\n if (isServer) return\n if (scope === 'global') return\n const count = scopeRefcounts.get(scope) ?? 0\n scopeRefcounts.set(scope, count + 1)\n if (count > 0) return // already active — just bumped the refcount\n const next = new Set(activeScopes.peek())\n next.add(scope)\n activeScopes.set(next)\n}\n\n/**\n * Deactivate a hotkey scope (release). Reference-counted — the scope only\n * deactivates once every {@link enableScope} acquire has been released. Cannot\n * deactivate `'global'`. Releasing an inactive scope is a no-op (the count\n * clamps at zero). No-op on the server.\n */\nexport function disableScope(scope: string): void {\n if (isServer) return\n if (scope === 'global') return\n const count = scopeRefcounts.get(scope) ?? 0\n if (count === 0) return // not active — nothing to release\n if (count > 1) {\n scopeRefcounts.set(scope, count - 1)\n return // still held by another acquirer\n }\n scopeRefcounts.delete(scope)\n const next = new Set(activeScopes.peek())\n next.delete(scope)\n activeScopes.set(next)\n}\n\n/**\n * Get the currently active scopes as a reactive signal.\n */\nexport function getActiveScopes(): Signal<Set<string>> {\n return activeScopes\n}\n\n/**\n * Get all registered hotkeys (for building help dialogs).\n */\nexport function getRegisteredHotkeys(): ReadonlyArray<{\n shortcut: string\n scope: string\n description?: string\n}> {\n return entries.map((e) => ({\n shortcut: e.shortcut,\n scope: e.options.scope,\n ...(e.options.description != null ? { description: e.options.description } : {}),\n }))\n}\n\n// ─── Conflict detection ──────────────────────────────────────────────────────\n\n/** Deterministic signature for one parsed combo (modifiers + key). */\nfunction comboSignature(c: KeyCombo): string {\n return `${c.ctrl ? 'c' : ''}${c.alt ? 'a' : ''}${c.shift ? 's' : ''}${c.meta ? 'm' : ''}:${c.key}`\n}\n\n/**\n * Full-sequence signature for an entry. Two entries with the SAME signature\n * resolve to the same keystroke(s) — so `'ctrl+s'` and `'control+s'`, or (on a\n * non-Mac) `'mod+s'` and `'ctrl+s'`, share a signature and are flagged as\n * conflicting despite different source strings.\n */\nfunction entrySignature(e: HotkeyEntry): string {\n return [e.combo, ...e.sequence].map(comboSignature).join(' ')\n}\n\n/**\n * Detect hotkeys that would fire on the SAME keystroke within the SAME scope —\n * i.e. registered shortcuts whose parsed combo sequence is identical. Because\n * matching happens on the parsed combo (not the source string), aliased\n * duplicates (`'ctrl+s'` vs `'control+s'`, `'mod+s'` vs `'ctrl+s'` off Mac)\n * are caught too.\n *\n * Cross-scope overlaps are NOT reported — a `'mod+s'` in `'global'` and one in\n * `'editor'` is intentional scope LAYERING, not a conflict. Use this for a\n * \"keyboard shortcut audit\" panel, a dev-time assertion, or a settings UI that\n * warns on duplicate bindings.\n *\n * @example\n * ```ts\n * registerHotkey('ctrl+s', saveA)\n * registerHotkey('control+s', saveB) // same combo, same (global) scope\n * getHotkeyConflicts()\n * // → [{ scope: 'global', shortcuts: ['ctrl+s', 'control+s'], descriptions: [undefined, undefined] }]\n * ```\n */\nexport function getHotkeyConflicts(): ReadonlyArray<{\n /** The scope in which the colliding hotkeys are registered. */\n scope: string\n /** The source shortcut strings that resolve to the same key sequence. */\n shortcuts: string[]\n /** Descriptions parallel to `shortcuts` (`undefined` where none was set). */\n descriptions: Array<string | undefined>\n}> {\n const groups = new Map<string, HotkeyEntry[]>()\n for (const e of entries) {\n const key = `${e.options.scope}\\u0000${entrySignature(e)}`\n const g = groups.get(key)\n if (g) g.push(e)\n else groups.set(key, [e])\n }\n const conflicts: {\n scope: string\n shortcuts: string[]\n descriptions: Array<string | undefined>\n }[] = []\n for (const g of groups.values()) {\n if (g.length < 2) continue\n conflicts.push({\n scope: g[0]!.options.scope,\n shortcuts: g.map((e) => e.shortcut),\n descriptions: g.map((e) => e.options.description),\n })\n }\n return conflicts\n}\n\n// ─── Reset (for testing) ────────────────────────────────────────────────\n\nexport function _resetHotkeys(): void {\n entries.length = 0\n activeScopes.set(new Set(['global']))\n scopeRefcounts.clear()\n clearPending()\n detachListener()\n}\n","import { onUnmount } from '@pyreon/core'\nimport { registerHotkey } from './registry'\nimport type { HotkeyOptions } from './types'\n\n/**\n * Register a keyboard shortcut scoped to a component's lifecycle.\n * Automatically unregisters when the component unmounts.\n *\n * @example\n * ```ts\n * function Editor() {\n * useHotkey('ctrl+s', () => save(), { description: 'Save document' })\n * useHotkey('ctrl+z', () => undo())\n * useHotkey('ctrl+shift+z', () => redo())\n * // ...\n * }\n * ```\n */\nexport function useHotkey(\n shortcut: string,\n handler: (event: KeyboardEvent) => void,\n options?: HotkeyOptions,\n): void {\n const unregister = registerHotkey(shortcut, handler, options)\n onUnmount(unregister)\n}\n","import { onUnmount } from '@pyreon/core'\nimport { disableScope, enableScope } from './registry'\n\n/**\n * Activate a hotkey scope for the lifetime of a component.\n * When the component unmounts, the scope is deactivated.\n *\n * @example\n * ```tsx\n * function Modal() {\n * useHotkeyScope('modal')\n * useHotkey('escape', () => closeModal(), { scope: 'modal' })\n * // ...\n * }\n * ```\n */\nexport function useHotkeyScope(scope: string): void {\n enableScope(scope)\n onUnmount(() => disableScope(scope))\n}\n","/**\n * @pyreon/hotkeys — Reactive keyboard shortcut management for Pyreon.\n *\n * Register global or scoped keyboard shortcuts with automatic lifecycle\n * management. Supports modifier keys, aliases, input filtering, and\n * scope-based activation.\n *\n * @example\n * ```ts\n * import { useHotkey, useHotkeyScope } from '@pyreon/hotkeys'\n *\n * // Global shortcut\n * useHotkey('ctrl+s', () => save(), { description: 'Save' })\n *\n * // Scoped shortcut — only active when scope is enabled\n * useHotkeyScope('editor')\n * useHotkey('ctrl+z', () => undo(), { scope: 'editor' })\n *\n * // Platform-aware — mod = ⌘ on Mac, Ctrl elsewhere\n * useHotkey('mod+k', () => openCommandPalette())\n * ```\n */\n\nimport { name as __pkgName, version as __pkgVersion } from '../package.json' with { type: 'json' }\nimport { registerSingleton } from '@pyreon/reactivity'\n\n// Singleton sentinel — fail-loud detection of duplicate @pyreon/hotkeys\n// instances in the same heap. See @pyreon/reactivity/singleton-sentinel for\n// full rationale. Hardcoded version is acceptable here — it's a diagnostic\n// aid, not a load-bearing identity check.\nregisterSingleton(__pkgName, __pkgVersion, import.meta.url)\n\n// ─── Hooks ───────────────────────────────────────────────────────────────────\n\nexport { useHotkey } from './use-hotkey'\nexport { useHotkeyScope } from './use-hotkey-scope'\n\n// ─── Imperative API ──────────────────────────────────────────────────────────\n\nexport {\n disableScope,\n enableScope,\n getActiveScopes,\n getHotkeyConflicts,\n getRegisteredHotkeys,\n registerHotkey,\n} from './registry'\n\n// ─── Utilities ───────────────────────────────────────────────────────────────\n\nexport { formatCombo, matchesCombo, parseShortcut } from './parse'\n\n// ─── Types ───────────────────────────────────────────────────────────────────\n\nexport type { HotkeyEntry, HotkeyOptions, KeyCombo } from './types'\n\n// ─── Testing ─────────────────────────────────────────────────────────────────\n\nexport { _resetHotkeys } from './registry'\n"],"mappings":"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"}
@@ -101,11 +101,17 @@ declare function useHotkeyScope(scope: string): void;
101
101
  */
102
102
  declare function registerHotkey(shortcut: string, handler: (event: KeyboardEvent) => void, options?: HotkeyOptions): () => void;
103
103
  /**
104
- * Activate a hotkey scope. 'global' is always active.
104
+ * Activate a hotkey scope (acquire). Reference-counted the scope becomes
105
+ * active on the first acquire and stays active until every acquire is matched
106
+ * by a {@link disableScope} release. `'global'` is always active. No-op on the
107
+ * server (scope state is client-runtime and must not bleed across requests).
105
108
  */
106
109
  declare function enableScope(scope: string): void;
107
110
  /**
108
- * Deactivate a hotkey scope. Cannot deactivate 'global'.
111
+ * Deactivate a hotkey scope (release). Reference-counted the scope only
112
+ * deactivates once every {@link enableScope} acquire has been released. Cannot
113
+ * deactivate `'global'`. Releasing an inactive scope is a no-op (the count
114
+ * clamps at zero). No-op on the server.
109
115
  */
110
116
  declare function disableScope(scope: string): void;
111
117
  /**
@@ -120,6 +126,31 @@ declare function getRegisteredHotkeys(): ReadonlyArray<{
120
126
  scope: string;
121
127
  description?: string;
122
128
  }>;
129
+ /**
130
+ * Detect hotkeys that would fire on the SAME keystroke within the SAME scope —
131
+ * i.e. registered shortcuts whose parsed combo sequence is identical. Because
132
+ * matching happens on the parsed combo (not the source string), aliased
133
+ * duplicates (`'ctrl+s'` vs `'control+s'`, `'mod+s'` vs `'ctrl+s'` off Mac)
134
+ * are caught too.
135
+ *
136
+ * Cross-scope overlaps are NOT reported — a `'mod+s'` in `'global'` and one in
137
+ * `'editor'` is intentional scope LAYERING, not a conflict. Use this for a
138
+ * "keyboard shortcut audit" panel, a dev-time assertion, or a settings UI that
139
+ * warns on duplicate bindings.
140
+ *
141
+ * @example
142
+ * ```ts
143
+ * registerHotkey('ctrl+s', saveA)
144
+ * registerHotkey('control+s', saveB) // same combo, same (global) scope
145
+ * getHotkeyConflicts()
146
+ * // → [{ scope: 'global', shortcuts: ['ctrl+s', 'control+s'], descriptions: [undefined, undefined] }]
147
+ * ```
148
+ */
149
+ declare function getHotkeyConflicts(): ReadonlyArray<{
150
+ /** The scope in which the colliding hotkeys are registered. */scope: string; /** The source shortcut strings that resolve to the same key sequence. */
151
+ shortcuts: string[]; /** Descriptions parallel to `shortcuts` (`undefined` where none was set). */
152
+ descriptions: Array<string | undefined>;
153
+ }>;
123
154
  declare function _resetHotkeys(): void;
124
155
  //#endregion
125
156
  //#region src/parse.d.ts
@@ -129,7 +160,8 @@ declare function _resetHotkeys(): void;
129
160
  */
130
161
  declare function parseShortcut(shortcut: string): KeyCombo;
131
162
  /**
132
- * Check if a KeyboardEvent matches a KeyCombo.
163
+ * Check if a KeyboardEvent matches a KeyCombo. See {@link matchesComboWithKey}
164
+ * for the Shift/symbol semantics.
133
165
  */
134
166
  declare function matchesCombo(event: KeyboardEvent, combo: KeyCombo): boolean;
135
167
  /**
@@ -137,4 +169,4 @@ declare function matchesCombo(event: KeyboardEvent, combo: KeyCombo): boolean;
137
169
  */
138
170
  declare function formatCombo(combo: KeyCombo): string;
139
171
  //#endregion
140
- export { type HotkeyEntry, type HotkeyOptions, type KeyCombo, _resetHotkeys, disableScope, enableScope, formatCombo, getActiveScopes, getRegisteredHotkeys, matchesCombo, parseShortcut, registerHotkey, useHotkey, useHotkeyScope };
172
+ export { type HotkeyEntry, type HotkeyOptions, type KeyCombo, _resetHotkeys, disableScope, enableScope, formatCombo, getActiveScopes, getHotkeyConflicts, getRegisteredHotkeys, matchesCombo, parseShortcut, registerHotkey, useHotkey, useHotkeyScope };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pyreon/hotkeys",
3
- "version": "0.44.0",
3
+ "version": "0.46.0",
4
4
  "description": "Reactive keyboard shortcut management for Pyreon — scope-aware, conflict detection",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/pyreon/pyreon/tree/main/packages/fundamentals/hotkeys#readme",
@@ -9,7 +9,7 @@
9
9
  },
10
10
  "repository": {
11
11
  "type": "git",
12
- "url": "https://github.com/pyreon/pyreon.git",
12
+ "url": "git+https://github.com/pyreon/pyreon.git",
13
13
  "directory": "packages/fundamentals/hotkeys"
14
14
  },
15
15
  "type": "module",
@@ -33,17 +33,21 @@
33
33
  "dev": "vl_rolldown_build-watch",
34
34
  "test": "vitest run",
35
35
  "typecheck": "tsc --noEmit",
36
- "lint": "oxlint ."
36
+ "lint": "oxlint .",
37
+ "bench:keys": "NODE_ENV=production bun bench/hotkeys-bench.ts"
37
38
  },
38
39
  "peerDependencies": {
39
- "@pyreon/core": "^0.44.0",
40
- "@pyreon/reactivity": "^0.44.0"
40
+ "@pyreon/core": "^0.46.0",
41
+ "@pyreon/reactivity": "^0.46.0"
41
42
  },
42
43
  "devDependencies": {
43
44
  "@happy-dom/global-registrator": "^20.9.0",
44
45
  "@pyreon/manifest": "0.13.2",
45
46
  "@pyreon/vitest-config": "0.13.3",
46
- "@vitus-labs/tools-lint": "^2.6.3"
47
+ "@vitus-labs/tools-lint": "^2.6.3",
48
+ "hotkeys-js": "^4.0.4",
49
+ "mousetrap": "^1.6.5",
50
+ "tinykeys": "^4.0.0"
47
51
  },
48
52
  "publishConfig": {
49
53
  "access": "public"