@inklu/keys 2.0.2 → 2.2.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.
@@ -0,0 +1,173 @@
1
+ import { C as SequenceBinding, D as Chord, T as SerializedKeymap, a as CommandDefinition, d as Conflict, g as KeymapStore, k as Platform, l as Commands, m as Json, p as DisplayOptions, r as Command, t as Binding, u as CommandsOptions } from "./types-D-hUKGoN.mjs";
2
+ //#region src/bindings.d.ts
3
+ /**
4
+ * Creates a sequence binding from ordered chords.
5
+ *
6
+ * @example
7
+ * ```ts
8
+ * keys: seq("G", "I")
9
+ * ```
10
+ */
11
+ declare function seq(...steps: Array<Chord>): SequenceBinding;
12
+ /**
13
+ * Whether every step is a printable character with no Control, Alt or Meta:
14
+ * the "character key shortcuts" WCAG 2.1.4 lets users turn off.
15
+ */
16
+ declare function isSingleKeyBinding(binding: Binding): boolean;
17
+ //#endregion
18
+ //#region src/display.d.ts
19
+ /**
20
+ * Formats a binding the way its platform writes shortcuts: `⇧⌘S` on a Mac,
21
+ * `Ctrl+Shift+S` elsewhere. The steps of a sequence are set apart by a space.
22
+ */
23
+ declare function formatBinding(binding: Binding, options?: DisplayOptions): string;
24
+ /**
25
+ * Splits a binding into display tokens for rendering `<kbd>` elements: one
26
+ * per key of a chord, one per step of a sequence.
27
+ */
28
+ declare function bindingTokens(binding: Binding, options?: DisplayOptions): Array<string>;
29
+ /**
30
+ * Phrases a binding for a screen reader: `"Command K"` for a chord,
31
+ * `"G then I"` for a sequence, which `aria-keyshortcuts` cannot express.
32
+ */
33
+ declare function spokenBinding(binding: Binding, platform?: Platform): string;
34
+ /**
35
+ * The `aria-keyshortcuts` value for a chord, as in `"Meta+K"`. Empty for a
36
+ * sequence and for a chord that cannot be read.
37
+ */
38
+ declare function ariaKeyShortcuts(binding: Binding, platform?: Platform): string;
39
+ //#endregion
40
+ //#region src/persist.d.ts
41
+ type WebStorage = Pick<Storage, "getItem" | "setItem" | "removeItem">;
42
+ /**
43
+ * Keeps user overrides in Web Storage under `key`, and follows edits made in
44
+ * other tabs. `storage` is only called in the browser; return
45
+ * `sessionStorage` or an object of your own to keep them somewhere else.
46
+ *
47
+ * @example
48
+ * ```ts
49
+ * createCommands(definitions, { persist: webStorage("my-app.keys") });
50
+ * ```
51
+ */
52
+ declare function webStorage(key?: string, storage?: () => WebStorage | null): KeymapStore;
53
+ /** A registry's user overrides as JSON-safe data, for your own storage or another device. */
54
+ declare function exportKeymap<Id extends string>(commands: Pick<Commands<Id>, "keymap">): SerializedKeymap;
55
+ /**
56
+ * Replaces a registry's user overrides with exported data, and saves them.
57
+ * Data in another format changes nothing.
58
+ */
59
+ declare function importKeymap<Id extends string>(commands: Pick<Commands<Id>, "keymap">, data: Json): void;
60
+ //#endregion
61
+ //#region src/rebind.d.ts
62
+ /** Hotkey capture mode: single `"chord"` or multi-step `"sequence"`. */
63
+ type RebindMode = "chord" | "sequence";
64
+ /** Current state of a rebinding session. */
65
+ interface RebindState<Id extends string = string> {
66
+ /** Whether keys are actively being recorded. */
67
+ readonly recording: boolean;
68
+ /** Most recently captured binding. */
69
+ readonly binding: Binding | null;
70
+ /** Captured steps in the current sequence session. */
71
+ readonly steps: ReadonlyArray<Chord>;
72
+ /** What stands in the captured binding's way: other commands using it, or the platform reserving it. */
73
+ readonly conflicts: ReadonlyArray<Conflict<Id>>;
74
+ }
75
+ /** Options for creating a rebinder. */
76
+ interface RebindOptions<Id extends string = string> {
77
+ /** Capture mode. Defaults to `"chord"`. */
78
+ mode?: RebindMode;
79
+ /** Whether to write the recorded (or cleared) binding into the keymap. Defaults to `true`. */
80
+ apply?: boolean;
81
+ /** Whether to append rather than replace existing bindings. Defaults to `false`. */
82
+ add?: boolean;
83
+ /**
84
+ * Prevent applying bindings that conflict with other commands. Defaults to
85
+ * `false`. A binding the browser or operating system reserves is never applied.
86
+ */
87
+ rejectConflicts?: boolean;
88
+ /** Ignore key events from inputs while recording. Defaults to `true`. */
89
+ ignoreInputs?: boolean;
90
+ /** Inactivity timeout in milliseconds before a sequence commits. */
91
+ idleTimeout?: number;
92
+ /** Callback fired when a binding is recorded. */
93
+ onRecord?: (binding: Binding, conflicts: ReadonlyArray<Conflict<Id>>) => void;
94
+ /** Callback fired when recording is cancelled. */
95
+ onCancel?: () => void;
96
+ /** Callback fired when Backspace or Delete clears the binding while recording. */
97
+ onClear?: () => void;
98
+ }
99
+ /** Controller instance for managing a rebinding session. */
100
+ interface Rebinder<Id extends string = string> {
101
+ /** Begins capturing keys. */
102
+ start: () => void;
103
+ /** Stops capturing keys without recording. */
104
+ stop: () => void;
105
+ /** Stops capturing keys and reports cancellation. */
106
+ cancel: () => void;
107
+ /** Commits the currently captured sequence steps. */
108
+ commit: () => void;
109
+ /** Current rebinder state. */
110
+ readonly state: RebindState<Id>;
111
+ /** Subscribes to rebinder state changes. Returns an unsubscribe function. */
112
+ subscribe: (listener: () => void) => () => void;
113
+ /** Destroys the rebinder and cleans up listeners. */
114
+ destroy: () => void;
115
+ }
116
+ /**
117
+ * Creates a rebinder for recording a command's keys. While it records, no
118
+ * command runs. Escape cancels, Backspace removes the shortcut (or the last
119
+ * step of a sequence), and Enter finishes a sequence.
120
+ *
121
+ * @example
122
+ * ```ts
123
+ * const rebinder = createRebinder(commands, "palette.open", {
124
+ * onRecord: (binding, conflicts) => showConflicts(conflicts),
125
+ * });
126
+ * rebinder.start();
127
+ * ```
128
+ */
129
+ declare function createRebinder<Id extends string>(registry: Commands<Id>, id: Id, options?: RebindOptions<Id>): Rebinder<Id>;
130
+ //#endregion
131
+ //#region src/registry.d.ts
132
+ /**
133
+ * Creates a command registry.
134
+ *
135
+ * @example
136
+ * ```ts
137
+ * export const commands = createCommands({
138
+ * "palette.open": { title: "Open command palette", keys: "Mod+K" },
139
+ * "nav.inbox": {
140
+ * title: "Go to inbox",
141
+ * keys: seq("G", "I"),
142
+ * run: () => router.navigate("/inbox"),
143
+ * },
144
+ * });
145
+ * ```
146
+ */
147
+ declare function createCommands<Id extends string>(definitions: { [K in Id]: CommandDefinition<NoInfer<Id>>; }, options?: CommandsOptions<NoInfer<Id>>): Commands<Id>;
148
+ /**
149
+ * Extracts command ID union from a Commands registry type.
150
+ *
151
+ * @example
152
+ * ```ts
153
+ * export const commands = createCommands({ … });
154
+ * export type AppCommandId = CommandId<typeof commands>;
155
+ * ```
156
+ */
157
+ type CommandId<C> = C extends Commands<infer Id> ? Id : never;
158
+ //#endregion
159
+ //#region src/search.d.ts
160
+ /**
161
+ * Filters commands for a search query, leaving out `hidden` ones. Every word
162
+ * must appear in the title, description, group, keywords, id or displayed
163
+ * shortcut. Titles starting with the query come first, then titles containing
164
+ * every word, then the rest. An empty query returns every visible command.
165
+ *
166
+ * @example
167
+ * ```ts
168
+ * const results = matchCommands(commands.list(), "go inbox");
169
+ * ```
170
+ */
171
+ declare function matchCommands<Id extends string>(commands: ReadonlyArray<Command<Id>>, query: string): Array<Command<Id>>;
172
+ //#endregion
173
+ export { seq as _, RebindOptions as a, createRebinder as c, webStorage as d, ariaKeyShortcuts as f, isSingleKeyBinding as g, spokenBinding as h, RebindMode as i, exportKeymap as l, formatBinding as m, CommandId as n, RebindState as o, bindingTokens as p, createCommands as r, Rebinder as s, matchCommands as t, importKeymap as u };
package/dist/index.d.mts CHANGED
@@ -1,3 +1,3 @@
1
- import { A as HotkeySequence, C as Command, D as Conflict, E as CommandSource, F as SequenceBinding, I as SerializedBinding, M as KeymapStorage, N as Platform, O as ConflictKind, P as ScopeOptions, S as Chord, T as CommandDefinition, _ as bindingTokens, a as Commands, b as Binding, c as PendingSequence, d as createCommands, f as CommandKeymap, g as ariaKeyShortcuts, h as DisplayOptions, i as CommandId, j as KeymapPersistence, k as Hotkey, l as RunBlockReason, m as SerializedOverride, n as observe, o as CommandsOptions, p as SerializedKeymap, r as CommandHandle, s as ConflictOptions, t as ObserveOptions, u as RunOptions, v as formatBinding, w as CommandContext, x as BindingInput, y as spokenBinding } from "./attributes-DotLr2MW.mjs";
2
- import { a as RebindState, c as BindingValidation, d as validateBinding, i as RebindOptions, l as isSingleKeyBinding, n as ALWAYS_SCOPE, o as Rebinder, r as RebindMode, s as createRebinder, t as matchCommands, u as seq } from "./index-BTgscYdI.mjs";
3
- export { ALWAYS_SCOPE, type Binding, type BindingInput, type BindingValidation, type Chord, type Command, type CommandContext, type CommandDefinition, type CommandHandle, type CommandId, type CommandKeymap, type CommandSource, type Commands, type CommandsOptions, type Conflict, type ConflictKind, type ConflictOptions, type DisplayOptions, type Hotkey, type HotkeySequence, type KeymapPersistence, type KeymapStorage, type ObserveOptions, type PendingSequence, type Platform, type RebindMode, type RebindOptions, type RebindState, type Rebinder, type RunBlockReason, type RunOptions, type ScopeOptions, type SequenceBinding, type SerializedBinding, type SerializedKeymap, type SerializedOverride, ariaKeyShortcuts, bindingTokens, createCommands, createRebinder, formatBinding, isSingleKeyBinding, matchCommands, observe, seq, spokenBinding, validateBinding };
1
+ import { C as SequenceBinding, D as Chord, E as SerializedOverride, O as Key, S as ScopeOptions, T as SerializedKeymap, _ as Override, a as CommandDefinition, b as RunBlockReason, c as CommandSource, d as Conflict, f as ConflictOptions, g as KeymapStore, h as KeyCapture, i as CommandContext, k as Platform, l as Commands, m as Json, n as BindingInput, o as CommandHandler, p as DisplayOptions, r as Command, s as CommandKeymap, t as Binding, u as CommandsOptions, v as PendingSequence, w as SerializedBinding, x as RunOptions, y as PlatformBindings } from "./types-D-hUKGoN.mjs";
2
+ import { _ as seq, a as RebindOptions, c as createRebinder, d as webStorage, f as ariaKeyShortcuts, g as isSingleKeyBinding, h as spokenBinding, i as RebindMode, l as exportKeymap, m as formatBinding, n as CommandId, o as RebindState, p as bindingTokens, r as createCommands, s as Rebinder, t as matchCommands, u as importKeymap } from "./index-DRXMJmbc.mjs";
3
+ export { type Binding, type BindingInput, type Chord, type Command, type CommandContext, type CommandDefinition, type CommandHandler, type CommandId, type CommandKeymap, type CommandSource, type Commands, type CommandsOptions, type Conflict, type ConflictOptions, type DisplayOptions, type Json, type Key, type KeyCapture, type KeymapStore, type Override, type PendingSequence, type Platform, type PlatformBindings, type RebindMode, type RebindOptions, type RebindState, type Rebinder, type RunBlockReason, type RunOptions, type ScopeOptions, type SequenceBinding, type SerializedBinding, type SerializedKeymap, type SerializedOverride, ariaKeyShortcuts, bindingTokens, createCommands, createRebinder, exportKeymap, formatBinding, importKeymap, isSingleKeyBinding, matchCommands, seq, spokenBinding, webStorage };
package/dist/index.mjs CHANGED
@@ -1,3 +1,2 @@
1
- import { observe } from "./attributes.mjs";
2
- import { a as ariaKeyShortcuts, c as spokenBinding, d as seq, i as createRebinder, n as createCommands, o as bindingTokens, p as validateBinding, r as ALWAYS_SCOPE, s as formatBinding, t as matchCommands, u as isSingleKeyBinding } from "./src-DgYjA1oS.mjs";
3
- export { ALWAYS_SCOPE, ariaKeyShortcuts, bindingTokens, createCommands, createRebinder, formatBinding, isSingleKeyBinding, matchCommands, observe, seq, spokenBinding, validateBinding };
1
+ import { a as importKeymap, c as bindingTokens, d as isSingleKeyBinding, f as seq, i as exportKeymap, l as formatBinding, n as createCommands, o as webStorage, r as createRebinder, s as ariaKeyShortcuts, t as matchCommands, u as spokenBinding } from "./src-TaZw4V61.mjs";
2
+ export { ariaKeyShortcuts, bindingTokens, createCommands, createRebinder, exportKeymap, formatBinding, importKeymap, isSingleKeyBinding, matchCommands, seq, spokenBinding, webStorage };
package/dist/react.d.mts CHANGED
@@ -1,5 +1,5 @@
1
- import { A as HotkeySequence, C as Command, D as Conflict, E as CommandSource, F as SequenceBinding, I as SerializedBinding, M as KeymapStorage, N as Platform, O as ConflictKind, P as ScopeOptions, S as Chord, T as CommandDefinition, _ as bindingTokens, a as Commands, b as Binding, c as PendingSequence, d as createCommands, f as CommandKeymap, g as ariaKeyShortcuts, h as DisplayOptions, i as CommandId, j as KeymapPersistence, k as Hotkey, l as RunBlockReason, m as SerializedOverride, n as observe, o as CommandsOptions, p as SerializedKeymap, r as CommandHandle, s as ConflictOptions, t as ObserveOptions, u as RunOptions, v as formatBinding, w as CommandContext, x as BindingInput, y as spokenBinding } from "./attributes-DotLr2MW.mjs";
2
- import { a as RebindState, c as BindingValidation, d as validateBinding, i as RebindOptions, l as isSingleKeyBinding, n as ALWAYS_SCOPE, o as Rebinder, r as RebindMode, s as createRebinder, t as matchCommands, u as seq } from "./index-BTgscYdI.mjs";
1
+ import { C as SequenceBinding, D as Chord, E as SerializedOverride, O as Key, S as ScopeOptions, T as SerializedKeymap, _ as Override, a as CommandDefinition, b as RunBlockReason, c as CommandSource, d as Conflict, f as ConflictOptions, g as KeymapStore, h as KeyCapture, i as CommandContext, k as Platform, l as Commands, m as Json, n as BindingInput, o as CommandHandler, p as DisplayOptions, r as Command, s as CommandKeymap, t as Binding, u as CommandsOptions, v as PendingSequence, w as SerializedBinding, x as RunOptions, y as PlatformBindings } from "./types-D-hUKGoN.mjs";
2
+ import { _ as seq, a as RebindOptions, c as createRebinder, d as webStorage, f as ariaKeyShortcuts, g as isSingleKeyBinding, h as spokenBinding, i as RebindMode, l as exportKeymap, m as formatBinding, n as CommandId, o as RebindState, p as bindingTokens, r as createCommands, s as Rebinder, t as matchCommands, u as importKeymap } from "./index-DRXMJmbc.mjs";
3
3
  //#region src/react/hooks.d.ts
