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.
Files changed (119) hide show
  1. package/LUMINA_LLM_EXAMPLES.json +234 -0
  2. package/README.md +18 -18
  3. package/dist/lumina-slides.js +13207 -12659
  4. package/dist/lumina-slides.umd.cjs +215 -215
  5. package/dist/style.css +1 -1
  6. package/package.json +5 -4
  7. package/src/App.vue +16 -0
  8. package/src/animation/index.ts +11 -0
  9. package/src/animation/registry.ts +126 -0
  10. package/src/animation/stagger.ts +95 -0
  11. package/src/animation/types.ts +53 -0
  12. package/src/components/LandingPage.vue +229 -0
  13. package/src/components/LuminaDeck.vue +224 -0
  14. package/src/components/LuminaSpeakerNotes.vue +701 -0
  15. package/src/components/base/BaseSlide.vue +122 -0
  16. package/src/components/base/LuminaElement.vue +67 -0
  17. package/src/components/base/VideoPlayer.vue +204 -0
  18. package/src/components/layouts/LayoutAuto.vue +71 -0
  19. package/src/components/layouts/LayoutChart.vue +287 -0
  20. package/src/components/layouts/LayoutCustom.vue +92 -0
  21. package/src/components/layouts/LayoutDiagram.vue +253 -0
  22. package/src/components/layouts/LayoutFeatures.vue +121 -0
  23. package/src/components/layouts/LayoutFlex.vue +172 -0
  24. package/src/components/layouts/LayoutFree.vue +62 -0
  25. package/src/components/layouts/LayoutHalf.vue +127 -0
  26. package/src/components/layouts/LayoutStatement.vue +74 -0
  27. package/src/components/layouts/LayoutSteps.vue +106 -0
  28. package/src/components/layouts/LayoutTimeline.vue +104 -0
  29. package/src/components/layouts/LayoutVideo.vue +41 -0
  30. package/src/components/parts/FlexBullets.vue +45 -0
  31. package/src/components/parts/FlexButton.vue +132 -0
  32. package/src/components/parts/FlexImage.vue +54 -0
  33. package/src/components/parts/FlexOrdered.vue +44 -0
  34. package/src/components/parts/FlexSpacer.vue +13 -0
  35. package/src/components/parts/FlexStepper.vue +59 -0
  36. package/src/components/parts/FlexText.vue +29 -0
  37. package/src/components/parts/FlexTimeline.vue +67 -0
  38. package/src/components/parts/FlexTitle.vue +39 -0
  39. package/src/components/parts/LuminaBackground.vue +100 -0
  40. package/src/components/site/LivePreview.vue +101 -0
  41. package/src/components/site/SiteApi.vue +301 -0
  42. package/src/components/site/SiteDashboard.vue +604 -0
  43. package/src/components/site/SiteDocs.vue +3267 -0
  44. package/src/components/site/SiteExamples.vue +65 -0
  45. package/src/components/site/SiteFooter.vue +6 -0
  46. package/src/components/site/SiteHome.vue +362 -0
  47. package/src/components/site/SiteNavBar.vue +122 -0
  48. package/src/components/site/SitePlayground.vue +389 -0
  49. package/src/components/site/SitePromptBuilder.vue +266 -0
  50. package/src/components/site/SiteUserMenu.vue +90 -0
  51. package/src/components/studio/ActionEditor.vue +108 -0
  52. package/src/components/studio/ArrayEditor.vue +124 -0
  53. package/src/components/studio/CollapsibleSection.vue +33 -0
  54. package/src/components/studio/ColorField.vue +22 -0
  55. package/src/components/studio/EditorCanvas.vue +326 -0
  56. package/src/components/studio/EditorLayoutFeatures.vue +18 -0
  57. package/src/components/studio/EditorLayoutFixed.vue +46 -0
  58. package/src/components/studio/EditorLayoutFlex.vue +133 -0
  59. package/src/components/studio/EditorLayoutHalf.vue +18 -0
  60. package/src/components/studio/EditorLayoutStatement.vue +18 -0
  61. package/src/components/studio/EditorLayoutSteps.vue +18 -0
  62. package/src/components/studio/EditorLayoutTimeline.vue +18 -0
  63. package/src/components/studio/EditorNode.vue +89 -0
  64. package/src/components/studio/FieldEditor.vue +133 -0
  65. package/src/components/studio/IconPicker.vue +109 -0
  66. package/src/components/studio/LayerItem.vue +117 -0
  67. package/src/components/studio/LuminaStudio.vue +30 -0
  68. package/src/components/studio/SaveSuccessModal.vue +138 -0
  69. package/src/components/studio/SlideNavigator.vue +373 -0
  70. package/src/components/studio/SliderField.vue +44 -0
  71. package/src/components/studio/StudioInspector.vue +595 -0
  72. package/src/components/studio/StudioJsonEditor.vue +191 -0
  73. package/src/components/studio/StudioLayers.vue +145 -0
  74. package/src/components/studio/StudioSettings.vue +514 -0
  75. package/src/components/studio/StudioSidebar.vue +29 -0
  76. package/src/components/studio/StudioToolbar.vue +222 -0
  77. package/src/components/studio/fieldLabels.ts +224 -0
  78. package/src/components/studio/inspectors/DiagramEdgeEditor.vue +77 -0
  79. package/src/components/studio/inspectors/DiagramNodeEditor.vue +117 -0
  80. package/src/components/studio/nodes/StudioDiagramNode.vue +138 -0
  81. package/src/composables/useAuth.ts +87 -0
  82. package/src/composables/useEditor.ts +224 -0
  83. package/src/composables/useElementState.ts +81 -0
  84. package/src/composables/useFlexLayout.ts +122 -0
  85. package/src/composables/useKeyboard.ts +45 -0
  86. package/src/composables/useLumina.ts +32 -0
  87. package/src/composables/useStudio.ts +87 -0
  88. package/src/composables/useSwipeNav.ts +53 -0
  89. package/src/composables/useTransition.ts +373 -0
  90. package/src/core/Lumina.ts +819 -0
  91. package/src/core/animationConfig.ts +251 -0
  92. package/src/core/compression.ts +34 -0
  93. package/src/core/elementController.ts +170 -0
  94. package/src/core/elementId.ts +27 -0
  95. package/src/core/elementResolver.ts +207 -0
  96. package/src/core/events.ts +53 -0
  97. package/src/core/fonts.ts +100 -0
  98. package/src/core/presets.ts +231 -0
  99. package/src/core/prompts.ts +272 -0
  100. package/src/core/schema.ts +478 -0
  101. package/src/core/speaker-channel.ts +250 -0
  102. package/src/core/store.ts +461 -0
  103. package/src/core/theme.ts +666 -0
  104. package/src/core/types.ts +1611 -0
  105. package/src/directives/vStudio.ts +45 -0
  106. package/src/index.ts +175 -0
  107. package/src/main.ts +17 -0
  108. package/src/router/index.ts +92 -0
  109. package/src/style/main.css +462 -0
  110. package/src/utils/deep.ts +127 -0
  111. package/src/utils/firebase.ts +184 -0
  112. package/src/utils/streaming.ts +134 -0
  113. package/src/views/DashboardView.vue +32 -0
  114. package/src/views/DeckView.vue +289 -0
  115. package/src/views/HomeView.vue +17 -0
  116. package/src/views/SiteLayout.vue +21 -0
  117. package/src/views/StudioView.vue +61 -0
  118. package/src/vite-env.d.ts +6 -0
  119. package/IMPLEMENTATION.md +0 -418
