rx-hotkeys 2.4.1 → 2.5.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/hotkeys.d.ts CHANGED
@@ -34,15 +34,19 @@ type KeyCombinationTrigger = {
34
34
  } | StandardKey;
35
35
  export interface KeyCombinationConfig extends ShortcutConfigBase {
36
36
  /**
37
- * Defines the key or key combination.
38
- * Can be an object specifying the main `key` (from `StandardKey`) and optional
37
+ * Defines the key or key combination(s) that trigger the shortcut.
38
+ * Can be a single trigger or an array of triggers.
39
+ * Each trigger can be an object specifying the main `key` (from `StandardKey`) and optional
39
40
  * modifiers (`ctrlKey`, `altKey`, `shiftKey`, `metaKey`).
40
41
  * Example: `{ key: Keys.S, ctrlKey: true }` for Ctrl+S.
41
42
  *
42
- * Alternatively, for a simple key press without any modifiers, this can be
43
+ * Alternatively, for a simple key press without any modifiers, a trigger can be
43
44
  * a `StandardKey` directly.
44
45
  * Example: `Keys.Escape` for the Escape key. When using this shorthand,
45
46
  * it implies that no modifier keys (Ctrl, Alt, Shift, Meta) should be active.
47
+ *
48
+ * To define multiple triggers for the same action:
49
+ * Example: `keys: [Keys.Enter, { key: Keys.Space, ctrlKey: true }]`
46
50
  */
47
51
  keys: KeyCombinationTrigger | KeyCombinationTrigger[];
48
52
  }
