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,32 @@
1
+ import { computed, inject } from 'vue';
2
+ import { StoreKey } from '../core/store';
3
+
4
+ /**
5
+ * LUMINA COMPOSABLE
6
+ *
7
+ * The primary way for Vue components (Slides, Layouts) to interact with the Engine.
8
+ * Provides typesafe access to state, getters, and actions.
9
+ *
10
+ * @throws Error if used outside of a <LuminaDeck> context (Dependency Injection).
11
+ * @returns {Object} { slide, index, total, next, prev, goto, options }
12
+ */
13
+ export function useLumina() {
14
+ const store = inject(StoreKey);
15
+
16
+ if (!store) {
17
+ throw new Error("Lumina: Component must be used within a Lumina instance.");
18
+ }
19
+
20
+ return {
21
+ // State
22
+ slide: computed(() => store.currentSlide()),
23
+ index: computed(() => store.state.currentIndex),
24
+ total: computed(() => store.state.deck?.slides.length || 0),
25
+
26
+ // Actions
27
+ next: store.next,
28
+ prev: store.prev,
29
+ goto: store.goto,
30
+ options: store.state.options
31
+ };
32
+ }
@@ -0,0 +1,87 @@
1
+ import { reactive, computed, inject, InjectionKey } from 'vue';
2
+ import { StoreKey } from '../core/store';
3
+ import { bus } from '../core/events';
4
+
5
+ export const StudioKey: InjectionKey<ReturnType<typeof createStudio>> = Symbol('StudioKey');
6
+
7
+ export function createStudio() {
8
+ const store = inject(StoreKey);
9
+ if (!store) {
10
+ throw new Error("Lumina Studio requires Lumina Store.");
11
+ }
12
+
13
+ const state = reactive({
14
+ isEnabled: false,
15
+ selectedElementPath: null as string | null,
16
+ selectedElementSchema: null as string | null, // 'title', 'image', etc.
17
+ selectionRect: null as DOMRect | null,
18
+ });
19
+
20
+ const toggle = () => {
21
+ state.isEnabled = !state.isEnabled;
22
+ if (!state.isEnabled) {
23
+ clearSelection();
24
+ }
25
+ };
26
+
27
+ const selectElement = (path: string, schema: string, rect?: DOMRect) => {
28
+ if (!state.isEnabled) return;
29
+ state.selectedElementPath = path;
30
+ state.selectedElementSchema = schema;
31
+ if (rect) state.selectionRect = rect;
32
+ };
33
+
34
+ const clearSelection = () => {
35
+ state.selectedElementPath = null;
36
+ state.selectedElementSchema = null;
37
+ state.selectionRect = null;
38
+ };
39
+
40
+ const currentSlide = computed(() => store.currentSlide());
41
+
42
+ // Helper to get nested value by path string "elements.0.title"
43
+ const getValueByPath = (obj: any, path: string) => {
44
+ return path.split('.').reduce((acc, part) => acc && acc[part], obj);
45
+ };
46
+
47
+ const selectedData = computed(() => {
48
+ if (!state.selectedElementPath || !currentSlide.value) return null;
49
+ // If path is empty or 'root', return slide itself
50
+ if (state.selectedElementPath === 'root') return currentSlide.value;
51
+ return getValueByPath(currentSlide.value, state.selectedElementPath);
52
+ });
53
+
54
+ const updateSelected = (payload: any) => {
55
+ if (!state.selectedElementPath || !store.state.deck) return;
56
+
57
+ const slideIndex = store.state.currentIndex;
58
+ // If path is 'root', we are updating the slide itself
59
+ const relativePath = state.selectedElementPath === 'root' ? '' : '.' + state.selectedElementPath;
60
+ const fullPath = `slides.${slideIndex}${relativePath}`;
61
+
62
+ // Update the store directly
63
+ if (store.updateNode) {
64
+ store.updateNode(fullPath, payload);
65
+ }
66
+
67
+ // Also emit for external listeners if needed
68
+ bus.emit('studio:update', { path: fullPath, value: payload });
69
+ };
70
+
71
+ return {
72
+ state,
73
+ toggle,
74
+ selectElement,
75
+ clearSelection,
76
+ selectedData,
77
+ updateSelected
78
+ };
79
+ }
80
+
81
+ export function useStudio() {
82
+ const studio = inject(StudioKey);
83
+ if (!studio) {
84
+ throw new Error("useStudio must be used within LuminaStudio");
85
+ }
86
+ return studio;
87
+ }
@@ -0,0 +1,53 @@
1
+ import { ref, inject } from 'vue';
2
+ import { StoreKey } from '../core/store';
3
+
4
+ const SWIPE_THRESHOLD = 50;
5
+ const SWIPE_HORIZONTAL_RATIO = 1.5;
6
+ const THROTTLE_MS = 400;
7
+
8
+ /**
9
+ * Touch swipe to navigate between slides on mobile.
10
+ * Swipe left -> next, swipe right -> prev.
11
+ * Only triggers when the gesture is clearly horizontal (avoids conflict with vertical scroll).
12
+ */
13
+ export function useSwipeNav() {
14
+ const store = inject(StoreKey);
15
+ if (!store) return { onTouchStart: () => {}, onTouchEnd: () => {} };
16
+
17
+ const touchStart = ref<{ x: number; y: number } | null>(null);
18
+ let lastSwipeTime = 0;
19
+
20
+ const onTouchStart = (e: TouchEvent) => {
21
+ if (e.touches.length !== 1) return;
22
+ touchStart.value = { x: e.touches[0].clientX, y: e.touches[0].clientY };
23
+ };
24
+
25
+ const onTouchEnd = (e: TouchEvent) => {
26
+ const start = touchStart.value;
27
+ touchStart.value = null;
28
+ if (!start || e.changedTouches.length !== 1) return;
29
+
30
+ const { options } = store.state;
31
+ if (!options.navigation) return;
32
+
33
+ const now = Date.now();
34
+ if (now - lastSwipeTime < THROTTLE_MS) return;
35
+
36
+ const end = e.changedTouches[0];
37
+ const dx = end.clientX - start.x;
38
+ const dy = end.clientY - start.y;
39
+
40
+ if (Math.abs(dx) < SWIPE_THRESHOLD) return;
41
+ if (Math.abs(dx) <= Math.abs(dy) * SWIPE_HORIZONTAL_RATIO) return;
42
+
43
+ if (dx < 0 && store.hasNext()) {
44
+ store.next();
45
+ lastSwipeTime = now;
46
+ } else if (dx > 0 && store.hasPrev()) {
47
+ store.prev();
48
+ lastSwipeTime = now;
49
+ }
50
+ };
51
+
52
+ return { onTouchStart, onTouchEnd };
53
+ }
@@ -0,0 +1,373 @@
1
+ import { onMounted, onUnmounted, nextTick, inject, watch, type Ref } from 'vue';
2
+ import gsap from 'gsap';
3
+ import { StoreKey } from '../core/store';
4
+ import { resolveTransitionConfig } from '../core/animationConfig';
5
+ import type { BaseSlideData } from '../core/types';
6
+
7
+ /**
8
+ * REUSABLE TRANSITION SYSTEM
9
+ *
10
+ * Automatically applies entrance animations to elements with 'reveal-*' classes.
11
+ * Reads configuration from the store, prioritizing slide-level then deck-level meta.
12
+ *
13
+ * @param containerRef - Vue Ref pointing to the slide's root element.
14
+ * @param slideData - Optional slide-specific data for local overrides.
15
+ * @param onComplete - Optional callback fired when the entrance animation finishes.
16
+ */
17
+ /** True if container itself or any [data-lumina-id] descendant has elementState.visible === false. */
18
+ function hasElementControlledHiddenDescendant(
19
+ container: Element,
20
+ elementState: Record<string, { visible?: boolean }>
21
+ ): boolean {
22
+ const selfId = container.getAttribute?.('data-lumina-id');
23
+ if (selfId && elementState[selfId]?.visible === false) return true;
24
+ const nodes = container.querySelectorAll?.('[data-lumina-id]') || [];
25
+ for (const el of nodes) {
26
+ const id = el.getAttribute('data-lumina-id');
27
+ if (id && elementState[id]?.visible === false) return true;
28
+ }
29
+ return false;
30
+ }
31
+
32
+ export function useTransition(containerRef: Ref<HTMLElement | null>, slideData?: BaseSlideData, onComplete?: () => void) {
33
+ const store = inject(StoreKey);
34
+ let ctx: gsap.Context | null = null;
35
+
36
+ // Helper: Manually split text into spans for character/word animation
37
+ const splitText = (elements: NodeListOf<Element> | Element[]) => {
38
+ // Restore elements that are no longer intended for split text
39
+ containerRef.value?.querySelectorAll('[data-split-init]').forEach(el => {
40
+ const element = el as HTMLElement;
41
+ if (!element.hasAttribute('data-split-text')) {
42
+ element.innerHTML = element.dataset.splitTextValue || element.innerText;
43
+ delete element.dataset.splitInit;
44
+ delete element.dataset.splitTextValue;
45
+ }
46
+ });
47
+
48
+ elements.forEach(el => {
49
+ const element = el as HTMLElement;
50
+ const text = element.innerText;
51
+ if (!text) return;
52
+
53
+ // If text changed, re-split
54
+ if (element.dataset.splitTextValue === text) return;
55
+
56
+ element.innerHTML = text.split('').map(char =>
57
+ char === ' ' ? '&nbsp;' : `<span class="inline-block reveal-char">${char}</span>`
58
+ ).join('');
59
+
60
+ element.dataset.splitInit = 'true';
61
+ element.dataset.splitTextValue = text;
62
+ element.style.whiteSpace = 'normal';
63
+ });
64
+ };
65
+
66
+ const animateIn = (immediate = false) => {
67
+ if (!containerRef.value) return;
68
+
69
+ ctx?.revert();
70
+
71
+ const config = resolveTransitionConfig(store, slideData);
72
+ const { enabled, type, entranceReveal } = config;
73
+
74
+ // Initial Properties
75
+ const isFlip = type === 'flip';
76
+ const isZoomRotate = type === 'zoom-rotate';
77
+ const isElastic = type === 'elastic-up';
78
+ const isBlur = type === 'blur';
79
+ const isSkew = type === 'skew';
80
+ const isSpring = type === 'spring' || isElastic;
81
+
82
+ // 1. NO REVEAL TIMELINE: when entranceReveal is off (default), animations disabled, or immediate
83
+ // The built-in GSAP reveal on .reveal-* runs only when meta.effects.entranceReveal or
84
+ // options.animation.entranceReveal is explicitly true. Otherwise we only set final state
85
+ // (excluded=opacity 0, toReveal=opacity 1) for any .reveal-* present; no stagger/timeline.
86
+ if (enabled === false || immediate || !entranceReveal) {
87
+ const allPossible = [
88
+ '.reveal-up', '.reveal-zoom', '.reveal-card', '.reveal-left', '.reveal-right',
89
+ '.reveal-content > *',
90
+ '.flex-layout > div > *',
91
+ '.reveal-img', '.reveal-char', '[data-split-text]', '.reveal-child'
92
+ ].join(', ');
93
+
94
+ // Should also process split text if needed for immediate display
95
+ const textToSplit = containerRef.value.querySelectorAll('[data-split-text]');
96
+ if (textToSplit.length) splitText(textToSplit);
97
+
98
+ const es = store?.state?.elementState ?? {};
99
+ const allEls = Array.from(containerRef.value.querySelectorAll(allPossible));
100
+ const toReveal = allEls.filter(e => !hasElementControlledHiddenDescendant(e, es));
101
+ const excluded = allEls.filter(e => hasElementControlledHiddenDescendant(e, es));
102
+ gsap.set(excluded, { opacity: 0 });
103
+ gsap.set(toReveal, {
104
+ opacity: 1, y: 0, x: 0, scale: 1, filter: 'blur(0px)', visibility: 'visible', rotation: 0, rotationY: 0, skewY: 0
105
+ });
106
+
107
+ // Ensure split text chars are visible
108
+ const chars = containerRef.value.querySelectorAll('.reveal-char');
109
+ if (chars.length) {
110
+ gsap.set(chars, { y: '0%', opacity: 1, rotateX: 0 });
111
+ }
112
+
113
+ return;
114
+ }
115
+
116
+ // 2. ANIMATION CONTEXT
117
+ ctx = gsap.context(() => {
118
+ const el = containerRef.value!;
119
+
120
+ // 1. Handle Text Splitting FIRST
121
+ const textToSplit = el.querySelectorAll('[data-split-text]');
122
+ if (textToSplit.length) splitText(textToSplit);
123
+
124
+ // Initial Properties
125
+ const initY = (type === 'fade' || isBlur || isFlip) ? 0 : (isElastic ? 100 : 30);
126
+ const initRotationY = isFlip ? 90 : 0;
127
+ const initRotation = isZoomRotate ? -10 : 0;
128
+ const initScale = (type === 'zoom' || isZoomRotate) ? 0.7 : 1;
129
+ const initBlur = isBlur ? 15 : 0;
130
+ const initSkew = isSkew ? 10 : 0;
131
+
132
+ // Set Initial States
133
+ const standardSelector = [
134
+ '.reveal-up', '.reveal-zoom', '.reveal-card', '.reveal-left', '.reveal-right',
135
+ '.reveal-content > *',
136
+ '.flex-layout > div > *'
137
+ ].join(', ');
138
+
139
+ const es = store?.state?.elementState ?? {};
140
+ const raw = Array.from(el.querySelectorAll(standardSelector)).filter(item => !item.hasAttribute('data-split-text'));
141
+ const standardElements = raw.filter(item => !hasElementControlledHiddenDescendant(item, es)) as HTMLElement[];
142
+ const excluded = raw.filter(item => hasElementControlledHiddenDescendant(item, es));
143
+ if (excluded.length) {
144
+ gsap.set(excluded, { opacity: 0 });
145
+ }
146
+
147
+ const chars = el.querySelectorAll('.reveal-char');
148
+ const images = el.querySelectorAll('.reveal-img');
149
+ const childReveals = el.querySelectorAll('.reveal-child');
150
+
151
+ // Ensure split-text containers themselves are visible (spans will handle the staggering)
152
+ if (textToSplit.length) {
153
+ gsap.set(textToSplit, { opacity: 1, visibility: 'visible' });
154
+ }
155
+
156
+ // Apply Initial States for standard elements
157
+ if (standardElements.length) {
158
+ gsap.set(standardElements, {
159
+ y: initY,
160
+ opacity: 0,
161
+ scale: initScale,
162
+ rotationY: initRotationY,
163
+ rotation: initRotation,
164
+ filter: `blur(${initBlur}px)`,
165
+ skewY: initSkew,
166
+ transformPerspective: 1200
167
+ });
168
+
169
+ // Directional Overrides
170
+ const lefts = standardElements.filter(e => e.classList.contains('reveal-left'));
171
+ const rights = standardElements.filter(e => e.classList.contains('reveal-right'));
172
+ if (lefts.length > 0) gsap.set(lefts, { x: -50, y: 0 });
173
+ if (rights.length > 0) gsap.set(rights, { x: 50, y: 0 });
174
+ }
175
+
176
+ if (images.length) {
177
+ gsap.set(images, { scale: 1.1, opacity: 0, filter: 'blur(10px)' });
178
+ }
179
+ if (chars.length) {
180
+ gsap.set(chars, { y: '100%', opacity: 0, rotateX: -90 });
181
+ }
182
+ if (childReveals.length) {
183
+ gsap.set(childReveals, { y: 15, opacity: 0 });
184
+ }
185
+
186
+ // Timeline
187
+ const dIn = config.durationIn || (isElastic ? config.durationInElastic : config.durationInStandard);
188
+ const tl = gsap.timeline({
189
+ defaults: {
190
+ ease: config.ease || (isElastic ? config.easeElastic : (isSpring ? config.easeSpring : config.ease)),
191
+ duration: dIn
192
+ },
193
+ onComplete
194
+ });
195
+
196
+ // Start Animation Sequence
197
+ // ------------------------
198
+
199
+ // 1. Images
200
+ if (images.length) {
201
+ tl.to(images, {
202
+ scale: 1, opacity: 1, filter: 'blur(0px)',
203
+ duration: dIn * config.imageDurationMultiplier,
204
+ ease: config.imageEase
205
+ }, 0);
206
+ }
207
+
208
+ // 2. Characters (Text)
209
+ if (chars.length) {
210
+ tl.to(chars, {
211
+ y: '0%', opacity: 1, rotateX: 0,
212
+ duration: dIn * config.characterDurationFactor,
213
+ stagger: config.characterStagger,
214
+ ease: config.characterEase
215
+ }, 0.05);
216
+ }
217
+
218
+ // 3. Standard Elements (Staggered)
219
+ if (standardElements.length) {
220
+ tl.to(standardElements, {
221
+ y: 0, x: 0, opacity: 1, scale: 1,
222
+ rotationY: 0, rotation: 0,
223
+ filter: 'blur(0px)',
224
+ skewY: 0,
225
+ duration: dIn,
226
+ stagger: config.stagger
227
+ }, 0.1);
228
+ }
229
+
230
+ // 4. Reveal Children (Micro-stagger inside cards)
231
+ if (childReveals.length) {
232
+ tl.to(childReveals, {
233
+ y: 0, opacity: 1,
234
+ duration: dIn * config.childRevealDurationFactor,
235
+ stagger: config.childRevealStagger,
236
+ ease: config.childRevealEase
237
+ }, config.childRevealStart);
238
+ }
239
+
240
+ }, containerRef.value);
241
+ };
242
+
243
+ const animateOut = (): Promise<void> => {
244
+ return new Promise((resolve) => {
245
+ if (!containerRef.value) { resolve(); return; }
246
+
247
+ const config = resolveTransitionConfig(store, slideData);
248
+ const { enabled, type, durationOut, stagger } = config;
249
+
250
+ if (enabled === false) { resolve(); return; }
251
+
252
+ const el = containerRef.value;
253
+ const standardReveals = [
254
+ '.reveal-up', '.reveal-zoom', '.reveal-card', '.reveal-left', '.reveal-right',
255
+ '.reveal-content > *',
256
+ '.flex-layout > div > *',
257
+ ].join(', ');
258
+ const elements = el.querySelectorAll(standardReveals);
259
+ const chars = el.querySelectorAll('.reveal-char');
260
+ const childReveals = el.querySelectorAll('.reveal-child');
261
+ const img = el.querySelector('.reveal-img');
262
+
263
+ const tl = gsap.timeline({
264
+ onComplete: resolve,
265
+ defaults: {
266
+ duration: durationOut,
267
+ ease: config.easeOut
268
+ }
269
+ });
270
+
271
+ // Exit Sequence (Reverse of Entry)
272
+ // -------------------------------
273
+
274
+ // 1. Elements
275
+ if (elements.length) {
276
+ const moveY = (type === 'fade' || type === 'blur') ? 0 : (type === 'cascade' ? -30 : 20);
277
+ tl.to(elements, {
278
+ opacity: 0,
279
+ y: moveY,
280
+ scale: type === 'zoom' ? 0.9 : 1,
281
+ stagger: stagger * config.exitStaggerFactor
282
+ }, 0);
283
+ }
284
+
285
+ // 2. Characters
286
+ if (chars.length) {
287
+ const charY = type === 'fade' ? 0 : '-50%';
288
+ tl.to(chars, {
289
+ opacity: 0,
290
+ y: charY,
291
+ stagger: config.exitCharStagger,
292
+ ease: config.easeOut
293
+ }, 0.1);
294
+ }
295
+
296
+ // 3. Image
297
+ if (img) {
298
+ tl.to(img, {
299
+ opacity: 0,
300
+ scale: 1.05,
301
+ filter: 'blur(10px)',
302
+ duration: durationOut * config.exitImageDurationFactor
303
+ }, 0.1);
304
+ }
305
+
306
+ if (childReveals.length) {
307
+ tl.to(childReveals, { opacity: 0, y: 10, duration: durationOut * config.exitChildDurationFactor }, 0);
308
+ }
309
+
310
+ // Fallback if no elements were found, ensure resolve is called
311
+ if (!elements.length && !chars.length && !img) {
312
+ resolve();
313
+ }
314
+ });
315
+ };
316
+
317
+ // Live Update Watcher (for Studio)
318
+ watch(() => ([
319
+ slideData,
320
+ store?.state.deck?.meta?.effects,
321
+ store?.state.options.animation
322
+ ]), () => {
323
+ nextTick(() => animateIn(true)); // Pass true for immediate update
324
+ }, { deep: true });
325
+
326
+ onMounted(() => {
327
+ // Initial trigger
328
+ animateIn();
329
+
330
+ // Watch for async content appearing (e.g. Charts after loading)
331
+ const observer = new MutationObserver(() => {
332
+ const el = containerRef.value;
333
+ if (!el) return;
334
+
335
+ const config = resolveTransitionConfig(store, slideData);
336
+
337
+ // If animations are DISABLED or entranceReveal is off, only set final state for late-appearing items (e.g. charts)
338
+ if (config.enabled === false || !config.entranceReveal) {
339
+ const allPossible = [
340
+ '.reveal-up', '.reveal-zoom', '.reveal-card', '.reveal-left', '.reveal-right',
341
+ '.reveal-content > *', '.flex-layout > div > *',
342
+ '.reveal-img', '.reveal-char', '[data-split-text]', '.reveal-child'
343
+ ].join(', ');
344
+ const es = store?.state?.elementState ?? {};
345
+ const raw = Array.from(el.querySelectorAll(allPossible));
346
+ const items = raw.filter(e => !hasElementControlledHiddenDescendant(e, es));
347
+ const excluded = raw.filter(e => hasElementControlledHiddenDescendant(e, es));
348
+ if (excluded.length) gsap.set(excluded, { opacity: 0 });
349
+ if (items.length) {
350
+ gsap.set(items, { opacity: 1, y: 0, x: 0, scale: 1, filter: 'blur(0px)', visibility: 'visible' });
351
+ }
352
+ return;
353
+ }
354
+
355
+ // If entranceReveal is ON, check for revealable elements that haven't been animated yet
356
+ if (config.entranceReveal) {
357
+ const lateElements = el.querySelectorAll('.reveal-up, .reveal-zoom, .reveal-card, .reveal-left, .reveal-right, .reveal-content > *, .flex-layout > div > *');
358
+ const hasInvisible = Array.from(lateElements).some(e => (e as HTMLElement).style.opacity === '' && getComputedStyle(e).opacity === '0');
359
+ if (hasInvisible) animateIn();
360
+ }
361
+ });
362
+
363
+ if (containerRef.value) {
364
+ observer.observe(containerRef.value, { childList: true, subtree: true });
365
+ }
366
+
367
+ onUnmounted(() => observer.disconnect());
368
+ });
369
+
370
+ onUnmounted(() => ctx?.revert());
371
+
372
+ return { animateIn, animateOut };
373
+ }