@remcostoeten/use-shortcut 1.3.0 → 2.0.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.
package/dist/index.js CHANGED
@@ -3,17 +3,35 @@
3
3
  var react = require('react');
4
4
 
5
5
  // src/constants.ts
6
- var Platform = {
6
+ var OS = {
7
7
  MAC: "mac",
8
8
  WINDOWS: "windows",
9
9
  LINUX: "linux"
10
10
  };
11
+ var Platform = OS;
12
+ var _cachedPlatform = null;
11
13
  function detectPlatform() {
12
- if (typeof navigator === "undefined") return Platform.WINDOWS;
13
- const platform = navigator.platform.toLowerCase();
14
- if (platform.includes("mac")) return Platform.MAC;
15
- if (platform.includes("linux")) return Platform.LINUX;
16
- return Platform.WINDOWS;
14
+ if (_cachedPlatform) return _cachedPlatform;
15
+ if (typeof navigator === "undefined") {
16
+ _cachedPlatform = OS.WINDOWS;
17
+ return _cachedPlatform;
18
+ }
19
+ const uaPlatform = navigator.userAgentData?.platform?.toLowerCase();
20
+ const platform = (uaPlatform ?? navigator.platform ?? navigator.userAgent ?? "").toLowerCase();
21
+ if (platform.includes("mac") || platform.includes("iphone") || platform.includes("ipad") || platform.includes("ipod")) {
22
+ _cachedPlatform = OS.MAC;
23
+ return _cachedPlatform;
24
+ }
25
+ if (platform.includes("linux") || platform.includes("android")) {
26
+ _cachedPlatform = OS.LINUX;
27
+ return _cachedPlatform;
28
+ }
29
+ if (platform.includes("win")) {
30
+ _cachedPlatform = OS.WINDOWS;
31
+ return _cachedPlatform;
32
+ }
33
+ _cachedPlatform = OS.WINDOWS;
34
+ return _cachedPlatform;
17
35
  }
18
36
  var ModifierKey = {
19
37
  META: "meta",
@@ -83,19 +101,19 @@ var SpecialKeyMap = {
83
101
  closebracket: "]"
84
102
  };
85
103
  var ModifierDisplaySymbols = {
86
- [Platform.MAC]: {
104
+ [OS.MAC]: {
87
105
  [ModifierKey.META]: "\u2318",
88
106
  [ModifierKey.CTRL]: "\u2303",
89
107
  [ModifierKey.ALT]: "\u2325",
90
108
  [ModifierKey.SHIFT]: "\u21E7"
91
109
  },
92
- [Platform.WINDOWS]: {
110
+ [OS.WINDOWS]: {
93
111
  [ModifierKey.META]: "Ctrl",
94
112
  [ModifierKey.CTRL]: "Ctrl",
95
113
  [ModifierKey.ALT]: "Alt",
96
114
  [ModifierKey.SHIFT]: "Shift"
97
115
  },
98
- [Platform.LINUX]: {
116
+ [OS.LINUX]: {
99
117
  [ModifierKey.META]: "Super",
100
118
  [ModifierKey.CTRL]: "Ctrl",
101
119
  [ModifierKey.ALT]: "Alt",
@@ -103,12 +121,15 @@ var ModifierDisplaySymbols = {
103
121
  }
104
122
  };
105
123
  var ModifierDisplayOrder = {
106
- [Platform.MAC]: [ModifierKey.CTRL, ModifierKey.ALT, ModifierKey.SHIFT, ModifierKey.META],
107
- [Platform.WINDOWS]: [ModifierKey.META, ModifierKey.ALT, ModifierKey.SHIFT, ModifierKey.CTRL],
108
- [Platform.LINUX]: [ModifierKey.META, ModifierKey.ALT, ModifierKey.SHIFT, ModifierKey.CTRL]
124
+ [OS.MAC]: [ModifierKey.CTRL, ModifierKey.ALT, ModifierKey.SHIFT, ModifierKey.META],
125
+ [OS.WINDOWS]: [ModifierKey.META, ModifierKey.ALT, ModifierKey.SHIFT, ModifierKey.CTRL],
126
+ [OS.LINUX]: [ModifierKey.META, ModifierKey.ALT, ModifierKey.SHIFT, ModifierKey.CTRL]
109
127
  };
110
128
 
111
129
  // src/parser.ts
130
+ function _normalizeKeyToken(key) {
131
+ return key === " " ? "space" : key.toLowerCase();
132
+ }
112
133
  function parseShortcut(shortcut) {
113
134
  const platform = detectPlatform();
114
135
  const normalized = shortcut.toLowerCase().trim();
@@ -160,9 +181,9 @@ function getModifiersFromEvent(event) {
160
181
  }
161
182
  function matchesShortcut(event, parsed) {
162
183
  const eventModifiers = getModifiersFromEvent(event);
163
- const eventKey = event.key.toLowerCase();
184
+ const eventKey = _normalizeKeyToken(event.key);
164
185
  const modifiersMatch = eventModifiers.meta === parsed.modifiers.meta && eventModifiers.ctrl === parsed.modifiers.ctrl && eventModifiers.alt === parsed.modifiers.alt && eventModifiers.shift === parsed.modifiers.shift;
165
- const keyMatches = eventKey === parsed.key.toLowerCase();
186
+ const keyMatches = eventKey === _normalizeKeyToken(parsed.key);
166
187
  return modifiersMatch && keyMatches;
167
188
  }
168
189
  function matchesAnyShortcut(event, parsedShortcuts) {
@@ -170,6 +191,34 @@ function matchesAnyShortcut(event, parsedShortcuts) {
170
191
  }
171
192
 
172
193
  // src/formatter.ts
194
+ var _BASE_DISPLAY_NAMES = {
195
+ ArrowUp: "\u2191",
196
+ ArrowDown: "\u2193",
197
+ ArrowLeft: "\u2190",
198
+ ArrowRight: "\u2192",
199
+ Home: "Home",
200
+ End: "End",
201
+ PageUp: "PgUp",
202
+ PageDown: "PgDn"
203
+ };
204
+ var _MAC_DISPLAY_NAMES = {
205
+ ..._BASE_DISPLAY_NAMES,
206
+ Enter: "\u21A9",
207
+ Tab: "\u21E5",
208
+ Escape: "\u238B",
209
+ Backspace: "\u232B",
210
+ Delete: "\u2326",
211
+ " ": "\u2423"
212
+ };
213
+ var _NON_MAC_DISPLAY_NAMES = {
214
+ ..._BASE_DISPLAY_NAMES,
215
+ Enter: "Enter",
216
+ Tab: "Tab",
217
+ Escape: "Esc",
218
+ Backspace: "Backspace",
219
+ Delete: "Del",
220
+ " ": "Space"
221
+ };
173
222
  function formatShortcut(shortcut, platform) {
174
223
  const targetPlatform = platform ?? detectPlatform();
175
224
  const parsed = parseShortcut(shortcut);
@@ -181,151 +230,87 @@ function formatShortcut(shortcut, platform) {
181
230
  parts.push(symbols[modifier]);
182
231
  }
183
232
  }
184
- const displayKey = formatKey(parsed.key, targetPlatform);
233
+ const displayKey = _formatKey(parsed.key, targetPlatform);
185
234
  parts.push(displayKey);
186
- const separator = targetPlatform === Platform.MAC ? "" : "+";
235
+ const separator = targetPlatform === OS.MAC ? "" : "+";
187
236
  return parts.join(separator);
188
237
  }
189
- function formatKey(key, platform) {
190
- const displayNames = {
191
- ArrowUp: "\u2191",
192
- ArrowDown: "\u2193",
193
- ArrowLeft: "\u2190",
194
- ArrowRight: "\u2192",
195
- Enter: platform === Platform.MAC ? "\u21A9" : "Enter",
196
- Tab: platform === Platform.MAC ? "\u21E5" : "Tab",
197
- Escape: platform === Platform.MAC ? "\u238B" : "Esc",
198
- Backspace: platform === Platform.MAC ? "\u232B" : "Backspace",
199
- Delete: platform === Platform.MAC ? "\u2326" : "Del",
200
- " ": platform === Platform.MAC ? "\u2423" : "Space",
201
- Home: "Home",
202
- End: "End",
203
- PageUp: "PgUp",
204
- PageDown: "PgDn"
205
- };
238
+ function _formatKey(key, platform) {
239
+ const displayNames = platform === OS.MAC ? _MAC_DISPLAY_NAMES : _NON_MAC_DISPLAY_NAMES;
206
240
  return displayNames[key] || key.toUpperCase();
207
241
  }
208
- function getModifierSymbols(platform) {
209
- const targetPlatform = platform ?? detectPlatform();
210
- return ModifierDisplaySymbols[targetPlatform];
211
- }
212
242
 
213
- // src/builder.ts
214
- var MODIFIER_KEYS = /* @__PURE__ */ new Set(["ctrl", "shift", "alt", "cmd", "mod"]);
215
- var IGNORED_TAGS = /* @__PURE__ */ new Set(["INPUT", "TEXTAREA", "SELECT"]);
216
- var EXCEPT_PREDICATES = {
217
- input: (e) => {
218
- const target = e.target;
219
- return IGNORED_TAGS.has(target.tagName);
220
- },
221
- editable: (e) => {
222
- const target = e.target;
223
- return target.isContentEditable;
224
- },
225
- typing: (e) => {
226
- const target = e.target;
227
- return IGNORED_TAGS.has(target.tagName) || target.isContentEditable;
228
- },
229
- modal: () => {
230
- return document.querySelector('[data-modal="true"], [role="dialog"]') !== null;
231
- },
232
- disabled: (e) => {
233
- const target = e.target;
234
- return target.hasAttribute("disabled") || target.getAttribute("aria-disabled") === "true";
235
- }
236
- };
237
- function shouldExcept(event, except) {
238
- if (!except) return false;
239
- if (typeof except === "function") {
240
- return except(event);
241
- }
242
- if (Array.isArray(except)) {
243
- return except.some((preset) => EXCEPT_PREDICATES[preset]?.(event));
244
- }
245
- return EXCEPT_PREDICATES[except]?.(event) ?? false;
246
- }
247
- function normalizeScopes(scopes) {
248
- if (!scopes) return [];
249
- return (Array.isArray(scopes) ? scopes : [scopes]).map((scope) => scope.trim()).filter(Boolean);
250
- }
251
- function scopeMatch(requiredScopes, activeScopes) {
252
- if (requiredScopes.size === 0) return true;
253
- for (const required of requiredScopes) {
254
- if (activeScopes.has(required)) return true;
243
+ // src/runtime/debug.ts
244
+ function _debugLog(debug, ...args) {
245
+ if (debug) {
246
+ console.log("[useShortcut]", ...args);
255
247
  }
256
- return false;
257
248
  }
258
- function getActiveModifierTokens(modifiers) {
249
+
250
+ // src/runtime/keys.ts
251
+ function _getActiveModifierTokens(modifiers) {
259
252
  const platform = detectPlatform();
260
253
  const order = ModifierDisplayOrder[platform];
261
- return order.filter((key) => {
262
- if (key === ModifierKey.CTRL) return modifiers.ctrl;
263
- if (key === ModifierKey.ALT) return modifiers.alt;
264
- if (key === ModifierKey.SHIFT) return modifiers.shift;
265
- if (key === ModifierKey.META) return modifiers.cmd;
266
- return false;
267
- }).map((key) => {
268
- if (key === ModifierKey.CTRL) return "ctrl";
269
- if (key === ModifierKey.ALT) return "alt";
270
- if (key === ModifierKey.SHIFT) return "shift";
271
- if (key === ModifierKey.META) return "cmd";
272
- return "";
273
- });
254
+ const tokens = [];
255
+ for (const key of order) {
256
+ if (key === ModifierKey.CTRL && modifiers.ctrl) tokens.push("ctrl");
257
+ if (key === ModifierKey.ALT && modifiers.alt) tokens.push("alt");
258
+ if (key === ModifierKey.SHIFT && modifiers.shift) tokens.push("shift");
259
+ if (key === ModifierKey.META && modifiers.cmd) tokens.push("cmd");
260
+ }
261
+ return tokens;
274
262
  }
275
- function buildComboString(modifiers, key) {
276
- const tokens = getActiveModifierTokens(modifiers);
263
+ function _buildComboString(modifiers, key) {
264
+ const tokens = _getActiveModifierTokens(modifiers);
277
265
  return [...tokens, key].join("+");
278
266
  }
279
- function formatSequenceDisplay(steps) {
267
+ function _formatSequenceDisplay(steps) {
280
268
  return steps.map((step) => formatShortcut(step)).join(" then ");
281
269
  }
282
- function debugLog(debug, ...args) {
283
- if (debug) {
284
- console.log("[useShortcut]", ...args);
285
- }
286
- }
287
- function canonicalizeParsed(parsed) {
270
+ function _canonicalizeParsed(parsed) {
288
271
  const modifiers = [];
289
272
  if (parsed.modifiers.ctrl) modifiers.push("ctrl");
290
273
  if (parsed.modifiers.alt) modifiers.push("alt");
291
274
  if (parsed.modifiers.shift) modifiers.push("shift");
292
275
  if (parsed.modifiers.meta) modifiers.push("cmd");
293
- return [...modifiers, parsed.key.toLowerCase()].join("+");
294
- }
295
- function isPureModifier(event) {
296
- const key = event.key.toLowerCase();
297
- return key === "shift" || key === "control" || key === "alt" || key === "meta";
276
+ const key = parsed.key === " " || parsed.key === "Spacebar" ? "space" : parsed.key.toLowerCase();
277
+ return [...modifiers, key].join("+");
298
278
  }
299
- function eventToCombo(event) {
300
- const platform = detectPlatform();
301
- const symbols = ModifierDisplaySymbols[platform];
279
+ function _eventToCombo(event) {
302
280
  const modifiers = [];
303
- if (event.ctrlKey) modifiers.push(symbols[ModifierKey.CTRL] === "\u2303" ? "ctrl" : "ctrl");
281
+ if (event.ctrlKey) modifiers.push("ctrl");
304
282
  if (event.altKey) modifiers.push("alt");
305
283
  if (event.shiftKey) modifiers.push("shift");
306
284
  if (event.metaKey) modifiers.push("cmd");
307
- const key = event.key.length === 1 ? event.key.toLowerCase() : event.key.toLowerCase();
285
+ const key = event.key === " " || event.key === "Spacebar" ? "space" : event.key.toLowerCase();
308
286
  return [...modifiers, key].join("+");
309
287
  }
310
- function isPrefix(a, b) {
288
+ function _eventToMatchStep(event) {
289
+ return _eventToCombo(event);
290
+ }
291
+
292
+ // src/runtime/conflicts.ts
293
+ function _isPrefix(a, b) {
311
294
  if (a.length > b.length) return false;
312
295
  for (let i = 0; i < a.length; i += 1) {
313
- if (canonicalizeParsed(a[i]) !== canonicalizeParsed(b[i])) {
296
+ if (a[i] !== b[i]) {
314
297
  return false;
315
298
  }
316
299
  }
317
300
  return true;
318
301
  }
319
- function detectConflict(newSteps, existingSteps) {
320
- const newCombo = newSteps.map(canonicalizeParsed).join(" ");
321
- const existingCombo = existingSteps.map(canonicalizeParsed).join(" ");
302
+ function _detectConflict(newSteps, existingSteps) {
303
+ const newCanonicalSteps = newSteps.map(_canonicalizeParsed);
304
+ const existingCanonicalSteps = existingSteps.map(_canonicalizeParsed);
305
+ const newCombo = newCanonicalSteps.join(" ");
306
+ const existingCombo = existingCanonicalSteps.join(" ");
322
307
  if (newCombo === existingCombo) return "exact";
323
- if (isPrefix(newSteps, existingSteps) || isPrefix(existingSteps, newSteps)) {
308
+ if (_isPrefix(newCanonicalSteps, existingCanonicalSteps) || _isPrefix(existingCanonicalSteps, newCanonicalSteps)) {
324
309
  return "sequence-prefix";
325
310
  }
326
311
  return null;
327
312
  }
328
- function emitConflict(registry, conflict) {
313
+ function _emitConflict(registry, conflict) {
329
314
  const conflictWarnings = registry.options.conflictWarnings ?? true;
330
315
  if (registry.options.onConflict) {
331
316
  registry.options.onConflict(conflict);
@@ -336,37 +321,195 @@ function emitConflict(registry, conflict) {
336
321
  `[useShortcut] Conflict detected (${conflict.reason}) between "${conflict.combo}" and "${conflict.existingCombo}"`
337
322
  );
338
323
  }
339
- function sortEntries(entries) {
324
+
325
+ // src/runtime/guards.ts
326
+ var _IGNORED_TAGS = /* @__PURE__ */ new Set(["INPUT", "TEXTAREA", "SELECT"]);
327
+ var _EXCEPT_PREDICATES = {
328
+ input: (e) => {
329
+ if (!(e.target instanceof HTMLElement)) return false;
330
+ const target = e.target;
331
+ return _IGNORED_TAGS.has(target.tagName);
332
+ },
333
+ editable: (e) => {
334
+ if (!(e.target instanceof HTMLElement)) return false;
335
+ const target = e.target;
336
+ return target.isContentEditable;
337
+ },
338
+ typing: (e) => {
339
+ if (!(e.target instanceof HTMLElement)) return false;
340
+ const target = e.target;
341
+ return _IGNORED_TAGS.has(target.tagName) || target.isContentEditable;
342
+ },
343
+ modal: () => {
344
+ if (typeof document === "undefined" || typeof document.querySelector !== "function")
345
+ return false;
346
+ return document.querySelector('[data-modal="true"], [role="dialog"]') !== null;
347
+ },
348
+ disabled: (e) => {
349
+ if (!(e.target instanceof HTMLElement)) return false;
350
+ const target = e.target;
351
+ return target.hasAttribute("disabled") || target.getAttribute("aria-disabled") === "true";
352
+ }
353
+ };
354
+ function _shouldExcept(event, except) {
355
+ if (!except) return false;
356
+ if (typeof except === "function") {
357
+ return except(event);
358
+ }
359
+ if (Array.isArray(except)) {
360
+ return except.some((preset) => _EXCEPT_PREDICATES[preset]?.(event));
361
+ }
362
+ return _EXCEPT_PREDICATES[except]?.(event) ?? false;
363
+ }
364
+ function _normalizeScopes(scopes) {
365
+ if (!scopes) return [];
366
+ return (Array.isArray(scopes) ? scopes : [scopes]).map((scope) => scope.trim()).filter(Boolean);
367
+ }
368
+ function _scopeMatch(requiredScopes, activeScopes) {
369
+ if (requiredScopes.size === 0) return true;
370
+ for (const required of requiredScopes) {
371
+ if (activeScopes.has(required)) return true;
372
+ }
373
+ return false;
374
+ }
375
+ function _isPureModifier(event) {
376
+ const key = event.key.toLowerCase();
377
+ return key === "shift" || key === "control" || key === "alt" || key === "meta";
378
+ }
379
+
380
+ // src/runtime/listener.ts
381
+ function _sortEntries(entries) {
382
+ if (entries.length <= 1) return entries;
340
383
  return [...entries].sort((a, b) => {
341
384
  if (b.priority !== a.priority) return b.priority - a.priority;
342
385
  return a.id - b.id;
343
386
  });
344
387
  }
345
- function createBinding(state, handler, handlerOptions = {}, registry) {
388
+ function _dispatchRegistryEvent(registry, event) {
389
+ const runtimeOptions = registry.options;
390
+ if (runtimeOptions.disabled) return;
391
+ if (runtimeOptions.eventFilter && !runtimeOptions.eventFilter(event)) return;
392
+ const candidateCombos = /* @__PURE__ */ new Set();
393
+ const firstStepCombos = registry.firstStepIndex.get(_eventToMatchStep(event));
394
+ if (firstStepCombos) {
395
+ for (const combo of firstStepCombos) candidateCombos.add(combo);
396
+ }
397
+ for (const combo of registry.activeSequenceCombos) {
398
+ candidateCombos.add(combo);
399
+ }
400
+ for (const combo of candidateCombos) {
401
+ const comboEntries = registry.listeners.get(combo);
402
+ if (!comboEntries) continue;
403
+ const orderedEntries = _sortEntries(comboEntries);
404
+ for (const item of orderedEntries) {
405
+ if (!item.isEnabled) continue;
406
+ if (!_scopeMatch(item.scopes, registry.activeScopes)) {
407
+ continue;
408
+ }
409
+ if (runtimeOptions.ignoreInputs !== false && !item.except) {
410
+ const targetEl = event.target;
411
+ if (targetEl && (_IGNORED_TAGS.has(targetEl.tagName) || targetEl.isContentEditable)) {
412
+ continue;
413
+ }
414
+ }
415
+ if (_shouldExcept(event, item.except)) {
416
+ _debugLog(runtimeOptions.debug, "Skipped due to except condition:", combo);
417
+ continue;
418
+ }
419
+ const expected = item.parsedSteps[item.progress];
420
+ const now = Date.now();
421
+ if (item.progress > 0 && now - item.lastMatchedAt > item.sequenceTimeout) {
422
+ item.progress = 0;
423
+ }
424
+ let matched = false;
425
+ if (matchesShortcut(event, expected)) {
426
+ item.progress += 1;
427
+ item.lastMatchedAt = now;
428
+ if (item.progress === item.parsedSteps.length) {
429
+ matched = true;
430
+ item.progress = 0;
431
+ }
432
+ } else if (item.progress > 0 && matchesShortcut(event, item.parsedSteps[0])) {
433
+ item.progress = 1;
434
+ item.lastMatchedAt = now;
435
+ } else {
436
+ item.progress = 0;
437
+ }
438
+ for (const cb of item.attemptCallbacks) {
439
+ cb(matched, event);
440
+ }
441
+ if (!matched) continue;
442
+ _debugLog(runtimeOptions.debug, "MATCHED:", combo);
443
+ if (item.preventDefault) {
444
+ event.preventDefault();
445
+ }
446
+ if (item.stopPropagation) {
447
+ event.stopPropagation();
448
+ }
449
+ const executeHandler = () => item.userHandler(event);
450
+ if (item.delay > 0) {
451
+ _debugLog(runtimeOptions.debug, "Delaying execution by", item.delay, "ms");
452
+ setTimeout(executeHandler, item.delay);
453
+ } else {
454
+ executeHandler();
455
+ }
456
+ if (item.stopOnMatch) {
457
+ break;
458
+ }
459
+ }
460
+ if (comboEntries.some((entry) => entry.progress > 0)) {
461
+ registry.activeSequenceCombos.add(combo);
462
+ } else {
463
+ registry.activeSequenceCombos.delete(combo);
464
+ }
465
+ }
466
+ }
467
+ function _attachRegistryListener(registry) {
468
+ if (registry.listener) return;
469
+ const target = registry.options.target ?? (typeof window !== "undefined" ? window : null);
470
+ if (!target) return;
471
+ const eventType = registry.options.eventType ?? "keydown";
472
+ const listener = (event) => _dispatchRegistryEvent(registry, event);
473
+ target.addEventListener(eventType, listener);
474
+ registry.listener = listener;
475
+ registry.listenerTarget = target;
476
+ registry.listenerEventType = eventType;
477
+ _debugLog(registry.options.debug, "Listener attached");
478
+ }
479
+ function _detachRegistryListener(registry) {
480
+ if (!registry.listener || !registry.listenerTarget) return;
481
+ registry.listenerTarget.removeEventListener(registry.listenerEventType, registry.listener);
482
+ registry.listener = null;
483
+ registry.listenerTarget = null;
484
+ _debugLog(registry.options.debug, "Listener detached");
485
+ }
486
+
487
+ // src/runtime/binding.ts
488
+ function _createBinding(state, handler, handlerOptions = {}, registry) {
346
489
  const { options, except: stateExcept } = state;
347
490
  const rawSteps = state.steps;
348
491
  if (rawSteps.length === 0) {
349
492
  throw new Error("[useShortcut] No key specified. Use .key() to set the action key.");
350
493
  }
351
494
  const parsedSteps = rawSteps.map((step) => parseShortcut(step));
352
- const combo = parsedSteps.map(canonicalizeParsed).join(" ");
353
- const display = formatSequenceDisplay(rawSteps);
495
+ const combo = parsedSteps.map(_canonicalizeParsed).join(" ");
496
+ const display = _formatSequenceDisplay(rawSteps);
354
497
  const debug = options.debug ?? false;
355
498
  const except = stateExcept ?? handlerOptions.except;
356
- for (const [existingCombo, listener] of registry.listeners.entries()) {
357
- for (const existing of listener.entries) {
499
+ for (const [existingCombo, entries] of registry.listeners.entries()) {
500
+ for (const existing of entries) {
358
501
  if (existingCombo === combo) continue;
359
- const reason = detectConflict(parsedSteps, existing.parsedSteps);
502
+ const reason = _detectConflict(parsedSteps, existing.parsedSteps);
360
503
  if (!reason) continue;
361
- emitConflict(registry, { combo, existingCombo, reason });
504
+ _emitConflict(registry, { combo, existingCombo, reason });
362
505
  }
363
506
  }
364
507
  const isEnabled = !handlerOptions.disabled && !options.disabled;
365
508
  const delay = handlerOptions.delay ?? options.delay ?? 0;
366
509
  const sequenceTimeout = handlerOptions.sequenceTimeout ?? options.sequenceTimeout ?? 800;
367
- const requiredScopes = new Set(normalizeScopes(state.scopes ?? handlerOptions.scopes));
510
+ const requiredScopes = new Set(_normalizeScopes(state.scopes ?? handlerOptions.scopes));
368
511
  const attemptCallbacks = /* @__PURE__ */ new Set();
369
- debugLog(debug, "Registering:", combo, "\u2192", display, {
512
+ _debugLog(debug, "Registering:", combo, "\u2192", display, {
370
513
  parsedSteps,
371
514
  except: !!except,
372
515
  scopes: [...requiredScopes]
@@ -388,97 +531,41 @@ function createBinding(state, handler, handlerOptions = {}, registry) {
388
531
  stopOnMatch: handlerOptions.stopOnMatch ?? false,
389
532
  priority: handlerOptions.priority ?? 0
390
533
  };
391
- let comboListener = registry.listeners.get(combo);
392
- if (!comboListener) {
393
- const target = options.target ?? (typeof window !== "undefined" ? window : null);
394
- const eventType = options.eventType ?? "keydown";
395
- const listener = (event) => {
396
- const runtimeOptions = registry.options;
397
- if (runtimeOptions.disabled) return;
398
- if (runtimeOptions.eventFilter && !runtimeOptions.eventFilter(event)) return;
399
- const current = registry.listeners.get(combo);
400
- if (!current) return;
401
- const orderedEntries = sortEntries(current.entries);
402
- for (const item of orderedEntries) {
403
- if (!item.isEnabled) continue;
404
- if (!scopeMatch(item.scopes, registry.activeScopes)) {
405
- continue;
406
- }
407
- if (runtimeOptions.ignoreInputs !== false && !item.except) {
408
- const targetEl = event.target;
409
- if (targetEl && (IGNORED_TAGS.has(targetEl.tagName) || targetEl.isContentEditable)) {
410
- continue;
411
- }
412
- }
413
- if (shouldExcept(event, item.except)) {
414
- debugLog(debug, "Skipped due to except condition:", combo);
415
- continue;
416
- }
417
- const expected = item.parsedSteps[item.progress];
418
- const now = Date.now();
419
- if (item.progress > 0 && now - item.lastMatchedAt > item.sequenceTimeout) {
420
- item.progress = 0;
421
- }
422
- let matched = false;
423
- if (matchesShortcut(event, expected)) {
424
- item.progress += 1;
425
- item.lastMatchedAt = now;
426
- if (item.progress === item.parsedSteps.length) {
427
- matched = true;
428
- item.progress = 0;
429
- }
430
- } else if (item.progress > 0 && matchesShortcut(event, item.parsedSteps[0])) {
431
- item.progress = 1;
432
- item.lastMatchedAt = now;
433
- } else {
434
- item.progress = 0;
435
- }
436
- item.attemptCallbacks.forEach((cb) => cb(matched, event));
437
- if (!matched) continue;
438
- debugLog(debug, "MATCHED:", combo, "\u2192", display);
439
- if (item.preventDefault) {
440
- event.preventDefault();
441
- }
442
- if (item.stopPropagation) {
443
- event.stopPropagation();
444
- }
445
- const executeHandler = () => item.userHandler(event);
446
- if (item.delay > 0) {
447
- debugLog(debug, "Delaying execution by", item.delay, "ms");
448
- setTimeout(executeHandler, item.delay);
449
- } else {
450
- executeHandler();
451
- }
452
- if (item.stopOnMatch) {
453
- break;
454
- }
455
- }
456
- };
457
- if (target) {
458
- target.addEventListener(eventType, listener);
459
- debugLog(debug, "Listener attached for:", combo);
534
+ const comboEntries = registry.listeners.get(combo);
535
+ if (comboEntries) {
536
+ comboEntries.push(entry);
537
+ } else {
538
+ registry.listeners.set(combo, [entry]);
539
+ const firstStep = _canonicalizeParsed(parsedSteps[0]);
540
+ const indexedCombos = registry.firstStepIndex.get(firstStep);
541
+ if (indexedCombos) {
542
+ indexedCombos.add(combo);
543
+ } else {
544
+ registry.firstStepIndex.set(firstStep, /* @__PURE__ */ new Set([combo]));
460
545
  }
461
- const unbind = () => {
462
- if (target) {
463
- target.removeEventListener(eventType, listener);
464
- registry.listeners.delete(combo);
465
- debugLog(debug, "Unregistered:", combo);
466
- }
467
- };
468
- comboListener = {
469
- listener,
470
- entries: [],
471
- unbind
472
- };
473
- registry.listeners.set(combo, comboListener);
474
546
  }
475
- comboListener.entries.push(entry);
547
+ _attachRegistryListener(registry);
476
548
  const unbindEntry = () => {
477
- const current = registry.listeners.get(combo);
478
- if (!current) return;
479
- current.entries = current.entries.filter((item) => item.id !== entry.id);
480
- if (current.entries.length === 0) {
481
- current.unbind();
549
+ const currentEntries = registry.listeners.get(combo);
550
+ if (!currentEntries) return;
551
+ const nextEntries = currentEntries.filter((item) => item.id !== entry.id);
552
+ if (nextEntries.length === 0) {
553
+ registry.listeners.delete(combo);
554
+ registry.activeSequenceCombos.delete(combo);
555
+ const firstStep = _canonicalizeParsed(parsedSteps[0]);
556
+ const indexedCombos = registry.firstStepIndex.get(firstStep);
557
+ if (indexedCombos) {
558
+ indexedCombos.delete(combo);
559
+ if (indexedCombos.size === 0) {
560
+ registry.firstStepIndex.delete(firstStep);
561
+ }
562
+ }
563
+ _debugLog(debug, "Unregistered:", combo);
564
+ } else {
565
+ registry.listeners.set(combo, nextEntries);
566
+ }
567
+ if (registry.listeners.size === 0) {
568
+ _detachRegistryListener(registry);
482
569
  }
483
570
  };
484
571
  return {
@@ -501,7 +588,9 @@ function createBinding(state, handler, handlerOptions = {}, registry) {
501
588
  }
502
589
  };
503
590
  }
504
- function createRecorder(options) {
591
+
592
+ // src/runtime/recording.ts
593
+ function _createRecorder(options) {
505
594
  return (recordingOptions = {}) => {
506
595
  return new Promise((resolve, reject) => {
507
596
  const target = recordingOptions.target ?? options.target ?? (typeof window !== "undefined" ? window : null);
@@ -513,13 +602,13 @@ function createRecorder(options) {
513
602
  let timeout;
514
603
  const listener = (event) => {
515
604
  const keyboardEvent = event;
516
- if (isPureModifier(keyboardEvent)) return;
605
+ if (_isPureModifier(keyboardEvent)) return;
517
606
  keyboardEvent.preventDefault();
518
607
  target.removeEventListener(eventType, listener);
519
608
  if (timeout) clearTimeout(timeout);
520
- resolve(eventToCombo(keyboardEvent));
609
+ resolve(_eventToCombo(keyboardEvent));
521
610
  };
522
- target.addEventListener(eventType, listener, { once: false });
611
+ target.addEventListener(eventType, listener);
523
612
  const timeoutMs = recordingOptions.timeoutMs;
524
613
  if (timeoutMs && timeoutMs > 0) {
525
614
  timeout = setTimeout(() => {
@@ -530,43 +619,51 @@ function createRecorder(options) {
530
619
  });
531
620
  };
532
621
  }
533
- function createShortcutBuilder(options = {}) {
622
+
623
+ // src/builder.ts
624
+ var _MODIFIER_KEYS = /* @__PURE__ */ new Set(["ctrl", "shift", "alt", "cmd", "mod"]);
625
+ function _createShortcutBuilder(options = {}) {
534
626
  const registry = {
535
627
  listeners: /* @__PURE__ */ new Map(),
628
+ firstStepIndex: /* @__PURE__ */ new Map(),
629
+ activeSequenceCombos: /* @__PURE__ */ new Set(),
536
630
  options,
537
- activeScopes: new Set(normalizeScopes(options.activeScopes)),
538
- nextId: 1
631
+ activeScopes: new Set(_normalizeScopes(options.activeScopes)),
632
+ nextId: 1,
633
+ listener: null,
634
+ listenerTarget: null,
635
+ listenerEventType: options.eventType ?? "keydown"
539
636
  };
540
- debugLog(options.debug, "Builder created with options:", options);
541
- function createProxy(currentState) {
637
+ _debugLog(options.debug, "Builder created with options:", options);
638
+ function _createProxy(currentState) {
542
639
  return new Proxy({}, {
543
640
  get(_, prop) {
544
641
  if (prop === "__debug") {
545
642
  return currentState.options.debug;
546
643
  }
547
- if (MODIFIER_KEYS.has(prop)) {
644
+ if (_MODIFIER_KEYS.has(prop)) {
548
645
  const platform = detectPlatform();
549
646
  const modKey = prop === "mod" ? platform === Platform.MAC ? "cmd" : "ctrl" : prop;
550
647
  const newState = {
551
648
  ...currentState,
552
649
  modifiers: { ...currentState.modifiers, [modKey]: true }
553
650
  };
554
- debugLog(currentState.options.debug, `Chain: +${prop} \u2192`, newState.modifiers);
555
- return createProxy(newState);
651
+ _debugLog(currentState.options.debug, `Chain: +${prop} \u2192`, newState.modifiers);
652
+ return _createProxy(newState);
556
653
  }
557
654
  if (prop === "in") {
558
655
  return (scopes) => {
559
- const nextScopes = [...normalizeScopes(currentState.scopes), ...normalizeScopes(scopes)];
656
+ const nextScopes = [..._normalizeScopes(currentState.scopes), ..._normalizeScopes(scopes)];
560
657
  const newState = {
561
658
  ...currentState,
562
659
  scopes: nextScopes
563
660
  };
564
- return createProxy(newState);
661
+ return _createProxy(newState);
565
662
  };
566
663
  }
567
664
  if (prop === "setScopes") {
568
665
  return (scopes) => {
569
- registry.activeScopes = new Set(normalizeScopes(scopes));
666
+ registry.activeScopes = new Set(_normalizeScopes(scopes));
570
667
  };
571
668
  }
572
669
  if (prop === "enableScope") {
@@ -588,18 +685,18 @@ function createShortcutBuilder(options = {}) {
588
685
  return (scope) => registry.activeScopes.has(scope);
589
686
  }
590
687
  if (prop === "record") {
591
- return createRecorder(registry.options);
688
+ return _createRecorder(registry.options);
592
689
  }
593
690
  if (prop === "key") {
594
691
  return (key) => {
595
- const nextStep = buildComboString(currentState.modifiers, key);
692
+ const nextStep = _buildComboString(currentState.modifiers, key);
596
693
  const newState = {
597
694
  ...currentState,
598
695
  modifiers: {},
599
696
  steps: [...currentState.steps, nextStep]
600
697
  };
601
- debugLog(currentState.options.debug, `Chain: .key("${key}")`);
602
- return createProxy(newState);
698
+ _debugLog(currentState.options.debug, `Chain: .key("${key}")`);
699
+ return _createProxy(newState);
603
700
  };
604
701
  }
605
702
  if (prop === "then") {
@@ -612,8 +709,8 @@ function createShortcutBuilder(options = {}) {
612
709
  ...currentState,
613
710
  steps: [...currentState.steps, nextStep]
614
711
  };
615
- debugLog(currentState.options.debug, `Chain: .then("${nextStep}")`);
616
- return createProxy(newState);
712
+ _debugLog(currentState.options.debug, `Chain: .then("${nextStep}")`);
713
+ return _createProxy(newState);
617
714
  };
618
715
  }
619
716
  if (prop === "except") {
@@ -622,19 +719,19 @@ function createShortcutBuilder(options = {}) {
622
719
  ...currentState,
623
720
  except: condition
624
721
  };
625
- debugLog(currentState.options.debug, "Chain: .except()", condition);
626
- return createProxy(newState);
722
+ _debugLog(currentState.options.debug, "Chain: .except()", condition);
723
+ return _createProxy(newState);
627
724
  };
628
725
  }
629
726
  if (prop === "on") {
630
727
  return (handler, handlerOptions) => {
631
- return createBinding(currentState, handler, handlerOptions, registry);
728
+ return _createBinding(currentState, handler, handlerOptions, registry);
632
729
  };
633
730
  }
634
731
  if (prop === "handle") {
635
732
  return (opts) => {
636
733
  const { handler, ...rest } = opts;
637
- return createBinding(currentState, handler, rest, registry);
734
+ return _createBinding(currentState, handler, rest, registry);
638
735
  };
639
736
  }
640
737
  return void 0;
@@ -647,79 +744,17 @@ function createShortcutBuilder(options = {}) {
647
744
  options
648
745
  };
649
746
  return {
650
- builder: createProxy(initialState),
747
+ builder: _createProxy(initialState),
651
748
  registry
652
749
  };
653
750
  }
654
751
 
655
752
  // src/hook.ts
656
- function normalizeShortcutMapKeys(keys) {
657
- if (Array.isArray(keys)) {
658
- return keys.map((key) => key.trim()).filter(Boolean);
659
- }
660
- const trimmed = keys.trim();
661
- if (!trimmed) return [];
662
- if (trimmed.includes(" then ")) {
663
- return trimmed.split(/\s+then\s+/i).map((key) => key.trim()).filter(Boolean);
664
- }
665
- if (trimmed.includes(" ") && !trimmed.includes("+")) {
666
- return trimmed.split(/\s+/).map((key) => key.trim()).filter(Boolean);
667
- }
668
- return [trimmed];
669
- }
670
- function applyStep(builder, step) {
671
- const tokens = step.toLowerCase().split("+").map((token) => token.trim()).filter(Boolean);
672
- if (tokens.length === 0) {
673
- throw new Error("[useShortcutMap] Invalid step: empty shortcut step");
674
- }
675
- const key = tokens.pop();
676
- let chain = builder;
677
- for (const token of tokens) {
678
- if (token === "ctrl" || token === "control") {
679
- chain = chain.ctrl;
680
- continue;
681
- }
682
- if (token === "shift") {
683
- chain = chain.shift;
684
- continue;
685
- }
686
- if (token === "alt" || token === "option") {
687
- chain = chain.alt;
688
- continue;
689
- }
690
- if (token === "cmd" || token === "command" || token === "meta") {
691
- chain = chain.cmd;
692
- continue;
693
- }
694
- if (token === "mod") {
695
- chain = chain.mod;
696
- continue;
697
- }
698
- throw new Error(`[useShortcutMap] Unsupported modifier token "${token}" in step "${step}"`);
699
- }
700
- return chain.key(key);
701
- }
702
- function registerShortcutMap(builder, shortcutMap) {
703
- const results = {};
704
- for (const id of Object.keys(shortcutMap)) {
705
- const entry = shortcutMap[id];
706
- const steps = normalizeShortcutMapKeys(entry.keys);
707
- if (steps.length === 0) {
708
- throw new Error(`[useShortcutMap] Shortcut "${String(id)}" has no key steps`);
709
- }
710
- let chain = applyStep(builder, steps[0]);
711
- for (const step of steps.slice(1)) {
712
- chain = chain.then(step);
713
- }
714
- results[id] = chain.on(entry.handler, entry.options);
715
- }
716
- return results;
717
- }
718
753
  function useShortcut(options = {}) {
719
754
  const optionsRef = react.useRef(options);
720
755
  optionsRef.current = options;
721
756
  const { builder, registry } = react.useMemo(() => {
722
- return createShortcutBuilder(optionsRef.current);
757
+ return _createShortcutBuilder(optionsRef.current);
723
758
  }, []);
724
759
  react.useEffect(() => {
725
760
  registry.options = optionsRef.current;
@@ -727,59 +762,21 @@ function useShortcut(options = {}) {
727
762
  const scopes = Array.isArray(optionsRef.current.activeScopes) ? optionsRef.current.activeScopes : [optionsRef.current.activeScopes];
728
763
  registry.activeScopes = new Set(scopes.map((scope) => scope.trim()).filter(Boolean));
729
764
  }
730
- });
765
+ }, [registry, options]);
731
766
  react.useEffect(() => {
732
767
  return () => {
733
- registry.listeners.forEach((entry) => entry.unbind());
734
768
  registry.listeners.clear();
769
+ registry.firstStepIndex.clear();
770
+ registry.activeSequenceCombos.clear();
771
+ if (registry.listener && registry.listenerTarget) {
772
+ registry.listenerTarget.removeEventListener(registry.listenerEventType, registry.listener);
773
+ registry.listener = null;
774
+ registry.listenerTarget = null;
775
+ }
735
776
  };
736
777
  }, [registry]);
737
778
  return builder;
738
779
  }
739
- function useShortcutMap(shortcutMap, options = {}) {
740
- const $ = useShortcut(options);
741
- return registerShortcutMap($, shortcutMap);
742
- }
743
- function createShortcut(options = {}) {
744
- const { builder } = createShortcutBuilder(options);
745
- return builder;
746
- }
747
- function createShortcutMap(shortcutMap, options = {}) {
748
- const builder = createShortcut(options);
749
- return registerShortcutMap(builder, shortcutMap);
750
- }
751
- function createShortcutGroup() {
752
- const results = [];
753
- return {
754
- add: (...entries) => {
755
- results.push(...entries);
756
- },
757
- addMany: (entries) => {
758
- if (Array.isArray(entries)) {
759
- results.push(...entries);
760
- return;
761
- }
762
- results.push(...Object.values(entries));
763
- },
764
- unbindAll: () => {
765
- for (const entry of results) {
766
- entry.unbind();
767
- }
768
- results.length = 0;
769
- },
770
- clear: () => {
771
- results.length = 0;
772
- },
773
- getResults: () => [...results]
774
- };
775
- }
776
- function useShortcutGroup() {
777
- const groupRef = react.useRef(null);
778
- if (!groupRef.current) {
779
- groupRef.current = createShortcutGroup();
780
- }
781
- return groupRef.current;
782
- }
783
780
 
784
781
  exports.ModifierAliases = ModifierAliases;
785
782
  exports.ModifierDisplayOrder = ModifierDisplayOrder;
@@ -787,20 +784,12 @@ exports.ModifierDisplaySymbols = ModifierDisplaySymbols;
787
784
  exports.ModifierKey = ModifierKey;
788
785
  exports.Platform = Platform;
789
786
  exports.SpecialKeyMap = SpecialKeyMap;
790
- exports.createShortcut = createShortcut;
791
- exports.createShortcutGroup = createShortcutGroup;
792
- exports.createShortcutMap = createShortcutMap;
793
787
  exports.detectPlatform = detectPlatform;
794
788
  exports.formatShortcut = formatShortcut;
795
- exports.getModifierSymbols = getModifierSymbols;
796
- exports.getModifiersFromEvent = getModifiersFromEvent;
797
789
  exports.matchesAnyShortcut = matchesAnyShortcut;
798
790
  exports.matchesShortcut = matchesShortcut;
799
791
  exports.parseShortcut = parseShortcut;
800
792
  exports.parseShortcuts = parseShortcuts;
801
- exports.registerShortcutMap = registerShortcutMap;
802
793
  exports.useShortcut = useShortcut;
803
- exports.useShortcutGroup = useShortcutGroup;
804
- exports.useShortcutMap = useShortcutMap;
805
794
  //# sourceMappingURL=index.js.map
806
795
  //# sourceMappingURL=index.js.map