@joewinke/jatui 0.26.0 → 0.27.1
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/CLAUDE.md +106 -1
- package/dist/actions/keyboardScope.d.ts +58 -0
- package/dist/actions/keyboardScope.js +110 -0
- package/dist/actions/listNav.d.ts +48 -3
- package/dist/actions/listNav.js +267 -77
- package/dist/actions/longpress.d.ts +37 -0
- package/dist/actions/longpress.js +105 -0
- package/dist/actions/stickToBottom.d.ts +66 -0
- package/dist/actions/stickToBottom.js +155 -0
- package/dist/actions/swipe.d.ts +64 -0
- package/dist/actions/swipe.js +179 -0
- package/dist/components/BottomSheet.svelte +280 -0
- package/dist/components/BottomSheet.svelte.d.ts +27 -0
- package/dist/components/ConfirmDialog.svelte +21 -0
- package/dist/components/ConfirmDialog.svelte.d.ts +11 -1
- package/dist/components/ConfirmModal.svelte +15 -0
- package/dist/components/ConfirmModal.svelte.d.ts +5 -0
- package/dist/components/Drawer.svelte +18 -1
- package/dist/components/Drawer.svelte.d.ts +9 -1
- package/dist/components/InputDialog.svelte +21 -0
- package/dist/components/InputDialog.svelte.d.ts +11 -1
- package/dist/components/Modal.svelte +18 -1
- package/dist/components/Modal.svelte.d.ts +8 -0
- package/dist/components/basic/Empty.svelte +173 -9
- package/dist/components/basic/Empty.svelte.d.ts +50 -1
- package/dist/components/basic/ErrorState.svelte +128 -0
- package/dist/components/basic/ErrorState.svelte.d.ts +46 -0
- package/dist/components/basic/JumpToLatest.svelte +60 -0
- package/dist/components/basic/JumpToLatest.svelte.d.ts +9 -0
- package/dist/components/basic/PageHeader.svelte +216 -0
- package/dist/components/basic/PageHeader.svelte.d.ts +52 -0
- package/dist/components/basic/ProgressButton.svelte +221 -0
- package/dist/components/basic/ProgressButton.svelte.d.ts +63 -0
- package/dist/components/graph/GraphApiBridge.svelte +36 -0
- package/dist/components/graph/GraphApiBridge.svelte.d.ts +9 -0
- package/dist/components/graph/GraphCanvas.svelte +212 -27
- package/dist/components/graph/GraphCanvas.svelte.d.ts +113 -19
- package/dist/components/graph/api.d.ts +52 -0
- package/dist/components/graph/api.js +66 -0
- package/dist/components/graph/index.d.ts +2 -0
- package/dist/components/graph/index.js +1 -0
- package/dist/components/graph/theme.js +13 -0
- package/dist/components/messaging/MessageItem.svelte +5 -0
- package/dist/components/messaging/MessageItem.svelte.d.ts +7 -0
- package/dist/components/table/DataTable.svelte +452 -78
- package/dist/components/table/DataTable.svelte.d.ts +2 -2
- package/dist/components/table/ManageColumnsDropdown.svelte +466 -0
- package/dist/components/table/ManageColumnsDropdown.svelte.d.ts +26 -0
- package/dist/components/table/columnStorage.d.ts +29 -0
- package/dist/components/table/columnStorage.js +61 -0
- package/dist/components/table/groupRows.d.ts +72 -0
- package/dist/components/table/groupRows.js +118 -0
- package/dist/components/table/index.d.ts +7 -1
- package/dist/components/table/index.js +3 -0
- package/dist/components/table/types.d.ts +121 -20
- package/dist/index.d.ts +24 -2
- package/dist/index.js +36 -1
- package/dist/utils/ansiToHtml.d.ts +83 -0
- package/dist/utils/ansiToHtml.js +543 -0
- package/dist/utils/keyboardShortcutRegistry.d.ts +332 -11
- package/dist/utils/keyboardShortcutRegistry.js +1070 -76
- package/dist/utils/pollBackoff.d.ts +83 -0
- package/dist/utils/pollBackoff.js +151 -0
- package/dist/utils/snapFlick.d.ts +59 -0
- package/dist/utils/snapFlick.js +66 -0
- package/dist/video/advisors/qr.d.ts +1 -1
- package/dist/virtual/createRowWindow.d.ts +75 -0
- package/dist/virtual/createRowWindow.js +129 -0
- package/dist/virtual/index.d.ts +4 -0
- package/dist/virtual/index.js +5 -0
- package/dist/virtual/rowWindowCore.d.ts +202 -0
- package/dist/virtual/rowWindowCore.js +510 -0
- package/package.json +12 -2
|
@@ -37,9 +37,66 @@
|
|
|
37
37
|
* browser (Chrome won't hand some of them to the page at all). The sidebar
|
|
38
38
|
* convention across JAT apps is `mod+\` — see `SIDEBAR_TOGGLE_KEY`.
|
|
39
39
|
*
|
|
40
|
+
* Rebinding (jatui-voc3i.1): a definition with an `id` is rebindable. Its
|
|
41
|
+
* `key` is the DEFAULT; the effective binding is the user's override from a
|
|
42
|
+
* `BindingStore` (or `rebind()`), and registered entries re-resolve on every
|
|
43
|
+
* binding change, so components never re-register because a key moved.
|
|
44
|
+
* `declare()` makes ids known before their component mounts (a settings page
|
|
45
|
+
* can list a route's shortcuts without visiting the route). Combos are
|
|
46
|
+
* stored and compared in the canonical form `normalizeCombo()` produces.
|
|
47
|
+
* A stored entry that can't be parsed is never dropped silently: it is
|
|
48
|
+
* logged once (`console.warn`) and listed by `rejectedBindings()` with its
|
|
49
|
+
* raw value, so a settings page can show it.
|
|
50
|
+
*
|
|
51
|
+
* Conflicts (jatui-voc3i.2): `findConflicts(keys, { id | context })` and
|
|
52
|
+
* `allConflicts()` report collisions by how the stack resolves keys — same
|
|
53
|
+
* context is an error (one of them is dead), contexts that can be co-active
|
|
54
|
+
* (`contextGraph`; `global` is beneath everything) shadow with a warning, a
|
|
55
|
+
* modal upper context is info (they never compete), and `reserved`
|
|
56
|
+
* browser combos carry their own severity. `rebind()` rejects on any error.
|
|
57
|
+
*
|
|
58
|
+
* Beyond combos (jatui-voc3i.3): `onRelease` fires on the keyup of the
|
|
59
|
+
* same physical key (push-to-talk); `onModifierChange` reports held
|
|
60
|
+
* modifiers and is reset on blur; `registerHandler()` adds raw handlers
|
|
61
|
+
* (count prefixes, chords) that run inside the context walk after the
|
|
62
|
+
* context's combos, so modals still suppress them; `pushContext` takes a
|
|
63
|
+
* `layer` (frames sort by layer, then push order) and, for modal frames, a
|
|
64
|
+
* `passthrough` list of ids/combos that still reach the contexts beneath.
|
|
65
|
+
*
|
|
66
|
+
* Known divergences from the JAT IDE's legacy `shortcutParser` (an importer
|
|
67
|
+
* passed to `localStorageBindingStore({ migrate })` must account for them):
|
|
68
|
+
* - `cmd`: the IDE maps `Cmd+K` to META on every platform; here `cmd` is
|
|
69
|
+
* an alias of `mod` (Ctrl on Linux/Windows). Rewrite `cmd` → `meta`
|
|
70
|
+
* when importing so a stored `Cmd+K` keeps meaning ⌘/Win.
|
|
71
|
+
* - Physical-key fallback: the IDE falls back to `event.code` for LETTER
|
|
72
|
+
* keys under any modifier (non-Latin layouts: Ctrl+K on a Cyrillic
|
|
73
|
+
* layout). Here the `event.code` fallback applies only to alt combos and
|
|
74
|
+
* to `` ` `` / Space; `ctrl+k` does not fire for `ctrl+л`.
|
|
75
|
+
*
|
|
40
76
|
* Pure TypeScript, no Svelte runes and no DOM globals at import time, so it
|
|
41
77
|
* is SSR-safe and unit-testable under plain node.
|
|
42
78
|
*/
|
|
79
|
+
/**
|
|
80
|
+
* Browser-owned combos. `mod+t/w/n` block: Chrome
|
|
81
|
+
* never hands them to the page. The editing/navigation combos (and Ctrl+B,
|
|
82
|
+
* the bookmarks bar) are delivered but warn.
|
|
83
|
+
*/
|
|
84
|
+
export const BROWSER_RESERVED_COMBOS = [
|
|
85
|
+
{ key: 'mod+t', reason: 'Browser: new tab', severity: 'block' },
|
|
86
|
+
{ key: 'mod+w', reason: 'Browser: close tab', severity: 'block' },
|
|
87
|
+
{ key: 'mod+n', reason: 'Browser: new window', severity: 'block' },
|
|
88
|
+
{ key: 'mod+b', reason: 'Browser: bookmarks bar', severity: 'warn' },
|
|
89
|
+
{ key: 'mod+c', reason: 'Browser: copy', severity: 'warn' },
|
|
90
|
+
{ key: 'mod+v', reason: 'Browser: paste', severity: 'warn' },
|
|
91
|
+
{ key: 'mod+x', reason: 'Browser: cut', severity: 'warn' },
|
|
92
|
+
{ key: 'mod+z', reason: 'Browser: undo', severity: 'warn' },
|
|
93
|
+
{ key: 'mod+y', reason: 'Browser: redo', severity: 'warn' },
|
|
94
|
+
{ key: 'mod+a', reason: 'Browser: select all', severity: 'warn' },
|
|
95
|
+
{ key: 'mod+s', reason: 'Browser: save page', severity: 'warn' },
|
|
96
|
+
{ key: 'mod+f', reason: 'Browser: find', severity: 'warn' },
|
|
97
|
+
{ key: 'mod+p', reason: 'Browser: print', severity: 'warn' },
|
|
98
|
+
{ key: 'mod+r', reason: 'Browser: reload', severity: 'warn' }
|
|
99
|
+
];
|
|
43
100
|
/** The sidebar-toggle convention. Ctrl+B is reserved by Chrome (bookmarks bar). */
|
|
44
101
|
export const SIDEBAR_TOGGLE_KEY = 'mod+\\';
|
|
45
102
|
const GLOBAL = 'global';
|
|
@@ -50,6 +107,33 @@ export function detectPlatform() {
|
|
|
50
107
|
const p = nav.userAgentData?.platform || nav.platform || nav.userAgent || '';
|
|
51
108
|
return /mac|iphone|ipad|ipod/i.test(p) ? 'mac' : 'other';
|
|
52
109
|
}
|
|
110
|
+
/** Every spelling of a modifier the IDE (or a human) has ever written. */
|
|
111
|
+
const MODIFIER_ALIASES = {
|
|
112
|
+
mod: 'mod',
|
|
113
|
+
cmd: 'mod',
|
|
114
|
+
meta: 'meta',
|
|
115
|
+
command: 'meta',
|
|
116
|
+
win: 'meta',
|
|
117
|
+
windows: 'meta',
|
|
118
|
+
super: 'meta',
|
|
119
|
+
ctrl: 'ctrl',
|
|
120
|
+
control: 'ctrl',
|
|
121
|
+
shift: 'shift',
|
|
122
|
+
alt: 'alt',
|
|
123
|
+
option: 'alt',
|
|
124
|
+
opt: 'alt'
|
|
125
|
+
};
|
|
126
|
+
/** Named-key aliases (case-insensitive) → the lowercased `KeyboardEvent.key` value. */
|
|
127
|
+
const KEY_ALIASES = {
|
|
128
|
+
esc: 'escape',
|
|
129
|
+
space: ' ',
|
|
130
|
+
spacebar: ' ',
|
|
131
|
+
del: 'delete',
|
|
132
|
+
up: 'arrowup',
|
|
133
|
+
down: 'arrowdown',
|
|
134
|
+
left: 'arrowleft',
|
|
135
|
+
right: 'arrowright'
|
|
136
|
+
};
|
|
53
137
|
/** Parse `'mod+shift+k'` (plus any extra modifiers) into a normalized combo. */
|
|
54
138
|
export function parseCombo(key, extra) {
|
|
55
139
|
const combo = { key: '', mod: false, meta: false, ctrl: false, shift: false, alt: false };
|
|
@@ -61,15 +145,12 @@ export function parseCombo(key, extra) {
|
|
|
61
145
|
base = parts.pop() ?? '';
|
|
62
146
|
for (const p of parts) {
|
|
63
147
|
const m = p.trim().toLowerCase();
|
|
64
|
-
if (m
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
mods.push('ctrl');
|
|
69
|
-
else if (m === 'option')
|
|
70
|
-
mods.push('alt');
|
|
71
|
-
else if (m)
|
|
148
|
+
if (!m)
|
|
149
|
+
continue;
|
|
150
|
+
const alias = MODIFIER_ALIASES[m];
|
|
151
|
+
if (!alias)
|
|
72
152
|
throw new Error(`keyboardShortcutRegistry: unknown modifier "${p}" in "${key}"`);
|
|
153
|
+
mods.push(alias);
|
|
73
154
|
}
|
|
74
155
|
}
|
|
75
156
|
for (const m of mods) {
|
|
@@ -82,18 +163,42 @@ export function parseCombo(key, extra) {
|
|
|
82
163
|
return combo;
|
|
83
164
|
}
|
|
84
165
|
function normalizeKey(key) {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
166
|
+
const k = key.toLowerCase();
|
|
167
|
+
return key.length > 1 ? (KEY_ALIASES[k] ?? k) : k;
|
|
168
|
+
}
|
|
169
|
+
const MODIFIER_ORDER = ['mod', 'meta', 'ctrl', 'alt', 'shift'];
|
|
170
|
+
function comboToString(c) {
|
|
171
|
+
const parts = MODIFIER_ORDER.filter((m) => c[m]);
|
|
172
|
+
parts.push(c.key === ' ' ? 'space' : c.key);
|
|
173
|
+
return parts.join('+');
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Canonical, order-stable form used for storage and equality:
|
|
177
|
+
* `"alt+shift+s"`, `"mod+k"`, `"ctrl+\`"`, `"space"`, `"mod++"`. Modifier
|
|
178
|
+
* order is mod, meta, ctrl, alt, shift. `mod` is never introduced: a stored
|
|
179
|
+
* `Ctrl+K` stays `ctrl+k`. Throws on an unknown modifier (see `validateCombo`).
|
|
180
|
+
*/
|
|
181
|
+
export function normalizeCombo(key, extra) {
|
|
182
|
+
return comboToString(parseCombo(key, extra));
|
|
92
183
|
}
|
|
93
184
|
/** A single printable non-letter (`?`, `/`, `\`, `1`): its shift state is baked into the char. */
|
|
94
185
|
function isSymbolKey(key) {
|
|
95
186
|
return key.length === 1 && key.toLowerCase() === key.toUpperCase() && key !== ' ';
|
|
96
187
|
}
|
|
188
|
+
const CODE_KEYS = {
|
|
189
|
+
Backslash: '\\',
|
|
190
|
+
Slash: '/',
|
|
191
|
+
Backquote: '`',
|
|
192
|
+
Space: ' ',
|
|
193
|
+
Minus: '-',
|
|
194
|
+
Equal: '=',
|
|
195
|
+
BracketLeft: '[',
|
|
196
|
+
BracketRight: ']',
|
|
197
|
+
Semicolon: ';',
|
|
198
|
+
Quote: "'",
|
|
199
|
+
Comma: ',',
|
|
200
|
+
Period: '.'
|
|
201
|
+
};
|
|
97
202
|
function codeToKey(code) {
|
|
98
203
|
if (!code)
|
|
99
204
|
return null;
|
|
@@ -101,19 +206,17 @@ function codeToKey(code) {
|
|
|
101
206
|
return code.slice(3).toLowerCase();
|
|
102
207
|
if (/^Digit\d$/.test(code))
|
|
103
208
|
return code.slice(5);
|
|
104
|
-
|
|
105
|
-
return '\\';
|
|
106
|
-
if (code === 'Slash')
|
|
107
|
-
return '/';
|
|
108
|
-
return null;
|
|
209
|
+
return CODE_KEYS[code] ?? null;
|
|
109
210
|
}
|
|
110
211
|
function matches(event, combo, platform) {
|
|
111
212
|
const evKey = normalizeKey(event.key ?? '');
|
|
112
213
|
let keyHit = evKey === combo.key;
|
|
113
|
-
// macOS Option (and some layouts) rewrite event.key for alt combos
|
|
114
|
-
//
|
|
115
|
-
|
|
214
|
+
// macOS Option (and some layouts) rewrite event.key for alt combos, and
|
|
215
|
+
// `` ` `` / Space vary by layout with any modifier — fall back to the
|
|
216
|
+
// physical key for those.
|
|
217
|
+
if (!keyHit && (combo.alt || combo.key === '`' || combo.key === ' ')) {
|
|
116
218
|
keyHit = codeToKey(event.code) === combo.key;
|
|
219
|
+
}
|
|
117
220
|
if (!keyHit)
|
|
118
221
|
return false;
|
|
119
222
|
const wantMeta = combo.meta || (combo.mod && platform === 'mac');
|
|
@@ -129,6 +232,76 @@ function matches(event, combo, platform) {
|
|
|
129
232
|
return true;
|
|
130
233
|
return !!event.shiftKey === combo.shift;
|
|
131
234
|
}
|
|
235
|
+
const MODIFIER_KEY_NAMES = new Set([
|
|
236
|
+
'control', 'ctrl', 'shift', 'alt', 'option', 'meta', 'os', 'super', 'hyper', 'altgraph',
|
|
237
|
+
'capslock', 'fn', 'fnlock', 'numlock', 'scrolllock', 'symbol', 'symbollock', 'cmd', 'command', 'win', 'windows'
|
|
238
|
+
]);
|
|
239
|
+
const NAMED_KEYS = new Set([
|
|
240
|
+
'escape', 'enter', 'tab', 'backspace', 'delete', 'insert', 'home', 'end', 'pageup', 'pagedown',
|
|
241
|
+
'arrowup', 'arrowdown', 'arrowleft', 'arrowright', 'contextmenu', 'pause', 'printscreen',
|
|
242
|
+
'mediaplaypause', 'mediatracknext', 'mediatrackprevious', 'mediastop',
|
|
243
|
+
'audiovolumeup', 'audiovolumedown', 'audiovolumemute'
|
|
244
|
+
]);
|
|
245
|
+
/**
|
|
246
|
+
* Canonical combo for a live keydown (a settings "press the new shortcut"
|
|
247
|
+
* recorder). Null while only modifiers are down, or when the key can't be
|
|
248
|
+
* identified. Ctrl/Meta are reported literally (never `mod`). Uses the
|
|
249
|
+
* physical key when macOS Option (or a dead key / non-ASCII layout) hides it.
|
|
250
|
+
*/
|
|
251
|
+
export function comboFromEvent(event, platform = detectPlatform()) {
|
|
252
|
+
const raw = event.key ?? '';
|
|
253
|
+
if (!raw || MODIFIER_KEY_NAMES.has(raw.toLowerCase()))
|
|
254
|
+
return null;
|
|
255
|
+
const fromCode = codeToKey(event.code);
|
|
256
|
+
const unusable = raw === 'Dead' || raw === 'Unidentified' || raw === 'Process';
|
|
257
|
+
let key = normalizeKey(raw);
|
|
258
|
+
let usedCode = false;
|
|
259
|
+
const nonAscii = key.length === 1 && !/^[\x20-\x7e]$/.test(key);
|
|
260
|
+
if (fromCode && (unusable || (event.altKey && (platform === 'mac' || nonAscii)))) {
|
|
261
|
+
key = fromCode;
|
|
262
|
+
usedCode = true;
|
|
263
|
+
}
|
|
264
|
+
else if (unusable) {
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
return comboToString({
|
|
268
|
+
key,
|
|
269
|
+
mod: false,
|
|
270
|
+
meta: !!event.metaKey,
|
|
271
|
+
ctrl: !!event.ctrlKey,
|
|
272
|
+
alt: !!event.altKey,
|
|
273
|
+
// `?` is already Shift+/ — keep shift implied, exactly as matching does.
|
|
274
|
+
shift: !!event.shiftKey && !(isSymbolKey(key) && !usedCode)
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Structural validation for a user-entered binding. Null means valid,
|
|
279
|
+
* otherwise a human-readable reason. `requireModifier` demands Ctrl, Alt,
|
|
280
|
+
* Meta or mod (Shift alone doesn't count) — the IDE's rule for global ids.
|
|
281
|
+
*/
|
|
282
|
+
export function validateCombo(key, options = {}) {
|
|
283
|
+
if (!key || !key.trim())
|
|
284
|
+
return 'Shortcut is empty';
|
|
285
|
+
let c;
|
|
286
|
+
try {
|
|
287
|
+
c = parseCombo(key);
|
|
288
|
+
}
|
|
289
|
+
catch (err) {
|
|
290
|
+
const msg = err instanceof Error ? err.message.replace(/^keyboardShortcutRegistry: /, '') : String(err);
|
|
291
|
+
return msg.charAt(0).toUpperCase() + msg.slice(1);
|
|
292
|
+
}
|
|
293
|
+
if (!c.key)
|
|
294
|
+
return 'Shortcut must include a key (e.g. A, 1, Enter)';
|
|
295
|
+
if (MODIFIER_KEY_NAMES.has(c.key))
|
|
296
|
+
return 'Shortcut must include a non-modifier key';
|
|
297
|
+
if (c.key.length > 1 && !NAMED_KEYS.has(c.key) && !/^f([1-9]|1\d|2[0-4])$/.test(c.key)) {
|
|
298
|
+
return `Unknown key "${key.split('+').pop()}"`;
|
|
299
|
+
}
|
|
300
|
+
if (options.requireModifier && !(c.mod || c.meta || c.ctrl || c.alt)) {
|
|
301
|
+
return 'Shortcut must include Ctrl, Alt or Meta';
|
|
302
|
+
}
|
|
303
|
+
return null;
|
|
304
|
+
}
|
|
132
305
|
const NON_TEXT_INPUT_TYPES = new Set([
|
|
133
306
|
'button', 'checkbox', 'color', 'file', 'hidden', 'image', 'radio', 'range', 'reset', 'submit'
|
|
134
307
|
]);
|
|
@@ -162,12 +335,7 @@ const KEY_GLYPHS = {
|
|
|
162
335
|
home: 'Home',
|
|
163
336
|
end: 'End'
|
|
164
337
|
};
|
|
165
|
-
|
|
166
|
-
export function formatShortcut(shortcut, platform = detectPlatform()) {
|
|
167
|
-
const def = typeof shortcut === 'string' ? null : shortcut;
|
|
168
|
-
if (def?.label)
|
|
169
|
-
return def.label;
|
|
170
|
-
const combo = def ? parseCombo(def.key, def.modifiers) : parseCombo(shortcut);
|
|
338
|
+
function formatCombo(combo, platform) {
|
|
171
339
|
const k = combo.key;
|
|
172
340
|
const keyLabel = KEY_GLYPHS[k] ?? (/^f\d{1,2}$/.test(k) ? k.toUpperCase() : k.length === 1 ? k.toUpperCase() : k);
|
|
173
341
|
if (platform === 'mac') {
|
|
@@ -194,51 +362,682 @@ export function formatShortcut(shortcut, platform = detectPlatform()) {
|
|
|
194
362
|
parts.push(keyLabel);
|
|
195
363
|
return parts.join('+');
|
|
196
364
|
}
|
|
365
|
+
/** Keycap label: `⌘⇧K` on macOS, `Ctrl+Shift+K` elsewhere. Several keys join with `' / '`. */
|
|
366
|
+
export function formatShortcut(shortcut, platform = detectPlatform()) {
|
|
367
|
+
if (typeof shortcut === 'string')
|
|
368
|
+
return formatCombo(parseCombo(shortcut), platform);
|
|
369
|
+
if (shortcut.label)
|
|
370
|
+
return shortcut.label;
|
|
371
|
+
return toArray(shortcut.key)
|
|
372
|
+
.map((k) => formatCombo(parseCombo(k, shortcut.modifiers), platform))
|
|
373
|
+
.join(' / ');
|
|
374
|
+
}
|
|
375
|
+
function toArray(v) {
|
|
376
|
+
return Array.isArray(v) ? v : [v];
|
|
377
|
+
}
|
|
378
|
+
function hasOwn(obj, key) {
|
|
379
|
+
return Object.prototype.hasOwnProperty.call(obj, key);
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* Canonicalize stored overrides. Anything unusable — a non-object blob, a
|
|
383
|
+
* non-array value, an unparsable combo — is handed to `reject`, never just
|
|
384
|
+
* dropped.
|
|
385
|
+
*/
|
|
386
|
+
function sanitizeOverrides(raw, reject) {
|
|
387
|
+
const out = {};
|
|
388
|
+
if (raw === null || raw === undefined)
|
|
389
|
+
return out;
|
|
390
|
+
if (typeof raw !== 'object' || Array.isArray(raw)) {
|
|
391
|
+
reject({ id: '*', raw, reason: 'Stored bindings are not an object of id → combos' });
|
|
392
|
+
return out;
|
|
393
|
+
}
|
|
394
|
+
for (const [id, value] of Object.entries(raw)) {
|
|
395
|
+
if (!Array.isArray(value)) {
|
|
396
|
+
reject({ id, raw: value, reason: 'Expected an array of combos' });
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
const combos = [];
|
|
400
|
+
for (const k of value) {
|
|
401
|
+
const err = typeof k === 'string' ? validateCombo(k) : 'Combo is not a string';
|
|
402
|
+
if (err !== null) {
|
|
403
|
+
reject({ id, raw: k, reason: err });
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
const n = normalizeCombo(k);
|
|
407
|
+
if (!combos.includes(n))
|
|
408
|
+
combos.push(n);
|
|
409
|
+
}
|
|
410
|
+
// A non-empty list with nothing usable is a bad record, not "unbound".
|
|
411
|
+
if (value.length > 0 && combos.length === 0)
|
|
412
|
+
continue;
|
|
413
|
+
out[id] = combos;
|
|
414
|
+
}
|
|
415
|
+
return out;
|
|
416
|
+
}
|
|
417
|
+
function isThenable(v) {
|
|
418
|
+
return !!v && typeof v.then === 'function';
|
|
419
|
+
}
|
|
420
|
+
function defaultDev() {
|
|
421
|
+
try {
|
|
422
|
+
const env = import.meta.env;
|
|
423
|
+
if (env && typeof env.DEV === 'boolean')
|
|
424
|
+
return env.DEV;
|
|
425
|
+
}
|
|
426
|
+
catch {
|
|
427
|
+
// import.meta.env absent outside vite
|
|
428
|
+
}
|
|
429
|
+
const proc = globalThis.process;
|
|
430
|
+
return proc?.env?.NODE_ENV !== 'production';
|
|
431
|
+
}
|
|
432
|
+
function warnStore(error) {
|
|
433
|
+
console.warn('keyboardShortcutRegistry: binding store failed', error);
|
|
434
|
+
}
|
|
435
|
+
function isRejectedBinding(v) {
|
|
436
|
+
const r = v;
|
|
437
|
+
return !!r && typeof r === 'object' && typeof r.id === 'string' && typeof r.reason === 'string' && 'raw' in r;
|
|
438
|
+
}
|
|
439
|
+
/** A rejected-entry collector that warns once per distinct entry. */
|
|
440
|
+
function rejectCollector() {
|
|
441
|
+
let list = [];
|
|
442
|
+
const warned = new Set();
|
|
443
|
+
const keyOf = (r) => `${r.id}\u0000${JSON.stringify(r.raw) ?? String(r.raw)}`;
|
|
444
|
+
return {
|
|
445
|
+
add(r) {
|
|
446
|
+
const k = keyOf(r);
|
|
447
|
+
if (list.some((x) => keyOf(x) === k))
|
|
448
|
+
return;
|
|
449
|
+
list.push(r);
|
|
450
|
+
if (warned.has(k))
|
|
451
|
+
return;
|
|
452
|
+
warned.add(k);
|
|
453
|
+
console.warn(`keyboardShortcutRegistry: rejected stored binding "${r.id}" (${JSON.stringify(r.raw)}): ${r.reason}`);
|
|
454
|
+
},
|
|
455
|
+
reset() {
|
|
456
|
+
list = [];
|
|
457
|
+
},
|
|
458
|
+
list: () => [...list]
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
/**
|
|
462
|
+
* Versioned localStorage `BindingStore`: `{ v: 1, bindings, rejected? }`.
|
|
463
|
+
* Tolerates corrupt JSON, a foreign shape and unavailable storage (private
|
|
464
|
+
* mode, SSR) — all read as "no overrides". `migrate` runs only when
|
|
465
|
+
* `storageKey` is absent, and its result (even an empty one) is written so
|
|
466
|
+
* it runs once. Entries it can't parse (from storage or from `migrate`) are
|
|
467
|
+
* warned once, exposed via `rejected()`, and kept in the stored `rejected`
|
|
468
|
+
* list so a later save doesn't lose them.
|
|
469
|
+
* `syncTabs` (default true) forwards other tabs' writes via `storage` events.
|
|
470
|
+
*/
|
|
471
|
+
export function localStorageBindingStore(storageKey, options = {}) {
|
|
472
|
+
function storage() {
|
|
473
|
+
try {
|
|
474
|
+
return globalThis.localStorage ?? null;
|
|
475
|
+
}
|
|
476
|
+
catch {
|
|
477
|
+
return null;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
const rejected = rejectCollector();
|
|
481
|
+
/** Read a stored blob; refreshes `rejected` to what this blob carries. */
|
|
482
|
+
function decode(raw) {
|
|
483
|
+
rejected.reset();
|
|
484
|
+
if (raw === null)
|
|
485
|
+
return {};
|
|
486
|
+
let parsed;
|
|
487
|
+
try {
|
|
488
|
+
parsed = JSON.parse(raw);
|
|
489
|
+
}
|
|
490
|
+
catch {
|
|
491
|
+
rejected.add({ id: '*', raw, reason: 'Stored bindings are not valid JSON' });
|
|
492
|
+
return {};
|
|
493
|
+
}
|
|
494
|
+
if (!parsed || typeof parsed !== 'object' || parsed.v !== 1) {
|
|
495
|
+
rejected.add({ id: '*', raw, reason: 'Unrecognised bindings format (expected { v: 1, bindings })' });
|
|
496
|
+
return {};
|
|
497
|
+
}
|
|
498
|
+
if (Array.isArray(parsed.rejected)) {
|
|
499
|
+
for (const r of parsed.rejected)
|
|
500
|
+
if (isRejectedBinding(r))
|
|
501
|
+
rejected.add(r);
|
|
502
|
+
}
|
|
503
|
+
return sanitizeOverrides(parsed.bindings, rejected.add);
|
|
504
|
+
}
|
|
505
|
+
function write(overrides) {
|
|
506
|
+
const list = rejected.list();
|
|
507
|
+
try {
|
|
508
|
+
storage()?.setItem(storageKey, JSON.stringify({ v: 1, bindings: overrides, ...(list.length ? { rejected: list } : {}) }));
|
|
509
|
+
}
|
|
510
|
+
catch (err) {
|
|
511
|
+
warnStore(err);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
return {
|
|
515
|
+
load() {
|
|
516
|
+
let raw = null;
|
|
517
|
+
try {
|
|
518
|
+
raw = storage()?.getItem(storageKey) ?? null;
|
|
519
|
+
}
|
|
520
|
+
catch {
|
|
521
|
+
return {};
|
|
522
|
+
}
|
|
523
|
+
if (raw !== null || !options.migrate)
|
|
524
|
+
return decode(raw);
|
|
525
|
+
let migrated = {};
|
|
526
|
+
rejected.reset();
|
|
527
|
+
try {
|
|
528
|
+
migrated = sanitizeOverrides(options.migrate(), rejected.add);
|
|
529
|
+
}
|
|
530
|
+
catch (err) {
|
|
531
|
+
warnStore(err);
|
|
532
|
+
}
|
|
533
|
+
write(migrated);
|
|
534
|
+
return migrated;
|
|
535
|
+
},
|
|
536
|
+
save: write,
|
|
537
|
+
subscribe(listener) {
|
|
538
|
+
const w = globalThis.window;
|
|
539
|
+
if (options.syncTabs === false || !w || typeof w.addEventListener !== 'function')
|
|
540
|
+
return () => { };
|
|
541
|
+
const onStorage = (e) => {
|
|
542
|
+
// key === null is localStorage.clear()
|
|
543
|
+
if (e.key !== storageKey && e.key !== null)
|
|
544
|
+
return;
|
|
545
|
+
listener(decode(e.key === null ? null : e.newValue));
|
|
546
|
+
};
|
|
547
|
+
w.addEventListener('storage', onStorage);
|
|
548
|
+
return () => w.removeEventListener('storage', onStorage);
|
|
549
|
+
},
|
|
550
|
+
rejected: rejected.list
|
|
551
|
+
};
|
|
552
|
+
}
|
|
197
553
|
export function createShortcutRegistry(options = {}) {
|
|
198
554
|
const platform = options.platform ?? detectPlatform();
|
|
199
555
|
const labels = { [GLOBAL]: 'General', ...options.contextLabels };
|
|
200
556
|
const onError = options.onError ??
|
|
201
557
|
((error) => console.error('keyboardShortcutRegistry: shortcut action failed', error));
|
|
558
|
+
const dev = options.dev ?? defaultDev();
|
|
559
|
+
const store = options.bindings;
|
|
560
|
+
const graph = options.contextGraph ?? {};
|
|
561
|
+
// Remembered for conflict reporting: a context once pushed modal stays modal.
|
|
562
|
+
const modalContexts = new Set(options.modalContexts ?? []);
|
|
563
|
+
const reservedPhysical = (options.reserved ?? BROWSER_RESERVED_COMBOS).map((r) => ({
|
|
564
|
+
physical: physical(normalizeCombo(r.key)),
|
|
565
|
+
reason: r.reason,
|
|
566
|
+
severity: r.severity
|
|
567
|
+
}));
|
|
202
568
|
const entries = [];
|
|
569
|
+
const declarations = [];
|
|
570
|
+
const handlers = [];
|
|
203
571
|
const stack = [];
|
|
204
572
|
const listeners = new Set();
|
|
205
573
|
let seq = 0;
|
|
574
|
+
let pushSeq = 0;
|
|
575
|
+
const typingTest = options.isTypingTarget ?? ((target) => isTypingTarget(target));
|
|
576
|
+
// Pending `onRelease` calls, keyed by physical key.
|
|
577
|
+
const pendingReleases = new Map();
|
|
578
|
+
let modifiers = { alt: false, ctrl: false, shift: false, meta: false };
|
|
579
|
+
let overrides = {};
|
|
580
|
+
// Edits made before the store finished loading are replayed on top of it.
|
|
581
|
+
let loaded = !store;
|
|
582
|
+
const preLoadEdits = new Map();
|
|
583
|
+
let preLoadResetAll = false;
|
|
584
|
+
// Entries a custom store handed back unparsed (localStorageBindingStore
|
|
585
|
+
// rejects its own before they get here).
|
|
586
|
+
const rejected = rejectCollector();
|
|
206
587
|
function notify() {
|
|
207
588
|
for (const l of [...listeners])
|
|
208
589
|
l();
|
|
209
590
|
}
|
|
591
|
+
function isRebindable(def) {
|
|
592
|
+
return !!def.id && (def.rebindable ?? true);
|
|
593
|
+
}
|
|
594
|
+
function isCustom(def) {
|
|
595
|
+
return isRebindable(def) && hasOwn(overrides, def.id);
|
|
596
|
+
}
|
|
597
|
+
function defaultsOf(def) {
|
|
598
|
+
const out = [];
|
|
599
|
+
for (const k of toArray(def.key)) {
|
|
600
|
+
const n = normalizeCombo(k, def.modifiers);
|
|
601
|
+
if (!out.includes(n))
|
|
602
|
+
out.push(n);
|
|
603
|
+
}
|
|
604
|
+
return out;
|
|
605
|
+
}
|
|
606
|
+
function currentOf(def) {
|
|
607
|
+
return isCustom(def) ? [...overrides[def.id]] : defaultsOf(def);
|
|
608
|
+
}
|
|
609
|
+
/** Same physical combo on this platform (`mod` resolved, implied shift dropped). */
|
|
610
|
+
function physical(canonical) {
|
|
611
|
+
const c = parseCombo(canonical);
|
|
612
|
+
if (c.mod) {
|
|
613
|
+
c.mod = false;
|
|
614
|
+
if (platform === 'mac')
|
|
615
|
+
c.meta = true;
|
|
616
|
+
else
|
|
617
|
+
c.ctrl = true;
|
|
618
|
+
}
|
|
619
|
+
if (isSymbolKey(c.key))
|
|
620
|
+
c.shift = false;
|
|
621
|
+
return comboToString(c);
|
|
622
|
+
}
|
|
623
|
+
function resolve(entry) {
|
|
624
|
+
entry.canon = currentOf(entry.def);
|
|
625
|
+
entry.combos = entry.canon.map((c) => parseCombo(c));
|
|
626
|
+
}
|
|
627
|
+
function reresolveAll() {
|
|
628
|
+
for (const e of entries)
|
|
629
|
+
if (e.def.id)
|
|
630
|
+
resolve(e);
|
|
631
|
+
}
|
|
632
|
+
function persist() {
|
|
633
|
+
if (!store || !loaded)
|
|
634
|
+
return;
|
|
635
|
+
try {
|
|
636
|
+
const r = store.save({ ...overrides });
|
|
637
|
+
if (isThenable(r))
|
|
638
|
+
Promise.resolve(r).catch(warnStore);
|
|
639
|
+
}
|
|
640
|
+
catch (err) {
|
|
641
|
+
warnStore(err);
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
function applyOverrides(next, { replay }) {
|
|
645
|
+
const hadEdits = replay && (preLoadResetAll || preLoadEdits.size > 0);
|
|
646
|
+
const base = replay && preLoadResetAll ? {} : next;
|
|
647
|
+
if (replay) {
|
|
648
|
+
for (const [id, v] of preLoadEdits) {
|
|
649
|
+
if (v === null)
|
|
650
|
+
delete base[id];
|
|
651
|
+
else
|
|
652
|
+
base[id] = v;
|
|
653
|
+
}
|
|
654
|
+
preLoadEdits.clear();
|
|
655
|
+
preLoadResetAll = false;
|
|
656
|
+
}
|
|
657
|
+
overrides = base;
|
|
658
|
+
loaded = true;
|
|
659
|
+
reresolveAll();
|
|
660
|
+
if (hadEdits)
|
|
661
|
+
persist();
|
|
662
|
+
notify();
|
|
663
|
+
}
|
|
664
|
+
function setOverride(id, keys, defaults) {
|
|
665
|
+
const same = keys.length === defaults.length && keys.every((k) => defaults.includes(k));
|
|
666
|
+
if (same)
|
|
667
|
+
delete overrides[id];
|
|
668
|
+
else
|
|
669
|
+
overrides[id] = keys;
|
|
670
|
+
if (!loaded)
|
|
671
|
+
preLoadEdits.set(id, same ? null : keys);
|
|
672
|
+
}
|
|
673
|
+
function commit() {
|
|
674
|
+
reresolveAll();
|
|
675
|
+
persist();
|
|
676
|
+
notify();
|
|
677
|
+
}
|
|
678
|
+
/** Latest live registration for `id`, else its latest declaration. */
|
|
679
|
+
function sourceOf(id) {
|
|
680
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
681
|
+
if (entries[i].def.id === id)
|
|
682
|
+
return entries[i];
|
|
683
|
+
}
|
|
684
|
+
for (let i = declarations.length - 1; i >= 0; i--) {
|
|
685
|
+
if (declarations[i].def.id === id)
|
|
686
|
+
return declarations[i];
|
|
687
|
+
}
|
|
688
|
+
return undefined;
|
|
689
|
+
}
|
|
690
|
+
function binding(id) {
|
|
691
|
+
const src = sourceOf(id);
|
|
692
|
+
if (!src)
|
|
693
|
+
return undefined;
|
|
694
|
+
const defaults = defaultsOf(src.def);
|
|
695
|
+
const custom = isCustom(src.def);
|
|
696
|
+
return {
|
|
697
|
+
id,
|
|
698
|
+
description: src.def.description,
|
|
699
|
+
context: src.context,
|
|
700
|
+
group: src.def.group,
|
|
701
|
+
defaults,
|
|
702
|
+
current: custom ? [...overrides[id]] : defaults,
|
|
703
|
+
isCustom: custom,
|
|
704
|
+
rebindable: isRebindable(src.def)
|
|
705
|
+
};
|
|
706
|
+
}
|
|
707
|
+
function bindings() {
|
|
708
|
+
const firstSeen = new Map();
|
|
709
|
+
for (const x of [...declarations, ...entries]) {
|
|
710
|
+
const id = x.def.id;
|
|
711
|
+
if (id && (!firstSeen.has(id) || x.seq < firstSeen.get(id)))
|
|
712
|
+
firstSeen.set(id, x.seq);
|
|
713
|
+
}
|
|
714
|
+
return [...firstSeen.entries()]
|
|
715
|
+
.sort((a, b) => a[1] - b[1])
|
|
716
|
+
.map(([id]) => binding(id))
|
|
717
|
+
.filter(Boolean);
|
|
718
|
+
}
|
|
719
|
+
/** Every live definition: registrations, plus declarations no registration covers yet. */
|
|
720
|
+
function units() {
|
|
721
|
+
const live = new Set(entries.filter((e) => e.def.id).map((e) => `${e.def.id}\u0000${e.context}`));
|
|
722
|
+
return [
|
|
723
|
+
...entries,
|
|
724
|
+
...declarations
|
|
725
|
+
.filter((d) => !live.has(`${d.def.id}\u0000${d.context}`))
|
|
726
|
+
.map((d) => ({ ...d, canon: currentOf(d.def) }))
|
|
727
|
+
];
|
|
728
|
+
}
|
|
729
|
+
function contextsOf(id) {
|
|
730
|
+
const out = [];
|
|
731
|
+
for (const x of [...entries, ...declarations]) {
|
|
732
|
+
if (x.def.id === id && !out.includes(x.context))
|
|
733
|
+
out.push(x.context);
|
|
734
|
+
}
|
|
735
|
+
return out;
|
|
736
|
+
}
|
|
737
|
+
/** Can `upper` be on the stack ABOVE `lower`? (`contextGraph`, transitive; `global` is beneath all.) */
|
|
738
|
+
function above(upper, lower) {
|
|
739
|
+
if (upper === lower || upper === GLOBAL)
|
|
740
|
+
return false;
|
|
741
|
+
if (lower === GLOBAL)
|
|
742
|
+
return true;
|
|
743
|
+
const seen = new Set();
|
|
744
|
+
const todo = [...(graph[upper] ?? [])];
|
|
745
|
+
while (todo.length) {
|
|
746
|
+
const c = todo.pop();
|
|
747
|
+
if (c === lower)
|
|
748
|
+
return true;
|
|
749
|
+
if (seen.has(c))
|
|
750
|
+
continue;
|
|
751
|
+
seen.add(c);
|
|
752
|
+
todo.push(...(graph[c] ?? []));
|
|
753
|
+
}
|
|
754
|
+
return false;
|
|
755
|
+
}
|
|
756
|
+
function isModalContext(ctx) {
|
|
757
|
+
return modalContexts.has(ctx);
|
|
758
|
+
}
|
|
759
|
+
/** How a candidate in `cand` relates to a definition in `other` on the same combo. */
|
|
760
|
+
function classify(cand, other) {
|
|
761
|
+
if (cand === other)
|
|
762
|
+
return { kind: 'same-context', severity: 'error' };
|
|
763
|
+
const upper = above(cand, other) ? cand : above(other, cand) ? other : null;
|
|
764
|
+
if (upper === null)
|
|
765
|
+
return null; // never on the stack together
|
|
766
|
+
if (isModalContext(upper))
|
|
767
|
+
return { kind: 'modal-blocked', severity: 'info' };
|
|
768
|
+
return upper === cand ? { kind: 'shadows', severity: 'warning' } : { kind: 'shadowed-by', severity: 'warning' };
|
|
769
|
+
}
|
|
770
|
+
function reservedConflicts(combo) {
|
|
771
|
+
const p = physical(combo);
|
|
772
|
+
return reservedPhysical
|
|
773
|
+
.filter((r) => r.physical === p)
|
|
774
|
+
.map((r) => ({
|
|
775
|
+
combo,
|
|
776
|
+
description: r.reason,
|
|
777
|
+
context: 'browser',
|
|
778
|
+
kind: 'reserved',
|
|
779
|
+
severity: r.severity === 'block' ? 'error' : 'warning'
|
|
780
|
+
}));
|
|
781
|
+
}
|
|
782
|
+
function findConflicts(keys, target = {}) {
|
|
783
|
+
const combos = [];
|
|
784
|
+
for (const k of toArray(keys)) {
|
|
785
|
+
if (validateCombo(k) !== null)
|
|
786
|
+
continue;
|
|
787
|
+
const n = normalizeCombo(k);
|
|
788
|
+
if (!combos.some((c) => physical(c) === physical(n)))
|
|
789
|
+
combos.push(n);
|
|
790
|
+
}
|
|
791
|
+
if (!combos.length)
|
|
792
|
+
return [];
|
|
793
|
+
const known = target.id ? contextsOf(target.id) : [];
|
|
794
|
+
const contexts = target.context ? [target.context] : known.length ? known : [GLOBAL];
|
|
795
|
+
const wanted = new Map(combos.map((c) => [physical(c), c]));
|
|
796
|
+
const out = [];
|
|
797
|
+
const seen = new Set();
|
|
798
|
+
for (const ctx of contexts) {
|
|
799
|
+
for (const u of units()) {
|
|
800
|
+
if (target.id && u.def.id === target.id)
|
|
801
|
+
continue;
|
|
802
|
+
const rel = classify(ctx, u.context);
|
|
803
|
+
if (!rel)
|
|
804
|
+
continue;
|
|
805
|
+
for (const c of u.canon) {
|
|
806
|
+
const combo = wanted.get(physical(c));
|
|
807
|
+
if (combo === undefined)
|
|
808
|
+
continue;
|
|
809
|
+
const key = `${u.def.id ?? `#${u.seq}`}|${u.context}|${combo}|${rel.kind}`;
|
|
810
|
+
if (seen.has(key))
|
|
811
|
+
continue;
|
|
812
|
+
seen.add(key);
|
|
813
|
+
out.push({
|
|
814
|
+
combo,
|
|
815
|
+
...(u.def.id ? { id: u.def.id } : {}),
|
|
816
|
+
description: u.def.description,
|
|
817
|
+
context: u.context,
|
|
818
|
+
...rel
|
|
819
|
+
});
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
for (const c of combos)
|
|
824
|
+
out.push(...reservedConflicts(c));
|
|
825
|
+
return out;
|
|
826
|
+
}
|
|
827
|
+
const SEVERITY_RANK = { info: 0, warning: 1, error: 2 };
|
|
828
|
+
function allConflicts(opts = {}) {
|
|
829
|
+
const min = SEVERITY_RANK[opts.minSeverity ?? 'info'];
|
|
830
|
+
const all = units().sort((a, b) => a.seq - b.seq);
|
|
831
|
+
const out = [];
|
|
832
|
+
const sourceOfUnit = (u) => ({
|
|
833
|
+
...(u.def.id ? { id: u.def.id } : {}),
|
|
834
|
+
description: u.def.description,
|
|
835
|
+
context: u.context
|
|
836
|
+
});
|
|
837
|
+
for (let i = 0; i < all.length; i++) {
|
|
838
|
+
const cand = all[i];
|
|
839
|
+
// The candidate is the NEWER of each pair, so it is compared with older units only.
|
|
840
|
+
for (let j = 0; j < i; j++) {
|
|
841
|
+
const other = all[j];
|
|
842
|
+
if (cand.def.id && cand.def.id === other.def.id)
|
|
843
|
+
continue;
|
|
844
|
+
const rel = classify(cand.context, other.context);
|
|
845
|
+
if (!rel || SEVERITY_RANK[rel.severity] < min)
|
|
846
|
+
continue;
|
|
847
|
+
const theirs = new Set(other.canon.map(physical));
|
|
848
|
+
for (const c of cand.canon) {
|
|
849
|
+
if (!theirs.has(physical(c)))
|
|
850
|
+
continue;
|
|
851
|
+
out.push({
|
|
852
|
+
combo: c,
|
|
853
|
+
...(other.def.id ? { id: other.def.id } : {}),
|
|
854
|
+
description: other.def.description,
|
|
855
|
+
context: other.context,
|
|
856
|
+
...rel,
|
|
857
|
+
source: sourceOfUnit(cand)
|
|
858
|
+
});
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
for (const c of cand.canon) {
|
|
862
|
+
for (const r of reservedConflicts(c)) {
|
|
863
|
+
if (SEVERITY_RANK[r.severity] >= min)
|
|
864
|
+
out.push({ ...r, source: sourceOfUnit(cand) });
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
return out;
|
|
869
|
+
}
|
|
870
|
+
function rebind(id, keys, opts = {}) {
|
|
871
|
+
const fail = (error, conflicts = []) => ({ ok: false, error, conflicts });
|
|
872
|
+
const src = sourceOf(id);
|
|
873
|
+
if (!src)
|
|
874
|
+
return fail(`Unknown shortcut id "${id}"`);
|
|
875
|
+
if (!isRebindable(src.def))
|
|
876
|
+
return fail(`"${src.def.description}" can't be rebound`);
|
|
877
|
+
const next = [];
|
|
878
|
+
for (const k of keys === null ? [] : toArray(keys)) {
|
|
879
|
+
const err = validateCombo(k);
|
|
880
|
+
if (err)
|
|
881
|
+
return fail(err);
|
|
882
|
+
const n = normalizeCombo(k);
|
|
883
|
+
if (!next.some((x) => physical(x) === physical(n)))
|
|
884
|
+
next.push(n);
|
|
885
|
+
}
|
|
886
|
+
const conflicts = findConflicts(next, { id });
|
|
887
|
+
const errors = conflicts.filter((c) => c.severity === 'error');
|
|
888
|
+
const mode = opts.onConflict ?? 'reject';
|
|
889
|
+
const displaced = [];
|
|
890
|
+
if (errors.length && mode !== 'allow') {
|
|
891
|
+
// The browser never delivers a blocked combo; no swap can fix that.
|
|
892
|
+
const blocked = errors.find((c) => c.kind === 'reserved');
|
|
893
|
+
if (blocked) {
|
|
894
|
+
return fail(`${formatShortcut(blocked.combo, platform)} is reserved (${blocked.description})`, conflicts);
|
|
895
|
+
}
|
|
896
|
+
const first = errors[0];
|
|
897
|
+
const msg = `${formatShortcut(first.combo, platform)} is already used by "${first.description}"`;
|
|
898
|
+
if (mode === 'reject')
|
|
899
|
+
return fail(msg, conflicts);
|
|
900
|
+
// swap: only possible when every owner is itself rebindable.
|
|
901
|
+
const fixed = errors.find((c) => {
|
|
902
|
+
const owner = c.id ? sourceOf(c.id) : undefined;
|
|
903
|
+
return !owner || !isRebindable(owner.def);
|
|
904
|
+
});
|
|
905
|
+
if (fixed) {
|
|
906
|
+
return fail(`${formatShortcut(fixed.combo, platform)} belongs to "${fixed.description}", which can't be rebound`, conflicts);
|
|
907
|
+
}
|
|
908
|
+
for (const c of errors) {
|
|
909
|
+
const other = binding(c.id);
|
|
910
|
+
const remaining = other.current.filter((k) => physical(k) !== physical(c.combo));
|
|
911
|
+
setOverride(c.id, remaining, other.defaults);
|
|
912
|
+
if (!displaced.includes(c.id))
|
|
913
|
+
displaced.push(c.id);
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
setOverride(id, next, defaultsOf(src.def));
|
|
917
|
+
commit();
|
|
918
|
+
const remaining = mode === 'allow' ? conflicts : conflicts.filter((c) => c.severity !== 'error');
|
|
919
|
+
return { ok: true, binding: binding(id), displaced, conflicts: remaining };
|
|
920
|
+
}
|
|
921
|
+
function resetBinding(id) {
|
|
922
|
+
if (loaded && !hasOwn(overrides, id))
|
|
923
|
+
return;
|
|
924
|
+
delete overrides[id];
|
|
925
|
+
if (!loaded)
|
|
926
|
+
preLoadEdits.set(id, null);
|
|
927
|
+
commit();
|
|
928
|
+
}
|
|
929
|
+
function resetAllBindings() {
|
|
930
|
+
overrides = {};
|
|
931
|
+
if (!loaded) {
|
|
932
|
+
preLoadEdits.clear();
|
|
933
|
+
preLoadResetAll = true;
|
|
934
|
+
}
|
|
935
|
+
commit();
|
|
936
|
+
}
|
|
937
|
+
function declare(defs) {
|
|
938
|
+
const added = toArray(defs)
|
|
939
|
+
.filter((def) => !!def.id)
|
|
940
|
+
.map((def) => {
|
|
941
|
+
defaultsOf(def); // throw on a malformed key now, not at render time
|
|
942
|
+
const d = { def, context: def.context ?? GLOBAL, seq: seq++ };
|
|
943
|
+
declarations.push(d);
|
|
944
|
+
return d;
|
|
945
|
+
});
|
|
946
|
+
notify();
|
|
947
|
+
let done = false;
|
|
948
|
+
return () => {
|
|
949
|
+
if (done)
|
|
950
|
+
return;
|
|
951
|
+
done = true;
|
|
952
|
+
for (const d of added) {
|
|
953
|
+
const i = declarations.indexOf(d);
|
|
954
|
+
if (i !== -1)
|
|
955
|
+
declarations.splice(i, 1);
|
|
956
|
+
}
|
|
957
|
+
notify();
|
|
958
|
+
};
|
|
959
|
+
}
|
|
960
|
+
/**
|
|
961
|
+
* The walk order, top first: frames sorted by layer then push order. A
|
|
962
|
+
* modal frame ends the walk unless it has a passthrough list, in which case
|
|
963
|
+
* the contexts beneath are visited with that frame as a filter. A context
|
|
964
|
+
* already visited higher up is not visited again.
|
|
965
|
+
*/
|
|
966
|
+
function liveContexts() {
|
|
967
|
+
const frames = [...stack].sort((a, b) => a.layer - b.layer || a.order - b.order);
|
|
968
|
+
const out = [];
|
|
969
|
+
const seen = new Set();
|
|
970
|
+
const filters = [];
|
|
971
|
+
const visit = (name) => {
|
|
972
|
+
if (seen.has(name))
|
|
973
|
+
return;
|
|
974
|
+
seen.add(name);
|
|
975
|
+
out.push({ name, filters: [...filters] });
|
|
976
|
+
};
|
|
977
|
+
for (let i = frames.length - 1; i >= 0; i--) {
|
|
978
|
+
const f = frames[i];
|
|
979
|
+
visit(f.name);
|
|
980
|
+
if (f.modal) {
|
|
981
|
+
if (!f.passIds.size && !f.passPhysical.size)
|
|
982
|
+
return out;
|
|
983
|
+
filters.push(f);
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
visit(GLOBAL);
|
|
987
|
+
return out;
|
|
988
|
+
}
|
|
989
|
+
/** Does a definition beneath passthrough modal frames get through all of them? */
|
|
990
|
+
function passes(def, canon, filters) {
|
|
991
|
+
return filters.every((f) => (!!def.id && f.passIds.has(def.id)) || canon.some((c) => f.passPhysical.has(physical(c))));
|
|
992
|
+
}
|
|
210
993
|
function activeContexts() {
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
994
|
+
return liveContexts()
|
|
995
|
+
.filter((c) => c.filters.length === 0)
|
|
996
|
+
.map((c) => c.name);
|
|
997
|
+
}
|
|
998
|
+
function registerHandler(handler) {
|
|
999
|
+
const h = { handler, context: handler.context ?? GLOBAL, seq: seq++ };
|
|
1000
|
+
handlers.push(h);
|
|
1001
|
+
notify();
|
|
1002
|
+
let done = false;
|
|
1003
|
+
return () => {
|
|
1004
|
+
if (done)
|
|
1005
|
+
return;
|
|
1006
|
+
done = true;
|
|
1007
|
+
const i = handlers.indexOf(h);
|
|
1008
|
+
if (i !== -1)
|
|
1009
|
+
handlers.splice(i, 1);
|
|
1010
|
+
notify();
|
|
1011
|
+
};
|
|
222
1012
|
}
|
|
223
1013
|
function register(shortcuts) {
|
|
224
|
-
const defs =
|
|
225
|
-
const
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
1014
|
+
const defs = toArray(shortcuts);
|
|
1015
|
+
const batch = [];
|
|
1016
|
+
for (const def of defs) {
|
|
1017
|
+
const entry = { def, canon: [], combos: [], context: def.context ?? GLOBAL, seq: 0 };
|
|
1018
|
+
resolve(entry); // throws on a malformed key before anything is added
|
|
1019
|
+
if (def.id) {
|
|
1020
|
+
const dup = [...entries, ...batch].some((e) => e.def.id === def.id && e.context === entry.context);
|
|
1021
|
+
if (dup) {
|
|
1022
|
+
const msg = `keyboardShortcutRegistry: duplicate shortcut id "${def.id}" in context "${entry.context}"`;
|
|
1023
|
+
if (dev)
|
|
1024
|
+
throw new Error(msg);
|
|
1025
|
+
console.warn(`${msg} — the later registration wins`);
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
batch.push(entry);
|
|
1029
|
+
}
|
|
1030
|
+
for (const entry of batch) {
|
|
1031
|
+
entry.seq = seq++;
|
|
232
1032
|
entries.push(entry);
|
|
233
|
-
|
|
234
|
-
});
|
|
1033
|
+
}
|
|
235
1034
|
notify();
|
|
236
1035
|
let done = false;
|
|
237
1036
|
return () => {
|
|
238
1037
|
if (done)
|
|
239
1038
|
return;
|
|
240
1039
|
done = true;
|
|
241
|
-
for (const e of
|
|
1040
|
+
for (const e of batch) {
|
|
242
1041
|
const i = entries.indexOf(e);
|
|
243
1042
|
if (i !== -1)
|
|
244
1043
|
entries.splice(i, 1);
|
|
@@ -247,9 +1046,27 @@ export function createShortcutRegistry(options = {}) {
|
|
|
247
1046
|
};
|
|
248
1047
|
}
|
|
249
1048
|
function pushContext(name, opts = {}) {
|
|
250
|
-
const
|
|
1049
|
+
const passIds = new Set();
|
|
1050
|
+
const passPhysical = new Set();
|
|
1051
|
+
if (opts.modal) {
|
|
1052
|
+
for (const p of opts.passthrough ?? []) {
|
|
1053
|
+
passIds.add(p);
|
|
1054
|
+
if (validateCombo(p) === null)
|
|
1055
|
+
passPhysical.add(physical(normalizeCombo(p)));
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
const frame = {
|
|
1059
|
+
name,
|
|
1060
|
+
modal: !!opts.modal,
|
|
1061
|
+
layer: opts.layer ?? 0,
|
|
1062
|
+
order: pushSeq++,
|
|
1063
|
+
passIds,
|
|
1064
|
+
passPhysical
|
|
1065
|
+
};
|
|
251
1066
|
if (opts.label)
|
|
252
1067
|
labels[name] = opts.label;
|
|
1068
|
+
if (frame.modal)
|
|
1069
|
+
modalContexts.add(name);
|
|
253
1070
|
stack.push(frame);
|
|
254
1071
|
notify();
|
|
255
1072
|
return () => {
|
|
@@ -262,18 +1079,48 @@ export function createShortcutRegistry(options = {}) {
|
|
|
262
1079
|
notify();
|
|
263
1080
|
};
|
|
264
1081
|
}
|
|
1082
|
+
function trackModifiers(next) {
|
|
1083
|
+
const cb = options.onModifierChange;
|
|
1084
|
+
if (!cb)
|
|
1085
|
+
return;
|
|
1086
|
+
const prev = modifiers;
|
|
1087
|
+
if (prev.alt === next.alt && prev.ctrl === next.ctrl && prev.shift === next.shift && prev.meta === next.meta) {
|
|
1088
|
+
return;
|
|
1089
|
+
}
|
|
1090
|
+
modifiers = next;
|
|
1091
|
+
try {
|
|
1092
|
+
cb({ ...next });
|
|
1093
|
+
}
|
|
1094
|
+
catch (err) {
|
|
1095
|
+
console.error('keyboardShortcutRegistry: onModifierChange failed', err);
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
function modifiersOf(event) {
|
|
1099
|
+
return { alt: !!event.altKey, ctrl: !!event.ctrlKey, shift: !!event.shiftKey, meta: !!event.metaKey };
|
|
1100
|
+
}
|
|
1101
|
+
/** Physical identity of a key for release matching; `event.key` when `code` is absent (synthetic events). */
|
|
1102
|
+
function releaseKey(event) {
|
|
1103
|
+
return event.code ? `code:${event.code}` : `key:${normalizeKey(event.key ?? '')}`;
|
|
1104
|
+
}
|
|
1105
|
+
/** A raw handler error is reported through `onError` with a stand-in definition. */
|
|
1106
|
+
function handlerAsDefinition(h) {
|
|
1107
|
+
return { key: [], description: 'Raw shortcut handler', context: h.context, action: () => { } };
|
|
1108
|
+
}
|
|
265
1109
|
function handleKeydown(event) {
|
|
1110
|
+
trackModifiers(modifiersOf(event));
|
|
266
1111
|
if (event.defaultPrevented || event.isComposing || event.key === 'Process')
|
|
267
1112
|
return false;
|
|
268
1113
|
const path = typeof event.composedPath === 'function' ? event.composedPath() : [];
|
|
269
|
-
const typing =
|
|
270
|
-
for (const ctx of
|
|
1114
|
+
const typing = typingTest(path[0] ?? event.target ?? null, event);
|
|
1115
|
+
for (const { name: ctx, filters } of liveContexts()) {
|
|
271
1116
|
// Later registrations shadow earlier ones within a context.
|
|
272
1117
|
for (let i = entries.length - 1; i >= 0; i--) {
|
|
273
1118
|
const e = entries[i];
|
|
274
1119
|
if (e.context !== ctx)
|
|
275
1120
|
continue;
|
|
276
|
-
if (!matches(event,
|
|
1121
|
+
if (!e.combos.some((c) => matches(event, c, platform)))
|
|
1122
|
+
continue;
|
|
1123
|
+
if (filters.length && !passes(e.def, e.canon, filters))
|
|
277
1124
|
continue;
|
|
278
1125
|
if (typing && !e.def.allowInInput)
|
|
279
1126
|
continue;
|
|
@@ -283,6 +1130,8 @@ export function createShortcutRegistry(options = {}) {
|
|
|
283
1130
|
continue;
|
|
284
1131
|
if (e.def.preventDefault !== false)
|
|
285
1132
|
event.preventDefault();
|
|
1133
|
+
if (e.def.onRelease)
|
|
1134
|
+
pendingReleases.set(releaseKey(event), e.def);
|
|
286
1135
|
try {
|
|
287
1136
|
const result = e.def.action(event);
|
|
288
1137
|
if (result && typeof result.catch === 'function') {
|
|
@@ -294,67 +1143,212 @@ export function createShortcutRegistry(options = {}) {
|
|
|
294
1143
|
}
|
|
295
1144
|
return true;
|
|
296
1145
|
}
|
|
1146
|
+
// Raw handlers: after the context's combos, never through a modal.
|
|
1147
|
+
if (filters.length)
|
|
1148
|
+
continue;
|
|
1149
|
+
for (let i = handlers.length - 1; i >= 0; i--) {
|
|
1150
|
+
const h = handlers[i];
|
|
1151
|
+
if (h.context !== ctx)
|
|
1152
|
+
continue;
|
|
1153
|
+
if (typing && !h.handler.allowInInput)
|
|
1154
|
+
continue;
|
|
1155
|
+
let handled = false;
|
|
1156
|
+
try {
|
|
1157
|
+
handled = h.handler.handle(event) === true;
|
|
1158
|
+
}
|
|
1159
|
+
catch (err) {
|
|
1160
|
+
onError(err, handlerAsDefinition(h));
|
|
1161
|
+
}
|
|
1162
|
+
if (!handled)
|
|
1163
|
+
continue;
|
|
1164
|
+
if (h.handler.preventDefault !== false)
|
|
1165
|
+
event.preventDefault();
|
|
1166
|
+
return true;
|
|
1167
|
+
}
|
|
297
1168
|
}
|
|
298
1169
|
return false;
|
|
299
1170
|
}
|
|
1171
|
+
function handleKeyup(event) {
|
|
1172
|
+
trackModifiers(modifiersOf(event));
|
|
1173
|
+
const k = releaseKey(event);
|
|
1174
|
+
const def = pendingReleases.get(k);
|
|
1175
|
+
if (!def)
|
|
1176
|
+
return false;
|
|
1177
|
+
pendingReleases.delete(k);
|
|
1178
|
+
try {
|
|
1179
|
+
def.onRelease?.(event);
|
|
1180
|
+
}
|
|
1181
|
+
catch (err) {
|
|
1182
|
+
onError(err, def);
|
|
1183
|
+
}
|
|
1184
|
+
return true;
|
|
1185
|
+
}
|
|
1186
|
+
function handleBlur() {
|
|
1187
|
+
pendingReleases.clear();
|
|
1188
|
+
trackModifiers({ alt: false, ctrl: false, shift: false, meta: false });
|
|
1189
|
+
}
|
|
300
1190
|
function attach(target) {
|
|
301
1191
|
const t = target ?? (typeof window !== 'undefined' ? window : null);
|
|
302
1192
|
if (!t)
|
|
303
1193
|
return () => { };
|
|
304
|
-
const
|
|
1194
|
+
const onDown = (e) => {
|
|
305
1195
|
handleKeydown(e);
|
|
306
1196
|
};
|
|
307
|
-
|
|
308
|
-
|
|
1197
|
+
const onUp = (e) => {
|
|
1198
|
+
handleKeyup(e);
|
|
1199
|
+
};
|
|
1200
|
+
const onBlur = () => handleBlur();
|
|
1201
|
+
// keydown last: a pre-A3 custom target that ignores `type` keeps the keydown.
|
|
1202
|
+
t.addEventListener('keyup', onUp);
|
|
1203
|
+
t.addEventListener('blur', onBlur);
|
|
1204
|
+
t.addEventListener('keydown', onDown);
|
|
1205
|
+
return () => {
|
|
1206
|
+
t.removeEventListener('keyup', onUp);
|
|
1207
|
+
t.removeEventListener('blur', onBlur);
|
|
1208
|
+
t.removeEventListener('keydown', onDown);
|
|
1209
|
+
};
|
|
1210
|
+
}
|
|
1211
|
+
/** Overlay keycap for a definition's live binding; null when unbound. */
|
|
1212
|
+
function labelOf(def, canon) {
|
|
1213
|
+
if (!canon.length)
|
|
1214
|
+
return null;
|
|
1215
|
+
if (def.label && !isCustom(def))
|
|
1216
|
+
return def.label;
|
|
1217
|
+
return canon.map((c) => formatCombo(parseCombo(c), platform)).join(' / ');
|
|
309
1218
|
}
|
|
310
1219
|
function toSections(opts = {}) {
|
|
1220
|
+
// Registrations, plus (opt-in) declarations nothing has registered yet.
|
|
1221
|
+
const pool = opts.includeDeclared ? units().sort((a, b) => a.seq - b.seq) : entries;
|
|
311
1222
|
const contexts = opts.scope === 'all'
|
|
312
|
-
? [...new Set(
|
|
313
|
-
|
|
1223
|
+
? [...new Set([...pool.map((e) => e.context), ...handlers.map((h) => h.context)])]
|
|
1224
|
+
.sort((a, b) => (a === GLOBAL ? 1 : b === GLOBAL ? -1 : 0))
|
|
1225
|
+
.map((name) => ({ name, filters: [] }))
|
|
1226
|
+
: liveContexts();
|
|
314
1227
|
const sections = new Map();
|
|
1228
|
+
const sectionFor = (title) => {
|
|
1229
|
+
let section = sections.get(title);
|
|
1230
|
+
if (!section) {
|
|
1231
|
+
section = { title, shortcuts: [] };
|
|
1232
|
+
sections.set(title, section);
|
|
1233
|
+
}
|
|
1234
|
+
return section;
|
|
1235
|
+
};
|
|
315
1236
|
const seen = new Set();
|
|
316
|
-
for (const ctx of contexts) {
|
|
1237
|
+
for (const { name: ctx, filters } of contexts) {
|
|
317
1238
|
// Walk newest-first so a shadowed combo (same context, or a lower
|
|
318
1239
|
// context) is the one dropped, then restore registration order.
|
|
319
1240
|
const kept = [];
|
|
320
|
-
for (let i =
|
|
321
|
-
const e =
|
|
1241
|
+
for (let i = pool.length - 1; i >= 0; i--) {
|
|
1242
|
+
const e = pool[i];
|
|
322
1243
|
if (e.context !== ctx || e.def.hidden)
|
|
323
1244
|
continue;
|
|
1245
|
+
if (filters.length && !passes(e.def, e.canon, filters))
|
|
1246
|
+
continue;
|
|
324
1247
|
if (opts.onlyEnabled && e.def.enabled && !e.def.enabled())
|
|
325
1248
|
continue;
|
|
326
|
-
const
|
|
327
|
-
if (
|
|
1249
|
+
const label = labelOf(e.def, e.canon);
|
|
1250
|
+
if (label === null)
|
|
1251
|
+
continue;
|
|
1252
|
+
const ids = e.canon.map(physical);
|
|
1253
|
+
if (opts.scope !== 'all' && ids.every((id) => seen.has(id)))
|
|
328
1254
|
continue;
|
|
329
|
-
|
|
330
|
-
|
|
1255
|
+
for (const id of ids)
|
|
1256
|
+
seen.add(id);
|
|
1257
|
+
kept.unshift({ e, label });
|
|
331
1258
|
}
|
|
332
|
-
for (const e of kept) {
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
1259
|
+
for (const { e, label } of kept) {
|
|
1260
|
+
sectionFor(e.def.group ?? labels[ctx] ?? ctx).shortcuts.push({ key: label, description: e.def.description });
|
|
1261
|
+
}
|
|
1262
|
+
if (filters.length)
|
|
1263
|
+
continue;
|
|
1264
|
+
for (const h of handlers) {
|
|
1265
|
+
if (h.context !== ctx || !h.handler.describe)
|
|
1266
|
+
continue;
|
|
1267
|
+
let rows = [];
|
|
1268
|
+
try {
|
|
1269
|
+
rows = h.handler.describe() ?? [];
|
|
1270
|
+
}
|
|
1271
|
+
catch (err) {
|
|
1272
|
+
console.error('keyboardShortcutRegistry: raw handler describe() failed', err);
|
|
1273
|
+
}
|
|
1274
|
+
for (const row of rows) {
|
|
1275
|
+
sectionFor(row.group ?? labels[ctx] ?? ctx).shortcuts.push({ key: row.key, description: row.description });
|
|
338
1276
|
}
|
|
339
|
-
section.shortcuts.push({ key: formatShortcut(e.def, platform), description: e.def.description });
|
|
340
1277
|
}
|
|
341
1278
|
}
|
|
342
1279
|
return [...sections.values()];
|
|
343
1280
|
}
|
|
1281
|
+
function format(s) {
|
|
1282
|
+
if (typeof s === 'string' || !s.id)
|
|
1283
|
+
return formatShortcut(s, platform);
|
|
1284
|
+
return labelOf(s, currentOf(s)) ?? '';
|
|
1285
|
+
}
|
|
1286
|
+
function formatBinding(id) {
|
|
1287
|
+
const src = sourceOf(id);
|
|
1288
|
+
if (!src)
|
|
1289
|
+
return null;
|
|
1290
|
+
return labelOf(src.def, currentOf(src.def));
|
|
1291
|
+
}
|
|
1292
|
+
// Load the store last, once everything it re-resolves exists.
|
|
1293
|
+
let ready = Promise.resolve();
|
|
1294
|
+
if (store) {
|
|
1295
|
+
let result;
|
|
1296
|
+
try {
|
|
1297
|
+
result = store.load();
|
|
1298
|
+
}
|
|
1299
|
+
catch (err) {
|
|
1300
|
+
warnStore(err);
|
|
1301
|
+
result = {};
|
|
1302
|
+
}
|
|
1303
|
+
if (isThenable(result)) {
|
|
1304
|
+
ready = Promise.resolve(result).then((o) => applyOverrides(sanitizeOverrides(o, rejected.add), { replay: true }), (err) => {
|
|
1305
|
+
warnStore(err);
|
|
1306
|
+
applyOverrides({}, { replay: true });
|
|
1307
|
+
});
|
|
1308
|
+
}
|
|
1309
|
+
else {
|
|
1310
|
+
applyOverrides(sanitizeOverrides(result, rejected.add), { replay: true });
|
|
1311
|
+
}
|
|
1312
|
+
store.subscribe?.((o) => applyOverrides(sanitizeOverrides(o, rejected.add), { replay: !loaded }));
|
|
1313
|
+
}
|
|
344
1314
|
return {
|
|
345
1315
|
register,
|
|
346
1316
|
pushContext,
|
|
347
1317
|
activeContexts,
|
|
348
1318
|
attach,
|
|
349
1319
|
handleKeydown,
|
|
1320
|
+
handleKeyup,
|
|
1321
|
+
handleBlur,
|
|
1322
|
+
registerHandler,
|
|
1323
|
+
formatBinding,
|
|
350
1324
|
toSections,
|
|
351
1325
|
list: () => [...entries].sort((a, b) => a.seq - b.seq).map((e) => e.def),
|
|
352
|
-
format
|
|
1326
|
+
format,
|
|
353
1327
|
subscribe(listener) {
|
|
354
1328
|
listeners.add(listener);
|
|
355
1329
|
return () => listeners.delete(listener);
|
|
356
1330
|
},
|
|
357
|
-
platform
|
|
1331
|
+
platform,
|
|
1332
|
+
ready,
|
|
1333
|
+
bindings,
|
|
1334
|
+
binding,
|
|
1335
|
+
rebind,
|
|
1336
|
+
resetBinding,
|
|
1337
|
+
resetAllBindings,
|
|
1338
|
+
findConflicts,
|
|
1339
|
+
allConflicts,
|
|
1340
|
+
rejectedBindings() {
|
|
1341
|
+
const fromStore = (() => {
|
|
1342
|
+
try {
|
|
1343
|
+
return store?.rejected?.() ?? [];
|
|
1344
|
+
}
|
|
1345
|
+
catch {
|
|
1346
|
+
return [];
|
|
1347
|
+
}
|
|
1348
|
+
})();
|
|
1349
|
+
return [...fromStore, ...rejected.list()];
|
|
1350
|
+
},
|
|
1351
|
+
declare
|
|
358
1352
|
};
|
|
359
1353
|
}
|
|
360
1354
|
let defaultRegistry = null;
|