lumina-slides 9.0.1 → 9.0.2
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/dist/lumina-slides.js +7575 -7539
- package/dist/lumina-slides.umd.cjs +192 -192
- package/dist/style.css +1 -1
- package/package.json +1 -1
- package/src/animation/registry.ts +13 -10
- package/src/animation/stagger.ts +1 -1
- package/src/animation/types.ts +3 -1
- package/src/components/LuminaDeck.vue +15 -7
- package/src/components/site/SiteDocs.vue +260 -396
- package/src/components/site/SiteExamples.vue +1 -0
- package/src/core/Lumina.ts +43 -18
- package/src/core/animationConfig.ts +1 -1
- package/src/core/elementController.ts +14 -8
- package/src/core/elementResolver.ts +4 -4
- package/src/core/store.ts +1 -1
- package/src/core/types.ts +25 -4
- package/src/index.ts +4 -1
- package/src/utils/templateInterpolation.ts +52 -0
- package/src/views/DeckView.vue +55 -31
|
@@ -36,6 +36,7 @@ const router = useRouter();
|
|
|
36
36
|
const decks = [
|
|
37
37
|
{ id: 'deck', title: 'Feature Showcase', description: 'A comprehensive tour of all major features.', icon: '🚀' },
|
|
38
38
|
{ id: 'layout-element-control', title: 'Element Control', description: 'Reveal-on-demand: elements start hidden and appear automatically.', icon: '🎭' },
|
|
39
|
+
{ id: 'layout-template-tags', title: 'Template Tags', description: '{{key}} placeholders in slide content, resolved from engine.data. Updates reactively.', icon: '🏷️' },
|
|
39
40
|
{ id: 'animation-presets', title: 'Animation: Presets', description: 'Built-in presets: fadeUp, scaleIn, spring. Use with animate() or revealInSequence.', icon: '✨' },
|
|
40
41
|
{ id: 'animation-stagger', title: 'Animation: Stagger', description: 'Stagger modes: center-out, wave, random. Control order and timing.', icon: '〰️' },
|
|
41
42
|
{ id: 'animation-timeline', title: 'Animation: Timeline', description: 'Remotion-style: scrub 0–1, keyframes per element, seekTo and playTimeline.', icon: '⏯️' },
|
package/src/core/Lumina.ts
CHANGED
|
@@ -25,7 +25,7 @@ import type { ElementPath } from './elementResolver';
|
|
|
25
25
|
import { bus } from './events';
|
|
26
26
|
import { ThemeManager, deepMerge } from './theme';
|
|
27
27
|
import { SpeakerChannel } from './speaker-channel';
|
|
28
|
-
import type { Deck, LuminaOptions, LuminaEventMap, LuminaDataStore, SpeakerSyncPayload, BaseSlideData, ElementController, RevealInSequenceOptions,
|
|
28
|
+
import type { Deck, LuminaOptions, LuminaEventMap, LuminaDataStore, SpeakerSyncPayload, BaseSlideData, ElementController, RevealInSequenceOptions, ThemeConfig } from './types';
|
|
29
29
|
import '../style/main.css';
|
|
30
30
|
|
|
31
31
|
/**
|
|
@@ -114,13 +114,18 @@ export class Lumina {
|
|
|
114
114
|
// Watch for state changes and emit public events
|
|
115
115
|
watch(() => this.store.state.currentIndex, (newIndex, oldIndex) => {
|
|
116
116
|
this.store.resetElementStateForSlide(newIndex);
|
|
117
|
-
const
|
|
117
|
+
const deck = this.store.state.deck;
|
|
118
|
+
const currentSlide = deck?.slides?.[newIndex];
|
|
118
119
|
if (currentSlide) {
|
|
119
120
|
bus.emit('slideChange', {
|
|
120
121
|
index: newIndex,
|
|
121
122
|
previousIndex: oldIndex ?? 0,
|
|
122
123
|
slide: currentSlide
|
|
123
124
|
});
|
|
125
|
+
const rev = deck?.meta?.reveal ?? currentSlide?.reveal;
|
|
126
|
+
if (rev && typeof rev === 'object') {
|
|
127
|
+
this.revealInSequence(newIndex, this.getRevealOptionsFromDeck(deck!, newIndex));
|
|
128
|
+
}
|
|
124
129
|
}
|
|
125
130
|
});
|
|
126
131
|
|
|
@@ -130,8 +135,8 @@ export class Lumina {
|
|
|
130
135
|
// 1. Construct Theme Overrides from Meta
|
|
131
136
|
// - meta.themeConfig: full ThemeConfig object (e.g. from theme-custom-example.json)
|
|
132
137
|
// - meta.colors, meta.typography, etc.: direct overrides from Studio (take precedence)
|
|
133
|
-
const fromThemeConfig =
|
|
134
|
-
const direct = {
|
|
138
|
+
const fromThemeConfig = newMeta.themeConfig || {};
|
|
139
|
+
const direct: Partial<ThemeConfig> = {
|
|
135
140
|
colors: newMeta.colors,
|
|
136
141
|
typography: newMeta.typography,
|
|
137
142
|
spacing: newMeta.spacing,
|
|
@@ -139,7 +144,8 @@ export class Lumina {
|
|
|
139
144
|
effects: newMeta.effects,
|
|
140
145
|
components: newMeta.components
|
|
141
146
|
};
|
|
142
|
-
const
|
|
147
|
+
const base = (fromThemeConfig && typeof fromThemeConfig === 'object' && !Array.isArray(fromThemeConfig) ? fromThemeConfig : {});
|
|
148
|
+
const overrides = deepMerge<ThemeConfig>(base as ThemeConfig, direct);
|
|
143
149
|
|
|
144
150
|
// Resolve theme: deck.theme (top-level, e.g. from theme-ocean.json) takes precedence over
|
|
145
151
|
// meta.theme, then constructor options. Ensures examples and loaded decks keep their theme.
|
|
@@ -208,13 +214,17 @@ export class Lumina {
|
|
|
208
214
|
* engine.load({ meta: { title: "Q4 Review" }, slides: [ { type: "statement", title: "Intro" } ] });
|
|
209
215
|
*/
|
|
210
216
|
public load(deckData: Deck) {
|
|
211
|
-
if (!deckData || !Array.isArray(
|
|
217
|
+
if (!deckData || !Array.isArray(deckData.slides)) {
|
|
212
218
|
bus.emit('error', new Error('Invalid deck: expected { meta?, slides: [] }'));
|
|
213
219
|
return;
|
|
214
220
|
}
|
|
215
221
|
try {
|
|
216
222
|
this.store.loadDeck(deckData);
|
|
217
223
|
bus.emit('ready', deckData);
|
|
224
|
+
const rev = deckData.meta?.reveal ?? deckData.slides?.[0]?.reveal;
|
|
225
|
+
if (rev && typeof rev === 'object') {
|
|
226
|
+
this.revealInSequence(0, this.getRevealOptionsFromDeck(deckData, 0));
|
|
227
|
+
}
|
|
218
228
|
this.syncSpeakerNotes();
|
|
219
229
|
} catch (e) {
|
|
220
230
|
bus.emit('error', e instanceof Error ? e : new Error(String(e)));
|
|
@@ -230,7 +240,7 @@ export class Lumina {
|
|
|
230
240
|
* @example
|
|
231
241
|
* engine.patch({ meta: { title: "Updated Title" } });
|
|
232
242
|
*/
|
|
233
|
-
public patch(diff:
|
|
243
|
+
public patch(diff: Partial<Deck>) {
|
|
234
244
|
this.store.patchDeck(diff);
|
|
235
245
|
bus.emit('patch', { diff: diff ?? {} });
|
|
236
246
|
this.syncSpeakerNotes();
|
|
@@ -257,7 +267,7 @@ export class Lumina {
|
|
|
257
267
|
index: currentIndex,
|
|
258
268
|
id: slide?.id || currentIndex,
|
|
259
269
|
type: slide?.type,
|
|
260
|
-
title: (slide
|
|
270
|
+
title: (typeof slide?.title === 'string' ? slide.title : null) || "(No Title)"
|
|
261
271
|
},
|
|
262
272
|
narrative: `User is currently on slide ${currentIndex + 1}. Session Flow: ${context || "Just started"}.`,
|
|
263
273
|
engagementLevel: interest,
|
|
@@ -368,7 +378,8 @@ export class Lumina {
|
|
|
368
378
|
if (!container) return null;
|
|
369
379
|
const root = container.querySelector('[data-lumina-root]') || container;
|
|
370
380
|
const escaped = id.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
371
|
-
|
|
381
|
+
const el = root.querySelector(`[data-lumina-id="${escaped}"]`);
|
|
382
|
+
return el instanceof HTMLElement ? el : null;
|
|
372
383
|
}
|
|
373
384
|
|
|
374
385
|
/**
|
|
@@ -529,13 +540,28 @@ export class Lumina {
|
|
|
529
540
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
530
541
|
}
|
|
531
542
|
|
|
543
|
+
/**
|
|
544
|
+
* Merges meta.reveal and slide.reveal into options for revealInSequence. slide overrides meta.
|
|
545
|
+
* Used when auto-running reveal from declarative slide.reveal or meta.reveal in the deck JSON.
|
|
546
|
+
*/
|
|
547
|
+
private getRevealOptionsFromDeck(
|
|
548
|
+
deck: { meta?: { reveal?: RevealInSequenceOptions }; slides?: ReadonlyArray<{ reveal?: RevealInSequenceOptions }> },
|
|
549
|
+
index: number
|
|
550
|
+
): RevealInSequenceOptions {
|
|
551
|
+
const meta = deck.meta;
|
|
552
|
+
const slide = deck.slides?.[index];
|
|
553
|
+
const a = (meta?.reveal && typeof meta.reveal === 'object') ? meta.reveal : {};
|
|
554
|
+
const b = (slide?.reveal && typeof slide.reveal === 'object') ? slide.reveal : {};
|
|
555
|
+
return { ...a, ...b };
|
|
556
|
+
}
|
|
557
|
+
|
|
532
558
|
/**
|
|
533
559
|
* Reveals slide elements one by one with optional stagger and animation. Optionally
|
|
534
560
|
* hides all first (hideFirst: true). Resolves when the sequence has been scheduled
|
|
535
561
|
* (does not wait for the last animation to finish).
|
|
536
562
|
*
|
|
537
563
|
* @param slideIndex - Zero-based slide index. Omit to use the current slide.
|
|
538
|
-
* @param options - Stagger delay, animation from/to, and filters (only, exclude).
|
|
564
|
+
* @param options - Stagger delay, animation from/to, and filters (only, exclude). Or from slide.reveal / meta.reveal when auto-run.
|
|
539
565
|
* @returns Promise that resolves after the last element's scheduled reveal time.
|
|
540
566
|
*
|
|
541
567
|
* @example
|
|
@@ -554,6 +580,7 @@ export class Lumina {
|
|
|
554
580
|
const delayMs = opts.delayMs ?? cfg.revealDelayMs;
|
|
555
581
|
const animate = opts.animate !== false;
|
|
556
582
|
|
|
583
|
+
const motions = this.store.state.deck?.meta?.motions;
|
|
557
584
|
const resolved = resolveAnimationFromInput(
|
|
558
585
|
{
|
|
559
586
|
preset: opts.preset,
|
|
@@ -563,7 +590,8 @@ export class Lumina {
|
|
|
563
590
|
ease: opts.ease,
|
|
564
591
|
duration: opts.duration,
|
|
565
592
|
},
|
|
566
|
-
{ from: { opacity: 0, y: 16 }, to: { opacity: 1, y: 0 }, ease: cfg.revealEase, duration: cfg.revealDuration }
|
|
593
|
+
{ from: { opacity: 0, y: 16 }, to: { opacity: 1, y: 0 }, ease: cfg.revealEase, duration: cfg.revealDuration },
|
|
594
|
+
motions
|
|
567
595
|
);
|
|
568
596
|
|
|
569
597
|
const steps = computeStagger({
|
|
@@ -628,7 +656,7 @@ export class Lumina {
|
|
|
628
656
|
*/
|
|
629
657
|
public seekTo(progress: number, slideIndex?: number): this {
|
|
630
658
|
const i = slideIndex ?? this.currentSlideIndex;
|
|
631
|
-
const slide = this.store.state.deck?.slides[i]
|
|
659
|
+
const slide = this.store.state.deck?.slides[i];
|
|
632
660
|
const timeline = slide?.timelineTracks;
|
|
633
661
|
this._timelineProgress = Math.max(0, Math.min(1, progress));
|
|
634
662
|
bus.emit('timelineSeek', { progress: this._timelineProgress, slideIndex: i });
|
|
@@ -654,7 +682,7 @@ export class Lumina {
|
|
|
654
682
|
*/
|
|
655
683
|
public playTimeline(durationSeconds = 5, slideIndex?: number): [Promise<void>, () => void] {
|
|
656
684
|
const i = slideIndex ?? this.currentSlideIndex;
|
|
657
|
-
const slide = this.store.state.deck?.slides[i]
|
|
685
|
+
const slide = this.store.state.deck?.slides[i];
|
|
658
686
|
if (!slide?.timelineTracks || typeof slide.timelineTracks !== 'object') {
|
|
659
687
|
return [Promise.resolve(), () => {}];
|
|
660
688
|
}
|
|
@@ -799,11 +827,8 @@ export class Lumina {
|
|
|
799
827
|
const currentSlide = deck.slides[currentIndex];
|
|
800
828
|
const nextSlide = deck.slides[currentIndex + 1];
|
|
801
829
|
|
|
802
|
-
const getSlideTitle = (slide: BaseSlideData | undefined): string | undefined =>
|
|
803
|
-
|
|
804
|
-
if ('title' in slide && typeof (slide as any).title === 'string') return (slide as any).title;
|
|
805
|
-
return undefined;
|
|
806
|
-
};
|
|
830
|
+
const getSlideTitle = (slide: BaseSlideData | undefined): string | undefined =>
|
|
831
|
+
(slide && typeof slide.title === 'string' ? slide.title : undefined);
|
|
807
832
|
|
|
808
833
|
this.speakerChannel.send({
|
|
809
834
|
action: 'state',
|
|
@@ -232,7 +232,7 @@ function pickCanonical(src: Record<string, unknown> | null | undefined): Partial
|
|
|
232
232
|
* @see ResolvedTransitionConfig
|
|
233
233
|
* @see LuminaAnimationOptions
|
|
234
234
|
*/
|
|
235
|
-
export function resolveTransitionConfig(store: LuminaStore | null | undefined, slideData?: BaseSlideData | null): ResolvedTransitionConfig {
|
|
235
|
+
export function resolveTransitionConfig(store: LuminaStore | null | undefined, slideData?: Readonly<BaseSlideData> | null): ResolvedTransitionConfig {
|
|
236
236
|
const base = { ...ANIMATION_DEFAULTS } as unknown as Record<string, unknown>;
|
|
237
237
|
|
|
238
238
|
const opt = (store?.state?.options?.animation || {}) as Record<string, unknown>;
|
|
@@ -40,7 +40,7 @@ export function createElementController(
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
function getClass(): string {
|
|
43
|
-
return
|
|
43
|
+
return store.state.elementState[id]?.class ?? '';
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
const ctrl: IElementController = {
|
|
@@ -121,12 +121,17 @@ export function createElementController(
|
|
|
121
121
|
const vb = keyframes[String(b)] ?? {};
|
|
122
122
|
const t = (progress - a) / (b - a);
|
|
123
123
|
state = {};
|
|
124
|
-
|
|
124
|
+
const numericKeys = ['opacity', 'x', 'y', 'scale', 'rotate'] as const;
|
|
125
|
+
for (const prop of numericKeys) {
|
|
125
126
|
const pa = va[prop];
|
|
126
127
|
const pb = vb[prop];
|
|
127
|
-
if (typeof pa === 'number' && typeof pb === 'number')
|
|
128
|
-
|
|
129
|
-
else if (
|
|
128
|
+
if (typeof pa === 'number' && typeof pb === 'number') {
|
|
129
|
+
state[prop] = pa + (pb - pa) * t;
|
|
130
|
+
} else if (typeof pb === 'number') {
|
|
131
|
+
state[prop] = pb;
|
|
132
|
+
} else if (typeof pa === 'number') {
|
|
133
|
+
state[prop] = pa;
|
|
134
|
+
}
|
|
130
135
|
}
|
|
131
136
|
if (typeof vb.visible === 'boolean') state.visible = vb.visible;
|
|
132
137
|
else if (typeof va.visible === 'boolean') state.visible = va.visible;
|
|
@@ -153,14 +158,15 @@ export function createElementController(
|
|
|
153
158
|
const el = engine.getElementById(id);
|
|
154
159
|
if (!el) return;
|
|
155
160
|
const cfg = resolveTransitionConfig(store, undefined);
|
|
161
|
+
const motions = store.state.deck?.meta?.motions;
|
|
156
162
|
const { from, to, ease, duration } = resolveAnimationFromInput(opts, {
|
|
157
163
|
duration: cfg.elementDuration,
|
|
158
164
|
ease: cfg.elementEase,
|
|
159
|
-
});
|
|
165
|
+
}, motions);
|
|
160
166
|
const cb = onCompleteOverride ?? opts.onComplete;
|
|
161
|
-
const toVars = { ...to, duration, ease, overwrite: true, onComplete: cb }
|
|
167
|
+
const toVars: gsap.TweenVars = { ...to, duration, ease, overwrite: true, onComplete: cb };
|
|
162
168
|
if (from && Object.keys(from).length) {
|
|
163
|
-
gsap.fromTo(el, from
|
|
169
|
+
gsap.fromTo(el, from, toVars);
|
|
164
170
|
} else {
|
|
165
171
|
gsap.to(el, toVars);
|
|
166
172
|
}
|
|
@@ -40,7 +40,7 @@ export function pathToKey(path: ElementPath): string {
|
|
|
40
40
|
* @param path - Path segments (e.g. ['features', 0] or ['elements', 0, 'elements', 1]).
|
|
41
41
|
* @returns The value at that path, or undefined if missing.
|
|
42
42
|
*/
|
|
43
|
-
export function getValueAt(slide: BaseSlideData
|
|
43
|
+
export function getValueAt(slide: Readonly<BaseSlideData>, path: ElementPath): unknown {
|
|
44
44
|
if (!slide || !path.length) return undefined;
|
|
45
45
|
return getByPath(slide, pathToKey(path));
|
|
46
46
|
}
|
|
@@ -83,7 +83,7 @@ export function parsePath(input: string | ElementPath): ElementPath {
|
|
|
83
83
|
* @see elemId
|
|
84
84
|
*/
|
|
85
85
|
export function resolveId(
|
|
86
|
-
slide: BaseSlideData
|
|
86
|
+
slide: Readonly<BaseSlideData>,
|
|
87
87
|
slideIndex: number,
|
|
88
88
|
path: ElementPath
|
|
89
89
|
): string {
|
|
@@ -103,7 +103,7 @@ export function resolveId(
|
|
|
103
103
|
}
|
|
104
104
|
|
|
105
105
|
/** Function that returns all logical element paths for a given slide. One per layout type. */
|
|
106
|
-
export type PathGenerator = (slide: BaseSlideData) => ElementPath[];
|
|
106
|
+
export type PathGenerator = (slide: Readonly<BaseSlideData>) => ElementPath[];
|
|
107
107
|
|
|
108
108
|
/** Map of slide.type → path generator. Extend to support new layouts. */
|
|
109
109
|
const PATH_GENERATORS: Record<string, PathGenerator> = {
|
|
@@ -202,6 +202,6 @@ export function getElementPaths(slide: BaseSlideData): ElementPath[] {
|
|
|
202
202
|
* @see Lumina.elements
|
|
203
203
|
* @see DeckMeta.initialElementState
|
|
204
204
|
*/
|
|
205
|
-
export function getElementIds(slide: BaseSlideData
|
|
205
|
+
export function getElementIds(slide: Readonly<BaseSlideData>, slideIndex: number): string[] {
|
|
206
206
|
return getElementPaths(slide).map((p) => resolveId(slide, slideIndex, p));
|
|
207
207
|
}
|
package/src/core/store.ts
CHANGED
|
@@ -267,7 +267,7 @@ export function createStore(initialOptions: LuminaOptions = {}) {
|
|
|
267
267
|
* Feature: Diff Updates
|
|
268
268
|
* Patches the current deck with partial data.
|
|
269
269
|
*/
|
|
270
|
-
function patchDeck(partial:
|
|
270
|
+
function patchDeck(partial: Partial<Deck>) {
|
|
271
271
|
if (!state.deck) return;
|
|
272
272
|
const merged = deepMerge(state.deck, partial);
|
|
273
273
|
state.deck = ensureDragKeys(merged);
|
package/src/core/types.ts
CHANGED
|
@@ -362,6 +362,17 @@ export interface AnimateOptions {
|
|
|
362
362
|
onComplete?: () => void;
|
|
363
363
|
}
|
|
364
364
|
|
|
365
|
+
/**
|
|
366
|
+
* Motion definition for meta.motions. Defines from/to/ease (and optional duration) for
|
|
367
|
+
* use when reveal.motion or animate({ motion: id }) references it. Overrides built-in presets when same id.
|
|
368
|
+
*/
|
|
369
|
+
export interface MotionDef {
|
|
370
|
+
from?: Record<string, string | number>;
|
|
371
|
+
to?: Record<string, string | number>;
|
|
372
|
+
ease?: string;
|
|
373
|
+
duration?: number;
|
|
374
|
+
}
|
|
375
|
+
|
|
365
376
|
/**
|
|
366
377
|
* Options for `Lumina.revealInSequence`. Controls staggered reveal of slide elements.
|
|
367
378
|
*
|
|
@@ -401,9 +412,9 @@ export interface RevealInSequenceOptions {
|
|
|
401
412
|
/** If true, hide all elements before starting the sequence. Default: true */
|
|
402
413
|
hideFirst?: boolean;
|
|
403
414
|
/** Only reveal these element ids (subset). Order follows slide elements. */
|
|
404
|
-
only?: string[];
|
|
415
|
+
only?: readonly string[];
|
|
405
416
|
/** Exclude these element ids from the sequence. */
|
|
406
|
-
exclude?: string[];
|
|
417
|
+
exclude?: readonly string[];
|
|
407
418
|
}
|
|
408
419
|
|
|
409
420
|
/**
|
|
@@ -515,6 +526,10 @@ interface SlideBase {
|
|
|
515
526
|
backgroundOpacity?: number;
|
|
516
527
|
/** Optional custom CSS class for the slide content container. */
|
|
517
528
|
class?: string;
|
|
529
|
+
/** Optional title used by exportState and speaker notes when the slide type has no required title. */
|
|
530
|
+
title?: string;
|
|
531
|
+
/** Declarative reveal options. When present, engine runs revealInSequence on ready (slide 0) and on slideChange. */
|
|
532
|
+
reveal?: RevealInSequenceOptions;
|
|
518
533
|
}
|
|
519
534
|
|
|
520
535
|
/**
|
|
@@ -852,6 +867,12 @@ export interface DeckMeta {
|
|
|
852
867
|
* defaultVisible: false = start all elements hidden; override per-id via initialElementState.
|
|
853
868
|
*/
|
|
854
869
|
elementControl?: { defaultVisible?: boolean };
|
|
870
|
+
/** Default reveal options for all slides. Overridden by slide.reveal. */
|
|
871
|
+
reveal?: RevealInSequenceOptions;
|
|
872
|
+
/** Motion definitions (id -> { from, to, ease?, duration? }). Used when reveal.motion or animate({ motion: id }). */
|
|
873
|
+
motions?: Record<string, MotionDef>;
|
|
874
|
+
/** Full or partial theme overrides (e.g. from theme-custom-example.json). */
|
|
875
|
+
themeConfig?: ThemeConfig | object;
|
|
855
876
|
[key: string]: any;
|
|
856
877
|
}
|
|
857
878
|
|
|
@@ -1408,7 +1429,7 @@ export interface LuminaOptions {
|
|
|
1408
1429
|
selector?: string;
|
|
1409
1430
|
/** Whether to loop back to the start after the last slide. */
|
|
1410
1431
|
loop?: boolean;
|
|
1411
|
-
/** Master switch for slide-by-slide navigation. When false, the on-screen prev/next buttons are
|
|
1432
|
+
/** Master switch for slide-by-slide navigation. When false, the on-screen prev/next arrow buttons are hidden and keyboard/touch do nothing. When true, keyboard and touch can be toggled via `keyboard` and `touch`; the nav buttons are shown/hidden with `ui.showControls`. Default: true. */
|
|
1412
1433
|
navigation?: boolean;
|
|
1413
1434
|
/** Enable or disable keyboard shortcuts for next/prev. Only has effect when `navigation` is true. Default: true. */
|
|
1414
1435
|
keyboard?: boolean;
|
|
@@ -1467,7 +1488,7 @@ export type LuminaEventType =
|
|
|
1467
1488
|
export interface SlideChangePayload {
|
|
1468
1489
|
index: number;
|
|
1469
1490
|
previousIndex: number;
|
|
1470
|
-
slide: BaseSlideData
|
|
1491
|
+
slide: Readonly<BaseSlideData>;
|
|
1471
1492
|
}
|
|
1472
1493
|
|
|
1473
1494
|
/**
|
package/src/index.ts
CHANGED
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
*
|
|
31
31
|
* **Events:** `engine.on("ready"|"slideChange"|"action"|"error"|"patch"|"destroy"|"navigate"|"themeChange"|"speakerNotesOpen"|"speakerNotesClose"|"timelineSeek"|"timelineComplete"|"revealStart"|"revealComplete"|"revealElement", handler)`. `engine.emitError(err)` to forward errors.
|
|
32
32
|
* **State for LLM:** `engine.exportState()` → `{ currentSlide, narrative, history }`.
|
|
33
|
-
* **Key-value store:** `engine.data.get(key)`, `engine.data.set(key, value)`, `engine.data.has(key)`, `engine.data.delete(key)`, `engine.data.clear()`, `engine.data.keys()`. Persists across deck loads.
|
|
33
|
+
* **Key-value store:** `engine.data.get(key)`, `engine.data.set(key, value)`, `engine.data.has(key)`, `engine.data.delete(key)`, `engine.data.clear()`, `engine.data.keys()`. Persists across deck loads. **Template tags:** any slide string supports `{{key}}` resolved from engine.data; updates reactively when data changes. Helpers: `interpolateString`, `interpolateObject`.
|
|
34
34
|
*
|
|
35
35
|
* **Streaming:** `parsePartialJson(buffer)` then `engine.load(json)`. Or `createDebouncedLoader(engine.load, ms)`.
|
|
36
36
|
* **Prompts:** `generateSystemPrompt({ mode, includeSchema, includeTheming })`, `generateThemePrompt()`.
|
|
@@ -146,6 +146,9 @@ export {
|
|
|
146
146
|
type PresetDef,
|
|
147
147
|
} from './animation';
|
|
148
148
|
|
|
149
|
+
// Template interpolation: {{tag}} in slide strings resolved from engine.data
|
|
150
|
+
export { interpolateString, interpolateObject } from './utils/templateInterpolation';
|
|
151
|
+
|
|
149
152
|
// Element control: resolution and discovery (for engine.element(id), engine.element(slideIndex, path), engine.elements(slideIndex))
|
|
150
153
|
export { elemId } from './core/elementId';
|
|
151
154
|
export {
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Template interpolation for Lumina slide content.
|
|
3
|
+
*
|
|
4
|
+
* Replaces `{{key}}` placeholders in strings with values from a key-value store
|
|
5
|
+
* (e.g. engine.data / store.userData). When the store changes, any component
|
|
6
|
+
* that uses the interpolated result will reactively update.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* interpolateString('Hello {{name}}', { name: 'World' }) // 'Hello World'
|
|
10
|
+
* interpolateObject({ title: '{{product}}', subtitle: 'v{{version}}' }, { product: 'Lumina', version: 1 })
|
|
11
|
+
* // { title: 'Lumina', subtitle: 'v1' }
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** Matches {{key}} or {{ key }}. Key: alphanumeric, underscore. */
|
|
15
|
+
const TAG_RE = /\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/g;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Replaces all {{key}} placeholders in a string with values from `data`.
|
|
19
|
+
* Missing keys render as empty string. Non-string values are coerced with String().
|
|
20
|
+
*
|
|
21
|
+
* @param str - Raw string possibly containing `{{key}}` tags.
|
|
22
|
+
* @param data - Key-value map (e.g. engine.data / store.state.userData).
|
|
23
|
+
* @returns Interpolated string.
|
|
24
|
+
*/
|
|
25
|
+
export function interpolateString(str: string, data: Record<string, unknown>): string {
|
|
26
|
+
if (typeof str !== 'string') return str;
|
|
27
|
+
return str.replace(TAG_RE, (_, key: string) => {
|
|
28
|
+
const v = data[key];
|
|
29
|
+
return v == null ? '' : String(v);
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Recursively interpolates all string values in an object/array with `{{key}}`
|
|
35
|
+
* placeholders. Other types (number, boolean, null, etc.) are returned unchanged.
|
|
36
|
+
* Does not mutate the input; returns a new structure.
|
|
37
|
+
*
|
|
38
|
+
* @param obj - Object, array, or primitive (strings are interpolated).
|
|
39
|
+
* @param data - Key-value map (e.g. engine.data / store.state.userData).
|
|
40
|
+
* @returns New structure with all strings interpolated.
|
|
41
|
+
*/
|
|
42
|
+
export function interpolateObject<T>(obj: T, data: Record<string, unknown>): T {
|
|
43
|
+
if (obj == null) return obj;
|
|
44
|
+
if (typeof obj === 'string') return interpolateString(obj, data) as T;
|
|
45
|
+
if (Array.isArray(obj)) return obj.map((item) => interpolateObject(item, data)) as T;
|
|
46
|
+
if (typeof obj === 'object' && obj.constructor === Object) {
|
|
47
|
+
return Object.fromEntries(
|
|
48
|
+
Object.entries(obj).map(([k, v]) => [k, interpolateObject(v, data)])
|
|
49
|
+
) as T;
|
|
50
|
+
}
|
|
51
|
+
return obj;
|
|
52
|
+
}
|
package/src/views/DeckView.vue
CHANGED
|
@@ -54,6 +54,29 @@
|
|
|
54
54
|
class="px-3 py-1.5 rounded bg-blue-600 text-white text-sm font-medium hover:bg-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
55
55
|
@click="onTimelinePlay">Play</button>
|
|
56
56
|
</div>
|
|
57
|
+
|
|
58
|
+
<!-- Template Tags: edit engine.data and see slides update (layout-template-tags example) -->
|
|
59
|
+
<div v-if="showTemplateTagsUI" class="fixed bottom-0 left-0 right-0 z-[90] flex items-center gap-4 px-4 py-3 bg-black/90 border-t border-white/10 backdrop-blur flex-wrap">
|
|
60
|
+
<span class="text-white/50 text-xs uppercase tracking-wider">engine.data</span>
|
|
61
|
+
<label class="flex items-center gap-2 text-sm text-white/80">
|
|
62
|
+
Product
|
|
63
|
+
<input v-model="templateProduct" type="text" placeholder="product"
|
|
64
|
+
class="w-28 px-2 py-1 rounded bg-white/10 border border-white/20 text-white text-sm focus:border-blue-500 focus:outline-none"
|
|
65
|
+
@input="onTemplateDataInput('product', ($event.target as HTMLInputElement).value)" />
|
|
66
|
+
</label>
|
|
67
|
+
<label class="flex items-center gap-2 text-sm text-white/80">
|
|
68
|
+
Version
|
|
69
|
+
<input v-model="templateVersion" type="text" placeholder="version"
|
|
70
|
+
class="w-24 px-2 py-1 rounded bg-white/10 border border-white/20 text-white text-sm focus:border-blue-500 focus:outline-none"
|
|
71
|
+
@input="onTemplateDataInput('version', ($event.target as HTMLInputElement).value)" />
|
|
72
|
+
</label>
|
|
73
|
+
<label class="flex items-center gap-2 text-sm text-white/80">
|
|
74
|
+
User
|
|
75
|
+
<input v-model="templateUser" type="text" placeholder="user"
|
|
76
|
+
class="w-28 px-2 py-1 rounded bg-white/10 border border-white/20 text-white text-sm focus:border-blue-500 focus:outline-none"
|
|
77
|
+
@input="onTemplateDataInput('user', ($event.target as HTMLInputElement).value)" />
|
|
78
|
+
</label>
|
|
79
|
+
</div>
|
|
57
80
|
</div>
|
|
58
81
|
</template>
|
|
59
82
|
|
|
@@ -85,16 +108,33 @@ const timelineCancelRef = ref<(() => void) | null>(null);
|
|
|
85
108
|
const timelineIntervalRef = ref<ReturnType<typeof setInterval> | null>(null);
|
|
86
109
|
|
|
87
110
|
const showTimelineUI = computed(() => {
|
|
88
|
-
const
|
|
111
|
+
const p = route.params.id;
|
|
112
|
+
const id = typeof p === 'string' ? p : (Array.isArray(p) && p[0] ? p[0] : '');
|
|
89
113
|
if (id !== 'animation-timeline') return false;
|
|
90
|
-
const slide = loadedDeck.value?.slides[currentSlideIndex.value]
|
|
114
|
+
const slide = loadedDeck.value?.slides[currentSlideIndex.value];
|
|
91
115
|
return !!slide?.timelineTracks;
|
|
92
116
|
});
|
|
93
117
|
|
|
118
|
+
// Template Tags example: edit engine.data and see slides update
|
|
119
|
+
const templateProduct = ref('Lumina');
|
|
120
|
+
const templateVersion = ref('2.0');
|
|
121
|
+
const templateUser = ref('World');
|
|
122
|
+
const showTemplateTagsUI = computed(() => {
|
|
123
|
+
const p = route.params.id;
|
|
124
|
+
const id = typeof p === 'string' ? p : (Array.isArray(p) && p[0] ? p[0] : '');
|
|
125
|
+
return id === 'layout-template-tags';
|
|
126
|
+
});
|
|
127
|
+
function onTemplateDataInput(key: string, value: string) {
|
|
128
|
+
engineRef.value?.data.set(key, value);
|
|
129
|
+
}
|
|
130
|
+
|
|
94
131
|
function onTimelineInput(e: Event) {
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
132
|
+
const t = e.target;
|
|
133
|
+
if (t instanceof HTMLInputElement) {
|
|
134
|
+
const v = parseFloat(t.value);
|
|
135
|
+
timelineProgress.value = v;
|
|
136
|
+
engineRef.value?.seekTo(v / 100);
|
|
137
|
+
}
|
|
98
138
|
}
|
|
99
139
|
|
|
100
140
|
function onTimelinePlay() {
|
|
@@ -163,7 +203,7 @@ async function onLoginAndDuplicate() {
|
|
|
163
203
|
await loginWithGoogle();
|
|
164
204
|
showLoginModal.value = false;
|
|
165
205
|
await duplicateAndEdit();
|
|
166
|
-
} catch (e
|
|
206
|
+
} catch (e) {
|
|
167
207
|
const msg = e instanceof Error ? e.message : 'Sign-in failed. Please try again.';
|
|
168
208
|
loginError.value = msg;
|
|
169
209
|
} finally {
|
|
@@ -176,7 +216,8 @@ onMounted(async () => {
|
|
|
176
216
|
initFirebase();
|
|
177
217
|
} catch (e) { }
|
|
178
218
|
|
|
179
|
-
const
|
|
219
|
+
const p = route.params.id;
|
|
220
|
+
const deckId = typeof p === 'string' ? p : (Array.isArray(p) && p[0] ? p[0] : '');
|
|
180
221
|
if (!deckId) return;
|
|
181
222
|
|
|
182
223
|
try {
|
|
@@ -208,7 +249,7 @@ onMounted(async () => {
|
|
|
208
249
|
showControls: true,
|
|
209
250
|
showProgressBar: true
|
|
210
251
|
},
|
|
211
|
-
theme:
|
|
252
|
+
theme: deckData.theme || 'default'
|
|
212
253
|
});
|
|
213
254
|
engineRef.value = engine;
|
|
214
255
|
|
|
@@ -227,29 +268,6 @@ onMounted(async () => {
|
|
|
227
268
|
});
|
|
228
269
|
}
|
|
229
270
|
|
|
230
|
-
if (deckId === 'animation-presets') {
|
|
231
|
-
const presets = ['fadeUp', 'scaleIn', 'spring'] as const;
|
|
232
|
-
engine.on('ready', () => engine?.revealInSequence(0, { preset: presets[0], delayMs: 450 }));
|
|
233
|
-
engine.on('slideChange', ({ index }) => {
|
|
234
|
-
engine?.revealInSequence(index, { preset: presets[index] ?? 'fadeUp', delayMs: 450 });
|
|
235
|
-
});
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
if (deckId === 'animation-stagger') {
|
|
239
|
-
const modes = ['center-out', 'wave', 'random'] as const;
|
|
240
|
-
engine.on('ready', () =>
|
|
241
|
-
engine?.revealInSequence(0, { preset: 'scaleIn', staggerMode: modes[0], delayMs: 380 })
|
|
242
|
-
);
|
|
243
|
-
engine.on('slideChange', ({ index }) => {
|
|
244
|
-
engine?.revealInSequence(index, {
|
|
245
|
-
preset: 'scaleIn',
|
|
246
|
-
staggerMode: modes[index] ?? 'sequential',
|
|
247
|
-
randomSeed: index,
|
|
248
|
-
delayMs: 380
|
|
249
|
-
});
|
|
250
|
-
});
|
|
251
|
-
}
|
|
252
|
-
|
|
253
271
|
if (deckId === 'animation-timeline') {
|
|
254
272
|
engine.on('ready', () => {
|
|
255
273
|
engine?.seekTo(0);
|
|
@@ -270,6 +288,12 @@ onMounted(async () => {
|
|
|
270
288
|
});
|
|
271
289
|
}
|
|
272
290
|
|
|
291
|
+
if (deckId === 'layout-template-tags') {
|
|
292
|
+
engine.data.set('product', templateProduct.value);
|
|
293
|
+
engine.data.set('version', templateVersion.value);
|
|
294
|
+
engine.data.set('user', templateUser.value);
|
|
295
|
+
}
|
|
296
|
+
|
|
273
297
|
engine.load(deckData);
|
|
274
298
|
} catch (error) {
|
|
275
299
|
console.error("Failed to load deck:", error);
|