@inklu/keys 2.0.1 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/react.mjs CHANGED
@@ -1,8 +1,73 @@
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 bindingTokens, c as isSingleKeyBinding, d as isModifierKey, f as keyName, i as ariaKeyShortcuts, l as seq, n as createCommands, o as formatBinding, r as createRebinder, s as spokenBinding, t as matchCommands, u as validateBinding } from "./src-B76YvObL.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
+ keys: [],
8
+ codes: {}
9
+ };
10
+ /** Held keys by physical key, so two keys producing one character stay apart. */
11
+ const down = /* @__PURE__ */ new Map();
12
+ const listeners = /* @__PURE__ */ new Set();
13
+ let state = NONE;
14
+ const identify = (event) => event.code || `key:${keyName(event.key)}`;
15
+ function publish() {
16
+ const entries = [...down.values()];
17
+ state = entries.length === 0 ? NONE : {
18
+ keys: [...new Set(entries.map((entry) => entry.key))],
19
+ codes: Object.fromEntries(entries.map((entry) => [entry.key, entry.code]))
20
+ };
21
+ for (const listener of listeners) listener();
22
+ }
23
+ function onKeyDown(event) {
24
+ if (!event.key || down.has(identify(event))) return;
25
+ down.set(identify(event), {
26
+ key: keyName(event.key),
27
+ code: event.code
28
+ });
29
+ publish();
30
+ }
31
+ function onKeyUp(event) {
32
+ down.delete(identify(event));
33
+ if (event.key === "Meta") {
34
+ for (const [identity, entry] of down) if (!isModifierKey(entry.key)) down.delete(identity);
35
+ }
36
+ publish();
37
+ }
38
+ /** Leaving the window swallows the keyups, so nothing is held any more. */
39
+ function onBlur() {
40
+ if (down.size === 0) return;
41
+ down.clear();
42
+ publish();
43
+ }
44
+ /** The keys held right now. Empty unless something is subscribed. */
45
+ function heldKeys() {
46
+ return state;
47
+ }
48
+ /** Whether `key` is held, by the name `KeyboardEvent.key` gives it. */
49
+ function isHeld(key) {
50
+ return state.keys.includes(keyName(key));
51
+ }
52
+ /** Watches held keys until the returned function is called. */
53
+ function subscribeHeldKeys(listener) {
54
+ listeners.add(listener);
55
+ if (listeners.size === 1) {
56
+ document.addEventListener("keydown", onKeyDown, true);
57
+ document.addEventListener("keyup", onKeyUp, true);
58
+ window.addEventListener("blur", onBlur);
59
+ }
60
+ return () => {
61
+ listeners.delete(listener);
62
+ if (listeners.size > 0) return;
63
+ document.removeEventListener("keydown", onKeyDown, true);
64
+ document.removeEventListener("keyup", onKeyUp, true);
65
+ window.removeEventListener("blur", onBlur);
66
+ down.clear();
67
+ state = NONE;
68
+ };
69
+ }
70
+ //#endregion
6
71
  //#region src/react/hooks.ts
7
72
  const EMPTY_TOKENS = [];
8
73
  const EMPTY_CODES = {};
@@ -55,7 +120,10 @@ function useShortcut(registry, id) {
55
120
  }), [command]);
56
121
  }
57
122
  /**
58
- * Activates a scope for the lifecycle of the component.
123
+ * Activates a scope for the lifecycle of the component. Scopes rank in the
124
+ * order they are pushed, which here is the order React runs effects in: a
125
+ * surface opened later ranks higher, but of a parent and a child opening in
126
+ * the same render the child is pushed first.
59
127
  *
60
128
  * @example
61
129
  * ```tsx
@@ -64,113 +132,85 @@ function useShortcut(registry, id) {
64
132
  */
65
133
  function useScope(registry, name, active = true, options = {}) {
66
134
  const exclusive = options.exclusive === true;
67
- const strict = options.strict === true;
68
135
  useEffect(() => {
69
136
  if (!active) return;
70
- return registry.pushScope(name, {
71
- exclusive,
72
- strict
73
- });
137
+ return registry.pushScope(name, { exclusive });
74
138
  }, [
75
139
  registry,
76
140
  name,
77
141
  active,
78
- exclusive,
79
- strict
142
+ exclusive
80
143
  ]);
81
144
  }
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]);
145
+ /**
146
+ * Attaches what a command does for the lifecycle of the component, so the
147
+ * behaviour lives where its state does. The latest `run` is always the one
148
+ * called. While `enabled` is `false` the component steps back: the command
149
+ * falls to the handler attached before it, or cannot run if there is none.
150
+ * The handler attached last wins, and React attaches a parent's after its
151
+ * children's when they mount together.
152
+ *
153
+ * @example
154
+ * ```tsx
155
+ * useCommandHandler(commands, "palette.open", () => setOpen(true));
156
+ * useCommandHandler(commands, "doc.save", save, dirty);
157
+ * ```
158
+ */
159
+ function useCommandHandler(registry, id, run, enabled = true) {
160
+ const latest = useRef(run);
161
+ latest.current = run;
87
162
  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();
163
+ if (!enabled) return;
164
+ return registry.handle(id, (context) => latest.current(context));
94
165
  }, [
95
166
  registry,
96
167
  id,
97
- signature
168
+ enabled
98
169
  ]);
