rx-hotkeys 4.2.0 → 5.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/README.md +5 -0
- package/dist/core/hotkeys.d.ts +37 -66
- package/dist/core/hotkeys.d.ts.map +1 -1
- package/dist/core/hotkeys.js +232 -145
- package/dist/core/hotkeys.js.map +1 -1
- package/dist/core/hotkeys.test.js +165 -4
- package/dist/core/hotkeys.test.js.map +1 -1
- package/dist/core/index.d.ts +2 -2
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js.map +1 -1
- package/dist/core/keys.d.ts.map +1 -1
- package/dist/core/testutils.d.ts +1 -1
- package/dist/core/testutils.js.map +1 -1
- package/dist/integrations/react/index.d.ts +2 -2
- package/dist/integrations/react/provider.d.ts +2 -2
- package/dist/integrations/react/provider.d.ts.map +1 -1
- package/dist/integrations/react/provider.js.map +1 -1
- package/dist/integrations/react/useHotkeys.d.ts +1 -1
- package/dist/integrations/react/useHotkeys.d.ts.map +1 -1
- package/dist/integrations/react/useHotkeys.js +6 -2
- package/dist/integrations/react/useHotkeys.js.map +1 -1
- package/dist/integrations/react/useIsomorphicLayoutEffect.d.ts +2 -2
- package/dist/integrations/react/useIsomorphicLayoutEffect.d.ts.map +1 -1
- package/package.json +4 -4
package/dist/core/hotkeys.js
CHANGED
|
@@ -1,17 +1,16 @@
|
|
|
1
|
-
|
|
1
|
+
var _a;
|
|
2
|
+
import { fromEvent, BehaviorSubject, EMPTY, Observable, filter, map, bufferCount, withLatestFrom, tap, catchError, scan, merge, Subject, takeUntil, share, distinctUntilChanged, combineLatest, } from "rxjs";
|
|
2
3
|
import { Keys, KeyAliases } from "./keys.js";
|
|
3
4
|
// --- Enums, Interfaces and Types ---
|
|
4
|
-
export
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
EmitStates[EmitStates["InProgress"] = 2] = "InProgress";
|
|
14
|
-
})(EmitStates || (EmitStates = {}));
|
|
5
|
+
export const ShortcutTypes = Object.freeze({
|
|
6
|
+
Combination: "combination",
|
|
7
|
+
Sequence: "sequence",
|
|
8
|
+
});
|
|
9
|
+
const EmitStates = Object.freeze({
|
|
10
|
+
Emit: "Emit",
|
|
11
|
+
Ignore: "Ignore",
|
|
12
|
+
InProgress: "InProgress",
|
|
13
|
+
});
|
|
15
14
|
// --- Helper function to compare keys ---
|
|
16
15
|
/**
|
|
17
16
|
* Compares a browser event's key with a configured key.
|
|
@@ -50,30 +49,31 @@ function normalizeKey(key) {
|
|
|
50
49
|
return finalKey || null;
|
|
51
50
|
}
|
|
52
51
|
// --- Hotkeys Library ---
|
|
52
|
+
const NO_OVERRIDE = Symbol("No Hotkey Override");
|
|
53
53
|
/**
|
|
54
54
|
* Manages keyboard shortcuts for web applications.
|
|
55
55
|
* Allows registration of single key combinations (e.g., Ctrl+S) and key sequences (e.g., g -> i).
|
|
56
56
|
* Supports contexts to enable/disable shortcuts based on application state.
|
|
57
57
|
*/
|
|
58
58
|
export class Hotkeys {
|
|
59
|
-
static KEYDOWN_EVENT = "keydown";
|
|
60
|
-
static KEYUP_EVENT = "keyup";
|
|
61
|
-
static LOG_PREFIX = "Hotkeys:";
|
|
59
|
+
static #KEYDOWN_EVENT = "keydown";
|
|
60
|
+
static #KEYUP_EVENT = "keyup";
|
|
61
|
+
static #LOG_PREFIX = "Hotkeys:";
|
|
62
62
|
// --- Sentinel value for no override ---
|
|
63
|
-
static NO_OVERRIDE =
|
|
63
|
+
static #NO_OVERRIDE = NO_OVERRIDE;
|
|
64
64
|
// NEW: A unified stream cache that handles different listener options.
|
|
65
|
-
eventStreams;
|
|
66
|
-
activeShortcuts;
|
|
67
|
-
debugMode;
|
|
65
|
+
#eventStreams;
|
|
66
|
+
#activeShortcuts;
|
|
67
|
+
#debugMode;
|
|
68
68
|
// --- Separate states for stack and override ---
|
|
69
|
-
contextStack$;
|
|
70
|
-
overrideContext$;
|
|
69
|
+
#contextStack$;
|
|
70
|
+
#overrideContext$;
|
|
71
71
|
/**
|
|
72
72
|
* An Observable that emits the new active context name (or null) whenever it changes.
|
|
73
73
|
* The active context is the override context if one is set, otherwise it's the context
|
|
74
74
|
* from the top of the stack.
|
|
75
75
|
*/
|
|
76
|
-
activeContext$;
|
|
76
|
+
#activeContext$;
|
|
77
77
|
/**
|
|
78
78
|
* Creates an instance of Hotkeys.
|
|
79
79
|
* @param initialContext - Optional initial context name. This forms the base of the context stack.
|
|
@@ -81,47 +81,47 @@ export class Hotkeys {
|
|
|
81
81
|
* @throws Error if not in a browser environment (i.e., `document` or `performance` is undefined).
|
|
82
82
|
*/
|
|
83
83
|
constructor(initialContext = null, debugMode = false) {
|
|
84
|
-
this
|
|
84
|
+
this.#debugMode = debugMode;
|
|
85
85
|
if (typeof document === "undefined" || typeof performance === "undefined") {
|
|
86
|
-
throw new Error(`${
|
|
86
|
+
throw new Error(`${_a.#LOG_PREFIX} Hotkeys can only be used in a browser environment.`);
|
|
87
87
|
}
|
|
88
|
-
this
|
|
89
|
-
this
|
|
88
|
+
this.#eventStreams = new WeakMap(); // Initialize the new unified cache
|
|
89
|
+
this.#activeShortcuts = new Map();
|
|
90
90
|
// The context stack is the source of truth for the active context.
|
|
91
|
-
this
|
|
92
|
-
this
|
|
91
|
+
this.#contextStack$ = new BehaviorSubject([initialContext]);
|
|
92
|
+
this.#overrideContext$ = new BehaviorSubject(_a.#NO_OVERRIDE);
|
|
93
93
|
// The public activeContext$ now correctly handles the sentinel value.
|
|
94
|
-
this
|
|
95
|
-
this
|
|
96
|
-
this
|
|
97
|
-
]).pipe(map(([overrideCtx, stackCtx]) => this
|
|
98
|
-
if (this
|
|
99
|
-
console.log(`${
|
|
94
|
+
this.#activeContext$ = combineLatest([
|
|
95
|
+
this.#overrideContext$,
|
|
96
|
+
this.#contextStack$.pipe(map(stack => stack.length > 0 ? stack[stack.length - 1] : null))
|
|
97
|
+
]).pipe(map(([overrideCtx, stackCtx]) => this.#resolveActiveContext(overrideCtx, stackCtx)), distinctUntilChanged());
|
|
98
|
+
if (this.#debugMode) {
|
|
99
|
+
console.log(`${_a.#LOG_PREFIX} Library initialized. Initial context: "${initialContext}". Debug mode: ${debugMode}.`);
|
|
100
100
|
// Optional: Log context changes for debugging
|
|
101
|
-
this
|
|
102
|
-
console.log(`${
|
|
101
|
+
this.#activeContext$.subscribe(newContext => {
|
|
102
|
+
console.log(`${_a.#LOG_PREFIX} Active context changed to: ${newContext}`);
|
|
103
103
|
});
|
|
104
104
|
}
|
|
105
105
|
}
|
|
106
106
|
/**
|
|
107
107
|
* Helper method to determine the active context based on override and stack.
|
|
108
108
|
*/
|
|
109
|
-
|
|
110
|
-
return overrideCtx !==
|
|
109
|
+
#resolveActiveContext(overrideCtx, stackCtx) {
|
|
110
|
+
return overrideCtx !== _a.#NO_OVERRIDE ? overrideCtx : stackCtx;
|
|
111
111
|
}
|
|
112
|
-
|
|
112
|
+
#normalizeAndParseTriggers(keys, shortcutId) {
|
|
113
113
|
const keyInputs = Array.isArray(keys) ? keys : [keys];
|
|
114
114
|
const parsedTriggers = [];
|
|
115
115
|
for (const input of keyInputs) {
|
|
116
116
|
let triggersToParse = [];
|
|
117
117
|
if (typeof input === "string" && input.length > 1 && input.includes("+")) {
|
|
118
|
-
triggersToParse.push(...this
|
|
118
|
+
triggersToParse.push(...this.#parseCombinationString(input));
|
|
119
119
|
}
|
|
120
120
|
else {
|
|
121
121
|
triggersToParse.push(input);
|
|
122
122
|
}
|
|
123
123
|
for (const trigger of triggersToParse) {
|
|
124
|
-
const parsed = this
|
|
124
|
+
const parsed = this.#parseKeyTrigger(trigger, shortcutId);
|
|
125
125
|
if (parsed) {
|
|
126
126
|
parsedTriggers.push({
|
|
127
127
|
key: parsed.configuredMainKey,
|
|
@@ -135,7 +135,7 @@ export class Hotkeys {
|
|
|
135
135
|
}
|
|
136
136
|
return parsedTriggers;
|
|
137
137
|
}
|
|
138
|
-
|
|
138
|
+
#normalizeSequence(sequence, shortcutId) {
|
|
139
139
|
const keyInputs = typeof sequence === "string" ? sequence.split("->") : sequence;
|
|
140
140
|
const results = [];
|
|
141
141
|
for (const keyStr of keyInputs) {
|
|
@@ -144,7 +144,7 @@ export class Hotkeys {
|
|
|
144
144
|
results.push(finalKey);
|
|
145
145
|
}
|
|
146
146
|
else {
|
|
147
|
-
console.warn(`${
|
|
147
|
+
console.warn(`${_a.#LOG_PREFIX} Could not parse key: "${keyStr}" in sequence for shortcut "${shortcutId}".`);
|
|
148
148
|
return null; // Fail fast if any key is invalid
|
|
149
149
|
}
|
|
150
150
|
}
|
|
@@ -153,7 +153,7 @@ export class Hotkeys {
|
|
|
153
153
|
/**
|
|
154
154
|
* [PRIVATE] Generates a unique key for the stream cache based on event type and options.
|
|
155
155
|
*/
|
|
156
|
-
|
|
156
|
+
#getStreamCacheKey(eventType, options) {
|
|
157
157
|
if (!options) {
|
|
158
158
|
return eventType;
|
|
159
159
|
}
|
|
@@ -170,19 +170,19 @@ export class Hotkeys {
|
|
|
170
170
|
* @param options The AddEventListenerOptions.
|
|
171
171
|
* @returns A shared Observable for the specified event.
|
|
172
172
|
*/
|
|
173
|
-
|
|
174
|
-
if (!this
|
|
175
|
-
this
|
|
173
|
+
#getEventStream(eventType, target, options) {
|
|
174
|
+
if (!this.#eventStreams.has(target)) {
|
|
175
|
+
this.#eventStreams.set(target, new Map());
|
|
176
176
|
}
|
|
177
|
-
const targetCache = this
|
|
178
|
-
const cacheKey = this
|
|
177
|
+
const targetCache = this.#eventStreams.get(target);
|
|
178
|
+
const cacheKey = this.#getStreamCacheKey(eventType, options);
|
|
179
179
|
if (!targetCache.has(cacheKey)) {
|
|
180
180
|
// Create the new stream with the specified options
|
|
181
181
|
const newStream = fromEvent(target, eventType, options).pipe(share());
|
|
182
182
|
targetCache.set(cacheKey, newStream);
|
|
183
|
-
if (this
|
|
183
|
+
if (this.#debugMode) {
|
|
184
184
|
const targetName = target === document ? "document" : `element "${target.id || target.tagName}"`;
|
|
185
|
-
console.log(`${
|
|
185
|
+
console.log(`${_a.#LOG_PREFIX} Created new shared listener for "${cacheKey}" on ${targetName}.`);
|
|
186
186
|
}
|
|
187
187
|
}
|
|
188
188
|
return targetCache.get(cacheKey);
|
|
@@ -193,18 +193,18 @@ export class Hotkeys {
|
|
|
193
193
|
* @returns A `restore` function that, when called, clears the override context, reverting to the stack.
|
|
194
194
|
*/
|
|
195
195
|
setContext(contextName) {
|
|
196
|
-
if (this
|
|
197
|
-
console.log(`${
|
|
196
|
+
if (this.#debugMode) {
|
|
197
|
+
console.log(`${_a.#LOG_PREFIX} Setting override context to: "${contextName}".`);
|
|
198
198
|
}
|
|
199
|
-
this
|
|
199
|
+
this.#overrideContext$.next(contextName);
|
|
200
200
|
const restore = () => {
|
|
201
201
|
// Only clear the override if it's still the one we set.
|
|
202
|
-
if (this
|
|
203
|
-
if (this
|
|
204
|
-
console.log(`${
|
|
202
|
+
if (this.#overrideContext$.getValue() === contextName) {
|
|
203
|
+
if (this.#debugMode) {
|
|
204
|
+
console.log(`${_a.#LOG_PREFIX} Restoring/clearing override context from: "${contextName}".`);
|
|
205
205
|
}
|
|
206
206
|
// Restore now sets the special "NO_OVERRIDE" value.
|
|
207
|
-
this
|
|
207
|
+
this.#overrideContext$.next(_a.#NO_OVERRIDE);
|
|
208
208
|
}
|
|
209
209
|
};
|
|
210
210
|
return restore;
|
|
@@ -215,7 +215,7 @@ export class Hotkeys {
|
|
|
215
215
|
* @returns The current context name as a string, or `null` if no context is set.
|
|
216
216
|
*/
|
|
217
217
|
getContext() {
|
|
218
|
-
console.warn(`${
|
|
218
|
+
console.warn(`${_a.#LOG_PREFIX} "getContext" is deprecated. Use "getActiveContext()" or subscribe to "onContextChange$" instead.`);
|
|
219
219
|
return this.getActiveContext();
|
|
220
220
|
}
|
|
221
221
|
/**
|
|
@@ -223,42 +223,42 @@ export class Hotkeys {
|
|
|
223
223
|
* @returns The current context name as a string, or `null` if no context is set.
|
|
224
224
|
*/
|
|
225
225
|
getActiveContext() {
|
|
226
|
-
const overrideCtx = this
|
|
227
|
-
const stack = this
|
|
226
|
+
const overrideCtx = this.#overrideContext$.getValue();
|
|
227
|
+
const stack = this.#contextStack$.getValue();
|
|
228
228
|
const stackCtx = stack.length > 0 ? stack[stack.length - 1] : null;
|
|
229
229
|
// Also uses the abstracted helper method.
|
|
230
|
-
return this
|
|
230
|
+
return this.#resolveActiveContext(overrideCtx, stackCtx);
|
|
231
231
|
}
|
|
232
232
|
/**
|
|
233
233
|
* Pushes a new context onto the context stack. It will become active if no override context is set.
|
|
234
234
|
* @param contextName The name of the context to enter (e.g., "modal", "editor").
|
|
235
235
|
*/
|
|
236
236
|
enterContext(contextName) {
|
|
237
|
-
const currentStack = this
|
|
237
|
+
const currentStack = this.#contextStack$.getValue();
|
|
238
238
|
const newStack = [...currentStack, contextName];
|
|
239
|
-
if (this
|
|
240
|
-
console.log(`${
|
|
239
|
+
if (this.#debugMode) {
|
|
240
|
+
console.log(`${_a.#LOG_PREFIX} Entering context: "${contextName}". New stack: [${newStack.join(", ")}]`);
|
|
241
241
|
}
|
|
242
|
-
this
|
|
242
|
+
this.#contextStack$.next(newStack);
|
|
243
243
|
}
|
|
244
244
|
/**
|
|
245
245
|
* Pops the current context from the stack.
|
|
246
246
|
* @returns The context that was just left from the stack, or `undefined` if at the base.
|
|
247
247
|
*/
|
|
248
248
|
leaveContext() {
|
|
249
|
-
const currentStack = this
|
|
249
|
+
const currentStack = this.#contextStack$.getValue();
|
|
250
250
|
if (currentStack.length <= 1) {
|
|
251
|
-
if (this
|
|
252
|
-
console.log(`${
|
|
251
|
+
if (this.#debugMode) {
|
|
252
|
+
console.log(`${_a.#LOG_PREFIX} Attempted to leave the base stack context. No change made.`);
|
|
253
253
|
}
|
|
254
254
|
return undefined; // Nothing was left
|
|
255
255
|
}
|
|
256
256
|
const leavingContext = currentStack[currentStack.length - 1];
|
|
257
257
|
const newStack = currentStack.slice(0, -1);
|
|
258
|
-
if (this
|
|
259
|
-
console.log(`${
|
|
258
|
+
if (this.#debugMode) {
|
|
259
|
+
console.log(`${_a.#LOG_PREFIX} Leaving context: "${leavingContext}". New stack: [${newStack.join(", ")}]`);
|
|
260
260
|
}
|
|
261
|
-
this
|
|
261
|
+
this.#contextStack$.next(newStack);
|
|
262
262
|
return leavingContext;
|
|
263
263
|
}
|
|
264
264
|
/**
|
|
@@ -267,15 +267,15 @@ export class Hotkeys {
|
|
|
267
267
|
* @param enable - True to enable debug logs, false to disable.
|
|
268
268
|
*/
|
|
269
269
|
setDebugMode(enable) {
|
|
270
|
-
if (this
|
|
270
|
+
if (this.#debugMode === enable) {
|
|
271
271
|
return;
|
|
272
272
|
}
|
|
273
|
-
this
|
|
273
|
+
this.#debugMode = enable;
|
|
274
274
|
if (enable) {
|
|
275
|
-
console.log(`${
|
|
275
|
+
console.log(`${_a.#LOG_PREFIX} Debug mode enabled.`);
|
|
276
276
|
}
|
|
277
277
|
else {
|
|
278
|
-
console.log(`${
|
|
278
|
+
console.log(`${_a.#LOG_PREFIX} Debug mode disabled.`);
|
|
279
279
|
}
|
|
280
280
|
}
|
|
281
281
|
/**
|
|
@@ -284,7 +284,7 @@ export class Hotkeys {
|
|
|
284
284
|
* @returns True if a shortcut with the specified ID exists, false otherwise.
|
|
285
285
|
*/
|
|
286
286
|
hasShortcut(id) {
|
|
287
|
-
return this
|
|
287
|
+
return this.#activeShortcuts.has(id);
|
|
288
288
|
}
|
|
289
289
|
/**
|
|
290
290
|
* An Observable that emits the new context name (or null) whenever the active context changes.
|
|
@@ -301,7 +301,7 @@ export class Hotkeys {
|
|
|
301
301
|
* ```
|
|
302
302
|
*/
|
|
303
303
|
get onContextChange$() {
|
|
304
|
-
return this
|
|
304
|
+
return this.#activeContext$;
|
|
305
305
|
}
|
|
306
306
|
/**
|
|
307
307
|
* Compares two sequences of StandardKey arrays to see if they are identical.
|
|
@@ -309,7 +309,7 @@ export class Hotkeys {
|
|
|
309
309
|
* @param seq2 - The second sequence array.
|
|
310
310
|
* @returns True if the sequences are identical, false otherwise.
|
|
311
311
|
*/
|
|
312
|
-
|
|
312
|
+
#areSequencesIdentical(seq1, seq2) {
|
|
313
313
|
if (seq1.length !== seq2.length) {
|
|
314
314
|
return false;
|
|
315
315
|
}
|
|
@@ -327,7 +327,7 @@ export class Hotkeys {
|
|
|
327
327
|
* @param event The KeyboardEvent to match against.
|
|
328
328
|
* @returns True if the shortcutConfig matches the event, false otherwise.
|
|
329
329
|
*/
|
|
330
|
-
|
|
330
|
+
#shortcutMatchesEvent(parsedTriggers, event) {
|
|
331
331
|
// It no longer does any parsing. It just compares against the pre-parsed triggers.
|
|
332
332
|
for (const trigger of parsedTriggers) {
|
|
333
333
|
const keyMatch = compareKey(event.key, trigger.key);
|
|
@@ -342,8 +342,8 @@ export class Hotkeys {
|
|
|
342
342
|
}
|
|
343
343
|
return false;
|
|
344
344
|
}
|
|
345
|
-
filterByContext(source$, context, strict) {
|
|
346
|
-
return source$.pipe(withLatestFrom(this
|
|
345
|
+
#filterByContext(source$, context, strict) {
|
|
346
|
+
return source$.pipe(withLatestFrom(this.#activeContext$), filter(([/* event */ , activeCtx]) => {
|
|
347
347
|
if (context == null) {
|
|
348
348
|
if (strict) {
|
|
349
349
|
return activeCtx == null;
|
|
@@ -357,16 +357,101 @@ export class Hotkeys {
|
|
|
357
357
|
}
|
|
358
358
|
}), map(([event, /* _activeCtx */]) => event));
|
|
359
359
|
}
|
|
360
|
-
|
|
361
|
-
|
|
360
|
+
#checkIsContentEditable(element) {
|
|
361
|
+
if (typeof element.isContentEditable === "boolean") {
|
|
362
|
+
return element.isContentEditable;
|
|
363
|
+
}
|
|
364
|
+
const closest = element.closest?.("[contenteditable]");
|
|
365
|
+
if (closest) {
|
|
366
|
+
const val = closest.getAttribute("contenteditable")?.toLowerCase();
|
|
367
|
+
return val !== "false";
|
|
368
|
+
}
|
|
369
|
+
return false;
|
|
370
|
+
}
|
|
371
|
+
#shouldHandleKeyEvent(event, config) {
|
|
372
|
+
// 1. IME composition check
|
|
373
|
+
const ignoreComposing = config.ignoreComposing ?? true;
|
|
374
|
+
if (ignoreComposing) {
|
|
375
|
+
// @ts-ignore
|
|
376
|
+
if (event.isComposing || event.keyCode === 229 || event.which === 229 || event.key === "Dead") {
|
|
377
|
+
if (this.#debugMode) {
|
|
378
|
+
console.log(`${_a.#LOG_PREFIX} Shortcut "${config.id}" suppressed during IME composition.`);
|
|
379
|
+
}
|
|
380
|
+
return false;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
// 2. Custom filter predicate
|
|
384
|
+
if (config.filter && !config.filter(event)) {
|
|
385
|
+
if (this.#debugMode) {
|
|
386
|
+
console.log(`${_a.#LOG_PREFIX} Shortcut "${config.id}" suppressed by custom filter.`);
|
|
387
|
+
}
|
|
388
|
+
return false;
|
|
389
|
+
}
|
|
390
|
+
// 3. Resolve target element (supporting Shadow DOM)
|
|
391
|
+
let targetElement = null;
|
|
392
|
+
const rawTarget = (typeof event.composedPath === "function" ? event.composedPath()[0] : event.target);
|
|
393
|
+
if (rawTarget) {
|
|
394
|
+
if (rawTarget instanceof HTMLElement) {
|
|
395
|
+
targetElement = rawTarget;
|
|
396
|
+
}
|
|
397
|
+
else if (rawTarget.parentElement instanceof HTMLElement) {
|
|
398
|
+
targetElement = rawTarget.parentElement;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
if (!targetElement) {
|
|
402
|
+
return true;
|
|
403
|
+
}
|
|
404
|
+
// 4. Target exemption: if explicitly attached to this target element, allow it
|
|
405
|
+
if (config.target && config.target === targetElement) {
|
|
406
|
+
return true;
|
|
407
|
+
}
|
|
408
|
+
const tagName = targetElement.tagName ? targetElement.tagName.toLowerCase() : "";
|
|
409
|
+
// 5. Form tags (<input>, <textarea>, <select>)
|
|
410
|
+
const isFormTag = tagName === "input" || tagName === "textarea" || tagName === "select";
|
|
411
|
+
if (isFormTag) {
|
|
412
|
+
const { enableOnFormTags = false } = config;
|
|
413
|
+
if (enableOnFormTags === false) {
|
|
414
|
+
if (this.#debugMode) {
|
|
415
|
+
console.log(`${_a.#LOG_PREFIX} Shortcut "${config.id}" suppressed in <${tagName}> form element.`);
|
|
416
|
+
}
|
|
417
|
+
return false;
|
|
418
|
+
}
|
|
419
|
+
if (Array.isArray(enableOnFormTags)) {
|
|
420
|
+
const allowed = enableOnFormTags.map(t => t.toLowerCase());
|
|
421
|
+
if (!allowed.includes(tagName)) {
|
|
422
|
+
if (this.#debugMode) {
|
|
423
|
+
console.log(`${_a.#LOG_PREFIX} Shortcut "${config.id}" suppressed in <${tagName}> (allowed: [${allowed.join(", ")}]).`);
|
|
424
|
+
}
|
|
425
|
+
return false;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
// 6. Rich-text editor (contentEditable)
|
|
430
|
+
const isContentEditable = this.#checkIsContentEditable(targetElement);
|
|
431
|
+
if (isContentEditable) {
|
|
432
|
+
const { enableOnContentEditable = false } = config;
|
|
433
|
+
if (!enableOnContentEditable) {
|
|
434
|
+
if (this.#debugMode) {
|
|
435
|
+
console.log(`${_a.#LOG_PREFIX} Shortcut "${config.id}" suppressed in contentEditable element.`);
|
|
436
|
+
}
|
|
437
|
+
return false;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
return true;
|
|
441
|
+
}
|
|
442
|
+
#filterEvent(source$, config) {
|
|
443
|
+
return this.#filterByContext(source$, config.context, config.strict ?? false).pipe(filter(event => this.#shouldHandleKeyEvent(event, config)));
|
|
444
|
+
}
|
|
445
|
+
#registerShortcut(config, terminator$, type, detailsForLog, parsedTriggers) {
|
|
446
|
+
const existingShortcut = this.#activeShortcuts.get(config.id);
|
|
362
447
|
if (existingShortcut) {
|
|
363
|
-
console.warn(`${
|
|
448
|
+
console.warn(`${_a.#LOG_PREFIX} Shortcut with ID "${config.id}" already exists. The old instance will be terminated and overwritten.`);
|
|
364
449
|
existingShortcut.terminator$.next();
|
|
365
450
|
existingShortcut.terminator$.complete();
|
|
366
451
|
}
|
|
367
|
-
this
|
|
368
|
-
if (this
|
|
369
|
-
console.log(`${
|
|
452
|
+
this.#activeShortcuts.set(config.id, { id: config.id, config, terminator$, parsedTriggers });
|
|
453
|
+
if (this.#debugMode) {
|
|
454
|
+
console.log(`${_a.#LOG_PREFIX} ${type} shortcut "${config.id}" added. ${detailsForLog}, Context: ${config.context ?? "any"}`);
|
|
370
455
|
}
|
|
371
456
|
}
|
|
372
457
|
/**
|
|
@@ -376,11 +461,11 @@ export class Hotkeys {
|
|
|
376
461
|
* @param shortcutId - The ID of the shortcut this key trigger belongs to (for logging).
|
|
377
462
|
* @returns An object containing configuredMainKey and modifier states, or null if parsing fails.
|
|
378
463
|
*/
|
|
379
|
-
|
|
464
|
+
#parseKeyTrigger(keyInput, shortcutId) {
|
|
380
465
|
if (typeof keyInput === "string") {
|
|
381
466
|
const finalKey = normalizeKey(keyInput);
|
|
382
467
|
if (!finalKey) {
|
|
383
|
-
console.warn(`${
|
|
468
|
+
console.warn(`${_a.#LOG_PREFIX} Could not parse key: "${keyInput}" in shortcut "${shortcutId}".`);
|
|
384
469
|
return null;
|
|
385
470
|
}
|
|
386
471
|
return {
|
|
@@ -393,7 +478,7 @@ export class Hotkeys {
|
|
|
393
478
|
}
|
|
394
479
|
else {
|
|
395
480
|
if (!keyInput.key || typeof keyInput.key !== "string" || keyInput.key === "") {
|
|
396
|
-
console.warn(`${
|
|
481
|
+
console.warn(`${_a.#LOG_PREFIX} Invalid "key" property in shortcut "${shortcutId}". Key must be a non-empty string value from Keys.`);
|
|
397
482
|
return null;
|
|
398
483
|
}
|
|
399
484
|
return {
|
|
@@ -405,11 +490,11 @@ export class Hotkeys {
|
|
|
405
490
|
};
|
|
406
491
|
}
|
|
407
492
|
}
|
|
408
|
-
|
|
493
|
+
#parseCombinationString(shortcut) {
|
|
409
494
|
const parts = shortcut.toLowerCase().split("+").map(p => p.trim());
|
|
410
495
|
const mainKeyStr = parts.pop();
|
|
411
496
|
if (!mainKeyStr) {
|
|
412
|
-
console.warn(`${
|
|
497
|
+
console.warn(`${_a.#LOG_PREFIX} Invalid shortcut string: "${shortcut}". No main key found.`);
|
|
413
498
|
return [];
|
|
414
499
|
}
|
|
415
500
|
const trigger = {
|
|
@@ -418,7 +503,7 @@ export class Hotkeys {
|
|
|
418
503
|
};
|
|
419
504
|
const finalKey = normalizeKey(mainKeyStr);
|
|
420
505
|
if (!finalKey) {
|
|
421
|
-
console.warn(`${
|
|
506
|
+
console.warn(`${_a.#LOG_PREFIX} Could not parse key: "${mainKeyStr}" in shortcut string "${shortcut}".`);
|
|
422
507
|
return [];
|
|
423
508
|
}
|
|
424
509
|
trigger.key = finalKey;
|
|
@@ -432,11 +517,11 @@ export class Hotkeys {
|
|
|
432
517
|
else if (part === "meta" || part === "cmd" || part === "command" || part === "win")
|
|
433
518
|
trigger.metaKey = true;
|
|
434
519
|
else
|
|
435
|
-
console.warn(`${
|
|
520
|
+
console.warn(`${_a.#LOG_PREFIX} Unknown modifier: "${part}" in shortcut string "${shortcut}".`);
|
|
436
521
|
}
|
|
437
522
|
return [trigger];
|
|
438
523
|
}
|
|
439
|
-
|
|
524
|
+
#parseSequenceString(sequence) {
|
|
440
525
|
const keyStrings = sequence.split("->").map(k => k.trim());
|
|
441
526
|
const results = [];
|
|
442
527
|
for (const keyStr of keyStrings) {
|
|
@@ -445,7 +530,7 @@ export class Hotkeys {
|
|
|
445
530
|
results.push(finalKey);
|
|
446
531
|
}
|
|
447
532
|
else {
|
|
448
|
-
console.warn(`${
|
|
533
|
+
console.warn(`${_a.#LOG_PREFIX} Could not parse key: "${keyStr}" in sequence string "${sequence}".`);
|
|
449
534
|
return []; // Fail fast
|
|
450
535
|
}
|
|
451
536
|
}
|
|
@@ -490,24 +575,25 @@ export class Hotkeys {
|
|
|
490
575
|
addCombination(config) {
|
|
491
576
|
const { keys, context, preventDefault = false, id, strict = false, target = document, event: eventType = "keydown", options } = config;
|
|
492
577
|
if (context != null && strict) {
|
|
493
|
-
console.warn(`${
|
|
578
|
+
console.warn(`${_a.#LOG_PREFIX} Shortcut "${id}" has both a context(${context}) and the "strict" flag. The "strict" flag will be ignored.`);
|
|
494
579
|
}
|
|
495
|
-
const parsedTriggers = this
|
|
580
|
+
const parsedTriggers = this.#normalizeAndParseTriggers(keys, id);
|
|
496
581
|
if (parsedTriggers.length === 0) {
|
|
497
|
-
console.error(`${
|
|
582
|
+
console.error(`${_a.#LOG_PREFIX} "keys" definition for combination shortcut "${id}" is empty or invalid. Shortcut not added.`);
|
|
498
583
|
return EMPTY;
|
|
499
584
|
}
|
|
500
|
-
const sourceStream$ = this
|
|
585
|
+
const sourceStream$ = this.#getEventStream(eventType, target, options);
|
|
586
|
+
const baseStream$ = this.#filterEvent(sourceStream$, config);
|
|
501
587
|
const observables = [];
|
|
502
588
|
for (const trigger of parsedTriggers) {
|
|
503
|
-
const stream =
|
|
589
|
+
const stream = baseStream$.pipe(filter(event => {
|
|
504
590
|
return event.ctrlKey === trigger.ctrlKey &&
|
|
505
591
|
event.altKey === trigger.altKey &&
|
|
506
592
|
event.shiftKey === trigger.shiftKey &&
|
|
507
593
|
event.metaKey === trigger.metaKey;
|
|
508
594
|
}), filter(event => compareKey(event.key, trigger.key)),
|
|
509
595
|
// New filter for priority: Specific context > Global context
|
|
510
|
-
withLatestFrom(this
|
|
596
|
+
withLatestFrom(this.#activeContext$), filter(([event, activeCtx]) => {
|
|
511
597
|
if (context != null || strict) { // This shortcut is NOT global or strict
|
|
512
598
|
return true;
|
|
513
599
|
}
|
|
@@ -515,13 +601,13 @@ export class Hotkeys {
|
|
|
515
601
|
if (activeCtx == null) { // No specific context active
|
|
516
602
|
return true;
|
|
517
603
|
}
|
|
518
|
-
for (const [, otherAS] of this
|
|
604
|
+
for (const [, otherAS] of this.#activeShortcuts) {
|
|
519
605
|
if (otherAS.config.id !== id &&
|
|
520
606
|
"keys" in otherAS.config &&
|
|
521
607
|
otherAS.config.context === activeCtx &&
|
|
522
|
-
this
|
|
523
|
-
if (this
|
|
524
|
-
console.log(`${
|
|
608
|
+
this.#shortcutMatchesEvent(otherAS.parsedTriggers ?? [], event)) {
|
|
609
|
+
if (this.#debugMode) {
|
|
610
|
+
console.log(`${_a.#LOG_PREFIX} Global shortcut "${id}" (key: "${event.key}") suppressed by specific context shortcut "${otherAS.config.id}".`);
|
|
525
611
|
}
|
|
526
612
|
return false; // Suppress global
|
|
527
613
|
}
|
|
@@ -532,7 +618,7 @@ export class Hotkeys {
|
|
|
532
618
|
}
|
|
533
619
|
if (observables.length === 0) {
|
|
534
620
|
// This path should now be much harder to hit, but remains a safeguard.
|
|
535
|
-
console.warn(`${
|
|
621
|
+
console.warn(`${_a.#LOG_PREFIX} No valid key triggers for combination shortcut "${id}". Shortcut not added.`);
|
|
536
622
|
return EMPTY;
|
|
537
623
|
}
|
|
538
624
|
const terminator$ = new Subject();
|
|
@@ -554,16 +640,16 @@ export class Hotkeys {
|
|
|
554
640
|
return `{ ${parts.join(", ")} }`;
|
|
555
641
|
});
|
|
556
642
|
const logDetails = `Triggers: [ ${logParts.join(", ")} ]`;
|
|
557
|
-
this
|
|
643
|
+
this.#registerShortcut(config, terminator$, ShortcutTypes.Combination, logDetails, parsedTriggers);
|
|
558
644
|
return finalShortcut$.pipe(tap(event => {
|
|
559
|
-
if (this
|
|
645
|
+
if (this.#debugMode) {
|
|
560
646
|
const preventAction = preventDefault ? ", preventing default" : "";
|
|
561
|
-
console.log(`${
|
|
647
|
+
console.log(`${_a.#LOG_PREFIX} Combination "${id}" triggered by key "${event.key}" ${preventAction}.`);
|
|
562
648
|
}
|
|
563
649
|
if (preventDefault)
|
|
564
650
|
event.preventDefault();
|
|
565
651
|
}), catchError(err => {
|
|
566
|
-
console.error(`${
|
|
652
|
+
console.error(`${_a.#LOG_PREFIX} Error in combination stream for shortcut "${id}":`, err);
|
|
567
653
|
return EMPTY;
|
|
568
654
|
}), takeUntil(terminator$));
|
|
569
655
|
}
|
|
@@ -600,18 +686,18 @@ export class Hotkeys {
|
|
|
600
686
|
*/
|
|
601
687
|
addSequence(config) {
|
|
602
688
|
const { sequence, context, preventDefault = false, id, sequenceTimeoutMs, strict = false, target = document, event: eventType = "keydown", options } = config;
|
|
603
|
-
const configuredSequence = this
|
|
689
|
+
const configuredSequence = this.#normalizeSequence(sequence, id);
|
|
604
690
|
if (!configuredSequence || configuredSequence.length === 0) {
|
|
605
|
-
console.error(`${
|
|
691
|
+
console.error(`${_a.#LOG_PREFIX} Sequence for shortcut "${id}" is empty or invalid. Shortcut not added.`);
|
|
606
692
|
return EMPTY;
|
|
607
693
|
}
|
|
608
694
|
if (context && strict) {
|
|
609
|
-
console.warn(`${
|
|
695
|
+
console.warn(`${_a.#LOG_PREFIX} Shortcut "${id}" has both a context and the "strict" flag. The "strict" flag will be ignored.`);
|
|
610
696
|
}
|
|
611
697
|
const sequenceLength = configuredSequence.length;
|
|
612
698
|
let shortcut$;
|
|
613
|
-
const sourceStream$ = this
|
|
614
|
-
const baseKeydownStream$ = this
|
|
699
|
+
const sourceStream$ = this.#getEventStream(eventType, target, options);
|
|
700
|
+
const baseKeydownStream$ = this.#filterEvent(sourceStream$, config);
|
|
615
701
|
if (sequenceTimeoutMs && sequenceTimeoutMs > 0) {
|
|
616
702
|
shortcut$ = baseKeydownStream$.pipe(scan((acc, event) => {
|
|
617
703
|
let { matchedEvents, lastEventTime } = acc;
|
|
@@ -621,8 +707,8 @@ export class Hotkeys {
|
|
|
621
707
|
lastEventTime = 0;
|
|
622
708
|
}
|
|
623
709
|
if (matchedEvents.length > 0 && (currentTime - lastEventTime > sequenceTimeoutMs)) {
|
|
624
|
-
if (this
|
|
625
|
-
console.log(`${
|
|
710
|
+
if (this.#debugMode) {
|
|
711
|
+
console.log(`${_a.#LOG_PREFIX} Sequence "${id}" (timeout: ${sequenceTimeoutMs}ms) attempt timed out. Matched: ${matchedEvents.map(e => e.key).join(",")}. Resetting.`);
|
|
626
712
|
}
|
|
627
713
|
matchedEvents = [];
|
|
628
714
|
}
|
|
@@ -638,8 +724,8 @@ export class Hotkeys {
|
|
|
638
724
|
if (compareKey(event.key, configuredSequence[nextExpectedKeyIndex])) {
|
|
639
725
|
const newMatchedEvents = [...matchedEvents, event];
|
|
640
726
|
if (newMatchedEvents.length === sequenceLength) {
|
|
641
|
-
if (this
|
|
642
|
-
console.log(`${
|
|
727
|
+
if (this.#debugMode && acc.emitState !== EmitStates.Emit)
|
|
728
|
+
console.log(`${_a.#LOG_PREFIX} Sequence "${id}" (timeout: ${sequenceTimeoutMs}ms) matched.`);
|
|
643
729
|
return { matchedEvents: newMatchedEvents, lastEventTime: currentTime, emitState: EmitStates.Emit };
|
|
644
730
|
}
|
|
645
731
|
else {
|
|
@@ -648,8 +734,8 @@ export class Hotkeys {
|
|
|
648
734
|
}
|
|
649
735
|
else {
|
|
650
736
|
// If current key breaks sequence, check if it starts a new sequence
|
|
651
|
-
if (matchedEvents.length > 0 && this
|
|
652
|
-
console.log(`${
|
|
737
|
+
if (matchedEvents.length > 0 && this.#debugMode) {
|
|
738
|
+
console.log(`${_a.#LOG_PREFIX} Sequence "${id}" (timeout: ${sequenceTimeoutMs}ms) broken by key "${event.key}". Matched: ${matchedEvents.map(e => e.key).join(",")}. Resetting.`);
|
|
653
739
|
}
|
|
654
740
|
if (sequenceLength > 0 && compareKey(event.key, configuredSequence[0])) {
|
|
655
741
|
return { matchedEvents: [event], lastEventTime: currentTime, emitState: EmitStates.InProgress };
|
|
@@ -669,7 +755,7 @@ export class Hotkeys {
|
|
|
669
755
|
}));
|
|
670
756
|
}
|
|
671
757
|
const terminator$ = new Subject();
|
|
672
|
-
const finalShortcutWithPriority$ = shortcut$.pipe(withLatestFrom(this
|
|
758
|
+
const finalShortcutWithPriority$ = shortcut$.pipe(withLatestFrom(this.#activeContext$), filter(([_completedEvents, activeCtx]) => {
|
|
673
759
|
if (context != null || strict) { // This sequence is NOT global or strict
|
|
674
760
|
return true;
|
|
675
761
|
}
|
|
@@ -677,33 +763,33 @@ export class Hotkeys {
|
|
|
677
763
|
if (activeCtx == null) { // No specific context active
|
|
678
764
|
return true;
|
|
679
765
|
}
|
|
680
|
-
for (const [, otherAS] of this
|
|
766
|
+
for (const [, otherAS] of this.#activeShortcuts) {
|
|
681
767
|
if (otherAS.config.id !== id &&
|
|
682
768
|
"sequence" in otherAS.config &&
|
|
683
769
|
otherAS.config.context === activeCtx &&
|
|
684
|
-
this
|
|
685
|
-
if (this
|
|
686
|
-
console.log(`${
|
|
770
|
+
this.#areSequencesIdentical(configuredSequence, typeof otherAS.config.sequence === "string" ? this.#parseSequenceString(otherAS.config.sequence) : otherAS.config.sequence)) {
|
|
771
|
+
if (this.#debugMode) {
|
|
772
|
+
console.log(`${_a.#LOG_PREFIX} Global sequence shortcut "${id}" suppressed by identical specific-context shortcut "${otherAS.config.id}".`);
|
|
687
773
|
}
|
|
688
774
|
return false; // Suppress global
|
|
689
775
|
}
|
|
690
776
|
}
|
|
691
777
|
return true; // Global sequence can proceed
|
|
692
778
|
}), map(([events]) => events), tap((events) => {
|
|
693
|
-
if (this
|
|
779
|
+
if (this.#debugMode) {
|
|
694
780
|
const timeoutInfo = (sequenceTimeoutMs && sequenceTimeoutMs > 0) ? ` (with timeout logic)` : ` (no timeout logic)`;
|
|
695
781
|
const preventAction = preventDefault ? ", preventing default for last event" : "";
|
|
696
|
-
console.log(`${
|
|
782
|
+
console.log(`${_a.#LOG_PREFIX} Sequence "${id}" triggered${timeoutInfo}${preventAction}.`);
|
|
697
783
|
}
|
|
698
784
|
if (preventDefault && events.length > 0) {
|
|
699
785
|
events[events.length - 1].preventDefault();
|
|
700
786
|
}
|
|
701
787
|
}), catchError(err => {
|
|
702
|
-
console.error(`${
|
|
788
|
+
console.error(`${_a.#LOG_PREFIX} Error in sequence stream for shortcut "${id}":`, err);
|
|
703
789
|
return EMPTY;
|
|
704
790
|
}));
|
|
705
791
|
const logDetails = `Sequence: ${configuredSequence.join(" -> ")}${sequenceTimeoutMs && sequenceTimeoutMs > 0 ? ` (timeout: ${sequenceTimeoutMs}ms)` : ""}`;
|
|
706
|
-
this
|
|
792
|
+
this.#registerShortcut(config, terminator$, ShortcutTypes.Sequence, logDetails);
|
|
707
793
|
return finalShortcutWithPriority$.pipe(map((events) => events[events.length - 1]), takeUntil(terminator$));
|
|
708
794
|
}
|
|
709
795
|
/**
|
|
@@ -714,16 +800,16 @@ export class Hotkeys {
|
|
|
714
800
|
* A warning is logged to the console if no shortcut with the given ID is found.
|
|
715
801
|
*/
|
|
716
802
|
remove(id) {
|
|
717
|
-
const shortcut = this
|
|
803
|
+
const shortcut = this.#activeShortcuts.get(id);
|
|
718
804
|
if (shortcut) {
|
|
719
805
|
shortcut.terminator$.next();
|
|
720
806
|
shortcut.terminator$.complete();
|
|
721
|
-
this
|
|
722
|
-
if (this
|
|
723
|
-
console.log(`${
|
|
807
|
+
this.#activeShortcuts.delete(id);
|
|
808
|
+
if (this.#debugMode)
|
|
809
|
+
console.log(`${_a.#LOG_PREFIX} Shortcut "${id}" removed.`);
|
|
724
810
|
return true;
|
|
725
811
|
}
|
|
726
|
-
console.warn(`${
|
|
812
|
+
console.warn(`${_a.#LOG_PREFIX} Shortcut with ID "${id}" not found for removal.`);
|
|
727
813
|
return false;
|
|
728
814
|
}
|
|
729
815
|
/**
|
|
@@ -735,7 +821,7 @@ export class Hotkeys {
|
|
|
735
821
|
*/
|
|
736
822
|
getActiveShortcuts() {
|
|
737
823
|
const shortcuts = [];
|
|
738
|
-
for (const [id, activeShortcut] of this
|
|
824
|
+
for (const [id, activeShortcut] of this.#activeShortcuts.entries()) {
|
|
739
825
|
shortcuts.push({
|
|
740
826
|
id,
|
|
741
827
|
description: activeShortcut.config.description,
|
|
@@ -752,16 +838,17 @@ export class Hotkeys {
|
|
|
752
838
|
* After calling `destroy()`, the instance should not be used further.
|
|
753
839
|
*/
|
|
754
840
|
destroy() {
|
|
755
|
-
if (this
|
|
756
|
-
console.log(`${
|
|
757
|
-
this
|
|
841
|
+
if (this.#debugMode)
|
|
842
|
+
console.log(`${_a.#LOG_PREFIX} Destroying library instance and terminating all shortcut streams.`);
|
|
843
|
+
this.#activeShortcuts.forEach(shortcut => {
|
|
758
844
|
shortcut.terminator$.next();
|
|
759
845
|
shortcut.terminator$.complete();
|
|
760
846
|
});
|
|
761
|
-
this
|
|
762
|
-
this
|
|
763
|
-
if (this
|
|
764
|
-
console.log(`${
|
|
847
|
+
this.#activeShortcuts.clear();
|
|
848
|
+
this.#contextStack$.complete();
|
|
849
|
+
if (this.#debugMode)
|
|
850
|
+
console.log(`${_a.#LOG_PREFIX} Library destroyed.`);
|
|
765
851
|
}
|
|
766
852
|
}
|
|
853
|
+
_a = Hotkeys;
|
|
767
854
|
//# sourceMappingURL=hotkeys.js.map
|