@inklu/keys 2.1.0 → 3.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 +106 -135
- package/dist/attributes.d.mts +1 -1
- package/dist/attributes.mjs +15 -18
- package/dist/index-BnB38jCF.d.mts +139 -0
- package/dist/index.d.mts +3 -3
- package/dist/index.mjs +2 -2
- package/dist/react.d.mts +19 -41
- package/dist/react.mjs +26 -145
- package/dist/src-CGKvYKjd.mjs +1333 -0
- package/dist/types-BxMI3b4a.d.mts +256 -0
- package/package.json +2 -3
- package/dist/index-3d9f54ln.d.mts +0 -156
- package/dist/src-B76YvObL.mjs +0 -1687
- package/dist/types-QEXldPnL.d.mts +0 -398
package/dist/src-B76YvObL.mjs
DELETED
|
@@ -1,1687 +0,0 @@
|
|
|
1
|
-
//#region src/chord.ts
|
|
2
|
-
/** The platform this code runs on, as `navigator` reports it. `"linux"` where there is none. */
|
|
3
|
-
function detectPlatform() {
|
|
4
|
-
if (typeof navigator === "undefined") return "linux";
|
|
5
|
-
const platform = `${navigator.platform} ${navigator.userAgent}`.toLowerCase();
|
|
6
|
-
if (platform.includes("mac")) return "mac";
|
|
7
|
-
return platform.includes("win") ? "windows" : "linux";
|
|
8
|
-
}
|
|
9
|
-
/**
|
|
10
|
-
* Named keys with a place in the `Key` type, as `KeyboardEvent.key` spells
|
|
11
|
-
* them. Any other name still works as a key; `validateChord` only remarks on it.
|
|
12
|
-
*/
|
|
13
|
-
const NAMED_KEYS = [
|
|
14
|
-
"ArrowUp",
|
|
15
|
-
"ArrowDown",
|
|
16
|
-
"ArrowLeft",
|
|
17
|
-
"ArrowRight",
|
|
18
|
-
"Home",
|
|
19
|
-
"End",
|
|
20
|
-
"PageUp",
|
|
21
|
-
"PageDown",
|
|
22
|
-
"Enter",
|
|
23
|
-
"Escape",
|
|
24
|
-
"Space",
|
|
25
|
-
"Tab",
|
|
26
|
-
"Backspace",
|
|
27
|
-
"Delete",
|
|
28
|
-
"Insert",
|
|
29
|
-
"CapsLock",
|
|
30
|
-
"ContextMenu",
|
|
31
|
-
"PrintScreen",
|
|
32
|
-
"ScrollLock",
|
|
33
|
-
"Pause",
|
|
34
|
-
"NumLock",
|
|
35
|
-
"Help",
|
|
36
|
-
"Clear",
|
|
37
|
-
"MediaPlayPause",
|
|
38
|
-
"MediaStop",
|
|
39
|
-
"MediaTrackNext",
|
|
40
|
-
"MediaTrackPrevious",
|
|
41
|
-
"AudioVolumeUp",
|
|
42
|
-
"AudioVolumeDown",
|
|
43
|
-
"AudioVolumeMute",
|
|
44
|
-
"BrowserBack",
|
|
45
|
-
"BrowserForward",
|
|
46
|
-
"BrowserRefresh",
|
|
47
|
-
"BrowserSearch",
|
|
48
|
-
"BrowserHome"
|
|
49
|
-
];
|
|
50
|
-
const MODIFIERS = [
|
|
51
|
-
"Control",
|
|
52
|
-
"Alt",
|
|
53
|
-
"Shift",
|
|
54
|
-
"Meta"
|
|
55
|
-
];
|
|
56
|
-
const PHYSICAL = /^\[([A-Za-z][A-Za-z0-9]*)\]$/;
|
|
57
|
-
const FUNCTION_KEY = /^F([1-9]|1\d|2[0-4])$/;
|
|
58
|
-
const LETTER = /^\p{Letter}$/u;
|
|
59
|
-
const LATIN_LETTER = /^\p{Script_Extensions=Latin}$/u;
|
|
60
|
-
/** Splits on `+`. The plus key itself is written last: `+` alone, or `Control++`. */
|
|
61
|
-
function split(chord) {
|
|
62
|
-
const plusKey = chord === "+" || chord.endsWith("++");
|
|
63
|
-
const parts = (plusKey ? chord.slice(0, -1) : chord).split("+");
|
|
64
|
-
if (plusKey) parts[parts.length - 1] = "+";
|
|
65
|
-
return parts.map((part) => part.trim());
|
|
66
|
-
}
|
|
67
|
-
/** One spelling per key: letters upper-cased, the space bar by name. */
|
|
68
|
-
function keyName(key) {
|
|
69
|
-
if (key === " ") return "Space";
|
|
70
|
-
if (!LETTER.test(key)) return key;
|
|
71
|
-
const upper = key.toUpperCase();
|
|
72
|
-
return upper.length === 1 ? upper : key;
|
|
73
|
-
}
|
|
74
|
-
/** The key a keypress produced. Empty for the keyless events browser autofill dispatches. */
|
|
75
|
-
function pressedKey(event) {
|
|
76
|
-
return keyName(String(event.key ?? "").normalize("NFC"));
|
|
77
|
-
}
|
|
78
|
-
function isOneCharacter(key) {
|
|
79
|
-
return Array.from(key).length === 1;
|
|
80
|
-
}
|
|
81
|
-
/** Reads a chord, or says why it cannot be read. */
|
|
82
|
-
function read(chord, platform) {
|
|
83
|
-
if (chord.trim() === "") return "Chord cannot be empty";
|
|
84
|
-
const parts = split(chord);
|
|
85
|
-
const key = parts.pop() ?? "";
|
|
86
|
-
if (key === "" || parts.includes("")) return "Invalid chord: empty part";
|
|
87
|
-
if (parts.includes("Mod") && (parts.includes("Control") || parts.includes("Meta"))) return "Mod already stands for Control or Meta: write one or the other";
|
|
88
|
-
const held = /* @__PURE__ */ new Set();
|
|
89
|
-
for (const part of parts) {
|
|
90
|
-
const name = part === "Mod" ? platform === "mac" ? "Meta" : "Control" : part;
|
|
91
|
-
const modifier = MODIFIERS.find((candidate) => candidate === name);
|
|
92
|
-
if (!modifier) return `Unknown modifier: '${part}'`;
|
|
93
|
-
if (held.has(modifier)) return `Repeated modifier: '${part}'`;
|
|
94
|
-
held.add(modifier);
|
|
95
|
-
}
|
|
96
|
-
const code = PHYSICAL.exec(key)?.[1];
|
|
97
|
-
if (code === void 0 && key.length > 1 && (key.startsWith("[") || key.endsWith("]"))) return "Invalid physical key: use a bracketed KeyboardEvent.code, such as [KeyS]";
|
|
98
|
-
return {
|
|
99
|
-
ctrl: held.has("Control"),
|
|
100
|
-
alt: held.has("Alt"),
|
|
101
|
-
shift: held.has("Shift"),
|
|
102
|
-
meta: held.has("Meta"),
|
|
103
|
-
...code === void 0 ? { key: keyName(key) } : { code }
|
|
104
|
-
};
|
|
105
|
-
}
|
|
106
|
-
/** Parses a chord for a platform, or returns `null` for one that cannot be read. */
|
|
107
|
-
function parseChord(chord, platform) {
|
|
108
|
-
const result = read(chord, platform);
|
|
109
|
-
return typeof result === "string" ? null : result;
|
|
110
|
-
}
|
|
111
|
-
/** Whether a string is a chord this package can read. */
|
|
112
|
-
function isChord(text) {
|
|
113
|
-
return parseChord(text, "mac") !== null;
|
|
114
|
-
}
|
|
115
|
-
/**
|
|
116
|
-
* What is wrong with a chord (`errors`: it will never fire) and what merely
|
|
117
|
-
* looks off (`warnings`: a key name this package does not know).
|
|
118
|
-
*/
|
|
119
|
-
function validateChord(chord) {
|
|
120
|
-
const result = read(chord, "mac");
|
|
121
|
-
if (typeof result === "string") return {
|
|
122
|
-
errors: [result],
|
|
123
|
-
warnings: []
|
|
124
|
-
};
|
|
125
|
-
const key = result.key;
|
|
126
|
-
return {
|
|
127
|
-
errors: [],
|
|
128
|
-
warnings: key === void 0 || isOneCharacter(key) || FUNCTION_KEY.test(key) || NAMED_KEYS.some((name) => name === key) ? [] : [`Unknown key: '${key}'. Key names are KeyboardEvent.key values, such as Escape.`]
|
|
129
|
-
};
|
|
130
|
-
}
|
|
131
|
-
/**
|
|
132
|
-
* Writes a parsed chord back as text, modifiers in canonical order. Given a
|
|
133
|
-
* platform it prefers `Mod`, which is how a chord stays portable when it is
|
|
134
|
-
* stored and read back on another machine.
|
|
135
|
-
*/
|
|
136
|
-
function writeChord(chord, mod) {
|
|
137
|
-
const portable = mod !== void 0 && (mod === "mac" ? chord.meta && !chord.ctrl : chord.ctrl && !chord.meta);
|
|
138
|
-
const parts = portable ? ["Mod"] : [];
|
|
139
|
-
if (chord.ctrl && !portable) parts.push("Control");
|
|
140
|
-
if (chord.alt) parts.push("Alt");
|
|
141
|
-
if (chord.shift) parts.push("Shift");
|
|
142
|
-
if (chord.meta && !portable) parts.push("Meta");
|
|
143
|
-
parts.push(chord.code === void 0 ? chord.key ?? "" : `[${chord.code}]`);
|
|
144
|
-
return parts.join("+");
|
|
145
|
-
}
|
|
146
|
-
/**
|
|
147
|
-
* Whether a chord types a character when pressed: one character, or a letter,
|
|
148
|
-
* digit or punctuation key by position, with no Control, Alt or Meta. Shift
|
|
149
|
-
* does not count as a modifier here: `Shift+A` still types a character.
|
|
150
|
-
*/
|
|
151
|
-
function typesCharacter(chord) {
|
|
152
|
-
if (chord.ctrl || chord.alt || chord.meta) return false;
|
|
153
|
-
return isOneCharacter(chord.key ?? keyAtCode(chord.code ?? "") ?? "");
|
|
154
|
-
}
|
|
155
|
-
const MODIFIER_KEYS = /* @__PURE__ */ new Set([
|
|
156
|
-
...MODIFIERS,
|
|
157
|
-
"AltGraph",
|
|
158
|
-
"OS",
|
|
159
|
-
"Fn",
|
|
160
|
-
"Hyper",
|
|
161
|
-
"Super",
|
|
162
|
-
"Symbol"
|
|
163
|
-
]);
|
|
164
|
-
/** Whether a `KeyboardEvent.key` is a modifier on its own. */
|
|
165
|
-
function isModifierKey(key) {
|
|
166
|
-
return MODIFIER_KEYS.has(key);
|
|
167
|
-
}
|
|
168
|
-
/**
|
|
169
|
-
* AltGr types a third character on many layouts, and Windows reports it as
|
|
170
|
-
* Control+Alt. While it is down those two flags say nothing about intent.
|
|
171
|
-
*/
|
|
172
|
-
function hasAltGraph(event, platform) {
|
|
173
|
-
return platform !== "mac" && event.getModifierState?.("AltGraph") === true;
|
|
174
|
-
}
|
|
175
|
-
/** The punctuation keys of the conventional (US) layout, by `KeyboardEvent.code`. */
|
|
176
|
-
const PUNCTUATION_CODES = {
|
|
177
|
-
Backquote: "`",
|
|
178
|
-
Backslash: "\\",
|
|
179
|
-
BracketLeft: "[",
|
|
180
|
-
BracketRight: "]",
|
|
181
|
-
Comma: ",",
|
|
182
|
-
Equal: "=",
|
|
183
|
-
Minus: "-",
|
|
184
|
-
Period: ".",
|
|
185
|
-
Quote: "'",
|
|
186
|
-
Semicolon: ";",
|
|
187
|
-
Slash: "/"
|
|
188
|
-
};
|
|
189
|
-
/** The key a `KeyboardEvent.code` sits on in the conventional (US) layout. */
|
|
190
|
-
function keyAtCode(code) {
|
|
191
|
-
if (/^Key[A-Z]$/.test(code)) return code.slice(3);
|
|
192
|
-
if (/^Digit[0-9]$/.test(code)) return code.slice(5);
|
|
193
|
-
return code === "Space" ? code : PUNCTUATION_CODES[code];
|
|
194
|
-
}
|
|
195
|
-
/** `KeyboardEvent.key` values that name neither a character nor a key. */
|
|
196
|
-
const NO_KEY = /* @__PURE__ */ new Set([
|
|
197
|
-
"",
|
|
198
|
-
"Dead",
|
|
199
|
-
"Unidentified",
|
|
200
|
-
"Process"
|
|
201
|
-
]);
|
|
202
|
-
const isAscii = (key) => /^[\x21-\x7e]$/.test(key);
|
|
203
|
-
/** A one-character key that is not a letter: a digit or a symbol. */
|
|
204
|
-
const isSymbol = (key) => isOneCharacter(key) && !LETTER.test(key);
|
|
205
|
-
/** A key known by name rather than by a character, such as `Enter`. */
|
|
206
|
-
const isNamed = (key) => !isOneCharacter(key) && !NO_KEY.has(key);
|
|
207
|
-
/**
|
|
208
|
-
* The key at a keypress's position on the conventional layout, when position
|
|
209
|
-
* may stand in for what the layout produced. That is when the layout produced
|
|
210
|
-
* nothing a chord would name:
|
|
211
|
-
*
|
|
212
|
-
* - On the digit row, always: `Mod+1` is the first key of that row on AZERTY,
|
|
213
|
-
* which types `&` there, and `Shift+2` is `@` on a US keyboard.
|
|
214
|
-
* - On a letter key, when it typed a character from another script (`ы` on a
|
|
215
|
-
* Russian keyboard still reaches `Mod+S`), or macOS Option turned the letter
|
|
216
|
-
* into something else.
|
|
217
|
-
* - On a punctuation key, the same, and also while Shift or Alt is held, so
|
|
218
|
-
* `Shift+/` can be written by its base key.
|
|
219
|
-
*
|
|
220
|
-
* Never when the layout typed a Latin letter or the key has a name (that is
|
|
221
|
-
* the key pressed), nor under AltGr (the user is typing a character).
|
|
222
|
-
*/
|
|
223
|
-
function positionKey(event, key, altGraph) {
|
|
224
|
-
if (altGraph || /^[A-Z]$/.test(key) || isNamed(key)) return void 0;
|
|
225
|
-
const code = event.code;
|
|
226
|
-
if (/^Digit[0-9]$/.test(code)) return code.slice(5);
|
|
227
|
-
if (LATIN_LETTER.test(key) && !event.altKey) return void 0;
|
|
228
|
-
if (/^Key[A-Z]$/.test(code)) return isAscii(key) ? void 0 : code.slice(3);
|
|
229
|
-
return isAscii(key) && !event.shiftKey && !event.altKey ? void 0 : PUNCTUATION_CODES[code];
|
|
230
|
-
}
|
|
231
|
-
/**
|
|
232
|
-
* Whether a keypress is this chord.
|
|
233
|
-
*
|
|
234
|
-
* A physical chord (`[KeyK]`) compares the key's position and nothing else.
|
|
235
|
-
* A logical one names what the key produced, and matches in one of two ways:
|
|
236
|
-
*
|
|
237
|
-
* 1. The key produced is the chord's key. For a symbol, the modifiers that
|
|
238
|
-
* were only there to type it need not be named: `?` is Shift+/ on a US
|
|
239
|
-
* keyboard and unshifted elsewhere, and `[` is Option+5 on a German Mac.
|
|
240
|
-
* 2. The key's position stands in for it, where `positionKey` allows.
|
|
241
|
-
*
|
|
242
|
-
* The approach follows the matcher in TanStack Hotkeys (MIT).
|
|
243
|
-
*/
|
|
244
|
-
function matchesChord(event, chord, platform) {
|
|
245
|
-
const altGraph = hasAltGraph(event, platform);
|
|
246
|
-
if (altGraph && (chord.ctrl || chord.alt)) return false;
|
|
247
|
-
const alt = !altGraph && event.altKey;
|
|
248
|
-
const modifiersMatch = (typing) => (!altGraph && event.ctrlKey) === chord.ctrl && event.metaKey === chord.meta && (alt === chord.alt || typing && alt && platform === "mac") && (event.shiftKey === chord.shift || typing && event.shiftKey);
|
|
249
|
-
if (chord.code !== void 0) return event.code === chord.code && modifiersMatch(false);
|
|
250
|
-
const key = pressedKey(event);
|
|
251
|
-
if (key === chord.key) return modifiersMatch(isSymbol(key));
|
|
252
|
-
return positionKey(event, key, altGraph) === chord.key && modifiersMatch(false);
|
|
253
|
-
}
|
|
254
|
-
/**
|
|
255
|
-
* The chord a keypress expresses, for recording a new shortcut, or `null` for
|
|
256
|
-
* a keypress that is not one: a modifier on its own, or a key with no name.
|
|
257
|
-
*
|
|
258
|
-
* It is the inverse of `matchesChord`: what it records, the same keypress
|
|
259
|
-
* matches. It writes the character the layout typed where a chord can name it
|
|
260
|
-
* (`?`, not `Shift+/`), the key's position otherwise (`Mod+S` for Ctrl+ы), and
|
|
261
|
-
* `Mod` for the primary modifier, so the chord works on the user's other
|
|
262
|
-
* machines too.
|
|
263
|
-
*/
|
|
264
|
-
function readChord(event, platform) {
|
|
265
|
-
if (isModifierKey(event.key)) return null;
|
|
266
|
-
const pressed = pressedKey(event);
|
|
267
|
-
const altGraph = hasAltGraph(event, platform);
|
|
268
|
-
const position = positionKey(event, pressed, altGraph);
|
|
269
|
-
const key = altGraph || isNamed(pressed) || isAscii(pressed) || LATIN_LETTER.test(pressed) && !event.altKey || position === void 0 ? pressed : position;
|
|
270
|
-
if (NO_KEY.has(key)) return null;
|
|
271
|
-
const typed = key === pressed && isSymbol(key);
|
|
272
|
-
const alt = !altGraph && event.altKey;
|
|
273
|
-
const chord = writeChord({
|
|
274
|
-
ctrl: !altGraph && event.ctrlKey,
|
|
275
|
-
alt: alt && !(typed && platform === "mac"),
|
|
276
|
-
shift: event.shiftKey && !typed,
|
|
277
|
-
meta: event.metaKey,
|
|
278
|
-
key
|
|
279
|
-
}, platform);
|
|
280
|
-
return isChord(chord) ? chord : null;
|
|
281
|
-
}
|
|
282
|
-
//#endregion
|
|
283
|
-
//#region src/bindings.ts
|
|
284
|
-
/** Bindings: a chord or a sequence of them. Comparing, validating and serializing. */
|
|
285
|
-
/**
|
|
286
|
-
* Creates a sequence binding from ordered chords.
|
|
287
|
-
*
|
|
288
|
-
* @example
|
|
289
|
-
* ```ts
|
|
290
|
-
* keys: seq("G", "I")
|
|
291
|
-
* ```
|
|
292
|
-
*/
|
|
293
|
-
function seq(...steps) {
|
|
294
|
-
return { sequence: steps };
|
|
295
|
-
}
|
|
296
|
-
/** Type guard checking if a binding is a sequence. */
|
|
297
|
-
function isSequence(binding) {
|
|
298
|
-
return typeof binding === "object" && binding !== null && "sequence" in binding;
|
|
299
|
-
}
|
|
300
|
-
/** The chords of a binding, in order: one for a chord, several for a sequence. */
|
|
301
|
-
function stepsOf(binding) {
|
|
302
|
-
return isSequence(binding) ? binding.sequence : [binding];
|
|
303
|
-
}
|
|
304
|
-
/**
|
|
305
|
-
* Normalizes a binding input into an array of bindings. A one-step sequence
|
|
306
|
-
* becomes a plain chord: it fires on exactly the same keypress, and keeping it
|
|
307
|
-
* a sequence gave it a different signature from the chord, so the two were
|
|
308
|
-
* neither arbitrated nor reported as conflicting — both ran.
|
|
309
|
-
*/
|
|
310
|
-
function toBindings(input) {
|
|
311
|
-
if (input == null) return [];
|
|
312
|
-
const candidates = isList(input) ? input : [input];
|
|
313
|
-
const bindings = [];
|
|
314
|
-
for (const candidate of candidates) {
|
|
315
|
-
if (candidate == null) continue;
|
|
316
|
-
const [first, ...rest] = stepsOf(candidate);
|
|
317
|
-
if (first === void 0) continue;
|
|
318
|
-
if (rest.length > 0) bindings.push({ sequence: [first, ...rest] });
|
|
319
|
-
else if (first.trim() !== "") bindings.push(first);
|
|
320
|
-
}
|
|
321
|
-
return bindings;
|
|
322
|
-
}
|
|
323
|
-
function isList(input) {
|
|
324
|
-
return Array.isArray(input);
|
|
325
|
-
}
|
|
326
|
-
/** Generates a normalized signature for binding comparison and conflict detection. */
|
|
327
|
-
function bindingSignature(binding, platform) {
|
|
328
|
-
const steps = canonicalSteps(binding, platform);
|
|
329
|
-
return isSequence(binding) ? `seq:${steps.join(" ")}` : `chord:${steps.join(" ")}`;
|
|
330
|
-
}
|
|
331
|
-
/**
|
|
332
|
-
* Whether one binding is a strict prefix of the other — chord `G` against
|
|
333
|
-
* `G then I`, or `G then G` against `G then G then I`. The shorter binding
|
|
334
|
-
* fires on the way to the longer one, so both run for a single intent.
|
|
335
|
-
*/
|
|
336
|
-
function isPrefixCollision(a, b, platform) {
|
|
337
|
-
const left = canonicalSteps(a, platform);
|
|
338
|
-
const right = canonicalSteps(b, platform);
|
|
339
|
-
if (left.length === right.length) return false;
|
|
340
|
-
const [short, long] = left.length < right.length ? [left, right] : [right, left];
|
|
341
|
-
return short.every((step, index) => step === long[index]);
|
|
342
|
-
}
|
|
343
|
-
/** Every step parsed for a platform, or `null` if any of them cannot be read. */
|
|
344
|
-
function parseBinding(binding, platform) {
|
|
345
|
-
const steps = [];
|
|
346
|
-
for (const step of stepsOf(binding)) {
|
|
347
|
-
const parsed = parseChord(step, platform);
|
|
348
|
-
if (!parsed) return null;
|
|
349
|
-
steps.push(parsed);
|
|
350
|
-
}
|
|
351
|
-
return steps;
|
|
352
|
-
}
|
|
353
|
-
/** Each step in one spelling, so equivalent chords compare equal. One that cannot be read stays as written. */
|
|
354
|
-
function canonicalSteps(binding, platform = detectPlatform()) {
|
|
355
|
-
return stepsOf(binding).map((step) => {
|
|
356
|
-
const parsed = parseChord(step, platform);
|
|
357
|
-
return parsed ? writeChord(parsed) : step;
|
|
358
|
-
});
|
|
359
|
-
}
|
|
360
|
-
/**
|
|
361
|
-
* Whether every step of a binding is a single printable character with no
|
|
362
|
-
* Control, Alt or Meta — the "character key shortcuts" WCAG 2.1.4 lets users
|
|
363
|
-
* turn off, since speech input and stray keypresses trigger them by accident.
|
|
364
|
-
*/
|
|
365
|
-
function isSingleKeyBinding(binding) {
|
|
366
|
-
return parseBinding(binding, "mac")?.every(typesCharacter) === true;
|
|
367
|
-
}
|
|
368
|
-
/** Converts a binding to a JSON-serializable format. */
|
|
369
|
-
function serializeBinding(binding) {
|
|
370
|
-
return isSequence(binding) ? [...binding.sequence] : binding;
|
|
371
|
-
}
|
|
372
|
-
/** Deserializes a stored value into a valid binding, or `null` if invalid. */
|
|
373
|
-
function deserializeBinding(value) {
|
|
374
|
-
if (typeof value === "string") {
|
|
375
|
-
const chord = value.trim();
|
|
376
|
-
return isChord(chord) ? chord : null;
|
|
377
|
-
}
|
|
378
|
-
if (!Array.isArray(value)) return null;
|
|
379
|
-
const steps = [];
|
|
380
|
-
for (const step of value) {
|
|
381
|
-
if (step === "") continue;
|
|
382
|
-
if (typeof step !== "string" || !isChord(step)) return null;
|
|
383
|
-
steps.push(step);
|
|
384
|
-
}
|
|
385
|
-
const [first, ...rest] = steps;
|
|
386
|
-
if (first === void 0) return null;
|
|
387
|
-
return rest.length === 0 ? first : { sequence: steps };
|
|
388
|
-
}
|
|
389
|
-
/** Validates a binding's syntax without logging warnings or errors. */
|
|
390
|
-
function validateBinding(binding) {
|
|
391
|
-
const steps = stepsOf(binding);
|
|
392
|
-
if (steps.length === 0) return {
|
|
393
|
-
valid: false,
|
|
394
|
-
errors: ["Sequence has no steps"],
|
|
395
|
-
warnings: []
|
|
396
|
-
};
|
|
397
|
-
const results = steps.map(validateChord);
|
|
398
|
-
const errors = results.flatMap((result) => result.errors);
|
|
399
|
-
return {
|
|
400
|
-
valid: errors.length === 0,
|
|
401
|
-
errors,
|
|
402
|
-
warnings: results.flatMap((r) => r.warnings)
|
|
403
|
-
};
|
|
404
|
-
}
|
|
405
|
-
//#endregion
|
|
406
|
-
//#region src/display.ts
|
|
407
|
-
/** Platform-aware hotkey display formatting and accessibility attributes. */
|
|
408
|
-
const MODIFIER_LABELS = {
|
|
409
|
-
symbols: [
|
|
410
|
-
"⌃",
|
|
411
|
-
"⌥",
|
|
412
|
-
"⇧",
|
|
413
|
-
"⌘"
|
|
414
|
-
],
|
|
415
|
-
mac: [
|
|
416
|
-
"Control",
|
|
417
|
-
"Option",
|
|
418
|
-
"Shift",
|
|
419
|
-
"Cmd"
|
|
420
|
-
],
|
|
421
|
-
windows: [
|
|
422
|
-
"Ctrl",
|
|
423
|
-
"Alt",
|
|
424
|
-
"Shift",
|
|
425
|
-
"Win"
|
|
426
|
-
],
|
|
427
|
-
linux: [
|
|
428
|
-
"Ctrl",
|
|
429
|
-
"Alt",
|
|
430
|
-
"Shift",
|
|
431
|
-
"Super"
|
|
432
|
-
]
|
|
433
|
-
};
|
|
434
|
-
const KEY_SYMBOLS = {
|
|
435
|
-
ArrowUp: "↑",
|
|
436
|
-
ArrowDown: "↓",
|
|
437
|
-
ArrowLeft: "←",
|
|
438
|
-
ArrowRight: "→",
|
|
439
|
-
Enter: "↵",
|
|
440
|
-
Escape: "Esc",
|
|
441
|
-
Backspace: "⌫",
|
|
442
|
-
Delete: "⌦",
|
|
443
|
-
Tab: "⇥",
|
|
444
|
-
Space: "␣"
|
|
445
|
-
};
|
|
446
|
-
/** Names for the keys whose character is hard to tell apart in running text. */
|
|
447
|
-
const PUNCTUATION_NAMES = {
|
|
448
|
-
"`": "Backquote",
|
|
449
|
-
"\\": "Backslash",
|
|
450
|
-
"[": "Left Bracket",
|
|
451
|
-
"]": "Right Bracket",
|
|
452
|
-
",": "Comma",
|
|
453
|
-
"=": "Equal",
|
|
454
|
-
"-": "Minus",
|
|
455
|
-
".": "Period",
|
|
456
|
-
";": "Semicolon"
|
|
457
|
-
};
|
|
458
|
-
/** Formats a binding for display on the target platform (e.g. `⌘ K` or `Ctrl+K`). */
|
|
459
|
-
function formatBinding(binding, options) {
|
|
460
|
-
if (isSequence(binding)) return binding.sequence.map((step) => formatChord(step, options)).join(" ");
|
|
461
|
-
return formatChord(binding, options);
|
|
462
|
-
}
|
|
463
|
-
/** Splits a binding into display tokens for rendering `<kbd>` elements. */
|
|
464
|
-
function bindingTokens(binding, options) {
|
|
465
|
-
if (isSequence(binding)) return binding.sequence.map((step) => formatChord(step, options));
|
|
466
|
-
return chordTokens(binding, options);
|
|
467
|
-
}
|
|
468
|
-
function formatChord(chord, options) {
|
|
469
|
-
const spaced = usesModifierSymbols(options);
|
|
470
|
-
return chordTokens(chord, options).join(spaced ? " " : "+");
|
|
471
|
-
}
|
|
472
|
-
function usesModifierSymbols(options) {
|
|
473
|
-
return (options?.platform ?? detectPlatform()) === "mac" && options?.useSymbols !== false;
|
|
474
|
-
}
|
|
475
|
-
function chordTokens(chord, options) {
|
|
476
|
-
const platform = options?.platform ?? detectPlatform();
|
|
477
|
-
const parsed = parseChord(chord, platform);
|
|
478
|
-
if (!parsed) return [chord];
|
|
479
|
-
const labels = MODIFIER_LABELS[usesModifierSymbols(options) ? "symbols" : platform];
|
|
480
|
-
const held = [
|
|
481
|
-
parsed.ctrl,
|
|
482
|
-
parsed.alt,
|
|
483
|
-
parsed.shift,
|
|
484
|
-
parsed.meta
|
|
485
|
-
];
|
|
486
|
-
return [...labels.filter((_, index) => held[index]), keyLabel(parsed, options)];
|
|
487
|
-
}
|
|
488
|
-
function keyLabel(chord, options) {
|
|
489
|
-
const names = options?.useSymbols === false ? PUNCTUATION_NAMES : KEY_SYMBOLS;
|
|
490
|
-
const label = (key) => names[key] ?? key;
|
|
491
|
-
if (chord.code === void 0) return label(chord.key ?? "");
|
|
492
|
-
if (chord.code.startsWith("Numpad")) return `Numpad ${label(chord.code.slice(6)).replace(/([a-z])([A-Z])/g, "$1 $2")}`;
|
|
493
|
-
return label(keyAtCode(chord.code) ?? chord.code);
|
|
494
|
-
}
|
|
495
|
-
/**
|
|
496
|
-
* Phrases a binding for a screen reader: `"Control+K"` for a chord,
|
|
497
|
-
* `"G then I"` for a sequence. `aria-keyshortcuts` cannot express a sequence
|
|
498
|
-
* (a space there means "or"), so this is how a sequence gets announced — put it
|
|
499
|
-
* in visually hidden text or `aria-description`.
|
|
500
|
-
*/
|
|
501
|
-
function spokenBinding(binding, platform) {
|
|
502
|
-
return stepsOf(binding).map((step) => keyNames(step, platform) || step).join(" then ");
|
|
503
|
-
}
|
|
504
|
-
/** Generates an `aria-keyshortcuts` attribute string for a binding. */
|
|
505
|
-
function ariaKeyShortcuts(binding, platform) {
|
|
506
|
-
return isSequence(binding) ? "" : keyNames(binding, platform);
|
|
507
|
-
}
|
|
508
|
-
/** A chord in key names, `Mod` resolved: `"Meta+K"`. Empty for one that cannot be read. */
|
|
509
|
-
function keyNames(chord, platform = detectPlatform()) {
|
|
510
|
-
const parsed = parseChord(chord, platform);
|
|
511
|
-
if (!parsed) return "";
|
|
512
|
-
const key = parsed.key ?? keyAtCode(parsed.code ?? "") ?? parsed.code;
|
|
513
|
-
return writeChord({
|
|
514
|
-
...parsed,
|
|
515
|
-
key,
|
|
516
|
-
code: void 0
|
|
517
|
-
});
|
|
518
|
-
}
|
|
519
|
-
//#endregion
|
|
520
|
-
//#region src/keys.ts
|
|
521
|
-
/** The one keyboard listener: matches a keypress to bindings and runs the command that wins it. */
|
|
522
|
-
const DEFAULT_SEQUENCE_TIMEOUT = 1e3;
|
|
523
|
-
/** The longest delay `setTimeout` honours; a longer one fires at once. */
|
|
524
|
-
const MAX_TIMER = 2 ** 31 - 1;
|
|
525
|
-
const NO_PROGRESS = [];
|
|
526
|
-
var KeyListener = class {
|
|
527
|
-
#host;
|
|
528
|
-
#target;
|
|
529
|
-
#attached = null;
|
|
530
|
-
#bound = null;
|
|
531
|
-
/** Whoever has taken the keyboard, the most recent last. See `capture`. */
|
|
532
|
-
#captures = [];
|
|
533
|
-
#spent = null;
|
|
534
|
-
#watchers = /* @__PURE__ */ new Set();
|
|
535
|
-
#progress = NO_PROGRESS;
|
|
536
|
-
#timer = null;
|
|
537
|
-
constructor(host, target) {
|
|
538
|
-
this.#host = host;
|
|
539
|
-
this.#target = target;
|
|
540
|
-
}
|
|
541
|
-
/** Starts listening. A no-op on the server. */
|
|
542
|
-
attach() {
|
|
543
|
-
if (this.#attached || typeof document === "undefined") return;
|
|
544
|
-
this.#attached = this.#target ?? document;
|
|
545
|
-
this.#attached.addEventListener("keydown", this.#onKeyDown);
|
|
546
|
-
}
|
|
547
|
-
/** Stops listening and abandons half-typed sequences. */
|
|
548
|
-
detach() {
|
|
549
|
-
this.#attached?.removeEventListener("keydown", this.#onKeyDown);
|
|
550
|
-
this.#attached = null;
|
|
551
|
-
this.refresh();
|
|
552
|
-
}
|
|
553
|
-
/** Stops listening for good, dropping the watchers and captures too. */
|
|
554
|
-
destroy() {
|
|
555
|
-
this.#watchers.clear();
|
|
556
|
-
for (let onKey = this.#captures.at(-1); onKey; onKey = this.#captures.at(-1)) this.#release(onKey);
|
|
557
|
-
this.detach();
|
|
558
|
-
}
|
|
559
|
-
/** Whether something has taken the keyboard, so no command's keys are live. */
|
|
560
|
-
get captured() {
|
|
561
|
-
return this.#captures.length > 0;
|
|
562
|
-
}
|
|
563
|
-
/**
|
|
564
|
-
* Hands every keypress to `onKey` until released. See `Commands.capture`.
|
|
565
|
-
* A capture listens first, in the capture phase and on the whole document,
|
|
566
|
-
* and only for as long as there is one.
|
|
567
|
-
*/
|
|
568
|
-
capture(onKey) {
|
|
569
|
-
if (typeof document === "undefined") return () => {};
|
|
570
|
-
this.#captures.push(onKey);
|
|
571
|
-
if (this.#captures.length === 1) document.addEventListener("keydown", this.#onCapture, true);
|
|
572
|
-
return () => this.#release(onKey);
|
|
573
|
-
}
|
|
574
|
-
#release(onKey) {
|
|
575
|
-
const index = this.#captures.lastIndexOf(onKey);
|
|
576
|
-
if (index === -1) return;
|
|
577
|
-
this.#captures.splice(index, 1);
|
|
578
|
-
if (this.#captures.length > 0) return;
|
|
579
|
-
document.removeEventListener("keydown", this.#onCapture, true);
|
|
580
|
-
}
|
|
581
|
-
/** Bindings or the platform changed: re-read them. Half-typed sequences start over. */
|
|
582
|
-
refresh() {
|
|
583
|
-
this.#bound = null;
|
|
584
|
-
this.#publish();
|
|
585
|
-
}
|
|
586
|
-
/**
|
|
587
|
-
* Reachability changed. A sequence that became unreachable halfway through
|
|
588
|
-
* loses its progress: otherwise a scope that closed and reopened within
|
|
589
|
-
* the timeout would let the next key complete a sequence begun in a
|
|
590
|
-
* different context.
|
|
591
|
-
*/
|
|
592
|
-
prune() {
|
|
593
|
-
let changed = false;
|
|
594
|
-
for (const bound of this.#bound ?? []) {
|
|
595
|
-
if (bound.matched === 0 || this.#host.reachable(bound.id)) continue;
|
|
596
|
-
bound.matched = 0;
|
|
597
|
-
changed = true;
|
|
598
|
-
}
|
|
599
|
-
if (changed) this.#publish();
|
|
600
|
-
}
|
|
601
|
-
/** Half-typed sequences. Tracked only while something is watching. */
|
|
602
|
-
progress() {
|
|
603
|
-
return this.#progress;
|
|
604
|
-
}
|
|
605
|
-
/** Watches sequence progress. It is tracked, and timed, only while someone is watching. */
|
|
606
|
-
watch(listener) {
|
|
607
|
-
this.#watchers.add(listener);
|
|
608
|
-
if (this.#watchers.size === 1) this.#publish();
|
|
609
|
-
return () => {
|
|
610
|
-
this.#watchers.delete(listener);
|
|
611
|
-
if (this.#watchers.size === 0) this.#publish();
|
|
612
|
-
};
|
|
613
|
-
}
|
|
614
|
-
#bindings() {
|
|
615
|
-
if (this.#bound) return this.#bound;
|
|
616
|
-
const platform = this.#host.platform();
|
|
617
|
-
const bound = [];
|
|
618
|
-
this.#host.ids().forEach((id, order) => {
|
|
619
|
-
for (const binding of this.#host.bindings(id)) {
|
|
620
|
-
const steps = parseBinding(binding, platform);
|
|
621
|
-
if (!steps) continue;
|
|
622
|
-
const timeout = this.#host.definition(id)?.sequenceTimeout ?? DEFAULT_SEQUENCE_TIMEOUT;
|
|
623
|
-
bound.push({
|
|
624
|
-
id,
|
|
625
|
-
order,
|
|
626
|
-
binding,
|
|
627
|
-
steps,
|
|
628
|
-
timeout,
|
|
629
|
-
matched: 0,
|
|
630
|
-
at: 0
|
|
631
|
-
});
|
|
632
|
-
}
|
|
633
|
-
});
|
|
634
|
-
this.#bound = bound;
|
|
635
|
-
return bound;
|
|
636
|
-
}
|
|
637
|
-
#onCapture = (event) => {
|
|
638
|
-
const onKey = this.#captures.at(-1);
|
|
639
|
-
if (!onKey || isComposing(event)) return;
|
|
640
|
-
this.#spent = event;
|
|
641
|
-
onKey(event, readChord(event, this.#host.platform()));
|
|
642
|
-
};
|
|
643
|
-
#onKeyDown = (event) => {
|
|
644
|
-
if (isComposing(event) || this.captured) return;
|
|
645
|
-
if (this.#spent) {
|
|
646
|
-
if (event === this.#spent || event.repeat) return;
|
|
647
|
-
this.#spent = null;
|
|
648
|
-
}
|
|
649
|
-
if (isModifierKey(event.key)) return;
|
|
650
|
-
const platform = this.#host.platform();
|
|
651
|
-
const typing = isTyping(event, this.#target);
|
|
652
|
-
const now = Date.now();
|
|
653
|
-
const chords = [];
|
|
654
|
-
const sequences = [];
|
|
655
|
-
let continued = false;
|
|
656
|
-
let prevent = false;
|
|
657
|
-
let progressed = false;
|
|
658
|
-
for (const bound of this.#bindings()) {
|
|
659
|
-
const definition = this.#host.definition(bound.id);
|
|
660
|
-
const first = bound.steps[0];
|
|
661
|
-
if (!definition || !first || !this.#host.reachable(bound.id)) continue;
|
|
662
|
-
if (typing && (definition.ignoreInputs ?? ignoresInputs(first))) continue;
|
|
663
|
-
if (bound.steps.length === 1) {
|
|
664
|
-
if (matchesChord(event, first, platform)) chords.push(bound);
|
|
665
|
-
continue;
|
|
666
|
-
}
|
|
667
|
-
if (event.repeat) continue;
|
|
668
|
-
const matched = now - bound.at > bound.timeout ? 0 : bound.matched;
|
|
669
|
-
const expected = bound.steps[matched];
|
|
670
|
-
let reached = 0;
|
|
671
|
-
if (expected && matchesChord(event, expected, platform)) reached = matched + 1;
|
|
672
|
-
else if (matched > 0 && matchesChord(event, first, platform)) reached = 1;
|
|
673
|
-
const next = reached === bound.steps.length ? 0 : reached;
|
|
674
|
-
if (next !== bound.matched) progressed = true;
|
|
675
|
-
bound.matched = next;
|
|
676
|
-
bound.at = now;
|
|
677
|
-
if (reached === 0) continue;
|
|
678
|
-
if (reached > 1) continued = true;
|
|
679
|
-
if (reached === bound.steps.length) {
|
|
680
|
-
sequences.push(bound);
|
|
681
|
-
continue;
|
|
682
|
-
}
|
|
683
|
-
const step = bound.steps[reached - 1];
|
|
684
|
-
if (!(typing && !step?.ctrl && !step?.meta) && definition.preventDefault !== false) prevent = true;
|
|
685
|
-
}
|
|
686
|
-
if (progressed) this.#publish();
|
|
687
|
-
if (continued) this.#spent = event;
|
|
688
|
-
const candidates = sequences.length > 0 ? sequences : continued ? [] : chords;
|
|
689
|
-
let winner;
|
|
690
|
-
for (const bound of candidates) if (!winner || this.#outranks(bound, winner)) winner = bound;
|
|
691
|
-
if (winner && this.#host.definition(winner.id)?.preventDefault !== false) prevent = true;
|
|
692
|
-
if (prevent) event.preventDefault();
|
|
693
|
-
if (winner) this.#host.run(winner.id, event, winner.binding);
|
|
694
|
-
};
|
|
695
|
-
#outranks(a, b) {
|
|
696
|
-
const depth = this.#host.depth(a.id) - this.#host.depth(b.id);
|
|
697
|
-
return depth === 0 ? a.order > b.order : depth > 0;
|
|
698
|
-
}
|
|
699
|
-
#publish = () => {
|
|
700
|
-
if (this.#timer !== null) clearTimeout(this.#timer);
|
|
701
|
-
this.#timer = null;
|
|
702
|
-
const progress = [];
|
|
703
|
-
let expires = null;
|
|
704
|
-
if (this.#watchers.size > 0) {
|
|
705
|
-
const now = Date.now();
|
|
706
|
-
for (const bound of this.#bound ?? []) {
|
|
707
|
-
if (bound.matched === 0 || !isSequence(bound.binding)) continue;
|
|
708
|
-
const deadline = bound.at + bound.timeout;
|
|
709
|
-
if (now > deadline) continue;
|
|
710
|
-
expires = expires === null ? deadline : Math.min(expires, deadline);
|
|
711
|
-
progress.push({
|
|
712
|
-
id: bound.id,
|
|
713
|
-
binding: bound.binding,
|
|
714
|
-
matched: bound.matched
|
|
715
|
-
});
|
|
716
|
-
}
|
|
717
|
-
}
|
|
718
|
-
const wait = expires === null ? Infinity : Math.max(0, expires - Date.now()) + 1;
|
|
719
|
-
if (wait < MAX_TIMER) this.#timer = setTimeout(this.#publish, wait);
|
|
720
|
-
if (progress.length === this.#progress.length && progress.every((entry, index) => {
|
|
721
|
-
const other = this.#progress[index];
|
|
722
|
-
return other?.binding === entry.binding && other.matched === entry.matched;
|
|
723
|
-
})) return;
|
|
724
|
-
this.#progress = progress.length === 0 ? NO_PROGRESS : progress;
|
|
725
|
-
for (const watcher of this.#watchers) watcher();
|
|
726
|
-
};
|
|
727
|
-
};
|
|
728
|
-
/**
|
|
729
|
-
* A keydown mid-composition belongs to the IME — confirming a kana or pinyin
|
|
730
|
-
* candidate must not also fire whatever the raw key is bound to. Older
|
|
731
|
-
* engines report composition only as `keyCode` 229 or the key `Process`.
|
|
732
|
-
*/
|
|
733
|
-
function isComposing(event) {
|
|
734
|
-
return event.isComposing || event.keyCode === 229 || event.key === "Process";
|
|
735
|
-
}
|
|
736
|
-
/**
|
|
737
|
-
* Whether a binding stays quiet while the user is typing, when its command
|
|
738
|
-
* does not say: chords holding Control or Meta and bare `Escape` still fire
|
|
739
|
-
* inside a text field, everything else would hijack a typed character.
|
|
740
|
-
*/
|
|
741
|
-
function ignoresInputs(step) {
|
|
742
|
-
return !(step.ctrl || step.meta || step.key === "Escape" || step.code === "Escape");
|
|
743
|
-
}
|
|
744
|
-
/**
|
|
745
|
-
* Whether a keypress lands in a field that takes text. `host` is the element
|
|
746
|
-
* the listener itself sits on, which never counts: keys bound on an editor
|
|
747
|
-
* are meant for that editor.
|
|
748
|
-
*/
|
|
749
|
-
function isTyping(event, host) {
|
|
750
|
-
return event.composedPath().some((node) => node !== host && isTextEntry(node));
|
|
751
|
-
}
|
|
752
|
-
function isTextEntry(node) {
|
|
753
|
-
if (typeof HTMLElement === "undefined" || !(node instanceof HTMLElement)) return false;
|
|
754
|
-
if (node instanceof HTMLInputElement) {
|
|
755
|
-
const type = node.type.toLowerCase();
|
|
756
|
-
return type !== "button" && type !== "submit" && type !== "reset";
|
|
757
|
-
}
|
|
758
|
-
return node instanceof HTMLTextAreaElement || node instanceof HTMLSelectElement || !["false", null].includes(node.getAttribute("contenteditable"));
|
|
759
|
-
}
|
|
760
|
-
//#endregion
|
|
761
|
-
//#region src/rebind.ts
|
|
762
|
-
/** Controller for recording and applying hotkey rebindings. */
|
|
763
|
-
const NO_STEPS = [];
|
|
764
|
-
/**
|
|
765
|
-
* Creates a rebinder instance for capturing and remapping a command's keys.
|
|
766
|
-
* While it records it holds the whole keyboard, so no command runs on the
|
|
767
|
-
* keys being pressed. Escape cancels, Backspace removes the shortcut (or the
|
|
768
|
-
* last step of a sequence), and Enter finishes a sequence.
|
|
769
|
-
*
|
|
770
|
-
* @example
|
|
771
|
-
* ```ts
|
|
772
|
-
* const rebinder = createRebinder(commands, "palette.open", {
|
|
773
|
-
* onRecord: (binding, conflicts) => {
|
|
774
|
-
* if (conflicts.length) console.warn("Conflict with", conflicts[0].id);
|
|
775
|
-
* },
|
|
776
|
-
* });
|
|
777
|
-
* rebinder.start();
|
|
778
|
-
* ```
|
|
779
|
-
*/
|
|
780
|
-
function createRebinder(registry, id, options = {}) {
|
|
781
|
-
const sequence = options.mode === "sequence";
|
|
782
|
-
const listeners = /* @__PURE__ */ new Set();
|
|
783
|
-
let state = {
|
|
784
|
-
recording: false,
|
|
785
|
-
binding: null,
|
|
786
|
-
steps: NO_STEPS,
|
|
787
|
-
conflicts: []
|
|
788
|
-
};
|
|
789
|
-
let release = null;
|
|
790
|
-
let idle = null;
|
|
791
|
-
const set = (next) => {
|
|
792
|
-
state = next;
|
|
793
|
-
for (const listener of listeners) listener();
|
|
794
|
-
};
|
|
795
|
-
/** Gives the keyboard back. */
|
|
796
|
-
const end = () => {
|
|
797
|
-
release?.();
|
|
798
|
-
release = null;
|
|
799
|
-
if (idle !== null) clearTimeout(idle);
|
|
800
|
-
idle = null;
|
|
801
|
-
};
|
|
802
|
-
const record = (binding) => {
|
|
803
|
-
end();
|
|
804
|
-
const conflicts = registry.conflicts(binding, {
|
|
805
|
-
ignore: id,
|
|
806
|
-
scope: registry.get(id)?.scope
|
|
807
|
-
});
|
|
808
|
-
const blocked = options.rejectConflicts === true && conflicts.length > 0;
|
|
809
|
-
if (options.apply !== false && !blocked) {
|
|
810
|
-
if (options.add) registry.keymap.add(id, binding);
|
|
811
|
-
else registry.keymap.set(id, binding);
|
|
812
|
-
}
|
|
813
|
-
set({
|
|
814
|
-
recording: false,
|
|
815
|
-
binding,
|
|
816
|
-
steps: NO_STEPS,
|
|
817
|
-
conflicts
|
|
818
|
-
});
|
|
819
|
-
options.onRecord?.(binding, conflicts);
|
|
820
|
-
};
|
|
821
|
-
const cancel = () => {
|
|
822
|
-
end();
|
|
823
|
-
set({
|
|
824
|
-
...state,
|
|
825
|
-
recording: false,
|
|
826
|
-
steps: NO_STEPS
|
|
827
|
-
});
|
|
828
|
-
options.onCancel?.();
|
|
829
|
-
};
|
|
830
|
-
const clear = () => {
|
|
831
|
-
end();
|
|
832
|
-
if (options.apply !== false) registry.keymap.set(id, null);
|
|
833
|
-
set({
|
|
834
|
-
recording: false,
|
|
835
|
-
binding: null,
|
|
836
|
-
steps: NO_STEPS,
|
|
837
|
-
conflicts: []
|
|
838
|
-
});
|
|
839
|
-
options.onClear?.();
|
|
840
|
-
};
|
|
841
|
-
const commit = () => {
|
|
842
|
-
const [first, ...rest] = state.steps;
|
|
843
|
-
if (first === void 0) return;
|
|
844
|
-
record(rest.length === 0 ? first : { sequence: state.steps });
|
|
845
|
-
};
|
|
846
|
-
/** Sets the steps typed so far, and restarts the wait for the next one. */
|
|
847
|
-
const step = (steps) => {
|
|
848
|
-
if (idle !== null) clearTimeout(idle);
|
|
849
|
-
const wait = options.idleTimeout ?? 0;
|
|
850
|
-
idle = wait > 0 && steps.length > 0 ? setTimeout(commit, wait) : null;
|
|
851
|
-
set({
|
|
852
|
-
...state,
|
|
853
|
-
steps
|
|
854
|
-
});
|
|
855
|
-
};
|
|
856
|
-
const stop = () => {
|
|
857
|
-
if (!state.recording) return;
|
|
858
|
-
end();
|
|
859
|
-
set({
|
|
860
|
-
...state,
|
|
861
|
-
recording: false,
|
|
862
|
-
steps: NO_STEPS
|
|
863
|
-
});
|
|
864
|
-
};
|
|
865
|
-
const onKey = (event, chord) => {
|
|
866
|
-
if (options.ignoreInputs !== false && event.key !== "Escape" && isTyping(event)) return;
|
|
867
|
-
event.preventDefault();
|
|
868
|
-
event.stopPropagation();
|
|
869
|
-
if (event.repeat) return;
|
|
870
|
-
const bare = !event.ctrlKey && !event.altKey && !event.shiftKey && !event.metaKey;
|
|
871
|
-
if (bare && event.key === "Escape") return cancel();
|
|
872
|
-
if (bare && (event.key === "Backspace" || event.key === "Delete")) return state.steps.length === 0 ? clear() : step(state.steps.slice(0, -1));
|
|
873
|
-
if (sequence && bare && event.key === "Enter") return commit();
|
|
874
|
-
if (chord === null) return;
|
|
875
|
-
if (sequence) step([...state.steps, chord]);
|
|
876
|
-
else record(chord);
|
|
877
|
-
};
|
|
878
|
-
return {
|
|
879
|
-
start: () => {
|
|
880
|
-
if (state.recording) return;
|
|
881
|
-
release = registry.capture(onKey);
|
|
882
|
-
set({
|
|
883
|
-
recording: true,
|
|
884
|
-
binding: null,
|
|
885
|
-
steps: NO_STEPS,
|
|
886
|
-
conflicts: []
|
|
887
|
-
});
|
|
888
|
-
},
|
|
889
|
-
stop,
|
|
890
|
-
cancel: () => {
|
|
891
|
-
if (state.recording) cancel();
|
|
892
|
-
},
|
|
893
|
-
commit: () => {
|
|
894
|
-
if (state.recording && sequence) commit();
|
|
895
|
-
},
|
|
896
|
-
get state() {
|
|
897
|
-
return state;
|
|
898
|
-
},
|
|
899
|
-
subscribe: (listener) => {
|
|
900
|
-
listeners.add(listener);
|
|
901
|
-
return () => {
|
|
902
|
-
listeners.delete(listener);
|
|
903
|
-
};
|
|
904
|
-
},
|
|
905
|
-
destroy: () => {
|
|
906
|
-
stop();
|
|
907
|
-
listeners.clear();
|
|
908
|
-
}
|
|
909
|
-
};
|
|
910
|
-
}
|
|
911
|
-
/**
|
|
912
|
-
* Whether commands in two scopes are live together whenever either is, so
|
|
913
|
-
* their bindings can collide. A global or `"*"` command is live alongside
|
|
914
|
-
* every scope; two different named scopes are separate surfaces, told apart
|
|
915
|
-
* by which was opened last.
|
|
916
|
-
*/
|
|
917
|
-
function scopesOverlap(a, b) {
|
|
918
|
-
if (a === void 0 || b === void 0) return true;
|
|
919
|
-
if (a === "*" || b === "*") return true;
|
|
920
|
-
return a === b;
|
|
921
|
-
}
|
|
922
|
-
/** Stack managing active scopes and command reachability. */
|
|
923
|
-
var ScopeStack = class {
|
|
924
|
-
#entries = [];
|
|
925
|
-
#listeners = /* @__PURE__ */ new Set();
|
|
926
|
-
/** Activates a scope until the returned disposal function is called. */
|
|
927
|
-
push(name, options = {}) {
|
|
928
|
-
const entry = {
|
|
929
|
-
name,
|
|
930
|
-
exclusive: options.exclusive === true
|
|
931
|
-
};
|
|
932
|
-
this.#entries.push(entry);
|
|
933
|
-
this.#notify();
|
|
934
|
-
return () => {
|
|
935
|
-
const index = this.#entries.indexOf(entry);
|
|
936
|
-
if (index === -1) return;
|
|
937
|
-
this.#entries.splice(index, 1);
|
|
938
|
-
this.#notify();
|
|
939
|
-
};
|
|
940
|
-
}
|
|
941
|
-
/**
|
|
942
|
-
* Whether the keys of commands in `scope` are live: the scope is open and
|
|
943
|
-
* no `exclusive` scope above it has claimed the keyboard.
|
|
944
|
-
*/
|
|
945
|
-
isActive(scope) {
|
|
946
|
-
if (scope === "*") return true;
|
|
947
|
-
const barrier = this.#barrierIndex();
|
|
948
|
-
if (barrier === -1) return this.isOpen(scope);
|
|
949
|
-
return scope !== void 0 && this.depth(scope) >= barrier;
|
|
950
|
-
}
|
|
951
|
-
/**
|
|
952
|
-
* Whether `scope` is open at all, barriers aside. This is what a direct
|
|
953
|
-
* `run()` checks: a barrier claims the keyboard, not the command.
|
|
954
|
-
*/
|
|
955
|
-
isOpen(scope) {
|
|
956
|
-
return scope === void 0 || scope === "*" || this.depth(scope) !== -1;
|
|
957
|
-
}
|
|
958
|
-
/**
|
|
959
|
-
* How far up the stack `scope` was last opened, or `-1` for a global or
|
|
960
|
-
* closed one. The surface opened last sits highest, and wins a shared key.
|
|
961
|
-
*/
|
|
962
|
-
depth(scope) {
|
|
963
|
-
if (scope === void 0 || scope === "*") return -1;
|
|
964
|
-
for (let index = this.#entries.length - 1; index >= 0; index--) if (this.#entries[index]?.name === scope) return index;
|
|
965
|
-
return -1;
|
|
966
|
-
}
|
|
967
|
-
/** Clears all active scopes. */
|
|
968
|
-
clear() {
|
|
969
|
-
if (this.#entries.length === 0) return;
|
|
970
|
-
this.#entries = [];
|
|
971
|
-
this.#notify();
|
|
972
|
-
}
|
|
973
|
-
/** Subscribes to scope stack changes. Returns an unsubscribe function. */
|
|
974
|
-
subscribe(listener) {
|
|
975
|
-
this.#listeners.add(listener);
|
|
976
|
-
return () => {
|
|
977
|
-
this.#listeners.delete(listener);
|
|
978
|
-
};
|
|
979
|
-
}
|
|
980
|
-
#barrierIndex() {
|
|
981
|
-
for (let index = this.#entries.length - 1; index >= 0; index--) if (this.#entries[index]?.exclusive) return index;
|
|
982
|
-
return -1;
|
|
983
|
-
}
|
|
984
|
-
#notify() {
|
|
985
|
-
for (const listener of this.#listeners) listener();
|
|
986
|
-
}
|
|
987
|
-
};
|
|
988
|
-
//#endregion
|
|
989
|
-
//#region src/audit.ts
|
|
990
|
-
/** Development-only warnings about bindings that cannot work as written. */
|
|
991
|
-
/**
|
|
992
|
-
* Warns about every command: bad syntax, unknown overrides, collisions. The
|
|
993
|
-
* registry calls this behind a `NODE_ENV` check, so a production build drops
|
|
994
|
-
* this whole module.
|
|
995
|
-
*/
|
|
996
|
-
function auditRegistry(source) {
|
|
997
|
-
for (const id of source.ids) warnAboutBindings(source, id);
|
|
998
|
-
warnAboutUnknownOverrides(source);
|
|
999
|
-
warnAboutConflicts(source);
|
|
1000
|
-
}
|
|
1001
|
-
function warnAboutConflicts(source) {
|
|
1002
|
-
const { platform, display } = source;
|
|
1003
|
-
const seen = /* @__PURE__ */ new Map();
|
|
1004
|
-
const collisions = [];
|
|
1005
|
-
const prefixes = [];
|
|
1006
|
-
const visited = [];
|
|
1007
|
-
for (const id of source.ids) {
|
|
1008
|
-
const scope = source.scope(id);
|
|
1009
|
-
for (const binding of source.bindings(id)) {
|
|
1010
|
-
for (const other of visited) {
|
|
1011
|
-
if (other.id === id || !scopesOverlap(other.scope, scope)) continue;
|
|
1012
|
-
if (!isPrefixCollision(other.binding, binding, platform)) continue;
|
|
1013
|
-
const [short, long] = stepsOf(other.binding).length < stepsOf(binding).length ? [other, {
|
|
1014
|
-
id,
|
|
1015
|
-
binding
|
|
1016
|
-
}] : [{
|
|
1017
|
-
id,
|
|
1018
|
-
binding
|
|
1019
|
-
}, other];
|
|
1020
|
-
prefixes.push(`${formatBinding(short.binding, display)} ("${short.id}") is a prefix of ${formatBinding(long.binding, display)} ("${long.id}")`);
|
|
1021
|
-
}
|
|
1022
|
-
visited.push({
|
|
1023
|
-
id,
|
|
1024
|
-
scope,
|
|
1025
|
-
binding
|
|
1026
|
-
});
|
|
1027
|
-
const key = `${scope ?? ""}::${bindingSignature(binding, platform)}`;
|
|
1028
|
-
const owner = seen.get(key);
|
|
1029
|
-
if (owner === void 0) {
|
|
1030
|
-
seen.set(key, id);
|
|
1031
|
-
continue;
|
|
1032
|
-
}
|
|
1033
|
-
collisions.push(`${formatBinding(binding, display)} — "${id}" shadows "${owner}"`);
|
|
1034
|
-
}
|
|
1035
|
-
}
|
|
1036
|
-
if (collisions.length > 0) console.warn(`[@inklu/keys] ${collisions.length} shadowed binding(s) — same chord, same scope, so the later command takes the key whenever both have a handler:\n ${collisions.join("\n ")}`);
|
|
1037
|
-
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 ")}`);
|
|
1038
|
-
}
|
|
1039
|
-
function warnAboutBindings(source, id) {
|
|
1040
|
-
for (const binding of source.declared(id)) {
|
|
1041
|
-
const { valid, errors, warnings } = validateBinding(binding);
|
|
1042
|
-
if (!valid) console.warn(`[@inklu/keys] "${id}" has an unrecognized binding ${describeBinding(binding)}, so it never fires. ${errors.join("; ")}.`);
|
|
1043
|
-
else if (warnings.length > 0) console.warn(`[@inklu/keys] "${id}" binds ${describeBinding(binding)}. ${warnings.join(" ")}`);
|
|
1044
|
-
}
|
|
1045
|
-
}
|
|
1046
|
-
/** Catches ids in `options.bindings` that TypeScript could not, as in plain JS. */
|
|
1047
|
-
function warnAboutUnknownOverrides(source) {
|
|
1048
|
-
if (!source.overrides) return;
|
|
1049
|
-
const known = new Set(source.ids);
|
|
1050
|
-
const unknown = Object.keys(source.overrides).filter((id) => !known.has(id));
|
|
1051
|
-
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"}.`);
|
|
1052
|
-
}
|
|
1053
|
-
function describeBinding(binding) {
|
|
1054
|
-
return isSequence(binding) ? `"${binding.sequence.join(" ")}"` : `"${binding}"`;
|
|
1055
|
-
}
|
|
1056
|
-
//#endregion
|
|
1057
|
-
//#region src/keymap.ts
|
|
1058
|
-
/** Keymap managing default bindings and user overrides. */
|
|
1059
|
-
const DEFAULT_STORAGE_KEY = "inklu.keys";
|
|
1060
|
-
const KEYMAP_VERSION = 1;
|
|
1061
|
-
/** Type guard narrowing a JSON value to a plain object (excludes arrays and `null`). */
|
|
1062
|
-
function isJsonObject(value) {
|
|
1063
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1064
|
-
}
|
|
1065
|
-
/**
|
|
1066
|
-
* Same keys on every platform. The keymap is user data that may travel
|
|
1067
|
-
* between machines, so `Mod+S` and `Control+S` stay distinct here even on
|
|
1068
|
-
* Windows, where they happen to coincide.
|
|
1069
|
-
*/
|
|
1070
|
-
function portableSignature(binding) {
|
|
1071
|
-
return `${bindingSignature(binding, "mac")} ${bindingSignature(binding, "windows")}`;
|
|
1072
|
-
}
|
|
1073
|
-
var Keymap = class {
|
|
1074
|
-
#defaults = /* @__PURE__ */ new Map();
|
|
1075
|
-
#overrides = /* @__PURE__ */ new Map();
|
|
1076
|
-
#listeners = /* @__PURE__ */ new Set();
|
|
1077
|
-
#persistence;
|
|
1078
|
-
#loaded = false;
|
|
1079
|
-
constructor(persistence) {
|
|
1080
|
-
this.#persistence = persistence;
|
|
1081
|
-
}
|
|
1082
|
-
/** Sets a command's default bindings. `null` leaves the command unbound. */
|
|
1083
|
-
setDefault(id, input) {
|
|
1084
|
-
const bindings = toBindings(input);
|
|
1085
|
-
if (bindings.length === 0) this.#defaults.delete(id);
|
|
1086
|
-
else this.#defaults.set(id, bindings);
|
|
1087
|
-
}
|
|
1088
|
-
/** Checks whether a command has an active user override. */
|
|
1089
|
-
isOverridden(id) {
|
|
1090
|
-
return this.#overrides.has(id);
|
|
1091
|
-
}
|
|
1092
|
-
get(id) {
|
|
1093
|
-
const defaults = this.#defaults.get(id) ?? [];
|
|
1094
|
-
const override = this.#overrides.get(id);
|
|
1095
|
-
if (!override) return defaults;
|
|
1096
|
-
if (override.kind === "replace") return override.bindings;
|
|
1097
|
-
return [...defaults, ...without(override.bindings, defaults)];
|
|
1098
|
-
}
|
|
1099
|
-
/** Replaces a command's bindings. Passing `null` unbinds it. */
|
|
1100
|
-
set(id, input) {
|
|
1101
|
-
this.load();
|
|
1102
|
-
const bindings = input === null ? [] : toBindings(input);
|
|
1103
|
-
if (sameBindings(bindings, this.#defaults.get(id) ?? [])) {
|
|
1104
|
-
if (!this.#overrides.delete(id)) return;
|
|
1105
|
-
} else this.#overrides.set(id, {
|
|
1106
|
-
kind: "replace",
|
|
1107
|
-
bindings
|
|
1108
|
-
});
|
|
1109
|
-
this.#changed();
|
|
1110
|
-
}
|
|
1111
|
-
/** Adds an additional binding to a command. */
|
|
1112
|
-
add(id, input) {
|
|
1113
|
-
this.load();
|
|
1114
|
-
const added = without(toBindings(input), this.get(id));
|
|
1115
|
-
if (added.length === 0) return;
|
|
1116
|
-
const override = this.#overrides.get(id);
|
|
1117
|
-
this.#overrides.set(id, {
|
|
1118
|
-
kind: override?.kind ?? "add",
|
|
1119
|
-
bindings: [...override?.bindings ?? [], ...added]
|
|
1120
|
-
});
|
|
1121
|
-
this.#changed();
|
|
1122
|
-
}
|
|
1123
|
-
/** Resets user overrides to defaults for one command or all commands. */
|
|
1124
|
-
reset(id) {
|
|
1125
|
-
this.load();
|
|
1126
|
-
if (id === void 0) {
|
|
1127
|
-
if (this.#overrides.size === 0) return;
|
|
1128
|
-
this.#overrides.clear();
|
|
1129
|
-
} else if (!this.#overrides.delete(id)) return;
|
|
1130
|
-
this.#changed();
|
|
1131
|
-
}
|
|
1132
|
-
/** Exports user overrides as a serialized keymap object. */
|
|
1133
|
-
export() {
|
|
1134
|
-
return {
|
|
1135
|
-
version: KEYMAP_VERSION,
|
|
1136
|
-
overrides: Object.fromEntries(Array.from(this.#overrides, ([id, override]) => [id, serializeOverride(override)]))
|
|
1137
|
-
};
|
|
1138
|
-
}
|
|
1139
|
-
/** Imports serialized user overrides and saves to storage. */
|
|
1140
|
-
import(data) {
|
|
1141
|
-
if (!this.#read(data)) return;
|
|
1142
|
-
this.#loaded = true;
|
|
1143
|
-
this.#changed();
|
|
1144
|
-
}
|
|
1145
|
-
/**
|
|
1146
|
-
* Loads persisted overrides from storage, once; returns whether that
|
|
1147
|
-
* changed anything (subscribers have then been told). The registry calls
|
|
1148
|
-
* this on its first mount. `set`, `add` and `reset` call it too, so an
|
|
1149
|
-
* edit made before mount lands on top of the stored overrides instead of
|
|
1150
|
-
* writing over them.
|
|
1151
|
-
*/
|
|
1152
|
-
load() {
|
|
1153
|
-
return !this.#loaded && this.#sync();
|
|
1154
|
-
}
|
|
1155
|
-
/** Reads the stored overrides, telling subscribers if they differ from the ones in memory. */
|
|
1156
|
-
#sync() {
|
|
1157
|
-
this.#loaded = true;
|
|
1158
|
-
const storage = this.#storage();
|
|
1159
|
-
if (!storage) return false;
|
|
1160
|
-
const before = JSON.stringify(this.export());
|
|
1161
|
-
try {
|
|
1162
|
-
const raw = storage.getItem(this.#storageKey());
|
|
1163
|
-
if (raw) this.#read(JSON.parse(raw));
|
|
1164
|
-
else this.#overrides.clear();
|
|
1165
|
-
} catch {
|
|
1166
|
-
return false;
|
|
1167
|
-
}
|
|
1168
|
-
if (JSON.stringify(this.export()) === before) return false;
|
|
1169
|
-
this.#notify();
|
|
1170
|
-
return true;
|
|
1171
|
-
}
|
|
1172
|
-
/**
|
|
1173
|
-
* Follows edits other tabs make to the stored overrides, so rebinding a
|
|
1174
|
-
* key in one tab reaches the rest instead of being overwritten by the next
|
|
1175
|
-
* stale tab to save. Returns a function that stops watching.
|
|
1176
|
-
*/
|
|
1177
|
-
watch() {
|
|
1178
|
-
if (!this.#persistence || typeof window === "undefined") return () => {};
|
|
1179
|
-
const onStorage = (event) => {
|
|
1180
|
-
if (event.key !== null && event.key !== this.#storageKey()) return;
|
|
1181
|
-
this.#sync();
|
|
1182
|
-
};
|
|
1183
|
-
window.addEventListener("storage", onStorage);
|
|
1184
|
-
return () => window.removeEventListener("storage", onStorage);
|
|
1185
|
-
}
|
|
1186
|
-
/** Subscribes to keymap changes. Returns an unsubscribe function. */
|
|
1187
|
-
subscribe(listener) {
|
|
1188
|
-
this.#listeners.add(listener);
|
|
1189
|
-
return () => {
|
|
1190
|
-
this.#listeners.delete(listener);
|
|
1191
|
-
};
|
|
1192
|
-
}
|
|
1193
|
-
/** Replaces the overrides with stored ones. `false` for data this release cannot read. */
|
|
1194
|
-
#read(data) {
|
|
1195
|
-
const entries = storedOverrides(data);
|
|
1196
|
-
if (!entries) return false;
|
|
1197
|
-
this.#overrides.clear();
|
|
1198
|
-
for (const [id, value] of Object.entries(entries)) {
|
|
1199
|
-
const override = deserializeOverride(value);
|
|
1200
|
-
if (override) this.#overrides.set(id, override);
|
|
1201
|
-
}
|
|
1202
|
-
return true;
|
|
1203
|
-
}
|
|
1204
|
-
#changed() {
|
|
1205
|
-
this.#persist();
|
|
1206
|
-
this.#notify();
|
|
1207
|
-
}
|
|
1208
|
-
#notify() {
|
|
1209
|
-
for (const listener of this.#listeners) listener();
|
|
1210
|
-
}
|
|
1211
|
-
#persist() {
|
|
1212
|
-
const storage = this.#storage();
|
|
1213
|
-
if (!storage) return;
|
|
1214
|
-
try {
|
|
1215
|
-
if (this.#overrides.size === 0) storage.removeItem(this.#storageKey());
|
|
1216
|
-
else storage.setItem(this.#storageKey(), JSON.stringify(this.export()));
|
|
1217
|
-
} catch {}
|
|
1218
|
-
}
|
|
1219
|
-
#storageKey() {
|
|
1220
|
-
return this.#persistence?.key ?? DEFAULT_STORAGE_KEY;
|
|
1221
|
-
}
|
|
1222
|
-
#storage() {
|
|
1223
|
-
try {
|
|
1224
|
-
return this.#persistence?.storage() ?? null;
|
|
1225
|
-
} catch {
|
|
1226
|
-
return null;
|
|
1227
|
-
}
|
|
1228
|
-
}
|
|
1229
|
-
};
|
|
1230
|
-
/** `bindings` minus any already in `existing` or repeated, compared across platforms. */
|
|
1231
|
-
function without(bindings, existing) {
|
|
1232
|
-
const known = new Set(existing.map(portableSignature));
|
|
1233
|
-
return bindings.filter((binding) => {
|
|
1234
|
-
const signature = portableSignature(binding);
|
|
1235
|
-
if (known.has(signature)) return false;
|
|
1236
|
-
known.add(signature);
|
|
1237
|
-
return true;
|
|
1238
|
-
});
|
|
1239
|
-
}
|
|
1240
|
-
/** Same bindings in the same order; the first is the one shortcuts display. */
|
|
1241
|
-
function sameBindings(a, b) {
|
|
1242
|
-
return a.length === b.length && a.every((binding, index) => {
|
|
1243
|
-
const other = b[index];
|
|
1244
|
-
return other !== void 0 && portableSignature(binding) === portableSignature(other);
|
|
1245
|
-
});
|
|
1246
|
-
}
|
|
1247
|
-
/** The overrides in stored data, or `null` if this release cannot read it. */
|
|
1248
|
-
function storedOverrides(data) {
|
|
1249
|
-
if (!isJsonObject(data)) return null;
|
|
1250
|
-
if (typeof data.version !== "number") return data;
|
|
1251
|
-
if (data.version !== KEYMAP_VERSION) return null;
|
|
1252
|
-
const overrides = data.overrides;
|
|
1253
|
-
return overrides !== void 0 && isJsonObject(overrides) ? overrides : null;
|
|
1254
|
-
}
|
|
1255
|
-
function serializeOverride(override) {
|
|
1256
|
-
const bindings = override.bindings.map(serializeBinding);
|
|
1257
|
-
return override.kind === "replace" ? bindings : { add: bindings };
|
|
1258
|
-
}
|
|
1259
|
-
function deserializeOverride(value) {
|
|
1260
|
-
if (Array.isArray(value)) {
|
|
1261
|
-
const bindings = deserializeBindings(value);
|
|
1262
|
-
return bindings && {
|
|
1263
|
-
kind: "replace",
|
|
1264
|
-
bindings
|
|
1265
|
-
};
|
|
1266
|
-
}
|
|
1267
|
-
if (isJsonObject(value) && Array.isArray(value.add)) {
|
|
1268
|
-
const bindings = deserializeBindings(value.add);
|
|
1269
|
-
return bindings && bindings.length > 0 ? {
|
|
1270
|
-
kind: "add",
|
|
1271
|
-
bindings
|
|
1272
|
-
} : null;
|
|
1273
|
-
}
|
|
1274
|
-
return null;
|
|
1275
|
-
}
|
|
1276
|
-
/**
|
|
1277
|
-
* `[]` is how an unbound command is stored. A non-empty list with nothing
|
|
1278
|
-
* usable in it is damage — hand edits, another version's format — and
|
|
1279
|
-
* reading it as "unbound" would silently strip the command of its default
|
|
1280
|
-
* shortcut, so it reads as `null` instead.
|
|
1281
|
-
*/
|
|
1282
|
-
function deserializeBindings(values) {
|
|
1283
|
-
const bindings = values.map(deserializeBinding).filter((binding) => binding !== null);
|
|
1284
|
-
return bindings.length === 0 && values.length > 0 ? null : bindings;
|
|
1285
|
-
}
|
|
1286
|
-
//#endregion
|
|
1287
|
-
//#region src/registry.ts
|
|
1288
|
-
/** Command registry: what an app can do, which keys do it, and where each is live. */
|
|
1289
|
-
const SSR_PLATFORM = "windows";
|
|
1290
|
-
var CommandRegistry = class {
|
|
1291
|
-
#definitions;
|
|
1292
|
-
/** Handlers attached per command, the most recent last. */
|
|
1293
|
-
#handlers = /* @__PURE__ */ new Map();
|
|
1294
|
-
#keymap;
|
|
1295
|
-
#scopes = new ScopeStack();
|
|
1296
|
-
#keys;
|
|
1297
|
-
#options;
|
|
1298
|
-
#listeners = /* @__PURE__ */ new Set();
|
|
1299
|
-
#snapshot = null;
|
|
1300
|
-
#serverSnapshot = null;
|
|
1301
|
-
#pending = null;
|
|
1302
|
-
/** Stops following other tabs' keymap edits; set while mounted. */
|
|
1303
|
-
#unwatch = null;
|
|
1304
|
-
#mounts = 0;
|
|
1305
|
-
#hydrated = false;
|
|
1306
|
-
#singleKeys;
|
|
1307
|
-
constructor(definitions, options = {}) {
|
|
1308
|
-
this.#options = options;
|
|
1309
|
-
this.#singleKeys = options.singleKeyShortcuts !== false;
|
|
1310
|
-
this.#keymap = new Keymap(options.persist);
|
|
1311
|
-
this.#definitions = /* @__PURE__ */ new Map();
|
|
1312
|
-
for (const id in definitions) {
|
|
1313
|
-
const definition = definitions[id];
|
|
1314
|
-
this.#definitions.set(id, definition);
|
|
1315
|
-
this.#keymap.setDefault(id, this.#defaultKeys(id, definition));
|
|
1316
|
-
}
|
|
1317
|
-
this.#keys = new KeyListener({
|
|
1318
|
-
ids: () => [...this.#definitions.keys()],
|
|
1319
|
-
definition: (id) => this.#definitions.get(id),
|
|
1320
|
-
bindings: (id) => this.#bindings(id),
|
|
1321
|
-
reachable: (id) => this.#reachable(id),
|
|
1322
|
-
depth: (id) => this.#scopes.depth(this.#definitions.get(id)?.scope),
|
|
1323
|
-
platform: () => this.#platform(),
|
|
1324
|
-
run: (id, event, binding) => this.#execute(id, {
|
|
1325
|
-
source: "keyboard",
|
|
1326
|
-
event,
|
|
1327
|
-
binding
|
|
1328
|
-
})
|
|
1329
|
-
}, options.target);
|
|
1330
|
-
this.#keymap.subscribe(() => this.#bindingsChanged());
|
|
1331
|
-
this.#scopes.subscribe(() => this.#reachabilityChanged());
|
|
1332
|
-
if (process.env.NODE_ENV !== "production") auditRegistry({
|
|
1333
|
-
ids: [...this.#definitions.keys()],
|
|
1334
|
-
scope: (id) => this.#definitions.get(id)?.scope,
|
|
1335
|
-
bindings: (id) => this.#bindings(id),
|
|
1336
|
-
declared: (id) => this.#keymap.get(id),
|
|
1337
|
-
overrides: options.bindings,
|
|
1338
|
-
platform: this.#platform(),
|
|
1339
|
-
display: this.#displayOptions()
|
|
1340
|
-
});
|
|
1341
|
-
}
|
|
1342
|
-
get keymap() {
|
|
1343
|
-
return this.#keymap;
|
|
1344
|
-
}
|
|
1345
|
-
get(id) {
|
|
1346
|
-
return this.#snapshots().index.get(id);
|
|
1347
|
-
}
|
|
1348
|
-
list() {
|
|
1349
|
-
return this.#snapshots().list;
|
|
1350
|
-
}
|
|
1351
|
-
serverList() {
|
|
1352
|
-
if (this.#serverSnapshot) return this.#serverSnapshot;
|
|
1353
|
-
const platform = this.#options.platform ?? SSR_PLATFORM;
|
|
1354
|
-
const options = {
|
|
1355
|
-
platform,
|
|
1356
|
-
...this.#options.display
|
|
1357
|
-
};
|
|
1358
|
-
const singleKeys = this.#options.singleKeyShortcuts !== false;
|
|
1359
|
-
this.#serverSnapshot = Array.from(this.#definitions, ([id, definition]) => {
|
|
1360
|
-
const bindings = toBindings(this.#defaultKeys(id, definition)).filter((binding) => singleKeys || !isSingleKeyBinding(binding));
|
|
1361
|
-
const global = definition.scope === void 0 || definition.scope === "*";
|
|
1362
|
-
return describeCommand(id, definition, bindings, platform, options, {
|
|
1363
|
-
reachable: definition.run !== void 0 && global,
|
|
1364
|
-
overridden: false
|
|
1365
|
-
});
|
|
1366
|
-
});
|
|
1367
|
-
return this.#serverSnapshot;
|
|
1368
|
-
}
|
|
1369
|
-
run(id, options = {}) {
|
|
1370
|
-
if (this.whyNotRun(id) !== null) return false;
|
|
1371
|
-
this.#execute(id, {
|
|
1372
|
-
source: options.source ?? "api",
|
|
1373
|
-
event: options.event
|
|
1374
|
-
});
|
|
1375
|
-
return true;
|
|
1376
|
-
}
|
|
1377
|
-
canRun(id) {
|
|
1378
|
-
return this.whyNotRun(id) === null;
|
|
1379
|
-
}
|
|
1380
|
-
whyNotRun(id) {
|
|
1381
|
-
const definition = this.#definitions.get(id);
|
|
1382
|
-
if (!definition) return "unknown";
|
|
1383
|
-
if (!this.#handler(id)) return "unhandled";
|
|
1384
|
-
return this.#scopes.isOpen(definition.scope) ? null : "out-of-scope";
|
|
1385
|
-
}
|
|
1386
|
-
handle(id, run) {
|
|
1387
|
-
if (!this.#definitions.has(id)) return () => {};
|
|
1388
|
-
let handlers = this.#handlers.get(id);
|
|
1389
|
-
if (!handlers) {
|
|
1390
|
-
handlers = [];
|
|
1391
|
-
this.#handlers.set(id, handlers);
|
|
1392
|
-
}
|
|
1393
|
-
const had = this.#handler(id) !== void 0;
|
|
1394
|
-
handlers.push(run);
|
|
1395
|
-
if (!had) this.#reachabilityChanged();
|
|
1396
|
-
let disposed = false;
|
|
1397
|
-
return () => {
|
|
1398
|
-
if (disposed) return;
|
|
1399
|
-
disposed = true;
|
|
1400
|
-
const index = handlers.lastIndexOf(run);
|
|
1401
|
-
if (index !== -1) handlers.splice(index, 1);
|
|
1402
|
-
if (this.#handler(id) === void 0) this.#reachabilityChanged();
|
|
1403
|
-
};
|
|
1404
|
-
}
|
|
1405
|
-
pushScope(name, options) {
|
|
1406
|
-
return this.#scopes.push(name, options);
|
|
1407
|
-
}
|
|
1408
|
-
capture(onKey) {
|
|
1409
|
-
const release = this.#keys.capture(onKey);
|
|
1410
|
-
this.#reachabilityChanged();
|
|
1411
|
-
let released = false;
|
|
1412
|
-
return () => {
|
|
1413
|
-
if (released) return;
|
|
1414
|
-
released = true;
|
|
1415
|
-
release();
|
|
1416
|
-
this.#reachabilityChanged();
|
|
1417
|
-
};
|
|
1418
|
-
}
|
|
1419
|
-
conflicts(binding, options = {}) {
|
|
1420
|
-
const platform = this.#platform();
|
|
1421
|
-
const candidates = toBindings(binding);
|
|
1422
|
-
const signatures = new Set(candidates.map((entry) => bindingSignature(entry, platform)));
|
|
1423
|
-
const conflicts = [];
|
|
1424
|
-
for (const [id, { scope }] of this.#definitions) {
|
|
1425
|
-
if (id === options.ignore || !scopesOverlap(scope, options.scope)) continue;
|
|
1426
|
-
for (const existing of this.#bindings(id)) {
|
|
1427
|
-
const kind = signatures.has(bindingSignature(existing, platform)) ? "exact" : candidates.some((candidate) => isPrefixCollision(candidate, existing, platform)) ? "prefix" : null;
|
|
1428
|
-
if (kind === null) continue;
|
|
1429
|
-
const display = formatBinding(existing, this.#displayOptions());
|
|
1430
|
-
conflicts.push({
|
|
1431
|
-
id,
|
|
1432
|
-
binding: existing,
|
|
1433
|
-
kind,
|
|
1434
|
-
display,
|
|
1435
|
-
scope
|
|
1436
|
-
});
|
|
1437
|
-
}
|
|
1438
|
-
}
|
|
1439
|
-
return conflicts;
|
|
1440
|
-
}
|
|
1441
|
-
pending() {
|
|
1442
|
-
const from = this.#keys.progress();
|
|
1443
|
-
if (this.#pending?.from !== from) {
|
|
1444
|
-
const options = this.#displayOptions();
|
|
1445
|
-
this.#pending = {
|
|
1446
|
-
from,
|
|
1447
|
-
list: from.map(({ id, binding, matched }) => ({
|
|
1448
|
-
id,
|
|
1449
|
-
binding,
|
|
1450
|
-
matched,
|
|
1451
|
-
total: binding.sequence.length,
|
|
1452
|
-
remaining: binding.sequence.slice(matched).map((step) => formatBinding(step, options))
|
|
1453
|
-
}))
|
|
1454
|
-
};
|
|
1455
|
-
}
|
|
1456
|
-
return this.#pending.list;
|
|
1457
|
-
}
|
|
1458
|
-
subscribePending(listener) {
|
|
1459
|
-
return this.#keys.watch(listener);
|
|
1460
|
-
}
|
|
1461
|
-
get singleKeyShortcuts() {
|
|
1462
|
-
return this.#singleKeys;
|
|
1463
|
-
}
|
|
1464
|
-
setSingleKeyShortcuts(enabled) {
|
|
1465
|
-
if (this.#singleKeys === enabled) return;
|
|
1466
|
-
this.#singleKeys = enabled;
|
|
1467
|
-
this.#bindingsChanged();
|
|
1468
|
-
}
|
|
1469
|
-
mount() {
|
|
1470
|
-
this.#mounts++;
|
|
1471
|
-
if (this.#mounts === 1) {
|
|
1472
|
-
this.#hydrated = true;
|
|
1473
|
-
this.#unwatch = this.#keymap.watch();
|
|
1474
|
-
this.#keys.attach();
|
|
1475
|
-
if (!this.#keymap.load()) this.#bindingsChanged();
|
|
1476
|
-
}
|
|
1477
|
-
let released = false;
|
|
1478
|
-
return () => {
|
|
1479
|
-
if (released) return;
|
|
1480
|
-
released = true;
|
|
1481
|
-
if (this.#mounts === 0) return;
|
|
1482
|
-
this.#mounts--;
|
|
1483
|
-
if (this.#mounts === 0) this.#unmount();
|
|
1484
|
-
};
|
|
1485
|
-
}
|
|
1486
|
-
subscribe(listener) {
|
|
1487
|
-
this.#listeners.add(listener);
|
|
1488
|
-
return () => {
|
|
1489
|
-
this.#listeners.delete(listener);
|
|
1490
|
-
};
|
|
1491
|
-
}
|
|
1492
|
-
destroy() {
|
|
1493
|
-
this.#mounts = 0;
|
|
1494
|
-
this.#unwatch?.();
|
|
1495
|
-
this.#unwatch = null;
|
|
1496
|
-
this.#keys.destroy();
|
|
1497
|
-
this.#definitions.clear();
|
|
1498
|
-
this.#handlers.clear();
|
|
1499
|
-
this.#serverSnapshot = null;
|
|
1500
|
-
this.#invalidate();
|
|
1501
|
-
this.#listeners.clear();
|
|
1502
|
-
this.#scopes.clear();
|
|
1503
|
-
}
|
|
1504
|
-
#unmount() {
|
|
1505
|
-
this.#keys.detach();
|
|
1506
|
-
this.#unwatch?.();
|
|
1507
|
-
this.#unwatch = null;
|
|
1508
|
-
}
|
|
1509
|
-
/** The handler that runs: the one attached last, else the definition's own. */
|
|
1510
|
-
#handler(id) {
|
|
1511
|
-
return this.#handlers.get(id)?.at(-1) ?? this.#definitions.get(id)?.run;
|
|
1512
|
-
}
|
|
1513
|
-
#reachable(id) {
|
|
1514
|
-
if (this.#keys.captured || !this.#handler(id)) return false;
|
|
1515
|
-
return this.#scopes.isActive(this.#definitions.get(id)?.scope);
|
|
1516
|
-
}
|
|
1517
|
-
/**
|
|
1518
|
-
* Bindings in effect: the keymap's, minus single-key ones while those are
|
|
1519
|
-
* switched off. Everything that binds, displays or compares keys reads
|
|
1520
|
-
* this, so a suppressed shortcut is not advertised either.
|
|
1521
|
-
*/
|
|
1522
|
-
#bindings(id) {
|
|
1523
|
-
const bindings = this.#keymap.get(id);
|
|
1524
|
-
if (this.#singleKeys) return bindings;
|
|
1525
|
-
return bindings.filter((binding) => !isSingleKeyBinding(binding));
|
|
1526
|
-
}
|
|
1527
|
-
/**
|
|
1528
|
-
* A command's default keys: the app's `options.bindings` entry if it has
|
|
1529
|
-
* one, else the definition's own. `hasOwn`, not `??`: a `null` entry
|
|
1530
|
-
* deliberately ships the command unbound, and `??` would read that as
|
|
1531
|
-
* "absent" and restore the very default the app asked to drop.
|
|
1532
|
-
*/
|
|
1533
|
-
#defaultKeys(id, definition) {
|
|
1534
|
-
const overrides = this.#options.bindings;
|
|
1535
|
-
return overrides && Object.hasOwn(overrides, id) ? overrides[id] : definition.keys;
|
|
1536
|
-
}
|
|
1537
|
-
#execute(id, context) {
|
|
1538
|
-
const run = this.#handler(id);
|
|
1539
|
-
if (!run) return;
|
|
1540
|
-
const full = {
|
|
1541
|
-
id,
|
|
1542
|
-
...context,
|
|
1543
|
-
commands: this
|
|
1544
|
-
};
|
|
1545
|
-
if (this.#options.onRun) try {
|
|
1546
|
-
this.#options.onRun(full);
|
|
1547
|
-
} catch (error) {
|
|
1548
|
-
this.#reportError(toError(error), full);
|
|
1549
|
-
}
|
|
1550
|
-
try {
|
|
1551
|
-
const result = run(full);
|
|
1552
|
-
if (result instanceof Promise) result.catch((cause) => this.#reportError(toError(cause), full));
|
|
1553
|
-
} catch (error) {
|
|
1554
|
-
this.#reportError(toError(error), full);
|
|
1555
|
-
}
|
|
1556
|
-
}
|
|
1557
|
-
#reportError(error, context) {
|
|
1558
|
-
if (this.#options.onError) {
|
|
1559
|
-
this.#options.onError(error, context);
|
|
1560
|
-
return;
|
|
1561
|
-
}
|
|
1562
|
-
console.error(`[@inklu/keys] command "${context.id}" failed`, error);
|
|
1563
|
-
}
|
|
1564
|
-
#bindingsChanged() {
|
|
1565
|
-
this.#keys.refresh();
|
|
1566
|
-
this.#invalidate();
|
|
1567
|
-
}
|
|
1568
|
-
#reachabilityChanged() {
|
|
1569
|
-
this.#keys.prune();
|
|
1570
|
-
this.#invalidate();
|
|
1571
|
-
}
|
|
1572
|
-
#platform() {
|
|
1573
|
-
return this.#options.platform ?? (this.#hydrated ? detectPlatform() : SSR_PLATFORM);
|
|
1574
|
-
}
|
|
1575
|
-
#displayOptions() {
|
|
1576
|
-
return {
|
|
1577
|
-
platform: this.#platform(),
|
|
1578
|
-
...this.#options.display
|
|
1579
|
-
};
|
|
1580
|
-
}
|
|
1581
|
-
#snapshots() {
|
|
1582
|
-
if (this.#snapshot) return this.#snapshot;
|
|
1583
|
-
const platform = this.#platform();
|
|
1584
|
-
const options = this.#displayOptions();
|
|
1585
|
-
const list = [];
|
|
1586
|
-
const index = /* @__PURE__ */ new Map();
|
|
1587
|
-
for (const [id, definition] of this.#definitions) {
|
|
1588
|
-
const command = describeCommand(id, definition, this.#bindings(id), platform, options, {
|
|
1589
|
-
reachable: this.#reachable(id),
|
|
1590
|
-
overridden: this.#keymap.isOverridden(id)
|
|
1591
|
-
});
|
|
1592
|
-
list.push(command);
|
|
1593
|
-
index.set(id, command);
|
|
1594
|
-
}
|
|
1595
|
-
this.#snapshot = {
|
|
1596
|
-
list,
|
|
1597
|
-
index
|
|
1598
|
-
};
|
|
1599
|
-
return this.#snapshot;
|
|
1600
|
-
}
|
|
1601
|
-
#invalidate() {
|
|
1602
|
-
this.#snapshot = null;
|
|
1603
|
-
for (const listener of this.#listeners) listener();
|
|
1604
|
-
}
|
|
1605
|
-
};
|
|
1606
|
-
function describeCommand(id, definition, source, platform, options, state) {
|
|
1607
|
-
const bindings = [...source];
|
|
1608
|
-
return {
|
|
1609
|
-
id,
|
|
1610
|
-
title: definition.title,
|
|
1611
|
-
description: definition.description,
|
|
1612
|
-
group: definition.group,
|
|
1613
|
-
keywords: definition.keywords ?? [],
|
|
1614
|
-
hidden: definition.hidden === true,
|
|
1615
|
-
scope: definition.scope,
|
|
1616
|
-
bindings,
|
|
1617
|
-
display: bindings.map((binding) => formatBinding(binding, options)),
|
|
1618
|
-
tokens: bindings.map((binding) => bindingTokens(binding, options)),
|
|
1619
|
-
aria: bindings.map((binding) => ariaKeyShortcuts(binding, platform)).filter((value) => value.length > 0).join(" "),
|
|
1620
|
-
spoken: bindings.map((binding) => spokenBinding(binding, platform)).join(" or "),
|
|
1621
|
-
...state
|
|
1622
|
-
};
|
|
1623
|
-
}
|
|
1624
|
-
/** Normalizes an arbitrary thrown value into a real `Error` at the catch boundary. */
|
|
1625
|
-
function toError(cause) {
|
|
1626
|
-
return cause instanceof Error ? cause : new Error(String(cause));
|
|
1627
|
-
}
|
|
1628
|
-
/**
|
|
1629
|
-
* Creates a command registry.
|
|
1630
|
-
*
|
|
1631
|
-
* @example
|
|
1632
|
-
* ```ts
|
|
1633
|
-
* export const commands = createCommands({
|
|
1634
|
-
* "palette.open": { title: "Open command palette", keys: "Mod+K" },
|
|
1635
|
-
* "nav.inbox": {
|
|
1636
|
-
* title: "Go to inbox",
|
|
1637
|
-
* keys: seq("G", "I"),
|
|
1638
|
-
* run: () => router.navigate("/inbox"),
|
|
1639
|
-
* },
|
|
1640
|
-
* });
|
|
1641
|
-
* ```
|
|
1642
|
-
*/
|
|
1643
|
-
function createCommands(definitions, options) {
|
|
1644
|
-
return new CommandRegistry(definitions, options);
|
|
1645
|
-
}
|
|
1646
|
-
//#endregion
|
|
1647
|
-
//#region src/search.ts
|
|
1648
|
-
/**
|
|
1649
|
-
* Filters commands for a search query, as a command palette does, leaving
|
|
1650
|
-
* out `hidden` ones. Every word of the query must appear somewhere in the
|
|
1651
|
-
* command — title, description, group, keywords, id or displayed shortcut —
|
|
1652
|
-
* in any order. Title matches come first: titles starting with the query,
|
|
1653
|
-
* then titles containing every word, then the rest; each tier keeps the
|
|
1654
|
-
* list's own order. An empty query returns every visible command.
|
|
1655
|
-
*
|
|
1656
|
-
* @example
|
|
1657
|
-
* ```ts
|
|
1658
|
-
* const results = matchCommands(commands.list(), "go inbox");
|
|
1659
|
-
* ```
|
|
1660
|
-
*/
|
|
1661
|
-
function matchCommands(commands, query) {
|
|
1662
|
-
const visible = commands.filter((command) => !command.hidden);
|
|
1663
|
-
const needle = query.trim().toLowerCase();
|
|
1664
|
-
if (!needle) return visible;
|
|
1665
|
-
const terms = needle.split(/\s+/);
|
|
1666
|
-
const ranked = [];
|
|
1667
|
-
for (const command of visible) {
|
|
1668
|
-
const title = command.title.toLowerCase();
|
|
1669
|
-
const text = [
|
|
1670
|
-
title,
|
|
1671
|
-
command.description,
|
|
1672
|
-
command.group,
|
|
1673
|
-
command.id,
|
|
1674
|
-
...command.keywords,
|
|
1675
|
-
...command.display
|
|
1676
|
-
].join("\n").toLowerCase();
|
|
1677
|
-
if (!terms.every((term) => text.includes(term))) continue;
|
|
1678
|
-
const rank = title.startsWith(needle) ? 0 : terms.every((term) => title.includes(term)) ? 1 : 2;
|
|
1679
|
-
ranked.push({
|
|
1680
|
-
command,
|
|
1681
|
-
rank
|
|
1682
|
-
});
|
|
1683
|
-
}
|
|
1684
|
-
return ranked.sort((a, b) => a.rank - b.rank).map(({ command }) => command);
|
|
1685
|
-
}
|
|
1686
|
-
//#endregion
|
|
1687
|
-
export { bindingTokens as a, isSingleKeyBinding as c, isModifierKey as d, keyName as f, ariaKeyShortcuts as i, seq as l, createCommands as n, formatBinding as o, createRebinder as r, spokenBinding as s, matchCommands as t, validateBinding as u };
|