lumina-slides 8.9.4 → 9.0.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/LUMINA_LLM_EXAMPLES.json +234 -0
- package/README.md +18 -18
- package/dist/lumina-slides.js +13207 -12659
- package/dist/lumina-slides.umd.cjs +215 -215
- package/dist/style.css +1 -1
- package/package.json +5 -4
- package/src/App.vue +16 -0
- package/src/animation/index.ts +11 -0
- package/src/animation/registry.ts +126 -0
- package/src/animation/stagger.ts +95 -0
- package/src/animation/types.ts +53 -0
- package/src/components/LandingPage.vue +229 -0
- package/src/components/LuminaDeck.vue +224 -0
- package/src/components/LuminaSpeakerNotes.vue +701 -0
- package/src/components/base/BaseSlide.vue +122 -0
- package/src/components/base/LuminaElement.vue +67 -0
- package/src/components/base/VideoPlayer.vue +204 -0
- package/src/components/layouts/LayoutAuto.vue +71 -0
- package/src/components/layouts/LayoutChart.vue +287 -0
- package/src/components/layouts/LayoutCustom.vue +92 -0
- package/src/components/layouts/LayoutDiagram.vue +253 -0
- package/src/components/layouts/LayoutFeatures.vue +121 -0
- package/src/components/layouts/LayoutFlex.vue +172 -0
- package/src/components/layouts/LayoutFree.vue +62 -0
- package/src/components/layouts/LayoutHalf.vue +127 -0
- package/src/components/layouts/LayoutStatement.vue +74 -0
- package/src/components/layouts/LayoutSteps.vue +106 -0
- package/src/components/layouts/LayoutTimeline.vue +104 -0
- package/src/components/layouts/LayoutVideo.vue +41 -0
- package/src/components/parts/FlexBullets.vue +45 -0
- package/src/components/parts/FlexButton.vue +132 -0
- package/src/components/parts/FlexImage.vue +54 -0
- package/src/components/parts/FlexOrdered.vue +44 -0
- package/src/components/parts/FlexSpacer.vue +13 -0
- package/src/components/parts/FlexStepper.vue +59 -0
- package/src/components/parts/FlexText.vue +29 -0
- package/src/components/parts/FlexTimeline.vue +67 -0
- package/src/components/parts/FlexTitle.vue +39 -0
- package/src/components/parts/LuminaBackground.vue +100 -0
- package/src/components/site/LivePreview.vue +101 -0
- package/src/components/site/SiteApi.vue +301 -0
- package/src/components/site/SiteDashboard.vue +604 -0
- package/src/components/site/SiteDocs.vue +3267 -0
- package/src/components/site/SiteExamples.vue +65 -0
- package/src/components/site/SiteFooter.vue +6 -0
- package/src/components/site/SiteHome.vue +362 -0
- package/src/components/site/SiteNavBar.vue +122 -0
- package/src/components/site/SitePlayground.vue +389 -0
- package/src/components/site/SitePromptBuilder.vue +266 -0
- package/src/components/site/SiteUserMenu.vue +90 -0
- package/src/components/studio/ActionEditor.vue +108 -0
- package/src/components/studio/ArrayEditor.vue +124 -0
- package/src/components/studio/CollapsibleSection.vue +33 -0
- package/src/components/studio/ColorField.vue +22 -0
- package/src/components/studio/EditorCanvas.vue +326 -0
- package/src/components/studio/EditorLayoutFeatures.vue +18 -0
- package/src/components/studio/EditorLayoutFixed.vue +46 -0
- package/src/components/studio/EditorLayoutFlex.vue +133 -0
- package/src/components/studio/EditorLayoutHalf.vue +18 -0
- package/src/components/studio/EditorLayoutStatement.vue +18 -0
- package/src/components/studio/EditorLayoutSteps.vue +18 -0
- package/src/components/studio/EditorLayoutTimeline.vue +18 -0
- package/src/components/studio/EditorNode.vue +89 -0
- package/src/components/studio/FieldEditor.vue +133 -0
- package/src/components/studio/IconPicker.vue +109 -0
- package/src/components/studio/LayerItem.vue +117 -0
- package/src/components/studio/LuminaStudio.vue +30 -0
- package/src/components/studio/SaveSuccessModal.vue +138 -0
- package/src/components/studio/SlideNavigator.vue +373 -0
- package/src/components/studio/SliderField.vue +44 -0
- package/src/components/studio/StudioInspector.vue +595 -0
- package/src/components/studio/StudioJsonEditor.vue +191 -0
- package/src/components/studio/StudioLayers.vue +145 -0
- package/src/components/studio/StudioSettings.vue +514 -0
- package/src/components/studio/StudioSidebar.vue +29 -0
- package/src/components/studio/StudioToolbar.vue +222 -0
- package/src/components/studio/fieldLabels.ts +224 -0
- package/src/components/studio/inspectors/DiagramEdgeEditor.vue +77 -0
- package/src/components/studio/inspectors/DiagramNodeEditor.vue +117 -0
- package/src/components/studio/nodes/StudioDiagramNode.vue +138 -0
- package/src/composables/useAuth.ts +87 -0
- package/src/composables/useEditor.ts +224 -0
- package/src/composables/useElementState.ts +81 -0
- package/src/composables/useFlexLayout.ts +122 -0
- package/src/composables/useKeyboard.ts +45 -0
- package/src/composables/useLumina.ts +32 -0
- package/src/composables/useStudio.ts +87 -0
- package/src/composables/useSwipeNav.ts +53 -0
- package/src/composables/useTransition.ts +373 -0
- package/src/core/Lumina.ts +819 -0
- package/src/core/animationConfig.ts +251 -0
- package/src/core/compression.ts +34 -0
- package/src/core/elementController.ts +170 -0
- package/src/core/elementId.ts +27 -0
- package/src/core/elementResolver.ts +207 -0
- package/src/core/events.ts +53 -0
- package/src/core/fonts.ts +100 -0
- package/src/core/presets.ts +231 -0
- package/src/core/prompts.ts +272 -0
- package/src/core/schema.ts +478 -0
- package/src/core/speaker-channel.ts +250 -0
- package/src/core/store.ts +461 -0
- package/src/core/theme.ts +666 -0
- package/src/core/types.ts +1611 -0
- package/src/directives/vStudio.ts +45 -0
- package/src/index.ts +175 -0
- package/src/main.ts +17 -0
- package/src/router/index.ts +92 -0
- package/src/style/main.css +462 -0
- package/src/utils/deep.ts +127 -0
- package/src/utils/firebase.ts +184 -0
- package/src/utils/streaming.ts +134 -0
- package/src/views/DashboardView.vue +32 -0
- package/src/views/DeckView.vue +289 -0
- package/src/views/HomeView.vue +17 -0
- package/src/views/SiteLayout.vue +21 -0
- package/src/views/StudioView.vue +61 -0
- package/src/vite-env.d.ts +6 -0
- package/IMPLEMENTATION.md +0 -418
|
@@ -0,0 +1,819 @@
|
|
|
1
|
+
import { createApp, App as VueApp, watch } from 'vue';
|
|
2
|
+
import LuminaDeck from '../components/LuminaDeck.vue';
|
|
3
|
+
import LuminaStudio from '../components/studio/LuminaStudio.vue';
|
|
4
|
+
import LuminaSpeakerNotes from '../components/LuminaSpeakerNotes.vue';
|
|
5
|
+
import LayoutStatement from '../components/layouts/LayoutStatement.vue';
|
|
6
|
+
import LayoutHalf from '../components/layouts/LayoutHalf.vue';
|
|
7
|
+
import LayoutFeatures from '../components/layouts/LayoutFeatures.vue';
|
|
8
|
+
import LayoutTimeline from '../components/layouts/LayoutTimeline.vue';
|
|
9
|
+
import LayoutSteps from '../components/layouts/LayoutSteps.vue';
|
|
10
|
+
import LayoutFlex from '../components/layouts/LayoutFlex.vue';
|
|
11
|
+
import LayoutAuto from '../components/layouts/LayoutAuto.vue';
|
|
12
|
+
import LayoutCustom from '../components/layouts/LayoutCustom.vue';
|
|
13
|
+
import LayoutChart from '../components/layouts/LayoutChart.vue';
|
|
14
|
+
import LayoutVideo from '../components/layouts/LayoutVideo.vue';
|
|
15
|
+
import LayoutDiagram from '../components/layouts/LayoutDiagram.vue';
|
|
16
|
+
import LayoutFree from '../components/layouts/LayoutFree.vue';
|
|
17
|
+
import LuminaElement from '../components/base/LuminaElement.vue';
|
|
18
|
+
import { createStore, StoreKey, LuminaStore } from './store';
|
|
19
|
+
import { resolveTransitionConfig } from './animationConfig';
|
|
20
|
+
import { computeStagger, resolveAnimationFromInput } from '../animation';
|
|
21
|
+
import { createElementController } from './elementController';
|
|
22
|
+
import { parsePath, resolveId, getElementIds } from './elementResolver';
|
|
23
|
+
import { elemId } from './elementId';
|
|
24
|
+
import type { ElementPath } from './elementResolver';
|
|
25
|
+
import { bus } from './events';
|
|
26
|
+
import { ThemeManager, deepMerge } from './theme';
|
|
27
|
+
import { SpeakerChannel } from './speaker-channel';
|
|
28
|
+
import type { Deck, LuminaOptions, LuminaEventMap, LuminaDataStore, SpeakerSyncPayload, BaseSlideData, ElementController, RevealInSequenceOptions, TimelineTracks, ThemeConfig } from './types';
|
|
29
|
+
import '../style/main.css';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Lumina Engine — presentation renderer from declarative JSON. Main entry point for the library.
|
|
33
|
+
*
|
|
34
|
+
* @description
|
|
35
|
+
* Mount a slide deck into a DOM element, load/patch deck data, subscribe to events (slideChange, action),
|
|
36
|
+
* and export state for LLM context. Vanilla JS: use `engine.load(deck)`, `engine.element(id)`, `engine.on(...)`.
|
|
37
|
+
* For reveal-on-demand: set `meta.initialElementState` and `meta.elementControl.defaultVisible: false`;
|
|
38
|
+
* register `ready` and `slideChange` before `load`; then `engine.element(id).show()` or `revealInSequence`.
|
|
39
|
+
* `engine.element(id).animate()` and `getElementById` require the element's slide to be mounted (no-op/null otherwise).
|
|
40
|
+
*
|
|
41
|
+
* @example
|
|
42
|
+
* ```ts
|
|
43
|
+
* import { Lumina } from "lumina-slides";
|
|
44
|
+
* import "lumina-slides/style.css";
|
|
45
|
+
*
|
|
46
|
+
* const engine = new Lumina("#app", { theme: "ocean", loop: true });
|
|
47
|
+
* engine.on("ready", () => engine.revealInSequence(0));
|
|
48
|
+
* engine.on("slideChange", (e) => engine.revealInSequence(e.index));
|
|
49
|
+
* engine.load({ meta: { title: "Demo", elementControl: { defaultVisible: false } }, slides: [
|
|
50
|
+
* { type: "statement", title: "Hello", subtitle: "World" }
|
|
51
|
+
* ]});
|
|
52
|
+
* engine.on("action", (p) => console.log("clicked", p));
|
|
53
|
+
* ```
|
|
54
|
+
*
|
|
55
|
+
* @see Deck
|
|
56
|
+
* @see LuminaOptions
|
|
57
|
+
* @see ElementController
|
|
58
|
+
* @see resolveTransitionConfig
|
|
59
|
+
* @see IMPLEMENTATION.md
|
|
60
|
+
* @see AGENTS.md
|
|
61
|
+
*/
|
|
62
|
+
export class Lumina {
|
|
63
|
+
private app: VueApp;
|
|
64
|
+
private store: LuminaStore;
|
|
65
|
+
|
|
66
|
+
/** Key-value store: `engine.data.get/set/has/delete/clear/keys`. Persists across deck loads. */
|
|
67
|
+
public readonly data: LuminaDataStore;
|
|
68
|
+
|
|
69
|
+
// Speaker Notes state
|
|
70
|
+
private speakerWindow: Window | null = null;
|
|
71
|
+
private speakerChannel: SpeakerChannel | null = null;
|
|
72
|
+
private speakerChannelId: string;
|
|
73
|
+
private speakerUnsubscribe: (() => void) | null = null;
|
|
74
|
+
|
|
75
|
+
// Timeline (Remotion-style) state
|
|
76
|
+
private _timelineProgress = 0;
|
|
77
|
+
private _timelineCancel: (() => void) | null = null;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Creates a new Lumina instance and mounts the presentation UI.
|
|
81
|
+
*
|
|
82
|
+
* @param selector - CSS selector for the mount target (e.g. `"#app"`, `".deck"`).
|
|
83
|
+
* @param options - Optional: theme, loop, navigation, touch, studio, etc. See {@link LuminaOptions}.
|
|
84
|
+
*/
|
|
85
|
+
constructor(public selector: string, options: LuminaOptions = {}) {
|
|
86
|
+
// Generate unique channel ID for this instance
|
|
87
|
+
this.speakerChannelId = `lumina-speaker-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
88
|
+
|
|
89
|
+
// Create isolated store for this instance
|
|
90
|
+
this.store = createStore(options);
|
|
91
|
+
|
|
92
|
+
const dataApi: LuminaDataStore = {
|
|
93
|
+
get: (k) => this.store.getUserData(k),
|
|
94
|
+
set: (k, v) => { this.store.setUserData(k, v); return dataApi; },
|
|
95
|
+
has: (k) => this.store.hasUserData(k),
|
|
96
|
+
delete: (k) => this.store.deleteUserData(k),
|
|
97
|
+
clear: () => this.store.clearUserData(),
|
|
98
|
+
keys: () => Object.keys(this.store.state.userData)
|
|
99
|
+
};
|
|
100
|
+
this.data = dataApi;
|
|
101
|
+
|
|
102
|
+
// Apply initial theme
|
|
103
|
+
if (options.theme) {
|
|
104
|
+
ThemeManager.inject(options.theme);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Initialize Vue App
|
|
108
|
+
this.app = options.studio ? createApp(LuminaStudio) : createApp(LuminaDeck);
|
|
109
|
+
|
|
110
|
+
// Dependency Injection
|
|
111
|
+
this.app.provide(StoreKey, this.store);
|
|
112
|
+
this.app.provide('LuminaEngine', this);
|
|
113
|
+
|
|
114
|
+
// Watch for state changes and emit public events
|
|
115
|
+
watch(() => this.store.state.currentIndex, (newIndex, oldIndex) => {
|
|
116
|
+
this.store.resetElementStateForSlide(newIndex);
|
|
117
|
+
const currentSlide = this.store.state.deck?.slides[newIndex];
|
|
118
|
+
if (currentSlide) {
|
|
119
|
+
bus.emit('slideChange', {
|
|
120
|
+
index: newIndex,
|
|
121
|
+
previousIndex: oldIndex ?? 0,
|
|
122
|
+
slide: currentSlide
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// Watch for metadata changes (Theme, Orb, etc.)
|
|
128
|
+
watch(() => this.store.state.deck?.meta, (newMeta) => {
|
|
129
|
+
if (newMeta) {
|
|
130
|
+
// 1. Construct Theme Overrides from Meta
|
|
131
|
+
// - meta.themeConfig: full ThemeConfig object (e.g. from theme-custom-example.json)
|
|
132
|
+
// - meta.colors, meta.typography, etc.: direct overrides from Studio (take precedence)
|
|
133
|
+
const fromThemeConfig = (newMeta as { themeConfig?: object }).themeConfig || {};
|
|
134
|
+
const direct = {
|
|
135
|
+
colors: newMeta.colors,
|
|
136
|
+
typography: newMeta.typography,
|
|
137
|
+
spacing: newMeta.spacing,
|
|
138
|
+
borderRadius: newMeta.borderRadius,
|
|
139
|
+
effects: newMeta.effects,
|
|
140
|
+
components: newMeta.components
|
|
141
|
+
};
|
|
142
|
+
const overrides = deepMerge(fromThemeConfig as Record<string, unknown>, direct as Record<string, unknown>) as ThemeConfig;
|
|
143
|
+
|
|
144
|
+
// Resolve theme: deck.theme (top-level, e.g. from theme-ocean.json) takes precedence over
|
|
145
|
+
// meta.theme, then constructor options. Ensures examples and loaded decks keep their theme.
|
|
146
|
+
const deck = this.store.state.deck;
|
|
147
|
+
const themeOrName = deck?.theme ?? newMeta.theme ?? this.store.state.options?.theme ?? 'default';
|
|
148
|
+
|
|
149
|
+
// 2. Dynamic Theme Application
|
|
150
|
+
// This ensures that any change in Studio (which updates deck.meta) is immediately reflected
|
|
151
|
+
// via CSS variables injected by ThemeManager.
|
|
152
|
+
ThemeManager.inject(themeOrName, overrides);
|
|
153
|
+
|
|
154
|
+
// 3. Fallback/Direct variable handling if needed (e.g. for specific legacy props)
|
|
155
|
+
if (newMeta.orbColor) {
|
|
156
|
+
// Update the primary color variable as a fallback or if specifically mapped
|
|
157
|
+
document.documentElement.style.setProperty('--lumina-colors-primary', newMeta.orbColor);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
bus.emit('themeChange', { theme: typeof themeOrName === 'string' ? themeOrName : 'custom' });
|
|
161
|
+
}
|
|
162
|
+
}, { deep: true });
|
|
163
|
+
|
|
164
|
+
// Register Core Components
|
|
165
|
+
this.app.component('layout-statement', LayoutStatement);
|
|
166
|
+
this.app.component('layout-half', LayoutHalf);
|
|
167
|
+
this.app.component('layout-features', LayoutFeatures);
|
|
168
|
+
this.app.component('layout-timeline', LayoutTimeline);
|
|
169
|
+
this.app.component('layout-steps', LayoutSteps);
|
|
170
|
+
this.app.component('layout-flex', LayoutFlex);
|
|
171
|
+
this.app.component('layout-auto', LayoutAuto);
|
|
172
|
+
this.app.component('layout-custom', LayoutCustom);
|
|
173
|
+
this.app.component('layout-chart', LayoutChart);
|
|
174
|
+
this.app.component('layout-video', LayoutVideo);
|
|
175
|
+
this.app.component('layout-diagram', LayoutDiagram);
|
|
176
|
+
this.app.component('layout-free', LayoutFree);
|
|
177
|
+
this.app.component('LuminaElement', LuminaElement);
|
|
178
|
+
|
|
179
|
+
// Internal Event Listeners
|
|
180
|
+
bus.on('action', (payload) => {
|
|
181
|
+
// Feature: Real-time Slide Updates (e.g. from Diagram Layout)
|
|
182
|
+
if (payload.type === 'slide-update' && typeof payload.slideIndex === 'number') {
|
|
183
|
+
const { slideIndex, patch } = payload;
|
|
184
|
+
if (patch && typeof patch === 'object') {
|
|
185
|
+
if ('nodes' in patch) {
|
|
186
|
+
this.store.updateNode(`slides.${slideIndex}.nodes`, patch.nodes);
|
|
187
|
+
}
|
|
188
|
+
if ('edges' in patch) {
|
|
189
|
+
this.store.updateNode(`slides.${slideIndex}.edges`, patch.edges);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
this.store.recordAction(payload);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
this.app.mount(this.selector);
|
|
197
|
+
|
|
198
|
+
this.syncSpeakerNotes();
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Loads a full deck, replacing any existing content.
|
|
203
|
+
* Use this for initial load or when swapping to a different deck.
|
|
204
|
+
* Emits `error` if the deck is invalid; `ready` on success.
|
|
205
|
+
*
|
|
206
|
+
* @param deckData - Full deck: `{ meta: { title }, slides: [...] }`. See {@link Deck}.
|
|
207
|
+
* @example
|
|
208
|
+
* engine.load({ meta: { title: "Q4 Review" }, slides: [ { type: "statement", title: "Intro" } ] });
|
|
209
|
+
*/
|
|
210
|
+
public load(deckData: Deck) {
|
|
211
|
+
if (!deckData || !Array.isArray((deckData as any)?.slides)) {
|
|
212
|
+
bus.emit('error', new Error('Invalid deck: expected { meta?, slides: [] }'));
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
try {
|
|
216
|
+
this.store.loadDeck(deckData);
|
|
217
|
+
bus.emit('ready', deckData);
|
|
218
|
+
this.syncSpeakerNotes();
|
|
219
|
+
} catch (e) {
|
|
220
|
+
bus.emit('error', e instanceof Error ? e : new Error(String(e)));
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Merges partial changes into the current deck without full reload.
|
|
226
|
+
* Use for incremental updates (e.g. from streaming or edits).
|
|
227
|
+
* Emits `patch` with the diff.
|
|
228
|
+
*
|
|
229
|
+
* @param diff - Partial deck: `meta` and/or `slides`. Deep-merged; `slides` array is replaced if provided.
|
|
230
|
+
* @example
|
|
231
|
+
* engine.patch({ meta: { title: "Updated Title" } });
|
|
232
|
+
*/
|
|
233
|
+
public patch(diff: any) {
|
|
234
|
+
this.store.patchDeck(diff);
|
|
235
|
+
bus.emit('patch', { diff: diff ?? {} });
|
|
236
|
+
this.syncSpeakerNotes();
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Exports current slide index, type, and interaction history for LLM context.
|
|
241
|
+
* Use when feeding "where the user is" back into an agent.
|
|
242
|
+
*
|
|
243
|
+
* @returns Frozen object: `{ status, currentSlide: { index, id, type, title }, narrative, engagementLevel, history }`.
|
|
244
|
+
* @example
|
|
245
|
+
* const state = engine.exportState();
|
|
246
|
+
* // "User is on slide 3 (features). Session: clicked 'Learn More' -> navigated next."
|
|
247
|
+
*/
|
|
248
|
+
public exportState() {
|
|
249
|
+
const { currentIndex, deck, actionHistory } = this.store.state;
|
|
250
|
+
const slide = deck?.slides[currentIndex];
|
|
251
|
+
const context = actionHistory.map(a => `User ${a.type}ed on ${a.label || a.value}`).join(' -> ');
|
|
252
|
+
const interest = actionHistory.length > 5 ? "High Engagement" : "Low Engagement";
|
|
253
|
+
|
|
254
|
+
return Object.freeze({
|
|
255
|
+
status: "active",
|
|
256
|
+
currentSlide: {
|
|
257
|
+
index: currentIndex,
|
|
258
|
+
id: slide?.id || currentIndex,
|
|
259
|
+
type: slide?.type,
|
|
260
|
+
title: (slide && 'title' in slide ? (slide as any).title : null) || "(No Title)"
|
|
261
|
+
},
|
|
262
|
+
narrative: `User is currently on slide ${currentIndex + 1}. Session Flow: ${context || "Just started"}.`,
|
|
263
|
+
engagementLevel: interest,
|
|
264
|
+
history: [...actionHistory]
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Subscribes to an engine event. Use for slide changes, button clicks, errors, and lifecycle.
|
|
270
|
+
*
|
|
271
|
+
* @param event - Event name. See {@link LuminaEventMap}: `ready`, `slideChange`, `action`, `error`,
|
|
272
|
+
* `patch`, `destroy`, `navigate`, `themeChange`, `speakerNotesOpen`, `speakerNotesClose`,
|
|
273
|
+
* `timelineSeek`, `timelineComplete`, `revealStart`, `revealComplete`, `revealElement`.
|
|
274
|
+
* @param handler - Callback with typed payload for the event.
|
|
275
|
+
* @example
|
|
276
|
+
* engine.on("slideChange", ({ index, slide }) => console.log(`Slide ${index}: ${slide.type}`));
|
|
277
|
+
* engine.on("action", (p) => { if (p.type === "button") sendToAgent(`Clicked: ${p.label}`); });
|
|
278
|
+
* engine.on("patch", ({ diff }) => console.log("Deck patched", diff));
|
|
279
|
+
*/
|
|
280
|
+
public on<K extends keyof LuminaEventMap>(event: K, handler: (e: LuminaEventMap[K]) => void) {
|
|
281
|
+
bus.on(event, handler);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Unsubscribes a previously added handler for an event.
|
|
286
|
+
*
|
|
287
|
+
* @param event - Event name.
|
|
288
|
+
* @param handler - The same function reference passed to `on`.
|
|
289
|
+
*/
|
|
290
|
+
public off<K extends keyof LuminaEventMap>(event: K, handler: (e: LuminaEventMap[K]) => void) {
|
|
291
|
+
bus.off(event, handler);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Emits an error to subscribers of the `error` event. Use to forward errors from your app (e.g. load failures, API errors).
|
|
296
|
+
*
|
|
297
|
+
* @param err - Error instance to emit.
|
|
298
|
+
*/
|
|
299
|
+
public emitError(err: Error) {
|
|
300
|
+
bus.emit('error', err);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Unmounts the app, clears events, and closes speaker notes. Call when removing the presentation from the page.
|
|
305
|
+
* Emits `destroy` before clearing handlers.
|
|
306
|
+
*/
|
|
307
|
+
public destroy() {
|
|
308
|
+
bus.emit('destroy', {});
|
|
309
|
+
this.closeSpeakerNotes();
|
|
310
|
+
if (this.app) {
|
|
311
|
+
this.app.unmount();
|
|
312
|
+
}
|
|
313
|
+
bus.clear();
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Zero-based index of the currently visible slide. */
|
|
317
|
+
public get currentSlideIndex() {
|
|
318
|
+
return this.store.state.currentIndex;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Jumps to a slide by index.
|
|
323
|
+
* @param index - Zero-based slide index. Clamped to [0, slides.length - 1].
|
|
324
|
+
*/
|
|
325
|
+
public goTo(index: number) {
|
|
326
|
+
const from = this.store.state.currentIndex;
|
|
327
|
+
this.store.goto(index);
|
|
328
|
+
if (this.store.state.currentIndex !== from) {
|
|
329
|
+
bus.emit('navigate', { direction: 'goto', fromIndex: from, toIndex: this.store.state.currentIndex });
|
|
330
|
+
}
|
|
331
|
+
this.syncSpeakerNotes();
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** Advances to the next slide (or first if loop and at end). */
|
|
335
|
+
public next() {
|
|
336
|
+
const from = this.store.state.currentIndex;
|
|
337
|
+
this.store.next();
|
|
338
|
+
if (this.store.state.currentIndex !== from) {
|
|
339
|
+
bus.emit('navigate', { direction: 'next', fromIndex: from, toIndex: this.store.state.currentIndex });
|
|
340
|
+
}
|
|
341
|
+
this.syncSpeakerNotes();
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** Goes to the previous slide (or last if loop and at start). */
|
|
345
|
+
public prev() {
|
|
346
|
+
const from = this.store.state.currentIndex;
|
|
347
|
+
this.store.prev();
|
|
348
|
+
if (this.store.state.currentIndex !== from) {
|
|
349
|
+
bus.emit('navigate', { direction: 'prev', fromIndex: from, toIndex: this.store.state.currentIndex });
|
|
350
|
+
}
|
|
351
|
+
this.syncSpeakerNotes();
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Returns the DOM node with data-lumina-id equal to `id`. Used by ElementController.animate
|
|
356
|
+
* and for direct DOM access. The element exists only when its slide is mounted (e.g. switching
|
|
357
|
+
* slides unmounts the previous slide’s content).
|
|
358
|
+
*
|
|
359
|
+
* @param id - Element id (from resolveId, elemId, or explicit id in the slide JSON).
|
|
360
|
+
* @returns The HTMLElement or null if not found or not yet mounted.
|
|
361
|
+
*
|
|
362
|
+
* @see element
|
|
363
|
+
* @see resolveId
|
|
364
|
+
* @see elemId
|
|
365
|
+
*/
|
|
366
|
+
public getElementById(id: string): HTMLElement | null {
|
|
367
|
+
const container = document.querySelector(this.selector);
|
|
368
|
+
if (!container) return null;
|
|
369
|
+
const root = container.querySelector('[data-lumina-root]') || container;
|
|
370
|
+
const escaped = id.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
371
|
+
return (root.querySelector(`[data-lumina-id="${escaped}"]`) as HTMLElement) || null;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Returns an {@link ElementController} to change one slide element’s state in real time
|
|
376
|
+
* (visibility, opacity, transform, classes, style, GSAP animation). Use for: starting with
|
|
377
|
+
* a blank slide and revealing items in order (via meta.initialElementState + .show()), hiding
|
|
378
|
+
* highlights, or syncing with voice/agent.
|
|
379
|
+
*
|
|
380
|
+
* Overloads:
|
|
381
|
+
* - **element(id: string)** — by resolved or explicit id (e.g. "s0-title", "intro-tag", or a
|
|
382
|
+
* diagram node id). Use when you already have the id (from elements(), getElementIds, or
|
|
383
|
+
* meta.initialElementState).
|
|
384
|
+
* - **element(slideIndex: number, path: string | ElementPath)** — by logical path. path can be
|
|
385
|
+
* a string ("tag", "features.0", "elements.0.elements.1") or an array (['elements', 0, 'elements', 1]).
|
|
386
|
+
* The id is resolved via resolveId; then the same controller is returned as element(id).
|
|
387
|
+
*
|
|
388
|
+
* The target element must be wrapped with LuminaElement or have data-lumina-id (layouts do this
|
|
389
|
+
* via resolveId). If the element is not in the DOM (e.g. another slide), show/hide/opacity/etc.
|
|
390
|
+
* still update the store and apply when the slide is shown; animate() is a no-op until mounted.
|
|
391
|
+
*
|
|
392
|
+
* @param idOrSlide - Element id string, or (when using path) zero-based slide index.
|
|
393
|
+
* @param path - When idOrSlide is a number: path string or ElementPath. Ignored when idOrSlide is string.
|
|
394
|
+
* @returns ElementController (chainable: .show(), .hide(), .opacity(n), .animate({...}), etc.).
|
|
395
|
+
*
|
|
396
|
+
* @example
|
|
397
|
+
* engine.element('s0-title').hide();
|
|
398
|
+
* engine.element(0, 'tag').show();
|
|
399
|
+
* engine.element(1, 'features.0').opacity(0.5).animate({ to: { opacity: 1 }, duration: 0.6 });
|
|
400
|
+
* engine.element(2, ['elements', 0, 'elements', 1]).show();
|
|
401
|
+
* // With meta.initialElementState hiding s0-tag, s0-title, s0-subtitle:
|
|
402
|
+
* engine.element(0, 'tag').show();
|
|
403
|
+
* await delay(400);
|
|
404
|
+
* engine.element(0, 'title').show().animate({ from: { y: 20, opacity: 0 }, to: { y: 0, opacity: 1 }, duration: 0.5 });
|
|
405
|
+
*
|
|
406
|
+
* @see elements
|
|
407
|
+
* @see getElementById
|
|
408
|
+
* @see resolveId
|
|
409
|
+
* @see getElementIds
|
|
410
|
+
* @see ElementController
|
|
411
|
+
* @see DeckMeta.initialElementState
|
|
412
|
+
*/
|
|
413
|
+
public element(id: string): ElementController;
|
|
414
|
+
public element(slideIndex: number, path: string | ElementPath): ElementController;
|
|
415
|
+
public element(idOrSlide: string | number, path?: string | ElementPath): ElementController {
|
|
416
|
+
if (typeof idOrSlide === 'number') {
|
|
417
|
+
const slide = this.store.state.deck?.slides[idOrSlide];
|
|
418
|
+
const p = parsePath(path!);
|
|
419
|
+
const id = slide ? resolveId(slide, idOrSlide, p) : elemId(idOrSlide, ...p);
|
|
420
|
+
return createElementController(this.store, this, id);
|
|
421
|
+
}
|
|
422
|
+
return createElementController(this.store, this, idOrSlide);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Returns all element ids for a slide. Use to discover which ids exist (e.g. for
|
|
427
|
+
* meta.initialElementState, or to iterate and control elements). Each id can be passed to
|
|
428
|
+
* engine.element(id). Uses the same path→id logic as element(slideIndex, path); ids follow
|
|
429
|
+
* resolveId (explicit id, slide.ids, or elemId(slideIndex, ...path)).
|
|
430
|
+
*
|
|
431
|
+
* @param slideIndex - Zero-based slide index. If out of range or deck not loaded, returns [].
|
|
432
|
+
* @returns Array of id strings (e.g. ["s0-slide","s0-tag","s0-title","s0-subtitle"] for a statement slide).
|
|
433
|
+
*
|
|
434
|
+
* @example
|
|
435
|
+
* const ids = engine.elements(0);
|
|
436
|
+
* ids.forEach(id => { if (id.endsWith('-title')) engine.element(id).hide(); });
|
|
437
|
+
*
|
|
438
|
+
* @see element
|
|
439
|
+
* @see getElementIds
|
|
440
|
+
* @see resolveId
|
|
441
|
+
* @see DeckMeta.initialElementState
|
|
442
|
+
*/
|
|
443
|
+
public elements(slideIndex: number): string[] {
|
|
444
|
+
const slide = this.store.state.deck?.slides[slideIndex];
|
|
445
|
+
if (!slide) return [];
|
|
446
|
+
return getElementIds(slide, slideIndex);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Hides all controlled elements of a slide. Use for blank-slide or reset-before-reveal flows.
|
|
451
|
+
*
|
|
452
|
+
* @param slideIndex - Zero-based slide index. Omit to use the current slide.
|
|
453
|
+
* @returns this (chainable).
|
|
454
|
+
*
|
|
455
|
+
* @example
|
|
456
|
+
* engine.hideAll(); // current slide
|
|
457
|
+
* engine.hideAll(2); await engine.delay(300); engine.revealInSequence(2);
|
|
458
|
+
*/
|
|
459
|
+
public hideAll(slideIndex?: number): this {
|
|
460
|
+
const i = slideIndex ?? this.currentSlideIndex;
|
|
461
|
+
this.elements(i).forEach((id) => this.element(id).hide());
|
|
462
|
+
return this;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Shows all controlled elements of a slide.
|
|
467
|
+
*
|
|
468
|
+
* @param slideIndex - Zero-based slide index. Omit to use the current slide.
|
|
469
|
+
* @returns this (chainable).
|
|
470
|
+
*/
|
|
471
|
+
public showAll(slideIndex?: number): this {
|
|
472
|
+
const i = slideIndex ?? this.currentSlideIndex;
|
|
473
|
+
this.elements(i).forEach((id) => this.element(id).show());
|
|
474
|
+
return this;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Hides all elements of the slide, then shows only the given ids. Use for focus mode or
|
|
479
|
+
* spotlight effects.
|
|
480
|
+
*
|
|
481
|
+
* @param ids - Element ids to keep visible.
|
|
482
|
+
* @param slideIndex - Zero-based slide index. Omit to use the current slide.
|
|
483
|
+
* @returns this (chainable).
|
|
484
|
+
*
|
|
485
|
+
* @example
|
|
486
|
+
* engine.showOnly(['s1-title', 's1-tag'], 1);
|
|
487
|
+
*/
|
|
488
|
+
public showOnly(ids: string[], slideIndex?: number): this {
|
|
489
|
+
const i = slideIndex ?? this.currentSlideIndex;
|
|
490
|
+
this.hideAll(i);
|
|
491
|
+
ids.forEach((id) => this.element(id).show());
|
|
492
|
+
return this;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Hides all elements except the given ids (convenience for hideAll + show on exceptIds).
|
|
497
|
+
*
|
|
498
|
+
* @param exceptIds - Element ids to keep visible.
|
|
499
|
+
* @param slideIndex - Zero-based slide index. Omit to use the current slide.
|
|
500
|
+
* @returns this (chainable).
|
|
501
|
+
*/
|
|
502
|
+
public hideAllExcept(exceptIds: string[], slideIndex?: number): this {
|
|
503
|
+
return this.showOnly(exceptIds, slideIndex);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Resets element state for a slide to the initial values (as when entering the slide).
|
|
508
|
+
* Respects meta.initialElementState and meta.elementControl.defaultVisible. Use before
|
|
509
|
+
* running a reveal sequence again.
|
|
510
|
+
*
|
|
511
|
+
* @param slideIndex - Zero-based slide index. Omit to use the current slide.
|
|
512
|
+
* @returns this (chainable).
|
|
513
|
+
*/
|
|
514
|
+
public resetSlide(slideIndex?: number): this {
|
|
515
|
+
this.store.resetElementStateForSlide(slideIndex ?? this.currentSlideIndex);
|
|
516
|
+
return this;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* Returns a Promise that resolves after the given milliseconds. Useful for chaining
|
|
521
|
+
* with async/await between reveal steps or after hideAll.
|
|
522
|
+
*
|
|
523
|
+
* @param ms - Delay in milliseconds.
|
|
524
|
+
*
|
|
525
|
+
* @example
|
|
526
|
+
* engine.hideAll(0); await engine.delay(500); engine.revealInSequence(0);
|
|
527
|
+
*/
|
|
528
|
+
public delay(ms: number): Promise<void> {
|
|
529
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Reveals slide elements one by one with optional stagger and animation. Optionally
|
|
534
|
+
* hides all first (hideFirst: true). Resolves when the sequence has been scheduled
|
|
535
|
+
* (does not wait for the last animation to finish).
|
|
536
|
+
*
|
|
537
|
+
* @param slideIndex - Zero-based slide index. Omit to use the current slide.
|
|
538
|
+
* @param options - Stagger delay, animation from/to, and filters (only, exclude).
|
|
539
|
+
* @returns Promise that resolves after the last element's scheduled reveal time.
|
|
540
|
+
*
|
|
541
|
+
* @example
|
|
542
|
+
* engine.on('slideChange', (e) => { engine.revealInSequence(e.index, { delayMs: 500 }); });
|
|
543
|
+
* await engine.revealInSequence(0, { only: ['s0-tag', 's0-title'], hideFirst: true });
|
|
544
|
+
*/
|
|
545
|
+
public revealInSequence(slideIndex?: number, options?: RevealInSequenceOptions): Promise<void> {
|
|
546
|
+
const i = slideIndex ?? this.currentSlideIndex;
|
|
547
|
+
let ids = this.elements(i);
|
|
548
|
+
const opts = options ?? {};
|
|
549
|
+
if (opts.only?.length) ids = ids.filter((id) => opts.only!.includes(id));
|
|
550
|
+
if (opts.exclude?.length) ids = ids.filter((id) => !opts.exclude!.includes(id));
|
|
551
|
+
|
|
552
|
+
const slide = this.store.state.deck?.slides[i];
|
|
553
|
+
const cfg = resolveTransitionConfig(this.store, slide ?? undefined);
|
|
554
|
+
const delayMs = opts.delayMs ?? cfg.revealDelayMs;
|
|
555
|
+
const animate = opts.animate !== false;
|
|
556
|
+
|
|
557
|
+
const resolved = resolveAnimationFromInput(
|
|
558
|
+
{
|
|
559
|
+
preset: opts.preset,
|
|
560
|
+
motion: opts.motion,
|
|
561
|
+
from: opts.from,
|
|
562
|
+
to: opts.to,
|
|
563
|
+
ease: opts.ease,
|
|
564
|
+
duration: opts.duration,
|
|
565
|
+
},
|
|
566
|
+
{ from: { opacity: 0, y: 16 }, to: { opacity: 1, y: 0 }, ease: cfg.revealEase, duration: cfg.revealDuration }
|
|
567
|
+
);
|
|
568
|
+
|
|
569
|
+
const steps = computeStagger({
|
|
570
|
+
ids,
|
|
571
|
+
delayMs,
|
|
572
|
+
mode: opts.staggerMode ?? 'sequential',
|
|
573
|
+
randomSeed: opts.randomSeed ?? i,
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
if (opts.hideFirst !== false) this.hideAll(i);
|
|
577
|
+
|
|
578
|
+
return new Promise((resolve) => {
|
|
579
|
+
bus.emit('revealStart', { slideIndex: i, elementIds: steps.map((s) => s.id) });
|
|
580
|
+
if (steps.length === 0) {
|
|
581
|
+
bus.emit('revealComplete', { slideIndex: i });
|
|
582
|
+
resolve();
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
steps.forEach((step, idx) => {
|
|
586
|
+
setTimeout(() => {
|
|
587
|
+
bus.emit('revealElement', { slideIndex: i, elementId: step.id, index: idx });
|
|
588
|
+
this.element(step.id).show();
|
|
589
|
+
if (animate) {
|
|
590
|
+
this.element(step.id).animate({
|
|
591
|
+
from: resolved.from,
|
|
592
|
+
to: resolved.to,
|
|
593
|
+
duration: resolved.duration,
|
|
594
|
+
ease: resolved.ease,
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
}, step.delayMs);
|
|
598
|
+
});
|
|
599
|
+
const mode = opts.staggerMode ?? 'sequential';
|
|
600
|
+
const totalDelay =
|
|
601
|
+
mode === 'sequential' ? steps.length * delayMs : (steps.length ? Math.max(...steps.map((s) => s.delayMs)) : 0);
|
|
602
|
+
setTimeout(() => {
|
|
603
|
+
bus.emit('revealComplete', { slideIndex: i });
|
|
604
|
+
resolve();
|
|
605
|
+
}, totalDelay);
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* Current timeline progress 0–1. Set by {@link seekTo} and {@link playTimeline}.
|
|
611
|
+
*/
|
|
612
|
+
public get timelineProgress(): number {
|
|
613
|
+
return this._timelineProgress;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* Seek the slide's timeline to a progress 0–1. Applies keyframes from `slide.timelineTracks` to each
|
|
618
|
+
* element via `element(id).animateToProgress(progress, keyframes)`. No-op if the slide has no
|
|
619
|
+
* `timelineTracks`. Remotion-style: scrub the composition.
|
|
620
|
+
*
|
|
621
|
+
* @param progress - 0–1. Interpolation between keyframes is linear.
|
|
622
|
+
* @param slideIndex - Omit to use the current slide.
|
|
623
|
+
*
|
|
624
|
+
* @example
|
|
625
|
+
* engine.seekTo(0); // reset to first keyframes
|
|
626
|
+
* engine.seekTo(0.5); // halfway
|
|
627
|
+
* slider.oninput = () => engine.seekTo(parseFloat(slider.value));
|
|
628
|
+
*/
|
|
629
|
+
public seekTo(progress: number, slideIndex?: number): this {
|
|
630
|
+
const i = slideIndex ?? this.currentSlideIndex;
|
|
631
|
+
const slide = this.store.state.deck?.slides[i] as BaseSlideData & { timelineTracks?: TimelineTracks } | undefined;
|
|
632
|
+
const timeline = slide?.timelineTracks;
|
|
633
|
+
this._timelineProgress = Math.max(0, Math.min(1, progress));
|
|
634
|
+
bus.emit('timelineSeek', { progress: this._timelineProgress, slideIndex: i });
|
|
635
|
+
if (!timeline || typeof timeline !== 'object') return this;
|
|
636
|
+
for (const id of Object.keys(timeline)) {
|
|
637
|
+
this.element(id).animateToProgress(this._timelineProgress, timeline[id]);
|
|
638
|
+
}
|
|
639
|
+
return this;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* Play the current slide's timeline from 0 to 1 over the given duration. Uses requestAnimationFrame.
|
|
644
|
+
* Resolves when done. Call the returned cancel to stop early. If the slide has no `timelineTracks`, resolves immediately.
|
|
645
|
+
*
|
|
646
|
+
* @param durationSeconds - Time to go from 0 to 1. Default 5.
|
|
647
|
+
* @param slideIndex - Omit to use the current slide.
|
|
648
|
+
* @returns Promise that resolves when finished, and a cancel function.
|
|
649
|
+
*
|
|
650
|
+
* @example
|
|
651
|
+
* const [promise, cancel] = engine.playTimeline(6);
|
|
652
|
+
* promise.then(() => console.log('done'));
|
|
653
|
+
* // cancel() to stop
|
|
654
|
+
*/
|
|
655
|
+
public playTimeline(durationSeconds = 5, slideIndex?: number): [Promise<void>, () => void] {
|
|
656
|
+
const i = slideIndex ?? this.currentSlideIndex;
|
|
657
|
+
const slide = this.store.state.deck?.slides[i] as BaseSlideData & { timelineTracks?: TimelineTracks } | undefined;
|
|
658
|
+
if (!slide?.timelineTracks || typeof slide.timelineTracks !== 'object') {
|
|
659
|
+
return [Promise.resolve(), () => {}];
|
|
660
|
+
}
|
|
661
|
+
if (this._timelineCancel) this._timelineCancel();
|
|
662
|
+
let rafId = 0;
|
|
663
|
+
let start = 0;
|
|
664
|
+
const cancel = () => {
|
|
665
|
+
if (rafId) cancelAnimationFrame(rafId);
|
|
666
|
+
rafId = 0;
|
|
667
|
+
this._timelineCancel = null;
|
|
668
|
+
};
|
|
669
|
+
this._timelineCancel = cancel;
|
|
670
|
+
const run = (t: number) => {
|
|
671
|
+
if (!start) start = t;
|
|
672
|
+
const elapsed = (t - start) / 1000;
|
|
673
|
+
const p = Math.min(1, elapsed / durationSeconds);
|
|
674
|
+
this.seekTo(p, i);
|
|
675
|
+
if (p < 1) rafId = requestAnimationFrame(run);
|
|
676
|
+
else {
|
|
677
|
+
rafId = 0;
|
|
678
|
+
this._timelineCancel = null;
|
|
679
|
+
bus.emit('timelineComplete', { slideIndex: i });
|
|
680
|
+
resolvePlay();
|
|
681
|
+
}
|
|
682
|
+
};
|
|
683
|
+
let resolvePlay: () => void;
|
|
684
|
+
const promise = new Promise<void>((r) => { resolvePlay = r; });
|
|
685
|
+
rafId = requestAnimationFrame(run);
|
|
686
|
+
return [promise, cancel];
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
public openSpeakerNotes(): Window | null {
|
|
690
|
+
if (this.speakerWindow && !this.speakerWindow.closed) {
|
|
691
|
+
this.speakerWindow.focus();
|
|
692
|
+
return this.speakerWindow;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
if (!SpeakerChannel.isSupported()) {
|
|
696
|
+
console.error('[Lumina] BroadcastChannel not supported.');
|
|
697
|
+
return null;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
const width = 600;
|
|
701
|
+
const height = 800;
|
|
702
|
+
const left = window.screenX + window.outerWidth;
|
|
703
|
+
const top = window.screenY;
|
|
704
|
+
|
|
705
|
+
this.speakerWindow = window.open(
|
|
706
|
+
'about:blank',
|
|
707
|
+
`lumina-speaker-notes-${this.speakerChannelId}`,
|
|
708
|
+
`width=${width},height=${height},left=${left},top=${top},resizable=yes,scrollbars=yes`
|
|
709
|
+
);
|
|
710
|
+
|
|
711
|
+
if (!this.speakerWindow) {
|
|
712
|
+
console.warn('[Lumina] Popup blocked.');
|
|
713
|
+
return null;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
const win = this.speakerWindow;
|
|
717
|
+
win.document.open();
|
|
718
|
+
win.document.write(`
|
|
719
|
+
<!DOCTYPE html>
|
|
720
|
+
<html lang="en">
|
|
721
|
+
<head>
|
|
722
|
+
<meta charset="UTF-8">
|
|
723
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
|
724
|
+
<title>Speaker Notes - Lumina</title>
|
|
725
|
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
|
726
|
+
<style>body{margin:0;padding:0;}#speaker-notes-root{min-height:100vh;min-height:100dvh;}</style>
|
|
727
|
+
</head>
|
|
728
|
+
<body>
|
|
729
|
+
<div id="speaker-notes-root"></div>
|
|
730
|
+
</body>
|
|
731
|
+
</html>
|
|
732
|
+
`);
|
|
733
|
+
win.document.close();
|
|
734
|
+
|
|
735
|
+
setTimeout(() => {
|
|
736
|
+
if (win.closed) return;
|
|
737
|
+
Array.from(document.querySelectorAll('style, link[rel="stylesheet"]')).forEach(node => {
|
|
738
|
+
win.document.head.appendChild(node.cloneNode(true));
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
const container = win.document.getElementById('speaker-notes-root');
|
|
742
|
+
if (container) {
|
|
743
|
+
const notesApp = createApp(LuminaSpeakerNotes, { channelId: this.speakerChannelId });
|
|
744
|
+
notesApp.mount(container);
|
|
745
|
+
win.addEventListener('unload', () => notesApp.unmount());
|
|
746
|
+
}
|
|
747
|
+
}, 0);
|
|
748
|
+
|
|
749
|
+
this.initSpeakerChannel();
|
|
750
|
+
bus.emit('speakerNotesOpen', {});
|
|
751
|
+
return this.speakerWindow;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
public closeSpeakerNotes(): void {
|
|
755
|
+
bus.emit('speakerNotesClose', {});
|
|
756
|
+
if (this.speakerUnsubscribe) {
|
|
757
|
+
this.speakerUnsubscribe();
|
|
758
|
+
this.speakerUnsubscribe = null;
|
|
759
|
+
}
|
|
760
|
+
if (this.speakerChannel) {
|
|
761
|
+
this.speakerChannel.notifyClose();
|
|
762
|
+
this.speakerChannel.destroy();
|
|
763
|
+
this.speakerChannel = null;
|
|
764
|
+
}
|
|
765
|
+
if (this.speakerWindow && !this.speakerWindow.closed) {
|
|
766
|
+
this.speakerWindow.close();
|
|
767
|
+
}
|
|
768
|
+
this.speakerWindow = null;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
public get isSpeakerNotesOpen(): boolean {
|
|
772
|
+
return this.speakerWindow !== null && !this.speakerWindow.closed;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
private initSpeakerChannel(): void {
|
|
776
|
+
this.speakerChannel = SpeakerChannel.getInstance(this.speakerChannelId);
|
|
777
|
+
this.speakerUnsubscribe = this.speakerChannel.subscribe((msg: SpeakerSyncPayload) => {
|
|
778
|
+
switch (msg.action) {
|
|
779
|
+
case 'next': this.next(); break;
|
|
780
|
+
case 'prev': this.prev(); break;
|
|
781
|
+
case 'goto': if (msg.index !== undefined) this.goTo(msg.index); break;
|
|
782
|
+
case 'ping': this.syncSpeakerNotes(); break;
|
|
783
|
+
}
|
|
784
|
+
});
|
|
785
|
+
this.syncSpeakerNotes();
|
|
786
|
+
const checkWindow = setInterval(() => {
|
|
787
|
+
if (this.speakerWindow?.closed) {
|
|
788
|
+
clearInterval(checkWindow);
|
|
789
|
+
this.closeSpeakerNotes();
|
|
790
|
+
}
|
|
791
|
+
}, 1000);
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
private syncSpeakerNotes(): void {
|
|
795
|
+
if (!this.speakerChannel) return;
|
|
796
|
+
const { currentIndex, deck } = this.store.state;
|
|
797
|
+
if (!deck) return;
|
|
798
|
+
|
|
799
|
+
const currentSlide = deck.slides[currentIndex];
|
|
800
|
+
const nextSlide = deck.slides[currentIndex + 1];
|
|
801
|
+
|
|
802
|
+
const getSlideTitle = (slide: BaseSlideData | undefined): string | undefined => {
|
|
803
|
+
if (!slide) return undefined;
|
|
804
|
+
if ('title' in slide && typeof (slide as any).title === 'string') return (slide as any).title;
|
|
805
|
+
return undefined;
|
|
806
|
+
};
|
|
807
|
+
|
|
808
|
+
this.speakerChannel.send({
|
|
809
|
+
action: 'state',
|
|
810
|
+
index: currentIndex,
|
|
811
|
+
totalSlides: deck.slides.length,
|
|
812
|
+
currentNotes: currentSlide?.notes,
|
|
813
|
+
nextSlidePreview: nextSlide ? {
|
|
814
|
+
title: getSlideTitle(nextSlide),
|
|
815
|
+
type: nextSlide.type
|
|
816
|
+
} : undefined
|
|
817
|
+
});
|
|
818
|
+
}
|
|
819
|
+
}
|