lumina-slides 9.0.5 → 9.0.7

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/README.md +63 -0
  2. package/dist/lumina-slides.js +21750 -19334
  3. package/dist/lumina-slides.umd.cjs +223 -223
  4. package/dist/style.css +1 -1
  5. package/package.json +1 -1
  6. package/src/components/LandingPage.vue +1 -1
  7. package/src/components/LuminaDeck.vue +237 -232
  8. package/src/components/base/LuminaElement.vue +2 -0
  9. package/src/components/layouts/LayoutFeatures.vue +125 -123
  10. package/src/components/layouts/LayoutFlex.vue +212 -212
  11. package/src/components/layouts/LayoutStatement.vue +5 -2
  12. package/src/components/layouts/LayoutSteps.vue +110 -108
  13. package/src/components/parts/FlexHtml.vue +65 -65
  14. package/src/components/parts/FlexImage.vue +81 -81
  15. package/src/components/site/SiteDocs.vue +3313 -3314
  16. package/src/components/site/SiteExamples.vue +66 -66
  17. package/src/components/studio/EditorLayoutChart.vue +18 -0
  18. package/src/components/studio/EditorLayoutCustom.vue +18 -0
  19. package/src/components/studio/EditorLayoutVideo.vue +18 -0
  20. package/src/components/studio/LuminaStudioEmbed.vue +68 -0
  21. package/src/components/studio/StudioEmbedRoot.vue +19 -0
  22. package/src/components/studio/StudioInspector.vue +1113 -7
  23. package/src/components/studio/StudioJsonEditor.vue +10 -3
  24. package/src/components/studio/StudioSettings.vue +658 -7
  25. package/src/components/studio/StudioToolbar.vue +26 -7
  26. package/src/composables/useElementState.ts +12 -1
  27. package/src/composables/useFlexLayout.ts +128 -128
  28. package/src/core/Lumina.ts +174 -113
  29. package/src/core/animationConfig.ts +10 -0
  30. package/src/core/elementController.ts +18 -0
  31. package/src/core/elementResolver.ts +4 -2
  32. package/src/core/schema.ts +503 -503
  33. package/src/core/store.ts +465 -465
  34. package/src/core/types.ts +26 -11
  35. package/src/index.ts +2 -2
  36. package/src/utils/prepareDeckForExport.ts +47 -0
  37. package/src/utils/templateInterpolation.ts +52 -52
  38. package/src/views/DeckView.vue +313 -313
