entangled-worlds-orbiters-shared 4.1.0 → 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -0
- package/dist/shortcuts/catalogue.d.ts +72 -0
- package/dist/shortcuts/catalogue.js +118 -0
- package/dist/shortcuts/chord.d.ts +35 -0
- package/dist/shortcuts/chord.js +99 -0
- package/dist/shortcuts/format.d.ts +44 -0
- package/dist/shortcuts/format.js +132 -0
- package/dist/shortcuts/index.d.ts +5 -0
- package/dist/shortcuts/index.js +9 -0
- package/dist/shortcuts/match.d.ts +71 -0
- package/dist/shortcuts/match.js +145 -0
- package/dist/shortcuts/source.d.ts +62 -0
- package/dist/shortcuts/source.js +139 -0
- package/package.json +5 -1
- package/src/shortcuts/catalogue.ts +172 -0
- package/src/shortcuts/chord.ts +122 -0
- package/src/shortcuts/format.ts +143 -0
- package/src/shortcuts/index.ts +67 -0
- package/src/shortcuts/match.ts +202 -0
- package/src/shortcuts/source.ts +204 -0
package/README.md
CHANGED
|
@@ -98,6 +98,15 @@ The capability registry (`createCapabilityRegistry`, `validate` / `assertValid`,
|
|
|
98
98
|
- `recipesFingerprint`, `readRecipes`, `readHeadShape`, `programsNamespace` - The recipes file a build publishes, and the checks a release step makes on it
|
|
99
99
|
- `largestFunction`, `shrinkWasm` - The size check and the shrink step
|
|
100
100
|
|
|
101
|
+
### Keyboard shortcuts (`entangled-worlds-orbiters-shared/shortcuts`)
|
|
102
|
+
- `ShortcutAction` - One catalogue entry: `id`, `label` (+ `names` by language), `defaultKeys` (by key place, `event.code`), `scope`
|
|
103
|
+
- `matchShortcut(event, bindings)` - Which action a press is, or `null`: modifiers, a field being typed in and surface claims decided in one place
|
|
104
|
+
- Key strings: `Shift+KeyR`, `Mod+KeyZ` (Ctrl or ⌘), `Shift?+KeyQ` (Shift may be held); `parseChord`, `chordsOverlap`
|
|
105
|
+
- `resolveBindings(catalogue, overrides)`, `findClashes` - Defaults plus a person's overrides (`[{ id, keys }]` as their settings store them, `keys: []` unbinds), and the clash check
|
|
106
|
+
- `claimKeys`, `isKeyClaimed` - A surface (an on-screen keyboard) takes letters away from the shortcuts while it is up
|
|
107
|
+
- `createShortcutSource({ readCatalogue, readAccount })` - The catalogue (every app's actions and default keys, served by the backend: this package holds none) and a person's own keys, read at start and again when the page comes back to the front; `actions(...sectionIds)`, `bindings(actions)`, `subscribe`
|
|
108
|
+
- `parseCatalogue`, `sectionActions`, `findSection`, `shortcutApps`, `localName` - Reading a served catalogue and its names in a language
|
|
109
|
+
|
|
101
110
|
### Uploads (`entangled-worlds-orbiters-shared/uploads`)
|
|
102
111
|
- `uploadViaSignedUrl` - The API's signed-URL upload: `/spaces/upload-init` → PUT → `/spaces/upload-complete`, over the app's own authenticated JSON caller
|
|
103
112
|
- `putToSignedUrl` - The storage PUT alone, with progress and abort
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { ShortcutAction, ShortcutNames } from './match.js';
|
|
2
|
+
/** Something with a name: English `label`, other languages in `names`. */
|
|
3
|
+
export interface ShortcutNamed {
|
|
4
|
+
readonly id: string;
|
|
5
|
+
readonly label: string;
|
|
6
|
+
readonly names?: ShortcutNames;
|
|
7
|
+
}
|
|
8
|
+
/** A row of a keys sheet (`?`): one line for one or more actions whose keys show together. */
|
|
9
|
+
export interface ShortcutSheetRow extends ShortcutNamed {
|
|
10
|
+
readonly actions: readonly string[];
|
|
11
|
+
}
|
|
12
|
+
export interface ShortcutSheetColumn extends ShortcutNamed {
|
|
13
|
+
readonly rows: readonly ShortcutSheetRow[];
|
|
14
|
+
}
|
|
15
|
+
/** One surface's keys (`orbiters-play`, `ew-studio`, `site`). Two actions clash only inside a section. */
|
|
16
|
+
export interface ShortcutSection {
|
|
17
|
+
readonly id: string;
|
|
18
|
+
/** The app it belongs to (`orbiters`) and that app's name. */
|
|
19
|
+
readonly app: string;
|
|
20
|
+
readonly appLabel: string;
|
|
21
|
+
readonly appNames?: ShortcutNames;
|
|
22
|
+
readonly label: string;
|
|
23
|
+
readonly names?: ShortcutNames;
|
|
24
|
+
/** Its name inside the app (`Play`, `Studio`); none when the app has only this section. */
|
|
25
|
+
readonly part?: string;
|
|
26
|
+
readonly partNames?: ShortcutNames;
|
|
27
|
+
/** How a command menu groups the actions, in its order. */
|
|
28
|
+
readonly groups: readonly ShortcutNamed[];
|
|
29
|
+
/** The keys sheet, where the app shows one in columns. */
|
|
30
|
+
readonly sheet?: readonly ShortcutSheetColumn[];
|
|
31
|
+
readonly actions: readonly ShortcutAction[];
|
|
32
|
+
/**
|
|
33
|
+
* Letters a surface of this section plays while it is open (`event.key`, lower-case; the Orbiters
|
|
34
|
+
* on-screen keyboard): a shortcut on one of them waits until it closes.
|
|
35
|
+
*/
|
|
36
|
+
readonly heldKeys?: readonly string[];
|
|
37
|
+
}
|
|
38
|
+
export interface ShortcutCatalogue {
|
|
39
|
+
/** Changes whenever the catalogue is edited. */
|
|
40
|
+
readonly version: string;
|
|
41
|
+
readonly sections: readonly ShortcutSection[];
|
|
42
|
+
}
|
|
43
|
+
/** Before the catalogue is read: no sections, so no key does anything yet. */
|
|
44
|
+
export declare const EMPTY_CATALOGUE: ShortcutCatalogue;
|
|
45
|
+
/**
|
|
46
|
+
* The catalogue a response holds (`{ version, sections }`), or `null` when it holds none. A malformed
|
|
47
|
+
* section or action is left out rather than failing the whole read.
|
|
48
|
+
*/
|
|
49
|
+
export declare function parseCatalogue(value: unknown): ShortcutCatalogue | null;
|
|
50
|
+
/** A name in a language (`es`, `es-AR`), the English when there is none in it. */
|
|
51
|
+
export declare function localName(item: {
|
|
52
|
+
readonly label: string;
|
|
53
|
+
readonly names?: ShortcutNames;
|
|
54
|
+
}, locale?: string): string;
|
|
55
|
+
/** A section's app name in a language. */
|
|
56
|
+
export declare function localAppName(section: Pick<ShortcutSection, 'appLabel' | 'appNames'>, locale?: string): string;
|
|
57
|
+
/** A section's part name (`Play`, `Studio`) in a language; `undefined` when it has none. */
|
|
58
|
+
export declare function localPartName(section: Pick<ShortcutSection, 'part' | 'partNames'>, locale?: string): string | undefined;
|
|
59
|
+
/** The apps the catalogue has, in its order. */
|
|
60
|
+
export declare function shortcutApps(catalogue: ShortcutCatalogue): ReadonlyArray<{
|
|
61
|
+
id: string;
|
|
62
|
+
label: string;
|
|
63
|
+
names?: ShortcutNames;
|
|
64
|
+
}>;
|
|
65
|
+
/**
|
|
66
|
+
* The actions of these sections, in the order named: where two sections hold one key, the first
|
|
67
|
+
* named wins (the Orbiter Studio lies over play). The same list object for as long as the catalogue
|
|
68
|
+
* is the same, so a cache keyed by it holds.
|
|
69
|
+
*/
|
|
70
|
+
export declare function sectionActions(catalogue: ShortcutCatalogue, ...sectionIds: string[]): readonly ShortcutAction[];
|
|
71
|
+
/** One section, if the catalogue has it. */
|
|
72
|
+
export declare function findSection(catalogue: ShortcutCatalogue, sectionId: string): ShortcutSection | undefined;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// The shortcut catalogue as the apps read it: every app's sections of actions, their default keys and
|
|
2
|
+
// names, as the backend serves them (from its config, so a key or a name changes without a library
|
|
3
|
+
// release). This file only reads what was served; it holds no actions of its own.
|
|
4
|
+
/** Before the catalogue is read: no sections, so no key does anything yet. */
|
|
5
|
+
export const EMPTY_CATALOGUE = Object.freeze({ version: '', sections: Object.freeze([]) });
|
|
6
|
+
const isRecord = (value) => !!value && typeof value === 'object' && !Array.isArray(value);
|
|
7
|
+
const isString = (value) => typeof value === 'string' && value.length > 0;
|
|
8
|
+
function names(value) {
|
|
9
|
+
if (!isRecord(value))
|
|
10
|
+
return undefined;
|
|
11
|
+
const out = {};
|
|
12
|
+
for (const [locale, name] of Object.entries(value))
|
|
13
|
+
if (isString(name))
|
|
14
|
+
out[locale] = name;
|
|
15
|
+
return Object.freeze(out);
|
|
16
|
+
}
|
|
17
|
+
function named(value) {
|
|
18
|
+
if (!isRecord(value) || !isString(value.id) || !isString(value.label))
|
|
19
|
+
return null;
|
|
20
|
+
return { id: value.id, label: value.label, names: names(value.names) };
|
|
21
|
+
}
|
|
22
|
+
function action(value) {
|
|
23
|
+
const base = named(value);
|
|
24
|
+
if (!base || !isRecord(value) || !isString(value.scope) || !Array.isArray(value.defaultKeys))
|
|
25
|
+
return null;
|
|
26
|
+
return Object.freeze({
|
|
27
|
+
...base,
|
|
28
|
+
defaultKeys: Object.freeze(value.defaultKeys.filter(isString)),
|
|
29
|
+
scope: value.scope,
|
|
30
|
+
...(isString(value.group) ? { group: value.group } : {}),
|
|
31
|
+
...(value.command === false ? { command: false } : {}),
|
|
32
|
+
...(isString(value.href) ? { href: value.href } : {}),
|
|
33
|
+
...(value.signedIn === true ? { signedIn: true } : {}),
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
const list = (value, read) => Object.freeze(Array.isArray(value) ? value.map(read).filter((item) => item !== null) : []);
|
|
37
|
+
function section(value) {
|
|
38
|
+
const base = named(value);
|
|
39
|
+
if (!base || !isRecord(value) || !isString(value.app) || !isString(value.appLabel))
|
|
40
|
+
return null;
|
|
41
|
+
return Object.freeze({
|
|
42
|
+
...base,
|
|
43
|
+
app: value.app,
|
|
44
|
+
appLabel: value.appLabel,
|
|
45
|
+
appNames: names(value.appNames),
|
|
46
|
+
...(isString(value.part) ? { part: value.part, partNames: names(value.partNames) } : {}),
|
|
47
|
+
groups: list(value.groups, named),
|
|
48
|
+
...(Array.isArray(value.sheet)
|
|
49
|
+
? {
|
|
50
|
+
sheet: list(value.sheet, (column) => {
|
|
51
|
+
const head = named(column);
|
|
52
|
+
if (!head || !isRecord(column))
|
|
53
|
+
return null;
|
|
54
|
+
return Object.freeze({
|
|
55
|
+
...head,
|
|
56
|
+
rows: list(column.rows, (row) => {
|
|
57
|
+
const line = named(row);
|
|
58
|
+
return line && isRecord(row) ? Object.freeze({ ...line, actions: list(row.actions, (id) => (isString(id) ? id : null)) }) : null;
|
|
59
|
+
}),
|
|
60
|
+
});
|
|
61
|
+
}),
|
|
62
|
+
}
|
|
63
|
+
: {}),
|
|
64
|
+
actions: list(value.actions, action),
|
|
65
|
+
...(Array.isArray(value.heldKeys) ? { heldKeys: list(value.heldKeys, (key) => (isString(key) ? key : null)) } : {}),
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* The catalogue a response holds (`{ version, sections }`), or `null` when it holds none. A malformed
|
|
70
|
+
* section or action is left out rather than failing the whole read.
|
|
71
|
+
*/
|
|
72
|
+
export function parseCatalogue(value) {
|
|
73
|
+
if (!isRecord(value) || !Array.isArray(value.sections))
|
|
74
|
+
return null;
|
|
75
|
+
return Object.freeze({ version: typeof value.version === 'string' ? value.version : '', sections: list(value.sections, section) });
|
|
76
|
+
}
|
|
77
|
+
/** A name in a language (`es`, `es-AR`), the English when there is none in it. */
|
|
78
|
+
export function localName(item, locale) {
|
|
79
|
+
return item.names?.[(locale ?? '').slice(0, 2).toLowerCase()] ?? item.label;
|
|
80
|
+
}
|
|
81
|
+
/** A section's app name in a language. */
|
|
82
|
+
export function localAppName(section, locale) {
|
|
83
|
+
return localName({ label: section.appLabel, names: section.appNames }, locale);
|
|
84
|
+
}
|
|
85
|
+
/** A section's part name (`Play`, `Studio`) in a language; `undefined` when it has none. */
|
|
86
|
+
export function localPartName(section, locale) {
|
|
87
|
+
return section.part === undefined ? undefined : localName({ label: section.part, names: section.partNames }, locale);
|
|
88
|
+
}
|
|
89
|
+
/** The apps the catalogue has, in its order. */
|
|
90
|
+
export function shortcutApps(catalogue) {
|
|
91
|
+
const seen = new Map();
|
|
92
|
+
for (const s of catalogue.sections)
|
|
93
|
+
if (!seen.has(s.app))
|
|
94
|
+
seen.set(s.app, { id: s.app, label: s.appLabel, names: s.appNames });
|
|
95
|
+
return [...seen.values()];
|
|
96
|
+
}
|
|
97
|
+
const actionLists = new WeakMap();
|
|
98
|
+
/**
|
|
99
|
+
* The actions of these sections, in the order named: where two sections hold one key, the first
|
|
100
|
+
* named wins (the Orbiter Studio lies over play). The same list object for as long as the catalogue
|
|
101
|
+
* is the same, so a cache keyed by it holds.
|
|
102
|
+
*/
|
|
103
|
+
export function sectionActions(catalogue, ...sectionIds) {
|
|
104
|
+
let lists = actionLists.get(catalogue);
|
|
105
|
+
if (!lists)
|
|
106
|
+
actionLists.set(catalogue, (lists = new Map()));
|
|
107
|
+
const key = sectionIds.join('\n');
|
|
108
|
+
let found = lists.get(key);
|
|
109
|
+
if (!found) {
|
|
110
|
+
found = Object.freeze(sectionIds.flatMap((id) => catalogue.sections.find((s) => s.id === id)?.actions ?? []));
|
|
111
|
+
lists.set(key, found);
|
|
112
|
+
}
|
|
113
|
+
return found;
|
|
114
|
+
}
|
|
115
|
+
/** One section, if the catalogue has it. */
|
|
116
|
+
export function findSection(catalogue, sectionId) {
|
|
117
|
+
return catalogue.sections.find((s) => s.id === sectionId);
|
|
118
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/** What a modifier has to be for a chord to match: held, not held, or either. */
|
|
2
|
+
export type ModifierRule = 'held' | 'free' | 'any';
|
|
3
|
+
export interface Chord {
|
|
4
|
+
readonly code: string;
|
|
5
|
+
readonly shift: ModifierRule;
|
|
6
|
+
readonly alt: ModifierRule;
|
|
7
|
+
readonly ctrl: ModifierRule;
|
|
8
|
+
readonly meta: ModifierRule;
|
|
9
|
+
/** Ctrl or ⌘: when `held`, at least one of the two is down, and neither is otherwise checked. */
|
|
10
|
+
readonly mod: ModifierRule;
|
|
11
|
+
}
|
|
12
|
+
/** The modifier state of a key press: the four flags of a `KeyboardEvent`. */
|
|
13
|
+
export interface ModifierState {
|
|
14
|
+
readonly shiftKey: boolean;
|
|
15
|
+
readonly altKey: boolean;
|
|
16
|
+
readonly ctrlKey: boolean;
|
|
17
|
+
readonly metaKey: boolean;
|
|
18
|
+
}
|
|
19
|
+
/** The chord a key string names, or `null` when it names none (an unknown modifier, a doubled one, no key). */
|
|
20
|
+
export declare function parseChord(keys: string): Chord | null;
|
|
21
|
+
/** Whether a press of `code` with these modifiers is this chord. */
|
|
22
|
+
export declare function chordMatches(chord: Chord, code: string, state: ModifierState): boolean;
|
|
23
|
+
/** A chord that needs Ctrl or ⌘: a command, never a character typed. */
|
|
24
|
+
export declare function isCommandChord(chord: Chord): boolean;
|
|
25
|
+
/** Whether one press could be both chords — the test for a clash. */
|
|
26
|
+
export declare function chordsOverlap(a: Chord, b: Chord): boolean;
|
|
27
|
+
/** The chords a key string names, one or two (a sequence); `null` when it names none. */
|
|
28
|
+
export declare function parseKeys(keys: string): Chord[] | null;
|
|
29
|
+
/**
|
|
30
|
+
* Whether two key strings could answer the same presses — the test for a clash. A single key and a
|
|
31
|
+
* sequence that starts with it clash too: the single key would fire before the sequence went on.
|
|
32
|
+
*/
|
|
33
|
+
export declare function keysOverlap(a: string, b: string): boolean;
|
|
34
|
+
/** A key that is only a modifier (or Caps Lock, Fn): never a shortcut's key on its own. */
|
|
35
|
+
export declare const isModifierCode: (code: string) => boolean;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// One key with its modifiers, written as a string: `Shift+KeyR`, `Mod+KeyZ`, `Shift?+Digit1`.
|
|
2
|
+
//
|
|
3
|
+
// The last part is the key's PLACE (`KeyboardEvent.code`), so a French or German keyboard keeps the
|
|
4
|
+
// same layout: the key, not the letter it prints. Before it, the modifiers:
|
|
5
|
+
//
|
|
6
|
+
// Shift, Alt, Ctrl, Meta held. One left out must NOT be held.
|
|
7
|
+
// Mod Ctrl or ⌘ (either, or both), the command key of every platform.
|
|
8
|
+
// Shift? (any, with ?) may be held or not: the action is the same either way.
|
|
9
|
+
//
|
|
10
|
+
// So `KeyQ` is Q alone, `Shift?+KeyQ` is Q with or without Shift, `Mod+Shift+KeyZ` is ⌘⇧Z or Ctrl⇧Z.
|
|
11
|
+
//
|
|
12
|
+
// Two chords with a space between are a sequence, pressed one after the other: `KeyG KeyH` is G,
|
|
13
|
+
// then H. A sequence is never one press, so `parseChord` names none; `parseKeys` reads both kinds.
|
|
14
|
+
const MODIFIERS = { Shift: 'shift', Alt: 'alt', Ctrl: 'ctrl', Meta: 'meta', Mod: 'mod' };
|
|
15
|
+
const CODE = /^[A-Za-z][A-Za-z0-9]*$/;
|
|
16
|
+
const parsed = new Map();
|
|
17
|
+
/** The chord a key string names, or `null` when it names none (an unknown modifier, a doubled one, no key). */
|
|
18
|
+
export function parseChord(keys) {
|
|
19
|
+
const known = parsed.get(keys);
|
|
20
|
+
if (known !== undefined)
|
|
21
|
+
return known;
|
|
22
|
+
const chord = parse(keys);
|
|
23
|
+
parsed.set(keys, chord);
|
|
24
|
+
return chord;
|
|
25
|
+
}
|
|
26
|
+
function parse(keys) {
|
|
27
|
+
if (typeof keys !== 'string')
|
|
28
|
+
return null;
|
|
29
|
+
const parts = keys.split('+');
|
|
30
|
+
const code = parts.pop();
|
|
31
|
+
if (!code || !CODE.test(code))
|
|
32
|
+
return null;
|
|
33
|
+
const rules = {
|
|
34
|
+
shift: 'free', alt: 'free', ctrl: 'free', meta: 'free', mod: 'free',
|
|
35
|
+
};
|
|
36
|
+
const seen = new Set();
|
|
37
|
+
for (const part of parts) {
|
|
38
|
+
const optional = part.endsWith('?');
|
|
39
|
+
const name = (optional ? part.slice(0, -1) : part);
|
|
40
|
+
if (!Object.hasOwn(MODIFIERS, name) || seen.has(name))
|
|
41
|
+
return null;
|
|
42
|
+
seen.add(name);
|
|
43
|
+
rules[MODIFIERS[name]] = optional ? 'any' : 'held';
|
|
44
|
+
}
|
|
45
|
+
// Mod already speaks for Ctrl and ⌘: naming one of them beside it says two things about one key.
|
|
46
|
+
if (rules.mod !== 'free' && (rules.ctrl !== 'free' || rules.meta !== 'free'))
|
|
47
|
+
return null;
|
|
48
|
+
return { code, ...rules };
|
|
49
|
+
}
|
|
50
|
+
const fits = (rule, down) => rule === 'any' || (rule === 'held') === Boolean(down);
|
|
51
|
+
/** Whether a press of `code` with these modifiers is this chord. */
|
|
52
|
+
export function chordMatches(chord, code, state) {
|
|
53
|
+
if (chord.code !== code)
|
|
54
|
+
return false;
|
|
55
|
+
if (!fits(chord.shift, state.shiftKey) || !fits(chord.alt, state.altKey))
|
|
56
|
+
return false;
|
|
57
|
+
if (chord.mod === 'held')
|
|
58
|
+
return Boolean(state.ctrlKey || state.metaKey);
|
|
59
|
+
if (chord.mod === 'any')
|
|
60
|
+
return true;
|
|
61
|
+
return fits(chord.ctrl, state.ctrlKey) && fits(chord.meta, state.metaKey);
|
|
62
|
+
}
|
|
63
|
+
/** A chord that needs Ctrl or ⌘: a command, never a character typed. */
|
|
64
|
+
export function isCommandChord(chord) {
|
|
65
|
+
return chord.mod === 'held' || chord.ctrl === 'held' || chord.meta === 'held';
|
|
66
|
+
}
|
|
67
|
+
const STATES = Array.from({ length: 16 }, (_, i) => ({
|
|
68
|
+
shiftKey: Boolean(i & 1), altKey: Boolean(i & 2), ctrlKey: Boolean(i & 4), metaKey: Boolean(i & 8),
|
|
69
|
+
}));
|
|
70
|
+
/** Whether one press could be both chords — the test for a clash. */
|
|
71
|
+
export function chordsOverlap(a, b) {
|
|
72
|
+
return a.code === b.code && STATES.some((state) => chordMatches(a, a.code, state) && chordMatches(b, b.code, state));
|
|
73
|
+
}
|
|
74
|
+
/** The chords a key string names, one or two (a sequence); `null` when it names none. */
|
|
75
|
+
export function parseKeys(keys) {
|
|
76
|
+
if (typeof keys !== 'string')
|
|
77
|
+
return null;
|
|
78
|
+
const steps = keys.split(' ');
|
|
79
|
+
if (steps.length > 2)
|
|
80
|
+
return null;
|
|
81
|
+
const chords = steps.map(parseChord);
|
|
82
|
+
return chords.every((chord) => chord !== null) ? chords : null;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Whether two key strings could answer the same presses — the test for a clash. A single key and a
|
|
86
|
+
* sequence that starts with it clash too: the single key would fire before the sequence went on.
|
|
87
|
+
*/
|
|
88
|
+
export function keysOverlap(a, b) {
|
|
89
|
+
const as = parseKeys(a);
|
|
90
|
+
const bs = parseKeys(b);
|
|
91
|
+
if (!as || !bs)
|
|
92
|
+
return false;
|
|
93
|
+
if (as.length !== bs.length)
|
|
94
|
+
return chordsOverlap(as[0], bs[0]);
|
|
95
|
+
return as.every((chord, i) => chordsOverlap(chord, bs[i]));
|
|
96
|
+
}
|
|
97
|
+
const MODIFIER_CODES = new Set(['ShiftLeft', 'ShiftRight', 'AltLeft', 'AltRight', 'ControlLeft', 'ControlRight', 'MetaLeft', 'MetaRight', 'CapsLock', 'Fn', 'FnLock']);
|
|
98
|
+
/** A key that is only a modifier (or Caps Lock, Fn): never a shortcut's key on its own. */
|
|
99
|
+
export const isModifierCode = (code) => MODIFIER_CODES.has(code);
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/** Apple keyboards show modifiers as symbols, and their command key is ⌘ (`Mod`); others write words and Ctrl. */
|
|
2
|
+
export type ShortcutPlatform = 'apple' | 'other';
|
|
3
|
+
/** The platform of this browser, by its own report; `other` outside a browser. */
|
|
4
|
+
export declare function shortcutPlatform(): ShortcutPlatform;
|
|
5
|
+
/**
|
|
6
|
+
* The character a key place prints on this person's keyboard, when the browser can say
|
|
7
|
+
* (`navigator.keyboard.getLayoutMap()`, Chromium only); a key place it does not know is shown from
|
|
8
|
+
* its name.
|
|
9
|
+
*/
|
|
10
|
+
export type KeyLayout = ReadonlyMap<string, string>;
|
|
11
|
+
/** What a key place is called on a keyboard: `KeyQ` → `Q`, `Digit1` → `1`, `Numpad4` → `Num 4`. */
|
|
12
|
+
export declare function keyLabel(code: string, layout?: KeyLayout): string;
|
|
13
|
+
/**
|
|
14
|
+
* The parts a chord is shown as, one per key to press: modifiers first, in the order the platform
|
|
15
|
+
* writes them, then the key. A modifier the chord merely allows (`Shift?`) is not shown — it is not
|
|
16
|
+
* a key anyone has to press. `null` for a key string that names no chord.
|
|
17
|
+
*/
|
|
18
|
+
export declare function chordParts(keys: string, platform?: ShortcutPlatform, layout?: KeyLayout): string[] | null;
|
|
19
|
+
/** One line for a chord: `⌘⇧Z` on Apple keyboards, `Ctrl+Shift+Z` elsewhere. */
|
|
20
|
+
export declare function formatChord(keys: string, platform?: ShortcutPlatform, layout?: KeyLayout): string;
|
|
21
|
+
/**
|
|
22
|
+
* The key string a press makes, for someone setting a key: exactly the modifiers held, the command
|
|
23
|
+
* key of their platform written `Mod` (⌘ on Apple, Ctrl elsewhere) so the key works on both. `null`
|
|
24
|
+
* while only modifiers are down, or for a press with no key place.
|
|
25
|
+
*/
|
|
26
|
+
export declare function chordFromEvent(event: {
|
|
27
|
+
code: string;
|
|
28
|
+
shiftKey: boolean;
|
|
29
|
+
altKey: boolean;
|
|
30
|
+
ctrlKey: boolean;
|
|
31
|
+
metaKey: boolean;
|
|
32
|
+
}, platform?: ShortcutPlatform): string | null;
|
|
33
|
+
/**
|
|
34
|
+
* The keys of several actions shown as one ("1 2 3", "Q W E", "⌥ 0–9"): when each has exactly one
|
|
35
|
+
* key and they differ only in the last key, the shared modifiers once and then the keys. Otherwise
|
|
36
|
+
* every key of every action, as it is. Read from the keys in force, so a person's own keys show.
|
|
37
|
+
*/
|
|
38
|
+
export declare function mergedKeyParts(keysPerAction: ReadonlyArray<readonly string[]>, platform?: ShortcutPlatform, layout?: KeyLayout): string[][];
|
|
39
|
+
/**
|
|
40
|
+
* A key someone just pressed for an action, as it is stored: when every default key of the action
|
|
41
|
+
* lets Shift be held (`Shift?`, the play keys that work while selecting several Orbiters) and the
|
|
42
|
+
* press had no Shift, the new key lets it be held too, so the action keeps working the same way.
|
|
43
|
+
*/
|
|
44
|
+
export declare function recordedKeys(defaultKeys: readonly string[], pressed: string): string;
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// A key string for people: how a chord is shown (`⌘⇧Z`, `Ctrl+Shift+Z`, `Q`), and the key string a
|
|
2
|
+
// press makes when someone sets a key ("press a key to change").
|
|
3
|
+
import { isModifierCode, parseChord } from './chord.js';
|
|
4
|
+
/** The platform of this browser, by its own report; `other` outside a browser. */
|
|
5
|
+
export function shortcutPlatform() {
|
|
6
|
+
const nav = globalThis.navigator;
|
|
7
|
+
const name = nav?.userAgentData?.platform ?? nav?.platform ?? '';
|
|
8
|
+
return /mac|iphone|ipad|ipod/i.test(name) ? 'apple' : 'other';
|
|
9
|
+
}
|
|
10
|
+
const NAMED = {
|
|
11
|
+
Space: 'Space', Enter: 'Enter', Escape: 'Esc', Tab: 'Tab', Backspace: 'Backspace', Delete: 'Delete',
|
|
12
|
+
Insert: 'Insert', Home: 'Home', End: 'End', PageUp: 'Page up', PageDown: 'Page down',
|
|
13
|
+
ArrowUp: '↑', ArrowDown: '↓', ArrowLeft: '←', ArrowRight: '→',
|
|
14
|
+
Minus: '-', Equal: '=', BracketLeft: '[', BracketRight: ']', Backslash: '\\', Semicolon: ';', Quote: "'",
|
|
15
|
+
Backquote: '`', Comma: ',', Period: '.', Slash: '/', IntlBackslash: '§',
|
|
16
|
+
NumpadAdd: 'Num +', NumpadSubtract: 'Num -', NumpadMultiply: 'Num *', NumpadDivide: 'Num /',
|
|
17
|
+
NumpadDecimal: 'Num .', NumpadEnter: 'Num Enter',
|
|
18
|
+
};
|
|
19
|
+
/** What a key place is called on a keyboard: `KeyQ` → `Q`, `Digit1` → `1`, `Numpad4` → `Num 4`. */
|
|
20
|
+
export function keyLabel(code, layout) {
|
|
21
|
+
const printed = layout?.get(code);
|
|
22
|
+
if (printed && printed.trim())
|
|
23
|
+
return printed.length === 1 ? printed.toUpperCase() : printed;
|
|
24
|
+
if (/^Key[A-Z]$/.test(code))
|
|
25
|
+
return code.slice(3);
|
|
26
|
+
if (/^Digit\d$/.test(code))
|
|
27
|
+
return code.slice(5);
|
|
28
|
+
if (/^Numpad\d$/.test(code))
|
|
29
|
+
return `Num ${code.slice(6)}`;
|
|
30
|
+
return NAMED[code] ?? code;
|
|
31
|
+
}
|
|
32
|
+
const SYMBOLS = { ctrl: '⌃', alt: '⌥', shift: '⇧', meta: '⌘' };
|
|
33
|
+
const WORDS = { ctrl: 'Ctrl', alt: 'Alt', shift: 'Shift', meta: 'Meta' };
|
|
34
|
+
/**
|
|
35
|
+
* The parts a chord is shown as, one per key to press: modifiers first, in the order the platform
|
|
36
|
+
* writes them, then the key. A modifier the chord merely allows (`Shift?`) is not shown — it is not
|
|
37
|
+
* a key anyone has to press. `null` for a key string that names no chord.
|
|
38
|
+
*/
|
|
39
|
+
export function chordParts(keys, platform = shortcutPlatform(), layout) {
|
|
40
|
+
// A sequence: each key's parts, one after the other (`G`, `H`).
|
|
41
|
+
if (typeof keys === 'string' && keys.includes(' ')) {
|
|
42
|
+
const steps = keys.split(' ').map((step) => (step.includes(' ') ? null : chordParts(step, platform, layout)));
|
|
43
|
+
return steps.length === 2 && steps.every(Boolean) ? steps.flat() : null;
|
|
44
|
+
}
|
|
45
|
+
const chord = parseChord(keys);
|
|
46
|
+
if (!chord)
|
|
47
|
+
return null;
|
|
48
|
+
const held = (rule) => rule === 'held';
|
|
49
|
+
const ctrl = held(chord.ctrl) || (held(chord.mod) && platform === 'other');
|
|
50
|
+
const meta = held(chord.meta) || (held(chord.mod) && platform === 'apple');
|
|
51
|
+
const names = platform === 'apple' ? SYMBOLS : WORDS;
|
|
52
|
+
// Apple's order is ⌃⌥⇧⌘; elsewhere Ctrl, Alt, Shift (and the rare Meta last).
|
|
53
|
+
const parts = [];
|
|
54
|
+
if (ctrl)
|
|
55
|
+
parts.push(names.ctrl);
|
|
56
|
+
if (held(chord.alt))
|
|
57
|
+
parts.push(platform === 'apple' ? names.alt : 'Alt');
|
|
58
|
+
if (held(chord.shift))
|
|
59
|
+
parts.push(names.shift);
|
|
60
|
+
if (meta)
|
|
61
|
+
parts.push(names.meta);
|
|
62
|
+
// The help key is `?` wherever it lets Shift be held (its place is `/`): show what people press.
|
|
63
|
+
parts.push(chord.code === 'Slash' && chord.shift === 'any' && !layout ? '?' : keyLabel(chord.code, layout));
|
|
64
|
+
return parts;
|
|
65
|
+
}
|
|
66
|
+
/** One line for a chord: `⌘⇧Z` on Apple keyboards, `Ctrl+Shift+Z` elsewhere. */
|
|
67
|
+
export function formatChord(keys, platform = shortcutPlatform(), layout) {
|
|
68
|
+
if (keys.includes(' '))
|
|
69
|
+
return keys.split(' ').map((step) => formatChord(step, platform, layout)).join(' ');
|
|
70
|
+
const parts = chordParts(keys, platform, layout);
|
|
71
|
+
if (!parts)
|
|
72
|
+
return keys;
|
|
73
|
+
return parts.join(platform === 'apple' ? '' : '+');
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* The key string a press makes, for someone setting a key: exactly the modifiers held, the command
|
|
77
|
+
* key of their platform written `Mod` (⌘ on Apple, Ctrl elsewhere) so the key works on both. `null`
|
|
78
|
+
* while only modifiers are down, or for a press with no key place.
|
|
79
|
+
*/
|
|
80
|
+
export function chordFromEvent(event, platform = shortcutPlatform()) {
|
|
81
|
+
if (!event.code || isModifierCode(event.code))
|
|
82
|
+
return null;
|
|
83
|
+
const parts = [];
|
|
84
|
+
// ⌘ (Ctrl elsewhere) is written Mod so the key works on both — unless Ctrl and ⌘ are both held,
|
|
85
|
+
// which Mod cannot say: then both by name.
|
|
86
|
+
if (event.ctrlKey && event.metaKey)
|
|
87
|
+
parts.push('Ctrl', 'Meta');
|
|
88
|
+
else if (platform === 'apple' ? event.metaKey : event.ctrlKey)
|
|
89
|
+
parts.push('Mod');
|
|
90
|
+
else if (event.ctrlKey)
|
|
91
|
+
parts.push('Ctrl');
|
|
92
|
+
else if (event.metaKey)
|
|
93
|
+
parts.push('Meta');
|
|
94
|
+
if (event.altKey)
|
|
95
|
+
parts.push('Alt');
|
|
96
|
+
if (event.shiftKey)
|
|
97
|
+
parts.push('Shift');
|
|
98
|
+
parts.push(event.code);
|
|
99
|
+
const keys = parts.join('+');
|
|
100
|
+
return parseChord(keys) ? keys : null;
|
|
101
|
+
}
|
|
102
|
+
const sameModifiers = (a, b) => a.shift === b.shift && a.alt === b.alt && a.ctrl === b.ctrl && a.meta === b.meta && a.mod === b.mod;
|
|
103
|
+
/**
|
|
104
|
+
* The keys of several actions shown as one ("1 2 3", "Q W E", "⌥ 0–9"): when each has exactly one
|
|
105
|
+
* key and they differ only in the last key, the shared modifiers once and then the keys. Otherwise
|
|
106
|
+
* every key of every action, as it is. Read from the keys in force, so a person's own keys show.
|
|
107
|
+
*/
|
|
108
|
+
export function mergedKeyParts(keysPerAction, platform = shortcutPlatform(), layout) {
|
|
109
|
+
const chords = keysPerAction.map((keys) => (keys.length === 1 ? parseChord(keys[0]) : null));
|
|
110
|
+
const first = chords[0];
|
|
111
|
+
if (keysPerAction.length > 1 && first && chords.every((c) => c && sameModifiers(c, first))) {
|
|
112
|
+
const modifiers = (chordParts(keysPerAction[0][0], platform, layout) ?? []).slice(0, -1);
|
|
113
|
+
const labels = chords.map((c) => keyLabel(c.code, layout));
|
|
114
|
+
const digits = labels.join('');
|
|
115
|
+
// The whole number row, in the row's order: one range.
|
|
116
|
+
const tail = digits === '1234567890' ? ['0–9'] : [labels.join(' ')];
|
|
117
|
+
return [[...modifiers, ...tail]];
|
|
118
|
+
}
|
|
119
|
+
return keysPerAction.flatMap((keys) => keys.map((k) => chordParts(k, platform, layout) ?? [k]));
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* A key someone just pressed for an action, as it is stored: when every default key of the action
|
|
123
|
+
* lets Shift be held (`Shift?`, the play keys that work while selecting several Orbiters) and the
|
|
124
|
+
* press had no Shift, the new key lets it be held too, so the action keeps working the same way.
|
|
125
|
+
*/
|
|
126
|
+
export function recordedKeys(defaultKeys, pressed) {
|
|
127
|
+
const chord = parseChord(pressed);
|
|
128
|
+
if (!chord || chord.shift !== 'free' || defaultKeys.length === 0)
|
|
129
|
+
return pressed;
|
|
130
|
+
const shiftFree = defaultKeys.every((keys) => parseChord(keys)?.shift === 'any');
|
|
131
|
+
return shiftFree ? `Shift?+${pressed}` : pressed;
|
|
132
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { parseChord, parseKeys, keysOverlap, chordMatches, chordsOverlap, isCommandChord, type Chord, type ModifierRule, type ModifierState, } from './chord.js';
|
|
2
|
+
export { matchShortcut, createShortcutMatcher, SEQUENCE_WAIT_MS, resolveBindings, findClashes, claimKeys, isKeyClaimed, isTypingTarget, type ShortcutAction, type ShortcutNames, type ShortcutBindings, type ShortcutOverrides, type ShortcutEvent, } from './match.js';
|
|
3
|
+
export { shortcutPlatform, keyLabel, chordParts, formatChord, chordFromEvent, mergedKeyParts, recordedKeys, type ShortcutPlatform, type KeyLayout, } from './format.js';
|
|
4
|
+
export { EMPTY_CATALOGUE, parseCatalogue, localName, localAppName, localPartName, shortcutApps, sectionActions, findSection, type ShortcutCatalogue, type ShortcutSection, type ShortcutNamed, type ShortcutSheetColumn, type ShortcutSheetRow, } from './catalogue.js';
|
|
5
|
+
export { createShortcutSource, localShortcutCatalogueStore, CATALOGUE_STORAGE_KEY, parseOverrides, type ShortcutCatalogueStore, type ShortcutSource, type ReadAccountSettings, type ReadShortcutCatalogue, } from './source.js';
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Keyboard shortcuts, one system for every app: the reader of the shortcut catalogue (every app's
|
|
2
|
+
// actions and default keys, served by the backend) and of a person's own keys, and one matcher that
|
|
3
|
+
// says which action a key press is. Keys are named by their place on the keyboard
|
|
4
|
+
// (`KeyboardEvent.code`). Framework-free: no React, no DOM beyond reading a key event.
|
|
5
|
+
export { parseChord, parseKeys, keysOverlap, chordMatches, chordsOverlap, isCommandChord, } from './chord.js';
|
|
6
|
+
export { matchShortcut, createShortcutMatcher, SEQUENCE_WAIT_MS, resolveBindings, findClashes, claimKeys, isKeyClaimed, isTypingTarget, } from './match.js';
|
|
7
|
+
export { shortcutPlatform, keyLabel, chordParts, formatChord, chordFromEvent, mergedKeyParts, recordedKeys, } from './format.js';
|
|
8
|
+
export { EMPTY_CATALOGUE, parseCatalogue, localName, localAppName, localPartName, shortcutApps, sectionActions, findSection, } from './catalogue.js';
|
|
9
|
+
export { createShortcutSource, localShortcutCatalogueStore, CATALOGUE_STORAGE_KEY, parseOverrides, } from './source.js';
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { type ModifierState } from './chord.js';
|
|
2
|
+
/** One thing a key can do, as the shortcut catalogue (served by the backend) lists it. */
|
|
3
|
+
export interface ShortcutAction<Scope extends string = string> {
|
|
4
|
+
/** `<app>.<area>.<action>`, e.g. `orbiters.play.panel.jamming`: the key a person's override is stored under. */
|
|
5
|
+
readonly id: string;
|
|
6
|
+
/** What it does, in sentence case, in English. */
|
|
7
|
+
readonly label: string;
|
|
8
|
+
/** Its name in other languages, by locale (`{ es: '…' }`); see `localName`. */
|
|
9
|
+
readonly names?: ShortcutNames;
|
|
10
|
+
/** The keys it has until the person changes them (chord strings, see `chord.ts`). */
|
|
11
|
+
readonly defaultKeys: readonly string[];
|
|
12
|
+
/** What it acts on, in the app's own terms (Orbiters: one deck, or this machine). */
|
|
13
|
+
readonly scope: Scope;
|
|
14
|
+
/** Where a command menu lists it (`panels`, `snapshots`); unset, under the section itself. */
|
|
15
|
+
readonly group?: string;
|
|
16
|
+
/** `false`: a key only, never a command to pick from a list (a key held down, the menu itself). */
|
|
17
|
+
readonly command?: boolean;
|
|
18
|
+
/** A site action: the page it opens (`{username}` stands for the signed-in person's). */
|
|
19
|
+
readonly href?: string;
|
|
20
|
+
/** It needs someone signed in. */
|
|
21
|
+
readonly signedIn?: boolean;
|
|
22
|
+
}
|
|
23
|
+
/** A name in other languages than English, by locale (`es`). */
|
|
24
|
+
export type ShortcutNames = Readonly<Record<string, string>>;
|
|
25
|
+
/** The keys of each action, in the order they are tried: the first action a press matches is the one. */
|
|
26
|
+
export type ShortcutBindings = Iterable<readonly [actionId: string, keys: readonly string[]]>;
|
|
27
|
+
/**
|
|
28
|
+
* A person's changes, as their settings store them: an action's own keys (`[]` = no key). An action
|
|
29
|
+
* left out keeps its defaults, so a default changed in the catalogue reaches everyone who never touched it.
|
|
30
|
+
*/
|
|
31
|
+
export type ShortcutOverrides = ReadonlyArray<{
|
|
32
|
+
readonly id: string;
|
|
33
|
+
readonly keys: readonly string[];
|
|
34
|
+
}>;
|
|
35
|
+
/** The key press as the matcher reads it: a `KeyboardEvent` has all of it. */
|
|
36
|
+
export interface ShortcutEvent extends ModifierState {
|
|
37
|
+
readonly code: string;
|
|
38
|
+
readonly key: string;
|
|
39
|
+
readonly target?: unknown;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Every action's keys: its defaults, or the person's override (unknown actions and keys that name no
|
|
43
|
+
* chord are dropped). Catalogue order is kept, so the earlier catalogue wins where two match one press.
|
|
44
|
+
*/
|
|
45
|
+
export declare function resolveBindings(catalogue: Iterable<ShortcutAction>, overrides?: ShortcutOverrides): Map<string, readonly string[]>;
|
|
46
|
+
/** Two actions one press could be: `[first, second, the key of the first that clashes]`. */
|
|
47
|
+
export declare function findClashes(bindings: ShortcutBindings): Array<readonly [string, string, string]>;
|
|
48
|
+
/**
|
|
49
|
+
* Take letters away from the shortcuts while a surface is on screen, until the returned release is
|
|
50
|
+
* called. An on-screen keyboard plays from the computer keyboard, and a note key that also switched a
|
|
51
|
+
* panel would take the keyboard away mid-phrase. Keys are `event.key` values, lower-cased (the
|
|
52
|
+
* character, as the keyboard surface reads them).
|
|
53
|
+
*/
|
|
54
|
+
export declare function claimKeys(keys: Iterable<string>): () => void;
|
|
55
|
+
/** Whether a surface holds this key. A key with Ctrl, ⌘ or Option is never claimed (⌘Z stays undo). */
|
|
56
|
+
export declare function isKeyClaimed(event: Pick<ShortcutEvent, 'key' | 'metaKey' | 'ctrlKey' | 'altKey'>): boolean;
|
|
57
|
+
/** A text field: every key typed there is the field's, ⌘Z included (it undoes the typing). */
|
|
58
|
+
export declare function isTypingTarget(target: unknown): boolean;
|
|
59
|
+
/**
|
|
60
|
+
* The action this press is, or `null`. Not one while the person is typing in a field, nor a key a
|
|
61
|
+
* surface holds; a widget that reads characters keeps its characters but not the ⌘ commands.
|
|
62
|
+
*/
|
|
63
|
+
export declare function matchShortcut(event: ShortcutEvent, bindings: ShortcutBindings): string | null;
|
|
64
|
+
/** How long the second key of a sequence (`G`, then `H`) may follow the first. */
|
|
65
|
+
export declare const SEQUENCE_WAIT_MS = 1500;
|
|
66
|
+
/**
|
|
67
|
+
* A matcher that also knows sequences: it remembers the first key of one for a moment and answers
|
|
68
|
+
* the action when the second follows. One per listener (it holds that moment). A first key answers
|
|
69
|
+
* nothing; any other key, a typed one or a wait past `waitMs` forgets it.
|
|
70
|
+
*/
|
|
71
|
+
export declare function createShortcutMatcher(waitMs?: number, now?: () => number): (event: ShortcutEvent, bindings: ShortcutBindings) => string | null;
|