99
170
  }
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
171
  /** Hook for recording and configuring a command's keybindings. */
123
172
  function useRebind(registry, id, options = {}) {
124
173
  const latest = useRef(options);
125
174
  latest.current = options;
126
- const command = useCommand(registry, id);
127
- const [rebinder, setRebinder] = useState(null);
128
- const [state, setState] = useState(IDLE_REBIND);
129
175
  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
- }, [
176
+ const rebinder = useMemo(() => createRebinder(registry, id, {
177
+ mode,
178
+ get apply() {
179
+ return latest.current.apply;
180
+ },
181
+ get add() {
182
+ return latest.current.add;
183
+ },
184
+ get rejectConflicts() {
185
+ return latest.current.rejectConflicts;
186
+ },
187
+ get ignoreInputs() {
188
+ return latest.current.ignoreInputs;
189
+ },
190
+ get idleTimeout() {
191
+ return latest.current.idleTimeout;
192
+ },
193
+ onRecord: (binding, conflicts) => latest.current.onRecord?.(binding, conflicts),
194
+ onCancel: () => latest.current.onCancel?.(),
195
+ onClear: () => latest.current.onClear?.()
196
+ }), [
160
197
  registry,
161
198
  id,
162
- mode,
163
- idleTimeout
199
+ mode
164
200
  ]);
201
+ useEffect(() => rebinder.destroy, [rebinder]);
202
+ const getState = useCallback(() => rebinder.state, [rebinder]);
203
+ const state = useSyncExternalStore(rebinder.subscribe, getState, getState);
204
+ const command = useCommand(registry, id);
165
205
  return useMemo(() => ({
166
206
  ...state,
167
207
  display: command?.display[0] ?? "",
168
208
  tokens: command?.tokens[0] ?? EMPTY_TOKENS,
169
209
  overridden: command?.overridden ?? false,
170
- start: () => rebinder?.start(),
171
- stop: () => rebinder?.stop(),
172
- cancel: () => rebinder?.cancel(),
173
- commit: () => rebinder?.commit(),
210
+ start: rebinder.start,
211
+ stop: rebinder.stop,
212
+ cancel: rebinder.cancel,
213
+ commit: rebinder.commit,
174
214
  clear: () => registry.keymap.set(id, null),
175
215
  reset: () => registry.keymap.reset(id)
176
216
  }), [
@@ -181,15 +221,9 @@ function useRebind(registry, id, options = {}) {
181
221
  id
182
222
  ]);
183
223
  }
184
- function useKeyState() {
185
- return useCallback((onChange) => {
186
- const subscription = getKeyStateTracker().store.subscribe(onChange);
187
- return () => subscription.unsubscribe();
188
- }, []);
189
- }
190
224
  /** Returns an array of keys currently held down. */
191
225
  function useHeldKeys() {
192
- return useSyncExternalStore(useKeyState(), () => getKeyStateTracker().store.state.heldKeys, getEmptyTokens);
226
+ return useSyncExternalStore(subscribeHeldKeys, () => heldKeys().keys, getEmptyTokens);
193
227
  }
194
228
  /**
195
229
  * Checks whether a specific key is currently held down.
@@ -200,13 +234,12 @@ function useHeldKeys() {
200
234
  * ```
201
235
  */
202
236
  function useKeyHold(key) {
203
- const subscribe = useKeyState();
204
- const getSnapshot = useCallback(() => getKeyStateTracker().isKeyHeld(key), [key]);
205
- return useSyncExternalStore(subscribe, getSnapshot, getFalse);
237
+ const getSnapshot = useCallback(() => isHeld(key), [key]);
238
+ return useSyncExternalStore(subscribeHeldKeys, getSnapshot, getFalse);
206
239
  }
207
240
  /** Returns a map of held key identifiers to physical event codes. */
208
241
  function useHeldKeyCodes() {
209
- return useSyncExternalStore(useKeyState(), () => getKeyStateTracker().store.state.heldCodes, getEmptyCodes);
242
+ return useSyncExternalStore(subscribeHeldKeys, () => heldKeys().codes, getEmptyCodes);
210
243
  }
211
244
  /**
212
245
  * Returns currently active partially-typed sequences.
@@ -222,4 +255,4 @@ function usePendingSequences(registry) {
222
255
  return useSyncExternalStore(subscribe, getSnapshot, getEmptyPending);
223
256
  }
224
257
  //#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 };
258
+ export { ariaKeyShortcuts, bindingTokens, createCommands, createRebinder, formatBinding, isSingleKeyBinding, matchCommands, seq, spokenBinding, useCommand, useCommandHandler, useCommands, useHeldKeyCodes, useHeldKeys, useKeyHold, useMountCommands, usePendingSequences, useRebind, useScope, useShortcut, validateBinding };