@inklu/keys 2.1.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +106 -135
- package/dist/attributes.d.mts +1 -1
- package/dist/attributes.mjs +15 -18
- package/dist/index-BnB38jCF.d.mts +139 -0
- package/dist/index.d.mts +3 -3
- package/dist/index.mjs +2 -2
- package/dist/react.d.mts +19 -41
- package/dist/react.mjs +26 -145
- package/dist/src-CGKvYKjd.mjs +1333 -0
- package/dist/types-BxMI3b4a.d.mts +256 -0
- package/package.json +2 -3
- package/dist/index-3d9f54ln.d.mts +0 -156
- package/dist/src-B76YvObL.mjs +0 -1687
- package/dist/types-QEXldPnL.d.mts +0 -398
|
@@ -0,0 +1,1333 @@
|
|
|
1
|
+
//#region src/chord.ts
|
|
2
|
+
/** The platform, as `navigator` reports it. `"other"` where there is none. */
|
|
3
|
+
function detectPlatform() {
|
|
4
|
+
if (typeof navigator === "undefined") return "other";
|
|
5
|
+
return /mac/i.test(`${navigator.platform} ${navigator.userAgent}`) ? "mac" : "other";
|
|
6
|
+
}
|
|
7
|
+
/** Named keys in the `Key` type, as `KeyboardEvent.key` spells them. Other names still work. */
|
|
8
|
+
const NAMED_KEYS = [
|
|
9
|
+
"ArrowUp",
|
|
10
|
+
"ArrowDown",
|
|
11
|
+
"ArrowLeft",
|
|
12
|
+
"ArrowRight",
|
|
13
|
+
"Home",
|
|
14
|
+
"End",
|
|
15
|
+
"PageUp",
|
|
16
|
+
"PageDown",
|
|
17
|
+
"Enter",
|
|
18
|
+
"Escape",
|
|
19
|
+
"Space",
|
|
20
|
+
"Tab",
|
|
21
|
+
"Backspace",
|
|
22
|
+
"Delete",
|
|
23
|
+
"Insert",
|
|
24
|
+
"CapsLock",
|
|
25
|
+
"ContextMenu",
|
|
26
|
+
"PrintScreen",
|
|
27
|
+
"ScrollLock",
|
|
28
|
+
"Pause",
|
|
29
|
+
"NumLock",
|
|
30
|
+
"Help",
|
|
31
|
+
"Clear",
|
|
32
|
+
"MediaPlayPause",
|
|
33
|
+
"MediaStop",
|
|
34
|
+
"MediaTrackNext",
|
|
35
|
+
"MediaTrackPrevious",
|
|
36
|
+
"AudioVolumeUp",
|
|
37
|
+
"AudioVolumeDown",
|
|
38
|
+
"AudioVolumeMute",
|
|
39
|
+
"BrowserBack",
|
|
40
|
+
"BrowserForward",
|
|
41
|
+
"BrowserRefresh",
|
|
42
|
+
"BrowserSearch",
|
|
43
|
+
"BrowserHome"
|
|
44
|
+
];
|
|
45
|
+
const MODIFIERS = [
|
|
46
|
+
"Control",
|
|
47
|
+
"Alt",
|
|
48
|
+
"Shift",
|
|
49
|
+
"Meta"
|
|
50
|
+
];
|
|
51
|
+
const FUNCTION_KEY = /^F([1-9]|1\d|2[0-4])$/;
|
|
52
|
+
const LETTER = /^\p{Letter}$/u;
|
|
53
|
+
const LATIN_LETTER = /^\p{Script_Extensions=Latin}$/u;
|
|
54
|
+
/** Splits on `+`. The plus key itself is written last: `+` alone, or `Control++`. */
|
|
55
|
+
function split(chord) {
|
|
56
|
+
const plusKey = chord === "+" || chord.endsWith("++");
|
|
57
|
+
const parts = (plusKey ? chord.slice(0, -1) : chord).split("+");
|
|
58
|
+
if (plusKey) parts[parts.length - 1] = "+";
|
|
59
|
+
return parts.map((part) => part.trim());
|
|
60
|
+
}
|
|
61
|
+
/** One spelling per key: letters upper-cased, the space bar by name. */
|
|
62
|
+
function keyName(key) {
|
|
63
|
+
if (key === " ") return "Space";
|
|
64
|
+
if (!LETTER.test(key)) return key;
|
|
65
|
+
const upper = key.toUpperCase();
|
|
66
|
+
return upper.length === 1 ? upper : key;
|
|
67
|
+
}
|
|
68
|
+
/** The key a keypress produced. Empty for the keyless events browser autofill dispatches. */
|
|
69
|
+
function pressedKey(event) {
|
|
70
|
+
return keyName(String(event.key ?? "").normalize("NFC"));
|
|
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);
|
|
85
|
+
/** Reads a chord, or says why it cannot be read. */
|
|
86
|
+
function read(chord, platform) {
|
|
87
|
+
if (chord.trim() === "") return "Chord cannot be empty";
|
|
88
|
+
const parts = split(chord);
|
|
89
|
+
const key = parts.pop() ?? "";
|
|
90
|
+
if (key === "" || parts.includes("")) return "Invalid chord: empty part";
|
|
91
|
+
if (parts.includes("Mod") && (parts.includes("Control") || parts.includes("Meta"))) return "Mod already stands for Control or Meta: write one or the other";
|
|
92
|
+
const held = /* @__PURE__ */ new Set();
|
|
93
|
+
for (const part of parts) {
|
|
94
|
+
const name = part === "Mod" ? platform === "mac" ? "Meta" : "Control" : part;
|
|
95
|
+
const modifier = MODIFIERS.find((candidate) => candidate === name);
|
|
96
|
+
if (!modifier) return `Unknown modifier: '${part}'`;
|
|
97
|
+
if (held.has(modifier)) return `Repeated modifier: '${part}'`;
|
|
98
|
+
held.add(modifier);
|
|
99
|
+
}
|
|
100
|
+
return {
|
|
101
|
+
ctrl: held.has("Control"),
|
|
102
|
+
alt: held.has("Alt"),
|
|
103
|
+
shift: held.has("Shift"),
|
|
104
|
+
meta: held.has("Meta"),
|
|
105
|
+
key: keyName(key)
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
/** Parses a chord for a platform, or returns `null` for one that cannot be read. */
|
|
109
|
+
function parseChord(chord, platform) {
|
|
110
|
+
const result = read(chord, platform);
|
|
111
|
+
return typeof result === "string" ? null : result;
|
|
112
|
+
}
|
|
113
|
+
/** Whether a string is a chord this package can read. */
|
|
114
|
+
function isChord(text) {
|
|
115
|
+
return parseChord(text, "mac") !== null;
|
|
116
|
+
}
|
|
117
|
+
/** A chord's `errors` (it will never fire) and `warnings` (a key name this package does not know). */
|
|
118
|
+
function validateChord(chord) {
|
|
119
|
+
const result = read(chord, "mac");
|
|
120
|
+
if (typeof result === "string") return {
|
|
121
|
+
errors: [result],
|
|
122
|
+
warnings: []
|
|
123
|
+
};
|
|
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 '?'.`);
|
|
129
|
+
return {
|
|
130
|
+
errors: [],
|
|
131
|
+
warnings
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
/** Writes a parsed chord as text. Given a platform, its primary modifier is written `Mod`. */
|
|
135
|
+
function writeChord(chord, mod) {
|
|
136
|
+
const portable = mod !== void 0 && (mod === "mac" ? chord.meta && !chord.ctrl : chord.ctrl && !chord.meta);
|
|
137
|
+
const parts = portable ? ["Mod"] : [];
|
|
138
|
+
if (chord.ctrl && !portable) parts.push("Control");
|
|
139
|
+
if (chord.alt) parts.push("Alt");
|
|
140
|
+
if (chord.shift) parts.push("Shift");
|
|
141
|
+
if (chord.meta && !portable) parts.push("Meta");
|
|
142
|
+
parts.push(chord.key);
|
|
143
|
+
return parts.join("+");
|
|
144
|
+
}
|
|
145
|
+
/** Whether a chord types a character: a one-character key with no Control, Alt or Meta. */
|
|
146
|
+
function typesCharacter(chord) {
|
|
147
|
+
return !chord.ctrl && !chord.alt && !chord.meta && isOneCharacter(chord.key);
|
|
148
|
+
}
|
|
149
|
+
const MODIFIER_KEYS = /* @__PURE__ */ new Set([
|
|
150
|
+
...MODIFIERS,
|
|
151
|
+
"AltGraph",
|
|
152
|
+
"OS",
|
|
153
|
+
"Fn",
|
|
154
|
+
"Hyper",
|
|
155
|
+
"Super",
|
|
156
|
+
"Symbol"
|
|
157
|
+
]);
|
|
158
|
+
/** Whether a `KeyboardEvent.key` is a modifier on its own. */
|
|
159
|
+
function isModifierKey(key) {
|
|
160
|
+
return MODIFIER_KEYS.has(key);
|
|
161
|
+
}
|
|
162
|
+
/** Whether AltGr is down. Windows reports it as Control+Alt, though a character is being typed. */
|
|
163
|
+
function hasAltGraph(event, platform) {
|
|
164
|
+
return platform !== "mac" && event.getModifierState?.("AltGraph") === true;
|
|
165
|
+
}
|
|
166
|
+
/** The punctuation keys of the conventional (US) layout, by `KeyboardEvent.code`. */
|
|
167
|
+
const PUNCTUATION_CODES = {
|
|
168
|
+
Backquote: "`",
|
|
169
|
+
Backslash: "\\",
|
|
170
|
+
BracketLeft: "[",
|
|
171
|
+
BracketRight: "]",
|
|
172
|
+
Comma: ",",
|
|
173
|
+
Equal: "=",
|
|
174
|
+
Minus: "-",
|
|
175
|
+
Period: ".",
|
|
176
|
+
Quote: "'",
|
|
177
|
+
Semicolon: ";",
|
|
178
|
+
Slash: "/"
|
|
179
|
+
};
|
|
180
|
+
/**
|
|
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).
|
|
185
|
+
*/
|
|
186
|
+
function positionKey(event, key) {
|
|
187
|
+
const code = event.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];
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
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.
|
|
197
|
+
*/
|
|
198
|
+
function matchesChord(event, chord, platform) {
|
|
199
|
+
const altGraph = hasAltGraph(event, platform);
|
|
200
|
+
if (altGraph && (chord.ctrl || chord.alt)) return false;
|
|
201
|
+
const key = pressedKey(event);
|
|
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);
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* The chord a keypress expresses, for recording a shortcut, or `null` for a
|
|
207
|
+
* modifier on its own or a key with no name. It records what `matchesChord`
|
|
208
|
+
* then matches: the character typed (`?`, not `Shift+/`), else the key's
|
|
209
|
+
* position, with the primary modifier written `Mod`.
|
|
210
|
+
*/
|
|
211
|
+
function readChord(event, platform) {
|
|
212
|
+
if (isModifierKey(event.key)) return null;
|
|
213
|
+
const pressed = pressedKey(event);
|
|
214
|
+
const altGraph = hasAltGraph(event, platform);
|
|
215
|
+
const key = altGraph || isAscii(pressed) || isNamed(pressed) ? pressed : positionKey(event, pressed) ?? pressed;
|
|
216
|
+
if (NO_KEY.has(key)) return null;
|
|
217
|
+
const typed = key === pressed && isSymbol(key);
|
|
218
|
+
const chord = writeChord({
|
|
219
|
+
ctrl: !altGraph && event.ctrlKey,
|
|
220
|
+
alt: !altGraph && event.altKey && !(typed && platform === "mac"),
|
|
221
|
+
shift: event.shiftKey && !typed,
|
|
222
|
+
meta: event.metaKey,
|
|
223
|
+
key
|
|
224
|
+
}, platform);
|
|
225
|
+
return isChord(chord) ? chord : null;
|
|
226
|
+
}
|
|
227
|
+
//#endregion
|
|
228
|
+
//#region src/bindings.ts
|
|
229
|
+
/** Bindings: a chord or a sequence of them. Comparing, validating and reading per platform. */
|
|
230
|
+
/**
|
|
231
|
+
* Creates a sequence binding from ordered chords.
|
|
232
|
+
*
|
|
233
|
+
* @example
|
|
234
|
+
* ```ts
|
|
235
|
+
* keys: seq("G", "I")
|
|
236
|
+
* ```
|
|
237
|
+
*/
|
|
238
|
+
function seq(...steps) {
|
|
239
|
+
return { sequence: steps };
|
|
240
|
+
}
|
|
241
|
+
/** Type guard checking if a binding is a sequence. */
|
|
242
|
+
function isSequence(binding) {
|
|
243
|
+
return typeof binding === "object" && binding !== null && "sequence" in binding;
|
|
244
|
+
}
|
|
245
|
+
/** The chords of a binding, in order: one for a chord, several for a sequence. */
|
|
246
|
+
function stepsOf(binding) {
|
|
247
|
+
return isSequence(binding) ? binding.sequence : [binding];
|
|
248
|
+
}
|
|
249
|
+
/** Normalizes a binding input into an array of bindings. A one-step sequence becomes a chord. */
|
|
250
|
+
function toBindings(input) {
|
|
251
|
+
if (input == null) return [];
|
|
252
|
+
const candidates = Array.isArray(input) ? input : [input];
|
|
253
|
+
const bindings = [];
|
|
254
|
+
for (const candidate of candidates) {
|
|
255
|
+
if (candidate == null) continue;
|
|
256
|
+
const [first, ...rest] = stepsOf(candidate);
|
|
257
|
+
if (first === void 0) continue;
|
|
258
|
+
if (rest.length > 0) bindings.push({ sequence: [first, ...rest] });
|
|
259
|
+
else if (first.trim() !== "") bindings.push(first);
|
|
260
|
+
}
|
|
261
|
+
return bindings;
|
|
262
|
+
}
|
|
263
|
+
/** The side of `{ mac, other }` keys that applies on a platform. Keys that do not differ pass through. */
|
|
264
|
+
function keysFor(keys, platform) {
|
|
265
|
+
if (keys == null || !isPerPlatform(keys)) return keys;
|
|
266
|
+
return platform === "mac" ? keys.mac : keys.other;
|
|
267
|
+
}
|
|
268
|
+
function isPerPlatform(keys) {
|
|
269
|
+
return typeof keys === "object" && !Array.isArray(keys) && !("sequence" in keys);
|
|
270
|
+
}
|
|
271
|
+
/** One spelling of a binding on a platform, so equivalent bindings compare equal. */
|
|
272
|
+
function bindingSignature(binding, platform) {
|
|
273
|
+
return canonicalSteps(binding, platform).join(" ");
|
|
274
|
+
}
|
|
275
|
+
/** Whether one binding is a strict prefix of the other, like chord `G` against `G then I`. */
|
|
276
|
+
function isPrefixCollision(a, b, platform) {
|
|
277
|
+
const left = canonicalSteps(a, platform);
|
|
278
|
+
const right = canonicalSteps(b, platform);
|
|
279
|
+
if (left.length === right.length) return false;
|
|
280
|
+
const [short, long] = left.length < right.length ? [left, right] : [right, left];
|
|
281
|
+
return short.every((step, index) => step === long[index]);
|
|
282
|
+
}
|
|
283
|
+
/** Every step parsed for a platform, or `null` if any of them cannot be read. */
|
|
284
|
+
function parseBinding(binding, platform) {
|
|
285
|
+
const steps = [];
|
|
286
|
+
for (const step of stepsOf(binding)) {
|
|
287
|
+
const parsed = parseChord(step, platform);
|
|
288
|
+
if (!parsed) return null;
|
|
289
|
+
steps.push(parsed);
|
|
290
|
+
}
|
|
291
|
+
return steps;
|
|
292
|
+
}
|
|
293
|
+
/** Each step in one spelling. One that cannot be read stays as written. */
|
|
294
|
+
function canonicalSteps(binding, platform) {
|
|
295
|
+
return stepsOf(binding).map((step) => {
|
|
296
|
+
const parsed = parseChord(step, platform);
|
|
297
|
+
return parsed ? writeChord(parsed) : step;
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* Whether every step is a printable character with no Control, Alt or Meta:
|
|
302
|
+
* the "character key shortcuts" WCAG 2.1.4 lets users turn off.
|
|
303
|
+
*/
|
|
304
|
+
function isSingleKeyBinding(binding) {
|
|
305
|
+
return parseBinding(binding, "mac")?.every(typesCharacter) === true;
|
|
306
|
+
}
|
|
307
|
+
/** A binding's syntax `errors` (it will never fire) and `warnings`, for the development audit. */
|
|
308
|
+
function validateBinding(binding) {
|
|
309
|
+
const results = stepsOf(binding).map(validateChord);
|
|
310
|
+
return {
|
|
311
|
+
errors: results.flatMap((result) => result.errors),
|
|
312
|
+
warnings: results.flatMap((result) => result.warnings)
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
//#endregion
|
|
316
|
+
//#region src/display.ts
|
|
317
|
+
/** Writing a binding for people: on screen, and in `aria-keyshortcuts`. */
|
|
318
|
+
const MODIFIER_LABELS = {
|
|
319
|
+
mac: [
|
|
320
|
+
"⌃",
|
|
321
|
+
"⌥",
|
|
322
|
+
"⇧",
|
|
323
|
+
"⌘"
|
|
324
|
+
],
|
|
325
|
+
other: [
|
|
326
|
+
"Ctrl",
|
|
327
|
+
"Alt",
|
|
328
|
+
"Shift",
|
|
329
|
+
"Meta"
|
|
330
|
+
]
|
|
331
|
+
};
|
|
332
|
+
/** Named keys as each platform's keyboard prints them: on a Mac, then on Windows and Linux. */
|
|
333
|
+
const KEY_LABELS = {
|
|
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"]
|
|
348
|
+
};
|
|
349
|
+
/**
|
|
350
|
+
* Formats a binding the way its platform writes shortcuts: `⇧⌘S` on a Mac,
|
|
351
|
+
* `Ctrl+Shift+S` elsewhere. The steps of a sequence are set apart by a space.
|
|
352
|
+
*/
|
|
353
|
+
function formatBinding(binding, platform = detectPlatform()) {
|
|
354
|
+
return stepsOf(binding).map((step) => formatChord(step, platform)).join(" ");
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* Splits a binding into display tokens for rendering `<kbd>` elements: one
|
|
358
|
+
* per key of a chord, one per step of a sequence.
|
|
359
|
+
*/
|
|
360
|
+
function bindingTokens(binding, platform = detectPlatform()) {
|
|
361
|
+
if (isSequence(binding)) return binding.sequence.map((step) => formatChord(step, platform));
|
|
362
|
+
return chordTokens(binding, platform);
|
|
363
|
+
}
|
|
364
|
+
function formatChord(chord, platform) {
|
|
365
|
+
return chordTokens(chord, platform).join(platform === "mac" ? "" : "+");
|
|
366
|
+
}
|
|
367
|
+
function chordTokens(chord, platform) {
|
|
368
|
+
const parsed = parseChord(chord, platform);
|
|
369
|
+
if (!parsed) return [chord];
|
|
370
|
+
const held = [
|
|
371
|
+
parsed.ctrl,
|
|
372
|
+
parsed.alt,
|
|
373
|
+
parsed.shift,
|
|
374
|
+
parsed.meta
|
|
375
|
+
];
|
|
376
|
+
return [...MODIFIER_LABELS[platform].filter((_, index) => held[index]), KEY_LABELS[parsed.key]?.[platform === "mac" ? 0 : 1] ?? parsed.key];
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* The `aria-keyshortcuts` value for a chord, as in `"Meta+K"`. Empty for a
|
|
380
|
+
* sequence, which the attribute cannot express, and for a chord that cannot be read.
|
|
381
|
+
*/
|
|
382
|
+
function ariaKeyShortcuts(binding, platform = detectPlatform()) {
|
|
383
|
+
const parsed = isSequence(binding) ? null : parseChord(binding, platform);
|
|
384
|
+
if (!parsed) return "";
|
|
385
|
+
return writeChord(parsed.key === "+" ? {
|
|
386
|
+
...parsed,
|
|
387
|
+
key: "Plus"
|
|
388
|
+
} : parsed);
|
|
389
|
+
}
|
|
390
|
+
//#endregion
|
|
391
|
+
//#region src/keymap.ts
|
|
392
|
+
/** Keymap: default bindings, and the user's overrides on top of them. */
|
|
393
|
+
/** A signature that holds on every platform, so `Mod+S` and `Control+S` stay distinct. */
|
|
394
|
+
const portable = (binding) => `${bindingSignature(binding, "mac")} ${bindingSignature(binding, "other")}`;
|
|
395
|
+
var Keymap = class {
|
|
396
|
+
#defaults = /* @__PURE__ */ new Map();
|
|
397
|
+
#overrides = /* @__PURE__ */ new Map();
|
|
398
|
+
#listeners = /* @__PURE__ */ new Set();
|
|
399
|
+
#store;
|
|
400
|
+
#loaded = false;
|
|
401
|
+
constructor(store) {
|
|
402
|
+
this.#store = store;
|
|
403
|
+
}
|
|
404
|
+
/** Sets a command's default bindings. `null` leaves the command unbound. */
|
|
405
|
+
setDefault(id, input) {
|
|
406
|
+
const bindings = toBindings(input);
|
|
407
|
+
if (bindings.length === 0) this.#defaults.delete(id);
|
|
408
|
+
else this.#defaults.set(id, bindings);
|
|
409
|
+
}
|
|
410
|
+
/** Checks whether a command has an active user override. */
|
|
411
|
+
isOverridden(id) {
|
|
412
|
+
return this.#overrides.has(id);
|
|
413
|
+
}
|
|
414
|
+
/** The user's overrides, for a store or an export to write out. */
|
|
415
|
+
get overrides() {
|
|
416
|
+
return this.#overrides;
|
|
417
|
+
}
|
|
418
|
+
get(id) {
|
|
419
|
+
return this.#overrides.get(id) ?? this.#defaults.get(id) ?? [];
|
|
420
|
+
}
|
|
421
|
+
set(id, input) {
|
|
422
|
+
this.load();
|
|
423
|
+
const bindings = input === null ? [] : toBindings(input);
|
|
424
|
+
if (sameBindings(bindings, this.#defaults.get(id) ?? [])) {
|
|
425
|
+
if (!this.#overrides.delete(id)) return;
|
|
426
|
+
} else this.#overrides.set(id, bindings);
|
|
427
|
+
this.#changed();
|
|
428
|
+
}
|
|
429
|
+
reset(id) {
|
|
430
|
+
this.load();
|
|
431
|
+
if (id === void 0) {
|
|
432
|
+
if (this.#overrides.size === 0) return;
|
|
433
|
+
this.#overrides.clear();
|
|
434
|
+
} else if (!this.#overrides.delete(id)) return;
|
|
435
|
+
this.#changed();
|
|
436
|
+
}
|
|
437
|
+
/** Replaces every override and saves, as an import does. */
|
|
438
|
+
replace(overrides) {
|
|
439
|
+
this.#loaded = true;
|
|
440
|
+
this.#overrides = overrides;
|
|
441
|
+
this.#changed();
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
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.
|
|
447
|
+
*/
|
|
448
|
+
load() {
|
|
449
|
+
if (this.#loaded) return false;
|
|
450
|
+
this.#loaded = true;
|
|
451
|
+
const stored = this.#store?.read();
|
|
452
|
+
if (!stored) return false;
|
|
453
|
+
const changed = JSON.stringify([...stored]) !== JSON.stringify([...this.#overrides]);
|
|
454
|
+
this.#overrides = stored;
|
|
455
|
+
if (changed) this.#notify();
|
|
456
|
+
return changed;
|
|
457
|
+
}
|
|
458
|
+
/** Subscribes to keymap changes. Returns an unsubscribe function. */
|
|
459
|
+
subscribe(listener) {
|
|
460
|
+
this.#listeners.add(listener);
|
|
461
|
+
return () => {
|
|
462
|
+
this.#listeners.delete(listener);
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
#changed() {
|
|
466
|
+
this.#store?.write(this.#overrides);
|
|
467
|
+
this.#notify();
|
|
468
|
+
}
|
|
469
|
+
#notify() {
|
|
470
|
+
for (const listener of this.#listeners) listener();
|
|
471
|
+
}
|
|
472
|
+
};
|
|
473
|
+
/** Same bindings in the same order; the first is the one shortcuts display. */
|
|
474
|
+
function sameBindings(a, b) {
|
|
475
|
+
return a.length === b.length && a.every((binding, index) => portable(binding) === portable(b[index]));
|
|
476
|
+
}
|
|
477
|
+
//#endregion
|
|
478
|
+
//#region src/persist.ts
|
|
479
|
+
/** Keeping a keymap between visits: Web Storage, and export and import as JSON. */
|
|
480
|
+
const VERSION = 1;
|
|
481
|
+
/**
|
|
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.
|
|
485
|
+
*
|
|
486
|
+
* @example
|
|
487
|
+
* ```ts
|
|
488
|
+
* createCommands(definitions, { persist: webStorage("my-app.keys") });
|
|
489
|
+
* ```
|
|
490
|
+
*/
|
|
491
|
+
function webStorage(key = "inklu.keys", storage = () => localStorage) {
|
|
492
|
+
const open = () => {
|
|
493
|
+
try {
|
|
494
|
+
return storage();
|
|
495
|
+
} catch {
|
|
496
|
+
return null;
|
|
497
|
+
}
|
|
498
|
+
};
|
|
499
|
+
return {
|
|
500
|
+
read() {
|
|
501
|
+
const area = open();
|
|
502
|
+
if (!area) return null;
|
|
503
|
+
try {
|
|
504
|
+
const raw = area.getItem(key);
|
|
505
|
+
return raw ? parseKeymap(JSON.parse(raw)) : /* @__PURE__ */ new Map();
|
|
506
|
+
} catch {
|
|
507
|
+
return null;
|
|
508
|
+
}
|
|
509
|
+
},
|
|
510
|
+
write(overrides) {
|
|
511
|
+
try {
|
|
512
|
+
if (overrides.size === 0) open()?.removeItem(key);
|
|
513
|
+
else open()?.setItem(key, JSON.stringify(serializeKeymap(overrides)));
|
|
514
|
+
} catch {}
|
|
515
|
+
}
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
/** A registry's user overrides as JSON-safe data, for your own storage or another device. */
|
|
519
|
+
function exportKeymap(commands) {
|
|
520
|
+
return serializeKeymap(keymapOf(commands).overrides);
|
|
521
|
+
}
|
|
522
|
+
/**
|
|
523
|
+
* Replaces a registry's user overrides with exported data, and saves them.
|
|
524
|
+
* Data in another format changes nothing.
|
|
525
|
+
*/
|
|
526
|
+
function importKeymap(commands, data) {
|
|
527
|
+
const overrides = parseKeymap(data);
|
|
528
|
+
if (overrides) keymapOf(commands).replace(overrides);
|
|
529
|
+
}
|
|
530
|
+
function keymapOf({ keymap }) {
|
|
531
|
+
if (keymap instanceof Keymap) return keymap;
|
|
532
|
+
throw new TypeError("[@inklu/keys] not a registry made by createCommands");
|
|
533
|
+
}
|
|
534
|
+
function serializeKeymap(overrides) {
|
|
535
|
+
return {
|
|
536
|
+
version: VERSION,
|
|
537
|
+
overrides: Object.fromEntries(Array.from(overrides, ([id, bindings]) => [id, bindings.map((binding) => isSequence(binding) ? [...binding.sequence] : binding)]))
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
/** The overrides in stored data, or `null` for data in another format. */
|
|
541
|
+
function parseKeymap(data) {
|
|
542
|
+
const stored = isObject(data) && data.version === VERSION ? data.overrides : void 0;
|
|
543
|
+
if (stored === void 0 || !isObject(stored)) return null;
|
|
544
|
+
const overrides = /* @__PURE__ */ new Map();
|
|
545
|
+
for (const [id, value] of Object.entries(stored)) {
|
|
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);
|
|
549
|
+
}
|
|
550
|
+
return overrides;
|
|
551
|
+
}
|
|
552
|
+
/** A stored chord (a string) or sequence (a list of them), or `null` if it cannot be read. */
|
|
553
|
+
function parseStoredBinding(value) {
|
|
554
|
+
if (typeof value === "string") return isChord(value) ? value : null;
|
|
555
|
+
if (!Array.isArray(value)) return null;
|
|
556
|
+
const steps = [];
|
|
557
|
+
for (const step of value) {
|
|
558
|
+
if (typeof step !== "string" || !isChord(step)) return null;
|
|
559
|
+
steps.push(step);
|
|
560
|
+
}
|
|
561
|
+
const [first, ...rest] = steps;
|
|
562
|
+
if (first === void 0) return null;
|
|
563
|
+
return rest.length === 0 ? first : { sequence: steps };
|
|
564
|
+
}
|
|
565
|
+
function isObject(value) {
|
|
566
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
567
|
+
}
|
|
568
|
+
//#endregion
|
|
569
|
+
//#region src/rebind.ts
|
|
570
|
+
const NO_STEPS = [];
|
|
571
|
+
/**
|
|
572
|
+
* Creates a rebinder for recording a command's keys. While it records, no
|
|
573
|
+
* command runs. Escape cancels, Backspace removes the shortcut (or the last
|
|
574
|
+
* step of a sequence), and Enter finishes a sequence.
|
|
575
|
+
*
|
|
576
|
+
* @example
|
|
577
|
+
* ```ts
|
|
578
|
+
* const rebinder = createRebinder(commands, "palette.open", {
|
|
579
|
+
* onRecord: (binding, conflicts) => showConflicts(conflicts),
|
|
580
|
+
* });
|
|
581
|
+
* rebinder.start();
|
|
582
|
+
* ```
|
|
583
|
+
*/
|
|
584
|
+
function createRebinder(registry, id, options = {}) {
|
|
585
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
586
|
+
let state = {
|
|
587
|
+
recording: false,
|
|
588
|
+
binding: null,
|
|
589
|
+
steps: NO_STEPS,
|
|
590
|
+
conflicts: []
|
|
591
|
+
};
|
|
592
|
+
let release = null;
|
|
593
|
+
const set = (next) => {
|
|
594
|
+
state = next;
|
|
595
|
+
for (const listener of listeners) listener();
|
|
596
|
+
};
|
|
597
|
+
/** Gives the keyboard back. */
|
|
598
|
+
const end = () => {
|
|
599
|
+
release?.();
|
|
600
|
+
release = null;
|
|
601
|
+
};
|
|
602
|
+
const record = (binding) => {
|
|
603
|
+
end();
|
|
604
|
+
const conflicts = binding ? registry.conflicts(binding, {
|
|
605
|
+
ignore: id,
|
|
606
|
+
scope: registry.get(id)?.scope
|
|
607
|
+
}) : [];
|
|
608
|
+
const blocked = options.rejectConflicts === true && conflicts.length > 0;
|
|
609
|
+
if (options.apply !== false && !blocked) registry.keymap.set(id, binding);
|
|
610
|
+
set({
|
|
611
|
+
recording: false,
|
|
612
|
+
binding,
|
|
613
|
+
steps: NO_STEPS,
|
|
614
|
+
conflicts
|
|
615
|
+
});
|
|
616
|
+
options.onRecord?.(binding, conflicts);
|
|
617
|
+
};
|
|
618
|
+
const cancel = () => {
|
|
619
|
+
if (!state.recording) return;
|
|
620
|
+
end();
|
|
621
|
+
set({
|
|
622
|
+
...state,
|
|
623
|
+
recording: false,
|
|
624
|
+
steps: NO_STEPS
|
|
625
|
+
});
|
|
626
|
+
options.onCancel?.();
|
|
627
|
+
};
|
|
628
|
+
const commit = () => {
|
|
629
|
+
const [first, ...rest] = state.steps;
|
|
630
|
+
if (!state.recording || first === void 0) return;
|
|
631
|
+
record(rest.length === 0 ? first : { sequence: state.steps });
|
|
632
|
+
};
|
|
633
|
+
const onKey = (event, chord) => {
|
|
634
|
+
event.preventDefault();
|
|
635
|
+
event.stopPropagation();
|
|
636
|
+
if (event.repeat) return;
|
|
637
|
+
const bare = !event.ctrlKey && !event.altKey && !event.shiftKey && !event.metaKey;
|
|
638
|
+
if (bare && event.key === "Escape") return cancel();
|
|
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();
|
|
644
|
+
if (chord === null) return;
|
|
645
|
+
if (options.sequence) set({
|
|
646
|
+
...state,
|
|
647
|
+
steps: [...state.steps, chord]
|
|
648
|
+
});
|
|
649
|
+
else record(chord);
|
|
650
|
+
};
|
|
651
|
+
return {
|
|
652
|
+
start: () => {
|
|
653
|
+
if (state.recording) return;
|
|
654
|
+
release = registry.capture(onKey);
|
|
655
|
+
set({
|
|
656
|
+
recording: true,
|
|
657
|
+
binding: null,
|
|
658
|
+
steps: NO_STEPS,
|
|
659
|
+
conflicts: []
|
|
660
|
+
});
|
|
661
|
+
},
|
|
662
|
+
cancel,
|
|
663
|
+
commit,
|
|
664
|
+
get state() {
|
|
665
|
+
return state;
|
|
666
|
+
},
|
|
667
|
+
subscribe: (listener) => {
|
|
668
|
+
listeners.add(listener);
|
|
669
|
+
return () => {
|
|
670
|
+
listeners.delete(listener);
|
|
671
|
+
};
|
|
672
|
+
},
|
|
673
|
+
destroy: () => {
|
|
674
|
+
end();
|
|
675
|
+
if (state.recording) set({
|
|
676
|
+
...state,
|
|
677
|
+
recording: false,
|
|
678
|
+
steps: NO_STEPS
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
/**
|
|
684
|
+
* Whether commands in two scopes can be live together, so their bindings can
|
|
685
|
+
* collide: a global or `"*"` command overlaps every scope, and two named
|
|
686
|
+
* scopes only when they are the same.
|
|
687
|
+
*/
|
|
688
|
+
function scopesOverlap(a, b) {
|
|
689
|
+
if (a === void 0 || b === void 0) return true;
|
|
690
|
+
if (a === "*" || b === "*") return true;
|
|
691
|
+
return a === b;
|
|
692
|
+
}
|
|
693
|
+
/** Stack managing active scopes and command reachability. */
|
|
694
|
+
var ScopeStack = class {
|
|
695
|
+
#entries = [];
|
|
696
|
+
#listeners = /* @__PURE__ */ new Set();
|
|
697
|
+
/** Activates a scope until the returned disposal function is called. */
|
|
698
|
+
push(name, options = {}) {
|
|
699
|
+
const entry = {
|
|
700
|
+
name,
|
|
701
|
+
exclusive: options.exclusive === true
|
|
702
|
+
};
|
|
703
|
+
this.#entries.push(entry);
|
|
704
|
+
this.#notify();
|
|
705
|
+
return () => {
|
|
706
|
+
const index = this.#entries.indexOf(entry);
|
|
707
|
+
if (index === -1) return;
|
|
708
|
+
this.#entries.splice(index, 1);
|
|
709
|
+
this.#notify();
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
/**
|
|
713
|
+
* Whether the keys of commands in `scope` are live: the scope is open and
|
|
714
|
+
* no `exclusive` scope above it has claimed the keyboard.
|
|
715
|
+
*/
|
|
716
|
+
isActive(scope) {
|
|
717
|
+
if (scope === "*") return true;
|
|
718
|
+
const barrier = this.#barrierIndex();
|
|
719
|
+
if (barrier === -1) return this.isOpen(scope);
|
|
720
|
+
return scope !== void 0 && this.depth(scope) >= barrier;
|
|
721
|
+
}
|
|
722
|
+
/** Whether `scope` is open at all, barriers aside. This is what a direct `run()` checks. */
|
|
723
|
+
isOpen(scope) {
|
|
724
|
+
return scope === void 0 || scope === "*" || this.depth(scope) !== -1;
|
|
725
|
+
}
|
|
726
|
+
/** How far up the stack `scope` was last opened, or `-1` for a global or closed one. */
|
|
727
|
+
depth(scope) {
|
|
728
|
+
if (scope === void 0 || scope === "*") return -1;
|
|
729
|
+
for (let index = this.#entries.length - 1; index >= 0; index--) if (this.#entries[index]?.name === scope) return index;
|
|
730
|
+
return -1;
|
|
731
|
+
}
|
|
732
|
+
/** Clears all active scopes. */
|
|
733
|
+
clear() {
|
|
734
|
+
if (this.#entries.length === 0) return;
|
|
735
|
+
this.#entries = [];
|
|
736
|
+
this.#notify();
|
|
737
|
+
}
|
|
738
|
+
/** Subscribes to scope stack changes. Returns an unsubscribe function. */
|
|
739
|
+
subscribe(listener) {
|
|
740
|
+
this.#listeners.add(listener);
|
|
741
|
+
return () => {
|
|
742
|
+
this.#listeners.delete(listener);
|
|
743
|
+
};
|
|
744
|
+
}
|
|
745
|
+
#barrierIndex() {
|
|
746
|
+
for (let index = this.#entries.length - 1; index >= 0; index--) if (this.#entries[index]?.exclusive) return index;
|
|
747
|
+
return -1;
|
|
748
|
+
}
|
|
749
|
+
#notify() {
|
|
750
|
+
for (const listener of this.#listeners) listener();
|
|
751
|
+
}
|
|
752
|
+
};
|
|
753
|
+
//#endregion
|
|
754
|
+
//#region src/audit.ts
|
|
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
|
+
}
|
|
765
|
+
const platformName = (platform) => platform === "mac" ? "macOS" : "Windows and Linux";
|
|
766
|
+
/** A binding as the app wrote it, which is what there is to search the code for. */
|
|
767
|
+
const written = (binding) => stepsOf(binding).join(" ");
|
|
768
|
+
/**
|
|
769
|
+
* Warns about bad syntax, keys the platform keeps or types with, unknown
|
|
770
|
+
* overrides and collisions. Called behind a `NODE_ENV` check, so a production
|
|
771
|
+
* build drops this module.
|
|
772
|
+
*/
|
|
773
|
+
function auditRegistry(source) {
|
|
774
|
+
const said = /* @__PURE__ */ new Set();
|
|
775
|
+
const warn = (message) => {
|
|
776
|
+
if (said.has(message)) return;
|
|
777
|
+
said.add(message);
|
|
778
|
+
console.warn(`[@inklu/keys] ${message}`);
|
|
779
|
+
};
|
|
780
|
+
/** Reserved bindings, and the platforms each is reserved on. */
|
|
781
|
+
const reserved = /* @__PURE__ */ new Map();
|
|
782
|
+
for (const platform of source.platforms) {
|
|
783
|
+
for (const id of source.ids) for (const binding of source.bindings(id, platform)) {
|
|
784
|
+
warnAboutBinding(id, binding, platform, warn);
|
|
785
|
+
if (!isReserved(binding, platform)) continue;
|
|
786
|
+
const subject = `"${id}" binds "${written(binding)}"`;
|
|
787
|
+
reserved.set(subject, (reserved.get(subject) ?? /* @__PURE__ */ new Set()).add(platformName(platform)));
|
|
788
|
+
}
|
|
789
|
+
warnAboutConflicts(source, platform, warn);
|
|
790
|
+
}
|
|
791
|
+
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.`);
|
|
792
|
+
warnAboutUnknownOverrides(source, warn);
|
|
793
|
+
}
|
|
794
|
+
function warnAboutBinding(id, binding, platform, warn) {
|
|
795
|
+
const { errors, warnings } = validateBinding(binding);
|
|
796
|
+
if (errors.length > 0) {
|
|
797
|
+
warn(`"${id}" has an unrecognized binding "${written(binding)}", so it never fires. ${errors.join("; ")}.`);
|
|
798
|
+
return;
|
|
799
|
+
}
|
|
800
|
+
if (warnings.length > 0) warn(`"${id}" binds "${written(binding)}". ${warnings.join(" ")}`);
|
|
801
|
+
const held = (parseBinding(binding, platform) ?? []).filter((step) => step.ctrl && step.alt);
|
|
802
|
+
if (held.length === 0) return;
|
|
803
|
+
if (platform === "mac") warn(`"${id}" binds "${written(binding)}". Control+Option is the VoiceOver modifier on macOS, so screen reader users cannot press it.`);
|
|
804
|
+
else if (held.some((step) => typesCharacter({
|
|
805
|
+
...step,
|
|
806
|
+
ctrl: false,
|
|
807
|
+
alt: false
|
|
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 }.`);
|
|
809
|
+
}
|
|
810
|
+
function warnAboutConflicts(source, platform, warn) {
|
|
811
|
+
const seen = /* @__PURE__ */ new Map();
|
|
812
|
+
const visited = [];
|
|
813
|
+
for (const id of source.ids) {
|
|
814
|
+
const scope = source.scope(id);
|
|
815
|
+
for (const binding of source.bindings(id, platform)) {
|
|
816
|
+
for (const other of visited) {
|
|
817
|
+
if (other.id === id || !scopesOverlap(other.scope, scope)) continue;
|
|
818
|
+
if (!isPrefixCollision(other.binding, binding, platform)) continue;
|
|
819
|
+
const [short, long] = stepsOf(other.binding).length < stepsOf(binding).length ? [other, {
|
|
820
|
+
id,
|
|
821
|
+
binding
|
|
822
|
+
}] : [{
|
|
823
|
+
id,
|
|
824
|
+
binding
|
|
825
|
+
}, other];
|
|
826
|
+
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.`);
|
|
827
|
+
}
|
|
828
|
+
visited.push({
|
|
829
|
+
id,
|
|
830
|
+
scope,
|
|
831
|
+
binding
|
|
832
|
+
});
|
|
833
|
+
const key = `${scope ?? ""}::${bindingSignature(binding, platform)}`;
|
|
834
|
+
const owner = seen.get(key);
|
|
835
|
+
if (owner === void 0) {
|
|
836
|
+
seen.set(key, id);
|
|
837
|
+
continue;
|
|
838
|
+
}
|
|
839
|
+
warn(`shadowed binding: "${id}" and "${owner}" share ${written(binding)} in the same scope, so "${id}" takes the key whenever both have a handler.`);
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
/** Catches ids in `options.bindings` that TypeScript could not, as in plain JS. */
|
|
844
|
+
function warnAboutUnknownOverrides(source, warn) {
|
|
845
|
+
if (!source.overrides) return;
|
|
846
|
+
const known = new Set(source.ids);
|
|
847
|
+
const unknown = Object.keys(source.overrides).filter((id) => !known.has(id));
|
|
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"}.`);
|
|
849
|
+
}
|
|
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
|
|
1026
|
+
//#region src/registry.ts
|
|
1027
|
+
/** Command registry: what an app can do, which keys do it, and where each is live. */
|
|
1028
|
+
const SSR_PLATFORM = "other";
|
|
1029
|
+
var CommandRegistry = class {
|
|
1030
|
+
#definitions = /* @__PURE__ */ new Map();
|
|
1031
|
+
/** Handlers attached per command, the most recent last. */
|
|
1032
|
+
#handlers = /* @__PURE__ */ new Map();
|
|
1033
|
+
#keymap;
|
|
1034
|
+
#scopes = new ScopeStack();
|
|
1035
|
+
#keys;
|
|
1036
|
+
#options;
|
|
1037
|
+
#listeners = /* @__PURE__ */ new Set();
|
|
1038
|
+
#snapshot = null;
|
|
1039
|
+
/** What the snapshot is behind on: who is reachable, or the bindings too. */
|
|
1040
|
+
#stale = "bindings";
|
|
1041
|
+
#serverSnapshot = null;
|
|
1042
|
+
#mounts = 0;
|
|
1043
|
+
#hydrated = false;
|
|
1044
|
+
#singleKeys;
|
|
1045
|
+
constructor(definitions, options = {}) {
|
|
1046
|
+
this.#options = options;
|
|
1047
|
+
this.#singleKeys = options.singleKeyShortcuts !== false;
|
|
1048
|
+
this.#keymap = new Keymap(options.persist);
|
|
1049
|
+
for (const id in definitions) this.#definitions.set(id, definitions[id]);
|
|
1050
|
+
this.#setDefaults();
|
|
1051
|
+
this.#keys = new KeyListener({
|
|
1052
|
+
ids: () => [...this.#definitions.keys()],
|
|
1053
|
+
definition: (id) => this.#definitions.get(id),
|
|
1054
|
+
bindings: (id) => this.#bindings(id),
|
|
1055
|
+
reachable: (id) => this.#reachable(id),
|
|
1056
|
+
depth: (id) => this.#scopes.depth(this.#definitions.get(id)?.scope),
|
|
1057
|
+
platform: () => this.#platform(),
|
|
1058
|
+
run: (id, event, binding) => this.#execute(id, {
|
|
1059
|
+
source: "keyboard",
|
|
1060
|
+
event,
|
|
1061
|
+
binding
|
|
1062
|
+
})
|
|
1063
|
+
});
|
|
1064
|
+
this.#keymap.subscribe(() => this.#bindingsChanged());
|
|
1065
|
+
this.#scopes.subscribe(() => this.#invalidate("reachable"));
|
|
1066
|
+
if (process.env.NODE_ENV !== "production") auditRegistry({
|
|
1067
|
+
ids: [...this.#definitions.keys()],
|
|
1068
|
+
scope: (id) => this.#definitions.get(id)?.scope,
|
|
1069
|
+
bindings: (id, platform) => this.#defaultBindings(id, platform),
|
|
1070
|
+
platforms: options.platform ? [options.platform] : ["mac", "other"],
|
|
1071
|
+
overrides: options.bindings
|
|
1072
|
+
});
|
|
1073
|
+
}
|
|
1074
|
+
get keymap() {
|
|
1075
|
+
return this.#keymap;
|
|
1076
|
+
}
|
|
1077
|
+
get(id) {
|
|
1078
|
+
return this.#snapshots().index.get(id);
|
|
1079
|
+
}
|
|
1080
|
+
list() {
|
|
1081
|
+
return this.#snapshots().list;
|
|
1082
|
+
}
|
|
1083
|
+
serverList() {
|
|
1084
|
+
if (this.#serverSnapshot) return this.#serverSnapshot;
|
|
1085
|
+
const platform = this.#options.platform ?? SSR_PLATFORM;
|
|
1086
|
+
const singleKeys = this.#options.singleKeyShortcuts !== false;
|
|
1087
|
+
this.#serverSnapshot = Array.from(this.#definitions, ([id, definition]) => {
|
|
1088
|
+
const bindings = this.#defaultBindings(id, platform).filter((binding) => singleKeys || !isSingleKeyBinding(binding));
|
|
1089
|
+
const global = definition.scope === void 0 || definition.scope === "*";
|
|
1090
|
+
return describeCommand(id, definition, bindings, platform, {
|
|
1091
|
+
reachable: definition.run !== void 0 && global,
|
|
1092
|
+
overridden: false
|
|
1093
|
+
});
|
|
1094
|
+
});
|
|
1095
|
+
return this.#serverSnapshot;
|
|
1096
|
+
}
|
|
1097
|
+
run(id, options = {}) {
|
|
1098
|
+
if (this.whyNotRun(id) !== null) return false;
|
|
1099
|
+
this.#execute(id, {
|
|
1100
|
+
source: options.source ?? "api",
|
|
1101
|
+
event: options.event
|
|
1102
|
+
});
|
|
1103
|
+
return true;
|
|
1104
|
+
}
|
|
1105
|
+
whyNotRun(id) {
|
|
1106
|
+
const definition = this.#definitions.get(id);
|
|
1107
|
+
if (!definition) return "unknown";
|
|
1108
|
+
if (!this.#handler(id)) return "unhandled";
|
|
1109
|
+
return this.#scopes.isOpen(definition.scope) ? null : "out-of-scope";
|
|
1110
|
+
}
|
|
1111
|
+
handle(id, run) {
|
|
1112
|
+
if (!this.#definitions.has(id)) return () => {};
|
|
1113
|
+
let handlers = this.#handlers.get(id);
|
|
1114
|
+
if (!handlers) {
|
|
1115
|
+
handlers = [];
|
|
1116
|
+
this.#handlers.set(id, handlers);
|
|
1117
|
+
}
|
|
1118
|
+
const had = this.#handler(id) !== void 0;
|
|
1119
|
+
handlers.push(run);
|
|
1120
|
+
if (!had) this.#invalidate("reachable");
|
|
1121
|
+
let disposed = false;
|
|
1122
|
+
return () => {
|
|
1123
|
+
if (disposed) return;
|
|
1124
|
+
disposed = true;
|
|
1125
|
+
const index = handlers.lastIndexOf(run);
|
|
1126
|
+
if (index !== -1) handlers.splice(index, 1);
|
|
1127
|
+
if (this.#handler(id) === void 0) this.#invalidate("reachable");
|
|
1128
|
+
};
|
|
1129
|
+
}
|
|
1130
|
+
pushScope(name, options) {
|
|
1131
|
+
return this.#scopes.push(name, options);
|
|
1132
|
+
}
|
|
1133
|
+
capture(onKey) {
|
|
1134
|
+
const release = this.#keys.capture(onKey);
|
|
1135
|
+
this.#invalidate("reachable");
|
|
1136
|
+
let released = false;
|
|
1137
|
+
return () => {
|
|
1138
|
+
if (released) return;
|
|
1139
|
+
released = true;
|
|
1140
|
+
release();
|
|
1141
|
+
this.#invalidate("reachable");
|
|
1142
|
+
};
|
|
1143
|
+
}
|
|
1144
|
+
conflicts(binding, options = {}) {
|
|
1145
|
+
const platform = this.#platform();
|
|
1146
|
+
const candidates = toBindings(binding);
|
|
1147
|
+
const signatures = new Set(candidates.map((entry) => bindingSignature(entry, platform)));
|
|
1148
|
+
const conflicts = [];
|
|
1149
|
+
for (const [id, { scope }] of this.#definitions) {
|
|
1150
|
+
if (id === options.ignore || !scopesOverlap(scope, options.scope)) continue;
|
|
1151
|
+
for (const existing of this.#bindings(id)) {
|
|
1152
|
+
const kind = signatures.has(bindingSignature(existing, platform)) ? "exact" : candidates.some((candidate) => isPrefixCollision(candidate, existing, platform)) ? "prefix" : null;
|
|
1153
|
+
if (kind === null) continue;
|
|
1154
|
+
conflicts.push({
|
|
1155
|
+
kind,
|
|
1156
|
+
id,
|
|
1157
|
+
binding: existing,
|
|
1158
|
+
display: formatBinding(existing, platform),
|
|
1159
|
+
scope
|
|
1160
|
+
});
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
return conflicts;
|
|
1164
|
+
}
|
|
1165
|
+
get singleKeyShortcuts() {
|
|
1166
|
+
return this.#singleKeys;
|
|
1167
|
+
}
|
|
1168
|
+
setSingleKeyShortcuts(enabled) {
|
|
1169
|
+
if (this.#singleKeys === enabled) return;
|
|
1170
|
+
this.#singleKeys = enabled;
|
|
1171
|
+
this.#bindingsChanged();
|
|
1172
|
+
}
|
|
1173
|
+
mount() {
|
|
1174
|
+
this.#mounts++;
|
|
1175
|
+
if (this.#mounts === 1) {
|
|
1176
|
+
const rendered = this.#platform();
|
|
1177
|
+
this.#hydrated = true;
|
|
1178
|
+
const moved = this.#platform() !== rendered;
|
|
1179
|
+
if (moved) this.#setDefaults();
|
|
1180
|
+
this.#keys.attach();
|
|
1181
|
+
if (!this.#keymap.load() && moved) this.#bindingsChanged();
|
|
1182
|
+
}
|
|
1183
|
+
let released = false;
|
|
1184
|
+
return () => {
|
|
1185
|
+
if (released) return;
|
|
1186
|
+
released = true;
|
|
1187
|
+
if (this.#mounts === 0) return;
|
|
1188
|
+
this.#mounts--;
|
|
1189
|
+
if (this.#mounts === 0) this.#keys.detach();
|
|
1190
|
+
};
|
|
1191
|
+
}
|
|
1192
|
+
subscribe(listener) {
|
|
1193
|
+
this.#listeners.add(listener);
|
|
1194
|
+
return () => {
|
|
1195
|
+
this.#listeners.delete(listener);
|
|
1196
|
+
};
|
|
1197
|
+
}
|
|
1198
|
+
destroy() {
|
|
1199
|
+
this.#mounts = 0;
|
|
1200
|
+
this.#keys.destroy();
|
|
1201
|
+
this.#definitions.clear();
|
|
1202
|
+
this.#handlers.clear();
|
|
1203
|
+
this.#serverSnapshot = null;
|
|
1204
|
+
this.#invalidate("bindings");
|
|
1205
|
+
this.#listeners.clear();
|
|
1206
|
+
this.#scopes.clear();
|
|
1207
|
+
}
|
|
1208
|
+
/** The handler that runs: the one attached last, else the definition's own. */
|
|
1209
|
+
#handler(id) {
|
|
1210
|
+
return this.#handlers.get(id)?.at(-1) ?? this.#definitions.get(id)?.run;
|
|
1211
|
+
}
|
|
1212
|
+
#reachable(id) {
|
|
1213
|
+
if (this.#keys.captured || !this.#handler(id)) return false;
|
|
1214
|
+
return this.#scopes.isActive(this.#definitions.get(id)?.scope);
|
|
1215
|
+
}
|
|
1216
|
+
/** Bindings in effect: the keymap's, minus single-key ones while those are switched off. */
|
|
1217
|
+
#bindings(id) {
|
|
1218
|
+
const bindings = this.#keymap.get(id);
|
|
1219
|
+
if (this.#singleKeys) return bindings;
|
|
1220
|
+
return bindings.filter((binding) => !isSingleKeyBinding(binding));
|
|
1221
|
+
}
|
|
1222
|
+
/**
|
|
1223
|
+
* A command's default bindings on a platform. An `options.bindings` entry
|
|
1224
|
+
* wins over the definition's `keys`, and a `null` one means unbound.
|
|
1225
|
+
*/
|
|
1226
|
+
#defaultBindings(id, platform) {
|
|
1227
|
+
const override = this.#options.bindings?.[id];
|
|
1228
|
+
return toBindings(keysFor(override === void 0 ? this.#definitions.get(id)?.keys : override, platform));
|
|
1229
|
+
}
|
|
1230
|
+
/** Hands the keymap every command's defaults for the platform in effect. */
|
|
1231
|
+
#setDefaults() {
|
|
1232
|
+
const platform = this.#platform();
|
|
1233
|
+
for (const id of this.#definitions.keys()) this.#keymap.setDefault(id, this.#defaultBindings(id, platform));
|
|
1234
|
+
}
|
|
1235
|
+
#execute(id, context) {
|
|
1236
|
+
const run = this.#handler(id);
|
|
1237
|
+
if (!run) return;
|
|
1238
|
+
const full = {
|
|
1239
|
+
id,
|
|
1240
|
+
...context,
|
|
1241
|
+
commands: this
|
|
1242
|
+
};
|
|
1243
|
+
this.#options.onRun?.(full);
|
|
1244
|
+
const report = (error) => console.error(`[@inklu/keys] "${id}" failed`, error);
|
|
1245
|
+
try {
|
|
1246
|
+
const result = run(full);
|
|
1247
|
+
if (result instanceof Promise) result.catch(report);
|
|
1248
|
+
} catch (error) {
|
|
1249
|
+
report(error);
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
#bindingsChanged() {
|
|
1253
|
+
this.#keys.refresh();
|
|
1254
|
+
this.#invalidate("bindings");
|
|
1255
|
+
}
|
|
1256
|
+
#platform() {
|
|
1257
|
+
return this.#options.platform ?? (this.#hydrated ? detectPlatform() : SSR_PLATFORM);
|
|
1258
|
+
}
|
|
1259
|
+
/**
|
|
1260
|
+
* The commands as they are now. A command that did not change keeps its
|
|
1261
|
+
* object, and the list keeps its own while none did, so a subscriber can
|
|
1262
|
+
* tell by identity whether it has anything to do.
|
|
1263
|
+
*/
|
|
1264
|
+
#snapshots() {
|
|
1265
|
+
const previous = this.#snapshot;
|
|
1266
|
+
if (previous && !this.#stale) return previous;
|
|
1267
|
+
const kept = this.#stale === "reachable" ? previous?.index : void 0;
|
|
1268
|
+
const platform = this.#platform();
|
|
1269
|
+
const list = [];
|
|
1270
|
+
const index = /* @__PURE__ */ new Map();
|
|
1271
|
+
let changed = !kept;
|
|
1272
|
+
for (const [id, definition] of this.#definitions) {
|
|
1273
|
+
const reachable = this.#reachable(id);
|
|
1274
|
+
let command = kept?.get(id);
|
|
1275
|
+
if (!command) command = describeCommand(id, definition, this.#bindings(id), platform, {
|
|
1276
|
+
reachable,
|
|
1277
|
+
overridden: this.#keymap.isOverridden(id)
|
|
1278
|
+
});
|
|
1279
|
+
else if (command.reachable !== reachable) {
|
|
1280
|
+
command = {
|
|
1281
|
+
...command,
|
|
1282
|
+
reachable
|
|
1283
|
+
};
|
|
1284
|
+
changed = true;
|
|
1285
|
+
}
|
|
1286
|
+
list.push(command);
|
|
1287
|
+
index.set(id, command);
|
|
1288
|
+
}
|
|
1289
|
+
this.#stale = null;
|
|
1290
|
+
this.#snapshot = changed || !previous ? {
|
|
1291
|
+
list,
|
|
1292
|
+
index
|
|
1293
|
+
} : previous;
|
|
1294
|
+
return this.#snapshot;
|
|
1295
|
+
}
|
|
1296
|
+
#invalidate(stale) {
|
|
1297
|
+
if (this.#stale !== "bindings") this.#stale = stale;
|
|
1298
|
+
for (const listener of this.#listeners) listener();
|
|
1299
|
+
}
|
|
1300
|
+
};
|
|
1301
|
+
function describeCommand(id, definition, source, platform, state) {
|
|
1302
|
+
const bindings = [...source];
|
|
1303
|
+
return {
|
|
1304
|
+
id,
|
|
1305
|
+
title: definition.title,
|
|
1306
|
+
scope: definition.scope,
|
|
1307
|
+
bindings,
|
|
1308
|
+
display: bindings.map((binding) => formatBinding(binding, platform)),
|
|
1309
|
+
tokens: bindings.map((binding) => bindingTokens(binding, platform)),
|
|
1310
|
+
aria: bindings.map((binding) => ariaKeyShortcuts(binding, platform)).filter((value) => value.length > 0).join(" "),
|
|
1311
|
+
...state
|
|
1312
|
+
};
|
|
1313
|
+
}
|
|
1314
|
+
/**
|
|
1315
|
+
* Creates a command registry.
|
|
1316
|
+
*
|
|
1317
|
+
* @example
|
|
1318
|
+
* ```ts
|
|
1319
|
+
* export const commands = createCommands({
|
|
1320
|
+
* "palette.open": { title: "Open command palette", keys: "Mod+K" },
|
|
1321
|
+
* "nav.inbox": {
|
|
1322
|
+
* title: "Go to inbox",
|
|
1323
|
+
* keys: seq("G", "I"),
|
|
1324
|
+
* run: () => router.navigate("/inbox"),
|
|
1325
|
+
* },
|
|
1326
|
+
* });
|
|
1327
|
+
* ```
|
|
1328
|
+
*/
|
|
1329
|
+
function createCommands(definitions, options) {
|
|
1330
|
+
return new CommandRegistry(definitions, options);
|
|
1331
|
+
}
|
|
1332
|
+
//#endregion
|
|
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 };
|