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