@peter.naydenov/shortcuts 4.0.2 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/AGENTS.md +54 -0
  2. package/Changelog.md +9 -0
  3. package/README.md +935 -426
  4. package/dist/plugins/click/_listenDOM.d.ts +2 -2
  5. package/dist/plugins/form/_listenDOM.d.ts +4 -4
  6. package/dist/plugins/hover/_listenDOM.d.ts +2 -2
  7. package/dist/plugins/key/_listenDOM.d.ts +2 -2
  8. package/dist/plugins/scroll/_listenDOM.d.ts +2 -2
  9. package/dist/shortcuts.cjs +1 -1
  10. package/dist/shortcuts.esm.mjs +1 -1
  11. package/dist/shortcuts.umd.js +1 -1
  12. package/graphify-out/.graphify_benchmark.json +35 -0
  13. package/graphify-out/.graphify_python +1 -0
  14. package/graphify-out/GRAPH_REPORT.md +121 -0
  15. package/graphify-out/cache/024633ee076562ce0dbc0bf3b5315906f4e5d48c57bc96856b98cd5d42a69009.json +1 -0
  16. package/graphify-out/cache/037d2ba5552034c06beca677b1914ac7a053f696dfeaf12ca0de06eb42659f18.json +1 -0
  17. package/graphify-out/cache/055bfc2a3bfb8747f7ee0b7335704d2dff1c071d25a9bdd4f954e4cc2d630772.json +1 -0
  18. package/graphify-out/cache/0b2f626892ed04f158e39593dbf5f266c2a5982a21b2dddb144edc79af81e2d1.json +1 -0
  19. package/graphify-out/cache/0c17fe866170c4ab4023ac0680d1e05c5dc232a17ac0f08dfa1e76c2eaf75c44.json +1 -0
  20. package/graphify-out/cache/1392dca26291b5396829f1a996aac2c1d34a03d134a5fcf54e4f15824ee74e2b.json +1 -0
  21. package/graphify-out/cache/18113cd08ebffb11ed91ffab9c6d34795e22bfb5993941db07a52eed6eba45b8.json +1 -0
  22. package/graphify-out/cache/1a7a71b157cadd117435818e1a6561157c2930c4066d3a207fe04e318f76b296.json +1 -0
  23. package/graphify-out/cache/2448db4b822a94d6f3512ce8788077f35dfb567aef8628a846fad841b40575e7.json +1 -0
  24. package/graphify-out/cache/2592868b7b9d2de3f2cb575b7bd68ca2f252d34f71c12f2e8721d789cfbfbf88.json +1 -0
  25. package/graphify-out/cache/2aaf58292523f67421e6f728fd97740c5bf07dd903cf267cf557a4383759ba5e.json +1 -0
  26. package/graphify-out/cache/349a418954e66e5ef45370dca740ebff559a72d11f5810f6d40c0af14ef768e0.json +1 -0
  27. package/graphify-out/cache/361eacb4abb14862b75257bdf673a353826bf5764fb187ccde94ae21454bcf99.json +1 -0
  28. package/graphify-out/cache/40b4c82a11a2a31b279563c143d91d7894eb3f3d0c386f8323cb8062bcdfadd5.json +1 -0
  29. package/graphify-out/cache/40db98ff036ad694953cb13628795fabc0c5892ff093dfdcc18a309ac0d10846.json +1 -0
  30. package/graphify-out/cache/43a590ac22f05be183ea1a4655922185595c969f79dd3df3d44d1c7e49355785.json +1 -0
  31. package/graphify-out/cache/4993a6e98dcf33bb6fa78341b4eeac4776e2322f10522eecdc5195aa7969f35a.json +1 -0
  32. package/graphify-out/cache/5095c6e52a24f4ecec9acf63835761ad508df88d56b1799faec47672fbd4e348.json +1 -0
  33. package/graphify-out/cache/5cba1d38ffea01d8c62e5a0b0d8b164cf9b115ff1b6f1acc606f78877712d5de.json +1 -0
  34. package/graphify-out/cache/67aadf0b8f90224cb725e903e6fedbf6828f203467a633f98031b0740930cb21.json +1 -0
  35. package/graphify-out/cache/68dffbffbd811942d85ef2600ca31e423d3c3c343de98cbba4c954c94dd11470.json +1 -0
  36. package/graphify-out/cache/69c684e506b9ce22e95aff044c1da4b0f88ef72c510da02a739edbe551372a8e.json +1 -0
  37. package/graphify-out/cache/6e336587fcfe2b298f23326e881f048d37f1bb062d00806c338da58a8fd281ca.json +1 -0
  38. package/graphify-out/cache/78d88737e6db913f091c4c48e96953df065f6808e2b60dd828fdee64067dac91.json +1 -0
  39. package/graphify-out/cache/7e5340b989299a0b7217d1fd0d2b919cba65142f3e468b0aca5f4ffd5c0594d8.json +1 -0
  40. package/graphify-out/cache/813d6bac52066ed8733781c35710ecb7995e6cabbe0d9abb9854e3c67610b974.json +1 -0
  41. package/graphify-out/cache/8180f34832546e874bc5f1931eee545d97300be49faac5c9b6d515653a763324.json +1 -0
  42. package/graphify-out/cache/885894ca90af6a3724182762bc4fc7ff7d22727a931d46fe7593d1eea10c0c71.json +1 -0
  43. package/graphify-out/cache/8a304ae8f6bf02bfa40923cdbea99e4bea943db52c185f22caa43ba7c34f94d3.json +1 -0
  44. package/graphify-out/cache/903a7dea28112a27dcc1b9ece66514f4d5dd6ca264f5ee70835aca069a8df2ad.json +1 -0
  45. package/graphify-out/cache/9831a7833c5bfeb9a0611e416f7038bd37884b42a9a720f9b4c0a01f860a4f54.json +1 -0
  46. package/graphify-out/cache/98dbdcdd1b19bc942850f50b1ebdeb1865c72ba724990217464efd28a3732b32.json +1 -0
  47. package/graphify-out/cache/a2459f621d588f0166ae6a4204bb6b89f9d669b3ad0c54a88afac6c7abb134b4.json +1 -0
  48. package/graphify-out/cache/a25d47ecf087fa6888d641f89f08cefd35c68b5823c8c55b3baa0243ab110110.json +1 -0
  49. package/graphify-out/cache/a3bd22d8493943a3195c3ef1254a7240624a962edf2baa2c30eb0ae60564fbe7.json +1 -0
  50. package/graphify-out/cache/a4d4fb674183a3b348f542b1b9fb9c0d7b176c43636afb2554af088a9613a1c0.json +1 -0
  51. package/graphify-out/cache/a87a705106773b14c5a25697d30c743cdab01df551cdd9892d6ec46f98ad1659.json +1 -0
  52. package/graphify-out/cache/a9416d0397b5fb994b8c3847aea2599a9d33940e6f0652accc5ba1de478349ee.json +1 -0
  53. package/graphify-out/cache/ab03b9df0e9b8a74db3782c96fee833d800d93838fc0c056306ac2ef9a3e0c09.json +1 -0
  54. package/graphify-out/cache/ad3a99182567225cc19374c28d33097f146547bd945967c723b66d1065134ce9.json +1 -0
  55. package/graphify-out/cache/aece91cfc3a5181bbb77a1758921dfb6a323ab04cc402ce42f2832446d04f420.json +1 -0
  56. package/graphify-out/cache/bd65fd515423e8964058f6aa997c05e3e0fb9e6d39209d4a1d76a079c6af46e8.json +1 -0
  57. package/graphify-out/cache/c2a85071784f9516ab2dea976eeb3a514a53b15701bbf60b4d8be6cd3385cd6c.json +1 -0
  58. package/graphify-out/cache/c9a8c9342926031f308af0eb0a8d60cf0b443e84bae839da42998956465e47e6.json +1 -0
  59. package/graphify-out/cache/d05c0aa647a624e0c696f53c027d066c35d0893695e9a23fd820235ee86b4a70.json +1 -0
  60. package/graphify-out/cache/d3d9832015ab51f52ae88375cea2cbeabecd4a000578e28e899ce23e74245733.json +1 -0
  61. package/graphify-out/cache/d449ad503a40840d41cbf24ed57f408bf5fdf891f830990f836cf52da5c605eb.json +1 -0
  62. package/graphify-out/cache/d92b22194973f3c39ac53d85a29f5d4837d07b0f9f0d375e3ddce8da158777fb.json +1 -0
  63. package/graphify-out/cache/dda8f89f688d8a4db8b7279031ad26a0d8d4accc0aa049abda5fa19eac4bd5ef.json +1 -0
  64. package/graphify-out/cache/e1d80dbca10b7e2ba65339eff0649699c6091d30b836a1e9d5d094bb95aacc48.json +1 -0
  65. package/graphify-out/cache/e207108277cbe1af0501688b0268fea879d0414424386fbaa93a5861f306bdba.json +1 -0
  66. package/graphify-out/cache/e6032dad287da859a517d6b59105595db90e81833dbd850b37653bbd0f3acef7.json +1 -0
  67. package/graphify-out/cache/f49d7295a833de68579e0e265832bc78d21e901764e31705423e621a703124dc.json +1 -0
  68. package/graphify-out/cache/fcf90a1251a332948a773c6aaaad4ce7f6de8d2f2333687cb2fe94e0d860a6c9.json +1 -0
  69. package/graphify-out/cache/fe06fcb623d36858b89c8741696482530a084f599d48bea88de7943fae0f9bea.json +1 -0
  70. package/graphify-out/cache/ffa0f819e023809d17aac1af75cf0f6fbf08500615aee27341b658f24357105a.json +1 -0
  71. package/graphify-out/cost.json +12 -0
  72. package/graphify-out/graph.html +266 -0
  73. package/graphify-out/graph.json +634 -0
  74. package/graphify-out/manifest.json +124 -0
  75. package/how-to-create-a-plugin.md +573 -0
  76. package/package.json +22 -18
  77. package/src/main.js +5 -5
  78. package/src/plugins/click/_listenDOM.js +3 -3
  79. package/src/plugins/form/_listenDOM.js +7 -6
  80. package/src/plugins/hover/_listenDOM.js +2 -2
  81. package/src/plugins/key/_listenDOM.js +4 -4
  82. package/src/plugins/scroll/_listenDOM.js +1 -1
  83. package/test/01-general.test.js +1 -1
  84. package/test/02-key.test.js +45 -1
  85. package/test/03-click.test.js +51 -2
  86. package/test/04-form.test.js +26 -1
  87. package/test/05-hover.test.js +50 -2
  88. package/test/06-scroll.test.js +21 -0
  89. package/How.to.create.plugins.md +0 -929