@@ -0,0 +1,250 @@
1
+ import type { SpeakerSyncPayload } from './types';
2
+
3
+ /**
4
+ * SPEAKER CHANNEL
5
+ *
6
+ * Implements cross-window communication for speaker notes synchronization
7
+ * using the BroadcastChannel API. Supports bidirectional messaging between
8
+ * the main presentation window and the speaker notes popup.
9
+ *
10
+ * Design Patterns:
11
+ * - Singleton per channel name (multiple instances on same page supported)
12
+ * - Observer pattern for message handling
13
+ * - Heartbeat mechanism for connection status
14
+ *
15
+ * @example
16
+ * const channel = SpeakerChannel.getInstance('lumina-deck-1');
17
+ * const unsubscribe = channel.subscribe((msg) => console.log(msg));
18
+ * channel.send({ action: 'next' });
19
+ */
20
+
21
+ /**
22
+ * Handler function type for processing speaker sync messages.
23
+ */
24
+ export type MessageHandler = (payload: SpeakerSyncPayload) => void;
25
+
26
+ export class SpeakerChannel {
27
+ private static instances = new Map<string, SpeakerChannel>();
28
+
29
+ private channel: BroadcastChannel | null = null;
30
+ private handlers = new Set<MessageHandler>();
31
+ private lastTimestamp = 0;
32
+ private heartbeatInterval: ReturnType<typeof setInterval> | null = null;
33
+ private isConnected = false;
34
+ private connectionHandlers = new Set<(connected: boolean) => void>();
35
+
36
+ /**
37
+ * Private constructor - use getInstance() instead.
38
+ */
39
+ private constructor(private readonly channelName: string) {
40
+ this.initChannel();
41
+ }
42
+
43
+ /**
44
+ * Gets or creates a SpeakerChannel instance for the given name.
45
+ * Uses singleton pattern to ensure one channel per name, unless forceNew is true.
46
+ *
47
+ * @param channelName - The unique ID for the channel
48
+ * @param forceNew - If true, creates a new instance even if one exists (needed for in-context communication)
49
+ */
50
+ public static getInstance(channelName: string, forceNew = false): SpeakerChannel {
51
+ if (forceNew) {
52
+ return new SpeakerChannel(channelName);
53
+ }
54
+
55
+ if (!SpeakerChannel.instances.has(channelName)) {
56
+ SpeakerChannel.instances.set(channelName, new SpeakerChannel(channelName));
57
+ }
58
+ return SpeakerChannel.instances.get(channelName)!;
59
+ }
60
+
61
+ /**
62
+ * Checks if BroadcastChannel is supported in the current environment.
63
+ */
64
+ public static isSupported(): boolean {
65
+ return typeof BroadcastChannel !== 'undefined';
66
+ }
67
+
68
+ /**
69
+ * Initializes the BroadcastChannel and sets up message handling.
70
+ */
71
+ private initChannel(): void {
72
+ if (!SpeakerChannel.isSupported()) {
73
+ console.warn('[SpeakerChannel] BroadcastChannel not supported in this environment');
74
+ return;
75
+ }
76
+
77
+ try {
78
+ this.channel = new BroadcastChannel(this.channelName);
79
+
80
+ this.channel.onmessage = (event: MessageEvent<SpeakerSyncPayload>) => {
81
+ const payload = event.data;
82
+
83
+ // Ignore echoed messages (same timestamp)
84
+ if (payload.timestamp && payload.timestamp === this.lastTimestamp) {
85
+ return;
86
+ }
87
+
88
+ // Handle connection status messages
89
+ if (payload.action === 'ping') {
90
+ this.send({ action: 'pong' });
91
+ // Also notify subscribers so they can respond with state
92
+ this.handlers.forEach(handler => {
93
+ try {
94
+ handler(payload);
95
+ } catch (err) {
96
+ console.error('[SpeakerChannel] Handler error:', err);
97
+ }
98
+ });
99
+ return;
100
+ }
101
+
102
+ if (payload.action === 'pong') {
103
+ this.setConnected(true);
104
+ return;
105
+ }
106
+
107
+ if (payload.action === 'close') {
108
+ this.setConnected(false);
109
+ return;
110
+ }
111
+
112
+ // Notify all subscribers
113
+ this.handlers.forEach(handler => {
114
+ try {
115
+ handler(payload);
116
+ } catch (err) {
117
+ console.error('[SpeakerChannel] Handler error:', err);
118
+ }
119
+ });
120
+ };
121
+
122
+ this.channel.onmessageerror = (event) => {
123
+ console.error('[SpeakerChannel] Message error:', event);
124
+ };
125
+
126
+ // Start heartbeat to detect connection status
127
+ this.startHeartbeat();
128
+
129
+ } catch (err) {
130
+ console.error('[SpeakerChannel] Failed to initialize:', err);
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Sends a message to all connected windows on this channel.
136
+ */
137
+ public send(payload: SpeakerSyncPayload): void {
138
+ if (!this.channel) return;
139
+
140
+ const message: SpeakerSyncPayload = {
141
+ ...payload,
142
+ timestamp: Date.now(),
143
+ channelId: this.channelName
144
+ };
145
+
146
+ this.lastTimestamp = message.timestamp!;
147
+ this.channel.postMessage(message);
148
+ }
149
+
150
+ /**
151
+ * Subscribes to incoming messages.
152
+ * @returns Unsubscribe function
153
+ */
154
+ public subscribe(handler: MessageHandler): () => void {
155
+ this.handlers.add(handler);
156
+ return () => this.handlers.delete(handler);
157
+ }
158
+
159
+ /**
160
+ * Subscribes to connection status changes.
161
+ * @returns Unsubscribe function
162
+ */
163
+ public onConnectionChange(handler: (connected: boolean) => void): () => void {
164
+ this.connectionHandlers.add(handler);
165
+ // Immediately notify of current status
166
+ handler(this.isConnected);
167
+ return () => this.connectionHandlers.delete(handler);
168
+ }
169
+
170
+ /**
171
+ * Gets current connection status.
172
+ */
173
+ public get connected(): boolean {
174
+ return this.isConnected;
175
+ }
176
+
177
+ /**
178
+ * Sends a ping to check if another window is listening.
179
+ */
180
+ public ping(): void {
181
+ this.send({ action: 'ping' });
182
+ }
183
+
184
+ /**
185
+ * Notifies other windows that this window is closing.
186
+ */
187
+ public notifyClose(): void {
188
+ this.send({ action: 'close' });
189
+ }
190
+
191
+ /**
192
+ * Starts the heartbeat interval to check connection status.
193
+ */
194
+ private startHeartbeat(): void {
195
+ // Initial ping
196
+ this.ping();
197
+
198
+ // Periodic heartbeat every 5 seconds
199
+ this.heartbeatInterval = setInterval(() => {
200
+ if (this.handlers.size > 0 || this.connectionHandlers.size > 0) {
201
+ this.ping();
202
+ }
203
+ }, 5000);
204
+ }
205
+
206
+ /**
207
+ * Updates and broadcasts connection status.
208
+ */
209
+ private setConnected(connected: boolean): void {
210
+ if (this.isConnected !== connected) {
211
+ this.isConnected = connected;
212
+ this.connectionHandlers.forEach(handler => handler(connected));
213
+ }
214
+ }
215
+
216
+ /**
217
+ * Cleans up resources and removes the instance from the cache.
218
+ */
219
+ public destroy(): void {
220
+ // Notify other windows
221
+ this.notifyClose();
222
+
223
+ // Clear heartbeat
224
+ if (this.heartbeatInterval) {
225
+ clearInterval(this.heartbeatInterval);
226
+ this.heartbeatInterval = null;
227
+ }
228
+
229
+ // Close channel
230
+ if (this.channel) {
231
+ this.channel.close();
232
+ this.channel = null;
233
+ }
234
+
235
+ // Clear handlers
236
+ this.handlers.clear();
237
+ this.connectionHandlers.clear();
238
+
239
+ // Remove from instances
240
+ SpeakerChannel.instances.delete(this.channelName);
241
+ }
242
+
243
+ /**
244
+ * Destroys all active channel instances.
245
+ */
246
+ public static destroyAll(): void {
247
+ SpeakerChannel.instances.forEach(instance => instance.destroy());
248
+ SpeakerChannel.instances.clear();
249
+ }
250
+ }
@@ -0,0 +1,461 @@
1
+ import { reactive, readonly, InjectionKey } from 'vue';
2
+ import type { Deck, LuminaOptions, ActionPayload, ElementState } from './types';
3
+ import { expandValue } from './compression';
4
+ import { normalizeAliases } from './schema';
5
+ import { setByPath, insertByPath, removeByPath, moveByPath, getByPath } from '../utils/deep';
6
+ import { getElementIds } from './elementResolver';
7
+
8
+ /**
9
+ * LUMINA STORE FACTORY
10
+ * Refactored to support multiple instances via Dependency Injection.
11
+ */
12
+
13
+ export interface LuminaState {
14
+ deck: Deck | null;
15
+ currentIndex: number;
16
+ options: LuminaOptions;
17
+ isReady: boolean;
18
+ // Feature: Feedback Loop (Context Window)
19
+ actionHistory: ActionPayload[];
20
+ /**
21
+ * Per-element state for {@link ElementController}: visibility, opacity, transform, class, style.
22
+ * Keys = element ids (e.g. "s0-title", "s1-features-0"). Initialized from
23
+ * {@link DeckMeta.initialElementState} on load; updated by engine.element(id).show/hide/opacity/etc.
24
+ * loadDeck applies initialElementState before assigning deck so the first paint sees the correct state.
25
+ * @see loadDeck
26
+ */
27
+ elementState: Record<string, ElementState>;
28
+ /**
29
+ * Key-value store for arbitrary user data. Access via engine.data or store.getUserData/setUserData.
30
+ * Persists across deck loads; not serialized.
31
+ */
32
+ userData: Record<string, unknown>;
33
+ }
34
+
35
+ export type LuminaStore = ReturnType<typeof createStore>;
36
+
37
+ export const StoreKey: InjectionKey<LuminaStore> = Symbol('LuminaStore');
38
+
39
+ const DEFAULT_OPTIONS: LuminaOptions = {
40
+ loop: false,
41
+ navigation: true,
42
+ keyboard: true,
43
+ touch: true,
44
+ debug: false,
45
+ theme: 'default',
46
+ ui: {
47
+ visible: true,
48
+ showProgressBar: true,
49
+ showSlideCount: true,
50
+ showControls: true
51
+ },
52
+ keys: {
53
+ next: ['ArrowRight', ' ', 'Enter'],
54
+ prev: ['ArrowLeft', 'Backspace']
55
+ },
56
+ animation: {
57
+ enabled: true,
58
+ type: 'cascade',
59
+ durationIn: 1.0,
60
+ durationOut: 0.5,
61
+ stagger: 0.1,
62
+ ease: 'power3.out'
63
+ }
64
+ };
65
+
66
+ /**
67
+ * Deeply expands compressed values in an object.
68
+ */
69
+ function deepExpand(obj: any): any {
70
+ if (typeof obj === 'string') return expandValue(obj);
71
+ if (Array.isArray(obj)) return obj.map(deepExpand);
72
+ if (obj && typeof obj === 'object') {
73
+ const newObj: any = {};
74
+ for (const key in obj) {
75
+ newObj[key] = deepExpand(obj[key]);
76
+ }
77
+ return newObj;
78
+ }
79
+ return obj;
80
+ }
81
+
82
+ /**
83
+ * Simple deep merge for patching.
84
+ */
85
+ function deepMerge(target: any, source: any) {
86
+ if (typeof target !== 'object' || target === null) return source;
87
+ if (typeof source !== 'object' || source === null) return source;
88
+
89
+ if (Array.isArray(source)) {
90
+ // Arrays are replaced, not merged, to avoid duplication issues in lists
91
+ return source.map(deepExpand);
92
+ }
93
+
94
+ const output = { ...target };
95
+ for (const key in source) {
96
+ if (source[key] === Object(source[key])) {
97
+ if (!(key in target)) {
98
+ Object.assign(output, { [key]: deepExpand(source[key]) });
99
+ } else {
100
+ output[key] = deepMerge(target[key], source[key]);
101
+ }
102
+ } else {
103
+ Object.assign(output, { [key]: deepExpand(source[key]) });
104
+ }
105
+ }
106
+ return output;
107
+ }
108
+
109
+ /**
110
+ * Creates a new isolated Lumina Store instance.
111
+ * Uses the Factory pattern to support multiple engine instances on the same page.
112
+ *
113
+ * @param initialOptions - Optional configuration overrides.
114
+ * @returns A store object containing the reactive state and methods.
115
+ */
116
+ export function createStore(initialOptions: LuminaOptions = {}) {
117
+ const state = reactive<LuminaState>({
118
+ deck: null,
119
+ currentIndex: 0,
120
+ options: { ...DEFAULT_OPTIONS, ...initialOptions },
121
+ isReady: false,
122
+ actionHistory: [],
123
+ elementState: {},
124
+ userData: {}
125
+ });
126
+
127
+ // --- Getters ---
128
+
129
+ /**
130
+ * Returns the complete data object for the currently active slide.
131
+ */
132
+ const currentSlide = () => {
133
+ if (!state.deck || state.deck.slides.length === 0) return null;
134
+ return state.deck.slides[state.currentIndex] || null;
135
+ };
136
+
137
+ /** Checks if there is a next slide available (respects loop). */
138
+ const hasNext = () => {
139
+ if (!state.deck) return false;
140
+ return state.options.loop
141
+ ? true
142
+ : state.currentIndex < state.deck.slides.length - 1;
143
+ };
144
+
145
+ /** Checks if there is a previous slide available (respects loop). */
146
+ const hasPrev = () => {
147
+ if (!state.deck) return false;
148
+ return state.options.loop
149
+ ? true
150
+ : state.currentIndex > 0;
151
+ };
152
+
153
+ /** Returns the current progress as a normalized value between 0 and 1. */
154
+ const progress = () => {
155
+ if (!state.deck || state.deck.slides.length <= 1) return 0;
156
+ return state.currentIndex / (state.deck.slides.length - 1);
157
+ };
158
+
159
+ // --- Actions ---
160
+
161
+ /**
162
+ * Updates the engine options at runtime.
163
+ * Merges provided options with existing ones.
164
+ *
165
+ * @param options - Partial options object to merge.
166
+ */
167
+ function setOptions(options: LuminaOptions) {
168
+ state.options = { ...state.options, ...options };
169
+
170
+ // Theme Merging Logic
171
+ if (options.theme) {
172
+ if (typeof options.theme === 'string') {
173
+ // Completely replace if string preset
174
+ state.options.theme = options.theme;
175
+ } else {
176
+ // Allow fine-grained object overrides
177
+ state.options.theme = options.theme;
178
+ }
179
+ }
180
+
181
+ if (options.ui) state.options.ui = { ...state.options.ui, ...options.ui };
182
+ if (options.keys) state.options.keys = { ...state.options.keys, ...options.keys };
183
+ if (options.animation) state.options.animation = { ...state.options.animation, ...options.animation };
184
+ if (options.elementControl) state.options.elementControl = { ...(state.options.elementControl || {}), ...options.elementControl };
185
+ }
186
+
187
+ /**
188
+ * Ensures all elements in flex layouts have unique dragKeys for vuedraggable.
189
+ */
190
+ function ensureDragKeys(obj: any): any {
191
+ if (!obj || typeof obj !== 'object') return obj;
192
+ if (Array.isArray(obj)) return obj.map(ensureDragKeys);
193
+
194
+ const newObj = { ...obj };
195
+ if (newObj.type === 'flex' && Array.isArray(newObj.elements)) {
196
+ newObj.elements = newObj.elements.map((el: any) => ({
197
+ dragKey: el.dragKey || Date.now() + Math.random(),
198
+ ...ensureDragKeys(el)
199
+ }));
200
+ } else if (newObj.type === 'content' && Array.isArray(newObj.elements)) {
201
+ newObj.elements = newObj.elements.map((el: any) => ({
202
+ dragKey: el.dragKey || Date.now() + Math.random() + 1,
203
+ ...ensureDragKeys(el)
204
+ }));
205
+ } else {
206
+ for (const key in newObj) {
207
+ newObj[key] = ensureDragKeys(newObj[key]);
208
+ }
209
+ }
210
+ return newObj;
211
+ }
212
+
213
+ /**
214
+ * Loads a new deck into the engine and resets state.
215
+ *
216
+ * **Critical order (element control):** Apply `meta.initialElementState` to
217
+ * `state.elementState` *before* assigning `state.deck`. Assigning `state.deck` triggers
218
+ * Vue's re-render; on that first paint, LuminaElements and useTransition read
219
+ * `elementState` to decide initial opacity and whether to exclude `.reveal-*` containers.
220
+ * If `elementState` were applied after `state.deck`, on that first frame they would see
221
+ * empty or stale state (e.g. `visible: true` from a previous load), and elements that
222
+ * should start hidden would appear visible until the next tick.
223
+ *
224
+ * @param deck - Full deck: `{ meta: { initialElementState?, ... }, slides: [...] }`.
225
+ */
226
+ function loadDeck(deck: Deck) {
227
+ if (!deck || !Array.isArray(deck.slides)) {
228
+ console.error('[LuminaStore] Invalid deck format');
229
+ return;
230
+ }
231
+ const normalizedDeck = normalizeAliases(deck);
232
+ const keyedDeck = ensureDragKeys(normalizedDeck);
233
+
234
+ const initial = keyedDeck.meta?.initialElementState;
235
+ const initialObj = initial && typeof initial === 'object' && !Array.isArray(initial) ? initial : {};
236
+ state.elementState = Object.fromEntries(
237
+ Object.entries(initialObj).map(([k, v]) => [k, { ...(v && typeof v === 'object' ? v : {}) }])
238
+ );
239
+
240
+ state.deck = deepExpand(keyedDeck);
241
+ state.currentIndex = 0;
242
+ state.isReady = true;
243
+ state.actionHistory = [];
244
+
245
+ // Hide all elements by default when requested from options or from deck meta (JSON-only).
246
+ const hideByDefault =
247
+ state.options.elementControl?.defaultVisible === false ||
248
+ keyedDeck.meta?.elementControl?.defaultVisible === false;
249
+ if (hideByDefault) {
250
+ const inInitial = new Set(Object.keys(initialObj));
251
+ (state.deck?.slides ?? []).forEach((slide, i) => {
252
+ getElementIds(slide, i).forEach((id) => {
253
+ if (!inInitial.has(id)) {
254
+ const cur = state.elementState[id] || {};
255
+ state.elementState[id] = { ...cur, visible: false };
256
+ }
257
+ });
258
+ });
259
+ }
260
+ }
261
+
262
+ /**
263
+ * Feature: Diff Updates
264
+ * Patches the current deck with partial data.
265
+ */
266
+ function patchDeck(partial: any) {
267
+ if (!state.deck) return;
268
+ const merged = deepMerge(state.deck, partial);
269
+ state.deck = ensureDragKeys(merged);
270
+ }
271
+
272
+ /**
273
+ * Records a user action for the Feedback Loop.
274
+ */
275
+ function recordAction(action: ActionPayload) {
276
+ state.actionHistory.push(action);
277
+ if (state.actionHistory.length > 50) state.actionHistory.shift(); // Keep buffer small
278
+ }
279
+
280
+ /** Advances to the next slide if possible. */
281
+ function next() {
282
+ if (!hasNext()) return;
283
+ const count = state.deck?.slides.length || 0;
284
+ let newIndex = state.currentIndex;
285
+
286
+ if (state.options.loop) {
287
+ newIndex = (state.currentIndex + 1) % count;
288
+ } else {
289
+ newIndex++;
290
+ }
291
+
292
+ if (newIndex !== state.currentIndex) {
293
+ state.currentIndex = newIndex;
294
+ // Emit event via bus (imported from events.ts, need to update imports)
295
+ // Ideally store shouldn't know about bus, but for now this is the direct fix
296
+ }
297
+ }
298
+
299
+ /** Returns to the previous slide if possible. */
300
+ function prev() {
301
+ if (!hasPrev()) return;
302
+ const count = state.deck?.slides.length || 0;
303
+ let newIndex = state.currentIndex;
304
+
305
+ if (state.options.loop) {
306
+ newIndex = (state.currentIndex - 1 + count) % count;
307
+ } else {
308
+ newIndex--;
309
+ }
310
+
311
+ if (newIndex !== state.currentIndex) {
312
+ state.currentIndex = newIndex;
313
+ }
314
+ }
315
+
316
+ /**
317
+ * Jumps immediately to a specific slide index.
318
+ * @param index - Zero-based index of the target slide.
319
+ */
320
+ function goto(index: number) {
321
+ if (!state.deck) return;
322
+ if (index >= 0 && index < state.deck.slides.length) {
323
+ if (state.currentIndex !== index) {
324
+ state.currentIndex = index;
325
+ }
326
+ }
327
+ }
328
+
329
+
330
+
331
+ // --- Editor Actions (Immutable) ---
332
+
333
+ function updateNode(path: string, value: any) {
334
+ if (!state.deck) return;
335
+ const updated = setByPath(state.deck, path, value);
336
+ state.deck = ensureDragKeys(updated);
337
+ }
338
+
339
+ function addNode(arrayPath: string, item: any, index?: number) {
340
+ if (!state.deck) return;
341
+ const target = getByPath(state.deck, arrayPath);
342
+ // Default to end of array if index not provided
343
+ const actualIndex = (index !== undefined) ? index : (Array.isArray(target) ? target.length : 0);
344
+ // Ensure item has keys
345
+ const keyedItem = ensureDragKeys(item);
346
+ state.deck = insertByPath(state.deck, arrayPath, actualIndex, keyedItem);
347
+ }
348
+
349
+ function removeNode(arrayPath: string, index: number) {
350
+ if (!state.deck) return;
351
+ state.deck = removeByPath(state.deck, arrayPath, index);
352
+ }
353
+
354
+ function moveNode(arrayPath: string, from: number, to: number) {
355
+ if (!state.deck) return;
356
+ state.deck = moveByPath(state.deck, arrayPath, from, to);
357
+ }
358
+
359
+ /**
360
+ * Merges a partial {@link ElementState} into the stored state for an element. Used by
361
+ * {@link ElementController} (engine.element(id)). style and class are merged; visible, opacity, transform
362
+ * are replaced when present in `patch`.
363
+ *
364
+ * @param id - Element id (from resolveId, elemId, or explicit id in JSON).
365
+ * @param patch - Partial ElementState. Only provided keys are applied.
366
+ */
367
+ function setElementState(id: string, patch: Partial<ElementState>) {
368
+ const prev = state.elementState[id] || {};
369
+ const next: ElementState = { ...prev };
370
+ if (patch.visible !== undefined) next.visible = patch.visible;
371
+ if (patch.opacity !== undefined) next.opacity = patch.opacity;
372
+ if (patch.transform !== undefined) next.transform = patch.transform;
373
+ if (patch.class !== undefined) next.class = patch.class;
374
+ if (patch.style !== undefined) next.style = { ...(prev.style || {}), ...patch.style };
375
+ state.elementState[id] = next;
376
+ }
377
+
378
+ /**
379
+ * Resets elementState for all ids of the given slide to their initial values.
380
+ * Used when navigating to a slide so that controlled entrance animations
381
+ * (e.g. engine.element(id).show()) can run again. Respects
382
+ * meta.initialElementState and meta.elementControl.defaultVisible (or
383
+ * options.elementControl.defaultVisible).
384
+ *
385
+ * @param slideIndex - Zero-based index of the slide to reset.
386
+ */
387
+ function resetElementStateForSlide(slideIndex: number) {
388
+ const deck = state.deck;
389
+ if (!deck?.slides) return;
390
+ const slide = deck.slides[slideIndex];
391
+ if (!slide) return;
392
+
393
+ const initial = deck.meta?.initialElementState;
394
+ const initialObj = initial && typeof initial === 'object' && !Array.isArray(initial) ? initial : {};
395
+ const hideByDefault =
396
+ state.options.elementControl?.defaultVisible === false ||
397
+ deck.meta?.elementControl?.defaultVisible === false;
398
+ const inInitial = new Set(Object.keys(initialObj));
399
+
400
+ getElementIds(slide, slideIndex).forEach((id) => {
401
+ if (inInitial.has(id)) {
402
+ const v = initialObj[id];
403
+ state.elementState[id] = { ...(v && typeof v === 'object' ? v : {}) };
404
+ } else if (hideByDefault) {
405
+ state.elementState[id] = { visible: false };
406
+ } else {
407
+ state.elementState[id] = {};
408
+ }
409
+ });
410
+ }
411
+
412
+ // --- Key-Value Store (engine.data) ---
413
+
414
+ function getUserData(key: string): unknown {
415
+ return state.userData[key];
416
+ }
417
+
418
+ function setUserData(key: string, value: unknown): void {
419
+ state.userData[key] = value;
420
+ }
421
+
422
+ function hasUserData(key: string): boolean {
423
+ return Object.prototype.hasOwnProperty.call(state.userData, key);
424
+ }
425
+
426
+ function deleteUserData(key: string): boolean {
427
+ if (!Object.prototype.hasOwnProperty.call(state.userData, key)) return false;
428
+ delete state.userData[key];
429
+ return true;
430
+ }
431
+
432
+ function clearUserData(): void {
433
+ state.userData = {};
434
+ }
435
+
436
+ return {
437
+ state: readonly(state),
438
+ currentSlide,
439
+ hasNext,
440
+ hasPrev,
441
+ progress,
442
+ setOptions,
443
+ loadDeck,
444
+ patchDeck,
445
+ updateNode,
446
+ addNode,
447
+ removeNode,
448
+ moveNode,
449
+ setElementState,
450
+ resetElementStateForSlide,
451
+ recordAction,
452
+ next,
453
+ prev,
454
+ goto,
455
+ getUserData,
456
+ setUserData,
457
+ hasUserData,
458
+ deleteUserData,
459
+ clearUserData
460
+ };
461
+ }