├── .gitignore
├── .prettierrc
├── LICENSE
├── README.md
├── package-lock.json
├── package.json
├── src
└── index.js
└── types
└── index.d.ts
/.gitignore:
--------------------------------------------------------------------------------
1 | # dependencies
2 | node_modules
3 |
4 | # build
5 | dist
6 |
7 | # misc
8 | .DS_Store
9 | .env
10 | .env.local
11 | .env.development.local
12 | .env.test.local
13 | .env.production.local
14 |
15 | npm-debug.log*
16 | yarn-debug.log*
17 | yarn-error.log*
18 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "tabWidth": 2,
3 | "useTabs": false,
4 | "printWidth": 120,
5 | "trailingComma": "all"
6 | }
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2019 Mihai Cernusca
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # useKeyState
2 |
3 | Keyboard events as values for React
4 |
5 | ## Introduction
6 |
7 | useKeyState monitors key presses and when a rule matches, your component re-renders.
8 |
9 | Read this first: https://use-key-state.mihaicernusca.com
10 |
11 | Example: https://codesandbox.io/s/n4o5z6yk3l
12 |
13 | ## Install
14 |
15 | ```text
16 | npm install use-key-state
17 | ```
18 |
19 | ## Usage
20 |
21 | Pass it a map of hotkey rules as strings and it hands back one of the same shape:
22 |
23 | ```javascript
24 | import {useKeyState} from 'use-key-state'
25 |
26 | const {asd} = useKeyState({asd: 'a+s+d'})
27 | ```
28 |
29 | Or pass it an array of rules per key:
30 |
31 | ```javascript
32 | const {asd, copy} = useKeyState({asd: 'a+s+d', copy: ['meta+c', 'ctrl+c']})
33 | ```
34 |
35 | The values are state objects with three boolean properties: `pressed`, `down` and `up`.
36 |
37 | Use `pressed` if you want to know if the keys are currently down. This is always true while the rule associated with it matches.
38 |
39 | Use `down` or `up` if you want to know when the `keydown` and `keyup` events that caused the rule to match trigger. These values will be false after you read the value so be sure to capture it if you need it in multiple places! This is the equivalent of an event callback - _you read it, consider yourself notified._
40 |
41 | This behavior is also what makes it safe to use because if it returns true in one render it is guaranteed to return false at the next:
42 |
43 | ```javascript
44 | React.useEffect(() => {
45 | if (asd.down) {
46 | dispatch({type: 'do-the-down-thing'})
47 | } else if (asd.up) {
48 | dispatch({type: 'do-the-up-thing'})
49 | }
50 | }, [asd])
51 | ```
52 |
53 | The pressed property is appropriate to use if you need to base your render logic on the pressed state:
54 |
55 | ```jsx
56 |
57 | ```
58 |
59 | or inside an event handler or other form of render loop:
60 |
61 | ```javascript
62 | handleDrag = (e) => {
63 | if (asd.pressed) {
64 | // do things differently while key is pressed
65 | }
66 | }
67 | ```
68 |
69 | ### Document Events
70 |
71 | While useKeyState hooks maintain their own internal state, they share one singleton document event listener making them relatively cheap. Events are called in a first-in-last-out order giving your deeper components a chance to handle the event first. If you use multiple instances of the useKeyState hook in one component the same rule applies.
72 |
73 | Late mounted children however get added to the end of the priority list despite being deeper in the tree.
74 | In complex apps where you rely on deterministic event order you can enforce a depth priority by wrapping your app layers in `` components which keep track of depth relative to parent ``. Key callbacks will be sorted first by depth and then by insertion order.
75 |
76 | ```javascript
77 | import {KeyStateLayer} from 'use-key-state'
78 |
79 | function Page() {
80 | return (
81 |
82 |
83 |
84 | )
85 | }
86 | ```
87 |
88 | Alternatively set priority config option (see Configuration below). This will override other default or inferred priorities.
89 |
90 | ### Configuration
91 |
92 | useKeyState accepts a second parameter for configuration which will be merged in with the default:
93 |
94 | ```javascript
95 | const defaultConfig = {
96 | captureEvents: false, // call event.preventDefault()
97 | ignoreRepeatEvents: true, // filter out repeat key events (whos event.repeat property is true)
98 | ignoreCapturedEvents: true, // respect the defaultPrevented event flag
99 | ignoreInputAcceptingElements: true, // filter out events from all forms of inputs
100 | priority: undefined, // see Document Events above
101 | debug: false, // enabled debug logging
102 | }
103 | ```
104 |
105 | Configuration is at the hook level - feel free to use multiple hooks in the same component where needed.
106 |
107 | ### Dynamic Rules and Configuration
108 |
109 | Both the rules map and the configuration objects can be updated dynamically. For example, only capture if we're in editing mode:
110 |
111 | ```javascript
112 | const {asd} = useKeyState({asd: 'a+s+d'}, {captureEvents: isEditing})
113 | ```
114 |
115 | or, don't bind at all unless we're editing:
116 |
117 | ```javascript
118 | const {asd} = useKeyState({asd: isEditing ? 'a+s+d' : ''})
119 | ```
120 |
121 | ### Query
122 |
123 | If you just need a way to query the pressed keys and not re-render your component you can instantiate the hook with no parameters and get a query object with a few helper methods on it:
124 |
125 | ```javascript
126 | const query = useKeyState().keyStateQuery
127 |
128 | if (query.pressed('space') {
129 | // true while space key is pressed
130 | }
131 |
132 | // also comes with some helper methods. Equivalent to above:
133 |
134 | if (query.space() {
135 | // true while space key is pressed
136 | }
137 | ```
138 |
139 | This object gets merged into all returns under the key `keyStateQuery` in case you need access to the query object but don't want to create another instance:
140 |
141 | ```javascript
142 | const { asd, keyStateQuery } = useKeyState({ "a+s+d"});
143 | ```
144 |
145 | ### Rule syntax
146 |
147 | useKeyState keeps track of keyboard [event.code](https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/code) values between key up and down events. A valid rule is a plus sign separated string of keyboard event codes.
148 |
149 | Codes map to the physical key not the key value. To test the key code of a particular key I recommend this tool: [keycode.info](https://keycode.info/). This is a valid rule:
150 |
151 | ```javascript
152 | const { shiftA } = useKeyState({ ["ShiftLeft + KeyA", "ShiftRight + KeyA"]});
153 | ```
154 |
155 | For convenience we map a few common codes to more sensible alternatives. This is also an equivalent rule:
156 |
157 | ```javascript
158 | const { shiftA } = useKeyState({"shift + a"});
159 | ```
160 |
161 | Much better!
162 |
163 | We map `a-Z`, `0-9`, `f1-f12`, `[`, `]`, `shift`,`meta|cmd|command|win`,`ctrl|cntrl|control`,`tab`,`esc|escape`,`plus|equal|equals|=`,`minus`,`delete|backspace`,`space`,`alt|opt`,`period|.`,`up`,`down`,`right`,`left`,`enter|return`,`slash|/`,`backslash|\`. This list may fall out of sync, check the source (toCodes function) if not sure!
164 |
165 | ### Overlapping rules
166 |
167 | Consider the example:
168 |
169 | ```javascript
170 | const {forward, backward, backspace, tab, undo, redo} = useKeyState({
171 | undo: ['meta+z', 'ctrl+z'],
172 | redo: ['shift+meta+z', 'shift+ctrl+z'],
173 | })
174 | ```
175 |
176 | If you have rules that are a subset of another rule they will both match when the more specific rule fires (although they'll both only match once - it won't reset). Because of this you have to be careful to check the specific rule first:
177 |
178 | ```javascript
179 | // If undo (meta+z) matches, make sure it isn't a redo (shift+meta+z)
180 | if (undo.down) {
181 | if (redo.down) {
182 | return void onRedo()
183 | }
184 | return void onUndo()
185 | }
186 | ```
187 |
188 | Avoid separate instances of the useKeyState hook that contain overlapping rules as your component will re-render twice. A good reason to use separate instances of this hook in one component is because you want a to pass a different configuration object in the 2nd parameter. Here is a real-life example:
189 |
190 | ```javascript
191 | // We want to capture and support key repeat for arrow keys while editing:
192 | const {upArrow, downArrow, leftArrow, rightArrow} = useKeyState(
193 | {
194 | upArrow: isEditing ? 'up' : '',
195 | downArrow: isEditing ? 'down' : '',
196 | leftArrow: isEditing ? 'left' : '',
197 | rightArrow: isEditing ? 'right' : '',
198 | },
199 | {
200 | ignoreRepeatEvents: false,
201 | captureEvents: isEdit && focusKey,
202 | }
203 | )
204 | // But we don't want to support key repeat for the undo and redo key bindings
205 | const {forward, backward, backspace, tab, undo, redo} = useKeyState(
206 | {
207 | undo: isEdit ? ['meta+z', 'ctrl+z'] : '',
208 | redo: isEdit ? ['shift+meta+z', 'shift+ctrl+z'] : '',
209 | },
210 | {
211 | captureEvents: isEditing,
212 | }
213 | )
214 | ```
215 |
216 | That's it!
217 |
218 | ## Goals
219 |
220 | - enable a different way to program with key events
221 |
222 | ## Non-Goals
223 |
224 | - legacy browsers support
225 |
226 | - support multiple rule syntaxes
227 |
228 | - key sequences, although that could be a specific form of the keyState hook at some point
229 |
230 | Think carefully about what you need!
231 |
232 | ## Quirks
233 |
234 | Meta key clears the map when it goes up as we don't get key up events after the meta key is pressed. That means while meta is down all further key presses will return `pressed` until meta goes up.
235 |
236 | These are implementation details which might change but this is the current behavior.
237 |
238 | ## Notes
239 |
240 | If you're still confused, this is essentially hook sugar over a callback API like:
241 |
242 | ```javascript
243 | // not real code
244 | KeyState.on('a+s+d', (down) => {
245 | this.setState({asdPressed: down}, () => {
246 | if (down) {
247 | // do the down thing
248 | } else {
249 | // do the up thing
250 | }
251 | })
252 | })
253 | ```
254 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "use-key-state",
3 | "description": "Keyboard events as values for React (hook)",
4 | "homepage": "https://use-key-state.mihaicernusca.com",
5 | "author": "Mihai Cernusca (http://mihaicernusca.com)",
6 | "version": "0.2.2",
7 | "license": "MIT",
8 | "main": "dist/index.js",
9 | "umd:main": "dist/index.umd.js",
10 | "module": "dist/index.es.js",
11 | "source": "src/index.js",
12 | "types": "types/index.d.ts",
13 | "repository": {
14 | "type": "git",
15 | "url": "git+ssh://git@github.com/mcernusca/use-key-state.git"
16 | },
17 | "bugs": {
18 | "url": "https://github.com/mcernusca/use-key-state/issues"
19 | },
20 | "keywords": [
21 | "react",
22 | "hook",
23 | "key",
24 | "keycombo",
25 | "event",
26 | "combo",
27 | "hotkey",
28 | "shortcut"
29 | ],
30 | "scripts": {
31 | "prebuild": "rm -rf ./dist",
32 | "build": "microbundle --sourcemap false --no-compress --no-generateTypes",
33 | "dev": "microbundle watch --sourcemap false --no-compress --no-generateTypes",
34 | "prepublishOnly": "npm run build"
35 | },
36 | "peerDependencies": {
37 | "react": ">= 16.8.0"
38 | },
39 | "devDependencies": {
40 | "microbundle": "^0.15.1",
41 | "react": "^18"
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | // useKeyState - keyboard events as values ( ¿ )
2 | // https://use-key-state.mihaicernusca.com
3 |
4 | import React from "react";
5 |
6 | // Event Emitter
7 |
8 | class EventEmitter {
9 | constructor() {
10 | this.events = {};
11 | }
12 | _getWeightedEventListByName(eventName) {
13 | if (typeof this.events[eventName] === "undefined") {
14 | this.events[eventName] = [];
15 | }
16 | return this.events[eventName];
17 | }
18 | on(eventName, fn, priority = 0) {
19 | const weightedLists = this._getWeightedEventListByName(eventName);
20 | if (!weightedLists[priority]) {
21 | weightedLists[priority] = new Set();
22 | }
23 | weightedLists[priority].add(fn);
24 | }
25 | once(eventName, fn) {
26 | const self = this;
27 | const onceFn = function (...args) {
28 | self.removeListener(eventName, onceFn);
29 | fn.apply(self, args);
30 | };
31 | this.on(eventName, onceFn);
32 | }
33 | emit(eventName, ...args) {
34 | const prioritizedCallbacks = [...this._getWeightedEventListByName(eventName)]
35 | .reverse() // highest weight first
36 | .map((list) => Array.from(list)) // preserving insertion order
37 | .flat();
38 | prioritizedCallbacks.forEach(
39 | function (fn) {
40 | fn.apply(this, args);
41 | }.bind(this),
42 | );
43 | }
44 | removeListener(eventName, fn) {
45 | this._getWeightedEventListByName(eventName).forEach((list) => list.delete(fn));
46 | }
47 | }
48 |
49 | // Document Event Listener
50 |
51 | const eventEmitter = new EventEmitter();
52 | const boundEvents = {};
53 | function emitDomEvent(event) {
54 | eventEmitter.emit(event.type, event);
55 | }
56 |
57 | const DocumentEventListener = {
58 | addEventListener(eventName, listener, priority) {
59 | if (!boundEvents[eventName]) {
60 | document.addEventListener(eventName, emitDomEvent, true);
61 | }
62 | eventEmitter.on(eventName, listener, priority);
63 | },
64 | removeEventListener(eventName, listener) {
65 | eventEmitter.removeListener(eventName, listener);
66 | },
67 | };
68 |
69 | // Key State
70 |
71 | function KeyState(isDown = false, justReset = false) {
72 | this.pressed = isDown; //current (live) combo pressed state
73 | this.down = isDown; //only true for one read after combo becomes valid
74 | this.up = justReset; //only true for one read after combo is no longer valid
75 | }
76 |
77 | function _get(context, key) {
78 | const expiredKey = `_${key}Expired`;
79 | const valueKey = `_${key}`;
80 | if (context[expiredKey]) {
81 | return false;
82 | }
83 | context[expiredKey] = true;
84 | return context[valueKey];
85 | }
86 |
87 | function _set(context, key, value) {
88 | const expiredKey = `_${key}Expired`;
89 | const valueKey = `_${key}`;
90 | context[expiredKey] = false;
91 | context[valueKey] = value;
92 | }
93 |
94 | Object.defineProperty(KeyState.prototype, "down", {
95 | get: function () {
96 | return _get(this, "down");
97 | },
98 | set: function (value) {
99 | _set(this, "down", value);
100 | },
101 | });
102 |
103 | Object.defineProperty(KeyState.prototype, "up", {
104 | get: function () {
105 | return _get(this, "up");
106 | },
107 | set: function (value) {
108 | _set(this, "up", value);
109 | },
110 | });
111 |
112 | // Utils
113 |
114 | // toCodes: maps our string notation to possible key event codes
115 | // See: https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/code
116 | // Returns an array because browser / platform inconsistencies and we don't care to
117 | // distinguish between left and right keys usually. If we can't map it we pass
118 | // it through which means everyone is free to use the actual codes in the rules
119 | // if they need more.
120 | function toCodes(input) {
121 | if (!input.length) {
122 | return [];
123 | }
124 |
125 | const str = input.toLowerCase();
126 | const len = str.length;
127 |
128 | if (len === 1 && str.match(/[a-z]/i)) {
129 | // a-z
130 | return [`Key${str.toUpperCase()}`];
131 | } else if (len === 1 && str.match(/[0-9]/i)) {
132 | // 0-9
133 | return [`Digit${str}`];
134 | }
135 |
136 | switch (str) {
137 | case "[":
138 | return ["BracketLeft"];
139 | case "]":
140 | return ["BracketRight"];
141 | case "\\":
142 | case "backslash":
143 | return ["Backslash"];
144 | case "period":
145 | case ".":
146 | return ["Period"];
147 | case "comma":
148 | case ",":
149 | return ["Comma", "NumpadComma"];
150 | case "slash":
151 | case "/":
152 | return ["Slash"];
153 | case "backquote":
154 | case "`":
155 | return ["Backquote"];
156 | case "delete":
157 | case "backspace":
158 | return ["Backspace", "Delete"];
159 | case "tab":
160 | return ["Tab"];
161 | case "enter":
162 | case "return":
163 | return ["Enter", "NumpadEnter"];
164 | case "shift":
165 | return ["ShiftLeft", "ShiftRight"];
166 | case "ctrl":
167 | case "cntrl":
168 | case "control":
169 | return ["ControlRight", "ControlLeft"];
170 | case "option":
171 | case "opt":
172 | case "alt":
173 | return ["AltLeft", "AltRight"];
174 | case "esc":
175 | case "escape":
176 | return ["Escape"];
177 | case "space":
178 | return ["Space"];
179 | case "left":
180 | return ["ArrowLeft"];
181 | case "up":
182 | return ["ArrowUp"];
183 | case "right":
184 | return ["ArrowRight"];
185 | case "down":
186 | return ["ArrowDown"];
187 | case "cmd":
188 | case "command":
189 | case "win":
190 | case "meta":
191 | return ["OSLeft", "OSRight", "MetaLeft", "MetaRight"];
192 | case "plus":
193 | case "equal":
194 | case "equals":
195 | case "=":
196 | return ["Equal"];
197 | case "minus":
198 | return ["Minus"];
199 | case "f1":
200 | case "f2":
201 | case "f3":
202 | case "f4":
203 | case "f5":
204 | case "f6":
205 | case "f8":
206 | case "f9":
207 | case "f10":
208 | case "f11":
209 | case "f12":
210 | return [str.toUpperCase()];
211 | default:
212 | // Pass input through to allow using specific event codes directly:
213 | return [input];
214 | }
215 | }
216 |
217 | function matchRule(rule, isDown) {
218 | function matchRuleStr(ruleStr) {
219 | const parts = parseRuleStr(ruleStr);
220 | const results = parts.map((str) => isDown(toCodes(str)));
221 | return results.every((r) => r === true);
222 | }
223 |
224 | if (Array.isArray(rule)) {
225 | return rule.some((ruleStr) => matchRuleStr(ruleStr, isDown) === true);
226 | }
227 | return matchRuleStr(rule, isDown);
228 | }
229 |
230 | function extractCaptureSet(rulesMap) {
231 | const captureSet = new Set();
232 | Object.entries(rulesMap).forEach(([, value]) => {
233 | const rules = Array.isArray(value) ? value : [value];
234 | rules.forEach((rule) => {
235 | const parts = parseRuleStr(rule);
236 | parts.forEach((part) => {
237 | toCodes(part).forEach((code) => captureSet.add(code));
238 | });
239 | });
240 | });
241 | return captureSet;
242 | }
243 |
244 | function parseRuleStr(rule) {
245 | return rule.split("+").map((str) => str.trim());
246 | }
247 |
248 | function mapRulesToState(rulesMap, prevState = {}, isDown = () => false) {
249 | const keysToState = { ...prevState };
250 | Object.entries(rulesMap).forEach(([key, rule]) => {
251 | const matched = matchRule(rule, isDown);
252 | const prevKeyState = keysToState[key];
253 | if (prevKeyState) {
254 | if (prevKeyState.pressed !== matched) {
255 | const up = prevKeyState.pressed && !matched;
256 | keysToState[key] = new KeyState(matched, up);
257 | }
258 | } else {
259 | keysToState[key] = new KeyState();
260 | }
261 | });
262 | return keysToState;
263 | }
264 |
265 | function validateRulesMap(map) {
266 | // Expecting an object
267 | if (!map || typeof map !== "object") {
268 | throw new Error(`useKeyState: expecting an object {key:value} as first parameter.`);
269 | }
270 | // Expecting string or array values for each key
271 | Object.entries(map).forEach(([key, value]) => {
272 | const isArray = Array.isArray(value);
273 | const isString = typeof value === "string";
274 | if (!isString && !isArray) {
275 | throw new Error(`useKeyState: expecting string or array value for key ${key}.`);
276 | }
277 | if (isArray) {
278 | value.forEach((rule) => {
279 | if (typeof rule !== "string") {
280 | throw new Error(`useKeyState: expecting array of strings for key ${key}`);
281 | }
282 | });
283 | }
284 | });
285 | }
286 |
287 | // Utils
288 |
289 | function deepEqual(o1, o2) {
290 | return JSON.stringify(o1) === JSON.stringify(o2);
291 | }
292 |
293 | function isInputAcceptingTarget(event) {
294 | // content editable
295 | if (event.target.isContentEditable) {
296 | return true;
297 | }
298 | // form elements
299 | var tagName = (event.target || event.srcElement).tagName;
300 | return tagName === "INPUT" || tagName === "SELECT" || tagName === "TEXTAREA";
301 | }
302 |
303 | // Config
304 |
305 | const defaultConfig = {
306 | captureEvents: false, // call event.preventDefault()
307 | ignoreRepeatEvents: true, // filter out repeat key events (whos event.repeat property is true)
308 | ignoreCapturedEvents: true, // respect the defaultPrevented event flag
309 | ignoreInputAcceptingElements: true, // filter out events from all forms of inputs
310 | priority: undefined, // see DepthContext below
311 | debug: false,
312 | };
313 |
314 | // DepthContext
315 | // We want to mimic the DOM bubbling behavior of giving deeper children a chance to capture events.
316 | // We subscribe to a global key handler on component mount in a filo fashion (useEffect (our subscription) is called in a bottom-up fashion).
317 | // Late mounted children however would get added to the end of the priority list despite being deeper in the tree.
318 | // To enforce a depth priority, you can wrap your app layers in components that keep track of depth relative to parent .
319 | // Key callbacks will be sorted first by depth and then by insertion order.
320 | // Alternatively set priority config option. This will override other default or inferred priorities.
321 |
322 | export const DepthContext = React.createContext(0);
323 |
324 | export function KeyStateLayer({ children }) {
325 | const parentDepth = React.useContext(DepthContext);
326 | return React.createElement(DepthContext.Provider, { value: parentDepth + 1 }, children);
327 | }
328 |
329 | // useKeyState ¿
330 |
331 | export const useKeyState = function (rulesMap = {}, configOverrides = {}) {
332 | const configRef = React.useRef({ ...defaultConfig, ...configOverrides });
333 | React.useEffect(() => {
334 | // configOverrides is likely to always be different:
335 | if (!deepEqual(configOverrides, configRef.current)) {
336 | configRef.current = { ...defaultConfig, ...configOverrides };
337 | }
338 | }, [configOverrides]);
339 | const inferredPriority = React.useContext(DepthContext);
340 | const depth = configRef.current.priority || inferredPriority || 0;
341 |
342 | // Maintain a copy of the rules map passed in
343 | const rulesMapRef = React.useRef({});
344 | // Validate and update rulesMap when it changes to enable dynamic rules
345 | React.useEffect(() => {
346 | // rulesMap is likely to always be different:
347 | if (!deepEqual(rulesMap, rulesMapRef.current)) {
348 | validateRulesMap(rulesMap);
349 | rulesMapRef.current = rulesMap;
350 | }
351 | }, [rulesMap]);
352 | // Keep track of what keys are down
353 | const keyMapRef = React.useRef({});
354 | // This gets passed back to the caller and is updated
355 | // once any hotkey rule matches or stops matching
356 | const [state, setState] = React.useState(() => mapRulesToState(rulesMap));
357 | // Query live key state and some common key utility fns:
358 | // This object gets merged into return object
359 | const keyStateQuery = {
360 | pressed: (input) => {
361 | return matchRule(input, isDown);
362 | },
363 | space: () => {
364 | return isDown(toCodes("space"));
365 | },
366 | shift: () => {
367 | return isDown(toCodes("shift"));
368 | },
369 | ctrl: () => {
370 | return isDown(toCodes("ctrl"));
371 | },
372 | alt: () => {
373 | return isDown(toCodes("alt"));
374 | },
375 | option: () => {
376 | return isDown(toCodes("option"));
377 | },
378 | meta: () => {
379 | return isDown(toCodes("meta"));
380 | },
381 | esc: () => {
382 | return isDown(toCodes("esc"));
383 | },
384 | };
385 |
386 | // Re-render the component if the key states have changed.
387 | // Must capture state value in a ref because the actual
388 | // updateKeyState function captures the initial value:
389 | const stateRef = React.useRef(state);
390 |
391 | const updateKeyState = React.useCallback(() => {
392 | const nextState = mapRulesToState(rulesMapRef.current, stateRef.current, isDown);
393 | const isEquivalentState = deepEqual(stateRef.current, nextState);
394 |
395 | if (configRef.current.debug) {
396 | console.log("useKeyState: rulesMap", { ...rulesMapRef.current }, "keyMap", {
397 | ...keyMapRef.current,
398 | });
399 | }
400 |
401 | if (!isEquivalentState) {
402 | stateRef.current = nextState;
403 | setState(nextState);
404 | }
405 | }, []);
406 |
407 | function isDown(codes) {
408 | const results = codes.map((code) => keyMapRef.current[code] || false);
409 | return results.some((r) => r === true);
410 | }
411 |
412 | // Event handlers
413 |
414 | const handleUp = React.useCallback(
415 | (event) => {
416 | if (configRef.current.debug) {
417 | console.log("useKeyState: up", event.code);
418 | }
419 | // If we have an up event for an already captured down, process it regardless of source.
420 | // This is because the down could have come in before the input got focus.
421 | const isDown = !!keyMapRef.current[event.code];
422 | // Ignore events from input accepting elements (inputs etc)
423 | if (configRef.current.ignoreInputAcceptingElements && isInputAcceptingTarget(event) && !isDown) {
424 | if (configRef.current.debug) {
425 | console.log("useKeyState: Ignoring captured up event:", event.code);
426 | }
427 | return;
428 | }
429 | // If Meta goes up, throw everything away because we might have stuck
430 | // keys (OSX gets no keyup events while the cmd key is held down)
431 | // http://web.archive.org/web/20160304022453/http://bitspushedaround.com/on-a-few-things-you-may-not-know-about-the-hellish-command-key-and-javascript-events/
432 | if (toCodes("meta").includes(event.code)) {
433 | keyMapRef.current = {};
434 | }
435 |
436 | delete keyMapRef.current[event.code];
437 | updateKeyState();
438 | },
439 | [updateKeyState],
440 | );
441 |
442 | const handleDown = React.useCallback(
443 | (event) => {
444 | if (configRef.current.debug) {
445 | console.log("useKeyState: down", event.code);
446 | }
447 |
448 | // Ignore events from input accepting elements (inputs etc)
449 | if (configRef.current.ignoreInputAcceptingElements && isInputAcceptingTarget(event)) {
450 | if (configRef.current.debug) {
451 | console.log("useKeyState: Ignoring event from input accepting element:", event.code);
452 | }
453 | return;
454 | }
455 |
456 | // Ignore handled event
457 | if (event.defaultPrevented && configRef.current.ignoreCapturedEvents) {
458 | if (configRef.current.debug) {
459 | console.log("useKeyState: Ignoring captured down event:", event.code);
460 | }
461 | return;
462 | }
463 |
464 | // Capture event if it is part of our rules and hook is configured to do so:
465 | if (configRef.current.captureEvents) {
466 | const captureSet = extractCaptureSet(rulesMapRef.current);
467 | if (captureSet.has(event.code)) {
468 | event.preventDefault();
469 | }
470 | }
471 |
472 | // Handle key repeat
473 | if (event.repeat && keyMapRef.current[event.code]) {
474 | if (configRef.current.ignoreRepeatEvents) {
475 | if (configRef.current.debug) {
476 | console.log("useKeyState: Ignoring event from repeat key event:", event.code);
477 | }
478 | } else {
479 | // handle it as a key up (drop every other frame, hack)
480 | handleUp(event);
481 | }
482 | return;
483 | }
484 |
485 | // Handle key that didn't receive a key up event - happens when meta key is down
486 | if (keyMapRef.current[event.code]) {
487 | delete keyMapRef.current[event.code];
488 | updateKeyState();
489 | }
490 |
491 | keyMapRef.current[event.code] = true;
492 | updateKeyState();
493 | },
494 | [handleUp, updateKeyState],
495 | );
496 |
497 | // Mark handlers to help debug callback order
498 | if (configRef.current.debug) {
499 | window.keyStateEventEmitter = eventEmitter;
500 | }
501 |
502 | const handleBlur = React.useCallback(() => {
503 | if (configRef.current.debug) {
504 | console.log("useKeyState: clearing keyMap on document blur");
505 | }
506 | keyMapRef.current = {};
507 | updateKeyState();
508 | }, [updateKeyState]);
509 |
510 | React.useEffect(() => {
511 | DocumentEventListener.addEventListener("keydown", handleDown, depth);
512 | DocumentEventListener.addEventListener("keyup", handleUp, depth);
513 | window.addEventListener("blur", handleBlur);
514 | return () => {
515 | DocumentEventListener.removeEventListener("keydown", handleDown);
516 | DocumentEventListener.removeEventListener("keyup", handleUp);
517 | window.removeEventListener("blur", handleBlur);
518 | };
519 | }, [handleDown, handleUp, handleBlur, depth]);
520 |
521 | return { ...state, keyStateQuery };
522 | };
523 |
--------------------------------------------------------------------------------
/types/index.d.ts:
--------------------------------------------------------------------------------
1 | export type KeyState = {
2 | pressed: boolean;
3 | down: boolean;
4 | up: boolean;
5 | };
6 |
7 | export type KeyStateOptions = {
8 | captureEvents?: boolean;
9 | ignoreRepeatEvents?: boolean;
10 | ignoreCapturedEvents?: boolean;
11 | ignoreInputAcceptingElements?: boolean;
12 | priority?: number;
13 | debug?: boolean;
14 | };
15 |
16 | export type KeyRules = {
17 | [x: string]: string | string[];
18 | };
19 |
20 | export type KeyStateQuery = {
21 | pressed: (input: string) => boolean;
22 | space: () => boolean;
23 | shift: () => boolean;
24 | ctrl: () => boolean;
25 | alt: () => boolean;
26 | option: () => boolean;
27 | meta: () => boolean;
28 | esc: () => boolean;
29 | };
30 |
31 | type KeyStateProps = { [P in keyof T]: KeyState };
32 |
33 | type KeyStateQueryObject = {
34 | keyStateQuery: KeyStateQuery;
35 | };
36 |
37 | export type KeyStates = KeyStateProps & KeyStateQueryObject;
38 |
39 | export function useKeyState(rulesMap?: T, configOverrides?: KeyStateOptions): KeyStates;
40 |
41 | export const DepthContext: React.Context;
42 |
43 | export interface KeyStateLayerProps {
44 | children: ReactNode;
45 | }
46 |
47 | export function KeyStateLayer(props: KeyStateLayerProps): React.JSX.Element;
48 |
--------------------------------------------------------------------------------