@inklu/keys 1.4.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,1078 +0,0 @@
1
- import { HotkeyRecorder, HotkeySequenceRecorder, detectPlatform, formatForDisplay, getHotkeyManager, getSequenceManager, normalizeHotkey, validateHotkey } from "@tanstack/hotkeys";
2
- //#region src/bindings.ts
3
- /** Comparing, validating and serializing hotkey bindings. */
4
- /** Type guard narrowing a JSON value to a string. */
5
- function isJsonString(value) {
6
- return typeof value === "string";
7
- }
8
- /**
9
- * Creates a sequence binding from ordered hotkey steps.
10
- *
11
- * @example
12
- * ```ts
13
- * keys: seq("G", "I")
14
- * ```
15
- */
16
- function seq(...steps) {
17
- return { sequence: steps };
18
- }
19
- /** Type guard checking if a binding is a sequence. */
20
- function isSequence(binding) {
21
- return typeof binding === "object" && binding !== null && "sequence" in binding;
22
- }
23
- /** Normalizes a binding input into an array of bindings. */
24
- function toBindings(input) {
25
- if (input == null) return [];
26
- const candidates = Array.isArray(input) ? input : [input];
27
- const bindings = [];
28
- for (const candidate of candidates) {
29
- if (candidate == null) continue;
30
- if (isSequence(candidate)) {
31
- if (candidate.sequence.length > 0) bindings.push({ sequence: [...candidate.sequence] });
32
- continue;
33
- }
34
- const trimmed = String(candidate).trim();
35
- if (trimmed) bindings.push(trimmed);
36
- }
37
- return bindings;
38
- }
39
- /** Generates a normalized signature for binding comparison and conflict detection. */
40
- function bindingSignature(binding, platform) {
41
- if (isSequence(binding)) return `seq:${binding.sequence.map((step) => safeNormalize(step, platform)).join(" ")}`;
42
- return `chord:${safeNormalize(binding, platform)}`;
43
- }
44
- function safeNormalize(hotkey, platform) {
45
- try {
46
- return normalizeHotkey(hotkey, platform);
47
- } catch {
48
- return hotkey;
49
- }
50
- }
51
- /** Converts a binding to a JSON-serializable format. */
52
- function serializeBinding(binding) {
53
- return isSequence(binding) ? [...binding.sequence] : binding;
54
- }
55
- /** Deserializes a stored value into a valid binding, or `null` if invalid. */
56
- function deserializeBinding(value) {
57
- if (isJsonString(value)) {
58
- const trimmed = value.trim();
59
- return trimmed && isBindingValid(trimmed) ? trimmed : null;
60
- }
61
- if (Array.isArray(value)) {
62
- const steps = value.filter((step) => typeof step === "string" && step !== "");
63
- if (steps.length === 0) return null;
64
- const binding = steps.length === 1 ? steps[0] : { sequence: steps };
65
- return isBindingValid(binding) ? binding : null;
66
- }
67
- return null;
68
- }
69
- /** Validates a binding's syntax without logging warnings or errors. */
70
- function validateBinding(binding) {
71
- const steps = isSequence(binding) ? binding.sequence : [binding];
72
- if (steps.length === 0) return {
73
- valid: false,
74
- errors: ["Sequence has no steps"],
75
- warnings: []
76
- };
77
- const errors = [];
78
- const warnings = [];
79
- for (const step of steps) {
80
- const result = validateHotkey(step);
81
- errors.push(...result.errors);
82
- warnings.push(...result.warnings);
83
- }
84
- return {
85
- valid: errors.length === 0,
86
- errors,
87
- warnings
88
- };
89
- }
90
- /** Checks whether a binding has valid syntax. */
91
- function isBindingValid(binding) {
92
- return validateBinding(binding).valid;
93
- }
94
- //#endregion
95
- //#region src/display.ts
96
- /** Platform-aware hotkey display formatting and accessibility attributes. */
97
- const TOKEN_SEPARATOR = "\0";
98
- /** Formats a binding for display on the target platform (e.g. `⌘K` or `Ctrl+K`). */
99
- function formatBinding(binding, options) {
100
- if (isSequence(binding)) return binding.sequence.map((step) => formatForDisplay(step, options)).join(" ");
101
- return formatForDisplay(binding, options);
102
- }
103
- /** Splits a binding into display tokens for rendering `<kbd>` elements. */
104
- function bindingTokens(binding, options) {
105
- if (isSequence(binding)) return binding.sequence.map((step) => formatForDisplay(step, options));
106
- return formatForDisplay(binding, {
107
- ...options,
108
- separatorToken: TOKEN_SEPARATOR
109
- }).split(TOKEN_SEPARATOR).map((token) => token.trim()).filter((token) => token.length > 0);
110
- }
111
- /** Generates an `aria-keyshortcuts` attribute string for a binding. */
112
- function ariaKeyShortcuts(binding, platform) {
113
- if (isSequence(binding)) return "";
114
- try {
115
- return resolveMod(normalizeHotkey(binding, platform), platform);
116
- } catch {
117
- return "";
118
- }
119
- }
120
- function resolveMod(hotkey, platform) {
121
- if (!hotkey.includes("Mod")) return hotkey;
122
- const modifier = (platform ?? detectPlatform()) === "mac" ? "Meta" : "Control";
123
- return hotkey.split("+").map((part) => part === "Mod" ? modifier : part).join("+");
124
- }
125
- //#endregion
126
- //#region src/rebind.ts
127
- /** Controller for recording and applying hotkey rebindings. */
128
- const RECORDING_SCOPE = "inklu-keys:recording";
129
- const NO_STEPS = [];
130
- function toRecordedBinding(sequence) {
131
- if (sequence.length === 0) return null;
132
- if (sequence.length === 1) return sequence[0];
133
- return { sequence: [...sequence] };
134
- }
135
- /**
136
- * Creates a rebinder instance for capturing and remapping a command's keys.
137
- *
138
- * @example
139
- * ```ts
140
- * const rebinder = createRebinder(commands, "palette.open", {
141
- * onRecord: (binding, conflicts) => {
142
- * if (conflicts.length) console.warn("Conflict with", conflicts[0].id);
143
- * },
144
- * });
145
- * rebinder.start();
146
- * ```
147
- */
148
- function createRebinder(registry, id, options = {}) {
149
- const listeners = /* @__PURE__ */ new Set();
150
- let state = {
151
- recording: false,
152
- binding: null,
153
- steps: NO_STEPS,
154
- conflicts: []
155
- };
156
- let scope = null;
157
- const notify = (next) => {
158
- state = next;
159
- for (const listener of listeners) listener();
160
- };
161
- const releaseScope = () => {
162
- scope?.();
163
- scope = null;
164
- };
165
- const record = (binding) => {
166
- releaseScope();
167
- const conflicts = registry.conflicts(binding, {
168
- ignore: id,
169
- scope: registry.get(id)?.scope
170
- });
171
- const blocked = options.rejectConflicts === true && conflicts.length > 0;
172
- if (options.apply !== false && !blocked) {
173
- if (options.add) registry.keymap.add(id, binding);
174
- else registry.keymap.set(id, binding);
175
- }
176
- notify({
177
- recording: false,
178
- binding,
179
- steps: NO_STEPS,
180
- conflicts
181
- });
182
- options.onRecord?.(binding, conflicts);
183
- };
184
- const cancelled = () => {
185
- releaseScope();
186
- notify({
187
- ...state,
188
- recording: false,
189
- steps: NO_STEPS
190
- });
191
- options.onCancel?.();
192
- };
193
- const cleared = () => {
194
- releaseScope();
195
- if (options.apply !== false) registry.keymap.set(id, null);
196
- notify({
197
- recording: false,
198
- binding: null,
199
- steps: NO_STEPS,
200
- conflicts: []
201
- });
202
- options.onClear?.();
203
- };
204
- const recorder = options.mode === "sequence" ? new HotkeySequenceRecorder({
205
- ignoreInputs: options.ignoreInputs ?? true,
206
- idleTimeoutMs: options.idleTimeout,
207
- onRecord: (sequence) => {
208
- const binding = toRecordedBinding(sequence);
209
- if (binding === null) return;
210
- record(binding);
211
- },
212
- onCancel: cancelled,
213
- onClear: cleared
214
- }) : new HotkeyRecorder({
215
- ignoreInputs: options.ignoreInputs ?? true,
216
- onRecord: (hotkey) => {
217
- if (hotkey) record(hotkey);
218
- },
219
- onCancel: cancelled,
220
- onClear: cleared
221
- });
222
- const stepsSubscription = recorder instanceof HotkeySequenceRecorder ? recorder.store.subscribe(() => {
223
- if (!state.recording) return;
224
- const steps = recorder.store.state.steps;
225
- if (steps.length === state.steps.length) return;
226
- notify({
227
- ...state,
228
- steps: [...steps]
229
- });
230
- }) : null;
231
- return {
232
- start: () => {
233
- if (state.recording) return;
234
- scope ??= registry.pushScope(RECORDING_SCOPE, { exclusive: true });
235
- recorder.start();
236
- notify({
237
- recording: true,
238
- binding: null,
239
- steps: NO_STEPS,
240
- conflicts: []
241
- });
242
- },
243
- stop: () => {
244
- if (!state.recording) return;
245
- recorder.stop();
246
- releaseScope();
247
- notify({
248
- ...state,
249
- recording: false,
250
- steps: NO_STEPS
251
- });
252
- },
253
- cancel: () => {
254
- if (!state.recording) return;
255
- recorder.cancel();
256
- },
257
- commit: () => {
258
- if (!state.recording || !(recorder instanceof HotkeySequenceRecorder)) return;
259
- recorder.commit();
260
- },
261
- get state() {
262
- return state;
263
- },
264
- subscribe: (listener) => {
265
- listeners.add(listener);
266
- return () => {
267
- listeners.delete(listener);
268
- };
269
- },
270
- destroy: () => {
271
- releaseScope();
272
- stepsSubscription?.unsubscribe();
273
- recorder.destroy();
274
- listeners.clear();
275
- }
276
- };
277
- }
278
- //#endregion
279
- //#region src/binder.ts
280
- /** Bridge connecting commands to underlying hotkey event listeners. */
281
- var Binder = class {
282
- #host;
283
- #target;
284
- #bound = /* @__PURE__ */ new Map();
285
- constructor(host, target) {
286
- this.#host = host;
287
- this.#target = target;
288
- }
289
- /** Syncs hotkey registrations for a command with its current bindings. */
290
- sync(id) {
291
- const definition = this.#host.getDefinition(id);
292
- if (!definition) {
293
- this.release(id);
294
- return;
295
- }
296
- const existing = this.#bound.get(id) ?? [];
297
- const reusable = new Map(existing.map((bound) => [bound.signature, bound]));
298
- const next = [];
299
- const enabled = this.#host.isReachable(id);
300
- for (const binding of this.#host.getBindings(id)) {
301
- const signature = bindingSignature(binding, this.#host.platform());
302
- if (next.some((bound) => bound.signature === signature)) continue;
303
- const kept = reusable.get(signature);
304
- if (kept) {
305
- reusable.delete(signature);
306
- kept.setEnabled(enabled);
307
- next.push(kept);
308
- continue;
309
- }
310
- const bound = this.#register(id, binding, signature, definition, enabled);
311
- if (bound) next.push(bound);
312
- }
313
- for (const stale of reusable.values()) stale.unregister();
314
- if (next.length === 0) this.#bound.delete(id);
315
- else this.#bound.set(id, next);
316
- }
317
- /** Updates enabled state for all active registrations without re-registering. */
318
- refreshEnabled() {
319
- for (const [id, bindings] of this.#bound) {
320
- const enabled = this.#host.isReachable(id);
321
- for (const bound of bindings) bound.setEnabled(enabled);
322
- }
323
- }
324
- /** Returns all registered sequences with their manager registration IDs. */
325
- sequences() {
326
- const entries = [];
327
- for (const [id, bindings] of this.#bound) for (const bound of bindings) {
328
- if (bound.sequenceId === null || !isSequence(bound.binding)) continue;
329
- entries.push({
330
- id,
331
- binding: bound.binding,
332
- registrationId: bound.sequenceId
333
- });
334
- }
335
- return entries;
336
- }
337
- /** Removes all registrations for a specific command. */
338
- release(id) {
339
- const bindings = this.#bound.get(id);
340
- if (!bindings) return;
341
- for (const bound of bindings) bound.unregister();
342
- this.#bound.delete(id);
343
- }
344
- /** Removes all registrations. */
345
- destroy() {
346
- for (const bindings of this.#bound.values()) for (const bound of bindings) bound.unregister();
347
- this.#bound.clear();
348
- }
349
- #register(id, binding, signature, definition, enabled) {
350
- const callback = (event) => this.#handle(id, binding, signature, event);
351
- const options = {
352
- preventDefault: false,
353
- stopPropagation: false,
354
- conflictBehavior: "allow",
355
- enabled,
356
- ignoreInputs: definition.ignoreInputs,
357
- platform: this.#host.platform(),
358
- target: this.#target,
359
- meta: { name: definition.title }
360
- };
361
- try {
362
- const handle = isSequence(binding) ? getSequenceManager().register([...binding.sequence], callback, {
363
- ...options,
364
- timeout: definition.sequenceTimeout
365
- }) : getHotkeyManager().register(binding, callback, {
366
- ...options,
367
- requireReset: definition.requireReset === true
368
- });
369
- return {
370
- signature,
371
- binding,
372
- sequenceId: isSequence(binding) ? handle.id : null,
373
- setEnabled: (value) => handle.setOptions({ enabled: value }),
374
- unregister: () => handle.unregister()
375
- };
376
- } catch {
377
- return null;
378
- }
379
- }
380
- /**
381
- * One keypress can reach several registrations (see `conflictBehavior`
382
- * above), so every candidate asks the registry whether it won before doing
383
- * anything observable. Arbitration comes before `preventDefault` on
384
- * purpose: a losing command must leave the event exactly as it found it,
385
- * or a command declaring `preventDefault: false` would still have the
386
- * browser's default action cancelled by whichever rival happened to be
387
- * registered alongside it.
388
- */
389
- #handle(id, binding, signature, event) {
390
- if (!this.#host.canRun(id)) return;
391
- if (!this.#host.claims(id, signature)) return;
392
- const definition = this.#host.getDefinition(id);
393
- if (!definition) return;
394
- if (definition.preventDefault !== false) event.preventDefault();
395
- this.#host.execute(id, {
396
- source: "keyboard",
397
- event,
398
- binding
399
- });
400
- }
401
- };
402
- //#endregion
403
- //#region src/keymap.ts
404
- /** Keymap managing default bindings and user overrides. */
405
- const DEFAULT_STORAGE_KEY = "inklu.keys";
406
- /** Type guard narrowing a JSON value to a plain object (excludes arrays and `null`). */
407
- function isJsonObject(value) {
408
- return typeof value === "object" && value !== null && !Array.isArray(value);
409
- }
410
- var Keymap = class {
411
- #defaults = /* @__PURE__ */ new Map();
412
- #overrides = /* @__PURE__ */ new Map();
413
- #listeners = /* @__PURE__ */ new Set();
414
- #persistence;
415
- constructor(persistence) {
416
- this.#persistence = persistence;
417
- }
418
- /** Sets a command's default bindings. `null` leaves the command unbound. */
419
- setDefault(loose, input) {
420
- const id = loose;
421
- const bindings = toBindings(input);
422
- if (bindings.length === 0) this.#defaults.delete(id);
423
- else this.#defaults.set(id, bindings);
424
- }
425
- /** Removes a command's default bindings, leaving overrides intact. */
426
- clearDefault(loose) {
427
- this.#defaults.delete(loose);
428
- }
429
- /** Checks whether a command has an active user override. */
430
- isOverridden(loose) {
431
- return this.#overrides.has(loose);
432
- }
433
- get(loose) {
434
- const id = loose;
435
- return this.#overrides.get(id) ?? this.#defaults.get(id) ?? [];
436
- }
437
- /** Sets a user binding override for a command. Passing `null` unbinds it. */
438
- set(loose, input) {
439
- this.#overrides.set(loose, input === null ? [] : toBindings(input));
440
- this.#changed();
441
- }
442
- /** Adds an additional binding override for a command. */
443
- add(loose, input) {
444
- const id = loose;
445
- this.#overrides.set(id, [...this.get(id), ...toBindings(input)]);
446
- this.#changed();
447
- }
448
- /** Resets user overrides to defaults for one command or all commands. */
449
- reset(loose) {
450
- const id = loose;
451
- if (id === void 0) {
452
- if (this.#overrides.size === 0) return;
453
- this.#overrides.clear();
454
- } else if (!this.#overrides.delete(id)) return;
455
- this.#changed();
456
- }
457
- /** Exports user overrides as a serialized keymap object. */
458
- export() {
459
- return Object.fromEntries(Array.from(this.#overrides, ([id, bindings]) => [id, bindings.map(serializeBinding)]));
460
- }
461
- /** Imports serialized user overrides and saves to storage. */
462
- import(data) {
463
- this.#read(data);
464
- this.#changed();
465
- }
466
- /** Loads persisted overrides from storage. */
467
- load() {
468
- const storage = this.#storage();
469
- if (!storage) return;
470
- try {
471
- const raw = storage.getItem(this.#storageKey());
472
- if (!raw) return;
473
- this.#read(JSON.parse(raw));
474
- } catch {
475
- return;
476
- }
477
- for (const listener of this.#listeners) listener();
478
- }
479
- /** Subscribes to keymap changes. Returns an unsubscribe function. */
480
- subscribe(listener) {
481
- this.#listeners.add(listener);
482
- return () => {
483
- this.#listeners.delete(listener);
484
- };
485
- }
486
- #read(data) {
487
- if (!isJsonObject(data)) return;
488
- this.#overrides.clear();
489
- for (const [id, value] of Object.entries(data)) {
490
- if (!Array.isArray(value)) continue;
491
- const bindings = value.map(deserializeBinding).filter((binding) => binding !== null);
492
- this.#overrides.set(id, bindings);
493
- }
494
- }
495
- #changed() {
496
- this.#persist();
497
- for (const listener of this.#listeners) listener();
498
- }
499
- #persist() {
500
- const storage = this.#storage();
501
- if (!storage) return;
502
- try {
503
- const data = this.export();
504
- if (Object.keys(data).length === 0) storage.removeItem(this.#storageKey());
505
- else storage.setItem(this.#storageKey(), JSON.stringify(data));
506
- } catch {}
507
- }
508
- #storageKey() {
509
- return this.#persistence?.key ?? DEFAULT_STORAGE_KEY;
510
- }
511
- #storage() {
512
- try {
513
- return this.#persistence?.storage() ?? null;
514
- } catch {
515
- return null;
516
- }
517
- }
518
- };
519
- //#endregion
520
- //#region src/scopes.ts
521
- /** Wildcard scope identifier for commands that remain active across exclusive scopes. */
522
- const ALWAYS_SCOPE = "*";
523
- /** Stack managing active scopes and command reachability. */
524
- var ScopeStack = class {
525
- #entries = [];
526
- #listeners = /* @__PURE__ */ new Set();
527
- /** Activates a scope until the returned disposal function is called. */
528
- push(name, options = {}) {
529
- const entry = {
530
- name,
531
- exclusive: options.exclusive === true
532
- };
533
- this.#entries.push(entry);
534
- this.#notify();
535
- let released = false;
536
- return () => {
537
- if (released) return;
538
- released = true;
539
- const index = this.#entries.indexOf(entry);
540
- if (index !== -1) this.#entries.splice(index, 1);
541
- this.#notify();
542
- };
543
- }
544
- /** Checks whether commands in the specified scope can execute given the current scope stack. */
545
- isActive(scope) {
546
- if (scope === "*") return true;
547
- const barrier = this.#barrierIndex();
548
- if (barrier === -1) return scope === void 0 || this.#entries.some((entry) => entry.name === scope);
549
- if (scope === void 0) return false;
550
- for (let index = barrier; index < this.#entries.length; index++) if (this.#entries[index]?.name === scope) return true;
551
- return false;
552
- }
553
- /** Checks whether an exclusive scope is active. */
554
- hasBarrier() {
555
- return this.#barrierIndex() !== -1;
556
- }
557
- /** Clears all active scopes. */
558
- clear() {
559
- if (this.#entries.length === 0) return;
560
- this.#entries = [];
561
- this.#notify();
562
- }
563
- /** Subscribes to scope stack changes. Returns an unsubscribe function. */
564
- subscribe(listener) {
565
- this.#listeners.add(listener);
566
- return () => {
567
- this.#listeners.delete(listener);
568
- };
569
- }
570
- #barrierIndex() {
571
- for (let index = this.#entries.length - 1; index >= 0; index--) if (this.#entries[index]?.exclusive) return index;
572
- return -1;
573
- }
574
- #notify() {
575
- for (const listener of this.#listeners) listener();
576
- }
577
- };
578
- //#endregion
579
- //#region src/registry.ts
580
- /** Command registry managing command definitions, keymaps, scopes, and execution. */
581
- const EMPTY_PENDING = [];
582
- const SSR_PLATFORM = "windows";
583
- var CommandRegistry = class {
584
- #stacks = /* @__PURE__ */ new Map();
585
- #order = [];
586
- #enabled = /* @__PURE__ */ new Map();
587
- #keymap;
588
- #scopes = new ScopeStack();
589
- #binder;
590
- #options;
591
- #listeners = /* @__PURE__ */ new Set();
592
- #pendingListeners = /* @__PURE__ */ new Set();
593
- #pending = EMPTY_PENDING;
594
- #pendingSubscription = null;
595
- #snapshot = null;
596
- #index = null;
597
- #ownerIndex = null;
598
- #mounts = 0;
599
- #hydrated = false;
600
- #barrier = false;
601
- constructor(definitions, options = {}) {
602
- this.#options = options;
603
- this.#keymap = new Keymap(options.persist);
604
- this.#binder = new Binder({
605
- getDefinition: (id) => this.#definition(id),
606
- getBindings: (id) => this.#keymap.get(id),
607
- canRun: (id) => this.canRun(id),
608
- isReachable: (id) => this.#isReachable(id),
609
- claims: (id, signature) => this.#claims(id, signature),
610
- platform: () => this.#platform(),
611
- execute: (id, context) => {
612
- this.#execute(id, context);
613
- }
614
- }, options.target);
615
- for (const [id, definition] of Object.entries(definitions)) this.#push(id, definition);
616
- this.#keymap.load();
617
- this.#keymap.subscribe(() => this.#onKeymapChange());
618
- this.#scopes.subscribe(() => this.#onScopeChange());
619
- this.#audit();
620
- }
621
- get keymap() {
622
- return this.#keymap;
623
- }
624
- get(id) {
625
- this.#snapshotList();
626
- return this.#index?.get(id);
627
- }
628
- list() {
629
- return this.#snapshotList();
630
- }
631
- run(id, options = {}) {
632
- if (!this.canRun(id)) return false;
633
- this.#execute(id, {
634
- source: options.source ?? "api",
635
- event: options.event
636
- });
637
- return true;
638
- }
639
- canRun(id) {
640
- return this.whyNotRun(id) === null;
641
- }
642
- whyNotRun(loose) {
643
- const id = loose;
644
- const definition = this.#definition(id);
645
- if (!definition) return "unknown";
646
- if (!(this.#enabled.get(id) ?? false)) return "disabled";
647
- if (!this.#scopes.isActive(definition.scope)) return "out-of-scope";
648
- if (!definition.when) return null;
649
- try {
650
- return definition.when() === false ? "guarded" : null;
651
- } catch (error) {
652
- this.#reportError(toError(error), {
653
- id,
654
- source: "api",
655
- commands: this
656
- });
657
- return "guarded";
658
- }
659
- }
660
- setEnabled(loose, enabled) {
661
- const id = loose;
662
- if (!this.#stacks.has(id)) return;
663
- if (this.#enabled.get(id) === enabled) return;
664
- this.#enabled.set(id, enabled);
665
- this.#binder.refreshEnabled();
666
- this.#invalidate();
667
- }
668
- add(loose, definition) {
669
- const id = loose;
670
- const entry = this.#push(id, definition);
671
- if (this.#mounts > 0) this.#binder.sync(id);
672
- this.#invalidate();
673
- this.#auditAdded(id);
674
- let disposed = false;
675
- return {
676
- run: (options) => !disposed && this.run(id, options),
677
- dispose: () => {
678
- if (disposed) return;
679
- disposed = true;
680
- this.#pop(id, entry);
681
- }
682
- };
683
- }
684
- remove(loose) {
685
- const id = loose;
686
- if (!this.#stacks.has(id)) return;
687
- this.#drop(id);
688
- this.#invalidate();
689
- }
690
- pushScope(name, options) {
691
- return this.#scopes.push(name, options);
692
- }
693
- conflicts(binding, options = {}) {
694
- const platform = this.#platform();
695
- const signatures = new Set(toBindings(binding).map((entry) => bindingSignature(entry, platform)));
696
- const conflicts = [];
697
- for (const id of this.#order) {
698
- if (id === options.ignore) continue;
699
- const scope = this.#definition(id)?.scope;
700
- if (!scopesOverlap(scope, options.scope)) continue;
701
- for (const existing of this.#keymap.get(id)) {
702
- if (!signatures.has(bindingSignature(existing, platform))) continue;
703
- const display = formatBinding(existing, this.#displayOptions());
704
- conflicts.push({
705
- id,
706
- binding: existing,
707
- display,
708
- scope
709
- });
710
- }
711
- }
712
- return conflicts;
713
- }
714
- display(id) {
715
- const binding = this.#keymap.get(id)[0];
716
- return binding ? formatBinding(binding, this.#displayOptions()) : "";
717
- }
718
- tokens(id) {
719
- const binding = this.#keymap.get(id)[0];
720
- return binding ? bindingTokens(binding, this.#displayOptions()) : [];
721
- }
722
- mount() {
723
- this.#mounts++;
724
- if (this.#mounts === 1) {
725
- this.#hydrated = true;
726
- for (const id of this.#order) this.#binder.sync(id);
727
- this.#invalidate();
728
- }
729
- let released = false;
730
- return () => {
731
- if (released) return;
732
- released = true;
733
- if (this.#mounts === 0) return;
734
- this.#mounts--;
735
- if (this.#mounts === 0) this.#binder.destroy();
736
- };
737
- }
738
- pending() {
739
- return this.#pending;
740
- }
741
- subscribePending(listener) {
742
- this.#pendingListeners.add(listener);
743
- if (this.#pendingListeners.size === 1) this.#watchPending();
744
- return () => {
745
- this.#pendingListeners.delete(listener);
746
- if (this.#pendingListeners.size > 0) return;
747
- this.#pendingSubscription?.unsubscribe();
748
- this.#pendingSubscription = null;
749
- this.#pending = EMPTY_PENDING;
750
- };
751
- }
752
- subscribe(listener) {
753
- this.#listeners.add(listener);
754
- return () => {
755
- this.#listeners.delete(listener);
756
- };
757
- }
758
- destroy() {
759
- this.#mounts = 0;
760
- this.#binder.destroy();
761
- this.#stacks.clear();
762
- this.#order = [];
763
- this.#enabled.clear();
764
- this.#scopes.clear();
765
- this.#listeners.clear();
766
- this.#pendingListeners.clear();
767
- this.#pendingSubscription?.unsubscribe();
768
- this.#pendingSubscription = null;
769
- this.#pending = EMPTY_PENDING;
770
- this.#invalidate();
771
- }
772
- #onScopeChange() {
773
- const barrier = this.#scopes.hasBarrier();
774
- this.#binder.refreshEnabled();
775
- if (barrier && !this.#barrier) getSequenceManager().resetAll();
776
- this.#barrier = barrier;
777
- this.#invalidate();
778
- }
779
- #watchPending() {
780
- const store = getSequenceManager().registrations;
781
- const read = () => {
782
- const next = this.#readPending(store.state);
783
- if (samePending(next, this.#pending)) return;
784
- this.#pending = next;
785
- for (const listener of this.#pendingListeners) listener();
786
- };
787
- this.#pendingSubscription = store.subscribe(read);
788
- read();
789
- }
790
- #readPending(state) {
791
- const options = this.#displayOptions();
792
- const pending = [];
793
- for (const { id, binding, registrationId } of this.#binder.sequences()) {
794
- const matched = state.get(registrationId)?.matchedStepCount ?? 0;
795
- if (matched === 0 || matched >= binding.sequence.length) continue;
796
- pending.push({
797
- id,
798
- binding,
799
- matched,
800
- total: binding.sequence.length,
801
- remaining: binding.sequence.slice(matched).map((step) => formatBinding(step, options))
802
- });
803
- }
804
- return pending.length === 0 ? EMPTY_PENDING : pending;
805
- }
806
- #definition(id) {
807
- const stack = this.#stacks.get(id);
808
- return stack?.[stack.length - 1]?.definition;
809
- }
810
- #push(id, definition) {
811
- let stack = this.#stacks.get(id);
812
- if (!stack) {
813
- stack = [];
814
- this.#stacks.set(id, stack);
815
- this.#order.push(id);
816
- }
817
- const entry = { definition };
818
- stack.push(entry);
819
- this.#applyTop(id);
820
- return entry;
821
- }
822
- #pop(id, entry) {
823
- const stack = this.#stacks.get(id);
824
- if (!stack) return;
825
- const index = stack.indexOf(entry);
826
- if (index === -1) return;
827
- stack.splice(index, 1);
828
- if (stack.length === 0) this.#drop(id);
829
- else {
830
- this.#applyTop(id);
831
- if (this.#mounts > 0) this.#binder.sync(id);
832
- }
833
- this.#invalidate();
834
- }
835
- #applyTop(id) {
836
- const definition = this.#definition(id);
837
- if (!definition) return;
838
- this.#enabled.set(id, definition.enabled !== false);
839
- const overrides = this.#options.bindings;
840
- const keys = overrides && Object.hasOwn(overrides, id) ? overrides[id] : definition.keys;
841
- this.#keymap.setDefault(id, keys);
842
- }
843
- #drop(id) {
844
- this.#stacks.delete(id);
845
- const index = this.#order.indexOf(id);
846
- if (index !== -1) this.#order.splice(index, 1);
847
- this.#enabled.delete(id);
848
- this.#keymap.clearDefault(id);
849
- this.#binder.release(id);
850
- }
851
- #isReachable(id) {
852
- if (!(this.#enabled.get(id) ?? false)) return false;
853
- return this.#scopes.isActive(this.#definition(id)?.scope);
854
- }
855
- /**
856
- * Decides which of several commands sharing one binding gets to run.
857
- *
858
- * Without this every registration for the pressed chord fires, so two
859
- * commands bound to `Mod+S` in overlapping scopes both ran and the only
860
- * defence was a dev-time console warning. Exclusive scopes could not fix
861
- * it either: a barrier shadows *every* global command, not just the one
862
- * colliding, so "Esc closes the dialog, and the panel otherwise" forced
863
- * the whole dialog to become exclusive.
864
- *
865
- * Ranking, highest first:
866
- *
867
- * 1. A command in a named scope beats a global one. `ALWAYS_SCOPE` is
868
- * global for this purpose — it means "survives barriers", not
869
- * "belongs to some region of the UI".
870
- * 2. Later registration beats earlier. Registration order tracks mount
871
- * order, so the most recently opened surface claims the key — the
872
- * same intuition that makes `add()` stack definitions per id.
873
- *
874
- * Guards are evaluated live rather than baked into the ranking, so a
875
- * winner whose `when()` currently returns false steps aside instead of
876
- * swallowing the keypress.
877
- */
878
- #claims(id, signature) {
879
- const candidates = this.#owners().get(signature);
880
- if (!candidates || candidates.length < 2) return true;
881
- for (const candidate of candidates) {
882
- if (candidate !== id && !this.canRun(candidate)) continue;
883
- return candidate === id;
884
- }
885
- return false;
886
- }
887
- /** Binding signature → command ids that share it, ranked by `#claims`. */
888
- #owners() {
889
- if (this.#ownerIndex) return this.#ownerIndex;
890
- const platform = this.#platform();
891
- const index = /* @__PURE__ */ new Map();
892
- for (const id of this.#order) for (const binding of this.#keymap.get(id)) {
893
- const signature = bindingSignature(binding, platform);
894
- const ids = index.get(signature);
895
- if (ids) ids.unshift(id);
896
- else index.set(signature, [id]);
897
- }
898
- for (const ids of index.values()) if (ids.length > 1) ids.sort((a, b) => this.#scopeRank(b) - this.#scopeRank(a));
899
- this.#ownerIndex = index;
900
- return index;
901
- }
902
- #scopeRank(id) {
903
- const scope = this.#definition(id)?.scope;
904
- return scope === void 0 || scope === "*" ? 0 : 1;
905
- }
906
- #execute(id, context) {
907
- const definition = this.#definition(id);
908
- if (!definition) return;
909
- const full = {
910
- id,
911
- ...context,
912
- commands: this
913
- };
914
- if (this.#options.onRun) try {
915
- this.#options.onRun(full);
916
- } catch (error) {
917
- this.#reportError(toError(error), full);
918
- }
919
- try {
920
- const result = definition.run(full);
921
- if (result instanceof Promise) result.catch((cause) => this.#reportError(toError(cause), full));
922
- } catch (error) {
923
- this.#reportError(toError(error), full);
924
- }
925
- }
926
- #reportError(error, context) {
927
- if (this.#options.onError) {
928
- this.#options.onError(error, context);
929
- return;
930
- }
931
- console.error(`[@inklu/keys] command "${context.id}" failed`, error);
932
- }
933
- #onKeymapChange() {
934
- if (this.#mounts > 0) for (const id of this.#order) this.#binder.sync(id);
935
- this.#invalidate();
936
- }
937
- #platform() {
938
- return this.#options.platform ?? (this.#hydrated ? detectPlatform() : SSR_PLATFORM);
939
- }
940
- #displayOptions() {
941
- return {
942
- platform: this.#platform(),
943
- ...this.#options.display
944
- };
945
- }
946
- #snapshotList() {
947
- if (this.#snapshot) return this.#snapshot;
948
- const platform = this.#platform();
949
- const options = this.#displayOptions();
950
- const commands = [];
951
- const index = /* @__PURE__ */ new Map();
952
- for (const id of this.#order) {
953
- const definition = this.#definition(id);
954
- if (!definition) continue;
955
- const bindings = [...this.#keymap.get(id)];
956
- const command = {
957
- id,
958
- title: definition.title,
959
- scope: definition.scope,
960
- bindings,
961
- display: bindings.map((binding) => formatBinding(binding, options)),
962
- tokens: bindings.map((binding) => bindingTokens(binding, options)),
963
- aria: bindings.map((binding) => ariaKeyShortcuts(binding, platform)).filter((value) => value.length > 0).join(" "),
964
- enabled: this.#enabled.get(id) ?? false,
965
- reachable: this.#isReachable(id),
966
- overridden: this.#keymap.isOverridden(id)
967
- };
968
- commands.push(command);
969
- index.set(id, command);
970
- }
971
- this.#snapshot = commands;
972
- this.#index = index;
973
- return commands;
974
- }
975
- #invalidate() {
976
- this.#snapshot = null;
977
- this.#index = null;
978
- this.#ownerIndex = null;
979
- for (const listener of this.#listeners) listener();
980
- }
981
- #warnAboutConflicts() {
982
- const seen = /* @__PURE__ */ new Map();
983
- const collisions = [];
984
- for (const id of this.#order) {
985
- const scope = this.#definition(id)?.scope;
986
- for (const binding of this.#keymap.get(id)) {
987
- const key = `${scope ?? ""}::${bindingSignature(binding, this.#platform())}`;
988
- const owner = seen.get(key);
989
- if (owner === void 0) {
990
- seen.set(key, id);
991
- continue;
992
- }
993
- collisions.push(`${formatBinding(binding, this.#displayOptions())} — "${id}" shadows "${owner}"`);
994
- }
995
- }
996
- if (collisions.length > 0) console.warn(`[@inklu/keys] ${collisions.length} unreachable binding(s) — same chord, same scope:\n ${collisions.join("\n ")}`);
997
- }
998
- #warnAboutBindings(id) {
999
- for (const binding of this.#keymap.get(id)) {
1000
- const { valid, errors } = validateBinding(binding);
1001
- if (valid) continue;
1002
- const resolved = formatBinding(binding, this.#displayOptions());
1003
- console.warn(`[@inklu/keys] "${id}" has an unrecognized binding ${describeBinding(binding)}, which resolves to "${resolved}". ${errors.join("; ")}.`);
1004
- }
1005
- }
1006
- /** Catches ids in `options.bindings` that TypeScript could not — plain JS, or a runtime-only command. */
1007
- #warnAboutUnknownOverrides() {
1008
- const overrides = this.#options.bindings;
1009
- if (!overrides) return;
1010
- const unknown = Object.keys(overrides).filter((id) => !this.#stacks.has(id));
1011
- if (unknown.length > 0) console.warn(`[@inklu/keys] bindings override ${unknown.map((id) => `"${id}"`).join(", ")}, which ${unknown.length === 1 ? "is not a registered command" : "are not registered commands"}.`);
1012
- }
1013
- #audit() {
1014
- if (!isDevelopment()) return;
1015
- for (const id of this.#order) this.#warnAboutBindings(id);
1016
- this.#warnAboutUnknownOverrides();
1017
- this.#warnAboutConflicts();
1018
- }
1019
- #auditAdded(id) {
1020
- if (!isDevelopment()) return;
1021
- this.#warnAboutBindings(id);
1022
- const scope = this.#definition(id)?.scope;
1023
- for (const conflict of this.conflicts(this.#keymap.get(id), {
1024
- ignore: id,
1025
- scope
1026
- })) {
1027
- const winner = this.#scopeRank(conflict.id) > this.#scopeRank(id) ? conflict.id : id;
1028
- console.warn(`[@inklu/keys] "${id}" binds ${conflict.display}, which "${conflict.id}" already uses. While both are runnable the key goes to "${winner}".`);
1029
- }
1030
- }
1031
- };
1032
- function describeBinding(binding) {
1033
- return isSequence(binding) ? `"${binding.sequence.join(" ")}"` : `"${binding}"`;
1034
- }
1035
- /** Normalizes an arbitrary thrown value into a real `Error` at the catch boundary. */
1036
- function toError(cause) {
1037
- return cause instanceof Error ? cause : new Error(String(cause));
1038
- }
1039
- function isDevelopment() {
1040
- const environment = globalThis.process?.env?.NODE_ENV;
1041
- return environment !== void 0 && environment !== "production";
1042
- }
1043
- function samePending(a, b) {
1044
- if (a.length !== b.length) return false;
1045
- return a.every((entry, index) => {
1046
- const other = b[index];
1047
- return other !== void 0 && entry.id === other.id && entry.matched === other.matched;
1048
- });
1049
- }
1050
- function scopesOverlap(a, b) {
1051
- if (a === void 0 || b === void 0) return true;
1052
- if (a === "*" || b === "*") return true;
1053
- return a === b;
1054
- }
1055
- /**
1056
- * Creates a command registry.
1057
- *
1058
- * @example
1059
- * ```ts
1060
- * export const commands = createCommands({
1061
- * "palette.open": {
1062
- * title: "Open command palette",
1063
- * keys: "Mod+K",
1064
- * run: () => setPaletteOpen(true),
1065
- * },
1066
- * "nav.inbox": {
1067
- * title: "Go to inbox",
1068
- * keys: seq("G", "I"),
1069
- * run: () => navigate("/inbox"),
1070
- * },
1071
- * });
1072
- * ```
1073
- */
1074
- function createCommands(definitions, options) {
1075
- return new CommandRegistry(definitions, options);
1076
- }
1077
- //#endregion
1078
- export { bindingTokens as a, seq as c, ariaKeyShortcuts as i, toBindings as l, ALWAYS_SCOPE as n, formatBinding as o, createRebinder as r, bindingSignature as s, createCommands as t, validateBinding as u };