@@ -0,0 +1,573 @@
1
+ # How to Create a Plugin for @peter.naydenov/shortcuts
2
+
3
+ > Audience: developers building a custom input source for the shortcuts library (touch gestures, gamepad, MIDI, custom DOM events, etc.).
4
+
5
+ This guide walks through the structure of a plugin. It assumes you are already familiar with [README.md](./README.md) — the public API, context/note model, action functions, and `data.dependencies.emit`.
6
+
7
+ The source code in `src/plugins/` is the canonical reference. The five shipped plugins (`key`, `click`, `hover`, `scroll`, `form`) follow the exact same contract. If you follow that contract, your plugin drops in alongside them with no changes to the core library.
8
+
9
+
10
+
11
+ ## Table of Contents
12
+ 1. [Mental Model](#mental-model)
13
+ 2. [The Plugin Contract in 60 Seconds](#the-plugin-contract-in-60-seconds)
14
+ 3. [The Plugin File Layout](#the-plugin-file-layout)
15
+ 4. [Step-by-Step: A Minimal `pluginGamepad`](#step-by-step-a-minimal-plugingamepad)
16
+ 5. [What `setupPlugin` injects into your pluginState](#what-setupplugin-injects-into-your-pluginstate)
17
+ 6. [What `setupPlugin` injects into your `dependencies`](#what-setupplugin-injects-into-your-dependencies)
18
+ 7. [The `data` object: what to put in it](#the-data-object-what-to-put-in-it)
19
+ 8. [The `PREFIX:SETUP` per-context event](#the-prefixsetup-per-context-event)
20
+ 9. [Naming shortcuts and the normalization rules](#naming-shortcuts-and-the-normalization-rules)
21
+ 10. [Listening to the DOM safely](#listening-to-the-dom-safely)
22
+ 11. [Reset, mute, unmute, destroy](#reset-mute-unmute-destroy)
23
+ 12. [Distributing your plugin](#distributing-your-plugin)
24
+ 13. [Full checklist](#full-checklist)
25
+ 14. [Appendix: the shipped plugins at a glance](#appendix-the-shipped-plugins-at-a-glance)
26
+
27
+
28
+
29
+ ## Mental Model
30
+
31
+ A plugin is the layer that turns a **physical input** (keystroke, mouse click, scroll, hover, form change, …) into a **shortcut event** in the library's internal event emitter. Everything else — contexts, notes, action functions, `data.dependencies.emit`, pause/resume, mute/unmute — is the same for every plugin.
32
+
33
+ Your plugin has four jobs:
34
+
35
+ 1. **Match** a shortcut name to your input family. (`'key:s+alt'` belongs to the `key` plugin, `'gamepad:a+start'` would belong to a `gamepad` plugin.)
36
+ 2. **Normalize** the name the user wrote so that `'key:alt+s'` and `'key:s+alt'` are the same.
37
+ 3. **Register** the user's shortcuts in the current context, including their `PREFIX:setup` options.
38
+ 4. **Listen** to the DOM (or the device API) and emit a normalized event with a `data` payload.
39
+
40
+ The library takes care of the rest: switching contexts, muting the whole plugin, pausing individual shortcuts, error reporting, and exposing your events to `shortcuts.emit()`.
41
+
42
+
43
+
44
+ ## The Plugin Contract in 60 Seconds
45
+
46
+ A plugin is **one function** that returns a frozen API. The function receives `setupPlugin` (provided by the core library) plus the user's `options`. You call `setupPlugin(...)` with six things and you get back a `pluginAPI` you can attach to the instance.
47
+
48
+ ```js
49
+ // src/plugins/myplugin/index.js
50
+ function pluginMy ( setupPlugin, options = {} ) {
51
+ const
52
+ deps = { resetState, regex: /MY\s*\:/i /*, helpers… */ }
53
+ , pluginState = { defaultOptions: {…}, listenOptions: {…}, /* state vars… */ }
54
+ ;
55
+
56
+ return setupPlugin ({
57
+ prefix : 'my' // (1) one-word tag for shortcut names
58
+ , _normalizeShortcutName // (2) parse user-written names into canonical form
59
+ , _registerShortcutEvents // (3) walk the active context, apply PREFIX:setup, count shortcuts
60
+ , _listenDOM // (4) attach/detach DOM listeners; returns { start, stop }
61
+ , pluginState // (5) mutable per-plugin state; core injects 4 fields into it
62
+ , deps // (6) helpers + resetState; spread into the dependencies your listeners receive
63
+ });
64
+ } // pluginMy
65
+
66
+ export default pluginMy
67
+ ```
68
+
69
+ That's the whole thing. The shipped plugins are 60-70 lines each.
70
+
71
+
72
+
73
+ ## The Plugin File Layout
74
+
75
+ Every shipped plugin uses the same directory layout. You can use one file, but splitting them makes maintenance easier and matches the rest of the codebase.
76
+
77
+ ```
78
+ src/plugins/my/
79
+ ├── index.js // plugin factory (this is the only file required for import)
80
+ ├── _normalizeShortcutName.js // (2) name normalization
81
+ ├── _registerShortcutEvents.js // (3) per-context setup
82
+ ├── _listenDOM.js // (4) DOM listeners
83
+ └── _optional helpers… // anything else you want in deps
84
+ ```
85
+
86
+ Conventions:
87
+ - Files starting with `_` are private to the plugin.
88
+ - The factory in `index.js` is the only thing you `export default`. It will be passed to `short.enablePlugin(pluginMy)`.
89
+ - Use `'use strict'` and ES module `import` syntax (the library ships ESM + CJS via a build).
90
+
91
+
92
+
93
+ ## Step-by-Step: A Minimal `pluginGamepad`
94
+
95
+ Below is a complete, working example for a hypothetical gamepad plugin. It illustrates every part of the contract without the historical noise of the shipped plugins. After this section, the rest of the guide documents the contract in detail.
96
+
97
+ ### 1. The factory
98
+
99
+ ```js
100
+ // src/plugins/gamepad/index.js
101
+ 'use strict'
102
+
103
+ import _normalizeShortcutName from './_normalizeShortcutName.js'
104
+ import _registerShortcutEvents from './_registerShortcutEvents.js'
105
+ import _listenDOM from './_listenDOM.js'
106
+
107
+ function pluginGamepad ( setupPlugin, options = {} ) {
108
+ const
109
+ pluginState = {
110
+ active : false
111
+ , buffer : [] // accumulating button presses in a sequence
112
+ , defaultOptions: {
113
+ gamepadWait : 600 // ms between buttons in a sequence
114
+ }
115
+ , listenOptions : {
116
+ gamepadWait : 600
117
+ }
118
+ , ...(options || {}) // user overrides go straight into state
119
+ }
120
+ , deps = {
121
+ regex : /GAMEPAD\s*\:/i
122
+ , resetState: function () {
123
+ pluginState.active = false
124
+ pluginState.buffer = []
125
+ }
126
+ }
127
+ ;
128
+
129
+ return setupPlugin ({
130
+ prefix : 'gamepad'
131
+ , _normalizeShortcutName
132
+ , _registerShortcutEvents
133
+ , _listenDOM
134
+ , pluginState
135
+ , deps
136
+ });
137
+ }
138
+
139
+ export default pluginGamepad
140
+ ```
141
+
142
+ ### 2. Normalize a shortcut name
143
+
144
+ `_normalizeShortcutName(name)` is called for **every** shortcut name across **every** context, by every plugin. Each plugin is responsible for recognizing its own prefix and returning the canonical form (or returning `name` unchanged if it doesn't belong to you).
145
+
146
+ ```js
147
+ // src/plugins/gamepad/_normalizeShortcutName.js
148
+ 'use strict'
149
+
150
+ function _normalizeShortcutName ( name ) {
151
+ const upper = name.toUpperCase()
152
+ if ( !/GAMEPAD\s*\:/i.test(upper) ) return name // not ours — pass through
153
+ if ( upper.includes('SETUP') ) return 'GAMEPAD:SETUP'
154
+
155
+ // Format: GAMEPAD:A+B,LEFT (commas separate sequence steps, + joins modifiers)
156
+ const body = upper.slice(upper.indexOf(':') + 1)
157
+ const steps = body.split(',').map( step =>
158
+ step.split('+').map( s => s.trim() ).sort().join('+')
159
+ )
160
+ return `GAMEPAD:${steps.join(',')}`
161
+ }
162
+
163
+ export default _normalizeShortcutName
164
+ ```
165
+
166
+ **Rules of thumb:**
167
+ - The returned name **must** start with `PREFIX:` (uppercase prefix).
168
+ - Modifiers should be alphabetized (`shift+ctrl` not `ctrl+shift`) so the same shortcut always normalizes the same way.
169
+ - Return `'PREFIX:SETUP'` (uppercase) when the user wrote a setup event.
170
+ - Return `name` unchanged when it doesn't match your prefix.
171
+
172
+ ### 3. Register shortcuts in the active context
173
+
174
+ `_registerShortcutEvents(deps, pluginState)` is called by the core every time the context changes. It walks the current context's shortcuts, applies the user's `PREFIX:setup` (if any), and returns the count of shortcuts that match this plugin. If the count is zero, the core will not call `listener.start()` — you don't have to guard against empty state yourself.
175
+
176
+ ```js
177
+ // src/plugins/gamepad/_registerShortcutEvents.js
178
+ 'use strict'
179
+
180
+ function _registerShortcutEvents ( deps, pluginState ) {
181
+ const
182
+ { regex } = deps
183
+ , { currentContext, shortcuts
184
+ , defaultOptions, listenOptions
185
+ } = pluginState
186
+ ;
187
+
188
+ if ( currentContext.name == null ) return 0
189
+
190
+ let count = 0
191
+ let hasSetup = false
192
+
193
+ Object.entries( shortcuts[currentContext.name] ).forEach( ([name, list]) => {
194
+ if ( !regex.test(name) ) return // not ours
195
+
196
+ if ( name === 'GAMEPAD:SETUP' ) {
197
+ hasSetup = true
198
+ // Each user's setup fn receives { dependencies, defaults, options }
199
+ // and returns a partial options object we merge into listenOptions.
200
+ const update = list.reduce( (res, fn) => {
201
+ const r = fn({
202
+ dependencies : deps.extra
203
+ , defaults : structuredClone(defaultOptions)
204
+ , options : listenOptions
205
+ })
206
+ return Object.assign(res, r)
207
+ }, defaultOptions)
208
+ Object.assign( listenOptions, update )
209
+ return
210
+ }
211
+
212
+ count++
213
+ })
214
+
215
+ if ( !hasSetup ) Object.assign( listenOptions, defaultOptions )
216
+ return count
217
+ }
218
+
219
+ export default _registerShortcutEvents
220
+ ```
221
+
222
+ **Contract summary:**
223
+ - Walk `pluginState.shortcuts[ pluginState.currentContext.name ]`.
224
+ - Skip entries that don't match your `deps.regex`.
225
+ - If you see `'PREFIX:SETUP'`, call each handler with `{ dependencies, defaults, options }` and merge the returned object into `pluginState.listenOptions`.
226
+ - If the context has **no** setup event, copy `defaultOptions` into `listenOptions` so they're always populated.
227
+ - Return the count of registered shortcuts.
228
+
229
+ ### 4. Listen to the device
230
+
231
+ `_listenDOM(deps, pluginState)` is called once at enable time and once on every context change (after the register step). It must return `{ start, stop }`. The core will call `start()` to begin listening and `stop()` to pause.
232
+
233
+ ```js
234
+ // src/plugins/gamepad/_listenDOM.js
235
+ 'use strict'
236
+
237
+ function _listenDOM ( deps, pluginState ) {
238
+ const { ev, extra } = deps
239
+ const { listenOptions, currentContext } = pluginState
240
+
241
+ function onGamepadEvent ( e ) {
242
+ // Build a canonical "GAMEPAD:A+B,LEFT" string from the device
243
+ const name = `GAMEPAD:${e.buttons.sort().join('+')}`
244
+
245
+ const data = {
246
+ type : 'gamepad'
247
+ , context : currentContext.name
248
+ , note : currentContext.note
249
+ , dependencies : extra
250
+ , event : e
251
+ , options : listenOptions
252
+ }
253
+
254
+ ev.emit( name, data ) // core + user listeners receive the event
255
+ }
256
+
257
+ function start () {
258
+ if ( pluginState.active ) return
259
+ window.addEventListener( 'gamepadbuttondown', onGamepadEvent )
260
+ pluginState.active = true
261
+ }
262
+
263
+ function stop () {
264
+ if ( !pluginState.active ) return
265
+ window.removeEventListener( 'gamepadbuttondown', onGamepadEvent )
266
+ pluginState.active = false
267
+ pluginState.buffer = [] // clear any in-flight state
268
+ }
269
+
270
+ return { start, stop }
271
+ }
272
+
273
+ export default _listenDOM
274
+ ```
275
+
276
+ **Contract summary:**
277
+ - Return `{ start, stop }`.
278
+ - `start()` adds your event listeners, sets `pluginState.active = true`. Must be idempotent (early return if already active).
279
+ - `stop()` removes listeners, sets `pluginState.active = false`, clears any pending timers. Must be idempotent.
280
+ - Inside your listeners, build a `data` object (see [next section](#the-data-object-what-to-put-in-it)) and call `ev.emit('PREFIX:EVENT_NAME', data)`.
281
+ - The `PREFIX` part of the emitted name must be uppercase.
282
+
283
+ ### 5. Wire it up
284
+
285
+ ```js
286
+ import { shortcuts, pluginGamepad } from '@my-scope/shortcut-plugin-gamepad'
287
+
288
+ const short = shortcuts()
289
+ short.enablePlugin( pluginGamepad )
290
+
291
+ short.load({
292
+ game: {
293
+ 'gamepad:setup': ({ defaults }) => ({ gamepadWait: 300 }),
294
+ 'gamepad: a, b, start': () => console.log('Combo A → B → Start')
295
+ }
296
+ })
297
+
298
+ short.changeContext('game')
299
+ ```
300
+
301
+ That's the full plugin. Drop the four files into your package, add it to your app, and the rest of the library (`pause`/`resume`, `mute`/`unmute`, `reset`, `getDependencies`, …) works for your input source with no extra code.
302
+
303
+
304
+
305
+ ## What `setupPlugin` injects into your pluginState
306
+
307
+ `setupPlugin` (provided by the core) **augments your `pluginState` object** with four references from the library before calling your `_registerShortcutEvents` and `_listenDOM`. Treat them as read-only unless you know what you're doing.
308
+
309
+ | Field | Type | What it is |
310
+ |---|---|---|
311
+ | `currentContext` | `{ name: string \| null, note: string \| null }` | The live context descriptor. `name` is `null` when no context is active. |
312
+ | `shortcuts` | `object` | The full shortcuts registry: `{ contextName: { shortcutName: [fn, …] } }`. Reading from it is fine. Mutating it can desync the library. |
313
+ | `exposeShortcut` | `function \| false` | The user's `onShortcut` constructor option, or `false` if they didn't set one. The core wires this up to fire on every emitted event. |
314
+ | `ERROR_EVENT_NAME` | `string` | The configured error event name (default `'@shortcuts-error'`). Emit here with `ev.emit(ERROR_EVENT_NAME, 'message')`. |
315
+
316
+ The core **also overrides** these four on your state when the context changes:
317
+
318
+ ```js
319
+ pluginState.currentContext // updated to the new context descriptor
320
+ pluginState.shortcuts // same reference, content may have changed via load/unload
321
+ pluginState.exposeShortcut // only set once at enable
322
+ pluginState.ERROR_EVENT_NAME // only set once at enable
323
+ ```
324
+
325
+ You don't need to read or write any of them — but you can use `currentContext.name` and `currentContext.note` in your `data` payload (the library's other plugins do).
326
+
327
+
328
+
329
+ ## What `setupPlugin` injects into your `dependencies`
330
+
331
+ `setupPlugin` spreads your `deps` and adds two library-owned fields. Your `_registerShortcutEvents` and `_listenDOM` receive the merged object as their first argument.
332
+
333
+ | Field | Type | What it is |
334
+ |---|---|---|
335
+ | `ev` | `object` | The library's event emitter ([`@peter.naydenov/notice`](https://github.com/PeterNaydenov/notice)). Has `.emit(name, …args)`, `.on(name, fn)`, `.off(name, fn)`, `.reset()`, `.start(name)`, `.stop(name)`. |
336
+ | `extra` | `object` | The bag the user registered via `shortcuts.setDependencies({…})`. Pass this through into your `data.dependencies` so user action functions can read it. |
337
+ | `…your deps` | | Everything you put in your `deps` object — `resetState`, `regex`, helper functions, etc. |
338
+
339
+ The `regex` field is conventional but not enforced. The library uses it to identify your shortcuts in `_registerShortcutEvents`. Pick a regex that matches your `PREFIX:` exactly (case-insensitive with whitespace tolerance) and nothing else.
340
+
341
+
342
+
343
+ ## The `data` object: what to put in it
344
+
345
+ The second argument to `ev.emit('PREFIX:NAME', data)` is the object every user action function receives. The contract is not enforced, but staying close to what the shipped plugins do makes your plugin feel native to users.
346
+
347
+ **Always include:**
348
+ - `type` — your plugin name as a string (e.g. `'key'`, `'click'`, `'gamepad'`). Used internally to tag the event.
349
+ - `context` — `pluginState.currentContext.name` at emit time.
350
+ - `note` — `pluginState.currentContext.note` (or `null`).
351
+ - `dependencies` — `deps.extra`. The library bakes `emit: ev.emit` into `extra` by default, so users get `data.dependencies.emit(eventName, ...args)` for free. Anything the host app adds via `setDependencies({...})` is merged into the same bag, so users can also call `data.dependencies.api.foo()` from their action.
352
+ - `options` — `pluginState.listenOptions` (the live options for the active context).
353
+
354
+ **Include when relevant:**
355
+ - `target` — the DOM element the event came from.
356
+ - `event` — the raw DOM/device event.
357
+ - `x` / `y` / `viewport` — coordinate data, mirroring the shipped `click`/`hover` plugins.
358
+ - `wait` / `end` / `ignore` / `isWaiting` — only meaningful for sequence-style plugins like `key`.
359
+
360
+ **Avoid putting:**
361
+ - Plugin-internal state (timers, buffers, etc.). Users will see it and rely on it, locking you in.
362
+ - The full `ev` object. The library already exposes `ev.emit` to user actions via `data.dependencies.emit` (it bakes `emit: ev.emit` into `extra` for you). Do not add a top-level `emit` field of your own — duplicating it will only confuse readers and break if the library's wrapping changes.
363
+
364
+
365
+
366
+ ## The `PREFIX:SETUP` per-context event
367
+
368
+ A `PREFIX:setup` event inside a context is the user's way to override your plugin's defaults **for that context only**. It's the preferred customization mechanism — see [README → Per-Context Setup vs `enablePlugin` Options](./README.md#per-context-setup-vs-enableplugin-options).
369
+
370
+ Your plugin must support it in `_registerShortcutEvents`. The pattern is:
371
+
372
+ ```js
373
+ if ( shortcutName === 'PREFIX:SETUP' ) {
374
+ const update = list.reduce( (res, fn) => {
375
+ const r = fn({
376
+ dependencies : deps.extra // user-set deps
377
+ , defaults : structuredClone(pluginState.defaultOptions) // clone — never mutate
378
+ , options : pluginState.listenOptions // live ref to per-context options
379
+ })
380
+ return Object.assign(res, r) // shallow merge into res
381
+ }, pluginState.defaultOptions) // initial value = defaults
382
+
383
+ Object.assign(pluginState.listenOptions, update) // final write to listenOptions
384
+ return
385
+ }
386
+ ```
387
+
388
+ **Key invariants:**
389
+ - The user gets `defaults` (a fresh `structuredClone` of your `defaultOptions`) and `options` (the live `listenOptions`). They may return a partial object with only the keys they want to override.
390
+ - The returned object is shallow-merged first across all setup functions (so order matters if the user defines multiple `PREFIX:setup` handlers), then into `listenOptions`.
391
+ - If the context does **not** define a `PREFIX:setup`, copy `defaultOptions` into `listenOptions` so they're always populated. Otherwise downstream code may read `undefined` options.
392
+
393
+ > The `form` plugin handles this slightly differently because it does not have per-action options — it returns the `defaults` object unchanged when no setup is provided. The `key`/`click`/`hover`/`scroll` plugins follow the pattern above.
394
+
395
+
396
+
397
+ ## Naming shortcuts and the normalization rules
398
+
399
+ A user writes shortcut names like:
400
+
401
+ ```js
402
+ 'key: ctrl+s' // 'key' is the prefix; 'ctrl+s' is the body
403
+ 'click: left-2-ctrl' // 'click' prefix; 'left-2-ctrl' body
404
+ 'form:action' // 'form' prefix; one of the form lifecycle events
405
+ ```
406
+
407
+ After your `_normalizeShortcutName` runs, the names look like:
408
+
409
+ ```js
410
+ 'KEY:CTRL+S'
411
+ 'CLICK:CTRL-LEFT-2' // modifiers alphabetized, button+count first
412
+ 'FORM:ACTION'
413
+ ```
414
+
415
+ **Rules:**
416
+ - Normalization runs at `load()` time on **every** shortcut name in **every** context — not just the active one. This is so that `shortcuts.emit('KEY:CTRL+S')` (with any spelling) matches.
417
+ - Each plugin receives every name. Yours must return the input unchanged when it doesn't match your prefix.
418
+ - Modifiers should be sorted alphabetically (`ctrl+shift+s`, not `shift+ctrl+s`).
419
+ - Always uppercase your prefix and the parts that should be uppercase. Lowercase parts (e.g. raw key names) are kept as the user wrote them.
420
+ - Return `'PREFIX:SETUP'` (uppercase) for the setup event, no matter how the user wrote it.
421
+
422
+
423
+
424
+ ## Listening to the DOM safely
425
+
426
+ A few rules the shipped plugins follow — copy them.
427
+
428
+ **Idempotency.** `start()` and `stop()` can be called multiple times. Use `pluginState.active` as a guard:
429
+
430
+ ```js
431
+ function start () {
432
+ if ( pluginState.active ) return
433
+ document.addEventListener( '…', handler )
434
+ pluginState.active = true
435
+ }
436
+
437
+ function stop () {
438
+ if ( !pluginState.active ) return
439
+ document.removeEventListener( '…', handler )
440
+ pluginState.active = false
441
+ clearTimeout( timer ) // clear any pending timers
442
+ timer = null
443
+ }
444
+ ```
445
+
446
+ **Clear timers in `stop`.** Any `setTimeout` / `setInterval` you start inside your listeners must be cleared in `stop()`, otherwise a muted plugin will still fire late. The shipped plugins use a `pluginState.wait[ type ]` for per-type throttles — a plain object on state is fine.
447
+
448
+ **Don't read from `window` at module load.** Reading `window.innerWidth` at import time breaks SSR and tests. Read inside the listener / inside `start()`.
449
+
450
+ **Don't attach global listeners that fire when the user is typing in an input.** The shipped plugins don't filter for this, but it's good practice to add an `isContentEditable` check or look at `event.target` if your plugin would conflict with text input. The `form` plugin only attaches to focus and input events, so it doesn't have this concern.
451
+
452
+ **Cleanup in `resetState`.** Add a `resetState` function to your `deps` that resets everything in `pluginState` to its initial values. The core calls it on every `contextChange` (see [below](#reset-mute-unmute-destroy)).
453
+
454
+
455
+
456
+ ## Reset, mute, unmute, destroy
457
+
458
+ The core calls these four methods on your returned `pluginAPI` (which is what `setupPlugin` constructs for you). You don't write them — `setupPlugin` writes them based on your `deps.resetState` and your `_listenDOM`'s `{start, stop}`.
459
+
460
+ | Method | When the core calls it | What it does |
461
+ |---|---|---|
462
+ | `resetState()` (your `deps`) | On every `contextChange`, on `mute`/`unmute` cycles if you choose, and on `destroy` | Resets your `pluginState` to its initial shape. **You write this.** It typically zeroes counters, clears buffers, and may also re-run defaults into `listenOptions`. |
463
+ | `pluginAPI.mute()` | `short.mutePlugin('prefix')` | Calls your `listener.stop()`. Your `resetState` is **not** called. |
464
+ | `pluginAPI.unmute()` | `short.unmutePlugin('prefix')` | Calls your `listener.start()`. |
465
+ | `pluginAPI.destroy()` | `short.disablePlugin('prefix')` or `short.reset()` | Calls your `listener.stop()` **and** your `resetState()`. After this, the plugin is gone. To bring it back, call `enablePlugin` again. |
466
+ | `pluginAPI.contextChange()` | Every `short.changeContext(name)` | Calls your `resetState()`, then re-runs `_registerShortcutEvents`, then either `listener.stop()` or `listener.start()` depending on the count. **Your listeners are re-attached** so any new `PREFIX:setup` options in the new context take effect. |
467
+ | `pluginAPI.shortcutName(name)` | `shortcuts.emit(name, …)` | Calls your `_normalizeShortcutName` so the user can pass any spelling. |
468
+ | `pluginAPI.getPrefix()` | `short.listPlugins()` | Returns your `prefix` string. |
469
+
470
+ **You write `resetState`.** Keep it focused: clear timers, clear buffers, reset any counters. Don't reset `defaultOptions` / `listenOptions` — those are managed by the core.
471
+
472
+ ```js
473
+ // inside pluginMy / index.js
474
+ function resetState () {
475
+ pluginState.active = false
476
+ pluginState.buffer = []
477
+ clearTimeout( pluginState.timer )
478
+ pluginState.timer = null
479
+ }
480
+ deps.resetState = resetState
481
+ ```
482
+
483
+
484
+
485
+ ## Distributing your plugin
486
+
487
+ A plugin is just a function. You can publish it as a normal npm package and the consumer does:
488
+
489
+ ```js
490
+ import { shortcuts } from '@peter.naydenov/shortcuts'
491
+ import { pluginGamepad } from '@my-scope/shortcut-plugin-gamepad'
492
+
493
+ const short = shortcuts()
494
+ short.enablePlugin( pluginGamepad )
495
+ ```
496
+
497
+ Recommended package layout:
498
+
499
+ ```
500
+ shortcut-plugin-gamepad/
501
+ ├── package.json
502
+ │ ├── name: '@my-scope/shortcut-plugin-gamepad'
503
+ │ ├── main: 'dist/index.cjs'
504
+ │ ├── module: 'dist/index.js'
505
+ │ ├── types: 'dist/index.d.ts'
506
+ │ └── peerDependencies: { '@peter.naydenov/shortcuts': '^4.1.0' }
507
+ ├── src/
508
+ │ └── plugins/gamepad/ // the files from this guide
509
+ ├── dist/ // build output
510
+ └── README.md // document your prefix, options, action-function `data` shape
511
+ ```
512
+
513
+ **Document for your users:**
514
+ 1. The prefix (`gamepad`).
515
+ 2. The `data` shape they receive in action functions (a TypeScript `type` is best).
516
+ 3. The `PREFIX:setup` options and their defaults.
517
+ 4. The shortcut name syntax (how to combine buttons, modifiers, sequences).
518
+ 5. Any browser/device support caveats.
519
+
520
+
521
+
522
+ ## Full checklist
523
+
524
+ Before publishing your plugin, verify each item.
525
+
526
+ **Contract**
527
+ - [ ] Factory is `(setupPlugin, options = {})` and returns the result of `setupPlugin({…})`.
528
+ - [ ] All six fields are passed to `setupPlugin`: `prefix`, `_normalizeShortcutName`, `_registerShortcutEvents`, `_listenDOM`, `pluginState`, `deps`.
529
+ - [ ] `pluginState` has `defaultOptions` and `listenOptions` (initialized from the same object).
530
+ - [ ] `deps.resetState` is defined and resets only mutable runtime state.
531
+ - [ ] `deps.regex` matches your prefix case-insensitively with optional whitespace.
532
+
533
+ **Naming**
534
+ - [ ] `_normalizeShortcutName` returns input unchanged when the prefix doesn't match.
535
+ - [ ] `_normalizeShortcutName` returns `'PREFIX:SETUP'` for any setup spelling.
536
+ - [ ] Normalized names are uppercase, modifier-sorted, sequence-aware.
537
+
538
+ **Registration**
539
+ - [ ] `_registerShortcutEvents` handles `PREFIX:SETUP` correctly: passes `{dependencies, defaults, options}` to each handler, merges all returned objects, applies to `listenOptions`.
540
+ - [ ] When no setup is defined, `defaultOptions` are copied into `listenOptions`.
541
+ - [ ] Returns the count of registered shortcuts for the active context.
542
+
543
+ **Listening**
544
+ - [ ] `_listenDOM` returns `{ start, stop }`.
545
+ - [ ] `start()` and `stop()` are idempotent.
546
+ - [ ] All timers/intervals are cleared in `stop()`.
547
+ - [ ] `pluginState.active` is set in `start()` and cleared in `stop()`.
548
+ - [ ] Emitted events have a `data` object that includes `type`, `context`, `note`, `dependencies`, `options`. (`emit` is already provided by the library via `data.dependencies.emit` — no need to add it yourself.)
549
+
550
+ **Behavior**
551
+ - [ ] `mutePlugin` stops the listener; `unmutePlugin` restarts it.
552
+ - [ ] `disablePlugin` / `reset` triggers `resetState`.
553
+ - [ ] `changeContext` re-registers and re-attaches the listener.
554
+
555
+ **Distribution**
556
+ - [ ] Package peer-depends on `@peter.naydenov/shortcuts`.
557
+ - [ ] README documents the `data` shape, options, and shortcut syntax.
558
+
559
+
560
+
561
+ ## Appendix: the shipped plugins at a glance
562
+
563
+ | Plugin | Prefix | Regex | Default options | Sequence? | Per-action `data` extras |
564
+ |---|---|---|---|---|---|
565
+ | `key` | `key` | `/KEY\s*:/i` | `keyWait: 480` | yes (`,` separator) | `wait`/`end`/`ignore`/`isWaiting`, `viewport` |
566
+ | `click` | `click` | `/CLICK\s*:/i` | `mouseWait: 320`, `clickTarget: ['data-click','href']` | no | `target`, `x`, `y`, `event` |
567
+ | `hover` | `hover` | `/HOVER\s*:/i` | `wait: 320`, `hoverTarget: ['data-hover']` | no | `target`, `x`, `y`, `event` |
568
+ | `scroll` | `scroll` | `/SCROLL\s*:/i` | `scrollWait: 50`, `endScrollWait: 400`, `minSpace: 40` | no | `event` |
569
+ | `form` | `form` | `/FORM\s*:/i` | (none) | no | `target`, `type`/`timing`/`wait`, `viewport`, `position`, `sizes`, `pagePosition` |
570
+
571
+ **The `form` plugin is the most different.** It does not have per-action `defaultOptions` (the wait is per-type, configured by the user's `form:action` return). Its `_registerShortcutEvents` reads `form:watch`, `form:define`, and `form:action` from the context and wires them up internally. If your plugin needs a multi-handler lifecycle, study `src/plugins/form/` for the closest pattern.
572
+
573
+ **The `key` plugin is the closest template for any "sequence of inputs"** — e.g. chord detection on a gamepad, multi-step voice commands, gesture sequences. Its `wait`/`end`/`ignore` helpers are documented in README under [Action Functions → Keyboard Action Functions](./README.md#keyboard-action-functions).
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@peter.naydenov/shortcuts",
3
3
  "description": "Context control of shortcuts based on keyboard and mouse events",
4
- "version": "4.0.2",
4
+ "version": "4.1.0",
5
5
  "license": "MIT",
6
6
  "author": "Peter Naydenov",
7
7
  "main": "./src/main.js",
@@ -35,26 +35,26 @@
35
35
  "@peter.naydenov/notice": "^2.4.2"
36
36
  },
37
37
  "devDependencies": {
38
- "@peter.naydenov/visual-controller-for-react": "^3.0.2",
39
- "@rollup/plugin-commonjs": "^29.0.0",
38
+ "@peter.naydenov/visual-controller-for-react": "^3.1.2",
39
+ "@rollup/plugin-commonjs": "^29.0.3",
40
40
  "@rollup/plugin-node-resolve": "^16.0.3",
41
41
  "@rollup/plugin-terser": "^1.0.0",
42
42
  "@testing-library/dom": "^10.4.1",
43
43
  "@testing-library/user-event": "^14.6.1",
44
- "@vitejs/plugin-react": "^6.0.1",
45
- "@vitest/browser": "^4.1.2",
46
- "@vitest/browser-playwright": "^4.1.2",
47
- "@vitest/coverage-v8": "^4.1.2",
48
- "@vitest/ui": "^4.1.2",
49
- "ask-for-promise": "^3.1.0",
50
- "eslint": "^10.1.0",
51
- "playwright": "^1.59.1",
52
- "react": "^19.2.4",
53
- "react-dom": "^19.2.4",
54
- "rollup": "^4.60.1",
55
- "typescript": "^6.0.2",
56
- "vite": "^8.0.3",
57
- "vitest": "^4.1.2"
44
+ "@vitejs/plugin-react": "^6.0.2",
45
+ "@vitest/browser": "^4.1.8",
46
+ "@vitest/browser-playwright": "^4.1.8",
47
+ "@vitest/coverage-v8": "^4.1.8",
48
+ "@vitest/ui": "^4.1.8",
49
+ "ask-for-promise": "^3.1.1",
50
+ "eslint": "^10.4.1",
51
+ "playwright": "^1.60.0",
52
+ "react": "^19.2.6",
53
+ "react-dom": "^19.2.7",
54
+ "rollup": "^4.61.1",
55
+ "typescript": "^6.0.3",
56
+ "vite": "^8.0.16",
57
+ "vitest": "^4.1.8"
58
58
  },
59
59
  "keywords": [
60
60
  "shortcut",
@@ -62,5 +62,9 @@
62
62
  "keyboard",
63
63
  "mouse",
64
64
  "click"
65
- ]
65
+ ],
66
+ "allowScripts": {
67
+ "fsevents@2.3.2": true,
68
+ "fsevents@2.3.3": true
69
+ }
66
70
  }
package/src/main.js CHANGED
@@ -100,7 +100,7 @@ function main ( options = {} ) {
100
100
  const
101
101
  inAPI = {} // API for internal methods
102
102
  , API = {} // API for public methods
103
- , ev = notice () // Event emitter instance
103
+ , ev = notice () // Event emitter instance
104
104
  , state = {
105
105
  currentContext : { name: null, note: null } // Context data container
106
106
  , shortcuts : {} // shortcuts = { contextName : { shortcut : callback[] } }
@@ -113,7 +113,7 @@ function main ( options = {} ) {
113
113
  ev
114
114
  , inAPI
115
115
  , API
116
- , extra : {}
116
+ , extra : { emit: ev.emit }
117
117
  };
118
118
 
119
119
  // ---------------------- > PLUGIN METHODS < ---------------------- //
@@ -232,10 +232,10 @@ function main ( options = {} ) {
232
232
  * @function emit
233
233
  * @description Emit event for shortcut in current context
234
234
  * @param {string} name - Shortcut name
235
- * @param {any} [args] - Arguments for callback function
235
+ * @param {...any} [args] - Arguments forwarded to the shortcut callback as additional positional parameters
236
236
  * @returns {void}
237
237
  **/
238
- API.emit = (name,...args) => ev.emit ( inAPI._readShortcutWithPlugins ( name ), ...args )
238
+ API.emit = (name,...args) => ev.emit ( inAPI._readShortcutWithPlugins ( name ), { dependencies: dependencies.extra, type: 'custom' }, ...args )
239
239
 
240
240
 
241
241
  /**
@@ -273,7 +273,7 @@ function main ( options = {} ) {
273
273
  API.changeContext ()
274
274
  state.plugins.forEach ( plugin => plugin.destroy () )
275
275
  API.listContexts ().map ( cx => API.unload ( cx ))
276
- dependencies.extra = {}
276
+ dependencies.extra = { emit: ev.emit }
277
277
  state.exposeShortcut = null
278
278
  } // reset func.
279
279