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.
Files changed (41) hide show
  1. package/README.md +8 -0
  2. package/dist/clock/Transport.d.ts +8 -2
  3. package/dist/clock/Transport.js +46 -7
  4. package/dist/clock/musical.js +13 -8
  5. package/dist/clock/sync.js +15 -2
  6. package/dist/loaders/glbLoader.js +17 -4
  7. package/dist/shortcuts/chord.d.ts +35 -0
  8. package/dist/shortcuts/chord.js +99 -0
  9. package/dist/shortcuts/entangledWorlds.d.ts +21 -0
  10. package/dist/shortcuts/entangledWorlds.js +40 -0
  11. package/dist/shortcuts/format.d.ts +44 -0
  12. package/dist/shortcuts/format.js +132 -0
  13. package/dist/shortcuts/index.d.ts +9 -0
  14. package/dist/shortcuts/index.js +13 -0
  15. package/dist/shortcuts/labels.d.ts +10 -0
  16. package/dist/shortcuts/labels.js +130 -0
  17. package/dist/shortcuts/match.d.ts +65 -0
  18. package/dist/shortcuts/match.js +145 -0
  19. package/dist/shortcuts/orbiters.d.ts +55 -0
  20. package/dist/shortcuts/orbiters.js +109 -0
  21. package/dist/shortcuts/overrides.d.ts +30 -0
  22. package/dist/shortcuts/overrides.js +81 -0
  23. package/dist/shortcuts/sections.d.ts +23 -0
  24. package/dist/shortcuts/sections.js +19 -0
  25. package/dist/shortcuts/site.d.ts +9 -0
  26. package/dist/shortcuts/site.js +25 -0
  27. package/package.json +5 -1
  28. package/src/clock/Transport.ts +37 -6
  29. package/src/clock/musical.ts +18 -8
  30. package/src/clock/sync.ts +15 -2
  31. package/src/loaders/glbLoader.ts +15 -6
  32. package/src/shortcuts/chord.ts +122 -0
  33. package/src/shortcuts/entangledWorlds.ts +51 -0
  34. package/src/shortcuts/format.ts +143 -0
  35. package/src/shortcuts/index.ts +69 -0
  36. package/src/shortcuts/labels.ts +139 -0
  37. package/src/shortcuts/match.ts +195 -0
  38. package/src/shortcuts/orbiters.ts +132 -0
  39. package/src/shortcuts/overrides.ts +110 -0
  40. package/src/shortcuts/sections.ts +36 -0
  41. package/src/shortcuts/site.ts +33 -0
package/README.md CHANGED
@@ -98,6 +98,14 @@ 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`, `section`, `label`, `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
+ - `ORBITERS_PLAY_SHORTCUTS`, `ORBITERS_STUDIO_SHORTCUTS` - The Orbiters catalogues
108
+
101
109
  ### Uploads (`entangled-worlds-orbiters-shared/uploads`)
102
110
  - `uploadViaSignedUrl` - The API's signed-URL upload: `/spaces/upload-init` → PUT → `/spaces/upload-complete`, over the app's own authenticated JSON caller
103
111
  - `putToSignedUrl` - The storage PUT alone, with progress and abort
