entangled-worlds-orbiters-shared 4.1.0 → 4.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -0
- package/dist/shortcuts/chord.d.ts +35 -0
- package/dist/shortcuts/chord.js +99 -0
- package/dist/shortcuts/entangledWorlds.d.ts +21 -0
- package/dist/shortcuts/entangledWorlds.js +40 -0
- package/dist/shortcuts/format.d.ts +44 -0
- package/dist/shortcuts/format.js +132 -0
- package/dist/shortcuts/index.d.ts +9 -0
- package/dist/shortcuts/index.js +13 -0
- package/dist/shortcuts/labels.d.ts +10 -0
- package/dist/shortcuts/labels.js +130 -0
- package/dist/shortcuts/match.d.ts +65 -0
- package/dist/shortcuts/match.js +145 -0
- package/dist/shortcuts/orbiters.d.ts +55 -0
- package/dist/shortcuts/orbiters.js +109 -0
- package/dist/shortcuts/overrides.d.ts +30 -0
- package/dist/shortcuts/overrides.js +81 -0
- package/dist/shortcuts/sections.d.ts +23 -0
- package/dist/shortcuts/sections.js +19 -0
- package/dist/shortcuts/site.d.ts +9 -0
- package/dist/shortcuts/site.js +25 -0
- package/package.json +5 -1
- package/src/shortcuts/chord.ts +122 -0
- package/src/shortcuts/entangledWorlds.ts +51 -0
- package/src/shortcuts/format.ts +143 -0
- package/src/shortcuts/index.ts +69 -0
- package/src/shortcuts/labels.ts +139 -0
- package/src/shortcuts/match.ts +195 -0
- package/src/shortcuts/orbiters.ts +132 -0
- package/src/shortcuts/overrides.ts +110 -0
- package/src/shortcuts/sections.ts +36 -0
- package/src/shortcuts/site.ts +33 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// Which action a key press is. Each app keeps what its actions DO; this only answers which one a
|
|
2
|
+
// press names, so every app reads keys the same way: the modifiers, a field being typed in, and a
|
|
3
|
+
// surface that holds letters of its own are decided here, once.
|
|
4
|
+
import { chordMatches, isCommandChord, isModifierCode, keysOverlap, parseChord, parseKeys } from './chord.js';
|
|
5
|
+
/**
|
|
6
|
+
* Every action's keys: its defaults, or the person's override (unknown actions and keys that name no
|
|
7
|
+
* chord are dropped). Catalogue order is kept, so the earlier catalogue wins where two match one press.
|
|
8
|
+
*/
|
|
9
|
+
export function resolveBindings(catalogue, overrides = []) {
|
|
10
|
+
const own = new Map();
|
|
11
|
+
for (const entry of Array.isArray(overrides) ? overrides : []) {
|
|
12
|
+
if (entry && typeof entry.id === 'string' && Array.isArray(entry.keys))
|
|
13
|
+
own.set(entry.id, entry.keys);
|
|
14
|
+
}
|
|
15
|
+
const bindings = new Map();
|
|
16
|
+
for (const action of catalogue) {
|
|
17
|
+
const keys = own.get(action.id) ?? action.defaultKeys;
|
|
18
|
+
bindings.set(action.id, keys.filter((key) => typeof key === 'string' && parseKeys(key) !== null));
|
|
19
|
+
}
|
|
20
|
+
return bindings;
|
|
21
|
+
}
|
|
22
|
+
/** Two actions one press could be: `[first, second, the key of the first that clashes]`. */
|
|
23
|
+
export function findClashes(bindings) {
|
|
24
|
+
const entries = [...bindings];
|
|
25
|
+
const clashes = [];
|
|
26
|
+
entries.forEach(([a, aKeys], i) => {
|
|
27
|
+
for (const [b, bKeys] of entries.slice(i + 1)) {
|
|
28
|
+
const key = aKeys.find((ak) => bKeys.some((bk) => keysOverlap(ak, bk)));
|
|
29
|
+
if (key !== undefined)
|
|
30
|
+
clashes.push([a, b, key]);
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
return clashes;
|
|
34
|
+
}
|
|
35
|
+
// ---------------------------------------------------------------------------------------------
|
|
36
|
+
// Surface claims
|
|
37
|
+
const claims = new Set();
|
|
38
|
+
/**
|
|
39
|
+
* Take letters away from the shortcuts while a surface is on screen, until the returned release is
|
|
40
|
+
* called. An on-screen keyboard plays from the computer keyboard, and a note key that also switched a
|
|
41
|
+
* panel would take the keyboard away mid-phrase. Keys are `event.key` values, lower-cased (the
|
|
42
|
+
* character, as the keyboard surface reads them).
|
|
43
|
+
*/
|
|
44
|
+
export function claimKeys(keys) {
|
|
45
|
+
const claim = new Set([...keys].map((key) => key.toLowerCase()));
|
|
46
|
+
claims.add(claim);
|
|
47
|
+
return () => {
|
|
48
|
+
claims.delete(claim);
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
/** Whether a surface holds this key. A key with Ctrl, ⌘ or Option is never claimed (⌘Z stays undo). */
|
|
52
|
+
export function isKeyClaimed(event) {
|
|
53
|
+
if (!claims.size || typeof event.key !== 'string' || event.metaKey || event.ctrlKey || event.altKey)
|
|
54
|
+
return false;
|
|
55
|
+
const key = event.key.toLowerCase();
|
|
56
|
+
for (const claim of claims)
|
|
57
|
+
if (claim.has(key))
|
|
58
|
+
return true;
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
const asTarget = (target) => target !== null && typeof target === 'object' ? target : null;
|
|
62
|
+
const roleOf = (el) => (typeof el.getAttribute === 'function' ? el.getAttribute('role') : null);
|
|
63
|
+
/** Inputs that take no typing: a slider keeps the shortcuts working while it holds focus. */
|
|
64
|
+
const UNTYPED_INPUTS = new Set(['range', 'color', 'file']);
|
|
65
|
+
/** A text field: every key typed there is the field's, ⌘Z included (it undoes the typing). */
|
|
66
|
+
export function isTypingTarget(target) {
|
|
67
|
+
const el = asTarget(target);
|
|
68
|
+
if (!el)
|
|
69
|
+
return false;
|
|
70
|
+
if (el.isContentEditable === true || roleOf(el) === 'textbox')
|
|
71
|
+
return true;
|
|
72
|
+
const tag = typeof el.tagName === 'string' ? el.tagName.toUpperCase() : '';
|
|
73
|
+
if (tag === 'INPUT')
|
|
74
|
+
return !UNTYPED_INPUTS.has(typeof el.type === 'string' ? el.type.toLowerCase() : '');
|
|
75
|
+
return tag === 'TEXTAREA' || tag === 'SELECT';
|
|
76
|
+
}
|
|
77
|
+
/** A widget that reads typed characters (a combobox's type-to-find), though it is no text field. */
|
|
78
|
+
function readsCharacters(target) {
|
|
79
|
+
const el = asTarget(target);
|
|
80
|
+
return el !== null && roleOf(el) === 'combobox';
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* The action this press is, or `null`. Not one while the person is typing in a field, nor a key a
|
|
84
|
+
* surface holds; a widget that reads characters keeps its characters but not the ⌘ commands.
|
|
85
|
+
*/
|
|
86
|
+
export function matchShortcut(event, bindings) {
|
|
87
|
+
if (!event.code || isTypingTarget(event.target) || isKeyClaimed(event))
|
|
88
|
+
return null;
|
|
89
|
+
const characters = readsCharacters(event.target);
|
|
90
|
+
for (const [actionId, keys] of bindings) {
|
|
91
|
+
for (const key of keys) {
|
|
92
|
+
const chord = parseChord(key);
|
|
93
|
+
if (!chord || !chordMatches(chord, event.code, event))
|
|
94
|
+
continue;
|
|
95
|
+
if (characters && !isCommandChord(chord))
|
|
96
|
+
return null;
|
|
97
|
+
return actionId;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
// ---------------------------------------------------------------------------------------------
|
|
103
|
+
// Sequences
|
|
104
|
+
/** How long the second key of a sequence (`G`, then `H`) may follow the first. */
|
|
105
|
+
export const SEQUENCE_WAIT_MS = 1500;
|
|
106
|
+
// The sequences of a set of bindings, read once per bindings object (they change with the overrides).
|
|
107
|
+
const sequencesOf = new WeakMap();
|
|
108
|
+
function sequences(bindings) {
|
|
109
|
+
const known = typeof bindings === 'object' ? sequencesOf.get(bindings) : undefined;
|
|
110
|
+
if (known)
|
|
111
|
+
return known;
|
|
112
|
+
const found = [...bindings].flatMap(([id, keys]) => keys.map((k) => [id, parseKeys(k)]).filter((entry) => entry[1]?.length === 2));
|
|
113
|
+
if (typeof bindings === 'object')
|
|
114
|
+
sequencesOf.set(bindings, found);
|
|
115
|
+
return found;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* A matcher that also knows sequences: it remembers the first key of one for a moment and answers
|
|
119
|
+
* the action when the second follows. One per listener (it holds that moment). A first key answers
|
|
120
|
+
* nothing; any other key, a typed one or a wait past `waitMs` forgets it.
|
|
121
|
+
*/
|
|
122
|
+
export function createShortcutMatcher(waitMs = SEQUENCE_WAIT_MS, now = Date.now) {
|
|
123
|
+
let first = null;
|
|
124
|
+
return function match(event, bindings) {
|
|
125
|
+
if (event.code && isModifierCode(event.code))
|
|
126
|
+
return null;
|
|
127
|
+
const pending = first && now() - first.at <= waitMs ? first : null;
|
|
128
|
+
first = null;
|
|
129
|
+
if (!event.code || isTypingTarget(event.target) || isKeyClaimed(event))
|
|
130
|
+
return null;
|
|
131
|
+
// A widget that reads characters keeps them: no sequence starts or ends there.
|
|
132
|
+
const characters = readsCharacters(event.target);
|
|
133
|
+
const known = characters ? [] : sequences(bindings);
|
|
134
|
+
if (pending) {
|
|
135
|
+
for (const [id, [one, two]] of known)
|
|
136
|
+
if (chordMatches(one, pending.code, pending) && chordMatches(two, event.code, event))
|
|
137
|
+
return id;
|
|
138
|
+
}
|
|
139
|
+
if (known.some(([, [one]]) => chordMatches(one, event.code, event))) {
|
|
140
|
+
first = { code: event.code, shiftKey: event.shiftKey, altKey: event.altKey, ctrlKey: event.ctrlKey, metaKey: event.metaKey, at: now() };
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
return matchShortcut(event, bindings);
|
|
144
|
+
};
|
|
145
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { ShortcutAction } from './match.js';
|
|
2
|
+
export type OrbitersShortcutScope = 'voice' | 'client';
|
|
3
|
+
export type OrbitersShortcut = ShortcutAction<OrbitersShortcutScope>;
|
|
4
|
+
export declare const ORBITERS_PLAY_SECTION = "orbiters-play";
|
|
5
|
+
export declare const ORBITERS_STUDIO_SECTION = "orbiters-studio";
|
|
6
|
+
/** How a command menu groups the actions, in its order. */
|
|
7
|
+
export declare const ORBITERS_COMMAND_GROUPS: readonly [{
|
|
8
|
+
readonly id: "transport";
|
|
9
|
+
readonly label: "Transport";
|
|
10
|
+
}, {
|
|
11
|
+
readonly id: "view";
|
|
12
|
+
readonly label: "View";
|
|
13
|
+
}, {
|
|
14
|
+
readonly id: "snapshots";
|
|
15
|
+
readonly label: "Snapshots";
|
|
16
|
+
}, {
|
|
17
|
+
readonly id: "panels";
|
|
18
|
+
readonly label: "Panels";
|
|
19
|
+
}, {
|
|
20
|
+
readonly id: "orbiter";
|
|
21
|
+
readonly label: "Orbiter";
|
|
22
|
+
}, {
|
|
23
|
+
readonly id: "studio";
|
|
24
|
+
readonly label: "Studio";
|
|
25
|
+
}];
|
|
26
|
+
/**
|
|
27
|
+
* Playing an Orbiter. Shift is free on most keys (`Shift?`): it is held to select several decks,
|
|
28
|
+
* and a key pressed while selecting means what it means alone.
|
|
29
|
+
*/
|
|
30
|
+
export declare const ORBITERS_PLAY_SHORTCUTS: readonly OrbitersShortcut[];
|
|
31
|
+
/**
|
|
32
|
+
* The keys sheet (`?`): what to look up at a glance, in three columns. A row may stand for several
|
|
33
|
+
* actions whose keys differ only in the last key ("Dimension I · II · III — 1 2 3"); the keys
|
|
34
|
+
* shown are always this person's (`mergedKeyParts`).
|
|
35
|
+
*/
|
|
36
|
+
export declare const ORBITERS_PLAY_SHEET: readonly Readonly<{
|
|
37
|
+
rows: readonly Readonly<{
|
|
38
|
+
actions: readonly string[];
|
|
39
|
+
id: string;
|
|
40
|
+
label: string;
|
|
41
|
+
}>[];
|
|
42
|
+
id: string;
|
|
43
|
+
label: string;
|
|
44
|
+
}>[];
|
|
45
|
+
/**
|
|
46
|
+
* The Studio. It is open over play, so the play keys above still work there; where both name a key,
|
|
47
|
+
* the Studio's action is the one.
|
|
48
|
+
*/
|
|
49
|
+
export declare const ORBITERS_STUDIO_SHORTCUTS: readonly OrbitersShortcut[];
|
|
50
|
+
/**
|
|
51
|
+
* The letters the on-screen keyboard plays while it is open (`event.key`, lower-case): the piano row,
|
|
52
|
+
* then Z and X for the octave and C and V for velocity. While it is up they are its notes, and the
|
|
53
|
+
* shortcuts on them wait (`claimKeys`); a settings page says so beside those keys.
|
|
54
|
+
*/
|
|
55
|
+
export declare const ORBITERS_KEYBOARD_SURFACE_KEYS: readonly string[];
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// The Orbiters catalogues: every key Orbiters answers, with the keys it has by default.
|
|
2
|
+
//
|
|
3
|
+
// `voice` acts on the focused deck(s), so in a shared session it only reaches a deck this person
|
|
4
|
+
// holds; `client` is about this machine and no deck (MIDI learn, performance mode, the Studio).
|
|
5
|
+
export const ORBITERS_PLAY_SECTION = 'orbiters-play';
|
|
6
|
+
export const ORBITERS_STUDIO_SECTION = 'orbiters-studio';
|
|
7
|
+
const play = (id, label, defaultKeys, { scope = 'voice', group, command } = {}) => Object.freeze({
|
|
8
|
+
id: `orbiters.play.${id}`, section: ORBITERS_PLAY_SECTION, label, defaultKeys: Object.freeze([...defaultKeys]), scope,
|
|
9
|
+
...(group ? { group } : {}), ...(command === false ? { command } : {}),
|
|
10
|
+
});
|
|
11
|
+
const studio = (id, label, defaultKeys) => Object.freeze({ id: `orbiters.studio.${id}`, section: ORBITERS_STUDIO_SECTION, label, defaultKeys: Object.freeze([...defaultKeys]), scope: 'client', group: 'studio' });
|
|
12
|
+
/** How a command menu groups the actions, in its order. */
|
|
13
|
+
export const ORBITERS_COMMAND_GROUPS = Object.freeze([
|
|
14
|
+
{ id: 'transport', label: 'Transport' },
|
|
15
|
+
{ id: 'view', label: 'View' },
|
|
16
|
+
{ id: 'snapshots', label: 'Snapshots' },
|
|
17
|
+
{ id: 'panels', label: 'Panels' },
|
|
18
|
+
{ id: 'orbiter', label: 'Orbiter' },
|
|
19
|
+
{ id: 'studio', label: 'Studio' },
|
|
20
|
+
]);
|
|
21
|
+
/** The snapshot slots, by the number each shows: the number row, 1 to 9, then 0. */
|
|
22
|
+
const SLOT_NUMBERS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 0];
|
|
23
|
+
/** Undo and redo, the same keys wherever an edit can be taken back. */
|
|
24
|
+
const UNDO_KEYS = ['Mod+KeyZ'];
|
|
25
|
+
const REDO_KEYS = ['Mod+Shift+KeyZ', 'Ctrl+Shift?+KeyY'];
|
|
26
|
+
/**
|
|
27
|
+
* Playing an Orbiter. Shift is free on most keys (`Shift?`): it is held to select several decks,
|
|
28
|
+
* and a key pressed while selecting means what it means alone.
|
|
29
|
+
*/
|
|
30
|
+
export const ORBITERS_PLAY_SHORTCUTS = Object.freeze([
|
|
31
|
+
// Space takes Ctrl, Option and ⌘ as it comes: only Shift changes what it does.
|
|
32
|
+
play('transport.toggle', 'Play or pause', ['Alt?+Ctrl?+Meta?+Space'], { group: 'transport' }),
|
|
33
|
+
play('transport.restart', 'Play from the beginning', ['Shift+Alt?+Ctrl?+Meta?+Space'], { group: 'transport' }),
|
|
34
|
+
play('rec.hold', 'Record while held', ['KeyR'], { group: 'transport', command: false }),
|
|
35
|
+
play('rec.toggle', 'Rec on or off', ['Shift+KeyR'], { group: 'transport' }),
|
|
36
|
+
play('history.undo', 'Undo', UNDO_KEYS, { group: 'transport' }),
|
|
37
|
+
play('history.redo', 'Redo', REDO_KEYS, { group: 'transport' }),
|
|
38
|
+
play('dimension.1', 'First dimension', ['Shift?+Digit1'], { group: 'view' }),
|
|
39
|
+
play('dimension.2', 'Second dimension', ['Shift?+Digit2'], { group: 'view' }),
|
|
40
|
+
play('dimension.3', 'Third dimension', ['Shift?+Digit3'], { group: 'view' }),
|
|
41
|
+
play('body.world', 'World', ['Shift?+KeyQ'], { group: 'view' }),
|
|
42
|
+
play('body.moon', 'Moon', ['Shift?+KeyW'], { group: 'view' }),
|
|
43
|
+
play('body.star', 'Star', ['Shift?+KeyE'], { group: 'view' }),
|
|
44
|
+
...SLOT_NUMBERS.map((n) => play(`snapshot.recall.${n}`, `Recall snapshot ${n}`, [`Alt+Digit${n}`], { group: 'snapshots' })),
|
|
45
|
+
...SLOT_NUMBERS.map((n) => play(`snapshot.store.${n}`, `Store snapshot ${n}`, [`Alt+Shift+Digit${n}`], { group: 'snapshots' })),
|
|
46
|
+
// The panels, from the bottom of the rail up.
|
|
47
|
+
play('panel.jamming', 'Jamming', ['KeyA'], { group: 'panels' }),
|
|
48
|
+
play('panel.playback', 'Playback', ['KeyS'], { group: 'panels' }),
|
|
49
|
+
play('panel.cosmicLfo', 'Cosmic LFO', ['KeyD'], { group: 'panels' }),
|
|
50
|
+
play('panel.sensors', 'Sensors', ['KeyF'], { group: 'panels' }),
|
|
51
|
+
// The More menu, and this machine's switches.
|
|
52
|
+
play('midiLearn', 'MIDI learn', ['Shift?+KeyM'], { scope: 'client', group: 'orbiter' }),
|
|
53
|
+
play('performanceMode', 'Performance mode', ['Shift?+KeyP'], { scope: 'client', group: 'orbiter' }),
|
|
54
|
+
play('screenCapture', 'Screen capture', [], { scope: 'client', group: 'orbiter' }),
|
|
55
|
+
play('share', 'Share', [], { scope: 'client', group: 'orbiter' }),
|
|
56
|
+
play('fullscreen', 'Fullscreen', [], { scope: 'client', group: 'orbiter' }),
|
|
57
|
+
// `?` on a US keyboard: the key's place, whatever this keyboard prints there.
|
|
58
|
+
play('help', 'Keyboard shortcuts', ['Shift?+Slash'], { scope: 'client', group: 'orbiter', command: false }),
|
|
59
|
+
play('commands', 'Commands', ['Mod+KeyK'], { scope: 'client', group: 'orbiter', command: false }),
|
|
60
|
+
]);
|
|
61
|
+
/**
|
|
62
|
+
* The keys sheet (`?`): what to look up at a glance, in three columns. A row may stand for several
|
|
63
|
+
* actions whose keys differ only in the last key ("Dimension I · II · III — 1 2 3"); the keys
|
|
64
|
+
* shown are always this person's (`mergedKeyParts`).
|
|
65
|
+
*/
|
|
66
|
+
export const ORBITERS_PLAY_SHEET = Object.freeze([
|
|
67
|
+
{ id: 'transport', label: 'Transport', rows: [
|
|
68
|
+
{ id: 'playPause', label: 'Play / pause', actions: ['transport.toggle'] },
|
|
69
|
+
{ id: 'playFromStart', label: 'Play from start', actions: ['transport.restart'] },
|
|
70
|
+
{ id: 'recHeld', label: 'REC while held', actions: ['rec.hold'] },
|
|
71
|
+
{ id: 'recLatch', label: 'REC latch', actions: ['rec.toggle'] },
|
|
72
|
+
{ id: 'undo', label: 'Undo', actions: ['history.undo'] },
|
|
73
|
+
{ id: 'redo', label: 'Redo', actions: ['history.redo'] },
|
|
74
|
+
] },
|
|
75
|
+
{ id: 'view', label: 'View', rows: [
|
|
76
|
+
{ id: 'dimensions', label: 'Dimension I · II · III', actions: ['dimension.1', 'dimension.2', 'dimension.3'] },
|
|
77
|
+
{ id: 'bodies', label: 'World · Moon · Star', actions: ['body.world', 'body.moon', 'body.star'] },
|
|
78
|
+
{ id: 'snapshotRecall', label: 'Snapshot recall', actions: SLOT_NUMBERS.map((n) => `snapshot.recall.${n}`) },
|
|
79
|
+
{ id: 'snapshotStore', label: 'Snapshot store', actions: SLOT_NUMBERS.map((n) => `snapshot.store.${n}`) },
|
|
80
|
+
] },
|
|
81
|
+
{ id: 'panels', label: 'Panels', rows: [
|
|
82
|
+
{ id: 'jamming', label: 'Jamming', actions: ['panel.jamming'] },
|
|
83
|
+
{ id: 'playback', label: 'Playback', actions: ['panel.playback'] },
|
|
84
|
+
{ id: 'cosmicLfo', label: 'Cosmic LFO', actions: ['panel.cosmicLfo'] },
|
|
85
|
+
{ id: 'sensors', label: 'Sensors', actions: ['panel.sensors'] },
|
|
86
|
+
{ id: 'midiLearn', label: 'MIDI learn', actions: ['midiLearn'] },
|
|
87
|
+
{ id: 'performanceMode', label: 'Performance mode', actions: ['performanceMode'] },
|
|
88
|
+
] },
|
|
89
|
+
].map((column) => Object.freeze({
|
|
90
|
+
...column,
|
|
91
|
+
rows: Object.freeze(column.rows.map((row) => Object.freeze({ ...row, actions: Object.freeze(row.actions.map((a) => `orbiters.play.${a}`)) }))),
|
|
92
|
+
})));
|
|
93
|
+
/**
|
|
94
|
+
* The Studio. It is open over play, so the play keys above still work there; where both name a key,
|
|
95
|
+
* the Studio's action is the one.
|
|
96
|
+
*/
|
|
97
|
+
export const ORBITERS_STUDIO_SHORTCUTS = Object.freeze([
|
|
98
|
+
studio('undo', 'Undo', UNDO_KEYS),
|
|
99
|
+
studio('redo', 'Redo', REDO_KEYS),
|
|
100
|
+
studio('map', 'Map', ['Shift+KeyM']),
|
|
101
|
+
]);
|
|
102
|
+
/**
|
|
103
|
+
* The letters the on-screen keyboard plays while it is open (`event.key`, lower-case): the piano row,
|
|
104
|
+
* then Z and X for the octave and C and V for velocity. While it is up they are its notes, and the
|
|
105
|
+
* shortcuts on them wait (`claimKeys`); a settings page says so beside those keys.
|
|
106
|
+
*/
|
|
107
|
+
export const ORBITERS_KEYBOARD_SURFACE_KEYS = Object.freeze([
|
|
108
|
+
'a', 'w', 's', 'e', 'd', 'f', 't', 'g', 'y', 'h', 'u', 'j', 'k', 'o', 'l', 'p', ';', "'", 'z', 'x', 'c', 'v',
|
|
109
|
+
]);
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { type ShortcutAction, type ShortcutOverrides } from './match.js';
|
|
2
|
+
/** The overrides in a stored value, or `null` when it is not a list of `{ id, keys }`. */
|
|
3
|
+
export declare function parseOverrides(value: unknown): ShortcutOverrides | null;
|
|
4
|
+
/**
|
|
5
|
+
* How the app reads the signed-in person's settings: `null` when no one is signed in (the
|
|
6
|
+
* default keys); `undefined` when the read failed (the overrides in force are kept: a blip must not
|
|
7
|
+
* swap someone's keys for the defaults).
|
|
8
|
+
*/
|
|
9
|
+
export type ReadAccountSettings = (options: {
|
|
10
|
+
fresh: boolean;
|
|
11
|
+
}) => Promise<{
|
|
12
|
+
shortcuts?: unknown;
|
|
13
|
+
} | null | undefined>;
|
|
14
|
+
export interface ShortcutOverridesSource {
|
|
15
|
+
/** The overrides in force. */
|
|
16
|
+
get(): ShortcutOverrides;
|
|
17
|
+
/** A catalogue's actions with their keys now (kept until the overrides change). */
|
|
18
|
+
bindings(catalogue: readonly ShortcutAction[]): Map<string, readonly string[]>;
|
|
19
|
+
/** Called whenever the overrides change; returns the unsubscribe. */
|
|
20
|
+
subscribe(listener: () => void): () => void;
|
|
21
|
+
/** Read again (`fresh`: past the app's own cached read). The newest read wins, whatever order they end in. */
|
|
22
|
+
refresh(options?: {
|
|
23
|
+
fresh?: boolean;
|
|
24
|
+
}): Promise<void>;
|
|
25
|
+
/** Read now, and again whenever the page comes back to the front; once per source. */
|
|
26
|
+
bind(): void;
|
|
27
|
+
/** For tests: set the overrides directly. */
|
|
28
|
+
set(overrides: ShortcutOverrides): void;
|
|
29
|
+
}
|
|
30
|
+
export declare function createShortcutOverridesSource(readAccount: ReadAccountSettings): ShortcutOverridesSource;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// A person's shortcut overrides, read the same way by every app: from their account when signed in
|
|
2
|
+
// (someone signed out has the default keys), and again whenever the page comes back to the front (a key changed
|
|
3
|
+
// on the settings page applies on return, without a reload).
|
|
4
|
+
//
|
|
5
|
+
// Framework-free: the app hands in how it reads the account (its own API client) and gets the
|
|
6
|
+
// overrides and each catalogue's resolved keys back, kept until the overrides change.
|
|
7
|
+
import { resolveBindings } from './match.js';
|
|
8
|
+
/** The overrides in a stored value, or `null` when it is not a list of `{ id, keys }`. */
|
|
9
|
+
export function parseOverrides(value) {
|
|
10
|
+
if (!Array.isArray(value))
|
|
11
|
+
return null;
|
|
12
|
+
return value.filter((entry) => !!entry && typeof entry.id === 'string' && Array.isArray(entry.keys) && entry.keys.every((k) => typeof k === 'string'));
|
|
13
|
+
}
|
|
14
|
+
/** Coming back to the front twice in a row (visible, then focus) asks the account once. */
|
|
15
|
+
const REFRESH_GAP_MS = 2000;
|
|
16
|
+
export function createShortcutOverridesSource(readAccount) {
|
|
17
|
+
let overrides = [];
|
|
18
|
+
let resolved = new WeakMap();
|
|
19
|
+
let latest = 0;
|
|
20
|
+
let applied = 0;
|
|
21
|
+
let lastRefreshAt = -Infinity;
|
|
22
|
+
let bound = false;
|
|
23
|
+
const listeners = new Set();
|
|
24
|
+
const apply = (next) => {
|
|
25
|
+
if (JSON.stringify(next) === JSON.stringify(overrides))
|
|
26
|
+
return;
|
|
27
|
+
overrides = next;
|
|
28
|
+
resolved = new WeakMap();
|
|
29
|
+
listeners.forEach((listener) => listener());
|
|
30
|
+
};
|
|
31
|
+
const refresh = async ({ fresh = false } = {}) => {
|
|
32
|
+
const ticket = ++latest;
|
|
33
|
+
lastRefreshAt = Date.now();
|
|
34
|
+
let settings;
|
|
35
|
+
try {
|
|
36
|
+
settings = await readAccount({ fresh });
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
settings = undefined;
|
|
40
|
+
}
|
|
41
|
+
// The newest read that answered wins: an older one ending after it says nothing new, and a newer
|
|
42
|
+
// one that failed does not throw away an older one's answer.
|
|
43
|
+
if (settings === undefined || ticket < applied)
|
|
44
|
+
return;
|
|
45
|
+
applied = ticket;
|
|
46
|
+
apply(settings === null ? [] : (parseOverrides(settings.shortcuts) ?? []));
|
|
47
|
+
};
|
|
48
|
+
return {
|
|
49
|
+
get: () => overrides,
|
|
50
|
+
bindings(catalogue) {
|
|
51
|
+
let map = resolved.get(catalogue);
|
|
52
|
+
if (!map) {
|
|
53
|
+
map = resolveBindings(catalogue, overrides);
|
|
54
|
+
resolved.set(catalogue, map);
|
|
55
|
+
}
|
|
56
|
+
return map;
|
|
57
|
+
},
|
|
58
|
+
subscribe(listener) {
|
|
59
|
+
listeners.add(listener);
|
|
60
|
+
return () => {
|
|
61
|
+
listeners.delete(listener);
|
|
62
|
+
};
|
|
63
|
+
},
|
|
64
|
+
refresh,
|
|
65
|
+
bind() {
|
|
66
|
+
const win = globalThis;
|
|
67
|
+
if (bound || typeof win.addEventListener !== 'function' || !win.document?.addEventListener)
|
|
68
|
+
return;
|
|
69
|
+
bound = true;
|
|
70
|
+
void refresh();
|
|
71
|
+
const onFront = () => {
|
|
72
|
+
if (win.document?.visibilityState !== 'visible' || Date.now() - lastRefreshAt < REFRESH_GAP_MS)
|
|
73
|
+
return;
|
|
74
|
+
void refresh({ fresh: true });
|
|
75
|
+
};
|
|
76
|
+
win.document.addEventListener('visibilitychange', onFront);
|
|
77
|
+
win.addEventListener('focus', onFront);
|
|
78
|
+
},
|
|
79
|
+
set: apply,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ShortcutAction } from './match.js';
|
|
2
|
+
/** The apps a person tells apart, in the order a settings page shows them. */
|
|
3
|
+
export declare const SHORTCUT_APPS: readonly [Readonly<{
|
|
4
|
+
id: "plantasia-space";
|
|
5
|
+
label: "Plantasia Space";
|
|
6
|
+
}>, Readonly<{
|
|
7
|
+
id: "orbiters";
|
|
8
|
+
label: "Orbiters";
|
|
9
|
+
}>, Readonly<{
|
|
10
|
+
id: "entangled-worlds";
|
|
11
|
+
label: "Entangled Worlds";
|
|
12
|
+
}>];
|
|
13
|
+
export type ShortcutAppId = (typeof SHORTCUT_APPS)[number]['id'];
|
|
14
|
+
export interface ShortcutSection {
|
|
15
|
+
readonly id: string;
|
|
16
|
+
readonly label: string;
|
|
17
|
+
/** The app it belongs to. */
|
|
18
|
+
readonly app: ShortcutAppId;
|
|
19
|
+
/** Its name inside that app (`Play`, `Studio`); none when the app has only this section. */
|
|
20
|
+
readonly part?: string;
|
|
21
|
+
readonly actions: readonly ShortcutAction[];
|
|
22
|
+
}
|
|
23
|
+
export declare const SHORTCUT_SECTIONS: readonly ShortcutSection[];
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Every app's catalogue in one place, in the order a settings page shows them. A section is one
|
|
2
|
+
// surface's keys; two actions may share a key only across sections (the Orbiter Studio lies over
|
|
3
|
+
// play, and takes ⌘Z from it while it is open).
|
|
4
|
+
import { EW_PLAYER_SECTION, EW_PLAYER_SHORTCUTS, EW_STUDIO_SECTION, EW_STUDIO_SHORTCUTS } from './entangledWorlds.js';
|
|
5
|
+
import { ORBITERS_PLAY_SECTION, ORBITERS_PLAY_SHORTCUTS, ORBITERS_STUDIO_SECTION, ORBITERS_STUDIO_SHORTCUTS } from './orbiters.js';
|
|
6
|
+
import { SITE_SECTION, SITE_SHORTCUTS } from './site.js';
|
|
7
|
+
/** The apps a person tells apart, in the order a settings page shows them. */
|
|
8
|
+
export const SHORTCUT_APPS = Object.freeze([
|
|
9
|
+
Object.freeze({ id: 'plantasia-space', label: 'Plantasia Space' }),
|
|
10
|
+
Object.freeze({ id: 'orbiters', label: 'Orbiters' }),
|
|
11
|
+
Object.freeze({ id: 'entangled-worlds', label: 'Entangled Worlds' }),
|
|
12
|
+
]);
|
|
13
|
+
export const SHORTCUT_SECTIONS = Object.freeze([
|
|
14
|
+
Object.freeze({ id: SITE_SECTION, label: 'Plantasia Space', app: 'plantasia-space', actions: SITE_SHORTCUTS }),
|
|
15
|
+
Object.freeze({ id: ORBITERS_PLAY_SECTION, label: 'Orbiters play', app: 'orbiters', part: 'Play', actions: ORBITERS_PLAY_SHORTCUTS }),
|
|
16
|
+
Object.freeze({ id: ORBITERS_STUDIO_SECTION, label: 'Orbiter Studio', app: 'orbiters', part: 'Studio', actions: ORBITERS_STUDIO_SHORTCUTS }),
|
|
17
|
+
Object.freeze({ id: EW_STUDIO_SECTION, label: 'Entangled Worlds studio', app: 'entangled-worlds', part: 'Studio', actions: EW_STUDIO_SHORTCUTS }),
|
|
18
|
+
Object.freeze({ id: EW_PLAYER_SECTION, label: 'Entangled Worlds player', app: 'entangled-worlds', part: 'Player', actions: EW_PLAYER_SHORTCUTS }),
|
|
19
|
+
]);
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { ShortcutAction } from './match.js';
|
|
2
|
+
export type SiteShortcut = ShortcutAction<'client'>;
|
|
3
|
+
export declare const SITE_SECTION = "site";
|
|
4
|
+
/**
|
|
5
|
+
* Two-key sequences move around the site (G, then H for Home; N, then O for a new Orbiter), as on
|
|
6
|
+
* many sites: a single letter is an Orbiter's on a page that plays one, and the browser holds most
|
|
7
|
+
* ⌘ and ⇧⌘ letters.
|
|
8
|
+
*/
|
|
9
|
+
export declare const SITE_SHORTCUTS: readonly SiteShortcut[];
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// The Plantasia Space site's own keys: the pages around the apps (home, explore, settings). They
|
|
2
|
+
// work on any page of the site, outside an Orbiter or a world.
|
|
3
|
+
export const SITE_SECTION = 'site';
|
|
4
|
+
const site = (id, label, keys) => Object.freeze({ id: `site.${id}`, section: SITE_SECTION, label, defaultKeys: Object.freeze([keys]), scope: 'client' });
|
|
5
|
+
/**
|
|
6
|
+
* Two-key sequences move around the site (G, then H for Home; N, then O for a new Orbiter), as on
|
|
7
|
+
* many sites: a single letter is an Orbiter's on a page that plays one, and the browser holds most
|
|
8
|
+
* ⌘ and ⇧⌘ letters.
|
|
9
|
+
*/
|
|
10
|
+
export const SITE_SHORTCUTS = Object.freeze([
|
|
11
|
+
site('settings', 'Open settings', 'Mod+Shift+Comma'),
|
|
12
|
+
site('commands', 'Commands', 'Mod+KeyK'),
|
|
13
|
+
site('search', 'Search', 'Slash'),
|
|
14
|
+
site('go.home', 'Home', 'KeyG KeyH'),
|
|
15
|
+
site('go.playground', 'Playground', 'KeyG KeyP'),
|
|
16
|
+
site('go.notifications', 'Notifications', 'KeyG KeyN'),
|
|
17
|
+
site('go.audios', 'My audios', 'KeyG KeyA'),
|
|
18
|
+
site('go.orbiters', 'My Orbiters', 'KeyG KeyO'),
|
|
19
|
+
site('go.worlds', 'My Entangled Worlds', 'KeyG KeyW'),
|
|
20
|
+
site('go.collections', 'My collections', 'KeyG KeyC'),
|
|
21
|
+
site('go.stars', 'My stars', 'KeyG KeyS'),
|
|
22
|
+
site('new.orbiter', 'New Orbiter', 'KeyN KeyO'),
|
|
23
|
+
site('new.audio', 'New audio', 'KeyN KeyA'),
|
|
24
|
+
site('new.world', 'New Entangled World', 'KeyN KeyW'),
|
|
25
|
+
]);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "entangled-worlds-orbiters-shared",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.2.0",
|
|
4
4
|
"description": "Shared library for entangled worlds orbiters - contains core world rendering, moons system, and UI components",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -86,6 +86,10 @@
|
|
|
86
86
|
"./audio": {
|
|
87
87
|
"types": "./dist/audio/index.d.ts",
|
|
88
88
|
"import": "./dist/audio/index.js"
|
|
89
|
+
},
|
|
90
|
+
"./shortcuts": {
|
|
91
|
+
"types": "./dist/shortcuts/index.d.ts",
|
|
92
|
+
"import": "./dist/shortcuts/index.js"
|
|
89
93
|
}
|
|
90
94
|
},
|
|
91
95
|
"files": [
|
|
@@ -0,0 +1,122 @@
|
|
|
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
|
+
|
|
15
|
+
/** What a modifier has to be for a chord to match: held, not held, or either. */
|
|
16
|
+
export type ModifierRule = 'held' | 'free' | 'any'
|
|
17
|
+
|
|
18
|
+
export interface Chord {
|
|
19
|
+
readonly code: string
|
|
20
|
+
readonly shift: ModifierRule
|
|
21
|
+
readonly alt: ModifierRule
|
|
22
|
+
readonly ctrl: ModifierRule
|
|
23
|
+
readonly meta: ModifierRule
|
|
24
|
+
/** Ctrl or ⌘: when `held`, at least one of the two is down, and neither is otherwise checked. */
|
|
25
|
+
readonly mod: ModifierRule
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The modifier state of a key press: the four flags of a `KeyboardEvent`. */
|
|
29
|
+
export interface ModifierState {
|
|
30
|
+
readonly shiftKey: boolean
|
|
31
|
+
readonly altKey: boolean
|
|
32
|
+
readonly ctrlKey: boolean
|
|
33
|
+
readonly metaKey: boolean
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const MODIFIERS = { Shift: 'shift', Alt: 'alt', Ctrl: 'ctrl', Meta: 'meta', Mod: 'mod' } as const
|
|
37
|
+
type ModifierName = keyof typeof MODIFIERS
|
|
38
|
+
|
|
39
|
+
const CODE = /^[A-Za-z][A-Za-z0-9]*$/
|
|
40
|
+
|
|
41
|
+
const parsed = new Map<string, Chord | null>()
|
|
42
|
+
|
|
43
|
+
/** The chord a key string names, or `null` when it names none (an unknown modifier, a doubled one, no key). */
|
|
44
|
+
export function parseChord(keys: string): Chord | null {
|
|
45
|
+
const known = parsed.get(keys)
|
|
46
|
+
if (known !== undefined) return known
|
|
47
|
+
const chord = parse(keys)
|
|
48
|
+
parsed.set(keys, chord)
|
|
49
|
+
return chord
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function parse(keys: string): Chord | null {
|
|
53
|
+
if (typeof keys !== 'string') return null
|
|
54
|
+
const parts = keys.split('+')
|
|
55
|
+
const code = parts.pop()
|
|
56
|
+
if (!code || !CODE.test(code)) return null
|
|
57
|
+
const rules: Record<(typeof MODIFIERS)[ModifierName], ModifierRule> = {
|
|
58
|
+
shift: 'free', alt: 'free', ctrl: 'free', meta: 'free', mod: 'free',
|
|
59
|
+
}
|
|
60
|
+
const seen = new Set<string>()
|
|
61
|
+
for (const part of parts) {
|
|
62
|
+
const optional = part.endsWith('?')
|
|
63
|
+
const name = (optional ? part.slice(0, -1) : part) as ModifierName
|
|
64
|
+
if (!Object.hasOwn(MODIFIERS, name) || seen.has(name)) return null
|
|
65
|
+
seen.add(name)
|
|
66
|
+
rules[MODIFIERS[name]] = optional ? 'any' : 'held'
|
|
67
|
+
}
|
|
68
|
+
// Mod already speaks for Ctrl and ⌘: naming one of them beside it says two things about one key.
|
|
69
|
+
if (rules.mod !== 'free' && (rules.ctrl !== 'free' || rules.meta !== 'free')) return null
|
|
70
|
+
return { code, ...rules }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const fits = (rule: ModifierRule, down: boolean) => rule === 'any' || (rule === 'held') === Boolean(down)
|
|
74
|
+
|
|
75
|
+
/** Whether a press of `code` with these modifiers is this chord. */
|
|
76
|
+
export function chordMatches(chord: Chord, code: string, state: ModifierState): boolean {
|
|
77
|
+
if (chord.code !== code) return false
|
|
78
|
+
if (!fits(chord.shift, state.shiftKey) || !fits(chord.alt, state.altKey)) return false
|
|
79
|
+
if (chord.mod === 'held') return Boolean(state.ctrlKey || state.metaKey)
|
|
80
|
+
if (chord.mod === 'any') return true
|
|
81
|
+
return fits(chord.ctrl, state.ctrlKey) && fits(chord.meta, state.metaKey)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** A chord that needs Ctrl or ⌘: a command, never a character typed. */
|
|
85
|
+
export function isCommandChord(chord: Chord): boolean {
|
|
86
|
+
return chord.mod === 'held' || chord.ctrl === 'held' || chord.meta === 'held'
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const STATES: ModifierState[] = Array.from({ length: 16 }, (_, i) => ({
|
|
90
|
+
shiftKey: Boolean(i & 1), altKey: Boolean(i & 2), ctrlKey: Boolean(i & 4), metaKey: Boolean(i & 8),
|
|
91
|
+
}))
|
|
92
|
+
|
|
93
|
+
/** Whether one press could be both chords — the test for a clash. */
|
|
94
|
+
export function chordsOverlap(a: Chord, b: Chord): boolean {
|
|
95
|
+
return a.code === b.code && STATES.some((state) => chordMatches(a, a.code, state) && chordMatches(b, b.code, state))
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The chords a key string names, one or two (a sequence); `null` when it names none. */
|
|
99
|
+
export function parseKeys(keys: string): Chord[] | null {
|
|
100
|
+
if (typeof keys !== 'string') return null
|
|
101
|
+
const steps = keys.split(' ')
|
|
102
|
+
if (steps.length > 2) return null
|
|
103
|
+
const chords = steps.map(parseChord)
|
|
104
|
+
return chords.every((chord): chord is Chord => chord !== null) ? chords : null
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Whether two key strings could answer the same presses — the test for a clash. A single key and a
|
|
109
|
+
* sequence that starts with it clash too: the single key would fire before the sequence went on.
|
|
110
|
+
*/
|
|
111
|
+
export function keysOverlap(a: string, b: string): boolean {
|
|
112
|
+
const as = parseKeys(a)
|
|
113
|
+
const bs = parseKeys(b)
|
|
114
|
+
if (!as || !bs) return false
|
|
115
|
+
if (as.length !== bs.length) return chordsOverlap(as[0], bs[0])
|
|
116
|
+
return as.every((chord, i) => chordsOverlap(chord, bs[i]))
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const MODIFIER_CODES = new Set(['ShiftLeft', 'ShiftRight', 'AltLeft', 'AltRight', 'ControlLeft', 'ControlRight', 'MetaLeft', 'MetaRight', 'CapsLock', 'Fn', 'FnLock'])
|
|
120
|
+
|
|
121
|
+
/** A key that is only a modifier (or Caps Lock, Fn): never a shortcut's key on its own. */
|
|
122
|
+
export const isModifierCode = (code: string): boolean => MODIFIER_CODES.has(code)
|