@tanstack/hotkeys 0.19.0 → 0.21.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,5 @@
1
+ import { AppHotkeys, AppHotkeysStore, AppHotkeysStoreOptions } from "./app-hotkeys.js";
2
+ //#region src/_app-hotkeys-factory.d.ts
3
+ /** Creates the store for app hotkeys from `defineAppHotkeys`. */
4
+ export declare function createAppHotkeysStore<T extends AppHotkeys>(appHotkeys: T, options: AppHotkeysStoreOptions<T>): AppHotkeysStore<T>;
5
+ //#endregion
@@ -0,0 +1,13 @@
1
+ //#region src/_app-hotkeys-factory.ts
2
+ let factory;
3
+ function setAppHotkeysStoreFactory(create) {
4
+ factory = create;
5
+ }
6
+ /** Creates the store for app hotkeys from `defineAppHotkeys`. */
7
+ function createAppHotkeysStore(appHotkeys, options) {
8
+ if (!factory) throw new Error("[hotkeys] App hotkeys must come from defineAppHotkeys.");
9
+ return factory(appHotkeys, options);
10
+ }
11
+
12
+ //#endregion
13
+ export { createAppHotkeysStore, setAppHotkeysStoreFactory };
@@ -34,9 +34,8 @@ function codeToLogicalKey(code) {
34
34
  if (/^Key[A-Z]$/i.test(code)) return code.slice(3).toUpperCase();
35
35
  if (/^Digit[0-9]$/i.test(code)) return code.slice(5);
36
36
  if (/^Numpad[0-9]$/i.test(code)) return code.slice(6);
37
- const punctuation = PUNCTUATION_CODE_MAP[code];
38
- if (punctuation) return punctuation;
39
- return {
37
+ if (Object.hasOwn(PUNCTUATION_CODE_MAP, code)) return PUNCTUATION_CODE_MAP[code];
38
+ const named = {
40
39
  NumpadAdd: "+",
41
40
  NumpadSubtract: "-",
42
41
  NumpadMultiply: "*",
@@ -44,7 +43,8 @@ function codeToLogicalKey(code) {
44
43
  NumpadDecimal: ".",
45
44
  NumpadEnter: "Enter",
46
45
  Space: "Space"
47
- }[code];
46
+ };
47
+ return Object.hasOwn(named, code) ? named[code] : void 0;
48
48
  }
49
49
  /**
50
50
  * Whether AltGr changed what a key typed, as in AltGr+Q typing `@`. False when
@@ -102,6 +102,14 @@ function keysEqual(left, right) {
102
102
  function assertLogicalKey(key) {
103
103
  if (/^\[.*\]$/s.test(key.trim())) throw new Error("Logical key must not contain a bracketed physical code. Use the code field instead.");
104
104
  }
105
+ /**
106
+ * Whether a key is written like a key the library knows: one character,
107
+ * F1-F24, or a capitalized name such as `Escape`. Key names are
108
+ * case-sensitive, so `escape` and `f1` don't qualify.
109
+ */
110
+ function isKeyNameShape(key) {
111
+ return /^(?:.|F(?:[1-9]|1\d|2[0-4])|[A-Z][a-z][A-Za-z\d]*)$/su.test(key);
112
+ }
105
113
 
106
114
  //#endregion
107
- export { altGraphTypedCharacter, assertLogicalKey, codeToLogicalKey, isAsciiLetter, isCodeName, isLatinLetter, isPrintableKey, keysEqual, modifierAlias, normalizeKeyboardEvent, physicalCodeOf, splitChordParts };
115
+ export { altGraphTypedCharacter, assertLogicalKey, codeToLogicalKey, isAsciiLetter, isCodeName, isKeyNameShape, isLatinLetter, isPrintableKey, keysEqual, modifierAlias, normalizeKeyboardEvent, physicalCodeOf, splitChordParts };
@@ -60,6 +60,26 @@ const LOGICAL_ONLY_NAMED_KEYS = [
60
60
  "Hankaku",
61
61
  "ZenkakuHankaku"
62
62
  ];
63
+ /** Arrow, Home/End, and PageUp/PageDown keys. */
64
+ const NAVIGATION_KEY_NAMES = [
65
+ "ArrowUp",
66
+ "ArrowDown",
67
+ "ArrowLeft",
68
+ "ArrowRight",
69
+ "Home",
70
+ "End",
71
+ "PageUp",
72
+ "PageDown"
73
+ ];
74
+ /** Editing and special keys. */
75
+ const EDITING_KEY_NAMES = [
76
+ "Enter",
77
+ "Escape",
78
+ "Space",
79
+ "Tab",
80
+ "Backspace",
81
+ "Delete"
82
+ ];
63
83
 
64
84
  //#endregion
65
- export { LOGICAL_ONLY_NAMED_KEYS, SHARED_NAMED_KEYS };
85
+ export { EDITING_KEY_NAMES, LOGICAL_ONLY_NAMED_KEYS, NAVIGATION_KEY_NAMES, SHARED_NAMED_KEYS };
@@ -0,0 +1,14 @@
1
+ //#region src/_registration.d.ts
2
+ /**
3
+ * Merges option layers left to right. A key set to `undefined` keeps the
4
+ * earlier layer's value, so an unset option keeps its default. Getters are
5
+ * read once.
6
+ */
7
+ export declare function definedOptions<T extends object>(...layers: ReadonlyArray<object | undefined>): T;
8
+ /**
9
+ * Merges options over defaults for an API whose `setOptions` treats
10
+ * `undefined` as "clear". An unset option takes its default, or stays
11
+ * `undefined` to clear an earlier value when it has no default.
12
+ */
13
+ export declare function withDefaults<T extends object>(defaults: object | undefined, options: T | undefined): T;
14
+ //#endregion
@@ -16,6 +16,37 @@ const defaultHotkeyOptions = {
16
16
  scope: GLOBAL_SCOPE
17
17
  };
18
18
  /**
19
+ * Merges option layers left to right. A key set to `undefined` keeps the
20
+ * earlier layer's value, so an unset option keeps its default. Getters are
21
+ * read once.
22
+ */
23
+ function definedOptions(...layers) {
24
+ const result = {};
25
+ for (const layer of layers) {
26
+ if (!layer) continue;
27
+ for (const key of Object.keys(layer)) {
28
+ const value = layer[key];
29
+ if (value !== void 0) result[key] = value;
30
+ }
31
+ }
32
+ return result;
33
+ }
34
+ /**
35
+ * Merges options over defaults for an API whose `setOptions` treats
36
+ * `undefined` as "clear". An unset option takes its default, or stays
37
+ * `undefined` to clear an earlier value when it has no default.
38
+ */
39
+ function withDefaults(defaults, options) {
40
+ return {
41
+ ...options,
42
+ ...definedOptions(defaults, options)
43
+ };
44
+ }
45
+ /** Whether a parsed chord has a key. An empty one would match key-less events. */
46
+ function chordHasKey(chord) {
47
+ return chord.code !== void 0 || !!chord.key;
48
+ }
49
+ /**
19
50
  * Resolves `ignoreInputs: 'auto'` from the hotkey (the first step of a sequence).
20
51
  * Ctrl/Meta shortcuts and Escape fire in inputs; single keys and Shift/Alt combos are ignored.
21
52
  */
@@ -35,7 +66,7 @@ function getDefaultIgnoreInputs(parsedChord) {
35
66
  function handleConflict(conflictingId, keyDisplay, conflictBehavior, unregister) {
36
67
  if (conflictBehavior === "allow") return;
37
68
  if (conflictBehavior === "warn") {
38
- 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.`);
69
+ if (process.env.NODE_ENV !== "production") 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.`);
39
70
  return;
40
71
  }
41
72
  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.`);
@@ -50,4 +81,4 @@ function optionsEqual(left, right) {
50
81
  }
51
82
 
52
83
  //#endregion
53
- export { GLOBAL_SCOPE, defaultHotkeyOptions, getDefaultIgnoreInputs, handleConflict, optionsEqual };
84
+ export { GLOBAL_SCOPE, chordHasKey, defaultHotkeyOptions, definedOptions, getDefaultIgnoreInputs, handleConflict, optionsEqual, withDefaults };
@@ -0,0 +1,14 @@
1
+ import { Store as Store$1 } from "./vendor/@tanstack/store/dist/store.js";
2
+ //#region src/_store.d.ts
3
+ /**
4
+ * The part of TanStack Store's `Store` that TanStack Hotkeys uses. Its stores
5
+ * work with TanStack Store's framework adapters, such as `useSelector` from
6
+ * `@tanstack/react-store`.
7
+ */
8
+ export type Store<T> = Pick<Store$1<T>, 'state' | 'get' | 'setState' | 'subscribe'>;
9
+ /**
10
+ * Creates a store. Listeners run synchronously after each change, and an
11
+ * update to an equal value (by `Object.is`) notifies no one.
12
+ */
13
+ export declare function createStore<T>(initialState: T): Store<T>;
14
+ //#endregion
package/dist/_store.js ADDED
@@ -0,0 +1,43 @@
1
+ //#region src/_store.ts
2
+ const queue = /* @__PURE__ */ new Set();
3
+ function notify(listeners) {
4
+ for (const listener of listeners) queue.add(listener);
5
+ for (const listener of queue) {
6
+ queue.delete(listener);
7
+ listener();
8
+ }
9
+ }
10
+ /**
11
+ * Creates a store. Listeners run synchronously after each change, and an
12
+ * update to an equal value (by `Object.is`) notifies no one.
13
+ */
14
+ function createStore(initialState) {
15
+ let state = initialState;
16
+ const listeners = /* @__PURE__ */ new Set();
17
+ return {
18
+ get state() {
19
+ return state;
20
+ },
21
+ get: () => state,
22
+ setState(updater) {
23
+ const next = updater(state);
24
+ if (Object.is(state, next)) return;
25
+ state = next;
26
+ notify(listeners);
27
+ },
28
+ subscribe(observerOrFn) {
29
+ const listener = () => {
30
+ if (typeof observerOrFn === "function") observerOrFn(state);
31
+ else observerOrFn.next?.(state);
32
+ };
33
+ listeners.add(listener);
34
+ return { unsubscribe() {
35
+ listeners.delete(listener);
36
+ queue.delete(listener);
37
+ } };
38
+ }
39
+ };
40
+ }
41
+
42
+ //#endregion
43
+ export { createStore };
package/dist/adapter.d.ts CHANGED
@@ -1,6 +1,9 @@
1
+ import { Store, createStore } from "./_store.js";
1
2
  import { HotkeyCallback, HotkeyChord, HotkeySequence, RegisterableHotkey, RegisterableHotkeyChord, RegisterableHotkeySequence } from "./hotkey.types.js";
2
3
  import { ActivateHotkeyScopeOptions, HotkeyChordOptions, HotkeyOptions, HotkeySequenceOptions, Target } from "./hotkey-manager.js";
3
4
  import { AppHotkey, AppHotkeysStore } from "./app-hotkeys.js";
5
+ import { createAppHotkeysStore } from "./_app-hotkeys-factory.js";
6
+ import { definedOptions, withDefaults } from "./_registration.js";
4
7
  //#region src/adapter.d.ts
5
8
  /**
6
9
  * A hotkey and its callback, with optional per-binding options. Chords accept
@@ -14,6 +17,11 @@ export type HotkeyDefinition = {
14
17
  hotkey: RegisterableHotkeySequence;
15
18
  callback: HotkeyCallback;
16
19
  options?: HotkeySequenceOptions;
20
+ } | {
21
+ /** No hotkey. Registers nothing. */
22
+ hotkey: null | undefined;
23
+ callback: HotkeyCallback;
24
+ options?: HotkeyChordOptions & HotkeySequenceOptions;
17
25
  };
18
26
  /** Options shared by every definition. Each option applies only to the kinds that support it. */
19
27
  export type HotkeyBindingsCommonOptions = HotkeyChordOptions & HotkeySequenceOptions;
@@ -21,7 +29,8 @@ export type HotkeyBindingsCommonOptions = HotkeyChordOptions & HotkeySequenceOpt
21
29
  export declare function createHotkeyBindings(): {
22
30
  /**
23
31
  * Registers new definitions, updates existing ones in place, and removes
24
- * stale ones. Definitions with an empty sequence or a null target are skipped.
32
+ * stale ones. Definitions with no hotkey (undefined, null, empty string, or
33
+ * empty sequence) or a null target are skipped.
25
34
  */
26
35
  update(definitions: ReadonlyArray<HotkeyDefinition>, commonOptions?: HotkeyBindingsCommonOptions): void;
27
36
  destroy(): void;
@@ -77,9 +86,10 @@ export type AdapterHotkeyDefinition<TTarget> = {
77
86
  } | {
78
87
  /**
79
88
  * A hotkey whose kind is not known statically, such as user data typed
80
- * `Hotkey`. Accepts only shared options.
89
+ * `Hotkey`. Accepts only shared options. `undefined`, `null`, and `''`
90
+ * register nothing, for an optional shortcut.
81
91
  */
82
- hotkey: RegisterableHotkey;
92
+ hotkey: RegisterableHotkey | null | undefined;
83
93
  callback: HotkeyCallback;
84
94
  options?: AdapterHotkeyOptions<TTarget>;
85
95
  };
@@ -94,7 +104,7 @@ export interface PrepareHotkeyDefinitionsOptions<TTarget> {
94
104
  /**
95
105
  * Resolves a framework target, such as a ref. Return `undefined` for the
96
106
  * document or `null` to skip registration until the target exists.
97
- * Without it, targets pass through and `null` means document.
107
+ * Without it, targets pass through, so `null` skips registration.
98
108
  */
99
109
  resolveTarget?: (target: TTarget) => Target | null | undefined;
100
110
  }
@@ -160,4 +170,5 @@ export declare function getHotkeysProviderScopeKey(options: {
160
170
  * Returns a function that releases the activation.
161
171
  */
162
172
  export declare function activateHotkeysProviderScope(key: string): () => void;
163
- //#endregion
173
+ //#endregion
174
+ export { type Store, createAppHotkeysStore, createStore, definedOptions, withDefaults };
package/dist/adapter.js CHANGED
@@ -1,7 +1,9 @@
1
1
  import { detectPlatform } from "./platform.js";
2
- import { isHotkeySequence, normalizeHotkey } from "./parse.js";
3
- import { defaultHotkeyOptions } from "./_registration.js";
2
+ import { isHotkeySequence, normalizeHotkey, parseHotkey } from "./parse.js";
3
+ import { chordHasKey, defaultHotkeyOptions, definedOptions, withDefaults } from "./_registration.js";
4
+ import { createStore } from "./_store.js";
4
5
  import { getHotkeyManager } from "./hotkey-manager.js";
6
+ import { createAppHotkeysStore } from "./_app-hotkeys-factory.js";
5
7
  import { getAppHotkeyRegistrationOptions, isAppHotkey, resolveAppHotkey } from "./app-hotkeys.js";
6
8
 
7
9
  //#region src/adapter.ts
@@ -11,24 +13,23 @@ function createHotkeyBindings() {
11
13
  return {
12
14
  /**
13
15
  * Registers new definitions, updates existing ones in place, and removes
14
- * stale ones. Definitions with an empty sequence or a null target are skipped.
16
+ * stale ones. Definitions with no hotkey (undefined, null, empty string, or
17
+ * empty sequence) or a null target are skipped.
15
18
  */
16
19
  update(definitions, commonOptions = {}) {
17
20
  const prepared = [];
18
21
  definitions.forEach((definition, index) => {
19
- const merged = {
20
- ...commonOptions,
21
- ...definition.options
22
- };
22
+ const merged = definedOptions(commonOptions, definition.options);
23
23
  const options = {
24
24
  ...defaultHotkeyOptions,
25
25
  ...merged,
26
- ignoreInputs: merged.ignoreInputs ?? "auto",
27
26
  target: "target" in merged ? merged.target ?? null : typeof document === "undefined" ? null : document,
28
27
  platform: merged.platform ?? detectPlatform()
29
28
  };
30
- if (!options.target || isHotkeySequence(definition.hotkey) && definition.hotkey.length === 0) return;
31
- const hotkey = normalizeHotkey(definition.hotkey, options.platform);
29
+ if (!options.target || !definition.hotkey || isHotkeySequence(definition.hotkey) && definition.hotkey.length === 0) return;
30
+ const parsed = parseHotkey(definition.hotkey, options.platform);
31
+ if (!(Array.isArray(parsed) ? parsed : [parsed]).every(chordHasKey)) return;
32
+ const hotkey = normalizeHotkey(parsed, options.platform);
32
33
  if (isHotkeySequence(hotkey)) delete options.requireReset;
33
34
  else delete options.timeout;
34
35
  prepared.push({
@@ -87,13 +88,8 @@ function prepareHotkeyDefinitions(definitions, options = {}) {
87
88
  declared = getAppHotkeyRegistrationOptions(appHotkey);
88
89
  hotkey = appHotkey.hotkey ?? [];
89
90
  }
90
- const { target, ...merged } = {
91
- ...options.defaults,
92
- ...declared,
93
- ...options.commonOptions,
94
- ...definition.options
95
- };
96
- const resolved = target === void 0 ? void 0 : options.resolveTarget ? options.resolveTarget(target) : target ?? void 0;
91
+ const { target, ...merged } = definedOptions(options.defaults, declared, options.commonOptions, definition.options);
92
+ const resolved = target === void 0 ? void 0 : options.resolveTarget ? options.resolveTarget(target) : target;
97
93
  return {
98
94
  hotkey,
99
95
  callback: definition.callback,
@@ -134,14 +130,10 @@ function subscribePublishedAppHotkeys(onChange) {
134
130
  function mergeHotkeysProviderDefaults(parent, own, scope) {
135
131
  return {
136
132
  hotkey: {
137
- ...parent?.hotkey,
138
- ...own?.hotkey,
133
+ ...definedOptions(parent?.hotkey, own?.hotkey),
139
134
  ...scope && { scope }
140
135
  },
141
- hotkeyRecorder: {
142
- ...parent?.hotkeyRecorder,
143
- ...own?.hotkeyRecorder
144
- }
136
+ hotkeyRecorder: definedOptions(parent?.hotkeyRecorder, own?.hotkeyRecorder)
145
137
  };
146
138
  }
147
139
  /**
@@ -171,4 +163,4 @@ function activateHotkeysProviderScope(key) {
171
163
  }
172
164
 
173
165
  //#endregion
174
- export { activateHotkeysProviderScope, createHotkeyBindings, getHotkeysProviderScopeKey, mergeHotkeysProviderDefaults, prepareHotkeyDefinitions, subscribePublishedAppHotkeys };
166
+ export { activateHotkeysProviderScope, createAppHotkeysStore, createHotkeyBindings, createStore, definedOptions, getHotkeysProviderScopeKey, mergeHotkeysProviderDefaults, prepareHotkeyDefinitions, subscribePublishedAppHotkeys, withDefaults };
@@ -1,6 +1,6 @@
1
+ import { Store } from "./_store.js";
1
2
  import { Hotkey, HotkeyChord, HotkeyMeta, HotkeySequence } from "./hotkey.types.js";
2
3
  import { HotkeyChordOptions, HotkeyRegistrationView, HotkeySequenceOptions } from "./hotkey-manager.js";
3
- import { Store } from "@tanstack/store";
4
4
  //#region src/app-hotkeys.d.ts
5
5
  type Platform = 'mac' | 'windows' | 'linux';
6
6
  type DeclaredOptionKeys = 'capture' | 'conflictBehavior' | 'eventType' | 'ignoreInputs' | 'preventDefault' | 'stopPropagation';
@@ -135,7 +135,13 @@ export interface AppHotkeysState<T extends AppHotkeys = AppHotkeys> {
135
135
  }
136
136
  /** Options for `AppHotkeysStore`. */
137
137
  export interface AppHotkeysStoreOptions<T extends AppHotkeys = AppHotkeys> {
138
- /** Starting custom hotkeys, such as saved data. Invalid entries are ignored with a warning. */
138
+ /**
139
+ * The user's saved custom hotkeys. Invalid entries are ignored with a
140
+ * warning. Passing a different value to `setOptions` later, such as data
141
+ * that finished loading or another user's data, replaces the current
142
+ * custom hotkeys. `undefined` leaves them unchanged; a change to `null`
143
+ * clears them.
144
+ */
139
145
  customHotkeys?: CustomHotkeys<T> | null;
140
146
  /** Called with the custom hotkeys after every change, ready to save. */
141
147
  onCustomHotkeysChange?: (customHotkeys: CustomHotkeys<T>) => void;
@@ -165,8 +171,17 @@ export declare class AppHotkeysStore<T extends AppHotkeys = AppHotkeys> {
165
171
  get appHotkeys(): T;
166
172
  /** The user's hotkeys that differ from the defaults. */
167
173
  get customHotkeys(): CustomHotkeys<T>;
168
- /** Updates callbacks and other options without resetting custom hotkeys. */
169
- setOptions(options: Omit<AppHotkeysStoreOptions<T>, 'customHotkeys'>): void;
174
+ /**
175
+ * Updates options. Providers call it with their props on every update.
176
+ *
177
+ * When `customHotkeys` differs from the value last passed in, it replaces
178
+ * the current custom hotkeys. An equal value, such as the same saved data
179
+ * parsed again, keeps changes the user made since. `undefined` changes
180
+ * nothing, such as while saved data loads; `null` clears them, such as on
181
+ * sign-out. Replacing does not call `onCustomHotkeysChange`, because the
182
+ * data came from the app.
183
+ */
184
+ setOptions(options: AppHotkeysStoreOptions<T>): void;
170
185
  /**
171
186
  * Replaces the declarations, for example after hot module replacement.
172
187
  * Custom hotkeys for ids that still exist are kept.
@@ -1,10 +1,11 @@
1
1
  import { detectPlatform } from "./platform.js";
2
2
  import { isHotkeySequence, simplifyHotkey } from "./parse.js";
3
3
  import { validateHotkey } from "./validate.js";
4
- import { areHotkeysEqual } from "./match.js";
5
4
  import { GLOBAL_SCOPE } from "./_registration.js";
5
+ import { areHotkeysEqual } from "./match.js";
6
+ import { createStore } from "./_store.js";
6
7
  import { getHotkeyManager } from "./hotkey-manager.js";
7
- import { Store } from "@tanstack/store";
8
+ import { setAppHotkeysStoreFactory } from "./_app-hotkeys-factory.js";
8
9
 
9
10
  //#region src/app-hotkeys.ts
10
11
  /**
@@ -22,6 +23,7 @@ import { Store } from "@tanstack/store";
22
23
  * ```
23
24
  */
24
25
  function defineAppHotkeys(definitions) {
26
+ setAppHotkeysStoreFactory((appHotkeys, options) => new AppHotkeysStore(appHotkeys, options));
25
27
  const result = {};
26
28
  for (const [id, input] of Object.entries(definitions)) {
27
29
  const hotkey = input.hotkey === null ? null : simplifyHotkey(input.hotkey);
@@ -73,19 +75,44 @@ function sanitizeCustomHotkeys(appHotkeys, saved) {
73
75
  if (typeof saved !== "object" || saved === null) return result;
74
76
  for (const [id, value] of Object.entries(saved)) {
75
77
  if (value === void 0) continue;
76
- if (!(id in appHotkeys)) {
77
- console.warn(`Ignoring custom hotkey for unknown app hotkey '${id}'`);
78
+ if (!Object.hasOwn(appHotkeys, id)) {
79
+ if (process.env.NODE_ENV !== "production") console.warn(`Ignoring custom hotkey for unknown app hotkey '${id}'`);
78
80
  continue;
79
81
  }
80
82
  const hotkey = parseCustomHotkey(value);
81
83
  if (hotkey === void 0) {
82
- console.warn(`Ignoring invalid custom hotkey for app hotkey '${id}':`, value);
84
+ if (process.env.NODE_ENV !== "production") console.warn(`Ignoring invalid custom hotkey for app hotkey '${id}':`, value);
83
85
  continue;
84
86
  }
85
87
  result[id] = hotkey;
86
88
  }
87
89
  return result;
88
90
  }
91
+ /** Whether two saved values hold the same data. Nothing saved equals `{}`. */
92
+ function sameSavedValue(left, right) {
93
+ if (Object.is(left, right)) return true;
94
+ if (Array.isArray(left) || Array.isArray(right)) return Array.isArray(left) && Array.isArray(right) && left.length === right.length && left.every((step, i) => Object.is(step, right[i]));
95
+ if (typeof left !== "object" || typeof right !== "object") return false;
96
+ if (left === null || right === null) return false;
97
+ const keys = Object.keys(left);
98
+ return keys.length === Object.keys(right).length && keys.every((key) => Object.hasOwn(right, key) && sameSavedValue(left[key], right[key]));
99
+ }
100
+ /** Copies saved custom hotkeys, so later changes made in place are detected. */
101
+ function copySavedCustomHotkeys(saved) {
102
+ if (typeof saved !== "object" || saved === null) return saved;
103
+ const copy = {};
104
+ for (const [id, value] of Object.entries(saved)) copy[id] = Array.isArray(value) ? [...value] : value;
105
+ return copy;
106
+ }
107
+ /** Whether two saved custom hotkeys objects hold the same data. */
108
+ function sameSavedCustomHotkeys(left, right) {
109
+ return sameSavedValue(left === void 0 ? {} : left, right === void 0 ? {} : right);
110
+ }
111
+ /** Whether two sanitized custom hotkeys objects bind the same hotkeys. */
112
+ function sameCustomHotkeys(left, right, platform) {
113
+ const keys = Object.keys(left);
114
+ return keys.length === Object.keys(right).length && keys.every((key) => Object.hasOwn(right, key) && hotkeysEqual(left[key] ?? null, right[key] ?? null, platform));
115
+ }
89
116
  /** Whether two app hotkeys can apply at the same time. */
90
117
  function scopesOverlap(a, b) {
91
118
  const left = a.scope ?? "global";
@@ -119,11 +146,14 @@ var AppHotkeysStore = class {
119
146
  store;
120
147
  #declarations;
121
148
  #options;
149
+ /** The `customHotkeys` option last passed in, compared by value. */
150
+ #savedCustomHotkeys;
122
151
  constructor(appHotkeys, options = {}) {
123
152
  this.#declarations = appHotkeys;
124
153
  this.#options = options;
154
+ this.#savedCustomHotkeys = copySavedCustomHotkeys(options.customHotkeys);
125
155
  const customHotkeys = sanitizeCustomHotkeys(appHotkeys, options.customHotkeys);
126
- this.store = new Store({
156
+ this.store = createStore({
127
157
  appHotkeys: applyCustomHotkeys(appHotkeys, customHotkeys),
128
158
  customHotkeys
129
159
  });
@@ -139,12 +169,31 @@ var AppHotkeysStore = class {
139
169
  get customHotkeys() {
140
170
  return this.store.state.customHotkeys;
141
171
  }
142
- /** Updates callbacks and other options without resetting custom hotkeys. */
172
+ /**
173
+ * Updates options. Providers call it with their props on every update.
174
+ *
175
+ * When `customHotkeys` differs from the value last passed in, it replaces
176
+ * the current custom hotkeys. An equal value, such as the same saved data
177
+ * parsed again, keeps changes the user made since. `undefined` changes
178
+ * nothing, such as while saved data loads; `null` clears them, such as on
179
+ * sign-out. Replacing does not call `onCustomHotkeysChange`, because the
180
+ * data came from the app.
181
+ */
143
182
  setOptions(options) {
183
+ const { customHotkeys, ...rest } = options;
144
184
  this.#options = {
145
185
  ...this.#options,
146
- ...options
186
+ ...rest
147
187
  };
188
+ if (customHotkeys === void 0) return;
189
+ if (sameSavedCustomHotkeys(customHotkeys, this.#savedCustomHotkeys)) return;
190
+ this.#savedCustomHotkeys = copySavedCustomHotkeys(customHotkeys);
191
+ const next = sanitizeCustomHotkeys(this.#declarations, customHotkeys);
192
+ if (sameCustomHotkeys(next, this.store.state.customHotkeys, this.#platform)) return;
193
+ this.store.setState(() => ({
194
+ appHotkeys: applyCustomHotkeys(this.#declarations, next),
195
+ customHotkeys: next
196
+ }));
148
197
  }
149
198
  /**
150
199
  * Replaces the declarations, for example after hot module replacement.
@@ -161,7 +210,8 @@ var AppHotkeysStore = class {
161
210
  }
162
211
  /** Effective app hotkey for an id, or `undefined` when it is not declared. */
163
212
  get(id) {
164
- return this.store.state.appHotkeys[id];
213
+ const { appHotkeys } = this.store.state;
214
+ return Object.hasOwn(appHotkeys, id) ? appHotkeys[id] : void 0;
165
215
  }
166
216
  /**
167
217
  * Other app hotkeys that would collide if `id` used `hotkey`. App hotkeys
@@ -169,7 +219,7 @@ var AppHotkeysStore = class {
169
219
  * hotkeys are equal or one sequence starts with the other.
170
220
  */
171
221
  findConflicts(id, hotkey) {
172
- const self = this.store.state.appHotkeys[id];
222
+ const self = this.get(id);
173
223
  if (!self) return [];
174
224
  const candidate = simplifyHotkey(hotkey);
175
225
  return Object.values(this.store.state.appHotkeys).filter((other) => other.id !== id && other.hotkey !== null && scopesOverlap(self, other) && hotkeysOverlap(candidate, other.hotkey, this.#platform));
@@ -181,9 +231,9 @@ var AppHotkeysStore = class {
181
231
  * @returns Conflicting app hotkeys. When any exist, nothing changes.
182
232
  */
183
233
  setCustomHotkey(id, hotkey) {
184
- const appHotkey = this.store.state.appHotkeys[id];
234
+ const appHotkey = this.get(id);
185
235
  if (!appHotkey) {
186
- console.warn(`Cannot set a custom hotkey for unknown app hotkey '${id}'`);
236
+ if (process.env.NODE_ENV !== "production") console.warn(`Cannot set a custom hotkey for unknown app hotkey '${id}'`);
187
237
  return [];
188
238
  }
189
239
  const next = hotkey === null ? null : simplifyHotkey(hotkey);
@@ -107,9 +107,9 @@ export declare const PUNCTUATION_CODE_MAP: Record<string, string>;
107
107
  /**
108
108
  * Set of all valid non-modifier keys.
109
109
  *
110
- * This is the union of all key type sets (letters, numbers, function keys, navigation,
111
- * editing, and punctuation). Used primarily for validation to check if a key string
112
- * is recognized and will have type-safe autocomplete support.
110
+ * This is the union of all key type sets (letters, numbers, function keys,
111
+ * navigation, editing, punctuation, and other named keys): every key with
112
+ * type-safe autocomplete support.
113
113
  *
114
114
  * @see {@link LETTER_KEYS}
115
115
  * @see {@link NUMBER_KEYS}
@@ -122,16 +122,17 @@ export declare const ALL_KEYS: Set<"CapsLock" | "ContextMenu" | "Convert" | "Kan
122
122
  /** Whether a key is a single Unicode letter, such as `'a'` or `'é'`. */
123
123
  export declare function isSingleLetterKey(key: string): boolean;
124
124
  /**
125
- * Normalizes a key name to its canonical form.
125
+ * Normalizes a key name from a `KeyboardEvent` or a hotkey: single letters
126
+ * become uppercase, and the space character becomes `Space`. Other names are
127
+ * returned as written, because key names are case-sensitive.
126
128
  *
127
- * @param key - The key name to normalize (can be an alias, lowercase, etc.)
129
+ * @param key - The key name to normalize
128
130
  * @returns The canonical key name
129
131
  *
130
132
  * @example
131
133
  * ```ts
132
- * normalizeKeyName('esc') // 'Escape'
133
134
  * normalizeKeyName('a') // 'A'
134
- * normalizeKeyName('f1') // 'F1'
135
+ * normalizeKeyName(' ') // 'Space'
135
136
  * normalizeKeyName('ArrowUp') // 'ArrowUp' (already canonical)
136
137
  * ```
137
138
  */