animot-presenter 0.5.24 → 0.6.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.
@@ -1,2056 +1,2271 @@
1
- <script lang="ts">
2
- import { onMount, onDestroy } from 'svelte';
3
- import { tween } from '@animotion/motion';
4
- import { highlightCode } from './highlight/highlighter';
5
- import CodeMorph from './highlight/CodeMorph.svelte';
6
- import ParticlesBackground from './effects/ParticlesBackground.svelte';
7
- import ConfettiEffect from './effects/ConfettiEffect.svelte';
8
- import CounterRenderer from './renderers/CounterRenderer.svelte';
9
- import ChartRenderer from './renderers/ChartRenderer.svelte';
10
- import ProgressBar from './renderers/ProgressBar.svelte';
11
- import Container from './renderers/Container.svelte';
12
- import IconRenderer from './renderers/IconRenderer.svelte';
13
- import FlowMarkers from './FlowMarkers.svelte';
14
- import { traceSvgPaths } from './utils/trace-svg-paths';
15
- import { arrowClipDraw } from './utils/arrow-clip-draw';
16
- import { textAnimate } from './utils/text-animate';
17
- import { decorations } from './utils/decorations';
18
- import { cameraTransform, defaultCamera, parallaxOffset } from './utils/camera';
19
- import { parseEmbedUrl } from './utils/video-embed';
20
- import EmbedPlayer from './EmbedPlayer.svelte';
21
- import { easeInOutCubic, getEasingFn, getBackgroundStyle, gradientShapeToCss, hashFraction, getFloatAnimName, computeFloatAmp, computeFloatSpeed } from './engine/utils';
22
- import type {
23
- AnimotProject, AnimotPresenterProps, CanvasElement, CodeElement, TextElement,
24
- ArrowElement, ImageElement, VideoElement, ShapeElement, CounterElement, ChartElement, IconElement,
25
- SvgElement, MotionPathElement, ProgressElement, ContainerElement, PathPoint,
26
- Slide, CodeAnimationMode, AnimatableProperty
27
- } from './types';
28
- import './styles/presenter.css';
29
-
30
- type TweenValue = ReturnType<typeof tween<number>>;
31
-
32
- // Svelte's underlying Tween.set() leaks: when retargeted before completion,
33
- // the previous rAF task is aborted but its promise is NEVER fulfilled
34
- // (see svelte/src/internal/client/loop.js — abort() only deletes the task,
35
- // doesn't call fulfill). The presenter calls .to() on 15+ tween properties
36
- // per element per slide transition, so a deck running in a loop accumulates
37
- // hundreds of thousands of orphaned promises plus the async-function
38
- // Contexts of any awaiter that's stuck on them. wrapTween() patches each
39
- // tween instance so:
40
- // 1) .to() on a value the tween is already at is a no-op (Promise.resolve)
41
- // 2) Each .to() resolves the previous wrapper promise immediately on
42
- // retarget, so any await chain unwinds and releases its Context even
43
- // if the underlying Svelte promise is left dangling.
44
- function wrapTween<TV extends { current: unknown; to: (v: never, o?: unknown) => Promise<void> }>(tv: TV): TV {
45
- const origTo = tv.to.bind(tv);
46
- let prevResolve: (() => void) | null = null;
47
- (tv as unknown as { to: (v: unknown, o?: unknown) => Promise<void> }).to = (value, options) => {
48
- if (tv.current === value) {
49
- if (prevResolve) { const r = prevResolve; prevResolve = null; r(); }
50
- return Promise.resolve();
51
- }
52
- if (prevResolve) { const r = prevResolve; prevResolve = null; r(); }
53
- const inner = origTo(value as never, options);
54
- return new Promise<void>((resolve) => {
55
- prevResolve = resolve;
56
- const done = () => {
57
- if (prevResolve === resolve) { prevResolve = null; resolve(); }
58
- };
59
- inner.then(done, done);
60
- });
61
- };
62
- return tv;
63
- }
64
- function mkTween<T>(value: T, options?: Parameters<typeof tween<T>>[1]): ReturnType<typeof tween<T>> {
65
- return wrapTween(tween(value, options)) as ReturnType<typeof tween<T>>;
66
- }
67
-
68
- interface AnimatedElement {
69
- x: TweenValue; y: TweenValue; width: TweenValue; height: TweenValue;
70
- rotation: TweenValue; skewX: TweenValue; skewY: TweenValue;
71
- tiltX: TweenValue; tiltY: TweenValue; perspective: TweenValue;
72
- opacity: TweenValue; borderRadius: TweenValue;
73
- fontSize: TweenValue | null;
74
- fillColor: ReturnType<typeof tween<string>> | null;
75
- strokeColor: ReturnType<typeof tween<string>> | null;
76
- strokeWidth: TweenValue | null;
77
- shapeMorph: TweenValue | null;
78
- motionPathProgress: TweenValue | null;
79
- blur: TweenValue;
80
- brightness: TweenValue;
81
- contrast: TweenValue;
82
- saturate: TweenValue;
83
- grayscale: TweenValue;
84
- }
85
-
86
- interface ShapeMorphState { fromType: string; toType: string; }
87
-
88
- // Race a promise against an AbortSignal so awaits unwind the instant a
89
- // loop is cancelled — otherwise tween.to() / setTimeout promises keep
90
- // pending and pin their async-function Context to the heap. Long-running
91
- // loops without this leak millions of closure contexts (see commit notes).
92
- function abortable<T>(p: Promise<T>, signal: AbortSignal): Promise<T> {
93
- if (signal.aborted) return Promise.reject(new DOMException('aborted', 'AbortError'));
94
- return new Promise<T>((resolve, reject) => {
95
- const onAbort = () => reject(new DOMException('aborted', 'AbortError'));
96
- signal.addEventListener('abort', onAbort, { once: true });
97
- p.then(
98
- (v) => { signal.removeEventListener('abort', onAbort); resolve(v); },
99
- (e) => { signal.removeEventListener('abort', onAbort); reject(e); }
100
- );
101
- });
102
- }
103
- function abortableSleep(ms: number, signal: AbortSignal): Promise<void> {
104
- if (signal.aborted) return Promise.reject(new DOMException('aborted', 'AbortError'));
105
- return new Promise<void>((resolve, reject) => {
106
- const id = setTimeout(() => { signal.removeEventListener('abort', onAbort); resolve(); }, ms);
107
- const onAbort = () => { clearTimeout(id); reject(new DOMException('aborted', 'AbortError')); };
108
- signal.addEventListener('abort', onAbort, { once: true });
109
- });
110
- }
111
- function isAbortError(e: unknown): boolean {
112
- return !!(e && typeof e === 'object' && (e as { name?: string }).name === 'AbortError');
113
- }
114
-
115
- // Active motion path loop cancellation tokens
116
- let motionPathLoopAbort: AbortController | null = null;
117
- function cancelMotionPathLoops() {
118
- if (motionPathLoopAbort) { motionPathLoopAbort.abort(); motionPathLoopAbort = null; }
119
- }
120
-
121
- // Keyframe schedule loop. Plain (non-reactive) module-scope state — adding
122
- // $state for keyframe overrides in 0.5.20 broke reactivity for the existing
123
- // tween-driven render. This implementation only retargets existing tweens
124
- // via setTimeout; nothing here is reactive, nothing is read from render.
125
- let keyframeLoopAbort: AbortController | null = null;
126
- // Scheduled-but-not-yet-fired keyframe timeouts. We track these so cancel
127
- // also clears them — otherwise their closures (which capture `signal`,
128
- // `animated`, etc.) survive until natural firing time and pin a Context.
129
- let keyframeTimeouts: ReturnType<typeof setTimeout>[] = [];
130
- // Per-element overrides for keyframe-driven props that aren't tweened
131
- // (backgroundColor, text color). The schedule writes these at each
132
- // keyframe boundary; liveProps reads them at render time.
133
- let keyframeOverrides = $state<Map<string, Record<string, any>>>(new Map());
134
- function cancelKeyframeLoops() {
135
- if (keyframeLoopAbort) { keyframeLoopAbort.abort(); keyframeLoopAbort = null; }
136
- if (keyframeTimeouts.length) {
137
- for (const id of keyframeTimeouts) clearTimeout(id);
138
- keyframeTimeouts = [];
139
- }
140
- }
141
- function setKeyframeOverride(elementId: string, prop: string, value: any) {
142
- const cur = keyframeOverrides.get(elementId) ?? {};
143
- keyframeOverrides.set(elementId, { ...cur, [prop]: value });
144
- keyframeOverrides = new Map(keyframeOverrides);
145
- }
146
- // Tweens take an easing FUNCTION (t→number), not a CSS keyword. Returning
147
- // a string here was the cause of "r is not a function" thrown deep in the
148
- // tween animation loop — `r(t)` crashed because `r` was the literal string
149
- // passed in. Reuse the engine's `getEasingFn` so keyframe easing matches
150
- // the slide-morph engine's vocabulary.
151
- function easingForTween(name: string | undefined): (t: number) => number {
152
- return getEasingFn(name ?? 'ease-out');
153
- }
154
- function animateKeyframes(slide: Slide) {
155
- cancelKeyframeLoops();
156
- const hasAnyKeyframes = slide.canvas.elements.some((el) => el.keyframes && el.keyframes.length > 0);
157
- if (keyframeOverrides.size > 0) keyframeOverrides = new Map();
158
- if (!hasAnyKeyframes) return;
159
- keyframeLoopAbort = new AbortController();
160
- const signal = keyframeLoopAbort.signal;
161
- for (const element of slide.canvas.elements) {
162
- if (!element.keyframes || element.keyframes.length === 0) continue;
163
- const animated = animatedElements.get(element.id) as any;
164
- if (!animated) continue;
165
- const sorted = [...element.keyframes].sort((a, b) => a.time - b.time);
166
- const first = sorted[0];
167
- // Snap tweens to KF1 instantly so the slide-displayed pose IS the start.
168
- if (first.position) { animated.x?.to(first.position.x, { duration: 0 }); animated.y?.to(first.position.y, { duration: 0 }); }
169
- if (first.size) { animated.width?.to(first.size.width, { duration: 0 }); animated.height?.to(first.size.height, { duration: 0 }); }
170
- if (first.rotation !== undefined) animated.rotation?.to(first.rotation, { duration: 0 });
171
- if (first.opacity !== undefined) animated.opacity?.to(first.opacity, { duration: 0 });
172
- if (first.skewX !== undefined) animated.skewX?.to(first.skewX, { duration: 0 });
173
- if (first.skewY !== undefined) animated.skewY?.to(first.skewY, { duration: 0 });
174
- if (first.tiltX !== undefined) animated.tiltX?.to(first.tiltX, { duration: 0 });
175
- if (first.tiltY !== undefined) animated.tiltY?.to(first.tiltY, { duration: 0 });
176
- if (first.borderRadius !== undefined) animated.borderRadius?.to(first.borderRadius, { duration: 0 });
177
- if (first.fontSize !== undefined) animated.fontSize?.to(first.fontSize, { duration: 0 });
178
- if (first.fillColor !== undefined) animated.fillColor?.to(first.fillColor, { duration: 0 });
179
- if (first.strokeColor !== undefined) animated.strokeColor?.to(first.strokeColor, { duration: 0 });
180
- if (first.strokeWidth !== undefined) animated.strokeWidth?.to(first.strokeWidth, { duration: 0 });
181
- if (first.blur !== undefined) animated.blur?.to(first.blur, { duration: 0 });
182
- if (first.brightness !== undefined) animated.brightness?.to(first.brightness, { duration: 0 });
183
- if (first.contrast !== undefined) animated.contrast?.to(first.contrast, { duration: 0 });
184
- if (first.saturate !== undefined) animated.saturate?.to(first.saturate, { duration: 0 });
185
- if (first.grayscale !== undefined) animated.grayscale?.to(first.grayscale, { duration: 0 });
186
- if (first.backgroundColor !== undefined) setKeyframeOverride(element.id, 'backgroundColor', first.backgroundColor);
187
- if (first.color !== undefined) setKeyframeOverride(element.id, 'color', first.color);
188
- for (const kf of sorted.slice(1)) {
189
- const tid = setTimeout(() => {
190
- if (signal.aborted) return;
191
- const easing = easingForTween(kf.easing);
192
- const idx = sorted.indexOf(kf);
193
- const prevTime = idx === 0 ? 0 : sorted[idx - 1].time;
194
- const span = Math.max(50, kf.time - prevTime);
195
- if (kf.position) { animated.x?.to(kf.position.x, { duration: span, easing }); animated.y?.to(kf.position.y, { duration: span, easing }); }
196
- if (kf.size) { animated.width?.to(kf.size.width, { duration: span, easing }); animated.height?.to(kf.size.height, { duration: span, easing }); }
197
- if (kf.rotation !== undefined) animated.rotation?.to(kf.rotation, { duration: span, easing });
198
- if (kf.opacity !== undefined) animated.opacity?.to(kf.opacity, { duration: span, easing });
199
- if (kf.skewX !== undefined) animated.skewX?.to(kf.skewX, { duration: span, easing });
200
- if (kf.skewY !== undefined) animated.skewY?.to(kf.skewY, { duration: span, easing });
201
- if (kf.tiltX !== undefined) animated.tiltX?.to(kf.tiltX, { duration: span, easing });
202
- if (kf.tiltY !== undefined) animated.tiltY?.to(kf.tiltY, { duration: span, easing });
203
- if (kf.borderRadius !== undefined) animated.borderRadius?.to(kf.borderRadius, { duration: span, easing });
204
- if (kf.fontSize !== undefined) animated.fontSize?.to(kf.fontSize, { duration: span, easing });
205
- if (kf.fillColor !== undefined) animated.fillColor?.to(kf.fillColor, { duration: span, easing });
206
- if (kf.strokeColor !== undefined) animated.strokeColor?.to(kf.strokeColor, { duration: span, easing });
207
- if (kf.strokeWidth !== undefined) animated.strokeWidth?.to(kf.strokeWidth, { duration: span, easing });
208
- if (kf.blur !== undefined) animated.blur?.to(kf.blur, { duration: span, easing });
209
- if (kf.brightness !== undefined) animated.brightness?.to(kf.brightness, { duration: span, easing });
210
- if (kf.contrast !== undefined) animated.contrast?.to(kf.contrast, { duration: span, easing });
211
- if (kf.saturate !== undefined) animated.saturate?.to(kf.saturate, { duration: span, easing });
212
- if (kf.grayscale !== undefined) animated.grayscale?.to(kf.grayscale, { duration: span, easing });
213
- if (kf.backgroundColor !== undefined) setKeyframeOverride(element.id, 'backgroundColor', kf.backgroundColor);
214
- if (kf.color !== undefined) setKeyframeOverride(element.id, 'color', kf.color);
215
- }, Math.max(0, kf.time));
216
- keyframeTimeouts.push(tid);
217
- }
218
- }
219
- }
220
-
221
- // --- Motion Path Utilities ---
222
- function buildPresenterPathD(points: PathPoint[], closed: boolean): string {
223
- if (points.length < 2) return '';
224
- let d = `M ${points[0].x} ${points[0].y}`;
225
- for (let i = 1; i < points.length; i++) {
226
- const prev = points[i - 1], curr = points[i];
227
- const cp1x = prev.x + (prev.handleOut?.x ?? 0), cp1y = prev.y + (prev.handleOut?.y ?? 0);
228
- const cp2x = curr.x + (curr.handleIn?.x ?? 0), cp2y = curr.y + (curr.handleIn?.y ?? 0);
229
- if (prev.handleOut || curr.handleIn) d += ` C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${curr.x} ${curr.y}`;
230
- else d += ` L ${curr.x} ${curr.y}`;
231
- }
232
- if (closed && points.length > 2) {
233
- const last = points[points.length - 1], first = points[0];
234
- const cp1x = last.x + (last.handleOut?.x ?? 0), cp1y = last.y + (last.handleOut?.y ?? 0);
235
- const cp2x = first.x + (first.handleIn?.x ?? 0), cp2y = first.y + (first.handleIn?.y ?? 0);
236
- if (last.handleOut || first.handleIn) d += ` C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${first.x} ${first.y}`;
237
- else d += ` Z`;
238
- }
239
- return d;
240
- }
241
-
242
- function cubicBez(p0: number, p1: number, p2: number, p3: number, t: number): number {
243
- const mt = 1 - t;
244
- return mt * mt * mt * p0 + 3 * mt * mt * t * p1 + 3 * mt * t * t * p2 + t * t * t * p3;
245
- }
246
- function cubicBezDeriv(p0: number, p1: number, p2: number, p3: number, t: number): number {
247
- const mt = 1 - t;
248
- return 3 * mt * mt * (p1 - p0) + 6 * mt * t * (p2 - p1) + 3 * t * t * (p3 - p2);
249
- }
250
-
251
- function getPresenterPointOnPath(points: PathPoint[], closed: boolean, progress: number): { x: number; y: number; angle: number } {
252
- if (points.length < 2) return { x: points[0]?.x ?? 0, y: points[0]?.y ?? 0, angle: 0 };
253
- const segs: { p0x: number; p0y: number; p1x: number; p1y: number; p2x: number; p2y: number; p3x: number; p3y: number; length: number }[] = [];
254
- const segCount = closed ? points.length : points.length - 1;
255
- for (let i = 0; i < segCount; i++) {
256
- const curr = points[i], next = points[(i + 1) % points.length];
257
- const p0x = curr.x, p0y = curr.y;
258
- const p1x = curr.x + (curr.handleOut?.x ?? 0), p1y = curr.y + (curr.handleOut?.y ?? 0);
259
- const p2x = next.x + (next.handleIn?.x ?? 0), p2y = next.y + (next.handleIn?.y ?? 0);
260
- const p3x = next.x, p3y = next.y;
261
- let length = 0, prevPx = p0x, prevPy = p0y;
262
- for (let s = 1; s <= 20; s++) {
263
- const t = s / 20;
264
- const px = cubicBez(p0x, p1x, p2x, p3x, t), py = cubicBez(p0y, p1y, p2y, p3y, t);
265
- length += Math.sqrt((px - prevPx) ** 2 + (py - prevPy) ** 2);
266
- prevPx = px; prevPy = py;
267
- }
268
- segs.push({ p0x, p0y, p1x, p1y, p2x, p2y, p3x, p3y, length });
269
- }
270
- const totalLength = segs.reduce((sum, s) => sum + s.length, 0);
271
- const targetLength = progress * totalLength;
272
- let accum = 0;
273
- for (const seg of segs) {
274
- if (accum + seg.length >= targetLength || seg === segs[segs.length - 1]) {
275
- const t = Math.max(0, Math.min(1, seg.length > 0 ? (targetLength - accum) / seg.length : 0));
276
- let dx = cubicBezDeriv(seg.p0x, seg.p1x, seg.p2x, seg.p3x, t);
277
- let dy = cubicBezDeriv(seg.p0y, seg.p1y, seg.p2y, seg.p3y, t);
278
- // Degenerate tangent at endpoints (no Bezier handles) — sample nearby
279
- if (Math.abs(dx) < 0.001 && Math.abs(dy) < 0.001) {
280
- const epsilon = t < 0.5 ? 0.01 : -0.01;
281
- dx = cubicBezDeriv(seg.p0x, seg.p1x, seg.p2x, seg.p3x, t + epsilon);
282
- dy = cubicBezDeriv(seg.p0y, seg.p1y, seg.p2y, seg.p3y, t + epsilon);
283
- }
284
- // Still zero (fully degenerate segment) — use chord direction
285
- if (Math.abs(dx) < 0.001 && Math.abs(dy) < 0.001) {
286
- dx = seg.p3x - seg.p0x;
287
- dy = seg.p3y - seg.p0y;
288
- }
289
- return {
290
- x: cubicBez(seg.p0x, seg.p1x, seg.p2x, seg.p3x, t),
291
- y: cubicBez(seg.p0y, seg.p1y, seg.p2y, seg.p3y, t),
292
- angle: Math.atan2(dy, dx) * (180 / Math.PI)
293
- };
294
- }
295
- accum += seg.length;
296
- }
297
- return { x: points[0].x, y: points[0].y, angle: 0 };
298
- }
299
-
300
- function computeMotionPathPosition(
301
- mpPoint: { x: number; y: number; angle: number },
302
- startPoint: { x: number; y: number; angle: number },
303
- animX: number, animY: number, animW: number, animH: number,
304
- closed: boolean
305
- ): { x: number; y: number } {
306
- if (!closed) {
307
- return { x: mpPoint.x - animW / 2, y: mpPoint.y - animH / 2 };
308
- }
309
- const offsetX = (animX + animW / 2) - startPoint.x;
310
- const offsetY = (animY + animH / 2) - startPoint.y;
311
- const angleDelta = (mpPoint.angle - startPoint.angle) * Math.PI / 180;
312
- const cos = Math.cos(angleDelta);
313
- const sin = Math.sin(angleDelta);
314
- return {
315
- x: mpPoint.x + offsetX * cos - offsetY * sin - animW / 2,
316
- y: mpPoint.y + offsetX * sin + offsetY * cos - animH / 2
317
- };
318
- }
319
-
320
- let {
321
- src, data, autoplay = false, loop = false, controls = true, arrows = false,
322
- progress: showProgress = true, keyboard = true, duration: durationOverride,
323
- startSlide = 0, muteNarration = false, class: className = '', onslidechange, oncomplete
324
- }: AnimotPresenterProps = $props();
325
-
326
- // State
327
- let project = $state<AnimotProject | null>(null);
328
- let loading = $state(true);
329
- let error = $state<string | null>(null);
330
- let currentSlideIndex = $state(0);
331
- let isTransitioning = $state(false);
332
- let isAutoplay = $state(false);
333
- let transitionClass = $state('');
334
- let transitionDirection = $state<'forward' | 'backward'>('forward');
335
- let transitionDurationMs = $state(500);
336
- let containerEl: HTMLElement;
337
- let containerWidth = $state(0);
338
- let containerHeight = $state(0);
339
-
340
- let animatedElements = $state<Map<string, AnimatedElement>>(new Map());
341
- let codeHighlights = $state<Map<string, string>>(new Map());
342
- let elementContent = $state<Map<string, CanvasElement>>(new Map());
343
- let previousCodeContent = $state<Map<string, string>>(new Map());
344
- // Snapshot of charts on the OUTGOING slide so the renderer can tween
345
- // data values into the new slide. Updated alongside previousCodeContent.
346
- let previousChartContent = $state<Map<string, ChartElement>>(new Map());
347
- let previousProgressContent = $state<Map<string, ProgressElement>>(new Map());
348
- let codeMorphState = $state<Map<string, {oldCode: string, newCode: string, mode: CodeAnimationMode, speed: number, highlightColor: string}>>(new Map());
349
- let textTypewriterState = $state<Map<string, {fullText: string, displayedChars: number, isAnimating: boolean}>>(new Map());
350
- let typewriterIntervals = new Map<string, ReturnType<typeof setInterval>>();
351
- let shapeMorphStates = $state<Map<string, ShapeMorphState>>(new Map());
352
- let autoplayTimer: ReturnType<typeof setTimeout> | null = null;
353
- let menuVisible = $state(true);
354
- let mouseIdleTimer: ReturnType<typeof setTimeout> | null = null;
355
-
356
- // Single <audio> element for per-slide narration playback. Only used
357
- // when `project.settings.narrationEnabled` is true. Lazy-allocated so
358
- // decks without narration don't pay for it.
359
- //
360
- // Narration is bound to the deck's PLAY state (`isAutoplay`), not to
361
- // arbitrary page clicks: the user pressing the play button is what
362
- // starts narration, the pause button stops it. This keeps multiple
363
- // decks on a page from playing each other's audio when one is clicked,
364
- // and keeps autoplay-blocked browsers from silently doing nothing —
365
- // the user's click on the play control is itself the unlocking gesture.
366
- let narrationAudio: HTMLAudioElement | null = null;
367
- function playNarrationForSlide(index: number) {
368
- if (muteNarration) return;
369
- if (!project?.settings?.narrationEnabled) return;
370
- const slide = project?.slides?.[index];
371
- if (narrationAudio) {
372
- narrationAudio.pause();
373
- narrationAudio.currentTime = 0;
374
- }
375
- const src = slide?.narration?.src;
376
- if (!src) return;
377
- if (!narrationAudio) narrationAudio = new Audio();
378
- narrationAudio.src = src;
379
- narrationAudio.play().catch(() => {});
380
- }
381
- function pauseNarration() {
382
- if (narrationAudio) narrationAudio.pause();
383
- }
384
- function stopNarration() {
385
- if (narrationAudio) { narrationAudio.pause(); narrationAudio = null; }
386
- }
387
-
388
- const slides = $derived(project?.slides ?? []);
389
- const currentSlide = $derived(slides[currentSlideIndex]);
390
- const isCinemaMode = $derived(project?.mode === 'cinema');
391
- const worldWidth = $derived(project?.settings?.worldWidth ?? currentSlide?.canvas.width ?? 1920);
392
- const worldHeight = $derived(project?.settings?.worldHeight ?? currentSlide?.canvas.height ?? 1080);
393
- const currentCamera = $derived(currentSlide?.camera ?? defaultCamera(worldWidth, worldHeight));
394
- const cinemaCameraTransform = $derived(
395
- isCinemaMode && currentSlide
396
- ? cameraTransform({ camera: currentCamera, viewportWidth: currentSlide.canvas.width, viewportHeight: currentSlide.canvas.height })
397
- : ''
398
- );
399
- const canvasWidth = $derived(currentSlide?.canvas.width ?? 800);
400
- const canvasHeight = $derived(currentSlide?.canvas.height ?? 600);
401
-
402
- const presentationScale = $derived.by(() => {
403
- if (!containerWidth || !containerHeight) return 1;
404
- const scaleX = containerWidth / canvasWidth;
405
- const scaleY = containerHeight / canvasHeight;
406
- return Math.min(scaleX, scaleY);
407
- });
408
-
409
- const backgroundStyle = $derived.by(() => {
410
- if (!currentSlide) return 'background: transparent';
411
- return getBackgroundStyle(currentSlide.canvas.background);
412
- });
413
-
414
- const allElementIds = $derived.by(() => {
415
- const ids = new Set<string>();
416
- slides.forEach(slide => slide.canvas.elements.forEach(el => ids.add(el.id)));
417
- return ids;
418
- });
419
-
420
- const sortedElementIds = $derived.by(() => {
421
- const elements: Array<{id: string, zIndex: number}> = [];
422
- for (const id of allElementIds) {
423
- const el = elementContent.get(id);
424
- if (el) elements.push({ id, zIndex: el.zIndex ?? 0 });
425
- }
426
- elements.sort((a, b) => a.zIndex - b.zIndex);
427
- return elements.map(e => e.id);
428
- });
429
-
430
- function getElementInSlide(slide: Slide | null, elementId: string): CanvasElement | undefined {
431
- return slide?.canvas.elements.find(el => el.id === elementId);
432
- }
433
-
434
- /**
435
- * Overlay tween-driven values + non-tweened keyframe overrides onto the
436
- * element used in render. Gated: returns the element unchanged when it
437
- * has no keyframes AND no active override — that's the 99% case and
438
- * keeps the existing slide-morph render pipeline allocation-free.
439
- */
440
- function liveProps<T extends CanvasElement>(element: T): T {
441
- const hasKeyframes = !!element.keyframes && element.keyframes.length > 0;
442
- const overrides = keyframeOverrides.get(element.id);
443
- if (!hasKeyframes && !overrides) return element;
444
- const a = animatedElements.get(element.id) as any;
445
- const e = element as any;
446
- const out: any = { ...element };
447
- if (a && hasKeyframes) {
448
- if (a.borderRadius && e.borderRadius !== undefined) out.borderRadius = a.borderRadius.current;
449
- if (a.fontSize && e.fontSize !== undefined) out.fontSize = a.fontSize.current;
450
- if (a.fillColor && e.fillColor !== undefined) out.fillColor = a.fillColor.current;
451
- if (a.strokeColor && e.strokeColor !== undefined) out.strokeColor = a.strokeColor.current;
452
- if (a.strokeWidth && e.strokeWidth !== undefined) out.strokeWidth = a.strokeWidth.current;
453
- if (a.blur && e.blur !== undefined) out.blur = a.blur.current;
454
- if (a.brightness && e.brightness !== undefined) out.brightness = a.brightness.current;
455
- if (a.contrast && e.contrast !== undefined) out.contrast = a.contrast.current;
456
- if (a.saturate && e.saturate !== undefined) out.saturate = a.saturate.current;
457
- if (a.grayscale && e.grayscale !== undefined) out.grayscale = a.grayscale.current;
458
- }
459
- if (overrides) Object.assign(out, overrides);
460
- return out as T;
461
- }
462
-
463
- // Typewriter
464
- function startTypewriterAnimation(elementId: string, fullText: string, speed: number) {
465
- const existing = typewriterIntervals.get(elementId);
466
- if (existing) { clearInterval(existing); typewriterIntervals.delete(elementId); }
467
- textTypewriterState.set(elementId, { fullText, displayedChars: 0, isAnimating: true });
468
- textTypewriterState = new Map(textTypewriterState);
469
- const intervalMs = 1000 / speed;
470
- const interval = setInterval(() => {
471
- const state = textTypewriterState.get(elementId);
472
- if (state && state.isAnimating) {
473
- if (state.displayedChars < state.fullText.length) {
474
- textTypewriterState.set(elementId, { ...state, displayedChars: state.displayedChars + 1 });
475
- textTypewriterState = new Map(textTypewriterState);
476
- } else {
477
- clearInterval(interval); typewriterIntervals.delete(elementId);
478
- textTypewriterState.set(elementId, { ...state, isAnimating: false });
479
- textTypewriterState = new Map(textTypewriterState);
480
- }
481
- } else { clearInterval(interval); typewriterIntervals.delete(elementId); }
482
- }, intervalMs);
483
- typewriterIntervals.set(elementId, interval);
484
- }
485
-
486
- function clearAllTypewriterAnimations() {
487
- for (const [, interval] of typewriterIntervals) clearInterval(interval);
488
- typewriterIntervals.clear();
489
- textTypewriterState.clear();
490
- textTypewriterState = new Map(textTypewriterState);
491
- }
492
-
493
- // Build SVG path for 3+ control points using Catmull-Rom spline
494
- function buildCatmullRomPath(start: {x:number,y:number}, cps: {x:number,y:number}[], end: {x:number,y:number}): string {
495
- const pts = [start, ...cps, end];
496
- let d = `M ${pts[0].x} ${pts[0].y}`;
497
- for (let i = 0; i < pts.length - 1; i++) {
498
- const p0 = pts[i === 0 ? 0 : i - 1];
499
- const p1 = pts[i];
500
- const p2 = pts[i + 1];
501
- const p3 = pts[i + 2 < pts.length ? i + 2 : pts.length - 1];
502
- const c1x = p1.x + (p2.x - p0.x) / 6;
503
- const c1y = p1.y + (p2.y - p0.y) / 6;
504
- const c2x = p2.x - (p3.x - p1.x) / 6;
505
- const c2y = p2.y - (p3.y - p1.y) / 6;
506
- d += ` C ${c1x} ${c1y} ${c2x} ${c2y} ${p2.x} ${p2.y}`;
507
- }
508
- return d;
509
- }
510
-
511
- // Arrow draw/undraw/draw-undraw animation action
512
- function animateStyledArrowDraw(node: SVGPathElement, params: { enabled: boolean; mode: string; duration: number; dashPattern: string; startX: number; endX: number; slideIndex: number; loop?: boolean; reverse?: boolean }) {
513
- let lastSlideIndex = params.slideIndex;
514
- let animationId: number | null = null;
515
- function runAnimation() {
516
- if (!params.enabled) return;
517
- if (animationId) cancelAnimationFrame(animationId);
518
- const svg = node.closest('svg') as SVGSVGElement | null;
519
- if (!svg) return;
520
- const baseLeftToRight = params.endX >= params.startX;
521
- const goesLeftToRight = params.reverse ? !baseLeftToRight : baseLeftToRight;
522
- const mode = params.mode;
523
- const dur = params.duration;
524
- const startTime = performance.now();
525
- if (mode === 'draw' || mode === 'draw-undraw') {
526
- svg.style.clipPath = goesLeftToRight ? 'inset(0 100% 0 0)' : 'inset(0 0 0 100%)';
527
- } else if (mode === 'undraw') {
528
- svg.style.clipPath = 'none';
529
- }
530
- function animate(currentTime: number) {
531
- const elapsed = currentTime - startTime;
532
- if (mode === 'draw') {
533
- const progress = Math.min(elapsed / dur, 1);
534
- const eased = 1 - Math.pow(1 - progress, 3);
535
- const inset = 100 * (1 - eased);
536
- svg!.style.clipPath = goesLeftToRight ? `inset(0 ${inset}% 0 0)` : `inset(0 0 0 ${inset}%)`;
537
- if (progress < 1) { animationId = requestAnimationFrame(animate); }
538
- else if (params.loop) { runAnimation(); }
539
- else { svg!.style.clipPath = 'none'; animationId = null; }
540
- } else if (mode === 'undraw') {
541
- const progress = Math.min(elapsed / dur, 1);
542
- const eased = 1 - Math.pow(1 - progress, 3);
543
- const inset = 100 * eased;
544
- svg!.style.clipPath = goesLeftToRight ? `inset(0 0 0 ${inset}%)` : `inset(0 ${inset}% 0 0)`;
545
- if (progress < 1) { animationId = requestAnimationFrame(animate); }
546
- else if (params.loop) { runAnimation(); }
547
- else { svg!.style.clipPath = 'inset(0 0 0 100%)'; animationId = null; }
548
- } else if (mode === 'draw-undraw') {
549
- const halfDur = dur / 2;
550
- if (elapsed < halfDur) {
551
- const progress = Math.min(elapsed / halfDur, 1);
552
- const eased = 1 - Math.pow(1 - progress, 3);
553
- const inset = 100 * (1 - eased);
554
- svg!.style.clipPath = goesLeftToRight ? `inset(0 ${inset}% 0 0)` : `inset(0 0 0 ${inset}%)`;
555
- animationId = requestAnimationFrame(animate);
556
- } else {
557
- const progress = Math.min((elapsed - halfDur) / halfDur, 1);
558
- const eased = 1 - Math.pow(1 - progress, 3);
559
- const inset = 100 * eased;
560
- svg!.style.clipPath = goesLeftToRight ? `inset(0 0 0 ${inset}%)` : `inset(0 ${inset}% 0 0)`;
561
- if (progress < 1) { animationId = requestAnimationFrame(animate); }
562
- else if (params.loop) { runAnimation(); }
563
- else { svg!.style.clipPath = 'inset(0 0 0 100%)'; animationId = null; }
564
- }
565
- }
566
- }
567
- animationId = requestAnimationFrame(animate);
568
- }
569
- runAnimation();
570
- return {
571
- update(newParams: typeof params) {
572
- const slideChanged = newParams.slideIndex !== lastSlideIndex;
573
- params = newParams;
574
- if (!params.enabled) {
575
- if (animationId) { cancelAnimationFrame(animationId); animationId = null; }
576
- const svg = node.closest('svg') as SVGSVGElement | null;
577
- if (svg) svg.style.clipPath = '';
578
- lastSlideIndex = newParams.slideIndex;
579
- return;
580
- }
581
- if (slideChanged) { lastSlideIndex = newParams.slideIndex; runAnimation(); }
582
- },
583
- destroy() { if (animationId) cancelAnimationFrame(animationId); }
584
- };
585
- }
586
-
587
- // Init animated elements
588
- function initAllAnimatedElements() {
589
- const firstSlide = slides[0];
590
- if (firstSlide) {
591
- for (const element of firstSlide.canvas.elements) {
592
- if (element.type === 'code') previousCodeContent.set(element.id, (element as CodeElement).code);
593
- if (element.type === 'text') {
594
- const textEl = element as TextElement;
595
- if (textEl.animation?.mode === 'typewriter') startTypewriterAnimation(element.id, textEl.content, textEl.animation.typewriterSpeed || 50);
596
- }
597
- }
598
- }
599
- for (const slide of slides) {
600
- for (const element of slide.canvas.elements) {
601
- if (!animatedElements.has(element.id)) {
602
- const inCurrent = getElementInSlide(currentSlide, element.id);
603
- let startOpacity = inCurrent ? ((inCurrent as any).opacity ?? 1) : 0;
604
- const br = (element as any).borderRadius ?? 0;
605
- const isShape = element.type === 'shape';
606
- const shapeEl = isShape ? element as ShapeElement : null;
607
- const isText = element.type === 'text';
608
- const textEl = isText ? element as TextElement : null;
609
-
610
- // If the element has keyframes, seed each tween with the FIRST
611
- // keyframe's value instead of the persisted (last-captured)
612
- // state. The persisted state is whatever was on the canvas
613
- // at the moment the user captured their final keyframe; using
614
- // it as the tween's starting value would cause the slide to
615
- // flash at the "wrong" pose before the keyframe schedule
616
- // snaps it to KF1. Doing it here also avoids depending on a
617
- // duration:0 snap that the tween library may not apply
618
- // synchronously.
619
- const sortedKfs = element.keyframes && element.keyframes.length > 0
620
- ? [...element.keyframes].sort((a, b) => a.time - b.time)
621
- : null;
622
- const k0 = sortedKfs ? sortedKfs[0] : null;
623
- const seedX = k0?.position ? k0.position.x : element.position.x;
624
- const seedY = k0?.position ? k0.position.y : element.position.y;
625
- const seedW = k0?.size ? k0.size.width : element.size.width;
626
- const seedH = k0?.size ? k0.size.height : element.size.height;
627
- const seedRot = k0?.rotation !== undefined ? k0.rotation : element.rotation;
628
- const seedSkewX = k0?.skewX !== undefined ? k0.skewX : (element.skewX ?? 0);
629
- const seedSkewY = k0?.skewY !== undefined ? k0.skewY : (element.skewY ?? 0);
630
- const seedTiltX = k0?.tiltX !== undefined ? k0.tiltX : (element.tiltX ?? 0);
631
- const seedTiltY = k0?.tiltY !== undefined ? k0.tiltY : (element.tiltY ?? 0);
632
- if (k0?.opacity !== undefined) startOpacity = k0.opacity;
633
- const seedBR = k0?.borderRadius !== undefined ? k0.borderRadius : br;
634
- const seedFontSize = k0?.fontSize !== undefined ? k0.fontSize : (textEl ? textEl.fontSize : 0);
635
- const seedFill = k0?.fillColor !== undefined ? k0.fillColor : (shapeEl ? shapeEl.fillColor : '');
636
- const seedStroke = k0?.strokeColor !== undefined ? k0.strokeColor : (shapeEl ? shapeEl.strokeColor : '');
637
- const seedStrokeW = k0?.strokeWidth !== undefined ? k0.strokeWidth : (shapeEl ? shapeEl.strokeWidth : 0);
638
- const seedBlur = k0?.blur !== undefined ? k0.blur : (element.blur ?? 0);
639
- const seedBright = k0?.brightness !== undefined ? k0.brightness : (element.brightness ?? 100);
640
- const seedContrast = k0?.contrast !== undefined ? k0.contrast : (element.contrast ?? 100);
641
- const seedSat = k0?.saturate !== undefined ? k0.saturate : (element.saturate ?? 100);
642
- const seedGray = k0?.grayscale !== undefined ? k0.grayscale : (element.grayscale ?? 0);
643
-
644
- animatedElements.set(element.id, {
645
- x: mkTween(seedX, { duration: 500 }),
646
- y: mkTween(seedY, { duration: 500 }),
647
- width: mkTween(seedW, { duration: 500 }),
648
- height: mkTween(seedH, { duration: 500 }),
649
- rotation: mkTween(seedRot, { duration: 500 }),
650
- skewX: mkTween(seedSkewX, { duration: 500 }),
651
- skewY: mkTween(seedSkewY, { duration: 500 }),
652
- tiltX: mkTween(seedTiltX, { duration: 500 }),
653
- tiltY: mkTween(seedTiltY, { duration: 500 }),
654
- perspective: mkTween(element.perspective ?? 1000, { duration: 500 }),
655
- opacity: mkTween(startOpacity, { duration: 300 }),
656
- borderRadius: mkTween(seedBR, { duration: 500 }),
657
- fontSize: textEl ? mkTween(seedFontSize, { duration: 500 }) : null,
658
- fillColor: shapeEl ? mkTween(seedFill, { duration: 500 }) : null,
659
- strokeColor: shapeEl ? mkTween(seedStroke, { duration: 500 }) : null,
660
- strokeWidth: shapeEl ? mkTween(seedStrokeW, { duration: 500 }) : null,
661
- shapeMorph: shapeEl ? mkTween(1, { duration: 500 }) : null,
662
- motionPathProgress: element.motionPathConfig ? mkTween(0, { duration: 500 }) : null,
663
- blur: mkTween(seedBlur, { duration: 500 }),
664
- brightness: mkTween(seedBright, { duration: 500 }),
665
- contrast: mkTween(seedContrast, { duration: 500 }),
666
- saturate: mkTween(seedSat, { duration: 500 }),
667
- grayscale: mkTween(seedGray, { duration: 500 })
668
- });
669
- const currentSlideEl = getElementInSlide(currentSlide, element.id);
670
- elementContent.set(element.id, JSON.parse(JSON.stringify(currentSlideEl || element)));
671
- }
672
- }
673
- }
674
- animatedElements = new Map(animatedElements);
675
- elementContent = new Map(elementContent);
676
- previousCodeContent = new Map(previousCodeContent);
677
- }
678
-
679
- async function animateMotionPaths(slide: Slide) {
680
- cancelMotionPathLoops();
681
- motionPathLoopAbort = new AbortController();
682
- const signal = motionPathLoopAbort.signal;
683
-
684
- const resets: Promise<void>[] = [];
685
- for (const element of slide.canvas.elements) {
686
- if (element.motionPathConfig) {
687
- const animated = animatedElements.get(element.id);
688
- if (animated?.motionPathProgress) {
689
- resets.push(animated.motionPathProgress.to(0, { duration: 0 }));
690
- }
691
- }
692
- }
693
- await Promise.all(resets);
694
- for (const element of slide.canvas.elements) {
695
- if (element.motionPathConfig) {
696
- const animated = animatedElements.get(element.id);
697
- if (animated?.motionPathProgress) {
698
- const config = element.animationConfig;
699
- const duration = config?.duration ?? 2000;
700
- const easing = getEasingFn(config?.easing);
701
- const shouldLoop = element.motionPathConfig.loop;
702
-
703
- if (shouldLoop) {
704
- const laps = element.motionPathConfig.laps ?? 0;
705
- (async () => {
706
- try {
707
- let lap = 0;
708
- while (!signal.aborted && (laps === 0 || lap < laps)) {
709
- await abortable(animated.motionPathProgress!.to(0, { duration: 0 }), signal);
710
- await abortable(animated.motionPathProgress!.to(1, { duration, easing }), signal);
711
- lap++;
712
- if (!signal.aborted && (laps === 0 || lap < laps)) await abortableSleep(50, signal);
713
- }
714
- } catch (e) {
715
- if (!isAbortError(e)) throw e;
716
- }
717
- })();
718
- } else {
719
- animated.motionPathProgress.to(1, { duration, easing }).catch(() => {});
720
- }
721
- }
722
- }
723
- }
724
- }
725
-
726
- // Reset presentation to first slide (snap all elements back to initial state)
727
- async function resetToFirstSlide() {
728
- if (isTransitioning) return;
729
- isTransitioning = true;
730
- clearAllTypewriterAnimations();
731
- cancelMotionPathLoops();
732
- cancelKeyframeLoops();
733
- const firstSlide = slides[0];
734
- if (!firstSlide) { isTransitioning = false; return; }
735
-
736
- for (const elementId of allElementIds) {
737
- const targetEl = getElementInSlide(firstSlide, elementId);
738
- const animated = animatedElements.get(elementId);
739
- if (!animated) continue;
740
- if (targetEl) {
741
- animated.x.to(targetEl.position.x, { duration: 0 }); animated.y.to(targetEl.position.y, { duration: 0 });
742
- animated.width.to(targetEl.size.width, { duration: 0 }); animated.height.to(targetEl.size.height, { duration: 0 });
743
- animated.rotation.to(targetEl.rotation, { duration: 0 });
744
- animated.skewX.to(targetEl.skewX ?? 0, { duration: 0 }); animated.skewY.to(targetEl.skewY ?? 0, { duration: 0 });
745
- animated.tiltX.to(targetEl.tiltX ?? 0, { duration: 0 }); animated.tiltY.to(targetEl.tiltY ?? 0, { duration: 0 });
746
- animated.perspective.to(targetEl.perspective ?? 1000, { duration: 0 });
747
- animated.opacity.to((targetEl as any).opacity ?? 1, { duration: 0 });
748
- animated.borderRadius.to((targetEl as any).borderRadius ?? 0, { duration: 0 });
749
- animated.blur.to(targetEl.blur ?? 0, { duration: 0 });
750
- animated.brightness.to(targetEl.brightness ?? 100, { duration: 0 });
751
- animated.contrast.to(targetEl.contrast ?? 100, { duration: 0 });
752
- animated.saturate.to(targetEl.saturate ?? 100, { duration: 0 });
753
- animated.grayscale.to(targetEl.grayscale ?? 0, { duration: 0 });
754
- if (targetEl.type === 'text' && animated.fontSize) animated.fontSize.to((targetEl as TextElement).fontSize, { duration: 0 });
755
- if (targetEl.type === 'shape') {
756
- const s = targetEl as ShapeElement;
757
- if (animated.fillColor) animated.fillColor.to(s.fillColor, { duration: 0 });
758
- if (animated.strokeColor) animated.strokeColor.to(s.strokeColor, { duration: 0 });
759
- if (animated.strokeWidth) animated.strokeWidth.to(s.strokeWidth, { duration: 0 });
760
- }
761
- if (animated.motionPathProgress) animated.motionPathProgress.to(0, { duration: 0 });
762
- } else {
763
- animated.opacity.to(0, { duration: 0 });
764
- }
765
- }
766
-
767
- for (const elementId of allElementIds) {
768
- const targetEl = getElementInSlide(firstSlide, elementId);
769
- if (targetEl) elementContent.set(elementId, JSON.parse(JSON.stringify(targetEl)));
770
- }
771
-
772
- const newPreviousCodeContent = new Map<string, string>();
773
- for (const element of firstSlide.canvas.elements) {
774
- if (element.type === 'code') newPreviousCodeContent.set(element.id, (element as CodeElement).code);
775
- }
776
-
777
- for (const element of firstSlide.canvas.elements) {
778
- if (element.type === 'code') {
779
- const codeEl = element as CodeElement;
780
- const key = `${codeEl.id}-${codeEl.code}-${codeEl.language}-${codeEl.showLineNumbers}`;
781
- if (!codeHighlights.has(key)) {
782
- const html = await highlightCode(codeEl.code, codeEl.language, codeEl.theme, { showLineNumbers: codeEl.showLineNumbers });
783
- codeHighlights.set(key, html);
784
- }
785
- }
786
- }
787
- codeHighlights = new Map(codeHighlights);
788
-
789
- codeMorphState = new Map();
790
- previousCodeContent = newPreviousCodeContent;
791
- previousChartContent = new Map();
792
- previousProgressContent = new Map();
793
- shapeMorphStates = new Map();
794
- elementContent = new Map(elementContent);
795
- currentSlideIndex = 0;
796
- isTransitioning = false;
797
-
798
- // Restart narration on loop. Setting `currentSlideIndex = 0` above
799
- // is a no-op for single-slide decks (was already 0) so the play-state
800
- // effect doesn't re-fire on its own. Calling explicitly here covers
801
- // both single- and multi-slide loops uniformly.
802
- if (isAutoplay) playNarrationForSlide(0);
803
-
804
- for (const element of firstSlide.canvas.elements) {
805
- if (element.type === 'text') {
806
- const textEl = element as TextElement;
807
- if (textEl.animation?.mode === 'typewriter') startTypewriterAnimation(element.id, textEl.content, textEl.animation.typewriterSpeed || 50);
808
- }
809
- }
810
-
811
- animateKeyframes(firstSlide);
812
- animateMotionPaths(firstSlide);
813
- onslidechange?.(0, slides.length);
814
- }
815
-
816
- // Animate to slide
817
- async function animateToSlide(targetIndex: number) {
818
- if (isTransitioning || targetIndex < 0 || targetIndex >= slides.length) return;
819
- if (targetIndex === currentSlideIndex) return;
820
- isTransitioning = true;
821
- transitionDirection = targetIndex > currentSlideIndex ? 'forward' : 'backward';
822
- const targetSlide = slides[targetIndex];
823
- clearAllTypewriterAnimations();
824
- cancelMotionPathLoops();
825
- cancelKeyframeLoops();
826
- // Manual arrow nav and the autoplay timer are both forms of "user
827
- // is moving forward in the deck" — both should swap narration to
828
- // the new slide. The pause button is what stops audio. Without
829
- // this, clicking an arrow while paused would render the new slide
830
- // silently, which feels broken.
831
- playNarrationForSlide(targetIndex);
832
- const transition = targetSlide.transition;
833
- const duration = durationOverride ?? transition.duration;
834
- transitionDurationMs = duration;
835
- const hasSlideTransition = transition.type !== 'none';
836
-
837
- if (hasSlideTransition) {
838
- transitionClass = `transition-${transition.type}-out`;
839
- await new Promise(r => setTimeout(r, duration * 0.4));
840
- const newElementContent = new Map(elementContent);
841
- const newCodeMorphState = new Map(codeMorphState);
842
- const newPreviousCodeContent = new Map(previousCodeContent);
843
- const newPreviousChartContent = new Map(previousChartContent);
844
- const newPreviousProgressContent = new Map(previousProgressContent);
845
- for (const elementId of allElementIds) {
846
- const targetEl = getElementInSlide(targetSlide, elementId);
847
- const animated = animatedElements.get(elementId);
848
- if (targetEl) {
849
- if (targetEl.type === 'code') {
850
- const codeEl = targetEl as CodeElement;
851
- const prevCode = newPreviousCodeContent.get(elementId) || '';
852
- newCodeMorphState.set(elementId, { oldCode: prevCode, newCode: codeEl.code, mode: codeEl.animation?.mode || 'highlight-changes', speed: codeEl.animation?.typewriterSpeed || 50, highlightColor: codeEl.animation?.highlightColor || '#fef08a' });
853
- newPreviousCodeContent.set(elementId, codeEl.code);
854
- }
855
- if (targetEl.type === 'chart') {
856
- const outgoing = elementContent.get(elementId);
857
- if (outgoing && outgoing.type === 'chart') {
858
- newPreviousChartContent.set(elementId, JSON.parse(JSON.stringify(outgoing)));
859
- }
860
- }
861
- if (targetEl.type === 'progress') {
862
- const outgoing = elementContent.get(elementId);
863
- if (outgoing && outgoing.type === 'progress') {
864
- newPreviousProgressContent.set(elementId, JSON.parse(JSON.stringify(outgoing)));
865
- }
866
- }
867
- newElementContent.set(elementId, JSON.parse(JSON.stringify(targetEl)));
868
- if (animated) {
869
- animated.x.to(targetEl.position.x, { duration: 0 }); animated.y.to(targetEl.position.y, { duration: 0 });
870
- animated.width.to(targetEl.size.width, { duration: 0 }); animated.height.to(targetEl.size.height, { duration: 0 });
871
- animated.rotation.to(targetEl.rotation, { duration: 0 });
872
- animated.skewX.to(targetEl.skewX ?? 0, { duration: 0 }); animated.skewY.to(targetEl.skewY ?? 0, { duration: 0 });
873
- animated.tiltX.to(targetEl.tiltX ?? 0, { duration: 0 }); animated.tiltY.to(targetEl.tiltY ?? 0, { duration: 0 });
874
- animated.perspective.to(targetEl.perspective ?? 1000, { duration: 0 });
875
- animated.opacity.to((targetEl as any).opacity ?? 1, { duration: 0 });
876
- animated.borderRadius.to((targetEl as any).borderRadius ?? 0, { duration: 0 });
877
- animated.blur.to(targetEl.blur ?? 0, { duration: 0 });
878
- animated.brightness.to(targetEl.brightness ?? 100, { duration: 0 });
879
- animated.contrast.to(targetEl.contrast ?? 100, { duration: 0 });
880
- animated.saturate.to(targetEl.saturate ?? 100, { duration: 0 });
881
- animated.grayscale.to(targetEl.grayscale ?? 0, { duration: 0 });
882
- if (targetEl.type === 'text') animated.fontSize?.to((targetEl as TextElement).fontSize, { duration: 0 });
883
- if (targetEl.type === 'shape') {
884
- const s = targetEl as ShapeElement;
885
- animated.fillColor?.to(s.fillColor, { duration: 0 });
886
- animated.strokeColor?.to(s.strokeColor, { duration: 0 });
887
- animated.strokeWidth?.to(s.strokeWidth, { duration: 0 });
888
- }
889
- if (animated.motionPathProgress) animated.motionPathProgress.to(0, { duration: 0 });
890
- }
891
- } else if (animated) { animated.opacity.to(0, { duration: 0 }); }
892
- }
893
- for (const [, element] of newElementContent) {
894
- if (element.type === 'code') {
895
- const codeEl = element as CodeElement;
896
- const key = `${codeEl.id}-${codeEl.code}-${codeEl.language}-${codeEl.showLineNumbers}`;
897
- if (!codeHighlights.has(key)) {
898
- const html = await highlightCode(codeEl.code, codeEl.language, codeEl.theme, { showLineNumbers: codeEl.showLineNumbers });
899
- codeHighlights.set(key, html);
900
- }
901
- }
902
- }
903
- codeHighlights = new Map(codeHighlights);
904
- shapeMorphStates = new Map();
905
- codeMorphState = newCodeMorphState;
906
- previousCodeContent = newPreviousCodeContent;
907
- previousChartContent = newPreviousChartContent;
908
- previousProgressContent = newPreviousProgressContent;
909
- elementContent = newElementContent;
910
- animatedElements = new Map(animatedElements);
911
- currentSlideIndex = targetIndex;
912
- for (const elementId of allElementIds) {
913
- const targetEl = getElementInSlide(targetSlide, elementId);
914
- if (targetEl?.type === 'text') {
915
- const textEl = targetEl as TextElement;
916
- if (textEl.animation?.mode === 'typewriter') startTypewriterAnimation(elementId, textEl.content, textEl.animation.typewriterSpeed || 50);
917
- }
918
- }
919
- transitionClass = `transition-${transition.type}-in`;
920
- await new Promise(r => setTimeout(r, duration * 0.6));
921
- transitionClass = '';
922
- animateKeyframes(targetSlide);
923
- animateMotionPaths(targetSlide);
924
- isTransitioning = false;
925
- onslidechange?.(targetIndex, slides.length);
926
- if (targetIndex === slides.length - 1 && !loop) oncomplete?.();
927
- return;
928
- }
929
-
930
- // Per-element morphing (transition type = 'none')
931
- const animations: Promise<void>[] = [];
932
- for (const elementId of allElementIds) {
933
- const currentEl = getElementInSlide(currentSlide, elementId);
934
- const animated = animatedElements.get(elementId);
935
- if (!animated) continue;
936
- if (currentEl) {
937
- await animated.x.to(currentEl.position.x, { duration: 0 });
938
- await animated.y.to(currentEl.position.y, { duration: 0 });
939
- await animated.width.to(currentEl.size.width, { duration: 0 });
940
- await animated.height.to(currentEl.size.height, { duration: 0 });
941
- await animated.rotation.to(currentEl.rotation, { duration: 0 });
942
- await animated.skewX.to(currentEl.skewX ?? 0, { duration: 0 });
943
- await animated.skewY.to(currentEl.skewY ?? 0, { duration: 0 });
944
- await animated.tiltX.to(currentEl.tiltX ?? 0, { duration: 0 });
945
- await animated.tiltY.to(currentEl.tiltY ?? 0, { duration: 0 });
946
- await animated.perspective.to(currentEl.perspective ?? 1000, { duration: 0 });
947
- await animated.borderRadius.to((currentEl as any).borderRadius ?? 0, { duration: 0 });
948
- await animated.blur.to(currentEl.blur ?? 0, { duration: 0 });
949
- await animated.brightness.to(currentEl.brightness ?? 100, { duration: 0 });
950
- await animated.contrast.to(currentEl.contrast ?? 100, { duration: 0 });
951
- await animated.saturate.to(currentEl.saturate ?? 100, { duration: 0 });
952
- await animated.grayscale.to(currentEl.grayscale ?? 0, { duration: 0 });
953
- await animated.opacity.to((currentEl as any).opacity ?? 1, { duration: 0 });
954
- if (currentEl.type === 'text' && animated.fontSize) await animated.fontSize.to((currentEl as TextElement).fontSize, { duration: 0 });
955
- if (currentEl.type === 'shape') {
956
- const s = currentEl as ShapeElement;
957
- if (animated.fillColor) await animated.fillColor.to(s.fillColor, { duration: 0 });
958
- if (animated.strokeColor) await animated.strokeColor.to(s.strokeColor, { duration: 0 });
959
- if (animated.strokeWidth) await animated.strokeWidth.to(s.strokeWidth, { duration: 0 });
960
- }
961
- }
962
- }
963
-
964
- // Update elementContent BEFORE animations start so rendered elements
965
- // (especially SVG viewBox) use target slide data while animating.
966
- // For charts, snapshot OUTGOING values into previousChartContent first
967
- // so the new render tweens from them.
968
- for (const elementId of allElementIds) {
969
- const targetEl = getElementInSlide(targetSlide, elementId);
970
- if (targetEl && targetEl.type !== 'code') {
971
- if (targetEl.type === 'chart') {
972
- const outgoing = elementContent.get(elementId);
973
- if (outgoing && outgoing.type === 'chart') {
974
- previousChartContent.set(elementId, JSON.parse(JSON.stringify(outgoing)));
975
- }
976
- }
977
- if (targetEl.type === 'progress') {
978
- const outgoing = elementContent.get(elementId);
979
- if (outgoing && outgoing.type === 'progress') {
980
- previousProgressContent.set(elementId, JSON.parse(JSON.stringify(outgoing)));
981
- }
982
- }
983
- elementContent.set(elementId, JSON.parse(JSON.stringify(targetEl)));
984
- }
985
- }
986
- elementContent = new Map(elementContent);
987
- previousProgressContent = new Map(previousProgressContent);
988
- previousChartContent = new Map(previousChartContent);
989
-
990
- interface AnimationTask { elementId: string; order: number; delay: number; elementDuration: number; run: () => Promise<void>[]; }
991
- const animationTasks: AnimationTask[] = [];
992
-
993
- for (const elementId of allElementIds) {
994
- const currentEl = getElementInSlide(currentSlide, elementId);
995
- const targetEl = getElementInSlide(targetSlide, elementId);
996
- const animated = animatedElements.get(elementId);
997
- if (!animated) continue;
998
- const animConfig = targetEl?.animationConfig || currentEl?.animationConfig;
999
- const order = animConfig?.order ?? 0;
1000
- const delay = animConfig?.delay ?? 0;
1001
- const elementDuration = animConfig?.duration ?? duration;
1002
-
1003
- if (targetEl) {
1004
- const easing = getEasingFn(animConfig?.easing);
1005
- const propertySequences = targetEl.animationConfig?.propertySequences;
1006
- if (targetEl.type === 'text') {
1007
- const textEl = targetEl as TextElement;
1008
- if (textEl.animation?.mode === 'typewriter') startTypewriterAnimation(elementId, textEl.content, textEl.animation.typewriterSpeed || 50);
1009
- }
1010
-
1011
- const getSeqTiming = (prop: AnimatableProperty) => {
1012
- if (!propertySequences?.length) return { duration: elementDuration, delay: 0, order: 0 };
1013
- const seq = propertySequences.find(s => s.property === prop);
1014
- return seq ? { duration: seq.duration, delay: seq.delay, order: seq.order } : { duration: elementDuration, delay: 0, order: 99 };
1015
- };
1016
-
1017
- animationTasks.push({
1018
- elementId, order, delay, elementDuration,
1019
- run: () => {
1020
- const anims: Promise<void>[] = [];
1021
- if (propertySequences?.length) {
1022
- const sequencedProps = new Set(propertySequences.map(s => s.property));
1023
- if (!sequencedProps.has('position')) { anims.push(animated.x.to(targetEl.position.x, { duration: elementDuration, easing })); anims.push(animated.y.to(targetEl.position.y, { duration: elementDuration, easing })); }
1024
- if (!sequencedProps.has('rotation')) anims.push(animated.rotation.to(targetEl.rotation, { duration: elementDuration, easing }));
1025
- if (!sequencedProps.has('tilt')) { anims.push(animated.tiltX.to(targetEl.tiltX ?? 0, { duration: elementDuration, easing })); anims.push(animated.tiltY.to(targetEl.tiltY ?? 0, { duration: elementDuration, easing })); }
1026
- if (!sequencedProps.has('skew')) { anims.push(animated.skewX.to(targetEl.skewX ?? 0, { duration: elementDuration, easing })); anims.push(animated.skewY.to(targetEl.skewY ?? 0, { duration: elementDuration, easing })); }
1027
- if (!sequencedProps.has('size')) { anims.push(animated.width.to(targetEl.size.width, { duration: elementDuration, easing })); anims.push(animated.height.to(targetEl.size.height, { duration: elementDuration, easing })); }
1028
- if (!sequencedProps.has('borderRadius')) anims.push(animated.borderRadius.to((targetEl as any).borderRadius ?? 0, { duration: elementDuration, easing }));
1029
- if (!sequencedProps.has('blur')) {
1030
- animated.blur.to(targetEl.blur ?? 0, { duration: elementDuration, easing });
1031
- animated.brightness.to(targetEl.brightness ?? 100, { duration: elementDuration, easing });
1032
- animated.contrast.to(targetEl.contrast ?? 100, { duration: elementDuration, easing });
1033
- animated.saturate.to(targetEl.saturate ?? 100, { duration: elementDuration, easing });
1034
- animated.grayscale.to(targetEl.grayscale ?? 0, { duration: elementDuration, easing });
1035
- }
1036
- if (!sequencedProps.has('perspective')) anims.push(animated.perspective.to(targetEl.perspective ?? 1000, { duration: elementDuration, easing }));
1037
- if (!sequencedProps.has('opacity')) {
1038
- const targetOpacity = (targetEl as any).opacity ?? 1;
1039
- if (animated.opacity.current !== targetOpacity) anims.push(animated.opacity.to(targetOpacity, { duration: elementDuration, easing }));
1040
- }
1041
- const sortedSeqs = [...propertySequences].sort((a, b) => a.order - b.order);
1042
- let cumulativeDelay = 0;
1043
- for (const seq of sortedSeqs) {
1044
- const seqDelay = cumulativeDelay + seq.delay;
1045
- const seqDuration = seq.duration;
1046
- setTimeout(() => {
1047
- if (seq.property === 'position') { animated.x.to(targetEl.position.x, { duration: seqDuration, easing }); animated.y.to(targetEl.position.y, { duration: seqDuration, easing }); }
1048
- else if (seq.property === 'rotation') animated.rotation.to(targetEl.rotation, { duration: seqDuration, easing });
1049
- else if (seq.property === 'tilt') { animated.tiltX.to(targetEl.tiltX ?? 0, { duration: seqDuration, easing }); animated.tiltY.to(targetEl.tiltY ?? 0, { duration: seqDuration, easing }); }
1050
- else if (seq.property === 'skew') { animated.skewX.to(targetEl.skewX ?? 0, { duration: seqDuration, easing }); animated.skewY.to(targetEl.skewY ?? 0, { duration: seqDuration, easing }); }
1051
- else if (seq.property === 'size') { animated.width.to(targetEl.size.width, { duration: seqDuration, easing }); animated.height.to(targetEl.size.height, { duration: seqDuration, easing }); }
1052
- else if (seq.property === 'borderRadius') animated.borderRadius.to((targetEl as any).borderRadius ?? 0, { duration: seqDuration, easing });
1053
- else if (seq.property === 'blur') {
1054
- animated.blur.to(targetEl.blur ?? 0, { duration: seqDuration, easing });
1055
- animated.brightness.to(targetEl.brightness ?? 100, { duration: seqDuration, easing });
1056
- animated.contrast.to(targetEl.contrast ?? 100, { duration: seqDuration, easing });
1057
- animated.saturate.to(targetEl.saturate ?? 100, { duration: seqDuration, easing });
1058
- animated.grayscale.to(targetEl.grayscale ?? 0, { duration: seqDuration, easing });
1059
- }
1060
- else if (seq.property === 'color' && targetEl.type === 'shape') {
1061
- const s = targetEl as ShapeElement;
1062
- animated.fillColor?.to(s.fillColor, { duration: seqDuration, easing });
1063
- animated.strokeColor?.to(s.strokeColor, { duration: seqDuration, easing });
1064
- animated.strokeWidth?.to(s.strokeWidth, { duration: seqDuration, easing });
1065
- }
1066
- else if (seq.property === 'perspective') animated.perspective.to(targetEl.perspective ?? 1000, { duration: seqDuration, easing });
1067
- else if (seq.property === 'opacity') animated.opacity.to((targetEl as any).opacity ?? 1, { duration: seqDuration, easing });
1068
- }, seqDelay);
1069
- cumulativeDelay = seqDelay + seqDuration;
1070
- }
1071
- anims.push(new Promise(r => setTimeout(r, cumulativeDelay)));
1072
- } else {
1073
- anims.push(animated.x.to(targetEl.position.x, { duration: elementDuration, easing }));
1074
- anims.push(animated.y.to(targetEl.position.y, { duration: elementDuration, easing }));
1075
- anims.push(animated.width.to(targetEl.size.width, { duration: elementDuration, easing }));
1076
- anims.push(animated.height.to(targetEl.size.height, { duration: elementDuration, easing }));
1077
- anims.push(animated.rotation.to(targetEl.rotation, { duration: elementDuration, easing }));
1078
- anims.push(animated.skewX.to(targetEl.skewX ?? 0, { duration: elementDuration, easing }));
1079
- anims.push(animated.skewY.to(targetEl.skewY ?? 0, { duration: elementDuration, easing }));
1080
- anims.push(animated.tiltX.to(targetEl.tiltX ?? 0, { duration: elementDuration, easing }));
1081
- anims.push(animated.tiltY.to(targetEl.tiltY ?? 0, { duration: elementDuration, easing }));
1082
- anims.push(animated.perspective.to(targetEl.perspective ?? 1000, { duration: elementDuration, easing }));
1083
- anims.push(animated.borderRadius.to((targetEl as any).borderRadius ?? 0, { duration: elementDuration, easing }));
1084
- anims.push(animated.blur.to(targetEl.blur ?? 0, { duration: elementDuration, easing }));
1085
- anims.push(animated.brightness.to(targetEl.brightness ?? 100, { duration: elementDuration, easing }));
1086
- anims.push(animated.contrast.to(targetEl.contrast ?? 100, { duration: elementDuration, easing }));
1087
- anims.push(animated.saturate.to(targetEl.saturate ?? 100, { duration: elementDuration, easing }));
1088
- anims.push(animated.grayscale.to(targetEl.grayscale ?? 0, { duration: elementDuration, easing }));
1089
- // Opacity interpolation for morphing elements
1090
- const currOpacity = animated.opacity.current;
1091
- const targetOpacity = (targetEl as any).opacity ?? 1;
1092
- if (currOpacity !== targetOpacity) {
1093
- anims.push(animated.opacity.to(targetOpacity, { duration: elementDuration, easing }));
1094
- }
1095
- }
1096
- // Motion path progress — await reset, then animate forward
1097
- if (animated.motionPathProgress && targetEl.motionPathConfig) {
1098
- const shouldLoop = targetEl.motionPathConfig.loop;
1099
- if (!shouldLoop) {
1100
- anims.push((async () => {
1101
- await animated.motionPathProgress!.to(0, { duration: 0 });
1102
- await animated.motionPathProgress!.to(1, { duration: elementDuration, easing });
1103
- })());
1104
- }
1105
- }
1106
- if (targetEl.type === 'text' && animated.fontSize) anims.push(animated.fontSize.to((targetEl as TextElement).fontSize, { duration: elementDuration, easing }));
1107
- if (targetEl.type === 'shape' && currentEl?.type === 'shape') {
1108
- const ts = targetEl as ShapeElement;
1109
- const cs = currentEl as ShapeElement;
1110
- if (!propertySequences?.length) {
1111
- if (animated.fillColor) anims.push(animated.fillColor.to(ts.fillColor, { duration: elementDuration, easing }));
1112
- if (animated.strokeColor) anims.push(animated.strokeColor.to(ts.strokeColor, { duration: elementDuration, easing }));
1113
- if (animated.strokeWidth) anims.push(animated.strokeWidth.to(ts.strokeWidth, { duration: elementDuration, easing }));
1114
- }
1115
- if (cs.shapeType !== ts.shapeType && animated.shapeMorph) {
1116
- shapeMorphStates.set(elementId, { fromType: cs.shapeType, toType: ts.shapeType });
1117
- shapeMorphStates = new Map(shapeMorphStates);
1118
- anims.push(animated.shapeMorph.to(0, { duration: 0 }));
1119
- anims.push(animated.shapeMorph.to(1, { duration: elementDuration, easing }));
1120
- }
1121
- } else if (targetEl.type === 'shape' && !propertySequences?.length) {
1122
- const s = targetEl as ShapeElement;
1123
- if (animated.fillColor) anims.push(animated.fillColor.to(s.fillColor, { duration: elementDuration, easing }));
1124
- if (animated.strokeColor) anims.push(animated.strokeColor.to(s.strokeColor, { duration: elementDuration, easing }));
1125
- if (animated.strokeWidth) anims.push(animated.strokeWidth.to(s.strokeWidth, { duration: elementDuration, easing }));
1126
- }
1127
- if (!currentEl) {
1128
- // Snap ALL properties to target instantly — the tween may hold
1129
- // stale values from a previous slide where the element last appeared
1130
- anims.push(animated.x.to(targetEl.position.x, { duration: 0 }));
1131
- anims.push(animated.y.to(targetEl.position.y, { duration: 0 }));
1132
- anims.push(animated.width.to(targetEl.size.width, { duration: 0 }));
1133
- anims.push(animated.height.to(targetEl.size.height, { duration: 0 }));
1134
- anims.push(animated.rotation.to(targetEl.rotation, { duration: 0 }));
1135
- anims.push(animated.skewX.to(targetEl.skewX ?? 0, { duration: 0 }));
1136
- anims.push(animated.skewY.to(targetEl.skewY ?? 0, { duration: 0 }));
1137
- anims.push(animated.tiltX.to(targetEl.tiltX ?? 0, { duration: 0 }));
1138
- anims.push(animated.tiltY.to(targetEl.tiltY ?? 0, { duration: 0 }));
1139
- anims.push(animated.perspective.to(targetEl.perspective ?? 1000, { duration: 0 }));
1140
- anims.push(animated.borderRadius.to((targetEl as any).borderRadius ?? 0, { duration: 0 }));
1141
- anims.push(animated.blur.to(targetEl.blur ?? 0, { duration: 0 }));
1142
- anims.push(animated.brightness.to(targetEl.brightness ?? 100, { duration: 0 }));
1143
- anims.push(animated.contrast.to(targetEl.contrast ?? 100, { duration: 0 }));
1144
- anims.push(animated.saturate.to(targetEl.saturate ?? 100, { duration: 0 }));
1145
- anims.push(animated.grayscale.to(targetEl.grayscale ?? 0, { duration: 0 }));
1146
- if (targetEl.type === 'text' && animated.fontSize) {
1147
- anims.push(animated.fontSize.to((targetEl as TextElement).fontSize, { duration: 0 }));
1148
- }
1149
- if (targetEl.type === 'shape') {
1150
- const s = targetEl as ShapeElement;
1151
- if (animated.fillColor) anims.push(animated.fillColor.to(s.fillColor, { duration: 0 }));
1152
- if (animated.strokeColor) anims.push(animated.strokeColor.to(s.strokeColor, { duration: 0 }));
1153
- if (animated.strokeWidth) anims.push(animated.strokeWidth.to(s.strokeWidth, { duration: 0 }));
1154
- }
1155
- const entrance = targetEl.animationConfig?.entrance ?? 'fade';
1156
- const targetOpacity = (targetEl as any).opacity ?? 1;
1157
- if (entrance === 'fade') {
1158
- anims.push(animated.opacity.to(targetOpacity, { duration: elementDuration / 2, easing }));
1159
- } else {
1160
- anims.push(animated.opacity.to(targetOpacity, { duration: 0 }));
1161
- }
1162
- }
1163
- return anims;
1164
- }
1165
- });
1166
- } else if (currentEl) {
1167
- const exit = currentEl.animationConfig?.exit ?? 'fade';
1168
- if (exit === 'fade') {
1169
- const fadeOutDuration = Math.min(elementDuration / 2, 300);
1170
- animationTasks.push({ elementId, order, delay, elementDuration, run: () => [animated.opacity.to(0, { duration: fadeOutDuration, easing: easeInOutCubic })] });
1171
- } else {
1172
- animationTasks.push({ elementId, order, delay: 0, elementDuration: 0, run: () => [animated.opacity.to(0, { duration: 0 })] });
1173
- }
1174
- }
1175
- }
1176
-
1177
- animationTasks.sort((a, b) => a.order - b.order);
1178
- const orderGroups = new Map<number, AnimationTask[]>();
1179
- for (const task of animationTasks) {
1180
- if (!orderGroups.has(task.order)) orderGroups.set(task.order, []);
1181
- orderGroups.get(task.order)!.push(task);
1182
- }
1183
- const sortedOrders = [...orderGroups.keys()].sort((a, b) => a - b);
1184
- for (let orderIdx = 0; orderIdx < sortedOrders.length; orderIdx++) {
1185
- const order = sortedOrders[orderIdx];
1186
- const tasks = orderGroups.get(order)!;
1187
- const groupAnimations: Promise<void>[] = [];
1188
- for (const task of tasks) {
1189
- if (task.delay > 0) setTimeout(() => { task.run().forEach(p => animations.push(p)); }, task.delay);
1190
- else groupAnimations.push(...task.run());
1191
- }
1192
- animations.push(...groupAnimations);
1193
- if (orderIdx < sortedOrders.length - 1) {
1194
- const maxDur = Math.max(...tasks.map(t => t.elementDuration));
1195
- await new Promise(r => setTimeout(r, maxDur * 0.3));
1196
- }
1197
- }
1198
-
1199
- const newElementContent = new Map(elementContent);
1200
- const newCodeMorphState = new Map(codeMorphState);
1201
- const newPreviousCodeContent = new Map(previousCodeContent);
1202
- for (const elementId of allElementIds) {
1203
- const targetEl = getElementInSlide(targetSlide, elementId);
1204
- if (targetEl) {
1205
- if (targetEl.type === 'code') {
1206
- const codeEl = targetEl as CodeElement;
1207
- const prevCode = newPreviousCodeContent.get(elementId) || '';
1208
- newCodeMorphState.set(elementId, { oldCode: prevCode, newCode: codeEl.code, mode: codeEl.animation?.mode || 'highlight-changes', speed: codeEl.animation?.typewriterSpeed || 50, highlightColor: codeEl.animation?.highlightColor || '#fef08a' });
1209
- newPreviousCodeContent.set(elementId, codeEl.code);
1210
- }
1211
- newElementContent.set(elementId, JSON.parse(JSON.stringify(targetEl)));
1212
- }
1213
- }
1214
- for (const [, element] of newElementContent) {
1215
- if (element.type === 'code') {
1216
- const codeEl = element as CodeElement;
1217
- const key = `${codeEl.id}-${codeEl.code}-${codeEl.language}-${codeEl.showLineNumbers}`;
1218
- if (!codeHighlights.has(key)) {
1219
- const html = await highlightCode(codeEl.code, codeEl.language, codeEl.theme, { showLineNumbers: codeEl.showLineNumbers });
1220
- codeHighlights.set(key, html);
1221
- }
1222
- }
1223
- }
1224
- codeHighlights = new Map(codeHighlights);
1225
- shapeMorphStates = new Map();
1226
- codeMorphState = newCodeMorphState;
1227
- previousCodeContent = newPreviousCodeContent;
1228
- elementContent = newElementContent;
1229
- currentSlideIndex = targetIndex;
1230
- isTransitioning = false;
1231
- // Ensure elements not on the new slide are fully hidden
1232
- const newSlide = slides[targetIndex];
1233
- for (const elementId of allElementIds) {
1234
- const onSlide = getElementInSlide(newSlide, elementId);
1235
- const animated = animatedElements.get(elementId);
1236
- if (!onSlide && animated) { animated.opacity.to(0, { duration: 0 }); }
1237
- }
1238
- animateKeyframes(slides[targetIndex]);
1239
- animateMotionPaths(slides[targetIndex]);
1240
- onslidechange?.(targetIndex, slides.length);
1241
- if (targetIndex === slides.length - 1 && !loop) oncomplete?.();
1242
- }
1243
-
1244
- // Autoplay
1245
- function clearAutoplayTimer() { if (autoplayTimer) { clearTimeout(autoplayTimer); autoplayTimer = null; } }
1246
- function scheduleNextSlide() {
1247
- clearAutoplayTimer();
1248
- if (!isAutoplay) return;
1249
- const slideDuration = durationOverride ?? currentSlide?.duration ?? 3000;
1250
- autoplayTimer = setTimeout(() => {
1251
- if (currentSlideIndex < slides.length - 1) animateToSlide(currentSlideIndex + 1);
1252
- else if (loop) {
1253
- const loopMode = project?.settings?.loopMode ?? 'reset';
1254
- if (loopMode === 'transition') animateToSlide(0);
1255
- else resetToFirstSlide();
1256
- }
1257
- else isAutoplay = false;
1258
- }, slideDuration);
1259
- }
1260
- $effect(() => { if (isAutoplay && !isTransitioning) scheduleNextSlide(); });
1261
- $effect(() => () => clearAutoplayTimer());
1262
-
1263
- function handleNextSlide() {
1264
- if (currentSlideIndex < slides.length - 1) {
1265
- animateToSlide(currentSlideIndex + 1);
1266
- } else if (loop) {
1267
- const loopMode = project?.settings?.loopMode ?? 'reset';
1268
- if (loopMode === 'transition') {
1269
- animateToSlide(0);
1270
- } else {
1271
- resetToFirstSlide();
1272
- }
1273
- }
1274
- }
1275
-
1276
- // Keyboard
1277
- function handleKeyDown(e: KeyboardEvent) {
1278
- if (!keyboard) return;
1279
- if (e.key === 'ArrowRight' || e.key === ' ' || e.key === 'Enter') { e.preventDefault(); handleNextSlide(); }
1280
- else if (e.key === 'ArrowLeft' || e.key === 'Backspace') { e.preventDefault(); animateToSlide(currentSlideIndex - 1); }
1281
- else if (e.key === 'Home') animateToSlide(0);
1282
- else if (e.key === 'End') animateToSlide(slides.length - 1);
1283
- else if (e.key === 'p' || e.key === 'P') { isAutoplay = !isAutoplay; if (!isAutoplay) clearAutoplayTimer(); }
1284
- }
1285
-
1286
- function resetMouseIdleTimer() {
1287
- menuVisible = true;
1288
- if (mouseIdleTimer) clearTimeout(mouseIdleTimer);
1289
- mouseIdleTimer = setTimeout(() => { menuVisible = false; }, 3000);
1290
- }
1291
-
1292
- // Code highlight helpers
1293
- async function loadCodeHighlights() {
1294
- for (const slide of slides) {
1295
- for (const element of slide.canvas.elements) {
1296
- if (element.type === 'code') {
1297
- const codeEl = element as CodeElement;
1298
- const key = `${codeEl.id}-${codeEl.code}-${codeEl.language}-${codeEl.showLineNumbers}`;
1299
- if (!codeHighlights.has(key)) {
1300
- const html = await highlightCode(codeEl.code, codeEl.language, codeEl.theme, { showLineNumbers: codeEl.showLineNumbers });
1301
- codeHighlights.set(key, html);
1302
- }
1303
- }
1304
- }
1305
- }
1306
- codeHighlights = new Map(codeHighlights);
1307
- }
1308
-
1309
- function getCodeHighlight(elementId: string): string {
1310
- const slideElement = getElementInSlide(currentSlide, elementId);
1311
- if (!slideElement || slideElement.type !== 'code') return '';
1312
- const codeEl = slideElement as CodeElement;
1313
- const key = `${codeEl.id}-${codeEl.code}-${codeEl.language}-${codeEl.showLineNumbers}`;
1314
- const cached = codeHighlights.get(key);
1315
- if (cached) return cached;
1316
- highlightCode(codeEl.code, codeEl.language, codeEl.theme, { showLineNumbers: codeEl.showLineNumbers }).then(html => {
1317
- codeHighlights.set(key, html);
1318
- codeHighlights = new Map(codeHighlights);
1319
- });
1320
- return '';
1321
- }
1322
-
1323
- // Public API (exposed via bind:this)
1324
- export async function goto(slideIndex: number) { await animateToSlide(slideIndex); }
1325
- export async function next() { handleNextSlide(); }
1326
- export async function prev() { await animateToSlide(currentSlideIndex - 1); }
1327
- export function play() { isAutoplay = true; }
1328
- export function pause() { isAutoplay = false; clearAutoplayTimer(); }
1329
- export function getCurrentSlide() { return currentSlideIndex; }
1330
- export function getTotalSlides() { return slides.length; }
1331
- export function getIsPlaying() { return isAutoplay; }
1332
-
1333
- // Auto-load Google Fonts used by text elements in the project.
1334
- // Generic CSS font families that don't need loading
1335
- const GENERIC_FONTS = new Set([
1336
- 'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy',
1337
- 'system-ui', 'ui-serif', 'ui-sans-serif', 'ui-monospace', 'ui-rounded',
1338
- 'math', 'emoji', 'fangsong', 'inherit', 'initial', 'unset'
1339
- ]);
1340
-
1341
- // Extract individual font names from a CSS font-family string.
1342
- // e.g. '"JetBrains Mono", system-ui, monospace' → ['JetBrains Mono']
1343
- function extractFontNames(fontFamily: string): string[] {
1344
- return fontFamily
1345
- .split(',')
1346
- .map(f => f.trim().replace(/^['"]|['"]$/g, ''))
1347
- .filter(f => f && !GENERIC_FONTS.has(f.toLowerCase()));
1348
- }
1349
-
1350
- // Auto-load fonts used by text/counter elements.
1351
- // Uses fontsource CDN (jsDelivr) which registers the SAME font-family names
1352
- // as the app (e.g. "Plus Jakarta Sans Variable"), unlike Google Fonts which
1353
- // strips the "Variable" suffix.
1354
- function loadProjectFonts(proj: AnimotProject) {
1355
- const fonts = new Set<string>();
1356
- for (const slide of proj.slides) {
1357
- for (const el of slide.canvas.elements) {
1358
- if (el.type === 'text' || el.type === 'counter') {
1359
- const f = (el as any).fontFamily as string | undefined;
1360
- if (f) {
1361
- for (const name of extractFontNames(f)) fonts.add(name);
1362
- }
1363
- }
1364
- }
1365
- }
1366
- if (fonts.size === 0) return;
1367
-
1368
- // Deduplicate against already-injected links to avoid double-loading
1369
- const loaded = new Set<string>();
1370
- document.querySelectorAll<HTMLLinkElement>('link[data-animot-font]').forEach(l => loaded.add(l.dataset.animotFont!));
1371
-
1372
- for (const font of fonts) {
1373
- if (loaded.has(font)) continue;
1374
- const isVariable = /\s+Variable$/i.test(font);
1375
- // Convert font name to fontsource package slug:
1376
- // "Plus Jakarta Sans Variable" → "plus-jakarta-sans"
1377
- // "JetBrains Mono" "jetbrains-mono"
1378
- const baseName = font.replace(/\s*Variable$/i, '');
1379
- const slug = baseName.toLowerCase().replace(/\s+/g, '-');
1380
- const pkg = isVariable
1381
- ? `@fontsource-variable/${slug}`
1382
- : `@fontsource/${slug}`;
1383
- const link = document.createElement('link');
1384
- link.rel = 'stylesheet';
1385
- link.href = `https://cdn.jsdelivr.net/npm/${pkg}/index.css`;
1386
- link.dataset.animotFont = font;
1387
- document.head.appendChild(link);
1388
- }
1389
- }
1390
-
1391
- // Load data
1392
- async function loadProject() {
1393
- loading = true; error = null;
1394
- try {
1395
- if (data) { project = data; }
1396
- else if (src) {
1397
- const res = await fetch(src);
1398
- if (!res.ok) throw new Error(`Failed to load: ${res.status}`);
1399
- project = await res.json();
1400
- } else { throw new Error('Either src or data prop is required'); }
1401
- loadProjectFonts(project!);
1402
- currentSlideIndex = startSlide;
1403
- await new Promise(r => setTimeout(r, 10));
1404
- initAllAnimatedElements();
1405
- await loadCodeHighlights();
1406
- loading = false;
1407
- if (currentSlide) setTimeout(() => { animateKeyframes(currentSlide!); animateMotionPaths(currentSlide!); }, 300);
1408
- // Narration starts via the play-state effect below — not on
1409
- // mount. That way the user's click on Play is the gesture
1410
- // that unlocks audio, and a paused deck stays silent.
1411
- if (autoplay) isAutoplay = true;
1412
- } catch (e: any) { error = e.message; loading = false; }
1413
- }
1414
-
1415
- // ResizeObserver
1416
- let resizeObserver: ResizeObserver;
1417
-
1418
- onMount(() => {
1419
- loadProject();
1420
- resizeObserver = new ResizeObserver(entries => {
1421
- for (const entry of entries) {
1422
- containerWidth = entry.contentRect.width;
1423
- containerHeight = entry.contentRect.height;
1424
- }
1425
- });
1426
- if (containerEl) resizeObserver.observe(containerEl);
1427
- resetMouseIdleTimer();
1428
-
1429
- return () => {
1430
- resizeObserver?.disconnect();
1431
- clearAutoplayTimer();
1432
- clearAllTypewriterAnimations();
1433
- if (mouseIdleTimer) clearTimeout(mouseIdleTimer);
1434
- stopNarration();
1435
- };
1436
- });
1437
-
1438
- // Narration follows the deck's play/pause state. The play button click
1439
- // flips `isAutoplay` true → this effect fires → audio starts. The
1440
- // click itself is the user gesture that unlocks the browser audio
1441
- // context. Pause/stop turns it back off. Each presenter instance
1442
- // scopes its own audio, so multiple decks on a page never overlap.
1443
- $effect(() => {
1444
- if (!project?.settings?.narrationEnabled) return;
1445
- if (isAutoplay) {
1446
- playNarrationForSlide(currentSlideIndex);
1447
- } else {
1448
- pauseNarration();
1449
- }
1450
- });
1451
-
1452
- // Watch for prop changes
1453
- $effect(() => { if (data) { project = data; } });
1454
- </script>
1455
-
1456
- <svelte:window onkeydown={handleKeyDown} />
1457
-
1458
- <div
1459
- class="animot-presenter {className}"
1460
- class:animot-menu-visible={menuVisible}
1461
- bind:this={containerEl}
1462
- onmousemove={resetMouseIdleTimer}
1463
- role="region"
1464
- aria-label="Animot Presentation"
1465
- >
1466
- {#if loading}
1467
- <div class="animot-loading"><div class="animot-spinner"></div></div>
1468
- {:else if error}
1469
- <div class="animot-error">{error}</div>
1470
- {:else if project && currentSlide}
1471
- <div class="animot-canvas-wrapper" style:transform="scale({presentationScale})">
1472
- <div
1473
- class="animot-canvas {transitionClass}"
1474
- class:forward={transitionDirection === 'forward'}
1475
- class:backward={transitionDirection === 'backward'}
1476
- style:width="{canvasWidth}px"
1477
- style:height="{canvasHeight}px"
1478
- style:--transition-duration="{transitionDurationMs}ms"
1479
- style={backgroundStyle}
1480
- >
1481
- {#if currentSlide.canvas.background.particles?.enabled}
1482
- <ParticlesBackground config={currentSlide.canvas.background.particles} width={canvasWidth} height={canvasHeight} />
1483
- {/if}
1484
- {#if currentSlide.canvas.background.confetti?.enabled}
1485
- <ConfettiEffect config={currentSlide.canvas.background.confetti} width={canvasWidth} height={canvasHeight} />
1486
- {/if}
1487
-
1488
- <div
1489
- class="animot-cinema-camera"
1490
- class:active={isCinemaMode}
1491
- style:transform={cinemaCameraTransform}
1492
- style:--cinema-transition-duration="{transitionDurationMs}ms"
1493
- >
1494
- {#each sortedElementIds as elementId}
1495
- {@const element = elementContent.get(elementId)}
1496
- {@const animated = animatedElements.get(elementId)}
1497
- {@const floatCfg = element?.floatingAnimation}
1498
- {@const hasFloat = floatCfg?.enabled}
1499
- {@const floatGroupId = element?.groupId}
1500
- {@const mpConfig = element?.motionPathConfig}
1501
- {@const mpElement = mpConfig ? currentSlide?.canvas.elements.find(el => el.id === mpConfig.motionPathId) as MotionPathElement | undefined : undefined}
1502
- {@const mpProgress = animated?.motionPathProgress?.current ?? 0}
1503
- {@const mpPoint = mpElement && mpConfig ? getPresenterPointOnPath(mpElement.points, mpElement.closed, (mpConfig.startPercent + (mpConfig.endPercent - mpConfig.startPercent) * mpProgress) / 100) : null}
1504
- {@const mpStartPoint = mpElement && mpConfig ? getPresenterPointOnPath(mpElement.points, mpElement.closed, mpConfig.startPercent / 100) : null}
1505
- {@const mpPos = mpPoint && mpStartPoint && animated && mpElement
1506
- ? computeMotionPathPosition(mpPoint, mpStartPoint,
1507
- animated.x.current, animated.y.current,
1508
- animated.width.current, animated.height.current,
1509
- mpElement.closed)
1510
- : null}
1511
- {@const elemX = mpPos ? mpPos.x : (animated?.x.current ?? 0)}
1512
- {@const elemY = mpPos ? mpPos.y : (animated?.y.current ?? 0)}
1513
- {@const mpRotation = mpPoint && mpConfig?.autoRotate
1514
- ? mpPoint.angle + (mpConfig.orientationOffset ?? 0)
1515
- : null}
1516
- {@const parallax = isCinemaMode && element?.depth ? parallaxOffset(currentCamera, element.depth, worldWidth, worldHeight) : { x: 0, y: 0 }}
1517
- {#if element && animated && animated.opacity.current > 0.01 && element.visible !== false && !(element.type === 'motionPath' && !(element as MotionPathElement).showInPresentation)}
1518
- <div
1519
- class="animot-element"
1520
- class:floating={hasFloat}
1521
- style:left="{elemX}px"
1522
- style:top="{elemY}px"
1523
- style:translate="{parallax.x}px {parallax.y}px"
1524
- style:width="{animated.width.current}px"
1525
- style:height="{animated.height.current}px"
1526
- style:opacity={animated.opacity.current}
1527
- style:transform="perspective({animated.perspective.current}px) rotateX({animated.tiltX.current}deg) rotateY({animated.tiltY.current}deg) rotate({mpRotation ?? animated.rotation.current}deg) skewX({animated.skewX.current}deg) skewY({animated.skewY.current}deg)"
1528
- style:transform-origin={element.tiltOrigin ?? 'center'}
1529
- style:backface-visibility={element.backfaceVisibility ?? 'visible'}
1530
- style:z-index={element.zIndex}
1531
- style:--float-amp="{hasFloat ? computeFloatAmp(floatCfg, floatGroupId || elementId) : 10}px"
1532
- style:--float-speed="{hasFloat ? computeFloatSpeed(floatCfg, floatGroupId || elementId) : 3}s"
1533
- style:--float-delay="{hashFraction(floatGroupId || elementId, 3) * 2}s"
1534
- style:animation-name={hasFloat ? getFloatAnimName(floatCfg!.direction, floatGroupId || elementId) : 'none'}
1535
- style:filter={(() => { const parts: string[] = []; const b = animated.blur.current; const br2 = animated.brightness.current; const c = animated.contrast.current; const s = animated.saturate.current; const g = animated.grayscale.current; if (b) parts.push(`blur(${b}px)`); if (br2 !== 100) parts.push(`brightness(${br2}%)`); if (c !== 100) parts.push(`contrast(${c}%)`); if (s !== 100) parts.push(`saturate(${s}%)`); if (g) parts.push(`grayscale(${g}%)`); return parts.length ? parts.join(' ') : 'none'; })()}
1536
- use:decorations={{ config: element.decorations, slideDuration: currentSlide?.duration, shape: element.type === 'shape' ? { type: (element as any).shapeType, borderRadius: (element as any).borderRadius } : undefined, key: `${currentSlideIndex}-${JSON.stringify(element.decorations ?? null)}-${element.type === 'shape' ? (element as any).shapeType + ':' + ((element as any).borderRadius ?? 0) : ''}` }}
1537
- >
1538
- {#if element.type === 'code'}
1539
- {@const codeEl = liveProps(element) as CodeElement}
1540
- {@const morphState = codeMorphState.get(codeEl.id)}
1541
- <div class="animot-code-block" class:transparent-bg={codeEl.transparentBackground} style:font-size="{codeEl.fontSize}px" style:font-weight={codeEl.fontWeight || 400} style:padding="{codeEl.padding}px" style:border-radius="{animated.borderRadius.current}px" style:background={codeEl.bgColor ?? '#0d1117'}>
1542
- {#if codeEl.showHeader}
1543
- <div class="animot-code-header" class:macos={codeEl.headerStyle === 'macos'} class:windows={codeEl.headerStyle === 'windows'} style:border-radius="{codeEl.headerRadius ?? animated.borderRadius.current}px {codeEl.headerRadius ?? animated.borderRadius.current}px 0 0">
1544
- {#if codeEl.headerStyle === 'macos'}
1545
- <div class="animot-window-controls">
1546
- <span class="animot-control close"></span>
1547
- <span class="animot-control minimize"></span>
1548
- <span class="animot-control maximize"></span>
1549
- </div>
1550
- {:else if codeEl.headerStyle === 'windows'}
1551
- <div class="animot-window-controls">
1552
- <span class="animot-control win-minimize">
1553
- <svg width="10" height="10" viewBox="0 0 10 10"><path d="M2 5h6" stroke="currentColor" stroke-width="1.2"/></svg>
1554
- </span>
1555
- <span class="animot-control win-maximize">
1556
- <svg width="10" height="10" viewBox="0 0 10 10"><rect x="1.5" y="1.5" width="7" height="7" rx="0.5" fill="none" stroke="currentColor" stroke-width="1.2"/></svg>
1557
- </span>
1558
- <span class="animot-control win-close">
1559
- <svg width="10" height="10" viewBox="0 0 10 10"><path d="M2 2l6 6M8 2l-6 6" stroke="currentColor" stroke-width="1.2"/></svg>
1560
- </span>
1561
- </div>
1562
- {/if}
1563
- <div class="animot-filename-tab" style:border-radius="{codeEl.tabRadius ?? 6}px">
1564
- <svg class="animot-file-icon" width="14" height="14" viewBox="0 0 16 16" fill="none">
1565
- <path d="M4 1h5.5L13 4.5V14a1 1 0 01-1 1H4a1 1 0 01-1-1V2a1 1 0 011-1z" stroke="currentColor" stroke-width="1.2" opacity="0.5"/>
1566
- <path d="M9.5 1v3.5H13" stroke="currentColor" stroke-width="1.2" opacity="0.5"/>
1567
- </svg>
1568
- <span class="animot-filename">{codeEl.filename}</span>
1569
- </div>
1570
- <button class="animot-copy-code-btn" onclick={(e) => { e.stopPropagation(); navigator.clipboard.writeText(codeEl.code); const btn = e.currentTarget as HTMLElement; btn.classList.add('copied'); setTimeout(() => btn.classList.remove('copied'), 1500); }}>
1571
- <span class="animot-copy-label">Copy</span><span class="animot-copied-label">Copied!</span>
1572
- <svg class="animot-copy-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
1573
- <svg class="animot-check-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"/></svg>
1574
- </button>
1575
- </div>
1576
- {:else}
1577
- <button class="animot-copy-code-btn animot-floating" onclick={(e) => { e.stopPropagation(); navigator.clipboard.writeText(codeEl.code); const btn = e.currentTarget as HTMLElement; btn.classList.add('copied'); setTimeout(() => btn.classList.remove('copied'), 1500); }}>
1578
- <span class="animot-copy-label">Copy</span><span class="animot-copied-label">Copied!</span>
1579
- <svg class="animot-copy-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
1580
- <svg class="animot-check-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"/></svg>
1581
- </button>
1582
- {/if}
1583
- <div class="animot-code-content">
1584
- <div class="animot-highlighted-code">
1585
- {#if morphState && morphState.oldCode !== morphState.newCode && morphState.mode !== 'instant'}
1586
- {#key currentSlideIndex}
1587
- <CodeMorph oldCode={morphState?.oldCode ?? ''} newCode={morphState?.newCode ?? ''} language={codeEl.language} theme={codeEl.theme} mode={morphState?.mode ?? 'highlight-changes'} speed={morphState?.speed ?? 50} highlightColor={morphState?.highlightColor ?? '#fef08a'} highlightDuration={codeEl.animation?.highlightDuration || 1000} showLineNumbers={(getElementInSlide(currentSlide, codeEl.id) as CodeElement | undefined)?.showLineNumbers ?? false} />
1588
- {/key}
1589
- {:else}
1590
- {@html getCodeHighlight(codeEl.id)}
1591
- {/if}
1592
- </div>
1593
- </div>
1594
- </div>
1595
- {:else if element.type === 'text'}
1596
- {@const textEl = liveProps(element) as TextElement}
1597
- {@const animFontSize = animated.fontSize?.current ?? textEl.fontSize}
1598
- {@const typewriterState = textTypewriterState.get(element.id)}
1599
- {@const displayText = typewriterState?.isAnimating ? typewriterState.fullText.slice(0, typewriterState.displayedChars) : textEl.content}
1600
- {@const textAnimMode = textEl.animation?.mode ?? 'instant'}
1601
- {@const isActionTextMode = textAnimMode === 'fade-letters' || textAnimMode === 'bounce-in' || textAnimMode === 'handwriting'}
1602
- <div
1603
- class="animot-text-element"
1604
- style:font-size="{animFontSize}px"
1605
- style:font-weight={textEl.fontWeight}
1606
- style:font-family="'{textEl.fontFamily}', sans-serif"
1607
- style:font-style={textEl.fontStyle ?? 'normal'}
1608
- style:text-decoration={textEl.textDecoration ?? 'none'}
1609
- style:color={(textEl.gradient || textEl.backgroundImage) ? 'transparent' : (textEl.hollow && textEl.textStroke?.enabled ? 'transparent' : textEl.color)}
1610
- style:background-color={(textEl.gradient || textEl.backgroundImage) ? 'transparent' : textEl.backgroundColor}
1611
- style:background-image={textEl.gradient ? gradientShapeToCss(textEl.gradient) : textEl.backgroundImage ? `url(${textEl.backgroundImage})` : 'none'}
1612
- style:background-size={textEl.backgroundImage && !textEl.gradient ? `${textEl.backgroundScale ?? 100}%` : 'cover'}
1613
- style:background-position={textEl.backgroundImage && !textEl.gradient ? `${textEl.backgroundPositionX ?? 50}% ${textEl.backgroundPositionY ?? 50}%` : 'center'}
1614
- style:-webkit-background-clip={(textEl.gradient || textEl.backgroundImage) ? 'text' : 'border-box'}
1615
- style:background-clip={(textEl.gradient || textEl.backgroundImage) ? 'text' : 'border-box'}
1616
- style:padding="{textEl.padding}px"
1617
- style:border-radius="{textEl.borderRadius}px"
1618
- style:text-align={textEl.textAlign}
1619
- style:justify-content={textEl.textAlign === 'center' ? 'center' : textEl.textAlign === 'right' ? 'flex-end' : 'flex-start'}
1620
- style:-webkit-text-stroke={textEl.textStroke?.enabled ? `${textEl.textStroke.width}px ${textEl.textStroke.color}` : '0'}
1621
- style:text-shadow={textEl.textShadow?.enabled ? `${textEl.textShadow.offsetX}px ${textEl.textShadow.offsetY}px ${textEl.textShadow.blur}px ${textEl.textShadow.color}` : 'none'}
1622
- use:textAnimate={{ enabled: isActionTextMode, mode: textAnimMode, content: textEl.content, duration: textEl.animation?.duration ?? 1500, stagger: textEl.animation?.stagger, loop: textEl.animation?.loop ?? false, color: textEl.color, fontSize: animFontSize, fontFamily: textEl.fontFamily, fontWeight: textEl.fontWeight, fontStyle: textEl.fontStyle, textAlign: textEl.textAlign, slideDuration: currentSlide?.duration, key: `${textAnimMode}-${textEl.content}-${textEl.animation?.duration}-${textEl.animation?.stagger}-${textEl.animation?.loop}-${currentSlideIndex}` }}
1623
- >
1624
- {#if !isActionTextMode}{displayText}{#if typewriterState?.isAnimating}<span class="animot-typewriter-cursor">|</span>{/if}{/if}
1625
- </div>
1626
- {:else if element.type === 'arrow'}
1627
- {@const arrowEl = liveProps(element) as ArrowElement}
1628
- {@const cp = arrowEl.controlPoints || []}
1629
- {@const pathD = cp.length === 0 ? `M ${arrowEl.startPoint.x} ${arrowEl.startPoint.y} L ${arrowEl.endPoint.x} ${arrowEl.endPoint.y}` : cp.length === 1 ? `M ${arrowEl.startPoint.x} ${arrowEl.startPoint.y} Q ${cp[0].x} ${cp[0].y} ${arrowEl.endPoint.x} ${arrowEl.endPoint.y}` : cp.length === 2 ? `M ${arrowEl.startPoint.x} ${arrowEl.startPoint.y} C ${cp[0].x} ${cp[0].y} ${cp[1].x} ${cp[1].y} ${arrowEl.endPoint.x} ${arrowEl.endPoint.y}` : buildCatmullRomPath(arrowEl.startPoint, cp, arrowEl.endPoint)}
1630
- {@const lastCp = cp.length > 0 ? cp[cp.length - 1] : arrowEl.startPoint}
1631
- {@const endAngle = Math.atan2(arrowEl.endPoint.y - lastCp.y, arrowEl.endPoint.x - lastCp.x)}
1632
- {@const headAngle = Math.PI / 6}
1633
- {@const headSize = arrowEl.headSize}
1634
- {@const arrowHeadPath = `M ${arrowEl.endPoint.x - headSize * Math.cos(endAngle - headAngle)} ${arrowEl.endPoint.y - headSize * Math.sin(endAngle - headAngle)} L ${arrowEl.endPoint.x} ${arrowEl.endPoint.y} L ${arrowEl.endPoint.x - headSize * Math.cos(endAngle + headAngle)} ${arrowEl.endPoint.y - headSize * Math.sin(endAngle + headAngle)}`}
1635
- {@const arrowAnimMode = arrowEl.animation?.mode ?? 'none'}
1636
- {@const arrowAnimDuration = arrowEl.animation?.duration ?? 500}
1637
- {@const isStyledArrow = arrowEl.style !== 'solid'}
1638
- {@const isDrawType = arrowAnimMode === 'draw' || arrowAnimMode === 'undraw' || arrowAnimMode === 'draw-undraw' || arrowAnimMode === 'flow'}
1639
- {@const baseDashArray = arrowEl.style === 'dashed' ? '10,5' : arrowEl.style === 'dotted' ? '2,5' : 'none'}
1640
- <svg class="animot-arrow-element" class:arrow-animate-grow={arrowAnimMode === 'grow'} viewBox="0 0 {arrowEl.size.width} {arrowEl.size.height}" preserveAspectRatio="none" style="--arrow-anim-duration: {arrowAnimDuration}ms;" use:arrowClipDraw={{ enabled: isDrawType, mode: arrowAnimMode, duration: arrowAnimDuration, startX: arrowEl.startPoint.x, startY: arrowEl.startPoint.y, endX: arrowEl.endPoint.x, endY: arrowEl.endPoint.y, loop: !!arrowEl.animation?.loop, reverse: arrowEl.animation?.direction === 'reverse', slideDuration: currentSlide?.duration, key: `${pathD}-${arrowAnimMode}-${arrowAnimDuration}-${arrowEl.animation?.loop}-${arrowEl.animation?.direction}-${currentSlideIndex}` }}>
1641
- <path class="arrow-path" d={pathD} fill="none" stroke={arrowEl.color} stroke-width={arrowEl.strokeWidth} stroke-dasharray={baseDashArray} stroke-linecap="round" stroke-linejoin="round" />
1642
- {#if arrowEl.showHead !== false}
1643
- <path class="arrow-head" class:arrow-head-styled-draw={isDrawType && isStyledArrow} class:arrow-head-undraw={arrowAnimMode === 'undraw'} class:arrow-head-draw-undraw={arrowAnimMode === 'draw-undraw'} d={arrowHeadPath} fill="none" stroke={arrowEl.color} stroke-width={arrowEl.strokeWidth} stroke-linecap="round" stroke-linejoin="round" style={isDrawType && isStyledArrow ? `--arrow-anim-duration: ${arrowAnimDuration}ms;` : ''} />
1644
- {/if}
1645
- {#if arrowEl.flowMarkers?.enabled}
1646
- <FlowMarkers config={arrowEl.flowMarkers} start={arrowEl.startPoint} end={arrowEl.endPoint} controlPoints={cp} slideDuration={currentSlide?.duration} />
1647
- {/if}
1648
- </svg>
1649
- {:else if element.type === 'image'}
1650
- {@const imgEl = liveProps(element) as ImageElement}
1651
- {@const clipPath = imgEl.clipMask?.enabled ? (imgEl.clipMask.shapeType === 'circle' ? 'circle(50% at 50% 50%)' : imgEl.clipMask.shapeType === 'ellipse' ? 'ellipse(50% 50% at 50% 50%)' : imgEl.clipMask.shapeType === 'triangle' ? 'polygon(50% 0%, 0% 100%, 100% 100%)' : imgEl.clipMask.shapeType === 'star' ? 'polygon(50% 0%, 61% 35%, 98% 35%, 68% 57%, 79% 91%, 50% 70%, 21% 91%, 32% 57%, 2% 35%, 39% 35%)' : imgEl.clipMask.shapeType === 'hexagon' ? 'polygon(25% 0%, 75% 0%, 100% 50%, 75% 100%, 25% 100%, 0% 50%)' : (imgEl.clipMask.borderRadius ?? 0) > 0 ? `inset(0 round ${imgEl.clipMask.borderRadius}px)` : 'none') : 'none'}
1652
- <img class="animot-image-element" src={imgEl.src} alt="" style:object-fit={imgEl.objectFit} style:border-radius="{imgEl.clipMask?.enabled ? 0 : imgEl.borderRadius}px" style:clip-path={clipPath} style:background-color={imgEl.backgroundColor ?? 'transparent'} />
1653
- {:else if element.type === 'video'}
1654
- {@const videoEl = liveProps(element) as VideoElement}
1655
- {@const videoEmbed = parseEmbedUrl(videoEl.src)}
1656
- {#if videoEmbed}
1657
- <div class="animot-video-element animot-embed-wrap" style:border-radius="{videoEl.borderRadius}px" style:opacity={videoEl.opacity}>
1658
- <EmbedPlayer element={videoEl} controlsOverlay={!!videoEl.showControls} />
1659
- </div>
1660
- {:else}
1661
- <video class="animot-video-element" src={videoEl.src} poster={videoEl.posterImage} autoplay={videoEl.autoplay} loop={videoEl.loop} muted={videoEl.muted} controls={!!videoEl.showControls} playsinline preload="auto" style:object-fit={videoEl.objectFit} style:border-radius="{videoEl.borderRadius}px" style:opacity={videoEl.opacity}></video>
1662
- {/if}
1663
- {:else if element.type === 'shape'}
1664
- {@const shapeEl = liveProps(element) as ShapeElement}
1665
- {@const animFill = animated.fillColor?.current ?? shapeEl.fillColor}
1666
- {@const animStroke = animated.strokeColor?.current ?? shapeEl.strokeColor}
1667
- {@const animStrokeWidth = animated.strokeWidth?.current ?? shapeEl.strokeWidth}
1668
- {@const mState = shapeMorphStates.get(element.id)}
1669
- {@const morphProgress = animated.shapeMorph?.current ?? 1}
1670
- {@const effectiveShapeType = mState ? (morphProgress >= 1 ? mState.toType : (morphProgress <= 0 ? mState.fromType : null)) : shapeEl.shapeType}
1671
- {@const isMorphing = mState && morphProgress > 0 && morphProgress < 1}
1672
- <svg class="animot-shape-element" viewBox="0 0 {Math.max(0, animated.width.current)} {Math.max(0, animated.height.current)}" fill-opacity={shapeEl.fillOpacity ?? 1} stroke-opacity={shapeEl.strokeOpacity ?? 1} style:filter={shapeEl.boxShadow?.enabled ? `drop-shadow(${shapeEl.boxShadow.offsetX}px ${shapeEl.boxShadow.offsetY}px ${shapeEl.boxShadow.blur}px ${shapeEl.boxShadow.color})` : 'none'}>
1673
- {#if isMorphing}
1674
- {@const w = Math.max(0, animated.width.current)}
1675
- {@const h = Math.max(0, animated.height.current)}
1676
- {@const sw = animStrokeWidth}
1677
- <g style:opacity={1 - morphProgress}>{@html renderShape(mState!.fromType, w, h, animated.borderRadius.current, animFill, animStroke, sw, shapeEl.strokeStyle, shapeEl.strokeDashGap)}</g>
1678
- <g style:opacity={morphProgress}>{@html renderShape(mState!.toType, w, h, animated.borderRadius.current, animFill, animStroke, sw, shapeEl.strokeStyle, shapeEl.strokeDashGap)}</g>
1679
- {:else}
1680
- {@html renderShape(effectiveShapeType ?? shapeEl.shapeType, Math.max(0, animated.width.current), Math.max(0, animated.height.current), animated.borderRadius.current, animFill, animStroke, animStrokeWidth, shapeEl.strokeStyle, shapeEl.strokeDashGap)}
1681
- {/if}
1682
- </svg>
1683
- {:else if element.type === 'counter'}
1684
- <CounterRenderer element={element as CounterElement} slideId={currentSlide?.id ?? ''} />
1685
- {:else if element.type === 'chart'}
1686
- <ChartRenderer
1687
- element={element as ChartElement}
1688
- slideId={currentSlide?.id ?? ''}
1689
- previousElement={previousChartContent.get(element.id) ?? null}
1690
- />
1691
- {:else if element.type === 'progress'}
1692
- <ProgressBar
1693
- element={element as ProgressElement}
1694
- isPresenting={true}
1695
- slideId={currentSlide?.id ?? ''}
1696
- previousElement={previousProgressContent.get(element.id) ?? null}
1697
- />
1698
- {:else if element.type === 'container'}
1699
- <Container element={element as ContainerElement} />
1700
- {:else if element.type === 'icon'}
1701
- <IconRenderer element={element as IconElement} />
1702
- {:else if element.type === 'svg'}
1703
- {@const svgEl = element as SvgElement}
1704
- {@const svgParsed = (() => { const m = svgEl.svgContent.trim().match(/^<svg([^>]*)>([\s\S]*)<\/svg>$/i); if (m) { const vb = m[1].match(/viewBox=["']([^"']+)["']/i); return { inner: m[2], viewBox: vb ? vb[1] : null }; } return { inner: svgEl.svgContent, viewBox: null }; })()}
1705
- {@const svgAnimMode = svgEl.animation?.mode ?? 'none'}
1706
- {@const svgAnimDur = svgEl.animation?.duration ?? 800}
1707
- {@const svgAnimLoop = svgEl.animation?.loop ?? false}
1708
- {@const svgAnimReverse = svgEl.animation?.direction === 'reverse'}
1709
- <div
1710
- class="animot-svg-element"
1711
- use:traceSvgPaths={{
1712
- enabled: svgAnimMode !== 'none',
1713
- mode: svgAnimMode as 'none' | 'draw' | 'undraw' | 'draw-undraw' | 'flow',
1714
- duration: svgAnimDur,
1715
- loop: svgAnimLoop,
1716
- reverse: svgAnimReverse,
1717
- key: `${svgEl.id}-${svgAnimMode}-${svgAnimDur}-${currentSlideIndex}`
1718
- }}
1719
- >
1720
- <svg width="100%" height="100%" viewBox={svgEl.viewBox ?? svgParsed.viewBox ?? `0 0 ${svgEl.size.width} ${svgEl.size.height}`} preserveAspectRatio={svgEl.preserveAspectRatio} xmlns="http://www.w3.org/2000/svg">
1721
- <g style={svgEl.color ? `fill:${svgEl.color};stroke:${svgEl.color}` : ''}>
1722
- {@html svgParsed.inner}
1723
- </g>
1724
- </svg>
1725
- </div>
1726
- {:else if element.type === 'motionPath'}
1727
- {@const mpEl = element as MotionPathElement}
1728
- {#if mpEl.showInPresentation}
1729
- <svg width="100%" height="100%" viewBox="0 0 {Math.max(0, animated.width.current)} {Math.max(0, animated.height.current)}" style="position:absolute;top:0;left:0;pointer-events:none;overflow:visible;">
1730
- <path d={buildPresenterPathD(mpEl.points, mpEl.closed)} stroke={mpEl.pathColor} stroke-width={mpEl.pathWidth} fill="none" stroke-dasharray="8 4" />
1731
- </svg>
1732
- {/if}
1733
- {/if}
1734
- </div>
1735
- {/if}
1736
- {/each}
1737
- </div><!-- /animot-cinema-camera -->
1738
- </div>
1739
- </div>
1740
-
1741
- {#if arrows}
1742
- <button class="animot-arrow animot-arrow-left" onclick={() => animateToSlide(currentSlideIndex - 1)} disabled={currentSlideIndex === 0 || isTransitioning} aria-label="Previous slide">
1743
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M15 18l-6-6 6-6"/></svg>
1744
- </button>
1745
- <button class="animot-arrow animot-arrow-right" onclick={() => handleNextSlide()} disabled={(!loop && currentSlideIndex === slides.length - 1) || isTransitioning} aria-label="Next slide">
1746
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18l6-6-6-6"/></svg>
1747
- </button>
1748
- {/if}
1749
-
1750
- {#if controls}
1751
- <div class="animot-controls">
1752
- <button onclick={() => animateToSlide(currentSlideIndex - 1)} disabled={currentSlideIndex === 0 || isTransitioning} aria-label="Previous">
1753
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>
1754
- </button>
1755
- <span class="animot-slide-indicator">{currentSlideIndex + 1} / {slides.length}</span>
1756
- <button onclick={() => handleNextSlide()} disabled={(!loop && currentSlideIndex === slides.length - 1) || isTransitioning} aria-label="Next">
1757
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
1758
- </button>
1759
- <button onclick={() => { isAutoplay = !isAutoplay; if (!isAutoplay) clearAutoplayTimer(); }} class:active={isAutoplay} aria-label={isAutoplay ? 'Pause' : 'Play'}>
1760
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1761
- {#if isAutoplay}<rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/>{:else}<polygon points="5 3 19 12 5 21 5 3"/>{/if}
1762
- </svg>
1763
- </button>
1764
- </div>
1765
- {/if}
1766
-
1767
- {#if showProgress}
1768
- <div class="animot-progress-bar">
1769
- <div class="animot-progress-fill" style:width="{((currentSlideIndex + 1) / slides.length) * 100}%"></div>
1770
- </div>
1771
- {/if}
1772
- {/if}
1773
- </div>
1774
-
1775
- <script module lang="ts">
1776
- function roundedPolygonPath(pointsStr: string, radius: number): string {
1777
- const pts = pointsStr.split(/\s+/).map(p => { const [x, y] = p.split(',').map(Number); return { x, y }; });
1778
- if (pts.length < 3 || radius <= 0) return 'M' + pts.map(p => `${p.x},${p.y}`).join('L') + 'Z';
1779
- const n = pts.length;
1780
- const parts: string[] = [];
1781
- for (let i = 0; i < n; i++) {
1782
- const prev = pts[(i - 1 + n) % n], curr = pts[i], next = pts[(i + 1) % n];
1783
- const dx1 = prev.x - curr.x, dy1 = prev.y - curr.y;
1784
- const dx2 = next.x - curr.x, dy2 = next.y - curr.y;
1785
- const len1 = Math.sqrt(dx1 * dx1 + dy1 * dy1);
1786
- const len2 = Math.sqrt(dx2 * dx2 + dy2 * dy2);
1787
- const r = Math.min(radius, len1 / 2, len2 / 2);
1788
- const sx = curr.x + (dx1 / len1) * r, sy = curr.y + (dy1 / len1) * r;
1789
- const ex = curr.x + (dx2 / len2) * r, ey = curr.y + (dy2 / len2) * r;
1790
- parts.push(i === 0 ? `M${sx},${sy}` : `L${sx},${sy}`);
1791
- parts.push(`Q${curr.x},${curr.y} ${ex},${ey}`);
1792
- }
1793
- parts.push('Z');
1794
- return parts.join(' ');
1795
- }
1796
-
1797
- function renderShape(type: string, w: number, h: number, br: number, fill: string, stroke: string, sw: number, strokeStyle?: string, strokeDashGap?: number): string {
1798
- const nn = (v: number) => (v > 0 ? v : 0);
1799
- w = nn(w); h = nn(h); br = nn(br); sw = nn(sw);
1800
- let dashAttr = '';
1801
- if (strokeStyle && strokeStyle !== 'solid') {
1802
- const s = sw || 1;
1803
- const gap = strokeDashGap ?? (strokeStyle === 'dashed' ? s * 3 : s * 2);
1804
- const da = strokeStyle === 'dashed' ? `${s * 3},${gap}` : `${s * 0.1},${gap}`;
1805
- const lc = strokeStyle === 'dotted' ? 'round' : 'butt';
1806
- dashAttr = ` stroke-dasharray="${da}" stroke-linecap="${lc}"`;
1807
- }
1808
- const polyOrPath = (pts: string) => {
1809
- if (br > 0) return `<path d="${roundedPolygonPath(pts, br)}" fill="${fill}" stroke="${stroke}" stroke-width="${sw}"${dashAttr}/>`;
1810
- return `<polygon points="${pts}" fill="${fill}" stroke="${stroke}" stroke-width="${sw}"${dashAttr} stroke-linejoin="round"/>`;
1811
- };
1812
- switch (type) {
1813
- case 'rectangle': return `<rect x="${sw/2}" y="${sw/2}" width="${nn(w-sw)}" height="${nn(h-sw)}" rx="${nn(Math.min(br, (w-sw)/2, (h-sw)/2))}" ry="${nn(Math.min(br, (w-sw)/2, (h-sw)/2))}" fill="${fill}" stroke="${stroke}" stroke-width="${sw}"${dashAttr}/>`;
1814
- case 'circle': return `<circle cx="${w/2}" cy="${h/2}" r="${nn(Math.min(w,h)/2-sw/2)}" fill="${fill}" stroke="${stroke}" stroke-width="${sw}"${dashAttr}/>`;
1815
- case 'ellipse': return `<ellipse cx="${w/2}" cy="${h/2}" rx="${nn(w/2-sw/2)}" ry="${nn(h/2-sw/2)}" fill="${fill}" stroke="${stroke}" stroke-width="${sw}"${dashAttr}/>`;
1816
- case 'triangle': return polyOrPath(`${w/2},${sw/2} ${sw/2},${h-sw/2} ${w-sw/2},${h-sw/2}`);
1817
- case 'star': {
1818
- const cx = w/2, cy = h/2, outerR = nn(Math.min(w,h)/2-sw/2), innerR = outerR*0.4;
1819
- const pts = Array.from({length:10},(_,i)=>{const a=(i*Math.PI/5)-Math.PI/2;const r=i%2===0?outerR:innerR;return`${cx+r*Math.cos(a)},${cy+r*Math.sin(a)}`;}).join(' ');
1820
- return polyOrPath(pts);
1821
- }
1822
- case 'hexagon': {
1823
- const cx = w/2, cy = h/2, r = nn(Math.min(w,h)/2-sw/2);
1824
- const pts = Array.from({length:6},(_,i)=>{const a=(i*Math.PI/3)-Math.PI/2;return`${cx+r*Math.cos(a)},${cy+r*Math.sin(a)}`;}).join(' ');
1825
- return polyOrPath(pts);
1826
- }
1827
- default: return '';
1828
- }
1829
- }
1830
- </script>
1831
-
1832
- <style>
1833
- /* Universal reset — mirrors the animot app's global * reset to prevent
1834
- host page defaults (margins on p/h1, padding, box-sizing) from leaking in */
1835
- .animot-presenter :global(*) {
1836
- margin: 0;
1837
- padding: 0;
1838
- box-sizing: border-box;
1839
- }
1840
-
1841
- .animot-presenter {
1842
- position: relative;
1843
- width: 100%;
1844
- height: 100%;
1845
- display: flex;
1846
- align-items: center;
1847
- justify-content: center;
1848
- overflow: hidden;
1849
- background: transparent;
1850
- /* Reset inheritable CSS from host page to prevent style leakage */
1851
- line-height: normal;
1852
- font-size: 16px;
1853
- font-weight: 400;
1854
- font-style: normal;
1855
- letter-spacing: normal;
1856
- word-spacing: normal;
1857
- text-transform: none;
1858
- text-indent: 0;
1859
- text-align: left;
1860
- color: inherit;
1861
- }
1862
-
1863
- .animot-canvas-wrapper {
1864
- display: flex;
1865
- align-items: center;
1866
- justify-content: center;
1867
- }
1868
-
1869
- .animot-canvas {
1870
- position: relative;
1871
- overflow: hidden;
1872
- }
1873
-
1874
- .animot-element {
1875
- position: absolute;
1876
- box-sizing: border-box;
1877
- will-change: transform, opacity, left, top, width, height;
1878
- isolation: isolate;
1879
- }
1880
-
1881
- .animot-element.floating {
1882
- animation-duration: var(--float-speed, 3s);
1883
- animation-timing-function: ease-in-out;
1884
- animation-iteration-count: infinite;
1885
- animation-delay: var(--float-delay, 0s);
1886
- }
1887
-
1888
- /* Code */
1889
- .animot-code-block {
1890
- width: 100%; height: 100%; overflow: hidden;
1891
- display: flex; flex-direction: column; box-shadow: 0 8px 32px rgba(0,0,0,0.4);
1892
- margin: 0; box-sizing: border-box;
1893
- }
1894
- .animot-code-block.transparent-bg { background: transparent !important; box-shadow: none; }
1895
- .animot-code-block.transparent-bg .animot-code-header { background: transparent; border-bottom-color: rgba(255,255,255,0.06); }
1896
- .animot-code-header { display: flex; align-items: center; gap: 8px; padding: 8px 12px; background: rgba(0, 0, 0, 0.2); border-bottom: 1px solid rgba(255, 255, 255, 0.06); flex-shrink: 0; min-height: 40px; }
1897
- .animot-window-controls { display: flex; gap: 8px; align-items: center; flex-shrink: 0; }
1898
- .macos .animot-control { width: 12px; height: 12px; border-radius: 50%; display: block; }
1899
- .macos .animot-control.close { background: #ff5f57; }
1900
- .macos .animot-control.minimize { background: #febc2e; }
1901
- .macos .animot-control.maximize { background: #28c840; }
1902
- .windows .animot-window-controls { order: 99; margin-left: auto; gap: 0; }
1903
- .windows .animot-control { display: flex; align-items: center; justify-content: center; width: 28px; height: 24px; border-radius: 4px; color: rgba(255,255,255,0.45); }
1904
- .animot-filename-tab { display: flex; align-items: center; gap: 6px; background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.08); border-radius: 6px; padding: 4px 10px; max-width: 220px; color: rgba(255,255,255,0.4); }
1905
- .animot-file-icon { flex-shrink: 0; }
1906
- .animot-filename { color: rgba(255,255,255,0.55); font-size: 12px; line-height: 18px; }
1907
- .animot-copy-code-btn { display: flex; align-items: center; gap: 5px; height: 28px; padding: 0 8px; margin-left: auto; background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.1); border-radius: 6px; color: rgba(255,255,255,0.4); cursor: pointer; opacity: 0; transition: opacity 0.2s, background 0.15s, color 0.15s; flex-shrink: 0; font-size: 12px; font-family: inherit; white-space: nowrap; }
1908
- .animot-copy-code-btn:hover { background: rgba(255,255,255,0.12); color: rgba(255,255,255,0.8); }
1909
- .animot-copy-code-btn svg { width: 14px; height: 14px; flex-shrink: 0; }
1910
- .animot-copy-code-btn .animot-check-icon { display: none; }
1911
- .animot-copy-code-btn .animot-copied-label { display: none; }
1912
- .animot-copy-code-btn.copied .animot-copy-icon { display: none; }
1913
- .animot-copy-code-btn.copied .animot-copy-label { display: none; }
1914
- .animot-copy-code-btn.copied .animot-check-icon { display: block; color: #4ade80; }
1915
- .animot-copy-code-btn.copied .animot-copied-label { display: inline; color: #4ade80; }
1916
- .animot-copy-code-btn.animot-floating { position: absolute; top: 8px; right: 8px; z-index: 2; }
1917
- .animot-code-block:hover .animot-copy-code-btn { opacity: 1; }
1918
- .animot-code-content { flex: 1; overflow: hidden; position: relative; }
1919
- .animot-highlighted-code { width: 100%; height: 100%; }
1920
- .animot-code-content :global(pre), .animot-highlighted-code :global(pre) { margin: 0; padding: 16px; background: transparent !important; line-height: 1.6; font-size: inherit; overflow: visible; }
1921
- .animot-highlighted-code :global(code) { font-family: inherit; font-size: inherit; font-weight: inherit; }
1922
- .animot-highlighted-code :global(.line-number) { display: inline-block; width: 2.5em; margin-right: 1em; text-align: right; color: #6e7681; user-select: none; opacity: 0.6; }
1923
-
1924
- /* Text */
1925
- .animot-text-element { width: 100%; height: 100%; display: flex; align-items: center; white-space: pre-wrap; word-wrap: break-word; }
1926
- .animot-typewriter-cursor { animation: animot-blink 0.7s infinite; font-weight: 100; }
1927
- @keyframes animot-blink { 0%, 50% { opacity: 1; } 51%, 100% { opacity: 0; } }
1928
-
1929
- /* Arrow */
1930
- .animot-arrow-element { width: 100%; height: 100%; }
1931
- .arrow-animate-draw .arrow-path { stroke-dashoffset: var(--path-len, 1000); animation: animot-arrow-draw var(--arrow-anim-duration, 500ms) ease-out forwards; }
1932
- .arrow-animate-undraw .arrow-path { stroke-dashoffset: 0; animation: animot-arrow-undraw var(--arrow-anim-duration, 500ms) ease-out forwards; }
1933
- .arrow-animate-draw-undraw .arrow-path { stroke-dashoffset: var(--path-len, 1000); animation: animot-arrow-draw-undraw var(--arrow-anim-duration, 500ms) ease-out forwards; }
1934
- .arrow-head-styled-draw { opacity: 0; animation: animot-arrow-head-appear var(--arrow-anim-duration, 500ms) ease-out forwards; animation-delay: calc(var(--arrow-anim-duration, 500ms) * 0.7); }
1935
- .arrow-animate-draw .arrow-head { opacity: 0; animation: animot-arrow-head-appear var(--arrow-anim-duration, 500ms) ease-out forwards; animation-delay: calc(var(--arrow-anim-duration, 500ms) * 0.7); }
1936
- .arrow-animate-undraw .arrow-head, .arrow-head-undraw { opacity: 1; animation: animot-arrow-head-disappear var(--arrow-anim-duration, 500ms) ease-out forwards; }
1937
- .arrow-animate-draw-undraw .arrow-head, .arrow-head-draw-undraw { opacity: 0; animation: animot-arrow-head-draw-undraw var(--arrow-anim-duration, 500ms) ease-out forwards; }
1938
- .arrow-animate-grow { transform-origin: left center; animation: animot-arrow-grow var(--arrow-anim-duration, 500ms) ease-out forwards; }
1939
- /* loop: replay continuously while slide is shown */
1940
- .arrow-anim-loop .arrow-path, .arrow-anim-loop .arrow-head { animation-iteration-count: infinite !important; }
1941
- /* reverse: flip start end */
1942
- .arrow-anim-reverse .arrow-path, .arrow-anim-reverse .arrow-head { animation-direction: reverse !important; }
1943
- @keyframes animot-arrow-draw { to { stroke-dashoffset: 0; } }
1944
- @keyframes animot-arrow-undraw { from { stroke-dashoffset: 0; } to { stroke-dashoffset: var(--path-len, 1000); } }
1945
- @keyframes animot-arrow-draw-undraw { 0% { stroke-dashoffset: var(--path-len, 1000); } 50% { stroke-dashoffset: 0; } 100% { stroke-dashoffset: var(--path-len, 1000); } }
1946
- @keyframes animot-arrow-head-appear { from { opacity: 0; } to { opacity: 1; } }
1947
- @keyframes animot-arrow-head-disappear { 0% { opacity: 1; } 70% { opacity: 1; } 100% { opacity: 0; } }
1948
- @keyframes animot-arrow-head-draw-undraw { 0% { opacity: 0; } 35% { opacity: 1; } 65% { opacity: 1; } 100% { opacity: 0; } }
1949
- @keyframes animot-arrow-grow { from { transform: scaleX(0); opacity: 0; } to { transform: scaleX(1); opacity: 1; } }
1950
-
1951
- /* Image */
1952
- .animot-image-element { width: 100%; height: 100%; display: block; }
1953
- .animot-video-element { width: 100%; height: 100%; display: block; background: #000; }
1954
- .animot-video-element.animot-embed-frame { border: 0; background: transparent; }
1955
- .animot-video-element.animot-embed-wrap { overflow: hidden; background: #000; }
1956
- .animot-cinema-camera { position: absolute; inset: 0; transform-origin: 0 0; }
1957
- .animot-cinema-camera.active { transition: transform var(--cinema-transition-duration, 800ms) cubic-bezier(0.65, 0, 0.35, 1); }
1958
- .animot-cinema-camera.active .animot-element { transition: translate var(--cinema-transition-duration, 800ms) cubic-bezier(0.65, 0, 0.35, 1); }
1959
-
1960
- /* Shape */
1961
- .animot-shape-element { width: 100%; height: 100%; display: block; overflow: visible; }
1962
-
1963
- /* Transitions */
1964
- .animot-canvas { --transition-duration: 500ms; transition: transform calc(var(--transition-duration) * 0.4) ease, opacity calc(var(--transition-duration) * 0.4) ease; }
1965
- .animot-canvas.transition-fade-out { opacity: 0; }
1966
- .animot-canvas.transition-fade-in { animation: animot-fadeIn calc(var(--transition-duration) * 0.6) ease forwards; }
1967
- .animot-canvas.transition-slide-left-out.forward { transform: translateX(-100%); opacity: 0; }
1968
- .animot-canvas.transition-slide-left-in.forward { animation: animot-slideInFromRight calc(var(--transition-duration) * 0.6) ease forwards; }
1969
- .animot-canvas.transition-slide-left-out.backward { transform: translateX(100%); opacity: 0; }
1970
- .animot-canvas.transition-slide-left-in.backward { animation: animot-slideInFromLeft calc(var(--transition-duration) * 0.6) ease forwards; }
1971
- .animot-canvas.transition-slide-right-out.forward { transform: translateX(100%); opacity: 0; }
1972
- .animot-canvas.transition-slide-right-in.forward { animation: animot-slideInFromLeft calc(var(--transition-duration) * 0.6) ease forwards; }
1973
- .animot-canvas.transition-slide-up-out { transform: translateY(-100%); opacity: 0; }
1974
- .animot-canvas.transition-slide-up-in { animation: animot-slideInFromBottom calc(var(--transition-duration) * 0.6) ease forwards; }
1975
- .animot-canvas.transition-slide-down-out { transform: translateY(100%); opacity: 0; }
1976
- .animot-canvas.transition-slide-down-in { animation: animot-slideInFromTop calc(var(--transition-duration) * 0.6) ease forwards; }
1977
- .animot-canvas.transition-zoom-in-out { transform: scale(0.5); opacity: 0; }
1978
- .animot-canvas.transition-zoom-in-in { animation: animot-zoomIn calc(var(--transition-duration) * 0.6) ease forwards; }
1979
- .animot-canvas.transition-zoom-out-out { transform: scale(1.5); opacity: 0; }
1980
- .animot-canvas.transition-zoom-out-in { animation: animot-zoomOut calc(var(--transition-duration) * 0.6) ease forwards; }
1981
- .animot-canvas.transition-flip-out { transform: perspective(1000px) rotateY(90deg); opacity: 0; }
1982
- .animot-canvas.transition-flip-in { animation: animot-flipIn calc(var(--transition-duration) * 0.6) ease forwards; }
1983
-
1984
- @keyframes animot-fadeIn { from { opacity: 0; } to { opacity: 1; } }
1985
- @keyframes animot-slideInFromRight { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
1986
- @keyframes animot-slideInFromLeft { from { transform: translateX(-100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
1987
- @keyframes animot-slideInFromBottom { from { transform: translateY(100%); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
1988
- @keyframes animot-slideInFromTop { from { transform: translateY(-100%); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
1989
- @keyframes animot-zoomIn { from { transform: scale(0.5); opacity: 0; } to { transform: scale(1); opacity: 1; } }
1990
- @keyframes animot-zoomOut { from { transform: scale(1.5); opacity: 0; } to { transform: scale(1); opacity: 1; } }
1991
- @keyframes animot-flipIn { from { transform: perspective(1000px) rotateY(-90deg); opacity: 0; } to { transform: perspective(1000px) rotateY(0); opacity: 1; } }
1992
-
1993
- /* Flip-X transition */
1994
- .animot-canvas.transition-flip-x-out { transform: perspective(1000px) rotateX(90deg); opacity: 0; }
1995
- .animot-canvas.transition-flip-x-in { animation: animot-flipXIn calc(var(--transition-duration) * 0.6) ease forwards; }
1996
- @keyframes animot-flipXIn { from { transform: perspective(1000px) rotateX(-90deg); opacity: 0; } to { transform: perspective(1000px) rotateX(0); opacity: 1; } }
1997
-
1998
- /* Flip-Y transition */
1999
- .animot-canvas.transition-flip-y-out { transform: perspective(1000px) rotateY(90deg); opacity: 0; }
2000
- .animot-canvas.transition-flip-y-in { animation: animot-flipYIn calc(var(--transition-duration) * 0.6) ease forwards; }
2001
- @keyframes animot-flipYIn { from { transform: perspective(1000px) rotateY(-90deg); opacity: 0; } to { transform: perspective(1000px) rotateY(0); opacity: 1; } }
2002
-
2003
- /* SVG element */
2004
- .animot-svg-element { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; }
2005
- .animot-svg-element :global(svg) { width: 100%; height: 100%; }
2006
-
2007
- /* Controls */
2008
- .animot-controls {
2009
- position: absolute; bottom: 12px; left: 50%; transform: translateX(-50%);
2010
- display: flex; align-items: center; gap: 8px; padding: 8px 16px;
2011
- background: rgba(0,0,0,0.7); backdrop-filter: blur(10px); border-radius: 10px;
2012
- opacity: 0; transition: opacity 0.3s ease 0.15s; z-index: 100;
2013
- }
2014
- .animot-presenter:hover .animot-controls, .animot-menu-visible .animot-controls { opacity: 1; transition-delay: 0s; }
2015
- .animot-controls button {
2016
- display: flex; align-items: center; justify-content: center;
2017
- width: 32px; height: 32px; border-radius: 6px; border: none; cursor: pointer;
2018
- background: rgba(255,255,255,0.1); color: white; transition: background 0.2s;
2019
- }
2020
- .animot-controls button:hover:not(:disabled) { background: rgba(255,255,255,0.2); }
2021
- .animot-controls button:disabled { opacity: 0.3; cursor: not-allowed; }
2022
- .animot-controls button.active { background: rgba(99,102,241,0.6); }
2023
- .animot-controls button svg { width: 16px; height: 16px; }
2024
- .animot-slide-indicator { font-size: 12px; color: white; min-width: 50px; text-align: center; font-family: system-ui, sans-serif; }
2025
-
2026
- /* Arrows */
2027
- .animot-arrow {
2028
- position: absolute; top: 50%; transform: translateY(-50%);
2029
- width: 40px; height: 40px; border-radius: 50%; border: none; cursor: pointer;
2030
- background: rgba(0,0,0,0.5); color: white; display: flex; align-items: center; justify-content: center;
2031
- opacity: 0; transition: opacity 0.3s 0.15s; z-index: 100;
2032
- /* Extra padding extends the hover hit area beyond the visible button */
2033
- padding: 0; margin: 0;
2034
- }
2035
- .animot-presenter:hover .animot-arrow { opacity: 1; transition-delay: 0s; }
2036
- .animot-presenter:hover .animot-arrow:disabled { opacity: 0.3; cursor: not-allowed; }
2037
- .animot-arrow:hover:not(:disabled) { background: rgba(0,0,0,0.7); }
2038
- .animot-arrow svg { width: 20px; height: 20px; }
2039
- .animot-arrow-left { left: 8px; }
2040
- .animot-arrow-right { right: 8px; }
2041
-
2042
- /* Progress bar */
2043
- .animot-progress-bar {
2044
- position: absolute; bottom: 0; left: 0; right: 0; height: 3px;
2045
- background: rgba(255,255,255,0.1); z-index: 100;
2046
- opacity: 0; transition: opacity 0.3s 0.15s;
2047
- }
2048
- .animot-presenter:hover .animot-progress-bar { opacity: 1; transition-delay: 0s; }
2049
- .animot-progress-fill { height: 100%; background: linear-gradient(135deg, #7c3aed, #ec4899); transition: width 0.6s ease; }
2050
-
2051
- /* Loading / Error */
2052
- .animot-loading { display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; }
2053
- .animot-spinner { width: 32px; height: 32px; border: 3px solid rgba(255,255,255,0.2); border-top-color: #7c3aed; border-radius: 50%; animation: animot-spin 0.8s linear infinite; }
2054
- @keyframes animot-spin { to { transform: rotate(360deg); } }
2055
- .animot-error { color: #ef4444; padding: 20px; text-align: center; font-family: system-ui, sans-serif; }
2056
- </style>
1
+ <script lang="ts">
2
+ import { onMount, onDestroy } from 'svelte';
3
+ import { tween } from '@animotion/motion';
4
+ import { highlightCode } from './highlight/highlighter';
5
+ import CodeMorph from './highlight/CodeMorph.svelte';
6
+ import ParticlesBackground from './effects/ParticlesBackground.svelte';
7
+ import ConfettiEffect from './effects/ConfettiEffect.svelte';
8
+ import CounterRenderer from './renderers/CounterRenderer.svelte';
9
+ import ChartRenderer from './renderers/ChartRenderer.svelte';
10
+ import ProgressBar from './renderers/ProgressBar.svelte';
11
+ import Container from './renderers/Container.svelte';
12
+ import IconRenderer from './renderers/IconRenderer.svelte';
13
+ import FlowMarkers from './FlowMarkers.svelte';
14
+ import { traceSvgPaths } from './utils/trace-svg-paths';
15
+ import { arrowClipDraw } from './utils/arrow-clip-draw';
16
+ import { textAnimate } from './utils/text-animate';
17
+ import { decorations } from './utils/decorations';
18
+ import { cameraTransform, defaultCamera, parallaxOffset } from './utils/camera';
19
+ import { parseEmbedUrl } from './utils/video-embed';
20
+ import EmbedPlayer from './EmbedPlayer.svelte';
21
+ import { easeInOutCubic, getEasingFn, getBackgroundStyle, gradientShapeToCss, hashFraction, getFloatAnimName, getIdleAnimName, computeFloatAmp, computeFloatSpeed, entranceRuntimeKeyframe, exitRuntimeKeyframe, emphasisKeyframeName } from './engine/utils';
22
+ import type {
23
+ AnimotProject, AnimotPresenterProps, CanvasElement, CodeElement, TextElement,
24
+ ArrowElement, ImageElement, VideoElement, ShapeElement, CounterElement, ChartElement, IconElement,
25
+ SvgElement, MotionPathElement, ProgressElement, ContainerElement, PathPoint,
26
+ Slide, CodeAnimationMode, AnimatableProperty
27
+ } from './types';
28
+ import './styles/presenter.css';
29
+
30
+ type TweenValue = ReturnType<typeof tween<number>>;
31
+
32
+ // Svelte's underlying Tween.set() leaks: when retargeted before completion,
33
+ // the previous rAF task is aborted but its promise is NEVER fulfilled
34
+ // (see svelte/src/internal/client/loop.js — abort() only deletes the task,
35
+ // doesn't call fulfill). The presenter calls .to() on 15+ tween properties
36
+ // per element per slide transition, so a deck running in a loop accumulates
37
+ // hundreds of thousands of orphaned promises plus the async-function
38
+ // Contexts of any awaiter that's stuck on them. wrapTween() patches each
39
+ // tween instance so:
40
+ // 1) .to() on a value the tween is already at is a no-op (Promise.resolve)
41
+ // 2) Each .to() resolves the previous wrapper promise immediately on
42
+ // retarget, so any await chain unwinds and releases its Context even
43
+ // if the underlying Svelte promise is left dangling.
44
+ function wrapTween<TV extends { current: unknown; to: (v: never, o?: unknown) => Promise<void> }>(tv: TV): TV {
45
+ const origTo = tv.to.bind(tv);
46
+ let prevResolve: (() => void) | null = null;
47
+ (tv as unknown as { to: (v: unknown, o?: unknown) => Promise<void> }).to = (value, options) => {
48
+ if (tv.current === value) {
49
+ if (prevResolve) { const r = prevResolve; prevResolve = null; r(); }
50
+ return Promise.resolve();
51
+ }
52
+ if (prevResolve) { const r = prevResolve; prevResolve = null; r(); }
53
+ const inner = origTo(value as never, options);
54
+ return new Promise<void>((resolve) => {
55
+ prevResolve = resolve;
56
+ const done = () => {
57
+ if (prevResolve === resolve) { prevResolve = null; resolve(); }
58
+ };
59
+ inner.then(done, done);
60
+ });
61
+ };
62
+ return tv;
63
+ }
64
+ function mkTween<T>(value: T, options?: Parameters<typeof tween<T>>[1]): ReturnType<typeof tween<T>> {
65
+ return wrapTween(tween(value, options)) as ReturnType<typeof tween<T>>;
66
+ }
67
+
68
+ interface AnimatedElement {
69
+ x: TweenValue; y: TweenValue; width: TweenValue; height: TweenValue;
70
+ rotation: TweenValue; skewX: TweenValue; skewY: TweenValue;
71
+ tiltX: TweenValue; tiltY: TweenValue; perspective: TweenValue;
72
+ opacity: TweenValue; borderRadius: TweenValue;
73
+ fontSize: TweenValue | null;
74
+ fillColor: ReturnType<typeof tween<string>> | null;
75
+ strokeColor: ReturnType<typeof tween<string>> | null;
76
+ strokeWidth: TweenValue | null;
77
+ shapeMorph: TweenValue | null;
78
+ motionPathProgress: TweenValue | null;
79
+ blur: TweenValue;
80
+ brightness: TweenValue;
81
+ contrast: TweenValue;
82
+ saturate: TweenValue;
83
+ grayscale: TweenValue;
84
+ }
85
+
86
+ interface ShapeMorphState { fromType: string; toType: string; }
87
+
88
+ // Race a promise against an AbortSignal so awaits unwind the instant a
89
+ // loop is cancelled — otherwise tween.to() / setTimeout promises keep
90
+ // pending and pin their async-function Context to the heap. Long-running
91
+ // loops without this leak millions of closure contexts (see commit notes).
92
+ function abortable<T>(p: Promise<T>, signal: AbortSignal): Promise<T> {
93
+ if (signal.aborted) return Promise.reject(new DOMException('aborted', 'AbortError'));
94
+ return new Promise<T>((resolve, reject) => {
95
+ const onAbort = () => reject(new DOMException('aborted', 'AbortError'));
96
+ signal.addEventListener('abort', onAbort, { once: true });
97
+ p.then(
98
+ (v) => { signal.removeEventListener('abort', onAbort); resolve(v); },
99
+ (e) => { signal.removeEventListener('abort', onAbort); reject(e); }
100
+ );
101
+ });
102
+ }
103
+ function abortableSleep(ms: number, signal: AbortSignal): Promise<void> {
104
+ if (signal.aborted) return Promise.reject(new DOMException('aborted', 'AbortError'));
105
+ return new Promise<void>((resolve, reject) => {
106
+ const id = setTimeout(() => { signal.removeEventListener('abort', onAbort); resolve(); }, ms);
107
+ const onAbort = () => { clearTimeout(id); reject(new DOMException('aborted', 'AbortError')); };
108
+ signal.addEventListener('abort', onAbort, { once: true });
109
+ });
110
+ }
111
+ function isAbortError(e: unknown): boolean {
112
+ return !!(e && typeof e === 'object' && (e as { name?: string }).name === 'AbortError');
113
+ }
114
+
115
+ // Active motion path loop cancellation tokens
116
+ let motionPathLoopAbort: AbortController | null = null;
117
+ function cancelMotionPathLoops() {
118
+ if (motionPathLoopAbort) { motionPathLoopAbort.abort(); motionPathLoopAbort = null; }
119
+ }
120
+
121
+ // Keyframe schedule loop. Plain (non-reactive) module-scope state — adding
122
+ // $state for keyframe overrides in 0.5.20 broke reactivity for the existing
123
+ // tween-driven render. This implementation only retargets existing tweens
124
+ // via setTimeout; nothing here is reactive, nothing is read from render.
125
+ let keyframeLoopAbort: AbortController | null = null;
126
+ // Scheduled-but-not-yet-fired keyframe timeouts. We track these so cancel
127
+ // also clears them — otherwise their closures (which capture `signal`,
128
+ // `animated`, etc.) survive until natural firing time and pin a Context.
129
+ let keyframeTimeouts: ReturnType<typeof setTimeout>[] = [];
130
+ // Per-element overrides for keyframe-driven props that aren't tweened
131
+ // (backgroundColor, text color). The schedule writes these at each
132
+ // keyframe boundary; liveProps reads them at render time.
133
+ let keyframeOverrides = $state<Map<string, Record<string, any>>>(new Map());
134
+ function cancelKeyframeLoops() {
135
+ if (keyframeLoopAbort) { keyframeLoopAbort.abort(); keyframeLoopAbort = null; }
136
+ if (keyframeTimeouts.length) {
137
+ for (const id of keyframeTimeouts) clearTimeout(id);
138
+ keyframeTimeouts = [];
139
+ }
140
+ }
141
+ function setKeyframeOverride(elementId: string, prop: string, value: any) {
142
+ const cur = keyframeOverrides.get(elementId) ?? {};
143
+ keyframeOverrides.set(elementId, { ...cur, [prop]: value });
144
+ keyframeOverrides = new Map(keyframeOverrides);
145
+ }
146
+ // Tweens take an easing FUNCTION (t→number), not a CSS keyword. Returning
147
+ // a string here was the cause of "r is not a function" thrown deep in the
148
+ // tween animation loop — `r(t)` crashed because `r` was the literal string
149
+ // passed in. Reuse the engine's `getEasingFn` so keyframe easing matches
150
+ // the slide-morph engine's vocabulary.
151
+ function easingForTween(name: string | undefined): (t: number) => number {
152
+ return getEasingFn(name ?? 'ease-out');
153
+ }
154
+ function animateKeyframes(slide: Slide) {
155
+ cancelKeyframeLoops();
156
+ const hasAnyKeyframes = slide.canvas.elements.some((el) => el.keyframes && el.keyframes.length > 0);
157
+ if (keyframeOverrides.size > 0) keyframeOverrides = new Map();
158
+ if (!hasAnyKeyframes) return;
159
+ keyframeLoopAbort = new AbortController();
160
+ const signal = keyframeLoopAbort.signal;
161
+ for (const element of slide.canvas.elements) {
162
+ if (!element.keyframes || element.keyframes.length === 0) continue;
163
+ const animated = animatedElements.get(element.id) as any;
164
+ if (!animated) continue;
165
+ const sorted = [...element.keyframes].sort((a, b) => a.time - b.time);
166
+ const first = sorted[0];
167
+ // Snap tweens to KF1 instantly so the slide-displayed pose IS the start.
168
+ if (first.position) { animated.x?.to(first.position.x, { duration: 0 }); animated.y?.to(first.position.y, { duration: 0 }); }
169
+ if (first.size) { animated.width?.to(first.size.width, { duration: 0 }); animated.height?.to(first.size.height, { duration: 0 }); }
170
+ if (first.rotation !== undefined) animated.rotation?.to(first.rotation, { duration: 0 });
171
+ if (first.opacity !== undefined) animated.opacity?.to(first.opacity, { duration: 0 });
172
+ if (first.skewX !== undefined) animated.skewX?.to(first.skewX, { duration: 0 });
173
+ if (first.skewY !== undefined) animated.skewY?.to(first.skewY, { duration: 0 });
174
+ if (first.tiltX !== undefined) animated.tiltX?.to(first.tiltX, { duration: 0 });
175
+ if (first.tiltY !== undefined) animated.tiltY?.to(first.tiltY, { duration: 0 });
176
+ if (first.borderRadius !== undefined) animated.borderRadius?.to(first.borderRadius, { duration: 0 });
177
+ if (first.fontSize !== undefined) animated.fontSize?.to(first.fontSize, { duration: 0 });
178
+ if (first.fillColor !== undefined) animated.fillColor?.to(first.fillColor, { duration: 0 });
179
+ if (first.strokeColor !== undefined) animated.strokeColor?.to(first.strokeColor, { duration: 0 });
180
+ if (first.strokeWidth !== undefined) animated.strokeWidth?.to(first.strokeWidth, { duration: 0 });
181
+ if (first.blur !== undefined) animated.blur?.to(first.blur, { duration: 0 });
182
+ if (first.brightness !== undefined) animated.brightness?.to(first.brightness, { duration: 0 });
183
+ if (first.contrast !== undefined) animated.contrast?.to(first.contrast, { duration: 0 });
184
+ if (first.saturate !== undefined) animated.saturate?.to(first.saturate, { duration: 0 });
185
+ if (first.grayscale !== undefined) animated.grayscale?.to(first.grayscale, { duration: 0 });
186
+ if (first.backgroundColor !== undefined) setKeyframeOverride(element.id, 'backgroundColor', first.backgroundColor);
187
+ if (first.color !== undefined) setKeyframeOverride(element.id, 'color', first.color);
188
+ for (const kf of sorted.slice(1)) {
189
+ const tid = setTimeout(() => {
190
+ if (signal.aborted) return;
191
+ const easing = easingForTween(kf.easing);
192
+ const idx = sorted.indexOf(kf);
193
+ const prevTime = idx === 0 ? 0 : sorted[idx - 1].time;
194
+ const span = Math.max(50, kf.time - prevTime);
195
+ if (kf.position) { animated.x?.to(kf.position.x, { duration: span, easing }); animated.y?.to(kf.position.y, { duration: span, easing }); }
196
+ if (kf.size) { animated.width?.to(kf.size.width, { duration: span, easing }); animated.height?.to(kf.size.height, { duration: span, easing }); }
197
+ if (kf.rotation !== undefined) animated.rotation?.to(kf.rotation, { duration: span, easing });
198
+ if (kf.opacity !== undefined) animated.opacity?.to(kf.opacity, { duration: span, easing });
199
+ if (kf.skewX !== undefined) animated.skewX?.to(kf.skewX, { duration: span, easing });
200
+ if (kf.skewY !== undefined) animated.skewY?.to(kf.skewY, { duration: span, easing });
201
+ if (kf.tiltX !== undefined) animated.tiltX?.to(kf.tiltX, { duration: span, easing });
202
+ if (kf.tiltY !== undefined) animated.tiltY?.to(kf.tiltY, { duration: span, easing });
203
+ if (kf.borderRadius !== undefined) animated.borderRadius?.to(kf.borderRadius, { duration: span, easing });
204
+ if (kf.fontSize !== undefined) animated.fontSize?.to(kf.fontSize, { duration: span, easing });
205
+ if (kf.fillColor !== undefined) animated.fillColor?.to(kf.fillColor, { duration: span, easing });
206
+ if (kf.strokeColor !== undefined) animated.strokeColor?.to(kf.strokeColor, { duration: span, easing });
207
+ if (kf.strokeWidth !== undefined) animated.strokeWidth?.to(kf.strokeWidth, { duration: span, easing });
208
+ if (kf.blur !== undefined) animated.blur?.to(kf.blur, { duration: span, easing });
209
+ if (kf.brightness !== undefined) animated.brightness?.to(kf.brightness, { duration: span, easing });
210
+ if (kf.contrast !== undefined) animated.contrast?.to(kf.contrast, { duration: span, easing });
211
+ if (kf.saturate !== undefined) animated.saturate?.to(kf.saturate, { duration: span, easing });
212
+ if (kf.grayscale !== undefined) animated.grayscale?.to(kf.grayscale, { duration: span, easing });
213
+ if (kf.backgroundColor !== undefined) setKeyframeOverride(element.id, 'backgroundColor', kf.backgroundColor);
214
+ if (kf.color !== undefined) setKeyframeOverride(element.id, 'color', kf.color);
215
+ }, Math.max(0, kf.time));
216
+ keyframeTimeouts.push(tid);
217
+ }
218
+ }
219
+ }
220
+
221
+ // --- Motion Path Utilities ---
222
+ function buildPresenterPathD(points: PathPoint[], closed: boolean): string {
223
+ if (points.length < 2) return '';
224
+ let d = `M ${points[0].x} ${points[0].y}`;
225
+ for (let i = 1; i < points.length; i++) {
226
+ const prev = points[i - 1], curr = points[i];
227
+ const cp1x = prev.x + (prev.handleOut?.x ?? 0), cp1y = prev.y + (prev.handleOut?.y ?? 0);
228
+ const cp2x = curr.x + (curr.handleIn?.x ?? 0), cp2y = curr.y + (curr.handleIn?.y ?? 0);
229
+ if (prev.handleOut || curr.handleIn) d += ` C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${curr.x} ${curr.y}`;
230
+ else d += ` L ${curr.x} ${curr.y}`;
231
+ }
232
+ if (closed && points.length > 2) {
233
+ const last = points[points.length - 1], first = points[0];
234
+ const cp1x = last.x + (last.handleOut?.x ?? 0), cp1y = last.y + (last.handleOut?.y ?? 0);
235
+ const cp2x = first.x + (first.handleIn?.x ?? 0), cp2y = first.y + (first.handleIn?.y ?? 0);
236
+ if (last.handleOut || first.handleIn) d += ` C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${first.x} ${first.y}`;
237
+ else d += ` Z`;
238
+ }
239
+ return d;
240
+ }
241
+
242
+ function cubicBez(p0: number, p1: number, p2: number, p3: number, t: number): number {
243
+ const mt = 1 - t;
244
+ return mt * mt * mt * p0 + 3 * mt * mt * t * p1 + 3 * mt * t * t * p2 + t * t * t * p3;
245
+ }
246
+ function cubicBezDeriv(p0: number, p1: number, p2: number, p3: number, t: number): number {
247
+ const mt = 1 - t;
248
+ return 3 * mt * mt * (p1 - p0) + 6 * mt * t * (p2 - p1) + 3 * t * t * (p3 - p2);
249
+ }
250
+
251
+ function getPresenterPointOnPath(points: PathPoint[], closed: boolean, progress: number): { x: number; y: number; angle: number } {
252
+ if (points.length < 2) return { x: points[0]?.x ?? 0, y: points[0]?.y ?? 0, angle: 0 };
253
+ const segs: { p0x: number; p0y: number; p1x: number; p1y: number; p2x: number; p2y: number; p3x: number; p3y: number; length: number }[] = [];
254
+ const segCount = closed ? points.length : points.length - 1;
255
+ for (let i = 0; i < segCount; i++) {
256
+ const curr = points[i], next = points[(i + 1) % points.length];
257
+ const p0x = curr.x, p0y = curr.y;
258
+ const p1x = curr.x + (curr.handleOut?.x ?? 0), p1y = curr.y + (curr.handleOut?.y ?? 0);
259
+ const p2x = next.x + (next.handleIn?.x ?? 0), p2y = next.y + (next.handleIn?.y ?? 0);
260
+ const p3x = next.x, p3y = next.y;
261
+ let length = 0, prevPx = p0x, prevPy = p0y;
262
+ for (let s = 1; s <= 20; s++) {
263
+ const t = s / 20;
264
+ const px = cubicBez(p0x, p1x, p2x, p3x, t), py = cubicBez(p0y, p1y, p2y, p3y, t);
265
+ length += Math.sqrt((px - prevPx) ** 2 + (py - prevPy) ** 2);
266
+ prevPx = px; prevPy = py;
267
+ }
268
+ segs.push({ p0x, p0y, p1x, p1y, p2x, p2y, p3x, p3y, length });
269
+ }
270
+ const totalLength = segs.reduce((sum, s) => sum + s.length, 0);
271
+ const targetLength = progress * totalLength;
272
+ let accum = 0;
273
+ for (const seg of segs) {
274
+ if (accum + seg.length >= targetLength || seg === segs[segs.length - 1]) {
275
+ const t = Math.max(0, Math.min(1, seg.length > 0 ? (targetLength - accum) / seg.length : 0));
276
+ let dx = cubicBezDeriv(seg.p0x, seg.p1x, seg.p2x, seg.p3x, t);
277
+ let dy = cubicBezDeriv(seg.p0y, seg.p1y, seg.p2y, seg.p3y, t);
278
+ // Degenerate tangent at endpoints (no Bezier handles) — sample nearby
279
+ if (Math.abs(dx) < 0.001 && Math.abs(dy) < 0.001) {
280
+ const epsilon = t < 0.5 ? 0.01 : -0.01;
281
+ dx = cubicBezDeriv(seg.p0x, seg.p1x, seg.p2x, seg.p3x, t + epsilon);
282
+ dy = cubicBezDeriv(seg.p0y, seg.p1y, seg.p2y, seg.p3y, t + epsilon);
283
+ }
284
+ // Still zero (fully degenerate segment) — use chord direction
285
+ if (Math.abs(dx) < 0.001 && Math.abs(dy) < 0.001) {
286
+ dx = seg.p3x - seg.p0x;
287
+ dy = seg.p3y - seg.p0y;
288
+ }
289
+ return {
290
+ x: cubicBez(seg.p0x, seg.p1x, seg.p2x, seg.p3x, t),
291
+ y: cubicBez(seg.p0y, seg.p1y, seg.p2y, seg.p3y, t),
292
+ angle: Math.atan2(dy, dx) * (180 / Math.PI)
293
+ };
294
+ }
295
+ accum += seg.length;
296
+ }
297
+ return { x: points[0].x, y: points[0].y, angle: 0 };
298
+ }
299
+
300
+ function computeMotionPathPosition(
301
+ mpPoint: { x: number; y: number; angle: number },
302
+ startPoint: { x: number; y: number; angle: number },
303
+ animX: number, animY: number, animW: number, animH: number,
304
+ closed: boolean
305
+ ): { x: number; y: number } {
306
+ if (!closed) {
307
+ return { x: mpPoint.x - animW / 2, y: mpPoint.y - animH / 2 };
308
+ }
309
+ const offsetX = (animX + animW / 2) - startPoint.x;
310
+ const offsetY = (animY + animH / 2) - startPoint.y;
311
+ const angleDelta = (mpPoint.angle - startPoint.angle) * Math.PI / 180;
312
+ const cos = Math.cos(angleDelta);
313
+ const sin = Math.sin(angleDelta);
314
+ return {
315
+ x: mpPoint.x + offsetX * cos - offsetY * sin - animW / 2,
316
+ y: mpPoint.y + offsetX * sin + offsetY * cos - animH / 2
317
+ };
318
+ }
319
+
320
+ let {
321
+ src, data, autoplay = false, loop = false, controls = true, arrows = false,
322
+ progress: showProgress = true, keyboard = true, duration: durationOverride,
323
+ startSlide = 0, muteNarration = false, class: className = '', onslidechange, oncomplete
324
+ }: AnimotPresenterProps = $props();
325
+
326
+ // State
327
+ let project = $state<AnimotProject | null>(null);
328
+ let loading = $state(true);
329
+ let error = $state<string | null>(null);
330
+ let currentSlideIndex = $state(0);
331
+ let isTransitioning = $state(false);
332
+ // Phase 2 — runtime entrance/exit registry. Read by the inline
333
+ // style:animation-name on each element render. Window-scoped so the
334
+ // rendering closure can pull the current value without each render
335
+ // needing to be a $derived (Svelte 5 reactive cost). Cleared by timers.
336
+ if (typeof window !== 'undefined') {
337
+ (window as any).__animotPresenterRuntime ||= new Map<string, { entrance?: string; exit?: string; durationMs: number }>();
338
+ }
339
+ const runtimeAnimTimersPresenter = new Map<string, number>();
340
+ function presenterRegisterRuntimeAnim(elementId: string, kind: 'entrance' | 'exit', keyframe: string, durationMs: number, delayMs: number = 0) {
341
+ if (typeof window === 'undefined') return;
342
+ const reg: Map<string, { entrance?: string; exit?: string; durationMs: number; entranceDelayMs?: number; exitDelayMs?: number }> = (window as any).__animotPresenterRuntime;
343
+ const existing = reg.get(elementId) ?? { durationMs };
344
+ const delayKey = kind === 'entrance' ? 'entranceDelayMs' : 'exitDelayMs';
345
+ const next = { ...existing, [kind]: keyframe, durationMs, [delayKey]: delayMs };
346
+ // Drop any stale entrance entry when registering exit so it can't
347
+ // re-fire after the exit clears (would briefly flash the element back).
348
+ if (kind === 'exit' && next.entrance) {
349
+ delete (next as any).entrance;
350
+ delete (next as any).entranceDelayMs;
351
+ const prevEntrTimer = runtimeAnimTimersPresenter.get(`${elementId}:entrance`);
352
+ if (prevEntrTimer) { clearTimeout(prevEntrTimer); runtimeAnimTimersPresenter.delete(`${elementId}:entrance`); }
353
+ }
354
+ reg.set(elementId, next);
355
+ runtimeBump = (runtimeBump + 1) % 1000;
356
+ const key = `${elementId}:${kind}`;
357
+ const prev = runtimeAnimTimersPresenter.get(key);
358
+ if (prev) clearTimeout(prev);
359
+ const t = window.setTimeout(() => {
360
+ const cur = reg.get(elementId);
361
+ if (!cur) return;
362
+ const cleared = { ...cur };
363
+ delete cleared[kind];
364
+ delete cleared[delayKey as 'entranceDelayMs' | 'exitDelayMs'];
365
+ if (!cleared.entrance && !cleared.exit) reg.delete(elementId);
366
+ else reg.set(elementId, cleared);
367
+ runtimeBump = (runtimeBump + 1) % 1000;
368
+ // On exit completion, force inline opacity to 0 so the keyframe's
369
+ // fill-mode-hold state is preserved when the animation-name drops.
370
+ if (kind === 'exit') {
371
+ const animated = animatedElements.get(elementId);
372
+ if (animated) animated.opacity.to(0, { duration: 0 });
373
+ }
374
+ }, delayMs + durationMs + 50);
375
+ runtimeAnimTimersPresenter.set(key, t);
376
+ }
377
+ let runtimeBump = $state(0);
378
+ let isAutoplay = $state(false);
379
+ let transitionClass = $state('');
380
+ let transitionDirection = $state<'forward' | 'backward'>('forward');
381
+ let transitionDurationMs = $state(500);
382
+ let containerEl: HTMLElement;
383
+ let containerWidth = $state(0);
384
+ let containerHeight = $state(0);
385
+
386
+ let animatedElements = $state<Map<string, AnimatedElement>>(new Map());
387
+ let codeHighlights = $state<Map<string, string>>(new Map());
388
+ let elementContent = $state<Map<string, CanvasElement>>(new Map());
389
+ let previousCodeContent = $state<Map<string, string>>(new Map());
390
+ // Snapshot of charts on the OUTGOING slide so the renderer can tween
391
+ // data values into the new slide. Updated alongside previousCodeContent.
392
+ let previousChartContent = $state<Map<string, ChartElement>>(new Map());
393
+ let previousProgressContent = $state<Map<string, ProgressElement>>(new Map());
394
+ let codeMorphState = $state<Map<string, {oldCode: string, newCode: string, mode: CodeAnimationMode, speed: number, highlightColor: string}>>(new Map());
395
+ let textTypewriterState = $state<Map<string, {fullText: string, displayedChars: number, isAnimating: boolean}>>(new Map());
396
+ let typewriterIntervals = new Map<string, ReturnType<typeof setInterval>>();
397
+ let shapeMorphStates = $state<Map<string, ShapeMorphState>>(new Map());
398
+ let autoplayTimer: ReturnType<typeof setTimeout> | null = null;
399
+ let menuVisible = $state(true);
400
+ let mouseIdleTimer: ReturnType<typeof setTimeout> | null = null;
401
+
402
+ // Single <audio> element for per-slide narration playback. Only used
403
+ // when `project.settings.narrationEnabled` is true. Lazy-allocated so
404
+ // decks without narration don't pay for it.
405
+ //
406
+ // Narration is bound to the deck's PLAY state (`isAutoplay`), not to
407
+ // arbitrary page clicks: the user pressing the play button is what
408
+ // starts narration, the pause button stops it. This keeps multiple
409
+ // decks on a page from playing each other's audio when one is clicked,
410
+ // and keeps autoplay-blocked browsers from silently doing nothing —
411
+ // the user's click on the play control is itself the unlocking gesture.
412
+ let narrationAudio: HTMLAudioElement | null = null;
413
+ function playNarrationForSlide(index: number) {
414
+ if (muteNarration) return;
415
+ if (!project?.settings?.narrationEnabled) return;
416
+ const slide = project?.slides?.[index];
417
+ if (narrationAudio) {
418
+ narrationAudio.pause();
419
+ narrationAudio.currentTime = 0;
420
+ }
421
+ const src = slide?.narration?.src;
422
+ if (!src) return;
423
+ if (!narrationAudio) narrationAudio = new Audio();
424
+ narrationAudio.src = src;
425
+ narrationAudio.play().catch(() => {});
426
+ }
427
+ function pauseNarration() {
428
+ if (narrationAudio) narrationAudio.pause();
429
+ }
430
+ function stopNarration() {
431
+ if (narrationAudio) { narrationAudio.pause(); narrationAudio = null; }
432
+ }
433
+
434
+ const slides = $derived(project?.slides ?? []);
435
+ const currentSlide = $derived(slides[currentSlideIndex]);
436
+ const isCinemaMode = $derived(project?.mode === 'cinema');
437
+ const worldWidth = $derived(project?.settings?.worldWidth ?? currentSlide?.canvas.width ?? 1920);
438
+ const worldHeight = $derived(project?.settings?.worldHeight ?? currentSlide?.canvas.height ?? 1080);
439
+ const currentCamera = $derived(currentSlide?.camera ?? defaultCamera(worldWidth, worldHeight));
440
+ const cinemaCameraTransform = $derived(
441
+ isCinemaMode && currentSlide
442
+ ? cameraTransform({ camera: currentCamera, viewportWidth: currentSlide.canvas.width, viewportHeight: currentSlide.canvas.height })
443
+ : ''
444
+ );
445
+ const canvasWidth = $derived(currentSlide?.canvas.width ?? 800);
446
+ const canvasHeight = $derived(currentSlide?.canvas.height ?? 600);
447
+
448
+ const presentationScale = $derived.by(() => {
449
+ if (!containerWidth || !containerHeight) return 1;
450
+ const scaleX = containerWidth / canvasWidth;
451
+ const scaleY = containerHeight / canvasHeight;
452
+ return Math.min(scaleX, scaleY);
453
+ });
454
+
455
+ const backgroundStyle = $derived.by(() => {
456
+ if (!currentSlide) return 'background: transparent';
457
+ return getBackgroundStyle(currentSlide.canvas.background);
458
+ });
459
+
460
+ const allElementIds = $derived.by(() => {
461
+ const ids = new Set<string>();
462
+ slides.forEach(slide => slide.canvas.elements.forEach(el => ids.add(el.id)));
463
+ return ids;
464
+ });
465
+
466
+ const sortedElementIds = $derived.by(() => {
467
+ const elements: Array<{id: string, zIndex: number}> = [];
468
+ for (const id of allElementIds) {
469
+ const el = elementContent.get(id);
470
+ if (el) elements.push({ id, zIndex: el.zIndex ?? 0 });
471
+ }
472
+ elements.sort((a, b) => a.zIndex - b.zIndex);
473
+ return elements.map(e => e.id);
474
+ });
475
+
476
+ function getElementInSlide(slide: Slide | null, elementId: string): CanvasElement | undefined {
477
+ return slide?.canvas.elements.find(el => el.id === elementId);
478
+ }
479
+
480
+ /**
481
+ * Overlay tween-driven values + non-tweened keyframe overrides onto the
482
+ * element used in render. Gated: returns the element unchanged when it
483
+ * has no keyframes AND no active override — that's the 99% case and
484
+ * keeps the existing slide-morph render pipeline allocation-free.
485
+ */
486
+ function liveProps<T extends CanvasElement>(element: T): T {
487
+ const hasKeyframes = !!element.keyframes && element.keyframes.length > 0;
488
+ const overrides = keyframeOverrides.get(element.id);
489
+ if (!hasKeyframes && !overrides) return element;
490
+ const a = animatedElements.get(element.id) as any;
491
+ const e = element as any;
492
+ const out: any = { ...element };
493
+ if (a && hasKeyframes) {
494
+ if (a.borderRadius && e.borderRadius !== undefined) out.borderRadius = a.borderRadius.current;
495
+ if (a.fontSize && e.fontSize !== undefined) out.fontSize = a.fontSize.current;
496
+ if (a.fillColor && e.fillColor !== undefined) out.fillColor = a.fillColor.current;
497
+ if (a.strokeColor && e.strokeColor !== undefined) out.strokeColor = a.strokeColor.current;
498
+ if (a.strokeWidth && e.strokeWidth !== undefined) out.strokeWidth = a.strokeWidth.current;
499
+ if (a.blur && e.blur !== undefined) out.blur = a.blur.current;
500
+ if (a.brightness && e.brightness !== undefined) out.brightness = a.brightness.current;
501
+ if (a.contrast && e.contrast !== undefined) out.contrast = a.contrast.current;
502
+ if (a.saturate && e.saturate !== undefined) out.saturate = a.saturate.current;
503
+ if (a.grayscale && e.grayscale !== undefined) out.grayscale = a.grayscale.current;
504
+ }
505
+ if (overrides) Object.assign(out, overrides);
506
+ return out as T;
507
+ }
508
+
509
+ // Typewriter
510
+ function startTypewriterAnimation(elementId: string, fullText: string, speed: number) {
511
+ const existing = typewriterIntervals.get(elementId);
512
+ if (existing) { clearInterval(existing); typewriterIntervals.delete(elementId); }
513
+ textTypewriterState.set(elementId, { fullText, displayedChars: 0, isAnimating: true });
514
+ textTypewriterState = new Map(textTypewriterState);
515
+ const intervalMs = 1000 / speed;
516
+ const interval = setInterval(() => {
517
+ const state = textTypewriterState.get(elementId);
518
+ if (state && state.isAnimating) {
519
+ if (state.displayedChars < state.fullText.length) {
520
+ textTypewriterState.set(elementId, { ...state, displayedChars: state.displayedChars + 1 });
521
+ textTypewriterState = new Map(textTypewriterState);
522
+ } else {
523
+ clearInterval(interval); typewriterIntervals.delete(elementId);
524
+ textTypewriterState.set(elementId, { ...state, isAnimating: false });
525
+ textTypewriterState = new Map(textTypewriterState);
526
+ }
527
+ } else { clearInterval(interval); typewriterIntervals.delete(elementId); }
528
+ }, intervalMs);
529
+ typewriterIntervals.set(elementId, interval);
530
+ }
531
+
532
+ function clearAllTypewriterAnimations() {
533
+ for (const [, interval] of typewriterIntervals) clearInterval(interval);
534
+ typewriterIntervals.clear();
535
+ textTypewriterState.clear();
536
+ textTypewriterState = new Map(textTypewriterState);
537
+ }
538
+
539
+ // Build SVG path for 3+ control points using Catmull-Rom spline
540
+ function buildCatmullRomPath(start: {x:number,y:number}, cps: {x:number,y:number}[], end: {x:number,y:number}): string {
541
+ const pts = [start, ...cps, end];
542
+ let d = `M ${pts[0].x} ${pts[0].y}`;
543
+ for (let i = 0; i < pts.length - 1; i++) {
544
+ const p0 = pts[i === 0 ? 0 : i - 1];
545
+ const p1 = pts[i];
546
+ const p2 = pts[i + 1];
547
+ const p3 = pts[i + 2 < pts.length ? i + 2 : pts.length - 1];
548
+ const c1x = p1.x + (p2.x - p0.x) / 6;
549
+ const c1y = p1.y + (p2.y - p0.y) / 6;
550
+ const c2x = p2.x - (p3.x - p1.x) / 6;
551
+ const c2y = p2.y - (p3.y - p1.y) / 6;
552
+ d += ` C ${c1x} ${c1y} ${c2x} ${c2y} ${p2.x} ${p2.y}`;
553
+ }
554
+ return d;
555
+ }
556
+
557
+ // Arrow draw/undraw/draw-undraw animation action
558
+ function animateStyledArrowDraw(node: SVGPathElement, params: { enabled: boolean; mode: string; duration: number; dashPattern: string; startX: number; endX: number; slideIndex: number; loop?: boolean; reverse?: boolean }) {
559
+ let lastSlideIndex = params.slideIndex;
560
+ let animationId: number | null = null;
561
+ function runAnimation() {
562
+ if (!params.enabled) return;
563
+ if (animationId) cancelAnimationFrame(animationId);
564
+ const svg = node.closest('svg') as SVGSVGElement | null;
565
+ if (!svg) return;
566
+ const baseLeftToRight = params.endX >= params.startX;
567
+ const goesLeftToRight = params.reverse ? !baseLeftToRight : baseLeftToRight;
568
+ const mode = params.mode;
569
+ const dur = params.duration;
570
+ const startTime = performance.now();
571
+ if (mode === 'draw' || mode === 'draw-undraw') {
572
+ svg.style.clipPath = goesLeftToRight ? 'inset(0 100% 0 0)' : 'inset(0 0 0 100%)';
573
+ } else if (mode === 'undraw') {
574
+ svg.style.clipPath = 'none';
575
+ }
576
+ function animate(currentTime: number) {
577
+ const elapsed = currentTime - startTime;
578
+ if (mode === 'draw') {
579
+ const progress = Math.min(elapsed / dur, 1);
580
+ const eased = 1 - Math.pow(1 - progress, 3);
581
+ const inset = 100 * (1 - eased);
582
+ svg!.style.clipPath = goesLeftToRight ? `inset(0 ${inset}% 0 0)` : `inset(0 0 0 ${inset}%)`;
583
+ if (progress < 1) { animationId = requestAnimationFrame(animate); }
584
+ else if (params.loop) { runAnimation(); }
585
+ else { svg!.style.clipPath = 'none'; animationId = null; }
586
+ } else if (mode === 'undraw') {
587
+ const progress = Math.min(elapsed / dur, 1);
588
+ const eased = 1 - Math.pow(1 - progress, 3);
589
+ const inset = 100 * eased;
590
+ svg!.style.clipPath = goesLeftToRight ? `inset(0 0 0 ${inset}%)` : `inset(0 ${inset}% 0 0)`;
591
+ if (progress < 1) { animationId = requestAnimationFrame(animate); }
592
+ else if (params.loop) { runAnimation(); }
593
+ else { svg!.style.clipPath = 'inset(0 0 0 100%)'; animationId = null; }
594
+ } else if (mode === 'draw-undraw') {
595
+ const halfDur = dur / 2;
596
+ if (elapsed < halfDur) {
597
+ const progress = Math.min(elapsed / halfDur, 1);
598
+ const eased = 1 - Math.pow(1 - progress, 3);
599
+ const inset = 100 * (1 - eased);
600
+ svg!.style.clipPath = goesLeftToRight ? `inset(0 ${inset}% 0 0)` : `inset(0 0 0 ${inset}%)`;
601
+ animationId = requestAnimationFrame(animate);
602
+ } else {
603
+ const progress = Math.min((elapsed - halfDur) / halfDur, 1);
604
+ const eased = 1 - Math.pow(1 - progress, 3);
605
+ const inset = 100 * eased;
606
+ svg!.style.clipPath = goesLeftToRight ? `inset(0 0 0 ${inset}%)` : `inset(0 ${inset}% 0 0)`;
607
+ if (progress < 1) { animationId = requestAnimationFrame(animate); }
608
+ else if (params.loop) { runAnimation(); }
609
+ else { svg!.style.clipPath = 'inset(0 0 0 100%)'; animationId = null; }
610
+ }
611
+ }
612
+ }
613
+ animationId = requestAnimationFrame(animate);
614
+ }
615
+ runAnimation();
616
+ return {
617
+ update(newParams: typeof params) {
618
+ const slideChanged = newParams.slideIndex !== lastSlideIndex;
619
+ params = newParams;
620
+ if (!params.enabled) {
621
+ if (animationId) { cancelAnimationFrame(animationId); animationId = null; }
622
+ const svg = node.closest('svg') as SVGSVGElement | null;
623
+ if (svg) svg.style.clipPath = '';
624
+ lastSlideIndex = newParams.slideIndex;
625
+ return;
626
+ }
627
+ if (slideChanged) { lastSlideIndex = newParams.slideIndex; runAnimation(); }
628
+ },
629
+ destroy() { if (animationId) cancelAnimationFrame(animationId); }
630
+ };
631
+ }
632
+
633
+ // Init animated elements
634
+ function initAllAnimatedElements() {
635
+ const firstSlide = slides[0];
636
+ if (firstSlide) {
637
+ for (const element of firstSlide.canvas.elements) {
638
+ if (element.type === 'code') previousCodeContent.set(element.id, (element as CodeElement).code);
639
+ if (element.type === 'text') {
640
+ const textEl = element as TextElement;
641
+ if (textEl.animation?.mode === 'typewriter') startTypewriterAnimation(element.id, textEl.content, textEl.animation.typewriterSpeed || 50);
642
+ }
643
+ }
644
+ }
645
+ for (const slide of slides) {
646
+ for (const element of slide.canvas.elements) {
647
+ if (!animatedElements.has(element.id)) {
648
+ const inCurrent = getElementInSlide(currentSlide, element.id);
649
+ let startOpacity = inCurrent ? ((inCurrent as any).opacity ?? 1) : 0;
650
+ const br = (element as any).borderRadius ?? 0;
651
+ const isShape = element.type === 'shape';
652
+ const shapeEl = isShape ? element as ShapeElement : null;
653
+ const isText = element.type === 'text';
654
+ const textEl = isText ? element as TextElement : null;
655
+
656
+ // If the element has keyframes, seed each tween with the FIRST
657
+ // keyframe's value instead of the persisted (last-captured)
658
+ // state. The persisted state is whatever was on the canvas
659
+ // at the moment the user captured their final keyframe; using
660
+ // it as the tween's starting value would cause the slide to
661
+ // flash at the "wrong" pose before the keyframe schedule
662
+ // snaps it to KF1. Doing it here also avoids depending on a
663
+ // duration:0 snap that the tween library may not apply
664
+ // synchronously.
665
+ const sortedKfs = element.keyframes && element.keyframes.length > 0
666
+ ? [...element.keyframes].sort((a, b) => a.time - b.time)
667
+ : null;
668
+ const k0 = sortedKfs ? sortedKfs[0] : null;
669
+ const seedX = k0?.position ? k0.position.x : element.position.x;
670
+ const seedY = k0?.position ? k0.position.y : element.position.y;
671
+ const seedW = k0?.size ? k0.size.width : element.size.width;
672
+ const seedH = k0?.size ? k0.size.height : element.size.height;
673
+ const seedRot = k0?.rotation !== undefined ? k0.rotation : element.rotation;
674
+ const seedSkewX = k0?.skewX !== undefined ? k0.skewX : (element.skewX ?? 0);
675
+ const seedSkewY = k0?.skewY !== undefined ? k0.skewY : (element.skewY ?? 0);
676
+ const seedTiltX = k0?.tiltX !== undefined ? k0.tiltX : (element.tiltX ?? 0);
677
+ const seedTiltY = k0?.tiltY !== undefined ? k0.tiltY : (element.tiltY ?? 0);
678
+ if (k0?.opacity !== undefined) startOpacity = k0.opacity;
679
+ const seedBR = k0?.borderRadius !== undefined ? k0.borderRadius : br;
680
+ const seedFontSize = k0?.fontSize !== undefined ? k0.fontSize : (textEl ? textEl.fontSize : 0);
681
+ const seedFill = k0?.fillColor !== undefined ? k0.fillColor : (shapeEl ? shapeEl.fillColor : '');
682
+ const seedStroke = k0?.strokeColor !== undefined ? k0.strokeColor : (shapeEl ? shapeEl.strokeColor : '');
683
+ const seedStrokeW = k0?.strokeWidth !== undefined ? k0.strokeWidth : (shapeEl ? shapeEl.strokeWidth : 0);
684
+ const seedBlur = k0?.blur !== undefined ? k0.blur : (element.blur ?? 0);
685
+ const seedBright = k0?.brightness !== undefined ? k0.brightness : (element.brightness ?? 100);
686
+ const seedContrast = k0?.contrast !== undefined ? k0.contrast : (element.contrast ?? 100);
687
+ const seedSat = k0?.saturate !== undefined ? k0.saturate : (element.saturate ?? 100);
688
+ const seedGray = k0?.grayscale !== undefined ? k0.grayscale : (element.grayscale ?? 0);
689
+
690
+ animatedElements.set(element.id, {
691
+ x: mkTween(seedX, { duration: 500 }),
692
+ y: mkTween(seedY, { duration: 500 }),
693
+ width: mkTween(seedW, { duration: 500 }),
694
+ height: mkTween(seedH, { duration: 500 }),
695
+ rotation: mkTween(seedRot, { duration: 500 }),
696
+ skewX: mkTween(seedSkewX, { duration: 500 }),
697
+ skewY: mkTween(seedSkewY, { duration: 500 }),
698
+ tiltX: mkTween(seedTiltX, { duration: 500 }),
699
+ tiltY: mkTween(seedTiltY, { duration: 500 }),
700
+ perspective: mkTween(element.perspective ?? 1000, { duration: 500 }),
701
+ opacity: mkTween(startOpacity, { duration: 300 }),
702
+ borderRadius: mkTween(seedBR, { duration: 500 }),
703
+ fontSize: textEl ? mkTween(seedFontSize, { duration: 500 }) : null,
704
+ fillColor: shapeEl ? mkTween(seedFill, { duration: 500 }) : null,
705
+ strokeColor: shapeEl ? mkTween(seedStroke, { duration: 500 }) : null,
706
+ strokeWidth: shapeEl ? mkTween(seedStrokeW, { duration: 500 }) : null,
707
+ shapeMorph: shapeEl ? mkTween(1, { duration: 500 }) : null,
708
+ motionPathProgress: element.motionPathConfig ? mkTween(0, { duration: 500 }) : null,
709
+ blur: mkTween(seedBlur, { duration: 500 }),
710
+ brightness: mkTween(seedBright, { duration: 500 }),
711
+ contrast: mkTween(seedContrast, { duration: 500 }),
712
+ saturate: mkTween(seedSat, { duration: 500 }),
713
+ grayscale: mkTween(seedGray, { duration: 500 })
714
+ });
715
+ const currentSlideEl = getElementInSlide(currentSlide, element.id);
716
+ elementContent.set(element.id, JSON.parse(JSON.stringify(currentSlideEl || element)));
717
+ }
718
+ }
719
+ }
720
+ animatedElements = new Map(animatedElements);
721
+ elementContent = new Map(elementContent);
722
+ previousCodeContent = new Map(previousCodeContent);
723
+ }
724
+
725
+ async function animateMotionPaths(slide: Slide) {
726
+ cancelMotionPathLoops();
727
+ motionPathLoopAbort = new AbortController();
728
+ const signal = motionPathLoopAbort.signal;
729
+
730
+ const resets: Promise<void>[] = [];
731
+ for (const element of slide.canvas.elements) {
732
+ if (element.motionPathConfig) {
733
+ const animated = animatedElements.get(element.id);
734
+ if (animated?.motionPathProgress) {
735
+ resets.push(animated.motionPathProgress.to(0, { duration: 0 }));
736
+ }
737
+ }
738
+ }
739
+ await Promise.all(resets);
740
+ for (const element of slide.canvas.elements) {
741
+ if (element.motionPathConfig) {
742
+ const animated = animatedElements.get(element.id);
743
+ if (animated?.motionPathProgress) {
744
+ const config = element.animationConfig;
745
+ const duration = config?.duration ?? 2000;
746
+ const easing = getEasingFn(config?.easing);
747
+ const shouldLoop = element.motionPathConfig.loop;
748
+
749
+ if (shouldLoop) {
750
+ const laps = element.motionPathConfig.laps ?? 0;
751
+ (async () => {
752
+ try {
753
+ let lap = 0;
754
+ while (!signal.aborted && (laps === 0 || lap < laps)) {
755
+ await abortable(animated.motionPathProgress!.to(0, { duration: 0 }), signal);
756
+ await abortable(animated.motionPathProgress!.to(1, { duration, easing }), signal);
757
+ lap++;
758
+ if (!signal.aborted && (laps === 0 || lap < laps)) await abortableSleep(50, signal);
759
+ }
760
+ } catch (e) {
761
+ if (!isAbortError(e)) throw e;
762
+ }
763
+ })();
764
+ } else {
765
+ animated.motionPathProgress.to(1, { duration, easing }).catch(() => {});
766
+ }
767
+ }
768
+ }
769
+ }
770
+ }
771
+
772
+ // Reset presentation to first slide (snap all elements back to initial state)
773
+ async function resetToFirstSlide() {
774
+ if (isTransitioning) return;
775
+ isTransitioning = true;
776
+ clearAllTypewriterAnimations();
777
+ cancelMotionPathLoops();
778
+ cancelKeyframeLoops();
779
+ const firstSlide = slides[0];
780
+ if (!firstSlide) { isTransitioning = false; return; }
781
+
782
+ for (const elementId of allElementIds) {
783
+ const targetEl = getElementInSlide(firstSlide, elementId);
784
+ const animated = animatedElements.get(elementId);
785
+ if (!animated) continue;
786
+ if (targetEl) {
787
+ animated.x.to(targetEl.position.x, { duration: 0 }); animated.y.to(targetEl.position.y, { duration: 0 });
788
+ animated.width.to(targetEl.size.width, { duration: 0 }); animated.height.to(targetEl.size.height, { duration: 0 });
789
+ animated.rotation.to(targetEl.rotation, { duration: 0 });
790
+ animated.skewX.to(targetEl.skewX ?? 0, { duration: 0 }); animated.skewY.to(targetEl.skewY ?? 0, { duration: 0 });
791
+ animated.tiltX.to(targetEl.tiltX ?? 0, { duration: 0 }); animated.tiltY.to(targetEl.tiltY ?? 0, { duration: 0 });
792
+ animated.perspective.to(targetEl.perspective ?? 1000, { duration: 0 });
793
+ animated.opacity.to((targetEl as any).opacity ?? 1, { duration: 0 });
794
+ animated.borderRadius.to((targetEl as any).borderRadius ?? 0, { duration: 0 });
795
+ animated.blur.to(targetEl.blur ?? 0, { duration: 0 });
796
+ animated.brightness.to(targetEl.brightness ?? 100, { duration: 0 });
797
+ animated.contrast.to(targetEl.contrast ?? 100, { duration: 0 });
798
+ animated.saturate.to(targetEl.saturate ?? 100, { duration: 0 });
799
+ animated.grayscale.to(targetEl.grayscale ?? 0, { duration: 0 });
800
+ if (targetEl.type === 'text' && animated.fontSize) animated.fontSize.to((targetEl as TextElement).fontSize, { duration: 0 });
801
+ if (targetEl.type === 'shape') {
802
+ const s = targetEl as ShapeElement;
803
+ if (animated.fillColor) animated.fillColor.to(s.fillColor, { duration: 0 });
804
+ if (animated.strokeColor) animated.strokeColor.to(s.strokeColor, { duration: 0 });
805
+ if (animated.strokeWidth) animated.strokeWidth.to(s.strokeWidth, { duration: 0 });
806
+ }
807
+ if (animated.motionPathProgress) animated.motionPathProgress.to(0, { duration: 0 });
808
+ } else {
809
+ animated.opacity.to(0, { duration: 0 });
810
+ }
811
+ }
812
+
813
+ for (const elementId of allElementIds) {
814
+ const targetEl = getElementInSlide(firstSlide, elementId);
815
+ if (targetEl) elementContent.set(elementId, JSON.parse(JSON.stringify(targetEl)));
816
+ }
817
+
818
+ const newPreviousCodeContent = new Map<string, string>();
819
+ for (const element of firstSlide.canvas.elements) {
820
+ if (element.type === 'code') newPreviousCodeContent.set(element.id, (element as CodeElement).code);
821
+ }
822
+
823
+ for (const element of firstSlide.canvas.elements) {
824
+ if (element.type === 'code') {
825
+ const codeEl = element as CodeElement;
826
+ const key = `${codeEl.id}-${codeEl.code}-${codeEl.language}-${codeEl.showLineNumbers}`;
827
+ if (!codeHighlights.has(key)) {
828
+ const html = await highlightCode(codeEl.code, codeEl.language, codeEl.theme, { showLineNumbers: codeEl.showLineNumbers });
829
+ codeHighlights.set(key, html);
830
+ }
831
+ }
832
+ }
833
+ codeHighlights = new Map(codeHighlights);
834
+
835
+ codeMorphState = new Map();
836
+ previousCodeContent = newPreviousCodeContent;
837
+ previousChartContent = new Map();
838
+ previousProgressContent = new Map();
839
+ shapeMorphStates = new Map();
840
+ elementContent = new Map(elementContent);
841
+ currentSlideIndex = 0;
842
+ isTransitioning = false;
843
+
844
+ // Restart narration on loop. Setting `currentSlideIndex = 0` above
845
+ // is a no-op for single-slide decks (was already 0) so the play-state
846
+ // effect doesn't re-fire on its own. Calling explicitly here covers
847
+ // both single- and multi-slide loops uniformly.
848
+ if (isAutoplay) playNarrationForSlide(0);
849
+
850
+ for (const element of firstSlide.canvas.elements) {
851
+ if (element.type === 'text') {
852
+ const textEl = element as TextElement;
853
+ if (textEl.animation?.mode === 'typewriter') startTypewriterAnimation(element.id, textEl.content, textEl.animation.typewriterSpeed || 50);
854
+ }
855
+ }
856
+
857
+ animateKeyframes(firstSlide);
858
+ animateMotionPaths(firstSlide);
859
+ // Fire entrance presets so a looping autoplay re-plays the first
860
+ // slide's entrance the same way the initial mount did.
861
+ firePresenterEntrancePresets(0);
862
+ onslidechange?.(0, slides.length);
863
+ }
864
+
865
+ // Animate to slide
866
+ async function animateToSlide(targetIndex: number) {
867
+ if (isTransitioning || targetIndex < 0 || targetIndex >= slides.length) return;
868
+ if (targetIndex === currentSlideIndex) return;
869
+ isTransitioning = true;
870
+ transitionDirection = targetIndex > currentSlideIndex ? 'forward' : 'backward';
871
+ const targetSlide = slides[targetIndex];
872
+ clearAllTypewriterAnimations();
873
+ cancelMotionPathLoops();
874
+ cancelKeyframeLoops();
875
+ // Manual arrow nav and the autoplay timer are both forms of "user
876
+ // is moving forward in the deck" both should swap narration to
877
+ // the new slide. The pause button is what stops audio. Without
878
+ // this, clicking an arrow while paused would render the new slide
879
+ // silently, which feels broken.
880
+ playNarrationForSlide(targetIndex);
881
+ const transition = targetSlide.transition;
882
+ const duration = durationOverride ?? transition.duration;
883
+ transitionDurationMs = duration;
884
+ const hasSlideTransition = transition.type !== 'none';
885
+
886
+ if (hasSlideTransition) {
887
+ // Phase 2 sprint-1 polish — fire per-element exit presets BEFORE
888
+ // the slide-level CSS transition starts so they're visible alongside
889
+ // the cross-fade. Skip elements still on the target slide (morph case).
890
+ for (const el of currentSlide.canvas.elements) {
891
+ const exitMode = el.animationConfig?.exit;
892
+ if (!exitMode || exitMode === 'none' || exitMode === 'fade') continue;
893
+ const kf = exitRuntimeKeyframe(exitMode);
894
+ if (!kf) continue;
895
+ const stillOnTarget = !!getElementInSlide(targetSlide, el.id);
896
+ if (stillOnTarget) continue;
897
+ const dur = el.animationConfig?.exitDuration ?? el.animationConfig?.duration ?? 600;
898
+ presenterRegisterRuntimeAnim(el.id, 'exit', kf, dur);
899
+ // Snap opacity to 0 after the CSS exit keyframe finishes so the
900
+ // element vanishes cleanly once the runtime registry entry expires.
901
+ const elId = el.id;
902
+ const animatedRef = animatedElements.get(elId);
903
+ if (animatedRef) setTimeout(() => animatedRef.opacity.to(0, { duration: 0 }), dur);
904
+ }
905
+
906
+ transitionClass = `transition-${transition.type}-out`;
907
+ await new Promise(r => setTimeout(r, duration * 0.4));
908
+ const newElementContent = new Map(elementContent);
909
+ const newCodeMorphState = new Map(codeMorphState);
910
+ const newPreviousCodeContent = new Map(previousCodeContent);
911
+ const newPreviousChartContent = new Map(previousChartContent);
912
+ const newPreviousProgressContent = new Map(previousProgressContent);
913
+ for (const elementId of allElementIds) {
914
+ const targetEl = getElementInSlide(targetSlide, elementId);
915
+ const animated = animatedElements.get(elementId);
916
+ if (targetEl) {
917
+ if (targetEl.type === 'code') {
918
+ const codeEl = targetEl as CodeElement;
919
+ const prevCode = newPreviousCodeContent.get(elementId) || '';
920
+ newCodeMorphState.set(elementId, { oldCode: prevCode, newCode: codeEl.code, mode: codeEl.animation?.mode || 'highlight-changes', speed: codeEl.animation?.typewriterSpeed || 50, highlightColor: codeEl.animation?.highlightColor || '#fef08a' });
921
+ newPreviousCodeContent.set(elementId, codeEl.code);
922
+ }
923
+ if (targetEl.type === 'chart') {
924
+ const outgoing = elementContent.get(elementId);
925
+ if (outgoing && outgoing.type === 'chart') {
926
+ newPreviousChartContent.set(elementId, JSON.parse(JSON.stringify(outgoing)));
927
+ }
928
+ }
929
+ if (targetEl.type === 'progress') {
930
+ const outgoing = elementContent.get(elementId);
931
+ if (outgoing && outgoing.type === 'progress') {
932
+ newPreviousProgressContent.set(elementId, JSON.parse(JSON.stringify(outgoing)));
933
+ }
934
+ }
935
+ newElementContent.set(elementId, JSON.parse(JSON.stringify(targetEl)));
936
+ if (animated) {
937
+ animated.x.to(targetEl.position.x, { duration: 0 }); animated.y.to(targetEl.position.y, { duration: 0 });
938
+ animated.width.to(targetEl.size.width, { duration: 0 }); animated.height.to(targetEl.size.height, { duration: 0 });
939
+ animated.rotation.to(targetEl.rotation, { duration: 0 });
940
+ animated.skewX.to(targetEl.skewX ?? 0, { duration: 0 }); animated.skewY.to(targetEl.skewY ?? 0, { duration: 0 });
941
+ animated.tiltX.to(targetEl.tiltX ?? 0, { duration: 0 }); animated.tiltY.to(targetEl.tiltY ?? 0, { duration: 0 });
942
+ animated.perspective.to(targetEl.perspective ?? 1000, { duration: 0 });
943
+ animated.opacity.to((targetEl as any).opacity ?? 1, { duration: 0 });
944
+ animated.borderRadius.to((targetEl as any).borderRadius ?? 0, { duration: 0 });
945
+ animated.blur.to(targetEl.blur ?? 0, { duration: 0 });
946
+ animated.brightness.to(targetEl.brightness ?? 100, { duration: 0 });
947
+ animated.contrast.to(targetEl.contrast ?? 100, { duration: 0 });
948
+ animated.saturate.to(targetEl.saturate ?? 100, { duration: 0 });
949
+ animated.grayscale.to(targetEl.grayscale ?? 0, { duration: 0 });
950
+ if (targetEl.type === 'text') animated.fontSize?.to((targetEl as TextElement).fontSize, { duration: 0 });
951
+ if (targetEl.type === 'shape') {
952
+ const s = targetEl as ShapeElement;
953
+ animated.fillColor?.to(s.fillColor, { duration: 0 });
954
+ animated.strokeColor?.to(s.strokeColor, { duration: 0 });
955
+ animated.strokeWidth?.to(s.strokeWidth, { duration: 0 });
956
+ }
957
+ if (animated.motionPathProgress) animated.motionPathProgress.to(0, { duration: 0 });
958
+ }
959
+ } else if (animated) {
960
+ // Skip instant opacity snap when an exit preset is mid-animation;
961
+ // its setTimeout handles the opacity-to-0 after exit duration.
962
+ const reg = typeof window !== 'undefined' ? (window as any).__animotPresenterRuntime as Map<string, { exit?: string }> | undefined : undefined;
963
+ const hasActiveExit = !!reg?.get(elementId)?.exit;
964
+ if (!hasActiveExit) animated.opacity.to(0, { duration: 0 });
965
+ }
966
+ }
967
+ for (const [, element] of newElementContent) {
968
+ if (element.type === 'code') {
969
+ const codeEl = element as CodeElement;
970
+ const key = `${codeEl.id}-${codeEl.code}-${codeEl.language}-${codeEl.showLineNumbers}`;
971
+ if (!codeHighlights.has(key)) {
972
+ const html = await highlightCode(codeEl.code, codeEl.language, codeEl.theme, { showLineNumbers: codeEl.showLineNumbers });
973
+ codeHighlights.set(key, html);
974
+ }
975
+ }
976
+ }
977
+ codeHighlights = new Map(codeHighlights);
978
+ shapeMorphStates = new Map();
979
+ codeMorphState = newCodeMorphState;
980
+ previousCodeContent = newPreviousCodeContent;
981
+ previousChartContent = newPreviousChartContent;
982
+ previousProgressContent = newPreviousProgressContent;
983
+ elementContent = newElementContent;
984
+ animatedElements = new Map(animatedElements);
985
+ currentSlideIndex = targetIndex;
986
+ for (const elementId of allElementIds) {
987
+ const targetEl = getElementInSlide(targetSlide, elementId);
988
+ if (targetEl?.type === 'text') {
989
+ const textEl = targetEl as TextElement;
990
+ if (textEl.animation?.mode === 'typewriter') startTypewriterAnimation(elementId, textEl.content, textEl.animation.typewriterSpeed || 50);
991
+ }
992
+ }
993
+ transitionClass = `transition-${transition.type}-in`;
994
+ // Fire per-element entrance presets on the target slide so they
995
+ // play alongside the slide-level cross-fade-in. Without this,
996
+ // slide-level transitions skip per-element entrance animations.
997
+ firePresenterEntrancePresets(targetIndex);
998
+ await new Promise(r => setTimeout(r, duration * 0.6));
999
+ transitionClass = '';
1000
+ animateKeyframes(targetSlide);
1001
+ animateMotionPaths(targetSlide);
1002
+ isTransitioning = false;
1003
+ onslidechange?.(targetIndex, slides.length);
1004
+ if (targetIndex === slides.length - 1 && !loop) oncomplete?.();
1005
+ return;
1006
+ }
1007
+
1008
+ // Per-element morphing (transition type = 'none')
1009
+ const animations: Promise<void>[] = [];
1010
+ for (const elementId of allElementIds) {
1011
+ const currentEl = getElementInSlide(currentSlide, elementId);
1012
+ const animated = animatedElements.get(elementId);
1013
+ if (!animated) continue;
1014
+ if (currentEl) {
1015
+ await animated.x.to(currentEl.position.x, { duration: 0 });
1016
+ await animated.y.to(currentEl.position.y, { duration: 0 });
1017
+ await animated.width.to(currentEl.size.width, { duration: 0 });
1018
+ await animated.height.to(currentEl.size.height, { duration: 0 });
1019
+ await animated.rotation.to(currentEl.rotation, { duration: 0 });
1020
+ await animated.skewX.to(currentEl.skewX ?? 0, { duration: 0 });
1021
+ await animated.skewY.to(currentEl.skewY ?? 0, { duration: 0 });
1022
+ await animated.tiltX.to(currentEl.tiltX ?? 0, { duration: 0 });
1023
+ await animated.tiltY.to(currentEl.tiltY ?? 0, { duration: 0 });
1024
+ await animated.perspective.to(currentEl.perspective ?? 1000, { duration: 0 });
1025
+ await animated.borderRadius.to((currentEl as any).borderRadius ?? 0, { duration: 0 });
1026
+ await animated.blur.to(currentEl.blur ?? 0, { duration: 0 });
1027
+ await animated.brightness.to(currentEl.brightness ?? 100, { duration: 0 });
1028
+ await animated.contrast.to(currentEl.contrast ?? 100, { duration: 0 });
1029
+ await animated.saturate.to(currentEl.saturate ?? 100, { duration: 0 });
1030
+ await animated.grayscale.to(currentEl.grayscale ?? 0, { duration: 0 });
1031
+ await animated.opacity.to((currentEl as any).opacity ?? 1, { duration: 0 });
1032
+ if (currentEl.type === 'text' && animated.fontSize) await animated.fontSize.to((currentEl as TextElement).fontSize, { duration: 0 });
1033
+ if (currentEl.type === 'shape') {
1034
+ const s = currentEl as ShapeElement;
1035
+ if (animated.fillColor) await animated.fillColor.to(s.fillColor, { duration: 0 });
1036
+ if (animated.strokeColor) await animated.strokeColor.to(s.strokeColor, { duration: 0 });
1037
+ if (animated.strokeWidth) await animated.strokeWidth.to(s.strokeWidth, { duration: 0 });
1038
+ }
1039
+ }
1040
+ }
1041
+
1042
+ // Update elementContent BEFORE animations start so rendered elements
1043
+ // (especially SVG viewBox) use target slide data while animating.
1044
+ // For charts, snapshot OUTGOING values into previousChartContent first
1045
+ // so the new render tweens from them.
1046
+ for (const elementId of allElementIds) {
1047
+ const targetEl = getElementInSlide(targetSlide, elementId);
1048
+ if (targetEl && targetEl.type !== 'code') {
1049
+ if (targetEl.type === 'chart') {
1050
+ const outgoing = elementContent.get(elementId);
1051
+ if (outgoing && outgoing.type === 'chart') {
1052
+ previousChartContent.set(elementId, JSON.parse(JSON.stringify(outgoing)));
1053
+ }
1054
+ }
1055
+ if (targetEl.type === 'progress') {
1056
+ const outgoing = elementContent.get(elementId);
1057
+ if (outgoing && outgoing.type === 'progress') {
1058
+ previousProgressContent.set(elementId, JSON.parse(JSON.stringify(outgoing)));
1059
+ }
1060
+ }
1061
+ elementContent.set(elementId, JSON.parse(JSON.stringify(targetEl)));
1062
+ }
1063
+ }
1064
+ elementContent = new Map(elementContent);
1065
+ previousProgressContent = new Map(previousProgressContent);
1066
+ previousChartContent = new Map(previousChartContent);
1067
+
1068
+ interface AnimationTask { elementId: string; order: number; delay: number; elementDuration: number; run: () => Promise<void>[]; }
1069
+ const animationTasks: AnimationTask[] = [];
1070
+
1071
+ for (const elementId of allElementIds) {
1072
+ const currentEl = getElementInSlide(currentSlide, elementId);
1073
+ const targetEl = getElementInSlide(targetSlide, elementId);
1074
+ const animated = animatedElements.get(elementId);
1075
+ if (!animated) continue;
1076
+ const animConfig = targetEl?.animationConfig || currentEl?.animationConfig;
1077
+ const order = animConfig?.order ?? 0;
1078
+ const delay = animConfig?.delay ?? 0;
1079
+ const elementDuration = animConfig?.duration ?? duration;
1080
+
1081
+ // Phase 2 opt-out: when currentEl has an exit preset and element
1082
+ // continues to next slide, exit on current, snap to target pose,
1083
+ // then play target's entrance. Overrides morph.
1084
+ const exitPresetMode = currentEl?.animationConfig?.exit;
1085
+ const hasExitOptOut = !!currentEl && !!targetEl
1086
+ && exitPresetMode && exitPresetMode !== 'none' && exitPresetMode !== 'fade';
1087
+ if (hasExitOptOut) {
1088
+ const exitKf = exitRuntimeKeyframe(exitPresetMode);
1089
+ const exitDuration = currentEl.animationConfig?.exitDuration ?? elementDuration;
1090
+ const entranceMode = targetEl.animationConfig?.entrance;
1091
+ const entranceKf = entranceMode ? entranceRuntimeKeyframe(entranceMode) : null;
1092
+ const entranceDuration = entranceMode && entranceMode !== 'none' && entranceMode !== 'fade'
1093
+ ? (targetEl.animationConfig?.entranceDuration ?? targetEl.animationConfig?.duration ?? 600)
1094
+ : 0;
1095
+ if (exitKf) presenterRegisterRuntimeAnim(currentEl.id, 'exit', exitKf, exitDuration);
1096
+ animationTasks.push({
1097
+ elementId,
1098
+ order,
1099
+ delay,
1100
+ elementDuration: exitDuration + entranceDuration,
1101
+ run: () => [(async () => {
1102
+ await new Promise(r => setTimeout(r, exitDuration));
1103
+ animated.x.to(targetEl.position.x, { duration: 0 });
1104
+ animated.y.to(targetEl.position.y, { duration: 0 });
1105
+ animated.width.to(targetEl.size.width, { duration: 0 });
1106
+ animated.height.to(targetEl.size.height, { duration: 0 });
1107
+ animated.rotation.to(targetEl.rotation, { duration: 0 });
1108
+ animated.skewX.to(targetEl.skewX ?? 0, { duration: 0 });
1109
+ animated.skewY.to(targetEl.skewY ?? 0, { duration: 0 });
1110
+ animated.tiltX.to(targetEl.tiltX ?? 0, { duration: 0 });
1111
+ animated.tiltY.to(targetEl.tiltY ?? 0, { duration: 0 });
1112
+ animated.perspective.to(targetEl.perspective ?? 1000, { duration: 0 });
1113
+ animated.borderRadius.to((targetEl as any).borderRadius ?? 0, { duration: 0 });
1114
+ animated.opacity.to((targetEl as any).opacity ?? 1, { duration: 0 });
1115
+ if (targetEl.type === 'text' && animated.fontSize) {
1116
+ animated.fontSize.to((targetEl as TextElement).fontSize, { duration: 0 });
1117
+ }
1118
+ if (targetEl.type === 'shape') {
1119
+ const shapeEl = targetEl as ShapeElement;
1120
+ if (animated.fillColor) animated.fillColor.to(shapeEl.fillColor, { duration: 0 });
1121
+ if (animated.strokeColor) animated.strokeColor.to(shapeEl.strokeColor, { duration: 0 });
1122
+ if (animated.strokeWidth) animated.strokeWidth.to(shapeEl.strokeWidth, { duration: 0 });
1123
+ }
1124
+ if (entranceKf) presenterRegisterRuntimeAnim(targetEl.id, 'entrance', entranceKf, entranceDuration);
1125
+ })()]
1126
+ });
1127
+ continue;
1128
+ }
1129
+
1130
+ if (targetEl) {
1131
+ const easing = getEasingFn(animConfig?.easing);
1132
+ const propertySequences = targetEl.animationConfig?.propertySequences;
1133
+ if (targetEl.type === 'text') {
1134
+ const textEl = targetEl as TextElement;
1135
+ if (textEl.animation?.mode === 'typewriter') startTypewriterAnimation(elementId, textEl.content, textEl.animation.typewriterSpeed || 50);
1136
+ }
1137
+
1138
+ const getSeqTiming = (prop: AnimatableProperty) => {
1139
+ if (!propertySequences?.length) return { duration: elementDuration, delay: 0, order: 0 };
1140
+ const seq = propertySequences.find(s => s.property === prop);
1141
+ return seq ? { duration: seq.duration, delay: seq.delay, order: seq.order } : { duration: elementDuration, delay: 0, order: 99 };
1142
+ };
1143
+
1144
+ animationTasks.push({
1145
+ elementId, order, delay, elementDuration,
1146
+ run: () => {
1147
+ const anims: Promise<void>[] = [];
1148
+ if (propertySequences?.length) {
1149
+ const sequencedProps = new Set(propertySequences.map(s => s.property));
1150
+ if (!sequencedProps.has('position')) { anims.push(animated.x.to(targetEl.position.x, { duration: elementDuration, easing })); anims.push(animated.y.to(targetEl.position.y, { duration: elementDuration, easing })); }
1151
+ if (!sequencedProps.has('rotation')) anims.push(animated.rotation.to(targetEl.rotation, { duration: elementDuration, easing }));
1152
+ if (!sequencedProps.has('tilt')) { anims.push(animated.tiltX.to(targetEl.tiltX ?? 0, { duration: elementDuration, easing })); anims.push(animated.tiltY.to(targetEl.tiltY ?? 0, { duration: elementDuration, easing })); }
1153
+ if (!sequencedProps.has('skew')) { anims.push(animated.skewX.to(targetEl.skewX ?? 0, { duration: elementDuration, easing })); anims.push(animated.skewY.to(targetEl.skewY ?? 0, { duration: elementDuration, easing })); }
1154
+ if (!sequencedProps.has('size')) { anims.push(animated.width.to(targetEl.size.width, { duration: elementDuration, easing })); anims.push(animated.height.to(targetEl.size.height, { duration: elementDuration, easing })); }
1155
+ if (!sequencedProps.has('borderRadius')) anims.push(animated.borderRadius.to((targetEl as any).borderRadius ?? 0, { duration: elementDuration, easing }));
1156
+ if (!sequencedProps.has('blur')) {
1157
+ animated.blur.to(targetEl.blur ?? 0, { duration: elementDuration, easing });
1158
+ animated.brightness.to(targetEl.brightness ?? 100, { duration: elementDuration, easing });
1159
+ animated.contrast.to(targetEl.contrast ?? 100, { duration: elementDuration, easing });
1160
+ animated.saturate.to(targetEl.saturate ?? 100, { duration: elementDuration, easing });
1161
+ animated.grayscale.to(targetEl.grayscale ?? 0, { duration: elementDuration, easing });
1162
+ }
1163
+ if (!sequencedProps.has('perspective')) anims.push(animated.perspective.to(targetEl.perspective ?? 1000, { duration: elementDuration, easing }));
1164
+ if (!sequencedProps.has('opacity')) {
1165
+ const targetOpacity = (targetEl as any).opacity ?? 1;
1166
+ if (animated.opacity.current !== targetOpacity) anims.push(animated.opacity.to(targetOpacity, { duration: elementDuration, easing }));
1167
+ }
1168
+ const sortedSeqs = [...propertySequences].sort((a, b) => a.order - b.order);
1169
+ let cumulativeDelay = 0;
1170
+ for (const seq of sortedSeqs) {
1171
+ const seqDelay = cumulativeDelay + seq.delay;
1172
+ const seqDuration = seq.duration;
1173
+ setTimeout(() => {
1174
+ if (seq.property === 'position') { animated.x.to(targetEl.position.x, { duration: seqDuration, easing }); animated.y.to(targetEl.position.y, { duration: seqDuration, easing }); }
1175
+ else if (seq.property === 'rotation') animated.rotation.to(targetEl.rotation, { duration: seqDuration, easing });
1176
+ else if (seq.property === 'tilt') { animated.tiltX.to(targetEl.tiltX ?? 0, { duration: seqDuration, easing }); animated.tiltY.to(targetEl.tiltY ?? 0, { duration: seqDuration, easing }); }
1177
+ else if (seq.property === 'skew') { animated.skewX.to(targetEl.skewX ?? 0, { duration: seqDuration, easing }); animated.skewY.to(targetEl.skewY ?? 0, { duration: seqDuration, easing }); }
1178
+ else if (seq.property === 'size') { animated.width.to(targetEl.size.width, { duration: seqDuration, easing }); animated.height.to(targetEl.size.height, { duration: seqDuration, easing }); }
1179
+ else if (seq.property === 'borderRadius') animated.borderRadius.to((targetEl as any).borderRadius ?? 0, { duration: seqDuration, easing });
1180
+ else if (seq.property === 'blur') {
1181
+ animated.blur.to(targetEl.blur ?? 0, { duration: seqDuration, easing });
1182
+ animated.brightness.to(targetEl.brightness ?? 100, { duration: seqDuration, easing });
1183
+ animated.contrast.to(targetEl.contrast ?? 100, { duration: seqDuration, easing });
1184
+ animated.saturate.to(targetEl.saturate ?? 100, { duration: seqDuration, easing });
1185
+ animated.grayscale.to(targetEl.grayscale ?? 0, { duration: seqDuration, easing });
1186
+ }
1187
+ else if (seq.property === 'color' && targetEl.type === 'shape') {
1188
+ const s = targetEl as ShapeElement;
1189
+ animated.fillColor?.to(s.fillColor, { duration: seqDuration, easing });
1190
+ animated.strokeColor?.to(s.strokeColor, { duration: seqDuration, easing });
1191
+ animated.strokeWidth?.to(s.strokeWidth, { duration: seqDuration, easing });
1192
+ }
1193
+ else if (seq.property === 'perspective') animated.perspective.to(targetEl.perspective ?? 1000, { duration: seqDuration, easing });
1194
+ else if (seq.property === 'opacity') animated.opacity.to((targetEl as any).opacity ?? 1, { duration: seqDuration, easing });
1195
+ }, seqDelay);
1196
+ cumulativeDelay = seqDelay + seqDuration;
1197
+ }
1198
+ anims.push(new Promise(r => setTimeout(r, cumulativeDelay)));
1199
+ } else {
1200
+ anims.push(animated.x.to(targetEl.position.x, { duration: elementDuration, easing }));
1201
+ anims.push(animated.y.to(targetEl.position.y, { duration: elementDuration, easing }));
1202
+ anims.push(animated.width.to(targetEl.size.width, { duration: elementDuration, easing }));
1203
+ anims.push(animated.height.to(targetEl.size.height, { duration: elementDuration, easing }));
1204
+ anims.push(animated.rotation.to(targetEl.rotation, { duration: elementDuration, easing }));
1205
+ anims.push(animated.skewX.to(targetEl.skewX ?? 0, { duration: elementDuration, easing }));
1206
+ anims.push(animated.skewY.to(targetEl.skewY ?? 0, { duration: elementDuration, easing }));
1207
+ anims.push(animated.tiltX.to(targetEl.tiltX ?? 0, { duration: elementDuration, easing }));
1208
+ anims.push(animated.tiltY.to(targetEl.tiltY ?? 0, { duration: elementDuration, easing }));
1209
+ anims.push(animated.perspective.to(targetEl.perspective ?? 1000, { duration: elementDuration, easing }));
1210
+ anims.push(animated.borderRadius.to((targetEl as any).borderRadius ?? 0, { duration: elementDuration, easing }));
1211
+ anims.push(animated.blur.to(targetEl.blur ?? 0, { duration: elementDuration, easing }));
1212
+ anims.push(animated.brightness.to(targetEl.brightness ?? 100, { duration: elementDuration, easing }));
1213
+ anims.push(animated.contrast.to(targetEl.contrast ?? 100, { duration: elementDuration, easing }));
1214
+ anims.push(animated.saturate.to(targetEl.saturate ?? 100, { duration: elementDuration, easing }));
1215
+ anims.push(animated.grayscale.to(targetEl.grayscale ?? 0, { duration: elementDuration, easing }));
1216
+ // Opacity interpolation for morphing elements
1217
+ const currOpacity = animated.opacity.current;
1218
+ const targetOpacity = (targetEl as any).opacity ?? 1;
1219
+ if (currOpacity !== targetOpacity) {
1220
+ anims.push(animated.opacity.to(targetOpacity, { duration: elementDuration, easing }));
1221
+ }
1222
+ }
1223
+ // Motion path progress — await reset, then animate forward
1224
+ if (animated.motionPathProgress && targetEl.motionPathConfig) {
1225
+ const shouldLoop = targetEl.motionPathConfig.loop;
1226
+ if (!shouldLoop) {
1227
+ anims.push((async () => {
1228
+ await animated.motionPathProgress!.to(0, { duration: 0 });
1229
+ await animated.motionPathProgress!.to(1, { duration: elementDuration, easing });
1230
+ })());
1231
+ }
1232
+ }
1233
+ if (targetEl.type === 'text' && animated.fontSize) anims.push(animated.fontSize.to((targetEl as TextElement).fontSize, { duration: elementDuration, easing }));
1234
+ if (targetEl.type === 'shape' && currentEl?.type === 'shape') {
1235
+ const ts = targetEl as ShapeElement;
1236
+ const cs = currentEl as ShapeElement;
1237
+ if (!propertySequences?.length) {
1238
+ if (animated.fillColor) anims.push(animated.fillColor.to(ts.fillColor, { duration: elementDuration, easing }));
1239
+ if (animated.strokeColor) anims.push(animated.strokeColor.to(ts.strokeColor, { duration: elementDuration, easing }));
1240
+ if (animated.strokeWidth) anims.push(animated.strokeWidth.to(ts.strokeWidth, { duration: elementDuration, easing }));
1241
+ }
1242
+ if (cs.shapeType !== ts.shapeType && animated.shapeMorph) {
1243
+ shapeMorphStates.set(elementId, { fromType: cs.shapeType, toType: ts.shapeType });
1244
+ shapeMorphStates = new Map(shapeMorphStates);
1245
+ anims.push(animated.shapeMorph.to(0, { duration: 0 }));
1246
+ anims.push(animated.shapeMorph.to(1, { duration: elementDuration, easing }));
1247
+ }
1248
+ } else if (targetEl.type === 'shape' && !propertySequences?.length) {
1249
+ const s = targetEl as ShapeElement;
1250
+ if (animated.fillColor) anims.push(animated.fillColor.to(s.fillColor, { duration: elementDuration, easing }));
1251
+ if (animated.strokeColor) anims.push(animated.strokeColor.to(s.strokeColor, { duration: elementDuration, easing }));
1252
+ if (animated.strokeWidth) anims.push(animated.strokeWidth.to(s.strokeWidth, { duration: elementDuration, easing }));
1253
+ }
1254
+ if (!currentEl) {
1255
+ // Snap ALL properties to target instantly — the tween may hold
1256
+ // stale values from a previous slide where the element last appeared
1257
+ anims.push(animated.x.to(targetEl.position.x, { duration: 0 }));
1258
+ anims.push(animated.y.to(targetEl.position.y, { duration: 0 }));
1259
+ anims.push(animated.width.to(targetEl.size.width, { duration: 0 }));
1260
+ anims.push(animated.height.to(targetEl.size.height, { duration: 0 }));
1261
+ anims.push(animated.rotation.to(targetEl.rotation, { duration: 0 }));
1262
+ anims.push(animated.skewX.to(targetEl.skewX ?? 0, { duration: 0 }));
1263
+ anims.push(animated.skewY.to(targetEl.skewY ?? 0, { duration: 0 }));
1264
+ anims.push(animated.tiltX.to(targetEl.tiltX ?? 0, { duration: 0 }));
1265
+ anims.push(animated.tiltY.to(targetEl.tiltY ?? 0, { duration: 0 }));
1266
+ anims.push(animated.perspective.to(targetEl.perspective ?? 1000, { duration: 0 }));
1267
+ anims.push(animated.borderRadius.to((targetEl as any).borderRadius ?? 0, { duration: 0 }));
1268
+ anims.push(animated.blur.to(targetEl.blur ?? 0, { duration: 0 }));
1269
+ anims.push(animated.brightness.to(targetEl.brightness ?? 100, { duration: 0 }));
1270
+ anims.push(animated.contrast.to(targetEl.contrast ?? 100, { duration: 0 }));
1271
+ anims.push(animated.saturate.to(targetEl.saturate ?? 100, { duration: 0 }));
1272
+ anims.push(animated.grayscale.to(targetEl.grayscale ?? 0, { duration: 0 }));
1273
+ if (targetEl.type === 'text' && animated.fontSize) {
1274
+ anims.push(animated.fontSize.to((targetEl as TextElement).fontSize, { duration: 0 }));
1275
+ }
1276
+ if (targetEl.type === 'shape') {
1277
+ const s = targetEl as ShapeElement;
1278
+ if (animated.fillColor) anims.push(animated.fillColor.to(s.fillColor, { duration: 0 }));
1279
+ if (animated.strokeColor) anims.push(animated.strokeColor.to(s.strokeColor, { duration: 0 }));
1280
+ if (animated.strokeWidth) anims.push(animated.strokeWidth.to(s.strokeWidth, { duration: 0 }));
1281
+ }
1282
+ const entrance = targetEl.animationConfig?.entrance ?? 'fade';
1283
+ const targetOpacity = (targetEl as any).opacity ?? 1;
1284
+ const presetKf = entranceRuntimeKeyframe(entrance);
1285
+ if (presetKf && entrance !== 'fade' && entrance !== 'none') {
1286
+ anims.push(animated.opacity.to(targetOpacity, { duration: 0 }));
1287
+ presenterRegisterRuntimeAnim(targetEl.id, 'entrance', presetKf, targetEl.animationConfig?.entranceDuration ?? elementDuration, order * 100 + delay);
1288
+ } else if (entrance === 'fade') {
1289
+ anims.push(animated.opacity.to(targetOpacity, { duration: elementDuration / 2, easing }));
1290
+ } else {
1291
+ anims.push(animated.opacity.to(targetOpacity, { duration: 0 }));
1292
+ }
1293
+ }
1294
+ return anims;
1295
+ }
1296
+ });
1297
+ } else if (currentEl) {
1298
+ // Default to instant when no exit preset is set; explicit 'fade' still fades.
1299
+ const exit = currentEl.animationConfig?.exit ?? 'none';
1300
+ const exitKf = exitRuntimeKeyframe(exit);
1301
+ if (exitKf && exit !== 'fade' && exit !== 'none') {
1302
+ const exitDuration = currentEl.animationConfig?.exitDuration ?? elementDuration;
1303
+ presenterRegisterRuntimeAnim(currentEl.id, 'exit', exitKf, exitDuration);
1304
+ animationTasks.push({
1305
+ elementId,
1306
+ order,
1307
+ delay,
1308
+ elementDuration: exitDuration,
1309
+ run: () => [(async () => {
1310
+ await new Promise(r => setTimeout(r, exitDuration));
1311
+ await animated.opacity.to(0, { duration: 0 });
1312
+ })()]
1313
+ });
1314
+ } else if (exit === 'fade') {
1315
+ const fadeOutDuration = Math.min(elementDuration / 2, 300);
1316
+ animationTasks.push({ elementId, order, delay, elementDuration, run: () => [animated.opacity.to(0, { duration: fadeOutDuration, easing: easeInOutCubic })] });
1317
+ } else {
1318
+ animationTasks.push({ elementId, order, delay: 0, elementDuration: 0, run: () => [animated.opacity.to(0, { duration: 0 })] });
1319
+ }
1320
+ }
1321
+ }
1322
+
1323
+ animationTasks.sort((a, b) => a.order - b.order);
1324
+ const orderGroups = new Map<number, AnimationTask[]>();
1325
+ for (const task of animationTasks) {
1326
+ if (!orderGroups.has(task.order)) orderGroups.set(task.order, []);
1327
+ orderGroups.get(task.order)!.push(task);
1328
+ }
1329
+ const sortedOrders = [...orderGroups.keys()].sort((a, b) => a - b);
1330
+ for (let orderIdx = 0; orderIdx < sortedOrders.length; orderIdx++) {
1331
+ const order = sortedOrders[orderIdx];
1332
+ const tasks = orderGroups.get(order)!;
1333
+ const groupAnimations: Promise<void>[] = [];
1334
+ for (const task of tasks) {
1335
+ if (task.delay > 0) setTimeout(() => { task.run().forEach(p => animations.push(p)); }, task.delay);
1336
+ else groupAnimations.push(...task.run());
1337
+ }
1338
+ animations.push(...groupAnimations);
1339
+ if (orderIdx < sortedOrders.length - 1) {
1340
+ const maxDur = Math.max(...tasks.map(t => t.elementDuration));
1341
+ await new Promise(r => setTimeout(r, maxDur * 0.3));
1342
+ }
1343
+ }
1344
+
1345
+ const newElementContent = new Map(elementContent);
1346
+ const newCodeMorphState = new Map(codeMorphState);
1347
+ const newPreviousCodeContent = new Map(previousCodeContent);
1348
+ for (const elementId of allElementIds) {
1349
+ const targetEl = getElementInSlide(targetSlide, elementId);
1350
+ if (targetEl) {
1351
+ if (targetEl.type === 'code') {
1352
+ const codeEl = targetEl as CodeElement;
1353
+ const prevCode = newPreviousCodeContent.get(elementId) || '';
1354
+ newCodeMorphState.set(elementId, { oldCode: prevCode, newCode: codeEl.code, mode: codeEl.animation?.mode || 'highlight-changes', speed: codeEl.animation?.typewriterSpeed || 50, highlightColor: codeEl.animation?.highlightColor || '#fef08a' });
1355
+ newPreviousCodeContent.set(elementId, codeEl.code);
1356
+ }
1357
+ newElementContent.set(elementId, JSON.parse(JSON.stringify(targetEl)));
1358
+ }
1359
+ }
1360
+ for (const [, element] of newElementContent) {
1361
+ if (element.type === 'code') {
1362
+ const codeEl = element as CodeElement;
1363
+ const key = `${codeEl.id}-${codeEl.code}-${codeEl.language}-${codeEl.showLineNumbers}`;
1364
+ if (!codeHighlights.has(key)) {
1365
+ const html = await highlightCode(codeEl.code, codeEl.language, codeEl.theme, { showLineNumbers: codeEl.showLineNumbers });
1366
+ codeHighlights.set(key, html);
1367
+ }
1368
+ }
1369
+ }
1370
+ codeHighlights = new Map(codeHighlights);
1371
+ shapeMorphStates = new Map();
1372
+ codeMorphState = newCodeMorphState;
1373
+ previousCodeContent = newPreviousCodeContent;
1374
+ elementContent = newElementContent;
1375
+ currentSlideIndex = targetIndex;
1376
+ isTransitioning = false;
1377
+ // Ensure elements not on the new slide are fully hidden. Phase 2:
1378
+ // skip elements with an active runtime exit registration — their
1379
+ // exit task snaps opacity itself after the keyframe finishes, so a
1380
+ // preemptive snap here would cut the visual exit short.
1381
+ const newSlide = slides[targetIndex];
1382
+ const reg = typeof window !== 'undefined' ? (window as any).__animotPresenterRuntime as Map<string, { exit?: string }> | undefined : undefined;
1383
+ for (const elementId of allElementIds) {
1384
+ const onSlide = getElementInSlide(newSlide, elementId);
1385
+ const animated = animatedElements.get(elementId);
1386
+ const hasActiveExit = !!reg?.get(elementId)?.exit;
1387
+ if (!onSlide && animated && !hasActiveExit) { animated.opacity.to(0, { duration: 0 }); }
1388
+ }
1389
+ animateKeyframes(slides[targetIndex]);
1390
+ animateMotionPaths(slides[targetIndex]);
1391
+ onslidechange?.(targetIndex, slides.length);
1392
+ if (targetIndex === slides.length - 1 && !loop) oncomplete?.();
1393
+ }
1394
+
1395
+ // Autoplay
1396
+ function clearAutoplayTimer() { if (autoplayTimer) { clearTimeout(autoplayTimer); autoplayTimer = null; } }
1397
+ function scheduleNextSlide() {
1398
+ clearAutoplayTimer();
1399
+ if (!isAutoplay) return;
1400
+ const slideDuration = durationOverride ?? currentSlide?.duration ?? 3000;
1401
+ autoplayTimer = setTimeout(() => {
1402
+ if (currentSlideIndex < slides.length - 1) animateToSlide(currentSlideIndex + 1);
1403
+ else if (loop) {
1404
+ const loopMode = project?.settings?.loopMode ?? 'reset';
1405
+ if (loopMode === 'transition') animateToSlide(0);
1406
+ else resetToFirstSlide();
1407
+ }
1408
+ else isAutoplay = false;
1409
+ }, slideDuration);
1410
+ }
1411
+ $effect(() => { if (isAutoplay && !isTransitioning) scheduleNextSlide(); });
1412
+ $effect(() => () => clearAutoplayTimer());
1413
+
1414
+ function handleNextSlide() {
1415
+ if (currentSlideIndex < slides.length - 1) {
1416
+ animateToSlide(currentSlideIndex + 1);
1417
+ } else if (loop) {
1418
+ const loopMode = project?.settings?.loopMode ?? 'reset';
1419
+ if (loopMode === 'transition') {
1420
+ animateToSlide(0);
1421
+ } else {
1422
+ resetToFirstSlide();
1423
+ }
1424
+ }
1425
+ }
1426
+
1427
+ // Keyboard
1428
+ function handleKeyDown(e: KeyboardEvent) {
1429
+ if (!keyboard) return;
1430
+ if (e.key === 'ArrowRight' || e.key === ' ' || e.key === 'Enter') { e.preventDefault(); handleNextSlide(); }
1431
+ else if (e.key === 'ArrowLeft' || e.key === 'Backspace') { e.preventDefault(); animateToSlide(currentSlideIndex - 1); }
1432
+ else if (e.key === 'Home') animateToSlide(0);
1433
+ else if (e.key === 'End') animateToSlide(slides.length - 1);
1434
+ else if (e.key === 'p' || e.key === 'P') { isAutoplay = !isAutoplay; if (!isAutoplay) clearAutoplayTimer(); }
1435
+ }
1436
+
1437
+ function resetMouseIdleTimer() {
1438
+ menuVisible = true;
1439
+ if (mouseIdleTimer) clearTimeout(mouseIdleTimer);
1440
+ mouseIdleTimer = setTimeout(() => { menuVisible = false; }, 3000);
1441
+ }
1442
+
1443
+ // Code highlight helpers
1444
+ async function loadCodeHighlights() {
1445
+ for (const slide of slides) {
1446
+ for (const element of slide.canvas.elements) {
1447
+ if (element.type === 'code') {
1448
+ const codeEl = element as CodeElement;
1449
+ const key = `${codeEl.id}-${codeEl.code}-${codeEl.language}-${codeEl.showLineNumbers}`;
1450
+ if (!codeHighlights.has(key)) {
1451
+ const html = await highlightCode(codeEl.code, codeEl.language, codeEl.theme, { showLineNumbers: codeEl.showLineNumbers });
1452
+ codeHighlights.set(key, html);
1453
+ }
1454
+ }
1455
+ }
1456
+ }
1457
+ codeHighlights = new Map(codeHighlights);
1458
+ }
1459
+
1460
+ function getCodeHighlight(elementId: string): string {
1461
+ const slideElement = getElementInSlide(currentSlide, elementId);
1462
+ if (!slideElement || slideElement.type !== 'code') return '';
1463
+ const codeEl = slideElement as CodeElement;
1464
+ const key = `${codeEl.id}-${codeEl.code}-${codeEl.language}-${codeEl.showLineNumbers}`;
1465
+ const cached = codeHighlights.get(key);
1466
+ if (cached) return cached;
1467
+ highlightCode(codeEl.code, codeEl.language, codeEl.theme, { showLineNumbers: codeEl.showLineNumbers }).then(html => {
1468
+ codeHighlights.set(key, html);
1469
+ codeHighlights = new Map(codeHighlights);
1470
+ });
1471
+ return '';
1472
+ }
1473
+
1474
+ // Public API (exposed via bind:this)
1475
+ export async function goto(slideIndex: number) { await animateToSlide(slideIndex); }
1476
+ export async function next() { handleNextSlide(); }
1477
+ export async function prev() { await animateToSlide(currentSlideIndex - 1); }
1478
+ export function play() { isAutoplay = true; }
1479
+ export function pause() { isAutoplay = false; clearAutoplayTimer(); }
1480
+ export function getCurrentSlide() { return currentSlideIndex; }
1481
+ export function getTotalSlides() { return slides.length; }
1482
+ export function getIsPlaying() { return isAutoplay; }
1483
+
1484
+ // Auto-load Google Fonts used by text elements in the project.
1485
+ // Generic CSS font families that don't need loading
1486
+ const GENERIC_FONTS = new Set([
1487
+ 'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy',
1488
+ 'system-ui', 'ui-serif', 'ui-sans-serif', 'ui-monospace', 'ui-rounded',
1489
+ 'math', 'emoji', 'fangsong', 'inherit', 'initial', 'unset'
1490
+ ]);
1491
+
1492
+ // Extract individual font names from a CSS font-family string.
1493
+ // e.g. '"JetBrains Mono", system-ui, monospace' → ['JetBrains Mono']
1494
+ function extractFontNames(fontFamily: string): string[] {
1495
+ return fontFamily
1496
+ .split(',')
1497
+ .map(f => f.trim().replace(/^['"]|['"]$/g, ''))
1498
+ .filter(f => f && !GENERIC_FONTS.has(f.toLowerCase()));
1499
+ }
1500
+
1501
+ // Auto-load fonts used by text/counter elements.
1502
+ // Uses fontsource CDN (jsDelivr) which registers the SAME font-family names
1503
+ // as the app (e.g. "Plus Jakarta Sans Variable"), unlike Google Fonts which
1504
+ // strips the "Variable" suffix.
1505
+ function loadProjectFonts(proj: AnimotProject) {
1506
+ const fonts = new Set<string>();
1507
+ for (const slide of proj.slides) {
1508
+ for (const el of slide.canvas.elements) {
1509
+ if (el.type === 'text' || el.type === 'counter') {
1510
+ const f = (el as any).fontFamily as string | undefined;
1511
+ if (f) {
1512
+ for (const name of extractFontNames(f)) fonts.add(name);
1513
+ }
1514
+ }
1515
+ }
1516
+ }
1517
+ if (fonts.size === 0) return;
1518
+
1519
+ // Deduplicate against already-injected links to avoid double-loading
1520
+ const loaded = new Set<string>();
1521
+ document.querySelectorAll<HTMLLinkElement>('link[data-animot-font]').forEach(l => loaded.add(l.dataset.animotFont!));
1522
+
1523
+ for (const font of fonts) {
1524
+ if (loaded.has(font)) continue;
1525
+ const isVariable = /\s+Variable$/i.test(font);
1526
+ // Convert font name to fontsource package slug:
1527
+ // "Plus Jakarta Sans Variable" "plus-jakarta-sans"
1528
+ // "JetBrains Mono" → "jetbrains-mono"
1529
+ const baseName = font.replace(/\s*Variable$/i, '');
1530
+ const slug = baseName.toLowerCase().replace(/\s+/g, '-');
1531
+ const pkg = isVariable
1532
+ ? `@fontsource-variable/${slug}`
1533
+ : `@fontsource/${slug}`;
1534
+ const link = document.createElement('link');
1535
+ link.rel = 'stylesheet';
1536
+ link.href = `https://cdn.jsdelivr.net/npm/${pkg}/index.css`;
1537
+ link.dataset.animotFont = font;
1538
+ document.head.appendChild(link);
1539
+ }
1540
+ }
1541
+
1542
+ // Load data
1543
+ async function loadProject() {
1544
+ loading = true; error = null;
1545
+ try {
1546
+ if (data) { project = data; }
1547
+ else if (src) {
1548
+ const res = await fetch(src);
1549
+ if (!res.ok) throw new Error(`Failed to load: ${res.status}`);
1550
+ project = await res.json();
1551
+ } else { throw new Error('Either src or data prop is required'); }
1552
+ loadProjectFonts(project!);
1553
+ currentSlideIndex = startSlide;
1554
+ await new Promise(r => setTimeout(r, 10));
1555
+ initAllAnimatedElements();
1556
+ await loadCodeHighlights();
1557
+ loading = false;
1558
+ if (currentSlide) setTimeout(() => { animateKeyframes(currentSlide!); animateMotionPaths(currentSlide!); }, 300);
1559
+ // Narration starts via the play-state effect below not on
1560
+ // mount. That way the user's click on Play is the gesture
1561
+ // that unlocks audio, and a paused deck stays silent.
1562
+ if (autoplay) isAutoplay = true;
1563
+ } catch (e: any) { error = e.message; loading = false; }
1564
+ }
1565
+
1566
+ // ResizeObserver
1567
+ let resizeObserver: ResizeObserver;
1568
+
1569
+ // Phase 2 sprint-1 polish — fire entrance presets for elements on the
1570
+ // currently visible slide. Used at initial load AND every time the
1571
+ // reactive `currentSlide` flips (covers navigation, autoplay loop reset,
1572
+ // remote-controlled slide jumps).
1573
+ function firePresenterEntrancePresets(slideIdx: number) {
1574
+ const slide = slides[slideIdx];
1575
+ if (!slide) return;
1576
+ for (const el of slide.canvas.elements) {
1577
+ const mode = el.animationConfig?.entrance;
1578
+ if (!mode || mode === 'none' || mode === 'fade') continue;
1579
+ const kf = entranceRuntimeKeyframe(mode);
1580
+ if (!kf) continue;
1581
+ const dur = el.animationConfig?.entranceDuration ?? el.animationConfig?.duration ?? 600;
1582
+ const delay = (el.animationConfig?.order ?? 0) * 100 + (el.animationConfig?.delay ?? 0);
1583
+ presenterRegisterRuntimeAnim(el.id, 'entrance', kf, dur, delay);
1584
+ }
1585
+ }
1586
+
1587
+ onMount(() => {
1588
+ loadProject();
1589
+ resizeObserver = new ResizeObserver(entries => {
1590
+ for (const entry of entries) {
1591
+ containerWidth = entry.contentRect.width;
1592
+ containerHeight = entry.contentRect.height;
1593
+ }
1594
+ });
1595
+ if (containerEl) resizeObserver.observe(containerEl);
1596
+ resetMouseIdleTimer();
1597
+ // Slide 0 entrance presets need to fire on initial mount — the
1598
+ // transition pipeline only runs on navigation, not on load.
1599
+ queueMicrotask(() => firePresenterEntrancePresets(currentSlideIndex));
1600
+
1601
+ return () => {
1602
+ resizeObserver?.disconnect();
1603
+ clearAutoplayTimer();
1604
+ clearAllTypewriterAnimations();
1605
+ if (mouseIdleTimer) clearTimeout(mouseIdleTimer);
1606
+ stopNarration();
1607
+ // Phase 2 — clean up runtime entrance/exit timers + registry to
1608
+ // avoid leaks when the presenter is unmounted (e.g. modal close).
1609
+ for (const t of runtimeAnimTimersPresenter.values()) clearTimeout(t);
1610
+ runtimeAnimTimersPresenter.clear();
1611
+ if (typeof window !== 'undefined') {
1612
+ const reg = (window as any).__animotPresenterRuntime as Map<string, unknown> | undefined;
1613
+ reg?.clear();
1614
+ }
1615
+ };
1616
+ });
1617
+
1618
+ // Narration follows the deck's play/pause state. The play button click
1619
+ // flips `isAutoplay` true this effect fires audio starts. The
1620
+ // click itself is the user gesture that unlocks the browser audio
1621
+ // context. Pause/stop turns it back off. Each presenter instance
1622
+ // scopes its own audio, so multiple decks on a page never overlap.
1623
+ $effect(() => {
1624
+ if (!project?.settings?.narrationEnabled) return;
1625
+ if (isAutoplay) {
1626
+ playNarrationForSlide(currentSlideIndex);
1627
+ } else {
1628
+ pauseNarration();
1629
+ }
1630
+ });
1631
+
1632
+ // Watch for prop changes
1633
+ $effect(() => { if (data) { project = data; } });
1634
+ </script>
1635
+
1636
+ <svelte:window onkeydown={handleKeyDown} />
1637
+
1638
+ <div
1639
+ class="animot-presenter {className}"
1640
+ class:animot-menu-visible={menuVisible}
1641
+ bind:this={containerEl}
1642
+ onmousemove={resetMouseIdleTimer}
1643
+ role="region"
1644
+ aria-label="Animot Presentation"
1645
+ >
1646
+ {#if loading}
1647
+ <div class="animot-loading"><div class="animot-spinner"></div></div>
1648
+ {:else if error}
1649
+ <div class="animot-error">{error}</div>
1650
+ {:else if project && currentSlide}
1651
+ <div class="animot-canvas-wrapper" style:transform="scale({presentationScale})">
1652
+ <div
1653
+ class="animot-canvas {transitionClass}"
1654
+ class:forward={transitionDirection === 'forward'}
1655
+ class:backward={transitionDirection === 'backward'}
1656
+ style:width="{canvasWidth}px"
1657
+ style:height="{canvasHeight}px"
1658
+ style:--transition-duration="{transitionDurationMs}ms"
1659
+ style={backgroundStyle}
1660
+ >
1661
+ {#if currentSlide.canvas.background.particles?.enabled}
1662
+ <ParticlesBackground config={currentSlide.canvas.background.particles} width={canvasWidth} height={canvasHeight} />
1663
+ {/if}
1664
+ {#if currentSlide.canvas.background.confetti?.enabled}
1665
+ <ConfettiEffect config={currentSlide.canvas.background.confetti} width={canvasWidth} height={canvasHeight} />
1666
+ {/if}
1667
+
1668
+ <div
1669
+ class="animot-cinema-camera"
1670
+ class:active={isCinemaMode}
1671
+ style:transform={cinemaCameraTransform}
1672
+ style:--cinema-transition-duration="{transitionDurationMs}ms"
1673
+ >
1674
+ {#each sortedElementIds as elementId}
1675
+ {@const element = elementContent.get(elementId)}
1676
+ {@const animated = animatedElements.get(elementId)}
1677
+ {@const floatCfg = element?.floatingAnimation}
1678
+ {@const hasFloat = floatCfg?.enabled}
1679
+ {@const floatGroupId = element?.groupId}
1680
+ {@const mpConfig = element?.motionPathConfig}
1681
+ {@const mpElement = mpConfig ? currentSlide?.canvas.elements.find(el => el.id === mpConfig.motionPathId) as MotionPathElement | undefined : undefined}
1682
+ {@const mpProgress = animated?.motionPathProgress?.current ?? 0}
1683
+ {@const mpPoint = mpElement && mpConfig ? getPresenterPointOnPath(mpElement.points, mpElement.closed, (mpConfig.startPercent + (mpConfig.endPercent - mpConfig.startPercent) * mpProgress) / 100) : null}
1684
+ {@const mpStartPoint = mpElement && mpConfig ? getPresenterPointOnPath(mpElement.points, mpElement.closed, mpConfig.startPercent / 100) : null}
1685
+ {@const mpPos = mpPoint && mpStartPoint && animated && mpElement
1686
+ ? computeMotionPathPosition(mpPoint, mpStartPoint,
1687
+ animated.x.current, animated.y.current,
1688
+ animated.width.current, animated.height.current,
1689
+ mpElement.closed)
1690
+ : null}
1691
+ {@const elemX = mpPos ? mpPos.x : (animated?.x.current ?? 0)}
1692
+ {@const elemY = mpPos ? mpPos.y : (animated?.y.current ?? 0)}
1693
+ {@const mpRotation = mpPoint && mpConfig?.autoRotate
1694
+ ? mpPoint.angle + (mpConfig.orientationOffset ?? 0)
1695
+ : null}
1696
+ {@const parallax = isCinemaMode && element?.depth ? parallaxOffset(currentCamera, element.depth, worldWidth, worldHeight) : { x: 0, y: 0 }}
1697
+ {#if element && animated && animated.opacity.current > 0.01 && element.visible !== false && !(element.type === 'motionPath' && !(element as MotionPathElement).showInPresentation)}
1698
+ <div
1699
+ class="animot-element"
1700
+ class:floating={hasFloat}
1701
+ style:left="{elemX}px"
1702
+ style:top="{elemY}px"
1703
+ style:translate="{parallax.x}px {parallax.y}px"
1704
+ style:width="{animated.width.current}px"
1705
+ style:height="{animated.height.current}px"
1706
+ style:opacity={animated.opacity.current}
1707
+ style:transform="perspective({animated.perspective.current}px) rotateX({animated.tiltX.current}deg) rotateY({animated.tiltY.current}deg) rotate({mpRotation ?? animated.rotation.current}deg) skewX({animated.skewX.current}deg) skewY({animated.skewY.current}deg)"
1708
+ style:transform-origin={element.tiltOrigin ?? 'center'}
1709
+ style:backface-visibility={element.backfaceVisibility ?? 'visible'}
1710
+ style:z-index={element.zIndex}
1711
+ style:--float-amp="{hasFloat ? computeFloatAmp(floatCfg, floatGroupId || elementId) : 10}px"
1712
+ style:--float-amp-scale={hasFloat ? 1 + computeFloatAmp(floatCfg, floatGroupId || elementId) / 200 : 1.05}
1713
+ style:--float-amp-rot="{hasFloat ? computeFloatAmp(floatCfg, floatGroupId || elementId) / 2 : 5}deg"
1714
+ style:--float-speed="{hasFloat ? computeFloatSpeed(floatCfg, floatGroupId || elementId) : 3}s"
1715
+ style:--float-delay="{hashFraction(floatGroupId || elementId, 3) * 2}s"
1716
+ style:animation={(() => {
1717
+ // Phase 2 — single CSS `animation:` shorthand composing
1718
+ // runtime exit + entrance + emphasis + floating. One
1719
+ // inline write keeps all layers in sync.
1720
+ void runtimeBump;
1721
+ const parts: string[] = [];
1722
+ const ra = (typeof window !== 'undefined' && (window as any).__animotPresenterRuntime)
1723
+ ? (window as any).__animotPresenterRuntime.get(elementId)
1724
+ : null;
1725
+ if (ra?.exit) {
1726
+ const xd = ra.exitDelayMs ?? 0;
1727
+ if (xd > 0) parts.push(`${ra.durationMs}ms ease-in ${xd}ms 1 forwards ${ra.exit}`);
1728
+ else parts.push(`${ra.durationMs}ms ease-in 1 forwards ${ra.exit}`);
1729
+ }
1730
+ if (ra?.entrance) {
1731
+ const ed = ra.entranceDelayMs ?? 0;
1732
+ if (ed > 0) parts.push(`${ra.durationMs}ms ease-out ${ed}ms 1 both ${ra.entrance}`);
1733
+ else parts.push(`${ra.durationMs}ms ease-out 1 both ${ra.entrance}`);
1734
+ }
1735
+ const emphKf = emphasisKeyframeName(element?.animationConfig?.emphasis);
1736
+ if (emphKf) {
1737
+ const isOneShot = element?.animationConfig?.emphasis === 'tada' || element?.animationConfig?.emphasis === 'bob-once';
1738
+ const cfgDur = element?.animationConfig?.emphasisDuration;
1739
+ const defaultDur = isOneShot ? 1200 : 2200;
1740
+ const dur = typeof cfgDur === 'number' && cfgDur > 0 ? cfgDur : defaultDur;
1741
+ const delay = element?.animationConfig?.emphasisDelay ?? 0;
1742
+ parts.push(`${dur}ms ease-in-out ${delay}ms ${isOneShot ? '1' : 'infinite'} ${isOneShot ? 'both' : 'none'} ${emphKf}`);
1743
+ }
1744
+ if (hasFloat) {
1745
+ const speed = computeFloatSpeed(floatCfg, floatGroupId || elementId);
1746
+ parts.push(`${speed}s ease-in-out infinite none ${getIdleAnimName(floatCfg, floatGroupId || elementId)}`);
1747
+ }
1748
+ return parts.length ? parts.join(', ') : 'none';
1749
+ })()}
1750
+ style:filter={(() => { const parts: string[] = []; const b = animated.blur.current; const br2 = animated.brightness.current; const c = animated.contrast.current; const s = animated.saturate.current; const g = animated.grayscale.current; if (b) parts.push(`blur(${b}px)`); if (br2 !== 100) parts.push(`brightness(${br2}%)`); if (c !== 100) parts.push(`contrast(${c}%)`); if (s !== 100) parts.push(`saturate(${s}%)`); if (g) parts.push(`grayscale(${g}%)`); return parts.length ? parts.join(' ') : 'none'; })()}
1751
+ use:decorations={{ config: element.decorations, slideDuration: currentSlide?.duration, shape: element.type === 'shape' ? { type: (element as any).shapeType, borderRadius: (element as any).borderRadius } : undefined, key: `${currentSlideIndex}-${JSON.stringify(element.decorations ?? null)}-${element.type === 'shape' ? (element as any).shapeType + ':' + ((element as any).borderRadius ?? 0) : ''}` }}
1752
+ >
1753
+ {#if element.type === 'code'}
1754
+ {@const codeEl = liveProps(element) as CodeElement}
1755
+ {@const morphState = codeMorphState.get(codeEl.id)}
1756
+ <div class="animot-code-block" class:transparent-bg={codeEl.transparentBackground} style:font-size="{codeEl.fontSize}px" style:font-weight={codeEl.fontWeight || 400} style:padding="{codeEl.padding}px" style:border-radius="{animated.borderRadius.current}px" style:background={codeEl.bgColor ?? '#0d1117'}>
1757
+ {#if codeEl.showHeader}
1758
+ <div class="animot-code-header" class:macos={codeEl.headerStyle === 'macos'} class:windows={codeEl.headerStyle === 'windows'} style:border-radius="{codeEl.headerRadius ?? animated.borderRadius.current}px {codeEl.headerRadius ?? animated.borderRadius.current}px 0 0">
1759
+ {#if codeEl.headerStyle === 'macos'}
1760
+ <div class="animot-window-controls">
1761
+ <span class="animot-control close"></span>
1762
+ <span class="animot-control minimize"></span>
1763
+ <span class="animot-control maximize"></span>
1764
+ </div>
1765
+ {:else if codeEl.headerStyle === 'windows'}
1766
+ <div class="animot-window-controls">
1767
+ <span class="animot-control win-minimize">
1768
+ <svg width="10" height="10" viewBox="0 0 10 10"><path d="M2 5h6" stroke="currentColor" stroke-width="1.2"/></svg>
1769
+ </span>
1770
+ <span class="animot-control win-maximize">
1771
+ <svg width="10" height="10" viewBox="0 0 10 10"><rect x="1.5" y="1.5" width="7" height="7" rx="0.5" fill="none" stroke="currentColor" stroke-width="1.2"/></svg>
1772
+ </span>
1773
+ <span class="animot-control win-close">
1774
+ <svg width="10" height="10" viewBox="0 0 10 10"><path d="M2 2l6 6M8 2l-6 6" stroke="currentColor" stroke-width="1.2"/></svg>
1775
+ </span>
1776
+ </div>
1777
+ {/if}
1778
+ <div class="animot-filename-tab" style:border-radius="{codeEl.tabRadius ?? 6}px">
1779
+ <svg class="animot-file-icon" width="14" height="14" viewBox="0 0 16 16" fill="none">
1780
+ <path d="M4 1h5.5L13 4.5V14a1 1 0 01-1 1H4a1 1 0 01-1-1V2a1 1 0 011-1z" stroke="currentColor" stroke-width="1.2" opacity="0.5"/>
1781
+ <path d="M9.5 1v3.5H13" stroke="currentColor" stroke-width="1.2" opacity="0.5"/>
1782
+ </svg>
1783
+ <span class="animot-filename">{codeEl.filename}</span>
1784
+ </div>
1785
+ <button class="animot-copy-code-btn" onclick={(e) => { e.stopPropagation(); navigator.clipboard.writeText(codeEl.code); const btn = e.currentTarget as HTMLElement; btn.classList.add('copied'); setTimeout(() => btn.classList.remove('copied'), 1500); }}>
1786
+ <span class="animot-copy-label">Copy</span><span class="animot-copied-label">Copied!</span>
1787
+ <svg class="animot-copy-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
1788
+ <svg class="animot-check-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"/></svg>
1789
+ </button>
1790
+ </div>
1791
+ {:else}
1792
+ <button class="animot-copy-code-btn animot-floating" onclick={(e) => { e.stopPropagation(); navigator.clipboard.writeText(codeEl.code); const btn = e.currentTarget as HTMLElement; btn.classList.add('copied'); setTimeout(() => btn.classList.remove('copied'), 1500); }}>
1793
+ <span class="animot-copy-label">Copy</span><span class="animot-copied-label">Copied!</span>
1794
+ <svg class="animot-copy-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
1795
+ <svg class="animot-check-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"/></svg>
1796
+ </button>
1797
+ {/if}
1798
+ <div class="animot-code-content">
1799
+ <div class="animot-highlighted-code">
1800
+ {#if morphState && morphState.oldCode !== morphState.newCode && morphState.mode !== 'instant'}
1801
+ {#key currentSlideIndex}
1802
+ <CodeMorph oldCode={morphState?.oldCode ?? ''} newCode={morphState?.newCode ?? ''} language={codeEl.language} theme={codeEl.theme} mode={morphState?.mode ?? 'highlight-changes'} speed={morphState?.speed ?? 50} highlightColor={morphState?.highlightColor ?? '#fef08a'} highlightDuration={codeEl.animation?.highlightDuration || 1000} showLineNumbers={(getElementInSlide(currentSlide, codeEl.id) as CodeElement | undefined)?.showLineNumbers ?? false} />
1803
+ {/key}
1804
+ {:else}
1805
+ {@html getCodeHighlight(codeEl.id)}
1806
+ {/if}
1807
+ </div>
1808
+ </div>
1809
+ </div>
1810
+ {:else if element.type === 'text'}
1811
+ {@const textEl = liveProps(element) as TextElement}
1812
+ {@const animFontSize = animated.fontSize?.current ?? textEl.fontSize}
1813
+ {@const typewriterState = textTypewriterState.get(element.id)}
1814
+ {@const displayText = typewriterState?.isAnimating ? typewriterState.fullText.slice(0, typewriterState.displayedChars) : textEl.content}
1815
+ {@const textAnimMode = textEl.animation?.mode ?? 'instant'}
1816
+ {@const isActionTextMode = textAnimMode === 'fade-letters' || textAnimMode === 'bounce-in' || textAnimMode === 'handwriting' || textAnimMode === 'scramble-in' || textAnimMode === 'slot-machine' || textAnimMode === 'drop' || textAnimMode === 'glitch' || textAnimMode === 'marquee' || textAnimMode === 'blur-in' || textAnimMode === 'stretch' || textAnimMode === 'slide-words' || textAnimMode === 'wave' || textAnimMode === 'typewriter-erase'}
1817
+ <div
1818
+ class="animot-text-element"
1819
+ style:font-size="{animFontSize}px"
1820
+ style:font-weight={textEl.fontWeight}
1821
+ style:font-family="'{textEl.fontFamily}', sans-serif"
1822
+ style:font-style={textEl.fontStyle ?? 'normal'}
1823
+ style:text-decoration={textEl.textDecoration ?? 'none'}
1824
+ style:color={(textEl.gradient || textEl.backgroundImage) ? 'transparent' : (textEl.hollow && textEl.textStroke?.enabled ? 'transparent' : textEl.color)}
1825
+ style:background-color={(textEl.gradient || textEl.backgroundImage) ? 'transparent' : textEl.backgroundColor}
1826
+ style:background-image={textEl.gradient ? gradientShapeToCss(textEl.gradient) : textEl.backgroundImage ? `url(${textEl.backgroundImage})` : 'none'}
1827
+ style:background-size={textEl.backgroundImage && !textEl.gradient ? `${textEl.backgroundScale ?? 100}%` : 'cover'}
1828
+ style:background-position={textEl.backgroundImage && !textEl.gradient ? `${textEl.backgroundPositionX ?? 50}% ${textEl.backgroundPositionY ?? 50}%` : 'center'}
1829
+ style:-webkit-background-clip={(textEl.gradient || textEl.backgroundImage) ? 'text' : 'border-box'}
1830
+ style:background-clip={(textEl.gradient || textEl.backgroundImage) ? 'text' : 'border-box'}
1831
+ style:padding="{textEl.padding}px"
1832
+ style:border-radius="{textEl.borderRadius}px"
1833
+ style:text-align={textEl.textAlign}
1834
+ style:justify-content={textEl.textAlign === 'center' ? 'center' : textEl.textAlign === 'right' ? 'flex-end' : 'flex-start'}
1835
+ style:-webkit-text-stroke={textEl.textStroke?.enabled ? `${textEl.textStroke.width}px ${textEl.textStroke.color}` : '0'}
1836
+ style:text-shadow={textEl.textShadow?.enabled ? `${textEl.textShadow.offsetX}px ${textEl.textShadow.offsetY}px ${textEl.textShadow.blur}px ${textEl.textShadow.color}` : 'none'}
1837
+ use:textAnimate={{ enabled: isActionTextMode, mode: textAnimMode, content: textEl.content, duration: textEl.animation?.duration ?? 1500, stagger: textEl.animation?.stagger, loop: textEl.animation?.loop ?? false, color: textEl.color, fontSize: animFontSize, fontFamily: textEl.fontFamily, fontWeight: textEl.fontWeight, fontStyle: textEl.fontStyle, textAlign: textEl.textAlign, slideDuration: currentSlide?.duration, key: `text-${currentSlideIndex}` }}
1838
+ >
1839
+ {#if !isActionTextMode}{displayText}{#if typewriterState?.isAnimating}<span class="animot-typewriter-cursor">|</span>{/if}{/if}
1840
+ </div>
1841
+ {:else if element.type === 'arrow'}
1842
+ {@const arrowEl = liveProps(element) as ArrowElement}
1843
+ {@const cp = arrowEl.controlPoints || []}
1844
+ {@const pathD = cp.length === 0 ? `M ${arrowEl.startPoint.x} ${arrowEl.startPoint.y} L ${arrowEl.endPoint.x} ${arrowEl.endPoint.y}` : cp.length === 1 ? `M ${arrowEl.startPoint.x} ${arrowEl.startPoint.y} Q ${cp[0].x} ${cp[0].y} ${arrowEl.endPoint.x} ${arrowEl.endPoint.y}` : cp.length === 2 ? `M ${arrowEl.startPoint.x} ${arrowEl.startPoint.y} C ${cp[0].x} ${cp[0].y} ${cp[1].x} ${cp[1].y} ${arrowEl.endPoint.x} ${arrowEl.endPoint.y}` : buildCatmullRomPath(arrowEl.startPoint, cp, arrowEl.endPoint)}
1845
+ {@const lastCp = cp.length > 0 ? cp[cp.length - 1] : arrowEl.startPoint}
1846
+ {@const endAngle = Math.atan2(arrowEl.endPoint.y - lastCp.y, arrowEl.endPoint.x - lastCp.x)}
1847
+ {@const headAngle = Math.PI / 6}
1848
+ {@const headSize = arrowEl.headSize}
1849
+ {@const arrowHeadPath = `M ${arrowEl.endPoint.x - headSize * Math.cos(endAngle - headAngle)} ${arrowEl.endPoint.y - headSize * Math.sin(endAngle - headAngle)} L ${arrowEl.endPoint.x} ${arrowEl.endPoint.y} L ${arrowEl.endPoint.x - headSize * Math.cos(endAngle + headAngle)} ${arrowEl.endPoint.y - headSize * Math.sin(endAngle + headAngle)}`}
1850
+ {@const arrowAnimMode = arrowEl.animation?.mode ?? 'none'}
1851
+ {@const arrowAnimDuration = arrowEl.animation?.duration ?? 500}
1852
+ {@const isStyledArrow = arrowEl.style !== 'solid'}
1853
+ {@const isDrawType = arrowAnimMode === 'draw' || arrowAnimMode === 'undraw' || arrowAnimMode === 'draw-undraw' || arrowAnimMode === 'flow'}
1854
+ {@const baseDashArray = arrowEl.style === 'dashed' ? '10,5' : arrowEl.style === 'dotted' ? '2,5' : 'none'}
1855
+ <svg class="animot-arrow-element" class:arrow-animate-grow={arrowAnimMode === 'grow'} viewBox="0 0 {arrowEl.size.width} {arrowEl.size.height}" preserveAspectRatio="none" style="--arrow-anim-duration: {arrowAnimDuration}ms;" use:arrowClipDraw={{ enabled: isDrawType, mode: arrowAnimMode, duration: arrowAnimDuration, startX: arrowEl.startPoint.x, startY: arrowEl.startPoint.y, endX: arrowEl.endPoint.x, endY: arrowEl.endPoint.y, loop: !!arrowEl.animation?.loop, reverse: arrowEl.animation?.direction === 'reverse', slideDuration: currentSlide?.duration, key: `arrow-${currentSlideIndex}` }}>
1856
+ <path class="arrow-path" d={pathD} fill="none" stroke={arrowEl.color} stroke-width={arrowEl.strokeWidth} stroke-dasharray={baseDashArray} stroke-linecap="round" stroke-linejoin="round" />
1857
+ {#if arrowEl.showHead !== false}
1858
+ <path class="arrow-head" class:arrow-head-styled-draw={isDrawType && isStyledArrow} class:arrow-head-undraw={arrowAnimMode === 'undraw'} class:arrow-head-draw-undraw={arrowAnimMode === 'draw-undraw'} d={arrowHeadPath} fill="none" stroke={arrowEl.color} stroke-width={arrowEl.strokeWidth} stroke-linecap="round" stroke-linejoin="round" style={isDrawType && isStyledArrow ? `--arrow-anim-duration: ${arrowAnimDuration}ms;` : ''} />
1859
+ {/if}
1860
+ {#if arrowEl.flowMarkers?.enabled}
1861
+ <FlowMarkers config={arrowEl.flowMarkers} start={arrowEl.startPoint} end={arrowEl.endPoint} controlPoints={cp} slideDuration={currentSlide?.duration} />
1862
+ {/if}
1863
+ </svg>
1864
+ {:else if element.type === 'image'}
1865
+ {@const imgEl = liveProps(element) as ImageElement}
1866
+ {@const clipPath = imgEl.clipMask?.enabled ? (imgEl.clipMask.shapeType === 'circle' ? 'circle(50% at 50% 50%)' : imgEl.clipMask.shapeType === 'ellipse' ? 'ellipse(50% 50% at 50% 50%)' : imgEl.clipMask.shapeType === 'triangle' ? 'polygon(50% 0%, 0% 100%, 100% 100%)' : imgEl.clipMask.shapeType === 'star' ? 'polygon(50% 0%, 61% 35%, 98% 35%, 68% 57%, 79% 91%, 50% 70%, 21% 91%, 32% 57%, 2% 35%, 39% 35%)' : imgEl.clipMask.shapeType === 'hexagon' ? 'polygon(25% 0%, 75% 0%, 100% 50%, 75% 100%, 25% 100%, 0% 50%)' : (imgEl.clipMask.borderRadius ?? 0) > 0 ? `inset(0 round ${imgEl.clipMask.borderRadius}px)` : 'none') : 'none'}
1867
+ <img class="animot-image-element" src={imgEl.src} alt="" style:object-fit={imgEl.objectFit} style:border-radius="{imgEl.clipMask?.enabled ? 0 : imgEl.borderRadius}px" style:clip-path={clipPath} style:background-color={imgEl.backgroundColor ?? 'transparent'} />
1868
+ {:else if element.type === 'video'}
1869
+ {@const videoEl = liveProps(element) as VideoElement}
1870
+ {@const videoEmbed = parseEmbedUrl(videoEl.src)}
1871
+ {#if videoEmbed}
1872
+ <div class="animot-video-element animot-embed-wrap" style:border-radius="{videoEl.borderRadius}px" style:opacity={videoEl.opacity}>
1873
+ <EmbedPlayer element={videoEl} controlsOverlay={!!videoEl.showControls} />
1874
+ </div>
1875
+ {:else}
1876
+ <video class="animot-video-element" src={videoEl.src} poster={videoEl.posterImage} autoplay={videoEl.autoplay} loop={videoEl.loop} muted={videoEl.muted} controls={!!videoEl.showControls} playsinline preload="auto" style:object-fit={videoEl.objectFit} style:border-radius="{videoEl.borderRadius}px" style:opacity={videoEl.opacity}></video>
1877
+ {/if}
1878
+ {:else if element.type === 'shape'}
1879
+ {@const shapeEl = liveProps(element) as ShapeElement}
1880
+ {@const animFill = animated.fillColor?.current ?? shapeEl.fillColor}
1881
+ {@const animStroke = animated.strokeColor?.current ?? shapeEl.strokeColor}
1882
+ {@const animStrokeWidth = animated.strokeWidth?.current ?? shapeEl.strokeWidth}
1883
+ {@const mState = shapeMorphStates.get(element.id)}
1884
+ {@const morphProgress = animated.shapeMorph?.current ?? 1}
1885
+ {@const effectiveShapeType = mState ? (morphProgress >= 1 ? mState.toType : (morphProgress <= 0 ? mState.fromType : null)) : shapeEl.shapeType}
1886
+ {@const isMorphing = mState && morphProgress > 0 && morphProgress < 1}
1887
+ <svg class="animot-shape-element" viewBox="0 0 {Math.max(0, animated.width.current)} {Math.max(0, animated.height.current)}" fill-opacity={shapeEl.fillOpacity ?? 1} stroke-opacity={shapeEl.strokeOpacity ?? 1} style:filter={shapeEl.boxShadow?.enabled ? `drop-shadow(${shapeEl.boxShadow.offsetX}px ${shapeEl.boxShadow.offsetY}px ${shapeEl.boxShadow.blur}px ${shapeEl.boxShadow.color})` : 'none'}>
1888
+ {#if isMorphing}
1889
+ {@const w = Math.max(0, animated.width.current)}
1890
+ {@const h = Math.max(0, animated.height.current)}
1891
+ {@const sw = animStrokeWidth}
1892
+ <g style:opacity={1 - morphProgress}>{@html renderShape(mState!.fromType, w, h, animated.borderRadius.current, animFill, animStroke, sw, shapeEl.strokeStyle, shapeEl.strokeDashGap)}</g>
1893
+ <g style:opacity={morphProgress}>{@html renderShape(mState!.toType, w, h, animated.borderRadius.current, animFill, animStroke, sw, shapeEl.strokeStyle, shapeEl.strokeDashGap)}</g>
1894
+ {:else}
1895
+ {@html renderShape(effectiveShapeType ?? shapeEl.shapeType, Math.max(0, animated.width.current), Math.max(0, animated.height.current), animated.borderRadius.current, animFill, animStroke, animStrokeWidth, shapeEl.strokeStyle, shapeEl.strokeDashGap)}
1896
+ {/if}
1897
+ </svg>
1898
+ {:else if element.type === 'counter'}
1899
+ <CounterRenderer element={element as CounterElement} slideId={currentSlide?.id ?? ''} />
1900
+ {:else if element.type === 'chart'}
1901
+ <ChartRenderer
1902
+ element={element as ChartElement}
1903
+ slideId={currentSlide?.id ?? ''}
1904
+ previousElement={previousChartContent.get(element.id) ?? null}
1905
+ />
1906
+ {:else if element.type === 'progress'}
1907
+ <ProgressBar
1908
+ element={element as ProgressElement}
1909
+ isPresenting={true}
1910
+ slideId={currentSlide?.id ?? ''}
1911
+ previousElement={previousProgressContent.get(element.id) ?? null}
1912
+ />
1913
+ {:else if element.type === 'container'}
1914
+ <Container element={element as ContainerElement} />
1915
+ {:else if element.type === 'icon'}
1916
+ <IconRenderer element={element as IconElement} />
1917
+ {:else if element.type === 'svg'}
1918
+ {@const svgEl = element as SvgElement}
1919
+ {@const svgParsed = (() => { const m = svgEl.svgContent.trim().match(/^<svg([^>]*)>([\s\S]*)<\/svg>$/i); if (m) { const vb = m[1].match(/viewBox=["']([^"']+)["']/i); return { inner: m[2], viewBox: vb ? vb[1] : null }; } return { inner: svgEl.svgContent, viewBox: null }; })()}
1920
+ {@const svgAnimMode = svgEl.animation?.mode ?? 'none'}
1921
+ {@const svgAnimDur = svgEl.animation?.duration ?? 800}
1922
+ {@const svgAnimLoop = svgEl.animation?.loop ?? false}
1923
+ {@const svgAnimReverse = svgEl.animation?.direction === 'reverse'}
1924
+ <div
1925
+ class="animot-svg-element"
1926
+ use:traceSvgPaths={{
1927
+ enabled: svgAnimMode !== 'none',
1928
+ mode: svgAnimMode as 'none' | 'draw' | 'undraw' | 'draw-undraw' | 'flow',
1929
+ duration: svgAnimDur,
1930
+ loop: svgAnimLoop,
1931
+ reverse: svgAnimReverse,
1932
+ key: `${svgEl.id}-${svgAnimMode}-${svgAnimDur}-${currentSlideIndex}`
1933
+ }}
1934
+ >
1935
+ <svg width="100%" height="100%" viewBox={svgEl.viewBox ?? svgParsed.viewBox ?? `0 0 ${svgEl.size.width} ${svgEl.size.height}`} preserveAspectRatio={svgEl.preserveAspectRatio} xmlns="http://www.w3.org/2000/svg">
1936
+ <g style={svgEl.color ? `fill:${svgEl.color};stroke:${svgEl.color}` : ''}>
1937
+ {@html svgParsed.inner}
1938
+ </g>
1939
+ </svg>
1940
+ </div>
1941
+ {:else if element.type === 'motionPath'}
1942
+ {@const mpEl = element as MotionPathElement}
1943
+ {#if mpEl.showInPresentation}
1944
+ <svg width="100%" height="100%" viewBox="0 0 {Math.max(0, animated.width.current)} {Math.max(0, animated.height.current)}" style="position:absolute;top:0;left:0;pointer-events:none;overflow:visible;">
1945
+ <path d={buildPresenterPathD(mpEl.points, mpEl.closed)} stroke={mpEl.pathColor} stroke-width={mpEl.pathWidth} fill="none" stroke-dasharray="8 4" />
1946
+ </svg>
1947
+ {/if}
1948
+ {/if}
1949
+ </div>
1950
+ {/if}
1951
+ {/each}
1952
+ </div><!-- /animot-cinema-camera -->
1953
+ </div>
1954
+ </div>
1955
+
1956
+ {#if arrows}
1957
+ <button class="animot-arrow animot-arrow-left" onclick={() => animateToSlide(currentSlideIndex - 1)} disabled={currentSlideIndex === 0 || isTransitioning} aria-label="Previous slide">
1958
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M15 18l-6-6 6-6"/></svg>
1959
+ </button>
1960
+ <button class="animot-arrow animot-arrow-right" onclick={() => handleNextSlide()} disabled={(!loop && currentSlideIndex === slides.length - 1) || isTransitioning} aria-label="Next slide">
1961
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18l6-6-6-6"/></svg>
1962
+ </button>
1963
+ {/if}
1964
+
1965
+ {#if controls}
1966
+ <div class="animot-controls">
1967
+ <button onclick={() => animateToSlide(currentSlideIndex - 1)} disabled={currentSlideIndex === 0 || isTransitioning} aria-label="Previous">
1968
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>
1969
+ </button>
1970
+ <span class="animot-slide-indicator">{currentSlideIndex + 1} / {slides.length}</span>
1971
+ <button onclick={() => handleNextSlide()} disabled={(!loop && currentSlideIndex === slides.length - 1) || isTransitioning} aria-label="Next">
1972
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
1973
+ </button>
1974
+ <button onclick={() => { isAutoplay = !isAutoplay; if (!isAutoplay) clearAutoplayTimer(); }} class:active={isAutoplay} aria-label={isAutoplay ? 'Pause' : 'Play'}>
1975
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1976
+ {#if isAutoplay}<rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/>{:else}<polygon points="5 3 19 12 5 21 5 3"/>{/if}
1977
+ </svg>
1978
+ </button>
1979
+ </div>
1980
+ {/if}
1981
+
1982
+ {#if showProgress}
1983
+ <div class="animot-progress-bar">
1984
+ <div class="animot-progress-fill" style:width="{((currentSlideIndex + 1) / slides.length) * 100}%"></div>
1985
+ </div>
1986
+ {/if}
1987
+ {/if}
1988
+ </div>
1989
+
1990
+ <script module lang="ts">
1991
+ function roundedPolygonPath(pointsStr: string, radius: number): string {
1992
+ const pts = pointsStr.split(/\s+/).map(p => { const [x, y] = p.split(',').map(Number); return { x, y }; });
1993
+ if (pts.length < 3 || radius <= 0) return 'M' + pts.map(p => `${p.x},${p.y}`).join('L') + 'Z';
1994
+ const n = pts.length;
1995
+ const parts: string[] = [];
1996
+ for (let i = 0; i < n; i++) {
1997
+ const prev = pts[(i - 1 + n) % n], curr = pts[i], next = pts[(i + 1) % n];
1998
+ const dx1 = prev.x - curr.x, dy1 = prev.y - curr.y;
1999
+ const dx2 = next.x - curr.x, dy2 = next.y - curr.y;
2000
+ const len1 = Math.sqrt(dx1 * dx1 + dy1 * dy1);
2001
+ const len2 = Math.sqrt(dx2 * dx2 + dy2 * dy2);
2002
+ const r = Math.min(radius, len1 / 2, len2 / 2);
2003
+ const sx = curr.x + (dx1 / len1) * r, sy = curr.y + (dy1 / len1) * r;
2004
+ const ex = curr.x + (dx2 / len2) * r, ey = curr.y + (dy2 / len2) * r;
2005
+ parts.push(i === 0 ? `M${sx},${sy}` : `L${sx},${sy}`);
2006
+ parts.push(`Q${curr.x},${curr.y} ${ex},${ey}`);
2007
+ }
2008
+ parts.push('Z');
2009
+ return parts.join(' ');
2010
+ }
2011
+
2012
+ function renderShape(type: string, w: number, h: number, br: number, fill: string, stroke: string, sw: number, strokeStyle?: string, strokeDashGap?: number): string {
2013
+ const nn = (v: number) => (v > 0 ? v : 0);
2014
+ w = nn(w); h = nn(h); br = nn(br); sw = nn(sw);
2015
+ let dashAttr = '';
2016
+ if (strokeStyle && strokeStyle !== 'solid') {
2017
+ const s = sw || 1;
2018
+ const gap = strokeDashGap ?? (strokeStyle === 'dashed' ? s * 3 : s * 2);
2019
+ const da = strokeStyle === 'dashed' ? `${s * 3},${gap}` : `${s * 0.1},${gap}`;
2020
+ const lc = strokeStyle === 'dotted' ? 'round' : 'butt';
2021
+ dashAttr = ` stroke-dasharray="${da}" stroke-linecap="${lc}"`;
2022
+ }
2023
+ const polyOrPath = (pts: string) => {
2024
+ if (br > 0) return `<path d="${roundedPolygonPath(pts, br)}" fill="${fill}" stroke="${stroke}" stroke-width="${sw}"${dashAttr}/>`;
2025
+ return `<polygon points="${pts}" fill="${fill}" stroke="${stroke}" stroke-width="${sw}"${dashAttr} stroke-linejoin="round"/>`;
2026
+ };
2027
+ switch (type) {
2028
+ case 'rectangle': return `<rect x="${sw/2}" y="${sw/2}" width="${nn(w-sw)}" height="${nn(h-sw)}" rx="${nn(Math.min(br, (w-sw)/2, (h-sw)/2))}" ry="${nn(Math.min(br, (w-sw)/2, (h-sw)/2))}" fill="${fill}" stroke="${stroke}" stroke-width="${sw}"${dashAttr}/>`;
2029
+ case 'circle': return `<circle cx="${w/2}" cy="${h/2}" r="${nn(Math.min(w,h)/2-sw/2)}" fill="${fill}" stroke="${stroke}" stroke-width="${sw}"${dashAttr}/>`;
2030
+ case 'ellipse': return `<ellipse cx="${w/2}" cy="${h/2}" rx="${nn(w/2-sw/2)}" ry="${nn(h/2-sw/2)}" fill="${fill}" stroke="${stroke}" stroke-width="${sw}"${dashAttr}/>`;
2031
+ case 'triangle': return polyOrPath(`${w/2},${sw/2} ${sw/2},${h-sw/2} ${w-sw/2},${h-sw/2}`);
2032
+ case 'star': {
2033
+ const cx = w/2, cy = h/2, outerR = nn(Math.min(w,h)/2-sw/2), innerR = outerR*0.4;
2034
+ const pts = Array.from({length:10},(_,i)=>{const a=(i*Math.PI/5)-Math.PI/2;const r=i%2===0?outerR:innerR;return`${cx+r*Math.cos(a)},${cy+r*Math.sin(a)}`;}).join(' ');
2035
+ return polyOrPath(pts);
2036
+ }
2037
+ case 'hexagon': {
2038
+ const cx = w/2, cy = h/2, r = nn(Math.min(w,h)/2-sw/2);
2039
+ const pts = Array.from({length:6},(_,i)=>{const a=(i*Math.PI/3)-Math.PI/2;return`${cx+r*Math.cos(a)},${cy+r*Math.sin(a)}`;}).join(' ');
2040
+ return polyOrPath(pts);
2041
+ }
2042
+ default: return '';
2043
+ }
2044
+ }
2045
+ </script>
2046
+
2047
+ <style>
2048
+ /* Universal reset — mirrors the animot app's global * reset to prevent
2049
+ host page defaults (margins on p/h1, padding, box-sizing) from leaking in */
2050
+ .animot-presenter :global(*) {
2051
+ margin: 0;
2052
+ padding: 0;
2053
+ box-sizing: border-box;
2054
+ }
2055
+
2056
+ .animot-presenter {
2057
+ position: relative;
2058
+ width: 100%;
2059
+ height: 100%;
2060
+ display: flex;
2061
+ align-items: center;
2062
+ justify-content: center;
2063
+ overflow: hidden;
2064
+ background: transparent;
2065
+ /* Reset inheritable CSS from host page to prevent style leakage */
2066
+ line-height: normal;
2067
+ font-size: 16px;
2068
+ font-weight: 400;
2069
+ font-style: normal;
2070
+ letter-spacing: normal;
2071
+ word-spacing: normal;
2072
+ text-transform: none;
2073
+ text-indent: 0;
2074
+ text-align: left;
2075
+ color: inherit;
2076
+ }
2077
+
2078
+ .animot-canvas-wrapper {
2079
+ display: flex;
2080
+ align-items: center;
2081
+ justify-content: center;
2082
+ }
2083
+
2084
+ .animot-canvas {
2085
+ position: relative;
2086
+ overflow: hidden;
2087
+ }
2088
+
2089
+ .animot-element {
2090
+ position: absolute;
2091
+ box-sizing: border-box;
2092
+ will-change: transform, opacity, left, top, width, height;
2093
+ isolation: isolate;
2094
+ }
2095
+
2096
+ .animot-element.floating {
2097
+ animation-duration: var(--float-speed, 3s);
2098
+ animation-timing-function: ease-in-out;
2099
+ animation-iteration-count: infinite;
2100
+ animation-delay: var(--float-delay, 0s);
2101
+ }
2102
+
2103
+ /* Code */
2104
+ .animot-code-block {
2105
+ width: 100%; height: 100%; overflow: hidden;
2106
+ display: flex; flex-direction: column; box-shadow: 0 8px 32px rgba(0,0,0,0.4);
2107
+ margin: 0; box-sizing: border-box;
2108
+ }
2109
+ .animot-code-block.transparent-bg { background: transparent !important; box-shadow: none; }
2110
+ .animot-code-block.transparent-bg .animot-code-header { background: transparent; border-bottom-color: rgba(255,255,255,0.06); }
2111
+ .animot-code-header { display: flex; align-items: center; gap: 8px; padding: 8px 12px; background: rgba(0, 0, 0, 0.2); border-bottom: 1px solid rgba(255, 255, 255, 0.06); flex-shrink: 0; min-height: 40px; }
2112
+ .animot-window-controls { display: flex; gap: 8px; align-items: center; flex-shrink: 0; }
2113
+ .macos .animot-control { width: 12px; height: 12px; border-radius: 50%; display: block; }
2114
+ .macos .animot-control.close { background: #ff5f57; }
2115
+ .macos .animot-control.minimize { background: #febc2e; }
2116
+ .macos .animot-control.maximize { background: #28c840; }
2117
+ .windows .animot-window-controls { order: 99; margin-left: auto; gap: 0; }
2118
+ .windows .animot-control { display: flex; align-items: center; justify-content: center; width: 28px; height: 24px; border-radius: 4px; color: rgba(255,255,255,0.45); }
2119
+ .animot-filename-tab { display: flex; align-items: center; gap: 6px; background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.08); border-radius: 6px; padding: 4px 10px; max-width: 220px; color: rgba(255,255,255,0.4); }
2120
+ .animot-file-icon { flex-shrink: 0; }
2121
+ .animot-filename { color: rgba(255,255,255,0.55); font-size: 12px; line-height: 18px; }
2122
+ .animot-copy-code-btn { display: flex; align-items: center; gap: 5px; height: 28px; padding: 0 8px; margin-left: auto; background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.1); border-radius: 6px; color: rgba(255,255,255,0.4); cursor: pointer; opacity: 0; transition: opacity 0.2s, background 0.15s, color 0.15s; flex-shrink: 0; font-size: 12px; font-family: inherit; white-space: nowrap; }
2123
+ .animot-copy-code-btn:hover { background: rgba(255,255,255,0.12); color: rgba(255,255,255,0.8); }
2124
+ .animot-copy-code-btn svg { width: 14px; height: 14px; flex-shrink: 0; }
2125
+ .animot-copy-code-btn .animot-check-icon { display: none; }
2126
+ .animot-copy-code-btn .animot-copied-label { display: none; }
2127
+ .animot-copy-code-btn.copied .animot-copy-icon { display: none; }
2128
+ .animot-copy-code-btn.copied .animot-copy-label { display: none; }
2129
+ .animot-copy-code-btn.copied .animot-check-icon { display: block; color: #4ade80; }
2130
+ .animot-copy-code-btn.copied .animot-copied-label { display: inline; color: #4ade80; }
2131
+ .animot-copy-code-btn.animot-floating { position: absolute; top: 8px; right: 8px; z-index: 2; }
2132
+ .animot-code-block:hover .animot-copy-code-btn { opacity: 1; }
2133
+ .animot-code-content { flex: 1; overflow: hidden; position: relative; }
2134
+ .animot-highlighted-code { width: 100%; height: 100%; }
2135
+ .animot-code-content :global(pre), .animot-highlighted-code :global(pre) { margin: 0; padding: 16px; background: transparent !important; line-height: 1.6; font-size: inherit; overflow: visible; }
2136
+ .animot-highlighted-code :global(code) { font-family: inherit; font-size: inherit; font-weight: inherit; }
2137
+ .animot-highlighted-code :global(.line-number) { display: inline-block; width: 2.5em; margin-right: 1em; text-align: right; color: #6e7681; user-select: none; opacity: 0.6; }
2138
+
2139
+ /* Text */
2140
+ .animot-text-element { width: 100%; height: 100%; display: flex; align-items: center; white-space: pre-wrap; word-wrap: break-word; }
2141
+ .animot-typewriter-cursor { animation: animot-blink 0.7s infinite; font-weight: 100; }
2142
+ @keyframes animot-blink { 0%, 50% { opacity: 1; } 51%, 100% { opacity: 0; } }
2143
+
2144
+ /* Arrow */
2145
+ .animot-arrow-element { width: 100%; height: 100%; }
2146
+ .arrow-animate-draw .arrow-path { stroke-dashoffset: var(--path-len, 1000); animation: animot-arrow-draw var(--arrow-anim-duration, 500ms) ease-out forwards; }
2147
+ .arrow-animate-undraw .arrow-path { stroke-dashoffset: 0; animation: animot-arrow-undraw var(--arrow-anim-duration, 500ms) ease-out forwards; }
2148
+ .arrow-animate-draw-undraw .arrow-path { stroke-dashoffset: var(--path-len, 1000); animation: animot-arrow-draw-undraw var(--arrow-anim-duration, 500ms) ease-out forwards; }
2149
+ .arrow-head-styled-draw { opacity: 0; animation: animot-arrow-head-appear var(--arrow-anim-duration, 500ms) ease-out forwards; animation-delay: calc(var(--arrow-anim-duration, 500ms) * 0.7); }
2150
+ .arrow-animate-draw .arrow-head { opacity: 0; animation: animot-arrow-head-appear var(--arrow-anim-duration, 500ms) ease-out forwards; animation-delay: calc(var(--arrow-anim-duration, 500ms) * 0.7); }
2151
+ .arrow-animate-undraw .arrow-head, .arrow-head-undraw { opacity: 1; animation: animot-arrow-head-disappear var(--arrow-anim-duration, 500ms) ease-out forwards; }
2152
+ .arrow-animate-draw-undraw .arrow-head, .arrow-head-draw-undraw { opacity: 0; animation: animot-arrow-head-draw-undraw var(--arrow-anim-duration, 500ms) ease-out forwards; }
2153
+ .arrow-animate-grow { transform-origin: left center; animation: animot-arrow-grow var(--arrow-anim-duration, 500ms) ease-out forwards; }
2154
+ /* loop: replay continuously while slide is shown */
2155
+ .arrow-anim-loop .arrow-path, .arrow-anim-loop .arrow-head { animation-iteration-count: infinite !important; }
2156
+ /* reverse: flip start ↔ end */
2157
+ .arrow-anim-reverse .arrow-path, .arrow-anim-reverse .arrow-head { animation-direction: reverse !important; }
2158
+ @keyframes animot-arrow-draw { to { stroke-dashoffset: 0; } }
2159
+ @keyframes animot-arrow-undraw { from { stroke-dashoffset: 0; } to { stroke-dashoffset: var(--path-len, 1000); } }
2160
+ @keyframes animot-arrow-draw-undraw { 0% { stroke-dashoffset: var(--path-len, 1000); } 50% { stroke-dashoffset: 0; } 100% { stroke-dashoffset: var(--path-len, 1000); } }
2161
+ @keyframes animot-arrow-head-appear { from { opacity: 0; } to { opacity: 1; } }
2162
+ @keyframes animot-arrow-head-disappear { 0% { opacity: 1; } 70% { opacity: 1; } 100% { opacity: 0; } }
2163
+ @keyframes animot-arrow-head-draw-undraw { 0% { opacity: 0; } 35% { opacity: 1; } 65% { opacity: 1; } 100% { opacity: 0; } }
2164
+ @keyframes animot-arrow-grow { from { transform: scaleX(0); opacity: 0; } to { transform: scaleX(1); opacity: 1; } }
2165
+
2166
+ /* Image */
2167
+ .animot-image-element { width: 100%; height: 100%; display: block; }
2168
+ .animot-video-element { width: 100%; height: 100%; display: block; background: #000; }
2169
+ .animot-video-element.animot-embed-frame { border: 0; background: transparent; }
2170
+ .animot-video-element.animot-embed-wrap { overflow: hidden; background: #000; }
2171
+ .animot-cinema-camera { position: absolute; inset: 0; transform-origin: 0 0; }
2172
+ .animot-cinema-camera.active { transition: transform var(--cinema-transition-duration, 800ms) cubic-bezier(0.65, 0, 0.35, 1); }
2173
+ .animot-cinema-camera.active .animot-element { transition: translate var(--cinema-transition-duration, 800ms) cubic-bezier(0.65, 0, 0.35, 1); }
2174
+
2175
+ /* Shape */
2176
+ .animot-shape-element { width: 100%; height: 100%; display: block; overflow: visible; }
2177
+
2178
+ /* Transitions */
2179
+ .animot-canvas { --transition-duration: 500ms; transition: transform calc(var(--transition-duration) * 0.4) ease, opacity calc(var(--transition-duration) * 0.4) ease; }
2180
+ .animot-canvas.transition-fade-out { opacity: 0; }
2181
+ .animot-canvas.transition-fade-in { animation: animot-fadeIn calc(var(--transition-duration) * 0.6) ease forwards; }
2182
+ .animot-canvas.transition-slide-left-out.forward { transform: translateX(-100%); opacity: 0; }
2183
+ .animot-canvas.transition-slide-left-in.forward { animation: animot-slideInFromRight calc(var(--transition-duration) * 0.6) ease forwards; }
2184
+ .animot-canvas.transition-slide-left-out.backward { transform: translateX(100%); opacity: 0; }
2185
+ .animot-canvas.transition-slide-left-in.backward { animation: animot-slideInFromLeft calc(var(--transition-duration) * 0.6) ease forwards; }
2186
+ .animot-canvas.transition-slide-right-out.forward { transform: translateX(100%); opacity: 0; }
2187
+ .animot-canvas.transition-slide-right-in.forward { animation: animot-slideInFromLeft calc(var(--transition-duration) * 0.6) ease forwards; }
2188
+ .animot-canvas.transition-slide-up-out { transform: translateY(-100%); opacity: 0; }
2189
+ .animot-canvas.transition-slide-up-in { animation: animot-slideInFromBottom calc(var(--transition-duration) * 0.6) ease forwards; }
2190
+ .animot-canvas.transition-slide-down-out { transform: translateY(100%); opacity: 0; }
2191
+ .animot-canvas.transition-slide-down-in { animation: animot-slideInFromTop calc(var(--transition-duration) * 0.6) ease forwards; }
2192
+ .animot-canvas.transition-zoom-in-out { transform: scale(0.5); opacity: 0; }
2193
+ .animot-canvas.transition-zoom-in-in { animation: animot-zoomIn calc(var(--transition-duration) * 0.6) ease forwards; }
2194
+ .animot-canvas.transition-zoom-out-out { transform: scale(1.5); opacity: 0; }
2195
+ .animot-canvas.transition-zoom-out-in { animation: animot-zoomOut calc(var(--transition-duration) * 0.6) ease forwards; }
2196
+ .animot-canvas.transition-flip-out { transform: perspective(1000px) rotateY(90deg); opacity: 0; }
2197
+ .animot-canvas.transition-flip-in { animation: animot-flipIn calc(var(--transition-duration) * 0.6) ease forwards; }
2198
+
2199
+ @keyframes animot-fadeIn { from { opacity: 0; } to { opacity: 1; } }
2200
+ @keyframes animot-slideInFromRight { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
2201
+ @keyframes animot-slideInFromLeft { from { transform: translateX(-100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
2202
+ @keyframes animot-slideInFromBottom { from { transform: translateY(100%); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
2203
+ @keyframes animot-slideInFromTop { from { transform: translateY(-100%); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
2204
+ @keyframes animot-zoomIn { from { transform: scale(0.5); opacity: 0; } to { transform: scale(1); opacity: 1; } }
2205
+ @keyframes animot-zoomOut { from { transform: scale(1.5); opacity: 0; } to { transform: scale(1); opacity: 1; } }
2206
+ @keyframes animot-flipIn { from { transform: perspective(1000px) rotateY(-90deg); opacity: 0; } to { transform: perspective(1000px) rotateY(0); opacity: 1; } }
2207
+
2208
+ /* Flip-X transition */
2209
+ .animot-canvas.transition-flip-x-out { transform: perspective(1000px) rotateX(90deg); opacity: 0; }
2210
+ .animot-canvas.transition-flip-x-in { animation: animot-flipXIn calc(var(--transition-duration) * 0.6) ease forwards; }
2211
+ @keyframes animot-flipXIn { from { transform: perspective(1000px) rotateX(-90deg); opacity: 0; } to { transform: perspective(1000px) rotateX(0); opacity: 1; } }
2212
+
2213
+ /* Flip-Y transition */
2214
+ .animot-canvas.transition-flip-y-out { transform: perspective(1000px) rotateY(90deg); opacity: 0; }
2215
+ .animot-canvas.transition-flip-y-in { animation: animot-flipYIn calc(var(--transition-duration) * 0.6) ease forwards; }
2216
+ @keyframes animot-flipYIn { from { transform: perspective(1000px) rotateY(-90deg); opacity: 0; } to { transform: perspective(1000px) rotateY(0); opacity: 1; } }
2217
+
2218
+ /* SVG element */
2219
+ .animot-svg-element { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; }
2220
+ .animot-svg-element :global(svg) { width: 100%; height: 100%; }
2221
+
2222
+ /* Controls */
2223
+ .animot-controls {
2224
+ position: absolute; bottom: 12px; left: 50%; transform: translateX(-50%);
2225
+ display: flex; align-items: center; gap: 8px; padding: 8px 16px;
2226
+ background: rgba(0,0,0,0.7); backdrop-filter: blur(10px); border-radius: 10px;
2227
+ opacity: 0; transition: opacity 0.3s ease 0.15s; z-index: 100;
2228
+ }
2229
+ .animot-presenter:hover .animot-controls, .animot-menu-visible .animot-controls { opacity: 1; transition-delay: 0s; }
2230
+ .animot-controls button {
2231
+ display: flex; align-items: center; justify-content: center;
2232
+ width: 32px; height: 32px; border-radius: 6px; border: none; cursor: pointer;
2233
+ background: rgba(255,255,255,0.1); color: white; transition: background 0.2s;
2234
+ }
2235
+ .animot-controls button:hover:not(:disabled) { background: rgba(255,255,255,0.2); }
2236
+ .animot-controls button:disabled { opacity: 0.3; cursor: not-allowed; }
2237
+ .animot-controls button.active { background: rgba(99,102,241,0.6); }
2238
+ .animot-controls button svg { width: 16px; height: 16px; }
2239
+ .animot-slide-indicator { font-size: 12px; color: white; min-width: 50px; text-align: center; font-family: system-ui, sans-serif; }
2240
+
2241
+ /* Arrows */
2242
+ .animot-arrow {
2243
+ position: absolute; top: 50%; transform: translateY(-50%);
2244
+ width: 40px; height: 40px; border-radius: 50%; border: none; cursor: pointer;
2245
+ background: rgba(0,0,0,0.5); color: white; display: flex; align-items: center; justify-content: center;
2246
+ opacity: 0; transition: opacity 0.3s 0.15s; z-index: 100;
2247
+ /* Extra padding extends the hover hit area beyond the visible button */
2248
+ padding: 0; margin: 0;
2249
+ }
2250
+ .animot-presenter:hover .animot-arrow { opacity: 1; transition-delay: 0s; }
2251
+ .animot-presenter:hover .animot-arrow:disabled { opacity: 0.3; cursor: not-allowed; }
2252
+ .animot-arrow:hover:not(:disabled) { background: rgba(0,0,0,0.7); }
2253
+ .animot-arrow svg { width: 20px; height: 20px; }
2254
+ .animot-arrow-left { left: 8px; }
2255
+ .animot-arrow-right { right: 8px; }
2256
+
2257
+ /* Progress bar */
2258
+ .animot-progress-bar {
2259
+ position: absolute; bottom: 0; left: 0; right: 0; height: 3px;
2260
+ background: rgba(255,255,255,0.1); z-index: 100;
2261
+ opacity: 0; transition: opacity 0.3s 0.15s;
2262
+ }
2263
+ .animot-presenter:hover .animot-progress-bar { opacity: 1; transition-delay: 0s; }
2264
+ .animot-progress-fill { height: 100%; background: linear-gradient(135deg, #7c3aed, #ec4899); transition: width 0.6s ease; }
2265
+
2266
+ /* Loading / Error */
2267
+ .animot-loading { display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; }
2268
+ .animot-spinner { width: 32px; height: 32px; border: 3px solid rgba(255,255,255,0.2); border-top-color: #7c3aed; border-radius: 50%; animation: animot-spin 0.8s linear infinite; }
2269
+ @keyframes animot-spin { to { transform: rotate(360deg); } }
2270
+ .animot-error { color: #ef4444; padding: 20px; text-align: center; font-family: system-ui, sans-serif; }
2271
+ </style>