├── .babelrc.js ├── .browserslistrc ├── .editorconfig ├── .eslintignore ├── .eslintrc.js ├── .github └── workflows │ ├── demo.yml │ └── publish.yml ├── .gitignore ├── .husky ├── .gitignore └── pre-push ├── .npmrc ├── LICENSE ├── README.md ├── package.json ├── pnpm-lock.yaml ├── rollup.config.mjs ├── src ├── constants.ts ├── demo │ ├── app.js │ └── index.html ├── index.ts ├── node.ts ├── subject.ts ├── types │ ├── index.ts │ └── shim.d.ts └── util.ts ├── test ├── node.test.ts └── util.test.ts ├── tsconfig.json └── tsconfig.prod.json /.babelrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: require.resolve('@gera2ld/plaid/config/babelrc-base'), 3 | presets: [ 4 | '@babel/preset-typescript', 5 | ], 6 | plugins: [ 7 | ].filter(Boolean), 8 | }; 9 | -------------------------------------------------------------------------------- /.browserslistrc: -------------------------------------------------------------------------------- 1 | Chrome >= 55 2 | Firefox >= 53 3 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | quote_type = single 12 | 13 | [*.md] 14 | trim_trailing_whitespace = false 15 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | /* 2 | !/src 3 | !/test 4 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: [ 4 | require.resolve('@gera2ld/plaid/eslint'), 5 | ], 6 | parserOptions: { 7 | project: './tsconfig.json', 8 | }, 9 | }; 10 | -------------------------------------------------------------------------------- /.github/workflows/demo.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v3 14 | - uses: actions/setup-node@v3 15 | with: 16 | node-version: '20' 17 | - uses: pnpm/action-setup@v2 18 | with: 19 | version: 8 20 | - name: Build 21 | run: pnpm i && pnpm demo 22 | - name: Deploy to GitHub Pages 23 | uses: JamesIves/github-pages-deploy-action@v4 24 | with: 25 | folder: dist 26 | branch: gh-pages 27 | single-commit: true 28 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish to npmjs 2 | 3 | on: 4 | push: 5 | tags: 6 | - v* 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | - uses: actions/setup-node@v3 14 | with: 15 | node-version: '20' 16 | registry-url: 'https://registry.npmjs.org' 17 | - uses: pnpm/action-setup@v2 18 | with: 19 | version: 8 20 | - run: pnpm i && pnpm publish --no-git-checks 21 | env: 22 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.log 3 | /.idea 4 | /dist 5 | /.nyc_output 6 | /coverage 7 | /types 8 | /docs 9 | -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.husky/pre-push: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npm run lint 5 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | shamefully-hoist = true 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Gerald <gera2ld@live.com> 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 | # VM.shortcut 2 | 3 | [![NPM](https://img.shields.io/npm/v/@violentmonkey/shortcut.svg)](https://npm.im/@violentmonkey/shortcut) 4 | ![License](https://img.shields.io/npm/l/@violentmonkey/shortcut.svg) 5 | [![jsDocs.io](https://img.shields.io/badge/jsDocs.io-reference-blue)](https://www.jsdocs.io/package/@violentmonkey/shortcut) 6 | 7 | Register a shortcut for a function. 8 | 9 | This is a helper script for Violentmonkey. 10 | 11 | 👉 Playground: https://violentmonkey.github.io/vm-shortcut/ 12 | 13 | ## Usage 14 | 15 | ### Importing 16 | 17 | 1. Use in a userscript: 18 | 19 | ```js 20 | // ... 21 | // @require https://cdn.jsdelivr.net/npm/@violentmonkey/shortcut@1 22 | // ... 23 | 24 | const { register, ... } = VM.shortcut; 25 | ``` 26 | 27 | 1. Use as a module: 28 | 29 | ```bash 30 | $ yarn add @violentmonkey/shortcut 31 | ``` 32 | 33 | ```js 34 | import { register, ... } from '@violentmonkey/shortcut'; 35 | ``` 36 | 37 | ### Registering Shortcuts 38 | 39 | 1. Register a shortcut: 40 | 41 | ```js 42 | import { register } from '@violentmonkey/shortcut'; 43 | 44 | register('c-i', () => { 45 | console.log('You just pressed Ctrl-I'); 46 | }); 47 | 48 | // shortcuts will be enabled by default 49 | ``` 50 | 51 | 1. Enable or disable all shortcuts: 52 | 53 | ```js 54 | import { enable, disable } from '@violentmonkey/shortcut'; 55 | 56 | disable(); 57 | // ... 58 | enable(); 59 | ``` 60 | 61 | 1. Key sequences: 62 | 63 | ```js 64 | import { register } from '@violentmonkey/shortcut'; 65 | 66 | register('c-a c-b', () => { 67 | console.log('You just pressed Ctrl-A Ctrl-B sequence'); 68 | }); 69 | ``` 70 | 71 | 1. Handle keys with custom listeners (e.g. use with text editor like TinyMCE): 72 | 73 | ```js 74 | import { handleKey } from '@violentmonkey/shortcut'; 75 | 76 | function onKeyDown(e) { 77 | handleKey(e); 78 | } 79 | 80 | addMyKeyDownListener(onKeyDown); 81 | ``` 82 | 83 | ### Advanced Usage 84 | 85 | The usage above is with the default keyboard service. However you can use the `KeyboardService` directly to get full control of the class: 86 | 87 | ```js 88 | import { KeyboardService } from '@violentmonkey/shortcut'; 89 | 90 | const service = new KeyboardService(); 91 | // Or pass options 92 | const service = new KeyboardService({ 93 | sequenceTimeout: 500, 94 | }); 95 | 96 | service.enable(); 97 | 98 | service.register('c-i', () => { 99 | console.log('You just pressed Ctrl-I'); 100 | }); 101 | 102 | // Only register the following key when `disableThisKey` is false 103 | service.register( 104 | 'g g', 105 | () => { 106 | console.log('Now disableThisKey is false and you pressed `g g`'); 107 | }, 108 | { 109 | condition: '!disableThisKey', 110 | } 111 | ); 112 | 113 | // Update `disableThisKey` on different conditions 114 | // Note: these callbacks are just demos, you need to implement them by yourself!!! 115 | onOneCondition(() => { 116 | service.setContext('disableThisKey', true); 117 | }); 118 | onAnotherCondition(() => { 119 | service.setContext('disableThisKey', false); 120 | }); 121 | 122 | // Disable the shortcuts and unbind all events whereever you want 123 | service.disable(); 124 | 125 | // Reenable the shortcuts later 126 | service.enable(); 127 | ``` 128 | 129 | ## API 130 | 131 | [![jsDocs.io](https://img.shields.io/badge/jsDocs.io-reference-blue)](https://www.jsdocs.io/package/@violentmonkey/shortcut) 132 | 133 | ## Key Definition 134 | 135 | A key sequence is a space-separated list of combined keys. Each combined key is composed of zero or more modifiers and exactly one base key in the end, concatenated with dashes (`-`). The modifiers are always case-insensitive and can be abbreviated as their first letters. 136 | 137 | Here are some valid examples: 138 | 139 | ``` 140 | ctrl-alt-c 141 | ctrl-a-c 142 | c-a-c 143 | ``` 144 | 145 | Possible modifiers are: 146 | 147 | - `c`, `ctrl`, `control` 148 | - `s`, `shift` 149 | - `a`, `alt` 150 | - `m`, `meta`, `cmd` 151 | - `cm`, `ctrlcmd` 152 | 153 | There is one special case, `ctrlcmd` for `ctrl` on Windows and `cmd` for macOS, so if we register `ctrlcmd-s` to save something, the callback will be called when `ctrl-s` is pressed on Windows, and when `cmd-s` is pressed on macOS. This is useful to register cross-platform shortcuts. 154 | 155 | ## Condition Syntax 156 | 157 | - `conditionA` - when `conditionA` is truthy 158 | - `!conditionB` - when `conditionB` is falsy 159 | - `conditionA && conditionB` - when both `conditionA` and `conditionB` are truthy 160 | 161 | For more complicated cases, it's recommended to handle the logic in a function and store the result as a simple condition to the context. 162 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@violentmonkey/shortcut", 3 | "version": "1.4.4", 4 | "description": "Register a shortcut for a function", 5 | "author": "Gerald ", 6 | "license": "ISC", 7 | "scripts": { 8 | "prepare": "husky || true", 9 | "dev": "rollup -wc", 10 | "prepublishOnly": "run-s build", 11 | "lint": "eslint --ext .ts,.tsx . && prettier -c --ignore-path=.eslintignore .", 12 | "lint:fix": "eslint --ext .ts,.tsx . --fix && prettier -c --ignore-path=.eslintignore . -w", 13 | "test": "jest", 14 | "ci": "run-s lint test", 15 | "clean": "del-cli dist types", 16 | "build:js": "NODE_ENV=production rollup -c", 17 | "build:types": "tsc -p tsconfig.prod.json", 18 | "build": "run-s ci clean build:*", 19 | "demo:dev": "DEMO=1 run-s demo:cp dev", 20 | "demo:build": "DEMO=1 run-s build", 21 | "demo:cp": "cp src/demo/index.html dist", 22 | "demo": "run-s demo:build demo:cp" 23 | }, 24 | "unpkg": "dist/index.js", 25 | "jsdelivr": "dist/index.js", 26 | "repository": "git@github.com:violentmonkey/vm-shortcut.git", 27 | "nyc": { 28 | "include": [ 29 | "src/**" 30 | ] 31 | }, 32 | "publishConfig": { 33 | "access": "public", 34 | "registry": "https://registry.npmjs.org/" 35 | }, 36 | "main": "dist/index.js", 37 | "module": "dist/index.mjs", 38 | "files": [ 39 | "dist", 40 | "types" 41 | ], 42 | "typings": "types/index.d.ts", 43 | "dependencies": { 44 | "@babel/runtime": "^7.24.0" 45 | }, 46 | "devDependencies": { 47 | "@gera2ld/plaid": "~2.7.0", 48 | "@gera2ld/plaid-rollup": "~2.7.0", 49 | "@gera2ld/plaid-test": "~2.6.0", 50 | "del-cli": "^5.1.0", 51 | "husky": "^9.0.11", 52 | "jest-environment-jsdom": "^29.7.0", 53 | "vue": "^3.4.21" 54 | }, 55 | "jest": { 56 | "testEnvironment": "jsdom" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /rollup.config.mjs: -------------------------------------------------------------------------------- 1 | import { defineExternal, definePlugins } from '@gera2ld/plaid-rollup'; 2 | import { execSync } from 'node:child_process'; 3 | import { defineConfig } from 'rollup'; 4 | import pkg from './package.json' assert { type: 'json' }; 5 | 6 | const banner = `/*! ${pkg.name} v${pkg.version} | ${pkg.license} License */`; 7 | const commit = execSync('git rev-parse --short HEAD').toString().trim(); 8 | 9 | const bundleOptions = { 10 | extend: true, 11 | esModule: false, 12 | }; 13 | const values = { 14 | 'process.env.VERSION': JSON.stringify(pkg.version), 15 | 'process.env.COMMIT': JSON.stringify(commit), 16 | }; 17 | const external = defineExternal( 18 | Object.keys({ ...pkg.dependencies, ...pkg.devDependencies }), 19 | ); 20 | 21 | export default defineConfig([ 22 | { 23 | input: 'src/index.ts', 24 | plugins: definePlugins({ 25 | esm: true, 26 | minimize: false, 27 | replaceValues: { 28 | 'process.env.VM': false, 29 | ...values, 30 | }, 31 | }), 32 | external, 33 | output: { 34 | format: 'esm', 35 | file: `dist/index.mjs`, 36 | banner, 37 | }, 38 | }, 39 | { 40 | input: 'src/index.ts', 41 | plugins: definePlugins({ 42 | esm: true, 43 | minimize: false, 44 | replaceValues: { 45 | 'process.env.VM': true, 46 | ...values, 47 | }, 48 | }), 49 | output: { 50 | format: 'iife', 51 | file: `dist/index.js`, 52 | name: 'VM.shortcut', 53 | banner, 54 | ...bundleOptions, 55 | }, 56 | }, 57 | ...(process.env.DEMO 58 | ? [ 59 | { 60 | input: 'src/demo/app.js', 61 | plugins: definePlugins({ 62 | esm: true, 63 | minimize: false, 64 | replaceValues: { 65 | 'process.env.VM': true, 66 | ...values, 67 | }, 68 | }), 69 | external, 70 | output: { 71 | format: 'iife', 72 | file: 'dist/app.js', 73 | globals: { 74 | vue: 'Vue', 75 | }, 76 | }, 77 | }, 78 | ] 79 | : []), 80 | ]); 81 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const isMacintosh = navigator.userAgent.includes('Macintosh'); 2 | 3 | export const modifierList = ['m', 'c', 's', 'a'] as const; 4 | 5 | export type IModifier = (typeof modifierList)[number]; 6 | 7 | export const modifiers: Record = { 8 | ctrl: 'c', 9 | control: 'c', // macOS 10 | shift: 's', 11 | alt: 'a', 12 | meta: 'm', 13 | cmd: 'm', 14 | }; 15 | 16 | export const modifierAliases: Record = { 17 | ...modifiers, 18 | c: 'c', 19 | s: 's', 20 | a: 'a', 21 | m: 'm', 22 | cm: isMacintosh ? 'm' : 'c', 23 | ctrlcmd: isMacintosh ? 'm' : 'c', 24 | }; 25 | 26 | export const modifierSymbols = { 27 | c: '^', 28 | s: '⇧', 29 | a: '⌥', 30 | m: '⌘', 31 | }; 32 | 33 | export const aliases: Record = { 34 | arrowup: 'up', 35 | arrowdown: 'down', 36 | arrowleft: 'left', 37 | arrowright: 'right', 38 | cr: 'enter', 39 | escape: 'esc', 40 | ' ': 'space', 41 | }; 42 | -------------------------------------------------------------------------------- /src/demo/app.js: -------------------------------------------------------------------------------- 1 | import { createApp, nextTick, ref, watch, onMounted } from 'vue'; 2 | 3 | createApp({ 4 | setup() { 5 | const shortcuts = ref(`\ 6 | g g 7 | c-a c-b 8 | a-a a-b 9 | ArrowUp ArrowUp ArrowDown ArrowDown ArrowLeft ArrowRight ArrowLeft ArrowRight B A`); 10 | const keyExpCS = ref(''); 11 | const keyExpCI = ref(''); 12 | const keyExpCICode = ref(''); 13 | const keyTriggered = ref(''); 14 | const sequence = ref(''); 15 | const caseSensitive = ref(false); 16 | let disposeList = []; 17 | 18 | const onFocus = () => { 19 | VM.shortcut.getService().setContext('input', true); 20 | }; 21 | const onBlur = () => { 22 | VM.shortcut.getService().setContext('input', false); 23 | }; 24 | 25 | watch( 26 | [shortcuts, caseSensitive], 27 | () => { 28 | disposeList.forEach((dispose) => dispose()); 29 | disposeList = shortcuts.value 30 | .split('\n') 31 | .map((row) => row.trim()) 32 | .filter(Boolean) 33 | .map((key) => { 34 | console.log('register', key, caseSensitive.value); 35 | return VM.shortcut.register( 36 | key, 37 | () => { 38 | nextTick(() => { 39 | keyTriggered.value = key; 40 | }); 41 | }, 42 | { 43 | caseSensitive: caseSensitive.value, 44 | condition: '!input', 45 | }, 46 | ); 47 | }); 48 | }, 49 | { immediate: true }, 50 | ); 51 | watch(sequence, () => { 52 | keyTriggered.value = ''; 53 | }); 54 | 55 | onMounted(() => { 56 | VM.shortcut.disable(); 57 | VM.shortcut.getService().sequence.subscribe((seq) => { 58 | sequence.value = seq.join(' '); 59 | }); 60 | window.addEventListener('keydown', (e) => { 61 | if (!VM.shortcut.modifiers[e.key.toLowerCase()]) { 62 | keyExpCS.value = VM.shortcut.buildKey({ 63 | base: e.key, 64 | modifierState: { 65 | c: e.ctrlKey, 66 | m: e.metaKey, 67 | }, 68 | caseSensitive: true, 69 | }); 70 | keyExpCICode.value = VM.shortcut 71 | .buildKey({ 72 | base: e.code, 73 | modifierState: { 74 | c: e.ctrlKey, 75 | s: e.shiftKey, 76 | a: e.altKey, 77 | m: e.metaKey, 78 | }, 79 | caseSensitive: false, 80 | }) 81 | .replace(/^i:/, ''); 82 | keyExpCI.value = VM.shortcut 83 | .buildKey({ 84 | base: e.key, 85 | modifierState: { 86 | c: e.ctrlKey, 87 | s: e.shiftKey, 88 | a: e.altKey, 89 | m: e.metaKey, 90 | }, 91 | caseSensitive: false, 92 | }) 93 | .replace(/^i:/, ''); 94 | } 95 | VM.shortcut.handleKey(e); 96 | }); 97 | }); 98 | 99 | return { 100 | version: VM.shortcut.version, 101 | commit: process.env.COMMIT, 102 | shortcuts, 103 | sequence, 104 | keyTriggered, 105 | keyExpCS, 106 | keyExpCI, 107 | keyExpCICode, 108 | caseSensitive, 109 | onFocus, 110 | onBlur, 111 | }; 112 | }, 113 | }).mount('#app'); 114 | -------------------------------------------------------------------------------- /src/demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | @violentmonkey/shortcut Playground 7 | 12 | 13 | 29 | 33 | 37 | 41 | 42 | 43 | 44 |
45 |

@violentmonkey/shortcut Playground

46 |

47 | Version: {{version}} 48 | 53 |

54 |

Triggering a Shortcut

55 |
56 |
57 | Declare your shortcuts here: (one sequence in a line) 58 |
59 | 62 |
63 | 69 |
70 | Current sequence: 71 | 72 |
73 |
74 |
75 | You just triggered 76 | ! 77 |
78 |
Try to trigger a shortcut sequence declared above.
79 |
80 |

Inspecting a Key

81 |
Press a key to see how it is represented.
82 |
83 |

Note:

84 |
    85 |
  • 86 | Alt/Opt/Shift 90 | do not work in case-sensitive mode. 91 |
  • 92 |
  • 93 | Some keys might not work because they are occupied by the browser. 94 |
  • 95 |
96 |
97 |
98 |
99 |
Case Insensitive:
100 |
    101 |
  • 102 | (event.key) 105 |
  • 106 |
  • 107 | (event.code) 110 |
  • 111 |
112 |
113 |
114 |
Case Sensitive:
115 |
    116 |
  • 117 |
118 |
119 |
120 |
124 | VM.shortcut.register({{ JSON.stringify(keyExpCI) }}, callback);
125 | // or
126 | VM.shortcut.register({{ JSON.stringify(keyExpCICode) }}, callback);
127 | 
129 |
130 |
131 |
135 | VM.shortcut.register({{ JSON.stringify(keyExpCS) }}, callback, {
136 |   caseSensitive: true,
137 | });
138 | 
140 |
141 |
142 |
143 | 144 | 145 | 146 | 147 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { modifiers } from './constants'; 2 | import { 3 | addKeyNode, 4 | createKeyNode, 5 | getKeyNode, 6 | removeKeyNode, 7 | reprNodeTree, 8 | } from './node'; 9 | import { Subject } from './subject'; 10 | import { 11 | IKeyNode, 12 | IShortcut, 13 | IShortcutCondition, 14 | IShortcutConditionCache, 15 | IShortcutOptions, 16 | IShortcutServiceOptions, 17 | } from './types'; 18 | import { buildKey, normalizeSequence, parseCondition } from './util'; 19 | 20 | export const version = process.env.VERSION; 21 | export * from './constants'; 22 | export * from './types'; 23 | export * from './util'; 24 | 25 | export class KeyboardService { 26 | private _context: Record = {}; 27 | 28 | private _conditionData: { [key: string]: IShortcutConditionCache } = {}; 29 | 30 | private _data: IShortcut[] = []; 31 | 32 | private _root = createKeyNode(); 33 | 34 | private _cur: IKeyNode | undefined; 35 | 36 | sequence = new Subject([]); 37 | 38 | private _timer = 0; 39 | 40 | static defaultOptions: IShortcutServiceOptions = { 41 | sequenceTimeout: 500, 42 | }; 43 | 44 | options: IShortcutServiceOptions; 45 | 46 | constructor(options?: Partial) { 47 | this.options = { 48 | ...KeyboardService.defaultOptions, 49 | ...options, 50 | }; 51 | } 52 | 53 | private _reset = () => { 54 | this._cur = undefined; 55 | this.sequence.set([]); 56 | this._resetTimer(); 57 | }; 58 | 59 | private _resetTimer() { 60 | if (this._timer) { 61 | window.clearTimeout(this._timer); 62 | this._timer = 0; 63 | } 64 | } 65 | 66 | private _addCondition(condition: string) { 67 | let cache = this._conditionData[condition]; 68 | if (!cache) { 69 | const value = parseCondition(condition); 70 | cache = { 71 | count: 0, 72 | value, 73 | result: this._evalCondition(value), 74 | }; 75 | this._conditionData[condition] = cache; 76 | } 77 | cache.count += 1; 78 | } 79 | 80 | private _removeCondition(condition: string) { 81 | const cache = this._conditionData[condition]; 82 | if (cache) { 83 | cache.count -= 1; 84 | if (!cache.count) { 85 | delete this._conditionData[condition]; 86 | } 87 | } 88 | } 89 | 90 | private _evalCondition(conditions: IShortcutCondition[]) { 91 | return conditions.every((cond) => { 92 | let value = this._context[cond.field]; 93 | if (cond.not) value = !value; 94 | return value; 95 | }); 96 | } 97 | 98 | private _checkShortcut(item: IShortcut) { 99 | const cache = item.condition && this._conditionData[item.condition]; 100 | const enabled = !cache || cache.result; 101 | if (item.enabled !== enabled) { 102 | item.enabled = enabled; 103 | this._enableShortcut(item); 104 | } 105 | } 106 | 107 | private _enableShortcut(item: IShortcut) { 108 | (item.enabled ? addKeyNode : removeKeyNode)( 109 | this._root, 110 | item.sequence, 111 | item, 112 | ); 113 | } 114 | 115 | enable() { 116 | this.disable(); 117 | document.addEventListener('keydown', this.handleKey); 118 | } 119 | 120 | disable() { 121 | document.removeEventListener('keydown', this.handleKey); 122 | } 123 | 124 | register( 125 | key: string, 126 | callback: () => void, 127 | options?: Partial, 128 | ) { 129 | const { caseSensitive, condition }: IShortcutOptions = { 130 | caseSensitive: false, 131 | ...options, 132 | }; 133 | const sequence = normalizeSequence(key, caseSensitive).map((key) => 134 | buildKey(key), 135 | ); 136 | const item: IShortcut = { 137 | sequence, 138 | condition, 139 | callback, 140 | enabled: false, 141 | caseSensitive, 142 | }; 143 | if (condition) this._addCondition(condition); 144 | this._checkShortcut(item); 145 | this._data.push(item); 146 | return () => { 147 | const index = this._data.indexOf(item); 148 | if (index >= 0) { 149 | this._data.splice(index, 1); 150 | if (condition) this._removeCondition(condition); 151 | item.enabled = false; 152 | this._enableShortcut(item); 153 | } 154 | }; 155 | } 156 | 157 | setContext(key: string, value: unknown) { 158 | this._context[key] = value; 159 | for (const cache of Object.values(this._conditionData)) { 160 | cache.result = this._evalCondition(cache.value); 161 | } 162 | for (const item of this._data) { 163 | this._checkShortcut(item); 164 | } 165 | } 166 | 167 | private _handleKeyOnce(keyExps: string[], fromRoot: boolean): 0 | 1 | 2 { 168 | let cur: IKeyNode | undefined = this._cur; 169 | if (fromRoot || !cur) { 170 | // set fromRoot to true to avoid another retry 171 | fromRoot = true; 172 | cur = this._root; 173 | } 174 | if (cur) { 175 | let next: IKeyNode | undefined; 176 | for (const key of keyExps) { 177 | next = getKeyNode(cur, [key]); 178 | if (next) { 179 | this.sequence.set([...this.sequence.get(), key]); 180 | break; 181 | } 182 | } 183 | cur = next; 184 | } 185 | this._cur = cur; 186 | const [shortcut] = [...(cur?.shortcuts || [])]; 187 | if (!fromRoot && !shortcut && !cur?.children.size) { 188 | // Nothing is matched with the last key, rematch from root 189 | this._reset(); 190 | return this._handleKeyOnce(keyExps, true); 191 | } 192 | if (shortcut) { 193 | try { 194 | shortcut.callback(); 195 | } catch { 196 | // ignore 197 | } 198 | return 2; 199 | } 200 | return this._cur ? 1 : 0; 201 | } 202 | 203 | handleKey = (e: KeyboardEvent) => { 204 | // Chrome sends a trusted keydown event with no key when choosing from autofill 205 | if (!e.key || modifiers[e.key.toLowerCase()]) return; 206 | this._resetTimer(); 207 | const keyExps = [ 208 | // case sensitive mode, `e.key` is the character considering Alt/Shift 209 | buildKey({ 210 | base: e.key, 211 | modifierState: { 212 | c: e.ctrlKey, 213 | m: e.metaKey, 214 | }, 215 | caseSensitive: true, 216 | }), 217 | // case insensitive mode, using `e.code` with modifiers including Alt/Shift 218 | buildKey({ 219 | base: e.code, 220 | modifierState: { 221 | c: e.ctrlKey, 222 | s: e.shiftKey, 223 | a: e.altKey, 224 | m: e.metaKey, 225 | }, 226 | caseSensitive: false, 227 | }), 228 | // case insensitive mode, using `e.key` with modifiers 229 | buildKey({ 230 | // Note: `e.key` might be different from what you expect because of Alt Graph 231 | // ref: https://en.wikipedia.org/wiki/AltGr_key 232 | base: e.key, 233 | modifierState: { 234 | c: e.ctrlKey, 235 | s: e.shiftKey, 236 | a: e.altKey, 237 | m: e.metaKey, 238 | }, 239 | caseSensitive: false, 240 | }), 241 | ]; 242 | const state = this._handleKeyOnce(keyExps, false); 243 | if (state) { 244 | e.preventDefault(); 245 | if (state === 2) this._reset(); 246 | } 247 | this._timer = window.setTimeout(this._reset, this.options.sequenceTimeout); 248 | }; 249 | 250 | repr() { 251 | return reprNodeTree(this._root); 252 | } 253 | } 254 | 255 | let service: KeyboardService; 256 | 257 | export function getService() { 258 | if (!service) { 259 | service = new KeyboardService(); 260 | service.enable(); 261 | } 262 | return service; 263 | } 264 | 265 | export const register = (...args: Parameters) => 266 | getService().register(...args); 267 | export const enable = () => getService().enable(); 268 | export const disable = () => getService().disable(); 269 | export const handleKey = (...args: Parameters) => 270 | getService().handleKey(...args); 271 | -------------------------------------------------------------------------------- /src/node.ts: -------------------------------------------------------------------------------- 1 | import { IKeyNode, IShortcut } from './types'; 2 | 3 | export function createKeyNode(): IKeyNode { 4 | return { 5 | children: new Map(), 6 | shortcuts: new Set(), 7 | }; 8 | } 9 | 10 | export function addKeyNode( 11 | root: IKeyNode, 12 | sequence: string[], 13 | shortcut: IShortcut, 14 | ) { 15 | let node: IKeyNode = root; 16 | for (const key of sequence) { 17 | let child = node.children.get(key); 18 | if (!child) { 19 | child = createKeyNode(); 20 | node.children.set(key, child); 21 | } 22 | node = child; 23 | } 24 | node.shortcuts.add(shortcut); 25 | } 26 | 27 | export function getKeyNode(root: IKeyNode, sequence: string[]) { 28 | let node: IKeyNode | undefined = root; 29 | for (const key of sequence) { 30 | node = node.children.get(key); 31 | if (!node) break; 32 | } 33 | return node; 34 | } 35 | 36 | export function removeKeyNode( 37 | root: IKeyNode, 38 | sequence: string[], 39 | shortcut?: IShortcut, 40 | ) { 41 | let node: IKeyNode | undefined = root; 42 | const ancestors = [node]; 43 | for (const key of sequence) { 44 | node = node.children.get(key); 45 | if (!node) return; 46 | ancestors.push(node); 47 | } 48 | if (shortcut) node.shortcuts.delete(shortcut); 49 | else node.shortcuts.clear(); 50 | let i = ancestors.length - 1; 51 | while (i > 0) { 52 | node = ancestors[i]; 53 | if (node.shortcuts.size || node.children.size) break; 54 | const last = ancestors[i - 1]; 55 | last.children.delete(sequence[i - 1]); 56 | i -= 1; 57 | } 58 | } 59 | 60 | export function reprNodeTree(root: IKeyNode) { 61 | const result: string[] = []; 62 | const reprChildren = (node: IKeyNode, level = 0) => { 63 | for (const [key, child] of node.children.entries()) { 64 | result.push( 65 | [ 66 | ' '.repeat(level), 67 | key, 68 | child.shortcuts.size ? ` (${child.shortcuts.size})` : '', 69 | ].join(''), 70 | ); 71 | reprChildren(child, level + 1); 72 | } 73 | }; 74 | reprChildren(root); 75 | return result.join('\n'); 76 | } 77 | -------------------------------------------------------------------------------- /src/subject.ts: -------------------------------------------------------------------------------- 1 | export class Subject { 2 | private listeners: Array<(value: T) => void> = []; 3 | 4 | constructor(private value: T) {} 5 | 6 | get() { 7 | return this.value; 8 | } 9 | 10 | set(value: T) { 11 | this.value = value; 12 | this.listeners.forEach((listener) => listener(value)); 13 | } 14 | 15 | subscribe(callback: (value: T) => void) { 16 | this.listeners.push(callback); 17 | callback(this.value); 18 | return () => this.unsubscribe(callback); 19 | } 20 | 21 | unsubscribe(callback: (value: T) => void) { 22 | const i = this.listeners.indexOf(callback); 23 | if (i >= 0) this.listeners.splice(i, 1); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/types/index.ts: -------------------------------------------------------------------------------- 1 | export interface IShortcutModifiers { 2 | c?: boolean; 3 | s?: boolean; 4 | a?: boolean; 5 | m?: boolean; 6 | } 7 | 8 | export interface IShortcutCondition { 9 | field: string; 10 | not: boolean; 11 | } 12 | 13 | export interface IShortcut { 14 | sequence: string[]; 15 | condition?: string; 16 | callback: () => void; 17 | enabled: boolean; 18 | caseSensitive: boolean; 19 | } 20 | 21 | export interface IKeyNode { 22 | shortcuts: Set; 23 | children: Map; 24 | } 25 | 26 | export interface IShortcutConditionCache { 27 | count: number; 28 | value: IShortcutCondition[]; 29 | result: boolean; 30 | } 31 | 32 | export interface IShortcutOptions { 33 | condition?: string; 34 | caseSensitive: boolean; 35 | } 36 | 37 | export interface IShortcutServiceOptions { 38 | /** Max timeout between two keys within a sequence. */ 39 | sequenceTimeout: number; 40 | } 41 | 42 | export interface IShortcutKey { 43 | base: string; 44 | modifierState: IShortcutModifiers; 45 | caseSensitive: boolean; 46 | } 47 | -------------------------------------------------------------------------------- /src/types/shim.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.module.css' { 2 | /** 3 | * Generated CSS for CSS modules 4 | */ 5 | export const stylesheet: string; 6 | /** 7 | * Exported classes 8 | */ 9 | const classMap: { 10 | [key: string]: string; 11 | }; 12 | export default classMap; 13 | } 14 | 15 | declare module '*.css' { 16 | /** 17 | * Generated CSS 18 | */ 19 | const css: string; 20 | export default css; 21 | } 22 | -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | import { 2 | aliases, 3 | modifierAliases, 4 | modifierList, 5 | modifierSymbols, 6 | } from './constants'; 7 | import { IShortcutCondition, IShortcutKey, IShortcutModifiers } from './types'; 8 | 9 | export function buildKey(key: IShortcutKey) { 10 | const { caseSensitive, modifierState } = key; 11 | let { base } = key; 12 | if (!caseSensitive || base.length > 1) base = base.toLowerCase(); 13 | base = aliases[base] || base; 14 | const keyExp = [...modifierList.filter((m) => modifierState[m]), base] 15 | .filter(Boolean) 16 | .join('-'); 17 | return `${caseSensitive ? '' : 'i:'}${keyExp}`; 18 | } 19 | 20 | function breakKey(shortcut: string) { 21 | const pieces = shortcut.split(/-(.)/); 22 | const parts: string[] = [pieces[0]]; 23 | for (let i = 1; i < pieces.length; i += 2) { 24 | parts.push(pieces[i] + pieces[i + 1]); 25 | } 26 | return parts; 27 | } 28 | 29 | export function parseKey( 30 | shortcut: string, 31 | caseSensitive: boolean, 32 | ): IShortcutKey { 33 | const parts = breakKey(shortcut); 34 | const base = parts.pop() as string; 35 | const modifierState: IShortcutModifiers = {}; 36 | for (const part of parts) { 37 | const key = modifierAliases[part.toLowerCase()]; 38 | if (!key) throw new Error(`Unknown modifier key: ${part}`); 39 | modifierState[key] = true; 40 | } 41 | // Alt/Shift modifies the character. 42 | // In case sensitive mode, we only need to check the modified character: = Ctrl+Shift+KeyA 43 | // In case insensitive mode, we check the keyCode as well as modifiers: = Ctrl+Shift+KeyA 44 | // So if Alt/Shift appears in the shortcut, we must switch to case insensitive mode. 45 | caseSensitive &&= !(modifierState.a || modifierState.s); 46 | return { base, modifierState, caseSensitive }; 47 | } 48 | 49 | function getSequence(input: string | string[]) { 50 | return Array.isArray(input) ? input : input.split(/\s+/); 51 | } 52 | 53 | export function normalizeSequence( 54 | input: string | string[], 55 | caseSensitive: boolean, 56 | ) { 57 | return getSequence(input).map((key) => parseKey(key, caseSensitive)); 58 | } 59 | 60 | export function parseCondition(condition: string): IShortcutCondition[] { 61 | return condition 62 | .split('&&') 63 | .map((key) => { 64 | key = key.trim(); 65 | if (!key) return; 66 | if (key[0] === '!') { 67 | return { not: true, field: key.slice(1).trim() }; 68 | } 69 | return { not: false, field: key }; 70 | }) 71 | .filter(Boolean) as IShortcutCondition[]; 72 | } 73 | 74 | export function reprKey(key: IShortcutKey) { 75 | const { modifierState, caseSensitive } = key; 76 | let { base } = key; 77 | if (!caseSensitive || base.length > 1) { 78 | base = base[0].toUpperCase() + base.slice(1); 79 | } 80 | const modifiers = modifierList 81 | .filter((m) => modifierState[m]) 82 | .map((m) => modifierSymbols[m]); 83 | return [...modifiers, base].join(''); 84 | } 85 | 86 | export function reprShortcut(input: string | string[], caseSensitive = false) { 87 | return getSequence(input) 88 | .map((key) => parseKey(key, caseSensitive)) 89 | .map((key) => reprKey(key)) 90 | .join(' '); 91 | } 92 | -------------------------------------------------------------------------------- /test/node.test.ts: -------------------------------------------------------------------------------- 1 | import { addKeyNode, createKeyNode, removeKeyNode } from '../src/node'; 2 | import { IKeyNode } from '../src/types'; 3 | 4 | type ISerializedNode = [number, Array<[string, ISerializedNode]>]; 5 | 6 | function toJSON(node: IKeyNode): ISerializedNode { 7 | return [ 8 | node.shortcuts.size, 9 | Array.from(node.children.entries(), ([key, child]) => [key, toJSON(child)]), 10 | ]; 11 | } 12 | 13 | function createShortcut() { 14 | return { 15 | sequence: [], 16 | callback: () => { 17 | /* dummy */ 18 | }, 19 | enabled: true, 20 | caseSensitive: true, 21 | }; 22 | } 23 | 24 | describe('KeyNode', () => { 25 | test('add', () => { 26 | const tree = createKeyNode(); 27 | addKeyNode(tree, ['a'], createShortcut()); 28 | addKeyNode(tree, ['a', 'b'], createShortcut()); 29 | addKeyNode(tree, ['a', 'c', 'd'], createShortcut()); 30 | addKeyNode(tree, ['a', 'c', 'd'], createShortcut()); 31 | expect(toJSON(tree)).toEqual([ 32 | 0, 33 | [ 34 | [ 35 | 'a', 36 | [ 37 | 1, 38 | [ 39 | ['b', [1, []]], 40 | ['c', [0, [['d', [2, []]]]]], 41 | ], 42 | ], 43 | ], 44 | ], 45 | ]); 46 | }); 47 | 48 | test('remove', () => { 49 | const tree = createKeyNode(); 50 | addKeyNode(tree, ['a'], createShortcut()); 51 | addKeyNode(tree, ['a', 'b'], createShortcut()); 52 | addKeyNode(tree, ['a', 'c', 'd'], createShortcut()); 53 | removeKeyNode(tree, ['a', 'c', 'd']); 54 | expect(toJSON(tree)).toEqual([0, [['a', [1, [['b', [1, []]]]]]]]); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /test/util.test.ts: -------------------------------------------------------------------------------- 1 | import { buildKey, parseKey, parseCondition, reprShortcut } from '../src/util'; 2 | 3 | it('buildKey', () => { 4 | expect( 5 | buildKey({ base: 'a', modifierState: {}, caseSensitive: false }), 6 | ).toEqual('i:a'); 7 | expect( 8 | buildKey({ base: 'A', modifierState: {}, caseSensitive: true }), 9 | ).toEqual('A'); 10 | expect( 11 | buildKey({ base: 'f8', modifierState: {}, caseSensitive: true }), 12 | ).toEqual('f8'); 13 | expect( 14 | buildKey({ base: 'a', modifierState: { c: true }, caseSensitive: false }), 15 | ).toEqual('i:c-a'); 16 | expect( 17 | buildKey({ base: 'A', modifierState: { c: true }, caseSensitive: true }), 18 | ).toEqual('c-A'); 19 | expect( 20 | buildKey({ 21 | base: 'a', 22 | modifierState: { c: true, s: true }, 23 | caseSensitive: false, 24 | }), 25 | ).toEqual('i:c-s-a'); 26 | }); 27 | 28 | it('parseKey', () => { 29 | expect(parseKey('i', false)).toEqual({ 30 | base: 'i', 31 | modifierState: {}, 32 | caseSensitive: false, 33 | }); 34 | expect(parseKey('c-i', false)).toEqual({ 35 | base: 'i', 36 | modifierState: { c: true }, 37 | caseSensitive: false, 38 | }); 39 | expect(parseKey('c-I', true)).toEqual({ 40 | base: 'I', 41 | modifierState: { c: true }, 42 | caseSensitive: true, 43 | }); 44 | expect(parseKey('ctrl-i', false)).toEqual({ 45 | base: 'i', 46 | modifierState: { c: true }, 47 | caseSensitive: false, 48 | }); 49 | expect(parseKey('ctrl-shift-i', false)).toEqual({ 50 | base: 'i', 51 | modifierState: { c: true, s: true }, 52 | caseSensitive: false, 53 | }); 54 | expect(parseKey('shift-ctrl-i', false)).toEqual({ 55 | base: 'i', 56 | modifierState: { c: true, s: true }, 57 | caseSensitive: false, 58 | }); 59 | expect(parseKey('F8', false)).toEqual({ 60 | base: 'F8', 61 | modifierState: {}, 62 | caseSensitive: false, 63 | }); 64 | expect(parseKey('ctrl-F8', false)).toEqual({ 65 | base: 'F8', 66 | modifierState: { c: true }, 67 | caseSensitive: false, 68 | }); 69 | expect(parseKey('-', false)).toEqual({ 70 | base: '-', 71 | modifierState: {}, 72 | caseSensitive: false, 73 | }); 74 | expect(parseKey('c--', false)).toEqual({ 75 | base: '-', 76 | modifierState: { c: true }, 77 | caseSensitive: false, 78 | }); 79 | }); 80 | 81 | it('parseCondition', () => { 82 | expect(parseCondition('a && b')).toEqual([ 83 | { field: 'a', not: false }, 84 | { field: 'b', not: false }, 85 | ]); 86 | expect(parseCondition('a && !b')).toEqual([ 87 | { field: 'a', not: false }, 88 | { field: 'b', not: true }, 89 | ]); 90 | }); 91 | 92 | it('reprShortcut', () => { 93 | expect(reprShortcut('c-s-a')).toEqual('^⇧A'); 94 | expect(reprShortcut('c-s-a', true)).toEqual('^⇧A'); 95 | expect(reprShortcut('c-s-enter')).toEqual('^⇧Enter'); 96 | expect(reprShortcut('c-s-enter', true)).toEqual('^⇧Enter'); 97 | expect(reprShortcut('ctrlcmd-c')).toEqual('^C'); 98 | expect(reprShortcut('ctrlcmd-c', true)).toEqual('^c'); 99 | expect(reprShortcut('c-a c-c')).toEqual('^A ^C'); 100 | expect(reprShortcut('g')).toEqual('G'); 101 | expect(reprShortcut('g', true)).toEqual('g'); 102 | expect(reprShortcut('g g', true)).toEqual('g g'); 103 | expect(reprShortcut('c--', false)).toEqual('^-'); 104 | }); 105 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "module": "es6", 5 | "moduleResolution": "node", 6 | "allowSyntheticDefaultImports": true, 7 | "strict": true, 8 | "jsx": "react" 9 | }, 10 | "include": [ 11 | "src/**/*", 12 | "test/**/*" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /tsconfig.prod.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "declaration": true, 5 | "emitDeclarationOnly": true, 6 | "outDir": "types" 7 | }, 8 | "include": [ 9 | "src/**/*.ts", 10 | "src/**/*.tsx" 11 | ] 12 | } 13 | --------------------------------------------------------------------------------