@@ -95,7 +99,7 @@ export declare class Hotkeys {
95
99
  * will be active and can be triggered.
96
100
  * @param contextName - The name of the context (e.g., "modal", "editor", "global").
97
101
  * Pass `null` to activate shortcuts with no context or to deactivate context-specific shortcuts.
98
- * @returns boolean
102
+ * @returns `true` if the context was changed, `false` if the new context was the same as the current one.
99
103
  */
100
104
  setContext(contextName: string | null): boolean;
101
105
  /**
@@ -109,6 +113,12 @@ export declare class Hotkeys {
109
113
  * @param enable - True to enable debug logs, false to disable.
110
114
  */
111
115
  setDebugMode(enable: boolean): void;
116
+ /**
117
+ * Checks if a shortcut with the given ID is currently registered and active.
118
+ * @param id - The unique ID of the shortcut to check.
119
+ * @returns True if a shortcut with the specified ID exists, false otherwise.
120
+ */
121
+ hasShortcut(id: string): boolean;
112
122
  /**
113
123
  * An Observable that emits the new context name (or null) whenever the active context changes.
114
124
  * This allows external parts of the application to react to context transitions.
@@ -129,11 +139,20 @@ export declare class Hotkeys {
129
139
  */
130
140
  get onContextChange$(): Observable<string | null>;
131
141
  /**
132
- * Checks if a shortcut with the given ID is currently registered and active.
133
- * @param id - The unique ID of the shortcut to check.
134
- * @returns True if a shortcut with the specified ID exists, false otherwise.
142
+ * Compares two sequences of StandardKey arrays to see if they are identical.
143
+ * @param seq1 - The first sequence array.
144
+ * @param seq2 - The second sequence array.
145
+ * @returns True if the sequences are identical, false otherwise.
135
146
  */
136
- hasShortcut(id: string): boolean;
147
+ private _areSequencesIdentical;
148
+ /**
149
+ * Checks if a given KeyCombinationConfig matches a given KeyboardEvent.
150
+ * This is used internally for priority checking.
151
+ * @param shortcutConfig The KeyCombinationConfig to check.
152
+ * @param event The KeyboardEvent to match against.
153
+ * @returns True if the shortcutConfig matches the event, false otherwise.
154
+ */
155
+ private _shortcutMatchesEvent;
137
156
  private filterByContext;
138
157
  private _registerShortcut;
139
158
  /**
@@ -162,10 +181,10 @@ export declare class Hotkeys {
162
181
  * callback: () => console.log("File saved!"),
163
182
  * context: "editor"
164
183
  * });
165
- * // For just the Escape key
184
+ * // For just the Escape key, or Ctrl+Space
166
185
  * keyManager.addCombination({
167
186
  * id: "closeModal",
168
- * keys: Keys.Escape, // Shorthand syntax
187
+ * keys: [Keys.Escape, {key: Keys.Space, ctrlKey: true}],
169
188
  * callback: () => console.log("Modal closed!")
170
189
  * });
171
190
  * ```
@@ -205,7 +224,7 @@ export declare class Hotkeys {
205
224
  * This can be useful for displaying available shortcuts to the user or for debugging.
206
225
  * @returns An array of objects, where each object represents an active shortcut
207
226
  * and includes its `id`, `description` (if provided), `context` (if any),
208
- * and `type` ("combination" or "sequence").
227
+ * and `type` (from `ShortcutTypes` enum).
209
228
  */
210
229
  getActiveShortcuts(): {
211
230
  id: string;
@@ -1 +1 @@
1
- {"version":3,"file":"hotkeys.d.ts","sourceRoot":"","sources":["../src/hotkeys.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"hotkeys.d.ts","sourceRoot":"","sources":["../src/hotkeys.ts"],"names":[],"mappings":"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"}
package/dist/hotkeys.js CHANGED
@@ -5,6 +5,12 @@ export var ShortcutTypes;
5
5
  ShortcutTypes["Combination"] = "combination";
6
6
  ShortcutTypes["Sequence"] = "sequence";
7
7
  })(ShortcutTypes || (ShortcutTypes = {}));
8
+ var EmitStates;
9
+ (function (EmitStates) {
10
+ EmitStates[EmitStates["Emit"] = 0] = "Emit";
11
+ EmitStates[EmitStates["Ignore"] = 1] = "Ignore";
12
+ EmitStates[EmitStates["InProgress"] = 2] = "InProgress";
13
+ })(EmitStates || (EmitStates = {}));
8
14
  // --- Helper function to compare keys ---
9
15
  /**
10
16
  * Compares a browser event's key with a configured key.
@@ -21,12 +27,6 @@ function compareKey(eventKey, configuredKey) {
21
27
  return eventKey === configuredKey;
22
28
  }
23
29
  // --- Hotkeys Library ---
24
- var EmitStates;
25
- (function (EmitStates) {
26
- EmitStates[EmitStates["Emit"] = 0] = "Emit";
27
- EmitStates[EmitStates["Ignore"] = 1] = "Ignore";
28
- EmitStates[EmitStates["InProgress"] = 2] = "InProgress";
29
- })(EmitStates || (EmitStates = {}));
30
30
  /**
31
31
  * Manages keyboard shortcuts for web applications.
32
32
  * Allows registration of single key combinations (e.g., Ctrl+S) and key sequences (e.g., g -> i).
@@ -63,7 +63,7 @@ export class Hotkeys {
63
63
  * will be active and can be triggered.
64
64
  * @param contextName - The name of the context (e.g., "modal", "editor", "global").
65
65
  * Pass `null` to activate shortcuts with no context or to deactivate context-specific shortcuts.
66
- * @returns boolean
66
+ * @returns `true` if the context was changed, `false` if the new context was the same as the current one.
67
67
  */
68
68
  setContext(contextName) {
69
69
  const currentContext = this.activeContext$.getValue();
@@ -72,14 +72,14 @@ export class Hotkeys {
72
72
  // Optional: Log that no change is happening, or simply do nothing.
73
73
  console.log(`${Hotkeys.LOG_PREFIX} setContext called with the same context "${contextName}". No change made.`);
74
74
  }
75
- return false; // Context is the same, so no further action is needed.
75
+ return false; // Context was NOT updated
76
76
  }
77
77
  // If we reach here, the context is actually changing.
78
78
  if (this.debugMode) {
79
79
  console.log(`${Hotkeys.LOG_PREFIX} Context changed from "${currentContext}" to "${contextName}".`);
80
80
  }
81
81
  this.activeContext$.next(contextName);
82
- return true;
82
+ return true; // Context WAS updated
83
83
  }
84
84
  /**
85
85
  * Gets the current active context.
@@ -94,17 +94,25 @@ export class Hotkeys {
94
94
  * @param enable - True to enable debug logs, false to disable.
95
95
  */
96
96
  setDebugMode(enable) {
97
- if (this.debugMode === enable) { // Check if state is actually changing
98
- return; // If no change, do nothing (no log)
97
+ if (this.debugMode === enable) {
98
+ return;
99
99
  }
100
- this.debugMode = enable; // Set the new state
101
- if (enable) { // Log based on the NEW state after a change
100
+ this.debugMode = enable;
101
+ if (enable) {
102
102
  console.log(`${Hotkeys.LOG_PREFIX} Debug mode enabled.`);
103
103
  }
104
104
  else {
105
105
  console.log(`${Hotkeys.LOG_PREFIX} Debug mode disabled.`);
106
106
  }
107
107
  }
108
+ /**
109
+ * Checks if a shortcut with the given ID is currently registered and active.
110
+ * @param id - The unique ID of the shortcut to check.
111
+ * @returns True if a shortcut with the specified ID exists, false otherwise.
112
+ */
113
+ hasShortcut(id) {
114
+ return this.activeShortcuts.has(id);
115
+ }
108
116
  /**
109
117
  * An Observable that emits the new context name (or null) whenever the active context changes.
110
118
  * This allows external parts of the application to react to context transitions.
@@ -127,18 +135,72 @@ export class Hotkeys {
127
135
  return this.activeContext$.asObservable();
128
136
  }
129
137
  /**
130
- * Checks if a shortcut with the given ID is currently registered and active.
131
- * @param id - The unique ID of the shortcut to check.
132
- * @returns True if a shortcut with the specified ID exists, false otherwise.
138
+ * Compares two sequences of StandardKey arrays to see if they are identical.
139
+ * @param seq1 - The first sequence array.
140
+ * @param seq2 - The second sequence array.
141
+ * @returns True if the sequences are identical, false otherwise.
133
142
  */
134
- hasShortcut(id) {
135
- return this.activeShortcuts.has(id);
143
+ _areSequencesIdentical(seq1, seq2) {
144
+ if (seq1.length !== seq2.length) {
145
+ return false;
146
+ }
147
+ for (let i = 0; i < seq1.length; i++) {
148
+ if (seq1[i] !== seq2[i]) { // Direct comparison for canonical StandardKey values
149
+ return false;
150
+ }
151
+ }
152
+ return true;
153
+ }
154
+ /**
155
+ * Checks if a given KeyCombinationConfig matches a given KeyboardEvent.
156
+ * This is used internally for priority checking.
157
+ * @param shortcutConfig The KeyCombinationConfig to check.
158
+ * @param event The KeyboardEvent to match against.
159
+ * @returns True if the shortcutConfig matches the event, false otherwise.
160
+ */
161
+ _shortcutMatchesEvent(shortcutConfig, event) {
162
+ const keyTriggers = Array.isArray(shortcutConfig.keys) ? shortcutConfig.keys : [shortcutConfig.keys];
163
+ for (const keyInput of keyTriggers) {
164
+ let configuredMainKey;
165
+ let ctrlKeyConfig;
166
+ let altKeyConfig;
167
+ let shiftKeyConfig;
168
+ let metaKeyConfig;
169
+ if (typeof keyInput === "string") {
170
+ if (keyInput === "")
171
+ continue; // Invalid trigger, skip
172
+ configuredMainKey = keyInput;
173
+ ctrlKeyConfig = false;
174
+ altKeyConfig = false;
175
+ shiftKeyConfig = false;
176
+ metaKeyConfig = false;
177
+ }
178
+ else {
179
+ if (!keyInput.key || keyInput.key === "")
180
+ continue; // Invalid trigger, skip
181
+ configuredMainKey = keyInput.key;
182
+ ctrlKeyConfig = keyInput.ctrlKey;
183
+ altKeyConfig = keyInput.altKey;
184
+ shiftKeyConfig = keyInput.shiftKey;
185
+ metaKeyConfig = keyInput.metaKey;
186
+ }
187
+ const keyMatch = compareKey(event.key, configuredMainKey);
188
+ if (!keyMatch)
189
+ continue;
190
+ const ctrlMatch = (ctrlKeyConfig === undefined) ? true : (event.ctrlKey === ctrlKeyConfig);
191
+ const altMatch = (altKeyConfig === undefined) ? true : (event.altKey === altKeyConfig);
192
+ const shiftMatch = (shiftKeyConfig === undefined) ? true : (event.shiftKey === shiftKeyConfig);
193
+ const metaMatch = (metaKeyConfig === undefined) ? true : (event.metaKey === metaKeyConfig);
194
+ if (ctrlMatch && altMatch && shiftMatch && metaMatch) {
195
+ return true;
196
+ }
197
+ }
198
+ return false;
136
199
  }
137
200
  filterByContext(source$, context) {
138
201
  return source$.pipe(withLatestFrom(this.activeContext$), filter(([/* event */ , activeCtx]) => context == null || context === activeCtx), map(([event, /* _activeCtx */]) => event));
139
202
  }
140
- _registerShortcut(config, subscription, type, // Changed to use Enum
141
- detailsForLog) {
203
+ _registerShortcut(config, subscription, type, detailsForLog) {
142
204
  const existingShortcut = this.activeShortcuts.get(config.id);
143
205
  if (existingShortcut) {
144
206
  console.warn(`${Hotkeys.LOG_PREFIX} Shortcut with ID "${config.id}" already exists. It will be overwritten.`);
@@ -163,11 +225,18 @@ export class Hotkeys {
163
225
  console.warn(`${Hotkeys.LOG_PREFIX} Invalid key (shorthand) in shortcut "${shortcutId}". Key string must not be empty.`);
164
226
  return null;
165
227
  }
166
- return { configuredMainKey: keyInput, ctrlKeyConfig: false, altKeyConfig: false, shiftKeyConfig: false, metaKeyConfig: false, logDetails: `key: "${keyInput}" (no mods)` };
228
+ return {
229
+ configuredMainKey: keyInput,
230
+ ctrlKeyConfig: false,
231
+ altKeyConfig: false,
232
+ shiftKeyConfig: false,
233
+ metaKeyConfig: false,
234
+ logDetails: `key: "${keyInput}" (no mods)`,
235
+ };
167
236
  }
168
237
  else {
169
- if (!keyInput.key || keyInput.key === "") {
170
- console.warn(`${Hotkeys.LOG_PREFIX} Invalid "key" property in shortcut "${shortcutId}". Key must be a non-empty value from Keys.`);
238
+ if (!keyInput.key || typeof keyInput.key !== "string" || keyInput.key === "") {
239
+ console.warn(`${Hotkeys.LOG_PREFIX} Invalid "key" property in shortcut "${shortcutId}". Key must be a non-empty string value from Keys.`);
171
240
  return null;
172
241
  }
173
242
  const logDetails = `key: "${keyInput.key}"` +
@@ -175,7 +244,14 @@ export class Hotkeys {
175
244
  (keyInput.altKey !== undefined ? `, alt: ${keyInput.altKey}` : "") +
176
245
  (keyInput.shiftKey !== undefined ? `, shift: ${keyInput.shiftKey}` : "") +
177
246
  (keyInput.metaKey !== undefined ? `, meta: ${keyInput.metaKey}` : "");
178
- return { configuredMainKey: keyInput.key, ctrlKeyConfig: keyInput.ctrlKey, altKeyConfig: keyInput.altKey, shiftKeyConfig: keyInput.shiftKey, metaKeyConfig: keyInput.metaKey, logDetails };
247
+ return {
248
+ configuredMainKey: keyInput.key,
249
+ ctrlKeyConfig: keyInput.ctrlKey,
250
+ altKeyConfig: keyInput.altKey,
251
+ shiftKeyConfig: keyInput.shiftKey,
252
+ metaKeyConfig: keyInput.metaKey,
253
+ logDetails,
254
+ };
179
255
  }
180
256
  }
181
257
  /**
@@ -196,10 +272,10 @@ export class Hotkeys {
196
272
  * callback: () => console.log("File saved!"),
197
273
  * context: "editor"
198
274
  * });
199
- * // For just the Escape key
275
+ * // For just the Escape key, or Ctrl+Space
200
276
  * keyManager.addCombination({
201
277
  * id: "closeModal",
202
- * keys: Keys.Escape, // Shorthand syntax
278
+ * keys: [Keys.Escape, {key: Keys.Space, ctrlKey: true}],
203
279
  * callback: () => console.log("Modal closed!")
204
280
  * });
205
281
  * ```
@@ -229,7 +305,30 @@ export class Hotkeys {
229
305
  const shiftMatch = (shiftKeyConfig === undefined) ? true : (event.shiftKey === shiftKeyConfig);
230
306
  const metaMatch = (metaKeyConfig === undefined) ? true : (event.metaKey === metaKeyConfig);
231
307
  return ctrlMatch && altMatch && shiftMatch && metaMatch;
232
- }), filter(event => compareKey(event.key, configuredMainKey)));
308
+ }), filter(event => compareKey(event.key, configuredMainKey)),
309
+ // New filter for priority: Specific context > Global context
310
+ filter(event => {
311
+ if (config.context != null) { // This shortcut is NOT global
312
+ return true;
313
+ }
314
+ // This shortcut IS global. Check for specific overrides.
315
+ const currentSpecificContext = this.activeContext$.getValue();
316
+ if (currentSpecificContext == null) { // No specific context active
317
+ return true;
318
+ }
319
+ for (const [, otherAS] of this.activeShortcuts) {
320
+ if (otherAS.config.id !== config.id &&
321
+ 'keys' in otherAS.config &&
322
+ otherAS.config.context === currentSpecificContext &&
323
+ this._shortcutMatchesEvent(otherAS.config, event)) {
324
+ if (this.debugMode) {
325
+ console.log(`${Hotkeys.LOG_PREFIX} Global shortcut "${config.id}" (key: "${event.key}") suppressed by specific context shortcut "${otherAS.config.id}".`);
326
+ }
327
+ return false; // Suppress global
328
+ }
329
+ }
330
+ return true; // Global can proceed
331
+ }));
233
332
  observables.push(stream);
234
333
  }
235
334
  if (observables.length === 0) {
@@ -320,7 +419,7 @@ export class Hotkeys {
320
419
  if (compareKey(event.key, configuredSequence[nextExpectedKeyIndex])) {
321
420
  const newMatchedEvents = [...matchedEvents, event];
322
421
  if (newMatchedEvents.length === sequenceLength) {
323
- if (this.debugMode && !acc.emitState)
422
+ if (this.debugMode && acc.emitState !== EmitStates.Emit)
324
423
  console.log(`${Hotkeys.LOG_PREFIX} Sequence "${id}" (timeout: ${sequenceTimeoutMs}ms) matched.`);
325
424
  return { matchedEvents: newMatchedEvents, lastEventTime: currentTime, emitState: EmitStates.Emit };
326
425
  }
@@ -350,7 +449,28 @@ export class Hotkeys {
350
449
  return events.every((event, index) => compareKey(event.key, configuredSequence[index]));
351
450
  }));
352
451
  }
353
- const finalShortcut$ = shortcut$.pipe(tap((events) => {
452
+ const finalShortcutWithPriority$ = shortcut$.pipe(filter((completedEvents) => {
453
+ if (config.context != null) { // This sequence is NOT global
454
+ return true;
455
+ }
456
+ // This sequence IS global. Check for specific overrides.
457
+ const currentSpecificContext = this.activeContext$.getValue();
458
+ if (currentSpecificContext == null) { // No specific context active
459
+ return true;
460
+ }
461
+ for (const [, otherAS] of this.activeShortcuts) {
462
+ if (otherAS.config.id !== config.id &&
463
+ "sequence" in otherAS.config &&
464
+ otherAS.config.context === currentSpecificContext &&
465
+ this._areSequencesIdentical(config.sequence, otherAS.config.sequence)) {
466
+ if (this.debugMode) {
467
+ console.log(`${Hotkeys.LOG_PREFIX} Global sequence shortcut "${config.id}" suppressed by identical specific-context shortcut "${otherAS.config.id}".`);
468
+ }
469
+ return false; // Suppress global
470
+ }
471
+ }
472
+ return true; // Global sequence can proceed
473
+ }), tap((events) => {
354
474
  if (this.debugMode) {
355
475
  const timeoutInfo = (sequenceTimeoutMs && sequenceTimeoutMs > 0) ? ` (with timeout logic)` : ` (no timeout logic)`;
356
476
  const preventAction = preventDefault ? ", preventing default for last event" : "";
@@ -363,7 +483,7 @@ export class Hotkeys {
363
483
  console.error(`${Hotkeys.LOG_PREFIX} Error in sequence stream for shortcut "${id}":`, err);
364
484
  return EMPTY;
365
485
  }));
366
- const subscription = finalShortcut$.subscribe((events) => {
486
+ const subscription = finalShortcutWithPriority$.subscribe((events) => {
367
487
  try {
368
488
  // Ensure callback receives the last event of the sequence, similar to combination.
369
489
  if (events.length > 0)
@@ -374,7 +494,7 @@ export class Hotkeys {
374
494
  }
375
495
  });
376
496
  const logDetails = `Sequence: ${sequence.join(" -> ")}${sequenceTimeoutMs && sequenceTimeoutMs > 0 ? ` (timeout: ${sequenceTimeoutMs}ms)` : ""}`;
377
- return this._registerShortcut(config, subscription, ShortcutTypes.Sequence, logDetails); // Use Enum
497
+ return this._registerShortcut(config, subscription, ShortcutTypes.Sequence, logDetails);
378
498
  }
379
499
  /**
380
500
  * Removes a registered shortcut by its ID.
@@ -400,7 +520,7 @@ export class Hotkeys {
400
520
  * This can be useful for displaying available shortcuts to the user or for debugging.
401
521
  * @returns An array of objects, where each object represents an active shortcut
402
522
  * and includes its `id`, `description` (if provided), `context` (if any),
403
- * and `type` ("combination" or "sequence").
523
+ * and `type` (from `ShortcutTypes` enum).
404
524
  */
405
525
  getActiveShortcuts() {
406
526
  const shortcuts = [];
@@ -409,7 +529,7 @@ export class Hotkeys {
409
529
  id,
410
530
  description: activeShortcut.config.description,
411
531
  context: activeShortcut.config.context,
412
- type: ("sequence" in activeShortcut.config) ? ShortcutTypes.Sequence : ShortcutTypes.Combination // Use Enum values
532
+ type: ("sequence" in activeShortcut.config) ? ShortcutTypes.Sequence : ShortcutTypes.Combination
413
533
  });
414
534
  }
415
535
  return shortcuts;
@@ -425,7 +545,7 @@ export class Hotkeys {
425
545
  console.log(`${Hotkeys.LOG_PREFIX} Destroying library instance and unsubscribing all shortcuts.`);
426
546
  this.activeShortcuts.forEach(shortcut => shortcut.subscription.unsubscribe());
427
547
  this.activeShortcuts.clear();
428
- this.activeContext$.complete(); // Complete the BehaviorSubject to release its resources
548
+ this.activeContext$.complete();
429
549
  if (this.debugMode)
430
550
  console.log(`${Hotkeys.LOG_PREFIX} Library destroyed.`);
431
551
  }
@@ -257,7 +257,7 @@ describe("Hotkeys Library (Node.js Test Runner)", () => {
257
257
  const result = keyManager.addCombination(config);
258
258
  assert.strictEqual(result, undefined, "Should return undefined for null key");
259
259
  assert.strictEqual(consoleWarnMock.mock.calls.length, 1);
260
- assert.ok(consoleWarnMock.mock.calls[0].arguments[0].includes(`Invalid "key" property in shortcut "nullKey". Key must be a non-empty value from Keys.`));
260
+ assert.ok(consoleWarnMock.mock.calls[0].arguments[0].includes(`Invalid "key" property in shortcut "nullKey". Key must be a non-empty string value from Keys.`));
261
261
  });
262
262
  it("should pass the KeyboardEvent to the callback", () => {
263
263
  const config = { id: "eventPass", keys: { key: Keys.E }, callback: mockCallback };
@@ -442,6 +442,84 @@ describe("Hotkeys Library (Node.js Test Runner)", () => {
442
442
  });
443
443
  });
444
444
  });
445
+ describe("Context Priority (Specific > Global)", () => {
446
+ let globalCallback;
447
+ let specificCallback;
448
+ beforeEach(() => {
449
+ globalCallback = createMockFn();
450
+ specificCallback = createMockFn();
451
+ // Global shortcut: Ctrl+G
452
+ keyManager.addCombination({
453
+ id: "globalCtrlG",
454
+ keys: { key: Keys.G, ctrlKey: true },
455
+ callback: globalCallback,
456
+ context: null // Explicitly global
457
+ });
458
+ // Specific context shortcut: Ctrl+G in "editor" context
459
+ keyManager.addCombination({
460
+ id: "editorCtrlG",
461
+ keys: { key: Keys.G, ctrlKey: true },
462
+ callback: specificCallback,
463
+ context: "editor"
464
+ });
465
+ });
466
+ it("should only trigger specific context callback when specific context is active", () => {
467
+ keyManager.setContext("editor");
468
+ dispatchKeyEvent(Keys.G, { ctrlKey: true });
469
+ assert.strictEqual(specificCallback.calledCount, 1, "Specific callback should have been called");
470
+ assert.strictEqual(globalCallback.calledCount, 0, "Global callback should NOT have been called");
471
+ });
472
+ it("should only trigger global callback when no specific context is active (or context doesn't match)", () => {
473
+ keyManager.setContext(null); // No specific context
474
+ dispatchKeyEvent(Keys.G, { ctrlKey: true });
475
+ assert.strictEqual(specificCallback.calledCount, 0, "Specific callback should NOT have been called");
476
+ assert.strictEqual(globalCallback.calledCount, 1, "Global callback should have been called");
477
+ globalCallback.mockClear();
478
+ keyManager.setContext("anotherContext"); // Different specific context
479
+ dispatchKeyEvent(Keys.G, { ctrlKey: true });
480
+ assert.strictEqual(specificCallback.calledCount, 0, `Specific callback should NOT have been called for "anotherContext"`);
481
+ assert.strictEqual(globalCallback.calledCount, 1, `Global callback should have been called when in "anotherContext"`);
482
+ });
483
+ });
484
+ describe("Sequence Context Priority (Specific > Global)", () => {
485
+ let globalSeqCallback;
486
+ let specificSeqCallback;
487
+ const testSequence = [Keys.G, Keys.I];
488
+ beforeEach(() => {
489
+ globalSeqCallback = createMockFn();
490
+ specificSeqCallback = createMockFn();
491
+ keyManager.addSequence({
492
+ id: "globalGI",
493
+ sequence: testSequence,
494
+ callback: globalSeqCallback,
495
+ context: null // Global
496
+ });
497
+ keyManager.addSequence({
498
+ id: "editorGI",
499
+ sequence: testSequence,
500
+ callback: specificSeqCallback,
501
+ context: "editor" // Specific
502
+ });
503
+ });
504
+ it("should only trigger specific context sequence callback when specific context is active", () => {
505
+ keyManager.setContext("editor");
506
+ testSequence.forEach(key => dispatchKeyEvent(key));
507
+ assert.strictEqual(specificSeqCallback.calledCount, 1, "Specific sequence callback should have been called");
508
+ assert.strictEqual(globalSeqCallback.calledCount, 0, "Global sequence callback should NOT have been called");
509
+ });
510
+ it("should only trigger global sequence callback when no specific context is active (or context doesn't match)", () => {
511
+ keyManager.setContext(null); // No specific context
512
+ testSequence.forEach(key => dispatchKeyEvent(key));
513
+ assert.strictEqual(specificSeqCallback.calledCount, 0, "Specific sequence callback should NOT have been called");
514
+ assert.strictEqual(globalSeqCallback.calledCount, 1, "Global sequence callback should have been called");
515
+ globalSeqCallback.mockClear();
516
+ specificSeqCallback.mockClear(); // Clear for next part of test
517
+ keyManager.setContext("anotherContext"); // Different specific context
518
+ testSequence.forEach(key => dispatchKeyEvent(key));
519
+ assert.strictEqual(specificSeqCallback.calledCount, 0, `Specific sequence callback should NOT have been called for "anotherContext"`);
520
+ assert.strictEqual(globalSeqCallback.calledCount, 1, `Global sequence callback should have been called when in "anotherContext"`);
521
+ });
522
+ });
445
523
  describe("addSequence", () => {
446
524
  it("should trigger callback for a simple key sequence", () => {
447
525
  const config = { id: "seqGI", sequence: [Keys.G, Keys.I], callback: mockCallback };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rx-hotkeys",
3
- "version": "2.4.1",
3
+ "version": "2.5.0",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "build": "tsc",