@@ -71,9 +71,15 @@ export declare class Transport {
71
71
  private _nextDist;
72
72
  setProjection(projection: MusicalProjectionLike | null): void;
73
73
  musicalPosition(): unknown;
74
- applyAnchoredState(s: AnchoredState): void;
74
+ /**
75
+ * Apply a peer's anchored state. A malformed one (unknown playState, non-finite position, anchor or
76
+ * loop bounds, or a rate that is not a finite number > 0) is DROPPED whole and `false` is returned: the transport keeps its last
77
+ * good state until the next good one arrives. It never throws — this runs inside `tick()`.
78
+ */
79
+ applyAnchoredState(s: AnchoredState): boolean;
75
80
  /** Arm an anchored state (with optional control) to apply when audio time reaches `atAudioSec`. */
76
- scheduleStateChange(atAudioSec: number, state: AnchoredState, meta?: ScheduleStateMeta): void;
81
+ /** Returns `false` (and arms nothing) for a non-finite `atAudioSec` or a malformed state. */
82
+ scheduleStateChange(atAudioSec: number, state: AnchoredState, meta?: ScheduleStateMeta): boolean;
77
83
  /** Drop pending changes older than a freshly-applied (term,seq) — supersession (Codex fix #4). */
78
84
  dropPendingOlderThan(term: number, seq: number): void;
79
85
  private _drainPending;
@@ -11,6 +11,22 @@
11
11
  // - Looping is handled by computing each event's NEXT occurrence distance (wrapping across the loop
12
12
  // boundary), so events just after loopStart are armed during the pre-wrap look-ahead.
13
13
  const EPS = 1e-9;
14
+ function isValidRate(rate) {
15
+ return Number.isFinite(rate) && rate > 0;
16
+ }
17
+ function isValidAnchoredState(s) {
18
+ if (s.playState !== 'playing' && s.playState !== 'paused')
19
+ return false;
20
+ if (!Number.isFinite(s.positionSec))
21
+ return false;
22
+ if (s.anchorAudioSec != null && !Number.isFinite(s.anchorAudioSec))
23
+ return false;
24
+ if (s.rate != null && !isValidRate(s.rate))
25
+ return false;
26
+ if (s.loop && !(Number.isFinite(s.loop.startSec) && Number.isFinite(s.loop.endSec)))
27
+ return false;
28
+ return true;
29
+ }
14
30
  export class Transport {
15
31
  backend;
16
32
  lookaheadSec;
@@ -63,13 +79,15 @@ export class Transport {
63
79
  this._cancelArmed(); // armed future audio must not sound during pause; re-armed on resume
64
80
  }
65
81
  seekSec(sec) {
82
+ if (!Number.isFinite(sec))
83
+ return; // a NaN seek is ignored, never freezes the transport
66
84
  this._reanchor(Math.max(0, sec));
67
85
  this._cancelArmed();
68
86
  this._reopenEvents(); // a seek MOVES position -> fired events may replay
69
87
  }
70
88
  setRate(rate) {
71
- if (rate <= 0)
72
- throw new Error('rate must be > 0');
89
+ if (!isValidRate(rate))
90
+ throw new Error('rate must be a finite number > 0');
73
91
  this._reanchor();
74
92
  this._rate = rate;
75
93
  this._cancelArmed(); // window/audio-time changed -> re-arm pending events (NOT replay fired ones)
@@ -81,6 +99,8 @@ export class Transport {
81
99
  return this._playing;
82
100
  }
83
101
  setLoopSec(loop) {
102
+ if (loop && !(Number.isFinite(loop.startSec) && Number.isFinite(loop.endSec)))
103
+ return; // ignored
84
104
  this._loop = loop ? { startSec: loop.startSec, endSec: loop.endSec } : null;
85
105
  this._cancelArmed();
86
106
  this._reopenEvents(); // loop topology changed -> events may re-enter/leave the loop window
@@ -149,6 +169,11 @@ export class Transport {
149
169
  d += len; // wrapped into the next pass
150
170
  return d;
151
171
  }
172
+ // At or past the loop end: the transport wraps back to startSec before getting there, so the
173
+ // event never occurs while looping. Without this, its look-ahead distance came out positive just
174
+ // before the wrap and it armed (and sounded) at the wrap instant, at a position never reached.
175
+ if (len > 0 && ev.atSec >= endSec - EPS)
176
+ return null;
152
177
  }
153
178
  const d = ev.atSec - pos;
154
179
  return d >= -EPS ? d : null; // non-loop: a passed event won't fire again until a seek
@@ -161,7 +186,14 @@ export class Transport {
161
186
  return this._projection ? this._projection.musicalAt(this.positionSec()) : null;
162
187
  }
163
188
  // ---- sync apply (Inc 3) -------------------------------------------------------------
189
+ /**
190
+ * Apply a peer's anchored state. A malformed one (unknown playState, non-finite position, anchor or
191
+ * loop bounds, or a rate that is not a finite number > 0) is DROPPED whole and `false` is returned: the transport keeps its last
192
+ * good state until the next good one arrives. It never throws — this runs inside `tick()`.
193
+ */
164
194
  applyAnchoredState(s) {
195
+ if (!isValidAnchoredState(s))
196
+ return false;
165
197
  this._playing = s.playState === 'playing';
166
198
  if (s.rate != null)
167
199
  this._rate = s.rate;
@@ -171,9 +203,14 @@ export class Transport {
171
203
  this._loop = s.loop ? { ...s.loop } : null;
172
204
  this._cancelArmed();
173
205
  this._reopenEvents(); // anchored apply repositions the timeline -> events re-evaluate
206
+ return true;
174
207
  }
175
208
  /** Arm an anchored state (with optional control) to apply when audio time reaches `atAudioSec`. */
209
+ /** Returns `false` (and arms nothing) for a non-finite `atAudioSec` or a malformed state. */
176
210
  scheduleStateChange(atAudioSec, state, meta = {}) {
211
+ // checked at intake too: a NaN time would never drain and would block every later change
212
+ if (!Number.isFinite(atAudioSec) || !isValidAnchoredState(state))
213
+ return false;
177
214
  this._pending.push({
178
215
  atAudioSec,
179
216
  state,
@@ -182,6 +219,7 @@ export class Transport {
182
219
  onApply: meta.onApply ?? null,
183
220
  });
184
221
  this._pending.sort((a, b) => a.atAudioSec - b.atAudioSec);
222
+ return true;
185
223
  }
186
224
  /** Drop pending changes older than a freshly-applied (term,seq) — supersession (Codex fix #4). */
187
225
  dropPendingOlderThan(term, seq) {
@@ -191,9 +229,10 @@ export class Transport {
191
229
  const now = this.backend.nowSec();
192
230
  while (this._pending.length && this._pending[0].atAudioSec <= now + EPS) {
193
231
  const p = this._pending.shift();
194
- this.applyAnchoredState({ ...p.state, anchorAudioSec: p.state.anchorAudioSec ?? now });
195
- if (p.onApply)
196
- p.onApply(); // control rides WITH the state, applied atomically here (fix #5)
232
+ const applied = this.applyAnchoredState({ ...p.state, anchorAudioSec: p.state.anchorAudioSec ?? now });
233
+ // control rides WITH the state, applied atomically here (fix #5) — a dropped state drops its control too
234
+ if (applied && p.onApply)
235
+ p.onApply();
197
236
  }
198
237
  }
199
238
  // ---- the pump -----------------------------------------------------------------------
@@ -248,8 +287,8 @@ export class Transport {
248
287
  return;
249
288
  const { startSec, endSec } = this._loop;
250
289
  const len = endSec - startSec;
251
- if (len <= 0)
252
- return;
290
+ if (!(len > 0))
291
+ return; // also catches NaN
253
292
  const pos = this.positionSec();
254
293
  if (pos < endSec - EPS)
255
294
  return;
@@ -14,20 +14,25 @@ export class MusicalProjection {
14
14
  _sig;
15
15
  constructor(spec = {}) {
16
16
  this.ppq = spec.ppq ?? DEFAULT_PPQ;
17
- const tempo = (spec.tempoMap && spec.tempoMap.length ? spec.tempoMap : [{ startSec: 0, bpm: 120 }])
18
- .slice()
19
- .sort((a, b) => a.startSec - b.startSec);
17
+ // An entry with bpm <= 0, a NaN bpm or a non-finite start would make beat <-> seconds return
18
+ // Infinity/NaN — skip it. If nothing usable is left, fall back to the same 120 BPM default as an
19
+ // empty map.
20
+ const usable = (spec.tempoMap ?? []).filter((e) => Number.isFinite(e.startSec) && Number.isFinite(e.bpm) && e.bpm > 0);
21
+ const tempo = (usable.length ? usable : [{ startSec: 0, bpm: 120 }]).sort((a, b) => a.startSec - b.startSec);
20
22
  // precompute the cumulative beat position at each tempo segment boundary
21
23
  this._tempo = tempo.map((s) => ({ ...s, startBeat: 0 }));
22
24
  for (let i = 1; i < this._tempo.length; i++) {
23
25
  const prev = this._tempo[i - 1];
24
26
  this._tempo[i].startBeat = prev.startBeat + (this._tempo[i].startSec - prev.startSec) * (prev.bpm / 60);
25
27
  }
26
- const sig = (spec.signatureMap && spec.signatureMap.length
27
- ? spec.signatureMap
28
- : [{ startBar: 0, numerator: 4, denominator: 4 }])
29
- .slice()
30
- .sort((a, b) => a.startBar - b.startBar);
28
+ // Same rule for signatures: a zero/negative/NaN numerator or denominator, or a non-finite start
29
+ // bar, would give Infinity/NaN bars — skip it, and fall back to 4/4 if nothing usable is left.
30
+ const usableSig = (spec.signatureMap ?? []).filter((e) => Number.isFinite(e.startBar) &&
31
+ Number.isFinite(e.numerator) &&
32
+ e.numerator > 0 &&
33
+ Number.isFinite(e.denominator) &&
34
+ e.denominator > 0);
35
+ const sig = (usableSig.length ? usableSig : [{ startBar: 0, numerator: 4, denominator: 4 }]).sort((a, b) => a.startBar - b.startBar);
31
36
  // precompute the cumulative beat position at each signature segment's startBar
32
37
  this._sig = sig.map((s) => ({ ...s, beatsPerBar: (s.numerator * 4) / s.denominator, startBeat: 0 }));
33
38
  for (let i = 1; i < this._sig.length; i++) {
@@ -12,6 +12,13 @@
12
12
  // Reuses the session-time substrate offset estimation; the server `sessionEpoch` gives every peer a
13
13
  // shared `sessionMs`; the beat timeline is anchored to that.
14
14
  const mod = (n, m) => ((n % m) + m) % m;
15
+ /** A usable timeline: a finite tempo above zero and finite anchors. Anything else makes every beat
16
+ * <-> time conversion NaN or Infinity, for this peer and for every peer that adopts it. */
17
+ const isValidTimeline = (t) => !!t &&
18
+ Number.isFinite(t.tempoBpm) &&
19
+ t.tempoBpm > 0 &&
20
+ Number.isFinite(t.anchorBeat) &&
21
+ Number.isFinite(t.anchorSessionMs);
15
22
  const newerTs = (a, b) => a.time > b.time || (a.time === b.time && a.id > b.id);
16
23
  /** Cristian/SNTP offset estimate: median of (serverStamp + rtt/2 - t1). Asymmetry biases by (up-down)/2. */
17
24
  export function estimateOffset(node, relay, { samples = 7, upMs = relay.upMs, downMs = relay.downMs, jitterFn = () => 0 } = {}) {
@@ -132,6 +139,9 @@ export class BeatTimeline {
132
139
  }
133
140
  // ---- timeline mutations (all leaderless: broadcast + last-writer-wins) ---------------
134
141
  _commit(timeline) {
142
+ // Validated before it is broadcast: last-writer-wins would hand a broken timeline to every peer.
143
+ if (!isValidTimeline(timeline))
144
+ throw new Error('beat timeline needs a finite tempo > 0 and finite anchors');
135
145
  this.timeline = { ...timeline };
136
146
  this._ts = { time: this.sessionNowMs(), id: this.id };
137
147
  if (this.enabled)
@@ -177,7 +187,9 @@ export class BeatTimeline {
177
187
  // ---- start/stop intent (opt-in; never adopted on join) ------------------------------
178
188
  setLocalPlaying(playing) {
179
189
  this.localPlaying = playing;
180
- this.relay.send(this.id, { kind: 'beat:transport', id: this.id, playing, atSessionMs: this.sessionNowMs() });
190
+ // Solo (`enabled` false) broadcasts nothing, as with the timeline and hello messages.
191
+ if (this.enabled)
192
+ this.relay.send(this.id, { kind: 'beat:transport', id: this.id, playing, atSessionMs: this.sessionNowMs() });
181
193
  if (this.onChange)
182
194
  this.onChange();
183
195
  }
@@ -196,7 +208,8 @@ export class BeatTimeline {
196
208
  case 'beat:timeline':
197
209
  if (msg.id)
198
210
  this.peers.add(msg.id);
199
- if (newerTs(msg.ts, this._ts)) {
211
+ // A malformed timeline (tempo 0, NaN anchor, from a buggy or older peer) is never adopted.
212
+ if (isValidTimeline(msg.timeline) && msg.ts && newerTs(msg.ts, this._ts)) {
200
213
  this.timeline = { ...msg.timeline };
201
214
  this._ts = msg.ts;
202
215
  }
@@ -2,6 +2,7 @@ import * as THREE from 'three';
2
2
  import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
3
3
  import { ensureStandardMaterial, applyFlagsToMaterial } from '../utils/materials.js';
4
4
  import { getKTX2LoaderForRenderer } from './ktx2.js';
5
+ import { disposeObject3D } from '../utils/dispose.js';
5
6
  /**
6
7
  * `renderer` is REQUIRED at runtime (throws without one).
7
8
  * - `retries` (3): load attempts before giving up.
@@ -20,16 +21,28 @@ export async function loadGLB(url, { retries = 3, signal, onProgress, applyFlags
20
21
  throw new Error('[glb] loadGLB requires the owning renderer (KTX2 textures bind per WebGL context).');
21
22
  let attempt = 0;
22
23
  while (attempt < retries) {
24
+ // An abort during the pause between attempts must stop here: a listener added to an
25
+ // already-aborted signal never fires, so the next attempt would load and resolve anyway.
26
+ if (signal?.aborted)
27
+ throw new Error('aborted');
23
28
  try {
24
29
  const loader = new GLTFLoader()
25
30
  .setCrossOrigin(withCredentials ? 'use-credentials' : 'anonymous')
26
31
  .setWithCredentials(Boolean(withCredentials))
27
32
  .setKTX2Loader(getKTX2LoaderForRenderer(renderer));
28
33
  const root = await new Promise((resolve, reject) => {
29
- loader.load(url, (gltf) => resolve(gltf.scene), onProgress, reject);
30
- if (signal) {
31
- signal.addEventListener('abort', () => reject(new Error('aborted')));
32
- }
34
+ const onAbort = () => reject(new Error('aborted'));
35
+ signal?.addEventListener('abort', onAbort, { once: true });
36
+ loader.load(url, (gltf) => {
37
+ signal?.removeEventListener('abort', onAbort);
38
+ // Aborted while loading: the promise has already rejected and nobody will own this
39
+ // scene, so free its geometry, materials and textures now instead of leaking them.
40
+ if (signal?.aborted) {
41
+ disposeObject3D(gltf.scene);
42
+ return;
43
+ }
44
+ resolve(gltf.scene);
45
+ }, onProgress, (err) => { signal?.removeEventListener('abort', onAbort); reject(err); });
33
46
  });
34
47
  // Post-process materials
35
48
  root.traverse((object) => {
@@ -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,21 @@
1
+ import type { ShortcutAction } from './match.js';
2
+ export declare const EW_STUDIO_SECTION = "ew-studio";
3
+ export declare const EW_PLAYER_SECTION = "ew-player";
4
+ /** The studio's actions all act on what is open in it; the player has no other scope. */
5
+ export type EntangledWorldsShortcut = ShortcutAction<'studio' | 'player'>;
6
+ /** How the studio's command menu groups its actions, in its order. */
7
+ export declare const EW_STUDIO_COMMAND_GROUPS: readonly [{
8
+ readonly id: "edit";
9
+ readonly label: "Edit";
10
+ }, {
11
+ readonly id: "playback";
12
+ readonly label: "Playback";
13
+ }, {
14
+ readonly id: "steps";
15
+ readonly label: "Steps";
16
+ }, {
17
+ readonly id: "help";
18
+ readonly label: "Help";
19
+ }];
20
+ export declare const EW_STUDIO_SHORTCUTS: readonly EntangledWorldsShortcut[];
21
+ export declare const EW_PLAYER_SHORTCUTS: readonly EntangledWorldsShortcut[];
@@ -0,0 +1,40 @@
1
+ // The Entangled Worlds catalogues: the studio (where a world's clip is made) and the full-window
2
+ // player. Escape and Enter inside dialogs stay with the dialogs and are not listed here.
3
+ export const EW_STUDIO_SECTION = 'ew-studio';
4
+ export const EW_PLAYER_SECTION = 'ew-player';
5
+ const studio = (id, label, defaultKeys, group, command) => Object.freeze({
6
+ id: `ew.studio.${id}`, section: EW_STUDIO_SECTION, label, defaultKeys: Object.freeze([...defaultKeys]), scope: 'studio',
7
+ group, ...(command === false ? { command } : {}),
8
+ });
9
+ /** How the studio's command menu groups its actions, in its order. */
10
+ export const EW_STUDIO_COMMAND_GROUPS = Object.freeze([
11
+ { id: 'edit', label: 'Edit' },
12
+ { id: 'playback', label: 'Playback' },
13
+ { id: 'steps', label: 'Steps' },
14
+ { id: 'help', label: 'Help' },
15
+ ]);
16
+ export const EW_STUDIO_SHORTCUTS = Object.freeze([
17
+ studio('undo', 'Undo', ['Mod+KeyZ'], 'edit'),
18
+ studio('redo', 'Redo', ['Mod+Shift+KeyZ'], 'edit'),
19
+ studio('duplicate', 'Duplicate the selection', ['Mod+KeyD'], 'edit'),
20
+ studio('copy', 'Copy the selection', ['Mod+KeyC'], 'edit'),
21
+ studio('paste', 'Paste', ['Mod+KeyV'], 'edit'),
22
+ studio('playPause', 'Play or pause', ['Shift?+Space'], 'playback'),
23
+ studio('remove', 'Remove the selection', ['Shift?+Delete', 'Shift?+Backspace'], 'edit'),
24
+ studio('step.audio', 'Audio step', ['Digit1', 'Numpad1'], 'steps'),
25
+ studio('step.animations', 'Animations step', ['Digit2', 'Numpad2'], 'steps'),
26
+ studio('step.third', 'Export step (Release where there is no export)', ['Digit3', 'Numpad3'], 'steps'),
27
+ studio('step.release', 'Release step', ['Digit4', 'Numpad4'], 'steps'),
28
+ studio('help', 'Keyboard shortcuts', ['Shift?+Slash'], 'help'),
29
+ // Opens the menu itself, so the menu does not list it.
30
+ studio('commands', 'Commands', ['Mod+KeyK'], 'help', false),
31
+ ]);
32
+ export const EW_PLAYER_SHORTCUTS = Object.freeze([
33
+ Object.freeze({
34
+ id: 'ew.player.playPause',
35
+ section: EW_PLAYER_SECTION,
36
+ label: 'Play or pause',
37
+ defaultKeys: Object.freeze(['Shift?+Alt?+Ctrl?+Meta?+Space']),
38
+ scope: 'player',
39
+ }),
40
+ ]);
@@ -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;