4
4
  /**
5
5
  * Mounts a commands registry for the lifecycle of the component.
@@ -13,9 +13,13 @@ import { a as RebindState, c as BindingValidation, d as validateBinding, i as Re
13
13
  * ```
14
14
  */
15
15
  export declare function useMountCommands<Id extends string>(registry: Commands<Id>): void;
16
- /** Returns all registered commands, re-rendering when the registry changes. */
16
+ /**
17
+ * Returns all registered commands, re-rendering when the registry changes.
18
+ * Hydrates against `serverList()`, so a subtree that hydrates late still
19
+ * matches the server's HTML.
20
+ */
17
21
  export declare function useCommands<Id extends string>(registry: Commands<Id>): ReadonlyArray<Command<Id>>;
18
- /** Returns a specific command snapshot by ID. */
22
+ /** Returns one command's snapshot, re-rendering only when that command changes. */
19
23
  export declare function useCommand<Id extends string>(registry: Commands<Id>, id: NoInfer<Id>): Command<Id> | undefined;
20
24
  /**
21
25
  * Returns display and accessibility shortcuts for a command's primary
@@ -29,7 +33,9 @@ export declare function useShortcut<Id extends string>(registry: Commands<Id>, i
29
33
  spoken: string;
30
34
  };
31
35
  /**
32
- * Activates a scope for the lifecycle of the component.
36
+ * Activates a scope for the lifecycle of the component. Scopes rank in the
37
+ * order React runs effects in: of a parent and a child opening in the same
38
+ * render, the child is pushed first.
33
39
  *
34
40
  * @example
35
41
  * ```tsx
@@ -37,8 +43,19 @@ export declare function useShortcut<Id extends string>(registry: Commands<Id>, i
37
43
  * ```
38
44
  */
