@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.
Files changed (38) hide show
  1. package/dist/browser-overview.d.ts +72 -0
  2. package/dist/browser-overview.d.ts.map +1 -0
  3. package/dist/browser-overview.js +431 -0
  4. package/dist/browser-overview.js.map +1 -0
  5. package/dist/browser-presenter.d.ts +63 -0
  6. package/dist/browser-presenter.d.ts.map +1 -0
  7. package/dist/browser-presenter.js +304 -0
  8. package/dist/browser-presenter.js.map +1 -0
  9. package/dist/browser-runtime.d.ts +43 -0
  10. package/dist/browser-runtime.d.ts.map +1 -0
  11. package/dist/browser-runtime.js +293 -0
  12. package/dist/browser-runtime.js.map +1 -0
  13. package/dist/browser-spatial.d.ts +42 -0
  14. package/dist/browser-spatial.d.ts.map +1 -0
  15. package/dist/browser-spatial.js +207 -0
  16. package/dist/browser-spatial.js.map +1 -0
  17. package/dist/browser-sync.d.ts +37 -0
  18. package/dist/browser-sync.d.ts.map +1 -0
  19. package/dist/browser-sync.js +77 -0
  20. package/dist/browser-sync.js.map +1 -0
  21. package/dist/browser-timer.d.ts +9 -0
  22. package/dist/browser-timer.d.ts.map +1 -0
  23. package/dist/browser-timer.js +13 -0
  24. package/dist/browser-timer.js.map +1 -0
  25. package/dist/index.d.ts +4 -0
  26. package/dist/index.d.ts.map +1 -0
  27. package/dist/index.js +902 -0
  28. package/dist/index.js.map +1 -0
  29. package/dist/tsconfig.tsbuildinfo +1 -0
  30. package/package.json +42 -0
  31. package/src/browser-overview.ts +641 -0
  32. package/src/browser-presenter.ts +602 -0
  33. package/src/browser-runtime.ts +493 -0
  34. package/src/browser-spatial.ts +292 -0
  35. package/src/browser-sync.ts +151 -0
  36. package/src/browser-timer.ts +24 -0
  37. package/src/index.ts +1273 -0
  38. package/tsconfig.json +16 -0
