entangled-worlds-orbiters-shared 4.0.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/clock/Transport.d.ts +8 -2
- package/dist/clock/Transport.js +46 -7
- package/dist/clock/musical.js +13 -8
- package/dist/clock/sync.js +15 -2
- package/dist/loaders/glbLoader.js +17 -4
- 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/clock/Transport.ts +37 -6
- package/src/clock/musical.ts +18 -8
- package/src/clock/sync.ts +15 -2
- package/src/loaders/glbLoader.ts +15 -6
- 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,195 @@
|
|
|
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
|
+
|
|
5
|
+
import { chordMatches, isCommandChord, isModifierCode, keysOverlap, parseChord, parseKeys, type Chord, type ModifierState } from './chord.js'
|
|
6
|
+
|
|
7
|
+
/** One thing a key can do, as an app's catalogue declares it. */
|
|
8
|
+
export interface ShortcutAction<Scope extends string = string> {
|
|
9
|
+
/** `<app>.<area>.<action>`, e.g. `orbiters.play.panel.jamming`: the key a person's override is stored under. */
|
|
10
|
+
readonly id: string
|
|
11
|
+
/** The group it is shown in, one per app surface (`orbiters-play`). Keys clash only inside a section. */
|
|
12
|
+
readonly section: string
|
|
13
|
+
/** What it does, in sentence case. */
|
|
14
|
+
readonly label: string
|
|
15
|
+
/** The keys it has until the person changes them (chord strings, see `chord.ts`). */
|
|
16
|
+
readonly defaultKeys: readonly string[]
|
|
17
|
+
/** What it acts on, in the app's own terms (Orbiters: one deck, or this machine). */
|
|
18
|
+
readonly scope: Scope
|
|
19
|
+
/** Where a command menu lists it (`panels`, `snapshots`); unset, under the section itself. */
|
|
20
|
+
readonly group?: string
|
|
21
|
+
/** `false`: a key only, never a command to pick from a list (a key held down, the menu itself). */
|
|
22
|
+
readonly command?: boolean
|
|
23
|
+
}
|
|
24
|
+
|
|
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
|
+
/**
|
|
29
|
+
* A person's changes, as their settings store them: an action's own keys (`[]` = no key). An action
|
|
30
|
+
* left out keeps its defaults, so a default changed in code reaches everyone who never touched it.
|
|
31
|
+
*/
|
|
32
|
+
export type ShortcutOverrides = ReadonlyArray<{ readonly id: string; readonly keys: readonly string[] }>
|
|
33
|
+
|
|
34
|
+
/** The key press as the matcher reads it: a `KeyboardEvent` has all of it. */
|
|
35
|
+
export interface ShortcutEvent extends ModifierState {
|
|
36
|
+
readonly code: string
|
|
37
|
+
readonly key: string
|
|
38
|
+
readonly target?: unknown
|
|
39
|
+
}
|
|
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 function resolveBindings(
|
|
46
|
+
catalogue: Iterable<ShortcutAction>,
|
|
47
|
+
overrides: ShortcutOverrides = [],
|
|
48
|
+
): Map<string, readonly string[]> {
|
|
49
|
+
const own = new Map<string, readonly string[]>()
|
|
50
|
+
for (const entry of Array.isArray(overrides) ? overrides : []) {
|
|
51
|
+
if (entry && typeof entry.id === 'string' && Array.isArray(entry.keys)) own.set(entry.id, entry.keys)
|
|
52
|
+
}
|
|
53
|
+
const bindings = new Map<string, readonly string[]>()
|
|
54
|
+
for (const action of catalogue) {
|
|
55
|
+
const keys = own.get(action.id) ?? action.defaultKeys
|
|
56
|
+
bindings.set(action.id, keys.filter((key) => typeof key === 'string' && parseKeys(key) !== null))
|
|
57
|
+
}
|
|
58
|
+
return bindings
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Two actions one press could be: `[first, second, the key of the first that clashes]`. */
|
|
62
|
+
export function findClashes(bindings: ShortcutBindings): Array<readonly [string, string, string]> {
|
|
63
|
+
const entries = [...bindings]
|
|
64
|
+
const clashes: Array<readonly [string, string, string]> = []
|
|
65
|
+
entries.forEach(([a, aKeys], i) => {
|
|
66
|
+
for (const [b, bKeys] of entries.slice(i + 1)) {
|
|
67
|
+
const key = aKeys.find((ak) => bKeys.some((bk) => keysOverlap(ak, bk)))
|
|
68
|
+
if (key !== undefined) clashes.push([a, b, key])
|
|
69
|
+
}
|
|
70
|
+
})
|
|
71
|
+
return clashes
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ---------------------------------------------------------------------------------------------
|
|
75
|
+
// Surface claims
|
|
76
|
+
|
|
77
|
+
const claims = new Set<ReadonlySet<string>>()
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Take letters away from the shortcuts while a surface is on screen, until the returned release is
|
|
81
|
+
* called. An on-screen keyboard plays from the computer keyboard, and a note key that also switched a
|
|
82
|
+
* panel would take the keyboard away mid-phrase. Keys are `event.key` values, lower-cased (the
|
|
83
|
+
* character, as the keyboard surface reads them).
|
|
84
|
+
*/
|
|
85
|
+
export function claimKeys(keys: Iterable<string>): () => void {
|
|
86
|
+
const claim: ReadonlySet<string> = new Set([...keys].map((key) => key.toLowerCase()))
|
|
87
|
+
claims.add(claim)
|
|
88
|
+
return () => {
|
|
89
|
+
claims.delete(claim)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Whether a surface holds this key. A key with Ctrl, ⌘ or Option is never claimed (⌘Z stays undo). */
|
|
94
|
+
export function isKeyClaimed(event: Pick<ShortcutEvent, 'key' | 'metaKey' | 'ctrlKey' | 'altKey'>): boolean {
|
|
95
|
+
if (!claims.size || typeof event.key !== 'string' || event.metaKey || event.ctrlKey || event.altKey) return false
|
|
96
|
+
const key = event.key.toLowerCase()
|
|
97
|
+
for (const claim of claims) if (claim.has(key)) return true
|
|
98
|
+
return false
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ---------------------------------------------------------------------------------------------
|
|
102
|
+
// Typing
|
|
103
|
+
|
|
104
|
+
interface TargetLike {
|
|
105
|
+
readonly isContentEditable?: boolean
|
|
106
|
+
readonly tagName?: unknown
|
|
107
|
+
readonly type?: unknown
|
|
108
|
+
getAttribute?(name: string): string | null
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const asTarget = (target: unknown): TargetLike | null =>
|
|
112
|
+
target !== null && typeof target === 'object' ? (target as TargetLike) : null
|
|
113
|
+
|
|
114
|
+
const roleOf = (el: TargetLike) => (typeof el.getAttribute === 'function' ? el.getAttribute('role') : null)
|
|
115
|
+
|
|
116
|
+
/** Inputs that take no typing: a slider keeps the shortcuts working while it holds focus. */
|
|
117
|
+
const UNTYPED_INPUTS = new Set(['range', 'color', 'file'])
|
|
118
|
+
|
|
119
|
+
/** A text field: every key typed there is the field's, ⌘Z included (it undoes the typing). */
|
|
120
|
+
export function isTypingTarget(target: unknown): boolean {
|
|
121
|
+
const el = asTarget(target)
|
|
122
|
+
if (!el) return false
|
|
123
|
+
if (el.isContentEditable === true || roleOf(el) === 'textbox') return true
|
|
124
|
+
const tag = typeof el.tagName === 'string' ? el.tagName.toUpperCase() : ''
|
|
125
|
+
if (tag === 'INPUT') return !UNTYPED_INPUTS.has(typeof el.type === 'string' ? el.type.toLowerCase() : '')
|
|
126
|
+
return tag === 'TEXTAREA' || tag === 'SELECT'
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** A widget that reads typed characters (a combobox's type-to-find), though it is no text field. */
|
|
130
|
+
function readsCharacters(target: unknown): boolean {
|
|
131
|
+
const el = asTarget(target)
|
|
132
|
+
return el !== null && roleOf(el) === 'combobox'
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The action this press is, or `null`. Not one while the person is typing in a field, nor a key a
|
|
137
|
+
* surface holds; a widget that reads characters keeps its characters but not the ⌘ commands.
|
|
138
|
+
*/
|
|
139
|
+
export function matchShortcut(event: ShortcutEvent, bindings: ShortcutBindings): string | null {
|
|
140
|
+
if (!event.code || isTypingTarget(event.target) || isKeyClaimed(event)) return null
|
|
141
|
+
const characters = readsCharacters(event.target)
|
|
142
|
+
for (const [actionId, keys] of bindings) {
|
|
143
|
+
for (const key of keys) {
|
|
144
|
+
const chord = parseChord(key)
|
|
145
|
+
if (!chord || !chordMatches(chord, event.code, event)) continue
|
|
146
|
+
if (characters && !isCommandChord(chord)) return null
|
|
147
|
+
return actionId
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return null
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ---------------------------------------------------------------------------------------------
|
|
154
|
+
// Sequences
|
|
155
|
+
|
|
156
|
+
/** How long the second key of a sequence (`G`, then `H`) may follow the first. */
|
|
157
|
+
export const SEQUENCE_WAIT_MS = 1500
|
|
158
|
+
|
|
159
|
+
// The sequences of a set of bindings, read once per bindings object (they change with the overrides).
|
|
160
|
+
const sequencesOf = new WeakMap<object, Array<readonly [string, Chord[]]>>()
|
|
161
|
+
function sequences(bindings: ShortcutBindings): Array<readonly [string, Chord[]]> {
|
|
162
|
+
const known = typeof bindings === 'object' ? sequencesOf.get(bindings) : undefined
|
|
163
|
+
if (known) return known
|
|
164
|
+
const found = [...bindings].flatMap(([id, keys]) =>
|
|
165
|
+
keys.map((k) => [id, parseKeys(k)] as const).filter((entry): entry is readonly [string, Chord[]] => entry[1]?.length === 2))
|
|
166
|
+
if (typeof bindings === 'object') sequencesOf.set(bindings, found)
|
|
167
|
+
return found
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* A matcher that also knows sequences: it remembers the first key of one for a moment and answers
|
|
172
|
+
* the action when the second follows. One per listener (it holds that moment). A first key answers
|
|
173
|
+
* nothing; any other key, a typed one or a wait past `waitMs` forgets it.
|
|
174
|
+
*/
|
|
175
|
+
export function createShortcutMatcher(waitMs: number = SEQUENCE_WAIT_MS, now: () => number = Date.now) {
|
|
176
|
+
let first: (ModifierState & { code: string; at: number }) | null = null
|
|
177
|
+
return function match(event: ShortcutEvent, bindings: ShortcutBindings): string | null {
|
|
178
|
+
if (event.code && isModifierCode(event.code)) return null
|
|
179
|
+
const pending = first && now() - first.at <= waitMs ? first : null
|
|
180
|
+
first = null
|
|
181
|
+
if (!event.code || isTypingTarget(event.target) || isKeyClaimed(event)) return null
|
|
182
|
+
// A widget that reads characters keeps them: no sequence starts or ends there.
|
|
183
|
+
const characters = readsCharacters(event.target)
|
|
184
|
+
const known = characters ? [] : sequences(bindings)
|
|
185
|
+
if (pending) {
|
|
186
|
+
for (const [id, [one, two]] of known)
|
|
187
|
+
if (chordMatches(one, pending.code, pending) && chordMatches(two, event.code, event)) return id
|
|
188
|
+
}
|
|
189
|
+
if (known.some(([, [one]]) => chordMatches(one, event.code, event))) {
|
|
190
|
+
first = { code: event.code, shiftKey: event.shiftKey, altKey: event.altKey, ctrlKey: event.ctrlKey, metaKey: event.metaKey, at: now() }
|
|
191
|
+
return null
|
|
192
|
+
}
|
|
193
|
+
return matchShortcut(event, bindings)
|
|
194
|
+
}
|
|
195
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
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
|
+
|
|
6
|
+
import type { ShortcutAction } from './match.js'
|
|
7
|
+
|
|
8
|
+
export type OrbitersShortcutScope = 'voice' | 'client'
|
|
9
|
+
export type OrbitersShortcut = ShortcutAction<OrbitersShortcutScope>
|
|
10
|
+
|
|
11
|
+
export const ORBITERS_PLAY_SECTION = 'orbiters-play'
|
|
12
|
+
export const ORBITERS_STUDIO_SECTION = 'orbiters-studio'
|
|
13
|
+
|
|
14
|
+
interface ActionOptions {
|
|
15
|
+
scope?: OrbitersShortcutScope
|
|
16
|
+
group?: string
|
|
17
|
+
command?: boolean
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const play = (id: string, label: string, defaultKeys: readonly string[], { scope = 'voice', group, command }: ActionOptions = {}): OrbitersShortcut =>
|
|
21
|
+
Object.freeze({
|
|
22
|
+
id: `orbiters.play.${id}`, section: ORBITERS_PLAY_SECTION, label, defaultKeys: Object.freeze([...defaultKeys]), scope,
|
|
23
|
+
...(group ? { group } : {}), ...(command === false ? { command } : {}),
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
const studio = (id: string, label: string, defaultKeys: readonly string[]): OrbitersShortcut =>
|
|
27
|
+
Object.freeze({ id: `orbiters.studio.${id}`, section: ORBITERS_STUDIO_SECTION, label, defaultKeys: Object.freeze([...defaultKeys]), scope: 'client', group: 'studio' })
|
|
28
|
+
|
|
29
|
+
/** How a command menu groups the actions, in its order. */
|
|
30
|
+
export const ORBITERS_COMMAND_GROUPS = Object.freeze([
|
|
31
|
+
{ id: 'transport', label: 'Transport' },
|
|
32
|
+
{ id: 'view', label: 'View' },
|
|
33
|
+
{ id: 'snapshots', label: 'Snapshots' },
|
|
34
|
+
{ id: 'panels', label: 'Panels' },
|
|
35
|
+
{ id: 'orbiter', label: 'Orbiter' },
|
|
36
|
+
{ id: 'studio', label: 'Studio' },
|
|
37
|
+
] as const)
|
|
38
|
+
|
|
39
|
+
/** The snapshot slots, by the number each shows: the number row, 1 to 9, then 0. */
|
|
40
|
+
const SLOT_NUMBERS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 0]
|
|
41
|
+
|
|
42
|
+
/** Undo and redo, the same keys wherever an edit can be taken back. */
|
|
43
|
+
const UNDO_KEYS = ['Mod+KeyZ']
|
|
44
|
+
const REDO_KEYS = ['Mod+Shift+KeyZ', 'Ctrl+Shift?+KeyY']
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Playing an Orbiter. Shift is free on most keys (`Shift?`): it is held to select several decks,
|
|
48
|
+
* and a key pressed while selecting means what it means alone.
|
|
49
|
+
*/
|
|
50
|
+
export const ORBITERS_PLAY_SHORTCUTS: readonly OrbitersShortcut[] = Object.freeze([
|
|
51
|
+
// Space takes Ctrl, Option and ⌘ as it comes: only Shift changes what it does.
|
|
52
|
+
play('transport.toggle', 'Play or pause', ['Alt?+Ctrl?+Meta?+Space'], { group: 'transport' }),
|
|
53
|
+
play('transport.restart', 'Play from the beginning', ['Shift+Alt?+Ctrl?+Meta?+Space'], { group: 'transport' }),
|
|
54
|
+
play('rec.hold', 'Record while held', ['KeyR'], { group: 'transport', command: false }),
|
|
55
|
+
play('rec.toggle', 'Rec on or off', ['Shift+KeyR'], { group: 'transport' }),
|
|
56
|
+
play('history.undo', 'Undo', UNDO_KEYS, { group: 'transport' }),
|
|
57
|
+
play('history.redo', 'Redo', REDO_KEYS, { group: 'transport' }),
|
|
58
|
+
play('dimension.1', 'First dimension', ['Shift?+Digit1'], { group: 'view' }),
|
|
59
|
+
play('dimension.2', 'Second dimension', ['Shift?+Digit2'], { group: 'view' }),
|
|
60
|
+
play('dimension.3', 'Third dimension', ['Shift?+Digit3'], { group: 'view' }),
|
|
61
|
+
play('body.world', 'World', ['Shift?+KeyQ'], { group: 'view' }),
|
|
62
|
+
play('body.moon', 'Moon', ['Shift?+KeyW'], { group: 'view' }),
|
|
63
|
+
play('body.star', 'Star', ['Shift?+KeyE'], { group: 'view' }),
|
|
64
|
+
...SLOT_NUMBERS.map((n) => play(`snapshot.recall.${n}`, `Recall snapshot ${n}`, [`Alt+Digit${n}`], { group: 'snapshots' })),
|
|
65
|
+
...SLOT_NUMBERS.map((n) => play(`snapshot.store.${n}`, `Store snapshot ${n}`, [`Alt+Shift+Digit${n}`], { group: 'snapshots' })),
|
|
66
|
+
// The panels, from the bottom of the rail up.
|
|
67
|
+
play('panel.jamming', 'Jamming', ['KeyA'], { group: 'panels' }),
|
|
68
|
+
play('panel.playback', 'Playback', ['KeyS'], { group: 'panels' }),
|
|
69
|
+
play('panel.cosmicLfo', 'Cosmic LFO', ['KeyD'], { group: 'panels' }),
|
|
70
|
+
play('panel.sensors', 'Sensors', ['KeyF'], { group: 'panels' }),
|
|
71
|
+
// The More menu, and this machine's switches.
|
|
72
|
+
play('midiLearn', 'MIDI learn', ['Shift?+KeyM'], { scope: 'client', group: 'orbiter' }),
|
|
73
|
+
play('performanceMode', 'Performance mode', ['Shift?+KeyP'], { scope: 'client', group: 'orbiter' }),
|
|
74
|
+
play('screenCapture', 'Screen capture', [], { scope: 'client', group: 'orbiter' }),
|
|
75
|
+
play('share', 'Share', [], { scope: 'client', group: 'orbiter' }),
|
|
76
|
+
play('fullscreen', 'Fullscreen', [], { scope: 'client', group: 'orbiter' }),
|
|
77
|
+
// `?` on a US keyboard: the key's place, whatever this keyboard prints there.
|
|
78
|
+
play('help', 'Keyboard shortcuts', ['Shift?+Slash'], { scope: 'client', group: 'orbiter', command: false }),
|
|
79
|
+
play('commands', 'Commands', ['Mod+KeyK'], { scope: 'client', group: 'orbiter', command: false }),
|
|
80
|
+
])
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The keys sheet (`?`): what to look up at a glance, in three columns. A row may stand for several
|
|
84
|
+
* actions whose keys differ only in the last key ("Dimension I · II · III — 1 2 3"); the keys
|
|
85
|
+
* shown are always this person's (`mergedKeyParts`).
|
|
86
|
+
*/
|
|
87
|
+
export const ORBITERS_PLAY_SHEET = Object.freeze([
|
|
88
|
+
{ id: 'transport', label: 'Transport', rows: [
|
|
89
|
+
{ id: 'playPause', label: 'Play / pause', actions: ['transport.toggle'] },
|
|
90
|
+
{ id: 'playFromStart', label: 'Play from start', actions: ['transport.restart'] },
|
|
91
|
+
{ id: 'recHeld', label: 'REC while held', actions: ['rec.hold'] },
|
|
92
|
+
{ id: 'recLatch', label: 'REC latch', actions: ['rec.toggle'] },
|
|
93
|
+
{ id: 'undo', label: 'Undo', actions: ['history.undo'] },
|
|
94
|
+
{ id: 'redo', label: 'Redo', actions: ['history.redo'] },
|
|
95
|
+
] },
|
|
96
|
+
{ id: 'view', label: 'View', rows: [
|
|
97
|
+
{ id: 'dimensions', label: 'Dimension I · II · III', actions: ['dimension.1', 'dimension.2', 'dimension.3'] },
|
|
98
|
+
{ id: 'bodies', label: 'World · Moon · Star', actions: ['body.world', 'body.moon', 'body.star'] },
|
|
99
|
+
{ id: 'snapshotRecall', label: 'Snapshot recall', actions: SLOT_NUMBERS.map((n) => `snapshot.recall.${n}`) },
|
|
100
|
+
{ id: 'snapshotStore', label: 'Snapshot store', actions: SLOT_NUMBERS.map((n) => `snapshot.store.${n}`) },
|
|
101
|
+
] },
|
|
102
|
+
{ id: 'panels', label: 'Panels', rows: [
|
|
103
|
+
{ id: 'jamming', label: 'Jamming', actions: ['panel.jamming'] },
|
|
104
|
+
{ id: 'playback', label: 'Playback', actions: ['panel.playback'] },
|
|
105
|
+
{ id: 'cosmicLfo', label: 'Cosmic LFO', actions: ['panel.cosmicLfo'] },
|
|
106
|
+
{ id: 'sensors', label: 'Sensors', actions: ['panel.sensors'] },
|
|
107
|
+
{ id: 'midiLearn', label: 'MIDI learn', actions: ['midiLearn'] },
|
|
108
|
+
{ id: 'performanceMode', label: 'Performance mode', actions: ['performanceMode'] },
|
|
109
|
+
] },
|
|
110
|
+
].map((column) => Object.freeze({
|
|
111
|
+
...column,
|
|
112
|
+
rows: Object.freeze(column.rows.map((row) => Object.freeze({ ...row, actions: Object.freeze(row.actions.map((a) => `orbiters.play.${a}`)) }))),
|
|
113
|
+
})))
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The Studio. It is open over play, so the play keys above still work there; where both name a key,
|
|
117
|
+
* the Studio's action is the one.
|
|
118
|
+
*/
|
|
119
|
+
export const ORBITERS_STUDIO_SHORTCUTS: readonly OrbitersShortcut[] = Object.freeze([
|
|
120
|
+
studio('undo', 'Undo', UNDO_KEYS),
|
|
121
|
+
studio('redo', 'Redo', REDO_KEYS),
|
|
122
|
+
studio('map', 'Map', ['Shift+KeyM']),
|
|
123
|
+
])
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The letters the on-screen keyboard plays while it is open (`event.key`, lower-case): the piano row,
|
|
127
|
+
* then Z and X for the octave and C and V for velocity. While it is up they are its notes, and the
|
|
128
|
+
* shortcuts on them wait (`claimKeys`); a settings page says so beside those keys.
|
|
129
|
+
*/
|
|
130
|
+
export const ORBITERS_KEYBOARD_SURFACE_KEYS: readonly string[] = Object.freeze([
|
|
131
|
+
'a', 'w', 's', 'e', 'd', 'f', 't', 'g', 'y', 'h', 'u', 'j', 'k', 'o', 'l', 'p', ';', "'", 'z', 'x', 'c', 'v',
|
|
132
|
+
])
|
|
@@ -0,0 +1,110 @@
|
|
|
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
|
+
|
|
8
|
+
import { resolveBindings, type ShortcutAction, type ShortcutOverrides } from './match.js'
|
|
9
|
+
|
|
10
|
+
/** The overrides in a stored value, or `null` when it is not a list of `{ id, keys }`. */
|
|
11
|
+
export function parseOverrides(value: unknown): ShortcutOverrides | null {
|
|
12
|
+
if (!Array.isArray(value)) return null
|
|
13
|
+
return value.filter(
|
|
14
|
+
(entry): entry is { id: string; keys: string[] } =>
|
|
15
|
+
!!entry && typeof entry.id === 'string' && Array.isArray(entry.keys) && entry.keys.every((k: unknown) => typeof k === 'string'),
|
|
16
|
+
)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* How the app reads the signed-in person's settings: `null` when no one is signed in (the
|
|
21
|
+
* default keys); `undefined` when the read failed (the overrides in force are kept: a blip must not
|
|
22
|
+
* swap someone's keys for the defaults).
|
|
23
|
+
*/
|
|
24
|
+
export type ReadAccountSettings = (options: { fresh: boolean }) => Promise<{ shortcuts?: unknown } | null | undefined>
|
|
25
|
+
|
|
26
|
+
export interface ShortcutOverridesSource {
|
|
27
|
+
/** The overrides in force. */
|
|
28
|
+
get(): ShortcutOverrides
|
|
29
|
+
/** A catalogue's actions with their keys now (kept until the overrides change). */
|
|
30
|
+
bindings(catalogue: readonly ShortcutAction[]): Map<string, readonly string[]>
|
|
31
|
+
/** Called whenever the overrides change; returns the unsubscribe. */
|
|
32
|
+
subscribe(listener: () => void): () => void
|
|
33
|
+
/** Read again (`fresh`: past the app's own cached read). The newest read wins, whatever order they end in. */
|
|
34
|
+
refresh(options?: { fresh?: boolean }): Promise<void>
|
|
35
|
+
/** Read now, and again whenever the page comes back to the front; once per source. */
|
|
36
|
+
bind(): void
|
|
37
|
+
/** For tests: set the overrides directly. */
|
|
38
|
+
set(overrides: ShortcutOverrides): void
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Coming back to the front twice in a row (visible, then focus) asks the account once. */
|
|
42
|
+
const REFRESH_GAP_MS = 2000
|
|
43
|
+
|
|
44
|
+
export function createShortcutOverridesSource(readAccount: ReadAccountSettings): ShortcutOverridesSource {
|
|
45
|
+
let overrides: ShortcutOverrides = []
|
|
46
|
+
let resolved = new WeakMap<readonly ShortcutAction[], Map<string, readonly string[]>>()
|
|
47
|
+
let latest = 0
|
|
48
|
+
let applied = 0
|
|
49
|
+
let lastRefreshAt = -Infinity
|
|
50
|
+
let bound = false
|
|
51
|
+
const listeners = new Set<() => void>()
|
|
52
|
+
|
|
53
|
+
const apply = (next: ShortcutOverrides) => {
|
|
54
|
+
if (JSON.stringify(next) === JSON.stringify(overrides)) return
|
|
55
|
+
overrides = next
|
|
56
|
+
resolved = new WeakMap()
|
|
57
|
+
listeners.forEach((listener) => listener())
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const refresh = async ({ fresh = false }: { fresh?: boolean } = {}) => {
|
|
61
|
+
const ticket = ++latest
|
|
62
|
+
lastRefreshAt = Date.now()
|
|
63
|
+
let settings: { shortcuts?: unknown } | null | undefined
|
|
64
|
+
try {
|
|
65
|
+
settings = await readAccount({ fresh })
|
|
66
|
+
} catch {
|
|
67
|
+
settings = undefined
|
|
68
|
+
}
|
|
69
|
+
// The newest read that answered wins: an older one ending after it says nothing new, and a newer
|
|
70
|
+
// one that failed does not throw away an older one's answer.
|
|
71
|
+
if (settings === undefined || ticket < applied) return
|
|
72
|
+
applied = ticket
|
|
73
|
+
apply(settings === null ? [] : (parseOverrides(settings.shortcuts) ?? []))
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
get: () => overrides,
|
|
78
|
+
bindings(catalogue) {
|
|
79
|
+
let map = resolved.get(catalogue)
|
|
80
|
+
if (!map) {
|
|
81
|
+
map = resolveBindings(catalogue, overrides)
|
|
82
|
+
resolved.set(catalogue, map)
|
|
83
|
+
}
|
|
84
|
+
return map
|
|
85
|
+
},
|
|
86
|
+
subscribe(listener) {
|
|
87
|
+
listeners.add(listener)
|
|
88
|
+
return () => {
|
|
89
|
+
listeners.delete(listener)
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
refresh,
|
|
93
|
+
bind() {
|
|
94
|
+
const win = globalThis as unknown as {
|
|
95
|
+
addEventListener?: (type: string, fn: () => void) => void
|
|
96
|
+
document?: { visibilityState?: string; addEventListener?: (type: string, fn: () => void) => void }
|
|
97
|
+
}
|
|
98
|
+
if (bound || typeof win.addEventListener !== 'function' || !win.document?.addEventListener) return
|
|
99
|
+
bound = true
|
|
100
|
+
void refresh()
|
|
101
|
+
const onFront = () => {
|
|
102
|
+
if (win.document?.visibilityState !== 'visible' || Date.now() - lastRefreshAt < REFRESH_GAP_MS) return
|
|
103
|
+
void refresh({ fresh: true })
|
|
104
|
+
}
|
|
105
|
+
win.document.addEventListener('visibilitychange', onFront)
|
|
106
|
+
win.addEventListener('focus', onFront)
|
|
107
|
+
},
|
|
108
|
+
set: apply,
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
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
|
+
|
|
5
|
+
import type { ShortcutAction } from './match.js'
|
|
6
|
+
import { EW_PLAYER_SECTION, EW_PLAYER_SHORTCUTS, EW_STUDIO_SECTION, EW_STUDIO_SHORTCUTS } from './entangledWorlds.js'
|
|
7
|
+
import { ORBITERS_PLAY_SECTION, ORBITERS_PLAY_SHORTCUTS, ORBITERS_STUDIO_SECTION, ORBITERS_STUDIO_SHORTCUTS } from './orbiters.js'
|
|
8
|
+
import { SITE_SECTION, SITE_SHORTCUTS } from './site.js'
|
|
9
|
+
|
|
10
|
+
/** The apps a person tells apart, in the order a settings page shows them. */
|
|
11
|
+
export const SHORTCUT_APPS = Object.freeze([
|
|
12
|
+
Object.freeze({ id: 'plantasia-space', label: 'Plantasia Space' }),
|
|
13
|
+
Object.freeze({ id: 'orbiters', label: 'Orbiters' }),
|
|
14
|
+
Object.freeze({ id: 'entangled-worlds', label: 'Entangled Worlds' }),
|
|
15
|
+
] as const)
|
|
16
|
+
|
|
17
|
+
export type ShortcutAppId = (typeof SHORTCUT_APPS)[number]['id']
|
|
18
|
+
|
|
19
|
+
export interface ShortcutSection {
|
|
20
|
+
readonly id: string
|
|
21
|
+
readonly label: string
|
|
22
|
+
/** The app it belongs to. */
|
|
23
|
+
readonly app: ShortcutAppId
|
|
24
|
+
/** Its name inside that app (`Play`, `Studio`); none when the app has only this section. */
|
|
25
|
+
readonly part?: string
|
|
26
|
+
readonly actions: readonly ShortcutAction[]
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const SHORTCUT_SECTIONS: readonly ShortcutSection[] = Object.freeze<ShortcutSection[]>([
|
|
30
|
+
Object.freeze({ id: SITE_SECTION, label: 'Plantasia Space', app: 'plantasia-space', actions: SITE_SHORTCUTS }),
|
|
31
|
+
Object.freeze({ id: ORBITERS_PLAY_SECTION, label: 'Orbiters play', app: 'orbiters', part: 'Play', actions: ORBITERS_PLAY_SHORTCUTS }),
|
|
32
|
+
Object.freeze({ id: ORBITERS_STUDIO_SECTION, label: 'Orbiter Studio', app: 'orbiters', part: 'Studio', actions: ORBITERS_STUDIO_SHORTCUTS }),
|
|
33
|
+
Object.freeze({ id: EW_STUDIO_SECTION, label: 'Entangled Worlds studio', app: 'entangled-worlds', part: 'Studio', actions: EW_STUDIO_SHORTCUTS }),
|
|
34
|
+
Object.freeze({ id: EW_PLAYER_SECTION, label: 'Entangled Worlds player', app: 'entangled-worlds', part: 'Player', actions: EW_PLAYER_SHORTCUTS }),
|
|
35
|
+
])
|
|
36
|
+
|
|
@@ -0,0 +1,33 @@
|
|
|
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
|
+
|
|
4
|
+
import type { ShortcutAction } from './match.js'
|
|
5
|
+
|
|
6
|
+
export type SiteShortcut = ShortcutAction<'client'>
|
|
7
|
+
|
|
8
|
+
export const SITE_SECTION = 'site'
|
|
9
|
+
|
|
10
|
+
const site = (id: string, label: string, keys: string): SiteShortcut =>
|
|
11
|
+
Object.freeze({ id: `site.${id}`, section: SITE_SECTION, label, defaultKeys: Object.freeze([keys]), scope: 'client' as const })
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Two-key sequences move around the site (G, then H for Home; N, then O for a new Orbiter), as on
|
|
15
|
+
* many sites: a single letter is an Orbiter's on a page that plays one, and the browser holds most
|
|
16
|
+
* ⌘ and ⇧⌘ letters.
|
|
17
|
+
*/
|
|
18
|
+
export const SITE_SHORTCUTS: readonly SiteShortcut[] = Object.freeze([
|
|
19
|
+
site('settings', 'Open settings', 'Mod+Shift+Comma'),
|
|
20
|
+
site('commands', 'Commands', 'Mod+KeyK'),
|
|
21
|
+
site('search', 'Search', 'Slash'),
|
|
22
|
+
site('go.home', 'Home', 'KeyG KeyH'),
|
|
23
|
+
site('go.playground', 'Playground', 'KeyG KeyP'),
|
|
24
|
+
site('go.notifications', 'Notifications', 'KeyG KeyN'),
|
|
25
|
+
site('go.audios', 'My audios', 'KeyG KeyA'),
|
|
26
|
+
site('go.orbiters', 'My Orbiters', 'KeyG KeyO'),
|
|
27
|
+
site('go.worlds', 'My Entangled Worlds', 'KeyG KeyW'),
|
|
28
|
+
site('go.collections', 'My collections', 'KeyG KeyC'),
|
|
29
|
+
site('go.stars', 'My stars', 'KeyG KeyS'),
|
|
30
|
+
site('new.orbiter', 'New Orbiter', 'KeyN KeyO'),
|
|
31
|
+
site('new.audio', 'New audio', 'KeyN KeyA'),
|
|
32
|
+
site('new.world', 'New Entangled World', 'KeyN KeyW'),
|
|
33
|
+
])
|