@dolphy-app/keybindings 0.4.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 +24 -0
- package/dist/index.d.ts +313 -0
- package/dist/index.js +1041 -0
- package/package.json +30 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1041 @@
|
|
|
1
|
+
//#region packages/keybindings/src/platform.ts
|
|
2
|
+
const PLATFORMS = [
|
|
3
|
+
"mac",
|
|
4
|
+
"windows",
|
|
5
|
+
"linux"
|
|
6
|
+
];
|
|
7
|
+
/** Платформа окна по `navigator`: Mac, Windows, всё остальное — Linux. */
|
|
8
|
+
const detectPlatform = (nav) => {
|
|
9
|
+
const platform = nav.platform ?? "";
|
|
10
|
+
const agent = nav.userAgent ?? "";
|
|
11
|
+
if (/^(Mac|iPhone|iPad|iPod)/i.test(platform)) return "mac";
|
|
12
|
+
if (/^Win/i.test(platform)) return "windows";
|
|
13
|
+
if (platform === "" && /Macintosh/i.test(agent)) return "mac";
|
|
14
|
+
if (platform === "" && /Windows/i.test(agent)) return "windows";
|
|
15
|
+
return "linux";
|
|
16
|
+
};
|
|
17
|
+
/** Платформа процесса Node по `process.platform`. */
|
|
18
|
+
const platformFromNode = (name) => {
|
|
19
|
+
if (name === "darwin") return "mac";
|
|
20
|
+
if (name === "win32") return "windows";
|
|
21
|
+
return "linux";
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
//#endregion
|
|
25
|
+
//#region packages/keybindings/src/keystroke.ts
|
|
26
|
+
const MAX_CHORD_LENGTH = 2;
|
|
27
|
+
var KeybindingSyntaxError = class extends Error {
|
|
28
|
+
reason;
|
|
29
|
+
text;
|
|
30
|
+
constructor(reason, text) {
|
|
31
|
+
super(`invalid keybinding "${text}": ${reason}`);
|
|
32
|
+
this.name = "KeybindingSyntaxError";
|
|
33
|
+
this.reason = reason;
|
|
34
|
+
this.text = text;
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
const MODIFIER_ALIASES = {
|
|
38
|
+
mod: "mod",
|
|
39
|
+
ctrl: "ctrl",
|
|
40
|
+
control: "ctrl",
|
|
41
|
+
alt: "alt",
|
|
42
|
+
option: "alt",
|
|
43
|
+
opt: "alt",
|
|
44
|
+
shift: "shift",
|
|
45
|
+
meta: "meta",
|
|
46
|
+
cmd: "meta",
|
|
47
|
+
command: "meta",
|
|
48
|
+
win: "meta",
|
|
49
|
+
super: "meta"
|
|
50
|
+
};
|
|
51
|
+
const NAMED_KEYS = {
|
|
52
|
+
enter: "enter",
|
|
53
|
+
return: "enter",
|
|
54
|
+
escape: "escape",
|
|
55
|
+
esc: "escape",
|
|
56
|
+
tab: "tab",
|
|
57
|
+
space: "space",
|
|
58
|
+
spacebar: "space",
|
|
59
|
+
backspace: "backspace",
|
|
60
|
+
delete: "delete",
|
|
61
|
+
del: "delete",
|
|
62
|
+
insert: "insert",
|
|
63
|
+
ins: "insert",
|
|
64
|
+
home: "home",
|
|
65
|
+
end: "end",
|
|
66
|
+
pageup: "pageup",
|
|
67
|
+
pgup: "pageup",
|
|
68
|
+
pagedown: "pagedown",
|
|
69
|
+
pgdn: "pagedown",
|
|
70
|
+
arrowup: "arrowup",
|
|
71
|
+
up: "arrowup",
|
|
72
|
+
arrowdown: "arrowdown",
|
|
73
|
+
down: "arrowdown",
|
|
74
|
+
arrowleft: "arrowleft",
|
|
75
|
+
left: "arrowleft",
|
|
76
|
+
arrowright: "arrowright",
|
|
77
|
+
right: "arrowright",
|
|
78
|
+
plus: "+"
|
|
79
|
+
};
|
|
80
|
+
const CANONICAL_TEXT = {
|
|
81
|
+
enter: "Enter",
|
|
82
|
+
escape: "Escape",
|
|
83
|
+
tab: "Tab",
|
|
84
|
+
space: "Space",
|
|
85
|
+
backspace: "Backspace",
|
|
86
|
+
delete: "Delete",
|
|
87
|
+
insert: "Insert",
|
|
88
|
+
home: "Home",
|
|
89
|
+
end: "End",
|
|
90
|
+
pageup: "PageUp",
|
|
91
|
+
pagedown: "PageDown",
|
|
92
|
+
arrowup: "ArrowUp",
|
|
93
|
+
arrowdown: "ArrowDown",
|
|
94
|
+
arrowleft: "ArrowLeft",
|
|
95
|
+
arrowright: "ArrowRight"
|
|
96
|
+
};
|
|
97
|
+
const LABELS = {
|
|
98
|
+
enter: "Enter",
|
|
99
|
+
escape: "Esc",
|
|
100
|
+
tab: "Tab",
|
|
101
|
+
space: "Space",
|
|
102
|
+
backspace: "Backspace",
|
|
103
|
+
delete: "Delete",
|
|
104
|
+
insert: "Insert",
|
|
105
|
+
home: "Home",
|
|
106
|
+
end: "End",
|
|
107
|
+
pageup: "Page Up",
|
|
108
|
+
pagedown: "Page Down",
|
|
109
|
+
arrowup: "↑",
|
|
110
|
+
arrowdown: "↓",
|
|
111
|
+
arrowleft: "←",
|
|
112
|
+
arrowright: "→"
|
|
113
|
+
};
|
|
114
|
+
const CODE_KEYS = {
|
|
115
|
+
Backquote: "`",
|
|
116
|
+
Minus: "-",
|
|
117
|
+
Equal: "=",
|
|
118
|
+
BracketLeft: "[",
|
|
119
|
+
BracketRight: "]",
|
|
120
|
+
Backslash: "\\",
|
|
121
|
+
Semicolon: ";",
|
|
122
|
+
Quote: "'",
|
|
123
|
+
Comma: ",",
|
|
124
|
+
Period: ".",
|
|
125
|
+
Slash: "/"
|
|
126
|
+
};
|
|
127
|
+
const MODIFIER_ONLY_KEYS = /* @__PURE__ */ new Set([
|
|
128
|
+
"control",
|
|
129
|
+
"shift",
|
|
130
|
+
"alt",
|
|
131
|
+
"altgraph",
|
|
132
|
+
"meta",
|
|
133
|
+
"os",
|
|
134
|
+
"capslock",
|
|
135
|
+
"numlock",
|
|
136
|
+
"fn",
|
|
137
|
+
"process",
|
|
138
|
+
"unidentified"
|
|
139
|
+
]);
|
|
140
|
+
const META_LABEL = {
|
|
141
|
+
mac: "⌘",
|
|
142
|
+
windows: "Win",
|
|
143
|
+
linux: "Super"
|
|
144
|
+
};
|
|
145
|
+
const META_WORD = {
|
|
146
|
+
mac: "command",
|
|
147
|
+
windows: "windows",
|
|
148
|
+
linux: "super"
|
|
149
|
+
};
|
|
150
|
+
const FUNCTION_KEY = /^f([1-9]|1\d|2[0-4])$/;
|
|
151
|
+
const PHYSICAL = /^\[([A-Za-z][A-Za-z0-9]*)\]$/;
|
|
152
|
+
const own = (table, key) => Object.hasOwn(table, key) ? table[key] : void 0;
|
|
153
|
+
/** Логическая клавиша, которую даёт физическая: `KeyK` → `k`, `Comma` → `,`; нет соответствия — `undefined`. */
|
|
154
|
+
const codeToKey = (code) => {
|
|
155
|
+
const letter = /^Key([A-Z])$/.exec(code);
|
|
156
|
+
if (letter) return letter[1].toLowerCase();
|
|
157
|
+
const digit = /^Digit(\d)$/.exec(code);
|
|
158
|
+
if (digit) return digit[1];
|
|
159
|
+
return own(CODE_KEYS, code);
|
|
160
|
+
};
|
|
161
|
+
const isSingleCharacter = (text) => Array.from(text).length === 1 && !/\s/u.test(text);
|
|
162
|
+
/** Имя клавиши записи в каноническую логическую форму; `null` — неизвестное имя. */
|
|
163
|
+
const normalizeKeyName = (part) => {
|
|
164
|
+
const lower = part.toLowerCase();
|
|
165
|
+
const named = own(NAMED_KEYS, lower);
|
|
166
|
+
if (named !== void 0) return named;
|
|
167
|
+
if (FUNCTION_KEY.test(lower)) return lower;
|
|
168
|
+
return isSingleCharacter(part) ? lower : null;
|
|
169
|
+
};
|
|
170
|
+
const stroke = (flags, key, code) => ({
|
|
171
|
+
ctrl: flags.ctrl,
|
|
172
|
+
alt: flags.alt,
|
|
173
|
+
shift: flags.shift,
|
|
174
|
+
meta: flags.meta,
|
|
175
|
+
key,
|
|
176
|
+
code
|
|
177
|
+
});
|
|
178
|
+
/** `Mod++` — клавиша `+`; `Mod+K` — модификаторы и клавиша. */
|
|
179
|
+
const splitStroke = (text) => {
|
|
180
|
+
if (text === "+") return {
|
|
181
|
+
modifiers: [],
|
|
182
|
+
key: "+"
|
|
183
|
+
};
|
|
184
|
+
const plus = text.endsWith("++");
|
|
185
|
+
const index = plus ? text.length - 2 : text.lastIndexOf("+");
|
|
186
|
+
if (index < 0) return {
|
|
187
|
+
modifiers: [],
|
|
188
|
+
key: text
|
|
189
|
+
};
|
|
190
|
+
return {
|
|
191
|
+
modifiers: text.slice(0, index).split("+"),
|
|
192
|
+
key: plus ? "+" : text.slice(index + 1)
|
|
193
|
+
};
|
|
194
|
+
};
|
|
195
|
+
const parseStroke = (text, platform, whole) => {
|
|
196
|
+
const { modifiers, key: keyPart } = splitStroke(text);
|
|
197
|
+
const flags = {
|
|
198
|
+
ctrl: false,
|
|
199
|
+
alt: false,
|
|
200
|
+
shift: false,
|
|
201
|
+
meta: false
|
|
202
|
+
};
|
|
203
|
+
for (const token of modifiers) {
|
|
204
|
+
if (token === "") throw new KeybindingSyntaxError("empty-part", whole);
|
|
205
|
+
const name = own(MODIFIER_ALIASES, token.toLowerCase());
|
|
206
|
+
if (name === void 0) throw new KeybindingSyntaxError("unknown-modifier", whole);
|
|
207
|
+
const resolved = name === "mod" ? platform === "mac" ? "meta" : "ctrl" : name;
|
|
208
|
+
if (flags[resolved]) throw new KeybindingSyntaxError("repeated-modifier", whole);
|
|
209
|
+
flags[resolved] = true;
|
|
210
|
+
}
|
|
211
|
+
if (keyPart === "" || Object.hasOwn(MODIFIER_ALIASES, keyPart.toLowerCase())) throw new KeybindingSyntaxError("modifier-only", whole);
|
|
212
|
+
const physical = PHYSICAL.exec(keyPart);
|
|
213
|
+
if (physical) return stroke(flags, "", physical[1]);
|
|
214
|
+
if (keyPart.length > 1 && (keyPart.startsWith("[") || keyPart.endsWith("]"))) throw new KeybindingSyntaxError("invalid-code", whole);
|
|
215
|
+
const key = normalizeKeyName(keyPart);
|
|
216
|
+
if (key === null) throw new KeybindingSyntaxError("unknown-key", whole);
|
|
217
|
+
return stroke(flags, key, "");
|
|
218
|
+
};
|
|
219
|
+
/**
|
|
220
|
+
* `Mod+Shift+L` или `Mod+K Mod+S` → сочетание для платформы. `Mod` — Meta на
|
|
221
|
+
* macOS и Ctrl на остальных; остальные модификаторы — по имени. Ошибка
|
|
222
|
+
* записи — `KeybindingSyntaxError` с причиной.
|
|
223
|
+
*/
|
|
224
|
+
const parseChord = (text, platform) => {
|
|
225
|
+
const trimmed = text.trim();
|
|
226
|
+
if (trimmed === "") throw new KeybindingSyntaxError("empty", text);
|
|
227
|
+
const parts = trimmed.split(/\s+/);
|
|
228
|
+
if (parts.length > 2) throw new KeybindingSyntaxError("too-long", text);
|
|
229
|
+
return parts.map((part) => parseStroke(part, platform, text));
|
|
230
|
+
};
|
|
231
|
+
/** `null` вместо ошибки. */
|
|
232
|
+
const tryParseChord = (text, platform) => {
|
|
233
|
+
try {
|
|
234
|
+
return parseChord(text, platform);
|
|
235
|
+
} catch (error) {
|
|
236
|
+
if (error instanceof KeybindingSyntaxError) return null;
|
|
237
|
+
throw error;
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
const keyText = (item) => {
|
|
241
|
+
if (item.code !== "") return `[${item.code}]`;
|
|
242
|
+
return own(CANONICAL_TEXT, item.key) ?? item.key.toUpperCase();
|
|
243
|
+
};
|
|
244
|
+
const strokeToText = (item, platform) => {
|
|
245
|
+
const primary = platform === "mac" ? "meta" : "ctrl";
|
|
246
|
+
const parts = [];
|
|
247
|
+
if (item[primary]) parts.push("Mod");
|
|
248
|
+
if (primary !== "ctrl" && item.ctrl) parts.push("Ctrl");
|
|
249
|
+
if (item.alt) parts.push("Alt");
|
|
250
|
+
if (item.shift) parts.push("Shift");
|
|
251
|
+
if (primary !== "meta" && item.meta) parts.push("Meta");
|
|
252
|
+
parts.push(keyText(item));
|
|
253
|
+
return parts.join("+");
|
|
254
|
+
};
|
|
255
|
+
/** Каноническая запись для хранения: `Mod` вместо основного модификатора платформы. */
|
|
256
|
+
const chordToText = (chord, platform) => chord.map((item) => strokeToText(item, platform)).join(" ");
|
|
257
|
+
const labelOfKey = (item) => {
|
|
258
|
+
if (item.code === "") return own(LABELS, item.key) ?? item.key.toUpperCase();
|
|
259
|
+
return /^Key([A-Z])$/.exec(item.code)?.[1] ?? /^Digit(\d)$/.exec(item.code)?.[1] ?? item.code;
|
|
260
|
+
};
|
|
261
|
+
const strokeLabel = (item, platform) => {
|
|
262
|
+
const key = labelOfKey(item);
|
|
263
|
+
if (platform === "mac") return `${item.ctrl ? "⌃" : ""}${item.alt ? "⌥" : ""}${item.shift ? "⇧" : ""}${item.meta ? "⌘" : ""}${key}`;
|
|
264
|
+
return [
|
|
265
|
+
item.ctrl ? "Ctrl" : null,
|
|
266
|
+
item.alt ? "Alt" : null,
|
|
267
|
+
item.shift ? "Shift" : null,
|
|
268
|
+
item.meta ? META_LABEL[platform] : null,
|
|
269
|
+
key
|
|
270
|
+
].filter((part) => part !== null).join("+");
|
|
271
|
+
};
|
|
272
|
+
/** Подпись для интерфейса: `⇧⌘L` на macOS, `Ctrl+Shift+L` на остальных; цепочка — через пробел. */
|
|
273
|
+
const formatChord = (chord, platform) => chord.map((item) => strokeLabel(item, platform)).join(" ");
|
|
274
|
+
const SPOKEN_KEYS = {
|
|
275
|
+
",": "comma",
|
|
276
|
+
"+": "plus",
|
|
277
|
+
enter: "enter",
|
|
278
|
+
escape: "escape",
|
|
279
|
+
tab: "tab",
|
|
280
|
+
space: "space",
|
|
281
|
+
backspace: "backspace",
|
|
282
|
+
delete: "delete",
|
|
283
|
+
insert: "insert",
|
|
284
|
+
home: "home",
|
|
285
|
+
end: "end",
|
|
286
|
+
pageup: "pageup",
|
|
287
|
+
pagedown: "pagedown",
|
|
288
|
+
arrowup: "arrowup",
|
|
289
|
+
arrowdown: "arrowdown",
|
|
290
|
+
arrowleft: "arrowleft",
|
|
291
|
+
arrowright: "arrowright"
|
|
292
|
+
};
|
|
293
|
+
const spokenStroke = (item, platform, word) => {
|
|
294
|
+
const mac = platform === "mac";
|
|
295
|
+
const control = item.ctrl ? word("control") : null;
|
|
296
|
+
const meta = item.meta ? word(META_WORD[platform]) : null;
|
|
297
|
+
const spoken = Object.hasOwn(SPOKEN_KEYS, item.key) ? SPOKEN_KEYS[item.key] : void 0;
|
|
298
|
+
return [
|
|
299
|
+
...mac ? [meta, control] : [control, meta],
|
|
300
|
+
item.alt ? word(mac ? "option" : "alt") : null,
|
|
301
|
+
item.shift ? word("shift") : null,
|
|
302
|
+
spoken === void 0 ? labelOfKey(item) : word(spoken)
|
|
303
|
+
].filter((part) => part !== null).join(" ");
|
|
304
|
+
};
|
|
305
|
+
/**
|
|
306
|
+
* Сочетание словами для скринридера: `⌘K` без этого читается как набор
|
|
307
|
+
* символов. На macOS — «Command Option Shift L», иначе — «Control Alt Shift L»;
|
|
308
|
+
* нажатия цепочки разделены запятой.
|
|
309
|
+
*/
|
|
310
|
+
const spokenChord = (chord, platform, word) => chord.map((item) => spokenStroke(item, platform, word)).join(", ");
|
|
311
|
+
/**
|
|
312
|
+
* Ключ сравнения нажатия: модификаторы и клавиша. Физическая клавиша, у
|
|
313
|
+
* которой есть логическое имя (`[KeyK]`), приравнена к нему (`k`): на любой
|
|
314
|
+
* раскладке такие записи могут сработать от одного нажатия.
|
|
315
|
+
*/
|
|
316
|
+
const strokeKey = (item) => {
|
|
317
|
+
const key = item.code === "" ? item.key : codeToKey(item.code) ?? `[${item.code}]`;
|
|
318
|
+
return `${`${item.ctrl ? "c" : ""}${item.alt ? "a" : ""}${item.shift ? "s" : ""}${item.meta ? "m" : ""}`}:${key}`;
|
|
319
|
+
};
|
|
320
|
+
/** Сочетание начинается нажатиями `prefix` (или совпадает с ним). */
|
|
321
|
+
const chordStartsWith = (chord, prefix) => prefix.length <= chord.length && prefix.every((item, index) => strokeKey(item) === strokeKey(chord[index]));
|
|
322
|
+
/**
|
|
323
|
+
* Первое нажатие печатает или правит текст (`K`, `Shift+K`, `,`, `Enter`,
|
|
324
|
+
* стрелки): без Ctrl и Meta такое сочетание срабатывало бы при наборе. Не
|
|
325
|
+
* считаются `Escape` и F1–F24.
|
|
326
|
+
*/
|
|
327
|
+
const isTypingChord = (chord) => {
|
|
328
|
+
const first = chord[0];
|
|
329
|
+
if (first === void 0 || first.ctrl || first.meta) return false;
|
|
330
|
+
if (first.code !== "") return true;
|
|
331
|
+
return first.key !== "escape" && !FUNCTION_KEY.test(first.key);
|
|
332
|
+
};
|
|
333
|
+
const eventKeyName = (key) => {
|
|
334
|
+
if (key === " ") return "space";
|
|
335
|
+
if (key.length > 1) return own(NAMED_KEYS, key.toLowerCase()) ?? key.toLowerCase();
|
|
336
|
+
return key.toLowerCase();
|
|
337
|
+
};
|
|
338
|
+
const isAsciiLetter = (key) => /^[A-Za-z]$/.test(key);
|
|
339
|
+
/** Модификаторы события; AltGr (Ctrl+Alt на Windows) не считается ни тем, ни другим. */
|
|
340
|
+
const eventFlags = (event) => {
|
|
341
|
+
const altGraph = event.getModifierState?.("AltGraph") === true;
|
|
342
|
+
return {
|
|
343
|
+
ctrl: altGraph ? false : event.ctrlKey,
|
|
344
|
+
alt: altGraph ? false : event.altKey,
|
|
345
|
+
shift: event.shiftKey,
|
|
346
|
+
meta: event.metaKey,
|
|
347
|
+
altGraph
|
|
348
|
+
};
|
|
349
|
+
};
|
|
350
|
+
/**
|
|
351
|
+
* Событие совпадает с нажатием. Модификаторы — ровно заданные (AltGr не
|
|
352
|
+
* считается Ctrl+Alt). Физическая запись сверяется по `code`. Логическая — по
|
|
353
|
+
* `event.key`: буква ASCII окончательна (Dvorak, Colemak, AZERTY); если `key`
|
|
354
|
+
* не ASCII-буква (русская раскладка, мёртвые клавиши, ⌥+буква на macOS),
|
|
355
|
+
* клавишу определяет `code`. Событие во время IME-ввода не совпадает.
|
|
356
|
+
*/
|
|
357
|
+
const matchKeystroke = (event, expected) => {
|
|
358
|
+
if (event.isComposing === true) return false;
|
|
359
|
+
const flags = eventFlags(event);
|
|
360
|
+
if (flags.altGraph && (expected.ctrl || expected.alt)) return false;
|
|
361
|
+
if (flags.ctrl !== expected.ctrl || flags.alt !== expected.alt) return false;
|
|
362
|
+
if (flags.meta !== expected.meta) return false;
|
|
363
|
+
if (expected.code !== "") return flags.shift === expected.shift && event.code === expected.code;
|
|
364
|
+
const name = eventKeyName(event.key);
|
|
365
|
+
if (name === expected.key) {
|
|
366
|
+
const isSymbol = isSingleCharacter(name) && !/^\p{Letter}$/u.test(name);
|
|
367
|
+
return flags.shift === expected.shift || isSymbol && !expected.shift;
|
|
368
|
+
}
|
|
369
|
+
if (isAsciiLetter(event.key)) return false;
|
|
370
|
+
if (/^\p{Script=Latin}$/u.test(event.key) && event.code.startsWith("Key") && !event.altKey) return false;
|
|
371
|
+
return codeToKey(event.code) === expected.key && flags.shift === expected.shift;
|
|
372
|
+
};
|
|
373
|
+
/**
|
|
374
|
+
* Событие в нажатие для записи сочетания в интерфейсе; `null` — одиночный
|
|
375
|
+
* модификатор или клавиша без имени. Именованные клавиши и буква ASCII
|
|
376
|
+
* записываются по `key`, остальное — по логическому имени физической клавиши
|
|
377
|
+
* (`KeyK` на русской раскладке → `k`), без имени — физической записью.
|
|
378
|
+
*/
|
|
379
|
+
const eventToKeystroke = (event) => {
|
|
380
|
+
const name = eventKeyName(event.key);
|
|
381
|
+
if (MODIFIER_ONLY_KEYS.has(name)) return null;
|
|
382
|
+
const { ctrl, alt, shift, meta } = eventFlags(event);
|
|
383
|
+
const flags = {
|
|
384
|
+
ctrl,
|
|
385
|
+
alt,
|
|
386
|
+
shift,
|
|
387
|
+
meta
|
|
388
|
+
};
|
|
389
|
+
if (name === "space" || Object.hasOwn(CANONICAL_TEXT, name) || FUNCTION_KEY.test(name) || isAsciiLetter(event.key)) return stroke(flags, name, "");
|
|
390
|
+
const mapped = codeToKey(event.code);
|
|
391
|
+
if (mapped !== void 0) return stroke(flags, mapped, "");
|
|
392
|
+
if (isSingleCharacter(event.key)) return stroke(flags, name, "");
|
|
393
|
+
return event.code === "" ? null : stroke(flags, "", event.code);
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
//#endregion
|
|
397
|
+
//#region packages/keybindings/src/when.ts
|
|
398
|
+
const WHEN_MAX_LENGTH = 256;
|
|
399
|
+
const MAX_DEPTH = 16;
|
|
400
|
+
const MAX_TERMS = 64;
|
|
401
|
+
var WhenSyntaxError = class extends Error {
|
|
402
|
+
reason;
|
|
403
|
+
/** Позиция ошибки в тексте (индекс символа). */
|
|
404
|
+
position;
|
|
405
|
+
constructor(reason, position) {
|
|
406
|
+
super(`invalid when clause: ${reason} at ${position}`);
|
|
407
|
+
this.name = "WhenSyntaxError";
|
|
408
|
+
this.reason = reason;
|
|
409
|
+
this.position = position;
|
|
410
|
+
}
|
|
411
|
+
};
|
|
412
|
+
const IDENT = /[A-Za-z_][A-Za-z0-9_.-]*/y;
|
|
413
|
+
const BARE = /[^\s&|()!='"]+/y;
|
|
414
|
+
const matchAt = (pattern, text, index) => {
|
|
415
|
+
pattern.lastIndex = index;
|
|
416
|
+
return pattern.exec(text)?.[0] ?? null;
|
|
417
|
+
};
|
|
418
|
+
/** После `==`/`!=` значение — слово или строка в одинарных кавычках. */
|
|
419
|
+
const tokenize = (text) => {
|
|
420
|
+
const tokens = [];
|
|
421
|
+
let index = 0;
|
|
422
|
+
let expectValue = false;
|
|
423
|
+
while (index < text.length) {
|
|
424
|
+
const char = text[index];
|
|
425
|
+
if (/\s/.test(char)) {
|
|
426
|
+
index++;
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
const two = text.slice(index, index + 2);
|
|
430
|
+
if (two === "&&" || two === "||" || two === "==" || two === "!=") {
|
|
431
|
+
tokens.push({
|
|
432
|
+
kind: "op",
|
|
433
|
+
text: two,
|
|
434
|
+
position: index
|
|
435
|
+
});
|
|
436
|
+
expectValue = two === "==" || two === "!=";
|
|
437
|
+
index += 2;
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
if (char === "(" || char === ")" || char === "!") {
|
|
441
|
+
tokens.push({
|
|
442
|
+
kind: "op",
|
|
443
|
+
text: char,
|
|
444
|
+
position: index
|
|
445
|
+
});
|
|
446
|
+
expectValue = false;
|
|
447
|
+
index++;
|
|
448
|
+
continue;
|
|
449
|
+
}
|
|
450
|
+
if (char === "'") {
|
|
451
|
+
const close = text.indexOf("'", index + 1);
|
|
452
|
+
if (close < 0) throw new WhenSyntaxError("unterminated-string", index);
|
|
453
|
+
tokens.push({
|
|
454
|
+
kind: "value",
|
|
455
|
+
text: text.slice(index + 1, close),
|
|
456
|
+
position: index
|
|
457
|
+
});
|
|
458
|
+
expectValue = false;
|
|
459
|
+
index = close + 1;
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
462
|
+
const word = matchAt(expectValue ? BARE : IDENT, text, index);
|
|
463
|
+
if (word === null) throw new WhenSyntaxError("unexpected-token", index);
|
|
464
|
+
tokens.push({
|
|
465
|
+
kind: expectValue ? "value" : "ident",
|
|
466
|
+
text: word,
|
|
467
|
+
position: index
|
|
468
|
+
});
|
|
469
|
+
expectValue = false;
|
|
470
|
+
index += word.length;
|
|
471
|
+
}
|
|
472
|
+
tokens.push({
|
|
473
|
+
kind: "end",
|
|
474
|
+
text: "",
|
|
475
|
+
position: text.length
|
|
476
|
+
});
|
|
477
|
+
return tokens;
|
|
478
|
+
};
|
|
479
|
+
var Parser = class {
|
|
480
|
+
index = 0;
|
|
481
|
+
tokens;
|
|
482
|
+
constructor(tokens) {
|
|
483
|
+
this.tokens = tokens;
|
|
484
|
+
}
|
|
485
|
+
parse() {
|
|
486
|
+
const result = this.parseOr(0);
|
|
487
|
+
if (this.peek().kind !== "end") this.fail();
|
|
488
|
+
return result;
|
|
489
|
+
}
|
|
490
|
+
peek() {
|
|
491
|
+
return this.tokens[this.index];
|
|
492
|
+
}
|
|
493
|
+
isOp(text) {
|
|
494
|
+
const token = this.peek();
|
|
495
|
+
return token.kind === "op" && token.text === text;
|
|
496
|
+
}
|
|
497
|
+
fail() {
|
|
498
|
+
const token = this.peek();
|
|
499
|
+
throw new WhenSyntaxError(token.kind === "end" ? "unexpected-end" : "unexpected-token", token.position);
|
|
500
|
+
}
|
|
501
|
+
parseOr(depth) {
|
|
502
|
+
if (depth > MAX_DEPTH) throw new WhenSyntaxError("too-deep", this.peek().position);
|
|
503
|
+
const terms = [this.parseAnd(depth)];
|
|
504
|
+
while (this.isOp("||")) {
|
|
505
|
+
this.index++;
|
|
506
|
+
terms.push(this.parseAnd(depth));
|
|
507
|
+
}
|
|
508
|
+
return terms.length === 1 ? terms[0] : {
|
|
509
|
+
type: "or",
|
|
510
|
+
terms
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
parseAnd(depth) {
|
|
514
|
+
const terms = [this.parseUnary(depth)];
|
|
515
|
+
while (this.isOp("&&")) {
|
|
516
|
+
this.index++;
|
|
517
|
+
terms.push(this.parseUnary(depth));
|
|
518
|
+
}
|
|
519
|
+
return terms.length === 1 ? terms[0] : {
|
|
520
|
+
type: "and",
|
|
521
|
+
terms
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
parseUnary(depth) {
|
|
525
|
+
if (this.isOp("!")) {
|
|
526
|
+
this.index++;
|
|
527
|
+
if (this.isOp("(")) return {
|
|
528
|
+
type: "not",
|
|
529
|
+
expr: this.parseGroup(depth)
|
|
530
|
+
};
|
|
531
|
+
const token = this.peek();
|
|
532
|
+
if (token.kind !== "ident") return this.fail();
|
|
533
|
+
this.index++;
|
|
534
|
+
return {
|
|
535
|
+
type: "not",
|
|
536
|
+
expr: {
|
|
537
|
+
type: "key",
|
|
538
|
+
key: token.text
|
|
539
|
+
}
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
if (this.isOp("(")) return this.parseGroup(depth);
|
|
543
|
+
const token = this.peek();
|
|
544
|
+
if (token.kind !== "ident") return this.fail();
|
|
545
|
+
this.index++;
|
|
546
|
+
if (this.isOp("==") || this.isOp("!=")) {
|
|
547
|
+
const operator = this.peek().text;
|
|
548
|
+
this.index++;
|
|
549
|
+
const value = this.peek();
|
|
550
|
+
if (value.kind !== "value") return this.fail();
|
|
551
|
+
this.index++;
|
|
552
|
+
return {
|
|
553
|
+
type: operator === "==" ? "eq" : "neq",
|
|
554
|
+
key: token.text,
|
|
555
|
+
value: value.text
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
return {
|
|
559
|
+
type: "key",
|
|
560
|
+
key: token.text
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
parseGroup(depth) {
|
|
564
|
+
this.index++;
|
|
565
|
+
const inner = this.parseOr(depth + 1);
|
|
566
|
+
if (!this.isOp(")")) return this.fail();
|
|
567
|
+
this.index++;
|
|
568
|
+
return inner;
|
|
569
|
+
}
|
|
570
|
+
};
|
|
571
|
+
/** Текст условия → выражение; ошибка — `WhenSyntaxError` с причиной и позицией. */
|
|
572
|
+
const parseWhen = (text) => {
|
|
573
|
+
if (text.length > 256) throw new WhenSyntaxError("too-long", 0);
|
|
574
|
+
if (text.trim() === "") throw new WhenSyntaxError("empty", 0);
|
|
575
|
+
return new Parser(tokenize(text)).parse();
|
|
576
|
+
};
|
|
577
|
+
/** `null` вместо ошибки. */
|
|
578
|
+
const tryParseWhen = (text) => {
|
|
579
|
+
try {
|
|
580
|
+
return parseWhen(text);
|
|
581
|
+
} catch (error) {
|
|
582
|
+
if (error instanceof WhenSyntaxError) return null;
|
|
583
|
+
throw error;
|
|
584
|
+
}
|
|
585
|
+
};
|
|
586
|
+
const needsQuotes = (value) => !/^[^\s&|()!='"]+$/.test(value);
|
|
587
|
+
/** Каноническая запись выражения (скобки только где нужны). */
|
|
588
|
+
const whenToText = (expr, parent = 0) => {
|
|
589
|
+
switch (expr.type) {
|
|
590
|
+
case "key": return expr.key;
|
|
591
|
+
case "not": return expr.expr.type === "key" ? `!${expr.expr.key}` : `!(${whenToText(expr.expr)})`;
|
|
592
|
+
case "eq":
|
|
593
|
+
case "neq": {
|
|
594
|
+
const value = needsQuotes(expr.value) ? `'${expr.value}'` : expr.value;
|
|
595
|
+
return `${expr.key} ${expr.type === "eq" ? "==" : "!="} ${value}`;
|
|
596
|
+
}
|
|
597
|
+
case "and": {
|
|
598
|
+
const text = expr.terms.map((term) => whenToText(term, 2)).join(" && ");
|
|
599
|
+
return parent > 2 ? `(${text})` : text;
|
|
600
|
+
}
|
|
601
|
+
default: {
|
|
602
|
+
const text = expr.terms.map((term) => whenToText(term, 1)).join(" || ");
|
|
603
|
+
return parent > 1 ? `(${text})` : text;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
};
|
|
607
|
+
const isTruthy = (value) => value !== void 0 && value !== null && value !== false && value !== "" && value !== 0;
|
|
608
|
+
/**
|
|
609
|
+
* Выражение истинно в контексте. `null` (нет условия) — всегда истина;
|
|
610
|
+
* неизвестный ключ — ложь; сравнение — по строковому виду значения, у
|
|
611
|
+
* незаданного ключа `==` ложно, а `!=` истинно.
|
|
612
|
+
*/
|
|
613
|
+
const evaluateWhen = (expr, lookup) => {
|
|
614
|
+
if (expr === null) return true;
|
|
615
|
+
switch (expr.type) {
|
|
616
|
+
case "key": return isTruthy(lookup(expr.key));
|
|
617
|
+
case "not": return !evaluateWhen(expr.expr, lookup);
|
|
618
|
+
case "eq": {
|
|
619
|
+
const value = lookup(expr.key);
|
|
620
|
+
return value !== void 0 && String(value) === expr.value;
|
|
621
|
+
}
|
|
622
|
+
case "neq": {
|
|
623
|
+
const value = lookup(expr.key);
|
|
624
|
+
return value === void 0 || String(value) !== expr.value;
|
|
625
|
+
}
|
|
626
|
+
case "and": return expr.terms.every((term) => evaluateWhen(term, lookup));
|
|
627
|
+
default: return expr.terms.some((term) => evaluateWhen(term, lookup));
|
|
628
|
+
}
|
|
629
|
+
};
|
|
630
|
+
/** Дизъюнкция конъюнкций литералов; `null` — слишком много термов. */
|
|
631
|
+
const toDnf = (expr, negated) => {
|
|
632
|
+
switch (expr.type) {
|
|
633
|
+
case "key": return [[{
|
|
634
|
+
kind: negated ? "falsy" : "truthy",
|
|
635
|
+
key: expr.key
|
|
636
|
+
}]];
|
|
637
|
+
case "eq":
|
|
638
|
+
case "neq": return [[{
|
|
639
|
+
kind: expr.type === "eq" !== negated ? "eq" : "neq",
|
|
640
|
+
key: expr.key,
|
|
641
|
+
value: expr.value
|
|
642
|
+
}]];
|
|
643
|
+
case "not": return toDnf(expr.expr, !negated);
|
|
644
|
+
default: {
|
|
645
|
+
const isConjunction = expr.type === "and" !== negated;
|
|
646
|
+
let result = isConjunction ? [[]] : [];
|
|
647
|
+
for (const part of expr.terms) {
|
|
648
|
+
const dnf = toDnf(part, negated);
|
|
649
|
+
if (dnf === null || result === null) return null;
|
|
650
|
+
const current = result;
|
|
651
|
+
result = isConjunction ? dnf.flatMap((right) => current.map((left) => [...left, ...right])) : [...current, ...dnf];
|
|
652
|
+
if (result.length > MAX_TERMS) return null;
|
|
653
|
+
}
|
|
654
|
+
return result;
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
};
|
|
658
|
+
/** Значение `value`, при котором `ключ == value` истинно, обязательно истинно или ложно как условие. */
|
|
659
|
+
const forcedTruth = (value) => {
|
|
660
|
+
if (value === "") return false;
|
|
661
|
+
if (value === "false" || value === "0") return null;
|
|
662
|
+
return true;
|
|
663
|
+
};
|
|
664
|
+
const isConsistent = (term) => {
|
|
665
|
+
const states = /* @__PURE__ */ new Map();
|
|
666
|
+
for (const literal of term) {
|
|
667
|
+
let state = states.get(literal.key);
|
|
668
|
+
if (state === void 0) {
|
|
669
|
+
state = {
|
|
670
|
+
truthy: null,
|
|
671
|
+
eq: null,
|
|
672
|
+
neq: /* @__PURE__ */ new Set()
|
|
673
|
+
};
|
|
674
|
+
states.set(literal.key, state);
|
|
675
|
+
}
|
|
676
|
+
if (literal.kind === "truthy" || literal.kind === "falsy") {
|
|
677
|
+
const wanted = literal.kind === "truthy";
|
|
678
|
+
if (state.truthy !== null && state.truthy !== wanted) return false;
|
|
679
|
+
state.truthy = wanted;
|
|
680
|
+
} else if (literal.kind === "eq") {
|
|
681
|
+
if (state.eq !== null && state.eq !== literal.value) return false;
|
|
682
|
+
state.eq = literal.value;
|
|
683
|
+
} else state.neq.add(literal.value);
|
|
684
|
+
}
|
|
685
|
+
for (const state of states.values()) {
|
|
686
|
+
if (state.eq === null) continue;
|
|
687
|
+
if (state.neq.has(state.eq)) return false;
|
|
688
|
+
const forced = forcedTruth(state.eq);
|
|
689
|
+
if (forced !== null && state.truthy !== null && state.truthy !== forced) return false;
|
|
690
|
+
}
|
|
691
|
+
return true;
|
|
692
|
+
};
|
|
693
|
+
/**
|
|
694
|
+
* Могут ли оба условия быть истинными одновременно. `null` — нет условия.
|
|
695
|
+
* Сверх 64 термов в нормальной форме ответ «да» (лучше лишнее предупреждение,
|
|
696
|
+
* чем молчание).
|
|
697
|
+
*/
|
|
698
|
+
const whenOverlaps = (left, right) => {
|
|
699
|
+
if (left === null || right === null) return true;
|
|
700
|
+
const leftDnf = toDnf(left, false);
|
|
701
|
+
const rightDnf = toDnf(right, false);
|
|
702
|
+
if (leftDnf === null || rightDnf === null) return true;
|
|
703
|
+
return leftDnf.some((a) => rightDnf.some((b) => isConsistent([...a, ...b])));
|
|
704
|
+
};
|
|
705
|
+
|
|
706
|
+
//#endregion
|
|
707
|
+
//#region packages/keybindings/src/keymap.ts
|
|
708
|
+
const SOURCE_WEIGHT = {
|
|
709
|
+
extension: 0,
|
|
710
|
+
default: 1,
|
|
711
|
+
user: 2
|
|
712
|
+
};
|
|
713
|
+
const SOURCE_STEP = 1e6;
|
|
714
|
+
const issueReason = (error) => {
|
|
715
|
+
if (error instanceof KeybindingSyntaxError) return error.reason;
|
|
716
|
+
if (error instanceof WhenSyntaxError) return error.reason;
|
|
717
|
+
return null;
|
|
718
|
+
};
|
|
719
|
+
const compare = (left, right) => {
|
|
720
|
+
const common = Math.min(left.chord.length, right.chord.length);
|
|
721
|
+
for (let index = 0; index < common; index++) if (strokeKey(left.chord[index]) !== strokeKey(right.chord[index])) return null;
|
|
722
|
+
return left.chord.length === right.chord.length ? "same" : "prefix";
|
|
723
|
+
};
|
|
724
|
+
/** Пересечение двух привязок разных команд или `null`. */
|
|
725
|
+
const conflictOf = (left, right) => {
|
|
726
|
+
if (left.command === right.command) return null;
|
|
727
|
+
const kind = compare(left, right);
|
|
728
|
+
if (kind === null || !whenOverlaps(left.when, right.when)) return null;
|
|
729
|
+
return left.rank >= right.rank ? {
|
|
730
|
+
kind,
|
|
731
|
+
winner: left,
|
|
732
|
+
loser: right
|
|
733
|
+
} : {
|
|
734
|
+
kind,
|
|
735
|
+
winner: right,
|
|
736
|
+
loser: left
|
|
737
|
+
};
|
|
738
|
+
};
|
|
739
|
+
const findConflictsIn = (bindings) => {
|
|
740
|
+
const buckets = /* @__PURE__ */ new Map();
|
|
741
|
+
for (const binding of bindings) {
|
|
742
|
+
const key = strokeKey(binding.chord[0]);
|
|
743
|
+
const bucket = buckets.get(key);
|
|
744
|
+
if (bucket === void 0) buckets.set(key, [binding]);
|
|
745
|
+
else bucket.push(binding);
|
|
746
|
+
}
|
|
747
|
+
const conflicts = [];
|
|
748
|
+
for (const bucket of buckets.values()) for (let i = 0; i < bucket.length; i++) for (let j = i + 1; j < bucket.length; j++) {
|
|
749
|
+
const conflict = conflictOf(bucket[i], bucket[j]);
|
|
750
|
+
if (conflict !== null) conflicts.push(conflict);
|
|
751
|
+
}
|
|
752
|
+
return conflicts;
|
|
753
|
+
};
|
|
754
|
+
const compile = (raw, platform, issues) => {
|
|
755
|
+
const problem = (field, error) => {
|
|
756
|
+
const reason = issueReason(error);
|
|
757
|
+
if (reason === null) throw error;
|
|
758
|
+
issues.push({
|
|
759
|
+
source: raw.source,
|
|
760
|
+
command: raw.command,
|
|
761
|
+
key: raw.key,
|
|
762
|
+
when: raw.when,
|
|
763
|
+
field,
|
|
764
|
+
reason
|
|
765
|
+
});
|
|
766
|
+
return null;
|
|
767
|
+
};
|
|
768
|
+
let chord;
|
|
769
|
+
try {
|
|
770
|
+
chord = parseChord(raw.key, platform);
|
|
771
|
+
} catch (error) {
|
|
772
|
+
return problem("key", error);
|
|
773
|
+
}
|
|
774
|
+
let when = null;
|
|
775
|
+
if (raw.when !== null && raw.when.trim() !== "") try {
|
|
776
|
+
when = parseWhen(raw.when);
|
|
777
|
+
} catch (error) {
|
|
778
|
+
return problem("when", error);
|
|
779
|
+
}
|
|
780
|
+
return {
|
|
781
|
+
command: raw.command,
|
|
782
|
+
source: raw.source,
|
|
783
|
+
text: raw.key,
|
|
784
|
+
chord,
|
|
785
|
+
when,
|
|
786
|
+
whenText: when === null ? null : whenToText(when),
|
|
787
|
+
rank: SOURCE_WEIGHT[raw.source] * SOURCE_STEP + raw.index
|
|
788
|
+
};
|
|
789
|
+
};
|
|
790
|
+
const definitionKey = (definition, platform) => definition[platform] ?? definition.key;
|
|
791
|
+
/**
|
|
792
|
+
* Собирает действующие привязки: набор пользователя заменяет привязки команды
|
|
793
|
+
* из кода и расширений целиком; приоритет `user` > `default` > `extension`,
|
|
794
|
+
* внутри источника позднее выше. Записи, которые не разбираются (повреждённые
|
|
795
|
+
* данные), пропускаются и попадают в `issues`.
|
|
796
|
+
*/
|
|
797
|
+
const buildKeymap = (input) => {
|
|
798
|
+
const { platform, user } = input;
|
|
799
|
+
const issues = [];
|
|
800
|
+
const replaced = new Set(Object.keys(user));
|
|
801
|
+
const raws = [];
|
|
802
|
+
const addDefinitions = (definitions, source) => {
|
|
803
|
+
definitions.forEach((definition, index) => {
|
|
804
|
+
if (replaced.has(definition.command)) return;
|
|
805
|
+
raws.push({
|
|
806
|
+
command: definition.command,
|
|
807
|
+
source,
|
|
808
|
+
key: definitionKey(definition, platform),
|
|
809
|
+
when: definition.when ?? null,
|
|
810
|
+
index
|
|
811
|
+
});
|
|
812
|
+
});
|
|
813
|
+
};
|
|
814
|
+
addDefinitions(input.defaults, "default");
|
|
815
|
+
addDefinitions(input.extensions, "extension");
|
|
816
|
+
let userIndex = 0;
|
|
817
|
+
for (const [command, entries] of Object.entries(user)) for (const entry of entries) raws.push({
|
|
818
|
+
command,
|
|
819
|
+
source: "user",
|
|
820
|
+
key: entry.key,
|
|
821
|
+
when: entry.when,
|
|
822
|
+
index: userIndex++
|
|
823
|
+
});
|
|
824
|
+
const bindings = raws.map((raw) => compile(raw, platform, issues)).filter((binding) => binding !== null).sort((left, right) => right.rank - left.rank);
|
|
825
|
+
const byCommand = /* @__PURE__ */ new Map();
|
|
826
|
+
for (const binding of bindings) {
|
|
827
|
+
const list = byCommand.get(binding.command);
|
|
828
|
+
if (list === void 0) byCommand.set(binding.command, [binding]);
|
|
829
|
+
else list.push(binding);
|
|
830
|
+
}
|
|
831
|
+
return {
|
|
832
|
+
bindings,
|
|
833
|
+
issues,
|
|
834
|
+
forCommand: (command) => byCommand.get(command) ?? [],
|
|
835
|
+
resolve: (presses, lookup, isEnabled) => {
|
|
836
|
+
for (const binding of bindings) {
|
|
837
|
+
if (binding.chord.length < presses.length) continue;
|
|
838
|
+
if (!presses.every((press, index) => matchKeystroke(press, binding.chord[index]))) continue;
|
|
839
|
+
if (!evaluateWhen(binding.when, lookup)) continue;
|
|
840
|
+
if (isEnabled !== void 0 && !isEnabled(binding.command)) continue;
|
|
841
|
+
return binding.chord.length > presses.length ? {
|
|
842
|
+
kind: "pending",
|
|
843
|
+
binding
|
|
844
|
+
} : {
|
|
845
|
+
kind: "run",
|
|
846
|
+
binding
|
|
847
|
+
};
|
|
848
|
+
}
|
|
849
|
+
return { kind: "none" };
|
|
850
|
+
},
|
|
851
|
+
conflicts: () => findConflictsIn(bindings)
|
|
852
|
+
};
|
|
853
|
+
};
|
|
854
|
+
/**
|
|
855
|
+
* Пересечения кандидата с действующими привязками **других** команд (набор
|
|
856
|
+
* самой команды заменяется кандидатами, поэтому его привязки не сравниваются).
|
|
857
|
+
* Для интерфейса записи сочетания: список до сохранения.
|
|
858
|
+
*/
|
|
859
|
+
const findCandidateConflicts = (keymap, candidate) => {
|
|
860
|
+
const self = {
|
|
861
|
+
command: candidate.command,
|
|
862
|
+
source: "user",
|
|
863
|
+
text: "",
|
|
864
|
+
chord: candidate.chord,
|
|
865
|
+
when: candidate.when,
|
|
866
|
+
whenText: null,
|
|
867
|
+
rank: Number.MAX_SAFE_INTEGER
|
|
868
|
+
};
|
|
869
|
+
return keymap.bindings.map((binding) => conflictOf(self, binding)).filter((conflict) => conflict !== null);
|
|
870
|
+
};
|
|
871
|
+
|
|
872
|
+
//#endregion
|
|
873
|
+
//#region packages/keybindings/src/validate.ts
|
|
874
|
+
const KEY_MAX_LENGTH = 64;
|
|
875
|
+
/** Условие «фокус в поле ввода»: привязка с набором, который с ним пересекается, срабатывала бы при наборе. */
|
|
876
|
+
const WHILE_TYPING = {
|
|
877
|
+
type: "key",
|
|
878
|
+
key: "inputFocus"
|
|
879
|
+
};
|
|
880
|
+
const TYPING = {
|
|
881
|
+
field: "when",
|
|
882
|
+
reason: "typing",
|
|
883
|
+
detail: "typing"
|
|
884
|
+
};
|
|
885
|
+
/**
|
|
886
|
+
* Проверка привязки: клавиши (на каждой из `platforms`: `Mod` раскрывается по
|
|
887
|
+
* платформе, и запись вроде `Mod+Ctrl+K` верна не везде), условие и правило
|
|
888
|
+
* «сочетание без Ctrl и Meta, которое печатает или правит текст, должно быть
|
|
889
|
+
* неактивно, пока фокус в поле ввода». `null` — привязка верна.
|
|
890
|
+
*/
|
|
891
|
+
const validateBinding = (binding, platforms) => {
|
|
892
|
+
if (binding.key.length > 64) return {
|
|
893
|
+
field: "key",
|
|
894
|
+
reason: "syntax",
|
|
895
|
+
detail: "too-long-text"
|
|
896
|
+
};
|
|
897
|
+
let typing = false;
|
|
898
|
+
for (const platform of platforms) try {
|
|
899
|
+
typing ||= isTypingChord(parseChord(binding.key, platform));
|
|
900
|
+
} catch (error) {
|
|
901
|
+
if (error instanceof KeybindingSyntaxError) return {
|
|
902
|
+
field: "key",
|
|
903
|
+
reason: "syntax",
|
|
904
|
+
detail: error.reason
|
|
905
|
+
};
|
|
906
|
+
throw error;
|
|
907
|
+
}
|
|
908
|
+
if (binding.when === null || binding.when.trim() === "") return typing ? TYPING : null;
|
|
909
|
+
try {
|
|
910
|
+
const when = parseWhen(binding.when);
|
|
911
|
+
return typing && whenOverlaps(when, WHILE_TYPING) ? TYPING : null;
|
|
912
|
+
} catch (error) {
|
|
913
|
+
if (error instanceof WhenSyntaxError) return {
|
|
914
|
+
field: "when",
|
|
915
|
+
reason: "syntax",
|
|
916
|
+
detail: error.reason,
|
|
917
|
+
position: error.position
|
|
918
|
+
};
|
|
919
|
+
throw error;
|
|
920
|
+
}
|
|
921
|
+
};
|
|
922
|
+
|
|
923
|
+
//#endregion
|
|
924
|
+
//#region packages/keybindings/src/user.ts
|
|
925
|
+
const KEYBINDING_LIMITS = {
|
|
926
|
+
commands: 512,
|
|
927
|
+
entriesPerCommand: 8,
|
|
928
|
+
commandLength: 200
|
|
929
|
+
};
|
|
930
|
+
/** Ключ команды в реестре окна: `app:<id>` или `extension:<extensionId>:<id>`. */
|
|
931
|
+
const COMMAND_KEY_PATTERN = /^(app|extension):[A-Za-z0-9._:-]+$/;
|
|
932
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
933
|
+
const decodeEntry = (value) => {
|
|
934
|
+
if (!isRecord(value) || typeof value.key !== "string") return null;
|
|
935
|
+
const when = value.when ?? null;
|
|
936
|
+
if (when !== null && typeof when !== "string") return null;
|
|
937
|
+
return {
|
|
938
|
+
key: value.key,
|
|
939
|
+
when
|
|
940
|
+
};
|
|
941
|
+
};
|
|
942
|
+
/**
|
|
943
|
+
* Хранимое значение → пользовательские привязки. Терпимо к мусору: команды с
|
|
944
|
+
* неверным ключом или не массивом и записи неверной формы отбрасываются,
|
|
945
|
+
* лишнее сверх лимитов обрезается; разбор клавиш и условий — дело карты и
|
|
946
|
+
* `validateUserKeybindings`.
|
|
947
|
+
*/
|
|
948
|
+
const decodeUserKeybindings = (raw) => {
|
|
949
|
+
const result = {};
|
|
950
|
+
if (!isRecord(raw) || !isRecord(raw.commands)) return result;
|
|
951
|
+
for (const [command, entries] of Object.entries(raw.commands)) {
|
|
952
|
+
if (Object.keys(result).length >= KEYBINDING_LIMITS.commands) break;
|
|
953
|
+
if (!(command.length <= KEYBINDING_LIMITS.commandLength && COMMAND_KEY_PATTERN.test(command)) || !Array.isArray(entries)) continue;
|
|
954
|
+
result[command] = entries.map(decodeEntry).filter((entry) => entry !== null).slice(0, KEYBINDING_LIMITS.entriesPerCommand);
|
|
955
|
+
}
|
|
956
|
+
return result;
|
|
957
|
+
};
|
|
958
|
+
const keyIdentity = (entry, platform) => {
|
|
959
|
+
const chord = tryParseChord(entry.key, platform);
|
|
960
|
+
const when = entry.when === null ? null : tryParseWhen(entry.when);
|
|
961
|
+
return `${chord?.map(strokeKey).join(" ") ?? entry.key}|${when === null ? "" : whenToText(when)}`;
|
|
962
|
+
};
|
|
963
|
+
/**
|
|
964
|
+
* Проверка набора пользователя на платформе хоста: лимиты, форма ключей
|
|
965
|
+
* команд, клавиши и условия (`validateBinding`), повтор «клавиши + условие»
|
|
966
|
+
* в одной команде и пересечения привязок разных команд. Пустой список — набор
|
|
967
|
+
* верен.
|
|
968
|
+
*/
|
|
969
|
+
const validateUserKeybindings = (user, platform) => {
|
|
970
|
+
const issues = [];
|
|
971
|
+
const commands = Object.entries(user);
|
|
972
|
+
if (commands.length > KEYBINDING_LIMITS.commands) issues.push({
|
|
973
|
+
reason: "limit",
|
|
974
|
+
field: "commands",
|
|
975
|
+
command: "",
|
|
976
|
+
message: `at most ${KEYBINDING_LIMITS.commands} commands`
|
|
977
|
+
});
|
|
978
|
+
const valid = {};
|
|
979
|
+
for (const [command, entries] of commands) {
|
|
980
|
+
const field = `commands.${command}`;
|
|
981
|
+
if (command.length > KEYBINDING_LIMITS.commandLength || !COMMAND_KEY_PATTERN.test(command)) {
|
|
982
|
+
issues.push({
|
|
983
|
+
reason: command.length > KEYBINDING_LIMITS.commandLength ? "limit" : "syntax",
|
|
984
|
+
field,
|
|
985
|
+
command,
|
|
986
|
+
message: "invalid command key"
|
|
987
|
+
});
|
|
988
|
+
continue;
|
|
989
|
+
}
|
|
990
|
+
if (entries.length > KEYBINDING_LIMITS.entriesPerCommand) issues.push({
|
|
991
|
+
reason: "limit",
|
|
992
|
+
field,
|
|
993
|
+
command,
|
|
994
|
+
message: `at most ${KEYBINDING_LIMITS.entriesPerCommand} bindings per command`
|
|
995
|
+
});
|
|
996
|
+
const seen = /* @__PURE__ */ new Set();
|
|
997
|
+
const accepted = [];
|
|
998
|
+
entries.forEach((entry, index) => {
|
|
999
|
+
const problem = validateBinding(entry, [platform]);
|
|
1000
|
+
if (problem !== null) {
|
|
1001
|
+
issues.push({
|
|
1002
|
+
reason: problem.reason,
|
|
1003
|
+
field: `${field}[${index}].${problem.field}`,
|
|
1004
|
+
command,
|
|
1005
|
+
message: `${problem.field}: ${problem.detail}`
|
|
1006
|
+
});
|
|
1007
|
+
return;
|
|
1008
|
+
}
|
|
1009
|
+
const identity = keyIdentity(entry, platform);
|
|
1010
|
+
if (seen.has(identity)) {
|
|
1011
|
+
issues.push({
|
|
1012
|
+
reason: "duplicate",
|
|
1013
|
+
field: `${field}[${index}]`,
|
|
1014
|
+
command,
|
|
1015
|
+
message: "the same key and condition are listed twice"
|
|
1016
|
+
});
|
|
1017
|
+
return;
|
|
1018
|
+
}
|
|
1019
|
+
seen.add(identity);
|
|
1020
|
+
accepted.push(entry);
|
|
1021
|
+
});
|
|
1022
|
+
valid[command] = accepted;
|
|
1023
|
+
}
|
|
1024
|
+
const keymap = buildKeymap({
|
|
1025
|
+
platform,
|
|
1026
|
+
defaults: [],
|
|
1027
|
+
extensions: [],
|
|
1028
|
+
user: valid
|
|
1029
|
+
});
|
|
1030
|
+
for (const { winner, loser } of keymap.conflicts()) issues.push({
|
|
1031
|
+
reason: "conflict",
|
|
1032
|
+
field: `commands.${loser.command}`,
|
|
1033
|
+
command: loser.command,
|
|
1034
|
+
other: winner.command,
|
|
1035
|
+
message: `${loser.text} overlaps ${winner.text} of ${winner.command}`
|
|
1036
|
+
});
|
|
1037
|
+
return issues;
|
|
1038
|
+
};
|
|
1039
|
+
|
|
1040
|
+
//#endregion
|
|
1041
|
+
export { COMMAND_KEY_PATTERN, KEYBINDING_LIMITS, KEY_MAX_LENGTH, KeybindingSyntaxError, MAX_CHORD_LENGTH, PLATFORMS, WHEN_MAX_LENGTH, WhenSyntaxError, buildKeymap, chordStartsWith, chordToText, codeToKey, decodeUserKeybindings, detectPlatform, evaluateWhen, eventToKeystroke, findCandidateConflicts, formatChord, isTypingChord, matchKeystroke, parseChord, parseWhen, platformFromNode, spokenChord, strokeKey, tryParseChord, tryParseWhen, validateBinding, validateUserKeybindings, whenOverlaps, whenToText };
|