@@ -0,0 +1,602 @@
1
+ import type { Manifest, SlideEntry } from "@velumo/core";
2
+ import {
3
+ createSpeakerNotesModel,
4
+ RuntimeController,
5
+ type RuntimeState,
6
+ type SpeakerNoteBlock,
7
+ } from "@velumo/runtime";
8
+ import { DomRenderer } from "@velumo/renderer-dom";
9
+ import {
10
+ createPresenterSync,
11
+ type PresenterCommand,
12
+ type PresenterSync,
13
+ type PresenterSyncChannel,
14
+ } from "./browser-sync.js";
15
+ import { createPresenterTimer, type PresenterTimer } from "./browser-timer.js";
16
+
17
+ interface ManifestResponse {
18
+ readonly ok: boolean;
19
+ readonly status: number;
20
+ json(): Promise<Manifest>;
21
+ }
22
+
23
+ type PresenterChild = PresenterElement | PresenterTextNode | string;
24
+
25
+ interface PresenterTextNode {
26
+ parentNode?: unknown;
27
+ textContent: string;
28
+ }
29
+
30
+ interface PresenterElement extends PresenterTextNode {
31
+ readonly ownerDocument: PresenterDocument;
32
+ addEventListener?(type: "click", listener: () => void): void;
33
+ append(...children: PresenterChild[]): void;
34
+ appendChild(
35
+ child: PresenterElement | PresenterTextNode,
36
+ ): PresenterElement | PresenterTextNode;
37
+ querySelector(selector: string): PresenterElement | null;
38
+ removeAttribute(name: string): void;
39
+ replaceChildren(...children: PresenterChild[]): void;
40
+ setAttribute(name: string, value: string): void;
41
+ }
42
+
43
+ interface PresenterDocument {
44
+ createElement(tagName: string): PresenterElement;
45
+ createElementNS(namespace: string, tagName: string): PresenterElement;
46
+ createTextNode(text: string): PresenterTextNode;
47
+ }
48
+
49
+ type PresenterRoot = PresenterElement;
50
+
51
+ interface PresenterWindowGlobals {
52
+ readonly BroadcastChannel?: new (name: string) => PresenterSyncChannel;
53
+ readonly crypto?: { randomUUID?(): string };
54
+ }
55
+
56
+ interface PresenterCommandPublisher {
57
+ publishCommand(command: PresenterCommand): void;
58
+ }
59
+
60
+ interface PresenterInjectedSync {
61
+ readonly channel: PresenterSyncChannel;
62
+ readonly instanceId: string;
63
+ }
64
+
65
+ interface PresenterSyncConnection {
66
+ readonly commandPublisher?: PresenterCommandPublisher;
67
+ readonly sync?: PresenterSync;
68
+ }
69
+
70
+ interface PresenterTimerScheduler {
71
+ clearInterval?(id: unknown): void;
72
+ setInterval(handler: () => void, ms: number): unknown;
73
+ }
74
+
75
+ interface ThumbnailHandle {
76
+ dispose(): void;
77
+ }
78
+
79
+ type MountThumbnailFn = (opts: {
80
+ readonly root: PresenterElement;
81
+ readonly manifest: Manifest;
82
+ readonly slideId: string;
83
+ }) => ThumbnailHandle;
84
+
85
+ export interface PresenterMountOptions {
86
+ readonly root: PresenterRoot | null | undefined;
87
+ readonly manifestUrl: string;
88
+ readonly initialState?: RuntimeState;
89
+ readonly sync?: false | PresenterCommandPublisher | PresenterInjectedSync;
90
+ readonly timer?: PresenterTimer;
91
+ readonly timerIntervalMs?: number;
92
+ readonly timerScheduler?: PresenterTimerScheduler;
93
+ readonly mountThumbnail?: MountThumbnailFn;
94
+ }
95
+
96
+ export interface PresenterMountResult {
97
+ dispose(): void;
98
+ readonly manifest: Manifest;
99
+ readonly sync?: PresenterSync;
100
+ readonly timer: PresenterTimer;
101
+ }
102
+
103
+ export async function mountVelumoPresenterView(
104
+ options: PresenterMountOptions,
105
+ ): Promise<PresenterMountResult | undefined> {
106
+ if (options.root === null || options.root === undefined) {
107
+ throw new Error("Velumo presenter root element not found");
108
+ }
109
+
110
+ const root = options.root;
111
+
112
+ try {
113
+ const response = (await fetch(options.manifestUrl)) as ManifestResponse;
114
+
115
+ if (!response.ok) {
116
+ throw new Error(`manifest request failed with status ${response.status}`);
117
+ }
118
+
119
+ const manifest = await response.json();
120
+ const timer = options.timer ?? createPresenterTimer();
121
+ const notes = createSpeakerNotesModel(manifest);
122
+ let state =
123
+ options.initialState ?? new RuntimeController(manifest).getState();
124
+ let sync: PresenterSync | undefined;
125
+ let commandPublisher: PresenterCommandPublisher | undefined;
126
+
127
+ let currentHandles: Array<ThumbnailHandle> = [];
128
+
129
+ function render(): void {
130
+ for (const h of currentHandles) h.dispose();
131
+ currentHandles = [];
132
+ renderPresenter(root, {
133
+ commandPublisher,
134
+ manifest,
135
+ notes,
136
+ state,
137
+ statusText: sync === undefined ? "Local disconnected" : "Connected",
138
+ timer,
139
+ mountThumbnail: options.mountThumbnail,
140
+ handles: currentHandles,
141
+ });
142
+ }
143
+
144
+ // The clock ticks every second, but the elapsed time is the only thing
145
+ // that changes between ticks. Refresh just that text node in place rather
146
+ // than calling render(): a full render() rebuilds the whole tree, including
147
+ // the two slide-preview DomRenderers, which restarts their one-shot entry
148
+ // animations every second (and leaks a renderer per tick). Full render()
149
+ // stays driven by mount and state changes (navigation/sync), where a single
150
+ // preview rebuild — and one entry-animation replay — is correct.
151
+ function updateTimerDisplay(): void {
152
+ const display = root.querySelector("[data-velumo-presenter-timer]");
153
+ if (display !== null) {
154
+ display.textContent = `Elapsed ${formatElapsed(timer.elapsedMs())}`;
155
+ }
156
+ }
157
+
158
+ const connection = createPresenterSyncConnection(
159
+ options.sync,
160
+ (nextState) => {
161
+ state = nextState;
162
+ render();
163
+ },
164
+ );
165
+ sync = connection.sync;
166
+ commandPublisher = connection.commandPublisher;
167
+
168
+ render();
169
+ const stopTimerUpdates = startTimerUpdates(options, updateTimerDisplay);
170
+
171
+ return {
172
+ dispose() {
173
+ stopTimerUpdates?.();
174
+ sync?.dispose();
175
+ for (const h of currentHandles) h.dispose();
176
+ currentHandles = [];
177
+ },
178
+ manifest,
179
+ sync,
180
+ timer,
181
+ };
182
+ } catch (error) {
183
+ renderPresenterError(root, error);
184
+ return undefined;
185
+ }
186
+ }
187
+
188
+ interface PresenterRenderOptions {
189
+ readonly commandPublisher: PresenterCommandPublisher | undefined;
190
+ readonly manifest: Manifest;
191
+ readonly notes: ReturnType<typeof createSpeakerNotesModel>;
192
+ readonly state: RuntimeState;
193
+ readonly statusText: string;
194
+ readonly timer: PresenterTimer;
195
+ readonly mountThumbnail?: MountThumbnailFn;
196
+ readonly handles: Array<ThumbnailHandle>;
197
+ }
198
+
199
+ function renderPresenter(
200
+ root: PresenterRoot,
201
+ options: PresenterRenderOptions,
202
+ ): void {
203
+ root.setAttribute("data-velumo-presenter", "mounted");
204
+ root.replaceChildren(
205
+ presenterHeader(root.ownerDocument, options),
206
+ presenterSlidePanel(root, options),
207
+ presenterNotesPanel(root.ownerDocument, options),
208
+ presenterControls(root.ownerDocument, options),
209
+ );
210
+ }
211
+
212
+ function presenterHeader(
213
+ document: PresenterDocument,
214
+ options: PresenterRenderOptions,
215
+ ): PresenterElement {
216
+ const header = createElement(document, "header");
217
+ header.setAttribute("data-velumo-presenter-header", "true");
218
+
219
+ const title = textElement(document, "h1", "Presenter View");
220
+ const deck = textElement(document, "p", options.manifest.deck.title);
221
+ const status = textElement(document, "p", options.statusText);
222
+ const timer = textElement(
223
+ document,
224
+ "p",
225
+ `Elapsed ${formatElapsed(options.timer.elapsedMs())}`,
226
+ );
227
+ timer.setAttribute("data-velumo-presenter-timer", "true");
228
+
229
+ header.append(title, deck, status, timer);
230
+ return header;
231
+ }
232
+
233
+ function presenterSlidePanel(
234
+ root: PresenterRoot,
235
+ options: PresenterRenderOptions,
236
+ ): PresenterElement {
237
+ const panel = createElement(root.ownerDocument, "section");
238
+ panel.setAttribute("data-velumo-presenter-slides", "true");
239
+ panel.append(
240
+ previewSection(
241
+ root,
242
+ "Current",
243
+ options.manifest,
244
+ options.state.slideId,
245
+ {
246
+ "data-velumo-presenter-current": "true",
247
+ },
248
+ options.mountThumbnail,
249
+ options.handles,
250
+ ),
251
+ nextPreviewSection(root, options),
252
+ );
253
+ return panel;
254
+ }
255
+
256
+ function nextPreviewSection(
257
+ root: PresenterRoot,
258
+ options: PresenterRenderOptions,
259
+ ): PresenterElement {
260
+ const nextSlide = options.manifest.slides[options.state.slideIndex + 1];
261
+
262
+ if (nextSlide === undefined) {
263
+ const section = createElement(root.ownerDocument, "section");
264
+ section.setAttribute("data-velumo-presenter-next", "true");
265
+ section.append(textElement(root.ownerDocument, "h2", "Next"));
266
+ section.append(textElement(root.ownerDocument, "p", "No next slide"));
267
+ return section;
268
+ }
269
+
270
+ return previewSection(
271
+ root,
272
+ "Next",
273
+ options.manifest,
274
+ nextSlide.id,
275
+ {
276
+ "data-velumo-presenter-next": "true",
277
+ },
278
+ options.mountThumbnail,
279
+ options.handles,
280
+ );
281
+ }
282
+
283
+ function previewSection(
284
+ root: PresenterRoot,
285
+ label: string,
286
+ manifest: Manifest,
287
+ slideId: string,
288
+ attributes: Record<string, string>,
289
+ mountThumbnail?: MountThumbnailFn,
290
+ handles?: Array<ThumbnailHandle>,
291
+ ): PresenterElement {
292
+ const section = createElement(root.ownerDocument, "section");
293
+ for (const [name, value] of Object.entries(attributes)) {
294
+ section.setAttribute(name, value);
295
+ }
296
+
297
+ section.append(textElement(root.ownerDocument, "h2", label));
298
+
299
+ const slide = slideById(manifest, slideId);
300
+ if (slide !== undefined) {
301
+ section.append(textElement(root.ownerDocument, "p", slideSummary(slide)));
302
+ }
303
+
304
+ const previewRoot = createElement(root.ownerDocument, "div");
305
+ previewRoot.setAttribute("data-velumo-presenter-preview", "true");
306
+
307
+ if (mountThumbnail !== undefined) {
308
+ const handle = mountThumbnail({ root: previewRoot, manifest, slideId });
309
+ handles?.push(handle);
310
+ } else {
311
+ new DomRenderer({
312
+ root: previewRoot,
313
+ manifest,
314
+ runtime: new RuntimeController(manifest, { initialSlideId: slideId }),
315
+ }).mount();
316
+ }
317
+
318
+ section.append(previewRoot);
319
+
320
+ return section;
321
+ }
322
+
323
+ function presenterNotesPanel(
324
+ document: PresenterDocument,
325
+ options: PresenterRenderOptions,
326
+ ): PresenterElement {
327
+ const section = createElement(document, "section");
328
+ section.setAttribute("data-velumo-presenter-notes", "true");
329
+ section.append(textElement(document, "h2", "Notes"));
330
+
331
+ const entry = options.notes.getForState(options.state);
332
+ if (!entry.hasNotes) {
333
+ section.append(textElement(document, "p", "No notes"));
334
+ return section;
335
+ }
336
+
337
+ for (const block of entry.blocks) {
338
+ section.append(renderNoteBlock(document, block));
339
+ }
340
+
341
+ return section;
342
+ }
343
+
344
+ function renderNoteBlock(
345
+ document: PresenterDocument,
346
+ block: SpeakerNoteBlock,
347
+ ): PresenterElement {
348
+ switch (block.type) {
349
+ case "heading":
350
+ return textElement(document, `h${block.level + 1}`, block.text);
351
+ case "list": {
352
+ const list = createElement(document, "ul");
353
+ for (const item of block.items) {
354
+ list.append(textElement(document, "li", item));
355
+ }
356
+ return list;
357
+ }
358
+ case "code":
359
+ return textElement(document, "pre", block.text);
360
+ case "paragraph":
361
+ return textElement(document, "p", block.text);
362
+ }
363
+ }
364
+
365
+ function presenterControls(
366
+ document: PresenterDocument,
367
+ options: PresenterRenderOptions,
368
+ ): PresenterElement {
369
+ const section = createElement(document, "section");
370
+ section.setAttribute("data-velumo-presenter-controls", "true");
371
+ section.append(
372
+ commandButton(
373
+ document,
374
+ "Previous",
375
+ "data-velumo-presenter-previous-button",
376
+ "previous",
377
+ options,
378
+ ),
379
+ commandButton(
380
+ document,
381
+ "Next",
382
+ "data-velumo-presenter-next-button",
383
+ "next",
384
+ options,
385
+ ),
386
+ commandButton(
387
+ document,
388
+ "Reset timer",
389
+ "data-velumo-presenter-reset-button",
390
+ "resetTimer",
391
+ options,
392
+ ),
393
+ );
394
+ return section;
395
+ }
396
+
397
+ function commandButton(
398
+ document: PresenterDocument,
399
+ label: string,
400
+ attributeName: string,
401
+ command: PresenterCommand,
402
+ options: PresenterRenderOptions,
403
+ ): PresenterElement {
404
+ const button = createElement(document, "button");
405
+ button.setAttribute(attributeName, "true");
406
+ button.textContent = label;
407
+ button.addEventListener?.("click", () => {
408
+ if (command === "resetTimer") {
409
+ options.timer.reset();
410
+ }
411
+ options.commandPublisher?.publishCommand(command);
412
+ });
413
+ return button;
414
+ }
415
+
416
+ function renderPresenterError(root: PresenterRoot, error: unknown): void {
417
+ const section = createElement(root.ownerDocument, "section");
418
+ section.setAttribute("data-velumo-presenter-error", "mount");
419
+ section.append(
420
+ textElement(root.ownerDocument, "h1", "Presenter mount error"),
421
+ );
422
+ section.append(
423
+ textElement(
424
+ root.ownerDocument,
425
+ "p",
426
+ error instanceof Error ? error.message : "Unknown presenter error",
427
+ ),
428
+ );
429
+ root.replaceChildren(section);
430
+ }
431
+
432
+ function slideById(
433
+ manifest: Manifest,
434
+ slideId: string,
435
+ ): SlideEntry | undefined {
436
+ return manifest.slides.find((slide) => slide.id === slideId);
437
+ }
438
+
439
+ function slideSummary(slide: SlideEntry): string {
440
+ const contentLayer = slide.layers?.find(
441
+ (layer) => typeof layer.content === "string",
442
+ );
443
+ const content =
444
+ typeof contentLayer?.content === "string" ? contentLayer.content : slide.id;
445
+ const firstLine = content
446
+ .split(/\n/)
447
+ .map((line) => line.replace(/^#+\s*/, "").trim())
448
+ .find((line) => line.length > 0);
449
+
450
+ return firstLine ?? slide.id;
451
+ }
452
+
453
+ function textElement(
454
+ document: PresenterDocument,
455
+ tagName: string,
456
+ text: string,
457
+ ): PresenterElement {
458
+ const element = createElement(document, tagName);
459
+ element.textContent = text;
460
+ return element;
461
+ }
462
+
463
+ function createElement(
464
+ document: PresenterDocument,
465
+ tagName: string,
466
+ ): PresenterElement {
467
+ return document.createElement(tagName) as PresenterElement;
468
+ }
469
+
470
+ function formatElapsed(elapsedMs: number): string {
471
+ const totalSeconds = Math.floor(elapsedMs / 1000);
472
+ const minutes = Math.floor(totalSeconds / 60);
473
+ const seconds = totalSeconds % 60;
474
+ return `${minutes}:${seconds.toString().padStart(2, "0")}`;
475
+ }
476
+
477
+ function isCommandPublisher(
478
+ sync: PresenterMountOptions["sync"],
479
+ ): sync is PresenterCommandPublisher {
480
+ return (
481
+ sync !== false &&
482
+ sync !== undefined &&
483
+ "publishCommand" in sync &&
484
+ typeof sync.publishCommand === "function"
485
+ );
486
+ }
487
+
488
+ function isInjectedSync(
489
+ sync: PresenterMountOptions["sync"],
490
+ ): sync is PresenterInjectedSync {
491
+ return (
492
+ sync !== false &&
493
+ sync !== undefined &&
494
+ "channel" in sync &&
495
+ "instanceId" in sync
496
+ );
497
+ }
498
+
499
+ function createPresenterSyncConnection(
500
+ syncOption: PresenterMountOptions["sync"],
501
+ onState: (state: RuntimeState) => void,
502
+ ): PresenterSyncConnection {
503
+ if (syncOption === false) {
504
+ return {};
505
+ }
506
+
507
+ if (isInjectedSync(syncOption)) {
508
+ return connectPresenterSync(syncOption, onState);
509
+ }
510
+
511
+ if (isCommandPublisher(syncOption)) {
512
+ return { commandPublisher: syncOption };
513
+ }
514
+
515
+ const injectedSync = createDefaultSync();
516
+ if (injectedSync === undefined) {
517
+ return {};
518
+ }
519
+
520
+ return connectPresenterSync(injectedSync, onState);
521
+ }
522
+
523
+ function connectPresenterSync(
524
+ injectedSync: PresenterInjectedSync,
525
+ onState: (state: RuntimeState) => void,
526
+ ): PresenterSyncConnection {
527
+ const sync = createPresenterSync({
528
+ channel: injectedSync.channel,
529
+ instanceId: injectedSync.instanceId,
530
+ role: "presenter",
531
+ onState,
532
+ });
533
+
534
+ return { commandPublisher: sync, sync };
535
+ }
536
+
537
+ function startTimerUpdates(
538
+ options: PresenterMountOptions,
539
+ onTick: () => void,
540
+ ): (() => void) | undefined {
541
+ const scheduler = options.timerScheduler ?? defaultTimerScheduler();
542
+
543
+ if (scheduler === undefined) {
544
+ return undefined;
545
+ }
546
+
547
+ const intervalId = scheduler.setInterval(
548
+ onTick,
549
+ options.timerIntervalMs ?? 1000,
550
+ );
551
+ (intervalId as { unref?: () => void }).unref?.();
552
+
553
+ return () => scheduler.clearInterval?.(intervalId);
554
+ }
555
+
556
+ function defaultTimerScheduler(): PresenterTimerScheduler | undefined {
557
+ const setIntervalRef = globalThis.setInterval;
558
+ const clearIntervalRef = globalThis.clearInterval;
559
+
560
+ if (setIntervalRef === undefined || clearIntervalRef === undefined) {
561
+ return undefined;
562
+ }
563
+
564
+ return {
565
+ setInterval(handler, ms) {
566
+ return setIntervalRef(handler, ms);
567
+ },
568
+ clearInterval(id) {
569
+ clearIntervalRef(id as ReturnType<typeof setInterval>);
570
+ },
571
+ };
572
+ }
573
+
574
+ function createDefaultSync(): PresenterInjectedSync | undefined {
575
+ const channel = createDefaultChannel();
576
+
577
+ return channel === undefined
578
+ ? undefined
579
+ : {
580
+ channel,
581
+ instanceId: createInstanceId(),
582
+ };
583
+ }
584
+
585
+ function createDefaultChannel(): PresenterSyncChannel | undefined {
586
+ const BroadcastChannelCtor = (globalThis as PresenterWindowGlobals)
587
+ .BroadcastChannel;
588
+ if (BroadcastChannelCtor === undefined) {
589
+ return undefined;
590
+ }
591
+
592
+ const channel = new BroadcastChannelCtor("velumo-presenter");
593
+ channel.unref?.();
594
+ return channel;
595
+ }
596
+
597
+ function createInstanceId(): string {
598
+ return (
599
+ (globalThis as PresenterWindowGlobals).crypto?.randomUUID?.() ??
600
+ `presenter-${Math.random().toString(36).slice(2)}`
601
+ );
602
+ }