package/src/core/store.ts CHANGED
@@ -1,465 +1,465 @@
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 baseOptions = { ...DEFAULT_OPTIONS, ...initialOptions };
118
- if (initialOptions && typeof initialOptions.keys === 'object') {
119
- baseOptions.keys = { ...DEFAULT_OPTIONS.keys!, ...initialOptions.keys };
120
- }
121
- const state = reactive<LuminaState>({
122
- deck: null,
123
- currentIndex: 0,
124
- options: baseOptions,
125
- isReady: false,
126
- actionHistory: [],
127
- elementState: {},
128
- userData: {}
129
- });
130
-
131
- // --- Getters ---
132
-
133
- /**
134
- * Returns the complete data object for the currently active slide.
135
- */
136
- const currentSlide = () => {
137
- if (!state.deck || state.deck.slides.length === 0) return null;
138
- return state.deck.slides[state.currentIndex] || null;
139
- };
140
-
141
- /** Checks if there is a next slide available (respects loop). */
142
- const hasNext = () => {
143
- if (!state.deck) return false;
144
- return state.options.loop
145
- ? true
146
- : state.currentIndex < state.deck.slides.length - 1;
147
- };
148
-
149
- /** Checks if there is a previous slide available (respects loop). */
150
- const hasPrev = () => {
151
- if (!state.deck) return false;
152
- return state.options.loop
153
- ? true
154
- : state.currentIndex > 0;
155
- };
156
-
157
- /** Returns the current progress as a normalized value between 0 and 1. */
158
- const progress = () => {
159
- if (!state.deck || state.deck.slides.length <= 1) return 0;
160
- return state.currentIndex / (state.deck.slides.length - 1);
161
- };
162
-
163
- // --- Actions ---
164
-
165
- /**
166
- * Updates the engine options at runtime.
167
- * Merges provided options with existing ones.
168
- *
169
- * @param options - Partial options object to merge.
170
- */
171
- function setOptions(options: LuminaOptions) {
172
- state.options = { ...state.options, ...options };
173
-
174
- // Theme Merging Logic
175
- if (options.theme) {
176
- if (typeof options.theme === 'string') {
177
- // Completely replace if string preset
178
- state.options.theme = options.theme;
179
- } else {
180
- // Allow fine-grained object overrides
181
- state.options.theme = options.theme;
182
- }
183
- }
184
-
185
- if (options.ui) state.options.ui = { ...state.options.ui, ...options.ui };
186
- if (options.keys) state.options.keys = { ...state.options.keys, ...options.keys };
187
- if (options.animation) state.options.animation = { ...state.options.animation, ...options.animation };
188
- if (options.elementControl) state.options.elementControl = { ...(state.options.elementControl || {}), ...options.elementControl };
189
- }
190
-
191
- /**
192
- * Ensures all elements in flex layouts have unique dragKeys for vuedraggable.
193
- */
194
- function ensureDragKeys(obj: any): any {
195
- if (!obj || typeof obj !== 'object') return obj;
196
- if (Array.isArray(obj)) return obj.map(ensureDragKeys);
197
-
198
- const newObj = { ...obj };
199
- if (newObj.type === 'flex' && Array.isArray(newObj.elements)) {
200
- newObj.elements = newObj.elements.map((el: any) => ({
201
- dragKey: el.dragKey || Date.now() + Math.random(),
202
- ...ensureDragKeys(el)
203
- }));
204
- } else if (newObj.type === 'content' && Array.isArray(newObj.elements)) {
205
- newObj.elements = newObj.elements.map((el: any) => ({
206
- dragKey: el.dragKey || Date.now() + Math.random() + 1,
207
- ...ensureDragKeys(el)
208
- }));
209
- } else {
210
- for (const key in newObj) {
211
- newObj[key] = ensureDragKeys(newObj[key]);
212
- }
213
- }
214
- return newObj;
215
- }
216
-
217
- /**
218
- * Loads a new deck into the engine and resets state.
219
- *
220
- * **Critical order (element control):** Apply `meta.initialElementState` to
221
- * `state.elementState` *before* assigning `state.deck`. Assigning `state.deck` triggers
222
- * Vue's re-render; on that first paint, LuminaElements and useTransition read
223
- * `elementState` to decide initial opacity and whether to exclude `.reveal-*` containers.
224
- * If `elementState` were applied after `state.deck`, on that first frame they would see
225
- * empty or stale state (e.g. `visible: true` from a previous load), and elements that
226
- * should start hidden would appear visible until the next tick.
227
- *
228
- * @param deck - Full deck: `{ meta: { initialElementState?, ... }, slides: [...] }`.
229
- */
230
- function loadDeck(deck: Deck) {
231
- if (!deck || !Array.isArray(deck.slides)) {
232
- console.error('[LuminaStore] Invalid deck format');
233
- return;
234
- }
235
- const normalizedDeck = normalizeAliases(deck);
236
- const keyedDeck = ensureDragKeys(normalizedDeck);
237
-
238
- const initial = keyedDeck.meta?.initialElementState;
239
- const initialObj = initial && typeof initial === 'object' && !Array.isArray(initial) ? initial : {};
240
- state.elementState = Object.fromEntries(
241
- Object.entries(initialObj).map(([k, v]) => [k, { ...(v && typeof v === 'object' ? v : {}) }])
242
- );
243
-
244
- state.deck = deepExpand(keyedDeck);
245
- state.currentIndex = 0;
246
- state.isReady = true;
247
- state.actionHistory = [];
248
-
249
- // Hide all elements by default when requested from options or from deck meta (JSON-only).
250
- const hideByDefault =
251
- state.options.elementControl?.defaultVisible === false ||
252
- keyedDeck.meta?.elementControl?.defaultVisible === false;
253
- if (hideByDefault) {
254
- const inInitial = new Set(Object.keys(initialObj));
255
- (state.deck?.slides ?? []).forEach((slide, i) => {
256
- getElementIds(slide, i).forEach((id) => {
257
- if (!inInitial.has(id)) {
258
- const cur = state.elementState[id] || {};
259
- state.elementState[id] = { ...cur, visible: false };
260
- }
261
- });
262
- });
263
- }
264
- }
265
-
266
- /**
267
- * Feature: Diff Updates
268
- * Patches the current deck with partial data.
269
- */
270
- function patchDeck(partial: Partial<Deck>) {
271
- if (!state.deck) return;
272
- const merged = deepMerge(state.deck, partial);
273
- state.deck = ensureDragKeys(merged);
274
- }
275
-
276
- /**
277
- * Records a user action for the Feedback Loop.
278
- */
279
- function recordAction(action: ActionPayload) {
280
- state.actionHistory.push(action);
281
- if (state.actionHistory.length > 50) state.actionHistory.shift(); // Keep buffer small
282
- }
283
-
284
- /** Advances to the next slide if possible. */
285
- function next() {
286
- if (!hasNext()) return;
287
- const count = state.deck?.slides.length || 0;
288
- let newIndex = state.currentIndex;
289
-
290
- if (state.options.loop) {
291
- newIndex = (state.currentIndex + 1) % count;
292
- } else {
293
- newIndex++;
294
- }
295
-
296
- if (newIndex !== state.currentIndex) {
297
- state.currentIndex = newIndex;
298
- // Emit event via bus (imported from events.ts, need to update imports)
299
- // Ideally store shouldn't know about bus, but for now this is the direct fix
300
- }
301
- }
302
-
303
- /** Returns to the previous slide if possible. */
304
- function prev() {
305
- if (!hasPrev()) return;
306
- const count = state.deck?.slides.length || 0;
307
- let newIndex = state.currentIndex;
308
-
309
- if (state.options.loop) {
310
- newIndex = (state.currentIndex - 1 + count) % count;
311
- } else {
312
- newIndex--;
313
- }
314
-
315
- if (newIndex !== state.currentIndex) {
316
- state.currentIndex = newIndex;
317
- }
318
- }
319
-
320
- /**
321
- * Jumps immediately to a specific slide index.
322
- * @param index - Zero-based index of the target slide.
323
- */
324
- function goto(index: number) {
325
- if (!state.deck) return;
326
- if (index >= 0 && index < state.deck.slides.length) {
327
- if (state.currentIndex !== index) {
328
- state.currentIndex = index;
329
- }
330
- }
331
- }
332
-
333
-
334
-
335
- // --- Editor Actions (Immutable) ---
336
-
337
- function updateNode(path: string, value: any) {
338
- if (!state.deck) return;
339
- const updated = setByPath(state.deck, path, value);
340
- state.deck = ensureDragKeys(updated);
341
- }
342
-
343
- function addNode(arrayPath: string, item: any, index?: number) {
344
- if (!state.deck) return;
345
- const target = getByPath(state.deck, arrayPath);
346
- // Default to end of array if index not provided
347
- const actualIndex = (index !== undefined) ? index : (Array.isArray(target) ? target.length : 0);
348
- // Ensure item has keys
349
- const keyedItem = ensureDragKeys(item);
350
- state.deck = insertByPath(state.deck, arrayPath, actualIndex, keyedItem);
351
- }
352
-
353
- function removeNode(arrayPath: string, index: number) {
354
- if (!state.deck) return;
355
- state.deck = removeByPath(state.deck, arrayPath, index);
356
- }
357
-
358
- function moveNode(arrayPath: string, from: number, to: number) {
359
- if (!state.deck) return;
360
- state.deck = moveByPath(state.deck, arrayPath, from, to);
361
- }
362
-
363
- /**
364
- * Merges a partial {@link ElementState} into the stored state for an element. Used by
365
- * {@link ElementController} (engine.element(id)). style and class are merged; visible, opacity, transform
366
- * are replaced when present in `patch`.
367
- *
368
- * @param id - Element id (from resolveId, elemId, or explicit id in JSON).
369
- * @param patch - Partial ElementState. Only provided keys are applied.
370
- */
371
- function setElementState(id: string, patch: Partial<ElementState>) {
372
- const prev = state.elementState[id] || {};
373
- const next: ElementState = { ...prev };
374
- if (patch.visible !== undefined) next.visible = patch.visible;
375
- if (patch.opacity !== undefined) next.opacity = patch.opacity;
376
- if (patch.transform !== undefined) next.transform = patch.transform;
377
- if (patch.class !== undefined) next.class = patch.class;
378
- if (patch.style !== undefined) next.style = { ...(prev.style || {}), ...patch.style };
379
- state.elementState[id] = next;
380
- }
381
-
382
- /**
383
- * Resets elementState for all ids of the given slide to their initial values.
384
- * Used when navigating to a slide so that controlled entrance animations
385
- * (e.g. engine.element(id).show()) can run again. Respects
386
- * meta.initialElementState and meta.elementControl.defaultVisible (or
387
- * options.elementControl.defaultVisible).
388
- *
389
- * @param slideIndex - Zero-based index of the slide to reset.
390
- */
391
- function resetElementStateForSlide(slideIndex: number) {
392
- const deck = state.deck;
393
- if (!deck?.slides) return;
394
- const slide = deck.slides[slideIndex];
395
- if (!slide) return;
396
-
397
- const initial = deck.meta?.initialElementState;
398
- const initialObj = initial && typeof initial === 'object' && !Array.isArray(initial) ? initial : {};
399
- const hideByDefault =
400
- state.options.elementControl?.defaultVisible === false ||
401
- deck.meta?.elementControl?.defaultVisible === false;
402
- const inInitial = new Set(Object.keys(initialObj));
403
-
404
- getElementIds(slide, slideIndex).forEach((id) => {
405
- if (inInitial.has(id)) {
406
- const v = initialObj[id];
407
- state.elementState[id] = { ...(v && typeof v === 'object' ? v : {}) };
408
- } else if (hideByDefault) {
409
- state.elementState[id] = { visible: false };
410
- } else {
411
- state.elementState[id] = {};
412
- }
413
- });
414
- }
415
-
416
- // --- Key-Value Store (engine.data) ---
417
-
418
- function getUserData(key: string): unknown {
419
- return state.userData[key];
420
- }
421
-
422
- function setUserData(key: string, value: unknown): void {
423
- state.userData[key] = value;
424
- }
425
-
426
- function hasUserData(key: string): boolean {
427
- return Object.prototype.hasOwnProperty.call(state.userData, key);
428
- }
429
-
430
- function deleteUserData(key: string): boolean {
431
- if (!Object.prototype.hasOwnProperty.call(state.userData, key)) return false;
432
- delete state.userData[key];
433
- return true;
434
- }
435
-
436
- function clearUserData(): void {
437
- state.userData = {};
438
- }
439
-
440
- return {
441
- state: readonly(state),
442
- currentSlide,
443
- hasNext,
444
- hasPrev,
445
- progress,
446
- setOptions,
447
- loadDeck,
448
- patchDeck,
449
- updateNode,
450
- addNode,
451
- removeNode,
452
- moveNode,
453
- setElementState,
454
- resetElementStateForSlide,
455
- recordAction,
456
- next,
457
- prev,
458
- goto,
459
- getUserData,
460
- setUserData,
461
- hasUserData,
462
- deleteUserData,
463
- clearUserData
464
- };
465
- }
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 baseOptions = { ...DEFAULT_OPTIONS, ...initialOptions };
118
+ if (initialOptions && typeof initialOptions.keys === 'object') {
119
+ baseOptions.keys = { ...DEFAULT_OPTIONS.keys!, ...initialOptions.keys };
120
+ }
121
+ const state = reactive<LuminaState>({
122
+ deck: null,
123
+ currentIndex: 0,
124
+ options: baseOptions,
125
+ isReady: false,
126
+ actionHistory: [],
127
+ elementState: {},
128
+ userData: {}
129
+ });
130
+
131
+ // --- Getters ---
132
+
133
+ /**
134
+ * Returns the complete data object for the currently active slide.
135
+ */
136
+ const currentSlide = () => {
137
+ if (!state.deck || state.deck.slides.length === 0) return null;
138
+ return state.deck.slides[state.currentIndex] || null;
139
+ };
140
+
141
+ /** Checks if there is a next slide available (respects loop). */
142
+ const hasNext = () => {
143
+ if (!state.deck) return false;
144
+ return state.options.loop
145
+ ? true
146
+ : state.currentIndex < state.deck.slides.length - 1;
147
+ };
148
+
149
+ /** Checks if there is a previous slide available (respects loop). */
150
+ const hasPrev = () => {
151
+ if (!state.deck) return false;
152
+ return state.options.loop
153
+ ? true
154
+ : state.currentIndex > 0;
155
+ };
156
+
157
+ /** Returns the current progress as a normalized value between 0 and 1. */
158
+ const progress = () => {
159
+ if (!state.deck || state.deck.slides.length <= 1) return 0;
160
+ return state.currentIndex / (state.deck.slides.length - 1);
161
+ };
162
+
163
+ // --- Actions ---
164
+
165
+ /**
166
+ * Updates the engine options at runtime.
167
+ * Merges provided options with existing ones.
168
+ *
169
+ * @param options - Partial options object to merge.
170
+ */
171
+ function setOptions(options: LuminaOptions) {
172
+ state.options = { ...state.options, ...options };
173
+
174
+ // Theme Merging Logic
175
+ if (options.theme) {
176
+ if (typeof options.theme === 'string') {
177
+ // Completely replace if string preset
178
+ state.options.theme = options.theme;
179
+ } else {
180
+ // Allow fine-grained object overrides
181
+ state.options.theme = options.theme;
182
+ }
183
+ }
184
+
185
+ if (options.ui) state.options.ui = { ...state.options.ui, ...options.ui };
186
+ if (options.keys) state.options.keys = { ...state.options.keys, ...options.keys };
187
+ if (options.animation) state.options.animation = { ...state.options.animation, ...options.animation };
188
+ if (options.elementControl) state.options.elementControl = { ...(state.options.elementControl || {}), ...options.elementControl };
189
+ }
190
+
191
+ /**
192
+ * Ensures all elements in flex layouts have unique dragKeys for vuedraggable.
193
+ */
194
+ function ensureDragKeys(obj: any): any {
195
+ if (!obj || typeof obj !== 'object') return obj;
196
+ if (Array.isArray(obj)) return obj.map(ensureDragKeys);
197
+
198
+ const newObj = { ...obj };
199
+ if (newObj.type === 'flex' && Array.isArray(newObj.elements)) {
200
+ newObj.elements = newObj.elements.map((el: any) => ({
201
+ dragKey: el.dragKey || Date.now() + Math.random(),
202
+ ...ensureDragKeys(el)
203
+ }));
204
+ } else if (newObj.type === 'content' && Array.isArray(newObj.elements)) {
205
+ newObj.elements = newObj.elements.map((el: any) => ({
206
+ dragKey: el.dragKey || Date.now() + Math.random() + 1,
207
+ ...ensureDragKeys(el)
208
+ }));
209
+ } else {
210
+ for (const key in newObj) {
211
+ newObj[key] = ensureDragKeys(newObj[key]);
212
+ }
213
+ }
214
+ return newObj;
215
+ }
216
+
217
+ /**
218
+ * Loads a new deck into the engine and resets state.
219
+ *
220
+ * **Critical order (element control):** Apply `meta.initialElementState` to
221
+ * `state.elementState` *before* assigning `state.deck`. Assigning `state.deck` triggers
222
+ * Vue's re-render; on that first paint, LuminaElements and useTransition read
223
+ * `elementState` to decide initial opacity and whether to exclude `.reveal-*` containers.
224
+ * If `elementState` were applied after `state.deck`, on that first frame they would see
225
+ * empty or stale state (e.g. `visible: true` from a previous load), and elements that
226
+ * should start hidden would appear visible until the next tick.
227
+ *
228
+ * @param deck - Full deck: `{ meta: { initialElementState?, ... }, slides: [...] }`.
229
+ */
230
+ function loadDeck(deck: Deck) {
231
+ if (!deck || !Array.isArray(deck.slides)) {
232
+ console.error('[LuminaStore] Invalid deck format');
233
+ return;
234
+ }
235
+ const normalizedDeck = normalizeAliases(deck);
236
+ const keyedDeck = ensureDragKeys(normalizedDeck);
237
+
238
+ const initial = keyedDeck.meta?.initialElementState;
239
+ const initialObj = initial && typeof initial === 'object' && !Array.isArray(initial) ? initial : {};
240
+ state.elementState = Object.fromEntries(
241
+ Object.entries(initialObj).map(([k, v]) => [k, { ...(v && typeof v === 'object' ? v : {}) }])
242
+ );
243
+
244
+ state.deck = deepExpand(keyedDeck);
245
+ state.currentIndex = 0;
246
+ state.isReady = true;
247
+ state.actionHistory = [];
248
+
249
+ // Elements are hidden by default. Set elementControl.defaultVisible: true (options or meta) to show all.
250
+ const hideByDefault =
251
+ state.options.elementControl?.defaultVisible !== true &&
252
+ keyedDeck.meta?.elementControl?.defaultVisible !== true;
253
+ if (hideByDefault) {
254
+ const inInitial = new Set(Object.keys(initialObj));
255
+ (state.deck?.slides ?? []).forEach((slide, i) => {
256
+ getElementIds(slide, i).forEach((id) => {
257
+ if (!inInitial.has(id)) {
258
+ const cur = state.elementState[id] || {};
259
+ state.elementState[id] = { ...cur, visible: false };
260
+ }
261
+ });
262
+ });
263
+ }
264
+ }
265
+
266
+ /**
267
+ * Feature: Diff Updates
268
+ * Patches the current deck with partial data.
269
+ */
270
+ function patchDeck(partial: Partial<Deck>) {
271
+ if (!state.deck) return;
272
+ const merged = deepMerge(state.deck, partial);
273
+ state.deck = ensureDragKeys(merged);
274
+ }
275
+
276
+ /**
277
+ * Records a user action for the Feedback Loop.
278
+ */
279
+ function recordAction(action: ActionPayload) {
280
+ state.actionHistory.push(action);
281
+ if (state.actionHistory.length > 50) state.actionHistory.shift(); // Keep buffer small
282
+ }
283
+
284
+ /** Advances to the next slide if possible. */
285
+ function next() {
286
+ if (!hasNext()) return;
287
+ const count = state.deck?.slides.length || 0;
288
+ let newIndex = state.currentIndex;
289
+
290
+ if (state.options.loop) {
291
+ newIndex = (state.currentIndex + 1) % count;
292
+ } else {
293
+ newIndex++;
294
+ }
295
+
296
+ if (newIndex !== state.currentIndex) {
297
+ state.currentIndex = newIndex;
298
+ // Emit event via bus (imported from events.ts, need to update imports)
299
+ // Ideally store shouldn't know about bus, but for now this is the direct fix
300
+ }
301
+ }
302
+
303
+ /** Returns to the previous slide if possible. */
304
+ function prev() {
305
+ if (!hasPrev()) return;
306
+ const count = state.deck?.slides.length || 0;
307
+ let newIndex = state.currentIndex;
308
+
309
+ if (state.options.loop) {
310
+ newIndex = (state.currentIndex - 1 + count) % count;
311
+ } else {
312
+ newIndex--;
313
+ }
314
+
315
+ if (newIndex !== state.currentIndex) {
316
+ state.currentIndex = newIndex;
317
+ }
318
+ }
319
+
320
+ /**
321
+ * Jumps immediately to a specific slide index.
322
+ * @param index - Zero-based index of the target slide.
323
+ */
324
+ function goto(index: number) {
325
+ if (!state.deck) return;
326
+ if (index >= 0 && index < state.deck.slides.length) {
327
+ if (state.currentIndex !== index) {
328
+ state.currentIndex = index;
329
+ }
330
+ }
331
+ }
332
+
333
+
334
+
335
+ // --- Editor Actions (Immutable) ---
336
+
337
+ function updateNode(path: string, value: any) {
338
+ if (!state.deck) return;
339
+ const updated = setByPath(state.deck, path, value);
340
+ state.deck = ensureDragKeys(updated);
341
+ }
342
+
343
+ function addNode(arrayPath: string, item: any, index?: number) {
344
+ if (!state.deck) return;
345
+ const target = getByPath(state.deck, arrayPath);
346
+ // Default to end of array if index not provided
347
+ const actualIndex = (index !== undefined) ? index : (Array.isArray(target) ? target.length : 0);
348
+ // Ensure item has keys
349
+ const keyedItem = ensureDragKeys(item);
350
+ state.deck = insertByPath(state.deck, arrayPath, actualIndex, keyedItem);
351
+ }
352
+
353
+ function removeNode(arrayPath: string, index: number) {
354
+ if (!state.deck) return;
355
+ state.deck = removeByPath(state.deck, arrayPath, index);
356
+ }
357
+
358
+ function moveNode(arrayPath: string, from: number, to: number) {
359
+ if (!state.deck) return;
360
+ state.deck = moveByPath(state.deck, arrayPath, from, to);
361
+ }
362
+
363
+ /**
364
+ * Merges a partial {@link ElementState} into the stored state for an element. Used by
365
+ * {@link ElementController} (engine.element(id)). style and class are merged; visible, opacity, transform
366
+ * are replaced when present in `patch`.
367
+ *
368
+ * @param id - Element id (from resolveId, elemId, or explicit id in JSON).
369
+ * @param patch - Partial ElementState. Only provided keys are applied.
370
+ */
371
+ function setElementState(id: string, patch: Partial<ElementState>) {
372
+ const prev = state.elementState[id] || {};
373
+ const next: ElementState = { ...prev };
374
+ if (patch.visible !== undefined) next.visible = patch.visible;
375
+ if (patch.opacity !== undefined) next.opacity = patch.opacity;
376
+ if (patch.transform !== undefined) next.transform = patch.transform;
377
+ if (patch.class !== undefined) next.class = patch.class;
378
+ if (patch.style !== undefined) next.style = { ...(prev.style || {}), ...patch.style };
379
+ state.elementState[id] = next;
380
+ }
381
+
382
+ /**
383
+ * Resets elementState for all ids of the given slide to their initial values.
384
+ * Used when navigating to a slide so that controlled entrance animations
385
+ * (e.g. engine.element(id).show()) can run again. Respects
386
+ * meta.initialElementState and meta.elementControl.defaultVisible (or
387
+ * options.elementControl.defaultVisible).
388
+ *
389
+ * @param slideIndex - Zero-based index of the slide to reset.
390
+ */
391
+ function resetElementStateForSlide(slideIndex: number) {
392
+ const deck = state.deck;
393
+ if (!deck?.slides) return;
394
+ const slide = deck.slides[slideIndex];
395
+ if (!slide) return;
396
+
397
+ const initial = deck.meta?.initialElementState;
398
+ const initialObj = initial && typeof initial === 'object' && !Array.isArray(initial) ? initial : {};
399
+ const hideByDefault =
400
+ state.options.elementControl?.defaultVisible !== true &&
401
+ deck.meta?.elementControl?.defaultVisible !== true;
402
+ const inInitial = new Set(Object.keys(initialObj));
403
+
404
+ getElementIds(slide, slideIndex).forEach((id) => {
405
+ if (inInitial.has(id)) {
406
+ const v = initialObj[id];
407
+ state.elementState[id] = { ...(v && typeof v === 'object' ? v : {}) };
408
+ } else if (hideByDefault) {
409
+ state.elementState[id] = { visible: false };
410
+ } else {
411
+ state.elementState[id] = {};
412
+ }
413
+ });
414
+ }
415
+
416
+ // --- Key-Value Store (engine.data) ---
417
+
418
+ function getUserData(key: string): unknown {
419
+ return state.userData[key];
420
+ }
421
+
422
+ function setUserData(key: string, value: unknown): void {
423
+ state.userData[key] = value;
424
+ }
425
+
426
+ function hasUserData(key: string): boolean {
427
+ return Object.prototype.hasOwnProperty.call(state.userData, key);
428
+ }
429
+
430
+ function deleteUserData(key: string): boolean {
431
+ if (!Object.prototype.hasOwnProperty.call(state.userData, key)) return false;
432
+ delete state.userData[key];
433
+ return true;
434
+ }
435
+
436
+ function clearUserData(): void {
437
+ state.userData = {};
438
+ }
439
+
440
+ return {
441
+ state: readonly(state),
442
+ currentSlide,
443
+ hasNext,
444
+ hasPrev,
445
+ progress,
446
+ setOptions,
447
+ loadDeck,
448
+ patchDeck,
449
+ updateNode,
450
+ addNode,
451
+ removeNode,
452
+ moveNode,
453
+ setElementState,
454
+ resetElementStateForSlide,
455
+ recordAction,
456
+ next,
457
+ prev,
458
+ goto,
459
+ getUserData,
460
+ setUserData,
461
+ hasUserData,
462
+ deleteUserData,
463
+ clearUserData
464
+ };
465
+ }