@tanstack/hotkeys 0.13.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/_registration.js +2 -1
- package/dist/_target-listeners.js +73 -0
- package/dist/app-hotkeys.d.ts +1 -1
- package/dist/hotkey-manager.d.ts +13 -2
- package/dist/hotkey-manager.js +72 -60
- package/dist/key-state-tracker.js +35 -5
- package/package.json +1 -1
package/dist/_registration.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* Omitted: platform, target (resolved at registration), requireReset (HotkeyManager only).
|
|
5
5
|
*/
|
|
6
6
|
const defaultHotkeyOptions = {
|
|
7
|
+
capture: false,
|
|
7
8
|
preventDefault: true,
|
|
8
9
|
stopPropagation: true,
|
|
9
10
|
eventType: "keydown",
|
|
@@ -31,7 +32,7 @@ function getDefaultIgnoreInputs(parsedHotkey) {
|
|
|
31
32
|
function handleConflict(conflictingId, keyDisplay, conflictBehavior, unregister) {
|
|
32
33
|
if (conflictBehavior === "allow") return;
|
|
33
34
|
if (conflictBehavior === "warn") {
|
|
34
|
-
console.warn(`'${keyDisplay}' is already registered. Multiple handlers
|
|
35
|
+
console.warn(`'${keyDisplay}' is already registered. Multiple handlers may be triggered. Use conflictBehavior: 'replace' to replace the existing handler, or conflictBehavior: 'allow' to suppress this warning.`);
|
|
35
36
|
return;
|
|
36
37
|
}
|
|
37
38
|
if (conflictBehavior === "error") throw new Error(`'${keyDisplay}' is already registered. Use conflictBehavior: 'replace' to replace the existing handler, or conflictBehavior: 'allow' to allow multiple registrations.`);
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
//#region src/_target-listeners.ts
|
|
2
|
+
/** Shares keyboard listeners within each target and capture phase. */
|
|
3
|
+
var TargetListeners = class {
|
|
4
|
+
dispatch;
|
|
5
|
+
#targets = /* @__PURE__ */ new Map();
|
|
6
|
+
#processed = /* @__PURE__ */ new WeakMap();
|
|
7
|
+
constructor(dispatch) {
|
|
8
|
+
this.dispatch = dispatch;
|
|
9
|
+
}
|
|
10
|
+
add(target, capture, id) {
|
|
11
|
+
if (typeof document === "undefined") return;
|
|
12
|
+
let phases = this.#targets.get(target);
|
|
13
|
+
if (!phases) {
|
|
14
|
+
phases = /* @__PURE__ */ new Map();
|
|
15
|
+
this.#targets.set(target, phases);
|
|
16
|
+
}
|
|
17
|
+
let group = phases.get(capture);
|
|
18
|
+
if (!group) {
|
|
19
|
+
const ids = /* @__PURE__ */ new Set();
|
|
20
|
+
const listener = (eventType) => (rawEvent) => {
|
|
21
|
+
const event = rawEvent;
|
|
22
|
+
let processed = this.#processed.get(event);
|
|
23
|
+
const first = !processed || processed.groups.has(ids);
|
|
24
|
+
if (!processed || first) {
|
|
25
|
+
processed = {
|
|
26
|
+
ids: /* @__PURE__ */ new Set(),
|
|
27
|
+
groups: /* @__PURE__ */ new Set()
|
|
28
|
+
};
|
|
29
|
+
this.#processed.set(event, processed);
|
|
30
|
+
}
|
|
31
|
+
processed.groups.add(ids);
|
|
32
|
+
const pending = /* @__PURE__ */ new Set();
|
|
33
|
+
for (const registrationId of ids) if (!processed.ids.has(registrationId)) {
|
|
34
|
+
processed.ids.add(registrationId);
|
|
35
|
+
pending.add(registrationId);
|
|
36
|
+
}
|
|
37
|
+
this.dispatch(event, target, eventType, capture, pending, first);
|
|
38
|
+
};
|
|
39
|
+
group = {
|
|
40
|
+
ids,
|
|
41
|
+
keydown: listener("keydown"),
|
|
42
|
+
keyup: listener("keyup")
|
|
43
|
+
};
|
|
44
|
+
phases.set(capture, group);
|
|
45
|
+
target.addEventListener("keydown", group.keydown, capture);
|
|
46
|
+
target.addEventListener("keyup", group.keyup, capture);
|
|
47
|
+
}
|
|
48
|
+
group.ids.add(id);
|
|
49
|
+
}
|
|
50
|
+
remove(target, capture, id) {
|
|
51
|
+
const phases = this.#targets.get(target);
|
|
52
|
+
const group = phases?.get(capture);
|
|
53
|
+
if (!group) return;
|
|
54
|
+
group.ids.delete(id);
|
|
55
|
+
if (group.ids.size === 0) {
|
|
56
|
+
target.removeEventListener("keydown", group.keydown, capture);
|
|
57
|
+
target.removeEventListener("keyup", group.keyup, capture);
|
|
58
|
+
phases.delete(capture);
|
|
59
|
+
if (phases.size === 0) this.#targets.delete(target);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
destroy() {
|
|
63
|
+
for (const [target, phases] of this.#targets) for (const [capture, group] of phases) {
|
|
64
|
+
target.removeEventListener("keydown", group.keydown, capture);
|
|
65
|
+
target.removeEventListener("keyup", group.keyup, capture);
|
|
66
|
+
}
|
|
67
|
+
this.#targets.clear();
|
|
68
|
+
this.#processed = /* @__PURE__ */ new WeakMap();
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
//#endregion
|
|
73
|
+
export { TargetListeners };
|
package/dist/app-hotkeys.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { HotkeyChordOptions, HotkeyRegistrationView, HotkeySequenceOptions } fro
|
|
|
3
3
|
import { Store } from "@tanstack/store";
|
|
4
4
|
//#region src/app-hotkeys.d.ts
|
|
5
5
|
type Platform = 'mac' | 'windows' | 'linux';
|
|
6
|
-
type DeclaredOptionKeys = 'conflictBehavior' | 'eventType' | 'ignoreInputs' | 'preventDefault' | 'stopPropagation';
|
|
6
|
+
type DeclaredOptionKeys = 'capture' | 'conflictBehavior' | 'eventType' | 'ignoreInputs' | 'preventDefault' | 'stopPropagation';
|
|
7
7
|
/** Declared options for an app hotkey whose default is a chord. */
|
|
8
8
|
export type AppHotkeyChordOptions = Pick<HotkeyChordOptions, DeclaredOptionKeys | 'requireReset'>;
|
|
9
9
|
/** Declared options for an app hotkey whose default is a sequence. */
|
package/dist/hotkey-manager.d.ts
CHANGED
|
@@ -8,6 +8,14 @@ export type Target = HTMLElement | Document | Window;
|
|
|
8
8
|
* Options shared by chords and sequences.
|
|
9
9
|
*/
|
|
10
10
|
export interface HotkeyOptions {
|
|
11
|
+
/**
|
|
12
|
+
* Listen during capture, before descendant listeners. Defaults to `false` (bubble).
|
|
13
|
+
* With `stopPropagation: true`, a match on an ancestor prevents the event from
|
|
14
|
+
* reaching descendant widgets. Other listeners on the same target still run.
|
|
15
|
+
* Applies to both `keydown` and `keyup`, for chords and sequences.
|
|
16
|
+
* Changing this option preserves held-key reset state and sequence progress.
|
|
17
|
+
*/
|
|
18
|
+
capture?: boolean;
|
|
11
19
|
/** Behavior when this hotkey conflicts with an existing registration on the same target. Defaults to 'warn' */
|
|
12
20
|
conflictBehavior?: ConflictBehavior;
|
|
13
21
|
/**
|
|
@@ -156,6 +164,9 @@ export interface HotkeyRegistrationHandle<TOptions extends HotkeyOptions = Hotke
|
|
|
156
164
|
/**
|
|
157
165
|
* Update options (merged with existing options).
|
|
158
166
|
* Useful for updating `enabled`, `preventDefault`, etc. without re-registering.
|
|
167
|
+
* Changing `capture` moves the listener phase without clearing `requireReset`
|
|
168
|
+
* state or sequence progress. A registration already processed for an event
|
|
169
|
+
* cannot process it again in another phase.
|
|
159
170
|
*/
|
|
160
171
|
setOptions: (options: Partial<TOptions>) => void;
|
|
161
172
|
/** Unregister this hotkey */
|
|
@@ -165,8 +176,8 @@ export interface HotkeyRegistrationHandle<TOptions extends HotkeyOptions = Hotke
|
|
|
165
176
|
* Singleton manager for hotkey registrations, including sequences.
|
|
166
177
|
*
|
|
167
178
|
* This class provides a centralized way to register and manage keyboard
|
|
168
|
-
* hotkeys. It
|
|
169
|
-
*
|
|
179
|
+
* hotkeys. It shares one keydown and one keyup listener per target and
|
|
180
|
+
* capture phase across registrations.
|
|
170
181
|
*
|
|
171
182
|
* Sequences (Vim-style hotkeys such as `['G', 'G']`) match when every step is
|
|
172
183
|
* pressed within the timeout. Modifier-only events (Shift, Control, Alt, or
|
package/dist/hotkey-manager.js
CHANGED
|
@@ -5,7 +5,8 @@ import { isHotkeySequence, isModifierKey, parseHotkey, rawHotkeyToParsedHotkey,
|
|
|
5
5
|
import { formatHotkey } from "./format.js";
|
|
6
6
|
import { matchKeyboardEvent } from "./_match.js";
|
|
7
7
|
import { DEFAULT_SEQUENCE_TIMEOUT, areHotkeysEqual } from "./match.js";
|
|
8
|
-
import { isEventForTarget, shouldIgnoreInputEvent } from "./_event-target.js";
|
|
8
|
+
import { getTargetDocument, isEventForTarget, shouldIgnoreInputEvent } from "./_event-target.js";
|
|
9
|
+
import { TargetListeners } from "./_target-listeners.js";
|
|
9
10
|
import { isRecordingEvent } from "./_recording-guard.js";
|
|
10
11
|
import { defaultHotkeyOptions, getDefaultIgnoreInputs, handleConflict, optionsEqual } from "./_registration.js";
|
|
11
12
|
import { Store } from "@tanstack/store";
|
|
@@ -73,8 +74,8 @@ function formatForMessage(hotkey) {
|
|
|
73
74
|
* Singleton manager for hotkey registrations, including sequences.
|
|
74
75
|
*
|
|
75
76
|
* This class provides a centralized way to register and manage keyboard
|
|
76
|
-
* hotkeys. It
|
|
77
|
-
*
|
|
77
|
+
* hotkeys. It shares one keydown and one keyup listener per target and
|
|
78
|
+
* capture phase across registrations.
|
|
78
79
|
*
|
|
79
80
|
* Sequences (Vim-style hotkeys such as `['G', 'G']`) match when every step is
|
|
80
81
|
* pressed within the timeout. Modifier-only events (Shift, Control, Alt, or
|
|
@@ -123,8 +124,11 @@ var HotkeyManager = class HotkeyManager {
|
|
|
123
124
|
*/
|
|
124
125
|
appHotkeys = new Store(null);
|
|
125
126
|
#platform;
|
|
126
|
-
#
|
|
127
|
-
|
|
127
|
+
#listeners = new TargetListeners((...args) => this.#processTargetEvent(...args));
|
|
128
|
+
/** Events that continue a sequence in progress, decided before any listener runs. */
|
|
129
|
+
#sequenceOwned = /* @__PURE__ */ new WeakMap();
|
|
130
|
+
/** Physical keys whose keydown a sequence owned, so their repeats and keyup are owned too. */
|
|
131
|
+
#sequenceOwnedKeys = /* @__PURE__ */ new Set();
|
|
128
132
|
constructor() {
|
|
129
133
|
this.#platform = detectPlatform();
|
|
130
134
|
}
|
|
@@ -214,9 +218,7 @@ var HotkeyManager = class HotkeyManager {
|
|
|
214
218
|
const conflicting = this.#findConflictingRegistration(registration);
|
|
215
219
|
if (conflicting) handleConflict(conflicting.id, formatForMessage(registration.hotkey), options.conflictBehavior ?? "warn", (conflictId) => this.#unregister(conflictId));
|
|
216
220
|
this.registrations.setState((prev) => new Map(prev).set(id, registration));
|
|
217
|
-
|
|
218
|
-
this.#targetRegistrations.get(target).add(id);
|
|
219
|
-
this.#ensureListenersForTarget(target);
|
|
221
|
+
this.#listeners.add(target, !!registration.options.capture, id);
|
|
220
222
|
const manager = this;
|
|
221
223
|
return {
|
|
222
224
|
get id() {
|
|
@@ -241,6 +243,11 @@ var HotkeyManager = class HotkeyManager {
|
|
|
241
243
|
...optionsForKind(reg.kind, newOptions)
|
|
242
244
|
};
|
|
243
245
|
if (optionsEqual(reg.options, options)) return prev;
|
|
246
|
+
const capture = !!reg.options.capture;
|
|
247
|
+
if (capture !== !!options.capture) {
|
|
248
|
+
manager.#listeners.remove(reg.target, capture, id);
|
|
249
|
+
manager.#listeners.add(reg.target, !!options.capture, id);
|
|
250
|
+
}
|
|
244
251
|
return new Map(prev).set(id, {
|
|
245
252
|
...reg,
|
|
246
253
|
options
|
|
@@ -264,65 +271,40 @@ var HotkeyManager = class HotkeyManager {
|
|
|
264
271
|
next.delete(id);
|
|
265
272
|
return next;
|
|
266
273
|
});
|
|
267
|
-
|
|
268
|
-
if (targetRegs) {
|
|
269
|
-
targetRegs.delete(id);
|
|
270
|
-
if (targetRegs.size === 0) this.#removeListenersForTarget(target);
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
/**
|
|
274
|
-
* Ensures event listeners are attached for a specific target.
|
|
275
|
-
*/
|
|
276
|
-
#ensureListenersForTarget(target) {
|
|
277
|
-
if (typeof document === "undefined") return;
|
|
278
|
-
if (this.#targetListeners.has(target)) return;
|
|
279
|
-
const keydown = (event) => this.#processTargetEvent(event, target, "keydown");
|
|
280
|
-
const keyup = (event) => this.#processTargetEvent(event, target, "keyup");
|
|
281
|
-
target.addEventListener("keydown", keydown);
|
|
282
|
-
target.addEventListener("keyup", keyup);
|
|
283
|
-
this.#targetListeners.set(target, {
|
|
284
|
-
keydown,
|
|
285
|
-
keyup
|
|
286
|
-
});
|
|
274
|
+
this.#listeners.remove(target, !!registration.options.capture, id);
|
|
287
275
|
}
|
|
288
|
-
/**
|
|
289
|
-
|
|
290
|
-
*/
|
|
291
|
-
#removeListenersForTarget(target) {
|
|
292
|
-
if (typeof document === "undefined") return;
|
|
293
|
-
const listeners = this.#targetListeners.get(target);
|
|
294
|
-
if (!listeners) return;
|
|
295
|
-
target.removeEventListener("keydown", listeners.keydown);
|
|
296
|
-
target.removeEventListener("keyup", listeners.keyup);
|
|
297
|
-
this.#targetListeners.delete(target);
|
|
298
|
-
this.#targetRegistrations.delete(target);
|
|
299
|
-
}
|
|
300
|
-
/** Registrations of one kind on a target, in registration order. */
|
|
301
|
-
#registrationsOn(target, kind) {
|
|
276
|
+
/** Registrations of one kind in the listener phase, in registration order. */
|
|
277
|
+
#registrationsIn(ids, capture, kind) {
|
|
302
278
|
const result = [];
|
|
303
|
-
for (const id of
|
|
279
|
+
for (const id of ids) {
|
|
304
280
|
const registration = this.registrations.state.get(id);
|
|
305
|
-
if (registration?.kind === kind) result.push(registration);
|
|
281
|
+
if (registration?.kind === kind && !!registration.options.capture === capture) result.push(registration);
|
|
306
282
|
}
|
|
307
283
|
return result;
|
|
308
284
|
}
|
|
285
|
+
/** The live registration, unless it was removed or moved to another phase. */
|
|
286
|
+
#currentIn(registration, capture) {
|
|
287
|
+
const current = this.registrations.state.get(registration.id);
|
|
288
|
+
return current && !!current.options.capture === capture ? current : void 0;
|
|
289
|
+
}
|
|
309
290
|
/**
|
|
310
291
|
* Processes keyboard events for a specific target and event type.
|
|
311
|
-
*
|
|
292
|
+
* A key that continues a sequence in progress belongs to that sequence, so
|
|
293
|
+
* chords skip it. Chords and first steps of sequences may both fire.
|
|
312
294
|
*/
|
|
313
|
-
#processTargetEvent(event, target, eventType) {
|
|
314
|
-
this.#
|
|
315
|
-
this.#
|
|
295
|
+
#processTargetEvent(event, target, eventType, capture, ids, first) {
|
|
296
|
+
if (first) this.#sequenceOwned.set(event, this.#isOwnedBySequence(event, eventType));
|
|
297
|
+
this.#processChords(event, target, eventType, capture, this.#registrationsIn(ids, capture, "chord"));
|
|
298
|
+
this.#processSequences(event, target, eventType, capture, this.#registrationsIn(ids, capture, "sequence"));
|
|
316
299
|
}
|
|
317
|
-
#processChords(event, target, eventType) {
|
|
318
|
-
const registrations = this.#registrationsOn(target, "chord");
|
|
300
|
+
#processChords(event, target, eventType, capture, registrations) {
|
|
319
301
|
if (eventType === "keyup") {
|
|
320
302
|
for (const registration of registrations) if (registration.hasFired && this.#shouldResetRegistration(registration, event)) {
|
|
321
303
|
registration.hasFired = false;
|
|
322
304
|
registration.activeMatch = void 0;
|
|
323
305
|
}
|
|
324
306
|
}
|
|
325
|
-
if (isRecordingEvent(event)) return;
|
|
307
|
+
if (isRecordingEvent(event) || this.#sequenceOwned.get(event)) return;
|
|
326
308
|
const matches = /* @__PURE__ */ new Map();
|
|
327
309
|
let bestScore = 0;
|
|
328
310
|
for (const registration of registrations) {
|
|
@@ -333,9 +315,11 @@ var HotkeyManager = class HotkeyManager {
|
|
|
333
315
|
bestScore = Math.max(bestScore, result.score);
|
|
334
316
|
}
|
|
335
317
|
}
|
|
336
|
-
for (const
|
|
337
|
-
const match = matches.get(
|
|
318
|
+
for (const snapshot of registrations) {
|
|
319
|
+
const match = matches.get(snapshot.id);
|
|
338
320
|
if (!match?.matched || match.score !== bestScore) continue;
|
|
321
|
+
const registration = this.#currentIn(snapshot, capture);
|
|
322
|
+
if (!registration) continue;
|
|
339
323
|
if (eventType === "keyup") {
|
|
340
324
|
this.#executeCallback(registration, event);
|
|
341
325
|
continue;
|
|
@@ -343,16 +327,43 @@ var HotkeyManager = class HotkeyManager {
|
|
|
343
327
|
if (registration.options.preventDefault) event.preventDefault();
|
|
344
328
|
if (registration.options.stopPropagation) event.stopPropagation();
|
|
345
329
|
if (!registration.options.requireReset || !registration.hasFired) {
|
|
346
|
-
this.#executeCallback(registration, event);
|
|
347
330
|
if (registration.options.requireReset) {
|
|
348
331
|
registration.hasFired = true;
|
|
349
332
|
registration.activeMatch = match.identity;
|
|
350
333
|
}
|
|
334
|
+
this.#executeCallback(registration, event);
|
|
351
335
|
}
|
|
352
336
|
}
|
|
353
337
|
}
|
|
354
|
-
|
|
355
|
-
|
|
338
|
+
/**
|
|
339
|
+
* Whether the event continues or completes a sequence in progress whose
|
|
340
|
+
* target the event reaches. The repeats and keyup of a keydown that a
|
|
341
|
+
* sequence owns belong to the sequence too.
|
|
342
|
+
*/
|
|
343
|
+
#isOwnedBySequence(event, eventType) {
|
|
344
|
+
const key = event.code || normalizeKeyName(event.key);
|
|
345
|
+
if (eventType === "keyup") {
|
|
346
|
+
const ownedKeydown = this.#sequenceOwnedKeys.delete(key);
|
|
347
|
+
return this.#continuesSequence(event, eventType) || ownedKeydown;
|
|
348
|
+
}
|
|
349
|
+
if (event.repeat) return this.#sequenceOwnedKeys.has(key);
|
|
350
|
+
const owned = this.#continuesSequence(event, eventType);
|
|
351
|
+
if (owned) this.#sequenceOwnedKeys.add(key);
|
|
352
|
+
else this.#sequenceOwnedKeys.delete(key);
|
|
353
|
+
return owned;
|
|
354
|
+
}
|
|
355
|
+
#continuesSequence(event, eventType) {
|
|
356
|
+
if (isRecordingEvent(event) || normalizeKeyboardEvent(event).isComposing || isModifierKey(normalizeKeyName(event.key))) return false;
|
|
357
|
+
const path = event.composedPath();
|
|
358
|
+
const now = Date.now();
|
|
359
|
+
for (const registration of this.registrations.state.values()) {
|
|
360
|
+
if (registration.kind !== "sequence" || registration.currentIndex === 0 || !registration.options.enabled || registration.options.eventType !== eventType || now - registration.lastKeyTime > (registration.options.timeout ?? 1e3)) continue;
|
|
361
|
+
const target = registration.target;
|
|
362
|
+
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
|
+
}
|
|
364
|
+
return false;
|
|
365
|
+
}
|
|
366
|
+
#processSequences(event, target, eventType, capture, registrations) {
|
|
356
367
|
if (registrations.length === 0) return;
|
|
357
368
|
if (isRecordingEvent(event)) {
|
|
358
369
|
let changed = false;
|
|
@@ -410,7 +421,10 @@ var HotkeyManager = class HotkeyManager {
|
|
|
410
421
|
}
|
|
411
422
|
}
|
|
412
423
|
if (changed && completed.length === 0) this.#notify();
|
|
413
|
-
for (const
|
|
424
|
+
for (const snapshot of completed) {
|
|
425
|
+
const registration = this.#currentIn(snapshot, capture);
|
|
426
|
+
if (registration) this.#executeCallback(registration, event);
|
|
427
|
+
}
|
|
414
428
|
}
|
|
415
429
|
/**
|
|
416
430
|
* Executes a hotkey callback with proper event handling.
|
|
@@ -517,11 +531,9 @@ var HotkeyManager = class HotkeyManager {
|
|
|
517
531
|
* Destroys the manager and removes all listeners.
|
|
518
532
|
*/
|
|
519
533
|
destroy() {
|
|
520
|
-
|
|
534
|
+
this.#listeners.destroy();
|
|
521
535
|
this.registrations.setState(() => /* @__PURE__ */ new Map());
|
|
522
536
|
this.appHotkeys.setState(() => null);
|
|
523
|
-
this.#targetListeners.clear();
|
|
524
|
-
this.#targetRegistrations.clear();
|
|
525
537
|
}
|
|
526
538
|
};
|
|
527
539
|
/**
|
|
@@ -55,6 +55,7 @@ var KeyStateTracker = class KeyStateTracker {
|
|
|
55
55
|
#heldEntries = /* @__PURE__ */ new Map();
|
|
56
56
|
#keydownListener = null;
|
|
57
57
|
#keyupListener = null;
|
|
58
|
+
#mouseListener = null;
|
|
58
59
|
#blurListener = null;
|
|
59
60
|
constructor() {
|
|
60
61
|
this.#setupListeners();
|
|
@@ -81,6 +82,7 @@ var KeyStateTracker = class KeyStateTracker {
|
|
|
81
82
|
#setupListeners() {
|
|
82
83
|
if (typeof document === "undefined") return;
|
|
83
84
|
this.#keydownListener = (event) => {
|
|
85
|
+
let changed = this.#reconcileModifiers(event);
|
|
84
86
|
const key = normalizeKeyName(event.key);
|
|
85
87
|
const identity = event.code || `key:${key}`;
|
|
86
88
|
if (!this.#heldEntries.has(identity)) {
|
|
@@ -88,8 +90,9 @@ var KeyStateTracker = class KeyStateTracker {
|
|
|
88
90
|
key,
|
|
89
91
|
code: event.code
|
|
90
92
|
});
|
|
91
|
-
|
|
93
|
+
changed = true;
|
|
92
94
|
}
|
|
95
|
+
if (changed) this.#syncState();
|
|
93
96
|
};
|
|
94
97
|
this.#keyupListener = (event) => {
|
|
95
98
|
const key = normalizeKeyName(event.key);
|
|
@@ -98,22 +101,44 @@ var KeyStateTracker = class KeyStateTracker {
|
|
|
98
101
|
if (!event.code) {
|
|
99
102
|
for (const [entryIdentity, entry] of this.#heldEntries) if (entry.key === key) this.#heldEntries.delete(entryIdentity);
|
|
100
103
|
}
|
|
104
|
+
this.#reconcileModifiers(event);
|
|
101
105
|
if (MODIFIER_KEYS.has(key)) {
|
|
102
106
|
for (const [entryIdentity, entry] of this.#heldEntries) if (!MODIFIER_KEYS.has(entry.key)) this.#heldEntries.delete(entryIdentity);
|
|
103
107
|
}
|
|
104
108
|
this.#syncState();
|
|
105
109
|
};
|
|
110
|
+
this.#mouseListener = (event) => {
|
|
111
|
+
if (this.#reconcileModifiers(event)) this.#syncState();
|
|
112
|
+
};
|
|
106
113
|
this.#blurListener = () => {
|
|
107
114
|
if (this.#heldEntries.size > 0) {
|
|
108
115
|
this.#heldEntries.clear();
|
|
109
116
|
this.#syncState();
|
|
110
117
|
}
|
|
111
118
|
};
|
|
112
|
-
|
|
113
|
-
|
|
119
|
+
window.addEventListener("keydown", this.#keydownListener, true);
|
|
120
|
+
window.addEventListener("keyup", this.#keyupListener, true);
|
|
121
|
+
window.addEventListener("mousemove", this.#mouseListener, true);
|
|
122
|
+
window.addEventListener("mousedown", this.#mouseListener, true);
|
|
114
123
|
window.addEventListener("blur", this.#blurListener);
|
|
115
124
|
}
|
|
116
125
|
/**
|
|
126
|
+
* Removes modifiers whose release was missed without a window blur.
|
|
127
|
+
* Event modifier state is shared by left/right keys, so a true flag keeps
|
|
128
|
+
* both physical entries until their individual keyup events arrive.
|
|
129
|
+
* Only real user input counts: events dispatched by page code report no
|
|
130
|
+
* modifiers and would clear keys the user is still holding.
|
|
131
|
+
*/
|
|
132
|
+
#reconcileModifiers(event) {
|
|
133
|
+
if (!event.isTrusted) return false;
|
|
134
|
+
let changed = false;
|
|
135
|
+
for (const [identity, entry] of this.#heldEntries) if (entry.key === "Control" && !event.ctrlKey || entry.key === "Alt" && !event.altKey || entry.key === "Shift" && !event.shiftKey || entry.key === "Meta" && !event.metaKey) {
|
|
136
|
+
this.#heldEntries.delete(identity);
|
|
137
|
+
changed = true;
|
|
138
|
+
}
|
|
139
|
+
return changed;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
117
142
|
* Syncs the internal Set to the Store state.
|
|
118
143
|
*/
|
|
119
144
|
#syncState() {
|
|
@@ -130,13 +155,18 @@ var KeyStateTracker = class KeyStateTracker {
|
|
|
130
155
|
#removeListeners() {
|
|
131
156
|
if (typeof document === "undefined") return;
|
|
132
157
|
if (this.#keydownListener) {
|
|
133
|
-
|
|
158
|
+
window.removeEventListener("keydown", this.#keydownListener, true);
|
|
134
159
|
this.#keydownListener = null;
|
|
135
160
|
}
|
|
136
161
|
if (this.#keyupListener) {
|
|
137
|
-
|
|
162
|
+
window.removeEventListener("keyup", this.#keyupListener, true);
|
|
138
163
|
this.#keyupListener = null;
|
|
139
164
|
}
|
|
165
|
+
if (this.#mouseListener) {
|
|
166
|
+
window.removeEventListener("mousemove", this.#mouseListener, true);
|
|
167
|
+
window.removeEventListener("mousedown", this.#mouseListener, true);
|
|
168
|
+
this.#mouseListener = null;
|
|
169
|
+
}
|
|
140
170
|
if (this.#blurListener) {
|
|
141
171
|
window.removeEventListener("blur", this.#blurListener);
|
|
142
172
|
this.#blurListener = null;
|