@keybindy/react 1.1.12 → 2.0.1
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 +260 -125
- package/dist/Keybindy.js +3 -59
- package/dist/index.d.ts +146 -81
- package/dist/index.js +2 -2
- package/dist/useKeybindy.js +112 -8
- package/dist/useShortcuts.js +151 -0
- package/package.json +7 -2
- package/dist/ShortcutLabel.js +0 -94
package/README.md
CHANGED
|
@@ -1,26 +1,46 @@
|
|
|
1
1
|
# @keybindy/react
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
<p align="center">
|
|
4
|
+
<strong>Modern, rock-solid React hooks and components for keyboard shortcuts.</strong><br />
|
|
5
|
+
<em>Zero lifecycle blinking. Zero stale closures. 100% headless.</em>
|
|
6
|
+
</p>
|
|
7
|
+
|
|
8
|
+
<p align="center">
|
|
9
|
+
<a href="https://www.npmjs.com/package/@keybindy/react"><img src="https://img.shields.io/npm/v/@keybindy/react.svg?style=flat&colorA=18181B&colorB=3B82F6" alt="npm version" /></a>
|
|
10
|
+
<a href="https://bundlephobia.com/package/@keybindy/react"><img src="https://img.shields.io/bundlephobia/minzip/@keybindy/react?style=flat&colorA=18181B&colorB=10B981" alt="minzipped size" /></a>
|
|
11
|
+
<a href="https://github.com/keybindyjs/keybindy/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg?style=flat&colorA=18181B&colorB=6366F1" alt="MIT License" /></a>
|
|
12
|
+
<a href="https://www.typescriptlang.org/"><img src="https://img.shields.io/badge/TypeScript-Ready-blue?style=flat&colorA=18181B&colorB=3178C6" alt="TypeScript" /></a>
|
|
13
|
+
</p>
|
|
4
14
|
|
|
5
|
-
|
|
6
|
-
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
Most React keyboard shortcut hooks fail in subtle, frustrating ways:
|
|
18
|
+
1. **The "Blinking" Problem**: Every state change re-renders the component, causing the hook to unregister and re-register the hotkey. Rapid typing or animations cause micro-gaps where shortcuts are dropped.
|
|
19
|
+
2. **Stale Closures**: Forgetting to update dependency arrays traps shortcuts with initial state values.
|
|
20
|
+
3. **Modal Leaks**: Closing a dialog forgets to re-enable background shortcuts or corrupts the active scope.
|
|
21
|
+
|
|
22
|
+
**`@keybindy/react`** completely eliminates these bugs with a **stable ref architecture**. Handlers always execute with the freshest state without ever re-registering the listener.
|
|
7
23
|
|
|
8
24
|
---
|
|
9
25
|
|
|
10
|
-
##
|
|
26
|
+
## Why `@keybindy/react`?
|
|
11
27
|
|
|
12
|
-
|
|
28
|
+
- ⚡️ **Zero Lifecycle Blinking** — Listeners register once on mount and unregister on unmount. No keystrokes are ever dropped during state updates.
|
|
29
|
+
- 🔄 **Always Fresh State** — Access your component's latest state and props inside callbacks without stale closures.
|
|
30
|
+
- 🎯 **Modal Isolation & Cascading Tools** — Effortlessly trap shortcuts in modals or cascade layered hotkeys.
|
|
31
|
+
- 🛡 **Hooks & Components** — Use `useShortcut`, `useShortcuts`, `useShortcutManager`, or `<Keybindy />` JSX.
|
|
32
|
+
- ⚡️ **SSR & Next.js App Router Ready** — Client-safe initialization that never crashes during server rendering.
|
|
13
33
|
|
|
14
34
|
---
|
|
15
35
|
|
|
16
|
-
## Installation
|
|
36
|
+
## 📦 Installation
|
|
17
37
|
|
|
18
38
|
```bash
|
|
19
39
|
# npm
|
|
20
40
|
npm install @keybindy/react
|
|
21
41
|
|
|
22
|
-
#
|
|
23
|
-
|
|
42
|
+
# pnpm
|
|
43
|
+
pnpm add @keybindy/react
|
|
24
44
|
|
|
25
45
|
# bun
|
|
26
46
|
bun add @keybindy/react
|
|
@@ -28,162 +48,277 @@ bun add @keybindy/react
|
|
|
28
48
|
|
|
29
49
|
---
|
|
30
50
|
|
|
31
|
-
##
|
|
51
|
+
## 🚀 Quick Start
|
|
32
52
|
|
|
33
|
-
|
|
53
|
+
### 1. The `useShortcut` Hook (Single Shortcut)
|
|
34
54
|
|
|
35
|
-
The
|
|
55
|
+
The cleanest, most ergonomic way to bind hotkeys in any functional component:
|
|
36
56
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
| `onShortcutFired` | `fn(info: Shortcut)` | `undefined` | Optional callback to handle shortcut firing events. |
|
|
41
|
-
| `disabled` | `boolean` | `false` | Whether to disable all shortcuts within the component's scope. |
|
|
42
|
-
| `scope` | `string` | `global` | The scope to apply the shortcuts to. |
|
|
43
|
-
| `shortcuts` | `Shortcut[]` | `[]` | Array of shortcut objects to register. |
|
|
44
|
-
| `children` | `React.ReactNode` | `undefined` | The content to render inside the component. |
|
|
57
|
+
```tsx
|
|
58
|
+
import { useState } from 'react';
|
|
59
|
+
import { useShortcut } from '@keybindy/react';
|
|
45
60
|
|
|
46
|
-
|
|
61
|
+
function DocumentEditor() {
|
|
62
|
+
const [content, setContent] = useState('');
|
|
47
63
|
|
|
48
|
-
|
|
49
|
-
|
|
64
|
+
// ⚡️ Always accesses the latest `content` state without re-registering!
|
|
65
|
+
useShortcut(['Ctrl', 'S'], (event) => {
|
|
66
|
+
saveDocument(content);
|
|
67
|
+
}, {
|
|
68
|
+
preventDefault: true,
|
|
69
|
+
ignoreInputs: true, // Won't trigger if user is typing in a textarea
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// Cross-platform Command/Ctrl + K
|
|
73
|
+
useShortcut([['Meta', 'K'], ['Ctrl', 'K']], () => {
|
|
74
|
+
openSearchPalette();
|
|
75
|
+
}, { preventDefault: true });
|
|
76
|
+
|
|
77
|
+
return <textarea value={content} onChange={e => setContent(e.target.value)} />;
|
|
78
|
+
}
|
|
79
|
+
```
|
|
50
80
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
### 2. The `useShortcuts` Hook (Multiple Shortcuts)
|
|
84
|
+
|
|
85
|
+
Great for components with many hotkeys (e.g. video players, canvas apps, tables):
|
|
86
|
+
|
|
87
|
+
```tsx
|
|
88
|
+
import { useShortcuts } from '@keybindy/react';
|
|
89
|
+
|
|
90
|
+
function VideoPlayer({ isPlaying, onPlayPause, onSeekForward, onSeekBackward }) {
|
|
91
|
+
useShortcuts([
|
|
54
92
|
{
|
|
55
|
-
keys: ['
|
|
56
|
-
handler:
|
|
57
|
-
options: {
|
|
58
|
-
preventDefault: true,
|
|
59
|
-
},
|
|
93
|
+
keys: ['Space'],
|
|
94
|
+
handler: onPlayPause,
|
|
95
|
+
options: { preventDefault: true },
|
|
60
96
|
},
|
|
61
97
|
{
|
|
62
|
-
keys: ['
|
|
63
|
-
handler: () =>
|
|
64
|
-
options: {
|
|
65
|
-
sequenceDelay: 1000,
|
|
66
|
-
sequential: true,
|
|
67
|
-
preventDefault: true,
|
|
68
|
-
},
|
|
98
|
+
keys: ['ArrowRight'],
|
|
99
|
+
handler: () => onSeekForward(5),
|
|
100
|
+
options: { preventDefault: true },
|
|
69
101
|
},
|
|
70
102
|
{
|
|
71
|
-
keys: ['
|
|
72
|
-
handler: () =>
|
|
73
|
-
options: {
|
|
74
|
-
preventDefault: true,
|
|
75
|
-
},
|
|
103
|
+
keys: ['ArrowLeft'],
|
|
104
|
+
handler: () => onSeekBackward(5),
|
|
105
|
+
options: { preventDefault: true },
|
|
76
106
|
},
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
107
|
+
{
|
|
108
|
+
// Push-to-talk / Hold action
|
|
109
|
+
keys: ['M'],
|
|
110
|
+
handler: (e, state) => setTemporaryMute(state === 'down'),
|
|
111
|
+
options: { hold: true },
|
|
112
|
+
},
|
|
113
|
+
], {
|
|
114
|
+
scope: 'video-player',
|
|
115
|
+
});
|
|
80
116
|
|
|
81
|
-
|
|
117
|
+
return <div>{/* Player UI */}</div>;
|
|
118
|
+
}
|
|
119
|
+
```
|
|
82
120
|
|
|
83
|
-
|
|
121
|
+
---
|
|
84
122
|
|
|
85
|
-
|
|
86
|
-
| ----------- | ---------------------------------------------- | ----------- | ---------------------------------------------- |
|
|
87
|
-
| `keys` | `Keys` | `[]` | The key combination(s) to display. |
|
|
88
|
-
| `renderKey` | `fn(key: string, index: number, keys: Keys[])` | `undefined` | Custom renderer for each key badge or segment. |
|
|
123
|
+
### 3. The `<Keybindy />` Component (Declarative JSX)
|
|
89
124
|
|
|
90
|
-
|
|
125
|
+
If you prefer wrapping views declaratively, `<Keybindy />` provides the same rock-solid behavior in JSX:
|
|
91
126
|
|
|
92
|
-
```
|
|
93
|
-
import {
|
|
127
|
+
```tsx
|
|
128
|
+
import { Keybindy } from '@keybindy/react';
|
|
94
129
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
130
|
+
function App() {
|
|
131
|
+
return (
|
|
132
|
+
<Keybindy
|
|
133
|
+
scope="global"
|
|
134
|
+
shortcuts={[
|
|
135
|
+
{
|
|
136
|
+
keys: ['Ctrl', 'S'],
|
|
137
|
+
handler: () => console.log('Saved!'),
|
|
138
|
+
options: { preventDefault: true },
|
|
139
|
+
},
|
|
140
|
+
]}
|
|
141
|
+
>
|
|
142
|
+
<MainLayout />
|
|
143
|
+
</Keybindy>
|
|
144
|
+
);
|
|
145
|
+
}
|
|
104
146
|
```
|
|
105
147
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
| [`disableAll()`](https://github.com/keybindy/core#enableall--disableall) | Disable all shortcuts (global or scoped) |
|
|
121
|
-
| [`setScope()`](https://github.com/keybindy/core#setactivescope) | Set the active scope |
|
|
122
|
-
| [`resetScope()`](https://github.com/keybindy/core#resetScope) | Reset to default scope |
|
|
123
|
-
| [`getScopes()`](https://github.com/keybindy/core#getScopes) | Get all defined scopes |
|
|
124
|
-
| [`getActiveScope()`](https://github.com/keybindy/core#getActiveScope) | Get the current active scope |
|
|
125
|
-
| [`popScope()`](https://github.com/keybindy/core#popScope) | Remove the top scope from the scope stack |
|
|
126
|
-
| [`pushScope()`](https://github.com/keybindy/core#pushScope) | Push a new scope onto the scope stack |
|
|
127
|
-
| [`getCheatSheet()`](https://github.com/keybindy/core#getCheatsheet) | Retrieve all shortcuts (optionally by scope) |
|
|
128
|
-
| [`onTyping()`](https://github.com/keybindy/core#onTyping) | Listen to every key press |
|
|
129
|
-
| [`destroy()`](https://github.com/keybindy/core#destroy) | Tear down the current manager instance |
|
|
130
|
-
| [`clear()`](https://github.com/keybindy/core#clear) | Unregister all shortcuts |
|
|
131
|
-
| [`getScopeInfo()`](https://github.com/keybindy/core#getScopeInfo) | Retrieve metadata about a specific scope |
|
|
132
|
-
| [`isScopeActive()`](https://github.com/keybindy/core#isScopeActive) | Check if a scope is currently active |
|
|
133
|
-
|
|
134
|
-
> _All methods mirror `@keybindy/core` with a React-friendly API._
|
|
135
|
-
|
|
136
|
-
##### Example
|
|
137
|
-
|
|
138
|
-
```ts
|
|
139
|
-
import { useKeybindy } from '@keybindy/react';
|
|
140
|
-
|
|
141
|
-
const { register, unregister, setScope, getCheatSheet } = useKeybindy();
|
|
142
|
-
|
|
143
|
-
React.useEffect(() => {
|
|
144
|
-
register(
|
|
145
|
-
['ctrl', 'k'],
|
|
146
|
-
() => {
|
|
147
|
-
console.log('Shortcut fired!');
|
|
148
|
+
---
|
|
149
|
+
|
|
150
|
+
## 🎯 Scoping: Modals vs. Layered Tools
|
|
151
|
+
|
|
152
|
+
### A. Modal Isolation (`default` mode)
|
|
153
|
+
When opening a modal or dialog, you want to **trap hotkeys** so background shortcuts cannot fire. When the modal unmounts, background shortcuts are automatically restored:
|
|
154
|
+
|
|
155
|
+
```tsx
|
|
156
|
+
function DeleteConfirmationModal({ isOpen, onClose, onDelete }) {
|
|
157
|
+
// Opening this modal automatically deactivates global shortcuts
|
|
158
|
+
useShortcuts([
|
|
159
|
+
{
|
|
160
|
+
keys: ['Enter'],
|
|
161
|
+
handler: onDelete,
|
|
148
162
|
},
|
|
149
163
|
{
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
164
|
+
keys: ['Esc'],
|
|
165
|
+
handler: onClose,
|
|
166
|
+
options: { enableInInput: true }, // Escape works even inside modal inputs
|
|
167
|
+
},
|
|
168
|
+
], {
|
|
169
|
+
scope: 'delete-dialog',
|
|
170
|
+
disabled: !isOpen,
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
if (!isOpen) return null;
|
|
174
|
+
return <div className="modal">Are you sure?</div>;
|
|
175
|
+
}
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
---
|
|
179
|
+
|
|
180
|
+
### B. Layered Tools with Priority (`cascade` mode)
|
|
181
|
+
In Figma / Photoshop style apps, global canvas shortcuts (like `Space` to pan or `Z` to zoom) should continue working while editing in a sub-tool, but sub-tool shortcuts should override colliding keys:
|
|
182
|
+
|
|
183
|
+
```tsx
|
|
184
|
+
// 1. Root Canvas (in cascade mode)
|
|
185
|
+
function CanvasApp() {
|
|
186
|
+
return (
|
|
187
|
+
<Keybindy scopeMode="cascade" scope="canvas" shortcuts={[
|
|
188
|
+
{ keys: ['Space'], handler: panCanvas, options: { hold: true } },
|
|
189
|
+
{ keys: ['V'], handler: selectTool },
|
|
190
|
+
]}>
|
|
191
|
+
<Toolbox />
|
|
192
|
+
<TextLayerEditor />
|
|
193
|
+
</Keybindy>
|
|
153
194
|
);
|
|
195
|
+
}
|
|
154
196
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
197
|
+
// 2. Focused Text Layer (higher priority weight)
|
|
198
|
+
function TextLayerEditor() {
|
|
199
|
+
useShortcuts([
|
|
200
|
+
{
|
|
201
|
+
keys: ['V'], // Overrides global 'V' tool while text editor is focused
|
|
202
|
+
handler: pastePlainText,
|
|
203
|
+
}
|
|
204
|
+
], {
|
|
205
|
+
scope: 'text-editor',
|
|
206
|
+
priority: 100, // Higher priority wins colliding keys
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// 3. Isolated Modal inside a cascading app
|
|
211
|
+
function SettingsModal({ isOpen, onClose }) {
|
|
212
|
+
// 💡 Want to trap shortcuts in a specific modal and block parent cascading?
|
|
213
|
+
// Pass scopeMode="default" to isolate this child from parent shortcuts!
|
|
214
|
+
useShortcuts([
|
|
215
|
+
{ keys: ['Esc'], handler: onClose, options: { enableInInput: true } }
|
|
216
|
+
], {
|
|
217
|
+
scope: 'settings-modal',
|
|
218
|
+
scopeMode: 'default', // Traps shortcuts: parent canvas keys won't fire
|
|
219
|
+
disabled: !isOpen,
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
if (!isOpen) return null;
|
|
223
|
+
return <div className="modal">Settings</div>;
|
|
224
|
+
}
|
|
159
225
|
```
|
|
160
226
|
|
|
227
|
+
> [!TIP]
|
|
228
|
+
> **Isolating a Child from Parent Cascading**: If your parent container is in `scopeMode="cascade"`, but you want a specific modal or dialog to **trap and block** all parent hotkeys, simply pass `scopeMode="default"` to that child `<Keybindy />` or `useShortcuts` / `useShortcut`. When the modal closes or unmounts, the parent's `cascade` mode is automatically restored.
|
|
229
|
+
|
|
161
230
|
---
|
|
162
231
|
|
|
163
|
-
##
|
|
232
|
+
## 🛡 Guard & Interceptor Hooks
|
|
233
|
+
|
|
234
|
+
Hook into shortcut lifecycles cleanly from any component:
|
|
235
|
+
|
|
236
|
+
```tsx
|
|
237
|
+
import { useBeforeShortcut, useAfterShortcut } from '@keybindy/react';
|
|
238
|
+
|
|
239
|
+
function GlobalHotkeysManager() {
|
|
240
|
+
// 🛑 Guard: Block all shortcuts while an async mutation is pending
|
|
241
|
+
useBeforeShortcut((shortcut, event) => {
|
|
242
|
+
if (isSaving) {
|
|
243
|
+
console.warn('Action blocked: Save in progress.');
|
|
244
|
+
return false; // Returning false cancels the shortcut
|
|
245
|
+
}
|
|
246
|
+
});
|
|
164
247
|
|
|
165
|
-
|
|
248
|
+
// 📊 Interceptor: Log analytics after shortcuts execute
|
|
249
|
+
useAfterShortcut((shortcut, event) => {
|
|
250
|
+
analytics.track('Shortcut Fired', { keys: shortcut.keys });
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
```
|
|
166
254
|
|
|
167
255
|
---
|
|
168
256
|
|
|
169
|
-
##
|
|
257
|
+
## 🛠 Programmatic Manager: `useShortcutManager`
|
|
258
|
+
|
|
259
|
+
When you need direct programmatic control over scopes, priorities, or cheat sheets:
|
|
260
|
+
|
|
261
|
+
```tsx
|
|
262
|
+
import { useShortcutManager } from '@keybindy/react';
|
|
170
263
|
|
|
171
|
-
|
|
264
|
+
function ShortcutsHelpModal() {
|
|
265
|
+
const { getCheatSheet, setScope, getActiveScope } = useShortcutManager();
|
|
172
266
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
267
|
+
const allShortcuts = getCheatSheet();
|
|
268
|
+
|
|
269
|
+
return (
|
|
270
|
+
<dialog>
|
|
271
|
+
<h2>Keyboard Shortcuts</h2>
|
|
272
|
+
{allShortcuts.map((s, i) => (
|
|
273
|
+
<div key={i}>
|
|
274
|
+
<kbd>{s.keys.join(' + ')}</kbd>
|
|
275
|
+
<span>{s.data?.description}</span>
|
|
276
|
+
</div>
|
|
277
|
+
))}
|
|
278
|
+
</dialog>
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
```
|
|
282
|
+
*(Note: `useKeybindy` is retained as an exact alias to `useShortcutManager`)*.
|
|
178
283
|
|
|
179
284
|
---
|
|
180
285
|
|
|
181
|
-
##
|
|
286
|
+
## 📝 Input Handling
|
|
182
287
|
|
|
183
|
-
|
|
288
|
+
Prevent shortcuts from firing while users type in `<input>`, `<textarea>`, `<select>`, or `contenteditable` elements:
|
|
184
289
|
|
|
185
|
-
|
|
290
|
+
```tsx
|
|
291
|
+
// Ignore inputs by default for this shortcut
|
|
292
|
+
useShortcut(['D'], deleteItem, { ignoreInputs: true });
|
|
293
|
+
|
|
294
|
+
// Allow this shortcut even while typing in an input
|
|
295
|
+
useShortcut(['Esc'], clearSearch, { enableInInput: true });
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
---
|
|
299
|
+
|
|
300
|
+
## 📖 Hook Options Reference
|
|
301
|
+
|
|
302
|
+
### `useShortcut(keys, handler, options?)`
|
|
303
|
+
|
|
304
|
+
| Option | Type | Default | Description |
|
|
305
|
+
| :--- | :--- | :--- | :--- |
|
|
306
|
+
| `scope` | `string` | `'global'` | Scope context for the shortcut. |
|
|
307
|
+
| `scopeMode` | `'default' \| 'cascade'` | `'default'` | Scope resolution behavior. |
|
|
308
|
+
| `priority` | `number` | `undefined` | Numeric priority weight for cascade mode (e.g. `100`). |
|
|
309
|
+
| `disabled` | `boolean` | `false` | Disable the shortcut without unmounting. |
|
|
310
|
+
| `preventDefault` | `boolean` | `false` | Calls `event.preventDefault()`. |
|
|
311
|
+
| `stopPropagation` | `boolean` | `false` | Calls `event.stopPropagation()`. |
|
|
312
|
+
| `sequential` | `boolean` | `false` | Treat keys as a sequence (e.g. `['G', 'D']`). |
|
|
313
|
+
| `sequenceDelay` | `number` | `1000` | Max milliseconds between sequential keys. |
|
|
314
|
+
| `hold` | `boolean` | `false` | Triggers handler with `state: 'down' \| 'up'`. |
|
|
315
|
+
| `repeat` | `boolean` | `false` | Allow continuous firing when holding key. |
|
|
316
|
+
| `ignoreInputs` | `boolean` | `false` | Ignore shortcut when typing in inputs/textareas. |
|
|
317
|
+
| `enableInInput` | `boolean` | `false` | Explicitly enable shortcut while typing in inputs. |
|
|
318
|
+
| `data` | `object` | `{}` | Custom metadata for cheat sheets. |
|
|
186
319
|
|
|
187
320
|
---
|
|
188
321
|
|
|
189
|
-
|
|
322
|
+
## 📄 License
|
|
323
|
+
|
|
324
|
+
MIT © [Keybindy Contributors](https://github.com/keybindyjs/keybindy)
|
package/dist/Keybindy.js
CHANGED
|
@@ -1,65 +1,9 @@
|
|
|
1
1
|
import { jsx, Fragment } from 'react/jsx-runtime';
|
|
2
2
|
import React from 'react';
|
|
3
|
-
import {
|
|
3
|
+
import { useShortcuts } from './useShortcuts.js';
|
|
4
4
|
|
|
5
|
-
const KeybindyComponent = ({
|
|
6
|
-
|
|
7
|
-
onShortcutFired,
|
|
8
|
-
logs,
|
|
9
|
-
});
|
|
10
|
-
const shortcuts = React.useMemo(() => (typeof shortcutsProp === 'function' ? shortcutsProp() : shortcutsProp) || [], [shortcutsProp]);
|
|
11
|
-
// Memoize a stable representation of shortcuts, excluding the handler.
|
|
12
|
-
// This prevents the effect from re-running unnecessarily.
|
|
13
|
-
const stableShortcuts = React.useMemo(() => {
|
|
14
|
-
return shortcuts.map(({ keys, options }) => ({ keys, options }));
|
|
15
|
-
}, [JSON.stringify(shortcuts.map(s => ({ keys: s.keys, options: s.options })))]);
|
|
16
|
-
// Use a ref to store the latest handlers, preventing re-renders from causing issues.
|
|
17
|
-
const handlersRef = React.useRef({});
|
|
18
|
-
React.useEffect(() => {
|
|
19
|
-
handlersRef.current = shortcuts.reduce((acc, { keys, handler }) => {
|
|
20
|
-
const key = JSON.stringify(keys);
|
|
21
|
-
acc[key] = handler;
|
|
22
|
-
return acc;
|
|
23
|
-
}, {});
|
|
24
|
-
});
|
|
25
|
-
React.useEffect(() => {
|
|
26
|
-
if (!manager) {
|
|
27
|
-
return;
|
|
28
|
-
}
|
|
29
|
-
if (!getScopes()?.includes(scope)) {
|
|
30
|
-
pushScope(scope);
|
|
31
|
-
}
|
|
32
|
-
setScope(scope);
|
|
33
|
-
// Register shortcuts using the stable definitions.
|
|
34
|
-
stableShortcuts.forEach(({ keys, options }) => {
|
|
35
|
-
const stableHandler = (event, state) => {
|
|
36
|
-
const key = JSON.stringify(keys);
|
|
37
|
-
const currentHandler = handlersRef.current[key];
|
|
38
|
-
if (currentHandler) {
|
|
39
|
-
currentHandler(event, state);
|
|
40
|
-
}
|
|
41
|
-
};
|
|
42
|
-
register(keys, stableHandler, { ...options, scope });
|
|
43
|
-
});
|
|
44
|
-
if (disabled) {
|
|
45
|
-
manager.disableAll(scope);
|
|
46
|
-
}
|
|
47
|
-
else {
|
|
48
|
-
manager.enableAll(scope);
|
|
49
|
-
}
|
|
50
|
-
return () => {
|
|
51
|
-
// Unregister using the same stable definitions.
|
|
52
|
-
stableShortcuts.forEach(({ keys }) => {
|
|
53
|
-
if (Array.isArray(keys[0])) {
|
|
54
|
-
keys.forEach(key => unregister(key, scope));
|
|
55
|
-
}
|
|
56
|
-
else {
|
|
57
|
-
unregister(keys, scope);
|
|
58
|
-
}
|
|
59
|
-
});
|
|
60
|
-
popScope();
|
|
61
|
-
};
|
|
62
|
-
}, [scope, manager, disabled, stableShortcuts]);
|
|
5
|
+
const KeybindyComponent = ({ children, shortcuts = [], ...options }) => {
|
|
6
|
+
useShortcuts(shortcuts, options);
|
|
63
7
|
return jsx(Fragment, { children: children });
|
|
64
8
|
};
|
|
65
9
|
const Keybindy = React.memo(KeybindyComponent);
|
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
|
-
import ShortcutManager, {
|
|
3
|
-
export { Keys, Shortcut, ShortcutOptions } from '@keybindy/core';
|
|
4
|
-
import * as react_jsx_runtime from 'react/jsx-runtime';
|
|
2
|
+
import ShortcutManager, { ShortcutBinding, ShortcutHandler, ShortcutOptions, ScopeMode, BeforeEachHook, AfterEachHook, Shortcut, Keys, ScopePriorityInput, HookOptions } from '@keybindy/core';
|
|
3
|
+
export { AfterEachHook, BeforeEachHook, HookOptions, Key, Keys, ScopeMode, ScopePriorityInput, ScopePriorityRecord, Shortcut, ShortcutBinding, ShortcutHandler, ShortcutManagerOptions, ShortcutOptions } from '@keybindy/core';
|
|
5
4
|
|
|
6
5
|
/**
|
|
7
6
|
* Represents a shortcut definition for the `<Keybindy />` component.
|
|
@@ -11,7 +10,7 @@ type KeybindyShortcut = {
|
|
|
11
10
|
* The key combination(s) to listen for.
|
|
12
11
|
* Can be a single array of keys or an array of key combinations.
|
|
13
12
|
*/
|
|
14
|
-
keys:
|
|
13
|
+
keys: ShortcutBinding;
|
|
15
14
|
/**
|
|
16
15
|
* Callback function to invoke when the shortcut is triggered.
|
|
17
16
|
*/
|
|
@@ -23,24 +22,44 @@ type KeybindyShortcut = {
|
|
|
23
22
|
};
|
|
24
23
|
|
|
25
24
|
/**
|
|
26
|
-
*
|
|
25
|
+
* Options for `useShortcuts` hook.
|
|
27
26
|
*/
|
|
28
|
-
type
|
|
27
|
+
type UseShortcutsOptions = {
|
|
29
28
|
/**
|
|
30
29
|
* The scope under which the shortcuts should be active.
|
|
31
|
-
*
|
|
30
|
+
* Defaults to `'global'`.
|
|
32
31
|
*/
|
|
33
32
|
scope?: 'global' | string;
|
|
34
33
|
/**
|
|
35
|
-
*
|
|
36
|
-
*
|
|
34
|
+
* Scope management mode:
|
|
35
|
+
* - `'default'`: Only shortcuts in the single active scope are enabled.
|
|
36
|
+
* - `'cascade'`: Shortcuts across all active scopes are enabled, with common shortcut collisions resolved by scope priority or stack order.
|
|
37
37
|
*/
|
|
38
|
-
|
|
38
|
+
scopeMode?: ScopeMode;
|
|
39
39
|
/**
|
|
40
|
-
* Whether
|
|
40
|
+
* Whether all shortcuts in this scope should be disabled.
|
|
41
41
|
* Defaults to `false`.
|
|
42
42
|
*/
|
|
43
43
|
disabled?: boolean;
|
|
44
|
+
/**
|
|
45
|
+
* Numeric priority weight for this scope in cascade mode.
|
|
46
|
+
* Higher numbers take precedence over lower numbers (e.g. 100 > 10 > 0).
|
|
47
|
+
*/
|
|
48
|
+
priority?: number;
|
|
49
|
+
/**
|
|
50
|
+
* Whether to ignore shortcuts when typing inside an input, textarea, select, or contenteditable.
|
|
51
|
+
* Can be overridden per shortcut via `enableInInput: true` or `ignoreInputs: false`.
|
|
52
|
+
*/
|
|
53
|
+
ignoreInputs?: boolean;
|
|
54
|
+
/**
|
|
55
|
+
* Guard hook that runs before any shortcut in this scope executes.
|
|
56
|
+
* Return `false` to cancel/abort execution.
|
|
57
|
+
*/
|
|
58
|
+
beforeEach?: BeforeEachHook;
|
|
59
|
+
/**
|
|
60
|
+
* Interceptor hook that runs after any shortcut in this scope successfully executes.
|
|
61
|
+
*/
|
|
62
|
+
afterEach?: AfterEachHook;
|
|
44
63
|
/**
|
|
45
64
|
* Callback function that will be called when a shortcut is fired.
|
|
46
65
|
* Receives the fired shortcut info as an argument.
|
|
@@ -50,81 +69,88 @@ type KeybindyProps = {
|
|
|
50
69
|
* Whether to enable debug logs in the console.
|
|
51
70
|
*/
|
|
52
71
|
logs?: boolean;
|
|
72
|
+
};
|
|
73
|
+
/**
|
|
74
|
+
* Options for single `useShortcut` hook.
|
|
75
|
+
*/
|
|
76
|
+
type UseShortcutOptions = Omit<ShortcutOptions, 'scope'> & {
|
|
53
77
|
/**
|
|
54
|
-
* The
|
|
78
|
+
* The scope under which the shortcut should be active.
|
|
79
|
+
* Defaults to `'global'`.
|
|
55
80
|
*/
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
81
|
+
scope?: 'global' | string;
|
|
82
|
+
/**
|
|
83
|
+
* Scope management mode: `'default'` | `'cascade'`.
|
|
84
|
+
*/
|
|
85
|
+
scopeMode?: ScopeMode;
|
|
86
|
+
/**
|
|
87
|
+
* Whether the shortcut is disabled.
|
|
88
|
+
*/
|
|
89
|
+
disabled?: boolean;
|
|
62
90
|
/**
|
|
63
|
-
*
|
|
91
|
+
* Numeric priority weight for this scope in cascade mode.
|
|
64
92
|
*/
|
|
65
|
-
|
|
93
|
+
priority?: number;
|
|
66
94
|
/**
|
|
67
|
-
*
|
|
68
|
-
* This function receives the entire `keys` array (either `string[]` or `string[][]`)
|
|
69
|
-
* and should return the ReactNode to be displayed.
|
|
95
|
+
* Whether to ignore shortcut when typing inside an input/textarea.
|
|
70
96
|
*/
|
|
71
|
-
|
|
72
|
-
|
|
97
|
+
ignoreInputs?: boolean;
|
|
98
|
+
/**
|
|
99
|
+
* Guard hook that runs before this shortcut executes. Return `false` to abort.
|
|
100
|
+
*/
|
|
101
|
+
beforeEach?: BeforeEachHook;
|
|
102
|
+
/**
|
|
103
|
+
* Interceptor hook that runs after this shortcut executes.
|
|
104
|
+
*/
|
|
105
|
+
afterEach?: AfterEachHook;
|
|
106
|
+
/**
|
|
107
|
+
* Whether to enable debug logs in the console.
|
|
108
|
+
*/
|
|
109
|
+
logs?: boolean;
|
|
110
|
+
};
|
|
73
111
|
/**
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
* @example
|
|
77
|
-
* // Default usage
|
|
78
|
-
* <ShortcutLabel keys={['ctrl', 's']} />
|
|
79
|
-
*
|
|
80
|
-
* @example
|
|
81
|
-
* // With multiple bindings
|
|
82
|
-
* <ShortcutLabel keys={[['ctrl', 's'], ['meta', 's']]} />
|
|
112
|
+
* React hook to register multiple keyboard shortcuts declaratively.
|
|
113
|
+
* Safe from stale closures and re-registration flickering/blinking.
|
|
83
114
|
*
|
|
84
|
-
* @
|
|
85
|
-
*
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
* <span key={key} style={{ color: '#00eaff' }}>
|
|
92
|
-
* {key.toUpperCase()}
|
|
93
|
-
* </span>
|
|
94
|
-
* ));
|
|
95
|
-
* }}
|
|
96
|
-
* />
|
|
115
|
+
* @param shortcutsProp - An array of shortcut definitions or a function returning shortcuts.
|
|
116
|
+
* @param options - Scope, priority, mode, and lifecycle configuration.
|
|
117
|
+
*/
|
|
118
|
+
declare const useShortcuts: (shortcutsProp?: KeybindyShortcut[] | (() => KeybindyShortcut[]), options?: UseShortcutsOptions) => void;
|
|
119
|
+
/**
|
|
120
|
+
* React hook to register a single keyboard shortcut.
|
|
121
|
+
* Safe from stale closures and re-registration flickering/blinking.
|
|
97
122
|
*
|
|
98
123
|
* @example
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
* return bindings.map((binding, bindingIndex) => (
|
|
105
|
-
* <React.Fragment key={bindingIndex}>
|
|
106
|
-
* {binding.map((key, keyIndex) => (
|
|
107
|
-
* <span key={keyIndex} style={{ fontWeight: 'bold' }}>
|
|
108
|
-
* {key}
|
|
109
|
-
* </span>
|
|
110
|
-
* ))}
|
|
111
|
-
* {bindingIndex < bindings.length - 1 && ' or '}
|
|
112
|
-
* </React.Fragment>
|
|
113
|
-
* ));
|
|
114
|
-
* }}
|
|
115
|
-
* />
|
|
124
|
+
* ```tsx
|
|
125
|
+
* useShortcut(['Ctrl', 'S'], (e) => {
|
|
126
|
+
* save(currentData);
|
|
127
|
+
* }, { preventDefault: true, scope: 'editor' });
|
|
128
|
+
* ```
|
|
116
129
|
*
|
|
117
|
-
* @param
|
|
118
|
-
* @param
|
|
119
|
-
* @param
|
|
120
|
-
|
|
121
|
-
|
|
130
|
+
* @param keys - Key combination (e.g. `['Ctrl', 'S']` or `[['Ctrl', 'K'], ['Meta', 'K']]`).
|
|
131
|
+
* @param handler - Callback to execute when shortcut is triggered. Always accesses fresh state without re-registering.
|
|
132
|
+
* @param options - Configuration options for scope, preventDefault, hold, repeat, etc.
|
|
133
|
+
*/
|
|
134
|
+
declare const useShortcut: (keys: ShortcutBinding, handler: ShortcutHandler, options?: UseShortcutOptions) => void;
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Props for the `<Keybindy />` component.
|
|
122
138
|
*/
|
|
123
|
-
|
|
139
|
+
type KeybindyProps = UseShortcutsOptions & {
|
|
140
|
+
/**
|
|
141
|
+
* An array of shortcut definitions or a function that returns an array of shortcuts.
|
|
142
|
+
*/
|
|
143
|
+
shortcuts?: KeybindyShortcut[] | (() => KeybindyShortcut[]);
|
|
144
|
+
/**
|
|
145
|
+
* Child elements to render within the Keybindy context.
|
|
146
|
+
*/
|
|
147
|
+
children?: React.ReactNode;
|
|
148
|
+
};
|
|
149
|
+
declare const Keybindy: React.NamedExoticComponent<KeybindyProps>;
|
|
124
150
|
|
|
125
|
-
type
|
|
126
|
-
register: (keys:
|
|
127
|
-
unregister: (keys:
|
|
151
|
+
type UseShortcutManagerReturn = {
|
|
152
|
+
register: (keys: ShortcutBinding, handler: ShortcutHandler, options?: ShortcutOptions) => void;
|
|
153
|
+
unregister: (keys: ShortcutBinding, scope?: string) => void;
|
|
128
154
|
enable: (keys: Keys[], scope?: string) => void;
|
|
129
155
|
disable: (keys: Keys[], scope?: string) => void;
|
|
130
156
|
toggle: (keys: Keys[], scope?: string) => void;
|
|
@@ -138,11 +164,19 @@ type UseKeybindyReturn = {
|
|
|
138
164
|
destroy: () => void;
|
|
139
165
|
getScopeInfo: (scope?: string) => any;
|
|
140
166
|
getActiveScope: () => string | undefined;
|
|
141
|
-
popScope: () => void;
|
|
167
|
+
popScope: (scope?: string) => void;
|
|
142
168
|
pushScope: (scope: string) => void;
|
|
143
169
|
resetScope: () => void;
|
|
144
170
|
getScopes: () => string[] | undefined;
|
|
145
171
|
isScopeActive: (scope: string) => boolean | undefined;
|
|
172
|
+
setScopeMode: (mode: ScopeMode) => void;
|
|
173
|
+
getScopeMode: () => ScopeMode | undefined;
|
|
174
|
+
setScopePriority: ((scope: string, priority: number) => void) & ((input: ScopePriorityInput) => void);
|
|
175
|
+
getScopePriority: (scope?: string) => number | Record<string, number> | null | undefined;
|
|
176
|
+
getScopePriorityValue: (scope: string) => number | undefined;
|
|
177
|
+
getSortedScopes: () => string[] | undefined;
|
|
178
|
+
clearScopePriority: () => void;
|
|
179
|
+
removeScopePriority: (scope: string) => void;
|
|
146
180
|
onTyping: (callback: (payload: {
|
|
147
181
|
key: string;
|
|
148
182
|
event: KeyboardEvent;
|
|
@@ -150,22 +184,53 @@ type UseKeybindyReturn = {
|
|
|
150
184
|
enableAll: (scope?: string) => void;
|
|
151
185
|
clear: () => void;
|
|
152
186
|
disableAll: (scope?: string) => void;
|
|
187
|
+
beforeEach: (hook: BeforeEachHook, options?: HookOptions) => () => void;
|
|
188
|
+
afterEach: (hook: AfterEachHook, options?: HookOptions) => () => void;
|
|
153
189
|
manager: ShortcutManager | null;
|
|
154
190
|
};
|
|
191
|
+
type UseKeybindyReturn = UseShortcutManagerReturn;
|
|
155
192
|
/**
|
|
156
|
-
*
|
|
157
|
-
*
|
|
193
|
+
* Low-level programmatic hook to manage keyboard shortcuts and the `ShortcutManager` instance.
|
|
194
|
+
* Safe for server-side rendering (SSR) - manager initializes on client.
|
|
158
195
|
*
|
|
159
196
|
* @param {Object} config - Configuration object.
|
|
160
197
|
* @param {boolean} [config.logs=false] - Whether to enable debug logs in the console.
|
|
198
|
+
* @param {boolean} [config.ignoreInputs=false] - Global default to ignore shortcuts when typing in inputs/textareas.
|
|
161
199
|
* @param {(info: Shortcut) => void} [config.onShortcutFired] - Callback for when a shortcut is fired.
|
|
200
|
+
* @param {ScopeMode} [config.scopeMode] - Initial scope mode ('default' | 'cascade').
|
|
162
201
|
*
|
|
163
|
-
* @returns {
|
|
202
|
+
* @returns {UseShortcutManagerReturn} Object containing programmatic methods and manager instance.
|
|
203
|
+
*/
|
|
204
|
+
declare const useShortcutManager: ({ logs, ignoreInputs, onShortcutFired, scopeMode, }?: {
|
|
205
|
+
logs?: boolean;
|
|
206
|
+
ignoreInputs?: boolean;
|
|
207
|
+
onShortcutFired?: (info: Shortcut) => void;
|
|
208
|
+
scopeMode?: ScopeMode;
|
|
209
|
+
}) => UseShortcutManagerReturn;
|
|
210
|
+
/**
|
|
211
|
+
* @deprecated Use `useShortcutManager` for programmatic manager access or `useShortcut` / `useShortcuts` for component shortcuts.
|
|
164
212
|
*/
|
|
165
|
-
declare const useKeybindy: ({ logs, onShortcutFired, }?: {
|
|
213
|
+
declare const useKeybindy: ({ logs, ignoreInputs, onShortcutFired, scopeMode, }?: {
|
|
166
214
|
logs?: boolean;
|
|
215
|
+
ignoreInputs?: boolean;
|
|
167
216
|
onShortcutFired?: (info: Shortcut) => void;
|
|
168
|
-
|
|
217
|
+
scopeMode?: ScopeMode;
|
|
218
|
+
}) => UseShortcutManagerReturn;
|
|
219
|
+
/**
|
|
220
|
+
* React hook to register a guard hook before shortcuts execute.
|
|
221
|
+
* Returning `false` will cancel/abort the shortcut execution.
|
|
222
|
+
*
|
|
223
|
+
* @param hook - Guard function to run before matching shortcuts.
|
|
224
|
+
* @param options - Optional filter options (scope, keys).
|
|
225
|
+
*/
|
|
226
|
+
declare const useBeforeShortcut: (hook: BeforeEachHook, options?: HookOptions) => void;
|
|
227
|
+
/**
|
|
228
|
+
* React hook to register an interceptor hook after shortcuts successfully execute.
|
|
229
|
+
*
|
|
230
|
+
* @param hook - Interceptor function to run after matching shortcuts.
|
|
231
|
+
* @param options - Optional filter options (scope, keys).
|
|
232
|
+
*/
|
|
233
|
+
declare const useAfterShortcut: (hook: AfterEachHook, options?: HookOptions) => void;
|
|
169
234
|
|
|
170
|
-
export { Keybindy,
|
|
171
|
-
export type { KeybindyShortcut };
|
|
235
|
+
export { Keybindy, useAfterShortcut, useBeforeShortcut, useKeybindy, useShortcut, useShortcutManager, useShortcuts };
|
|
236
|
+
export type { KeybindyProps, KeybindyShortcut, UseKeybindyReturn, UseShortcutManagerReturn, UseShortcutOptions, UseShortcutsOptions };
|
package/dist/index.js
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
export { Keybindy } from './Keybindy.js';
|
|
2
|
-
export {
|
|
3
|
-
export { useKeybindy } from './useKeybindy.js';
|
|
2
|
+
export { useShortcut, useShortcuts } from './useShortcuts.js';
|
|
3
|
+
export { useAfterShortcut, useBeforeShortcut, useKeybindy, useShortcutManager } from './useKeybindy.js';
|
package/dist/useKeybindy.js
CHANGED
|
@@ -12,20 +12,27 @@ const getSharedInstance = (options) => {
|
|
|
12
12
|
return sharedInstance;
|
|
13
13
|
};
|
|
14
14
|
/**
|
|
15
|
-
*
|
|
16
|
-
*
|
|
15
|
+
* Low-level programmatic hook to manage keyboard shortcuts and the `ShortcutManager` instance.
|
|
16
|
+
* Safe for server-side rendering (SSR) - manager initializes on client.
|
|
17
17
|
*
|
|
18
18
|
* @param {Object} config - Configuration object.
|
|
19
19
|
* @param {boolean} [config.logs=false] - Whether to enable debug logs in the console.
|
|
20
|
+
* @param {boolean} [config.ignoreInputs=false] - Global default to ignore shortcuts when typing in inputs/textareas.
|
|
20
21
|
* @param {(info: Shortcut) => void} [config.onShortcutFired] - Callback for when a shortcut is fired.
|
|
22
|
+
* @param {ScopeMode} [config.scopeMode] - Initial scope mode ('default' | 'cascade').
|
|
21
23
|
*
|
|
22
|
-
* @returns {
|
|
24
|
+
* @returns {UseShortcutManagerReturn} Object containing programmatic methods and manager instance.
|
|
23
25
|
*/
|
|
24
|
-
const
|
|
26
|
+
const useShortcutManager = ({ logs = false, ignoreInputs = false, onShortcutFired, scopeMode, } = {}) => {
|
|
25
27
|
const [manager, setManager] = React.useState(null);
|
|
26
28
|
React.useEffect(() => {
|
|
27
29
|
if (!manager) {
|
|
28
|
-
const instance = getSharedInstance({
|
|
30
|
+
const instance = getSharedInstance({
|
|
31
|
+
onShortcutFired,
|
|
32
|
+
silent: !logs,
|
|
33
|
+
ignoreInputs,
|
|
34
|
+
scopeMode,
|
|
35
|
+
});
|
|
29
36
|
setManager(instance);
|
|
30
37
|
}
|
|
31
38
|
}, []);
|
|
@@ -116,11 +123,45 @@ const useKeybindy = ({ logs = false, onShortcutFired, } = {}) => {
|
|
|
116
123
|
const isScopeActive = React.useCallback((scope) => {
|
|
117
124
|
return manager?.isScopeActive(scope);
|
|
118
125
|
}, [manager]);
|
|
126
|
+
const setScopeMode = React.useCallback((mode) => {
|
|
127
|
+
manager?.setScopeMode(mode);
|
|
128
|
+
log('Scope mode set to:', mode);
|
|
129
|
+
}, [manager]);
|
|
130
|
+
const getScopeMode = React.useCallback(() => {
|
|
131
|
+
return manager?.getScopeMode();
|
|
132
|
+
}, [manager]);
|
|
133
|
+
const setScopePriority = React.useCallback((scopeOrInput, priority) => {
|
|
134
|
+
if (typeof scopeOrInput === 'string' && typeof priority === 'number') {
|
|
135
|
+
manager?.setScopePriority(scopeOrInput, priority);
|
|
136
|
+
log(`Scope "${scopeOrInput}" priority set to:`, priority);
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
manager?.setScopePriority(scopeOrInput);
|
|
140
|
+
log('Scope priority set');
|
|
141
|
+
}
|
|
142
|
+
}, [manager]);
|
|
143
|
+
const getScopePriority = React.useCallback((scope) => {
|
|
144
|
+
return manager?.getScopePriority(scope);
|
|
145
|
+
}, [manager]);
|
|
146
|
+
const getScopePriorityValue = React.useCallback((scope) => {
|
|
147
|
+
return manager?.getScopePriorityValue(scope);
|
|
148
|
+
}, [manager]);
|
|
149
|
+
const getSortedScopes = React.useCallback(() => {
|
|
150
|
+
return manager?.getSortedScopes();
|
|
151
|
+
}, [manager]);
|
|
152
|
+
const clearScopePriority = React.useCallback(() => {
|
|
153
|
+
manager?.clearScopePriority();
|
|
154
|
+
log('Cleared scope priority');
|
|
155
|
+
}, [manager]);
|
|
156
|
+
const removeScopePriority = React.useCallback((scope) => {
|
|
157
|
+
manager?.removeScopePriority(scope);
|
|
158
|
+
log(`Removed scope "${scope}" from priority`);
|
|
159
|
+
}, [manager]);
|
|
119
160
|
const onTyping = React.useCallback((callback) => {
|
|
120
161
|
manager?.onTyping(callback);
|
|
121
162
|
}, [manager]);
|
|
122
|
-
const popScope = React.useCallback(() => {
|
|
123
|
-
manager?.popScope();
|
|
163
|
+
const popScope = React.useCallback((scope) => {
|
|
164
|
+
manager?.popScope(scope);
|
|
124
165
|
log('Popped scope, active scope is:', manager?.getActiveScope());
|
|
125
166
|
}, [manager]);
|
|
126
167
|
const pushScope = React.useCallback((scope) => {
|
|
@@ -130,6 +171,12 @@ const useKeybindy = ({ logs = false, onShortcutFired, } = {}) => {
|
|
|
130
171
|
const getScopeInfo = React.useCallback((scope) => {
|
|
131
172
|
return manager?.getScopesInfo(scope);
|
|
132
173
|
}, [manager]);
|
|
174
|
+
const beforeEach = React.useCallback((hook, options) => {
|
|
175
|
+
return manager?.beforeEach(hook, options) ?? (() => { });
|
|
176
|
+
}, [manager]);
|
|
177
|
+
const afterEach = React.useCallback((hook, options) => {
|
|
178
|
+
return manager?.afterEach(hook, options) ?? (() => { });
|
|
179
|
+
}, [manager]);
|
|
133
180
|
const destroy = () => {
|
|
134
181
|
manager?.destroy();
|
|
135
182
|
};
|
|
@@ -152,12 +199,69 @@ const useKeybindy = ({ logs = false, onShortcutFired, } = {}) => {
|
|
|
152
199
|
resetScope,
|
|
153
200
|
getScopes,
|
|
154
201
|
isScopeActive,
|
|
202
|
+
setScopeMode,
|
|
203
|
+
getScopeMode,
|
|
204
|
+
setScopePriority,
|
|
205
|
+
getScopePriority,
|
|
206
|
+
getScopePriorityValue,
|
|
207
|
+
getSortedScopes,
|
|
208
|
+
clearScopePriority,
|
|
209
|
+
removeScopePriority,
|
|
155
210
|
onTyping,
|
|
156
211
|
enableAll,
|
|
157
212
|
clear,
|
|
158
213
|
disableAll,
|
|
214
|
+
beforeEach,
|
|
215
|
+
afterEach,
|
|
159
216
|
manager,
|
|
160
217
|
};
|
|
161
218
|
};
|
|
219
|
+
/**
|
|
220
|
+
* @deprecated Use `useShortcutManager` for programmatic manager access or `useShortcut` / `useShortcuts` for component shortcuts.
|
|
221
|
+
*/
|
|
222
|
+
const useKeybindy = useShortcutManager;
|
|
223
|
+
/**
|
|
224
|
+
* React hook to register a guard hook before shortcuts execute.
|
|
225
|
+
* Returning `false` will cancel/abort the shortcut execution.
|
|
226
|
+
*
|
|
227
|
+
* @param hook - Guard function to run before matching shortcuts.
|
|
228
|
+
* @param options - Optional filter options (scope, keys).
|
|
229
|
+
*/
|
|
230
|
+
const useBeforeShortcut = (hook, options) => {
|
|
231
|
+
const hookRef = React.useRef(hook);
|
|
232
|
+
hookRef.current = hook;
|
|
233
|
+
React.useEffect(() => {
|
|
234
|
+
const instance = getSharedInstance();
|
|
235
|
+
if (!instance)
|
|
236
|
+
return;
|
|
237
|
+
const unregister = instance.beforeEach((shortcut, event) => {
|
|
238
|
+
return hookRef.current(shortcut, event);
|
|
239
|
+
}, options);
|
|
240
|
+
return () => {
|
|
241
|
+
unregister();
|
|
242
|
+
};
|
|
243
|
+
}, [options?.scope, JSON.stringify(options?.keys)]);
|
|
244
|
+
};
|
|
245
|
+
/**
|
|
246
|
+
* React hook to register an interceptor hook after shortcuts successfully execute.
|
|
247
|
+
*
|
|
248
|
+
* @param hook - Interceptor function to run after matching shortcuts.
|
|
249
|
+
* @param options - Optional filter options (scope, keys).
|
|
250
|
+
*/
|
|
251
|
+
const useAfterShortcut = (hook, options) => {
|
|
252
|
+
const hookRef = React.useRef(hook);
|
|
253
|
+
hookRef.current = hook;
|
|
254
|
+
React.useEffect(() => {
|
|
255
|
+
const instance = getSharedInstance();
|
|
256
|
+
if (!instance)
|
|
257
|
+
return;
|
|
258
|
+
const unregister = instance.afterEach((shortcut, event) => {
|
|
259
|
+
hookRef.current(shortcut, event);
|
|
260
|
+
}, options);
|
|
261
|
+
return () => {
|
|
262
|
+
unregister();
|
|
263
|
+
};
|
|
264
|
+
}, [options?.scope, JSON.stringify(options?.keys)]);
|
|
265
|
+
};
|
|
162
266
|
|
|
163
|
-
export { useKeybindy };
|
|
267
|
+
export { useAfterShortcut, useBeforeShortcut, useKeybindy, useShortcutManager };
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { useShortcutManager } from './useKeybindy.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* React hook to register multiple keyboard shortcuts declaratively.
|
|
6
|
+
* Safe from stale closures and re-registration flickering/blinking.
|
|
7
|
+
*
|
|
8
|
+
* @param shortcutsProp - An array of shortcut definitions or a function returning shortcuts.
|
|
9
|
+
* @param options - Scope, priority, mode, and lifecycle configuration.
|
|
10
|
+
*/
|
|
11
|
+
const useShortcuts = (shortcutsProp = [], options = {}) => {
|
|
12
|
+
const { scope = 'global', scopeMode, disabled, priority, ignoreInputs, beforeEach, afterEach, onShortcutFired, logs = false, } = options;
|
|
13
|
+
const { register, unregister, manager, pushScope, popScope, getScopes, setScope, setScopeMode, setScopePriority, removeScopePriority, } = useShortcutManager({
|
|
14
|
+
onShortcutFired,
|
|
15
|
+
logs,
|
|
16
|
+
});
|
|
17
|
+
const beforeEachRef = React.useRef(beforeEach);
|
|
18
|
+
beforeEachRef.current = beforeEach;
|
|
19
|
+
const afterEachRef = React.useRef(afterEach);
|
|
20
|
+
afterEachRef.current = afterEach;
|
|
21
|
+
// Resolve shortcuts from prop, whether array or function
|
|
22
|
+
const shortcuts = typeof shortcutsProp === 'function' ? shortcutsProp() : shortcutsProp;
|
|
23
|
+
// Keep a ref of current handlers so our stable handler closures never go stale.
|
|
24
|
+
const handlersRef = React.useRef({});
|
|
25
|
+
handlersRef.current = {};
|
|
26
|
+
shortcuts.forEach(({ keys, handler }) => {
|
|
27
|
+
handlersRef.current[JSON.stringify(keys)] = handler;
|
|
28
|
+
});
|
|
29
|
+
// Create stable shortcuts definitions containing only serializable fields (keys, options).
|
|
30
|
+
const stableShortcuts = React.useMemo(() => {
|
|
31
|
+
return shortcuts.map(({ keys, options: opt }) => ({ keys, options: opt }));
|
|
32
|
+
}, [JSON.stringify(shortcuts.map(s => ({ keys: s.keys, options: s.options })))]);
|
|
33
|
+
React.useEffect(() => {
|
|
34
|
+
if (!manager)
|
|
35
|
+
return;
|
|
36
|
+
let prevScopeMode;
|
|
37
|
+
if (scopeMode) {
|
|
38
|
+
prevScopeMode = manager.getScopeMode();
|
|
39
|
+
setScopeMode(scopeMode);
|
|
40
|
+
}
|
|
41
|
+
if (typeof priority === 'number') {
|
|
42
|
+
setScopePriority(scope, priority);
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
if (!getScopes()?.includes(scope)) {
|
|
46
|
+
pushScope(scope);
|
|
47
|
+
}
|
|
48
|
+
setScope(scope);
|
|
49
|
+
}
|
|
50
|
+
let unregisterBefore;
|
|
51
|
+
let unregisterAfter;
|
|
52
|
+
const hookKeys = stableShortcuts.flatMap(s => Array.isArray(s.keys[0]) ? s.keys : [s.keys]);
|
|
53
|
+
if (beforeEach) {
|
|
54
|
+
unregisterBefore = manager.beforeEach((shortcut, event) => {
|
|
55
|
+
return beforeEachRef.current ? beforeEachRef.current(shortcut, event) : undefined;
|
|
56
|
+
}, { scope, keys: hookKeys.length > 0 ? hookKeys : undefined });
|
|
57
|
+
}
|
|
58
|
+
if (afterEach) {
|
|
59
|
+
unregisterAfter = manager.afterEach((shortcut, event) => {
|
|
60
|
+
if (afterEachRef.current)
|
|
61
|
+
afterEachRef.current(shortcut, event);
|
|
62
|
+
}, { scope, keys: hookKeys.length > 0 ? hookKeys : undefined });
|
|
63
|
+
}
|
|
64
|
+
// Register shortcuts using the stable definitions.
|
|
65
|
+
stableShortcuts.forEach(({ keys, options: opt }) => {
|
|
66
|
+
const stableHandler = (event, state) => {
|
|
67
|
+
const key = JSON.stringify(keys);
|
|
68
|
+
const currentHandler = handlersRef.current[key];
|
|
69
|
+
if (currentHandler) {
|
|
70
|
+
currentHandler(event, state);
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
register(keys, stableHandler, {
|
|
74
|
+
...opt,
|
|
75
|
+
scope,
|
|
76
|
+
ignoreInputs: opt?.ignoreInputs ?? ignoreInputs,
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
if (disabled) {
|
|
80
|
+
manager.disableAll(scope);
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
manager.enableAll(scope);
|
|
84
|
+
}
|
|
85
|
+
return () => {
|
|
86
|
+
if (unregisterBefore)
|
|
87
|
+
unregisterBefore();
|
|
88
|
+
if (unregisterAfter)
|
|
89
|
+
unregisterAfter();
|
|
90
|
+
// Unregister using the same stable definitions.
|
|
91
|
+
stableShortcuts.forEach(({ keys }) => {
|
|
92
|
+
unregister(keys, scope);
|
|
93
|
+
});
|
|
94
|
+
if (typeof priority === 'number') {
|
|
95
|
+
removeScopePriority(scope);
|
|
96
|
+
}
|
|
97
|
+
if (scope !== 'global') {
|
|
98
|
+
const remaining = manager.getCheatSheet(scope);
|
|
99
|
+
if (!remaining || remaining.length === 0) {
|
|
100
|
+
popScope(scope);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (scopeMode && prevScopeMode !== undefined) {
|
|
104
|
+
setScopeMode(prevScopeMode);
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
}, [scope, manager, disabled, priority, scopeMode, Boolean(beforeEach), Boolean(afterEach), stableShortcuts]);
|
|
108
|
+
};
|
|
109
|
+
/**
|
|
110
|
+
* React hook to register a single keyboard shortcut.
|
|
111
|
+
* Safe from stale closures and re-registration flickering/blinking.
|
|
112
|
+
*
|
|
113
|
+
* @example
|
|
114
|
+
* ```tsx
|
|
115
|
+
* useShortcut(['Ctrl', 'S'], (e) => {
|
|
116
|
+
* save(currentData);
|
|
117
|
+
* }, { preventDefault: true, scope: 'editor' });
|
|
118
|
+
* ```
|
|
119
|
+
*
|
|
120
|
+
* @param keys - Key combination (e.g. `['Ctrl', 'S']` or `[['Ctrl', 'K'], ['Meta', 'K']]`).
|
|
121
|
+
* @param handler - Callback to execute when shortcut is triggered. Always accesses fresh state without re-registering.
|
|
122
|
+
* @param options - Configuration options for scope, preventDefault, hold, repeat, etc.
|
|
123
|
+
*/
|
|
124
|
+
const useShortcut = (keys, handler, options) => {
|
|
125
|
+
const { scope = 'global', scopeMode, disabled, priority, ignoreInputs, beforeEach, afterEach, logs, ...shortcutOptions } = options || {};
|
|
126
|
+
const handlerRef = React.useRef(handler);
|
|
127
|
+
handlerRef.current = handler;
|
|
128
|
+
const shortcutsGetter = React.useCallback(() => [
|
|
129
|
+
{
|
|
130
|
+
keys,
|
|
131
|
+
handler: ((e, state) => {
|
|
132
|
+
if (handlerRef.current) {
|
|
133
|
+
handlerRef.current(e, state);
|
|
134
|
+
}
|
|
135
|
+
}),
|
|
136
|
+
options: shortcutOptions,
|
|
137
|
+
}
|
|
138
|
+
], [JSON.stringify(keys), JSON.stringify(shortcutOptions)]);
|
|
139
|
+
useShortcuts(shortcutsGetter, {
|
|
140
|
+
scope,
|
|
141
|
+
scopeMode,
|
|
142
|
+
disabled,
|
|
143
|
+
priority,
|
|
144
|
+
ignoreInputs,
|
|
145
|
+
beforeEach,
|
|
146
|
+
afterEach,
|
|
147
|
+
logs,
|
|
148
|
+
});
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
export { useShortcut, useShortcuts };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@keybindy/react",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.1",
|
|
4
4
|
"description": "Keybindy for React: Simple, scoped keyboard shortcuts that require little setup. designed to smoothly blend in with your React applications, allowing for robust keybinding functionality without the overhead.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "PRASSamin",
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
"types": "dist/index.d.ts",
|
|
23
23
|
"exports": {
|
|
24
24
|
".": {
|
|
25
|
+
"types": "./dist/index.d.ts",
|
|
25
26
|
"require": "./dist/index.js",
|
|
26
27
|
"import": "./dist/index.js"
|
|
27
28
|
}
|
|
@@ -51,7 +52,11 @@
|
|
|
51
52
|
},
|
|
52
53
|
"dependencies": {
|
|
53
54
|
"react": "^19.1.0",
|
|
54
|
-
"@keybindy/core": "
|
|
55
|
+
"@keybindy/core": "2.0.1"
|
|
56
|
+
},
|
|
57
|
+
"publishConfig": {
|
|
58
|
+
"access": "public",
|
|
59
|
+
"provenance": true
|
|
55
60
|
},
|
|
56
61
|
"scripts": {
|
|
57
62
|
"format": "prettier --write \"src/**/*.{ts,tsx}\"",
|
package/dist/ShortcutLabel.js
DELETED
|
@@ -1,94 +0,0 @@
|
|
|
1
|
-
import { jsx, jsxs } from 'react/jsx-runtime';
|
|
2
|
-
import React from 'react';
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* ... (rest of the description) ...
|
|
6
|
-
*
|
|
7
|
-
* @example
|
|
8
|
-
* // Default usage
|
|
9
|
-
* <ShortcutLabel keys={['ctrl', 's']} />
|
|
10
|
-
*
|
|
11
|
-
* @example
|
|
12
|
-
* // With multiple bindings
|
|
13
|
-
* <ShortcutLabel keys={[['ctrl', 's'], ['meta', 's']]} />
|
|
14
|
-
*
|
|
15
|
-
* @example
|
|
16
|
-
* // With custom render prop
|
|
17
|
-
* <ShortcutLabel
|
|
18
|
-
* keys={['ctrl', 'shift', 'a']}
|
|
19
|
-
* render={(keys) => {
|
|
20
|
-
* // 'keys' here will be ['ctrl', 'shift', 'a']
|
|
21
|
-
* return keys.map((key) => (
|
|
22
|
-
* <span key={key} style={{ color: '#00eaff' }}>
|
|
23
|
-
* {key.toUpperCase()}
|
|
24
|
-
* </span>
|
|
25
|
-
* ));
|
|
26
|
-
* }}
|
|
27
|
-
* />
|
|
28
|
-
*
|
|
29
|
-
* @example
|
|
30
|
-
* // With custom render prop for multiple bindings
|
|
31
|
-
* <ShortcutLabel
|
|
32
|
-
* keys={[['ctrl', 's'], ['meta', 's']]}
|
|
33
|
-
* render={(bindings) => {
|
|
34
|
-
* // 'bindings' here will be [['ctrl', 's'], ['meta', 's']]
|
|
35
|
-
* return bindings.map((binding, bindingIndex) => (
|
|
36
|
-
* <React.Fragment key={bindingIndex}>
|
|
37
|
-
* {binding.map((key, keyIndex) => (
|
|
38
|
-
* <span key={keyIndex} style={{ fontWeight: 'bold' }}>
|
|
39
|
-
* {key}
|
|
40
|
-
* </span>
|
|
41
|
-
* ))}
|
|
42
|
-
* {bindingIndex < bindings.length - 1 && ' or '}
|
|
43
|
-
* </React.Fragment>
|
|
44
|
-
* ));
|
|
45
|
-
* }}
|
|
46
|
-
* />
|
|
47
|
-
*
|
|
48
|
-
* @param {ShortcutLabelProps} props - Props for the ShortcutLabel component
|
|
49
|
-
* @param {string[] | string[][]} props.keys - The list of keys to display.
|
|
50
|
-
* @param {Function} [props.render] - Optional custom render function for full control over how your keys are rendered.
|
|
51
|
-
*
|
|
52
|
-
* @returns {JSX.Element} Rendered shortcut label
|
|
53
|
-
*/
|
|
54
|
-
const ShortcutLabel = ({ keys, render, style, ...props }) => {
|
|
55
|
-
const isMac = typeof navigator !== 'undefined' && /Mac/.test(navigator.userAgent);
|
|
56
|
-
const defaultRenderKey = (key) => {
|
|
57
|
-
switch (key.toLowerCase()) {
|
|
58
|
-
case 'meta':
|
|
59
|
-
return isMac ? '⌘' : 'Ctrl';
|
|
60
|
-
case 'ctrl':
|
|
61
|
-
return 'Ctrl';
|
|
62
|
-
case 'shift':
|
|
63
|
-
return '⇧';
|
|
64
|
-
case 'alt':
|
|
65
|
-
return isMac ? '⌥' : 'Alt';
|
|
66
|
-
case 'enter':
|
|
67
|
-
return '↵';
|
|
68
|
-
default:
|
|
69
|
-
return key.toUpperCase();
|
|
70
|
-
}
|
|
71
|
-
};
|
|
72
|
-
const renderBinding = (binding) => {
|
|
73
|
-
if (render) {
|
|
74
|
-
return render(binding);
|
|
75
|
-
}
|
|
76
|
-
return binding.map(key => defaultRenderKey(key)).join(' + ');
|
|
77
|
-
};
|
|
78
|
-
const isNestedArray = Array.isArray(keys[0]);
|
|
79
|
-
return (jsx("kbd", { style: {
|
|
80
|
-
fontFamily: 'monospace',
|
|
81
|
-
padding: '2.5px 5px',
|
|
82
|
-
border: '1px solid #cccccc2f',
|
|
83
|
-
backgroundColor: '#2e2e2e',
|
|
84
|
-
borderRadius: '4px',
|
|
85
|
-
userSelect: 'none',
|
|
86
|
-
...style,
|
|
87
|
-
}, ...props, children: isNestedArray
|
|
88
|
-
? render
|
|
89
|
-
? renderBinding(keys)
|
|
90
|
-
: keys.map((binding, index) => (jsxs(React.Fragment, { children: [renderBinding(binding), index < keys.length - 1 && ' / '] }, index)))
|
|
91
|
-
: renderBinding(keys) }));
|
|
92
|
-
};
|
|
93
|
-
|
|
94
|
-
export { ShortcutLabel };
|