@pyreon/hotkeys 0.12.14 → 0.13.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/lib/analysis/index.js.html +1 -1
- package/lib/index.js +12 -2
- package/lib/index.js.map +1 -1
- package/lib/types/index.d.ts.map +1 -1
- package/package.json +6 -5
- package/src/manifest.ts +119 -0
- package/src/registry.ts +19 -3
- package/src/tests/hotkeys.test.ts +63 -1
- package/src/tests/manifest-snapshot.test.ts +69 -0
|
@@ -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":[{"name":"src","children":[{"uid":"
|
|
5389
|
+
const data = {"version":2,"tree":{"name":"root","children":[{"name":"index.js","children":[{"name":"src","children":[{"uid":"7a21fee1-1","name":"parse.ts"},{"uid":"7a21fee1-3","name":"registry.ts"},{"uid":"7a21fee1-5","name":"use-hotkey.ts"},{"uid":"7a21fee1-7","name":"use-hotkey-scope.ts"},{"uid":"7a21fee1-9","name":"index.ts"}]}]}],"isRoot":true},"nodeParts":{"7a21fee1-1":{"renderedLength":1941,"gzipLength":838,"brotliLength":0,"metaUid":"7a21fee1-0"},"7a21fee1-3":{"renderedLength":3232,"gzipLength":1141,"brotliLength":0,"metaUid":"7a21fee1-2"},"7a21fee1-5":{"renderedLength":496,"gzipLength":295,"brotliLength":0,"metaUid":"7a21fee1-4"},"7a21fee1-7":{"renderedLength":421,"gzipLength":264,"brotliLength":0,"metaUid":"7a21fee1-6"},"7a21fee1-9":{"renderedLength":0,"gzipLength":0,"brotliLength":0,"metaUid":"7a21fee1-8"}},"nodeMetas":{"7a21fee1-0":{"id":"/src/parse.ts","moduleParts":{"index.js":"7a21fee1-1"},"imported":[],"importedBy":[{"uid":"7a21fee1-8"},{"uid":"7a21fee1-2"}]},"7a21fee1-2":{"id":"/src/registry.ts","moduleParts":{"index.js":"7a21fee1-3"},"imported":[{"uid":"7a21fee1-11"},{"uid":"7a21fee1-0"}],"importedBy":[{"uid":"7a21fee1-8"},{"uid":"7a21fee1-4"},{"uid":"7a21fee1-6"}]},"7a21fee1-4":{"id":"/src/use-hotkey.ts","moduleParts":{"index.js":"7a21fee1-5"},"imported":[{"uid":"7a21fee1-10"},{"uid":"7a21fee1-2"}],"importedBy":[{"uid":"7a21fee1-8"}]},"7a21fee1-6":{"id":"/src/use-hotkey-scope.ts","moduleParts":{"index.js":"7a21fee1-7"},"imported":[{"uid":"7a21fee1-10"},{"uid":"7a21fee1-2"}],"importedBy":[{"uid":"7a21fee1-8"}]},"7a21fee1-8":{"id":"/src/index.ts","moduleParts":{"index.js":"7a21fee1-9"},"imported":[{"uid":"7a21fee1-4"},{"uid":"7a21fee1-6"},{"uid":"7a21fee1-2"},{"uid":"7a21fee1-0"}],"importedBy":[],"isEntry":true},"7a21fee1-10":{"id":"@pyreon/core","moduleParts":{},"imported":[],"importedBy":[{"uid":"7a21fee1-4"},{"uid":"7a21fee1-6"}]},"7a21fee1-11":{"id":"@pyreon/reactivity","moduleParts":{},"imported":[],"importedBy":[{"uid":"7a21fee1-2"}]}},"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
|
@@ -75,6 +75,7 @@ function isMac() {
|
|
|
75
75
|
const entries = [];
|
|
76
76
|
const activeScopes = signal(new Set(["global"]));
|
|
77
77
|
let listenerAttached = false;
|
|
78
|
+
let keydownHandler = null;
|
|
78
79
|
const INPUT_TAGS = new Set([
|
|
79
80
|
"INPUT",
|
|
80
81
|
"TEXTAREA",
|
|
@@ -91,7 +92,7 @@ function attachListener() {
|
|
|
91
92
|
if (listenerAttached) return;
|
|
92
93
|
if (typeof window === "undefined") return;
|
|
93
94
|
listenerAttached = true;
|
|
94
|
-
|
|
95
|
+
keydownHandler = (event) => {
|
|
95
96
|
const scopes = activeScopes.peek();
|
|
96
97
|
for (const entry of entries) {
|
|
97
98
|
if (!scopes.has(entry.options.scope)) continue;
|
|
@@ -102,7 +103,14 @@ function attachListener() {
|
|
|
102
103
|
if (entry.options.stopPropagation) event.stopPropagation();
|
|
103
104
|
entry.handler(event);
|
|
104
105
|
}
|
|
105
|
-
}
|
|
106
|
+
};
|
|
107
|
+
window.addEventListener("keydown", keydownHandler);
|
|
108
|
+
}
|
|
109
|
+
function detachListener() {
|
|
110
|
+
if (!listenerAttached || !keydownHandler) return;
|
|
111
|
+
window.removeEventListener("keydown", keydownHandler);
|
|
112
|
+
listenerAttached = false;
|
|
113
|
+
keydownHandler = null;
|
|
106
114
|
}
|
|
107
115
|
/**
|
|
108
116
|
* Register a keyboard shortcut. Returns an unregister function.
|
|
@@ -132,6 +140,7 @@ function registerHotkey(shortcut, handler, options) {
|
|
|
132
140
|
return () => {
|
|
133
141
|
const idx = entries.indexOf(entry);
|
|
134
142
|
if (idx !== -1) entries.splice(idx, 1);
|
|
143
|
+
if (entries.length === 0) detachListener();
|
|
135
144
|
};
|
|
136
145
|
}
|
|
137
146
|
/**
|
|
@@ -174,6 +183,7 @@ function getRegisteredHotkeys() {
|
|
|
174
183
|
function _resetHotkeys() {
|
|
175
184
|
entries.length = 0;
|
|
176
185
|
activeScopes.set(new Set(["global"]));
|
|
186
|
+
detachListener();
|
|
177
187
|
}
|
|
178
188
|
|
|
179
189
|
//#endregion
|
package/lib/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","names":[],"sources":["../src/parse.ts","../src/registry.ts","../src/use-hotkey.ts","../src/use-hotkey-scope.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 { signal } from '@pyreon/reactivity'\nimport { matchesCombo, parseShortcut } from './parse'\nimport type { HotkeyEntry, HotkeyOptions } from './types'\n\n// ─── State ───────────────────────────────────────────────────────────────────\n\nconst entries: HotkeyEntry[] = []\nconst activeScopes = signal<Set<string>>(new Set(['global']))\nlet listenerAttached = false\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 (typeof window === 'undefined') return\n listenerAttached = true\n\n window.addEventListener('keydown', (event) => {\n const scopes = activeScopes.peek()\n\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 key match\n if (!matchesCombo(event, entry.combo)) continue\n\n // Match found\n if (entry.options.preventDefault) event.preventDefault()\n if (entry.options.stopPropagation) event.stopPropagation()\n entry.handler(event)\n }\n })\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 const entry: HotkeyEntry = {\n shortcut,\n combo: parseShortcut(shortcut),\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}\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}\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"],"mappings":";;;;AAIA,MAAM,cAAsC;CAC1C,KAAK;CACL,QAAQ;CACR,KAAK;CACL,KAAK;CACL,OAAO;CACP,UAAU;CACV,IAAI;CACJ,MAAM;CACN,MAAM;CACN,OAAO;CACP,MAAM;CACP;;;;;AAMD,SAAgB,cAAc,UAA4B;CACxD,MAAM,QAAQ,SAAS,aAAa,CAAC,MAAM,CAAC,MAAM,IAAI;CACtD,MAAM,QAAkB;EACtB,MAAM;EACN,OAAO;EACP,KAAK;EACL,MAAM;EACN,KAAK;EACN;AAED,MAAK,MAAM,QAAQ,OAAO;EACxB,MAAM,IAAI,KAAK,MAAM;AACrB,MAAI,MAAM,UAAU,MAAM,UACxB,OAAM,OAAO;WACJ,MAAM,QACf,OAAM,QAAQ;WACL,MAAM,MACf,OAAM,MAAM;WACH,MAAM,UAAU,MAAM,SAAS,MAAM,UAC9C,OAAM,OAAO;WACJ,MAAM,MAEf,KAAI,OAAO,CACT,OAAM,OAAO;MAEb,OAAM,OAAO;MAGf,OAAM,MAAM,YAAY,MAAM;;AAIlC,QAAO;;;;;AAMT,SAAgB,aAAa,OAAsB,OAA0B;AAC3E,KAAI,MAAM,YAAY,MAAM,KAAM,QAAO;AACzC,KAAI,MAAM,aAAa,MAAM,MAAO,QAAO;AAC3C,KAAI,MAAM,WAAW,MAAM,IAAK,QAAO;AACvC,KAAI,MAAM,YAAY,MAAM,KAAM,QAAO;AAGzC,QADiB,MAAM,IAAI,aAAa,KACpB,MAAM;;;;;AAM5B,SAAgB,YAAY,OAAyB;CACnD,MAAM,QAAkB,EAAE;AAC1B,KAAI,MAAM,KAAM,OAAM,KAAK,OAAO;AAClC,KAAI,MAAM,MAAO,OAAM,KAAK,QAAQ;AACpC,KAAI,MAAM,IAAK,OAAM,KAAK,MAAM;AAChC,KAAI,MAAM,KAAM,OAAM,KAAK,OAAO,GAAG,MAAM,OAAO;AAClD,OAAM,KAAK,MAAM,IAAI,WAAW,IAAI,MAAM,IAAI,aAAa,GAAG,WAAW,MAAM,IAAI,CAAC;AACpF,QAAO,MAAM,KAAK,IAAI;;AAGxB,SAAS,WAAW,GAAmB;AACrC,QAAO,EAAE,OAAO,EAAE,CAAC,aAAa,GAAG,EAAE,MAAM,EAAE;;AAG/C,SAAS,QAAiB;AACxB,KAAI,OAAO,cAAc,YAAa,QAAO;AAC7C,QAAO,wBAAwB,KAAK,UAAU,UAAU;;;;;AClF1D,MAAM,UAAyB,EAAE;AACjC,MAAM,eAAe,OAAoB,IAAI,IAAI,CAAC,SAAS,CAAC,CAAC;AAC7D,IAAI,mBAAmB;AAIvB,MAAM,aAAa,IAAI,IAAI;CAAC;CAAS;CAAY;CAAS,CAAC;AAE3D,SAAS,eAAe,OAA+B;CACrD,MAAM,SAAS,MAAM;AACrB,KAAI,CAAC,OAAQ,QAAO;AACpB,KAAI,WAAW,IAAI,OAAO,QAAQ,CAAE,QAAO;AAC3C,KAAI,OAAO,kBAAmB,QAAO;AACrC,QAAO;;AAKT,SAAS,iBAAuB;AAC9B,KAAI,iBAAkB;AACtB,KAAI,OAAO,WAAW,YAAa;AACnC,oBAAmB;AAEnB,QAAO,iBAAiB,YAAY,UAAU;EAC5C,MAAM,SAAS,aAAa,MAAM;AAElC,OAAK,MAAM,SAAS,SAAS;AAE3B,OAAI,CAAC,OAAO,IAAI,MAAM,QAAQ,MAAM,CAAE;AAOtC,OAAI,EAHF,OAAO,MAAM,QAAQ,YAAY,aAC7B,MAAM,QAAQ,SAAS,GACvB,MAAM,QAAQ,SACN;AAGd,OAAI,CAAC,MAAM,QAAQ,kBAAkB,eAAe,MAAM,CAAE;AAG5D,OAAI,CAAC,aAAa,OAAO,MAAM,MAAM,CAAE;AAGvC,OAAI,MAAM,QAAQ,eAAgB,OAAM,gBAAgB;AACxD,OAAI,MAAM,QAAQ,gBAAiB,OAAM,iBAAiB;AAC1D,SAAM,QAAQ,MAAM;;GAEtB;;;;;;;;;;;AAcJ,SAAgB,eACd,UACA,SACA,SACY;AACZ,iBAAgB;CAEhB,MAAM,QAAqB;EACzB;EACA,OAAO,cAAc,SAAS;EAC9B;EACA,SAAS;GACP,OAAO,SAAS,SAAS;GACzB,gBAAgB,SAAS,mBAAmB;GAC5C,iBAAiB,SAAS,oBAAoB;GAC9C,gBAAgB,SAAS,mBAAmB;GAC5C,SAAS,SAAS,WAAW;GAC7B,GAAI,SAAS,eAAe,OAAO,EAAE,aAAa,QAAQ,aAAa,GAAG,EAAE;GAC7E;EACF;AAED,SAAQ,KAAK,MAAM;AAEnB,cAAa;EACX,MAAM,MAAM,QAAQ,QAAQ,MAAM;AAClC,MAAI,QAAQ,GAAI,SAAQ,OAAO,KAAK,EAAE;;;;;;AAS1C,SAAgB,YAAY,OAAqB;CAC/C,MAAM,UAAU,aAAa,MAAM;AACnC,KAAI,QAAQ,IAAI,MAAM,CAAE;CACxB,MAAM,OAAO,IAAI,IAAI,QAAQ;AAC7B,MAAK,IAAI,MAAM;AACf,cAAa,IAAI,KAAK;;;;;AAMxB,SAAgB,aAAa,OAAqB;AAChD,KAAI,UAAU,SAAU;CACxB,MAAM,UAAU,aAAa,MAAM;AACnC,KAAI,CAAC,QAAQ,IAAI,MAAM,CAAE;CACzB,MAAM,OAAO,IAAI,IAAI,QAAQ;AAC7B,MAAK,OAAO,MAAM;AAClB,cAAa,IAAI,KAAK;;;;;AAMxB,SAAgB,kBAAuC;AACrD,QAAO;;;;;AAMT,SAAgB,uBAIb;AACD,QAAO,QAAQ,KAAK,OAAO;EACzB,UAAU,EAAE;EACZ,OAAO,EAAE,QAAQ;EACjB,GAAI,EAAE,QAAQ,eAAe,OAAO,EAAE,aAAa,EAAE,QAAQ,aAAa,GAAG,EAAE;EAChF,EAAE;;AAKL,SAAgB,gBAAsB;AACpC,SAAQ,SAAS;AACjB,cAAa,IAAI,IAAI,IAAI,CAAC,SAAS,CAAC,CAAC;;;;;;;;;;;;;;;;;;;ACnIvC,SAAgB,UACd,UACA,SACA,SACM;AAEN,WADmB,eAAe,UAAU,SAAS,QAAQ,CACxC;;;;;;;;;;;;;;;;;;ACRvB,SAAgB,eAAe,OAAqB;AAClD,aAAY,MAAM;AAClB,iBAAgB,aAAa,MAAM,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.js","names":[],"sources":["../src/parse.ts","../src/registry.ts","../src/use-hotkey.ts","../src/use-hotkey-scope.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 { signal } from '@pyreon/reactivity'\nimport { matchesCombo, parseShortcut } from './parse'\nimport type { HotkeyEntry, HotkeyOptions } from './types'\n\n// ─── State ───────────────────────────────────────────────────────────────────\n\nconst entries: HotkeyEntry[] = []\nconst activeScopes = signal<Set<string>>(new Set(['global']))\nlet listenerAttached = false\nlet keydownHandler: ((event: KeyboardEvent) => void) | null = null\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 (typeof window === 'undefined') return\n listenerAttached = true\n\n keydownHandler = (event) => {\n const scopes = activeScopes.peek()\n\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 key match\n if (!matchesCombo(event, entry.combo)) continue\n\n // Match found\n if (entry.options.preventDefault) event.preventDefault()\n if (entry.options.stopPropagation) event.stopPropagation()\n entry.handler(event)\n }\n }\n\n window.addEventListener('keydown', keydownHandler)\n}\n\nfunction detachListener(): void {\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 const entry: HotkeyEntry = {\n shortcut,\n combo: parseShortcut(shortcut),\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 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"],"mappings":"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"}
|
package/lib/types/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index2.d.ts","names":[],"sources":["../../../src/types.ts","../../../src/use-hotkey.ts","../../../src/use-hotkey-scope.ts","../../../src/registry.ts","../../../src/parse.ts"],"mappings":";;;;;;AAMA;UAAiB,QAAA;EACf,IAAA;EACA,KAAA;EACA,GAAA;EACA,IAAA;EACA,GAAA;AAAA;;;;UAMe,aAAA;EAAa;EAE5B,KAAA;EAF4B;EAI5B,cAAA;EAAA;EAEA,eAAA;EAEA;EAAA,cAAA;EAIA;EAFA,WAAA;EAEO;EAAP,OAAA;AAAA;;;;UAMe,WAAA;EASb;EAPF,QAAA;EAMiB;EAJjB,KAAA,EAAO,QAAA;EAFP;EAIA,OAAA,GAAU,KAAA,EAAO,aAAA;EAFV;EAIP,OAAA,EAAS,QAAA,CACP,IAAA,CACE,aAAA;IAGE,WAAA;EAAA;AAAA;;;;;AA1CR;;;;;;;;;;;AAWA;iBCCgB,SAAA,CACd,QAAA,UACA,OAAA,GAAU,KAAA,EAAO,aAAA,WACjB,OAAA,GAAU,aAAA;;;;;;ADfZ;;;;;;;;;;iBEUgB,cAAA,CAAe,KAAA;;;;AFV/B;;;;;;;;
|
|
1
|
+
{"version":3,"file":"index2.d.ts","names":[],"sources":["../../../src/types.ts","../../../src/use-hotkey.ts","../../../src/use-hotkey-scope.ts","../../../src/registry.ts","../../../src/parse.ts"],"mappings":";;;;;;AAMA;UAAiB,QAAA;EACf,IAAA;EACA,KAAA;EACA,GAAA;EACA,IAAA;EACA,GAAA;AAAA;;;;UAMe,aAAA;EAAa;EAE5B,KAAA;EAF4B;EAI5B,cAAA;EAAA;EAEA,eAAA;EAEA;EAAA,cAAA;EAIA;EAFA,WAAA;EAEO;EAAP,OAAA;AAAA;;;;UAMe,WAAA;EASb;EAPF,QAAA;EAMiB;EAJjB,KAAA,EAAO,QAAA;EAFP;EAIA,OAAA,GAAU,KAAA,EAAO,aAAA;EAFV;EAIP,OAAA,EAAS,QAAA,CACP,IAAA,CACE,aAAA;IAGE,WAAA;EAAA;AAAA;;;;;AA1CR;;;;;;;;;;;AAWA;iBCCgB,SAAA,CACd,QAAA,UACA,OAAA,GAAU,KAAA,EAAO,aAAA,WACjB,OAAA,GAAU,aAAA;;;;;;ADfZ;;;;;;;;;;iBEUgB,cAAA,CAAe,KAAA;;;;AFV/B;;;;;;;;iBGyEgB,cAAA,CACd,QAAA,UACA,OAAA,GAAU,KAAA,EAAO,aAAA,WACjB,OAAA,GAAU,aAAA;;;AHjEZ;iBGqGgB,WAAA,CAAY,KAAA;;;;iBAWZ,YAAA,CAAa,KAAA;;;;iBAYb,eAAA,CAAA,GAAmB,MAAA,CAAO,GAAA;;;AH1G1C;iBGiHgB,oBAAA,CAAA,GAAwB,aAAA;EACtC,QAAA;EACA,KAAA;EACA,WAAA;AAAA;AAAA,iBAWc,aAAA,CAAA;;;;;AH5JhB;;iBIgBgB,aAAA,CAAc,QAAA,WAAmB,QAAA;;;;iBAsCjC,YAAA,CAAa,KAAA,EAAO,aAAA,EAAe,KAAA,EAAO,QAAA;;;;iBAa1C,WAAA,CAAY,KAAA,EAAO,QAAA"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pyreon/hotkeys",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "Reactive keyboard shortcut management for Pyreon — scope-aware, conflict detection",
|
|
5
5
|
"homepage": "https://github.com/pyreon/pyreon/tree/main/packages/hotkeys#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -42,12 +42,13 @@
|
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
44
|
"@happy-dom/global-registrator": "^20.8.9",
|
|
45
|
-
"@pyreon/core": "^0.
|
|
46
|
-
"@pyreon/
|
|
45
|
+
"@pyreon/core": "^0.13.0",
|
|
46
|
+
"@pyreon/manifest": "0.13.0",
|
|
47
|
+
"@pyreon/reactivity": "^0.13.0",
|
|
47
48
|
"@vitus-labs/tools-lint": "^1.15.5"
|
|
48
49
|
},
|
|
49
50
|
"peerDependencies": {
|
|
50
|
-
"@pyreon/core": "^0.
|
|
51
|
-
"@pyreon/reactivity": "^0.
|
|
51
|
+
"@pyreon/core": "^0.13.0",
|
|
52
|
+
"@pyreon/reactivity": "^0.13.0"
|
|
52
53
|
}
|
|
53
54
|
}
|
package/src/manifest.ts
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { defineManifest } from '@pyreon/manifest'
|
|
2
|
+
|
|
3
|
+
export default defineManifest({
|
|
4
|
+
name: '@pyreon/hotkeys',
|
|
5
|
+
title: 'Keyboard Shortcuts',
|
|
6
|
+
tagline:
|
|
7
|
+
'Keyboard shortcut management — scope-aware, modifier keys, conflict detection',
|
|
8
|
+
description:
|
|
9
|
+
'Reactive keyboard shortcut management for Pyreon. Register global or scoped shortcuts with automatic lifecycle management. Supports `mod` alias (Command on Mac, Ctrl elsewhere), multi-key combos, scope-based activation for context-aware shortcuts, and conflict detection. Component-scoped hooks auto-unregister on unmount. Imperative API available for non-component contexts.',
|
|
10
|
+
category: 'universal',
|
|
11
|
+
longExample: `import { useHotkey, useHotkeyScope, registerHotkey, getRegisteredHotkeys, enableScope, disableScope } from '@pyreon/hotkeys'
|
|
12
|
+
|
|
13
|
+
// Global shortcut — auto-unregisters on unmount
|
|
14
|
+
useHotkey('mod+s', (e) => {
|
|
15
|
+
e.preventDefault() // prevent browser save dialog
|
|
16
|
+
save()
|
|
17
|
+
}, { description: 'Save document' })
|
|
18
|
+
|
|
19
|
+
// Platform-aware: mod = ⌘ on Mac, Ctrl on Windows/Linux
|
|
20
|
+
useHotkey('mod+k', () => openCommandPalette())
|
|
21
|
+
|
|
22
|
+
// Multi-key combo
|
|
23
|
+
useHotkey('ctrl+shift+p', () => openPreferences())
|
|
24
|
+
|
|
25
|
+
// Scoped shortcuts — only active when scope is enabled
|
|
26
|
+
useHotkeyScope('editor') // activates 'editor' scope for this component's lifetime
|
|
27
|
+
|
|
28
|
+
useHotkey('ctrl+z', () => undo(), { scope: 'editor', description: 'Undo' })
|
|
29
|
+
useHotkey('ctrl+shift+z', () => redo(), { scope: 'editor', description: 'Redo' })
|
|
30
|
+
useHotkey('ctrl+d', () => duplicateLine(), { scope: 'editor' })
|
|
31
|
+
|
|
32
|
+
// Imperative API — for non-component contexts (stores, middleware)
|
|
33
|
+
const unregister = registerHotkey('ctrl+q', () => quit(), { scope: 'global' })
|
|
34
|
+
// unregister() when done
|
|
35
|
+
|
|
36
|
+
// Introspection
|
|
37
|
+
const hotkeys = getRegisteredHotkeys() // all registered shortcuts
|
|
38
|
+
enableScope('modal') // programmatically enable a scope
|
|
39
|
+
disableScope('editor') // programmatically disable a scope
|
|
40
|
+
|
|
41
|
+
// Shortcuts can filter input elements — by default, shortcuts
|
|
42
|
+
// don't fire when focused on <input>, <textarea>, <select>.
|
|
43
|
+
// Override with:
|
|
44
|
+
useHotkey('escape', () => closeModal(), { enableOnFormElements: true })`,
|
|
45
|
+
features: [
|
|
46
|
+
'useHotkey(shortcut, handler, options?) — component-scoped, auto-unregisters on unmount',
|
|
47
|
+
'useHotkeyScope(scope) — activate a scope for a component\'s lifetime',
|
|
48
|
+
'mod alias — Command on Mac, Ctrl elsewhere',
|
|
49
|
+
'Scope-based activation for context-aware shortcuts',
|
|
50
|
+
'Imperative API: registerHotkey, enableScope, disableScope, getRegisteredHotkeys',
|
|
51
|
+
'parseShortcut / matchesCombo / formatCombo utilities',
|
|
52
|
+
],
|
|
53
|
+
api: [
|
|
54
|
+
{
|
|
55
|
+
name: 'useHotkey',
|
|
56
|
+
kind: 'hook',
|
|
57
|
+
signature:
|
|
58
|
+
'(shortcut: string, handler: (e: KeyboardEvent) => void, options?: HotkeyOptions) => void',
|
|
59
|
+
summary:
|
|
60
|
+
'Register a keyboard shortcut that auto-unregisters when the component unmounts. Shortcut format: `mod+s`, `ctrl+shift+p`, `escape`, etc. `mod` is Command on Mac, Ctrl elsewhere. By default, shortcuts don\'t fire when focused on form elements (input, textarea, select) — override with `enableOnFormElements: true`. Supports `scope` option for context-aware activation and `description` for introspection.',
|
|
61
|
+
example: `useHotkey('mod+s', (e) => {
|
|
62
|
+
e.preventDefault()
|
|
63
|
+
save()
|
|
64
|
+
}, { description: 'Save' })
|
|
65
|
+
|
|
66
|
+
useHotkey('ctrl+z', () => undo(), { scope: 'editor' })
|
|
67
|
+
useHotkey('escape', () => close(), { enableOnFormElements: true })`,
|
|
68
|
+
mistakes: [
|
|
69
|
+
'Forgetting e.preventDefault() for browser-reserved shortcuts (mod+s, mod+p) — the browser dialog fires alongside your handler',
|
|
70
|
+
'Registering the same shortcut in overlapping scopes without priority — both handlers fire; use scope isolation to prevent conflicts',
|
|
71
|
+
'Using useHotkey outside a component body — the onUnmount cleanup requires an active component setup context',
|
|
72
|
+
'Not activating the scope — useHotkey with a scope option does nothing unless useHotkeyScope(scope) is called or enableScope(scope) is invoked',
|
|
73
|
+
],
|
|
74
|
+
seeAlso: ['useHotkeyScope', 'registerHotkey'],
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
name: 'useHotkeyScope',
|
|
78
|
+
kind: 'hook',
|
|
79
|
+
signature: '(scope: string) => void',
|
|
80
|
+
summary:
|
|
81
|
+
'Activate a hotkey scope for the lifetime of the current component. When the component mounts, the scope is enabled; when it unmounts, the scope is disabled. Shortcuts registered with a matching `scope` option only fire when the scope is active. Multiple components can activate the same scope — it stays active until the last one unmounts.',
|
|
82
|
+
example: `// In an editor component:
|
|
83
|
+
useHotkeyScope('editor')
|
|
84
|
+
useHotkey('ctrl+z', () => undo(), { scope: 'editor' })
|
|
85
|
+
|
|
86
|
+
// In a modal component:
|
|
87
|
+
useHotkeyScope('modal')
|
|
88
|
+
useHotkey('escape', () => close(), { scope: 'modal' })`,
|
|
89
|
+
mistakes: [
|
|
90
|
+
'Using useHotkeyScope outside a component body — the lifecycle hooks require an active setup context',
|
|
91
|
+
'Assuming scope deactivation is immediate on unmount — if another component also activated the scope, it stays active',
|
|
92
|
+
],
|
|
93
|
+
seeAlso: ['useHotkey', 'enableScope', 'disableScope'],
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
name: 'registerHotkey',
|
|
97
|
+
kind: 'function',
|
|
98
|
+
signature:
|
|
99
|
+
'(shortcut: string, handler: (e: KeyboardEvent) => void, options?: HotkeyOptions) => () => void',
|
|
100
|
+
summary:
|
|
101
|
+
'Imperative hotkey registration for non-component contexts (stores, global setup). Returns an unregister function. Unlike useHotkey, this does NOT auto-cleanup on unmount — caller is responsible for calling the returned unregister function.',
|
|
102
|
+
example: `const unregister = registerHotkey('ctrl+q', () => quit(), { scope: 'global' })
|
|
103
|
+
// Later:
|
|
104
|
+
unregister()`,
|
|
105
|
+
seeAlso: ['useHotkey'],
|
|
106
|
+
},
|
|
107
|
+
],
|
|
108
|
+
gotchas: [
|
|
109
|
+
'By default, shortcuts do NOT fire when focused on form elements (input, textarea, select). Pass `enableOnFormElements: true` in options to override. Escape is a common candidate for this override.',
|
|
110
|
+
{
|
|
111
|
+
label: 'mod alias',
|
|
112
|
+
note: '`mod` maps to Command on macOS, Ctrl on Windows/Linux. Write `mod+s` instead of platform-specific `ctrl+s` / `cmd+s` for cross-platform shortcuts.',
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
label: 'Scopes',
|
|
116
|
+
note: 'Scoped shortcuts only fire when their scope is active. Activate with `useHotkeyScope(scope)` (component-scoped) or `enableScope(scope)` (imperative). Without activation, scoped handlers are silently dormant.',
|
|
117
|
+
},
|
|
118
|
+
],
|
|
119
|
+
})
|
package/src/registry.ts
CHANGED
|
@@ -8,6 +8,7 @@ import type { HotkeyEntry, HotkeyOptions } from './types'
|
|
|
8
8
|
const entries: HotkeyEntry[] = []
|
|
9
9
|
const activeScopes = signal<Set<string>>(new Set(['global']))
|
|
10
10
|
let listenerAttached = false
|
|
11
|
+
let keydownHandler: ((event: KeyboardEvent) => void) | null = null
|
|
11
12
|
|
|
12
13
|
// ─── Input detection ─────────────────────────────────────────────────────────
|
|
13
14
|
|
|
@@ -28,7 +29,7 @@ function attachListener(): void {
|
|
|
28
29
|
if (typeof window === 'undefined') return
|
|
29
30
|
listenerAttached = true
|
|
30
31
|
|
|
31
|
-
|
|
32
|
+
keydownHandler = (event) => {
|
|
32
33
|
const scopes = activeScopes.peek()
|
|
33
34
|
|
|
34
35
|
for (const entry of entries) {
|
|
@@ -53,7 +54,16 @@ function attachListener(): void {
|
|
|
53
54
|
if (entry.options.stopPropagation) event.stopPropagation()
|
|
54
55
|
entry.handler(event)
|
|
55
56
|
}
|
|
56
|
-
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
window.addEventListener('keydown', keydownHandler)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function detachListener(): void {
|
|
63
|
+
if (!listenerAttached || !keydownHandler) return
|
|
64
|
+
window.removeEventListener('keydown', keydownHandler)
|
|
65
|
+
listenerAttached = false
|
|
66
|
+
keydownHandler = null
|
|
57
67
|
}
|
|
58
68
|
|
|
59
69
|
// ─── Registration ────────────────────────────────────────────────────────────
|
|
@@ -93,6 +103,11 @@ export function registerHotkey(
|
|
|
93
103
|
return () => {
|
|
94
104
|
const idx = entries.indexOf(entry)
|
|
95
105
|
if (idx !== -1) entries.splice(idx, 1)
|
|
106
|
+
|
|
107
|
+
// Detach listener if no more entries
|
|
108
|
+
if (entries.length === 0) {
|
|
109
|
+
detachListener()
|
|
110
|
+
}
|
|
96
111
|
}
|
|
97
112
|
}
|
|
98
113
|
|
|
@@ -143,9 +158,10 @@ export function getRegisteredHotkeys(): ReadonlyArray<{
|
|
|
143
158
|
}))
|
|
144
159
|
}
|
|
145
160
|
|
|
146
|
-
// ─── Reset (for testing)
|
|
161
|
+
// ─── Reset (for testing) ────────────────────────────────────────────────
|
|
147
162
|
|
|
148
163
|
export function _resetHotkeys(): void {
|
|
149
164
|
entries.length = 0
|
|
150
165
|
activeScopes.set(new Set(['global']))
|
|
166
|
+
detachListener()
|
|
151
167
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
2
2
|
import {
|
|
3
3
|
_resetHotkeys,
|
|
4
4
|
disableScope,
|
|
@@ -457,3 +457,65 @@ describe('getRegisteredHotkeys', () => {
|
|
|
457
457
|
expect(getRegisteredHotkeys()).toHaveLength(0)
|
|
458
458
|
})
|
|
459
459
|
})
|
|
460
|
+
|
|
461
|
+
describe('Event listener cleanup', () => {
|
|
462
|
+
beforeEach(() => {
|
|
463
|
+
_resetHotkeys()
|
|
464
|
+
})
|
|
465
|
+
|
|
466
|
+
afterEach(() => {
|
|
467
|
+
_resetHotkeys()
|
|
468
|
+
})
|
|
469
|
+
|
|
470
|
+
it('attaches listener on first registration', () => {
|
|
471
|
+
const addEventListenerSpy = vi.spyOn(window, 'addEventListener')
|
|
472
|
+
const noop = () => {}
|
|
473
|
+
|
|
474
|
+
registerHotkey('ctrl+s', noop)
|
|
475
|
+
|
|
476
|
+
expect(addEventListenerSpy).toHaveBeenCalledWith('keydown', expect.any(Function))
|
|
477
|
+
addEventListenerSpy.mockRestore()
|
|
478
|
+
})
|
|
479
|
+
|
|
480
|
+
it('detaches listener when all hotkeys are unregistered', () => {
|
|
481
|
+
const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener')
|
|
482
|
+
const noop = () => {}
|
|
483
|
+
|
|
484
|
+
const unsub1 = registerHotkey('ctrl+s', noop)
|
|
485
|
+
const unsub2 = registerHotkey('ctrl+z', noop)
|
|
486
|
+
|
|
487
|
+
// Both registered — listener still attached
|
|
488
|
+
unsub1()
|
|
489
|
+
expect(removeEventListenerSpy).not.toHaveBeenCalled()
|
|
490
|
+
|
|
491
|
+
// Last one unregistered — listener should detach
|
|
492
|
+
unsub2()
|
|
493
|
+
expect(removeEventListenerSpy).toHaveBeenCalledWith('keydown', expect.any(Function))
|
|
494
|
+
|
|
495
|
+
removeEventListenerSpy.mockRestore()
|
|
496
|
+
})
|
|
497
|
+
|
|
498
|
+
it('reattaches listener when new hotkey registered after cleanup', () => {
|
|
499
|
+
const addEventListenerSpy = vi.spyOn(window, 'addEventListener')
|
|
500
|
+
const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener')
|
|
501
|
+
const noop = () => {}
|
|
502
|
+
|
|
503
|
+
const unsub1 = registerHotkey('ctrl+s', noop)
|
|
504
|
+
expect(addEventListenerSpy).toHaveBeenCalledTimes(1)
|
|
505
|
+
|
|
506
|
+
unsub1()
|
|
507
|
+
expect(removeEventListenerSpy).toHaveBeenCalledTimes(1)
|
|
508
|
+
|
|
509
|
+
// Register new hotkey — should reattach
|
|
510
|
+
registerHotkey('ctrl+z', noop)
|
|
511
|
+
expect(addEventListenerSpy).toHaveBeenCalledTimes(2)
|
|
512
|
+
|
|
513
|
+
addEventListenerSpy.mockRestore()
|
|
514
|
+
removeEventListenerSpy.mockRestore()
|
|
515
|
+
})
|
|
516
|
+
|
|
517
|
+
it('preserves vi for test environment', () => {
|
|
518
|
+
// This test just verifies vi is available (vitest spy helpers)
|
|
519
|
+
expect(typeof vi).toBe('object')
|
|
520
|
+
})
|
|
521
|
+
})
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import {
|
|
2
|
+
renderApiReferenceEntries,
|
|
3
|
+
renderLlmsFullSection,
|
|
4
|
+
renderLlmsTxtLine,
|
|
5
|
+
} from '@pyreon/manifest'
|
|
6
|
+
import manifest from '../manifest'
|
|
7
|
+
|
|
8
|
+
describe('gen-docs — hotkeys snapshot', () => {
|
|
9
|
+
it('renders to llms.txt bullet', () => {
|
|
10
|
+
expect(renderLlmsTxtLine(manifest)).toMatchInlineSnapshot(`"- @pyreon/hotkeys — Keyboard shortcut management — scope-aware, modifier keys, conflict detection. By default, shortcuts do NOT fire when focused on form elements (input, textarea, select). Pass \`enableOnFormElements: true\` in options to override. Escape is a common candidate for this override."`)
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('renders to llms-full.txt section', () => {
|
|
14
|
+
expect(renderLlmsFullSection(manifest)).toMatchInlineSnapshot(`
|
|
15
|
+
"## @pyreon/hotkeys — Keyboard Shortcuts
|
|
16
|
+
|
|
17
|
+
Reactive keyboard shortcut management for Pyreon. Register global or scoped shortcuts with automatic lifecycle management. Supports \`mod\` alias (Command on Mac, Ctrl elsewhere), multi-key combos, scope-based activation for context-aware shortcuts, and conflict detection. Component-scoped hooks auto-unregister on unmount. Imperative API available for non-component contexts.
|
|
18
|
+
|
|
19
|
+
\`\`\`typescript
|
|
20
|
+
import { useHotkey, useHotkeyScope, registerHotkey, getRegisteredHotkeys, enableScope, disableScope } from '@pyreon/hotkeys'
|
|
21
|
+
|
|
22
|
+
// Global shortcut — auto-unregisters on unmount
|
|
23
|
+
useHotkey('mod+s', (e) => {
|
|
24
|
+
e.preventDefault() // prevent browser save dialog
|
|
25
|
+
save()
|
|
26
|
+
}, { description: 'Save document' })
|
|
27
|
+
|
|
28
|
+
// Platform-aware: mod = ⌘ on Mac, Ctrl on Windows/Linux
|
|
29
|
+
useHotkey('mod+k', () => openCommandPalette())
|
|
30
|
+
|
|
31
|
+
// Multi-key combo
|
|
32
|
+
useHotkey('ctrl+shift+p', () => openPreferences())
|
|
33
|
+
|
|
34
|
+
// Scoped shortcuts — only active when scope is enabled
|
|
35
|
+
useHotkeyScope('editor') // activates 'editor' scope for this component's lifetime
|
|
36
|
+
|
|
37
|
+
useHotkey('ctrl+z', () => undo(), { scope: 'editor', description: 'Undo' })
|
|
38
|
+
useHotkey('ctrl+shift+z', () => redo(), { scope: 'editor', description: 'Redo' })
|
|
39
|
+
useHotkey('ctrl+d', () => duplicateLine(), { scope: 'editor' })
|
|
40
|
+
|
|
41
|
+
// Imperative API — for non-component contexts (stores, middleware)
|
|
42
|
+
const unregister = registerHotkey('ctrl+q', () => quit(), { scope: 'global' })
|
|
43
|
+
// unregister() when done
|
|
44
|
+
|
|
45
|
+
// Introspection
|
|
46
|
+
const hotkeys = getRegisteredHotkeys() // all registered shortcuts
|
|
47
|
+
enableScope('modal') // programmatically enable a scope
|
|
48
|
+
disableScope('editor') // programmatically disable a scope
|
|
49
|
+
|
|
50
|
+
// Shortcuts can filter input elements — by default, shortcuts
|
|
51
|
+
// don't fire when focused on <input>, <textarea>, <select>.
|
|
52
|
+
// Override with:
|
|
53
|
+
useHotkey('escape', () => closeModal(), { enableOnFormElements: true })
|
|
54
|
+
\`\`\`
|
|
55
|
+
|
|
56
|
+
> **Note**: By default, shortcuts do NOT fire when focused on form elements (input, textarea, select). Pass \`enableOnFormElements: true\` in options to override. Escape is a common candidate for this override.
|
|
57
|
+
>
|
|
58
|
+
> **mod alias**: \`mod\` maps to Command on macOS, Ctrl on Windows/Linux. Write \`mod+s\` instead of platform-specific \`ctrl+s\` / \`cmd+s\` for cross-platform shortcuts.
|
|
59
|
+
>
|
|
60
|
+
> **Scopes**: Scoped shortcuts only fire when their scope is active. Activate with \`useHotkeyScope(scope)\` (component-scoped) or \`enableScope(scope)\` (imperative). Without activation, scoped handlers are silently dormant.
|
|
61
|
+
"
|
|
62
|
+
`)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('renders to MCP api-reference entries', () => {
|
|
66
|
+
const record = renderApiReferenceEntries(manifest)
|
|
67
|
+
expect(Object.keys(record).length).toBe(3)
|
|
68
|
+
})
|
|
69
|
+
})
|