39
45
  export declare function useScope<Id extends string>(registry: Commands<Id>, name: string, active?: boolean, options?: ScopeOptions): void;
40
- /** Registers a dynamic command for the lifecycle of the component. */
41
- export declare function useRegisterCommand<Id extends string>(registry: Commands<Id>, id: NoInfer<Id> | (string & {}), definition: CommandDefinition<NoInfer<Id>>): void;
46
+ /**
47
+ * Attaches what a command does for the lifecycle of the component. The latest
48
+ * `run` is always the one called, and the handler attached last wins. While
49
+ * `enabled` is `false` the command falls to the handler attached before it,
50
+ * or cannot run if there is none.
51
+ *
52
+ * @example
53
+ * ```tsx
54
+ * useCommandHandler(commands, "palette.open", () => setOpen(true));
55
+ * useCommandHandler(commands, "doc.save", save, dirty);
56
+ * ```
57
+ */
58
+ export declare function useCommandHandler<Id extends string>(registry: Commands<Id>, id: NoInfer<Id>, run: CommandHandler<NoInfer<Id>>, enabled?: boolean): void;
42
59
  /** State and actions for rebinding a command. */
43
60
  interface UseRebindResult<Id extends string = string> extends RebindState<Id> {
44
61
  /** Active display string for the shortcut. */
@@ -56,27 +73,9 @@ interface UseRebindResult<Id extends string = string> extends RebindState<Id> {
56
73
  }
57
74
  /** Hook for recording and configuring a command's keybindings. */
58
75
  export declare function useRebind<Id extends string>(registry: Commands<Id>, id: NoInfer<Id>, options?: RebindOptions<Id>): UseRebindResult<Id>;
59
- /** Returns an array of keys currently held down. */
76
+ /** Returns the keys held down right now, by the name `KeyboardEvent.key` gives each. */
60
77
  export declare function useHeldKeys(): ReadonlyArray<string>;
61
- /**
62
- * Checks whether a specific key is currently held down.
63
- *
64
- * @example
65
- * ```tsx
66
- * const shift = useKeyHold("Shift");
67
- * ```
68
- */
69
- export declare function useKeyHold(key: string): boolean;
70
- /** Returns a map of held key identifiers to physical event codes. */
71
- export declare function useHeldKeyCodes(): Readonly<Record<string, string>>;
72
- /**
73
- * Returns currently active partially-typed sequences.
74
- *
75
- * @example
76
- * ```tsx
77
- * const pending = usePendingSequences(commands);
78
- * ```
79
- */
78
+ /** Returns the half-typed sequences, for a hint such as "G …". */
80
79
  export declare function usePendingSequences<Id extends string>(registry: Commands<Id>): ReadonlyArray<PendingSequence<Id>>;
81
80
  //#endregion
82
- export { ALWAYS_SCOPE, type Binding, type BindingInput, type BindingValidation, type Chord, type Command, type CommandContext, type CommandDefinition, type CommandHandle, type CommandId, type CommandKeymap, type CommandSource, type Commands, type CommandsOptions, type Conflict, type ConflictKind, type ConflictOptions, type DisplayOptions, type Hotkey, type HotkeySequence, type KeymapPersistence, type KeymapStorage, type ObserveOptions, type PendingSequence, type Platform, type RebindMode, type RebindOptions, type RebindState, type Rebinder, type RunBlockReason, type RunOptions, type ScopeOptions, type SequenceBinding, type SerializedBinding, type SerializedKeymap, type SerializedOverride, type UseRebindResult, ariaKeyShortcuts, bindingTokens, createCommands, createRebinder, formatBinding, isSingleKeyBinding, matchCommands, observe, seq, spokenBinding, validateBinding };
81
+ export { type Binding, type BindingInput, type Chord, type Command, type CommandContext, type CommandDefinition, type CommandHandler, type CommandId, type CommandKeymap, type CommandSource, type Commands, type CommandsOptions, type Conflict, type ConflictOptions, type DisplayOptions, type Json, type Key, type KeyCapture, type KeymapStore, type Override, type PendingSequence, type Platform, type PlatformBindings, type RebindMode, type RebindOptions, type RebindState, type Rebinder, type RunBlockReason, type RunOptions, type ScopeOptions, type SequenceBinding, type SerializedBinding, type SerializedKeymap, type SerializedOverride, type UseRebindResult, ariaKeyShortcuts, bindingTokens, createCommands, createRebinder, exportKeymap, formatBinding, importKeymap, isSingleKeyBinding, matchCommands, seq, spokenBinding, webStorage };
package/dist/react.mjs CHANGED
@@ -1,16 +1,66 @@
1
1
  "use client";
2
- import { observe } from "./attributes.mjs";
3
- import { a as ariaKeyShortcuts, c as spokenBinding, d as seq, f as toBindings, i as createRebinder, l as bindingSignature, n as createCommands, o as bindingTokens, p as validateBinding, r as ALWAYS_SCOPE, s as formatBinding, t as matchCommands, u as isSingleKeyBinding } from "./src-DgYjA1oS.mjs";
4
- import { getKeyStateTracker } from "@tanstack/hotkeys";
5
- import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
2
+ import { a as importKeymap, c as bindingTokens, d as isSingleKeyBinding, f as seq, i as exportKeymap, l as formatBinding, m as keyName, n as createCommands, o as webStorage, p as isModifierKey, r as createRebinder, s as ariaKeyShortcuts, t as matchCommands, u as spokenBinding } from "./src-TaZw4V61.mjs";
3
+ import { useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from "react";
4
+ //#region src/held.ts
5
+ /** Which keys are down right now. Listens only while something is subscribed. */
6
+ const NONE = [];
7
+ /** Held keys by physical key, so two keys producing one character stay apart. */
8
+ const down = /* @__PURE__ */ new Map();
9
+ const listeners = /* @__PURE__ */ new Set();
10
+ let held = NONE;
11
+ const identify = (event) => event.code || `key:${keyName(event.key)}`;
12
+ function publish() {
13
+ held = down.size === 0 ? NONE : [...new Set(down.values())];
14
+ for (const listener of listeners) listener();
15
+ }
16
+ function onKeyDown(event) {
17
+ if (!event.key || down.has(identify(event))) return;
18
+ down.set(identify(event), keyName(event.key));
19
+ publish();
20
+ }
21
+ function onKeyUp(event) {
22
+ down.delete(identify(event));
23
+ if (event.key === "Meta") {
24
+ for (const [identity, key] of down) if (!isModifierKey(key)) down.delete(identity);
25
+ }
26
+ publish();
27
+ }
28
+ /** Leaving the window or hiding the page swallows the keyups, so nothing is held any more. */
29
+ function onLeave() {
30
+ if (down.size === 0) return;
31
+ down.clear();
32
+ publish();
33
+ }
34
+ /** The keys held right now, by name. Empty unless something is subscribed. */
35
+ function heldKeys() {
36
+ return held;
37
+ }
38
+ /** Watches held keys until the returned function is called. */
39
+ function subscribeHeldKeys(listener) {
40
+ listeners.add(listener);
41
+ if (listeners.size === 1) {
42
+ document.addEventListener("keydown", onKeyDown, true);
43
+ document.addEventListener("keyup", onKeyUp, true);
44
+ document.addEventListener("visibilitychange", onLeave);
45
+ window.addEventListener("blur", onLeave);
46
+ }
47
+ return () => {
48
+ listeners.delete(listener);
49
+ if (listeners.size > 0) return;
50
+ document.removeEventListener("keydown", onKeyDown, true);
51
+ document.removeEventListener("keyup", onKeyUp, true);
52
+ document.removeEventListener("visibilitychange", onLeave);
53
+ window.removeEventListener("blur", onLeave);
54
+ down.clear();
55
+ held = NONE;
56
+ };
57
+ }
58
+ //#endregion
6
59
  //#region src/react/hooks.ts
7
60
  const EMPTY_TOKENS = [];
8
- const EMPTY_CODES = {};
9
61
  const EMPTY_PENDING = [];
10
62
  const getEmptyTokens = () => EMPTY_TOKENS;
11
- const getEmptyCodes = () => EMPTY_CODES;
12
63
  const getEmptyPending = () => EMPTY_PENDING;
13
- const getFalse = () => false;
14
64
  /**
15
65
  * Mounts a commands registry for the lifecycle of the component.
16
66
  *
@@ -25,20 +75,23 @@ const getFalse = () => false;
25
75
  function useMountCommands(registry) {
26
76
  useEffect(() => registry.mount(), [registry]);
27
77
  }
28
- function useCommandList(registry) {
78
+ /**
79
+ * Returns all registered commands, re-rendering when the registry changes.
80
+ * Hydrates against `serverList()`, so a subtree that hydrates late still
81
+ * matches the server's HTML.
82
+ */
83
+ function useCommands(registry) {
29
84
  const subscribe = useCallback((onChange) => registry.subscribe(onChange), [registry]);
30
85
  const getSnapshot = useCallback(() => registry.list(), [registry]);
31
86
  const getServerSnapshot = useCallback(() => registry.serverList(), [registry]);
32
87
  return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
33
88
  }
34
- /** Returns all registered commands, re-rendering when the registry changes. */
35
- function useCommands(registry) {
36
- return useCommandList(registry);
37
- }
38
- /** Returns a specific command snapshot by ID. */
89
+ /** Returns one command's snapshot, re-rendering only when that command changes. */
39
90
  function useCommand(registry, id) {
40
- const commands = useCommandList(registry);
41
- return useMemo(() => commands.find((command) => command.id === id), [commands, id]);
91
+ const subscribe = useCallback((onChange) => registry.subscribe(onChange), [registry]);
92
+ const getSnapshot = useCallback(() => registry.get(id), [registry, id]);
93
+ const getServerSnapshot = useCallback(() => registry.serverList().find((command) => command.id === id), [registry, id]);
94
+ return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
42
95
  }
43
96
  /**
44
97
  * Returns display and accessibility shortcuts for a command's primary
@@ -55,7 +108,9 @@ function useShortcut(registry, id) {
55
108
  }), [command]);
56
109
  }
57
110
  /**
58
- * Activates a scope for the lifecycle of the component.
111
+ * Activates a scope for the lifecycle of the component. Scopes rank in the
112
+ * order React runs effects in: of a parent and a child opening in the same
113
+ * render, the child is pushed first.
59
114
  *
60
115
  * @example
61
116
  * ```tsx
@@ -64,113 +119,83 @@ function useShortcut(registry, id) {
64
119
  */
65
120
  function useScope(registry, name, active = true, options = {}) {
66
121
  const exclusive = options.exclusive === true;
67
- const strict = options.strict === true;
68
122
  useEffect(() => {
69
123
  if (!active) return;
70
- return registry.pushScope(name, {
71
- exclusive,
72
- strict
73
- });
124
+ return registry.pushScope(name, { exclusive });
74
125
  }, [
75
126
  registry,
76
127
  name,
77
128
  active,
78
- exclusive,
79
- strict
129
+ exclusive
80
130
  ]);
81
131
  }
82
- /** Registers a dynamic command for the lifecycle of the component. */
83
- function useRegisterCommand(registry, id, definition) {
84
- const latest = useRef(definition);
85
- latest.current = definition;
86
- const signature = useMemo(() => definitionSignature(definition), [definition]);
132
+ /**
133
+ * Attaches what a command does for the lifecycle of the component. The latest
134
+ * `run` is always the one called, and the handler attached last wins. While
135
+ * `enabled` is `false` the command falls to the handler attached before it,
136
+ * or cannot run if there is none.
137
+ *
138
+ * @example
139
+ * ```tsx
140
+ * useCommandHandler(commands, "palette.open", () => setOpen(true));
141
+ * useCommandHandler(commands, "doc.save", save, dirty);
142
+ * ```
143
+ */
144
+ function useCommandHandler(registry, id, run, enabled = true) {
145
+ const latest = useRef(run);
146
+ latest.current = run;
87
147
  useEffect(() => {
88
- const handle = registry.add(id, {
89
- ...latest.current,
90
- run: (context) => latest.current.run(context),
91
- when: () => latest.current.when?.() !== false
92
- });
93
- return () => handle.dispose();
148
+ if (!enabled) return;
149
+ return registry.handle(id, (context) => latest.current(context));
94
150
  }, [
95
151
  registry,
96
152
  id,
97
- signature
153
+ enabled
98
154
  ]);
99
155
  }
100
- function definitionSignature(definition) {
101
- return [
102
- toBindings(definition.keys).map((binding) => bindingSignature(binding)).join("|"),
103
- definition.scope ?? "",
104
- definition.title,
105
- definition.description ?? "",
106
- definition.group ?? "",
107
- (definition.keywords ?? []).join(","),
108
- definition.hidden === true ? "1" : "0",
109
- definition.enabled === false ? "0" : "1",
110
- definition.preventDefault === false ? "0" : "1",
111
- definition.ignoreInputs === void 0 ? "" : String(definition.ignoreInputs),
112
- definition.requireReset === true ? "1" : "0",
113
- definition.sequenceTimeout === void 0 ? "" : String(definition.sequenceTimeout)
114
- ].join(" ");
115
- }
116
- const IDLE_REBIND = {
117
- recording: false,
118
- binding: null,
119
- steps: [],
120
- conflicts: []
121
- };
122
156
  /** Hook for recording and configuring a command's keybindings. */
123
157
  function useRebind(registry, id, options = {}) {
124
158
  const latest = useRef(options);
125
159
  latest.current = options;
126
- const command = useCommand(registry, id);
127
- const [rebinder, setRebinder] = useState(null);
128
- const [state, setState] = useState(IDLE_REBIND);
129
160
  const mode = options.mode ?? "chord";
130
- const idleTimeout = options.idleTimeout;
131
- useEffect(() => {
132
- const instance = createRebinder(registry, id, {
133
- mode,
134
- idleTimeout,
135
- get apply() {
136
- return latest.current.apply;
137
- },
138
- get add() {
139
- return latest.current.add;
140
- },
141
- get rejectConflicts() {
142
- return latest.current.rejectConflicts;
143
- },
144
- get ignoreInputs() {
145
- return latest.current.ignoreInputs;
146
- },
147
- onRecord: (binding, conflicts) => latest.current.onRecord?.(binding, conflicts),
148
- onCancel: () => latest.current.onCancel?.(),
149
- onClear: () => latest.current.onClear?.()
150
- });
151
- const unsubscribe = instance.subscribe(() => setState(instance.state));
152
- setRebinder(instance);
153
- return () => {
154
- unsubscribe();
155
- instance.destroy();
156
- setRebinder(null);
157
- setState(IDLE_REBIND);
158
- };
159
- }, [
161
+ const rebinder = useMemo(() => createRebinder(registry, id, {
162
+ mode,
163
+ get apply() {
164
+ return latest.current.apply;
165
+ },
166
+ get add() {
167
+ return latest.current.add;
168
+ },
169
+ get rejectConflicts() {
170
+ return latest.current.rejectConflicts;
171
+ },
172
+ get ignoreInputs() {
173
+ return latest.current.ignoreInputs;
174
+ },
175
+ get idleTimeout() {
176
+ return latest.current.idleTimeout;
177
+ },
178
+ onRecord: (binding, conflicts) => latest.current.onRecord?.(binding, conflicts),
179
+ onCancel: () => latest.current.onCancel?.(),
180
+ onClear: () => latest.current.onClear?.()
181
+ }), [
160
182
  registry,
161
183
  id,
162
- mode,
163
- idleTimeout
184
+ mode
164
185
  ]);
186
+ useEffect(() => rebinder.destroy, [rebinder]);
187
+ const getState = useCallback(() => rebinder.state, [rebinder]);
188
+ const state = useSyncExternalStore(rebinder.subscribe, getState, getState);
189
+ const command = useCommand(registry, id);
165
190
  return useMemo(() => ({
166
191
  ...state,
167
192
  display: command?.display[0] ?? "",
168
193
  tokens: command?.tokens[0] ?? EMPTY_TOKENS,
169
194
  overridden: command?.overridden ?? false,
170
- start: () => rebinder?.start(),
171
- stop: () => rebinder?.stop(),
172
- cancel: () => rebinder?.cancel(),
173
- commit: () => rebinder?.commit(),
195
+ start: rebinder.start,
196
+ stop: rebinder.stop,
197
+ cancel: rebinder.cancel,
198
+ commit: rebinder.commit,
174
199
  clear: () => registry.keymap.set(id, null),
175
200
  reset: () => registry.keymap.reset(id)
176
201
  }), [
@@ -181,45 +206,15 @@ function useRebind(registry, id, options = {}) {
181
206
  id
182
207
  ]);
183
208
  }
184
- function useKeyState() {
185
- return useCallback((onChange) => {
186
- const subscription = getKeyStateTracker().store.subscribe(onChange);
187
- return () => subscription.unsubscribe();
188
- }, []);
189
- }
190
- /** Returns an array of keys currently held down. */
209
+ /** Returns the keys held down right now, by the name `KeyboardEvent.key` gives each. */
191
210
  function useHeldKeys() {
192
- return useSyncExternalStore(useKeyState(), () => getKeyStateTracker().store.state.heldKeys, getEmptyTokens);
211
+ return useSyncExternalStore(subscribeHeldKeys, heldKeys, getEmptyTokens);
193
212
  }
194
- /**
195
- * Checks whether a specific key is currently held down.
196
- *
197
- * @example
198
- * ```tsx
199
- * const shift = useKeyHold("Shift");
200
- * ```
201
- */
202
- function useKeyHold(key) {
203
- const subscribe = useKeyState();
204
- const getSnapshot = useCallback(() => getKeyStateTracker().isKeyHeld(key), [key]);
205
- return useSyncExternalStore(subscribe, getSnapshot, getFalse);
206
- }
207
- /** Returns a map of held key identifiers to physical event codes. */
208
- function useHeldKeyCodes() {
209
- return useSyncExternalStore(useKeyState(), () => getKeyStateTracker().store.state.heldCodes, getEmptyCodes);
210
- }
211
- /**
212
- * Returns currently active partially-typed sequences.
213
- *
214
- * @example
215
- * ```tsx
216
- * const pending = usePendingSequences(commands);
217
- * ```
218
- */
213
+ /** Returns the half-typed sequences, for a hint such as "G …". */
219
214
  function usePendingSequences(registry) {
220
215
  const subscribe = useCallback((onChange) => registry.subscribePending(onChange), [registry]);
221
216
  const getSnapshot = useCallback(() => registry.pending(), [registry]);
222
217
  return useSyncExternalStore(subscribe, getSnapshot, getEmptyPending);
223
218
  }
224
219
  //#endregion
225
- export { ALWAYS_SCOPE, ariaKeyShortcuts, bindingTokens, createCommands, createRebinder, formatBinding, isSingleKeyBinding, matchCommands, observe, seq, spokenBinding, useCommand, useCommands, useHeldKeyCodes, useHeldKeys, useKeyHold, useMountCommands, usePendingSequences, useRebind, useRegisterCommand, useScope, useShortcut, validateBinding };
220
+ export { ariaKeyShortcuts, bindingTokens, createCommands, createRebinder, exportKeymap, formatBinding, importKeymap, isSingleKeyBinding, matchCommands, seq, spokenBinding, useCommand, useCommandHandler, useCommands, useHeldKeys, useMountCommands, usePendingSequences, useRebind, useScope, useShortcut, webStorage };