@inklu/keys 2.1.0 → 2.2.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 +138 -21
- package/dist/attributes.d.mts +1 -1
- package/dist/attributes.mjs +15 -18
- package/dist/{index-3d9f54ln.d.mts → index-DRXMJmbc.d.mts} +53 -36
- package/dist/index.d.mts +3 -3
- package/dist/index.mjs +2 -2
- package/dist/react.d.mts +17 -34
- package/dist/react.mjs +36 -74
- package/dist/{src-B76YvObL.mjs → src-TaZw4V61.mjs} +624 -514
- package/dist/{types-QEXldPnL.d.mts → types-D-hUKGoN.d.mts} +101 -142
- package/package.json +1 -1
|
@@ -1,15 +1,10 @@
|
|
|
1
1
|
//#region src/chord.ts
|
|
2
|
-
/** The platform
|
|
2
|
+
/** The platform, as `navigator` reports it. `"other"` where there is none. */
|
|
3
3
|
function detectPlatform() {
|
|
4
|
-
if (typeof navigator === "undefined") return "
|
|
5
|
-
|
|
6
|
-
if (platform.includes("mac")) return "mac";
|
|
7
|
-
return platform.includes("win") ? "windows" : "linux";
|
|
4
|
+
if (typeof navigator === "undefined") return "other";
|
|
5
|
+
return /mac/i.test(`${navigator.platform} ${navigator.userAgent}`) ? "mac" : "other";
|
|
8
6
|
}
|
|
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
|
-
*/
|
|
7
|
+
/** Named keys in the `Key` type, as `KeyboardEvent.key` spells them. Other names still work. */
|
|
13
8
|
const NAMED_KEYS = [
|
|
14
9
|
"ArrowUp",
|
|
15
10
|
"ArrowDown",
|
|
@@ -65,7 +60,7 @@ function split(chord) {
|
|
|
65
60
|
return parts.map((part) => part.trim());
|
|
66
61
|
}
|
|
67
62
|
/** One spelling per key: letters upper-cased, the space bar by name. */
|
|
68
|
-
function keyName(key) {
|
|
63
|
+
function keyName$1(key) {
|
|
69
64
|
if (key === " ") return "Space";
|
|
70
65
|
if (!LETTER.test(key)) return key;
|
|
71
66
|
const upper = key.toUpperCase();
|
|
@@ -73,7 +68,7 @@ function keyName(key) {
|
|
|
73
68
|
}
|
|
74
69
|
/** The key a keypress produced. Empty for the keyless events browser autofill dispatches. */
|
|
75
70
|
function pressedKey(event) {
|
|
76
|
-
return keyName(String(event.key ?? "").normalize("NFC"));
|
|
71
|
+
return keyName$1(String(event.key ?? "").normalize("NFC"));
|
|
77
72
|
}
|
|
78
73
|
function isOneCharacter(key) {
|
|
79
74
|
return Array.from(key).length === 1;
|
|
@@ -100,7 +95,7 @@ function read(chord, platform) {
|
|
|
100
95
|
alt: held.has("Alt"),
|
|
101
96
|
shift: held.has("Shift"),
|
|
102
97
|
meta: held.has("Meta"),
|
|
103
|
-
...code === void 0 ? { key: keyName(key) } : { code }
|
|
98
|
+
...code === void 0 ? { key: keyName$1(key) } : { code }
|
|
104
99
|
};
|
|
105
100
|
}
|
|
106
101
|
/** Parses a chord for a platform, or returns `null` for one that cannot be read. */
|
|
@@ -112,10 +107,7 @@ function parseChord(chord, platform) {
|
|
|
112
107
|
function isChord(text) {
|
|
113
108
|
return parseChord(text, "mac") !== null;
|
|
114
109
|
}
|
|
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
|
-
*/
|
|
110
|
+
/** A chord's `errors` (it will never fire) and `warnings` (a key name this package does not know). */
|
|
119
111
|
function validateChord(chord) {
|
|
120
112
|
const result = read(chord, "mac");
|
|
121
113
|
if (typeof result === "string") return {
|
|
@@ -128,11 +120,7 @@ function validateChord(chord) {
|
|
|
128
120
|
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
121
|
};
|
|
130
122
|
}
|
|
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
|
-
*/
|
|
123
|
+
/** Writes a parsed chord as text. Given a platform, its primary modifier is written `Mod`. */
|
|
136
124
|
function writeChord(chord, mod) {
|
|
137
125
|
const portable = mod !== void 0 && (mod === "mac" ? chord.meta && !chord.ctrl : chord.ctrl && !chord.meta);
|
|
138
126
|
const parts = portable ? ["Mod"] : [];
|
|
@@ -143,11 +131,7 @@ function writeChord(chord, mod) {
|
|
|
143
131
|
parts.push(chord.code === void 0 ? chord.key ?? "" : `[${chord.code}]`);
|
|
144
132
|
return parts.join("+");
|
|
145
133
|
}
|
|
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
|
-
*/
|
|
134
|
+
/** Whether a chord types a character: a one-character key with no Control, Alt or Meta. */
|
|
151
135
|
function typesCharacter(chord) {
|
|
152
136
|
if (chord.ctrl || chord.alt || chord.meta) return false;
|
|
153
137
|
return isOneCharacter(chord.key ?? keyAtCode(chord.code ?? "") ?? "");
|
|
@@ -165,10 +149,7 @@ const MODIFIER_KEYS = /* @__PURE__ */ new Set([
|
|
|
165
149
|
function isModifierKey(key) {
|
|
166
150
|
return MODIFIER_KEYS.has(key);
|
|
167
151
|
}
|
|
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
|
-
*/
|
|
152
|
+
/** Whether AltGr is down. Windows reports it as Control+Alt, though a character is being typed. */
|
|
172
153
|
function hasAltGraph(event, platform) {
|
|
173
154
|
return platform !== "mac" && event.getModifierState?.("AltGraph") === true;
|
|
174
155
|
}
|
|
@@ -192,6 +173,15 @@ function keyAtCode(code) {
|
|
|
192
173
|
if (/^Digit[0-9]$/.test(code)) return code.slice(5);
|
|
193
174
|
return code === "Space" ? code : PUNCTUATION_CODES[code];
|
|
194
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
|
+
}
|
|
195
185
|
/** `KeyboardEvent.key` values that name neither a character nor a key. */
|
|
196
186
|
const NO_KEY = /* @__PURE__ */ new Set([
|
|
197
187
|
"",
|
|
@@ -205,20 +195,14 @@ const isSymbol = (key) => isOneCharacter(key) && !LETTER.test(key);
|
|
|
205
195
|
/** A key known by name rather than by a character, such as `Enter`. */
|
|
206
196
|
const isNamed = (key) => !isOneCharacter(key) && !NO_KEY.has(key);
|
|
207
197
|
/**
|
|
208
|
-
* The key at a keypress's position on the
|
|
209
|
-
*
|
|
210
|
-
* nothing a chord would name:
|
|
198
|
+
* The key at a keypress's position on the US layout, where position may stand
|
|
199
|
+
* in for what the layout produced:
|
|
211
200
|
*
|
|
212
|
-
* -
|
|
213
|
-
*
|
|
214
|
-
* -
|
|
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.
|
|
201
|
+
* - on the digit row, always (`Mod+1` on AZERTY, `Shift+2` on a US keyboard);
|
|
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.
|
|
219
204
|
*
|
|
220
|
-
* Never
|
|
221
|
-
* the key pressed), nor under AltGr (the user is typing a character).
|
|
205
|
+
* Never for a Latin letter, a named key, or under AltGr.
|
|
222
206
|
*/
|
|
223
207
|
function positionKey(event, key, altGraph) {
|
|
224
208
|
if (altGraph || /^[A-Z]$/.test(key) || isNamed(key)) return void 0;
|
|
@@ -229,17 +213,12 @@ function positionKey(event, key, altGraph) {
|
|
|
229
213
|
return isAscii(key) && !event.shiftKey && !event.altKey ? void 0 : PUNCTUATION_CODES[code];
|
|
230
214
|
}
|
|
231
215
|
/**
|
|
232
|
-
* Whether a keypress is this chord.
|
|
233
|
-
*
|
|
234
|
-
*
|
|
235
|
-
*
|
|
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.
|
|
216
|
+
* Whether a keypress is this chord. A physical chord (`[KeyK]`) compares
|
|
217
|
+
* position only. A logical one matches the key produced, where modifiers that
|
|
218
|
+
* only typed a symbol need not be named (`?` is Shift+/ on a US keyboard), or
|
|
219
|
+
* else the key's position where `positionKey` allows.
|
|
241
220
|
*
|
|
242
|
-
*
|
|
221
|
+
* Follows the matcher in TanStack Hotkeys (MIT).
|
|
243
222
|
*/
|
|
244
223
|
function matchesChord(event, chord, platform) {
|
|
245
224
|
const altGraph = hasAltGraph(event, platform);
|
|
@@ -252,14 +231,10 @@ function matchesChord(event, chord, platform) {
|
|
|
252
231
|
return positionKey(event, key, altGraph) === chord.key && modifiersMatch(false);
|
|
253
232
|
}
|
|
254
233
|
/**
|
|
255
|
-
* The chord a keypress expresses, for recording a
|
|
256
|
-
*
|
|
257
|
-
*
|
|
258
|
-
*
|
|
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.
|
|
234
|
+
* The chord a keypress expresses, for recording a shortcut, or `null` for a
|
|
235
|
+
* modifier on its own or a key with no name. It records what `matchesChord`
|
|
236
|
+
* then matches: the character typed (`?`, not `Shift+/`), else the key's
|
|
237
|
+
* position, with the primary modifier written `Mod`.
|
|
263
238
|
*/
|
|
264
239
|
function readChord(event, platform) {
|
|
265
240
|
if (isModifierKey(event.key)) return null;
|
|
@@ -301,12 +276,7 @@ function isSequence(binding) {
|
|
|
301
276
|
function stepsOf(binding) {
|
|
302
277
|
return isSequence(binding) ? binding.sequence : [binding];
|
|
303
278
|
}
|
|
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
|
-
*/
|
|
279
|
+
/** Normalizes a binding input into an array of bindings. A one-step sequence becomes a chord. */
|
|
310
280
|
function toBindings(input) {
|
|
311
281
|
if (input == null) return [];
|
|
312
282
|
const candidates = isList(input) ? input : [input];
|
|
@@ -323,16 +293,20 @@ function toBindings(input) {
|
|
|
323
293
|
function isList(input) {
|
|
324
294
|
return Array.isArray(input);
|
|
325
295
|
}
|
|
296
|
+
/** The side of `{ mac, other }` keys that applies on a platform. Keys that do not differ pass through. */
|
|
297
|
+
function keysFor(keys, platform) {
|
|
298
|
+
if (keys == null || !isPerPlatform(keys)) return keys;
|
|
299
|
+
return platform === "mac" ? keys.mac : keys.other;
|
|
300
|
+
}
|
|
301
|
+
function isPerPlatform(keys) {
|
|
302
|
+
return typeof keys === "object" && !Array.isArray(keys) && !("sequence" in keys);
|
|
303
|
+
}
|
|
326
304
|
/** Generates a normalized signature for binding comparison and conflict detection. */
|
|
327
305
|
function bindingSignature(binding, platform) {
|
|
328
306
|
const steps = canonicalSteps(binding, platform);
|
|
329
307
|
return isSequence(binding) ? `seq:${steps.join(" ")}` : `chord:${steps.join(" ")}`;
|
|
330
308
|
}
|
|
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
|
-
*/
|
|
309
|
+
/** Whether one binding is a strict prefix of the other, like chord `G` against `G then I`. */
|
|
336
310
|
function isPrefixCollision(a, b, platform) {
|
|
337
311
|
const left = canonicalSteps(a, platform);
|
|
338
312
|
const right = canonicalSteps(b, platform);
|
|
@@ -358,53 +332,23 @@ function canonicalSteps(binding, platform = detectPlatform()) {
|
|
|
358
332
|
});
|
|
359
333
|
}
|
|
360
334
|
/**
|
|
361
|
-
* Whether every step
|
|
362
|
-
*
|
|
363
|
-
* turn off, since speech input and stray keypresses trigger them by accident.
|
|
335
|
+
* Whether every step is a printable character with no Control, Alt or Meta:
|
|
336
|
+
* the "character key shortcuts" WCAG 2.1.4 lets users turn off.
|
|
364
337
|
*/
|
|
365
338
|
function isSingleKeyBinding(binding) {
|
|
366
339
|
return parseBinding(binding, "mac")?.every(typesCharacter) === true;
|
|
367
340
|
}
|
|
368
|
-
/**
|
|
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. */
|
|
341
|
+
/** A binding's syntax `errors` (it will never fire) and `warnings`, for the development audit. */
|
|
390
342
|
function validateBinding(binding) {
|
|
391
|
-
const
|
|
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);
|
|
343
|
+
const results = stepsOf(binding).map(validateChord);
|
|
399
344
|
return {
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
warnings: results.flatMap((r) => r.warnings)
|
|
345
|
+
errors: results.flatMap((result) => result.errors),
|
|
346
|
+
warnings: results.flatMap((result) => result.warnings)
|
|
403
347
|
};
|
|
404
348
|
}
|
|
405
349
|
//#endregion
|
|
406
350
|
//#region src/display.ts
|
|
407
|
-
/**
|
|
351
|
+
/** Writing a binding for people: on screen, in `aria-keyshortcuts`, and read aloud. */
|
|
408
352
|
const MODIFIER_LABELS = {
|
|
409
353
|
symbols: [
|
|
410
354
|
"⌃",
|
|
@@ -418,32 +362,94 @@ const MODIFIER_LABELS = {
|
|
|
418
362
|
"Shift",
|
|
419
363
|
"Cmd"
|
|
420
364
|
],
|
|
421
|
-
|
|
365
|
+
other: [
|
|
422
366
|
"Ctrl",
|
|
423
367
|
"Alt",
|
|
424
368
|
"Shift",
|
|
425
|
-
"
|
|
426
|
-
],
|
|
427
|
-
linux: [
|
|
428
|
-
"Ctrl",
|
|
429
|
-
"Alt",
|
|
430
|
-
"Shift",
|
|
431
|
-
"Super"
|
|
369
|
+
"Meta"
|
|
432
370
|
]
|
|
433
371
|
};
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
372
|
+
/** The abbreviated modifier labels, in full, for reading aloud. */
|
|
373
|
+
const SPOKEN_MODIFIERS = {
|
|
374
|
+
Ctrl: "Control",
|
|
375
|
+
Cmd: "Command"
|
|
376
|
+
};
|
|
377
|
+
const KEY_LABELS = {
|
|
378
|
+
Enter: [
|
|
379
|
+
"↩",
|
|
380
|
+
"Enter",
|
|
381
|
+
"Return",
|
|
382
|
+
"Enter"
|
|
383
|
+
],
|
|
384
|
+
Backspace: [
|
|
385
|
+
"⌫",
|
|
386
|
+
"Backspace",
|
|
387
|
+
"Delete",
|
|
388
|
+
"Backspace"
|
|
389
|
+
],
|
|
390
|
+
Delete: [
|
|
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
|
+
]
|
|
445
451
|
};
|
|
446
|
-
/** Names for the keys whose character is hard to tell apart in running text. */
|
|
452
|
+
/** Names for the keys whose character is hard to tell apart in running text, or to hear. */
|
|
447
453
|
const PUNCTUATION_NAMES = {
|
|
448
454
|
"`": "Backquote",
|
|
449
455
|
"\\": "Backslash",
|
|
@@ -453,21 +459,28 @@ const PUNCTUATION_NAMES = {
|
|
|
453
459
|
"=": "Equal",
|
|
454
460
|
"-": "Minus",
|
|
455
461
|
".": "Period",
|
|
456
|
-
";": "Semicolon"
|
|
462
|
+
";": "Semicolon",
|
|
463
|
+
"/": "Slash",
|
|
464
|
+
"?": "Question Mark"
|
|
457
465
|
};
|
|
458
|
-
|
|
466
|
+
const spaced = (name) => name.replace(/([a-z])([A-Z])/g, "$1 $2");
|
|
467
|
+
/**
|
|
468
|
+
* Formats a binding the way its platform writes shortcuts: `⇧⌘S` on a Mac,
|
|
469
|
+
* `Ctrl+Shift+S` elsewhere. The steps of a sequence are set apart by a space.
|
|
470
|
+
*/
|
|
459
471
|
function formatBinding(binding, options) {
|
|
460
|
-
|
|
461
|
-
return formatChord(binding, options);
|
|
472
|
+
return stepsOf(binding).map((step) => formatChord(step, options)).join(" ");
|
|
462
473
|
}
|
|
463
|
-
/**
|
|
474
|
+
/**
|
|
475
|
+
* Splits a binding into display tokens for rendering `<kbd>` elements: one
|
|
476
|
+
* per key of a chord, one per step of a sequence.
|
|
477
|
+
*/
|
|
464
478
|
function bindingTokens(binding, options) {
|
|
465
479
|
if (isSequence(binding)) return binding.sequence.map((step) => formatChord(step, options));
|
|
466
480
|
return chordTokens(binding, options);
|
|
467
481
|
}
|
|
468
482
|
function formatChord(chord, options) {
|
|
469
|
-
|
|
470
|
-
return chordTokens(chord, options).join(spaced ? " " : "+");
|
|
483
|
+
return chordTokens(chord, options).join(usesModifierSymbols(options) ? "" : "+");
|
|
471
484
|
}
|
|
472
485
|
function usesModifierSymbols(options) {
|
|
473
486
|
return (options?.platform ?? detectPlatform()) === "mac" && options?.useSymbols !== false;
|
|
@@ -477,46 +490,308 @@ function chordTokens(chord, options) {
|
|
|
477
490
|
const parsed = parseChord(chord, platform);
|
|
478
491
|
if (!parsed) return [chord];
|
|
479
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) {
|
|
480
496
|
const held = [
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
497
|
+
chord.ctrl,
|
|
498
|
+
chord.alt,
|
|
499
|
+
chord.shift,
|
|
500
|
+
chord.meta
|
|
485
501
|
];
|
|
486
|
-
return
|
|
502
|
+
return labels.filter((_, index) => held[index]);
|
|
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);
|
|
487
509
|
}
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
const label =
|
|
491
|
-
if (
|
|
492
|
-
|
|
493
|
-
return label(keyAtCode(chord.code) ?? chord.code);
|
|
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);
|
|
494
515
|
}
|
|
495
516
|
/**
|
|
496
|
-
* Phrases a binding for a screen reader: `"
|
|
497
|
-
* `"G then I"` for 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`.
|
|
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.
|
|
500
519
|
*/
|
|
501
|
-
function spokenBinding(binding, platform) {
|
|
502
|
-
return stepsOf(binding).map((step) =>
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
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 ");
|
|
507
526
|
}
|
|
508
|
-
/**
|
|
509
|
-
|
|
510
|
-
|
|
527
|
+
/**
|
|
528
|
+
* The `aria-keyshortcuts` value for a chord, as in `"Meta+K"`. Empty for a
|
|
529
|
+
* sequence and for a chord that cannot be read.
|
|
530
|
+
*/
|
|
531
|
+
function ariaKeyShortcuts(binding, platform = detectPlatform()) {
|
|
532
|
+
const parsed = isSequence(binding) ? null : parseChord(binding, platform);
|
|
511
533
|
if (!parsed) return "";
|
|
512
|
-
const
|
|
513
|
-
return writeChord({
|
|
514
|
-
...
|
|
515
|
-
key
|
|
516
|
-
|
|
534
|
+
const chord = namedChord(parsed);
|
|
535
|
+
return writeChord(chord.key === "+" ? {
|
|
536
|
+
...chord,
|
|
537
|
+
key: "Plus"
|
|
538
|
+
} : chord);
|
|
539
|
+
}
|
|
540
|
+
//#endregion
|
|
541
|
+
//#region src/keymap.ts
|
|
542
|
+
/** Keymap: default bindings, and the user's overrides on top of them. */
|
|
543
|
+
/** A signature that holds on every platform, so `Mod+S` and `Control+S` stay distinct. */
|
|
544
|
+
function portableSignature(binding) {
|
|
545
|
+
return `${bindingSignature(binding, "mac")} ${bindingSignature(binding, "other")}`;
|
|
546
|
+
}
|
|
547
|
+
var Keymap = class {
|
|
548
|
+
#defaults = /* @__PURE__ */ new Map();
|
|
549
|
+
#overrides = /* @__PURE__ */ new Map();
|
|
550
|
+
#listeners = /* @__PURE__ */ new Set();
|
|
551
|
+
#store;
|
|
552
|
+
#loaded = false;
|
|
553
|
+
constructor(store) {
|
|
554
|
+
this.#store = store;
|
|
555
|
+
}
|
|
556
|
+
/** Sets a command's default bindings. `null` leaves the command unbound. */
|
|
557
|
+
setDefault(id, input) {
|
|
558
|
+
const bindings = toBindings(input);
|
|
559
|
+
if (bindings.length === 0) this.#defaults.delete(id);
|
|
560
|
+
else this.#defaults.set(id, bindings);
|
|
561
|
+
}
|
|
562
|
+
/** Checks whether a command has an active user override. */
|
|
563
|
+
isOverridden(id) {
|
|
564
|
+
return this.#overrides.has(id);
|
|
565
|
+
}
|
|
566
|
+
/** The user's overrides, for a store or an export to write out. */
|
|
567
|
+
get overrides() {
|
|
568
|
+
return this.#overrides;
|
|
569
|
+
}
|
|
570
|
+
get(id) {
|
|
571
|
+
const defaults = this.#defaults.get(id) ?? [];
|
|
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)];
|
|
576
|
+
}
|
|
577
|
+
set(id, input) {
|
|
578
|
+
this.#load();
|
|
579
|
+
const bindings = input === null ? [] : toBindings(input);
|
|
580
|
+
if (sameBindings(bindings, this.#defaults.get(id) ?? [])) {
|
|
581
|
+
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
|
+
});
|
|
597
|
+
this.#changed();
|
|
598
|
+
}
|
|
599
|
+
reset(id) {
|
|
600
|
+
this.#load();
|
|
601
|
+
if (id === void 0) {
|
|
602
|
+
if (this.#overrides.size === 0) return;
|
|
603
|
+
this.#overrides.clear();
|
|
604
|
+
} else if (!this.#overrides.delete(id)) return;
|
|
605
|
+
this.#changed();
|
|
606
|
+
}
|
|
607
|
+
/** Replaces every override and saves, as an import does. */
|
|
608
|
+
replace(overrides) {
|
|
609
|
+
this.#loaded = true;
|
|
610
|
+
this.#overrides = overrides;
|
|
611
|
+
this.#changed();
|
|
612
|
+
}
|
|
613
|
+
/**
|
|
614
|
+
* Takes the stored overrides, telling subscribers if they differ from the
|
|
615
|
+
* ones in memory, and returns whether they did. Each mount starts with it,
|
|
616
|
+
* which catches up on what other tabs stored while nothing was listening.
|
|
617
|
+
*/
|
|
618
|
+
sync() {
|
|
619
|
+
this.#loaded = true;
|
|
620
|
+
const stored = this.#store?.read();
|
|
621
|
+
if (!stored) return false;
|
|
622
|
+
const before = JSON.stringify([...this.#overrides]);
|
|
623
|
+
this.#overrides = stored;
|
|
624
|
+
if (JSON.stringify([...stored]) === before) return false;
|
|
625
|
+
this.#notify();
|
|
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()) ?? (() => {});
|
|
631
|
+
}
|
|
632
|
+
/** Subscribes to keymap changes. Returns an unsubscribe function. */
|
|
633
|
+
subscribe(listener) {
|
|
634
|
+
this.#listeners.add(listener);
|
|
635
|
+
return () => {
|
|
636
|
+
this.#listeners.delete(listener);
|
|
637
|
+
};
|
|
638
|
+
}
|
|
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
|
+
#changed() {
|
|
644
|
+
this.#store?.write(this.#overrides);
|
|
645
|
+
this.#notify();
|
|
646
|
+
}
|
|
647
|
+
#notify() {
|
|
648
|
+
for (const listener of this.#listeners) listener();
|
|
649
|
+
}
|
|
650
|
+
};
|
|
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
|
+
/** Same bindings in the same order; the first is the one shortcuts display. */
|
|
662
|
+
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);
|
|
517
666
|
});
|
|
518
667
|
}
|
|
519
668
|
//#endregion
|
|
669
|
+
//#region src/persist.ts
|
|
670
|
+
/** Keeping a keymap between visits: Web Storage, and export and import as JSON. */
|
|
671
|
+
const VERSION = 1;
|
|
672
|
+
/**
|
|
673
|
+
* Keeps user overrides in Web Storage under `key`, and follows edits made in
|
|
674
|
+
* other tabs. `storage` is only called in the browser; return
|
|
675
|
+
* `sessionStorage` or an object of your own to keep them somewhere else.
|
|
676
|
+
*
|
|
677
|
+
* @example
|
|
678
|
+
* ```ts
|
|
679
|
+
* createCommands(definitions, { persist: webStorage("my-app.keys") });
|
|
680
|
+
* ```
|
|
681
|
+
*/
|
|
682
|
+
function webStorage(key = "inklu.keys", storage = () => localStorage) {
|
|
683
|
+
const open = () => {
|
|
684
|
+
try {
|
|
685
|
+
return storage();
|
|
686
|
+
} catch {
|
|
687
|
+
return null;
|
|
688
|
+
}
|
|
689
|
+
};
|
|
690
|
+
return {
|
|
691
|
+
read() {
|
|
692
|
+
const area = open();
|
|
693
|
+
if (!area) return null;
|
|
694
|
+
try {
|
|
695
|
+
const raw = area.getItem(key);
|
|
696
|
+
return raw ? parseKeymap(JSON.parse(raw)) : /* @__PURE__ */ new Map();
|
|
697
|
+
} catch {
|
|
698
|
+
return null;
|
|
699
|
+
}
|
|
700
|
+
},
|
|
701
|
+
write(overrides) {
|
|
702
|
+
try {
|
|
703
|
+
if (overrides.size === 0) open()?.removeItem(key);
|
|
704
|
+
else open()?.setItem(key, JSON.stringify(serializeKeymap(overrides)));
|
|
705
|
+
} 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
|
+
}
|
|
715
|
+
};
|
|
716
|
+
}
|
|
717
|
+
/** A registry's user overrides as JSON-safe data, for your own storage or another device. */
|
|
718
|
+
function exportKeymap(commands) {
|
|
719
|
+
return serializeKeymap(keymapOf(commands).overrides);
|
|
720
|
+
}
|
|
721
|
+
/**
|
|
722
|
+
* Replaces a registry's user overrides with exported data, and saves them.
|
|
723
|
+
* Data in another format changes nothing.
|
|
724
|
+
*/
|
|
725
|
+
function importKeymap(commands, data) {
|
|
726
|
+
const overrides = parseKeymap(data);
|
|
727
|
+
if (overrides) keymapOf(commands).replace(overrides);
|
|
728
|
+
}
|
|
729
|
+
function keymapOf({ keymap }) {
|
|
730
|
+
if (keymap instanceof Keymap) return keymap;
|
|
731
|
+
throw new TypeError("[@inklu/keys] not a registry made by createCommands");
|
|
732
|
+
}
|
|
733
|
+
function serializeKeymap(overrides) {
|
|
734
|
+
return {
|
|
735
|
+
version: VERSION,
|
|
736
|
+
overrides: Object.fromEntries(Array.from(overrides, ([id, { kind, bindings }]) => {
|
|
737
|
+
const list = bindings.map((binding) => isSequence(binding) ? [...binding.sequence] : binding);
|
|
738
|
+
return [id, kind === "replace" ? list : { add: list }];
|
|
739
|
+
}))
|
|
740
|
+
};
|
|
741
|
+
}
|
|
742
|
+
/** The overrides in stored data, or `null` for data in another format. */
|
|
743
|
+
function parseKeymap(data) {
|
|
744
|
+
const stored = isObject(data) && data.version === VERSION ? data.overrides : void 0;
|
|
745
|
+
if (stored === void 0 || !isObject(stored)) return null;
|
|
746
|
+
const overrides = /* @__PURE__ */ new Map();
|
|
747
|
+
for (const [id, value] of Object.entries(stored)) {
|
|
748
|
+
const override = parseOverride(value);
|
|
749
|
+
if (override) overrides.set(id, override);
|
|
750
|
+
}
|
|
751
|
+
return overrides;
|
|
752
|
+
}
|
|
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
|
+
/** A stored chord (a string) or sequence (a list of them), or `null` if it cannot be read. */
|
|
779
|
+
function parseStoredBinding(value) {
|
|
780
|
+
if (typeof value === "string") return isChord(value) ? value : null;
|
|
781
|
+
if (!Array.isArray(value)) return null;
|
|
782
|
+
const steps = [];
|
|
783
|
+
for (const step of value) {
|
|
784
|
+
if (typeof step !== "string" || !isChord(step)) return null;
|
|
785
|
+
steps.push(step);
|
|
786
|
+
}
|
|
787
|
+
const [first, ...rest] = steps;
|
|
788
|
+
if (first === void 0) return null;
|
|
789
|
+
return rest.length === 0 ? first : { sequence: steps };
|
|
790
|
+
}
|
|
791
|
+
function isObject(value) {
|
|
792
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
793
|
+
}
|
|
794
|
+
//#endregion
|
|
520
795
|
//#region src/keys.ts
|
|
521
796
|
/** The one keyboard listener: matches a keypress to bindings and runs the command that wins it. */
|
|
522
797
|
const DEFAULT_SEQUENCE_TIMEOUT = 1e3;
|
|
@@ -560,11 +835,7 @@ var KeyListener = class {
|
|
|
560
835
|
get captured() {
|
|
561
836
|
return this.#captures.length > 0;
|
|
562
837
|
}
|
|
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
|
-
*/
|
|
838
|
+
/** Hands every keypress to `onKey` until released, in the capture phase on the document. */
|
|
568
839
|
capture(onKey) {
|
|
569
840
|
if (typeof document === "undefined") return () => {};
|
|
570
841
|
this.#captures.push(onKey);
|
|
@@ -583,12 +854,7 @@ var KeyListener = class {
|
|
|
583
854
|
this.#bound = null;
|
|
584
855
|
this.#publish();
|
|
585
856
|
}
|
|
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
|
-
*/
|
|
857
|
+
/** Reachability changed: a half-typed sequence that is no longer reachable starts over. */
|
|
592
858
|
prune() {
|
|
593
859
|
let changed = false;
|
|
594
860
|
for (const bound of this.#bound ?? []) {
|
|
@@ -659,7 +925,7 @@ var KeyListener = class {
|
|
|
659
925
|
const definition = this.#host.definition(bound.id);
|
|
660
926
|
const first = bound.steps[0];
|
|
661
927
|
if (!definition || !first || !this.#host.reachable(bound.id)) continue;
|
|
662
|
-
if (typing && (definition.ignoreInputs ?? ignoresInputs(first))) continue;
|
|
928
|
+
if (typing && (definition.ignoreInputs ?? ignoresInputs(first, platform))) continue;
|
|
663
929
|
if (bound.steps.length === 1) {
|
|
664
930
|
if (matchesChord(event, first, platform)) chords.push(bound);
|
|
665
931
|
continue;
|
|
@@ -688,9 +954,14 @@ var KeyListener = class {
|
|
|
688
954
|
const candidates = sequences.length > 0 ? sequences : continued ? [] : chords;
|
|
689
955
|
let winner;
|
|
690
956
|
for (const bound of candidates) if (!winner || this.#outranks(bound, winner)) winner = bound;
|
|
691
|
-
|
|
692
|
-
if (
|
|
693
|
-
|
|
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);
|
|
694
965
|
};
|
|
695
966
|
#outranks(a, b) {
|
|
696
967
|
const depth = this.#host.depth(a.id) - this.#host.depth(b.id);
|
|
@@ -726,53 +997,71 @@ var KeyListener = class {
|
|
|
726
997
|
};
|
|
727
998
|
};
|
|
728
999
|
/**
|
|
729
|
-
*
|
|
730
|
-
*
|
|
731
|
-
* engines report composition only as `keyCode` 229 or the key `Process`.
|
|
1000
|
+
* Whether a keydown belongs to an IME composition. Some engines report it
|
|
1001
|
+
* only as `keyCode` 229 or the key `Process`.
|
|
732
1002
|
*/
|
|
733
1003
|
function isComposing(event) {
|
|
734
1004
|
return event.isComposing || event.keyCode === 229 || event.key === "Process";
|
|
735
1005
|
}
|
|
736
1006
|
/**
|
|
737
|
-
* Whether a binding stays quiet
|
|
738
|
-
*
|
|
739
|
-
*
|
|
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.
|
|
740
1010
|
*/
|
|
741
|
-
function ignoresInputs(step) {
|
|
742
|
-
|
|
1011
|
+
function ignoresInputs(step, platform) {
|
|
1012
|
+
if (step.key === "Escape" || step.code === "Escape") return false;
|
|
1013
|
+
return !(step.ctrl || step.meta) || editsText(step, platform);
|
|
743
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+)$/;
|
|
744
1017
|
/**
|
|
745
|
-
* Whether a
|
|
746
|
-
*
|
|
747
|
-
*
|
|
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.
|
|
748
1032
|
*/
|
|
749
1033
|
function isTyping(event, host) {
|
|
750
1034
|
return event.composedPath().some((node) => node !== host && isTextEntry(node));
|
|
751
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
|
+
]);
|
|
752
1044
|
function isTextEntry(node) {
|
|
753
|
-
if (
|
|
1045
|
+
if (!(node instanceof HTMLElement)) return false;
|
|
754
1046
|
if (node instanceof HTMLInputElement) {
|
|
755
1047
|
const type = node.type.toLowerCase();
|
|
756
1048
|
return type !== "button" && type !== "submit" && type !== "reset";
|
|
757
1049
|
}
|
|
758
|
-
return node instanceof HTMLTextAreaElement || node instanceof HTMLSelectElement || !["false", null].includes(node.getAttribute("contenteditable"));
|
|
1050
|
+
return node instanceof HTMLTextAreaElement || node instanceof HTMLSelectElement || !["false", null].includes(node.getAttribute("contenteditable")) || TEXT_ENTRY_ROLES.has(node.getAttribute("role") ?? "");
|
|
759
1051
|
}
|
|
760
1052
|
//#endregion
|
|
761
1053
|
//#region src/rebind.ts
|
|
762
1054
|
/** Controller for recording and applying hotkey rebindings. */
|
|
763
1055
|
const NO_STEPS = [];
|
|
764
1056
|
/**
|
|
765
|
-
* Creates a rebinder
|
|
766
|
-
*
|
|
767
|
-
*
|
|
768
|
-
* last step of a sequence), and Enter finishes a sequence.
|
|
1057
|
+
* Creates a rebinder for recording a command's keys. While it records, no
|
|
1058
|
+
* command runs. Escape cancels, Backspace removes the shortcut (or the last
|
|
1059
|
+
* step of a sequence), and Enter finishes a sequence.
|
|
769
1060
|
*
|
|
770
1061
|
* @example
|
|
771
1062
|
* ```ts
|
|
772
1063
|
* const rebinder = createRebinder(commands, "palette.open", {
|
|
773
|
-
* onRecord: (binding, conflicts) =>
|
|
774
|
-
* if (conflicts.length) console.warn("Conflict with", conflicts[0].id);
|
|
775
|
-
* },
|
|
1064
|
+
* onRecord: (binding, conflicts) => showConflicts(conflicts),
|
|
776
1065
|
* });
|
|
777
1066
|
* rebinder.start();
|
|
778
1067
|
* ```
|
|
@@ -805,7 +1094,7 @@ function createRebinder(registry, id, options = {}) {
|
|
|
805
1094
|
ignore: id,
|
|
806
1095
|
scope: registry.get(id)?.scope
|
|
807
1096
|
});
|
|
808
|
-
const blocked = options.rejectConflicts === true && conflicts.length > 0;
|
|
1097
|
+
const blocked = conflicts.some((conflict) => conflict.kind === "reserved") || options.rejectConflicts === true && conflicts.length > 0;
|
|
809
1098
|
if (options.apply !== false && !blocked) {
|
|
810
1099
|
if (options.add) registry.keymap.add(id, binding);
|
|
811
1100
|
else registry.keymap.set(id, binding);
|
|
@@ -908,11 +1197,25 @@ function createRebinder(registry, id, options = {}) {
|
|
|
908
1197
|
}
|
|
909
1198
|
};
|
|
910
1199
|
}
|
|
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
|
+
}
|
|
911
1215
|
/**
|
|
912
|
-
* Whether commands in two scopes
|
|
913
|
-
*
|
|
914
|
-
*
|
|
915
|
-
* by which was opened last.
|
|
1216
|
+
* Whether commands in two scopes can be live together, so their bindings can
|
|
1217
|
+
* collide: a global or `"*"` command overlaps every scope, and two named
|
|
1218
|
+
* scopes only when they are the same.
|
|
916
1219
|
*/
|
|
917
1220
|
function scopesOverlap(a, b) {
|
|
918
1221
|
if (a === void 0 || b === void 0) return true;
|
|
@@ -948,17 +1251,11 @@ var ScopeStack = class {
|
|
|
948
1251
|
if (barrier === -1) return this.isOpen(scope);
|
|
949
1252
|
return scope !== void 0 && this.depth(scope) >= barrier;
|
|
950
1253
|
}
|
|
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
|
-
*/
|
|
1254
|
+
/** Whether `scope` is open at all, barriers aside. This is what a direct `run()` checks. */
|
|
955
1255
|
isOpen(scope) {
|
|
956
1256
|
return scope === void 0 || scope === "*" || this.depth(scope) !== -1;
|
|
957
1257
|
}
|
|
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
|
-
*/
|
|
1258
|
+
/** How far up the stack `scope` was last opened, or `-1` for a global or closed one. */
|
|
962
1259
|
depth(scope) {
|
|
963
1260
|
if (scope === void 0 || scope === "*") return -1;
|
|
964
1261
|
for (let index = this.#entries.length - 1; index >= 0; index--) if (this.#entries[index]?.name === scope) return index;
|
|
@@ -987,26 +1284,58 @@ var ScopeStack = class {
|
|
|
987
1284
|
};
|
|
988
1285
|
//#endregion
|
|
989
1286
|
//#region src/audit.ts
|
|
990
|
-
/** Development-only warnings about bindings that cannot work as written. */
|
|
1287
|
+
/** Development-only warnings about default bindings that cannot work as written. */
|
|
1288
|
+
const platformName = (platform) => platform === "mac" ? "macOS" : "Windows and Linux";
|
|
1289
|
+
/** A binding as the app wrote it, which is what there is to search the code for. */
|
|
1290
|
+
const written = (binding) => stepsOf(binding).join(" ");
|
|
991
1291
|
/**
|
|
992
|
-
* Warns about
|
|
993
|
-
*
|
|
994
|
-
* this
|
|
1292
|
+
* Warns about bad syntax, keys the platform keeps or types with, unknown
|
|
1293
|
+
* overrides and collisions. Called behind a `NODE_ENV` check, so a production
|
|
1294
|
+
* build drops this module.
|
|
995
1295
|
*/
|
|
996
1296
|
function auditRegistry(source) {
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1297
|
+
const said = /* @__PURE__ */ new Set();
|
|
1298
|
+
const warn = (message) => {
|
|
1299
|
+
if (said.has(message)) return;
|
|
1300
|
+
said.add(message);
|
|
1301
|
+
console.warn(`[@inklu/keys] ${message}`);
|
|
1302
|
+
};
|
|
1303
|
+
/** Reserved bindings, and the platforms each is reserved on. */
|
|
1304
|
+
const reserved = /* @__PURE__ */ new Map();
|
|
1305
|
+
for (const platform of source.platforms) {
|
|
1306
|
+
for (const id of source.ids) for (const binding of source.bindings(id, platform)) {
|
|
1307
|
+
warnAboutBinding(id, binding, platform, warn);
|
|
1308
|
+
if (!isReserved(binding, platform)) continue;
|
|
1309
|
+
const subject = `"${id}" binds "${written(binding)}"`;
|
|
1310
|
+
reserved.set(subject, (reserved.get(subject) ?? /* @__PURE__ */ new Set()).add(platformName(platform)));
|
|
1311
|
+
}
|
|
1312
|
+
warnAboutConflicts(source, platform, warn);
|
|
1313
|
+
}
|
|
1314
|
+
for (const [subject, platforms] of reserved) warn(`${subject}, which the browser or operating system keeps for itself on ${[...platforms].join(" and ")}, so it may never reach the page.`);
|
|
1315
|
+
warnAboutUnknownOverrides(source, warn);
|
|
1316
|
+
}
|
|
1317
|
+
function warnAboutBinding(id, binding, platform, warn) {
|
|
1318
|
+
const { errors, warnings } = validateBinding(binding);
|
|
1319
|
+
if (errors.length > 0) {
|
|
1320
|
+
warn(`"${id}" has an unrecognized binding "${written(binding)}", so it never fires. ${errors.join("; ")}.`);
|
|
1321
|
+
return;
|
|
1322
|
+
}
|
|
1323
|
+
if (warnings.length > 0) warn(`"${id}" binds "${written(binding)}". ${warnings.join(" ")}`);
|
|
1324
|
+
const held = (parseBinding(binding, platform) ?? []).filter((step) => step.ctrl && step.alt);
|
|
1325
|
+
if (held.length === 0) return;
|
|
1326
|
+
if (platform === "mac") warn(`"${id}" binds "${written(binding)}". Control+Option is the VoiceOver modifier on macOS, so screen reader users cannot press it.`);
|
|
1327
|
+
else if (held.some((step) => typesCharacter({
|
|
1328
|
+
...namedChord(step),
|
|
1329
|
+
ctrl: false,
|
|
1330
|
+
alt: false
|
|
1331
|
+
}))) 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 }.`);
|
|
1000
1332
|
}
|
|
1001
|
-
function warnAboutConflicts(source) {
|
|
1002
|
-
const { platform, display } = source;
|
|
1333
|
+
function warnAboutConflicts(source, platform, warn) {
|
|
1003
1334
|
const seen = /* @__PURE__ */ new Map();
|
|
1004
|
-
const collisions = [];
|
|
1005
|
-
const prefixes = [];
|
|
1006
1335
|
const visited = [];
|
|
1007
1336
|
for (const id of source.ids) {
|
|
1008
1337
|
const scope = source.scope(id);
|
|
1009
|
-
for (const binding of source.bindings(id)) {
|
|
1338
|
+
for (const binding of source.bindings(id, platform)) {
|
|
1010
1339
|
for (const other of visited) {
|
|
1011
1340
|
if (other.id === id || !scopesOverlap(other.scope, scope)) continue;
|
|
1012
1341
|
if (!isPrefixCollision(other.binding, binding, platform)) continue;
|
|
@@ -1017,7 +1346,7 @@ function warnAboutConflicts(source) {
|
|
|
1017
1346
|
id,
|
|
1018
1347
|
binding
|
|
1019
1348
|
}, other];
|
|
1020
|
-
|
|
1349
|
+
warn(`prefix collision: ${written(short.binding)} ("${short.id}") is a prefix of ${written(long.binding)} ("${long.id}"), so the shorter binding fires on the way to the longer one.`);
|
|
1021
1350
|
}
|
|
1022
1351
|
visited.push({
|
|
1023
1352
|
id,
|
|
@@ -1030,263 +1359,21 @@ function warnAboutConflicts(source) {
|
|
|
1030
1359
|
seen.set(key, id);
|
|
1031
1360
|
continue;
|
|
1032
1361
|
}
|
|
1033
|
-
|
|
1362
|
+
warn(`shadowed binding: "${id}" and "${owner}" share ${written(binding)} in the same scope, so "${id}" takes the key whenever both have a handler.`);
|
|
1034
1363
|
}
|
|
1035
1364
|
}
|
|
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
1365
|
}
|
|
1046
1366
|
/** Catches ids in `options.bindings` that TypeScript could not, as in plain JS. */
|
|
1047
|
-
function warnAboutUnknownOverrides(source) {
|
|
1367
|
+
function warnAboutUnknownOverrides(source, warn) {
|
|
1048
1368
|
if (!source.overrides) return;
|
|
1049
1369
|
const known = new Set(source.ids);
|
|
1050
1370
|
const unknown = Object.keys(source.overrides).filter((id) => !known.has(id));
|
|
1051
|
-
if (unknown.length > 0)
|
|
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;
|
|
1371
|
+
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"}.`);
|
|
1285
1372
|
}
|
|
1286
1373
|
//#endregion
|
|
1287
1374
|
//#region src/registry.ts
|
|
1288
1375
|
/** Command registry: what an app can do, which keys do it, and where each is live. */
|
|
1289
|
-
const SSR_PLATFORM = "
|
|
1376
|
+
const SSR_PLATFORM = "other";
|
|
1290
1377
|
var CommandRegistry = class {
|
|
1291
1378
|
#definitions;
|
|
1292
1379
|
/** Handlers attached per command, the most recent last. */
|
|
@@ -1297,6 +1384,8 @@ var CommandRegistry = class {
|
|
|
1297
1384
|
#options;
|
|
1298
1385
|
#listeners = /* @__PURE__ */ new Set();
|
|
1299
1386
|
#snapshot = null;
|
|
1387
|
+
/** What the snapshot is behind on: who is reachable, or the bindings too. */
|
|
1388
|
+
#stale = "bindings";
|
|
1300
1389
|
#serverSnapshot = null;
|
|
1301
1390
|
#pending = null;
|
|
1302
1391
|
/** Stops following other tabs' keymap edits; set while mounted. */
|
|
@@ -1309,11 +1398,8 @@ var CommandRegistry = class {
|
|
|
1309
1398
|
this.#singleKeys = options.singleKeyShortcuts !== false;
|
|
1310
1399
|
this.#keymap = new Keymap(options.persist);
|
|
1311
1400
|
this.#definitions = /* @__PURE__ */ new Map();
|
|
1312
|
-
for (const id in definitions)
|
|
1313
|
-
|
|
1314
|
-
this.#definitions.set(id, definition);
|
|
1315
|
-
this.#keymap.setDefault(id, this.#defaultKeys(id, definition));
|
|
1316
|
-
}
|
|
1401
|
+
for (const id in definitions) this.#definitions.set(id, definitions[id]);
|
|
1402
|
+
this.#setDefaults();
|
|
1317
1403
|
this.#keys = new KeyListener({
|
|
1318
1404
|
ids: () => [...this.#definitions.keys()],
|
|
1319
1405
|
definition: (id) => this.#definitions.get(id),
|
|
@@ -1332,11 +1418,9 @@ var CommandRegistry = class {
|
|
|
1332
1418
|
if (process.env.NODE_ENV !== "production") auditRegistry({
|
|
1333
1419
|
ids: [...this.#definitions.keys()],
|
|
1334
1420
|
scope: (id) => this.#definitions.get(id)?.scope,
|
|
1335
|
-
bindings: (id) => this.#
|
|
1336
|
-
|
|
1337
|
-
overrides: options.bindings
|
|
1338
|
-
platform: this.#platform(),
|
|
1339
|
-
display: this.#displayOptions()
|
|
1421
|
+
bindings: (id, platform) => this.#defaultBindings(id, platform),
|
|
1422
|
+
platforms: options.platform ? [options.platform] : ["mac", "other"],
|
|
1423
|
+
overrides: options.bindings
|
|
1340
1424
|
});
|
|
1341
1425
|
}
|
|
1342
1426
|
get keymap() {
|
|
@@ -1357,7 +1441,7 @@ var CommandRegistry = class {
|
|
|
1357
1441
|
};
|
|
1358
1442
|
const singleKeys = this.#options.singleKeyShortcuts !== false;
|
|
1359
1443
|
this.#serverSnapshot = Array.from(this.#definitions, ([id, definition]) => {
|
|
1360
|
-
const bindings =
|
|
1444
|
+
const bindings = this.#defaultBindings(id, platform).filter((binding) => singleKeys || !isSingleKeyBinding(binding));
|
|
1361
1445
|
const global = definition.scope === void 0 || definition.scope === "*";
|
|
1362
1446
|
return describeCommand(id, definition, bindings, platform, options, {
|
|
1363
1447
|
reachable: definition.run !== void 0 && global,
|
|
@@ -1418,20 +1502,28 @@ var CommandRegistry = class {
|
|
|
1418
1502
|
}
|
|
1419
1503
|
conflicts(binding, options = {}) {
|
|
1420
1504
|
const platform = this.#platform();
|
|
1505
|
+
const display = (entry) => formatBinding(entry, this.#displayOptions());
|
|
1421
1506
|
const candidates = toBindings(binding);
|
|
1422
1507
|
const signatures = new Set(candidates.map((entry) => bindingSignature(entry, platform)));
|
|
1423
1508
|
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
|
+
}
|
|
1424
1517
|
for (const [id, { scope }] of this.#definitions) {
|
|
1425
1518
|
if (id === options.ignore || !scopesOverlap(scope, options.scope)) continue;
|
|
1426
1519
|
for (const existing of this.#bindings(id)) {
|
|
1427
1520
|
const kind = signatures.has(bindingSignature(existing, platform)) ? "exact" : candidates.some((candidate) => isPrefixCollision(candidate, existing, platform)) ? "prefix" : null;
|
|
1428
1521
|
if (kind === null) continue;
|
|
1429
|
-
const display = formatBinding(existing, this.#displayOptions());
|
|
1430
1522
|
conflicts.push({
|
|
1523
|
+
kind,
|
|
1431
1524
|
id,
|
|
1432
1525
|
binding: existing,
|
|
1433
|
-
|
|
1434
|
-
display,
|
|
1526
|
+
display: display(existing),
|
|
1435
1527
|
scope
|
|
1436
1528
|
});
|
|
1437
1529
|
}
|
|
@@ -1469,10 +1561,13 @@ var CommandRegistry = class {
|
|
|
1469
1561
|
mount() {
|
|
1470
1562
|
this.#mounts++;
|
|
1471
1563
|
if (this.#mounts === 1) {
|
|
1564
|
+
const rendered = this.#platform();
|
|
1472
1565
|
this.#hydrated = true;
|
|
1566
|
+
const moved = this.#platform() !== rendered;
|
|
1567
|
+
if (moved) this.#setDefaults();
|
|
1473
1568
|
this.#unwatch = this.#keymap.watch();
|
|
1474
1569
|
this.#keys.attach();
|
|
1475
|
-
if (!this.#keymap.
|
|
1570
|
+
if (!this.#keymap.sync() && moved) this.#bindingsChanged();
|
|
1476
1571
|
}
|
|
1477
1572
|
let released = false;
|
|
1478
1573
|
return () => {
|
|
@@ -1497,7 +1592,7 @@ var CommandRegistry = class {
|
|
|
1497
1592
|
this.#definitions.clear();
|
|
1498
1593
|
this.#handlers.clear();
|
|
1499
1594
|
this.#serverSnapshot = null;
|
|
1500
|
-
this.#invalidate();
|
|
1595
|
+
this.#invalidate("bindings");
|
|
1501
1596
|
this.#listeners.clear();
|
|
1502
1597
|
this.#scopes.clear();
|
|
1503
1598
|
}
|
|
@@ -1514,25 +1609,24 @@ var CommandRegistry = class {
|
|
|
1514
1609
|
if (this.#keys.captured || !this.#handler(id)) return false;
|
|
1515
1610
|
return this.#scopes.isActive(this.#definitions.get(id)?.scope);
|
|
1516
1611
|
}
|
|
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
|
-
*/
|
|
1612
|
+
/** Bindings in effect: the keymap's, minus single-key ones while those are switched off. */
|
|
1522
1613
|
#bindings(id) {
|
|
1523
1614
|
const bindings = this.#keymap.get(id);
|
|
1524
1615
|
if (this.#singleKeys) return bindings;
|
|
1525
1616
|
return bindings.filter((binding) => !isSingleKeyBinding(binding));
|
|
1526
1617
|
}
|
|
1527
1618
|
/**
|
|
1528
|
-
* A command's default
|
|
1529
|
-
*
|
|
1530
|
-
* deliberately ships the command unbound, and `??` would read that as
|
|
1531
|
-
* "absent" and restore the very default the app asked to drop.
|
|
1619
|
+
* A command's default bindings on a platform. An `options.bindings` entry
|
|
1620
|
+
* wins over the definition's `keys`, and a `null` one means unbound.
|
|
1532
1621
|
*/
|
|
1533
|
-
#
|
|
1622
|
+
#defaultBindings(id, platform) {
|
|
1534
1623
|
const overrides = this.#options.bindings;
|
|
1535
|
-
return overrides && Object.hasOwn(overrides, id) ? overrides[id] :
|
|
1624
|
+
return toBindings(keysFor(overrides && Object.hasOwn(overrides, id) ? overrides[id] : this.#definitions.get(id)?.keys, platform));
|
|
1625
|
+
}
|
|
1626
|
+
/** Hands the keymap every command's defaults for the platform in effect. */
|
|
1627
|
+
#setDefaults() {
|
|
1628
|
+
const platform = this.#platform();
|
|
1629
|
+
for (const id of this.#definitions.keys()) this.#keymap.setDefault(id, this.#defaultBindings(id, platform));
|
|
1536
1630
|
}
|
|
1537
1631
|
#execute(id, context) {
|
|
1538
1632
|
const run = this.#handler(id);
|
|
@@ -1563,11 +1657,11 @@ var CommandRegistry = class {
|
|
|
1563
1657
|
}
|
|
1564
1658
|
#bindingsChanged() {
|
|
1565
1659
|
this.#keys.refresh();
|
|
1566
|
-
this.#invalidate();
|
|
1660
|
+
this.#invalidate("bindings");
|
|
1567
1661
|
}
|
|
1568
1662
|
#reachabilityChanged() {
|
|
1569
1663
|
this.#keys.prune();
|
|
1570
|
-
this.#invalidate();
|
|
1664
|
+
this.#invalidate("reachable");
|
|
1571
1665
|
}
|
|
1572
1666
|
#platform() {
|
|
1573
1667
|
return this.#options.platform ?? (this.#hydrated ? detectPlatform() : SSR_PLATFORM);
|
|
@@ -1578,28 +1672,46 @@ var CommandRegistry = class {
|
|
|
1578
1672
|
...this.#options.display
|
|
1579
1673
|
};
|
|
1580
1674
|
}
|
|
1675
|
+
/**
|
|
1676
|
+
* The commands as they are now. A command that did not change keeps its
|
|
1677
|
+
* object, and the list keeps its own while none did, so a subscriber can
|
|
1678
|
+
* tell by identity whether it has anything to do.
|
|
1679
|
+
*/
|
|
1581
1680
|
#snapshots() {
|
|
1582
|
-
|
|
1681
|
+
const previous = this.#snapshot;
|
|
1682
|
+
if (previous && !this.#stale) return previous;
|
|
1683
|
+
const kept = this.#stale === "reachable" ? previous?.index : void 0;
|
|
1583
1684
|
const platform = this.#platform();
|
|
1584
1685
|
const options = this.#displayOptions();
|
|
1585
1686
|
const list = [];
|
|
1586
1687
|
const index = /* @__PURE__ */ new Map();
|
|
1688
|
+
let changed = !kept;
|
|
1587
1689
|
for (const [id, definition] of this.#definitions) {
|
|
1588
|
-
const
|
|
1589
|
-
|
|
1690
|
+
const reachable = this.#reachable(id);
|
|
1691
|
+
let command = kept?.get(id);
|
|
1692
|
+
if (!command) command = describeCommand(id, definition, this.#bindings(id), platform, options, {
|
|
1693
|
+
reachable,
|
|
1590
1694
|
overridden: this.#keymap.isOverridden(id)
|
|
1591
1695
|
});
|
|
1696
|
+
else if (command.reachable !== reachable) {
|
|
1697
|
+
command = {
|
|
1698
|
+
...command,
|
|
1699
|
+
reachable
|
|
1700
|
+
};
|
|
1701
|
+
changed = true;
|
|
1702
|
+
}
|
|
1592
1703
|
list.push(command);
|
|
1593
1704
|
index.set(id, command);
|
|
1594
1705
|
}
|
|
1595
|
-
this.#
|
|
1706
|
+
this.#stale = null;
|
|
1707
|
+
this.#snapshot = changed || !previous ? {
|
|
1596
1708
|
list,
|
|
1597
1709
|
index
|
|
1598
|
-
};
|
|
1710
|
+
} : previous;
|
|
1599
1711
|
return this.#snapshot;
|
|
1600
1712
|
}
|
|
1601
|
-
#invalidate() {
|
|
1602
|
-
this.#
|
|
1713
|
+
#invalidate(stale) {
|
|
1714
|
+
if (this.#stale !== "bindings") this.#stale = stale;
|
|
1603
1715
|
for (const listener of this.#listeners) listener();
|
|
1604
1716
|
}
|
|
1605
1717
|
};
|
|
@@ -1646,12 +1758,10 @@ function createCommands(definitions, options) {
|
|
|
1646
1758
|
//#endregion
|
|
1647
1759
|
//#region src/search.ts
|
|
1648
1760
|
/**
|
|
1649
|
-
* Filters commands for a search query,
|
|
1650
|
-
*
|
|
1651
|
-
*
|
|
1652
|
-
*
|
|
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.
|
|
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.
|
|
1655
1765
|
*
|
|
1656
1766
|
* @example
|
|
1657
1767
|
* ```ts
|
|
@@ -1684,4 +1794,4 @@ function matchCommands(commands, query) {
|
|
|
1684
1794
|
return ranked.sort((a, b) => a.rank - b.rank).map(({ command }) => command);
|
|
1685
1795
|
}
|
|
1686
1796
|
//#endregion
|
|
1687
|
-
export {
|
|
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 };
|