@inklu/keys 2.2.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 +67 -213
- package/dist/attributes.d.mts +1 -1
- package/dist/{index-DRXMJmbc.d.mts → index-BnB38jCF.d.mts} +20 -54
- package/dist/index.d.mts +3 -3
- package/dist/index.mjs +2 -2
- package/dist/react.d.mts +7 -12
- package/dist/react.mjs +9 -90
- package/dist/{src-TaZw4V61.mjs → src-CGKvYKjd.mjs} +331 -795
- package/dist/{types-D-hUKGoN.d.mts → types-BxMI3b4a.d.mts} +16 -117
- package/package.json +2 -3
|
@@ -48,7 +48,6 @@ const MODIFIERS = [
|
|
|
48
48
|
"Shift",
|
|
49
49
|
"Meta"
|
|
50
50
|
];
|
|
51
|
-
const PHYSICAL = /^\[([A-Za-z][A-Za-z0-9]*)\]$/;
|
|
52
51
|
const FUNCTION_KEY = /^F([1-9]|1\d|2[0-4])$/;
|
|
53
52
|
const LETTER = /^\p{Letter}$/u;
|
|
54
53
|
const LATIN_LETTER = /^\p{Script_Extensions=Latin}$/u;
|
|
@@ -60,7 +59,7 @@ function split(chord) {
|
|
|
60
59
|
return parts.map((part) => part.trim());
|
|
61
60
|
}
|
|
62
61
|
/** One spelling per key: letters upper-cased, the space bar by name. */
|
|
63
|
-
function keyName
|
|
62
|
+
function keyName(key) {
|
|
64
63
|
if (key === " ") return "Space";
|
|
65
64
|
if (!LETTER.test(key)) return key;
|
|
66
65
|
const upper = key.toUpperCase();
|
|
@@ -68,11 +67,21 @@ function keyName$1(key) {
|
|
|
68
67
|
}
|
|
69
68
|
/** The key a keypress produced. Empty for the keyless events browser autofill dispatches. */
|
|
70
69
|
function pressedKey(event) {
|
|
71
|
-
return keyName
|
|
72
|
-
}
|
|
73
|
-
function isOneCharacter(key) {
|
|
74
|
-
return Array.from(key).length === 1;
|
|
70
|
+
return keyName(String(event.key ?? "").normalize("NFC"));
|
|
75
71
|
}
|
|
72
|
+
const isOneCharacter = (key) => Array.from(key).length === 1;
|
|
73
|
+
const isAscii = (key) => /^[\x21-\x7e]$/.test(key);
|
|
74
|
+
/** A one-character key that is not a letter: a digit or a symbol. */
|
|
75
|
+
const isSymbol = (key) => isOneCharacter(key) && !LETTER.test(key);
|
|
76
|
+
/** `KeyboardEvent.key` values that name neither a character nor a key. */
|
|
77
|
+
const NO_KEY = /* @__PURE__ */ new Set([
|
|
78
|
+
"",
|
|
79
|
+
"Dead",
|
|
80
|
+
"Unidentified",
|
|
81
|
+
"Process"
|
|
82
|
+
]);
|
|
83
|
+
/** A key known by name rather than by a character, such as `Enter`. */
|
|
84
|
+
const isNamed = (key) => !isOneCharacter(key) && !NO_KEY.has(key);
|
|
76
85
|
/** Reads a chord, or says why it cannot be read. */
|
|
77
86
|
function read(chord, platform) {
|
|
78
87
|
if (chord.trim() === "") return "Chord cannot be empty";
|
|
@@ -88,14 +97,12 @@ function read(chord, platform) {
|
|
|
88
97
|
if (held.has(modifier)) return `Repeated modifier: '${part}'`;
|
|
89
98
|
held.add(modifier);
|
|
90
99
|
}
|
|
91
|
-
const code = PHYSICAL.exec(key)?.[1];
|
|
92
|
-
if (code === void 0 && key.length > 1 && (key.startsWith("[") || key.endsWith("]"))) return "Invalid physical key: use a bracketed KeyboardEvent.code, such as [KeyS]";
|
|
93
100
|
return {
|
|
94
101
|
ctrl: held.has("Control"),
|
|
95
102
|
alt: held.has("Alt"),
|
|
96
103
|
shift: held.has("Shift"),
|
|
97
104
|
meta: held.has("Meta"),
|
|
98
|
-
|
|
105
|
+
key: keyName(key)
|
|
99
106
|
};
|
|
100
107
|
}
|
|
101
108
|
/** Parses a chord for a platform, or returns `null` for one that cannot be read. */
|
|
@@ -114,10 +121,14 @@ function validateChord(chord) {
|
|
|
114
121
|
errors: [result],
|
|
115
122
|
warnings: []
|
|
116
123
|
};
|
|
117
|
-
const key = result
|
|
124
|
+
const { key } = result;
|
|
125
|
+
const known = isOneCharacter(key) || FUNCTION_KEY.test(key) || NAMED_KEYS.some((name) => name === key);
|
|
126
|
+
const warnings = [];
|
|
127
|
+
if (!known) warnings.push(`Unknown key: '${key}'. Key names are KeyboardEvent.key values, such as Escape.`);
|
|
128
|
+
if (result.shift && isSymbol(key) && !/\d/.test(key)) warnings.push(`Shift+${key} never fires: write the symbol the key types with Shift held, such as '?'.`);
|
|
118
129
|
return {
|
|
119
130
|
errors: [],
|
|
120
|
-
warnings
|
|
131
|
+
warnings
|
|
121
132
|
};
|
|
122
133
|
}
|
|
123
134
|
/** Writes a parsed chord as text. Given a platform, its primary modifier is written `Mod`. */
|
|
@@ -128,13 +139,12 @@ function writeChord(chord, mod) {
|
|
|
128
139
|
if (chord.alt) parts.push("Alt");
|
|
129
140
|
if (chord.shift) parts.push("Shift");
|
|
130
141
|
if (chord.meta && !portable) parts.push("Meta");
|
|
131
|
-
parts.push(chord.
|
|
142
|
+
parts.push(chord.key);
|
|
132
143
|
return parts.join("+");
|
|
133
144
|
}
|
|
134
145
|
/** Whether a chord types a character: a one-character key with no Control, Alt or Meta. */
|
|
135
146
|
function typesCharacter(chord) {
|
|
136
|
-
|
|
137
|
-
return isOneCharacter(chord.key ?? keyAtCode(chord.code ?? "") ?? "");
|
|
147
|
+
return !chord.ctrl && !chord.alt && !chord.meta && isOneCharacter(chord.key);
|
|
138
148
|
}
|
|
139
149
|
const MODIFIER_KEYS = /* @__PURE__ */ new Set([
|
|
140
150
|
...MODIFIERS,
|
|
@@ -167,68 +177,30 @@ const PUNCTUATION_CODES = {
|
|
|
167
177
|
Semicolon: ";",
|
|
168
178
|
Slash: "/"
|
|
169
179
|
};
|
|
170
|
-
/** The key a `KeyboardEvent.code` sits on in the conventional (US) layout. */
|
|
171
|
-
function keyAtCode(code) {
|
|
172
|
-
if (/^Key[A-Z]$/.test(code)) return code.slice(3);
|
|
173
|
-
if (/^Digit[0-9]$/.test(code)) return code.slice(5);
|
|
174
|
-
return code === "Space" ? code : PUNCTUATION_CODES[code];
|
|
175
|
-
}
|
|
176
|
-
/** A chord with its key by name: a physical key becomes the key at its position, or its `code`. */
|
|
177
|
-
function namedChord(chord) {
|
|
178
|
-
const key = chord.key ?? keyAtCode(chord.code ?? "") ?? chord.code ?? "";
|
|
179
|
-
return {
|
|
180
|
-
...chord,
|
|
181
|
-
key,
|
|
182
|
-
code: void 0
|
|
183
|
-
};
|
|
184
|
-
}
|
|
185
|
-
/** `KeyboardEvent.key` values that name neither a character nor a key. */
|
|
186
|
-
const NO_KEY = /* @__PURE__ */ new Set([
|
|
187
|
-
"",
|
|
188
|
-
"Dead",
|
|
189
|
-
"Unidentified",
|
|
190
|
-
"Process"
|
|
191
|
-
]);
|
|
192
|
-
const isAscii = (key) => /^[\x21-\x7e]$/.test(key);
|
|
193
|
-
/** A one-character key that is not a letter: a digit or a symbol. */
|
|
194
|
-
const isSymbol = (key) => isOneCharacter(key) && !LETTER.test(key);
|
|
195
|
-
/** A key known by name rather than by a character, such as `Enter`. */
|
|
196
|
-
const isNamed = (key) => !isOneCharacter(key) && !NO_KEY.has(key);
|
|
197
180
|
/**
|
|
198
|
-
* The key at a keypress's position on the US layout, where
|
|
199
|
-
*
|
|
200
|
-
*
|
|
201
|
-
*
|
|
202
|
-
* - on a letter key that typed another script, or that macOS Option altered;
|
|
203
|
-
* - on a punctuation key likewise, and while Shift or Alt is held.
|
|
204
|
-
*
|
|
205
|
-
* Never for a Latin letter, a named key, or under AltGr.
|
|
181
|
+
* The key at a keypress's position on the US layout, where that stands in for
|
|
182
|
+
* what the layout typed: on the digit row always (`Mod+1` on AZERTY, whose
|
|
183
|
+
* digit row types symbols), and on a letter or punctuation key when the layout
|
|
184
|
+
* typed no Latin character there (Cyrillic or Greek, or a key macOS Option altered).
|
|
206
185
|
*/
|
|
207
|
-
function positionKey(event, key
|
|
208
|
-
if (altGraph || /^[A-Z]$/.test(key) || isNamed(key)) return void 0;
|
|
186
|
+
function positionKey(event, key) {
|
|
209
187
|
const code = event.code;
|
|
210
|
-
if (/^Digit
|
|
211
|
-
if (LATIN_LETTER.test(key) && !event.altKey) return void 0;
|
|
212
|
-
|
|
213
|
-
return isAscii(key) && !event.shiftKey && !event.altKey ? void 0 : PUNCTUATION_CODES[code];
|
|
188
|
+
if (/^Digit\d$/.test(code)) return code.slice(5);
|
|
189
|
+
if (isAscii(key) || LATIN_LETTER.test(key) && !event.altKey) return void 0;
|
|
190
|
+
return /^Key[A-Z]$/.test(code) ? code.slice(3) : PUNCTUATION_CODES[code];
|
|
214
191
|
}
|
|
215
192
|
/**
|
|
216
|
-
* Whether a keypress is this chord
|
|
217
|
-
* position
|
|
218
|
-
*
|
|
219
|
-
*
|
|
220
|
-
*
|
|
221
|
-
* Follows the matcher in TanStack Hotkeys (MIT).
|
|
193
|
+
* Whether a keypress is this chord: the key it names was typed, or sits at
|
|
194
|
+
* that position where `positionKey` allows, with the same modifiers held. A
|
|
195
|
+
* symbol carries the Shift it took to type (`?` is Shift+/ on a US keyboard),
|
|
196
|
+
* so that Shift need not be named.
|
|
222
197
|
*/
|
|
223
198
|
function matchesChord(event, chord, platform) {
|
|
224
199
|
const altGraph = hasAltGraph(event, platform);
|
|
225
200
|
if (altGraph && (chord.ctrl || chord.alt)) return false;
|
|
226
|
-
const alt = !altGraph && event.altKey;
|
|
227
|
-
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);
|
|
228
|
-
if (chord.code !== void 0) return event.code === chord.code && modifiersMatch(false);
|
|
229
201
|
const key = pressedKey(event);
|
|
230
|
-
|
|
231
|
-
return positionKey(event, key
|
|
202
|
+
const typed = key === chord.key && isSymbol(key);
|
|
203
|
+
return (key === chord.key || !altGraph && positionKey(event, key) === chord.key) && (!altGraph && event.ctrlKey) === chord.ctrl && ((!altGraph && event.altKey) === chord.alt || typed && event.altKey && platform === "mac") && event.metaKey === chord.meta && (event.shiftKey === chord.shift || typed && event.shiftKey);
|
|
232
204
|
}
|
|
233
205
|
/**
|
|
234
206
|
* The chord a keypress expresses, for recording a shortcut, or `null` for a
|
|
@@ -240,14 +212,12 @@ function readChord(event, platform) {
|
|
|
240
212
|
if (isModifierKey(event.key)) return null;
|
|
241
213
|
const pressed = pressedKey(event);
|
|
242
214
|
const altGraph = hasAltGraph(event, platform);
|
|
243
|
-
const
|
|
244
|
-
const key = altGraph || isNamed(pressed) || isAscii(pressed) || LATIN_LETTER.test(pressed) && !event.altKey || position === void 0 ? pressed : position;
|
|
215
|
+
const key = altGraph || isAscii(pressed) || isNamed(pressed) ? pressed : positionKey(event, pressed) ?? pressed;
|
|
245
216
|
if (NO_KEY.has(key)) return null;
|
|
246
217
|
const typed = key === pressed && isSymbol(key);
|
|
247
|
-
const alt = !altGraph && event.altKey;
|
|
248
218
|
const chord = writeChord({
|
|
249
219
|
ctrl: !altGraph && event.ctrlKey,
|
|
250
|
-
alt:
|
|
220
|
+
alt: !altGraph && event.altKey && !(typed && platform === "mac"),
|
|
251
221
|
shift: event.shiftKey && !typed,
|
|
252
222
|
meta: event.metaKey,
|
|
253
223
|
key
|
|
@@ -256,7 +226,7 @@ function readChord(event, platform) {
|
|
|
256
226
|
}
|
|
257
227
|
//#endregion
|
|
258
228
|
//#region src/bindings.ts
|
|
259
|
-
/** Bindings: a chord or a sequence of them. Comparing, validating and
|
|
229
|
+
/** Bindings: a chord or a sequence of them. Comparing, validating and reading per platform. */
|
|
260
230
|
/**
|
|
261
231
|
* Creates a sequence binding from ordered chords.
|
|
262
232
|
*
|
|
@@ -279,7 +249,7 @@ function stepsOf(binding) {
|
|
|
279
249
|
/** Normalizes a binding input into an array of bindings. A one-step sequence becomes a chord. */
|
|
280
250
|
function toBindings(input) {
|
|
281
251
|
if (input == null) return [];
|
|
282
|
-
const candidates =
|
|
252
|
+
const candidates = Array.isArray(input) ? input : [input];
|
|
283
253
|
const bindings = [];
|
|
284
254
|
for (const candidate of candidates) {
|
|
285
255
|
if (candidate == null) continue;
|
|
@@ -290,9 +260,6 @@ function toBindings(input) {
|
|
|
290
260
|
}
|
|
291
261
|
return bindings;
|
|
292
262
|
}
|
|
293
|
-
function isList(input) {
|
|
294
|
-
return Array.isArray(input);
|
|
295
|
-
}
|
|
296
263
|
/** The side of `{ mac, other }` keys that applies on a platform. Keys that do not differ pass through. */
|
|
297
264
|
function keysFor(keys, platform) {
|
|
298
265
|
if (keys == null || !isPerPlatform(keys)) return keys;
|
|
@@ -301,10 +268,9 @@ function keysFor(keys, platform) {
|
|
|
301
268
|
function isPerPlatform(keys) {
|
|
302
269
|
return typeof keys === "object" && !Array.isArray(keys) && !("sequence" in keys);
|
|
303
270
|
}
|
|
304
|
-
/**
|
|
271
|
+
/** One spelling of a binding on a platform, so equivalent bindings compare equal. */
|
|
305
272
|
function bindingSignature(binding, platform) {
|
|
306
|
-
|
|
307
|
-
return isSequence(binding) ? `seq:${steps.join(" ")}` : `chord:${steps.join(" ")}`;
|
|
273
|
+
return canonicalSteps(binding, platform).join(" ");
|
|
308
274
|
}
|
|
309
275
|
/** Whether one binding is a strict prefix of the other, like chord `G` against `G then I`. */
|
|
310
276
|
function isPrefixCollision(a, b, platform) {
|
|
@@ -324,8 +290,8 @@ function parseBinding(binding, platform) {
|
|
|
324
290
|
}
|
|
325
291
|
return steps;
|
|
326
292
|
}
|
|
327
|
-
/** Each step in one spelling
|
|
328
|
-
function canonicalSteps(binding, platform
|
|
293
|
+
/** Each step in one spelling. One that cannot be read stays as written. */
|
|
294
|
+
function canonicalSteps(binding, platform) {
|
|
329
295
|
return stepsOf(binding).map((step) => {
|
|
330
296
|
const parsed = parseChord(step, platform);
|
|
331
297
|
return parsed ? writeChord(parsed) : step;
|
|
@@ -348,20 +314,14 @@ function validateBinding(binding) {
|
|
|
348
314
|
}
|
|
349
315
|
//#endregion
|
|
350
316
|
//#region src/display.ts
|
|
351
|
-
/** Writing a binding for people: on screen, in `aria-keyshortcuts
|
|
317
|
+
/** Writing a binding for people: on screen, and in `aria-keyshortcuts`. */
|
|
352
318
|
const MODIFIER_LABELS = {
|
|
353
|
-
|
|
319
|
+
mac: [
|
|
354
320
|
"⌃",
|
|
355
321
|
"⌥",
|
|
356
322
|
"⇧",
|
|
357
323
|
"⌘"
|
|
358
324
|
],
|
|
359
|
-
mac: [
|
|
360
|
-
"Control",
|
|
361
|
-
"Option",
|
|
362
|
-
"Shift",
|
|
363
|
-
"Cmd"
|
|
364
|
-
],
|
|
365
325
|
other: [
|
|
366
326
|
"Ctrl",
|
|
367
327
|
"Alt",
|
|
@@ -369,181 +329,69 @@ const MODIFIER_LABELS = {
|
|
|
369
329
|
"Meta"
|
|
370
330
|
]
|
|
371
331
|
};
|
|
372
|
-
/**
|
|
373
|
-
const SPOKEN_MODIFIERS = {
|
|
374
|
-
Ctrl: "Control",
|
|
375
|
-
Cmd: "Command"
|
|
376
|
-
};
|
|
332
|
+
/** Named keys as each platform's keyboard prints them: on a Mac, then on Windows and Linux. */
|
|
377
333
|
const KEY_LABELS = {
|
|
378
|
-
Enter: [
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
],
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
],
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
"Del",
|
|
393
|
-
"Forward Delete",
|
|
394
|
-
"Delete"
|
|
395
|
-
],
|
|
396
|
-
Tab: [
|
|
397
|
-
"⇥",
|
|
398
|
-
"Tab",
|
|
399
|
-
"Tab"
|
|
400
|
-
],
|
|
401
|
-
Escape: [
|
|
402
|
-
"⎋",
|
|
403
|
-
"Esc",
|
|
404
|
-
"Escape"
|
|
405
|
-
],
|
|
406
|
-
ArrowUp: [
|
|
407
|
-
"↑",
|
|
408
|
-
"↑",
|
|
409
|
-
"Up Arrow"
|
|
410
|
-
],
|
|
411
|
-
ArrowDown: [
|
|
412
|
-
"↓",
|
|
413
|
-
"↓",
|
|
414
|
-
"Down Arrow"
|
|
415
|
-
],
|
|
416
|
-
ArrowLeft: [
|
|
417
|
-
"←",
|
|
418
|
-
"←",
|
|
419
|
-
"Left Arrow"
|
|
420
|
-
],
|
|
421
|
-
ArrowRight: [
|
|
422
|
-
"→",
|
|
423
|
-
"→",
|
|
424
|
-
"Right Arrow"
|
|
425
|
-
],
|
|
426
|
-
Home: [
|
|
427
|
-
"↖",
|
|
428
|
-
"Home",
|
|
429
|
-
"Home"
|
|
430
|
-
],
|
|
431
|
-
End: [
|
|
432
|
-
"↘",
|
|
433
|
-
"End",
|
|
434
|
-
"End"
|
|
435
|
-
],
|
|
436
|
-
PageUp: [
|
|
437
|
-
"⇞",
|
|
438
|
-
"PgUp",
|
|
439
|
-
"Page Up"
|
|
440
|
-
],
|
|
441
|
-
PageDown: [
|
|
442
|
-
"⇟",
|
|
443
|
-
"PgDn",
|
|
444
|
-
"Page Down"
|
|
445
|
-
],
|
|
446
|
-
"+": [
|
|
447
|
-
"+",
|
|
448
|
-
"Plus",
|
|
449
|
-
"Plus"
|
|
450
|
-
]
|
|
334
|
+
Enter: ["↩", "Enter"],
|
|
335
|
+
Backspace: ["⌫", "Backspace"],
|
|
336
|
+
Delete: ["⌦", "Del"],
|
|
337
|
+
Tab: ["⇥", "Tab"],
|
|
338
|
+
Escape: ["⎋", "Esc"],
|
|
339
|
+
ArrowUp: ["↑", "↑"],
|
|
340
|
+
ArrowDown: ["↓", "↓"],
|
|
341
|
+
ArrowLeft: ["←", "←"],
|
|
342
|
+
ArrowRight: ["→", "→"],
|
|
343
|
+
Home: ["↖", "Home"],
|
|
344
|
+
End: ["↘", "End"],
|
|
345
|
+
PageUp: ["⇞", "PgUp"],
|
|
346
|
+
PageDown: ["⇟", "PgDn"],
|
|
347
|
+
"+": ["+", "Plus"]
|
|
451
348
|
};
|
|
452
|
-
/** Names for the keys whose character is hard to tell apart in running text, or to hear. */
|
|
453
|
-
const PUNCTUATION_NAMES = {
|
|
454
|
-
"`": "Backquote",
|
|
455
|
-
"\\": "Backslash",
|
|
456
|
-
"[": "Left Bracket",
|
|
457
|
-
"]": "Right Bracket",
|
|
458
|
-
",": "Comma",
|
|
459
|
-
"=": "Equal",
|
|
460
|
-
"-": "Minus",
|
|
461
|
-
".": "Period",
|
|
462
|
-
";": "Semicolon",
|
|
463
|
-
"/": "Slash",
|
|
464
|
-
"?": "Question Mark"
|
|
465
|
-
};
|
|
466
|
-
const spaced = (name) => name.replace(/([a-z])([A-Z])/g, "$1 $2");
|
|
467
349
|
/**
|
|
468
350
|
* Formats a binding the way its platform writes shortcuts: `⇧⌘S` on a Mac,
|
|
469
351
|
* `Ctrl+Shift+S` elsewhere. The steps of a sequence are set apart by a space.
|
|
470
352
|
*/
|
|
471
|
-
function formatBinding(binding,
|
|
472
|
-
return stepsOf(binding).map((step) => formatChord(step,
|
|
353
|
+
function formatBinding(binding, platform = detectPlatform()) {
|
|
354
|
+
return stepsOf(binding).map((step) => formatChord(step, platform)).join(" ");
|
|
473
355
|
}
|
|
474
356
|
/**
|
|
475
357
|
* Splits a binding into display tokens for rendering `<kbd>` elements: one
|
|
476
358
|
* per key of a chord, one per step of a sequence.
|
|
477
359
|
*/
|
|
478
|
-
function bindingTokens(binding,
|
|
479
|
-
if (isSequence(binding)) return binding.sequence.map((step) => formatChord(step,
|
|
480
|
-
return chordTokens(binding,
|
|
481
|
-
}
|
|
482
|
-
function formatChord(chord, options) {
|
|
483
|
-
return chordTokens(chord, options).join(usesModifierSymbols(options) ? "" : "+");
|
|
360
|
+
function bindingTokens(binding, platform = detectPlatform()) {
|
|
361
|
+
if (isSequence(binding)) return binding.sequence.map((step) => formatChord(step, platform));
|
|
362
|
+
return chordTokens(binding, platform);
|
|
484
363
|
}
|
|
485
|
-
function
|
|
486
|
-
return (
|
|
364
|
+
function formatChord(chord, platform) {
|
|
365
|
+
return chordTokens(chord, platform).join(platform === "mac" ? "" : "+");
|
|
487
366
|
}
|
|
488
|
-
function chordTokens(chord,
|
|
489
|
-
const platform = options?.platform ?? detectPlatform();
|
|
367
|
+
function chordTokens(chord, platform) {
|
|
490
368
|
const parsed = parseChord(chord, platform);
|
|
491
369
|
if (!parsed) return [chord];
|
|
492
|
-
const labels = MODIFIER_LABELS[usesModifierSymbols(options) ? "symbols" : platform];
|
|
493
|
-
return [...heldLabels(parsed, labels), keyLabel(parsed, platform, options?.useSymbols !== false)];
|
|
494
|
-
}
|
|
495
|
-
function heldLabels(chord, labels) {
|
|
496
370
|
const held = [
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
371
|
+
parsed.ctrl,
|
|
372
|
+
parsed.alt,
|
|
373
|
+
parsed.shift,
|
|
374
|
+
parsed.meta
|
|
501
375
|
];
|
|
502
|
-
return
|
|
503
|
-
}
|
|
504
|
-
function keyLabel(chord, platform, symbols) {
|
|
505
|
-
const mac = platform === "mac";
|
|
506
|
-
const label = (key) => symbols ? KEY_LABELS[key]?.[mac ? 0 : 1] ?? key : keyName(key, mac);
|
|
507
|
-
if (chord.code?.startsWith("Numpad")) return `Numpad ${spaced(label(chord.code.slice(6)))}`;
|
|
508
|
-
return label(namedChord(chord).key);
|
|
509
|
-
}
|
|
510
|
-
/** A key in words: `"Return"`, `"Up Arrow"`, `"Comma"`, `"Caps Lock"`. */
|
|
511
|
-
function keyName(key, mac) {
|
|
512
|
-
const label = KEY_LABELS[key];
|
|
513
|
-
if (label) return (mac ? void 0 : label[3]) ?? label[2];
|
|
514
|
-
return PUNCTUATION_NAMES[key] ?? spaced(key);
|
|
515
|
-
}
|
|
516
|
-
/**
|
|
517
|
-
* Phrases a binding for a screen reader: `"Command K"` for a chord,
|
|
518
|
-
* `"G then I"` for a sequence, which `aria-keyshortcuts` cannot express.
|
|
519
|
-
*/
|
|
520
|
-
function spokenBinding(binding, platform = detectPlatform()) {
|
|
521
|
-
return stepsOf(binding).map((step) => {
|
|
522
|
-
const parsed = parseChord(step, platform);
|
|
523
|
-
if (!parsed) return step;
|
|
524
|
-
return [...heldLabels(parsed, MODIFIER_LABELS[platform]).map((label) => SPOKEN_MODIFIERS[label] ?? label), keyName(namedChord(parsed).key, platform === "mac")].join(" ");
|
|
525
|
-
}).join(" then ");
|
|
376
|
+
return [...MODIFIER_LABELS[platform].filter((_, index) => held[index]), KEY_LABELS[parsed.key]?.[platform === "mac" ? 0 : 1] ?? parsed.key];
|
|
526
377
|
}
|
|
527
378
|
/**
|
|
528
379
|
* The `aria-keyshortcuts` value for a chord, as in `"Meta+K"`. Empty for a
|
|
529
|
-
* sequence and for a chord that cannot be read.
|
|
380
|
+
* sequence, which the attribute cannot express, and for a chord that cannot be read.
|
|
530
381
|
*/
|
|
531
382
|
function ariaKeyShortcuts(binding, platform = detectPlatform()) {
|
|
532
383
|
const parsed = isSequence(binding) ? null : parseChord(binding, platform);
|
|
533
384
|
if (!parsed) return "";
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
...chord,
|
|
385
|
+
return writeChord(parsed.key === "+" ? {
|
|
386
|
+
...parsed,
|
|
537
387
|
key: "Plus"
|
|
538
|
-
} :
|
|
388
|
+
} : parsed);
|
|
539
389
|
}
|
|
540
390
|
//#endregion
|
|
541
391
|
//#region src/keymap.ts
|
|
542
392
|
/** Keymap: default bindings, and the user's overrides on top of them. */
|
|
543
393
|
/** A signature that holds on every platform, so `Mod+S` and `Control+S` stay distinct. */
|
|
544
|
-
|
|
545
|
-
return `${bindingSignature(binding, "mac")} ${bindingSignature(binding, "other")}`;
|
|
546
|
-
}
|
|
394
|
+
const portable = (binding) => `${bindingSignature(binding, "mac")} ${bindingSignature(binding, "other")}`;
|
|
547
395
|
var Keymap = class {
|
|
548
396
|
#defaults = /* @__PURE__ */ new Map();
|
|
549
397
|
#overrides = /* @__PURE__ */ new Map();
|
|
@@ -568,36 +416,18 @@ var Keymap = class {
|
|
|
568
416
|
return this.#overrides;
|
|
569
417
|
}
|
|
570
418
|
get(id) {
|
|
571
|
-
|
|
572
|
-
const override = this.#overrides.get(id);
|
|
573
|
-
if (!override) return defaults;
|
|
574
|
-
if (override.kind === "replace") return override.bindings;
|
|
575
|
-
return [...defaults, ...without(override.bindings, defaults)];
|
|
419
|
+
return this.#overrides.get(id) ?? this.#defaults.get(id) ?? [];
|
|
576
420
|
}
|
|
577
421
|
set(id, input) {
|
|
578
|
-
this
|
|
422
|
+
this.load();
|
|
579
423
|
const bindings = input === null ? [] : toBindings(input);
|
|
580
424
|
if (sameBindings(bindings, this.#defaults.get(id) ?? [])) {
|
|
581
425
|
if (!this.#overrides.delete(id)) return;
|
|
582
|
-
} else this.#overrides.set(id,
|
|
583
|
-
kind: "replace",
|
|
584
|
-
bindings
|
|
585
|
-
});
|
|
586
|
-
this.#changed();
|
|
587
|
-
}
|
|
588
|
-
add(id, input) {
|
|
589
|
-
this.#load();
|
|
590
|
-
const added = without(toBindings(input), this.get(id));
|
|
591
|
-
if (added.length === 0) return;
|
|
592
|
-
const override = this.#overrides.get(id);
|
|
593
|
-
this.#overrides.set(id, {
|
|
594
|
-
kind: override?.kind ?? "add",
|
|
595
|
-
bindings: [...override?.bindings ?? [], ...added]
|
|
596
|
-
});
|
|
426
|
+
} else this.#overrides.set(id, bindings);
|
|
597
427
|
this.#changed();
|
|
598
428
|
}
|
|
599
429
|
reset(id) {
|
|
600
|
-
this
|
|
430
|
+
this.load();
|
|
601
431
|
if (id === void 0) {
|
|
602
432
|
if (this.#overrides.size === 0) return;
|
|
603
433
|
this.#overrides.clear();
|
|
@@ -611,23 +441,19 @@ var Keymap = class {
|
|
|
611
441
|
this.#changed();
|
|
612
442
|
}
|
|
613
443
|
/**
|
|
614
|
-
* Takes the stored overrides,
|
|
615
|
-
*
|
|
616
|
-
*
|
|
444
|
+
* Takes the stored overrides, once: on the first mount, or before an edit
|
|
445
|
+
* made earlier, so the edit lands on top of them. Tells subscribers if they
|
|
446
|
+
* differ from the ones in memory, and returns whether they did.
|
|
617
447
|
*/
|
|
618
|
-
|
|
448
|
+
load() {
|
|
449
|
+
if (this.#loaded) return false;
|
|
619
450
|
this.#loaded = true;
|
|
620
451
|
const stored = this.#store?.read();
|
|
621
452
|
if (!stored) return false;
|
|
622
|
-
const
|
|
453
|
+
const changed = JSON.stringify([...stored]) !== JSON.stringify([...this.#overrides]);
|
|
623
454
|
this.#overrides = stored;
|
|
624
|
-
if (
|
|
625
|
-
|
|
626
|
-
return true;
|
|
627
|
-
}
|
|
628
|
-
/** Follows edits other tabs make to the stored overrides. Returns a function that stops. */
|
|
629
|
-
watch() {
|
|
630
|
-
return this.#store?.watch(() => this.sync()) ?? (() => {});
|
|
455
|
+
if (changed) this.#notify();
|
|
456
|
+
return changed;
|
|
631
457
|
}
|
|
632
458
|
/** Subscribes to keymap changes. Returns an unsubscribe function. */
|
|
633
459
|
subscribe(listener) {
|
|
@@ -636,10 +462,6 @@ var Keymap = class {
|
|
|
636
462
|
this.#listeners.delete(listener);
|
|
637
463
|
};
|
|
638
464
|
}
|
|
639
|
-
/** Before an edit: stored overrides load first, once, so the edit lands on top of them. */
|
|
640
|
-
#load() {
|
|
641
|
-
if (!this.#loaded) this.sync();
|
|
642
|
-
}
|
|
643
465
|
#changed() {
|
|
644
466
|
this.#store?.write(this.#overrides);
|
|
645
467
|
this.#notify();
|
|
@@ -648,31 +470,18 @@ var Keymap = class {
|
|
|
648
470
|
for (const listener of this.#listeners) listener();
|
|
649
471
|
}
|
|
650
472
|
};
|
|
651
|
-
/** `bindings` minus any already in `existing` or repeated, compared across platforms. */
|
|
652
|
-
function without(bindings, existing) {
|
|
653
|
-
const known = new Set(existing.map(portableSignature));
|
|
654
|
-
return bindings.filter((binding) => {
|
|
655
|
-
const signature = portableSignature(binding);
|
|
656
|
-
if (known.has(signature)) return false;
|
|
657
|
-
known.add(signature);
|
|
658
|
-
return true;
|
|
659
|
-
});
|
|
660
|
-
}
|
|
661
473
|
/** Same bindings in the same order; the first is the one shortcuts display. */
|
|
662
474
|
function sameBindings(a, b) {
|
|
663
|
-
return a.length === b.length && a.every((binding, index) =>
|
|
664
|
-
const other = b[index];
|
|
665
|
-
return other !== void 0 && portableSignature(binding) === portableSignature(other);
|
|
666
|
-
});
|
|
475
|
+
return a.length === b.length && a.every((binding, index) => portable(binding) === portable(b[index]));
|
|
667
476
|
}
|
|
668
477
|
//#endregion
|
|
669
478
|
//#region src/persist.ts
|
|
670
479
|
/** Keeping a keymap between visits: Web Storage, and export and import as JSON. */
|
|
671
480
|
const VERSION = 1;
|
|
672
481
|
/**
|
|
673
|
-
* Keeps user overrides in Web Storage under `key
|
|
674
|
-
*
|
|
675
|
-
*
|
|
482
|
+
* Keeps user overrides in Web Storage under `key`. `storage` is only called in
|
|
483
|
+
* the browser; return `sessionStorage` or an object of your own to keep them
|
|
484
|
+
* somewhere else.
|
|
676
485
|
*
|
|
677
486
|
* @example
|
|
678
487
|
* ```ts
|
|
@@ -703,14 +512,6 @@ function webStorage(key = "inklu.keys", storage = () => localStorage) {
|
|
|
703
512
|
if (overrides.size === 0) open()?.removeItem(key);
|
|
704
513
|
else open()?.setItem(key, JSON.stringify(serializeKeymap(overrides)));
|
|
705
514
|
} catch {}
|
|
706
|
-
},
|
|
707
|
-
watch(onChange) {
|
|
708
|
-
if (typeof window === "undefined") return () => {};
|
|
709
|
-
const onStorage = (event) => {
|
|
710
|
-
if (event.key === null || event.key === key) onChange();
|
|
711
|
-
};
|
|
712
|
-
window.addEventListener("storage", onStorage);
|
|
713
|
-
return () => window.removeEventListener("storage", onStorage);
|
|
714
515
|
}
|
|
715
516
|
};
|
|
716
517
|
}
|
|
@@ -733,10 +534,7 @@ function keymapOf({ keymap }) {
|
|
|
733
534
|
function serializeKeymap(overrides) {
|
|
734
535
|
return {
|
|
735
536
|
version: VERSION,
|
|
736
|
-
overrides: Object.fromEntries(Array.from(overrides, ([id,
|
|
737
|
-
const list = bindings.map((binding) => isSequence(binding) ? [...binding.sequence] : binding);
|
|
738
|
-
return [id, kind === "replace" ? list : { add: list }];
|
|
739
|
-
}))
|
|
537
|
+
overrides: Object.fromEntries(Array.from(overrides, ([id, bindings]) => [id, bindings.map((binding) => isSequence(binding) ? [...binding.sequence] : binding)]))
|
|
740
538
|
};
|
|
741
539
|
}
|
|
742
540
|
/** The overrides in stored data, or `null` for data in another format. */
|
|
@@ -745,36 +543,12 @@ function parseKeymap(data) {
|
|
|
745
543
|
if (stored === void 0 || !isObject(stored)) return null;
|
|
746
544
|
const overrides = /* @__PURE__ */ new Map();
|
|
747
545
|
for (const [id, value] of Object.entries(stored)) {
|
|
748
|
-
|
|
749
|
-
|
|
546
|
+
if (!Array.isArray(value)) continue;
|
|
547
|
+
const bindings = value.map(parseStoredBinding).filter((binding) => binding !== null);
|
|
548
|
+
if (bindings.length > 0 || value.length === 0) overrides.set(id, bindings);
|
|
750
549
|
}
|
|
751
550
|
return overrides;
|
|
752
551
|
}
|
|
753
|
-
function parseOverride(value) {
|
|
754
|
-
if (Array.isArray(value)) {
|
|
755
|
-
const bindings = parseBindings(value);
|
|
756
|
-
return bindings && {
|
|
757
|
-
kind: "replace",
|
|
758
|
-
bindings
|
|
759
|
-
};
|
|
760
|
-
}
|
|
761
|
-
if (isObject(value) && Array.isArray(value.add)) {
|
|
762
|
-
const bindings = parseBindings(value.add);
|
|
763
|
-
return bindings && bindings.length > 0 ? {
|
|
764
|
-
kind: "add",
|
|
765
|
-
bindings
|
|
766
|
-
} : null;
|
|
767
|
-
}
|
|
768
|
-
return null;
|
|
769
|
-
}
|
|
770
|
-
/**
|
|
771
|
-
* Reads a stored list. `[]` is an unbound command; a non-empty list with
|
|
772
|
-
* nothing usable in it is damage, and reads as `null`.
|
|
773
|
-
*/
|
|
774
|
-
function parseBindings(values) {
|
|
775
|
-
const bindings = values.map(parseStoredBinding).filter((binding) => binding !== null);
|
|
776
|
-
return bindings.length === 0 && values.length > 0 ? null : bindings;
|
|
777
|
-
}
|
|
778
552
|
/** A stored chord (a string) or sequence (a list of them), or `null` if it cannot be read. */
|
|
779
553
|
function parseStoredBinding(value) {
|
|
780
554
|
if (typeof value === "string") return isChord(value) ? value : null;
|
|
@@ -792,266 +566,7 @@ function isObject(value) {
|
|
|
792
566
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
793
567
|
}
|
|
794
568
|
//#endregion
|
|
795
|
-
//#region src/keys.ts
|
|
796
|
-
/** The one keyboard listener: matches a keypress to bindings and runs the command that wins it. */
|
|
797
|
-
const DEFAULT_SEQUENCE_TIMEOUT = 1e3;
|
|
798
|
-
/** The longest delay `setTimeout` honours; a longer one fires at once. */
|
|
799
|
-
const MAX_TIMER = 2 ** 31 - 1;
|
|
800
|
-
const NO_PROGRESS = [];
|
|
801
|
-
var KeyListener = class {
|
|
802
|
-
#host;
|
|
803
|
-
#target;
|
|
804
|
-
#attached = null;
|
|
805
|
-
#bound = null;
|
|
806
|
-
/** Whoever has taken the keyboard, the most recent last. See `capture`. */
|
|
807
|
-
#captures = [];
|
|
808
|
-
#spent = null;
|
|
809
|
-
#watchers = /* @__PURE__ */ new Set();
|
|
810
|
-
#progress = NO_PROGRESS;
|
|
811
|
-
#timer = null;
|
|
812
|
-
constructor(host, target) {
|
|
813
|
-
this.#host = host;
|
|
814
|
-
this.#target = target;
|
|
815
|
-
}
|
|
816
|
-
/** Starts listening. A no-op on the server. */
|
|
817
|
-
attach() {
|
|
818
|
-
if (this.#attached || typeof document === "undefined") return;
|
|
819
|
-
this.#attached = this.#target ?? document;
|
|
820
|
-
this.#attached.addEventListener("keydown", this.#onKeyDown);
|
|
821
|
-
}
|
|
822
|
-
/** Stops listening and abandons half-typed sequences. */
|
|
823
|
-
detach() {
|
|
824
|
-
this.#attached?.removeEventListener("keydown", this.#onKeyDown);
|
|
825
|
-
this.#attached = null;
|
|
826
|
-
this.refresh();
|
|
827
|
-
}
|
|
828
|
-
/** Stops listening for good, dropping the watchers and captures too. */
|
|
829
|
-
destroy() {
|
|
830
|
-
this.#watchers.clear();
|
|
831
|
-
for (let onKey = this.#captures.at(-1); onKey; onKey = this.#captures.at(-1)) this.#release(onKey);
|
|
832
|
-
this.detach();
|
|
833
|
-
}
|
|
834
|
-
/** Whether something has taken the keyboard, so no command's keys are live. */
|
|
835
|
-
get captured() {
|
|
836
|
-
return this.#captures.length > 0;
|
|
837
|
-
}
|
|
838
|
-
/** Hands every keypress to `onKey` until released, in the capture phase on the document. */
|
|
839
|
-
capture(onKey) {
|
|
840
|
-
if (typeof document === "undefined") return () => {};
|
|
841
|
-
this.#captures.push(onKey);
|
|
842
|
-
if (this.#captures.length === 1) document.addEventListener("keydown", this.#onCapture, true);
|
|
843
|
-
return () => this.#release(onKey);
|
|
844
|
-
}
|
|
845
|
-
#release(onKey) {
|
|
846
|
-
const index = this.#captures.lastIndexOf(onKey);
|
|
847
|
-
if (index === -1) return;
|
|
848
|
-
this.#captures.splice(index, 1);
|
|
849
|
-
if (this.#captures.length > 0) return;
|
|
850
|
-
document.removeEventListener("keydown", this.#onCapture, true);
|
|
851
|
-
}
|
|
852
|
-
/** Bindings or the platform changed: re-read them. Half-typed sequences start over. */
|
|
853
|
-
refresh() {
|
|
854
|
-
this.#bound = null;
|
|
855
|
-
this.#publish();
|
|
856
|
-
}
|
|
857
|
-
/** Reachability changed: a half-typed sequence that is no longer reachable starts over. */
|
|
858
|
-
prune() {
|
|
859
|
-
let changed = false;
|
|
860
|
-
for (const bound of this.#bound ?? []) {
|
|
861
|
-
if (bound.matched === 0 || this.#host.reachable(bound.id)) continue;
|
|
862
|
-
bound.matched = 0;
|
|
863
|
-
changed = true;
|
|
864
|
-
}
|
|
865
|
-
if (changed) this.#publish();
|
|
866
|
-
}
|
|
867
|
-
/** Half-typed sequences. Tracked only while something is watching. */
|
|
868
|
-
progress() {
|
|
869
|
-
return this.#progress;
|
|
870
|
-
}
|
|
871
|
-
/** Watches sequence progress. It is tracked, and timed, only while someone is watching. */
|
|
872
|
-
watch(listener) {
|
|
873
|
-
this.#watchers.add(listener);
|
|
874
|
-
if (this.#watchers.size === 1) this.#publish();
|
|
875
|
-
return () => {
|
|
876
|
-
this.#watchers.delete(listener);
|
|
877
|
-
if (this.#watchers.size === 0) this.#publish();
|
|
878
|
-
};
|
|
879
|
-
}
|
|
880
|
-
#bindings() {
|
|
881
|
-
if (this.#bound) return this.#bound;
|
|
882
|
-
const platform = this.#host.platform();
|
|
883
|
-
const bound = [];
|
|
884
|
-
this.#host.ids().forEach((id, order) => {
|
|
885
|
-
for (const binding of this.#host.bindings(id)) {
|
|
886
|
-
const steps = parseBinding(binding, platform);
|
|
887
|
-
if (!steps) continue;
|
|
888
|
-
const timeout = this.#host.definition(id)?.sequenceTimeout ?? DEFAULT_SEQUENCE_TIMEOUT;
|
|
889
|
-
bound.push({
|
|
890
|
-
id,
|
|
891
|
-
order,
|
|
892
|
-
binding,
|
|
893
|
-
steps,
|
|
894
|
-
timeout,
|
|
895
|
-
matched: 0,
|
|
896
|
-
at: 0
|
|
897
|
-
});
|
|
898
|
-
}
|
|
899
|
-
});
|
|
900
|
-
this.#bound = bound;
|
|
901
|
-
return bound;
|
|
902
|
-
}
|
|
903
|
-
#onCapture = (event) => {
|
|
904
|
-
const onKey = this.#captures.at(-1);
|
|
905
|
-
if (!onKey || isComposing(event)) return;
|
|
906
|
-
this.#spent = event;
|
|
907
|
-
onKey(event, readChord(event, this.#host.platform()));
|
|
908
|
-
};
|
|
909
|
-
#onKeyDown = (event) => {
|
|
910
|
-
if (isComposing(event) || this.captured) return;
|
|
911
|
-
if (this.#spent) {
|
|
912
|
-
if (event === this.#spent || event.repeat) return;
|
|
913
|
-
this.#spent = null;
|
|
914
|
-
}
|
|
915
|
-
if (isModifierKey(event.key)) return;
|
|
916
|
-
const platform = this.#host.platform();
|
|
917
|
-
const typing = isTyping(event, this.#target);
|
|
918
|
-
const now = Date.now();
|
|
919
|
-
const chords = [];
|
|
920
|
-
const sequences = [];
|
|
921
|
-
let continued = false;
|
|
922
|
-
let prevent = false;
|
|
923
|
-
let progressed = false;
|
|
924
|
-
for (const bound of this.#bindings()) {
|
|
925
|
-
const definition = this.#host.definition(bound.id);
|
|
926
|
-
const first = bound.steps[0];
|
|
927
|
-
if (!definition || !first || !this.#host.reachable(bound.id)) continue;
|
|
928
|
-
if (typing && (definition.ignoreInputs ?? ignoresInputs(first, platform))) continue;
|
|
929
|
-
if (bound.steps.length === 1) {
|
|
930
|
-
if (matchesChord(event, first, platform)) chords.push(bound);
|
|
931
|
-
continue;
|
|
932
|
-
}
|
|
933
|
-
if (event.repeat) continue;
|
|
934
|
-
const matched = now - bound.at > bound.timeout ? 0 : bound.matched;
|
|
935
|
-
const expected = bound.steps[matched];
|
|
936
|
-
let reached = 0;
|
|
937
|
-
if (expected && matchesChord(event, expected, platform)) reached = matched + 1;
|
|
938
|
-
else if (matched > 0 && matchesChord(event, first, platform)) reached = 1;
|
|
939
|
-
const next = reached === bound.steps.length ? 0 : reached;
|
|
940
|
-
if (next !== bound.matched) progressed = true;
|
|
941
|
-
bound.matched = next;
|
|
942
|
-
bound.at = now;
|
|
943
|
-
if (reached === 0) continue;
|
|
944
|
-
if (reached > 1) continued = true;
|
|
945
|
-
if (reached === bound.steps.length) {
|
|
946
|
-
sequences.push(bound);
|
|
947
|
-
continue;
|
|
948
|
-
}
|
|
949
|
-
const step = bound.steps[reached - 1];
|
|
950
|
-
if (!(typing && !step?.ctrl && !step?.meta) && definition.preventDefault !== false) prevent = true;
|
|
951
|
-
}
|
|
952
|
-
if (progressed) this.#publish();
|
|
953
|
-
if (continued) this.#spent = event;
|
|
954
|
-
const candidates = sequences.length > 0 ? sequences : continued ? [] : chords;
|
|
955
|
-
let winner;
|
|
956
|
-
for (const bound of candidates) if (!winner || this.#outranks(bound, winner)) winner = bound;
|
|
957
|
-
const definition = winner && this.#host.definition(winner.id);
|
|
958
|
-
if (!winner || !definition) {
|
|
959
|
-
if (prevent) event.preventDefault();
|
|
960
|
-
return;
|
|
961
|
-
}
|
|
962
|
-
if (prevent || definition.preventDefault !== false) event.preventDefault();
|
|
963
|
-
if (event.repeat && definition.repeat === false) return;
|
|
964
|
-
this.#host.run(winner.id, event, winner.binding);
|
|
965
|
-
};
|
|
966
|
-
#outranks(a, b) {
|
|
967
|
-
const depth = this.#host.depth(a.id) - this.#host.depth(b.id);
|
|
968
|
-
return depth === 0 ? a.order > b.order : depth > 0;
|
|
969
|
-
}
|
|
970
|
-
#publish = () => {
|
|
971
|
-
if (this.#timer !== null) clearTimeout(this.#timer);
|
|
972
|
-
this.#timer = null;
|
|
973
|
-
const progress = [];
|
|
974
|
-
let expires = null;
|
|
975
|
-
if (this.#watchers.size > 0) {
|
|
976
|
-
const now = Date.now();
|
|
977
|
-
for (const bound of this.#bound ?? []) {
|
|
978
|
-
if (bound.matched === 0 || !isSequence(bound.binding)) continue;
|
|
979
|
-
const deadline = bound.at + bound.timeout;
|
|
980
|
-
if (now > deadline) continue;
|
|
981
|
-
expires = expires === null ? deadline : Math.min(expires, deadline);
|
|
982
|
-
progress.push({
|
|
983
|
-
id: bound.id,
|
|
984
|
-
binding: bound.binding,
|
|
985
|
-
matched: bound.matched
|
|
986
|
-
});
|
|
987
|
-
}
|
|
988
|
-
}
|
|
989
|
-
const wait = expires === null ? Infinity : Math.max(0, expires - Date.now()) + 1;
|
|
990
|
-
if (wait < MAX_TIMER) this.#timer = setTimeout(this.#publish, wait);
|
|
991
|
-
if (progress.length === this.#progress.length && progress.every((entry, index) => {
|
|
992
|
-
const other = this.#progress[index];
|
|
993
|
-
return other?.binding === entry.binding && other.matched === entry.matched;
|
|
994
|
-
})) return;
|
|
995
|
-
this.#progress = progress.length === 0 ? NO_PROGRESS : progress;
|
|
996
|
-
for (const watcher of this.#watchers) watcher();
|
|
997
|
-
};
|
|
998
|
-
};
|
|
999
|
-
/**
|
|
1000
|
-
* Whether a keydown belongs to an IME composition. Some engines report it
|
|
1001
|
-
* only as `keyCode` 229 or the key `Process`.
|
|
1002
|
-
*/
|
|
1003
|
-
function isComposing(event) {
|
|
1004
|
-
return event.isComposing || event.keyCode === 229 || event.key === "Process";
|
|
1005
|
-
}
|
|
1006
|
-
/**
|
|
1007
|
-
* Whether a binding stays quiet in a text field when its command does not
|
|
1008
|
-
* say: everything but Control/Meta chords and bare `Escape`, and also the
|
|
1009
|
-
* chords a field edits its own text with.
|
|
1010
|
-
*/
|
|
1011
|
-
function ignoresInputs(step, platform) {
|
|
1012
|
-
if (step.key === "Escape" || step.code === "Escape") return false;
|
|
1013
|
-
return !(step.ctrl || step.meta) || editsText(step, platform);
|
|
1014
|
-
}
|
|
1015
|
-
/** Keys that, with the primary modifier, move the caret or delete by word or by line. */
|
|
1016
|
-
const CARET_KEYS = /^(Backspace|Delete|Home|End|Arrow\w+)$/;
|
|
1017
|
-
/**
|
|
1018
|
-
* Whether a text field acts on a chord itself, on the platform's primary
|
|
1019
|
-
* modifier: select all, copy, cut, paste, undo and redo, and moving or
|
|
1020
|
-
* deleting by word or line.
|
|
1021
|
-
*/
|
|
1022
|
-
function editsText(step, platform) {
|
|
1023
|
-
const mac = platform === "mac";
|
|
1024
|
-
if (step.alt || step.ctrl === mac || step.meta !== mac) return false;
|
|
1025
|
-
const key = namedChord(step).key;
|
|
1026
|
-
if (key.length > 1) return CARET_KEYS.test(key);
|
|
1027
|
-
return (step.shift ? "VZ" : mac ? "ACVXZ" : "ACVXYZ").includes(key);
|
|
1028
|
-
}
|
|
1029
|
-
/**
|
|
1030
|
-
* Whether a keypress lands in a field that takes text. `host`, the element
|
|
1031
|
-
* the listener sits on, does not count.
|
|
1032
|
-
*/
|
|
1033
|
-
function isTyping(event, host) {
|
|
1034
|
-
return event.composedPath().some((node) => node !== host && isTextEntry(node));
|
|
1035
|
-
}
|
|
1036
|
-
/** ARIA roles of widgets that take typed keys the way their native counterparts do. */
|
|
1037
|
-
const TEXT_ENTRY_ROLES = /* @__PURE__ */ new Set([
|
|
1038
|
-
"textbox",
|
|
1039
|
-
"searchbox",
|
|
1040
|
-
"combobox",
|
|
1041
|
-
"spinbutton",
|
|
1042
|
-
"slider"
|
|
1043
|
-
]);
|
|
1044
|
-
function isTextEntry(node) {
|
|
1045
|
-
if (!(node instanceof HTMLElement)) return false;
|
|
1046
|
-
if (node instanceof HTMLInputElement) {
|
|
1047
|
-
const type = node.type.toLowerCase();
|
|
1048
|
-
return type !== "button" && type !== "submit" && type !== "reset";
|
|
1049
|
-
}
|
|
1050
|
-
return node instanceof HTMLTextAreaElement || node instanceof HTMLSelectElement || !["false", null].includes(node.getAttribute("contenteditable")) || TEXT_ENTRY_ROLES.has(node.getAttribute("role") ?? "");
|
|
1051
|
-
}
|
|
1052
|
-
//#endregion
|
|
1053
569
|
//#region src/rebind.ts
|
|
1054
|
-
/** Controller for recording and applying hotkey rebindings. */
|
|
1055
570
|
const NO_STEPS = [];
|
|
1056
571
|
/**
|
|
1057
572
|
* Creates a rebinder for recording a command's keys. While it records, no
|
|
@@ -1067,7 +582,6 @@ const NO_STEPS = [];
|
|
|
1067
582
|
* ```
|
|
1068
583
|
*/
|
|
1069
584
|
function createRebinder(registry, id, options = {}) {
|
|
1070
|
-
const sequence = options.mode === "sequence";
|
|
1071
585
|
const listeners = /* @__PURE__ */ new Set();
|
|
1072
586
|
let state = {
|
|
1073
587
|
recording: false,
|
|
@@ -1076,7 +590,6 @@ function createRebinder(registry, id, options = {}) {
|
|
|
1076
590
|
conflicts: []
|
|
1077
591
|
};
|
|
1078
592
|
let release = null;
|
|
1079
|
-
let idle = null;
|
|
1080
593
|
const set = (next) => {
|
|
1081
594
|
state = next;
|
|
1082
595
|
for (const listener of listeners) listener();
|
|
@@ -1085,20 +598,15 @@ function createRebinder(registry, id, options = {}) {
|
|
|
1085
598
|
const end = () => {
|
|
1086
599
|
release?.();
|
|
1087
600
|
release = null;
|
|
1088
|
-
if (idle !== null) clearTimeout(idle);
|
|
1089
|
-
idle = null;
|
|
1090
601
|
};
|
|
1091
602
|
const record = (binding) => {
|
|
1092
603
|
end();
|
|
1093
|
-
const conflicts = registry.conflicts(binding, {
|
|
604
|
+
const conflicts = binding ? registry.conflicts(binding, {
|
|
1094
605
|
ignore: id,
|
|
1095
606
|
scope: registry.get(id)?.scope
|
|
1096
|
-
});
|
|
1097
|
-
const blocked =
|
|
1098
|
-
if (options.apply !== false && !blocked)
|
|
1099
|
-
if (options.add) registry.keymap.add(id, binding);
|
|
1100
|
-
else registry.keymap.set(id, binding);
|
|
1101
|
-
}
|
|
607
|
+
}) : [];
|
|
608
|
+
const blocked = options.rejectConflicts === true && conflicts.length > 0;
|
|
609
|
+
if (options.apply !== false && !blocked) registry.keymap.set(id, binding);
|
|
1102
610
|
set({
|
|
1103
611
|
recording: false,
|
|
1104
612
|
binding,
|
|
@@ -1108,6 +616,7 @@ function createRebinder(registry, id, options = {}) {
|
|
|
1108
616
|
options.onRecord?.(binding, conflicts);
|
|
1109
617
|
};
|
|
1110
618
|
const cancel = () => {
|
|
619
|
+
if (!state.recording) return;
|
|
1111
620
|
end();
|
|
1112
621
|
set({
|
|
1113
622
|
...state,
|
|
@@ -1116,52 +625,27 @@ function createRebinder(registry, id, options = {}) {
|
|
|
1116
625
|
});
|
|
1117
626
|
options.onCancel?.();
|
|
1118
627
|
};
|
|
1119
|
-
const clear = () => {
|
|
1120
|
-
end();
|
|
1121
|
-
if (options.apply !== false) registry.keymap.set(id, null);
|
|
1122
|
-
set({
|
|
1123
|
-
recording: false,
|
|
1124
|
-
binding: null,
|
|
1125
|
-
steps: NO_STEPS,
|
|
1126
|
-
conflicts: []
|
|
1127
|
-
});
|
|
1128
|
-
options.onClear?.();
|
|
1129
|
-
};
|
|
1130
628
|
const commit = () => {
|
|
1131
629
|
const [first, ...rest] = state.steps;
|
|
1132
|
-
if (first === void 0) return;
|
|
630
|
+
if (!state.recording || first === void 0) return;
|
|
1133
631
|
record(rest.length === 0 ? first : { sequence: state.steps });
|
|
1134
632
|
};
|
|
1135
|
-
/** Sets the steps typed so far, and restarts the wait for the next one. */
|
|
1136
|
-
const step = (steps) => {
|
|
1137
|
-
if (idle !== null) clearTimeout(idle);
|
|
1138
|
-
const wait = options.idleTimeout ?? 0;
|
|
1139
|
-
idle = wait > 0 && steps.length > 0 ? setTimeout(commit, wait) : null;
|
|
1140
|
-
set({
|
|
1141
|
-
...state,
|
|
1142
|
-
steps
|
|
1143
|
-
});
|
|
1144
|
-
};
|
|
1145
|
-
const stop = () => {
|
|
1146
|
-
if (!state.recording) return;
|
|
1147
|
-
end();
|
|
1148
|
-
set({
|
|
1149
|
-
...state,
|
|
1150
|
-
recording: false,
|
|
1151
|
-
steps: NO_STEPS
|
|
1152
|
-
});
|
|
1153
|
-
};
|
|
1154
633
|
const onKey = (event, chord) => {
|
|
1155
|
-
if (options.ignoreInputs !== false && event.key !== "Escape" && isTyping(event)) return;
|
|
1156
634
|
event.preventDefault();
|
|
1157
635
|
event.stopPropagation();
|
|
1158
636
|
if (event.repeat) return;
|
|
1159
637
|
const bare = !event.ctrlKey && !event.altKey && !event.shiftKey && !event.metaKey;
|
|
1160
638
|
if (bare && event.key === "Escape") return cancel();
|
|
1161
|
-
if (bare && (event.key === "Backspace" || event.key === "Delete")) return state.steps.length === 0 ?
|
|
1162
|
-
|
|
639
|
+
if (bare && (event.key === "Backspace" || event.key === "Delete")) return state.steps.length === 0 ? record(null) : set({
|
|
640
|
+
...state,
|
|
641
|
+
steps: state.steps.slice(0, -1)
|
|
642
|
+
});
|
|
643
|
+
if (options.sequence && bare && event.key === "Enter") return commit();
|
|
1163
644
|
if (chord === null) return;
|
|
1164
|
-
if (sequence)
|
|
645
|
+
if (options.sequence) set({
|
|
646
|
+
...state,
|
|
647
|
+
steps: [...state.steps, chord]
|
|
648
|
+
});
|
|
1165
649
|
else record(chord);
|
|
1166
650
|
};
|
|
1167
651
|
return {
|
|
@@ -1175,13 +659,8 @@ function createRebinder(registry, id, options = {}) {
|
|
|
1175
659
|
conflicts: []
|
|
1176
660
|
});
|
|
1177
661
|
},
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
if (state.recording) cancel();
|
|
1181
|
-
},
|
|
1182
|
-
commit: () => {
|
|
1183
|
-
if (state.recording && sequence) commit();
|
|
1184
|
-
},
|
|
662
|
+
cancel,
|
|
663
|
+
commit,
|
|
1185
664
|
get state() {
|
|
1186
665
|
return state;
|
|
1187
666
|
},
|
|
@@ -1192,26 +671,15 @@ function createRebinder(registry, id, options = {}) {
|
|
|
1192
671
|
};
|
|
1193
672
|
},
|
|
1194
673
|
destroy: () => {
|
|
1195
|
-
|
|
1196
|
-
|
|
674
|
+
end();
|
|
675
|
+
if (state.recording) set({
|
|
676
|
+
...state,
|
|
677
|
+
recording: false,
|
|
678
|
+
steps: NO_STEPS
|
|
679
|
+
});
|
|
1197
680
|
}
|
|
1198
681
|
};
|
|
1199
682
|
}
|
|
1200
|
-
//#endregion
|
|
1201
|
-
//#region src/reserved.ts
|
|
1202
|
-
/** Chords the browser or the operating system keeps for itself. */
|
|
1203
|
-
const chords = (list) => new Set(list.split(" "));
|
|
1204
|
-
const RESERVED = {
|
|
1205
|
-
mac: chords("Meta+N Meta+T Meta+W Meta+Q Meta+H Meta+M Meta+Tab Meta+Space Shift+Meta+N Shift+Meta+T Shift+Meta+W Shift+Meta+Q Shift+Meta+Tab Shift+Meta+3 Shift+Meta+4 Shift+Meta+5 Control+Tab Control+Shift+Tab Control+Meta+Q"),
|
|
1206
|
-
other: chords("Control+N Control+T Control+W Control+Shift+N Control+Shift+T Control+Shift+W Control+Tab Control+Shift+Tab Control+PageUp Control+PageDown Control+F4 Alt+F4 Alt+Tab Alt+Shift+Tab Alt+Space Control+Alt+Tab Control+Alt+Delete")
|
|
1207
|
-
};
|
|
1208
|
-
/** Whether any step of a binding is a chord the browser or OS reserves on a platform. */
|
|
1209
|
-
function isReserved(binding, platform) {
|
|
1210
|
-
return (parseBinding(binding, platform) ?? []).some((chord) => {
|
|
1211
|
-
const written = writeChord(namedChord(chord));
|
|
1212
|
-
return platform === "mac" ? RESERVED.mac.has(written) : chord.meta || RESERVED.other.has(written);
|
|
1213
|
-
});
|
|
1214
|
-
}
|
|
1215
683
|
/**
|
|
1216
684
|
* Whether commands in two scopes can be live together, so their bindings can
|
|
1217
685
|
* collide: a global or `"*"` command overlaps every scope, and two named
|
|
@@ -1285,6 +753,15 @@ var ScopeStack = class {
|
|
|
1285
753
|
//#endregion
|
|
1286
754
|
//#region src/audit.ts
|
|
1287
755
|
/** Development-only warnings about default bindings that cannot work as written. */
|
|
756
|
+
const chords = (list) => new Set(list.split(" "));
|
|
757
|
+
const RESERVED = {
|
|
758
|
+
mac: chords("Meta+N Meta+T Meta+W Meta+Q Meta+H Meta+M Meta+Tab Meta+Space Shift+Meta+N Shift+Meta+T Shift+Meta+W Shift+Meta+Q Shift+Meta+Tab Shift+Meta+3 Shift+Meta+4 Shift+Meta+5 Control+Tab Control+Shift+Tab Control+Meta+Q"),
|
|
759
|
+
other: chords("Control+N Control+T Control+W Control+Shift+N Control+Shift+T Control+Shift+W Control+Tab Control+Shift+Tab Control+PageUp Control+PageDown Control+F4 Alt+F4 Alt+Tab Alt+Shift+Tab Alt+Space Control+Alt+Tab Control+Alt+Delete")
|
|
760
|
+
};
|
|
761
|
+
/** Whether any step of a binding is a chord the browser or OS reserves on a platform. */
|
|
762
|
+
function isReserved(binding, platform) {
|
|
763
|
+
return (parseBinding(binding, platform) ?? []).some((chord) => platform === "mac" ? RESERVED.mac.has(writeChord(chord)) : chord.meta || RESERVED.other.has(writeChord(chord)));
|
|
764
|
+
}
|
|
1288
765
|
const platformName = (platform) => platform === "mac" ? "macOS" : "Windows and Linux";
|
|
1289
766
|
/** A binding as the app wrote it, which is what there is to search the code for. */
|
|
1290
767
|
const written = (binding) => stepsOf(binding).join(" ");
|
|
@@ -1325,7 +802,7 @@ function warnAboutBinding(id, binding, platform, warn) {
|
|
|
1325
802
|
if (held.length === 0) return;
|
|
1326
803
|
if (platform === "mac") warn(`"${id}" binds "${written(binding)}". Control+Option is the VoiceOver modifier on macOS, so screen reader users cannot press it.`);
|
|
1327
804
|
else if (held.some((step) => typesCharacter({
|
|
1328
|
-
...
|
|
805
|
+
...step,
|
|
1329
806
|
ctrl: false,
|
|
1330
807
|
alt: false
|
|
1331
808
|
}))) warn(`"${id}" binds "${written(binding)}". Control+Alt is AltGr on many Windows and Linux keyboard layouts, where it types a character instead. Give those platforms other keys with { mac, other }.`);
|
|
@@ -1371,11 +848,186 @@ function warnAboutUnknownOverrides(source, warn) {
|
|
|
1371
848
|
if (unknown.length > 0) warn(`bindings override ${unknown.map((id) => `"${id}"`).join(", ")}, which ${unknown.length === 1 ? "is not a registered command" : "are not registered commands"}.`);
|
|
1372
849
|
}
|
|
1373
850
|
//#endregion
|
|
851
|
+
//#region src/keys.ts
|
|
852
|
+
/** The one keyboard listener: matches a keypress to bindings and runs the command that wins it. */
|
|
853
|
+
/** How long a sequence waits for its next step, in milliseconds. */
|
|
854
|
+
const SEQUENCE_TIMEOUT = 1e3;
|
|
855
|
+
var KeyListener = class {
|
|
856
|
+
#host;
|
|
857
|
+
#attached = false;
|
|
858
|
+
#bound = null;
|
|
859
|
+
/** Whoever has taken the keyboard, the most recent last. See `capture`. */
|
|
860
|
+
#captures = [];
|
|
861
|
+
/** The keypress a capture used up: released in the same event, it must not run a chord. */
|
|
862
|
+
#spent = null;
|
|
863
|
+
constructor(host) {
|
|
864
|
+
this.#host = host;
|
|
865
|
+
}
|
|
866
|
+
/** Starts listening. A no-op on the server. */
|
|
867
|
+
attach() {
|
|
868
|
+
if (this.#attached || typeof document === "undefined") return;
|
|
869
|
+
this.#attached = true;
|
|
870
|
+
document.addEventListener("keydown", this.#onKeyDown);
|
|
871
|
+
}
|
|
872
|
+
/** Stops listening and abandons half-typed sequences. */
|
|
873
|
+
detach() {
|
|
874
|
+
if (this.#attached) document.removeEventListener("keydown", this.#onKeyDown);
|
|
875
|
+
this.#attached = false;
|
|
876
|
+
this.refresh();
|
|
877
|
+
}
|
|
878
|
+
/** Stops listening for good, dropping the captures too. */
|
|
879
|
+
destroy() {
|
|
880
|
+
while (this.#captures.length > 0) this.#release(this.#captures[0]);
|
|
881
|
+
this.detach();
|
|
882
|
+
}
|
|
883
|
+
/** Whether something has taken the keyboard, so no command's keys are live. */
|
|
884
|
+
get captured() {
|
|
885
|
+
return this.#captures.length > 0;
|
|
886
|
+
}
|
|
887
|
+
/** Hands every keypress to `onKey` until released, in the capture phase on the document. */
|
|
888
|
+
capture(onKey) {
|
|
889
|
+
if (typeof document === "undefined") return () => {};
|
|
890
|
+
this.#captures.push(onKey);
|
|
891
|
+
if (this.#captures.length === 1) document.addEventListener("keydown", this.#onCapture, true);
|
|
892
|
+
return () => this.#release(onKey);
|
|
893
|
+
}
|
|
894
|
+
#release(onKey) {
|
|
895
|
+
const index = this.#captures.lastIndexOf(onKey);
|
|
896
|
+
if (index === -1) return;
|
|
897
|
+
this.#captures.splice(index, 1);
|
|
898
|
+
if (this.#captures.length === 0) document.removeEventListener("keydown", this.#onCapture, true);
|
|
899
|
+
}
|
|
900
|
+
/** Bindings or the platform changed: re-read them. Half-typed sequences start over. */
|
|
901
|
+
refresh() {
|
|
902
|
+
this.#bound = null;
|
|
903
|
+
}
|
|
904
|
+
#bindings() {
|
|
905
|
+
if (this.#bound) return this.#bound;
|
|
906
|
+
const platform = this.#host.platform();
|
|
907
|
+
const bound = [];
|
|
908
|
+
this.#host.ids().forEach((id, order) => {
|
|
909
|
+
for (const binding of this.#host.bindings(id)) {
|
|
910
|
+
const steps = parseBinding(binding, platform);
|
|
911
|
+
if (steps) bound.push({
|
|
912
|
+
id,
|
|
913
|
+
order,
|
|
914
|
+
binding,
|
|
915
|
+
steps,
|
|
916
|
+
matched: 0,
|
|
917
|
+
at: 0
|
|
918
|
+
});
|
|
919
|
+
}
|
|
920
|
+
});
|
|
921
|
+
this.#bound = bound;
|
|
922
|
+
return bound;
|
|
923
|
+
}
|
|
924
|
+
#onCapture = (event) => {
|
|
925
|
+
const onKey = this.#captures.at(-1);
|
|
926
|
+
if (!onKey || isComposing(event)) return;
|
|
927
|
+
this.#spent = event;
|
|
928
|
+
onKey(event, readChord(event, this.#host.platform()));
|
|
929
|
+
};
|
|
930
|
+
#onKeyDown = (event) => {
|
|
931
|
+
if (event === this.#spent) {
|
|
932
|
+
this.#spent = null;
|
|
933
|
+
return;
|
|
934
|
+
}
|
|
935
|
+
if (event.defaultPrevented || isComposing(event) || this.captured) return;
|
|
936
|
+
if (isModifierKey(event.key)) return;
|
|
937
|
+
const platform = this.#host.platform();
|
|
938
|
+
const typing = isTyping(event);
|
|
939
|
+
const now = Date.now();
|
|
940
|
+
const chords = [];
|
|
941
|
+
const sequences = [];
|
|
942
|
+
let continued = false;
|
|
943
|
+
let prevent = false;
|
|
944
|
+
for (const bound of this.#bindings()) {
|
|
945
|
+
const definition = this.#host.definition(bound.id);
|
|
946
|
+
const first = bound.steps[0];
|
|
947
|
+
if (!definition || !first || !this.#host.reachable(bound.id)) continue;
|
|
948
|
+
if (typing && (definition.ignoreInputs ?? !isBareEscape(bound))) continue;
|
|
949
|
+
if (bound.steps.length === 1) {
|
|
950
|
+
if (matchesChord(event, first, platform)) chords.push(bound);
|
|
951
|
+
continue;
|
|
952
|
+
}
|
|
953
|
+
if (event.repeat) continue;
|
|
954
|
+
const matched = now - bound.at > SEQUENCE_TIMEOUT ? 0 : bound.matched;
|
|
955
|
+
const expected = bound.steps[matched];
|
|
956
|
+
let reached = 0;
|
|
957
|
+
if (expected && matchesChord(event, expected, platform)) reached = matched + 1;
|
|
958
|
+
else if (matched > 0 && matchesChord(event, first, platform)) reached = 1;
|
|
959
|
+
bound.matched = reached === bound.steps.length ? 0 : reached;
|
|
960
|
+
bound.at = now;
|
|
961
|
+
if (reached === 0) continue;
|
|
962
|
+
if (reached === bound.steps.length) sequences.push(bound);
|
|
963
|
+
else {
|
|
964
|
+
const step = bound.steps[reached - 1];
|
|
965
|
+
if (!typing || step?.ctrl || step?.meta) prevent = true;
|
|
966
|
+
if (reached > 1) continued = true;
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
const candidates = sequences.length > 0 ? sequences : continued ? [] : chords;
|
|
970
|
+
let winner;
|
|
971
|
+
for (const bound of candidates) if (!winner || this.#outranks(bound, winner)) winner = bound;
|
|
972
|
+
if (!winner) {
|
|
973
|
+
if (prevent) event.preventDefault();
|
|
974
|
+
return;
|
|
975
|
+
}
|
|
976
|
+
event.preventDefault();
|
|
977
|
+
if (!event.repeat) this.#host.run(winner.id, event, winner.binding);
|
|
978
|
+
};
|
|
979
|
+
#outranks(a, b) {
|
|
980
|
+
const depth = this.#host.depth(a.id) - this.#host.depth(b.id);
|
|
981
|
+
return depth === 0 ? a.order > b.order : depth > 0;
|
|
982
|
+
}
|
|
983
|
+
};
|
|
984
|
+
/**
|
|
985
|
+
* Whether a keydown belongs to an IME composition. Some engines report it
|
|
986
|
+
* only as `keyCode` 229 or the key `Process`.
|
|
987
|
+
*/
|
|
988
|
+
function isComposing(event) {
|
|
989
|
+
return event.isComposing || event.keyCode === 229 || event.key === "Process";
|
|
990
|
+
}
|
|
991
|
+
/** A chord of the Escape key alone: the one key a text field does not keep. */
|
|
992
|
+
function isBareEscape(bound) {
|
|
993
|
+
const [step] = bound.steps;
|
|
994
|
+
return bound.steps.length === 1 && step?.key === "Escape" && !step.ctrl && !step.alt && !step.shift && !step.meta;
|
|
995
|
+
}
|
|
996
|
+
/** Whether a keypress lands in a field that takes text. */
|
|
997
|
+
function isTyping(event) {
|
|
998
|
+
return event.composedPath().some(isTextEntry);
|
|
999
|
+
}
|
|
1000
|
+
/** ARIA roles of widgets that take typed keys the way their native counterparts do. */
|
|
1001
|
+
const TEXT_ENTRY_ROLES = /* @__PURE__ */ new Set([
|
|
1002
|
+
"textbox",
|
|
1003
|
+
"searchbox",
|
|
1004
|
+
"combobox",
|
|
1005
|
+
"spinbutton",
|
|
1006
|
+
"slider"
|
|
1007
|
+
]);
|
|
1008
|
+
/** Input types that take no typed keys, so a focused one keeps nothing from a command. */
|
|
1009
|
+
const KEYLESS_INPUTS = /* @__PURE__ */ new Set([
|
|
1010
|
+
"button",
|
|
1011
|
+
"submit",
|
|
1012
|
+
"reset",
|
|
1013
|
+
"image",
|
|
1014
|
+
"checkbox",
|
|
1015
|
+
"radio",
|
|
1016
|
+
"color",
|
|
1017
|
+
"file",
|
|
1018
|
+
"hidden"
|
|
1019
|
+
]);
|
|
1020
|
+
function isTextEntry(node) {
|
|
1021
|
+
if (!(node instanceof HTMLElement)) return false;
|
|
1022
|
+
if (node instanceof HTMLInputElement) return !KEYLESS_INPUTS.has(node.type.toLowerCase());
|
|
1023
|
+
return node instanceof HTMLTextAreaElement || node instanceof HTMLSelectElement || !["false", null].includes(node.getAttribute("contenteditable")) || TEXT_ENTRY_ROLES.has(node.getAttribute("role") ?? "");
|
|
1024
|
+
}
|
|
1025
|
+
//#endregion
|
|
1374
1026
|
//#region src/registry.ts
|
|
1375
1027
|
/** Command registry: what an app can do, which keys do it, and where each is live. */
|
|
1376
1028
|
const SSR_PLATFORM = "other";
|
|
1377
1029
|
var CommandRegistry = class {
|
|
1378
|
-
#definitions;
|
|
1030
|
+
#definitions = /* @__PURE__ */ new Map();
|
|
1379
1031
|
/** Handlers attached per command, the most recent last. */
|
|
1380
1032
|
#handlers = /* @__PURE__ */ new Map();
|
|
1381
1033
|
#keymap;
|
|
@@ -1387,9 +1039,6 @@ var CommandRegistry = class {
|
|
|
1387
1039
|
/** What the snapshot is behind on: who is reachable, or the bindings too. */
|
|
1388
1040
|
#stale = "bindings";
|
|
1389
1041
|
#serverSnapshot = null;
|
|
1390
|
-
#pending = null;
|
|
1391
|
-
/** Stops following other tabs' keymap edits; set while mounted. */
|
|
1392
|
-
#unwatch = null;
|
|
1393
1042
|
#mounts = 0;
|
|
1394
1043
|
#hydrated = false;
|
|
1395
1044
|
#singleKeys;
|
|
@@ -1397,7 +1046,6 @@ var CommandRegistry = class {
|
|
|
1397
1046
|
this.#options = options;
|
|
1398
1047
|
this.#singleKeys = options.singleKeyShortcuts !== false;
|
|
1399
1048
|
this.#keymap = new Keymap(options.persist);
|
|
1400
|
-
this.#definitions = /* @__PURE__ */ new Map();
|
|
1401
1049
|
for (const id in definitions) this.#definitions.set(id, definitions[id]);
|
|
1402
1050
|
this.#setDefaults();
|
|
1403
1051
|
this.#keys = new KeyListener({
|
|
@@ -1412,9 +1060,9 @@ var CommandRegistry = class {
|
|
|
1412
1060
|
event,
|
|
1413
1061
|
binding
|
|
1414
1062
|
})
|
|
1415
|
-
}
|
|
1063
|
+
});
|
|
1416
1064
|
this.#keymap.subscribe(() => this.#bindingsChanged());
|
|
1417
|
-
this.#scopes.subscribe(() => this.#
|
|
1065
|
+
this.#scopes.subscribe(() => this.#invalidate("reachable"));
|
|
1418
1066
|
if (process.env.NODE_ENV !== "production") auditRegistry({
|
|
1419
1067
|
ids: [...this.#definitions.keys()],
|
|
1420
1068
|
scope: (id) => this.#definitions.get(id)?.scope,
|
|
@@ -1435,15 +1083,11 @@ var CommandRegistry = class {
|
|
|
1435
1083
|
serverList() {
|
|
1436
1084
|
if (this.#serverSnapshot) return this.#serverSnapshot;
|
|
1437
1085
|
const platform = this.#options.platform ?? SSR_PLATFORM;
|
|
1438
|
-
const options = {
|
|
1439
|
-
platform,
|
|
1440
|
-
...this.#options.display
|
|
1441
|
-
};
|
|
1442
1086
|
const singleKeys = this.#options.singleKeyShortcuts !== false;
|
|
1443
1087
|
this.#serverSnapshot = Array.from(this.#definitions, ([id, definition]) => {
|
|
1444
1088
|
const bindings = this.#defaultBindings(id, platform).filter((binding) => singleKeys || !isSingleKeyBinding(binding));
|
|
1445
1089
|
const global = definition.scope === void 0 || definition.scope === "*";
|
|
1446
|
-
return describeCommand(id, definition, bindings, platform,
|
|
1090
|
+
return describeCommand(id, definition, bindings, platform, {
|
|
1447
1091
|
reachable: definition.run !== void 0 && global,
|
|
1448
1092
|
overridden: false
|
|
1449
1093
|
});
|
|
@@ -1458,9 +1102,6 @@ var CommandRegistry = class {
|
|
|
1458
1102
|
});
|
|
1459
1103
|
return true;
|
|
1460
1104
|
}
|
|
1461
|
-
canRun(id) {
|
|
1462
|
-
return this.whyNotRun(id) === null;
|
|
1463
|
-
}
|
|
1464
1105
|
whyNotRun(id) {
|
|
1465
1106
|
const definition = this.#definitions.get(id);
|
|
1466
1107
|
if (!definition) return "unknown";
|
|
@@ -1476,14 +1117,14 @@ var CommandRegistry = class {
|
|
|
1476
1117
|
}
|
|
1477
1118
|
const had = this.#handler(id) !== void 0;
|
|
1478
1119
|
handlers.push(run);
|
|
1479
|
-
if (!had) this.#
|
|
1120
|
+
if (!had) this.#invalidate("reachable");
|
|
1480
1121
|
let disposed = false;
|
|
1481
1122
|
return () => {
|
|
1482
1123
|
if (disposed) return;
|
|
1483
1124
|
disposed = true;
|
|
1484
1125
|
const index = handlers.lastIndexOf(run);
|
|
1485
1126
|
if (index !== -1) handlers.splice(index, 1);
|
|
1486
|
-
if (this.#handler(id) === void 0) this.#
|
|
1127
|
+
if (this.#handler(id) === void 0) this.#invalidate("reachable");
|
|
1487
1128
|
};
|
|
1488
1129
|
}
|
|
1489
1130
|
pushScope(name, options) {
|
|
@@ -1491,29 +1132,20 @@ var CommandRegistry = class {
|
|
|
1491
1132
|
}
|
|
1492
1133
|
capture(onKey) {
|
|
1493
1134
|
const release = this.#keys.capture(onKey);
|
|
1494
|
-
this.#
|
|
1135
|
+
this.#invalidate("reachable");
|
|
1495
1136
|
let released = false;
|
|
1496
1137
|
return () => {
|
|
1497
1138
|
if (released) return;
|
|
1498
1139
|
released = true;
|
|
1499
1140
|
release();
|
|
1500
|
-
this.#
|
|
1141
|
+
this.#invalidate("reachable");
|
|
1501
1142
|
};
|
|
1502
1143
|
}
|
|
1503
1144
|
conflicts(binding, options = {}) {
|
|
1504
1145
|
const platform = this.#platform();
|
|
1505
|
-
const display = (entry) => formatBinding(entry, this.#displayOptions());
|
|
1506
1146
|
const candidates = toBindings(binding);
|
|
1507
1147
|
const signatures = new Set(candidates.map((entry) => bindingSignature(entry, platform)));
|
|
1508
1148
|
const conflicts = [];
|
|
1509
|
-
for (const candidate of candidates) {
|
|
1510
|
-
if (!isReserved(candidate, platform)) continue;
|
|
1511
|
-
conflicts.push({
|
|
1512
|
-
kind: "reserved",
|
|
1513
|
-
binding: candidate,
|
|
1514
|
-
display: display(candidate)
|
|
1515
|
-
});
|
|
1516
|
-
}
|
|
1517
1149
|
for (const [id, { scope }] of this.#definitions) {
|
|
1518
1150
|
if (id === options.ignore || !scopesOverlap(scope, options.scope)) continue;
|
|
1519
1151
|
for (const existing of this.#bindings(id)) {
|
|
@@ -1523,33 +1155,13 @@ var CommandRegistry = class {
|
|
|
1523
1155
|
kind,
|
|
1524
1156
|
id,
|
|
1525
1157
|
binding: existing,
|
|
1526
|
-
display:
|
|
1158
|
+
display: formatBinding(existing, platform),
|
|
1527
1159
|
scope
|
|
1528
1160
|
});
|
|
1529
1161
|
}
|
|
1530
1162
|
}
|
|
1531
1163
|
return conflicts;
|
|
1532
1164
|
}
|
|
1533
|
-
pending() {
|
|
1534
|
-
const from = this.#keys.progress();
|
|
1535
|
-
if (this.#pending?.from !== from) {
|
|
1536
|
-
const options = this.#displayOptions();
|
|
1537
|
-
this.#pending = {
|
|
1538
|
-
from,
|
|
1539
|
-
list: from.map(({ id, binding, matched }) => ({
|
|
1540
|
-
id,
|
|
1541
|
-
binding,
|
|
1542
|
-
matched,
|
|
1543
|
-
total: binding.sequence.length,
|
|
1544
|
-
remaining: binding.sequence.slice(matched).map((step) => formatBinding(step, options))
|
|
1545
|
-
}))
|
|
1546
|
-
};
|
|
1547
|
-
}
|
|
1548
|
-
return this.#pending.list;
|
|
1549
|
-
}
|
|
1550
|
-
subscribePending(listener) {
|
|
1551
|
-
return this.#keys.watch(listener);
|
|
1552
|
-
}
|
|
1553
1165
|
get singleKeyShortcuts() {
|
|
1554
1166
|
return this.#singleKeys;
|
|
1555
1167
|
}
|
|
@@ -1565,9 +1177,8 @@ var CommandRegistry = class {
|
|
|
1565
1177
|
this.#hydrated = true;
|
|
1566
1178
|
const moved = this.#platform() !== rendered;
|
|
1567
1179
|
if (moved) this.#setDefaults();
|
|
1568
|
-
this.#unwatch = this.#keymap.watch();
|
|
1569
1180
|
this.#keys.attach();
|
|
1570
|
-
if (!this.#keymap.
|
|
1181
|
+
if (!this.#keymap.load() && moved) this.#bindingsChanged();
|
|
1571
1182
|
}
|
|
1572
1183
|
let released = false;
|
|
1573
1184
|
return () => {
|
|
@@ -1575,7 +1186,7 @@ var CommandRegistry = class {
|
|
|
1575
1186
|
released = true;
|
|
1576
1187
|
if (this.#mounts === 0) return;
|
|
1577
1188
|
this.#mounts--;
|
|
1578
|
-
if (this.#mounts === 0) this.#
|
|
1189
|
+
if (this.#mounts === 0) this.#keys.detach();
|
|
1579
1190
|
};
|
|
1580
1191
|
}
|
|
1581
1192
|
subscribe(listener) {
|
|
@@ -1586,8 +1197,6 @@ var CommandRegistry = class {
|
|
|
1586
1197
|
}
|
|
1587
1198
|
destroy() {
|
|
1588
1199
|
this.#mounts = 0;
|
|
1589
|
-
this.#unwatch?.();
|
|
1590
|
-
this.#unwatch = null;
|
|
1591
1200
|
this.#keys.destroy();
|
|
1592
1201
|
this.#definitions.clear();
|
|
1593
1202
|
this.#handlers.clear();
|
|
@@ -1596,11 +1205,6 @@ var CommandRegistry = class {
|
|
|
1596
1205
|
this.#listeners.clear();
|
|
1597
1206
|
this.#scopes.clear();
|
|
1598
1207
|
}
|
|
1599
|
-
#unmount() {
|
|
1600
|
-
this.#keys.detach();
|
|
1601
|
-
this.#unwatch?.();
|
|
1602
|
-
this.#unwatch = null;
|
|
1603
|
-
}
|
|
1604
1208
|
/** The handler that runs: the one attached last, else the definition's own. */
|
|
1605
1209
|
#handler(id) {
|
|
1606
1210
|
return this.#handlers.get(id)?.at(-1) ?? this.#definitions.get(id)?.run;
|
|
@@ -1620,8 +1224,8 @@ var CommandRegistry = class {
|
|
|
1620
1224
|
* wins over the definition's `keys`, and a `null` one means unbound.
|
|
1621
1225
|
*/
|
|
1622
1226
|
#defaultBindings(id, platform) {
|
|
1623
|
-
const
|
|
1624
|
-
return toBindings(keysFor(
|
|
1227
|
+
const override = this.#options.bindings?.[id];
|
|
1228
|
+
return toBindings(keysFor(override === void 0 ? this.#definitions.get(id)?.keys : override, platform));
|
|
1625
1229
|
}
|
|
1626
1230
|
/** Hands the keymap every command's defaults for the platform in effect. */
|
|
1627
1231
|
#setDefaults() {
|
|
@@ -1636,42 +1240,22 @@ var CommandRegistry = class {
|
|
|
1636
1240
|
...context,
|
|
1637
1241
|
commands: this
|
|
1638
1242
|
};
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
} catch (error) {
|
|
1642
|
-
this.#reportError(toError(error), full);
|
|
1643
|
-
}
|
|
1243
|
+
this.#options.onRun?.(full);
|
|
1244
|
+
const report = (error) => console.error(`[@inklu/keys] "${id}" failed`, error);
|
|
1644
1245
|
try {
|
|
1645
1246
|
const result = run(full);
|
|
1646
|
-
if (result instanceof Promise) result.catch(
|
|
1247
|
+
if (result instanceof Promise) result.catch(report);
|
|
1647
1248
|
} catch (error) {
|
|
1648
|
-
|
|
1249
|
+
report(error);
|
|
1649
1250
|
}
|
|
1650
1251
|
}
|
|
1651
|
-
#reportError(error, context) {
|
|
1652
|
-
if (this.#options.onError) {
|
|
1653
|
-
this.#options.onError(error, context);
|
|
1654
|
-
return;
|
|
1655
|
-
}
|
|
1656
|
-
console.error(`[@inklu/keys] command "${context.id}" failed`, error);
|
|
1657
|
-
}
|
|
1658
1252
|
#bindingsChanged() {
|
|
1659
1253
|
this.#keys.refresh();
|
|
1660
1254
|
this.#invalidate("bindings");
|
|
1661
1255
|
}
|
|
1662
|
-
#reachabilityChanged() {
|
|
1663
|
-
this.#keys.prune();
|
|
1664
|
-
this.#invalidate("reachable");
|
|
1665
|
-
}
|
|
1666
1256
|
#platform() {
|
|
1667
1257
|
return this.#options.platform ?? (this.#hydrated ? detectPlatform() : SSR_PLATFORM);
|
|
1668
1258
|
}
|
|
1669
|
-
#displayOptions() {
|
|
1670
|
-
return {
|
|
1671
|
-
platform: this.#platform(),
|
|
1672
|
-
...this.#options.display
|
|
1673
|
-
};
|
|
1674
|
-
}
|
|
1675
1259
|
/**
|
|
1676
1260
|
* The commands as they are now. A command that did not change keeps its
|
|
1677
1261
|
* object, and the list keeps its own while none did, so a subscriber can
|
|
@@ -1682,14 +1266,13 @@ var CommandRegistry = class {
|
|
|
1682
1266
|
if (previous && !this.#stale) return previous;
|
|
1683
1267
|
const kept = this.#stale === "reachable" ? previous?.index : void 0;
|
|
1684
1268
|
const platform = this.#platform();
|
|
1685
|
-
const options = this.#displayOptions();
|
|
1686
1269
|
const list = [];
|
|
1687
1270
|
const index = /* @__PURE__ */ new Map();
|
|
1688
1271
|
let changed = !kept;
|
|
1689
1272
|
for (const [id, definition] of this.#definitions) {
|
|
1690
1273
|
const reachable = this.#reachable(id);
|
|
1691
1274
|
let command = kept?.get(id);
|
|
1692
|
-
if (!command) command = describeCommand(id, definition, this.#bindings(id), platform,
|
|
1275
|
+
if (!command) command = describeCommand(id, definition, this.#bindings(id), platform, {
|
|
1693
1276
|
reachable,
|
|
1694
1277
|
overridden: this.#keymap.isOverridden(id)
|
|
1695
1278
|
});
|
|
@@ -1715,28 +1298,19 @@ var CommandRegistry = class {
|
|
|
1715
1298
|
for (const listener of this.#listeners) listener();
|
|
1716
1299
|
}
|
|
1717
1300
|
};
|
|
1718
|
-
function describeCommand(id, definition, source, platform,
|
|
1301
|
+
function describeCommand(id, definition, source, platform, state) {
|
|
1719
1302
|
const bindings = [...source];
|
|
1720
1303
|
return {
|
|
1721
1304
|
id,
|
|
1722
1305
|
title: definition.title,
|
|
1723
|
-
description: definition.description,
|
|
1724
|
-
group: definition.group,
|
|
1725
|
-
keywords: definition.keywords ?? [],
|
|
1726
|
-
hidden: definition.hidden === true,
|
|
1727
1306
|
scope: definition.scope,
|
|
1728
1307
|
bindings,
|
|
1729
|
-
display: bindings.map((binding) => formatBinding(binding,
|
|
1730
|
-
tokens: bindings.map((binding) => bindingTokens(binding,
|
|
1308
|
+
display: bindings.map((binding) => formatBinding(binding, platform)),
|
|
1309
|
+
tokens: bindings.map((binding) => bindingTokens(binding, platform)),
|
|
1731
1310
|
aria: bindings.map((binding) => ariaKeyShortcuts(binding, platform)).filter((value) => value.length > 0).join(" "),
|
|
1732
|
-
spoken: bindings.map((binding) => spokenBinding(binding, platform)).join(" or "),
|
|
1733
1311
|
...state
|
|
1734
1312
|
};
|
|
1735
1313
|
}
|
|
1736
|
-
/** Normalizes an arbitrary thrown value into a real `Error` at the catch boundary. */
|
|
1737
|
-
function toError(cause) {
|
|
1738
|
-
return cause instanceof Error ? cause : new Error(String(cause));
|
|
1739
|
-
}
|
|
1740
1314
|
/**
|
|
1741
1315
|
* Creates a command registry.
|
|
1742
1316
|
*
|
|
@@ -1756,42 +1330,4 @@ function createCommands(definitions, options) {
|
|
|
1756
1330
|
return new CommandRegistry(definitions, options);
|
|
1757
1331
|
}
|
|
1758
1332
|
//#endregion
|
|
1759
|
-
|
|
1760
|
-
/**
|
|
1761
|
-
* Filters commands for a search query, leaving out `hidden` ones. Every word
|
|
1762
|
-
* must appear in the title, description, group, keywords, id or displayed
|
|
1763
|
-
* shortcut. Titles starting with the query come first, then titles containing
|
|
1764
|
-
* every word, then the rest. An empty query returns every visible command.
|
|
1765
|
-
*
|
|
1766
|
-
* @example
|
|
1767
|
-
* ```ts
|
|
1768
|
-
* const results = matchCommands(commands.list(), "go inbox");
|
|
1769
|
-
* ```
|
|
1770
|
-
*/
|
|
1771
|
-
function matchCommands(commands, query) {
|
|
1772
|
-
const visible = commands.filter((command) => !command.hidden);
|
|
1773
|
-
const needle = query.trim().toLowerCase();
|
|
1774
|
-
if (!needle) return visible;
|
|
1775
|
-
const terms = needle.split(/\s+/);
|
|
1776
|
-
const ranked = [];
|
|
1777
|
-
for (const command of visible) {
|
|
1778
|
-
const title = command.title.toLowerCase();
|
|
1779
|
-
const text = [
|
|
1780
|
-
title,
|
|
1781
|
-
command.description,
|
|
1782
|
-
command.group,
|
|
1783
|
-
command.id,
|
|
1784
|
-
...command.keywords,
|
|
1785
|
-
...command.display
|
|
1786
|
-
].join("\n").toLowerCase();
|
|
1787
|
-
if (!terms.every((term) => text.includes(term))) continue;
|
|
1788
|
-
const rank = title.startsWith(needle) ? 0 : terms.every((term) => title.includes(term)) ? 1 : 2;
|
|
1789
|
-
ranked.push({
|
|
1790
|
-
command,
|
|
1791
|
-
rank
|
|
1792
|
-
});
|
|
1793
|
-
}
|
|
1794
|
-
return ranked.sort((a, b) => a.rank - b.rank).map(({ command }) => command);
|
|
1795
|
-
}
|
|
1796
|
-
//#endregion
|
|
1797
|
-
export { importKeymap as a, bindingTokens as c, isSingleKeyBinding as d, seq as f, exportKeymap as i, formatBinding as l, keyName$1 as m, createCommands as n, webStorage as o, isModifierKey as p, createRebinder as r, ariaKeyShortcuts as s, matchCommands as t, spokenBinding as u };
|
|
1333
|
+
export { webStorage as a, formatBinding as c, importKeymap as i, isSingleKeyBinding as l, createRebinder as n, ariaKeyShortcuts as o, exportKeymap as r, bindingTokens as s, createCommands as t, seq as u };
|