@tanstack/hotkeys 0.15.1 → 0.16.1
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 +1 -1
- package/dist/_keyboard-event.js +22 -6
- package/dist/_match.js +4 -3
- package/dist/_recorder-chord.js +3 -3
- package/dist/_registration.js +8 -5
- package/dist/adapter.d.ts +53 -6
- package/dist/adapter.js +45 -3
- package/dist/app-hotkeys.d.ts +11 -8
- package/dist/app-hotkeys.js +13 -7
- package/dist/conflicts.js +5 -5
- package/dist/format.d.ts +13 -9
- package/dist/format.js +7 -16
- package/dist/hint.js +2 -2
- package/dist/hotkey-manager.d.ts +94 -11
- package/dist/hotkey-manager.js +208 -31
- package/dist/hotkey-recorder.d.ts +20 -10
- package/dist/hotkey-recorder.js +12 -7
- package/dist/hotkey.types.d.ts +59 -32
- package/dist/index.d.ts +6 -6
- package/dist/index.js +3 -3
- package/dist/match.d.ts +19 -18
- package/dist/match.js +24 -21
- package/dist/parse.d.ts +35 -73
- package/dist/parse.js +39 -108
- package/dist/validate.js +2 -2
- package/package.json +1 -1
package/dist/hotkey-manager.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ConflictBehavior, HotkeyCallback, HotkeyChord, HotkeyMeta, HotkeySequence,
|
|
1
|
+
import { ConflictBehavior, HotkeyCallback, HotkeyCallbackContextFor, HotkeyChord, HotkeyMeta, HotkeySequence, ParsedHotkeyChord, RegisterableHotkey, RegisterableHotkeyChord, RegisterableHotkeySequence } from "./hotkey.types.js";
|
|
2
2
|
import { AppHotkeysStore } from "./app-hotkeys.js";
|
|
3
3
|
import { Store } from "@tanstack/store";
|
|
4
4
|
//#region src/hotkey-manager.d.ts
|
|
@@ -39,14 +39,30 @@ export interface HotkeyOptions {
|
|
|
39
39
|
ignoreInputs?: boolean | 'auto';
|
|
40
40
|
/** The target platform for resolving 'Mod' */
|
|
41
41
|
platform?: 'mac' | 'windows' | 'linux';
|
|
42
|
-
/**
|
|
42
|
+
/**
|
|
43
|
+
* Prevent the default browser action when the hotkey matches. For a
|
|
44
|
+
* sequence, every step that advances it does. Defaults to true
|
|
45
|
+
*/
|
|
43
46
|
preventDefault?: boolean;
|
|
44
|
-
/**
|
|
47
|
+
/**
|
|
48
|
+
* Stop event propagation when the hotkey matches. For a sequence, only the
|
|
49
|
+
* last step does, so earlier steps reach other listeners. Defaults to true
|
|
50
|
+
*/
|
|
45
51
|
stopPropagation?: boolean;
|
|
46
52
|
/** The DOM element to attach the event listener to. Defaults to document. */
|
|
47
53
|
target?: Target | null;
|
|
48
54
|
/** Optional metadata (name, description, custom fields via declaration merging) */
|
|
49
55
|
meta?: HotkeyMeta;
|
|
56
|
+
/**
|
|
57
|
+
* Scope this hotkey belongs to. Defaults to `'global'`, which is always
|
|
58
|
+
* active. Other scopes are active while activated with
|
|
59
|
+
* `HotkeyManager.activateScope`, which framework providers do while
|
|
60
|
+
* mounted. While an exclusive scope is active, only its hotkeys, those of
|
|
61
|
+
* scopes nested in it, and those of scopes it allows fire.
|
|
62
|
+
* When active scopes share a hotkey on the same target, the most recently
|
|
63
|
+
* activated scope handles it.
|
|
64
|
+
*/
|
|
65
|
+
scope?: string;
|
|
50
66
|
/** Id of the app hotkey this registration implements. Adapters set this when registering an app hotkey from `defineAppHotkeys`. */
|
|
51
67
|
appHotkeyId?: string;
|
|
52
68
|
}
|
|
@@ -64,7 +80,7 @@ export interface HotkeySequenceOptions extends HotkeyOptions {
|
|
|
64
80
|
* The options type for a hotkey: chord options for chords (including one-step
|
|
65
81
|
* tuples), sequence options for sequences.
|
|
66
82
|
*/
|
|
67
|
-
export type HotkeyOptionsFor<T extends RegisterableHotkey> = [T] extends [never] ? HotkeyChordOptions & HotkeySequenceOptions : T extends readonly [RegisterableHotkeyChord] ? HotkeyChordOptions : T extends
|
|
83
|
+
export type HotkeyOptionsFor<T extends RegisterableHotkey> = [T] extends [never] ? HotkeyChordOptions & HotkeySequenceOptions : T extends readonly [RegisterableHotkeyChord] ? HotkeyChordOptions : T extends RegisterableHotkeySequence ? HotkeySequenceOptions : HotkeyChordOptions;
|
|
68
84
|
interface RegistrationBase<TOptions extends HotkeyOptions> {
|
|
69
85
|
/** Unique identifier for this registration */
|
|
70
86
|
id: string;
|
|
@@ -83,7 +99,7 @@ interface RegistrationBase<TOptions extends HotkeyOptions> {
|
|
|
83
99
|
export interface HotkeyChordRegistration extends RegistrationBase<HotkeyChordOptions> {
|
|
84
100
|
kind: 'chord';
|
|
85
101
|
hotkey: HotkeyChord;
|
|
86
|
-
|
|
102
|
+
parsedChord: ParsedHotkeyChord;
|
|
87
103
|
/** The concrete key/code that activated a requireReset registration. */
|
|
88
104
|
activeMatch?: {
|
|
89
105
|
key: string;
|
|
@@ -94,7 +110,7 @@ export interface HotkeyChordRegistration extends RegistrationBase<HotkeyChordOpt
|
|
|
94
110
|
export interface HotkeySequenceRegistration extends RegistrationBase<HotkeySequenceOptions> {
|
|
95
111
|
kind: 'sequence';
|
|
96
112
|
hotkey: HotkeySequence;
|
|
97
|
-
parsedSequence: Array<
|
|
113
|
+
parsedSequence: Array<ParsedHotkeyChord>;
|
|
98
114
|
/** Steps matched in the current attempt */
|
|
99
115
|
currentIndex: number;
|
|
100
116
|
/** `Date.now()` when the last step in the current attempt matched */
|
|
@@ -112,14 +128,14 @@ interface RegistrationViewBase {
|
|
|
112
128
|
export interface HotkeyChordRegistrationView extends RegistrationViewBase {
|
|
113
129
|
kind: 'chord';
|
|
114
130
|
hotkey: HotkeyChord;
|
|
115
|
-
|
|
131
|
+
parsedChord: ParsedHotkeyChord;
|
|
116
132
|
options: HotkeyChordOptions;
|
|
117
133
|
}
|
|
118
134
|
/** Public view of a sequence registration, without its callback. */
|
|
119
135
|
export interface HotkeySequenceRegistrationView extends RegistrationViewBase {
|
|
120
136
|
kind: 'sequence';
|
|
121
137
|
hotkey: HotkeySequence;
|
|
122
|
-
parsedSequence: Array<
|
|
138
|
+
parsedSequence: Array<ParsedHotkeyChord>;
|
|
123
139
|
options: HotkeySequenceOptions;
|
|
124
140
|
/** Steps matched in the current attempt (0 when idle or just completed). */
|
|
125
141
|
matchedStepCount: number;
|
|
@@ -172,6 +188,44 @@ export interface HotkeyRegistrationHandle<TOptions extends HotkeyOptions = Hotke
|
|
|
172
188
|
/** Unregister this hotkey */
|
|
173
189
|
unregister: () => void;
|
|
174
190
|
}
|
|
191
|
+
/** Active hotkey scopes, published on `HotkeyManager.scopes`. */
|
|
192
|
+
export interface HotkeyScopesState {
|
|
193
|
+
/**
|
|
194
|
+
* Activated scopes, lowest precedence first. `'global'` is always active
|
|
195
|
+
* and not listed.
|
|
196
|
+
*/
|
|
197
|
+
active: ReadonlyArray<string>;
|
|
198
|
+
/** The exclusive scope that owns the keyboard, or `null` when none is active. */
|
|
199
|
+
exclusive: string | null;
|
|
200
|
+
/**
|
|
201
|
+
* Scopes the exclusive scope allows that are active, possibly including
|
|
202
|
+
* `'global'`. Empty when no scope is exclusive.
|
|
203
|
+
*/
|
|
204
|
+
allowed: ReadonlyArray<string>;
|
|
205
|
+
/**
|
|
206
|
+
* Scopes that are active but cannot fire because another scope is
|
|
207
|
+
* exclusive, possibly including `'global'`.
|
|
208
|
+
*/
|
|
209
|
+
suspended: ReadonlyArray<string>;
|
|
210
|
+
}
|
|
211
|
+
/** Options for `HotkeyManager.activateScope`. */
|
|
212
|
+
export interface ActivateHotkeyScopeOptions {
|
|
213
|
+
/**
|
|
214
|
+
* Suspend every other scope, including `'global'`, until this activation is
|
|
215
|
+
* released. Scopes nested in this one stay active. Pass `{ allow }` to keep
|
|
216
|
+
* other scopes active too, such as `{ allow: ['global'] }`. An allowed scope
|
|
217
|
+
* still has to be activated, except `'global'`.
|
|
218
|
+
*/
|
|
219
|
+
exclusive?: boolean | {
|
|
220
|
+
allow: ReadonlyArray<string>;
|
|
221
|
+
};
|
|
222
|
+
/**
|
|
223
|
+
* The scope this one is nested in. A nested scope takes precedence over its
|
|
224
|
+
* ancestors even when it was activated first, as nested providers are when
|
|
225
|
+
* their effects run child-first. Defaults to `'global'`.
|
|
226
|
+
*/
|
|
227
|
+
parent?: string;
|
|
228
|
+
}
|
|
175
229
|
/**
|
|
176
230
|
* Singleton manager for hotkey registrations, including sequences.
|
|
177
231
|
*
|
|
@@ -225,6 +279,8 @@ export declare class HotkeyManager {
|
|
|
225
279
|
* devtools can list declared hotkeys that are not registered.
|
|
226
280
|
*/
|
|
227
281
|
readonly appHotkeys: Store<AppHotkeysStore | null>;
|
|
282
|
+
/** Active scopes. Subscribe to show which hotkeys are suspended. */
|
|
283
|
+
readonly scopes: Store<HotkeyScopesState>;
|
|
228
284
|
private constructor();
|
|
229
285
|
/**
|
|
230
286
|
* Gets the singleton instance of HotkeyManager.
|
|
@@ -234,17 +290,44 @@ export declare class HotkeyManager {
|
|
|
234
290
|
* Resets the singleton instance. Useful for testing.
|
|
235
291
|
*/
|
|
236
292
|
static resetInstance(): void;
|
|
293
|
+
/**
|
|
294
|
+
* Activates a scope until the returned function is called. Activations are
|
|
295
|
+
* counted, so a scope stays active until every activation is released.
|
|
296
|
+
* Framework providers call this while mounted.
|
|
297
|
+
*
|
|
298
|
+
* When active scopes share a hotkey, a nested scope takes precedence over
|
|
299
|
+
* its ancestors, and otherwise the most recently activated scope wins. The
|
|
300
|
+
* same order picks the exclusive scope when several are active, and only
|
|
301
|
+
* that scope's `allow` list applies.
|
|
302
|
+
*
|
|
303
|
+
* @example
|
|
304
|
+
* ```ts
|
|
305
|
+
* const release = manager.activateScope('dialog', { exclusive: true })
|
|
306
|
+
* // Only hotkeys with scope 'dialog', or scopes nested in it, fire now.
|
|
307
|
+
* release()
|
|
308
|
+
*
|
|
309
|
+
* manager.activateScope('dialog', { exclusive: { allow: ['global'] } })
|
|
310
|
+
* // Hotkeys with scope 'global' fire too.
|
|
311
|
+
* ```
|
|
312
|
+
*/
|
|
313
|
+
activateScope(scope: string, options?: ActivateHotkeyScopeOptions): () => void;
|
|
314
|
+
/**
|
|
315
|
+
* Whether hotkeys in a scope can fire: the scope is `'global'` or
|
|
316
|
+
* activated, and either no scope is exclusive or this scope is the
|
|
317
|
+
* exclusive one, nested in it, or allowed by it.
|
|
318
|
+
*/
|
|
319
|
+
isScopeActive(scope?: string): boolean;
|
|
237
320
|
/**
|
|
238
321
|
* Registers a hotkey handler and returns a handle for updating the registration.
|
|
239
322
|
*
|
|
240
|
-
* Pass a chord (`'Mod+S'` or a
|
|
323
|
+
* Pass a chord (`'Mod+S'` or a RawHotkeyChord object) or a sequence (`['G', 'G']`).
|
|
241
324
|
* A one-step sequence registers as its chord. Chords accept `requireReset`;
|
|
242
325
|
* sequences accept `timeout`.
|
|
243
326
|
*
|
|
244
327
|
* The returned handle allows updating the callback and options without
|
|
245
328
|
* re-registering, which is useful for avoiding stale closures in React.
|
|
246
329
|
*
|
|
247
|
-
* @param hotkey - A chord string,
|
|
330
|
+
* @param hotkey - A chord string, RawHotkeyChord object, or sequence
|
|
248
331
|
* @param callback - The function to call when the hotkey is pressed
|
|
249
332
|
* @param options - Options for the hotkey behavior
|
|
250
333
|
* @returns A handle for managing the registration
|
|
@@ -263,7 +346,7 @@ export declare class HotkeyManager {
|
|
|
263
346
|
* handle.unregister()
|
|
264
347
|
* ```
|
|
265
348
|
*/
|
|
266
|
-
register<T extends RegisterableHotkey>(hotkey: T, callback: HotkeyCallback
|
|
349
|
+
register<const T extends RegisterableHotkey>(hotkey: T, callback: HotkeyCallback<HotkeyCallbackContextFor<T>>, options?: HotkeyOptionsFor<T>): HotkeyRegistrationHandle<HotkeyOptionsFor<T>>;
|
|
267
350
|
/**
|
|
268
351
|
* Resets the progress of every in-progress sequence.
|
|
269
352
|
*/
|
package/dist/hotkey-manager.js
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
import { normalizeKeyName } from "./constants.js";
|
|
2
2
|
import { detectPlatform } from "./platform.js";
|
|
3
3
|
import { keysEqual, normalizeKeyboardEvent } from "./_keyboard-event.js";
|
|
4
|
-
import { isHotkeySequence, isModifierKey, parseHotkey,
|
|
4
|
+
import { isHotkeySequence, isModifierKey, parseHotkey, simplifyHotkey } from "./parse.js";
|
|
5
5
|
import { formatHotkey } from "./format.js";
|
|
6
|
-
import { matchKeyboardEvent,
|
|
6
|
+
import { matchKeyboardEvent, parsedChordsEqual } from "./_match.js";
|
|
7
7
|
import { DEFAULT_SEQUENCE_TIMEOUT, areHotkeysEqual } from "./match.js";
|
|
8
8
|
import { getTargetDocument, isEventForTarget, shouldIgnoreInputEvent } from "./_event-target.js";
|
|
9
9
|
import { TargetListeners } from "./_target-listeners.js";
|
|
10
10
|
import { managerRef } from "./_manager-ref.js";
|
|
11
11
|
import { isRecordingEvent } from "./_recording-guard.js";
|
|
12
|
-
import { defaultHotkeyOptions, getDefaultIgnoreInputs, handleConflict, optionsEqual } from "./_registration.js";
|
|
12
|
+
import { GLOBAL_SCOPE, defaultHotkeyOptions, getDefaultIgnoreInputs, handleConflict, optionsEqual } from "./_registration.js";
|
|
13
13
|
import { Store } from "@tanstack/store";
|
|
14
14
|
|
|
15
15
|
//#region src/hotkey-manager.ts
|
|
@@ -25,7 +25,7 @@ function toHotkeyRegistrationView(reg) {
|
|
|
25
25
|
...base,
|
|
26
26
|
kind: "chord",
|
|
27
27
|
hotkey: reg.hotkey,
|
|
28
|
-
|
|
28
|
+
parsedChord: reg.parsedChord,
|
|
29
29
|
options: reg.options
|
|
30
30
|
};
|
|
31
31
|
const length = reg.parsedSequence.length;
|
|
@@ -41,6 +41,10 @@ function toHotkeyRegistrationView(reg) {
|
|
|
41
41
|
};
|
|
42
42
|
}
|
|
43
43
|
let registrationIdCounter = 0;
|
|
44
|
+
/** The string a chord registers as: strings as written, objects formatted from their parsed form. */
|
|
45
|
+
function chordString(chord, parsed) {
|
|
46
|
+
return typeof chord === "string" ? chord : formatHotkey(parsed);
|
|
47
|
+
}
|
|
44
48
|
/**
|
|
45
49
|
* Generates a unique ID for hotkey registrations.
|
|
46
50
|
*/
|
|
@@ -58,7 +62,7 @@ function optionsForKind(kind, options) {
|
|
|
58
62
|
}
|
|
59
63
|
/** Steps with Mod already resolved for the registration's platform. */
|
|
60
64
|
function resolvedSteps(registration) {
|
|
61
|
-
return registration.kind === "chord" ? [registration.
|
|
65
|
+
return registration.kind === "chord" ? [registration.parsedChord] : registration.parsedSequence;
|
|
62
66
|
}
|
|
63
67
|
/** Whether a registration skips keys that focused controls use, resolving `'auto'`. */
|
|
64
68
|
function ignoresInputs(registration) {
|
|
@@ -66,7 +70,7 @@ function ignoresInputs(registration) {
|
|
|
66
70
|
return ignoreInputs === "auto" ? getDefaultIgnoreInputs(resolvedSteps(registration)[0]) : ignoreInputs;
|
|
67
71
|
}
|
|
68
72
|
function sameResolvedSteps(left, right) {
|
|
69
|
-
return left.length === right.length && left.every((step, index) =>
|
|
73
|
+
return left.length === right.length && left.every((step, index) => parsedChordsEqual(step, right[index]));
|
|
70
74
|
}
|
|
71
75
|
function formatForMessage(hotkey) {
|
|
72
76
|
return isHotkeySequence(hotkey) ? hotkey.join(" ") : hotkey;
|
|
@@ -123,12 +127,27 @@ var HotkeyManager = class HotkeyManager {
|
|
|
123
127
|
* devtools can list declared hotkeys that are not registered.
|
|
124
128
|
*/
|
|
125
129
|
appHotkeys = new Store(null);
|
|
130
|
+
/** Active scopes. Subscribe to show which hotkeys are suspended. */
|
|
131
|
+
scopes = new Store({
|
|
132
|
+
active: [],
|
|
133
|
+
exclusive: null,
|
|
134
|
+
allowed: [],
|
|
135
|
+
suspended: []
|
|
136
|
+
});
|
|
126
137
|
#platform;
|
|
138
|
+
#scopeActivations = /* @__PURE__ */ new Set();
|
|
139
|
+
#scopeActivationCount = 0;
|
|
140
|
+
/** Active scopes, lowest precedence first, derived from activations. */
|
|
141
|
+
#scopeOrder = [];
|
|
142
|
+
/** Scopes that can fire while a scope is exclusive, or `null` when none is. */
|
|
143
|
+
#liveScopes = null;
|
|
127
144
|
#listeners = new TargetListeners((...args) => this.#processTargetEvent(...args));
|
|
128
145
|
/** Events that continue a sequence in progress, decided before any listener runs. */
|
|
129
146
|
#sequenceOwned = /* @__PURE__ */ new WeakMap();
|
|
130
147
|
/** Physical keys whose keydown a sequence owned, so their repeats and keyup are owned too. */
|
|
131
148
|
#sequenceOwnedKeys = /* @__PURE__ */ new Set();
|
|
149
|
+
/** Timers that clear a sequence's progress once its timeout passes. */
|
|
150
|
+
#sequenceResetTimers = /* @__PURE__ */ new Map();
|
|
132
151
|
constructor() {
|
|
133
152
|
this.#platform = detectPlatform();
|
|
134
153
|
}
|
|
@@ -145,6 +164,114 @@ var HotkeyManager = class HotkeyManager {
|
|
|
145
164
|
managerRef.current?.destroy();
|
|
146
165
|
managerRef.current = null;
|
|
147
166
|
}
|
|
167
|
+
/**
|
|
168
|
+
* Activates a scope until the returned function is called. Activations are
|
|
169
|
+
* counted, so a scope stays active until every activation is released.
|
|
170
|
+
* Framework providers call this while mounted.
|
|
171
|
+
*
|
|
172
|
+
* When active scopes share a hotkey, a nested scope takes precedence over
|
|
173
|
+
* its ancestors, and otherwise the most recently activated scope wins. The
|
|
174
|
+
* same order picks the exclusive scope when several are active, and only
|
|
175
|
+
* that scope's `allow` list applies.
|
|
176
|
+
*
|
|
177
|
+
* @example
|
|
178
|
+
* ```ts
|
|
179
|
+
* const release = manager.activateScope('dialog', { exclusive: true })
|
|
180
|
+
* // Only hotkeys with scope 'dialog', or scopes nested in it, fire now.
|
|
181
|
+
* release()
|
|
182
|
+
*
|
|
183
|
+
* manager.activateScope('dialog', { exclusive: { allow: ['global'] } })
|
|
184
|
+
* // Hotkeys with scope 'global' fire too.
|
|
185
|
+
* ```
|
|
186
|
+
*/
|
|
187
|
+
activateScope(scope, options = {}) {
|
|
188
|
+
if (scope === "global") return () => {};
|
|
189
|
+
const { exclusive } = options;
|
|
190
|
+
const allow = exclusive ? exclusive === true ? [] : exclusive.allow : null;
|
|
191
|
+
if (allow && [...this.#scopeActivations].some((other) => other.allow && other.scope === scope)) console.warn(`Exclusive hotkey scope '${scope}' is already active. Hotkeys from both activations will fire; give each a unique scope.`);
|
|
192
|
+
const activation = {
|
|
193
|
+
scope,
|
|
194
|
+
parent: options.parent ?? "global",
|
|
195
|
+
allow,
|
|
196
|
+
order: ++this.#scopeActivationCount
|
|
197
|
+
};
|
|
198
|
+
this.#scopeActivations.add(activation);
|
|
199
|
+
this.#updateScopes();
|
|
200
|
+
return () => {
|
|
201
|
+
if (this.#scopeActivations.delete(activation)) this.#updateScopes();
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Whether hotkeys in a scope can fire: the scope is `'global'` or
|
|
206
|
+
* activated, and either no scope is exclusive or this scope is the
|
|
207
|
+
* exclusive one, nested in it, or allowed by it.
|
|
208
|
+
*/
|
|
209
|
+
isScopeActive(scope = GLOBAL_SCOPE) {
|
|
210
|
+
if (this.#liveScopes) return this.#liveScopes.has(scope);
|
|
211
|
+
return scope === "global" || this.#scopeOrder.includes(scope);
|
|
212
|
+
}
|
|
213
|
+
/** Higher for scopes with higher precedence; `'global'` is 0. */
|
|
214
|
+
#scopeRank(scope = GLOBAL_SCOPE) {
|
|
215
|
+
return this.#scopeOrder.indexOf(scope) + 1;
|
|
216
|
+
}
|
|
217
|
+
/** Recomputes scope precedence and publishes it. */
|
|
218
|
+
#updateScopes() {
|
|
219
|
+
const parents = /* @__PURE__ */ new Map();
|
|
220
|
+
const latest = /* @__PURE__ */ new Map();
|
|
221
|
+
for (const activation of this.#scopeActivations) {
|
|
222
|
+
parents.set(activation.scope, activation.parent);
|
|
223
|
+
latest.set(activation.scope, Math.max(latest.get(activation.scope) ?? 0, activation.order));
|
|
224
|
+
}
|
|
225
|
+
const depth = (scope) => {
|
|
226
|
+
let result = 0;
|
|
227
|
+
const seen = /* @__PURE__ */ new Set();
|
|
228
|
+
for (let current = parents.get(scope); current !== void 0 && !seen.has(current); current = parents.get(current)) {
|
|
229
|
+
seen.add(current);
|
|
230
|
+
result++;
|
|
231
|
+
}
|
|
232
|
+
return result;
|
|
233
|
+
};
|
|
234
|
+
const since = /* @__PURE__ */ new Map();
|
|
235
|
+
for (const [scope, order] of latest) {
|
|
236
|
+
const seen = /* @__PURE__ */ new Set();
|
|
237
|
+
for (let current = scope; current !== void 0 && !seen.has(current); current = parents.get(current)) {
|
|
238
|
+
seen.add(current);
|
|
239
|
+
const own = latest.get(current);
|
|
240
|
+
if (own !== void 0) since.set(current, Math.min(since.get(current) ?? own, order));
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
const compare = (a, b) => since.get(a) - since.get(b) || depth(a) - depth(b) || latest.get(a) - latest.get(b);
|
|
244
|
+
this.#scopeOrder = [...latest.keys()].sort(compare);
|
|
245
|
+
const exclusive = [...this.#scopeActivations].filter((activation) => activation.allow).sort((a, b) => compare(a.scope, b.scope) || a.order - b.order).pop();
|
|
246
|
+
const activated = [GLOBAL_SCOPE, ...this.#scopeOrder];
|
|
247
|
+
let allowed = [];
|
|
248
|
+
this.#liveScopes = null;
|
|
249
|
+
if (exclusive?.allow) {
|
|
250
|
+
const { allow } = exclusive;
|
|
251
|
+
const isNested = (scope) => {
|
|
252
|
+
const seen = /* @__PURE__ */ new Set();
|
|
253
|
+
for (let current = scope; current !== void 0 && !seen.has(current); current = parents.get(current)) {
|
|
254
|
+
if (current === exclusive.scope) return true;
|
|
255
|
+
seen.add(current);
|
|
256
|
+
}
|
|
257
|
+
return false;
|
|
258
|
+
};
|
|
259
|
+
allowed = activated.filter((scope) => !isNested(scope) && allow.includes(scope));
|
|
260
|
+
this.#liveScopes = /* @__PURE__ */ new Set([...activated.filter(isNested), ...allowed]);
|
|
261
|
+
}
|
|
262
|
+
const live = this.#liveScopes;
|
|
263
|
+
this.scopes.setState(() => ({
|
|
264
|
+
active: this.#scopeOrder,
|
|
265
|
+
exclusive: exclusive?.scope ?? null,
|
|
266
|
+
allowed,
|
|
267
|
+
suspended: live ? activated.filter((scope) => !live.has(scope)) : []
|
|
268
|
+
}));
|
|
269
|
+
this.#notify();
|
|
270
|
+
}
|
|
271
|
+
/** Whether a registration can fire now: enabled, in an active scope. */
|
|
272
|
+
#isLive(registration) {
|
|
273
|
+
return registration.options.enabled !== false && this.isScopeActive(registration.options.scope);
|
|
274
|
+
}
|
|
148
275
|
/** Notifies subscribers after in-place updates such as counts and sequence progress. */
|
|
149
276
|
#notify() {
|
|
150
277
|
this.registrations.setState((prev) => new Map(prev));
|
|
@@ -188,21 +315,23 @@ var HotkeyManager = class HotkeyManager {
|
|
|
188
315
|
hasFired: false
|
|
189
316
|
};
|
|
190
317
|
let registration;
|
|
191
|
-
if (sequence)
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
318
|
+
if (sequence) {
|
|
319
|
+
const parsedSequence = parseHotkey(hotkey, platform);
|
|
320
|
+
registration = {
|
|
321
|
+
...common,
|
|
322
|
+
kind: "sequence",
|
|
323
|
+
hotkey: hotkey.map((step, index) => chordString(step, parsedSequence[index])),
|
|
324
|
+
parsedSequence,
|
|
325
|
+
currentIndex: 0,
|
|
326
|
+
lastKeyTime: 0
|
|
327
|
+
};
|
|
328
|
+
} else {
|
|
329
|
+
const parsedChord = parseHotkey(hotkey, platform);
|
|
201
330
|
registration = {
|
|
202
331
|
...common,
|
|
203
332
|
kind: "chord",
|
|
204
|
-
hotkey:
|
|
205
|
-
|
|
333
|
+
hotkey: chordString(hotkey, parsedChord),
|
|
334
|
+
parsedChord
|
|
206
335
|
};
|
|
207
336
|
}
|
|
208
337
|
const conflicting = this.#findConflictingRegistration(registration);
|
|
@@ -262,6 +391,35 @@ var HotkeyManager = class HotkeyManager {
|
|
|
262
391
|
return next;
|
|
263
392
|
});
|
|
264
393
|
this.#listeners.remove(target, !!registration.options.capture, id);
|
|
394
|
+
this.#clearSequenceReset(id);
|
|
395
|
+
}
|
|
396
|
+
/**
|
|
397
|
+
* Clears a sequence's progress, and notifies subscribers, once its timeout
|
|
398
|
+
* passes without another step. Key presses check the timeout too, so this
|
|
399
|
+
* only keeps views such as `matchedStepCount` current.
|
|
400
|
+
*/
|
|
401
|
+
#scheduleSequenceReset(id, delay) {
|
|
402
|
+
this.#clearSequenceReset(id);
|
|
403
|
+
this.#sequenceResetTimers.set(id, setTimeout(() => {
|
|
404
|
+
this.#sequenceResetTimers.delete(id);
|
|
405
|
+
const registration = this.registrations.state.get(id);
|
|
406
|
+
if (registration?.kind !== "sequence" || registration.currentIndex === 0) return;
|
|
407
|
+
const timeout = registration.options.timeout ?? 1e3;
|
|
408
|
+
const remaining = registration.lastKeyTime + timeout - Date.now();
|
|
409
|
+
if (remaining >= 0) {
|
|
410
|
+
this.#scheduleSequenceReset(id, remaining + 1);
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
registration.currentIndex = 0;
|
|
414
|
+
registration.lastKeyTime = 0;
|
|
415
|
+
this.#notify();
|
|
416
|
+
}, delay));
|
|
417
|
+
}
|
|
418
|
+
#clearSequenceReset(id) {
|
|
419
|
+
const timer = this.#sequenceResetTimers.get(id);
|
|
420
|
+
if (timer === void 0) return;
|
|
421
|
+
clearTimeout(timer);
|
|
422
|
+
this.#sequenceResetTimers.delete(id);
|
|
265
423
|
}
|
|
266
424
|
/** Registrations of one kind in the listener phase, in registration order. */
|
|
267
425
|
#registrationsIn(ids, capture, kind) {
|
|
@@ -298,16 +456,18 @@ var HotkeyManager = class HotkeyManager {
|
|
|
298
456
|
const matches = /* @__PURE__ */ new Map();
|
|
299
457
|
let bestScore = 0;
|
|
300
458
|
for (const registration of registrations) {
|
|
301
|
-
if (!registration
|
|
302
|
-
const result = matchKeyboardEvent(event, registration.
|
|
459
|
+
if (!this.#isLive(registration) || registration.options.eventType !== eventType || !isEventForTarget(event, target) || ignoresInputs(registration) && shouldIgnoreInputEvent(event, target, registration.target)) continue;
|
|
460
|
+
const result = matchKeyboardEvent(event, registration.parsedChord, registration.options.platform);
|
|
303
461
|
if (result.matched) {
|
|
304
462
|
matches.set(registration.id, result);
|
|
305
463
|
bestScore = Math.max(bestScore, result.score);
|
|
306
464
|
}
|
|
307
465
|
}
|
|
466
|
+
let bestRank = -1;
|
|
467
|
+
for (const registration of registrations) if (matches.get(registration.id)?.score === bestScore) bestRank = Math.max(bestRank, this.#scopeRank(registration.options.scope));
|
|
308
468
|
for (const snapshot of registrations) {
|
|
309
469
|
const match = matches.get(snapshot.id);
|
|
310
|
-
if (!match?.matched || match.score !== bestScore) continue;
|
|
470
|
+
if (!match?.matched || match.score !== bestScore || this.#scopeRank(snapshot.options.scope) !== bestRank) continue;
|
|
311
471
|
const registration = this.#currentIn(snapshot, capture);
|
|
312
472
|
if (!registration) continue;
|
|
313
473
|
this.#consume(registration, event);
|
|
@@ -346,7 +506,7 @@ var HotkeyManager = class HotkeyManager {
|
|
|
346
506
|
const path = event.composedPath();
|
|
347
507
|
const now = Date.now();
|
|
348
508
|
for (const registration of this.registrations.state.values()) {
|
|
349
|
-
if (registration.kind !== "sequence" || registration.currentIndex === 0 || !registration
|
|
509
|
+
if (registration.kind !== "sequence" || registration.currentIndex === 0 || !this.#isLive(registration) || registration.options.eventType !== eventType || now - registration.lastKeyTime > (registration.options.timeout ?? 1e3)) continue;
|
|
350
510
|
const target = registration.target;
|
|
351
511
|
if (path.includes(target.nodeType === 1 ? target : getTargetDocument(target)) && !(ignoresInputs(registration) && shouldIgnoreInputEvent(event, target, target)) && matchKeyboardEvent(event, registration.parsedSequence[registration.currentIndex], registration.options.platform).matched) return true;
|
|
352
512
|
}
|
|
@@ -358,6 +518,7 @@ var HotkeyManager = class HotkeyManager {
|
|
|
358
518
|
let changed = false;
|
|
359
519
|
for (const registration of registrations) if (registration.currentIndex > 0) {
|
|
360
520
|
registration.currentIndex = 0;
|
|
521
|
+
this.#clearSequenceReset(registration.id);
|
|
361
522
|
changed = true;
|
|
362
523
|
}
|
|
363
524
|
if (changed) this.#notify();
|
|
@@ -370,7 +531,7 @@ var HotkeyManager = class HotkeyManager {
|
|
|
370
531
|
const candidates = /* @__PURE__ */ new Map();
|
|
371
532
|
let bestScore = 0;
|
|
372
533
|
for (const registration of registrations) {
|
|
373
|
-
if (!isEventForTarget(event, target) || !registration
|
|
534
|
+
if (!isEventForTarget(event, target) || !this.#isLive(registration) || ignoresInputs(registration) && shouldIgnoreInputEvent(event, target, registration.target) || registration.options.eventType !== eventType) continue;
|
|
374
535
|
const timeout = registration.options.timeout ?? 1e3;
|
|
375
536
|
if (registration.currentIndex > 0 && now - registration.lastKeyTime > timeout) {
|
|
376
537
|
registration.currentIndex = 0;
|
|
@@ -402,12 +563,19 @@ var HotkeyManager = class HotkeyManager {
|
|
|
402
563
|
this.#consume(registration, event);
|
|
403
564
|
registration.currentIndex = 0;
|
|
404
565
|
completed.push(registration);
|
|
405
|
-
}
|
|
566
|
+
} else if (registration.options.preventDefault) event.preventDefault();
|
|
406
567
|
} else if (registration.currentIndex > 0) {
|
|
407
568
|
registration.currentIndex = 0;
|
|
408
569
|
changed = true;
|
|
409
570
|
}
|
|
410
571
|
}
|
|
572
|
+
for (const registration of registrations) {
|
|
573
|
+
if (!candidates.has(registration.id)) continue;
|
|
574
|
+
if (registration.currentIndex > 0) {
|
|
575
|
+
const timeout = registration.options.timeout ?? 1e3;
|
|
576
|
+
this.#scheduleSequenceReset(registration.id, timeout + 1);
|
|
577
|
+
} else this.#clearSequenceReset(registration.id);
|
|
578
|
+
}
|
|
411
579
|
if (changed && completed.length === 0) this.#notify();
|
|
412
580
|
for (const snapshot of completed) {
|
|
413
581
|
const registration = this.#currentIn(snapshot, capture);
|
|
@@ -427,26 +595,31 @@ var HotkeyManager = class HotkeyManager {
|
|
|
427
595
|
registration.callback(event, context);
|
|
428
596
|
}
|
|
429
597
|
#callbackContext(registration) {
|
|
430
|
-
return {
|
|
598
|
+
return registration.kind === "chord" ? {
|
|
599
|
+
kind: "chord",
|
|
600
|
+
hotkey: registration.hotkey,
|
|
601
|
+
parsedChord: registration.parsedChord
|
|
602
|
+
} : {
|
|
603
|
+
kind: "sequence",
|
|
431
604
|
hotkey: registration.hotkey,
|
|
432
|
-
|
|
605
|
+
parsedSequence: registration.parsedSequence
|
|
433
606
|
};
|
|
434
607
|
}
|
|
435
608
|
/**
|
|
436
|
-
* Finds an existing registration on the same target whose steps
|
|
437
|
-
* the same identity. Each side keeps its own platform, so `Mod+S` on macOS
|
|
609
|
+
* Finds an existing registration on the same target and scope whose steps
|
|
610
|
+
* resolve to the same identity. Each side keeps its own platform, so `Mod+S` on macOS
|
|
438
611
|
* and `Mod+S` on Windows are different bindings.
|
|
439
612
|
*/
|
|
440
613
|
#findConflictingRegistration(candidate) {
|
|
441
614
|
const steps = resolvedSteps(candidate);
|
|
442
|
-
for (const registration of this.registrations.state.values()) if (registration.target === candidate.target && sameResolvedSteps(resolvedSteps(registration), steps)) return registration;
|
|
615
|
+
for (const registration of this.registrations.state.values()) if (registration.target === candidate.target && registration.options.scope === candidate.options.scope && sameResolvedSteps(resolvedSteps(registration), steps)) return registration;
|
|
443
616
|
return null;
|
|
444
617
|
}
|
|
445
618
|
/**
|
|
446
619
|
* Determines if a registration should be reset based on the keyup event.
|
|
447
620
|
*/
|
|
448
621
|
#shouldResetRegistration(registration, event) {
|
|
449
|
-
const parsed = registration.
|
|
622
|
+
const parsed = registration.parsedChord;
|
|
450
623
|
const releasedKey = normalizeKeyName(event.key);
|
|
451
624
|
if (registration.activeMatch?.code && event.code === registration.activeMatch.code) return true;
|
|
452
625
|
return (parsed.code !== void 0 ? event.code === parsed.code : keysEqual(releasedKey, parsed.key)) || parsed.modifiers.includes(releasedKey);
|
|
@@ -459,6 +632,8 @@ var HotkeyManager = class HotkeyManager {
|
|
|
459
632
|
registration.currentIndex = 0;
|
|
460
633
|
registration.lastKeyTime = 0;
|
|
461
634
|
}
|
|
635
|
+
for (const timer of this.#sequenceResetTimers.values()) clearTimeout(timer);
|
|
636
|
+
this.#sequenceResetTimers.clear();
|
|
462
637
|
this.#notify();
|
|
463
638
|
}
|
|
464
639
|
/**
|
|
@@ -473,7 +648,7 @@ var HotkeyManager = class HotkeyManager {
|
|
|
473
648
|
const registration = this.registrations.state.get(id);
|
|
474
649
|
if (!registration) return false;
|
|
475
650
|
const context = this.#callbackContext(registration);
|
|
476
|
-
const parsed =
|
|
651
|
+
const parsed = registration.kind === "chord" ? registration.parsedChord : registration.parsedSequence[registration.parsedSequence.length - 1];
|
|
477
652
|
const syntheticEvent = new KeyboardEvent(registration.options.eventType ?? "keydown", {
|
|
478
653
|
...parsed.code !== void 0 ? { code: parsed.code } : { key: parsed.key },
|
|
479
654
|
ctrlKey: parsed.ctrl,
|
|
@@ -507,6 +682,8 @@ var HotkeyManager = class HotkeyManager {
|
|
|
507
682
|
* Destroys the manager and removes all listeners.
|
|
508
683
|
*/
|
|
509
684
|
destroy() {
|
|
685
|
+
for (const timer of this.#sequenceResetTimers.values()) clearTimeout(timer);
|
|
686
|
+
this.#sequenceResetTimers.clear();
|
|
510
687
|
this.#listeners.destroy();
|
|
511
688
|
this.registrations.setState(() => /* @__PURE__ */ new Map());
|
|
512
689
|
this.appHotkeys.setState(() => null);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Hotkey, HotkeyChord, HotkeySequence,
|
|
1
|
+
import { Hotkey, HotkeyChord, HotkeySequence, ParsedHotkeyChord } from "./hotkey.types.js";
|
|
2
2
|
import { HotkeyConflict, HotkeyConflictOptions } from "./conflicts.js";
|
|
3
3
|
import { Store } from "@tanstack/store";
|
|
4
4
|
//#region src/hotkey-recorder.d.ts
|
|
@@ -21,17 +21,27 @@ export interface RecorderOptions {
|
|
|
21
21
|
/** Rejected candidates leave recording active. */
|
|
22
22
|
onReject?: (rejection: RecorderRejection) => void;
|
|
23
23
|
}
|
|
24
|
-
/** Validation context for a recorded chord
|
|
25
|
-
export interface
|
|
26
|
-
|
|
24
|
+
/** Validation context for a recorded chord. */
|
|
25
|
+
export interface HotkeyChordRecorderValidationContext {
|
|
26
|
+
kind: 'chord';
|
|
27
|
+
/** The event that recorded the chord. */
|
|
27
28
|
event: KeyboardEvent;
|
|
28
|
-
/** The
|
|
29
|
-
|
|
29
|
+
/** The chord with `Mod` resolved for the recorder's platform. */
|
|
30
|
+
parsedChord: ParsedHotkeyChord;
|
|
31
|
+
}
|
|
32
|
+
/** Validation context for a recorded sequence. */
|
|
33
|
+
export interface HotkeySequenceRecorderValidationContext {
|
|
34
|
+
kind: 'sequence';
|
|
30
35
|
/** The event that recorded each step. */
|
|
31
36
|
events: ReadonlyArray<KeyboardEvent>;
|
|
32
|
-
/**
|
|
33
|
-
parsedSequence:
|
|
37
|
+
/** Each step with `Mod` resolved for the recorder's platform. */
|
|
38
|
+
parsedSequence: Array<ParsedHotkeyChord>;
|
|
34
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* Validation context for a recorded chord or sequence. Narrow with `kind` to
|
|
42
|
+
* read `parsedChord` or `parsedSequence`.
|
|
43
|
+
*/
|
|
44
|
+
export type HotkeyRecorderValidationContext = HotkeyChordRecorderValidationContext | HotkeySequenceRecorderValidationContext;
|
|
35
45
|
/**
|
|
36
46
|
* State interface for the HotkeyRecorder.
|
|
37
47
|
*/
|
|
@@ -65,7 +75,7 @@ export interface HotkeyChordRecorderOptions extends HotkeyRecorderBaseOptions {
|
|
|
65
75
|
/** Maximum number of chords to record. Default: 1 */
|
|
66
76
|
maxSteps?: 1;
|
|
67
77
|
/** Return true to accept, or false/a message to reject while staying in recording mode. */
|
|
68
|
-
validate?: (hotkey: HotkeyChord, context:
|
|
78
|
+
validate?: (hotkey: HotkeyChord, context: HotkeyChordRecorderValidationContext) => boolean | string;
|
|
69
79
|
/** Callback when a chord is successfully recorded */
|
|
70
80
|
onRecord: (hotkey: HotkeyChord) => void;
|
|
71
81
|
}
|
|
@@ -113,7 +123,7 @@ export type HotkeyRecorderOptions = HotkeyChordRecorderOptions | HotkeySequenceR
|
|
|
113
123
|
* })
|
|
114
124
|
*
|
|
115
125
|
* const sequenceRecorder = new HotkeyRecorder({
|
|
116
|
-
* maxSteps:
|
|
126
|
+
* maxSteps: Infinity,
|
|
117
127
|
* onRecord: (hotkey) => {
|
|
118
128
|
* console.log('Recorded:', hotkey) // 'Mod+S' or ['G', 'H']
|
|
119
129
|
* },
|