@tanstack/hotkeys 0.15.0 → 0.16.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 +1 -1
- package/dist/_keyboard-event.js +19 -3
- package/dist/_manager-ref.js +10 -0
- package/dist/_match.js +9 -1
- package/dist/_recorder-chord.js +10 -6
- 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 +9 -8
- package/dist/constants.d.ts +1 -24
- package/dist/constants.js +21 -83
- package/dist/format.d.ts +13 -9
- package/dist/format.js +12 -28
- package/dist/hint.js +2 -2
- package/dist/hotkey-manager.d.ts +94 -11
- package/dist/hotkey-manager.js +231 -78
- package/dist/hotkey-recorder.d.ts +44 -4
- package/dist/hotkey-recorder.js +9 -5
- package/dist/hotkey.types.d.ts +72 -46
- package/dist/index.d.ts +6 -7
- package/dist/index.js +3 -3
- package/dist/key-state-tracker.js +36 -31
- package/dist/match.d.ts +19 -18
- package/dist/match.js +26 -40
- package/dist/parse.d.ts +36 -73
- package/dist/parse.js +58 -151
- package/dist/validate.js +8 -24
- package/package.json +1 -1
- package/dist/recorder-options.d.ts +0 -34
package/dist/hotkey-manager.js
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
import { normalizeKeyName } from "./constants.js";
|
|
2
2
|
import { detectPlatform } from "./platform.js";
|
|
3
|
-
import { normalizeKeyboardEvent } from "./_keyboard-event.js";
|
|
4
|
-
import { isHotkeySequence, isModifierKey, parseHotkey,
|
|
3
|
+
import { keysEqual, normalizeKeyboardEvent } from "./_keyboard-event.js";
|
|
4
|
+
import { isHotkeySequence, isModifierKey, parseHotkey, simplifyHotkey } from "./parse.js";
|
|
5
5
|
import { formatHotkey } from "./format.js";
|
|
6
|
-
import { matchKeyboardEvent } from "./_match.js";
|
|
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
|
+
import { managerRef } from "./_manager-ref.js";
|
|
10
11
|
import { isRecordingEvent } from "./_recording-guard.js";
|
|
11
|
-
import { defaultHotkeyOptions, getDefaultIgnoreInputs, handleConflict, optionsEqual } from "./_registration.js";
|
|
12
|
+
import { GLOBAL_SCOPE, defaultHotkeyOptions, getDefaultIgnoreInputs, handleConflict, optionsEqual } from "./_registration.js";
|
|
12
13
|
import { Store } from "@tanstack/store";
|
|
13
14
|
|
|
14
15
|
//#region src/hotkey-manager.ts
|
|
@@ -24,7 +25,7 @@ function toHotkeyRegistrationView(reg) {
|
|
|
24
25
|
...base,
|
|
25
26
|
kind: "chord",
|
|
26
27
|
hotkey: reg.hotkey,
|
|
27
|
-
|
|
28
|
+
parsedChord: reg.parsedChord,
|
|
28
29
|
options: reg.options
|
|
29
30
|
};
|
|
30
31
|
const length = reg.parsedSequence.length;
|
|
@@ -40,6 +41,10 @@ function toHotkeyRegistrationView(reg) {
|
|
|
40
41
|
};
|
|
41
42
|
}
|
|
42
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
|
+
}
|
|
43
48
|
/**
|
|
44
49
|
* Generates a unique ID for hotkey registrations.
|
|
45
50
|
*/
|
|
@@ -57,7 +62,7 @@ function optionsForKind(kind, options) {
|
|
|
57
62
|
}
|
|
58
63
|
/** Steps with Mod already resolved for the registration's platform. */
|
|
59
64
|
function resolvedSteps(registration) {
|
|
60
|
-
return registration.kind === "chord" ? [registration.
|
|
65
|
+
return registration.kind === "chord" ? [registration.parsedChord] : registration.parsedSequence;
|
|
61
66
|
}
|
|
62
67
|
/** Whether a registration skips keys that focused controls use, resolving `'auto'`. */
|
|
63
68
|
function ignoresInputs(registration) {
|
|
@@ -65,7 +70,7 @@ function ignoresInputs(registration) {
|
|
|
65
70
|
return ignoreInputs === "auto" ? getDefaultIgnoreInputs(resolvedSteps(registration)[0]) : ignoreInputs;
|
|
66
71
|
}
|
|
67
72
|
function sameResolvedSteps(left, right) {
|
|
68
|
-
return left.length === right.length && left.every((step, index) =>
|
|
73
|
+
return left.length === right.length && left.every((step, index) => parsedChordsEqual(step, right[index]));
|
|
69
74
|
}
|
|
70
75
|
function formatForMessage(hotkey) {
|
|
71
76
|
return isHotkeySequence(hotkey) ? hotkey.join(" ") : hotkey;
|
|
@@ -97,7 +102,6 @@ function formatForMessage(hotkey) {
|
|
|
97
102
|
* ```
|
|
98
103
|
*/
|
|
99
104
|
var HotkeyManager = class HotkeyManager {
|
|
100
|
-
static #instance = null;
|
|
101
105
|
/**
|
|
102
106
|
* The TanStack Store containing all hotkey registrations, chords and sequences.
|
|
103
107
|
* Use this to subscribe to registration changes or access current registrations.
|
|
@@ -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
|
}
|
|
@@ -136,17 +155,122 @@ var HotkeyManager = class HotkeyManager {
|
|
|
136
155
|
* Gets the singleton instance of HotkeyManager.
|
|
137
156
|
*/
|
|
138
157
|
static getInstance() {
|
|
139
|
-
|
|
140
|
-
return HotkeyManager.#instance;
|
|
158
|
+
return managerRef.current ??= new HotkeyManager();
|
|
141
159
|
}
|
|
142
160
|
/**
|
|
143
161
|
* Resets the singleton instance. Useful for testing.
|
|
144
162
|
*/
|
|
145
163
|
static resetInstance() {
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
164
|
+
managerRef.current?.destroy();
|
|
165
|
+
managerRef.current = null;
|
|
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]);
|
|
149
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);
|
|
150
274
|
}
|
|
151
275
|
/** Notifies subscribers after in-place updates such as counts and sequence progress. */
|
|
152
276
|
#notify() {
|
|
@@ -175,44 +299,39 @@ var HotkeyManager = class HotkeyManager {
|
|
|
175
299
|
const id = generateId();
|
|
176
300
|
const platform = options.platform ?? this.#platform;
|
|
177
301
|
const target = options.target ?? document;
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
kind: "sequence",
|
|
302
|
+
const sequence = isHotkeySequence(hotkey);
|
|
303
|
+
const common = {
|
|
181
304
|
id,
|
|
182
|
-
hotkey,
|
|
183
|
-
parsedSequence: hotkey.map((step) => parseHotkey(step, platform)),
|
|
184
305
|
callback,
|
|
185
306
|
options: {
|
|
186
307
|
...defaultHotkeyOptions,
|
|
187
|
-
timeout: DEFAULT_SEQUENCE_TIMEOUT,
|
|
188
|
-
...optionsForKind("sequence", options),
|
|
308
|
+
...sequence ? { timeout: DEFAULT_SEQUENCE_TIMEOUT } : { requireReset: false },
|
|
309
|
+
...optionsForKind(sequence ? "sequence" : "chord", options),
|
|
189
310
|
platform,
|
|
190
311
|
ignoreInputs: options.ignoreInputs ?? "auto"
|
|
191
312
|
},
|
|
192
313
|
target,
|
|
193
|
-
currentIndex: 0,
|
|
194
|
-
lastKeyTime: 0,
|
|
195
314
|
triggerCount: 0,
|
|
196
315
|
hasFired: false
|
|
197
316
|
};
|
|
198
|
-
|
|
199
|
-
|
|
317
|
+
let registration;
|
|
318
|
+
if (sequence) {
|
|
319
|
+
const parsedSequence = parseHotkey(hotkey, platform);
|
|
200
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);
|
|
330
|
+
registration = {
|
|
331
|
+
...common,
|
|
201
332
|
kind: "chord",
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
parsedHotkey,
|
|
205
|
-
callback,
|
|
206
|
-
options: {
|
|
207
|
-
...defaultHotkeyOptions,
|
|
208
|
-
requireReset: false,
|
|
209
|
-
...optionsForKind("chord", options),
|
|
210
|
-
platform,
|
|
211
|
-
ignoreInputs: options.ignoreInputs ?? "auto"
|
|
212
|
-
},
|
|
213
|
-
target,
|
|
214
|
-
hasFired: false,
|
|
215
|
-
triggerCount: 0
|
|
333
|
+
hotkey: chordString(hotkey, parsedChord),
|
|
334
|
+
parsedChord
|
|
216
335
|
};
|
|
217
336
|
}
|
|
218
337
|
const conflicting = this.#findConflictingRegistration(registration);
|
|
@@ -272,6 +391,35 @@ var HotkeyManager = class HotkeyManager {
|
|
|
272
391
|
return next;
|
|
273
392
|
});
|
|
274
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);
|
|
275
423
|
}
|
|
276
424
|
/** Registrations of one kind in the listener phase, in registration order. */
|
|
277
425
|
#registrationsIn(ids, capture, kind) {
|
|
@@ -308,30 +456,31 @@ var HotkeyManager = class HotkeyManager {
|
|
|
308
456
|
const matches = /* @__PURE__ */ new Map();
|
|
309
457
|
let bestScore = 0;
|
|
310
458
|
for (const registration of registrations) {
|
|
311
|
-
if (!registration
|
|
312
|
-
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);
|
|
313
461
|
if (result.matched) {
|
|
314
462
|
matches.set(registration.id, result);
|
|
315
463
|
bestScore = Math.max(bestScore, result.score);
|
|
316
464
|
}
|
|
317
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));
|
|
318
468
|
for (const snapshot of registrations) {
|
|
319
469
|
const match = matches.get(snapshot.id);
|
|
320
|
-
if (!match?.matched || match.score !== bestScore) continue;
|
|
470
|
+
if (!match?.matched || match.score !== bestScore || this.#scopeRank(snapshot.options.scope) !== bestRank) continue;
|
|
321
471
|
const registration = this.#currentIn(snapshot, capture);
|
|
322
472
|
if (!registration) continue;
|
|
473
|
+
this.#consume(registration, event);
|
|
323
474
|
if (eventType === "keyup") {
|
|
324
|
-
this.#
|
|
475
|
+
this.#fire(registration, event);
|
|
325
476
|
continue;
|
|
326
477
|
}
|
|
327
|
-
if (registration.options.preventDefault) event.preventDefault();
|
|
328
|
-
if (registration.options.stopPropagation) event.stopPropagation();
|
|
329
478
|
if (!registration.options.requireReset || !registration.hasFired) {
|
|
330
479
|
if (registration.options.requireReset) {
|
|
331
480
|
registration.hasFired = true;
|
|
332
481
|
registration.activeMatch = match.identity;
|
|
333
482
|
}
|
|
334
|
-
this.#
|
|
483
|
+
this.#fire(registration, event);
|
|
335
484
|
}
|
|
336
485
|
}
|
|
337
486
|
}
|
|
@@ -357,7 +506,7 @@ var HotkeyManager = class HotkeyManager {
|
|
|
357
506
|
const path = event.composedPath();
|
|
358
507
|
const now = Date.now();
|
|
359
508
|
for (const registration of this.registrations.state.values()) {
|
|
360
|
-
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;
|
|
361
510
|
const target = registration.target;
|
|
362
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;
|
|
363
512
|
}
|
|
@@ -369,6 +518,7 @@ var HotkeyManager = class HotkeyManager {
|
|
|
369
518
|
let changed = false;
|
|
370
519
|
for (const registration of registrations) if (registration.currentIndex > 0) {
|
|
371
520
|
registration.currentIndex = 0;
|
|
521
|
+
this.#clearSequenceReset(registration.id);
|
|
372
522
|
changed = true;
|
|
373
523
|
}
|
|
374
524
|
if (changed) this.#notify();
|
|
@@ -381,7 +531,7 @@ var HotkeyManager = class HotkeyManager {
|
|
|
381
531
|
const candidates = /* @__PURE__ */ new Map();
|
|
382
532
|
let bestScore = 0;
|
|
383
533
|
for (const registration of registrations) {
|
|
384
|
-
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;
|
|
385
535
|
const timeout = registration.options.timeout ?? 1e3;
|
|
386
536
|
if (registration.currentIndex > 0 && now - registration.lastKeyTime > timeout) {
|
|
387
537
|
registration.currentIndex = 0;
|
|
@@ -410,67 +560,69 @@ var HotkeyManager = class HotkeyManager {
|
|
|
410
560
|
registration.currentIndex = candidate.nextIndex;
|
|
411
561
|
changed = true;
|
|
412
562
|
if (registration.currentIndex >= registration.parsedSequence.length) {
|
|
413
|
-
|
|
414
|
-
if (registration.options.stopPropagation) event.stopPropagation();
|
|
563
|
+
this.#consume(registration, event);
|
|
415
564
|
registration.currentIndex = 0;
|
|
416
565
|
completed.push(registration);
|
|
417
|
-
}
|
|
566
|
+
} else if (registration.options.preventDefault) event.preventDefault();
|
|
418
567
|
} else if (registration.currentIndex > 0) {
|
|
419
568
|
registration.currentIndex = 0;
|
|
420
569
|
changed = true;
|
|
421
570
|
}
|
|
422
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
|
+
}
|
|
423
579
|
if (changed && completed.length === 0) this.#notify();
|
|
424
580
|
for (const snapshot of completed) {
|
|
425
581
|
const registration = this.#currentIn(snapshot, capture);
|
|
426
|
-
if (registration) this.#
|
|
582
|
+
if (registration) this.#fire(registration, event);
|
|
427
583
|
}
|
|
428
584
|
}
|
|
429
|
-
/**
|
|
430
|
-
|
|
431
|
-
*/
|
|
432
|
-
#executeCallback(registration, event) {
|
|
585
|
+
/** Applies a matched registration's preventDefault and stopPropagation options. */
|
|
586
|
+
#consume(registration, event) {
|
|
433
587
|
if (registration.options.preventDefault) event.preventDefault();
|
|
434
588
|
if (registration.options.stopPropagation) event.stopPropagation();
|
|
589
|
+
}
|
|
590
|
+
/** Counts a trigger, notifies subscribers (such as devtools), and runs the callback. */
|
|
591
|
+
#fire(registration, event, context = this.#callbackContext(registration)) {
|
|
435
592
|
registration.triggerCount++;
|
|
436
593
|
if (registration.kind === "sequence") registration.hasFired = true;
|
|
437
594
|
this.#notify();
|
|
438
|
-
registration.callback(event,
|
|
595
|
+
registration.callback(event, context);
|
|
439
596
|
}
|
|
440
597
|
#callbackContext(registration) {
|
|
441
|
-
return {
|
|
598
|
+
return registration.kind === "chord" ? {
|
|
599
|
+
kind: "chord",
|
|
600
|
+
hotkey: registration.hotkey,
|
|
601
|
+
parsedChord: registration.parsedChord
|
|
602
|
+
} : {
|
|
603
|
+
kind: "sequence",
|
|
442
604
|
hotkey: registration.hotkey,
|
|
443
|
-
|
|
605
|
+
parsedSequence: registration.parsedSequence
|
|
444
606
|
};
|
|
445
607
|
}
|
|
446
608
|
/**
|
|
447
|
-
* Finds an existing registration on the same target whose steps
|
|
448
|
-
* 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
|
|
449
611
|
* and `Mod+S` on Windows are different bindings.
|
|
450
612
|
*/
|
|
451
613
|
#findConflictingRegistration(candidate) {
|
|
452
614
|
const steps = resolvedSteps(candidate);
|
|
453
|
-
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;
|
|
454
616
|
return null;
|
|
455
617
|
}
|
|
456
618
|
/**
|
|
457
619
|
* Determines if a registration should be reset based on the keyup event.
|
|
458
620
|
*/
|
|
459
621
|
#shouldResetRegistration(registration, event) {
|
|
460
|
-
const parsed = registration.
|
|
622
|
+
const parsed = registration.parsedChord;
|
|
461
623
|
const releasedKey = normalizeKeyName(event.key);
|
|
462
624
|
if (registration.activeMatch?.code && event.code === registration.activeMatch.code) return true;
|
|
463
|
-
|
|
464
|
-
if (event.code === parsed.code) return true;
|
|
465
|
-
} else {
|
|
466
|
-
const parsedKeyNormalized = parsed.key.length === 1 ? parsed.key.toUpperCase() : parsed.key;
|
|
467
|
-
if ((releasedKey.length === 1 ? releasedKey.toUpperCase() : releasedKey) === parsedKeyNormalized) return true;
|
|
468
|
-
}
|
|
469
|
-
if (parsed.ctrl && releasedKey === "Control") return true;
|
|
470
|
-
if (parsed.shift && releasedKey === "Shift") return true;
|
|
471
|
-
if (parsed.alt && releasedKey === "Alt") return true;
|
|
472
|
-
if (parsed.meta && releasedKey === "Meta") return true;
|
|
473
|
-
return false;
|
|
625
|
+
return (parsed.code !== void 0 ? event.code === parsed.code : keysEqual(releasedKey, parsed.key)) || parsed.modifiers.includes(releasedKey);
|
|
474
626
|
}
|
|
475
627
|
/**
|
|
476
628
|
* Resets the progress of every in-progress sequence.
|
|
@@ -480,6 +632,8 @@ var HotkeyManager = class HotkeyManager {
|
|
|
480
632
|
registration.currentIndex = 0;
|
|
481
633
|
registration.lastKeyTime = 0;
|
|
482
634
|
}
|
|
635
|
+
for (const timer of this.#sequenceResetTimers.values()) clearTimeout(timer);
|
|
636
|
+
this.#sequenceResetTimers.clear();
|
|
483
637
|
this.#notify();
|
|
484
638
|
}
|
|
485
639
|
/**
|
|
@@ -494,7 +648,7 @@ var HotkeyManager = class HotkeyManager {
|
|
|
494
648
|
const registration = this.registrations.state.get(id);
|
|
495
649
|
if (!registration) return false;
|
|
496
650
|
const context = this.#callbackContext(registration);
|
|
497
|
-
const parsed =
|
|
651
|
+
const parsed = registration.kind === "chord" ? registration.parsedChord : registration.parsedSequence[registration.parsedSequence.length - 1];
|
|
498
652
|
const syntheticEvent = new KeyboardEvent(registration.options.eventType ?? "keydown", {
|
|
499
653
|
...parsed.code !== void 0 ? { code: parsed.code } : { key: parsed.key },
|
|
500
654
|
ctrlKey: parsed.ctrl,
|
|
@@ -504,10 +658,7 @@ var HotkeyManager = class HotkeyManager {
|
|
|
504
658
|
bubbles: true,
|
|
505
659
|
cancelable: true
|
|
506
660
|
});
|
|
507
|
-
registration
|
|
508
|
-
if (registration.kind === "sequence") registration.hasFired = true;
|
|
509
|
-
this.#notify();
|
|
510
|
-
registration.callback(syntheticEvent, context);
|
|
661
|
+
this.#fire(registration, syntheticEvent, context);
|
|
511
662
|
return true;
|
|
512
663
|
}
|
|
513
664
|
/**
|
|
@@ -531,6 +682,8 @@ var HotkeyManager = class HotkeyManager {
|
|
|
531
682
|
* Destroys the manager and removes all listeners.
|
|
532
683
|
*/
|
|
533
684
|
destroy() {
|
|
685
|
+
for (const timer of this.#sequenceResetTimers.values()) clearTimeout(timer);
|
|
686
|
+
this.#sequenceResetTimers.clear();
|
|
534
687
|
this.#listeners.destroy();
|
|
535
688
|
this.registrations.setState(() => /* @__PURE__ */ new Map());
|
|
536
689
|
this.appHotkeys.setState(() => null);
|
|
@@ -1,7 +1,47 @@
|
|
|
1
|
-
import { Hotkey, HotkeyChord, HotkeySequence } from "./hotkey.types.js";
|
|
2
|
-
import {
|
|
1
|
+
import { Hotkey, HotkeyChord, HotkeySequence, ParsedHotkeyChord } from "./hotkey.types.js";
|
|
2
|
+
import { HotkeyConflict, HotkeyConflictOptions } from "./conflicts.js";
|
|
3
3
|
import { Store } from "@tanstack/store";
|
|
4
4
|
//#region src/hotkey-recorder.d.ts
|
|
5
|
+
/** Event property used to record a binding. Physical codes are the default. */
|
|
6
|
+
export type RecorderKeyMode = 'code' | 'key';
|
|
7
|
+
export interface RecorderRejection {
|
|
8
|
+
reason: 'missing-code' | 'alt-graph' | 'invalid' | 'conflict' | 'validation';
|
|
9
|
+
message: string;
|
|
10
|
+
/** The rejected chord or sequence, when one was recorded. */
|
|
11
|
+
hotkey?: Hotkey;
|
|
12
|
+
conflicts?: Array<HotkeyConflict>;
|
|
13
|
+
}
|
|
14
|
+
export interface RecorderOptions {
|
|
15
|
+
/** Physical code recording is the default. Choose key to record logical characters. */
|
|
16
|
+
recordBy?: RecorderKeyMode;
|
|
17
|
+
/** Platform used for Mod and AltGraph handling. Defaults to platform detection. */
|
|
18
|
+
platform?: 'mac' | 'windows' | 'linux';
|
|
19
|
+
/** Opt in to live-registry conflicts, including sequence prefixes. Default: false. */
|
|
20
|
+
detectConflicts?: boolean | HotkeyConflictOptions;
|
|
21
|
+
/** Rejected candidates leave recording active. */
|
|
22
|
+
onReject?: (rejection: RecorderRejection) => void;
|
|
23
|
+
}
|
|
24
|
+
/** Validation context for a recorded chord. */
|
|
25
|
+
export interface HotkeyChordRecorderValidationContext {
|
|
26
|
+
kind: 'chord';
|
|
27
|
+
/** The event that recorded the chord. */
|
|
28
|
+
event: KeyboardEvent;
|
|
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';
|
|
35
|
+
/** The event that recorded each step. */
|
|
36
|
+
events: ReadonlyArray<KeyboardEvent>;
|
|
37
|
+
/** Each step with `Mod` resolved for the recorder's platform. */
|
|
38
|
+
parsedSequence: Array<ParsedHotkeyChord>;
|
|
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;
|
|
5
45
|
/**
|
|
6
46
|
* State interface for the HotkeyRecorder.
|
|
7
47
|
*/
|
|
@@ -35,7 +75,7 @@ export interface HotkeyChordRecorderOptions extends HotkeyRecorderBaseOptions {
|
|
|
35
75
|
/** Maximum number of chords to record. Default: 1 */
|
|
36
76
|
maxSteps?: 1;
|
|
37
77
|
/** Return true to accept, or false/a message to reject while staying in recording mode. */
|
|
38
|
-
validate?: (hotkey: HotkeyChord, context:
|
|
78
|
+
validate?: (hotkey: HotkeyChord, context: HotkeyChordRecorderValidationContext) => boolean | string;
|
|
39
79
|
/** Callback when a chord is successfully recorded */
|
|
40
80
|
onRecord: (hotkey: HotkeyChord) => void;
|
|
41
81
|
}
|
|
@@ -83,7 +123,7 @@ export type HotkeyRecorderOptions = HotkeyChordRecorderOptions | HotkeySequenceR
|
|
|
83
123
|
* })
|
|
84
124
|
*
|
|
85
125
|
* const sequenceRecorder = new HotkeyRecorder({
|
|
86
|
-
* maxSteps:
|
|
126
|
+
* maxSteps: Infinity,
|
|
87
127
|
* onRecord: (hotkey) => {
|
|
88
128
|
* console.log('Recorded:', hotkey) // 'Mod+S' or ['G', 'H']
|
|
89
129
|
* },
|
package/dist/hotkey-recorder.js
CHANGED
|
@@ -37,7 +37,7 @@ const initialState = {
|
|
|
37
37
|
* })
|
|
38
38
|
*
|
|
39
39
|
* const sequenceRecorder = new HotkeyRecorder({
|
|
40
|
-
* maxSteps:
|
|
40
|
+
* maxSteps: Infinity,
|
|
41
41
|
* onRecord: (hotkey) => {
|
|
42
42
|
* console.log('Recorded:', hotkey) // 'Mod+S' or ['G', 'H']
|
|
43
43
|
* },
|
|
@@ -198,13 +198,17 @@ var HotkeyRecorder = class {
|
|
|
198
198
|
}
|
|
199
199
|
options.onReject?.(rejection);
|
|
200
200
|
};
|
|
201
|
-
const parsedSequence =
|
|
202
|
-
const
|
|
201
|
+
const parsedSequence = parseHotkey(steps, platform);
|
|
202
|
+
const context = steps.length === 1 ? {
|
|
203
|
+
kind: "chord",
|
|
203
204
|
event: this.#events[this.#events.length - 1],
|
|
204
|
-
|
|
205
|
+
parsedChord: parsedSequence[0]
|
|
206
|
+
} : {
|
|
207
|
+
kind: "sequence",
|
|
205
208
|
events: [...this.#events],
|
|
206
209
|
parsedSequence
|
|
207
|
-
}
|
|
210
|
+
};
|
|
211
|
+
const accepted = options.validate?.(hotkey, context);
|
|
208
212
|
if (accepted !== void 0 && accepted !== true) {
|
|
209
213
|
rejectAndKeepRecording({
|
|
210
214
|
reason: "validation",
|