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
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
|
-
|
|
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
|
-
|
|
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;
|
package/dist/clock/Transport.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
196
|
-
|
|
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
|
|
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;
|
package/dist/clock/musical.js
CHANGED
|
@@ -14,20 +14,25 @@ export class MusicalProjection {
|
|
|
14
14
|
_sig;
|
|
15
15
|
constructor(spec = {}) {
|
|
16
16
|
this.ppq = spec.ppq ?? DEFAULT_PPQ;
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
.
|
|
30
|
-
.
|
|
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++) {
|
package/dist/clock/sync.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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;
|