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,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
|
+
}
|