@inklu/keys 1.4.1 → 2.0.1
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/README.md +161 -8
- package/dist/{attributes-O6bXNKT9.d.ts → attributes-DotLr2MW.d.mts} +142 -23
- package/dist/{attributes.d.ts → attributes.d.mts} +1 -1
- package/dist/{attributes.js → attributes.mjs} +12 -5
- package/dist/{index-B5iiRwCy.d.ts → index-BTgscYdI.d.mts} +25 -2
- package/dist/index.d.mts +3 -0
- package/dist/index.mjs +3 -0
- package/dist/react.d.mts +82 -0
- package/dist/{react.js → react.mjs} +24 -13
- package/dist/src-DgYjA1oS.mjs +1697 -0
- package/package.json +15 -42
- package/dist/index.d.ts +0 -3
- package/dist/index.js +0 -3
- package/dist/react.d.ts +0 -77
- package/dist/src-DWmUPZSL.js +0 -1078
package/dist/src-DWmUPZSL.js
DELETED
|
@@ -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 };
|