@inklu/keys 1.4.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
|
@@ -0,0 +1,1697 @@
|
|
|
1
|
+
import { DEFAULT_SEQUENCE_TIMEOUT, HotkeyRecorder, HotkeySequenceRecorder, MAC_MODIFIER_LABELS, MAC_MODIFIER_SYMBOLS, detectPlatform, formatForDisplay, getHotkeyManager, getSequenceManager, isModifierKey, matchesKeyboardEvent, normalizeHotkey, normalizeKeyName, parseHotkey, 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
|
+
/**
|
|
24
|
+
* Normalizes a binding input into an array of bindings. A one-step sequence
|
|
25
|
+
* becomes a plain chord: it fires on exactly the same keypress, and keeping it
|
|
26
|
+
* a sequence gave it a different signature from the chord, so the two were
|
|
27
|
+
* neither arbitrated nor reported as conflicting — both ran.
|
|
28
|
+
*/
|
|
29
|
+
function toBindings(input) {
|
|
30
|
+
if (input == null) return [];
|
|
31
|
+
const candidates = Array.isArray(input) ? input : [input];
|
|
32
|
+
const bindings = [];
|
|
33
|
+
for (const candidate of candidates) {
|
|
34
|
+
if (candidate == null) continue;
|
|
35
|
+
if (isSequence(candidate) && candidate.sequence.length !== 1) {
|
|
36
|
+
if (candidate.sequence.length > 0) bindings.push({ sequence: [...candidate.sequence] });
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
const chord = isSequence(candidate) ? candidate.sequence[0] : candidate;
|
|
40
|
+
const trimmed = String(chord).trim();
|
|
41
|
+
if (trimmed) bindings.push(trimmed);
|
|
42
|
+
}
|
|
43
|
+
return bindings;
|
|
44
|
+
}
|
|
45
|
+
/** Generates a normalized signature for binding comparison and conflict detection. */
|
|
46
|
+
function bindingSignature(binding, platform) {
|
|
47
|
+
if (isSequence(binding)) return `seq:${binding.sequence.map((step) => safeNormalize(step, platform)).join(" ")}`;
|
|
48
|
+
return `chord:${safeNormalize(binding, platform)}`;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Whether one binding is a strict prefix of the other — chord `G` against
|
|
52
|
+
* `G then I`, or `G then G` against `G then G then I`. The shorter binding
|
|
53
|
+
* fires on the way to the longer one, so both run for a single intent.
|
|
54
|
+
*/
|
|
55
|
+
function isPrefixCollision(a, b, platform) {
|
|
56
|
+
const left = normalizedSteps(a, platform);
|
|
57
|
+
const right = normalizedSteps(b, platform);
|
|
58
|
+
if (left.length === right.length) return false;
|
|
59
|
+
const [short, long] = left.length < right.length ? [left, right] : [right, left];
|
|
60
|
+
return short.every((step, index) => step === long[index]);
|
|
61
|
+
}
|
|
62
|
+
function normalizedSteps(binding, platform) {
|
|
63
|
+
return (isSequence(binding) ? binding.sequence : [binding]).map((step) => safeNormalize(step, platform));
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Whether every step of a binding is a single printable character with no
|
|
67
|
+
* Control, Alt or Meta — the "character key shortcuts" WCAG 2.1.4 lets users
|
|
68
|
+
* turn off, since speech input and stray keypresses trigger them by accident.
|
|
69
|
+
* Shift does not count as a modifier here: `Shift+A` still types a character.
|
|
70
|
+
*/
|
|
71
|
+
function isSingleKeyBinding(binding, platform) {
|
|
72
|
+
return (isSequence(binding) ? binding.sequence : [binding]).every((step) => {
|
|
73
|
+
try {
|
|
74
|
+
const parsed = parseHotkey(step, platform);
|
|
75
|
+
const character = parsed.key === void 0 ? /^(Key[A-Z]|Digit[0-9])$/.test(parsed.code ?? "") : parsed.key.length === 1;
|
|
76
|
+
return !parsed.ctrl && !parsed.alt && !parsed.meta && character;
|
|
77
|
+
} catch {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
function safeNormalize(hotkey, platform) {
|
|
83
|
+
try {
|
|
84
|
+
return normalizeHotkey(hotkey, platform);
|
|
85
|
+
} catch {
|
|
86
|
+
return hotkey;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/** Converts a binding to a JSON-serializable format. */
|
|
90
|
+
function serializeBinding(binding) {
|
|
91
|
+
return isSequence(binding) ? [...binding.sequence] : binding;
|
|
92
|
+
}
|
|
93
|
+
/** Deserializes a stored value into a valid binding, or `null` if invalid. */
|
|
94
|
+
function deserializeBinding(value) {
|
|
95
|
+
if (isJsonString(value)) {
|
|
96
|
+
const trimmed = value.trim();
|
|
97
|
+
return trimmed && isBindingValid(trimmed) ? trimmed : null;
|
|
98
|
+
}
|
|
99
|
+
if (Array.isArray(value)) {
|
|
100
|
+
const steps = value.filter((step) => typeof step === "string" && step !== "");
|
|
101
|
+
if (steps.length === 0) return null;
|
|
102
|
+
const binding = steps.length === 1 ? steps[0] : { sequence: steps };
|
|
103
|
+
return isBindingValid(binding) ? binding : null;
|
|
104
|
+
}
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
/** Validates a binding's syntax without logging warnings or errors. */
|
|
108
|
+
function validateBinding(binding) {
|
|
109
|
+
const steps = isSequence(binding) ? binding.sequence : [binding];
|
|
110
|
+
if (steps.length === 0) return {
|
|
111
|
+
valid: false,
|
|
112
|
+
errors: ["Sequence has no steps"],
|
|
113
|
+
warnings: []
|
|
114
|
+
};
|
|
115
|
+
const errors = [];
|
|
116
|
+
const warnings = [];
|
|
117
|
+
for (const step of steps) {
|
|
118
|
+
const result = validateHotkey(step);
|
|
119
|
+
errors.push(...result.errors);
|
|
120
|
+
warnings.push(...result.warnings);
|
|
121
|
+
}
|
|
122
|
+
return {
|
|
123
|
+
valid: errors.length === 0,
|
|
124
|
+
errors,
|
|
125
|
+
warnings
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
/** Checks whether a binding has valid syntax. */
|
|
129
|
+
function isBindingValid(binding) {
|
|
130
|
+
return validateBinding(binding).valid;
|
|
131
|
+
}
|
|
132
|
+
//#endregion
|
|
133
|
+
//#region src/display.ts
|
|
134
|
+
/** Platform-aware hotkey display formatting and accessibility attributes. */
|
|
135
|
+
const MAC_MODIFIER_RANK = new Map([
|
|
136
|
+
"Control",
|
|
137
|
+
"Alt",
|
|
138
|
+
"Shift",
|
|
139
|
+
"Meta"
|
|
140
|
+
].flatMap((modifier, rank) => [[MAC_MODIFIER_SYMBOLS[modifier], rank], [MAC_MODIFIER_LABELS[modifier], rank]]));
|
|
141
|
+
/** Formats a binding for display on the target platform (e.g. `⌘K` or `Ctrl+K`). */
|
|
142
|
+
function formatBinding(binding, options) {
|
|
143
|
+
if (isSequence(binding)) return binding.sequence.map((step) => formatChord(step, options)).join(" ");
|
|
144
|
+
return formatChord(binding, options);
|
|
145
|
+
}
|
|
146
|
+
/** Splits a binding into display tokens for rendering `<kbd>` elements. */
|
|
147
|
+
function bindingTokens(binding, options) {
|
|
148
|
+
if (isSequence(binding)) return binding.sequence.map((step) => formatChord(step, options));
|
|
149
|
+
return chordTokens(binding, options);
|
|
150
|
+
}
|
|
151
|
+
function formatChord(chord, options) {
|
|
152
|
+
const platform = options?.platform ?? detectPlatform();
|
|
153
|
+
if (platform !== "mac") return formatForDisplay(chord, {
|
|
154
|
+
...options,
|
|
155
|
+
platform
|
|
156
|
+
});
|
|
157
|
+
const symbols = options?.useSymbols ?? true;
|
|
158
|
+
const modifierSymbols = typeof symbols === "boolean" ? symbols : symbols.modifiers ?? true;
|
|
159
|
+
const separator = options?.separatorToken ?? (modifierSymbols ? " " : "+");
|
|
160
|
+
return chordTokens(chord, {
|
|
161
|
+
...options,
|
|
162
|
+
platform
|
|
163
|
+
}).join(separator);
|
|
164
|
+
}
|
|
165
|
+
function chordTokens(chord, options) {
|
|
166
|
+
const platform = options?.platform ?? detectPlatform();
|
|
167
|
+
const tokens = formatForDisplay(chord, {
|
|
168
|
+
...options,
|
|
169
|
+
platform,
|
|
170
|
+
parts: true
|
|
171
|
+
});
|
|
172
|
+
if (platform !== "mac" || tokens.length < 3) return tokens;
|
|
173
|
+
const key = tokens.pop() ?? "";
|
|
174
|
+
tokens.sort((a, b) => (MAC_MODIFIER_RANK.get(a) ?? MAC_MODIFIER_RANK.size) - (MAC_MODIFIER_RANK.get(b) ?? MAC_MODIFIER_RANK.size));
|
|
175
|
+
return [...tokens, key];
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Phrases a binding for a screen reader: `"Control+K"` for a chord,
|
|
179
|
+
* `"G then I"` for a sequence. `aria-keyshortcuts` cannot express a sequence
|
|
180
|
+
* (a space there means "or"), so this is how a sequence gets announced — put it
|
|
181
|
+
* in visually hidden text or `aria-description`.
|
|
182
|
+
*/
|
|
183
|
+
function spokenBinding(binding, platform) {
|
|
184
|
+
return (isSequence(binding) ? binding.sequence : [binding]).map((step) => ariaChord(step, platform) || step).join(" then ");
|
|
185
|
+
}
|
|
186
|
+
/** Generates an `aria-keyshortcuts` attribute string for a binding. */
|
|
187
|
+
function ariaKeyShortcuts(binding, platform) {
|
|
188
|
+
if (isSequence(binding)) return "";
|
|
189
|
+
return ariaChord(binding, platform);
|
|
190
|
+
}
|
|
191
|
+
function ariaChord(chord, platform) {
|
|
192
|
+
try {
|
|
193
|
+
return resolveMod(normalizeHotkey(chord, platform), platform);
|
|
194
|
+
} catch {
|
|
195
|
+
return "";
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
function resolveMod(hotkey, platform) {
|
|
199
|
+
if (!hotkey.includes("Mod")) return hotkey;
|
|
200
|
+
const modifier = (platform ?? detectPlatform()) === "mac" ? "Meta" : "Control";
|
|
201
|
+
return hotkey.split("+").map((part) => part === "Mod" ? modifier : part).join("+");
|
|
202
|
+
}
|
|
203
|
+
//#endregion
|
|
204
|
+
//#region src/rebind.ts
|
|
205
|
+
/** Controller for recording and applying hotkey rebindings. */
|
|
206
|
+
const RECORDING_SCOPE = "inklu-keys:recording";
|
|
207
|
+
const NO_STEPS = [];
|
|
208
|
+
function toRecordedBinding(sequence) {
|
|
209
|
+
if (sequence.length === 0) return null;
|
|
210
|
+
if (sequence.length === 1) return sequence[0];
|
|
211
|
+
return { sequence: [...sequence] };
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Creates a rebinder instance for capturing and remapping a command's keys.
|
|
215
|
+
*
|
|
216
|
+
* @example
|
|
217
|
+
* ```ts
|
|
218
|
+
* const rebinder = createRebinder(commands, "palette.open", {
|
|
219
|
+
* onRecord: (binding, conflicts) => {
|
|
220
|
+
* if (conflicts.length) console.warn("Conflict with", conflicts[0].id);
|
|
221
|
+
* },
|
|
222
|
+
* });
|
|
223
|
+
* rebinder.start();
|
|
224
|
+
* ```
|
|
225
|
+
*/
|
|
226
|
+
function createRebinder(registry, id, options = {}) {
|
|
227
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
228
|
+
let state = {
|
|
229
|
+
recording: false,
|
|
230
|
+
binding: null,
|
|
231
|
+
steps: NO_STEPS,
|
|
232
|
+
conflicts: []
|
|
233
|
+
};
|
|
234
|
+
let scope = null;
|
|
235
|
+
const notify = (next) => {
|
|
236
|
+
state = next;
|
|
237
|
+
for (const listener of listeners) listener();
|
|
238
|
+
};
|
|
239
|
+
const releaseScope = () => {
|
|
240
|
+
scope?.();
|
|
241
|
+
scope = null;
|
|
242
|
+
};
|
|
243
|
+
const record = (binding) => {
|
|
244
|
+
releaseScope();
|
|
245
|
+
const conflicts = registry.conflicts(binding, {
|
|
246
|
+
ignore: id,
|
|
247
|
+
scope: registry.get(id)?.scope
|
|
248
|
+
});
|
|
249
|
+
const blocked = options.rejectConflicts === true && conflicts.length > 0;
|
|
250
|
+
if (options.apply !== false && !blocked) {
|
|
251
|
+
if (options.add) registry.keymap.add(id, binding);
|
|
252
|
+
else registry.keymap.set(id, binding);
|
|
253
|
+
}
|
|
254
|
+
notify({
|
|
255
|
+
recording: false,
|
|
256
|
+
binding,
|
|
257
|
+
steps: NO_STEPS,
|
|
258
|
+
conflicts
|
|
259
|
+
});
|
|
260
|
+
options.onRecord?.(binding, conflicts);
|
|
261
|
+
};
|
|
262
|
+
const cancelled = () => {
|
|
263
|
+
releaseScope();
|
|
264
|
+
notify({
|
|
265
|
+
...state,
|
|
266
|
+
recording: false,
|
|
267
|
+
steps: NO_STEPS
|
|
268
|
+
});
|
|
269
|
+
options.onCancel?.();
|
|
270
|
+
};
|
|
271
|
+
const cleared = () => {
|
|
272
|
+
releaseScope();
|
|
273
|
+
if (options.apply !== false) registry.keymap.set(id, null);
|
|
274
|
+
notify({
|
|
275
|
+
recording: false,
|
|
276
|
+
binding: null,
|
|
277
|
+
steps: NO_STEPS,
|
|
278
|
+
conflicts: []
|
|
279
|
+
});
|
|
280
|
+
options.onClear?.();
|
|
281
|
+
};
|
|
282
|
+
const sequenceMode = options.mode === "sequence";
|
|
283
|
+
const recordBy = "key";
|
|
284
|
+
const recorder = sequenceMode ? new HotkeySequenceRecorder({
|
|
285
|
+
recordBy,
|
|
286
|
+
ignoreInputs: options.ignoreInputs ?? true,
|
|
287
|
+
idleTimeoutMs: options.idleTimeout,
|
|
288
|
+
onRecord: (sequence) => {
|
|
289
|
+
const binding = toRecordedBinding(sequence);
|
|
290
|
+
if (binding === null) return;
|
|
291
|
+
record(binding);
|
|
292
|
+
},
|
|
293
|
+
onCancel: cancelled,
|
|
294
|
+
onClear: cleared
|
|
295
|
+
}) : new HotkeyRecorder({
|
|
296
|
+
recordBy,
|
|
297
|
+
ignoreInputs: options.ignoreInputs ?? true,
|
|
298
|
+
onRecord: (hotkey) => {
|
|
299
|
+
if (hotkey) record(hotkey);
|
|
300
|
+
},
|
|
301
|
+
onCancel: cancelled,
|
|
302
|
+
onClear: cleared
|
|
303
|
+
});
|
|
304
|
+
const stepsSubscription = recorder instanceof HotkeySequenceRecorder ? recorder.store.subscribe(() => {
|
|
305
|
+
if (!state.recording) return;
|
|
306
|
+
const steps = recorder.store.state.steps;
|
|
307
|
+
if (steps.length === state.steps.length) return;
|
|
308
|
+
notify({
|
|
309
|
+
...state,
|
|
310
|
+
steps: [...steps]
|
|
311
|
+
});
|
|
312
|
+
}) : null;
|
|
313
|
+
return {
|
|
314
|
+
start: () => {
|
|
315
|
+
if (state.recording) return;
|
|
316
|
+
scope ??= registry.pushScope(RECORDING_SCOPE, {
|
|
317
|
+
exclusive: true,
|
|
318
|
+
strict: true
|
|
319
|
+
});
|
|
320
|
+
recorder.setOptions({ ignoreInputs: options.ignoreInputs ?? true });
|
|
321
|
+
recorder.start();
|
|
322
|
+
notify({
|
|
323
|
+
recording: true,
|
|
324
|
+
binding: null,
|
|
325
|
+
steps: NO_STEPS,
|
|
326
|
+
conflicts: []
|
|
327
|
+
});
|
|
328
|
+
},
|
|
329
|
+
stop: () => {
|
|
330
|
+
if (!state.recording) return;
|
|
331
|
+
recorder.stop();
|
|
332
|
+
releaseScope();
|
|
333
|
+
notify({
|
|
334
|
+
...state,
|
|
335
|
+
recording: false,
|
|
336
|
+
steps: NO_STEPS
|
|
337
|
+
});
|
|
338
|
+
},
|
|
339
|
+
cancel: () => {
|
|
340
|
+
if (!state.recording) return;
|
|
341
|
+
recorder.cancel();
|
|
342
|
+
},
|
|
343
|
+
commit: () => {
|
|
344
|
+
if (!state.recording || !(recorder instanceof HotkeySequenceRecorder)) return;
|
|
345
|
+
recorder.commit();
|
|
346
|
+
},
|
|
347
|
+
get state() {
|
|
348
|
+
return state;
|
|
349
|
+
},
|
|
350
|
+
subscribe: (listener) => {
|
|
351
|
+
listeners.add(listener);
|
|
352
|
+
return () => {
|
|
353
|
+
listeners.delete(listener);
|
|
354
|
+
};
|
|
355
|
+
},
|
|
356
|
+
destroy: () => {
|
|
357
|
+
releaseScope();
|
|
358
|
+
stepsSubscription?.unsubscribe();
|
|
359
|
+
recorder.destroy();
|
|
360
|
+
listeners.clear();
|
|
361
|
+
}
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
//#endregion
|
|
365
|
+
//#region src/scopes.ts
|
|
366
|
+
/** Wildcard scope identifier for commands that remain active across exclusive scopes. */
|
|
367
|
+
const ALWAYS_SCOPE = "*";
|
|
368
|
+
/** Whether two scopes can be active at once, so their commands' bindings can collide. */
|
|
369
|
+
function scopesOverlap(a, b) {
|
|
370
|
+
if (a === void 0 || b === void 0) return true;
|
|
371
|
+
if (a === "*" || b === "*") return true;
|
|
372
|
+
return a === b;
|
|
373
|
+
}
|
|
374
|
+
/** Stack managing active scopes and command reachability. */
|
|
375
|
+
var ScopeStack = class {
|
|
376
|
+
#entries = [];
|
|
377
|
+
#listeners = /* @__PURE__ */ new Set();
|
|
378
|
+
/** Activates a scope until the returned disposal function is called. */
|
|
379
|
+
push(name, options = {}) {
|
|
380
|
+
const strict = options.strict === true;
|
|
381
|
+
const entry = {
|
|
382
|
+
name,
|
|
383
|
+
exclusive: strict || options.exclusive === true,
|
|
384
|
+
strict
|
|
385
|
+
};
|
|
386
|
+
this.#entries.push(entry);
|
|
387
|
+
this.#notify();
|
|
388
|
+
let released = false;
|
|
389
|
+
return () => {
|
|
390
|
+
if (released) return;
|
|
391
|
+
released = true;
|
|
392
|
+
const index = this.#entries.indexOf(entry);
|
|
393
|
+
if (index !== -1) this.#entries.splice(index, 1);
|
|
394
|
+
this.#notify();
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
/** Checks whether commands in the specified scope can execute given the current scope stack. */
|
|
398
|
+
isActive(scope) {
|
|
399
|
+
if (scope === "*") return !this.#entries.some((entry) => entry.strict);
|
|
400
|
+
const barrier = this.#barrierIndex();
|
|
401
|
+
if (barrier === -1) return scope === void 0 || this.#entries.some((entry) => entry.name === scope);
|
|
402
|
+
if (scope === void 0) return false;
|
|
403
|
+
for (let index = barrier; index < this.#entries.length; index++) if (this.#entries[index]?.name === scope) return true;
|
|
404
|
+
return false;
|
|
405
|
+
}
|
|
406
|
+
/** Checks whether an exclusive scope is active. */
|
|
407
|
+
hasBarrier() {
|
|
408
|
+
return this.#barrierIndex() !== -1;
|
|
409
|
+
}
|
|
410
|
+
/** Clears all active scopes. */
|
|
411
|
+
clear() {
|
|
412
|
+
if (this.#entries.length === 0) return;
|
|
413
|
+
this.#entries = [];
|
|
414
|
+
this.#notify();
|
|
415
|
+
}
|
|
416
|
+
/** Subscribes to scope stack changes. Returns an unsubscribe function. */
|
|
417
|
+
subscribe(listener) {
|
|
418
|
+
this.#listeners.add(listener);
|
|
419
|
+
return () => {
|
|
420
|
+
this.#listeners.delete(listener);
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
#barrierIndex() {
|
|
424
|
+
for (let index = this.#entries.length - 1; index >= 0; index--) if (this.#entries[index]?.exclusive) return index;
|
|
425
|
+
return -1;
|
|
426
|
+
}
|
|
427
|
+
#notify() {
|
|
428
|
+
for (const listener of this.#listeners) listener();
|
|
429
|
+
}
|
|
430
|
+
};
|
|
431
|
+
//#endregion
|
|
432
|
+
//#region src/arbitration.ts
|
|
433
|
+
/**
|
|
434
|
+
* Deciding which of several commands sharing one binding gets to run.
|
|
435
|
+
*
|
|
436
|
+
* Without this every registration for the pressed chord fires, so two
|
|
437
|
+
* commands bound to `Mod+S` in overlapping scopes both ran and the only
|
|
438
|
+
* defence was a dev-time console warning. Exclusive scopes could not fix it
|
|
439
|
+
* either: a barrier shadows *every* global command, not just the one
|
|
440
|
+
* colliding, so "Esc closes the dialog, and the panel otherwise" forced the
|
|
441
|
+
* whole dialog to become exclusive.
|
|
442
|
+
*
|
|
443
|
+
* Ranking, highest first:
|
|
444
|
+
*
|
|
445
|
+
* 1. A command in a named scope beats a global one. `ALWAYS_SCOPE` is
|
|
446
|
+
* global for this purpose — it means "survives barriers", not "belongs
|
|
447
|
+
* to some region of the UI".
|
|
448
|
+
* 2. Later registration beats earlier. Registration order tracks mount
|
|
449
|
+
* order, so the most recently opened surface claims the key — the same
|
|
450
|
+
* intuition that makes `add()` stack definitions per id.
|
|
451
|
+
*
|
|
452
|
+
* Guards are not part of the ranking. The registry evaluates them live at
|
|
453
|
+
* keypress time, so a winner whose `when()` currently returns false steps
|
|
454
|
+
* aside instead of swallowing the keypress.
|
|
455
|
+
*/
|
|
456
|
+
/** Negative when `a` outranks `b`, positive when `b` does. */
|
|
457
|
+
function compareClaims(a, b) {
|
|
458
|
+
return scopeRank(b.scope) - scopeRank(a.scope) || b.order - a.order;
|
|
459
|
+
}
|
|
460
|
+
function scopeRank(scope) {
|
|
461
|
+
return scope === void 0 || scope === "*" ? 0 : 1;
|
|
462
|
+
}
|
|
463
|
+
/** Binding signature → ids of the commands bound to it, highest-ranked first. */
|
|
464
|
+
function rankOwners(source) {
|
|
465
|
+
const index = /* @__PURE__ */ new Map();
|
|
466
|
+
source.ids.forEach((id, order) => {
|
|
467
|
+
const claimant = {
|
|
468
|
+
id,
|
|
469
|
+
scope: source.scope(id),
|
|
470
|
+
order
|
|
471
|
+
};
|
|
472
|
+
for (const binding of source.bindings(id)) {
|
|
473
|
+
const signature = bindingSignature(binding, source.platform);
|
|
474
|
+
const claimants = index.get(signature);
|
|
475
|
+
if (claimants) claimants.push(claimant);
|
|
476
|
+
else index.set(signature, [claimant]);
|
|
477
|
+
}
|
|
478
|
+
});
|
|
479
|
+
return new Map(Array.from(index, ([signature, claimants]) => [signature, claimants.sort(compareClaims).map((claimant) => claimant.id)]));
|
|
480
|
+
}
|
|
481
|
+
//#endregion
|
|
482
|
+
//#region src/audit.ts
|
|
483
|
+
/** Development-only warnings about bindings that cannot work as written. */
|
|
484
|
+
/** Warns about every registered command: bad syntax, unknown overrides, collisions. */
|
|
485
|
+
function auditRegistry(source) {
|
|
486
|
+
if (!isDevelopment()) return;
|
|
487
|
+
for (const id of source.ids) warnAboutBindings(source, id);
|
|
488
|
+
warnAboutUnknownOverrides(source);
|
|
489
|
+
warnAboutConflicts(source);
|
|
490
|
+
}
|
|
491
|
+
/** Warns about a command added at runtime and the keys it now shares. */
|
|
492
|
+
function auditAdded(source, id) {
|
|
493
|
+
if (!isDevelopment()) return;
|
|
494
|
+
warnAboutBindings(source, id);
|
|
495
|
+
const scope = source.scope(id);
|
|
496
|
+
const claimant = (other) => ({
|
|
497
|
+
scope: source.scope(other),
|
|
498
|
+
order: source.ids.indexOf(other)
|
|
499
|
+
});
|
|
500
|
+
for (const conflict of source.conflicts(source.bindings(id), {
|
|
501
|
+
ignore: id,
|
|
502
|
+
scope
|
|
503
|
+
})) {
|
|
504
|
+
if (conflict.kind === "prefix") {
|
|
505
|
+
console.warn(`[@inklu/keys] "${id}" and "${conflict.id}" (${conflict.display}) have bindings where one is a prefix of the other, so the shorter one fires on the way to the longer one.`);
|
|
506
|
+
continue;
|
|
507
|
+
}
|
|
508
|
+
const winner = compareClaims(claimant(conflict.id), claimant(id)) < 0 ? conflict.id : id;
|
|
509
|
+
console.warn(`[@inklu/keys] "${id}" binds ${conflict.display}, which "${conflict.id}" already uses. While both are runnable the key goes to "${winner}".`);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
function warnAboutConflicts(source) {
|
|
513
|
+
const { platform, display } = source;
|
|
514
|
+
const seen = /* @__PURE__ */ new Map();
|
|
515
|
+
const collisions = [];
|
|
516
|
+
const prefixes = [];
|
|
517
|
+
const visited = [];
|
|
518
|
+
for (const id of source.ids) {
|
|
519
|
+
const scope = source.scope(id);
|
|
520
|
+
for (const binding of source.bindings(id)) {
|
|
521
|
+
for (const other of visited) {
|
|
522
|
+
if (other.id === id || !scopesOverlap(other.scope, scope)) continue;
|
|
523
|
+
if (!isPrefixCollision(other.binding, binding, platform)) continue;
|
|
524
|
+
prefixes.push(`${formatBinding(other.binding, display)} ("${other.id}") is a prefix of ${formatBinding(binding, display)} ("${id}")`);
|
|
525
|
+
}
|
|
526
|
+
visited.push({
|
|
527
|
+
id,
|
|
528
|
+
scope,
|
|
529
|
+
binding
|
|
530
|
+
});
|
|
531
|
+
const key = `${scope ?? ""}::${bindingSignature(binding, platform)}`;
|
|
532
|
+
const owner = seen.get(key);
|
|
533
|
+
if (owner === void 0) {
|
|
534
|
+
seen.set(key, id);
|
|
535
|
+
continue;
|
|
536
|
+
}
|
|
537
|
+
collisions.push(`${formatBinding(binding, display)} — "${id}" shadows "${owner}"`);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
if (collisions.length > 0) console.warn(`[@inklu/keys] ${collisions.length} unreachable binding(s) — same chord, same scope:\n ${collisions.join("\n ")}`);
|
|
541
|
+
if (prefixes.length > 0) console.warn(`[@inklu/keys] ${prefixes.length} prefix collision(s) — the shorter binding fires on the way to the longer one:\n ${prefixes.join("\n ")}`);
|
|
542
|
+
}
|
|
543
|
+
function warnAboutBindings(source, id) {
|
|
544
|
+
for (const binding of source.declared(id)) {
|
|
545
|
+
const { valid, errors } = validateBinding(binding);
|
|
546
|
+
if (valid) continue;
|
|
547
|
+
const resolved = formatBinding(binding, source.display);
|
|
548
|
+
console.warn(`[@inklu/keys] "${id}" has an unrecognized binding ${describeBinding(binding)}, which resolves to "${resolved}". ${errors.join("; ")}.`);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
/** Catches ids in `options.bindings` that TypeScript could not — plain JS, or a runtime-only command. */
|
|
552
|
+
function warnAboutUnknownOverrides(source) {
|
|
553
|
+
if (!source.overrides) return;
|
|
554
|
+
const unknown = Object.keys(source.overrides).filter((id) => !source.isRegistered(id));
|
|
555
|
+
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"}.`);
|
|
556
|
+
}
|
|
557
|
+
function describeBinding(binding) {
|
|
558
|
+
return isSequence(binding) ? `"${binding.sequence.join(" ")}"` : `"${binding}"`;
|
|
559
|
+
}
|
|
560
|
+
function isDevelopment() {
|
|
561
|
+
const environment = globalThis.process?.env?.NODE_ENV;
|
|
562
|
+
return environment !== void 0 && environment !== "production";
|
|
563
|
+
}
|
|
564
|
+
//#endregion
|
|
565
|
+
//#region src/binder.ts
|
|
566
|
+
/** Bridge connecting commands to underlying hotkey event listeners. */
|
|
567
|
+
var Binder = class {
|
|
568
|
+
#host;
|
|
569
|
+
#target;
|
|
570
|
+
#bound = /* @__PURE__ */ new Map();
|
|
571
|
+
#ran = /* @__PURE__ */ new WeakMap();
|
|
572
|
+
#continued = /* @__PURE__ */ new WeakSet();
|
|
573
|
+
#capture = null;
|
|
574
|
+
constructor(host, target) {
|
|
575
|
+
this.#host = host;
|
|
576
|
+
this.#target = target;
|
|
577
|
+
}
|
|
578
|
+
/** Syncs hotkey registrations for a command with its current bindings. */
|
|
579
|
+
sync(id) {
|
|
580
|
+
const definition = this.#host.getDefinition(id);
|
|
581
|
+
if (!definition) {
|
|
582
|
+
this.release(id);
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
const existing = this.#bound.get(id) ?? [];
|
|
586
|
+
const reusable = new Map(existing.map((bound) => [bound.signature, bound]));
|
|
587
|
+
const next = [];
|
|
588
|
+
const enabled = this.#host.isReachable(id);
|
|
589
|
+
const config = registrationConfig(definition, this.#host.platform());
|
|
590
|
+
for (const binding of this.#host.getBindings(id)) {
|
|
591
|
+
const signature = bindingSignature(binding, this.#host.platform());
|
|
592
|
+
if (next.some((bound) => bound.signature === signature)) continue;
|
|
593
|
+
const kept = reusable.get(signature);
|
|
594
|
+
if (kept && kept.config === config) {
|
|
595
|
+
reusable.delete(signature);
|
|
596
|
+
setEnabled(kept, enabled);
|
|
597
|
+
next.push(kept);
|
|
598
|
+
continue;
|
|
599
|
+
}
|
|
600
|
+
const bound = this.#register(id, binding, signature, definition, enabled);
|
|
601
|
+
if (bound) next.push(bound);
|
|
602
|
+
}
|
|
603
|
+
for (const stale of reusable.values()) stale.handle.unregister();
|
|
604
|
+
if (next.length === 0) this.#bound.delete(id);
|
|
605
|
+
else this.#bound.set(id, next);
|
|
606
|
+
this.#updateCapture();
|
|
607
|
+
}
|
|
608
|
+
/**
|
|
609
|
+
* Updates enabled state for all active registrations without re-registering.
|
|
610
|
+
* A sequence that becomes unreachable halfway through also loses its
|
|
611
|
+
* progress: TanStack only skips a disabled sequence, so without the reset a
|
|
612
|
+
* scope that closed and reopened within the timeout would let the next key
|
|
613
|
+
* complete a sequence begun in a different context.
|
|
614
|
+
*/
|
|
615
|
+
refreshEnabled() {
|
|
616
|
+
for (const [id, bindings] of this.#bound) {
|
|
617
|
+
const enabled = this.#host.isReachable(id);
|
|
618
|
+
const abandoned = (bound) => bound.enabled && !enabled && bound.steps !== null && hasProgress(bound);
|
|
619
|
+
if (bindings.some(abandoned)) {
|
|
620
|
+
this.#reregister(id, abandoned);
|
|
621
|
+
continue;
|
|
622
|
+
}
|
|
623
|
+
for (const bound of bindings) setEnabled(bound, enabled);
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
/** Returns all registered sequences with their manager registration IDs. */
|
|
627
|
+
sequences() {
|
|
628
|
+
const entries = [];
|
|
629
|
+
for (const [id, bindings] of this.#bound) for (const bound of bindings) {
|
|
630
|
+
if (bound.steps === null || !isSequence(bound.binding)) continue;
|
|
631
|
+
entries.push({
|
|
632
|
+
id,
|
|
633
|
+
binding: bound.binding,
|
|
634
|
+
registrationId: bound.handle.id
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
return entries;
|
|
638
|
+
}
|
|
639
|
+
/**
|
|
640
|
+
* Abandons half-typed progress on this binder's sequences only. TanStack's
|
|
641
|
+
* `resetAll()` is global — it would also wipe sequences other registries,
|
|
642
|
+
* or the app itself, registered on the shared manager — and there is no
|
|
643
|
+
* per-registration reset, so each sequence is re-registered from scratch.
|
|
644
|
+
*/
|
|
645
|
+
resetSequences() {
|
|
646
|
+
for (const id of this.#bound.keys()) this.#reregister(id, (bound) => bound.steps !== null);
|
|
647
|
+
}
|
|
648
|
+
/** Removes all registrations for a specific command. */
|
|
649
|
+
release(id) {
|
|
650
|
+
const bindings = this.#bound.get(id);
|
|
651
|
+
if (!bindings) return;
|
|
652
|
+
for (const bound of bindings) bound.handle.unregister();
|
|
653
|
+
this.#bound.delete(id);
|
|
654
|
+
this.#updateCapture();
|
|
655
|
+
}
|
|
656
|
+
/** Removes all registrations. */
|
|
657
|
+
destroy() {
|
|
658
|
+
for (const bindings of this.#bound.values()) for (const bound of bindings) bound.handle.unregister();
|
|
659
|
+
this.#bound.clear();
|
|
660
|
+
this.#updateCapture();
|
|
661
|
+
}
|
|
662
|
+
/** Re-registers the bindings of `id` that `select` picks, resetting their progress. */
|
|
663
|
+
#reregister(id, select) {
|
|
664
|
+
const bindings = this.#bound.get(id);
|
|
665
|
+
const definition = this.#host.getDefinition(id);
|
|
666
|
+
if (!bindings || !definition) return;
|
|
667
|
+
const enabled = this.#host.isReachable(id);
|
|
668
|
+
const next = [];
|
|
669
|
+
for (const bound of bindings) {
|
|
670
|
+
if (!select(bound)) {
|
|
671
|
+
setEnabled(bound, enabled);
|
|
672
|
+
next.push(bound);
|
|
673
|
+
continue;
|
|
674
|
+
}
|
|
675
|
+
bound.handle.unregister();
|
|
676
|
+
const fresh = this.#register(id, bound.binding, bound.signature, definition, enabled);
|
|
677
|
+
if (fresh) next.push(fresh);
|
|
678
|
+
}
|
|
679
|
+
if (next.length === 0) this.#bound.delete(id);
|
|
680
|
+
else this.#bound.set(id, next);
|
|
681
|
+
this.#updateCapture();
|
|
682
|
+
}
|
|
683
|
+
#register(id, binding, signature, definition, enabled) {
|
|
684
|
+
const callback = (event) => this.#handle(id, binding, signature, event);
|
|
685
|
+
const platform = this.#host.platform();
|
|
686
|
+
const options = {
|
|
687
|
+
preventDefault: false,
|
|
688
|
+
stopPropagation: false,
|
|
689
|
+
conflictBehavior: "allow",
|
|
690
|
+
enabled,
|
|
691
|
+
ignoreInputs: definition.ignoreInputs,
|
|
692
|
+
platform,
|
|
693
|
+
target: this.#target,
|
|
694
|
+
meta: { name: definition.title }
|
|
695
|
+
};
|
|
696
|
+
try {
|
|
697
|
+
const handle = isSequence(binding) ? getSequenceManager().register([...binding.sequence], callback, {
|
|
698
|
+
...options,
|
|
699
|
+
timeout: definition.sequenceTimeout ?? DEFAULT_SEQUENCE_TIMEOUT
|
|
700
|
+
}) : getHotkeyManager().register(binding, callback, {
|
|
701
|
+
...options,
|
|
702
|
+
requireReset: definition.requireReset === true
|
|
703
|
+
});
|
|
704
|
+
return {
|
|
705
|
+
signature,
|
|
706
|
+
binding,
|
|
707
|
+
config: registrationConfig(definition, platform),
|
|
708
|
+
handle,
|
|
709
|
+
steps: isSequence(binding) ? binding.sequence.map((step) => parseHotkey(step, platform)) : null,
|
|
710
|
+
enabled
|
|
711
|
+
};
|
|
712
|
+
} catch {
|
|
713
|
+
return null;
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
/** Listens ahead of TanStack's sequence manager while any sequence is bound. */
|
|
717
|
+
#updateCapture() {
|
|
718
|
+
const needed = [...this.#bound.values()].some((bindings) => bindings.some((bound) => bound.steps !== null));
|
|
719
|
+
if (needed && !this.#capture && typeof document !== "undefined") {
|
|
720
|
+
const target = this.#target ?? document;
|
|
721
|
+
const listener = (event) => this.#beforeSequences(event);
|
|
722
|
+
target.addEventListener("keydown", listener, true);
|
|
723
|
+
this.#capture = {
|
|
724
|
+
target,
|
|
725
|
+
listener
|
|
726
|
+
};
|
|
727
|
+
} else if (!needed && this.#capture) {
|
|
728
|
+
this.#capture.target.removeEventListener("keydown", this.#capture.listener, true);
|
|
729
|
+
this.#capture = null;
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
/**
|
|
733
|
+
* Runs in the capture phase, so it sees each sequence's progress before
|
|
734
|
+
* TanStack advances it, whatever order the managers' own listeners were
|
|
735
|
+
* attached in. Two things depend on that:
|
|
736
|
+
*
|
|
737
|
+
* - A step that advances a sequence without completing it prevents the
|
|
738
|
+
* browser default, as the final step does (unless the command sets
|
|
739
|
+
* `preventDefault: false`). Otherwise `Mod+K then Mod+S` could never be
|
|
740
|
+
* finished: `Mod+K` would move focus to the browser's search bar first.
|
|
741
|
+
* - A key that continues a half-typed sequence belongs to that sequence.
|
|
742
|
+
* With `G then C` and a chord `C` both bound, typing G, C used to run
|
|
743
|
+
* both commands; now the chord waits unless the key breaks the sequence.
|
|
744
|
+
*/
|
|
745
|
+
#beforeSequences(event) {
|
|
746
|
+
if (event.isComposing || event.keyCode === 229) return;
|
|
747
|
+
if (isModifierKey(normalizeKeyName(event.key))) return;
|
|
748
|
+
const views = getSequenceManager().registrations.state;
|
|
749
|
+
const now = Date.now();
|
|
750
|
+
let prevent = false;
|
|
751
|
+
for (const [id, bindings] of this.#bound) for (const bound of bindings) {
|
|
752
|
+
const steps = bound.steps;
|
|
753
|
+
const view = steps && views.get(bound.handle.id);
|
|
754
|
+
if (!steps || !view || !view.options.enabled) continue;
|
|
755
|
+
if (view.options.ignoreInputs !== false && this.#isTyping(event)) continue;
|
|
756
|
+
const platform = view.options.platform;
|
|
757
|
+
const timeout = view.options.timeout ?? DEFAULT_SEQUENCE_TIMEOUT;
|
|
758
|
+
const matched = now - view.partialMatchLastKeyTime > timeout ? 0 : view.matchedStepCount;
|
|
759
|
+
const expected = steps[matched];
|
|
760
|
+
const first = steps[0];
|
|
761
|
+
let reached = 0;
|
|
762
|
+
if (expected && matchesKeyboardEvent(event, expected, platform)) reached = matched + 1;
|
|
763
|
+
else if (matched > 0 && first && matchesKeyboardEvent(event, first, platform)) reached = 1;
|
|
764
|
+
if (reached === 0) continue;
|
|
765
|
+
if (reached < steps.length) {
|
|
766
|
+
const step = steps[reached - 1];
|
|
767
|
+
if (!(!step?.ctrl && !step?.meta && this.#isTyping(event)) && this.#host.getDefinition(id)?.preventDefault !== false) prevent = true;
|
|
768
|
+
if (matched > 0) this.#continued.add(event);
|
|
769
|
+
} else if (this.#host.canRun(id, event)) this.#continued.add(event);
|
|
770
|
+
}
|
|
771
|
+
if (prevent) event.preventDefault();
|
|
772
|
+
}
|
|
773
|
+
#isTyping(event) {
|
|
774
|
+
return event.composedPath().some((node) => node !== this.#target && isEditableElement(node));
|
|
775
|
+
}
|
|
776
|
+
/**
|
|
777
|
+
* One keypress can reach several registrations (see `conflictBehavior`
|
|
778
|
+
* above), so every candidate asks the registry whether it won before doing
|
|
779
|
+
* anything observable. Arbitration comes before `preventDefault` on
|
|
780
|
+
* purpose: a losing command must leave the event exactly as it found it,
|
|
781
|
+
* or a command declaring `preventDefault: false` would still have the
|
|
782
|
+
* browser's default action cancelled by whichever rival happened to be
|
|
783
|
+
* registered alongside it.
|
|
784
|
+
*/
|
|
785
|
+
#handle(id, binding, signature, event) {
|
|
786
|
+
if (event.isComposing || event.keyCode === 229) return;
|
|
787
|
+
if (!isSequence(binding) && this.#continued.has(event)) return;
|
|
788
|
+
if (typesAltGraphCharacter(event, binding, this.#host.platform())) return;
|
|
789
|
+
const ran = this.#ran.get(event);
|
|
790
|
+
if (ran?.has(signature)) return;
|
|
791
|
+
if (!this.#host.canRun(id, event)) return;
|
|
792
|
+
if (!this.#host.claims(id, signature, event)) return;
|
|
793
|
+
const definition = this.#host.getDefinition(id);
|
|
794
|
+
if (!definition) return;
|
|
795
|
+
if (ran) ran.add(signature);
|
|
796
|
+
else this.#ran.set(event, /* @__PURE__ */ new Set([signature]));
|
|
797
|
+
if (definition.preventDefault !== false) event.preventDefault();
|
|
798
|
+
this.#host.execute(id, {
|
|
799
|
+
source: "keyboard",
|
|
800
|
+
event,
|
|
801
|
+
binding
|
|
802
|
+
});
|
|
803
|
+
}
|
|
804
|
+
};
|
|
805
|
+
function setEnabled(bound, enabled) {
|
|
806
|
+
if (bound.enabled === enabled) return;
|
|
807
|
+
bound.enabled = enabled;
|
|
808
|
+
bound.handle.setOptions({ enabled });
|
|
809
|
+
}
|
|
810
|
+
function hasProgress(bound) {
|
|
811
|
+
return (getSequenceManager().registrations.state.get(bound.handle.id)?.matchedStepCount ?? 0) > 0;
|
|
812
|
+
}
|
|
813
|
+
function registrationConfig(definition, platform) {
|
|
814
|
+
return [
|
|
815
|
+
platform,
|
|
816
|
+
String(definition.ignoreInputs),
|
|
817
|
+
definition.requireReset === true ? "1" : "0",
|
|
818
|
+
String(definition.sequenceTimeout ?? DEFAULT_SEQUENCE_TIMEOUT)
|
|
819
|
+
].join("|");
|
|
820
|
+
}
|
|
821
|
+
/** Mirrors TanStack's notion of a text field, which `ignoreInputs` refers to. */
|
|
822
|
+
function isEditableElement(node) {
|
|
823
|
+
if (typeof HTMLElement === "undefined" || !(node instanceof HTMLElement)) return false;
|
|
824
|
+
if (node instanceof HTMLInputElement) {
|
|
825
|
+
const type = node.type.toLowerCase();
|
|
826
|
+
return type !== "button" && type !== "submit" && type !== "reset";
|
|
827
|
+
}
|
|
828
|
+
return node instanceof HTMLTextAreaElement || node instanceof HTMLSelectElement || node.isContentEditable === true;
|
|
829
|
+
}
|
|
830
|
+
/**
|
|
831
|
+
* Whether the keypress types a character through AltGr rather than pressing
|
|
832
|
+
* the bound shortcut. Windows reports AltGr as Control+Alt, and TanStack
|
|
833
|
+
* falls back to the physical key when the character differs, so AltGr+Q —
|
|
834
|
+
* `@` on a German layout — matched `Control+Alt+Q`, and AltGr+A — `ą` on a
|
|
835
|
+
* Polish one — matched `Control+Alt+A`. Control chords fire inside text
|
|
836
|
+
* fields, so the command ran and swallowed the character mid-sentence. When
|
|
837
|
+
* AltGraph is down and the key produced a character other than the one the
|
|
838
|
+
* binding names, the user is typing. macOS has no AltGr: Option+letter
|
|
839
|
+
* shortcuts rely on that same physical-key fallback.
|
|
840
|
+
*/
|
|
841
|
+
function typesAltGraphCharacter(event, binding, platform) {
|
|
842
|
+
if (platform === "mac" || !event.getModifierState?.("AltGraph")) return false;
|
|
843
|
+
const produced = event.key;
|
|
844
|
+
if (produced !== "Dead" && !/^.$/u.test(produced)) return false;
|
|
845
|
+
const chord = isSequence(binding) ? binding.sequence.at(-1) : binding;
|
|
846
|
+
if (chord === void 0) return false;
|
|
847
|
+
const key = parseHotkey(chord, platform).key;
|
|
848
|
+
return key !== void 0 && produced.toUpperCase() !== key.toUpperCase();
|
|
849
|
+
}
|
|
850
|
+
//#endregion
|
|
851
|
+
//#region src/ids.ts
|
|
852
|
+
/**
|
|
853
|
+
* Recovers the strict id type from the loose one the public API accepts.
|
|
854
|
+
*
|
|
855
|
+
* Lookups — `get`, `setEnabled`, `keymap.set` and the rest — take
|
|
856
|
+
* `Id | (string & {})`, which keeps autocomplete for the registry's own ids
|
|
857
|
+
* while still accepting ids that exist only at runtime: a command `add()`ed
|
|
858
|
+
* later, a `data-command` attribute. Internally every map is keyed by `Id`,
|
|
859
|
+
* and an id nothing registered simply misses, so narrowing it never lets a
|
|
860
|
+
* wrong value in.
|
|
861
|
+
*/
|
|
862
|
+
function strictId(id) {
|
|
863
|
+
return id;
|
|
864
|
+
}
|
|
865
|
+
//#endregion
|
|
866
|
+
//#region src/keymap.ts
|
|
867
|
+
/** Keymap managing default bindings and user overrides. */
|
|
868
|
+
const DEFAULT_STORAGE_KEY = "inklu.keys";
|
|
869
|
+
const KEYMAP_VERSION = 1;
|
|
870
|
+
/** Type guard narrowing a JSON value to a plain object (excludes arrays and `null`). */
|
|
871
|
+
function isJsonObject(value) {
|
|
872
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
873
|
+
}
|
|
874
|
+
/**
|
|
875
|
+
* Same keys on every platform. The keymap is user data that may travel
|
|
876
|
+
* between machines, so `Mod+S` and `Control+S` stay distinct here even on
|
|
877
|
+
* Windows, where they happen to coincide.
|
|
878
|
+
*/
|
|
879
|
+
function portableSignature(binding) {
|
|
880
|
+
return `${bindingSignature(binding, "mac")} ${bindingSignature(binding, "windows")}`;
|
|
881
|
+
}
|
|
882
|
+
var Keymap = class {
|
|
883
|
+
#defaults = /* @__PURE__ */ new Map();
|
|
884
|
+
#overrides = /* @__PURE__ */ new Map();
|
|
885
|
+
#listeners = /* @__PURE__ */ new Set();
|
|
886
|
+
#persistence;
|
|
887
|
+
#loaded = false;
|
|
888
|
+
constructor(persistence) {
|
|
889
|
+
this.#persistence = persistence;
|
|
890
|
+
}
|
|
891
|
+
/** Sets a command's default bindings. `null` leaves the command unbound. */
|
|
892
|
+
setDefault(loose, input) {
|
|
893
|
+
const id = strictId(loose);
|
|
894
|
+
const bindings = toBindings(input);
|
|
895
|
+
if (bindings.length === 0) this.#defaults.delete(id);
|
|
896
|
+
else this.#defaults.set(id, bindings);
|
|
897
|
+
}
|
|
898
|
+
/** Removes a command's default bindings, leaving overrides intact. */
|
|
899
|
+
clearDefault(loose) {
|
|
900
|
+
this.#defaults.delete(strictId(loose));
|
|
901
|
+
}
|
|
902
|
+
/** Checks whether a command has an active user override. */
|
|
903
|
+
isOverridden(loose) {
|
|
904
|
+
return this.#overrides.has(strictId(loose));
|
|
905
|
+
}
|
|
906
|
+
get(loose) {
|
|
907
|
+
const id = strictId(loose);
|
|
908
|
+
const defaults = this.#defaults.get(id) ?? [];
|
|
909
|
+
const override = this.#overrides.get(id);
|
|
910
|
+
if (!override) return defaults;
|
|
911
|
+
if (override.kind === "replace") return override.bindings;
|
|
912
|
+
return [...defaults, ...without(override.bindings, defaults)];
|
|
913
|
+
}
|
|
914
|
+
/** Replaces a command's bindings. Passing `null` unbinds it. */
|
|
915
|
+
set(loose, input) {
|
|
916
|
+
const id = strictId(loose);
|
|
917
|
+
this.load();
|
|
918
|
+
const bindings = input === null ? [] : toBindings(input);
|
|
919
|
+
const defaults = this.#defaults.get(id);
|
|
920
|
+
if (defaults && sameBindings(bindings, defaults)) {
|
|
921
|
+
if (!this.#overrides.delete(id)) return;
|
|
922
|
+
} else this.#overrides.set(id, {
|
|
923
|
+
kind: "replace",
|
|
924
|
+
bindings
|
|
925
|
+
});
|
|
926
|
+
this.#changed();
|
|
927
|
+
}
|
|
928
|
+
/** Adds an additional binding to a command. */
|
|
929
|
+
add(loose, input) {
|
|
930
|
+
const id = strictId(loose);
|
|
931
|
+
this.load();
|
|
932
|
+
const added = without(toBindings(input), this.get(id));
|
|
933
|
+
if (added.length === 0) return;
|
|
934
|
+
const override = this.#overrides.get(id);
|
|
935
|
+
this.#overrides.set(id, {
|
|
936
|
+
kind: override?.kind ?? "add",
|
|
937
|
+
bindings: [...override?.bindings ?? [], ...added]
|
|
938
|
+
});
|
|
939
|
+
this.#changed();
|
|
940
|
+
}
|
|
941
|
+
/** Resets user overrides to defaults for one command or all commands. */
|
|
942
|
+
reset(loose) {
|
|
943
|
+
this.load();
|
|
944
|
+
if (loose === void 0) {
|
|
945
|
+
if (this.#overrides.size === 0) return;
|
|
946
|
+
this.#overrides.clear();
|
|
947
|
+
} else if (!this.#overrides.delete(strictId(loose))) return;
|
|
948
|
+
this.#changed();
|
|
949
|
+
}
|
|
950
|
+
/** Exports user overrides as a serialized keymap object. */
|
|
951
|
+
export() {
|
|
952
|
+
return {
|
|
953
|
+
version: KEYMAP_VERSION,
|
|
954
|
+
overrides: Object.fromEntries(Array.from(this.#overrides, ([id, override]) => [id, serializeOverride(override)]))
|
|
955
|
+
};
|
|
956
|
+
}
|
|
957
|
+
/** Imports serialized user overrides and saves to storage. */
|
|
958
|
+
import(data) {
|
|
959
|
+
this.#loaded = true;
|
|
960
|
+
this.#read(data);
|
|
961
|
+
this.#changed();
|
|
962
|
+
}
|
|
963
|
+
/**
|
|
964
|
+
* Loads persisted overrides from storage, once. The registry calls this on
|
|
965
|
+
* its first mount; `set`, `add` and `reset` call it too, so an edit made
|
|
966
|
+
* before mount lands on top of the stored overrides instead of writing
|
|
967
|
+
* over them.
|
|
968
|
+
*/
|
|
969
|
+
load() {
|
|
970
|
+
if (this.#loaded) return;
|
|
971
|
+
this.#loaded = true;
|
|
972
|
+
const storage = this.#storage();
|
|
973
|
+
if (!storage) return;
|
|
974
|
+
try {
|
|
975
|
+
const raw = storage.getItem(this.#storageKey());
|
|
976
|
+
if (!raw) return;
|
|
977
|
+
this.#read(JSON.parse(raw));
|
|
978
|
+
} catch {
|
|
979
|
+
return;
|
|
980
|
+
}
|
|
981
|
+
this.#notify();
|
|
982
|
+
}
|
|
983
|
+
/**
|
|
984
|
+
* Follows edits other tabs make to the stored overrides, so rebinding a
|
|
985
|
+
* key in one tab reaches the rest instead of being overwritten by the next
|
|
986
|
+
* stale tab to save. Returns a function that stops watching.
|
|
987
|
+
*/
|
|
988
|
+
watch() {
|
|
989
|
+
if (!this.#persistence || typeof window === "undefined") return () => {};
|
|
990
|
+
const onStorage = (event) => {
|
|
991
|
+
if (event.key !== null && event.key !== this.#storageKey()) return;
|
|
992
|
+
this.#reload();
|
|
993
|
+
};
|
|
994
|
+
window.addEventListener("storage", onStorage);
|
|
995
|
+
return () => window.removeEventListener("storage", onStorage);
|
|
996
|
+
}
|
|
997
|
+
/** Subscribes to keymap changes. Returns an unsubscribe function. */
|
|
998
|
+
subscribe(listener) {
|
|
999
|
+
this.#listeners.add(listener);
|
|
1000
|
+
return () => {
|
|
1001
|
+
this.#listeners.delete(listener);
|
|
1002
|
+
};
|
|
1003
|
+
}
|
|
1004
|
+
#reload() {
|
|
1005
|
+
const storage = this.#storage();
|
|
1006
|
+
if (!storage) return;
|
|
1007
|
+
const before = JSON.stringify(this.export());
|
|
1008
|
+
try {
|
|
1009
|
+
const raw = storage.getItem(this.#storageKey());
|
|
1010
|
+
if (raw) this.#read(JSON.parse(raw));
|
|
1011
|
+
else this.#overrides.clear();
|
|
1012
|
+
} catch {
|
|
1013
|
+
return;
|
|
1014
|
+
}
|
|
1015
|
+
this.#loaded = true;
|
|
1016
|
+
if (JSON.stringify(this.export()) !== before) this.#notify();
|
|
1017
|
+
}
|
|
1018
|
+
#read(data) {
|
|
1019
|
+
const entries = storedOverrides(data);
|
|
1020
|
+
if (!entries) return;
|
|
1021
|
+
this.#overrides.clear();
|
|
1022
|
+
for (const [id, value] of Object.entries(entries)) {
|
|
1023
|
+
const override = deserializeOverride(value);
|
|
1024
|
+
if (override) this.#overrides.set(strictId(id), override);
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
#changed() {
|
|
1028
|
+
this.#persist();
|
|
1029
|
+
this.#notify();
|
|
1030
|
+
}
|
|
1031
|
+
#notify() {
|
|
1032
|
+
for (const listener of this.#listeners) listener();
|
|
1033
|
+
}
|
|
1034
|
+
#persist() {
|
|
1035
|
+
const storage = this.#storage();
|
|
1036
|
+
if (!storage) return;
|
|
1037
|
+
try {
|
|
1038
|
+
if (this.#overrides.size === 0) storage.removeItem(this.#storageKey());
|
|
1039
|
+
else storage.setItem(this.#storageKey(), JSON.stringify(this.export()));
|
|
1040
|
+
} catch {}
|
|
1041
|
+
}
|
|
1042
|
+
#storageKey() {
|
|
1043
|
+
return this.#persistence?.key ?? DEFAULT_STORAGE_KEY;
|
|
1044
|
+
}
|
|
1045
|
+
#storage() {
|
|
1046
|
+
try {
|
|
1047
|
+
return this.#persistence?.storage() ?? null;
|
|
1048
|
+
} catch {
|
|
1049
|
+
return null;
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
};
|
|
1053
|
+
/** `bindings` minus any already in `existing` or repeated, compared across platforms. */
|
|
1054
|
+
function without(bindings, existing) {
|
|
1055
|
+
const known = new Set(existing.map(portableSignature));
|
|
1056
|
+
return bindings.filter((binding) => {
|
|
1057
|
+
const signature = portableSignature(binding);
|
|
1058
|
+
if (known.has(signature)) return false;
|
|
1059
|
+
known.add(signature);
|
|
1060
|
+
return true;
|
|
1061
|
+
});
|
|
1062
|
+
}
|
|
1063
|
+
/** Same bindings in the same order; the first is the one shortcuts display. */
|
|
1064
|
+
function sameBindings(a, b) {
|
|
1065
|
+
return a.length === b.length && a.every((binding, index) => {
|
|
1066
|
+
const other = b[index];
|
|
1067
|
+
return other !== void 0 && portableSignature(binding) === portableSignature(other);
|
|
1068
|
+
});
|
|
1069
|
+
}
|
|
1070
|
+
/** The overrides in stored data, or `null` if this release cannot read it. */
|
|
1071
|
+
function storedOverrides(data) {
|
|
1072
|
+
if (!isJsonObject(data)) return null;
|
|
1073
|
+
if (typeof data.version !== "number") return data;
|
|
1074
|
+
if (data.version !== KEYMAP_VERSION) return null;
|
|
1075
|
+
const overrides = data.overrides;
|
|
1076
|
+
return overrides !== void 0 && isJsonObject(overrides) ? overrides : null;
|
|
1077
|
+
}
|
|
1078
|
+
function serializeOverride(override) {
|
|
1079
|
+
const bindings = override.bindings.map(serializeBinding);
|
|
1080
|
+
return override.kind === "replace" ? bindings : { add: bindings };
|
|
1081
|
+
}
|
|
1082
|
+
function deserializeOverride(value) {
|
|
1083
|
+
if (Array.isArray(value)) {
|
|
1084
|
+
const bindings = deserializeBindings(value);
|
|
1085
|
+
return bindings && {
|
|
1086
|
+
kind: "replace",
|
|
1087
|
+
bindings
|
|
1088
|
+
};
|
|
1089
|
+
}
|
|
1090
|
+
if (isJsonObject(value) && Array.isArray(value.add)) {
|
|
1091
|
+
const bindings = deserializeBindings(value.add);
|
|
1092
|
+
return bindings && bindings.length > 0 ? {
|
|
1093
|
+
kind: "add",
|
|
1094
|
+
bindings
|
|
1095
|
+
} : null;
|
|
1096
|
+
}
|
|
1097
|
+
return null;
|
|
1098
|
+
}
|
|
1099
|
+
/**
|
|
1100
|
+
* `[]` is how an unbound command is stored. A non-empty list with nothing
|
|
1101
|
+
* usable in it is damage — hand edits, another version's format — and
|
|
1102
|
+
* reading it as "unbound" would silently strip the command of its default
|
|
1103
|
+
* shortcut, so it reads as `null` instead.
|
|
1104
|
+
*/
|
|
1105
|
+
function deserializeBindings(values) {
|
|
1106
|
+
const bindings = values.map(deserializeBinding).filter((binding) => binding !== null);
|
|
1107
|
+
return bindings.length === 0 && values.length > 0 ? null : bindings;
|
|
1108
|
+
}
|
|
1109
|
+
//#endregion
|
|
1110
|
+
//#region src/registry.ts
|
|
1111
|
+
/** Command registry managing command definitions, keymaps, scopes, and execution. */
|
|
1112
|
+
const EMPTY_PENDING = [];
|
|
1113
|
+
const SSR_PLATFORM = "windows";
|
|
1114
|
+
var CommandRegistry = class {
|
|
1115
|
+
#stacks = /* @__PURE__ */ new Map();
|
|
1116
|
+
#order = [];
|
|
1117
|
+
#enabled = /* @__PURE__ */ new Map();
|
|
1118
|
+
#keymap;
|
|
1119
|
+
#scopes = new ScopeStack();
|
|
1120
|
+
#binder;
|
|
1121
|
+
#options;
|
|
1122
|
+
#listeners = /* @__PURE__ */ new Set();
|
|
1123
|
+
#pendingListeners = /* @__PURE__ */ new Set();
|
|
1124
|
+
#pending = EMPTY_PENDING;
|
|
1125
|
+
#pendingSubscription = null;
|
|
1126
|
+
#snapshot = null;
|
|
1127
|
+
#serverSnapshot = null;
|
|
1128
|
+
#initial;
|
|
1129
|
+
/** Stops following other tabs' keymap edits; set while mounted. */
|
|
1130
|
+
#unwatch = null;
|
|
1131
|
+
#pendingTimer = null;
|
|
1132
|
+
#index = null;
|
|
1133
|
+
#ownerIndex = null;
|
|
1134
|
+
#mounts = 0;
|
|
1135
|
+
#hydrated = false;
|
|
1136
|
+
#barrier = false;
|
|
1137
|
+
#singleKeys;
|
|
1138
|
+
#verdicts = /* @__PURE__ */ new WeakMap();
|
|
1139
|
+
constructor(definitions, options = {}) {
|
|
1140
|
+
this.#options = options;
|
|
1141
|
+
this.#singleKeys = options.singleKeyShortcuts !== false;
|
|
1142
|
+
this.#keymap = new Keymap(options.persist);
|
|
1143
|
+
this.#binder = new Binder({
|
|
1144
|
+
getDefinition: (id) => this.#definition(id),
|
|
1145
|
+
getBindings: (id) => this.#bindings(id),
|
|
1146
|
+
canRun: (id, event) => this.#whyNotRun(id, "keyboard", event) === null,
|
|
1147
|
+
isReachable: (id) => this.#isReachable(id),
|
|
1148
|
+
claims: (id, signature, event) => this.#claims(id, signature, event),
|
|
1149
|
+
platform: () => this.#platform(),
|
|
1150
|
+
execute: (id, context) => {
|
|
1151
|
+
this.#execute(id, context);
|
|
1152
|
+
}
|
|
1153
|
+
}, options.target);
|
|
1154
|
+
this.#initial = Object.entries(definitions);
|
|
1155
|
+
for (const [id, definition] of this.#initial) this.#push(id, definition);
|
|
1156
|
+
this.#keymap.subscribe(() => this.#onKeymapChange());
|
|
1157
|
+
this.#scopes.subscribe(() => this.#onScopeChange());
|
|
1158
|
+
auditRegistry(this.#auditSource());
|
|
1159
|
+
}
|
|
1160
|
+
get keymap() {
|
|
1161
|
+
return this.#keymap;
|
|
1162
|
+
}
|
|
1163
|
+
get(id) {
|
|
1164
|
+
this.#snapshotList();
|
|
1165
|
+
return this.#index?.get(strictId(id));
|
|
1166
|
+
}
|
|
1167
|
+
list() {
|
|
1168
|
+
return this.#snapshotList();
|
|
1169
|
+
}
|
|
1170
|
+
serverList() {
|
|
1171
|
+
if (this.#serverSnapshot) return this.#serverSnapshot;
|
|
1172
|
+
const platform = this.#options.platform ?? SSR_PLATFORM;
|
|
1173
|
+
const options = {
|
|
1174
|
+
platform,
|
|
1175
|
+
...this.#options.display
|
|
1176
|
+
};
|
|
1177
|
+
const singleKeys = this.#options.singleKeyShortcuts !== false;
|
|
1178
|
+
this.#serverSnapshot = this.#initial.map(([id, definition]) => {
|
|
1179
|
+
const bindings = toBindings(this.#defaultKeys(id, definition)).filter((binding) => singleKeys || !isSingleKeyBinding(binding, platform));
|
|
1180
|
+
const enabled = definition.enabled !== false;
|
|
1181
|
+
const global = definition.scope === void 0 || definition.scope === "*";
|
|
1182
|
+
return describeCommand(id, definition, bindings, platform, options, {
|
|
1183
|
+
enabled,
|
|
1184
|
+
reachable: enabled && global,
|
|
1185
|
+
overridden: false
|
|
1186
|
+
});
|
|
1187
|
+
});
|
|
1188
|
+
return this.#serverSnapshot;
|
|
1189
|
+
}
|
|
1190
|
+
run(id, options = {}) {
|
|
1191
|
+
const source = options.source ?? "api";
|
|
1192
|
+
if (this.#whyNotRun(id, source, options.event) !== null) return false;
|
|
1193
|
+
this.#execute(id, {
|
|
1194
|
+
source,
|
|
1195
|
+
event: options.event
|
|
1196
|
+
});
|
|
1197
|
+
return true;
|
|
1198
|
+
}
|
|
1199
|
+
canRun(id) {
|
|
1200
|
+
return this.whyNotRun(id) === null;
|
|
1201
|
+
}
|
|
1202
|
+
whyNotRun(id) {
|
|
1203
|
+
return this.#whyNotRun(strictId(id), "api");
|
|
1204
|
+
}
|
|
1205
|
+
setEnabled(loose, enabled) {
|
|
1206
|
+
const id = strictId(loose);
|
|
1207
|
+
if (!this.#stacks.has(id)) return;
|
|
1208
|
+
if (this.#enabled.get(id) === enabled) return;
|
|
1209
|
+
this.#enabled.set(id, enabled);
|
|
1210
|
+
this.#binder.refreshEnabled();
|
|
1211
|
+
this.#invalidate();
|
|
1212
|
+
}
|
|
1213
|
+
add(loose, definition) {
|
|
1214
|
+
const id = strictId(loose);
|
|
1215
|
+
const entry = this.#push(id, definition);
|
|
1216
|
+
if (this.#mounts > 0) this.#binder.sync(id);
|
|
1217
|
+
this.#invalidate();
|
|
1218
|
+
auditAdded(this.#auditSource(), id);
|
|
1219
|
+
let disposed = false;
|
|
1220
|
+
return {
|
|
1221
|
+
run: (options) => !disposed && this.run(id, options),
|
|
1222
|
+
dispose: () => {
|
|
1223
|
+
if (disposed) return;
|
|
1224
|
+
disposed = true;
|
|
1225
|
+
this.#pop(id, entry);
|
|
1226
|
+
}
|
|
1227
|
+
};
|
|
1228
|
+
}
|
|
1229
|
+
remove(loose) {
|
|
1230
|
+
const id = strictId(loose);
|
|
1231
|
+
if (!this.#stacks.has(id)) return;
|
|
1232
|
+
this.#drop(id);
|
|
1233
|
+
this.#invalidate();
|
|
1234
|
+
}
|
|
1235
|
+
pushScope(name, options) {
|
|
1236
|
+
return this.#scopes.push(name, options);
|
|
1237
|
+
}
|
|
1238
|
+
conflicts(binding, options = {}) {
|
|
1239
|
+
const platform = this.#platform();
|
|
1240
|
+
const candidates = toBindings(binding);
|
|
1241
|
+
const signatures = new Set(candidates.map((entry) => bindingSignature(entry, platform)));
|
|
1242
|
+
const conflicts = [];
|
|
1243
|
+
for (const id of this.#order) {
|
|
1244
|
+
if (id === options.ignore) continue;
|
|
1245
|
+
const scope = this.#definition(id)?.scope;
|
|
1246
|
+
if (!scopesOverlap(scope, options.scope)) continue;
|
|
1247
|
+
for (const existing of this.#bindings(id)) {
|
|
1248
|
+
const kind = signatures.has(bindingSignature(existing, platform)) ? "exact" : candidates.some((candidate) => isPrefixCollision(candidate, existing, platform)) ? "prefix" : null;
|
|
1249
|
+
if (kind === null) continue;
|
|
1250
|
+
const display = formatBinding(existing, this.#displayOptions());
|
|
1251
|
+
conflicts.push({
|
|
1252
|
+
id,
|
|
1253
|
+
binding: existing,
|
|
1254
|
+
kind,
|
|
1255
|
+
display,
|
|
1256
|
+
scope
|
|
1257
|
+
});
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
return conflicts;
|
|
1261
|
+
}
|
|
1262
|
+
display(id) {
|
|
1263
|
+
const binding = this.#bindings(strictId(id))[0];
|
|
1264
|
+
return binding ? formatBinding(binding, this.#displayOptions()) : "";
|
|
1265
|
+
}
|
|
1266
|
+
tokens(id) {
|
|
1267
|
+
const binding = this.#bindings(strictId(id))[0];
|
|
1268
|
+
return binding ? bindingTokens(binding, this.#displayOptions()) : [];
|
|
1269
|
+
}
|
|
1270
|
+
get singleKeyShortcuts() {
|
|
1271
|
+
return this.#singleKeys;
|
|
1272
|
+
}
|
|
1273
|
+
setSingleKeyShortcuts(enabled) {
|
|
1274
|
+
if (this.#singleKeys === enabled) return;
|
|
1275
|
+
this.#singleKeys = enabled;
|
|
1276
|
+
if (this.#mounts > 0) for (const id of this.#order) this.#binder.sync(id);
|
|
1277
|
+
this.#invalidate();
|
|
1278
|
+
}
|
|
1279
|
+
mount() {
|
|
1280
|
+
this.#mounts++;
|
|
1281
|
+
if (this.#mounts === 1) {
|
|
1282
|
+
this.#hydrated = true;
|
|
1283
|
+
this.#keymap.load();
|
|
1284
|
+
this.#unwatch = this.#keymap.watch();
|
|
1285
|
+
for (const id of this.#order) this.#binder.sync(id);
|
|
1286
|
+
this.#invalidate();
|
|
1287
|
+
}
|
|
1288
|
+
let released = false;
|
|
1289
|
+
return () => {
|
|
1290
|
+
if (released) return;
|
|
1291
|
+
released = true;
|
|
1292
|
+
if (this.#mounts === 0) return;
|
|
1293
|
+
this.#mounts--;
|
|
1294
|
+
if (this.#mounts === 0) this.#unmount();
|
|
1295
|
+
};
|
|
1296
|
+
}
|
|
1297
|
+
pending() {
|
|
1298
|
+
return this.#pending;
|
|
1299
|
+
}
|
|
1300
|
+
subscribePending(listener) {
|
|
1301
|
+
this.#pendingListeners.add(listener);
|
|
1302
|
+
if (this.#pendingListeners.size === 1) this.#watchPending();
|
|
1303
|
+
return () => {
|
|
1304
|
+
this.#pendingListeners.delete(listener);
|
|
1305
|
+
if (this.#pendingListeners.size > 0) return;
|
|
1306
|
+
this.#unwatchPending();
|
|
1307
|
+
};
|
|
1308
|
+
}
|
|
1309
|
+
subscribe(listener) {
|
|
1310
|
+
this.#listeners.add(listener);
|
|
1311
|
+
return () => {
|
|
1312
|
+
this.#listeners.delete(listener);
|
|
1313
|
+
};
|
|
1314
|
+
}
|
|
1315
|
+
destroy() {
|
|
1316
|
+
this.#mounts = 0;
|
|
1317
|
+
this.#unmount();
|
|
1318
|
+
this.#stacks.clear();
|
|
1319
|
+
this.#order = [];
|
|
1320
|
+
this.#enabled.clear();
|
|
1321
|
+
this.#scopes.clear();
|
|
1322
|
+
this.#listeners.clear();
|
|
1323
|
+
this.#pendingListeners.clear();
|
|
1324
|
+
this.#unwatchPending();
|
|
1325
|
+
this.#initial = [];
|
|
1326
|
+
this.#serverSnapshot = null;
|
|
1327
|
+
this.#invalidate();
|
|
1328
|
+
}
|
|
1329
|
+
#unmount() {
|
|
1330
|
+
this.#binder.destroy();
|
|
1331
|
+
this.#unwatch?.();
|
|
1332
|
+
this.#unwatch = null;
|
|
1333
|
+
}
|
|
1334
|
+
#onScopeChange() {
|
|
1335
|
+
const barrier = this.#scopes.hasBarrier();
|
|
1336
|
+
this.#binder.refreshEnabled();
|
|
1337
|
+
if (barrier && !this.#barrier) this.#binder.resetSequences();
|
|
1338
|
+
this.#barrier = barrier;
|
|
1339
|
+
this.#invalidate();
|
|
1340
|
+
}
|
|
1341
|
+
#watchPending() {
|
|
1342
|
+
const store = getSequenceManager().registrations;
|
|
1343
|
+
const read = () => {
|
|
1344
|
+
this.#clearPendingTimer();
|
|
1345
|
+
const { pending, expires } = this.#readPending(store.state);
|
|
1346
|
+
if (expires !== null) this.#pendingTimer = setTimeout(read, Math.max(0, expires - Date.now()) + 1);
|
|
1347
|
+
if (samePending(pending, this.#pending)) return;
|
|
1348
|
+
this.#pending = pending;
|
|
1349
|
+
for (const listener of this.#pendingListeners) listener();
|
|
1350
|
+
};
|
|
1351
|
+
this.#pendingSubscription = store.subscribe(read);
|
|
1352
|
+
read();
|
|
1353
|
+
}
|
|
1354
|
+
#unwatchPending() {
|
|
1355
|
+
this.#pendingSubscription?.unsubscribe();
|
|
1356
|
+
this.#pendingSubscription = null;
|
|
1357
|
+
this.#clearPendingTimer();
|
|
1358
|
+
this.#pending = EMPTY_PENDING;
|
|
1359
|
+
}
|
|
1360
|
+
#clearPendingTimer() {
|
|
1361
|
+
if (this.#pendingTimer === null) return;
|
|
1362
|
+
clearTimeout(this.#pendingTimer);
|
|
1363
|
+
this.#pendingTimer = null;
|
|
1364
|
+
}
|
|
1365
|
+
/** Sequences in progress, and when the first of them times out. */
|
|
1366
|
+
#readPending(state) {
|
|
1367
|
+
const options = this.#displayOptions();
|
|
1368
|
+
const pending = [];
|
|
1369
|
+
const now = Date.now();
|
|
1370
|
+
let expires = null;
|
|
1371
|
+
for (const { id, binding, registrationId } of this.#binder.sequences()) {
|
|
1372
|
+
const view = state.get(registrationId);
|
|
1373
|
+
if (!view?.options.enabled) continue;
|
|
1374
|
+
const matched = view.matchedStepCount;
|
|
1375
|
+
if (matched === 0 || matched >= binding.sequence.length) continue;
|
|
1376
|
+
const deadline = view.partialMatchLastKeyTime + (view.options.timeout ?? DEFAULT_SEQUENCE_TIMEOUT);
|
|
1377
|
+
if (now > deadline) continue;
|
|
1378
|
+
expires = expires === null ? deadline : Math.min(expires, deadline);
|
|
1379
|
+
pending.push({
|
|
1380
|
+
id,
|
|
1381
|
+
binding,
|
|
1382
|
+
matched,
|
|
1383
|
+
total: binding.sequence.length,
|
|
1384
|
+
remaining: binding.sequence.slice(matched).map((step) => formatBinding(step, options))
|
|
1385
|
+
});
|
|
1386
|
+
}
|
|
1387
|
+
return {
|
|
1388
|
+
pending: pending.length === 0 ? EMPTY_PENDING : pending,
|
|
1389
|
+
expires
|
|
1390
|
+
};
|
|
1391
|
+
}
|
|
1392
|
+
/**
|
|
1393
|
+
* Bindings in effect: the keymap's, minus single-key ones while those are
|
|
1394
|
+
* switched off. Everything that binds, displays or compares keys reads
|
|
1395
|
+
* this, so a suppressed shortcut is not advertised either.
|
|
1396
|
+
*/
|
|
1397
|
+
#bindings(id) {
|
|
1398
|
+
const bindings = this.#keymap.get(id);
|
|
1399
|
+
if (this.#singleKeys) return bindings;
|
|
1400
|
+
const platform = this.#platform();
|
|
1401
|
+
return bindings.filter((binding) => !isSingleKeyBinding(binding, platform));
|
|
1402
|
+
}
|
|
1403
|
+
/**
|
|
1404
|
+
* `source` and `event` describe what asked, so a throwing guard is
|
|
1405
|
+
* reported as the keypress (or click) that evaluated it rather than as an
|
|
1406
|
+
* API call. With an event the verdict is cached for that event.
|
|
1407
|
+
*/
|
|
1408
|
+
#whyNotRun(id, source, event) {
|
|
1409
|
+
let cache;
|
|
1410
|
+
if (event) {
|
|
1411
|
+
cache = this.#verdicts.get(event);
|
|
1412
|
+
if (cache?.has(id)) return cache.get(id) ?? null;
|
|
1413
|
+
}
|
|
1414
|
+
const verdict = this.#evaluate(id, source, event);
|
|
1415
|
+
if (event) {
|
|
1416
|
+
if (!cache) {
|
|
1417
|
+
cache = /* @__PURE__ */ new Map();
|
|
1418
|
+
this.#verdicts.set(event, cache);
|
|
1419
|
+
}
|
|
1420
|
+
cache.set(id, verdict);
|
|
1421
|
+
}
|
|
1422
|
+
return verdict;
|
|
1423
|
+
}
|
|
1424
|
+
#evaluate(id, source, event) {
|
|
1425
|
+
const definition = this.#definition(id);
|
|
1426
|
+
if (!definition) return "unknown";
|
|
1427
|
+
if (!(this.#enabled.get(id) ?? false)) return "disabled";
|
|
1428
|
+
if (!this.#scopes.isActive(definition.scope)) return "out-of-scope";
|
|
1429
|
+
if (!definition.when) return null;
|
|
1430
|
+
try {
|
|
1431
|
+
return definition.when() === false ? "guarded" : null;
|
|
1432
|
+
} catch (error) {
|
|
1433
|
+
this.#reportError(toError(error), {
|
|
1434
|
+
id,
|
|
1435
|
+
source,
|
|
1436
|
+
event,
|
|
1437
|
+
commands: this
|
|
1438
|
+
});
|
|
1439
|
+
return "guarded";
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
#definition(id) {
|
|
1443
|
+
const stack = this.#stacks.get(id);
|
|
1444
|
+
return stack?.[stack.length - 1]?.definition;
|
|
1445
|
+
}
|
|
1446
|
+
#push(id, definition) {
|
|
1447
|
+
let stack = this.#stacks.get(id);
|
|
1448
|
+
if (!stack) {
|
|
1449
|
+
stack = [];
|
|
1450
|
+
this.#stacks.set(id, stack);
|
|
1451
|
+
this.#order.push(id);
|
|
1452
|
+
}
|
|
1453
|
+
const entry = { definition };
|
|
1454
|
+
stack.push(entry);
|
|
1455
|
+
this.#applyTop(id);
|
|
1456
|
+
return entry;
|
|
1457
|
+
}
|
|
1458
|
+
#pop(id, entry) {
|
|
1459
|
+
const stack = this.#stacks.get(id);
|
|
1460
|
+
if (!stack) return;
|
|
1461
|
+
const index = stack.indexOf(entry);
|
|
1462
|
+
if (index === -1) return;
|
|
1463
|
+
stack.splice(index, 1);
|
|
1464
|
+
if (stack.length === 0) this.#drop(id);
|
|
1465
|
+
else {
|
|
1466
|
+
this.#applyTop(id);
|
|
1467
|
+
if (this.#mounts > 0) this.#binder.sync(id);
|
|
1468
|
+
}
|
|
1469
|
+
this.#invalidate();
|
|
1470
|
+
}
|
|
1471
|
+
#applyTop(id) {
|
|
1472
|
+
const definition = this.#definition(id);
|
|
1473
|
+
if (!definition) return;
|
|
1474
|
+
this.#enabled.set(id, definition.enabled !== false);
|
|
1475
|
+
this.#keymap.setDefault(id, this.#defaultKeys(id, definition));
|
|
1476
|
+
}
|
|
1477
|
+
/**
|
|
1478
|
+
* A command's default keys: the app's `options.bindings` entry if it has
|
|
1479
|
+
* one, else the definition's own. `hasOwn`, not `??`: a `null` entry
|
|
1480
|
+
* deliberately ships the command unbound, and `??` would read that as
|
|
1481
|
+
* "absent" and restore the very default the app asked to drop.
|
|
1482
|
+
*/
|
|
1483
|
+
#defaultKeys(id, definition) {
|
|
1484
|
+
const overrides = this.#options.bindings;
|
|
1485
|
+
return overrides && Object.hasOwn(overrides, id) ? overrides[id] : definition.keys;
|
|
1486
|
+
}
|
|
1487
|
+
#drop(id) {
|
|
1488
|
+
this.#stacks.delete(id);
|
|
1489
|
+
const index = this.#order.indexOf(id);
|
|
1490
|
+
if (index !== -1) this.#order.splice(index, 1);
|
|
1491
|
+
this.#enabled.delete(id);
|
|
1492
|
+
this.#keymap.clearDefault(id);
|
|
1493
|
+
this.#binder.release(id);
|
|
1494
|
+
}
|
|
1495
|
+
#isReachable(id) {
|
|
1496
|
+
if (!(this.#enabled.get(id) ?? false)) return false;
|
|
1497
|
+
return this.#scopes.isActive(this.#definition(id)?.scope);
|
|
1498
|
+
}
|
|
1499
|
+
/**
|
|
1500
|
+
* Whether `id` wins a keypress on a binding it shares, by the ranking in
|
|
1501
|
+
* `arbitration.ts`. Guards run live here rather than in the ranking, so a
|
|
1502
|
+
* winner whose `when()` currently returns false steps aside instead of
|
|
1503
|
+
* swallowing the keypress.
|
|
1504
|
+
*/
|
|
1505
|
+
#claims(id, signature, event) {
|
|
1506
|
+
const candidates = this.#owners().get(signature);
|
|
1507
|
+
if (!candidates || candidates.length < 2) return true;
|
|
1508
|
+
for (const candidate of candidates) {
|
|
1509
|
+
if (candidate !== id && this.#whyNotRun(candidate, "keyboard", event) !== null) continue;
|
|
1510
|
+
return candidate === id;
|
|
1511
|
+
}
|
|
1512
|
+
return false;
|
|
1513
|
+
}
|
|
1514
|
+
/** Binding signature → command ids that share it, ranked by `#claims`. */
|
|
1515
|
+
#owners() {
|
|
1516
|
+
this.#ownerIndex ??= rankOwners({
|
|
1517
|
+
ids: this.#order,
|
|
1518
|
+
scope: (id) => this.#definition(id)?.scope,
|
|
1519
|
+
bindings: (id) => this.#bindings(id),
|
|
1520
|
+
platform: this.#platform()
|
|
1521
|
+
});
|
|
1522
|
+
return this.#ownerIndex;
|
|
1523
|
+
}
|
|
1524
|
+
#execute(id, context) {
|
|
1525
|
+
const definition = this.#definition(id);
|
|
1526
|
+
if (!definition) return;
|
|
1527
|
+
const full = {
|
|
1528
|
+
id,
|
|
1529
|
+
...context,
|
|
1530
|
+
commands: this
|
|
1531
|
+
};
|
|
1532
|
+
if (this.#options.onRun) try {
|
|
1533
|
+
this.#options.onRun(full);
|
|
1534
|
+
} catch (error) {
|
|
1535
|
+
this.#reportError(toError(error), full);
|
|
1536
|
+
}
|
|
1537
|
+
try {
|
|
1538
|
+
const result = definition.run(full);
|
|
1539
|
+
if (result instanceof Promise) result.catch((cause) => this.#reportError(toError(cause), full));
|
|
1540
|
+
} catch (error) {
|
|
1541
|
+
this.#reportError(toError(error), full);
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
#reportError(error, context) {
|
|
1545
|
+
if (this.#options.onError) {
|
|
1546
|
+
this.#options.onError(error, context);
|
|
1547
|
+
return;
|
|
1548
|
+
}
|
|
1549
|
+
console.error(`[@inklu/keys] command "${context.id}" failed`, error);
|
|
1550
|
+
}
|
|
1551
|
+
#onKeymapChange() {
|
|
1552
|
+
if (this.#mounts > 0) for (const id of this.#order) this.#binder.sync(id);
|
|
1553
|
+
this.#invalidate();
|
|
1554
|
+
}
|
|
1555
|
+
#platform() {
|
|
1556
|
+
return this.#options.platform ?? (this.#hydrated ? detectPlatform() : SSR_PLATFORM);
|
|
1557
|
+
}
|
|
1558
|
+
#displayOptions() {
|
|
1559
|
+
return {
|
|
1560
|
+
platform: this.#platform(),
|
|
1561
|
+
...this.#options.display
|
|
1562
|
+
};
|
|
1563
|
+
}
|
|
1564
|
+
#snapshotList() {
|
|
1565
|
+
if (this.#snapshot) return this.#snapshot;
|
|
1566
|
+
const platform = this.#platform();
|
|
1567
|
+
const options = this.#displayOptions();
|
|
1568
|
+
const commands = [];
|
|
1569
|
+
const index = /* @__PURE__ */ new Map();
|
|
1570
|
+
for (const id of this.#order) {
|
|
1571
|
+
const definition = this.#definition(id);
|
|
1572
|
+
if (!definition) continue;
|
|
1573
|
+
const command = describeCommand(id, definition, this.#bindings(id), platform, options, {
|
|
1574
|
+
enabled: this.#enabled.get(id) ?? false,
|
|
1575
|
+
reachable: this.#isReachable(id),
|
|
1576
|
+
overridden: this.#keymap.isOverridden(id)
|
|
1577
|
+
});
|
|
1578
|
+
commands.push(command);
|
|
1579
|
+
index.set(id, command);
|
|
1580
|
+
}
|
|
1581
|
+
this.#snapshot = commands;
|
|
1582
|
+
this.#index = index;
|
|
1583
|
+
return commands;
|
|
1584
|
+
}
|
|
1585
|
+
#invalidate() {
|
|
1586
|
+
this.#snapshot = null;
|
|
1587
|
+
this.#index = null;
|
|
1588
|
+
this.#ownerIndex = null;
|
|
1589
|
+
for (const listener of this.#listeners) listener();
|
|
1590
|
+
}
|
|
1591
|
+
#auditSource() {
|
|
1592
|
+
return {
|
|
1593
|
+
ids: this.#order,
|
|
1594
|
+
scope: (id) => this.#definition(id)?.scope,
|
|
1595
|
+
bindings: (id) => this.#bindings(id),
|
|
1596
|
+
declared: (id) => this.#keymap.get(id),
|
|
1597
|
+
isRegistered: (id) => this.#stacks.has(strictId(id)),
|
|
1598
|
+
overrides: this.#options.bindings,
|
|
1599
|
+
conflicts: (binding, options) => this.conflicts(binding, options),
|
|
1600
|
+
platform: this.#platform(),
|
|
1601
|
+
display: this.#displayOptions()
|
|
1602
|
+
};
|
|
1603
|
+
}
|
|
1604
|
+
};
|
|
1605
|
+
function describeCommand(id, definition, source, platform, options, state) {
|
|
1606
|
+
const bindings = [...source];
|
|
1607
|
+
return {
|
|
1608
|
+
id,
|
|
1609
|
+
title: definition.title,
|
|
1610
|
+
description: definition.description,
|
|
1611
|
+
group: definition.group,
|
|
1612
|
+
keywords: definition.keywords ?? [],
|
|
1613
|
+
hidden: definition.hidden === true,
|
|
1614
|
+
scope: definition.scope,
|
|
1615
|
+
bindings,
|
|
1616
|
+
display: bindings.map((binding) => formatBinding(binding, options)),
|
|
1617
|
+
tokens: bindings.map((binding) => bindingTokens(binding, options)),
|
|
1618
|
+
aria: bindings.map((binding) => ariaKeyShortcuts(binding, platform)).filter((value) => value.length > 0).join(" "),
|
|
1619
|
+
spoken: bindings.map((binding) => spokenBinding(binding, platform)).join(" or "),
|
|
1620
|
+
...state
|
|
1621
|
+
};
|
|
1622
|
+
}
|
|
1623
|
+
/** Normalizes an arbitrary thrown value into a real `Error` at the catch boundary. */
|
|
1624
|
+
function toError(cause) {
|
|
1625
|
+
return cause instanceof Error ? cause : new Error(String(cause));
|
|
1626
|
+
}
|
|
1627
|
+
function samePending(a, b) {
|
|
1628
|
+
if (a.length !== b.length) return false;
|
|
1629
|
+
return a.every((entry, index) => {
|
|
1630
|
+
const other = b[index];
|
|
1631
|
+
return other !== void 0 && entry.id === other.id && entry.matched === other.matched;
|
|
1632
|
+
});
|
|
1633
|
+
}
|
|
1634
|
+
/**
|
|
1635
|
+
* Creates a command registry.
|
|
1636
|
+
*
|
|
1637
|
+
* @example
|
|
1638
|
+
* ```ts
|
|
1639
|
+
* export const commands = createCommands({
|
|
1640
|
+
* "palette.open": {
|
|
1641
|
+
* title: "Open command palette",
|
|
1642
|
+
* keys: "Mod+K",
|
|
1643
|
+
* run: () => setPaletteOpen(true),
|
|
1644
|
+
* },
|
|
1645
|
+
* "nav.inbox": {
|
|
1646
|
+
* title: "Go to inbox",
|
|
1647
|
+
* keys: seq("G", "I"),
|
|
1648
|
+
* run: () => navigate("/inbox"),
|
|
1649
|
+
* },
|
|
1650
|
+
* });
|
|
1651
|
+
* ```
|
|
1652
|
+
*/
|
|
1653
|
+
function createCommands(definitions, options) {
|
|
1654
|
+
return new CommandRegistry(definitions, options);
|
|
1655
|
+
}
|
|
1656
|
+
//#endregion
|
|
1657
|
+
//#region src/search.ts
|
|
1658
|
+
/**
|
|
1659
|
+
* Filters commands for a search query, as a command palette does, leaving
|
|
1660
|
+
* out `hidden` ones. Every word of the query must appear somewhere in the
|
|
1661
|
+
* command — title, description, group, keywords, id or displayed shortcut —
|
|
1662
|
+
* in any order. Title matches come first: titles starting with the query,
|
|
1663
|
+
* then titles containing every word, then the rest; each tier keeps the
|
|
1664
|
+
* list's own order. An empty query returns every visible command.
|
|
1665
|
+
*
|
|
1666
|
+
* @example
|
|
1667
|
+
* ```ts
|
|
1668
|
+
* const results = matchCommands(commands.list(), "go inbox");
|
|
1669
|
+
* ```
|
|
1670
|
+
*/
|
|
1671
|
+
function matchCommands(commands, query) {
|
|
1672
|
+
const visible = commands.filter((command) => !command.hidden);
|
|
1673
|
+
const needle = query.trim().toLowerCase();
|
|
1674
|
+
if (!needle) return visible;
|
|
1675
|
+
const terms = needle.split(/\s+/);
|
|
1676
|
+
const ranked = [];
|
|
1677
|
+
for (const command of visible) {
|
|
1678
|
+
const title = command.title.toLowerCase();
|
|
1679
|
+
const text = [
|
|
1680
|
+
title,
|
|
1681
|
+
command.description,
|
|
1682
|
+
command.group,
|
|
1683
|
+
command.id,
|
|
1684
|
+
...command.keywords,
|
|
1685
|
+
...command.display
|
|
1686
|
+
].join("\n").toLowerCase();
|
|
1687
|
+
if (!terms.every((term) => text.includes(term))) continue;
|
|
1688
|
+
const rank = title.startsWith(needle) ? 0 : terms.every((term) => title.includes(term)) ? 1 : 2;
|
|
1689
|
+
ranked.push({
|
|
1690
|
+
command,
|
|
1691
|
+
rank
|
|
1692
|
+
});
|
|
1693
|
+
}
|
|
1694
|
+
return ranked.sort((a, b) => a.rank - b.rank).map(({ command }) => command);
|
|
1695
|
+
}
|
|
1696
|
+
//#endregion
|
|
1697
|
+
export { ariaKeyShortcuts as a, spokenBinding as c, seq as d, toBindings as f, createRebinder as i, bindingSignature as l, createCommands as n, bindingTokens as o, validateBinding as p, ALWAYS_SCOPE as r, formatBinding as s, matchCommands as t, isSingleKeyBinding as u };
|