├── .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 | --------------------------------------------------------------------------------