@velumo/cli 0.1.0-beta.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/dist/browser-overview.d.ts +72 -0
- package/dist/browser-overview.d.ts.map +1 -0
- package/dist/browser-overview.js +431 -0
- package/dist/browser-overview.js.map +1 -0
- package/dist/browser-presenter.d.ts +63 -0
- package/dist/browser-presenter.d.ts.map +1 -0
- package/dist/browser-presenter.js +304 -0
- package/dist/browser-presenter.js.map +1 -0
- package/dist/browser-runtime.d.ts +43 -0
- package/dist/browser-runtime.d.ts.map +1 -0
- package/dist/browser-runtime.js +293 -0
- package/dist/browser-runtime.js.map +1 -0
- package/dist/browser-spatial.d.ts +42 -0
- package/dist/browser-spatial.d.ts.map +1 -0
- package/dist/browser-spatial.js +207 -0
- package/dist/browser-spatial.js.map +1 -0
- package/dist/browser-sync.d.ts +37 -0
- package/dist/browser-sync.d.ts.map +1 -0
- package/dist/browser-sync.js +77 -0
- package/dist/browser-sync.js.map +1 -0
- package/dist/browser-timer.d.ts +9 -0
- package/dist/browser-timer.d.ts.map +1 -0
- package/dist/browser-timer.js +13 -0
- package/dist/browser-timer.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +902 -0
- package/dist/index.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/package.json +42 -0
- package/src/browser-overview.ts +641 -0
- package/src/browser-presenter.ts +602 -0
- package/src/browser-runtime.ts +493 -0
- package/src/browser-spatial.ts +292 -0
- package/src/browser-sync.ts +151 -0
- package/src/browser-timer.ts +24 -0
- package/src/index.ts +1273 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import type { CueAction, JsonValue, Scene, SlideEntry } from "@velumo/core";
|
|
2
|
+
import { createScenePlayer, type ScenePlayer } from "@velumo/runtime";
|
|
3
|
+
import {
|
|
4
|
+
applyCameraToDomRoot,
|
|
5
|
+
applyCameraTransition,
|
|
6
|
+
renderSpatialWorld,
|
|
7
|
+
type LayoutRendererElement,
|
|
8
|
+
type LayoutRendererFactories,
|
|
9
|
+
} from "@velumo/renderer-dom";
|
|
10
|
+
|
|
11
|
+
interface SpatialRoot {
|
|
12
|
+
readonly ownerDocument: {
|
|
13
|
+
createElement(tag: string): SpatialElement;
|
|
14
|
+
createElementNS(namespace: string, tag: string): SpatialElement;
|
|
15
|
+
};
|
|
16
|
+
replaceChildren(...children: unknown[]): void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface SpatialElement extends LayoutRendererElement {
|
|
20
|
+
readonly ownerDocument: {
|
|
21
|
+
createElement(tag: string): SpatialElement;
|
|
22
|
+
createElementNS(namespace: string, tag: string): SpatialElement;
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface SpatialWindow {
|
|
27
|
+
matchMedia?: (query: string) => { matches: boolean } | undefined;
|
|
28
|
+
requestAnimationFrame?: (cb: (t: number) => void) => number;
|
|
29
|
+
cancelAnimationFrame?: (handle: number) => void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface SpatialSessionOptions {
|
|
33
|
+
readonly root: SpatialRoot;
|
|
34
|
+
readonly slide: SlideEntry;
|
|
35
|
+
readonly window: SpatialWindow;
|
|
36
|
+
readonly direction?: "forward" | "backward";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface SpatialSession {
|
|
40
|
+
advance(): boolean;
|
|
41
|
+
back(): boolean;
|
|
42
|
+
dispose(): void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function asSpatialScene(slide: SlideEntry): Scene | undefined {
|
|
46
|
+
if (slide.kind !== "scene") {
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
const scene = slide as Scene;
|
|
50
|
+
if (scene.cameraPath === undefined || scene.cameraPath.length === 0) {
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
return scene;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function readReducedMotion(window: SpatialWindow): boolean {
|
|
57
|
+
return (
|
|
58
|
+
window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches === true
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function payloadTarget(payload: JsonValue | undefined): string | undefined {
|
|
63
|
+
if (
|
|
64
|
+
payload !== null &&
|
|
65
|
+
typeof payload === "object" &&
|
|
66
|
+
!Array.isArray(payload) &&
|
|
67
|
+
typeof (payload as { target?: unknown }).target === "string"
|
|
68
|
+
) {
|
|
69
|
+
return (payload as { target: string }).target;
|
|
70
|
+
}
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function createSpatialSession(
|
|
75
|
+
options: SpatialSessionOptions,
|
|
76
|
+
): SpatialSession | undefined {
|
|
77
|
+
const scene = asSpatialScene(options.slide);
|
|
78
|
+
if (scene === undefined) {
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const factories: LayoutRendererFactories = {
|
|
83
|
+
createElement: (tag) =>
|
|
84
|
+
options.root.ownerDocument.createElement(tag) as SpatialElement,
|
|
85
|
+
createElementNS: (ns, tag) =>
|
|
86
|
+
options.root.ownerDocument.createElementNS(ns, tag) as SpatialElement,
|
|
87
|
+
setStyleProperty: (el, prop, value) => {
|
|
88
|
+
(el as SpatialElement).style?.setProperty(prop, value);
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const { viewport, world } = renderSpatialWorld(scene, factories);
|
|
93
|
+
options.root.replaceChildren(viewport);
|
|
94
|
+
|
|
95
|
+
const reducedMotion = readReducedMotion(options.window);
|
|
96
|
+
const player = createScenePlayer({
|
|
97
|
+
stops: scene.cameraPath ?? [],
|
|
98
|
+
reducedMotion,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const disposers: Array<() => void> = [];
|
|
102
|
+
disposers.push(
|
|
103
|
+
player.on("camera:move", (event) => {
|
|
104
|
+
if (event.type !== "camera:move") {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
applyCameraTransition(world, event.durationMs, event.easing, factories);
|
|
108
|
+
applyCameraToDomRoot(
|
|
109
|
+
world as unknown as { style: { transform: string } },
|
|
110
|
+
event.view,
|
|
111
|
+
);
|
|
112
|
+
}),
|
|
113
|
+
);
|
|
114
|
+
disposers.push(
|
|
115
|
+
player.on("cue:fired", (event) => {
|
|
116
|
+
if (event.type !== "cue:fired") {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
handleDomCue(world, event.cue.action);
|
|
120
|
+
}),
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
let rafHandle: number | undefined;
|
|
124
|
+
let lastTime: number | undefined;
|
|
125
|
+
let disposed = false;
|
|
126
|
+
const raf = options.window.requestAnimationFrame;
|
|
127
|
+
const caf = options.window.cancelAnimationFrame;
|
|
128
|
+
|
|
129
|
+
function loop(time: number): void {
|
|
130
|
+
if (disposed) {
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
const delta = lastTime === undefined ? 0 : time - lastTime;
|
|
134
|
+
lastTime = time;
|
|
135
|
+
try {
|
|
136
|
+
player.tick(delta);
|
|
137
|
+
} catch {
|
|
138
|
+
// A cue-handler fault must not kill the animation loop.
|
|
139
|
+
}
|
|
140
|
+
if (raf !== undefined && !player.isIdle()) {
|
|
141
|
+
rafHandle = raf(loop);
|
|
142
|
+
} else {
|
|
143
|
+
rafHandle = undefined;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function startLoop(): void {
|
|
148
|
+
if (raf === undefined || rafHandle !== undefined) {
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
lastTime = undefined;
|
|
152
|
+
rafHandle = raf(loop);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
player.enter(options.direction ?? "forward");
|
|
156
|
+
startLoop();
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
advance() {
|
|
160
|
+
const moved = player.advance() === "moved";
|
|
161
|
+
if (moved) {
|
|
162
|
+
startLoop();
|
|
163
|
+
}
|
|
164
|
+
return moved;
|
|
165
|
+
},
|
|
166
|
+
back() {
|
|
167
|
+
const moved = player.back() === "moved";
|
|
168
|
+
if (moved) {
|
|
169
|
+
startLoop();
|
|
170
|
+
}
|
|
171
|
+
return moved;
|
|
172
|
+
},
|
|
173
|
+
dispose() {
|
|
174
|
+
disposed = true;
|
|
175
|
+
if (rafHandle !== undefined && caf !== undefined) {
|
|
176
|
+
caf(rafHandle);
|
|
177
|
+
rafHandle = undefined;
|
|
178
|
+
}
|
|
179
|
+
for (const dispose of disposers) {
|
|
180
|
+
dispose();
|
|
181
|
+
}
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Minimal structural type for cue-target traversal. Works in real browsers
|
|
187
|
+
// (Element.getAttribute / Element.children as HTMLCollection) and in test stubs
|
|
188
|
+
// (getAttribute method + children as array). Does not widen LayoutRendererElement.
|
|
189
|
+
interface CueTargetNode {
|
|
190
|
+
getAttribute?(name: string): string | null;
|
|
191
|
+
setAttribute(name: string, value: string): void;
|
|
192
|
+
children?: ArrayLike<CueTargetNode> | CueTargetNode[];
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function handleDomCue(world: LayoutRendererElement, action: CueAction): void {
|
|
196
|
+
const { type, payload } = action;
|
|
197
|
+
const target = action.target ?? payloadTarget(payload);
|
|
198
|
+
switch (type) {
|
|
199
|
+
case "show":
|
|
200
|
+
case "hide": {
|
|
201
|
+
if (target === undefined) {
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
const el = findCueTarget(world as unknown as CueTargetNode, target);
|
|
205
|
+
el?.setAttribute(
|
|
206
|
+
"data-velumo-cue",
|
|
207
|
+
type === "show" ? "visible" : "hidden",
|
|
208
|
+
);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
case "fx": {
|
|
212
|
+
world.setAttribute("data-velumo-fx", "fired");
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
case "setClass": {
|
|
216
|
+
if (target === undefined) {
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
// Require payload to be a plain object to read className from it.
|
|
220
|
+
if (
|
|
221
|
+
payload === null ||
|
|
222
|
+
typeof payload !== "object" ||
|
|
223
|
+
Array.isArray(payload)
|
|
224
|
+
) {
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
const p = payload as { className?: unknown; mode?: unknown };
|
|
228
|
+
if (typeof p.className !== "string" || p.className === "") {
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
const className = p.className;
|
|
232
|
+
const mode =
|
|
233
|
+
p.mode === "remove" || p.mode === "toggle"
|
|
234
|
+
? (p.mode as "remove" | "toggle")
|
|
235
|
+
: "add";
|
|
236
|
+
const el = findCueTarget(world as unknown as CueTargetNode, target);
|
|
237
|
+
if (el === undefined) {
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
// Read the existing class attribute via getAttribute so this works in
|
|
241
|
+
// both real browsers (Element.getAttribute) and test stubs.
|
|
242
|
+
const existing =
|
|
243
|
+
typeof el.getAttribute === "function"
|
|
244
|
+
? (el.getAttribute("data-velumo-cue-class") ?? "")
|
|
245
|
+
: "";
|
|
246
|
+
const parts = existing.split(" ").filter((s: string) => s.length > 0);
|
|
247
|
+
const set = new Set(parts);
|
|
248
|
+
if (mode === "add") {
|
|
249
|
+
set.add(className);
|
|
250
|
+
} else if (mode === "remove") {
|
|
251
|
+
set.delete(className);
|
|
252
|
+
} else {
|
|
253
|
+
if (set.has(className)) {
|
|
254
|
+
set.delete(className);
|
|
255
|
+
} else {
|
|
256
|
+
set.add(className);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
el.setAttribute("data-velumo-cue-class", [...set].sort().join(" "));
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
default:
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export function findCueTarget(
|
|
268
|
+
world: CueTargetNode,
|
|
269
|
+
target: string,
|
|
270
|
+
): CueTargetNode | undefined {
|
|
271
|
+
const id = target.startsWith("#") ? target.slice(1) : target;
|
|
272
|
+
const stack: CueTargetNode[] = [world];
|
|
273
|
+
while (stack.length > 0) {
|
|
274
|
+
const node = stack.pop() as CueTargetNode;
|
|
275
|
+
// Use getAttribute when available (real browsers + stubs that implement it).
|
|
276
|
+
const nodeId =
|
|
277
|
+
typeof node.getAttribute === "function"
|
|
278
|
+
? node.getAttribute("id")
|
|
279
|
+
: undefined;
|
|
280
|
+
if (nodeId === id) {
|
|
281
|
+
return node;
|
|
282
|
+
}
|
|
283
|
+
// Array.from handles both HTMLCollection (real browser) and plain arrays (stubs).
|
|
284
|
+
const kids = node.children;
|
|
285
|
+
if (kids !== undefined) {
|
|
286
|
+
for (const k of Array.from(kids)) {
|
|
287
|
+
stack.push(k);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return undefined;
|
|
292
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import type { RuntimeState } from "@velumo/runtime";
|
|
2
|
+
|
|
3
|
+
export type PresenterCommand = "next" | "previous" | "resetTimer";
|
|
4
|
+
|
|
5
|
+
export type PresenterSyncMessage =
|
|
6
|
+
| {
|
|
7
|
+
readonly source: "audience";
|
|
8
|
+
readonly instanceId: string;
|
|
9
|
+
readonly type: "state";
|
|
10
|
+
readonly state: RuntimeState;
|
|
11
|
+
}
|
|
12
|
+
| {
|
|
13
|
+
readonly source: "presenter";
|
|
14
|
+
readonly instanceId: string;
|
|
15
|
+
readonly type: "command";
|
|
16
|
+
readonly command: PresenterCommand;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
interface PresenterSyncEvent {
|
|
20
|
+
readonly data: unknown;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface PresenterSyncChannel {
|
|
24
|
+
addEventListener(
|
|
25
|
+
type: "message",
|
|
26
|
+
listener: (event: PresenterSyncEvent) => void,
|
|
27
|
+
): void;
|
|
28
|
+
close?(): void;
|
|
29
|
+
postMessage(message: PresenterSyncMessage): void;
|
|
30
|
+
unref?(): void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface PresenterSyncOptions {
|
|
34
|
+
readonly channel: PresenterSyncChannel;
|
|
35
|
+
readonly instanceId: string;
|
|
36
|
+
readonly role: "audience" | "presenter";
|
|
37
|
+
readonly onCommand?: (command: PresenterCommand) => void;
|
|
38
|
+
readonly onState?: (state: RuntimeState) => void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface PresenterSync {
|
|
42
|
+
dispose(): void;
|
|
43
|
+
publishCommand(command: PresenterCommand): void;
|
|
44
|
+
publishState(state: RuntimeState): void;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function createPresenterSync(
|
|
48
|
+
options: PresenterSyncOptions,
|
|
49
|
+
): PresenterSync {
|
|
50
|
+
options.channel.addEventListener("message", (event) => {
|
|
51
|
+
const message = parsePresenterSyncMessage(event.data);
|
|
52
|
+
|
|
53
|
+
if (message === undefined || message.instanceId === options.instanceId) {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (options.role === "audience" && message.type === "command") {
|
|
58
|
+
options.onCommand?.(message.command);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (options.role === "presenter" && message.type === "state") {
|
|
63
|
+
options.onState?.(message.state);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
dispose() {
|
|
69
|
+
options.channel.close?.();
|
|
70
|
+
},
|
|
71
|
+
publishCommand(command) {
|
|
72
|
+
options.channel.postMessage({
|
|
73
|
+
source: "presenter",
|
|
74
|
+
instanceId: options.instanceId,
|
|
75
|
+
type: "command",
|
|
76
|
+
command,
|
|
77
|
+
});
|
|
78
|
+
},
|
|
79
|
+
publishState(state) {
|
|
80
|
+
options.channel.postMessage({
|
|
81
|
+
source: "audience",
|
|
82
|
+
instanceId: options.instanceId,
|
|
83
|
+
type: "state",
|
|
84
|
+
state,
|
|
85
|
+
});
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function parsePresenterSyncMessage(
|
|
91
|
+
message: unknown,
|
|
92
|
+
): PresenterSyncMessage | undefined {
|
|
93
|
+
if (message === null || typeof message !== "object") {
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const candidate = message as {
|
|
98
|
+
readonly command?: unknown;
|
|
99
|
+
readonly instanceId?: unknown;
|
|
100
|
+
readonly source?: unknown;
|
|
101
|
+
readonly state?: unknown;
|
|
102
|
+
readonly type?: unknown;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
if (typeof candidate.instanceId !== "string") {
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (
|
|
110
|
+
candidate.source === "audience" &&
|
|
111
|
+
candidate.type === "state" &&
|
|
112
|
+
isRuntimeState(candidate.state)
|
|
113
|
+
) {
|
|
114
|
+
return {
|
|
115
|
+
source: "audience",
|
|
116
|
+
instanceId: candidate.instanceId,
|
|
117
|
+
type: "state",
|
|
118
|
+
state: candidate.state,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (
|
|
123
|
+
candidate.source === "presenter" &&
|
|
124
|
+
candidate.type === "command" &&
|
|
125
|
+
isPresenterCommand(candidate.command)
|
|
126
|
+
) {
|
|
127
|
+
return {
|
|
128
|
+
source: "presenter",
|
|
129
|
+
instanceId: candidate.instanceId,
|
|
130
|
+
type: "command",
|
|
131
|
+
command: candidate.command,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return undefined;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function isPresenterCommand(command: unknown): command is PresenterCommand {
|
|
139
|
+
return (
|
|
140
|
+
command === "next" || command === "previous" || command === "resetTimer"
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function isRuntimeState(state: unknown): state is RuntimeState {
|
|
145
|
+
if (state === null || typeof state !== "object") {
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const candidate = state as { readonly slideId?: unknown };
|
|
150
|
+
return typeof candidate.slideId === "string";
|
|
151
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export interface PresenterTimerOptions {
|
|
2
|
+
readonly now?: () => number;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export interface PresenterTimer {
|
|
6
|
+
elapsedMs(): number;
|
|
7
|
+
reset(): void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function createPresenterTimer(
|
|
11
|
+
options: PresenterTimerOptions = {},
|
|
12
|
+
): PresenterTimer {
|
|
13
|
+
const now = options.now ?? Date.now;
|
|
14
|
+
let startedAt = now();
|
|
15
|
+
|
|
16
|
+
return {
|
|
17
|
+
elapsedMs() {
|
|
18
|
+
return Math.max(0, now() - startedAt);
|
|
19
|
+
},
|
|
20
|
+
reset() {
|
|
21
|
+
startedAt = now();
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
}
|