animot-presenter 0.2.8 → 0.5.4

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,1529 +1,1654 @@
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 IconRenderer from './renderers/IconRenderer.svelte';
11
- import { easeInOutCubic, getEasingFn, getBackgroundStyle, hashFraction, getFloatAnimName, computeFloatAmp, computeFloatSpeed } from './engine/utils';
12
- import type {
13
- AnimotProject, AnimotPresenterProps, CanvasElement, CodeElement, TextElement,
14
- ArrowElement, ImageElement, ShapeElement, CounterElement, ChartElement, IconElement,
15
- SvgElement, MotionPathElement, PathPoint,
16
- Slide, CodeAnimationMode, AnimatableProperty
17
- } from './types';
18
- import './styles/presenter.css';
19
-
20
- type TweenValue = ReturnType<typeof tween<number>>;
21
-
22
- interface AnimatedElement {
23
- x: TweenValue; y: TweenValue; width: TweenValue; height: TweenValue;
24
- rotation: TweenValue; skewX: TweenValue; skewY: TweenValue;
25
- tiltX: TweenValue; tiltY: TweenValue; perspective: TweenValue;
26
- opacity: TweenValue; borderRadius: TweenValue;
27
- fontSize: TweenValue | null;
28
- fillColor: ReturnType<typeof tween<string>> | null;
29
- strokeColor: ReturnType<typeof tween<string>> | null;
30
- strokeWidth: TweenValue | null;
31
- shapeMorph: TweenValue | null;
32
- motionPathProgress: TweenValue | null;
33
- blur: TweenValue;
34
- brightness: TweenValue;
35
- contrast: TweenValue;
36
- saturate: TweenValue;
37
- grayscale: TweenValue;
38
- }
39
-
40
- interface ShapeMorphState { fromType: string; toType: string; }
41
-
42
- // Active motion path loop cancellation tokens
43
- let motionPathLoopAbort: AbortController | null = null;
44
- function cancelMotionPathLoops() {
45
- if (motionPathLoopAbort) { motionPathLoopAbort.abort(); motionPathLoopAbort = null; }
46
- }
47
-
48
- // --- Motion Path Utilities ---
49
- function buildPresenterPathD(points: PathPoint[], closed: boolean): string {
50
- if (points.length < 2) return '';
51
- let d = `M ${points[0].x} ${points[0].y}`;
52
- for (let i = 1; i < points.length; i++) {
53
- const prev = points[i - 1], curr = points[i];
54
- const cp1x = prev.x + (prev.handleOut?.x ?? 0), cp1y = prev.y + (prev.handleOut?.y ?? 0);
55
- const cp2x = curr.x + (curr.handleIn?.x ?? 0), cp2y = curr.y + (curr.handleIn?.y ?? 0);
56
- if (prev.handleOut || curr.handleIn) d += ` C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${curr.x} ${curr.y}`;
57
- else d += ` L ${curr.x} ${curr.y}`;
58
- }
59
- if (closed && points.length > 2) {
60
- const last = points[points.length - 1], first = points[0];
61
- const cp1x = last.x + (last.handleOut?.x ?? 0), cp1y = last.y + (last.handleOut?.y ?? 0);
62
- const cp2x = first.x + (first.handleIn?.x ?? 0), cp2y = first.y + (first.handleIn?.y ?? 0);
63
- if (last.handleOut || first.handleIn) d += ` C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${first.x} ${first.y}`;
64
- else d += ` Z`;
65
- }
66
- return d;
67
- }
68
-
69
- function cubicBez(p0: number, p1: number, p2: number, p3: number, t: number): number {
70
- const mt = 1 - t;
71
- return mt * mt * mt * p0 + 3 * mt * mt * t * p1 + 3 * mt * t * t * p2 + t * t * t * p3;
72
- }
73
- function cubicBezDeriv(p0: number, p1: number, p2: number, p3: number, t: number): number {
74
- const mt = 1 - t;
75
- return 3 * mt * mt * (p1 - p0) + 6 * mt * t * (p2 - p1) + 3 * t * t * (p3 - p2);
76
- }
77
-
78
- function getPresenterPointOnPath(points: PathPoint[], closed: boolean, progress: number): { x: number; y: number; angle: number } {
79
- if (points.length < 2) return { x: points[0]?.x ?? 0, y: points[0]?.y ?? 0, angle: 0 };
80
- const segs: { p0x: number; p0y: number; p1x: number; p1y: number; p2x: number; p2y: number; p3x: number; p3y: number; length: number }[] = [];
81
- const segCount = closed ? points.length : points.length - 1;
82
- for (let i = 0; i < segCount; i++) {
83
- const curr = points[i], next = points[(i + 1) % points.length];
84
- const p0x = curr.x, p0y = curr.y;
85
- const p1x = curr.x + (curr.handleOut?.x ?? 0), p1y = curr.y + (curr.handleOut?.y ?? 0);
86
- const p2x = next.x + (next.handleIn?.x ?? 0), p2y = next.y + (next.handleIn?.y ?? 0);
87
- const p3x = next.x, p3y = next.y;
88
- let length = 0, prevPx = p0x, prevPy = p0y;
89
- for (let s = 1; s <= 20; s++) {
90
- const t = s / 20;
91
- const px = cubicBez(p0x, p1x, p2x, p3x, t), py = cubicBez(p0y, p1y, p2y, p3y, t);
92
- length += Math.sqrt((px - prevPx) ** 2 + (py - prevPy) ** 2);
93
- prevPx = px; prevPy = py;
94
- }
95
- segs.push({ p0x, p0y, p1x, p1y, p2x, p2y, p3x, p3y, length });
96
- }
97
- const totalLength = segs.reduce((sum, s) => sum + s.length, 0);
98
- const targetLength = progress * totalLength;
99
- let accum = 0;
100
- for (const seg of segs) {
101
- if (accum + seg.length >= targetLength || seg === segs[segs.length - 1]) {
102
- const t = Math.max(0, Math.min(1, seg.length > 0 ? (targetLength - accum) / seg.length : 0));
103
- let dx = cubicBezDeriv(seg.p0x, seg.p1x, seg.p2x, seg.p3x, t);
104
- let dy = cubicBezDeriv(seg.p0y, seg.p1y, seg.p2y, seg.p3y, t);
105
- // Degenerate tangent at endpoints (no Bezier handles) sample nearby
106
- if (Math.abs(dx) < 0.001 && Math.abs(dy) < 0.001) {
107
- const epsilon = t < 0.5 ? 0.01 : -0.01;
108
- dx = cubicBezDeriv(seg.p0x, seg.p1x, seg.p2x, seg.p3x, t + epsilon);
109
- dy = cubicBezDeriv(seg.p0y, seg.p1y, seg.p2y, seg.p3y, t + epsilon);
110
- }
111
- // Still zero (fully degenerate segment) use chord direction
112
- if (Math.abs(dx) < 0.001 && Math.abs(dy) < 0.001) {
113
- dx = seg.p3x - seg.p0x;
114
- dy = seg.p3y - seg.p0y;
115
- }
116
- return {
117
- x: cubicBez(seg.p0x, seg.p1x, seg.p2x, seg.p3x, t),
118
- y: cubicBez(seg.p0y, seg.p1y, seg.p2y, seg.p3y, t),
119
- angle: Math.atan2(dy, dx) * (180 / Math.PI)
120
- };
121
- }
122
- accum += seg.length;
123
- }
124
- return { x: points[0].x, y: points[0].y, angle: 0 };
125
- }
126
-
127
- function computeMotionPathPosition(
128
- mpPoint: { x: number; y: number; angle: number },
129
- startPoint: { x: number; y: number; angle: number },
130
- animX: number, animY: number, animW: number, animH: number,
131
- closed: boolean
132
- ): { x: number; y: number } {
133
- if (!closed) {
134
- return { x: mpPoint.x - animW / 2, y: mpPoint.y - animH / 2 };
135
- }
136
- const offsetX = (animX + animW / 2) - startPoint.x;
137
- const offsetY = (animY + animH / 2) - startPoint.y;
138
- const angleDelta = (mpPoint.angle - startPoint.angle) * Math.PI / 180;
139
- const cos = Math.cos(angleDelta);
140
- const sin = Math.sin(angleDelta);
141
- return {
142
- x: mpPoint.x + offsetX * cos - offsetY * sin - animW / 2,
143
- y: mpPoint.y + offsetX * sin + offsetY * cos - animH / 2
144
- };
145
- }
146
-
147
- let {
148
- src, data, autoplay = false, loop = false, controls = true, arrows = false,
149
- progress: showProgress = true, keyboard = true, duration: durationOverride,
150
- startSlide = 0, class: className = '', onslidechange, oncomplete
151
- }: AnimotPresenterProps = $props();
152
-
153
- // State
154
- let project = $state<AnimotProject | null>(null);
155
- let loading = $state(true);
156
- let error = $state<string | null>(null);
157
- let currentSlideIndex = $state(0);
158
- let isTransitioning = $state(false);
159
- let isAutoplay = $state(false);
160
- let transitionClass = $state('');
161
- let transitionDirection = $state<'forward' | 'backward'>('forward');
162
- let transitionDurationMs = $state(500);
163
- let containerEl: HTMLElement;
164
- let containerWidth = $state(0);
165
- let containerHeight = $state(0);
166
-
167
- let animatedElements = $state<Map<string, AnimatedElement>>(new Map());
168
- let codeHighlights = $state<Map<string, string>>(new Map());
169
- let elementContent = $state<Map<string, CanvasElement>>(new Map());
170
- let previousCodeContent = $state<Map<string, string>>(new Map());
171
- let codeMorphState = $state<Map<string, {oldCode: string, newCode: string, mode: CodeAnimationMode, speed: number, highlightColor: string}>>(new Map());
172
- let textTypewriterState = $state<Map<string, {fullText: string, displayedChars: number, isAnimating: boolean}>>(new Map());
173
- let typewriterIntervals = new Map<string, ReturnType<typeof setInterval>>();
174
- let shapeMorphStates = $state<Map<string, ShapeMorphState>>(new Map());
175
- let autoplayTimer: ReturnType<typeof setTimeout> | null = null;
176
- let menuVisible = $state(true);
177
- let mouseIdleTimer: ReturnType<typeof setTimeout> | null = null;
178
-
179
- const slides = $derived(project?.slides ?? []);
180
- const currentSlide = $derived(slides[currentSlideIndex]);
181
- const canvasWidth = $derived(currentSlide?.canvas.width ?? 800);
182
- const canvasHeight = $derived(currentSlide?.canvas.height ?? 600);
183
-
184
- const presentationScale = $derived.by(() => {
185
- if (!containerWidth || !containerHeight) return 1;
186
- const scaleX = containerWidth / canvasWidth;
187
- const scaleY = containerHeight / canvasHeight;
188
- return Math.min(scaleX, scaleY);
189
- });
190
-
191
- const backgroundStyle = $derived.by(() => {
192
- if (!currentSlide) return 'background: transparent';
193
- return getBackgroundStyle(currentSlide.canvas.background);
194
- });
195
-
196
- const allElementIds = $derived.by(() => {
197
- const ids = new Set<string>();
198
- slides.forEach(slide => slide.canvas.elements.forEach(el => ids.add(el.id)));
199
- return ids;
200
- });
201
-
202
- const sortedElementIds = $derived.by(() => {
203
- const elements: Array<{id: string, zIndex: number}> = [];
204
- for (const id of allElementIds) {
205
- const el = elementContent.get(id);
206
- if (el) elements.push({ id, zIndex: el.zIndex ?? 0 });
207
- }
208
- elements.sort((a, b) => a.zIndex - b.zIndex);
209
- return elements.map(e => e.id);
210
- });
211
-
212
- function getElementInSlide(slide: Slide | null, elementId: string): CanvasElement | undefined {
213
- return slide?.canvas.elements.find(el => el.id === elementId);
214
- }
215
-
216
- // Typewriter
217
- function startTypewriterAnimation(elementId: string, fullText: string, speed: number) {
218
- const existing = typewriterIntervals.get(elementId);
219
- if (existing) { clearInterval(existing); typewriterIntervals.delete(elementId); }
220
- textTypewriterState.set(elementId, { fullText, displayedChars: 0, isAnimating: true });
221
- textTypewriterState = new Map(textTypewriterState);
222
- const intervalMs = 1000 / speed;
223
- const interval = setInterval(() => {
224
- const state = textTypewriterState.get(elementId);
225
- if (state && state.isAnimating) {
226
- if (state.displayedChars < state.fullText.length) {
227
- textTypewriterState.set(elementId, { ...state, displayedChars: state.displayedChars + 1 });
228
- textTypewriterState = new Map(textTypewriterState);
229
- } else {
230
- clearInterval(interval); typewriterIntervals.delete(elementId);
231
- textTypewriterState.set(elementId, { ...state, isAnimating: false });
232
- textTypewriterState = new Map(textTypewriterState);
233
- }
234
- } else { clearInterval(interval); typewriterIntervals.delete(elementId); }
235
- }, intervalMs);
236
- typewriterIntervals.set(elementId, interval);
237
- }
238
-
239
- function clearAllTypewriterAnimations() {
240
- for (const [, interval] of typewriterIntervals) clearInterval(interval);
241
- typewriterIntervals.clear();
242
- textTypewriterState.clear();
243
- textTypewriterState = new Map(textTypewriterState);
244
- }
245
-
246
- // Build SVG path for 3+ control points using Catmull-Rom spline
247
- function buildCatmullRomPath(start: {x:number,y:number}, cps: {x:number,y:number}[], end: {x:number,y:number}): string {
248
- const pts = [start, ...cps, end];
249
- let d = `M ${pts[0].x} ${pts[0].y}`;
250
- for (let i = 0; i < pts.length - 1; i++) {
251
- const p0 = pts[i === 0 ? 0 : i - 1];
252
- const p1 = pts[i];
253
- const p2 = pts[i + 1];
254
- const p3 = pts[i + 2 < pts.length ? i + 2 : pts.length - 1];
255
- const c1x = p1.x + (p2.x - p0.x) / 6;
256
- const c1y = p1.y + (p2.y - p0.y) / 6;
257
- const c2x = p2.x - (p3.x - p1.x) / 6;
258
- const c2y = p2.y - (p3.y - p1.y) / 6;
259
- d += ` C ${c1x} ${c1y} ${c2x} ${c2y} ${p2.x} ${p2.y}`;
260
- }
261
- return d;
262
- }
263
-
264
- // Arrow draw/undraw/draw-undraw animation action
265
- function animateStyledArrowDraw(node: SVGPathElement, params: { enabled: boolean; mode: string; duration: number; dashPattern: string; startX: number; endX: number; slideIndex: number }) {
266
- let lastSlideIndex = params.slideIndex;
267
- let animationId: number | null = null;
268
- function runAnimation() {
269
- if (!params.enabled) return;
270
- if (animationId) cancelAnimationFrame(animationId);
271
- const svg = node.closest('svg') as SVGSVGElement | null;
272
- if (!svg) return;
273
- const goesLeftToRight = params.endX >= params.startX;
274
- const mode = params.mode;
275
- const dur = params.duration;
276
- const startTime = performance.now();
277
- if (mode === 'draw' || mode === 'draw-undraw') {
278
- svg.style.clipPath = goesLeftToRight ? 'inset(0 100% 0 0)' : 'inset(0 0 0 100%)';
279
- } else if (mode === 'undraw') {
280
- svg.style.clipPath = 'none';
281
- }
282
- function animate(currentTime: number) {
283
- const elapsed = currentTime - startTime;
284
- if (mode === 'draw') {
285
- const progress = Math.min(elapsed / dur, 1);
286
- const eased = 1 - Math.pow(1 - progress, 3);
287
- const inset = 100 * (1 - eased);
288
- svg!.style.clipPath = goesLeftToRight ? `inset(0 ${inset}% 0 0)` : `inset(0 0 0 ${inset}%)`;
289
- if (progress < 1) { animationId = requestAnimationFrame(animate); }
290
- else { svg!.style.clipPath = 'none'; animationId = null; }
291
- } else if (mode === 'undraw') {
292
- const progress = Math.min(elapsed / dur, 1);
293
- const eased = 1 - Math.pow(1 - progress, 3);
294
- const inset = 100 * eased;
295
- svg!.style.clipPath = goesLeftToRight ? `inset(0 0 0 ${inset}%)` : `inset(0 ${inset}% 0 0)`;
296
- if (progress < 1) { animationId = requestAnimationFrame(animate); }
297
- else { svg!.style.clipPath = 'inset(0 0 0 100%)'; animationId = null; }
298
- } else if (mode === 'draw-undraw') {
299
- const halfDur = dur / 2;
300
- if (elapsed < halfDur) {
301
- const progress = Math.min(elapsed / halfDur, 1);
302
- const eased = 1 - Math.pow(1 - progress, 3);
303
- const inset = 100 * (1 - eased);
304
- svg!.style.clipPath = goesLeftToRight ? `inset(0 ${inset}% 0 0)` : `inset(0 0 0 ${inset}%)`;
305
- animationId = requestAnimationFrame(animate);
306
- } else {
307
- const progress = Math.min((elapsed - halfDur) / halfDur, 1);
308
- const eased = 1 - Math.pow(1 - progress, 3);
309
- const inset = 100 * eased;
310
- svg!.style.clipPath = goesLeftToRight ? `inset(0 0 0 ${inset}%)` : `inset(0 ${inset}% 0 0)`;
311
- if (progress < 1) { animationId = requestAnimationFrame(animate); }
312
- else { svg!.style.clipPath = 'inset(0 0 0 100%)'; animationId = null; }
313
- }
314
- }
315
- }
316
- animationId = requestAnimationFrame(animate);
317
- }
318
- runAnimation();
319
- return {
320
- update(newParams: typeof params) {
321
- const slideChanged = newParams.slideIndex !== lastSlideIndex;
322
- params = newParams;
323
- if (!params.enabled) {
324
- if (animationId) { cancelAnimationFrame(animationId); animationId = null; }
325
- const svg = node.closest('svg') as SVGSVGElement | null;
326
- if (svg) svg.style.clipPath = '';
327
- lastSlideIndex = newParams.slideIndex;
328
- return;
329
- }
330
- if (slideChanged) { lastSlideIndex = newParams.slideIndex; runAnimation(); }
331
- },
332
- destroy() { if (animationId) cancelAnimationFrame(animationId); }
333
- };
334
- }
335
-
336
- // Init animated elements
337
- function initAllAnimatedElements() {
338
- const firstSlide = slides[0];
339
- if (firstSlide) {
340
- for (const element of firstSlide.canvas.elements) {
341
- if (element.type === 'code') previousCodeContent.set(element.id, (element as CodeElement).code);
342
- if (element.type === 'text') {
343
- const textEl = element as TextElement;
344
- if (textEl.animation?.mode === 'typewriter') startTypewriterAnimation(element.id, textEl.content, textEl.animation.typewriterSpeed || 50);
345
- }
346
- }
347
- }
348
- for (const slide of slides) {
349
- for (const element of slide.canvas.elements) {
350
- if (!animatedElements.has(element.id)) {
351
- const inCurrent = getElementInSlide(currentSlide, element.id);
352
- const startOpacity = inCurrent ? ((inCurrent as any).opacity ?? 1) : 0;
353
- const br = (element as any).borderRadius ?? 0;
354
- const isShape = element.type === 'shape';
355
- const shapeEl = isShape ? element as ShapeElement : null;
356
- const isText = element.type === 'text';
357
- const textEl = isText ? element as TextElement : null;
358
- animatedElements.set(element.id, {
359
- x: tween(element.position.x, { duration: 500 }),
360
- y: tween(element.position.y, { duration: 500 }),
361
- width: tween(element.size.width, { duration: 500 }),
362
- height: tween(element.size.height, { duration: 500 }),
363
- rotation: tween(element.rotation, { duration: 500 }),
364
- skewX: tween(element.skewX ?? 0, { duration: 500 }),
365
- skewY: tween(element.skewY ?? 0, { duration: 500 }),
366
- tiltX: tween(element.tiltX ?? 0, { duration: 500 }),
367
- tiltY: tween(element.tiltY ?? 0, { duration: 500 }),
368
- perspective: tween(element.perspective ?? 1000, { duration: 500 }),
369
- opacity: tween(startOpacity, { duration: 300 }),
370
- borderRadius: tween(br, { duration: 500 }),
371
- fontSize: textEl ? tween(textEl.fontSize, { duration: 500 }) : null,
372
- fillColor: shapeEl ? tween(shapeEl.fillColor, { duration: 500 }) : null,
373
- strokeColor: shapeEl ? tween(shapeEl.strokeColor, { duration: 500 }) : null,
374
- strokeWidth: shapeEl ? tween(shapeEl.strokeWidth, { duration: 500 }) : null,
375
- shapeMorph: shapeEl ? tween(1, { duration: 500 }) : null,
376
- motionPathProgress: element.motionPathConfig ? tween(0, { duration: 500 }) : null,
377
- blur: tween(element.blur ?? 0, { duration: 500 }),
378
- brightness: tween(element.brightness ?? 100, { duration: 500 }),
379
- contrast: tween(element.contrast ?? 100, { duration: 500 }),
380
- saturate: tween(element.saturate ?? 100, { duration: 500 }),
381
- grayscale: tween(element.grayscale ?? 0, { duration: 500 })
382
- });
383
- const currentSlideEl = getElementInSlide(currentSlide, element.id);
384
- elementContent.set(element.id, JSON.parse(JSON.stringify(currentSlideEl || element)));
385
- }
386
- }
387
- }
388
- animatedElements = new Map(animatedElements);
389
- elementContent = new Map(elementContent);
390
- previousCodeContent = new Map(previousCodeContent);
391
- }
392
-
393
- async function animateMotionPaths(slide: Slide) {
394
- cancelMotionPathLoops();
395
- motionPathLoopAbort = new AbortController();
396
- const signal = motionPathLoopAbort.signal;
397
-
398
- const resets: Promise<void>[] = [];
399
- for (const element of slide.canvas.elements) {
400
- if (element.motionPathConfig) {
401
- const animated = animatedElements.get(element.id);
402
- if (animated?.motionPathProgress) {
403
- resets.push(animated.motionPathProgress.to(0, { duration: 0 }));
404
- }
405
- }
406
- }
407
- await Promise.all(resets);
408
- for (const element of slide.canvas.elements) {
409
- if (element.motionPathConfig) {
410
- const animated = animatedElements.get(element.id);
411
- if (animated?.motionPathProgress) {
412
- const config = element.animationConfig;
413
- const duration = config?.duration ?? 2000;
414
- const easing = getEasingFn(config?.easing);
415
- const shouldLoop = element.motionPathConfig.loop;
416
-
417
- if (shouldLoop) {
418
- const laps = element.motionPathConfig.laps ?? 0;
419
- (async () => {
420
- let lap = 0;
421
- while (!signal.aborted && (laps === 0 || lap < laps)) {
422
- await animated.motionPathProgress!.to(0, { duration: 0 });
423
- await animated.motionPathProgress!.to(1, { duration, easing });
424
- lap++;
425
- if (!signal.aborted && (laps === 0 || lap < laps)) await new Promise(r => setTimeout(r, 50));
426
- }
427
- })();
428
- } else {
429
- animated.motionPathProgress.to(1, { duration, easing });
430
- }
431
- }
432
- }
433
- }
434
- }
435
-
436
- // Animate to slide
437
- async function animateToSlide(targetIndex: number) {
438
- if (isTransitioning || targetIndex < 0 || targetIndex >= slides.length) return;
439
- if (targetIndex === currentSlideIndex) return;
440
- isTransitioning = true;
441
- transitionDirection = targetIndex > currentSlideIndex ? 'forward' : 'backward';
442
- const targetSlide = slides[targetIndex];
443
- clearAllTypewriterAnimations();
444
- cancelMotionPathLoops();
445
- const transition = targetSlide.transition;
446
- const duration = durationOverride ?? transition.duration;
447
- transitionDurationMs = duration;
448
- const hasSlideTransition = transition.type !== 'none';
449
-
450
- if (hasSlideTransition) {
451
- transitionClass = `transition-${transition.type}-out`;
452
- await new Promise(r => setTimeout(r, duration * 0.4));
453
- const newElementContent = new Map(elementContent);
454
- const newCodeMorphState = new Map(codeMorphState);
455
- const newPreviousCodeContent = new Map(previousCodeContent);
456
- for (const elementId of allElementIds) {
457
- const targetEl = getElementInSlide(targetSlide, elementId);
458
- const animated = animatedElements.get(elementId);
459
- if (targetEl) {
460
- if (targetEl.type === 'code') {
461
- const codeEl = targetEl as CodeElement;
462
- const prevCode = newPreviousCodeContent.get(elementId) || '';
463
- newCodeMorphState.set(elementId, { oldCode: prevCode, newCode: codeEl.code, mode: codeEl.animation?.mode || 'highlight-changes', speed: codeEl.animation?.typewriterSpeed || 50, highlightColor: codeEl.animation?.highlightColor || '#fef08a' });
464
- newPreviousCodeContent.set(elementId, codeEl.code);
465
- }
466
- newElementContent.set(elementId, JSON.parse(JSON.stringify(targetEl)));
467
- if (animated) {
468
- animated.x.to(targetEl.position.x, { duration: 0 }); animated.y.to(targetEl.position.y, { duration: 0 });
469
- animated.width.to(targetEl.size.width, { duration: 0 }); animated.height.to(targetEl.size.height, { duration: 0 });
470
- animated.rotation.to(targetEl.rotation, { duration: 0 });
471
- animated.skewX.to(targetEl.skewX ?? 0, { duration: 0 }); animated.skewY.to(targetEl.skewY ?? 0, { duration: 0 });
472
- animated.tiltX.to(targetEl.tiltX ?? 0, { duration: 0 }); animated.tiltY.to(targetEl.tiltY ?? 0, { duration: 0 });
473
- animated.perspective.to(targetEl.perspective ?? 1000, { duration: 0 });
474
- animated.opacity.to((targetEl as any).opacity ?? 1, { duration: 0 });
475
- animated.borderRadius.to((targetEl as any).borderRadius ?? 0, { duration: 0 });
476
- animated.blur.to(targetEl.blur ?? 0, { duration: 0 });
477
- animated.brightness.to(targetEl.brightness ?? 100, { duration: 0 });
478
- animated.contrast.to(targetEl.contrast ?? 100, { duration: 0 });
479
- animated.saturate.to(targetEl.saturate ?? 100, { duration: 0 });
480
- animated.grayscale.to(targetEl.grayscale ?? 0, { duration: 0 });
481
- if (targetEl.type === 'text') animated.fontSize?.to((targetEl as TextElement).fontSize, { duration: 0 });
482
- if (targetEl.type === 'shape') {
483
- const s = targetEl as ShapeElement;
484
- animated.fillColor?.to(s.fillColor, { duration: 0 });
485
- animated.strokeColor?.to(s.strokeColor, { duration: 0 });
486
- animated.strokeWidth?.to(s.strokeWidth, { duration: 0 });
487
- }
488
- if (animated.motionPathProgress) animated.motionPathProgress.to(0, { duration: 0 });
489
- }
490
- } else if (animated) { animated.opacity.to(0, { duration: 0 }); }
491
- }
492
- for (const [, element] of newElementContent) {
493
- if (element.type === 'code') {
494
- const codeEl = element as CodeElement;
495
- const key = `${codeEl.id}-${codeEl.code}-${codeEl.language}-${codeEl.showLineNumbers}`;
496
- if (!codeHighlights.has(key)) {
497
- const html = await highlightCode(codeEl.code, codeEl.language, codeEl.theme, { showLineNumbers: codeEl.showLineNumbers });
498
- codeHighlights.set(key, html);
499
- }
500
- }
501
- }
502
- codeHighlights = new Map(codeHighlights);
503
- shapeMorphStates = new Map();
504
- codeMorphState = newCodeMorphState;
505
- previousCodeContent = newPreviousCodeContent;
506
- elementContent = newElementContent;
507
- animatedElements = new Map(animatedElements);
508
- currentSlideIndex = targetIndex;
509
- for (const elementId of allElementIds) {
510
- const targetEl = getElementInSlide(targetSlide, elementId);
511
- if (targetEl?.type === 'text') {
512
- const textEl = targetEl as TextElement;
513
- if (textEl.animation?.mode === 'typewriter') startTypewriterAnimation(elementId, textEl.content, textEl.animation.typewriterSpeed || 50);
514
- }
515
- }
516
- transitionClass = `transition-${transition.type}-in`;
517
- await new Promise(r => setTimeout(r, duration * 0.6));
518
- transitionClass = '';
519
- animateMotionPaths(targetSlide);
520
- isTransitioning = false;
521
- onslidechange?.(targetIndex, slides.length);
522
- if (targetIndex === slides.length - 1 && !loop) oncomplete?.();
523
- return;
524
- }
525
-
526
- // Per-element morphing (transition type = 'none')
527
- const animations: Promise<void>[] = [];
528
- for (const elementId of allElementIds) {
529
- const currentEl = getElementInSlide(currentSlide, elementId);
530
- const animated = animatedElements.get(elementId);
531
- if (!animated) continue;
532
- if (currentEl) {
533
- await animated.x.to(currentEl.position.x, { duration: 0 });
534
- await animated.y.to(currentEl.position.y, { duration: 0 });
535
- await animated.width.to(currentEl.size.width, { duration: 0 });
536
- await animated.height.to(currentEl.size.height, { duration: 0 });
537
- await animated.rotation.to(currentEl.rotation, { duration: 0 });
538
- await animated.skewX.to(currentEl.skewX ?? 0, { duration: 0 });
539
- await animated.skewY.to(currentEl.skewY ?? 0, { duration: 0 });
540
- await animated.tiltX.to(currentEl.tiltX ?? 0, { duration: 0 });
541
- await animated.tiltY.to(currentEl.tiltY ?? 0, { duration: 0 });
542
- await animated.perspective.to(currentEl.perspective ?? 1000, { duration: 0 });
543
- await animated.borderRadius.to((currentEl as any).borderRadius ?? 0, { duration: 0 });
544
- await animated.blur.to(currentEl.blur ?? 0, { duration: 0 });
545
- await animated.brightness.to(currentEl.brightness ?? 100, { duration: 0 });
546
- await animated.contrast.to(currentEl.contrast ?? 100, { duration: 0 });
547
- await animated.saturate.to(currentEl.saturate ?? 100, { duration: 0 });
548
- await animated.grayscale.to(currentEl.grayscale ?? 0, { duration: 0 });
549
- await animated.opacity.to((currentEl as any).opacity ?? 1, { duration: 0 });
550
- if (currentEl.type === 'text' && animated.fontSize) await animated.fontSize.to((currentEl as TextElement).fontSize, { duration: 0 });
551
- if (currentEl.type === 'shape') {
552
- const s = currentEl as ShapeElement;
553
- if (animated.fillColor) await animated.fillColor.to(s.fillColor, { duration: 0 });
554
- if (animated.strokeColor) await animated.strokeColor.to(s.strokeColor, { duration: 0 });
555
- if (animated.strokeWidth) await animated.strokeWidth.to(s.strokeWidth, { duration: 0 });
556
- }
557
- }
558
- }
559
-
560
- // Update elementContent BEFORE animations start so rendered elements
561
- // (especially SVG viewBox) use target slide data while animating
562
- for (const elementId of allElementIds) {
563
- const targetEl = getElementInSlide(targetSlide, elementId);
564
- if (targetEl && targetEl.type !== 'code') {
565
- elementContent.set(elementId, JSON.parse(JSON.stringify(targetEl)));
566
- }
567
- }
568
- elementContent = new Map(elementContent);
569
-
570
- interface AnimationTask { elementId: string; order: number; delay: number; elementDuration: number; run: () => Promise<void>[]; }
571
- const animationTasks: AnimationTask[] = [];
572
-
573
- for (const elementId of allElementIds) {
574
- const currentEl = getElementInSlide(currentSlide, elementId);
575
- const targetEl = getElementInSlide(targetSlide, elementId);
576
- const animated = animatedElements.get(elementId);
577
- if (!animated) continue;
578
- const animConfig = targetEl?.animationConfig || currentEl?.animationConfig;
579
- const order = animConfig?.order ?? 0;
580
- const delay = animConfig?.delay ?? 0;
581
- const elementDuration = animConfig?.duration ?? duration;
582
-
583
- if (targetEl) {
584
- const easing = getEasingFn(animConfig?.easing);
585
- const propertySequences = targetEl.animationConfig?.propertySequences;
586
- if (targetEl.type === 'text') {
587
- const textEl = targetEl as TextElement;
588
- if (textEl.animation?.mode === 'typewriter') startTypewriterAnimation(elementId, textEl.content, textEl.animation.typewriterSpeed || 50);
589
- }
590
-
591
- const getSeqTiming = (prop: AnimatableProperty) => {
592
- if (!propertySequences?.length) return { duration: elementDuration, delay: 0, order: 0 };
593
- const seq = propertySequences.find(s => s.property === prop);
594
- return seq ? { duration: seq.duration, delay: seq.delay, order: seq.order } : { duration: elementDuration, delay: 0, order: 99 };
595
- };
596
-
597
- animationTasks.push({
598
- elementId, order, delay, elementDuration,
599
- run: () => {
600
- const anims: Promise<void>[] = [];
601
- if (propertySequences?.length) {
602
- const sequencedProps = new Set(propertySequences.map(s => s.property));
603
- 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 })); }
604
- if (!sequencedProps.has('rotation')) anims.push(animated.rotation.to(targetEl.rotation, { duration: elementDuration, easing }));
605
- 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 })); }
606
- 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 })); }
607
- 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 })); }
608
- if (!sequencedProps.has('borderRadius')) anims.push(animated.borderRadius.to((targetEl as any).borderRadius ?? 0, { duration: elementDuration, easing }));
609
- if (!sequencedProps.has('blur')) {
610
- animated.blur.to(targetEl.blur ?? 0, { duration: elementDuration, easing });
611
- animated.brightness.to(targetEl.brightness ?? 100, { duration: elementDuration, easing });
612
- animated.contrast.to(targetEl.contrast ?? 100, { duration: elementDuration, easing });
613
- animated.saturate.to(targetEl.saturate ?? 100, { duration: elementDuration, easing });
614
- animated.grayscale.to(targetEl.grayscale ?? 0, { duration: elementDuration, easing });
615
- }
616
- if (!sequencedProps.has('perspective')) anims.push(animated.perspective.to(targetEl.perspective ?? 1000, { duration: elementDuration, easing }));
617
- if (!sequencedProps.has('opacity')) {
618
- const targetOpacity = (targetEl as any).opacity ?? 1;
619
- if (animated.opacity.current !== targetOpacity) anims.push(animated.opacity.to(targetOpacity, { duration: elementDuration, easing }));
620
- }
621
- const sortedSeqs = [...propertySequences].sort((a, b) => a.order - b.order);
622
- let cumulativeDelay = 0;
623
- for (const seq of sortedSeqs) {
624
- const seqDelay = cumulativeDelay + seq.delay;
625
- const seqDuration = seq.duration;
626
- setTimeout(() => {
627
- if (seq.property === 'position') { animated.x.to(targetEl.position.x, { duration: seqDuration, easing }); animated.y.to(targetEl.position.y, { duration: seqDuration, easing }); }
628
- else if (seq.property === 'rotation') animated.rotation.to(targetEl.rotation, { duration: seqDuration, easing });
629
- else if (seq.property === 'tilt') { animated.tiltX.to(targetEl.tiltX ?? 0, { duration: seqDuration, easing }); animated.tiltY.to(targetEl.tiltY ?? 0, { duration: seqDuration, easing }); }
630
- else if (seq.property === 'skew') { animated.skewX.to(targetEl.skewX ?? 0, { duration: seqDuration, easing }); animated.skewY.to(targetEl.skewY ?? 0, { duration: seqDuration, easing }); }
631
- else if (seq.property === 'size') { animated.width.to(targetEl.size.width, { duration: seqDuration, easing }); animated.height.to(targetEl.size.height, { duration: seqDuration, easing }); }
632
- else if (seq.property === 'borderRadius') animated.borderRadius.to((targetEl as any).borderRadius ?? 0, { duration: seqDuration, easing });
633
- else if (seq.property === 'blur') {
634
- animated.blur.to(targetEl.blur ?? 0, { duration: seqDuration, easing });
635
- animated.brightness.to(targetEl.brightness ?? 100, { duration: seqDuration, easing });
636
- animated.contrast.to(targetEl.contrast ?? 100, { duration: seqDuration, easing });
637
- animated.saturate.to(targetEl.saturate ?? 100, { duration: seqDuration, easing });
638
- animated.grayscale.to(targetEl.grayscale ?? 0, { duration: seqDuration, easing });
639
- }
640
- else if (seq.property === 'color' && targetEl.type === 'shape') {
641
- const s = targetEl as ShapeElement;
642
- animated.fillColor?.to(s.fillColor, { duration: seqDuration, easing });
643
- animated.strokeColor?.to(s.strokeColor, { duration: seqDuration, easing });
644
- animated.strokeWidth?.to(s.strokeWidth, { duration: seqDuration, easing });
645
- }
646
- else if (seq.property === 'perspective') animated.perspective.to(targetEl.perspective ?? 1000, { duration: seqDuration, easing });
647
- else if (seq.property === 'opacity') animated.opacity.to((targetEl as any).opacity ?? 1, { duration: seqDuration, easing });
648
- }, seqDelay);
649
- cumulativeDelay = seqDelay + seqDuration;
650
- }
651
- anims.push(new Promise(r => setTimeout(r, cumulativeDelay)));
652
- } else {
653
- anims.push(animated.x.to(targetEl.position.x, { duration: elementDuration, easing }));
654
- anims.push(animated.y.to(targetEl.position.y, { duration: elementDuration, easing }));
655
- anims.push(animated.width.to(targetEl.size.width, { duration: elementDuration, easing }));
656
- anims.push(animated.height.to(targetEl.size.height, { duration: elementDuration, easing }));
657
- anims.push(animated.rotation.to(targetEl.rotation, { duration: elementDuration, easing }));
658
- anims.push(animated.skewX.to(targetEl.skewX ?? 0, { duration: elementDuration, easing }));
659
- anims.push(animated.skewY.to(targetEl.skewY ?? 0, { duration: elementDuration, easing }));
660
- anims.push(animated.tiltX.to(targetEl.tiltX ?? 0, { duration: elementDuration, easing }));
661
- anims.push(animated.tiltY.to(targetEl.tiltY ?? 0, { duration: elementDuration, easing }));
662
- anims.push(animated.perspective.to(targetEl.perspective ?? 1000, { duration: elementDuration, easing }));
663
- anims.push(animated.borderRadius.to((targetEl as any).borderRadius ?? 0, { duration: elementDuration, easing }));
664
- anims.push(animated.blur.to(targetEl.blur ?? 0, { duration: elementDuration, easing }));
665
- anims.push(animated.brightness.to(targetEl.brightness ?? 100, { duration: elementDuration, easing }));
666
- anims.push(animated.contrast.to(targetEl.contrast ?? 100, { duration: elementDuration, easing }));
667
- anims.push(animated.saturate.to(targetEl.saturate ?? 100, { duration: elementDuration, easing }));
668
- anims.push(animated.grayscale.to(targetEl.grayscale ?? 0, { duration: elementDuration, easing }));
669
- // Opacity interpolation for morphing elements
670
- const currOpacity = animated.opacity.current;
671
- const targetOpacity = (targetEl as any).opacity ?? 1;
672
- if (currOpacity !== targetOpacity) {
673
- anims.push(animated.opacity.to(targetOpacity, { duration: elementDuration, easing }));
674
- }
675
- }
676
- // Motion path progress — await reset, then animate forward
677
- if (animated.motionPathProgress && targetEl.motionPathConfig) {
678
- const shouldLoop = targetEl.motionPathConfig.loop;
679
- if (!shouldLoop) {
680
- anims.push((async () => {
681
- await animated.motionPathProgress!.to(0, { duration: 0 });
682
- await animated.motionPathProgress!.to(1, { duration: elementDuration, easing });
683
- })());
684
- }
685
- }
686
- if (targetEl.type === 'text' && animated.fontSize) anims.push(animated.fontSize.to((targetEl as TextElement).fontSize, { duration: elementDuration, easing }));
687
- if (targetEl.type === 'shape' && currentEl?.type === 'shape') {
688
- const ts = targetEl as ShapeElement;
689
- const cs = currentEl as ShapeElement;
690
- if (!propertySequences?.length) {
691
- if (animated.fillColor) anims.push(animated.fillColor.to(ts.fillColor, { duration: elementDuration, easing }));
692
- if (animated.strokeColor) anims.push(animated.strokeColor.to(ts.strokeColor, { duration: elementDuration, easing }));
693
- if (animated.strokeWidth) anims.push(animated.strokeWidth.to(ts.strokeWidth, { duration: elementDuration, easing }));
694
- }
695
- if (cs.shapeType !== ts.shapeType && animated.shapeMorph) {
696
- shapeMorphStates.set(elementId, { fromType: cs.shapeType, toType: ts.shapeType });
697
- shapeMorphStates = new Map(shapeMorphStates);
698
- anims.push(animated.shapeMorph.to(0, { duration: 0 }));
699
- anims.push(animated.shapeMorph.to(1, { duration: elementDuration, easing }));
700
- }
701
- } else if (targetEl.type === 'shape' && !propertySequences?.length) {
702
- const s = targetEl as ShapeElement;
703
- if (animated.fillColor) anims.push(animated.fillColor.to(s.fillColor, { duration: elementDuration, easing }));
704
- if (animated.strokeColor) anims.push(animated.strokeColor.to(s.strokeColor, { duration: elementDuration, easing }));
705
- if (animated.strokeWidth) anims.push(animated.strokeWidth.to(s.strokeWidth, { duration: elementDuration, easing }));
706
- }
707
- if (!currentEl) {
708
- // Snap ALL properties to target instantly the tween may hold
709
- // stale values from a previous slide where the element last appeared
710
- anims.push(animated.x.to(targetEl.position.x, { duration: 0 }));
711
- anims.push(animated.y.to(targetEl.position.y, { duration: 0 }));
712
- anims.push(animated.width.to(targetEl.size.width, { duration: 0 }));
713
- anims.push(animated.height.to(targetEl.size.height, { duration: 0 }));
714
- anims.push(animated.rotation.to(targetEl.rotation, { duration: 0 }));
715
- anims.push(animated.skewX.to(targetEl.skewX ?? 0, { duration: 0 }));
716
- anims.push(animated.skewY.to(targetEl.skewY ?? 0, { duration: 0 }));
717
- anims.push(animated.tiltX.to(targetEl.tiltX ?? 0, { duration: 0 }));
718
- anims.push(animated.tiltY.to(targetEl.tiltY ?? 0, { duration: 0 }));
719
- anims.push(animated.perspective.to(targetEl.perspective ?? 1000, { duration: 0 }));
720
- anims.push(animated.borderRadius.to((targetEl as any).borderRadius ?? 0, { duration: 0 }));
721
- anims.push(animated.blur.to(targetEl.blur ?? 0, { duration: 0 }));
722
- anims.push(animated.brightness.to(targetEl.brightness ?? 100, { duration: 0 }));
723
- anims.push(animated.contrast.to(targetEl.contrast ?? 100, { duration: 0 }));
724
- anims.push(animated.saturate.to(targetEl.saturate ?? 100, { duration: 0 }));
725
- anims.push(animated.grayscale.to(targetEl.grayscale ?? 0, { duration: 0 }));
726
- if (targetEl.type === 'text' && animated.fontSize) {
727
- anims.push(animated.fontSize.to((targetEl as TextElement).fontSize, { duration: 0 }));
728
- }
729
- if (targetEl.type === 'shape') {
730
- const s = targetEl as ShapeElement;
731
- if (animated.fillColor) anims.push(animated.fillColor.to(s.fillColor, { duration: 0 }));
732
- if (animated.strokeColor) anims.push(animated.strokeColor.to(s.strokeColor, { duration: 0 }));
733
- if (animated.strokeWidth) anims.push(animated.strokeWidth.to(s.strokeWidth, { duration: 0 }));
734
- }
735
- const entrance = targetEl.animationConfig?.entrance ?? 'fade';
736
- const targetOpacity = (targetEl as any).opacity ?? 1;
737
- if (entrance === 'fade') {
738
- anims.push(animated.opacity.to(targetOpacity, { duration: elementDuration / 2, easing }));
739
- } else {
740
- anims.push(animated.opacity.to(targetOpacity, { duration: 0 }));
741
- }
742
- }
743
- return anims;
744
- }
745
- });
746
- } else if (currentEl) {
747
- const exit = currentEl.animationConfig?.exit ?? 'fade';
748
- if (exit === 'fade') {
749
- const fadeOutDuration = Math.min(elementDuration / 2, 300);
750
- animationTasks.push({ elementId, order, delay, elementDuration, run: () => [animated.opacity.to(0, { duration: fadeOutDuration, easing: easeInOutCubic })] });
751
- } else {
752
- animationTasks.push({ elementId, order, delay: 0, elementDuration: 0, run: () => [animated.opacity.to(0, { duration: 0 })] });
753
- }
754
- }
755
- }
756
-
757
- animationTasks.sort((a, b) => a.order - b.order);
758
- const orderGroups = new Map<number, AnimationTask[]>();
759
- for (const task of animationTasks) {
760
- if (!orderGroups.has(task.order)) orderGroups.set(task.order, []);
761
- orderGroups.get(task.order)!.push(task);
762
- }
763
- const sortedOrders = [...orderGroups.keys()].sort((a, b) => a - b);
764
- for (let orderIdx = 0; orderIdx < sortedOrders.length; orderIdx++) {
765
- const order = sortedOrders[orderIdx];
766
- const tasks = orderGroups.get(order)!;
767
- const groupAnimations: Promise<void>[] = [];
768
- for (const task of tasks) {
769
- if (task.delay > 0) setTimeout(() => { task.run().forEach(p => animations.push(p)); }, task.delay);
770
- else groupAnimations.push(...task.run());
771
- }
772
- animations.push(...groupAnimations);
773
- if (orderIdx < sortedOrders.length - 1) {
774
- const maxDur = Math.max(...tasks.map(t => t.elementDuration));
775
- await new Promise(r => setTimeout(r, maxDur * 0.3));
776
- }
777
- }
778
-
779
- const newElementContent = new Map(elementContent);
780
- const newCodeMorphState = new Map(codeMorphState);
781
- const newPreviousCodeContent = new Map(previousCodeContent);
782
- for (const elementId of allElementIds) {
783
- const targetEl = getElementInSlide(targetSlide, elementId);
784
- if (targetEl) {
785
- if (targetEl.type === 'code') {
786
- const codeEl = targetEl as CodeElement;
787
- const prevCode = newPreviousCodeContent.get(elementId) || '';
788
- newCodeMorphState.set(elementId, { oldCode: prevCode, newCode: codeEl.code, mode: codeEl.animation?.mode || 'highlight-changes', speed: codeEl.animation?.typewriterSpeed || 50, highlightColor: codeEl.animation?.highlightColor || '#fef08a' });
789
- newPreviousCodeContent.set(elementId, codeEl.code);
790
- }
791
- newElementContent.set(elementId, JSON.parse(JSON.stringify(targetEl)));
792
- }
793
- }
794
- for (const [, element] of newElementContent) {
795
- if (element.type === 'code') {
796
- const codeEl = element as CodeElement;
797
- const key = `${codeEl.id}-${codeEl.code}-${codeEl.language}-${codeEl.showLineNumbers}`;
798
- if (!codeHighlights.has(key)) {
799
- const html = await highlightCode(codeEl.code, codeEl.language, codeEl.theme, { showLineNumbers: codeEl.showLineNumbers });
800
- codeHighlights.set(key, html);
801
- }
802
- }
803
- }
804
- codeHighlights = new Map(codeHighlights);
805
- shapeMorphStates = new Map();
806
- codeMorphState = newCodeMorphState;
807
- previousCodeContent = newPreviousCodeContent;
808
- elementContent = newElementContent;
809
- currentSlideIndex = targetIndex;
810
- isTransitioning = false;
811
- // Ensure elements not on the new slide are fully hidden
812
- const newSlide = slides[targetIndex];
813
- for (const elementId of allElementIds) {
814
- const onSlide = getElementInSlide(newSlide, elementId);
815
- const animated = animatedElements.get(elementId);
816
- if (!onSlide && animated) { animated.opacity.to(0, { duration: 0 }); }
817
- }
818
- animateMotionPaths(slides[targetIndex]);
819
- onslidechange?.(targetIndex, slides.length);
820
- if (targetIndex === slides.length - 1 && !loop) oncomplete?.();
821
- }
822
-
823
- // Autoplay
824
- function clearAutoplayTimer() { if (autoplayTimer) { clearTimeout(autoplayTimer); autoplayTimer = null; } }
825
- function scheduleNextSlide() {
826
- clearAutoplayTimer();
827
- if (!isAutoplay) return;
828
- const slideDuration = durationOverride ?? currentSlide?.duration ?? 3000;
829
- autoplayTimer = setTimeout(() => {
830
- if (currentSlideIndex < slides.length - 1) animateToSlide(currentSlideIndex + 1);
831
- else if (loop) animateToSlide(0);
832
- else isAutoplay = false;
833
- }, slideDuration);
834
- }
835
- $effect(() => { if (isAutoplay && !isTransitioning) scheduleNextSlide(); });
836
- $effect(() => () => clearAutoplayTimer());
837
-
838
- // Keyboard
839
- function handleKeyDown(e: KeyboardEvent) {
840
- if (!keyboard) return;
841
- if (e.key === 'ArrowRight' || e.key === ' ' || e.key === 'Enter') { e.preventDefault(); animateToSlide(currentSlideIndex + 1); }
842
- else if (e.key === 'ArrowLeft' || e.key === 'Backspace') { e.preventDefault(); animateToSlide(currentSlideIndex - 1); }
843
- else if (e.key === 'Home') animateToSlide(0);
844
- else if (e.key === 'End') animateToSlide(slides.length - 1);
845
- else if (e.key === 'p' || e.key === 'P') { isAutoplay = !isAutoplay; if (!isAutoplay) clearAutoplayTimer(); }
846
- }
847
-
848
- function resetMouseIdleTimer() {
849
- menuVisible = true;
850
- if (mouseIdleTimer) clearTimeout(mouseIdleTimer);
851
- mouseIdleTimer = setTimeout(() => { menuVisible = false; }, 3000);
852
- }
853
-
854
- // Code highlight helpers
855
- async function loadCodeHighlights() {
856
- for (const slide of slides) {
857
- for (const element of slide.canvas.elements) {
858
- if (element.type === 'code') {
859
- const codeEl = element as CodeElement;
860
- const key = `${codeEl.id}-${codeEl.code}-${codeEl.language}-${codeEl.showLineNumbers}`;
861
- if (!codeHighlights.has(key)) {
862
- const html = await highlightCode(codeEl.code, codeEl.language, codeEl.theme, { showLineNumbers: codeEl.showLineNumbers });
863
- codeHighlights.set(key, html);
864
- }
865
- }
866
- }
867
- }
868
- codeHighlights = new Map(codeHighlights);
869
- }
870
-
871
- function getCodeHighlight(elementId: string): string {
872
- const slideElement = getElementInSlide(currentSlide, elementId);
873
- if (!slideElement || slideElement.type !== 'code') return '';
874
- const codeEl = slideElement as CodeElement;
875
- const key = `${codeEl.id}-${codeEl.code}-${codeEl.language}-${codeEl.showLineNumbers}`;
876
- const cached = codeHighlights.get(key);
877
- if (cached) return cached;
878
- highlightCode(codeEl.code, codeEl.language, codeEl.theme, { showLineNumbers: codeEl.showLineNumbers }).then(html => {
879
- codeHighlights.set(key, html);
880
- codeHighlights = new Map(codeHighlights);
881
- });
882
- return '';
883
- }
884
-
885
- // Public API (exposed via bind:this)
886
- export async function goto(slideIndex: number) { await animateToSlide(slideIndex); }
887
- export async function next() { await animateToSlide(currentSlideIndex + 1); }
888
- export async function prev() { await animateToSlide(currentSlideIndex - 1); }
889
- export function play() { isAutoplay = true; }
890
- export function pause() { isAutoplay = false; clearAutoplayTimer(); }
891
- export function getCurrentSlide() { return currentSlideIndex; }
892
- export function getTotalSlides() { return slides.length; }
893
- export function getIsPlaying() { return isAutoplay; }
894
-
895
- // Auto-load Google Fonts used by text elements in the project.
896
- // Generic CSS font families that don't need loading
897
- const GENERIC_FONTS = new Set([
898
- 'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy',
899
- 'system-ui', 'ui-serif', 'ui-sans-serif', 'ui-monospace', 'ui-rounded',
900
- 'math', 'emoji', 'fangsong', 'inherit', 'initial', 'unset'
901
- ]);
902
-
903
- // Extract individual font names from a CSS font-family string.
904
- // e.g. '"JetBrains Mono", system-ui, monospace' → ['JetBrains Mono']
905
- function extractFontNames(fontFamily: string): string[] {
906
- return fontFamily
907
- .split(',')
908
- .map(f => f.trim().replace(/^['"]|['"]$/g, ''))
909
- .filter(f => f && !GENERIC_FONTS.has(f.toLowerCase()));
910
- }
911
-
912
- // Auto-load fonts used by text/counter elements.
913
- // Uses fontsource CDN (jsDelivr) which registers the SAME font-family names
914
- // as the app (e.g. "Plus Jakarta Sans Variable"), unlike Google Fonts which
915
- // strips the "Variable" suffix.
916
- function loadProjectFonts(proj: AnimotProject) {
917
- const fonts = new Set<string>();
918
- for (const slide of proj.slides) {
919
- for (const el of slide.canvas.elements) {
920
- if (el.type === 'text' || el.type === 'counter') {
921
- const f = (el as any).fontFamily as string | undefined;
922
- if (f) {
923
- for (const name of extractFontNames(f)) fonts.add(name);
924
- }
925
- }
926
- }
927
- }
928
- if (fonts.size === 0) return;
929
-
930
- // Deduplicate against already-injected links to avoid double-loading
931
- const loaded = new Set<string>();
932
- document.querySelectorAll<HTMLLinkElement>('link[data-animot-font]').forEach(l => loaded.add(l.dataset.animotFont!));
933
-
934
- for (const font of fonts) {
935
- if (loaded.has(font)) continue;
936
- const isVariable = /\s+Variable$/i.test(font);
937
- // Convert font name to fontsource package slug:
938
- // "Plus Jakarta Sans Variable" → "plus-jakarta-sans"
939
- // "JetBrains Mono" → "jetbrains-mono"
940
- const baseName = font.replace(/\s*Variable$/i, '');
941
- const slug = baseName.toLowerCase().replace(/\s+/g, '-');
942
- const pkg = isVariable
943
- ? `@fontsource-variable/${slug}`
944
- : `@fontsource/${slug}`;
945
- const link = document.createElement('link');
946
- link.rel = 'stylesheet';
947
- link.href = `https://cdn.jsdelivr.net/npm/${pkg}/index.css`;
948
- link.dataset.animotFont = font;
949
- document.head.appendChild(link);
950
- }
951
- }
952
-
953
- // Load data
954
- async function loadProject() {
955
- loading = true; error = null;
956
- try {
957
- if (data) { project = data; }
958
- else if (src) {
959
- const res = await fetch(src);
960
- if (!res.ok) throw new Error(`Failed to load: ${res.status}`);
961
- project = await res.json();
962
- } else { throw new Error('Either src or data prop is required'); }
963
- loadProjectFonts(project!);
964
- currentSlideIndex = startSlide;
965
- await new Promise(r => setTimeout(r, 10));
966
- initAllAnimatedElements();
967
- await loadCodeHighlights();
968
- loading = false;
969
- if (currentSlide) setTimeout(() => animateMotionPaths(currentSlide!), 300);
970
- if (autoplay) isAutoplay = true;
971
- } catch (e: any) { error = e.message; loading = false; }
972
- }
973
-
974
- // ResizeObserver
975
- let resizeObserver: ResizeObserver;
976
-
977
- onMount(() => {
978
- loadProject();
979
- resizeObserver = new ResizeObserver(entries => {
980
- for (const entry of entries) {
981
- containerWidth = entry.contentRect.width;
982
- containerHeight = entry.contentRect.height;
983
- }
984
- });
985
- if (containerEl) resizeObserver.observe(containerEl);
986
- resetMouseIdleTimer();
987
- return () => { resizeObserver?.disconnect(); clearAutoplayTimer(); clearAllTypewriterAnimations(); if (mouseIdleTimer) clearTimeout(mouseIdleTimer); };
988
- });
989
-
990
- // Watch for prop changes
991
- $effect(() => { if (data) { project = data; } });
992
- </script>
993
-
994
- <svelte:window onkeydown={handleKeyDown} />
995
-
996
- <div
997
- class="animot-presenter {className}"
998
- class:animot-menu-visible={menuVisible}
999
- bind:this={containerEl}
1000
- onmousemove={resetMouseIdleTimer}
1001
- role="region"
1002
- aria-label="Animot Presentation"
1003
- >
1004
- {#if loading}
1005
- <div class="animot-loading"><div class="animot-spinner"></div></div>
1006
- {:else if error}
1007
- <div class="animot-error">{error}</div>
1008
- {:else if project && currentSlide}
1009
- <div class="animot-canvas-wrapper" style:transform="scale({presentationScale})">
1010
- <div
1011
- class="animot-canvas {transitionClass}"
1012
- class:forward={transitionDirection === 'forward'}
1013
- class:backward={transitionDirection === 'backward'}
1014
- style:width="{canvasWidth}px"
1015
- style:height="{canvasHeight}px"
1016
- style:--transition-duration="{transitionDurationMs}ms"
1017
- style={backgroundStyle}
1018
- >
1019
- {#if currentSlide.canvas.background.particles?.enabled}
1020
- <ParticlesBackground config={currentSlide.canvas.background.particles} width={canvasWidth} height={canvasHeight} />
1021
- {/if}
1022
- {#if currentSlide.canvas.background.confetti?.enabled}
1023
- <ConfettiEffect config={currentSlide.canvas.background.confetti} width={canvasWidth} height={canvasHeight} />
1024
- {/if}
1025
-
1026
- {#each sortedElementIds as elementId}
1027
- {@const element = elementContent.get(elementId)}
1028
- {@const animated = animatedElements.get(elementId)}
1029
- {@const floatCfg = element?.floatingAnimation}
1030
- {@const hasFloat = floatCfg?.enabled}
1031
- {@const floatGroupId = element?.groupId}
1032
- {@const mpConfig = element?.motionPathConfig}
1033
- {@const mpElement = mpConfig ? currentSlide?.canvas.elements.find(el => el.id === mpConfig.motionPathId) as MotionPathElement | undefined : undefined}
1034
- {@const mpProgress = animated?.motionPathProgress?.current ?? 0}
1035
- {@const mpPoint = mpElement && mpConfig ? getPresenterPointOnPath(mpElement.points, mpElement.closed, (mpConfig.startPercent + (mpConfig.endPercent - mpConfig.startPercent) * mpProgress) / 100) : null}
1036
- {@const mpStartPoint = mpElement && mpConfig ? getPresenterPointOnPath(mpElement.points, mpElement.closed, mpConfig.startPercent / 100) : null}
1037
- {@const mpPos = mpPoint && mpStartPoint && animated && mpElement
1038
- ? computeMotionPathPosition(mpPoint, mpStartPoint,
1039
- animated.x.current, animated.y.current,
1040
- animated.width.current, animated.height.current,
1041
- mpElement.closed)
1042
- : null}
1043
- {@const elemX = mpPos ? mpPos.x : (animated?.x.current ?? 0)}
1044
- {@const elemY = mpPos ? mpPos.y : (animated?.y.current ?? 0)}
1045
- {@const mpRotation = mpPoint && mpConfig?.autoRotate
1046
- ? mpPoint.angle + (mpConfig.orientationOffset ?? 0)
1047
- : null}
1048
- {#if element && animated && animated.opacity.current > 0.01 && element.visible !== false && !(element.type === 'motionPath' && !(element as MotionPathElement).showInPresentation)}
1049
- <div
1050
- class="animot-element"
1051
- class:floating={hasFloat}
1052
- style:left="{elemX}px"
1053
- style:top="{elemY}px"
1054
- style:width="{animated.width.current}px"
1055
- style:height="{animated.height.current}px"
1056
- style:opacity={animated.opacity.current}
1057
- 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)"
1058
- style:transform-origin={element.tiltOrigin ?? 'center'}
1059
- style:backface-visibility={element.backfaceVisibility ?? 'visible'}
1060
- style:z-index={element.zIndex}
1061
- style:--float-amp="{hasFloat ? computeFloatAmp(floatCfg, floatGroupId || elementId) : 10}px"
1062
- style:--float-speed="{hasFloat ? computeFloatSpeed(floatCfg, floatGroupId || elementId) : 3}s"
1063
- style:--float-delay="{hashFraction(floatGroupId || elementId, 3) * 2}s"
1064
- style:animation-name={hasFloat ? getFloatAnimName(floatCfg!.direction, floatGroupId || elementId) : 'none'}
1065
- 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'; })()}
1066
- >
1067
- {#if element.type === 'code'}
1068
- {@const codeEl = element as CodeElement}
1069
- {@const morphState = codeMorphState.get(codeEl.id)}
1070
- <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'}>
1071
- {#if codeEl.showHeader}
1072
- <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">
1073
- {#if codeEl.headerStyle === 'macos'}
1074
- <div class="animot-window-controls">
1075
- <span class="animot-control close"></span>
1076
- <span class="animot-control minimize"></span>
1077
- <span class="animot-control maximize"></span>
1078
- </div>
1079
- {:else if codeEl.headerStyle === 'windows'}
1080
- <div class="animot-window-controls">
1081
- <span class="animot-control win-minimize">
1082
- <svg width="10" height="10" viewBox="0 0 10 10"><path d="M2 5h6" stroke="currentColor" stroke-width="1.2"/></svg>
1083
- </span>
1084
- <span class="animot-control win-maximize">
1085
- <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>
1086
- </span>
1087
- <span class="animot-control win-close">
1088
- <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>
1089
- </span>
1090
- </div>
1091
- {/if}
1092
- <div class="animot-filename-tab" style:border-radius="{codeEl.tabRadius ?? 6}px">
1093
- <svg class="animot-file-icon" width="14" height="14" viewBox="0 0 16 16" fill="none">
1094
- <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"/>
1095
- <path d="M9.5 1v3.5H13" stroke="currentColor" stroke-width="1.2" opacity="0.5"/>
1096
- </svg>
1097
- <span class="animot-filename">{codeEl.filename}</span>
1098
- </div>
1099
- <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); }}>
1100
- <span class="animot-copy-label">Copy</span><span class="animot-copied-label">Copied!</span>
1101
- <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>
1102
- <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>
1103
- </button>
1104
- </div>
1105
- {:else}
1106
- <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); }}>
1107
- <span class="animot-copy-label">Copy</span><span class="animot-copied-label">Copied!</span>
1108
- <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>
1109
- <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>
1110
- </button>
1111
- {/if}
1112
- <div class="animot-code-content">
1113
- <div class="animot-highlighted-code">
1114
- {#if morphState && morphState.oldCode !== morphState.newCode && morphState.mode !== 'instant'}
1115
- {#key currentSlideIndex}
1116
- <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} />
1117
- {/key}
1118
- {:else}
1119
- {@html getCodeHighlight(codeEl.id)}
1120
- {/if}
1121
- </div>
1122
- </div>
1123
- </div>
1124
- {:else if element.type === 'text'}
1125
- {@const textEl = element as TextElement}
1126
- {@const animFontSize = animated.fontSize?.current ?? textEl.fontSize}
1127
- {@const typewriterState = textTypewriterState.get(element.id)}
1128
- {@const displayText = typewriterState?.isAnimating ? typewriterState.fullText.slice(0, typewriterState.displayedChars) : textEl.content}
1129
- <div
1130
- class="animot-text-element"
1131
- style:font-size="{animFontSize}px"
1132
- style:font-weight={textEl.fontWeight}
1133
- style:font-family="'{textEl.fontFamily}', sans-serif"
1134
- style:font-style={textEl.fontStyle ?? 'normal'}
1135
- style:text-decoration={textEl.textDecoration ?? 'none'}
1136
- style:color={textEl.backgroundImage ? 'transparent' : (textEl.hollow && textEl.textStroke?.enabled ? 'transparent' : textEl.color)}
1137
- style:background-color={textEl.backgroundImage ? 'transparent' : textEl.backgroundColor}
1138
- style:background-image={textEl.backgroundImage ? `url(${textEl.backgroundImage})` : 'none'}
1139
- style:background-size={textEl.backgroundImage ? `${textEl.backgroundScale ?? 100}%` : 'cover'}
1140
- style:background-position={textEl.backgroundImage ? `${textEl.backgroundPositionX ?? 50}% ${textEl.backgroundPositionY ?? 50}%` : 'center'}
1141
- style:-webkit-background-clip={textEl.backgroundImage ? 'text' : 'border-box'}
1142
- style:background-clip={textEl.backgroundImage ? 'text' : 'border-box'}
1143
- style:padding="{textEl.padding}px"
1144
- style:border-radius="{textEl.borderRadius}px"
1145
- style:text-align={textEl.textAlign}
1146
- style:justify-content={textEl.textAlign === 'center' ? 'center' : textEl.textAlign === 'right' ? 'flex-end' : 'flex-start'}
1147
- style:-webkit-text-stroke={textEl.textStroke?.enabled ? `${textEl.textStroke.width}px ${textEl.textStroke.color}` : '0'}
1148
- style:text-shadow={textEl.textShadow?.enabled ? `${textEl.textShadow.offsetX}px ${textEl.textShadow.offsetY}px ${textEl.textShadow.blur}px ${textEl.textShadow.color}` : 'none'}
1149
- >
1150
- {displayText}{#if typewriterState?.isAnimating}<span class="animot-typewriter-cursor">|</span>{/if}
1151
- </div>
1152
- {:else if element.type === 'arrow'}
1153
- {@const arrowEl = element as ArrowElement}
1154
- {@const cp = arrowEl.controlPoints || []}
1155
- {@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)}
1156
- {@const lastCp = cp.length > 0 ? cp[cp.length - 1] : arrowEl.startPoint}
1157
- {@const endAngle = Math.atan2(arrowEl.endPoint.y - lastCp.y, arrowEl.endPoint.x - lastCp.x)}
1158
- {@const headAngle = Math.PI / 6}
1159
- {@const headSize = arrowEl.headSize}
1160
- {@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)}`}
1161
- {@const arrowAnimMode = arrowEl.animation?.mode ?? 'none'}
1162
- {@const arrowAnimDuration = arrowEl.animation?.duration ?? 500}
1163
- {@const isStyledArrow = arrowEl.style !== 'solid'}
1164
- {@const isDrawType = arrowAnimMode === 'draw' || arrowAnimMode === 'undraw' || arrowAnimMode === 'draw-undraw'}
1165
- {@const baseDashArray = arrowEl.style === 'dashed' ? '10,5' : arrowEl.style === 'dotted' ? '2,5' : 'none'}
1166
- <svg class="animot-arrow-element" class:arrow-animate-draw={arrowAnimMode === 'draw' && !isStyledArrow} class:arrow-animate-undraw={arrowAnimMode === 'undraw' && !isStyledArrow} class:arrow-animate-draw-undraw={arrowAnimMode === 'draw-undraw' && !isStyledArrow} class:arrow-animate-grow={arrowAnimMode === 'grow'} viewBox="0 0 {arrowEl.size.width} {arrowEl.size.height}" preserveAspectRatio="none" style="--arrow-anim-duration: {arrowAnimDuration}ms;">
1167
- <path class="arrow-path" d={pathD} fill="none" stroke={arrowEl.color} stroke-width={arrowEl.strokeWidth} stroke-dasharray={baseDashArray} stroke-linecap="round" stroke-linejoin="round" use:animateStyledArrowDraw={{ enabled: isDrawType && isStyledArrow, mode: arrowAnimMode, duration: arrowAnimDuration, dashPattern: baseDashArray, startX: arrowEl.startPoint.x, endX: arrowEl.endPoint.x, slideIndex: currentSlideIndex }} />
1168
- {#if arrowEl.showHead !== false}
1169
- <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;` : ''} />
1170
- {/if}
1171
- </svg>
1172
- {:else if element.type === 'image'}
1173
- {@const imgEl = element as ImageElement}
1174
- {@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'}
1175
- <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'} />
1176
- {:else if element.type === 'shape'}
1177
- {@const shapeEl = element as ShapeElement}
1178
- {@const animFill = animated.fillColor?.current ?? shapeEl.fillColor}
1179
- {@const animStroke = animated.strokeColor?.current ?? shapeEl.strokeColor}
1180
- {@const animStrokeWidth = animated.strokeWidth?.current ?? shapeEl.strokeWidth}
1181
- {@const mState = shapeMorphStates.get(element.id)}
1182
- {@const morphProgress = animated.shapeMorph?.current ?? 1}
1183
- {@const effectiveShapeType = mState ? (morphProgress >= 1 ? mState.toType : (morphProgress <= 0 ? mState.fromType : null)) : shapeEl.shapeType}
1184
- {@const isMorphing = mState && morphProgress > 0 && morphProgress < 1}
1185
- <svg class="animot-shape-element" viewBox="0 0 {animated.width.current} {animated.height.current}" style:filter={shapeEl.boxShadow?.enabled ? `drop-shadow(${shapeEl.boxShadow.offsetX}px ${shapeEl.boxShadow.offsetY}px ${shapeEl.boxShadow.blur}px ${shapeEl.boxShadow.color})` : 'none'}>
1186
- {#if isMorphing}
1187
- {@const w = animated.width.current}
1188
- {@const h = animated.height.current}
1189
- {@const sw = animStrokeWidth}
1190
- <g style:opacity={1 - morphProgress}>{@html renderShape(mState!.fromType, w, h, animated.borderRadius.current, animFill, animStroke, sw, shapeEl.strokeStyle, shapeEl.strokeDashGap)}</g>
1191
- <g style:opacity={morphProgress}>{@html renderShape(mState!.toType, w, h, animated.borderRadius.current, animFill, animStroke, sw, shapeEl.strokeStyle, shapeEl.strokeDashGap)}</g>
1192
- {:else}
1193
- {@html renderShape(effectiveShapeType ?? shapeEl.shapeType, animated.width.current, animated.height.current, animated.borderRadius.current, animFill, animStroke, animStrokeWidth, shapeEl.strokeStyle, shapeEl.strokeDashGap)}
1194
- {/if}
1195
- </svg>
1196
- {:else if element.type === 'counter'}
1197
- <CounterRenderer element={element as CounterElement} slideId={currentSlide?.id ?? ''} />
1198
- {:else if element.type === 'chart'}
1199
- <ChartRenderer element={element as ChartElement} slideId={currentSlide?.id ?? ''} />
1200
- {:else if element.type === 'icon'}
1201
- <IconRenderer element={element as IconElement} />
1202
- {:else if element.type === 'svg'}
1203
- {@const svgEl = element as SvgElement}
1204
- {@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 }; })()}
1205
- <div class="animot-svg-element">
1206
- <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">
1207
- <g style={svgEl.color ? `fill:${svgEl.color};stroke:${svgEl.color}` : ''}>
1208
- {@html svgParsed.inner}
1209
- </g>
1210
- </svg>
1211
- </div>
1212
- {:else if element.type === 'motionPath'}
1213
- {@const mpEl = element as MotionPathElement}
1214
- {#if mpEl.showInPresentation}
1215
- <svg width="100%" height="100%" viewBox="0 0 {animated.width.current} {animated.height.current}" style="position:absolute;top:0;left:0;pointer-events:none;overflow:visible;">
1216
- <path d={buildPresenterPathD(mpEl.points, mpEl.closed)} stroke={mpEl.pathColor} stroke-width={mpEl.pathWidth} fill="none" stroke-dasharray="8 4" />
1217
- </svg>
1218
- {/if}
1219
- {/if}
1220
- </div>
1221
- {/if}
1222
- {/each}
1223
- </div>
1224
- </div>
1225
-
1226
- {#if arrows}
1227
- <button class="animot-arrow animot-arrow-left" onclick={() => animateToSlide(currentSlideIndex - 1)} disabled={currentSlideIndex === 0 || isTransitioning} aria-label="Previous slide">
1228
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M15 18l-6-6 6-6"/></svg>
1229
- </button>
1230
- <button class="animot-arrow animot-arrow-right" onclick={() => animateToSlide(currentSlideIndex + 1)} disabled={currentSlideIndex === slides.length - 1 || isTransitioning} aria-label="Next slide">
1231
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18l6-6-6-6"/></svg>
1232
- </button>
1233
- {/if}
1234
-
1235
- {#if controls}
1236
- <div class="animot-controls">
1237
- <button onclick={() => animateToSlide(currentSlideIndex - 1)} disabled={currentSlideIndex === 0 || isTransitioning} aria-label="Previous">
1238
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>
1239
- </button>
1240
- <span class="animot-slide-indicator">{currentSlideIndex + 1} / {slides.length}</span>
1241
- <button onclick={() => animateToSlide(currentSlideIndex + 1)} disabled={currentSlideIndex === slides.length - 1 || isTransitioning} aria-label="Next">
1242
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
1243
- </button>
1244
- <button onclick={() => { isAutoplay = !isAutoplay; if (!isAutoplay) clearAutoplayTimer(); }} class:active={isAutoplay} aria-label={isAutoplay ? 'Pause' : 'Play'}>
1245
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1246
- {#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}
1247
- </svg>
1248
- </button>
1249
- </div>
1250
- {/if}
1251
-
1252
- {#if showProgress}
1253
- <div class="animot-progress-bar">
1254
- <div class="animot-progress-fill" style:width="{((currentSlideIndex + 1) / slides.length) * 100}%"></div>
1255
- </div>
1256
- {/if}
1257
- {/if}
1258
- </div>
1259
-
1260
- <script module lang="ts">
1261
- function roundedPolygonPath(pointsStr: string, radius: number): string {
1262
- const pts = pointsStr.split(/\s+/).map(p => { const [x, y] = p.split(',').map(Number); return { x, y }; });
1263
- if (pts.length < 3 || radius <= 0) return 'M' + pts.map(p => `${p.x},${p.y}`).join('L') + 'Z';
1264
- const n = pts.length;
1265
- const parts: string[] = [];
1266
- for (let i = 0; i < n; i++) {
1267
- const prev = pts[(i - 1 + n) % n], curr = pts[i], next = pts[(i + 1) % n];
1268
- const dx1 = prev.x - curr.x, dy1 = prev.y - curr.y;
1269
- const dx2 = next.x - curr.x, dy2 = next.y - curr.y;
1270
- const len1 = Math.sqrt(dx1 * dx1 + dy1 * dy1);
1271
- const len2 = Math.sqrt(dx2 * dx2 + dy2 * dy2);
1272
- const r = Math.min(radius, len1 / 2, len2 / 2);
1273
- const sx = curr.x + (dx1 / len1) * r, sy = curr.y + (dy1 / len1) * r;
1274
- const ex = curr.x + (dx2 / len2) * r, ey = curr.y + (dy2 / len2) * r;
1275
- parts.push(i === 0 ? `M${sx},${sy}` : `L${sx},${sy}`);
1276
- parts.push(`Q${curr.x},${curr.y} ${ex},${ey}`);
1277
- }
1278
- parts.push('Z');
1279
- return parts.join(' ');
1280
- }
1281
-
1282
- function renderShape(type: string, w: number, h: number, br: number, fill: string, stroke: string, sw: number, strokeStyle?: string, strokeDashGap?: number): string {
1283
- let dashAttr = '';
1284
- if (strokeStyle && strokeStyle !== 'solid') {
1285
- const s = sw || 1;
1286
- const gap = strokeDashGap ?? (strokeStyle === 'dashed' ? s * 3 : s * 2);
1287
- const da = strokeStyle === 'dashed' ? `${s * 3},${gap}` : `${s * 0.1},${gap}`;
1288
- const lc = strokeStyle === 'dotted' ? 'round' : 'butt';
1289
- dashAttr = ` stroke-dasharray="${da}" stroke-linecap="${lc}"`;
1290
- }
1291
- const polyOrPath = (pts: string) => {
1292
- if (br > 0) return `<path d="${roundedPolygonPath(pts, br)}" fill="${fill}" stroke="${stroke}" stroke-width="${sw}"${dashAttr}/>`;
1293
- return `<polygon points="${pts}" fill="${fill}" stroke="${stroke}" stroke-width="${sw}"${dashAttr} stroke-linejoin="round"/>`;
1294
- };
1295
- switch (type) {
1296
- case 'rectangle': return `<rect x="${sw/2}" y="${sw/2}" width="${w-sw}" height="${h-sw}" rx="${br}" ry="${br}" fill="${fill}" stroke="${stroke}" stroke-width="${sw}"${dashAttr}/>`;
1297
- case 'circle': return `<circle cx="${w/2}" cy="${h/2}" r="${Math.min(w,h)/2-sw/2}" fill="${fill}" stroke="${stroke}" stroke-width="${sw}"${dashAttr}/>`;
1298
- case 'ellipse': return `<ellipse cx="${w/2}" cy="${h/2}" rx="${w/2-sw/2}" ry="${h/2-sw/2}" fill="${fill}" stroke="${stroke}" stroke-width="${sw}"${dashAttr}/>`;
1299
- case 'triangle': return polyOrPath(`${w/2},${sw/2} ${sw/2},${h-sw/2} ${w-sw/2},${h-sw/2}`);
1300
- case 'star': {
1301
- const cx = w/2, cy = h/2, outerR = Math.min(w,h)/2-sw/2, innerR = outerR*0.4;
1302
- 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(' ');
1303
- return polyOrPath(pts);
1304
- }
1305
- case 'hexagon': {
1306
- const cx = w/2, cy = h/2, r = Math.min(w,h)/2-sw/2;
1307
- 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(' ');
1308
- return polyOrPath(pts);
1309
- }
1310
- default: return '';
1311
- }
1312
- }
1313
- </script>
1314
-
1315
- <style>
1316
- /* Universal reset — mirrors the animot app's global * reset to prevent
1317
- host page defaults (margins on p/h1, padding, box-sizing) from leaking in */
1318
- .animot-presenter :global(*) {
1319
- margin: 0;
1320
- padding: 0;
1321
- box-sizing: border-box;
1322
- }
1323
-
1324
- .animot-presenter {
1325
- position: relative;
1326
- width: 100%;
1327
- height: 100%;
1328
- display: flex;
1329
- align-items: center;
1330
- justify-content: center;
1331
- overflow: hidden;
1332
- background: transparent;
1333
- /* Reset inheritable CSS from host page to prevent style leakage */
1334
- line-height: normal;
1335
- font-size: 16px;
1336
- font-weight: 400;
1337
- font-style: normal;
1338
- letter-spacing: normal;
1339
- word-spacing: normal;
1340
- text-transform: none;
1341
- text-indent: 0;
1342
- text-align: left;
1343
- color: inherit;
1344
- }
1345
-
1346
- .animot-canvas-wrapper {
1347
- display: flex;
1348
- align-items: center;
1349
- justify-content: center;
1350
- }
1351
-
1352
- .animot-canvas {
1353
- position: relative;
1354
- overflow: hidden;
1355
- }
1356
-
1357
- .animot-element {
1358
- position: absolute;
1359
- box-sizing: border-box;
1360
- will-change: transform, opacity, left, top, width, height;
1361
- isolation: isolate;
1362
- }
1363
-
1364
- .animot-element.floating {
1365
- animation-duration: var(--float-speed, 3s);
1366
- animation-timing-function: ease-in-out;
1367
- animation-iteration-count: infinite;
1368
- animation-delay: var(--float-delay, 0s);
1369
- }
1370
-
1371
- /* Code */
1372
- .animot-code-block {
1373
- width: 100%; height: 100%; overflow: hidden;
1374
- display: flex; flex-direction: column; box-shadow: 0 8px 32px rgba(0,0,0,0.4);
1375
- margin: 0; box-sizing: border-box;
1376
- }
1377
- .animot-code-block.transparent-bg { background: transparent !important; box-shadow: none; }
1378
- .animot-code-block.transparent-bg .animot-code-header { background: transparent; border-bottom-color: rgba(255,255,255,0.06); }
1379
- .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; }
1380
- .animot-window-controls { display: flex; gap: 8px; align-items: center; flex-shrink: 0; }
1381
- .macos .animot-control { width: 12px; height: 12px; border-radius: 50%; display: block; }
1382
- .macos .animot-control.close { background: #ff5f57; }
1383
- .macos .animot-control.minimize { background: #febc2e; }
1384
- .macos .animot-control.maximize { background: #28c840; }
1385
- .windows .animot-window-controls { order: 99; margin-left: auto; gap: 0; }
1386
- .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); }
1387
- .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); }
1388
- .animot-file-icon { flex-shrink: 0; }
1389
- .animot-filename { color: rgba(255,255,255,0.55); font-size: 12px; line-height: 18px; }
1390
- .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; }
1391
- .animot-copy-code-btn:hover { background: rgba(255,255,255,0.12); color: rgba(255,255,255,0.8); }
1392
- .animot-copy-code-btn svg { width: 14px; height: 14px; flex-shrink: 0; }
1393
- .animot-copy-code-btn .animot-check-icon { display: none; }
1394
- .animot-copy-code-btn .animot-copied-label { display: none; }
1395
- .animot-copy-code-btn.copied .animot-copy-icon { display: none; }
1396
- .animot-copy-code-btn.copied .animot-copy-label { display: none; }
1397
- .animot-copy-code-btn.copied .animot-check-icon { display: block; color: #4ade80; }
1398
- .animot-copy-code-btn.copied .animot-copied-label { display: inline; color: #4ade80; }
1399
- .animot-copy-code-btn.animot-floating { position: absolute; top: 8px; right: 8px; z-index: 2; }
1400
- .animot-code-block:hover .animot-copy-code-btn { opacity: 1; }
1401
- .animot-code-content { flex: 1; overflow: hidden; position: relative; }
1402
- .animot-highlighted-code { width: 100%; height: 100%; }
1403
- .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; }
1404
- .animot-highlighted-code :global(code) { font-family: inherit; font-size: inherit; font-weight: inherit; }
1405
- .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; }
1406
-
1407
- /* Text */
1408
- .animot-text-element { width: 100%; height: 100%; display: flex; align-items: center; white-space: pre-wrap; word-wrap: break-word; }
1409
- .animot-typewriter-cursor { animation: animot-blink 0.7s infinite; font-weight: 100; }
1410
- @keyframes animot-blink { 0%, 50% { opacity: 1; } 51%, 100% { opacity: 0; } }
1411
-
1412
- /* Arrow */
1413
- .animot-arrow-element { width: 100%; height: 100%; }
1414
- .arrow-animate-draw .arrow-path { stroke-dasharray: 1000; stroke-dashoffset: 1000; animation: animot-arrow-draw var(--arrow-anim-duration, 500ms) ease-out forwards; }
1415
- .arrow-animate-undraw .arrow-path { stroke-dasharray: 1000; stroke-dashoffset: 0; animation: animot-arrow-undraw var(--arrow-anim-duration, 500ms) ease-out forwards; }
1416
- .arrow-animate-draw-undraw .arrow-path { stroke-dasharray: 1000; stroke-dashoffset: 1000; animation: animot-arrow-draw-undraw var(--arrow-anim-duration, 500ms) ease-out forwards; }
1417
- .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); }
1418
- .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); }
1419
- .arrow-animate-undraw .arrow-head, .arrow-head-undraw { opacity: 1; animation: animot-arrow-head-disappear var(--arrow-anim-duration, 500ms) ease-out forwards; }
1420
- .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; }
1421
- .arrow-animate-grow { transform-origin: left center; animation: animot-arrow-grow var(--arrow-anim-duration, 500ms) ease-out forwards; }
1422
- @keyframes animot-arrow-draw { to { stroke-dashoffset: 0; } }
1423
- @keyframes animot-arrow-undraw { from { stroke-dashoffset: 0; } to { stroke-dashoffset: 1000; } }
1424
- @keyframes animot-arrow-draw-undraw { 0% { stroke-dashoffset: 1000; } 50% { stroke-dashoffset: 0; } 100% { stroke-dashoffset: 1000; } }
1425
- @keyframes animot-arrow-head-appear { from { opacity: 0; } to { opacity: 1; } }
1426
- @keyframes animot-arrow-head-disappear { 0% { opacity: 1; } 70% { opacity: 1; } 100% { opacity: 0; } }
1427
- @keyframes animot-arrow-head-draw-undraw { 0% { opacity: 0; } 35% { opacity: 1; } 65% { opacity: 1; } 100% { opacity: 0; } }
1428
- @keyframes animot-arrow-grow { from { transform: scaleX(0); opacity: 0; } to { transform: scaleX(1); opacity: 1; } }
1429
-
1430
- /* Image */
1431
- .animot-image-element { width: 100%; height: 100%; display: block; }
1432
-
1433
- /* Shape */
1434
- .animot-shape-element { width: 100%; height: 100%; display: block; overflow: visible; }
1435
-
1436
- /* Transitions */
1437
- .animot-canvas { --transition-duration: 500ms; transition: transform calc(var(--transition-duration) * 0.4) ease, opacity calc(var(--transition-duration) * 0.4) ease; }
1438
- .animot-canvas.transition-fade-out { opacity: 0; }
1439
- .animot-canvas.transition-fade-in { animation: animot-fadeIn calc(var(--transition-duration) * 0.6) ease forwards; }
1440
- .animot-canvas.transition-slide-left-out.forward { transform: translateX(-100%); opacity: 0; }
1441
- .animot-canvas.transition-slide-left-in.forward { animation: animot-slideInFromRight calc(var(--transition-duration) * 0.6) ease forwards; }
1442
- .animot-canvas.transition-slide-left-out.backward { transform: translateX(100%); opacity: 0; }
1443
- .animot-canvas.transition-slide-left-in.backward { animation: animot-slideInFromLeft calc(var(--transition-duration) * 0.6) ease forwards; }
1444
- .animot-canvas.transition-slide-right-out.forward { transform: translateX(100%); opacity: 0; }
1445
- .animot-canvas.transition-slide-right-in.forward { animation: animot-slideInFromLeft calc(var(--transition-duration) * 0.6) ease forwards; }
1446
- .animot-canvas.transition-slide-up-out { transform: translateY(-100%); opacity: 0; }
1447
- .animot-canvas.transition-slide-up-in { animation: animot-slideInFromBottom calc(var(--transition-duration) * 0.6) ease forwards; }
1448
- .animot-canvas.transition-slide-down-out { transform: translateY(100%); opacity: 0; }
1449
- .animot-canvas.transition-slide-down-in { animation: animot-slideInFromTop calc(var(--transition-duration) * 0.6) ease forwards; }
1450
- .animot-canvas.transition-zoom-in-out { transform: scale(0.5); opacity: 0; }
1451
- .animot-canvas.transition-zoom-in-in { animation: animot-zoomIn calc(var(--transition-duration) * 0.6) ease forwards; }
1452
- .animot-canvas.transition-zoom-out-out { transform: scale(1.5); opacity: 0; }
1453
- .animot-canvas.transition-zoom-out-in { animation: animot-zoomOut calc(var(--transition-duration) * 0.6) ease forwards; }
1454
- .animot-canvas.transition-flip-out { transform: perspective(1000px) rotateY(90deg); opacity: 0; }
1455
- .animot-canvas.transition-flip-in { animation: animot-flipIn calc(var(--transition-duration) * 0.6) ease forwards; }
1456
-
1457
- @keyframes animot-fadeIn { from { opacity: 0; } to { opacity: 1; } }
1458
- @keyframes animot-slideInFromRight { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
1459
- @keyframes animot-slideInFromLeft { from { transform: translateX(-100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
1460
- @keyframes animot-slideInFromBottom { from { transform: translateY(100%); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
1461
- @keyframes animot-slideInFromTop { from { transform: translateY(-100%); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
1462
- @keyframes animot-zoomIn { from { transform: scale(0.5); opacity: 0; } to { transform: scale(1); opacity: 1; } }
1463
- @keyframes animot-zoomOut { from { transform: scale(1.5); opacity: 0; } to { transform: scale(1); opacity: 1; } }
1464
- @keyframes animot-flipIn { from { transform: perspective(1000px) rotateY(-90deg); opacity: 0; } to { transform: perspective(1000px) rotateY(0); opacity: 1; } }
1465
-
1466
- /* Flip-X transition */
1467
- .animot-canvas.transition-flip-x-out { transform: perspective(1000px) rotateX(90deg); opacity: 0; }
1468
- .animot-canvas.transition-flip-x-in { animation: animot-flipXIn calc(var(--transition-duration) * 0.6) ease forwards; }
1469
- @keyframes animot-flipXIn { from { transform: perspective(1000px) rotateX(-90deg); opacity: 0; } to { transform: perspective(1000px) rotateX(0); opacity: 1; } }
1470
-
1471
- /* Flip-Y transition */
1472
- .animot-canvas.transition-flip-y-out { transform: perspective(1000px) rotateY(90deg); opacity: 0; }
1473
- .animot-canvas.transition-flip-y-in { animation: animot-flipYIn calc(var(--transition-duration) * 0.6) ease forwards; }
1474
- @keyframes animot-flipYIn { from { transform: perspective(1000px) rotateY(-90deg); opacity: 0; } to { transform: perspective(1000px) rotateY(0); opacity: 1; } }
1475
-
1476
- /* SVG element */
1477
- .animot-svg-element { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; }
1478
- .animot-svg-element :global(svg) { width: 100%; height: 100%; }
1479
-
1480
- /* Controls */
1481
- .animot-controls {
1482
- position: absolute; bottom: 12px; left: 50%; transform: translateX(-50%);
1483
- display: flex; align-items: center; gap: 8px; padding: 8px 16px;
1484
- background: rgba(0,0,0,0.7); backdrop-filter: blur(10px); border-radius: 10px;
1485
- opacity: 0; transition: opacity 0.3s ease 0.15s; z-index: 100;
1486
- }
1487
- .animot-presenter:hover .animot-controls, .animot-menu-visible .animot-controls { opacity: 1; transition-delay: 0s; }
1488
- .animot-controls button {
1489
- display: flex; align-items: center; justify-content: center;
1490
- width: 32px; height: 32px; border-radius: 6px; border: none; cursor: pointer;
1491
- background: rgba(255,255,255,0.1); color: white; transition: background 0.2s;
1492
- }
1493
- .animot-controls button:hover:not(:disabled) { background: rgba(255,255,255,0.2); }
1494
- .animot-controls button:disabled { opacity: 0.3; cursor: not-allowed; }
1495
- .animot-controls button.active { background: rgba(99,102,241,0.6); }
1496
- .animot-controls button svg { width: 16px; height: 16px; }
1497
- .animot-slide-indicator { font-size: 12px; color: white; min-width: 50px; text-align: center; font-family: system-ui, sans-serif; }
1498
-
1499
- /* Arrows */
1500
- .animot-arrow {
1501
- position: absolute; top: 50%; transform: translateY(-50%);
1502
- width: 40px; height: 40px; border-radius: 50%; border: none; cursor: pointer;
1503
- background: rgba(0,0,0,0.5); color: white; display: flex; align-items: center; justify-content: center;
1504
- opacity: 0; transition: opacity 0.3s 0.15s; z-index: 100;
1505
- /* Extra padding extends the hover hit area beyond the visible button */
1506
- padding: 0; margin: 0;
1507
- }
1508
- .animot-presenter:hover .animot-arrow { opacity: 1; transition-delay: 0s; }
1509
- .animot-presenter:hover .animot-arrow:disabled { opacity: 0.3; cursor: not-allowed; }
1510
- .animot-arrow:hover:not(:disabled) { background: rgba(0,0,0,0.7); }
1511
- .animot-arrow svg { width: 20px; height: 20px; }
1512
- .animot-arrow-left { left: 8px; }
1513
- .animot-arrow-right { right: 8px; }
1514
-
1515
- /* Progress bar */
1516
- .animot-progress-bar {
1517
- position: absolute; bottom: 0; left: 0; right: 0; height: 3px;
1518
- background: rgba(255,255,255,0.1); z-index: 100;
1519
- opacity: 0; transition: opacity 0.3s 0.15s;
1520
- }
1521
- .animot-presenter:hover .animot-progress-bar { opacity: 1; transition-delay: 0s; }
1522
- .animot-progress-fill { height: 100%; background: linear-gradient(135deg, #7c3aed, #ec4899); transition: width 0.6s ease; }
1523
-
1524
- /* Loading / Error */
1525
- .animot-loading { display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; }
1526
- .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; }
1527
- @keyframes animot-spin { to { transform: rotate(360deg); } }
1528
- .animot-error { color: #ef4444; padding: 20px; text-align: center; font-family: system-ui, sans-serif; }
1529
- </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 IconRenderer from './renderers/IconRenderer.svelte';
11
+ import FlowMarkers from './FlowMarkers.svelte';
12
+ import { traceSvgPaths } from './utils/trace-svg-paths';
13
+ import { arrowClipDraw } from './utils/arrow-clip-draw';
14
+ import { easeInOutCubic, getEasingFn, getBackgroundStyle, hashFraction, getFloatAnimName, computeFloatAmp, computeFloatSpeed } from './engine/utils';
15
+ import type {
16
+ AnimotProject, AnimotPresenterProps, CanvasElement, CodeElement, TextElement,
17
+ ArrowElement, ImageElement, ShapeElement, CounterElement, ChartElement, IconElement,
18
+ SvgElement, MotionPathElement, PathPoint,
19
+ Slide, CodeAnimationMode, AnimatableProperty
20
+ } from './types';
21
+ import './styles/presenter.css';
22
+
23
+ type TweenValue = ReturnType<typeof tween<number>>;
24
+
25
+ interface AnimatedElement {
26
+ x: TweenValue; y: TweenValue; width: TweenValue; height: TweenValue;
27
+ rotation: TweenValue; skewX: TweenValue; skewY: TweenValue;
28
+ tiltX: TweenValue; tiltY: TweenValue; perspective: TweenValue;
29
+ opacity: TweenValue; borderRadius: TweenValue;
30
+ fontSize: TweenValue | null;
31
+ fillColor: ReturnType<typeof tween<string>> | null;
32
+ strokeColor: ReturnType<typeof tween<string>> | null;
33
+ strokeWidth: TweenValue | null;
34
+ shapeMorph: TweenValue | null;
35
+ motionPathProgress: TweenValue | null;
36
+ blur: TweenValue;
37
+ brightness: TweenValue;
38
+ contrast: TweenValue;
39
+ saturate: TweenValue;
40
+ grayscale: TweenValue;
41
+ }
42
+
43
+ interface ShapeMorphState { fromType: string; toType: string; }
44
+
45
+ // Active motion path loop cancellation tokens
46
+ let motionPathLoopAbort: AbortController | null = null;
47
+ function cancelMotionPathLoops() {
48
+ if (motionPathLoopAbort) { motionPathLoopAbort.abort(); motionPathLoopAbort = null; }
49
+ }
50
+
51
+ // --- Motion Path Utilities ---
52
+ function buildPresenterPathD(points: PathPoint[], closed: boolean): string {
53
+ if (points.length < 2) return '';
54
+ let d = `M ${points[0].x} ${points[0].y}`;
55
+ for (let i = 1; i < points.length; i++) {
56
+ const prev = points[i - 1], curr = points[i];
57
+ const cp1x = prev.x + (prev.handleOut?.x ?? 0), cp1y = prev.y + (prev.handleOut?.y ?? 0);
58
+ const cp2x = curr.x + (curr.handleIn?.x ?? 0), cp2y = curr.y + (curr.handleIn?.y ?? 0);
59
+ if (prev.handleOut || curr.handleIn) d += ` C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${curr.x} ${curr.y}`;
60
+ else d += ` L ${curr.x} ${curr.y}`;
61
+ }
62
+ if (closed && points.length > 2) {
63
+ const last = points[points.length - 1], first = points[0];
64
+ const cp1x = last.x + (last.handleOut?.x ?? 0), cp1y = last.y + (last.handleOut?.y ?? 0);
65
+ const cp2x = first.x + (first.handleIn?.x ?? 0), cp2y = first.y + (first.handleIn?.y ?? 0);
66
+ if (last.handleOut || first.handleIn) d += ` C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${first.x} ${first.y}`;
67
+ else d += ` Z`;
68
+ }
69
+ return d;
70
+ }
71
+
72
+ function cubicBez(p0: number, p1: number, p2: number, p3: number, t: number): number {
73
+ const mt = 1 - t;
74
+ return mt * mt * mt * p0 + 3 * mt * mt * t * p1 + 3 * mt * t * t * p2 + t * t * t * p3;
75
+ }
76
+ function cubicBezDeriv(p0: number, p1: number, p2: number, p3: number, t: number): number {
77
+ const mt = 1 - t;
78
+ return 3 * mt * mt * (p1 - p0) + 6 * mt * t * (p2 - p1) + 3 * t * t * (p3 - p2);
79
+ }
80
+
81
+ function getPresenterPointOnPath(points: PathPoint[], closed: boolean, progress: number): { x: number; y: number; angle: number } {
82
+ if (points.length < 2) return { x: points[0]?.x ?? 0, y: points[0]?.y ?? 0, angle: 0 };
83
+ const segs: { p0x: number; p0y: number; p1x: number; p1y: number; p2x: number; p2y: number; p3x: number; p3y: number; length: number }[] = [];
84
+ const segCount = closed ? points.length : points.length - 1;
85
+ for (let i = 0; i < segCount; i++) {
86
+ const curr = points[i], next = points[(i + 1) % points.length];
87
+ const p0x = curr.x, p0y = curr.y;
88
+ const p1x = curr.x + (curr.handleOut?.x ?? 0), p1y = curr.y + (curr.handleOut?.y ?? 0);
89
+ const p2x = next.x + (next.handleIn?.x ?? 0), p2y = next.y + (next.handleIn?.y ?? 0);
90
+ const p3x = next.x, p3y = next.y;
91
+ let length = 0, prevPx = p0x, prevPy = p0y;
92
+ for (let s = 1; s <= 20; s++) {
93
+ const t = s / 20;
94
+ const px = cubicBez(p0x, p1x, p2x, p3x, t), py = cubicBez(p0y, p1y, p2y, p3y, t);
95
+ length += Math.sqrt((px - prevPx) ** 2 + (py - prevPy) ** 2);
96
+ prevPx = px; prevPy = py;
97
+ }
98
+ segs.push({ p0x, p0y, p1x, p1y, p2x, p2y, p3x, p3y, length });
99
+ }
100
+ const totalLength = segs.reduce((sum, s) => sum + s.length, 0);
101
+ const targetLength = progress * totalLength;
102
+ let accum = 0;
103
+ for (const seg of segs) {
104
+ if (accum + seg.length >= targetLength || seg === segs[segs.length - 1]) {
105
+ const t = Math.max(0, Math.min(1, seg.length > 0 ? (targetLength - accum) / seg.length : 0));
106
+ let dx = cubicBezDeriv(seg.p0x, seg.p1x, seg.p2x, seg.p3x, t);
107
+ let dy = cubicBezDeriv(seg.p0y, seg.p1y, seg.p2y, seg.p3y, t);
108
+ // Degenerate tangent at endpoints (no Bezier handles) sample nearby
109
+ if (Math.abs(dx) < 0.001 && Math.abs(dy) < 0.001) {
110
+ const epsilon = t < 0.5 ? 0.01 : -0.01;
111
+ dx = cubicBezDeriv(seg.p0x, seg.p1x, seg.p2x, seg.p3x, t + epsilon);
112
+ dy = cubicBezDeriv(seg.p0y, seg.p1y, seg.p2y, seg.p3y, t + epsilon);
113
+ }
114
+ // Still zero (fully degenerate segment) — use chord direction
115
+ if (Math.abs(dx) < 0.001 && Math.abs(dy) < 0.001) {
116
+ dx = seg.p3x - seg.p0x;
117
+ dy = seg.p3y - seg.p0y;
118
+ }
119
+ return {
120
+ x: cubicBez(seg.p0x, seg.p1x, seg.p2x, seg.p3x, t),
121
+ y: cubicBez(seg.p0y, seg.p1y, seg.p2y, seg.p3y, t),
122
+ angle: Math.atan2(dy, dx) * (180 / Math.PI)
123
+ };
124
+ }
125
+ accum += seg.length;
126
+ }
127
+ return { x: points[0].x, y: points[0].y, angle: 0 };
128
+ }
129
+
130
+ function computeMotionPathPosition(
131
+ mpPoint: { x: number; y: number; angle: number },
132
+ startPoint: { x: number; y: number; angle: number },
133
+ animX: number, animY: number, animW: number, animH: number,
134
+ closed: boolean
135
+ ): { x: number; y: number } {
136
+ if (!closed) {
137
+ return { x: mpPoint.x - animW / 2, y: mpPoint.y - animH / 2 };
138
+ }
139
+ const offsetX = (animX + animW / 2) - startPoint.x;
140
+ const offsetY = (animY + animH / 2) - startPoint.y;
141
+ const angleDelta = (mpPoint.angle - startPoint.angle) * Math.PI / 180;
142
+ const cos = Math.cos(angleDelta);
143
+ const sin = Math.sin(angleDelta);
144
+ return {
145
+ x: mpPoint.x + offsetX * cos - offsetY * sin - animW / 2,
146
+ y: mpPoint.y + offsetX * sin + offsetY * cos - animH / 2
147
+ };
148
+ }
149
+
150
+ let {
151
+ src, data, autoplay = false, loop = false, controls = true, arrows = false,
152
+ progress: showProgress = true, keyboard = true, duration: durationOverride,
153
+ startSlide = 0, class: className = '', onslidechange, oncomplete
154
+ }: AnimotPresenterProps = $props();
155
+
156
+ // State
157
+ let project = $state<AnimotProject | null>(null);
158
+ let loading = $state(true);
159
+ let error = $state<string | null>(null);
160
+ let currentSlideIndex = $state(0);
161
+ let isTransitioning = $state(false);
162
+ let isAutoplay = $state(false);
163
+ let transitionClass = $state('');
164
+ let transitionDirection = $state<'forward' | 'backward'>('forward');
165
+ let transitionDurationMs = $state(500);
166
+ let containerEl: HTMLElement;
167
+ let containerWidth = $state(0);
168
+ let containerHeight = $state(0);
169
+
170
+ let animatedElements = $state<Map<string, AnimatedElement>>(new Map());
171
+ let codeHighlights = $state<Map<string, string>>(new Map());
172
+ let elementContent = $state<Map<string, CanvasElement>>(new Map());
173
+ let previousCodeContent = $state<Map<string, string>>(new Map());
174
+ let codeMorphState = $state<Map<string, {oldCode: string, newCode: string, mode: CodeAnimationMode, speed: number, highlightColor: string}>>(new Map());
175
+ let textTypewriterState = $state<Map<string, {fullText: string, displayedChars: number, isAnimating: boolean}>>(new Map());
176
+ let typewriterIntervals = new Map<string, ReturnType<typeof setInterval>>();
177
+ let shapeMorphStates = $state<Map<string, ShapeMorphState>>(new Map());
178
+ let autoplayTimer: ReturnType<typeof setTimeout> | null = null;
179
+ let menuVisible = $state(true);
180
+ let mouseIdleTimer: ReturnType<typeof setTimeout> | null = null;
181
+
182
+ const slides = $derived(project?.slides ?? []);
183
+ const currentSlide = $derived(slides[currentSlideIndex]);
184
+ const canvasWidth = $derived(currentSlide?.canvas.width ?? 800);
185
+ const canvasHeight = $derived(currentSlide?.canvas.height ?? 600);
186
+
187
+ const presentationScale = $derived.by(() => {
188
+ if (!containerWidth || !containerHeight) return 1;
189
+ const scaleX = containerWidth / canvasWidth;
190
+ const scaleY = containerHeight / canvasHeight;
191
+ return Math.min(scaleX, scaleY);
192
+ });
193
+
194
+ const backgroundStyle = $derived.by(() => {
195
+ if (!currentSlide) return 'background: transparent';
196
+ return getBackgroundStyle(currentSlide.canvas.background);
197
+ });
198
+
199
+ const allElementIds = $derived.by(() => {
200
+ const ids = new Set<string>();
201
+ slides.forEach(slide => slide.canvas.elements.forEach(el => ids.add(el.id)));
202
+ return ids;
203
+ });
204
+
205
+ const sortedElementIds = $derived.by(() => {
206
+ const elements: Array<{id: string, zIndex: number}> = [];
207
+ for (const id of allElementIds) {
208
+ const el = elementContent.get(id);
209
+ if (el) elements.push({ id, zIndex: el.zIndex ?? 0 });
210
+ }
211
+ elements.sort((a, b) => a.zIndex - b.zIndex);
212
+ return elements.map(e => e.id);
213
+ });
214
+
215
+ function getElementInSlide(slide: Slide | null, elementId: string): CanvasElement | undefined {
216
+ return slide?.canvas.elements.find(el => el.id === elementId);
217
+ }
218
+
219
+ // Typewriter
220
+ function startTypewriterAnimation(elementId: string, fullText: string, speed: number) {
221
+ const existing = typewriterIntervals.get(elementId);
222
+ if (existing) { clearInterval(existing); typewriterIntervals.delete(elementId); }
223
+ textTypewriterState.set(elementId, { fullText, displayedChars: 0, isAnimating: true });
224
+ textTypewriterState = new Map(textTypewriterState);
225
+ const intervalMs = 1000 / speed;
226
+ const interval = setInterval(() => {
227
+ const state = textTypewriterState.get(elementId);
228
+ if (state && state.isAnimating) {
229
+ if (state.displayedChars < state.fullText.length) {
230
+ textTypewriterState.set(elementId, { ...state, displayedChars: state.displayedChars + 1 });
231
+ textTypewriterState = new Map(textTypewriterState);
232
+ } else {
233
+ clearInterval(interval); typewriterIntervals.delete(elementId);
234
+ textTypewriterState.set(elementId, { ...state, isAnimating: false });
235
+ textTypewriterState = new Map(textTypewriterState);
236
+ }
237
+ } else { clearInterval(interval); typewriterIntervals.delete(elementId); }
238
+ }, intervalMs);
239
+ typewriterIntervals.set(elementId, interval);
240
+ }
241
+
242
+ function clearAllTypewriterAnimations() {
243
+ for (const [, interval] of typewriterIntervals) clearInterval(interval);
244
+ typewriterIntervals.clear();
245
+ textTypewriterState.clear();
246
+ textTypewriterState = new Map(textTypewriterState);
247
+ }
248
+
249
+ // Build SVG path for 3+ control points using Catmull-Rom spline
250
+ function buildCatmullRomPath(start: {x:number,y:number}, cps: {x:number,y:number}[], end: {x:number,y:number}): string {
251
+ const pts = [start, ...cps, end];
252
+ let d = `M ${pts[0].x} ${pts[0].y}`;
253
+ for (let i = 0; i < pts.length - 1; i++) {
254
+ const p0 = pts[i === 0 ? 0 : i - 1];
255
+ const p1 = pts[i];
256
+ const p2 = pts[i + 1];
257
+ const p3 = pts[i + 2 < pts.length ? i + 2 : pts.length - 1];
258
+ const c1x = p1.x + (p2.x - p0.x) / 6;
259
+ const c1y = p1.y + (p2.y - p0.y) / 6;
260
+ const c2x = p2.x - (p3.x - p1.x) / 6;
261
+ const c2y = p2.y - (p3.y - p1.y) / 6;
262
+ d += ` C ${c1x} ${c1y} ${c2x} ${c2y} ${p2.x} ${p2.y}`;
263
+ }
264
+ return d;
265
+ }
266
+
267
+ // Arrow draw/undraw/draw-undraw animation action
268
+ function animateStyledArrowDraw(node: SVGPathElement, params: { enabled: boolean; mode: string; duration: number; dashPattern: string; startX: number; endX: number; slideIndex: number; loop?: boolean; reverse?: boolean }) {
269
+ let lastSlideIndex = params.slideIndex;
270
+ let animationId: number | null = null;
271
+ function runAnimation() {
272
+ if (!params.enabled) return;
273
+ if (animationId) cancelAnimationFrame(animationId);
274
+ const svg = node.closest('svg') as SVGSVGElement | null;
275
+ if (!svg) return;
276
+ const baseLeftToRight = params.endX >= params.startX;
277
+ const goesLeftToRight = params.reverse ? !baseLeftToRight : baseLeftToRight;
278
+ const mode = params.mode;
279
+ const dur = params.duration;
280
+ const startTime = performance.now();
281
+ if (mode === 'draw' || mode === 'draw-undraw') {
282
+ svg.style.clipPath = goesLeftToRight ? 'inset(0 100% 0 0)' : 'inset(0 0 0 100%)';
283
+ } else if (mode === 'undraw') {
284
+ svg.style.clipPath = 'none';
285
+ }
286
+ function animate(currentTime: number) {
287
+ const elapsed = currentTime - startTime;
288
+ if (mode === 'draw') {
289
+ const progress = Math.min(elapsed / dur, 1);
290
+ const eased = 1 - Math.pow(1 - progress, 3);
291
+ const inset = 100 * (1 - eased);
292
+ svg!.style.clipPath = goesLeftToRight ? `inset(0 ${inset}% 0 0)` : `inset(0 0 0 ${inset}%)`;
293
+ if (progress < 1) { animationId = requestAnimationFrame(animate); }
294
+ else if (params.loop) { runAnimation(); }
295
+ else { svg!.style.clipPath = 'none'; animationId = null; }
296
+ } else if (mode === 'undraw') {
297
+ const progress = Math.min(elapsed / dur, 1);
298
+ const eased = 1 - Math.pow(1 - progress, 3);
299
+ const inset = 100 * eased;
300
+ svg!.style.clipPath = goesLeftToRight ? `inset(0 0 0 ${inset}%)` : `inset(0 ${inset}% 0 0)`;
301
+ if (progress < 1) { animationId = requestAnimationFrame(animate); }
302
+ else if (params.loop) { runAnimation(); }
303
+ else { svg!.style.clipPath = 'inset(0 0 0 100%)'; animationId = null; }
304
+ } else if (mode === 'draw-undraw') {
305
+ const halfDur = dur / 2;
306
+ if (elapsed < halfDur) {
307
+ const progress = Math.min(elapsed / halfDur, 1);
308
+ const eased = 1 - Math.pow(1 - progress, 3);
309
+ const inset = 100 * (1 - eased);
310
+ svg!.style.clipPath = goesLeftToRight ? `inset(0 ${inset}% 0 0)` : `inset(0 0 0 ${inset}%)`;
311
+ animationId = requestAnimationFrame(animate);
312
+ } else {
313
+ const progress = Math.min((elapsed - halfDur) / halfDur, 1);
314
+ const eased = 1 - Math.pow(1 - progress, 3);
315
+ const inset = 100 * eased;
316
+ svg!.style.clipPath = goesLeftToRight ? `inset(0 0 0 ${inset}%)` : `inset(0 ${inset}% 0 0)`;
317
+ if (progress < 1) { animationId = requestAnimationFrame(animate); }
318
+ else if (params.loop) { runAnimation(); }
319
+ else { svg!.style.clipPath = 'inset(0 0 0 100%)'; animationId = null; }
320
+ }
321
+ }
322
+ }
323
+ animationId = requestAnimationFrame(animate);
324
+ }
325
+ runAnimation();
326
+ return {
327
+ update(newParams: typeof params) {
328
+ const slideChanged = newParams.slideIndex !== lastSlideIndex;
329
+ params = newParams;
330
+ if (!params.enabled) {
331
+ if (animationId) { cancelAnimationFrame(animationId); animationId = null; }
332
+ const svg = node.closest('svg') as SVGSVGElement | null;
333
+ if (svg) svg.style.clipPath = '';
334
+ lastSlideIndex = newParams.slideIndex;
335
+ return;
336
+ }
337
+ if (slideChanged) { lastSlideIndex = newParams.slideIndex; runAnimation(); }
338
+ },
339
+ destroy() { if (animationId) cancelAnimationFrame(animationId); }
340
+ };
341
+ }
342
+
343
+ // Init animated elements
344
+ function initAllAnimatedElements() {
345
+ const firstSlide = slides[0];
346
+ if (firstSlide) {
347
+ for (const element of firstSlide.canvas.elements) {
348
+ if (element.type === 'code') previousCodeContent.set(element.id, (element as CodeElement).code);
349
+ if (element.type === 'text') {
350
+ const textEl = element as TextElement;
351
+ if (textEl.animation?.mode === 'typewriter') startTypewriterAnimation(element.id, textEl.content, textEl.animation.typewriterSpeed || 50);
352
+ }
353
+ }
354
+ }
355
+ for (const slide of slides) {
356
+ for (const element of slide.canvas.elements) {
357
+ if (!animatedElements.has(element.id)) {
358
+ const inCurrent = getElementInSlide(currentSlide, element.id);
359
+ const startOpacity = inCurrent ? ((inCurrent as any).opacity ?? 1) : 0;
360
+ const br = (element as any).borderRadius ?? 0;
361
+ const isShape = element.type === 'shape';
362
+ const shapeEl = isShape ? element as ShapeElement : null;
363
+ const isText = element.type === 'text';
364
+ const textEl = isText ? element as TextElement : null;
365
+ animatedElements.set(element.id, {
366
+ x: tween(element.position.x, { duration: 500 }),
367
+ y: tween(element.position.y, { duration: 500 }),
368
+ width: tween(element.size.width, { duration: 500 }),
369
+ height: tween(element.size.height, { duration: 500 }),
370
+ rotation: tween(element.rotation, { duration: 500 }),
371
+ skewX: tween(element.skewX ?? 0, { duration: 500 }),
372
+ skewY: tween(element.skewY ?? 0, { duration: 500 }),
373
+ tiltX: tween(element.tiltX ?? 0, { duration: 500 }),
374
+ tiltY: tween(element.tiltY ?? 0, { duration: 500 }),
375
+ perspective: tween(element.perspective ?? 1000, { duration: 500 }),
376
+ opacity: tween(startOpacity, { duration: 300 }),
377
+ borderRadius: tween(br, { duration: 500 }),
378
+ fontSize: textEl ? tween(textEl.fontSize, { duration: 500 }) : null,
379
+ fillColor: shapeEl ? tween(shapeEl.fillColor, { duration: 500 }) : null,
380
+ strokeColor: shapeEl ? tween(shapeEl.strokeColor, { duration: 500 }) : null,
381
+ strokeWidth: shapeEl ? tween(shapeEl.strokeWidth, { duration: 500 }) : null,
382
+ shapeMorph: shapeEl ? tween(1, { duration: 500 }) : null,
383
+ motionPathProgress: element.motionPathConfig ? tween(0, { duration: 500 }) : null,
384
+ blur: tween(element.blur ?? 0, { duration: 500 }),
385
+ brightness: tween(element.brightness ?? 100, { duration: 500 }),
386
+ contrast: tween(element.contrast ?? 100, { duration: 500 }),
387
+ saturate: tween(element.saturate ?? 100, { duration: 500 }),
388
+ grayscale: tween(element.grayscale ?? 0, { duration: 500 })
389
+ });
390
+ const currentSlideEl = getElementInSlide(currentSlide, element.id);
391
+ elementContent.set(element.id, JSON.parse(JSON.stringify(currentSlideEl || element)));
392
+ }
393
+ }
394
+ }
395
+ animatedElements = new Map(animatedElements);
396
+ elementContent = new Map(elementContent);
397
+ previousCodeContent = new Map(previousCodeContent);
398
+ }
399
+
400
+ async function animateMotionPaths(slide: Slide) {
401
+ cancelMotionPathLoops();
402
+ motionPathLoopAbort = new AbortController();
403
+ const signal = motionPathLoopAbort.signal;
404
+
405
+ const resets: Promise<void>[] = [];
406
+ for (const element of slide.canvas.elements) {
407
+ if (element.motionPathConfig) {
408
+ const animated = animatedElements.get(element.id);
409
+ if (animated?.motionPathProgress) {
410
+ resets.push(animated.motionPathProgress.to(0, { duration: 0 }));
411
+ }
412
+ }
413
+ }
414
+ await Promise.all(resets);
415
+ for (const element of slide.canvas.elements) {
416
+ if (element.motionPathConfig) {
417
+ const animated = animatedElements.get(element.id);
418
+ if (animated?.motionPathProgress) {
419
+ const config = element.animationConfig;
420
+ const duration = config?.duration ?? 2000;
421
+ const easing = getEasingFn(config?.easing);
422
+ const shouldLoop = element.motionPathConfig.loop;
423
+
424
+ if (shouldLoop) {
425
+ const laps = element.motionPathConfig.laps ?? 0;
426
+ (async () => {
427
+ let lap = 0;
428
+ while (!signal.aborted && (laps === 0 || lap < laps)) {
429
+ await animated.motionPathProgress!.to(0, { duration: 0 });
430
+ await animated.motionPathProgress!.to(1, { duration, easing });
431
+ lap++;
432
+ if (!signal.aborted && (laps === 0 || lap < laps)) await new Promise(r => setTimeout(r, 50));
433
+ }
434
+ })();
435
+ } else {
436
+ animated.motionPathProgress.to(1, { duration, easing });
437
+ }
438
+ }
439
+ }
440
+ }
441
+ }
442
+
443
+ // Reset presentation to first slide (snap all elements back to initial state)
444
+ async function resetToFirstSlide() {
445
+ if (isTransitioning) return;
446
+ isTransitioning = true;
447
+ clearAllTypewriterAnimations();
448
+ cancelMotionPathLoops();
449
+ const firstSlide = slides[0];
450
+ if (!firstSlide) { isTransitioning = false; return; }
451
+
452
+ for (const elementId of allElementIds) {
453
+ const targetEl = getElementInSlide(firstSlide, elementId);
454
+ const animated = animatedElements.get(elementId);
455
+ if (!animated) continue;
456
+ if (targetEl) {
457
+ animated.x.to(targetEl.position.x, { duration: 0 }); animated.y.to(targetEl.position.y, { duration: 0 });
458
+ animated.width.to(targetEl.size.width, { duration: 0 }); animated.height.to(targetEl.size.height, { duration: 0 });
459
+ animated.rotation.to(targetEl.rotation, { duration: 0 });
460
+ animated.skewX.to(targetEl.skewX ?? 0, { duration: 0 }); animated.skewY.to(targetEl.skewY ?? 0, { duration: 0 });
461
+ animated.tiltX.to(targetEl.tiltX ?? 0, { duration: 0 }); animated.tiltY.to(targetEl.tiltY ?? 0, { duration: 0 });
462
+ animated.perspective.to(targetEl.perspective ?? 1000, { duration: 0 });
463
+ animated.opacity.to((targetEl as any).opacity ?? 1, { duration: 0 });
464
+ animated.borderRadius.to((targetEl as any).borderRadius ?? 0, { duration: 0 });
465
+ animated.blur.to(targetEl.blur ?? 0, { duration: 0 });
466
+ animated.brightness.to(targetEl.brightness ?? 100, { duration: 0 });
467
+ animated.contrast.to(targetEl.contrast ?? 100, { duration: 0 });
468
+ animated.saturate.to(targetEl.saturate ?? 100, { duration: 0 });
469
+ animated.grayscale.to(targetEl.grayscale ?? 0, { duration: 0 });
470
+ if (targetEl.type === 'text' && animated.fontSize) animated.fontSize.to((targetEl as TextElement).fontSize, { duration: 0 });
471
+ if (targetEl.type === 'shape') {
472
+ const s = targetEl as ShapeElement;
473
+ if (animated.fillColor) animated.fillColor.to(s.fillColor, { duration: 0 });
474
+ if (animated.strokeColor) animated.strokeColor.to(s.strokeColor, { duration: 0 });
475
+ if (animated.strokeWidth) animated.strokeWidth.to(s.strokeWidth, { duration: 0 });
476
+ }
477
+ if (animated.motionPathProgress) animated.motionPathProgress.to(0, { duration: 0 });
478
+ } else {
479
+ animated.opacity.to(0, { duration: 0 });
480
+ }
481
+ }
482
+
483
+ for (const elementId of allElementIds) {
484
+ const targetEl = getElementInSlide(firstSlide, elementId);
485
+ if (targetEl) elementContent.set(elementId, JSON.parse(JSON.stringify(targetEl)));
486
+ }
487
+
488
+ const newPreviousCodeContent = new Map<string, string>();
489
+ for (const element of firstSlide.canvas.elements) {
490
+ if (element.type === 'code') newPreviousCodeContent.set(element.id, (element as CodeElement).code);
491
+ }
492
+
493
+ for (const element of firstSlide.canvas.elements) {
494
+ if (element.type === 'code') {
495
+ const codeEl = element as CodeElement;
496
+ const key = `${codeEl.id}-${codeEl.code}-${codeEl.language}-${codeEl.showLineNumbers}`;
497
+ if (!codeHighlights.has(key)) {
498
+ const html = await highlightCode(codeEl.code, codeEl.language, codeEl.theme, { showLineNumbers: codeEl.showLineNumbers });
499
+ codeHighlights.set(key, html);
500
+ }
501
+ }
502
+ }
503
+ codeHighlights = new Map(codeHighlights);
504
+
505
+ codeMorphState = new Map();
506
+ previousCodeContent = newPreviousCodeContent;
507
+ shapeMorphStates = new Map();
508
+ elementContent = new Map(elementContent);
509
+ currentSlideIndex = 0;
510
+ isTransitioning = false;
511
+
512
+ for (const element of firstSlide.canvas.elements) {
513
+ if (element.type === 'text') {
514
+ const textEl = element as TextElement;
515
+ if (textEl.animation?.mode === 'typewriter') startTypewriterAnimation(element.id, textEl.content, textEl.animation.typewriterSpeed || 50);
516
+ }
517
+ }
518
+
519
+ animateMotionPaths(firstSlide);
520
+ onslidechange?.(0, slides.length);
521
+ }
522
+
523
+ // Animate to slide
524
+ async function animateToSlide(targetIndex: number) {
525
+ if (isTransitioning || targetIndex < 0 || targetIndex >= slides.length) return;
526
+ if (targetIndex === currentSlideIndex) return;
527
+ isTransitioning = true;
528
+ transitionDirection = targetIndex > currentSlideIndex ? 'forward' : 'backward';
529
+ const targetSlide = slides[targetIndex];
530
+ clearAllTypewriterAnimations();
531
+ cancelMotionPathLoops();
532
+ const transition = targetSlide.transition;
533
+ const duration = durationOverride ?? transition.duration;
534
+ transitionDurationMs = duration;
535
+ const hasSlideTransition = transition.type !== 'none';
536
+
537
+ if (hasSlideTransition) {
538
+ transitionClass = `transition-${transition.type}-out`;
539
+ await new Promise(r => setTimeout(r, duration * 0.4));
540
+ const newElementContent = new Map(elementContent);
541
+ const newCodeMorphState = new Map(codeMorphState);
542
+ const newPreviousCodeContent = new Map(previousCodeContent);
543
+ for (const elementId of allElementIds) {
544
+ const targetEl = getElementInSlide(targetSlide, elementId);
545
+ const animated = animatedElements.get(elementId);
546
+ if (targetEl) {
547
+ if (targetEl.type === 'code') {
548
+ const codeEl = targetEl as CodeElement;
549
+ const prevCode = newPreviousCodeContent.get(elementId) || '';
550
+ newCodeMorphState.set(elementId, { oldCode: prevCode, newCode: codeEl.code, mode: codeEl.animation?.mode || 'highlight-changes', speed: codeEl.animation?.typewriterSpeed || 50, highlightColor: codeEl.animation?.highlightColor || '#fef08a' });
551
+ newPreviousCodeContent.set(elementId, codeEl.code);
552
+ }
553
+ newElementContent.set(elementId, JSON.parse(JSON.stringify(targetEl)));
554
+ if (animated) {
555
+ animated.x.to(targetEl.position.x, { duration: 0 }); animated.y.to(targetEl.position.y, { duration: 0 });
556
+ animated.width.to(targetEl.size.width, { duration: 0 }); animated.height.to(targetEl.size.height, { duration: 0 });
557
+ animated.rotation.to(targetEl.rotation, { duration: 0 });
558
+ animated.skewX.to(targetEl.skewX ?? 0, { duration: 0 }); animated.skewY.to(targetEl.skewY ?? 0, { duration: 0 });
559
+ animated.tiltX.to(targetEl.tiltX ?? 0, { duration: 0 }); animated.tiltY.to(targetEl.tiltY ?? 0, { duration: 0 });
560
+ animated.perspective.to(targetEl.perspective ?? 1000, { duration: 0 });
561
+ animated.opacity.to((targetEl as any).opacity ?? 1, { duration: 0 });
562
+ animated.borderRadius.to((targetEl as any).borderRadius ?? 0, { duration: 0 });
563
+ animated.blur.to(targetEl.blur ?? 0, { duration: 0 });
564
+ animated.brightness.to(targetEl.brightness ?? 100, { duration: 0 });
565
+ animated.contrast.to(targetEl.contrast ?? 100, { duration: 0 });
566
+ animated.saturate.to(targetEl.saturate ?? 100, { duration: 0 });
567
+ animated.grayscale.to(targetEl.grayscale ?? 0, { duration: 0 });
568
+ if (targetEl.type === 'text') animated.fontSize?.to((targetEl as TextElement).fontSize, { duration: 0 });
569
+ if (targetEl.type === 'shape') {
570
+ const s = targetEl as ShapeElement;
571
+ animated.fillColor?.to(s.fillColor, { duration: 0 });
572
+ animated.strokeColor?.to(s.strokeColor, { duration: 0 });
573
+ animated.strokeWidth?.to(s.strokeWidth, { duration: 0 });
574
+ }
575
+ if (animated.motionPathProgress) animated.motionPathProgress.to(0, { duration: 0 });
576
+ }
577
+ } else if (animated) { animated.opacity.to(0, { duration: 0 }); }
578
+ }
579
+ for (const [, element] of newElementContent) {
580
+ if (element.type === 'code') {
581
+ const codeEl = element as CodeElement;
582
+ const key = `${codeEl.id}-${codeEl.code}-${codeEl.language}-${codeEl.showLineNumbers}`;
583
+ if (!codeHighlights.has(key)) {
584
+ const html = await highlightCode(codeEl.code, codeEl.language, codeEl.theme, { showLineNumbers: codeEl.showLineNumbers });
585
+ codeHighlights.set(key, html);
586
+ }
587
+ }
588
+ }
589
+ codeHighlights = new Map(codeHighlights);
590
+ shapeMorphStates = new Map();
591
+ codeMorphState = newCodeMorphState;
592
+ previousCodeContent = newPreviousCodeContent;
593
+ elementContent = newElementContent;
594
+ animatedElements = new Map(animatedElements);
595
+ currentSlideIndex = targetIndex;
596
+ for (const elementId of allElementIds) {
597
+ const targetEl = getElementInSlide(targetSlide, elementId);
598
+ if (targetEl?.type === 'text') {
599
+ const textEl = targetEl as TextElement;
600
+ if (textEl.animation?.mode === 'typewriter') startTypewriterAnimation(elementId, textEl.content, textEl.animation.typewriterSpeed || 50);
601
+ }
602
+ }
603
+ transitionClass = `transition-${transition.type}-in`;
604
+ await new Promise(r => setTimeout(r, duration * 0.6));
605
+ transitionClass = '';
606
+ animateMotionPaths(targetSlide);
607
+ isTransitioning = false;
608
+ onslidechange?.(targetIndex, slides.length);
609
+ if (targetIndex === slides.length - 1 && !loop) oncomplete?.();
610
+ return;
611
+ }
612
+
613
+ // Per-element morphing (transition type = 'none')
614
+ const animations: Promise<void>[] = [];
615
+ for (const elementId of allElementIds) {
616
+ const currentEl = getElementInSlide(currentSlide, elementId);
617
+ const animated = animatedElements.get(elementId);
618
+ if (!animated) continue;
619
+ if (currentEl) {
620
+ await animated.x.to(currentEl.position.x, { duration: 0 });
621
+ await animated.y.to(currentEl.position.y, { duration: 0 });
622
+ await animated.width.to(currentEl.size.width, { duration: 0 });
623
+ await animated.height.to(currentEl.size.height, { duration: 0 });
624
+ await animated.rotation.to(currentEl.rotation, { duration: 0 });
625
+ await animated.skewX.to(currentEl.skewX ?? 0, { duration: 0 });
626
+ await animated.skewY.to(currentEl.skewY ?? 0, { duration: 0 });
627
+ await animated.tiltX.to(currentEl.tiltX ?? 0, { duration: 0 });
628
+ await animated.tiltY.to(currentEl.tiltY ?? 0, { duration: 0 });
629
+ await animated.perspective.to(currentEl.perspective ?? 1000, { duration: 0 });
630
+ await animated.borderRadius.to((currentEl as any).borderRadius ?? 0, { duration: 0 });
631
+ await animated.blur.to(currentEl.blur ?? 0, { duration: 0 });
632
+ await animated.brightness.to(currentEl.brightness ?? 100, { duration: 0 });
633
+ await animated.contrast.to(currentEl.contrast ?? 100, { duration: 0 });
634
+ await animated.saturate.to(currentEl.saturate ?? 100, { duration: 0 });
635
+ await animated.grayscale.to(currentEl.grayscale ?? 0, { duration: 0 });
636
+ await animated.opacity.to((currentEl as any).opacity ?? 1, { duration: 0 });
637
+ if (currentEl.type === 'text' && animated.fontSize) await animated.fontSize.to((currentEl as TextElement).fontSize, { duration: 0 });
638
+ if (currentEl.type === 'shape') {
639
+ const s = currentEl as ShapeElement;
640
+ if (animated.fillColor) await animated.fillColor.to(s.fillColor, { duration: 0 });
641
+ if (animated.strokeColor) await animated.strokeColor.to(s.strokeColor, { duration: 0 });
642
+ if (animated.strokeWidth) await animated.strokeWidth.to(s.strokeWidth, { duration: 0 });
643
+ }
644
+ }
645
+ }
646
+
647
+ // Update elementContent BEFORE animations start so rendered elements
648
+ // (especially SVG viewBox) use target slide data while animating
649
+ for (const elementId of allElementIds) {
650
+ const targetEl = getElementInSlide(targetSlide, elementId);
651
+ if (targetEl && targetEl.type !== 'code') {
652
+ elementContent.set(elementId, JSON.parse(JSON.stringify(targetEl)));
653
+ }
654
+ }
655
+ elementContent = new Map(elementContent);
656
+
657
+ interface AnimationTask { elementId: string; order: number; delay: number; elementDuration: number; run: () => Promise<void>[]; }
658
+ const animationTasks: AnimationTask[] = [];
659
+
660
+ for (const elementId of allElementIds) {
661
+ const currentEl = getElementInSlide(currentSlide, elementId);
662
+ const targetEl = getElementInSlide(targetSlide, elementId);
663
+ const animated = animatedElements.get(elementId);
664
+ if (!animated) continue;
665
+ const animConfig = targetEl?.animationConfig || currentEl?.animationConfig;
666
+ const order = animConfig?.order ?? 0;
667
+ const delay = animConfig?.delay ?? 0;
668
+ const elementDuration = animConfig?.duration ?? duration;
669
+
670
+ if (targetEl) {
671
+ const easing = getEasingFn(animConfig?.easing);
672
+ const propertySequences = targetEl.animationConfig?.propertySequences;
673
+ if (targetEl.type === 'text') {
674
+ const textEl = targetEl as TextElement;
675
+ if (textEl.animation?.mode === 'typewriter') startTypewriterAnimation(elementId, textEl.content, textEl.animation.typewriterSpeed || 50);
676
+ }
677
+
678
+ const getSeqTiming = (prop: AnimatableProperty) => {
679
+ if (!propertySequences?.length) return { duration: elementDuration, delay: 0, order: 0 };
680
+ const seq = propertySequences.find(s => s.property === prop);
681
+ return seq ? { duration: seq.duration, delay: seq.delay, order: seq.order } : { duration: elementDuration, delay: 0, order: 99 };
682
+ };
683
+
684
+ animationTasks.push({
685
+ elementId, order, delay, elementDuration,
686
+ run: () => {
687
+ const anims: Promise<void>[] = [];
688
+ if (propertySequences?.length) {
689
+ const sequencedProps = new Set(propertySequences.map(s => s.property));
690
+ 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 })); }
691
+ if (!sequencedProps.has('rotation')) anims.push(animated.rotation.to(targetEl.rotation, { duration: elementDuration, easing }));
692
+ 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 })); }
693
+ 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 })); }
694
+ 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 })); }
695
+ if (!sequencedProps.has('borderRadius')) anims.push(animated.borderRadius.to((targetEl as any).borderRadius ?? 0, { duration: elementDuration, easing }));
696
+ if (!sequencedProps.has('blur')) {
697
+ animated.blur.to(targetEl.blur ?? 0, { duration: elementDuration, easing });
698
+ animated.brightness.to(targetEl.brightness ?? 100, { duration: elementDuration, easing });
699
+ animated.contrast.to(targetEl.contrast ?? 100, { duration: elementDuration, easing });
700
+ animated.saturate.to(targetEl.saturate ?? 100, { duration: elementDuration, easing });
701
+ animated.grayscale.to(targetEl.grayscale ?? 0, { duration: elementDuration, easing });
702
+ }
703
+ if (!sequencedProps.has('perspective')) anims.push(animated.perspective.to(targetEl.perspective ?? 1000, { duration: elementDuration, easing }));
704
+ if (!sequencedProps.has('opacity')) {
705
+ const targetOpacity = (targetEl as any).opacity ?? 1;
706
+ if (animated.opacity.current !== targetOpacity) anims.push(animated.opacity.to(targetOpacity, { duration: elementDuration, easing }));
707
+ }
708
+ const sortedSeqs = [...propertySequences].sort((a, b) => a.order - b.order);
709
+ let cumulativeDelay = 0;
710
+ for (const seq of sortedSeqs) {
711
+ const seqDelay = cumulativeDelay + seq.delay;
712
+ const seqDuration = seq.duration;
713
+ setTimeout(() => {
714
+ if (seq.property === 'position') { animated.x.to(targetEl.position.x, { duration: seqDuration, easing }); animated.y.to(targetEl.position.y, { duration: seqDuration, easing }); }
715
+ else if (seq.property === 'rotation') animated.rotation.to(targetEl.rotation, { duration: seqDuration, easing });
716
+ else if (seq.property === 'tilt') { animated.tiltX.to(targetEl.tiltX ?? 0, { duration: seqDuration, easing }); animated.tiltY.to(targetEl.tiltY ?? 0, { duration: seqDuration, easing }); }
717
+ else if (seq.property === 'skew') { animated.skewX.to(targetEl.skewX ?? 0, { duration: seqDuration, easing }); animated.skewY.to(targetEl.skewY ?? 0, { duration: seqDuration, easing }); }
718
+ else if (seq.property === 'size') { animated.width.to(targetEl.size.width, { duration: seqDuration, easing }); animated.height.to(targetEl.size.height, { duration: seqDuration, easing }); }
719
+ else if (seq.property === 'borderRadius') animated.borderRadius.to((targetEl as any).borderRadius ?? 0, { duration: seqDuration, easing });
720
+ else if (seq.property === 'blur') {
721
+ animated.blur.to(targetEl.blur ?? 0, { duration: seqDuration, easing });
722
+ animated.brightness.to(targetEl.brightness ?? 100, { duration: seqDuration, easing });
723
+ animated.contrast.to(targetEl.contrast ?? 100, { duration: seqDuration, easing });
724
+ animated.saturate.to(targetEl.saturate ?? 100, { duration: seqDuration, easing });
725
+ animated.grayscale.to(targetEl.grayscale ?? 0, { duration: seqDuration, easing });
726
+ }
727
+ else if (seq.property === 'color' && targetEl.type === 'shape') {
728
+ const s = targetEl as ShapeElement;
729
+ animated.fillColor?.to(s.fillColor, { duration: seqDuration, easing });
730
+ animated.strokeColor?.to(s.strokeColor, { duration: seqDuration, easing });
731
+ animated.strokeWidth?.to(s.strokeWidth, { duration: seqDuration, easing });
732
+ }
733
+ else if (seq.property === 'perspective') animated.perspective.to(targetEl.perspective ?? 1000, { duration: seqDuration, easing });
734
+ else if (seq.property === 'opacity') animated.opacity.to((targetEl as any).opacity ?? 1, { duration: seqDuration, easing });
735
+ }, seqDelay);
736
+ cumulativeDelay = seqDelay + seqDuration;
737
+ }
738
+ anims.push(new Promise(r => setTimeout(r, cumulativeDelay)));
739
+ } else {
740
+ anims.push(animated.x.to(targetEl.position.x, { duration: elementDuration, easing }));
741
+ anims.push(animated.y.to(targetEl.position.y, { duration: elementDuration, easing }));
742
+ anims.push(animated.width.to(targetEl.size.width, { duration: elementDuration, easing }));
743
+ anims.push(animated.height.to(targetEl.size.height, { duration: elementDuration, easing }));
744
+ anims.push(animated.rotation.to(targetEl.rotation, { duration: elementDuration, easing }));
745
+ anims.push(animated.skewX.to(targetEl.skewX ?? 0, { duration: elementDuration, easing }));
746
+ anims.push(animated.skewY.to(targetEl.skewY ?? 0, { duration: elementDuration, easing }));
747
+ anims.push(animated.tiltX.to(targetEl.tiltX ?? 0, { duration: elementDuration, easing }));
748
+ anims.push(animated.tiltY.to(targetEl.tiltY ?? 0, { duration: elementDuration, easing }));
749
+ anims.push(animated.perspective.to(targetEl.perspective ?? 1000, { duration: elementDuration, easing }));
750
+ anims.push(animated.borderRadius.to((targetEl as any).borderRadius ?? 0, { duration: elementDuration, easing }));
751
+ anims.push(animated.blur.to(targetEl.blur ?? 0, { duration: elementDuration, easing }));
752
+ anims.push(animated.brightness.to(targetEl.brightness ?? 100, { duration: elementDuration, easing }));
753
+ anims.push(animated.contrast.to(targetEl.contrast ?? 100, { duration: elementDuration, easing }));
754
+ anims.push(animated.saturate.to(targetEl.saturate ?? 100, { duration: elementDuration, easing }));
755
+ anims.push(animated.grayscale.to(targetEl.grayscale ?? 0, { duration: elementDuration, easing }));
756
+ // Opacity interpolation for morphing elements
757
+ const currOpacity = animated.opacity.current;
758
+ const targetOpacity = (targetEl as any).opacity ?? 1;
759
+ if (currOpacity !== targetOpacity) {
760
+ anims.push(animated.opacity.to(targetOpacity, { duration: elementDuration, easing }));
761
+ }
762
+ }
763
+ // Motion path progress await reset, then animate forward
764
+ if (animated.motionPathProgress && targetEl.motionPathConfig) {
765
+ const shouldLoop = targetEl.motionPathConfig.loop;
766
+ if (!shouldLoop) {
767
+ anims.push((async () => {
768
+ await animated.motionPathProgress!.to(0, { duration: 0 });
769
+ await animated.motionPathProgress!.to(1, { duration: elementDuration, easing });
770
+ })());
771
+ }
772
+ }
773
+ if (targetEl.type === 'text' && animated.fontSize) anims.push(animated.fontSize.to((targetEl as TextElement).fontSize, { duration: elementDuration, easing }));
774
+ if (targetEl.type === 'shape' && currentEl?.type === 'shape') {
775
+ const ts = targetEl as ShapeElement;
776
+ const cs = currentEl as ShapeElement;
777
+ if (!propertySequences?.length) {
778
+ if (animated.fillColor) anims.push(animated.fillColor.to(ts.fillColor, { duration: elementDuration, easing }));
779
+ if (animated.strokeColor) anims.push(animated.strokeColor.to(ts.strokeColor, { duration: elementDuration, easing }));
780
+ if (animated.strokeWidth) anims.push(animated.strokeWidth.to(ts.strokeWidth, { duration: elementDuration, easing }));
781
+ }
782
+ if (cs.shapeType !== ts.shapeType && animated.shapeMorph) {
783
+ shapeMorphStates.set(elementId, { fromType: cs.shapeType, toType: ts.shapeType });
784
+ shapeMorphStates = new Map(shapeMorphStates);
785
+ anims.push(animated.shapeMorph.to(0, { duration: 0 }));
786
+ anims.push(animated.shapeMorph.to(1, { duration: elementDuration, easing }));
787
+ }
788
+ } else if (targetEl.type === 'shape' && !propertySequences?.length) {
789
+ const s = targetEl as ShapeElement;
790
+ if (animated.fillColor) anims.push(animated.fillColor.to(s.fillColor, { duration: elementDuration, easing }));
791
+ if (animated.strokeColor) anims.push(animated.strokeColor.to(s.strokeColor, { duration: elementDuration, easing }));
792
+ if (animated.strokeWidth) anims.push(animated.strokeWidth.to(s.strokeWidth, { duration: elementDuration, easing }));
793
+ }
794
+ if (!currentEl) {
795
+ // Snap ALL properties to target instantly — the tween may hold
796
+ // stale values from a previous slide where the element last appeared
797
+ anims.push(animated.x.to(targetEl.position.x, { duration: 0 }));
798
+ anims.push(animated.y.to(targetEl.position.y, { duration: 0 }));
799
+ anims.push(animated.width.to(targetEl.size.width, { duration: 0 }));
800
+ anims.push(animated.height.to(targetEl.size.height, { duration: 0 }));
801
+ anims.push(animated.rotation.to(targetEl.rotation, { duration: 0 }));
802
+ anims.push(animated.skewX.to(targetEl.skewX ?? 0, { duration: 0 }));
803
+ anims.push(animated.skewY.to(targetEl.skewY ?? 0, { duration: 0 }));
804
+ anims.push(animated.tiltX.to(targetEl.tiltX ?? 0, { duration: 0 }));
805
+ anims.push(animated.tiltY.to(targetEl.tiltY ?? 0, { duration: 0 }));
806
+ anims.push(animated.perspective.to(targetEl.perspective ?? 1000, { duration: 0 }));
807
+ anims.push(animated.borderRadius.to((targetEl as any).borderRadius ?? 0, { duration: 0 }));
808
+ anims.push(animated.blur.to(targetEl.blur ?? 0, { duration: 0 }));
809
+ anims.push(animated.brightness.to(targetEl.brightness ?? 100, { duration: 0 }));
810
+ anims.push(animated.contrast.to(targetEl.contrast ?? 100, { duration: 0 }));
811
+ anims.push(animated.saturate.to(targetEl.saturate ?? 100, { duration: 0 }));
812
+ anims.push(animated.grayscale.to(targetEl.grayscale ?? 0, { duration: 0 }));
813
+ if (targetEl.type === 'text' && animated.fontSize) {
814
+ anims.push(animated.fontSize.to((targetEl as TextElement).fontSize, { duration: 0 }));
815
+ }
816
+ if (targetEl.type === 'shape') {
817
+ const s = targetEl as ShapeElement;
818
+ if (animated.fillColor) anims.push(animated.fillColor.to(s.fillColor, { duration: 0 }));
819
+ if (animated.strokeColor) anims.push(animated.strokeColor.to(s.strokeColor, { duration: 0 }));
820
+ if (animated.strokeWidth) anims.push(animated.strokeWidth.to(s.strokeWidth, { duration: 0 }));
821
+ }
822
+ const entrance = targetEl.animationConfig?.entrance ?? 'fade';
823
+ const targetOpacity = (targetEl as any).opacity ?? 1;
824
+ if (entrance === 'fade') {
825
+ anims.push(animated.opacity.to(targetOpacity, { duration: elementDuration / 2, easing }));
826
+ } else {
827
+ anims.push(animated.opacity.to(targetOpacity, { duration: 0 }));
828
+ }
829
+ }
830
+ return anims;
831
+ }
832
+ });
833
+ } else if (currentEl) {
834
+ const exit = currentEl.animationConfig?.exit ?? 'fade';
835
+ if (exit === 'fade') {
836
+ const fadeOutDuration = Math.min(elementDuration / 2, 300);
837
+ animationTasks.push({ elementId, order, delay, elementDuration, run: () => [animated.opacity.to(0, { duration: fadeOutDuration, easing: easeInOutCubic })] });
838
+ } else {
839
+ animationTasks.push({ elementId, order, delay: 0, elementDuration: 0, run: () => [animated.opacity.to(0, { duration: 0 })] });
840
+ }
841
+ }
842
+ }
843
+
844
+ animationTasks.sort((a, b) => a.order - b.order);
845
+ const orderGroups = new Map<number, AnimationTask[]>();
846
+ for (const task of animationTasks) {
847
+ if (!orderGroups.has(task.order)) orderGroups.set(task.order, []);
848
+ orderGroups.get(task.order)!.push(task);
849
+ }
850
+ const sortedOrders = [...orderGroups.keys()].sort((a, b) => a - b);
851
+ for (let orderIdx = 0; orderIdx < sortedOrders.length; orderIdx++) {
852
+ const order = sortedOrders[orderIdx];
853
+ const tasks = orderGroups.get(order)!;
854
+ const groupAnimations: Promise<void>[] = [];
855
+ for (const task of tasks) {
856
+ if (task.delay > 0) setTimeout(() => { task.run().forEach(p => animations.push(p)); }, task.delay);
857
+ else groupAnimations.push(...task.run());
858
+ }
859
+ animations.push(...groupAnimations);
860
+ if (orderIdx < sortedOrders.length - 1) {
861
+ const maxDur = Math.max(...tasks.map(t => t.elementDuration));
862
+ await new Promise(r => setTimeout(r, maxDur * 0.3));
863
+ }
864
+ }
865
+
866
+ const newElementContent = new Map(elementContent);
867
+ const newCodeMorphState = new Map(codeMorphState);
868
+ const newPreviousCodeContent = new Map(previousCodeContent);
869
+ for (const elementId of allElementIds) {
870
+ const targetEl = getElementInSlide(targetSlide, elementId);
871
+ if (targetEl) {
872
+ if (targetEl.type === 'code') {
873
+ const codeEl = targetEl as CodeElement;
874
+ const prevCode = newPreviousCodeContent.get(elementId) || '';
875
+ newCodeMorphState.set(elementId, { oldCode: prevCode, newCode: codeEl.code, mode: codeEl.animation?.mode || 'highlight-changes', speed: codeEl.animation?.typewriterSpeed || 50, highlightColor: codeEl.animation?.highlightColor || '#fef08a' });
876
+ newPreviousCodeContent.set(elementId, codeEl.code);
877
+ }
878
+ newElementContent.set(elementId, JSON.parse(JSON.stringify(targetEl)));
879
+ }
880
+ }
881
+ for (const [, element] of newElementContent) {
882
+ if (element.type === 'code') {
883
+ const codeEl = element as CodeElement;
884
+ const key = `${codeEl.id}-${codeEl.code}-${codeEl.language}-${codeEl.showLineNumbers}`;
885
+ if (!codeHighlights.has(key)) {
886
+ const html = await highlightCode(codeEl.code, codeEl.language, codeEl.theme, { showLineNumbers: codeEl.showLineNumbers });
887
+ codeHighlights.set(key, html);
888
+ }
889
+ }
890
+ }
891
+ codeHighlights = new Map(codeHighlights);
892
+ shapeMorphStates = new Map();
893
+ codeMorphState = newCodeMorphState;
894
+ previousCodeContent = newPreviousCodeContent;
895
+ elementContent = newElementContent;
896
+ currentSlideIndex = targetIndex;
897
+ isTransitioning = false;
898
+ // Ensure elements not on the new slide are fully hidden
899
+ const newSlide = slides[targetIndex];
900
+ for (const elementId of allElementIds) {
901
+ const onSlide = getElementInSlide(newSlide, elementId);
902
+ const animated = animatedElements.get(elementId);
903
+ if (!onSlide && animated) { animated.opacity.to(0, { duration: 0 }); }
904
+ }
905
+ animateMotionPaths(slides[targetIndex]);
906
+ onslidechange?.(targetIndex, slides.length);
907
+ if (targetIndex === slides.length - 1 && !loop) oncomplete?.();
908
+ }
909
+
910
+ // Autoplay
911
+ function clearAutoplayTimer() { if (autoplayTimer) { clearTimeout(autoplayTimer); autoplayTimer = null; } }
912
+ function scheduleNextSlide() {
913
+ clearAutoplayTimer();
914
+ if (!isAutoplay) return;
915
+ const slideDuration = durationOverride ?? currentSlide?.duration ?? 3000;
916
+ autoplayTimer = setTimeout(() => {
917
+ if (currentSlideIndex < slides.length - 1) animateToSlide(currentSlideIndex + 1);
918
+ else if (loop) {
919
+ const loopMode = project?.settings?.loopMode ?? 'reset';
920
+ if (loopMode === 'transition') animateToSlide(0);
921
+ else resetToFirstSlide();
922
+ }
923
+ else isAutoplay = false;
924
+ }, slideDuration);
925
+ }
926
+ $effect(() => { if (isAutoplay && !isTransitioning) scheduleNextSlide(); });
927
+ $effect(() => () => clearAutoplayTimer());
928
+
929
+ function handleNextSlide() {
930
+ if (currentSlideIndex < slides.length - 1) {
931
+ animateToSlide(currentSlideIndex + 1);
932
+ } else if (loop) {
933
+ const loopMode = project?.settings?.loopMode ?? 'reset';
934
+ if (loopMode === 'transition') {
935
+ animateToSlide(0);
936
+ } else {
937
+ resetToFirstSlide();
938
+ }
939
+ }
940
+ }
941
+
942
+ // Keyboard
943
+ function handleKeyDown(e: KeyboardEvent) {
944
+ if (!keyboard) return;
945
+ if (e.key === 'ArrowRight' || e.key === ' ' || e.key === 'Enter') { e.preventDefault(); handleNextSlide(); }
946
+ else if (e.key === 'ArrowLeft' || e.key === 'Backspace') { e.preventDefault(); animateToSlide(currentSlideIndex - 1); }
947
+ else if (e.key === 'Home') animateToSlide(0);
948
+ else if (e.key === 'End') animateToSlide(slides.length - 1);
949
+ else if (e.key === 'p' || e.key === 'P') { isAutoplay = !isAutoplay; if (!isAutoplay) clearAutoplayTimer(); }
950
+ }
951
+
952
+ function resetMouseIdleTimer() {
953
+ menuVisible = true;
954
+ if (mouseIdleTimer) clearTimeout(mouseIdleTimer);
955
+ mouseIdleTimer = setTimeout(() => { menuVisible = false; }, 3000);
956
+ }
957
+
958
+ // Code highlight helpers
959
+ async function loadCodeHighlights() {
960
+ for (const slide of slides) {
961
+ for (const element of slide.canvas.elements) {
962
+ if (element.type === 'code') {
963
+ const codeEl = element as CodeElement;
964
+ const key = `${codeEl.id}-${codeEl.code}-${codeEl.language}-${codeEl.showLineNumbers}`;
965
+ if (!codeHighlights.has(key)) {
966
+ const html = await highlightCode(codeEl.code, codeEl.language, codeEl.theme, { showLineNumbers: codeEl.showLineNumbers });
967
+ codeHighlights.set(key, html);
968
+ }
969
+ }
970
+ }
971
+ }
972
+ codeHighlights = new Map(codeHighlights);
973
+ }
974
+
975
+ function getCodeHighlight(elementId: string): string {
976
+ const slideElement = getElementInSlide(currentSlide, elementId);
977
+ if (!slideElement || slideElement.type !== 'code') return '';
978
+ const codeEl = slideElement as CodeElement;
979
+ const key = `${codeEl.id}-${codeEl.code}-${codeEl.language}-${codeEl.showLineNumbers}`;
980
+ const cached = codeHighlights.get(key);
981
+ if (cached) return cached;
982
+ highlightCode(codeEl.code, codeEl.language, codeEl.theme, { showLineNumbers: codeEl.showLineNumbers }).then(html => {
983
+ codeHighlights.set(key, html);
984
+ codeHighlights = new Map(codeHighlights);
985
+ });
986
+ return '';
987
+ }
988
+
989
+ // Public API (exposed via bind:this)
990
+ export async function goto(slideIndex: number) { await animateToSlide(slideIndex); }
991
+ export async function next() { handleNextSlide(); }
992
+ export async function prev() { await animateToSlide(currentSlideIndex - 1); }
993
+ export function play() { isAutoplay = true; }
994
+ export function pause() { isAutoplay = false; clearAutoplayTimer(); }
995
+ export function getCurrentSlide() { return currentSlideIndex; }
996
+ export function getTotalSlides() { return slides.length; }
997
+ export function getIsPlaying() { return isAutoplay; }
998
+
999
+ // Auto-load Google Fonts used by text elements in the project.
1000
+ // Generic CSS font families that don't need loading
1001
+ const GENERIC_FONTS = new Set([
1002
+ 'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy',
1003
+ 'system-ui', 'ui-serif', 'ui-sans-serif', 'ui-monospace', 'ui-rounded',
1004
+ 'math', 'emoji', 'fangsong', 'inherit', 'initial', 'unset'
1005
+ ]);
1006
+
1007
+ // Extract individual font names from a CSS font-family string.
1008
+ // e.g. '"JetBrains Mono", system-ui, monospace' → ['JetBrains Mono']
1009
+ function extractFontNames(fontFamily: string): string[] {
1010
+ return fontFamily
1011
+ .split(',')
1012
+ .map(f => f.trim().replace(/^['"]|['"]$/g, ''))
1013
+ .filter(f => f && !GENERIC_FONTS.has(f.toLowerCase()));
1014
+ }
1015
+
1016
+ // Auto-load fonts used by text/counter elements.
1017
+ // Uses fontsource CDN (jsDelivr) which registers the SAME font-family names
1018
+ // as the app (e.g. "Plus Jakarta Sans Variable"), unlike Google Fonts which
1019
+ // strips the "Variable" suffix.
1020
+ function loadProjectFonts(proj: AnimotProject) {
1021
+ const fonts = new Set<string>();
1022
+ for (const slide of proj.slides) {
1023
+ for (const el of slide.canvas.elements) {
1024
+ if (el.type === 'text' || el.type === 'counter') {
1025
+ const f = (el as any).fontFamily as string | undefined;
1026
+ if (f) {
1027
+ for (const name of extractFontNames(f)) fonts.add(name);
1028
+ }
1029
+ }
1030
+ }
1031
+ }
1032
+ if (fonts.size === 0) return;
1033
+
1034
+ // Deduplicate against already-injected links to avoid double-loading
1035
+ const loaded = new Set<string>();
1036
+ document.querySelectorAll<HTMLLinkElement>('link[data-animot-font]').forEach(l => loaded.add(l.dataset.animotFont!));
1037
+
1038
+ for (const font of fonts) {
1039
+ if (loaded.has(font)) continue;
1040
+ const isVariable = /\s+Variable$/i.test(font);
1041
+ // Convert font name to fontsource package slug:
1042
+ // "Plus Jakarta Sans Variable" → "plus-jakarta-sans"
1043
+ // "JetBrains Mono" "jetbrains-mono"
1044
+ const baseName = font.replace(/\s*Variable$/i, '');
1045
+ const slug = baseName.toLowerCase().replace(/\s+/g, '-');
1046
+ const pkg = isVariable
1047
+ ? `@fontsource-variable/${slug}`
1048
+ : `@fontsource/${slug}`;
1049
+ const link = document.createElement('link');
1050
+ link.rel = 'stylesheet';
1051
+ link.href = `https://cdn.jsdelivr.net/npm/${pkg}/index.css`;
1052
+ link.dataset.animotFont = font;
1053
+ document.head.appendChild(link);
1054
+ }
1055
+ }
1056
+
1057
+ // Load data
1058
+ async function loadProject() {
1059
+ loading = true; error = null;
1060
+ try {
1061
+ if (data) { project = data; }
1062
+ else if (src) {
1063
+ const res = await fetch(src);
1064
+ if (!res.ok) throw new Error(`Failed to load: ${res.status}`);
1065
+ project = await res.json();
1066
+ } else { throw new Error('Either src or data prop is required'); }
1067
+ loadProjectFonts(project!);
1068
+ currentSlideIndex = startSlide;
1069
+ await new Promise(r => setTimeout(r, 10));
1070
+ initAllAnimatedElements();
1071
+ await loadCodeHighlights();
1072
+ loading = false;
1073
+ if (currentSlide) setTimeout(() => animateMotionPaths(currentSlide!), 300);
1074
+ if (autoplay) isAutoplay = true;
1075
+ } catch (e: any) { error = e.message; loading = false; }
1076
+ }
1077
+
1078
+ // ResizeObserver
1079
+ let resizeObserver: ResizeObserver;
1080
+
1081
+ onMount(() => {
1082
+ loadProject();
1083
+ resizeObserver = new ResizeObserver(entries => {
1084
+ for (const entry of entries) {
1085
+ containerWidth = entry.contentRect.width;
1086
+ containerHeight = entry.contentRect.height;
1087
+ }
1088
+ });
1089
+ if (containerEl) resizeObserver.observe(containerEl);
1090
+ resetMouseIdleTimer();
1091
+ return () => { resizeObserver?.disconnect(); clearAutoplayTimer(); clearAllTypewriterAnimations(); if (mouseIdleTimer) clearTimeout(mouseIdleTimer); };
1092
+ });
1093
+
1094
+ // Watch for prop changes
1095
+ $effect(() => { if (data) { project = data; } });
1096
+ </script>
1097
+
1098
+ <svelte:window onkeydown={handleKeyDown} />
1099
+
1100
+ <div
1101
+ class="animot-presenter {className}"
1102
+ class:animot-menu-visible={menuVisible}
1103
+ bind:this={containerEl}
1104
+ onmousemove={resetMouseIdleTimer}
1105
+ role="region"
1106
+ aria-label="Animot Presentation"
1107
+ >
1108
+ {#if loading}
1109
+ <div class="animot-loading"><div class="animot-spinner"></div></div>
1110
+ {:else if error}
1111
+ <div class="animot-error">{error}</div>
1112
+ {:else if project && currentSlide}
1113
+ <div class="animot-canvas-wrapper" style:transform="scale({presentationScale})">
1114
+ <div
1115
+ class="animot-canvas {transitionClass}"
1116
+ class:forward={transitionDirection === 'forward'}
1117
+ class:backward={transitionDirection === 'backward'}
1118
+ style:width="{canvasWidth}px"
1119
+ style:height="{canvasHeight}px"
1120
+ style:--transition-duration="{transitionDurationMs}ms"
1121
+ style={backgroundStyle}
1122
+ >
1123
+ {#if currentSlide.canvas.background.particles?.enabled}
1124
+ <ParticlesBackground config={currentSlide.canvas.background.particles} width={canvasWidth} height={canvasHeight} />
1125
+ {/if}
1126
+ {#if currentSlide.canvas.background.confetti?.enabled}
1127
+ <ConfettiEffect config={currentSlide.canvas.background.confetti} width={canvasWidth} height={canvasHeight} />
1128
+ {/if}
1129
+
1130
+ {#each sortedElementIds as elementId}
1131
+ {@const element = elementContent.get(elementId)}
1132
+ {@const animated = animatedElements.get(elementId)}
1133
+ {@const floatCfg = element?.floatingAnimation}
1134
+ {@const hasFloat = floatCfg?.enabled}
1135
+ {@const floatGroupId = element?.groupId}
1136
+ {@const mpConfig = element?.motionPathConfig}
1137
+ {@const mpElement = mpConfig ? currentSlide?.canvas.elements.find(el => el.id === mpConfig.motionPathId) as MotionPathElement | undefined : undefined}
1138
+ {@const mpProgress = animated?.motionPathProgress?.current ?? 0}
1139
+ {@const mpPoint = mpElement && mpConfig ? getPresenterPointOnPath(mpElement.points, mpElement.closed, (mpConfig.startPercent + (mpConfig.endPercent - mpConfig.startPercent) * mpProgress) / 100) : null}
1140
+ {@const mpStartPoint = mpElement && mpConfig ? getPresenterPointOnPath(mpElement.points, mpElement.closed, mpConfig.startPercent / 100) : null}
1141
+ {@const mpPos = mpPoint && mpStartPoint && animated && mpElement
1142
+ ? computeMotionPathPosition(mpPoint, mpStartPoint,
1143
+ animated.x.current, animated.y.current,
1144
+ animated.width.current, animated.height.current,
1145
+ mpElement.closed)
1146
+ : null}
1147
+ {@const elemX = mpPos ? mpPos.x : (animated?.x.current ?? 0)}
1148
+ {@const elemY = mpPos ? mpPos.y : (animated?.y.current ?? 0)}
1149
+ {@const mpRotation = mpPoint && mpConfig?.autoRotate
1150
+ ? mpPoint.angle + (mpConfig.orientationOffset ?? 0)
1151
+ : null}
1152
+ {#if element && animated && animated.opacity.current > 0.01 && element.visible !== false && !(element.type === 'motionPath' && !(element as MotionPathElement).showInPresentation)}
1153
+ <div
1154
+ class="animot-element"
1155
+ class:floating={hasFloat}
1156
+ style:left="{elemX}px"
1157
+ style:top="{elemY}px"
1158
+ style:width="{animated.width.current}px"
1159
+ style:height="{animated.height.current}px"
1160
+ style:opacity={animated.opacity.current}
1161
+ 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)"
1162
+ style:transform-origin={element.tiltOrigin ?? 'center'}
1163
+ style:backface-visibility={element.backfaceVisibility ?? 'visible'}
1164
+ style:z-index={element.zIndex}
1165
+ style:--float-amp="{hasFloat ? computeFloatAmp(floatCfg, floatGroupId || elementId) : 10}px"
1166
+ style:--float-speed="{hasFloat ? computeFloatSpeed(floatCfg, floatGroupId || elementId) : 3}s"
1167
+ style:--float-delay="{hashFraction(floatGroupId || elementId, 3) * 2}s"
1168
+ style:animation-name={hasFloat ? getFloatAnimName(floatCfg!.direction, floatGroupId || elementId) : 'none'}
1169
+ 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'; })()}
1170
+ >
1171
+ {#if element.type === 'code'}
1172
+ {@const codeEl = element as CodeElement}
1173
+ {@const morphState = codeMorphState.get(codeEl.id)}
1174
+ <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'}>
1175
+ {#if codeEl.showHeader}
1176
+ <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">
1177
+ {#if codeEl.headerStyle === 'macos'}
1178
+ <div class="animot-window-controls">
1179
+ <span class="animot-control close"></span>
1180
+ <span class="animot-control minimize"></span>
1181
+ <span class="animot-control maximize"></span>
1182
+ </div>
1183
+ {:else if codeEl.headerStyle === 'windows'}
1184
+ <div class="animot-window-controls">
1185
+ <span class="animot-control win-minimize">
1186
+ <svg width="10" height="10" viewBox="0 0 10 10"><path d="M2 5h6" stroke="currentColor" stroke-width="1.2"/></svg>
1187
+ </span>
1188
+ <span class="animot-control win-maximize">
1189
+ <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>
1190
+ </span>
1191
+ <span class="animot-control win-close">
1192
+ <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>
1193
+ </span>
1194
+ </div>
1195
+ {/if}
1196
+ <div class="animot-filename-tab" style:border-radius="{codeEl.tabRadius ?? 6}px">
1197
+ <svg class="animot-file-icon" width="14" height="14" viewBox="0 0 16 16" fill="none">
1198
+ <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"/>
1199
+ <path d="M9.5 1v3.5H13" stroke="currentColor" stroke-width="1.2" opacity="0.5"/>
1200
+ </svg>
1201
+ <span class="animot-filename">{codeEl.filename}</span>
1202
+ </div>
1203
+ <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); }}>
1204
+ <span class="animot-copy-label">Copy</span><span class="animot-copied-label">Copied!</span>
1205
+ <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>
1206
+ <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>
1207
+ </button>
1208
+ </div>
1209
+ {:else}
1210
+ <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); }}>
1211
+ <span class="animot-copy-label">Copy</span><span class="animot-copied-label">Copied!</span>
1212
+ <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>
1213
+ <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>
1214
+ </button>
1215
+ {/if}
1216
+ <div class="animot-code-content">
1217
+ <div class="animot-highlighted-code">
1218
+ {#if morphState && morphState.oldCode !== morphState.newCode && morphState.mode !== 'instant'}
1219
+ {#key currentSlideIndex}
1220
+ <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} />
1221
+ {/key}
1222
+ {:else}
1223
+ {@html getCodeHighlight(codeEl.id)}
1224
+ {/if}
1225
+ </div>
1226
+ </div>
1227
+ </div>
1228
+ {:else if element.type === 'text'}
1229
+ {@const textEl = element as TextElement}
1230
+ {@const animFontSize = animated.fontSize?.current ?? textEl.fontSize}
1231
+ {@const typewriterState = textTypewriterState.get(element.id)}
1232
+ {@const displayText = typewriterState?.isAnimating ? typewriterState.fullText.slice(0, typewriterState.displayedChars) : textEl.content}
1233
+ <div
1234
+ class="animot-text-element"
1235
+ style:font-size="{animFontSize}px"
1236
+ style:font-weight={textEl.fontWeight}
1237
+ style:font-family="'{textEl.fontFamily}', sans-serif"
1238
+ style:font-style={textEl.fontStyle ?? 'normal'}
1239
+ style:text-decoration={textEl.textDecoration ?? 'none'}
1240
+ style:color={textEl.backgroundImage ? 'transparent' : (textEl.hollow && textEl.textStroke?.enabled ? 'transparent' : textEl.color)}
1241
+ style:background-color={textEl.backgroundImage ? 'transparent' : textEl.backgroundColor}
1242
+ style:background-image={textEl.backgroundImage ? `url(${textEl.backgroundImage})` : 'none'}
1243
+ style:background-size={textEl.backgroundImage ? `${textEl.backgroundScale ?? 100}%` : 'cover'}
1244
+ style:background-position={textEl.backgroundImage ? `${textEl.backgroundPositionX ?? 50}% ${textEl.backgroundPositionY ?? 50}%` : 'center'}
1245
+ style:-webkit-background-clip={textEl.backgroundImage ? 'text' : 'border-box'}
1246
+ style:background-clip={textEl.backgroundImage ? 'text' : 'border-box'}
1247
+ style:padding="{textEl.padding}px"
1248
+ style:border-radius="{textEl.borderRadius}px"
1249
+ style:text-align={textEl.textAlign}
1250
+ style:justify-content={textEl.textAlign === 'center' ? 'center' : textEl.textAlign === 'right' ? 'flex-end' : 'flex-start'}
1251
+ style:-webkit-text-stroke={textEl.textStroke?.enabled ? `${textEl.textStroke.width}px ${textEl.textStroke.color}` : '0'}
1252
+ style:text-shadow={textEl.textShadow?.enabled ? `${textEl.textShadow.offsetX}px ${textEl.textShadow.offsetY}px ${textEl.textShadow.blur}px ${textEl.textShadow.color}` : 'none'}
1253
+ >
1254
+ {displayText}{#if typewriterState?.isAnimating}<span class="animot-typewriter-cursor">|</span>{/if}
1255
+ </div>
1256
+ {:else if element.type === 'arrow'}
1257
+ {@const arrowEl = element as ArrowElement}
1258
+ {@const cp = arrowEl.controlPoints || []}
1259
+ {@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)}
1260
+ {@const lastCp = cp.length > 0 ? cp[cp.length - 1] : arrowEl.startPoint}
1261
+ {@const endAngle = Math.atan2(arrowEl.endPoint.y - lastCp.y, arrowEl.endPoint.x - lastCp.x)}
1262
+ {@const headAngle = Math.PI / 6}
1263
+ {@const headSize = arrowEl.headSize}
1264
+ {@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)}`}
1265
+ {@const arrowAnimMode = arrowEl.animation?.mode ?? 'none'}
1266
+ {@const arrowAnimDuration = arrowEl.animation?.duration ?? 500}
1267
+ {@const isStyledArrow = arrowEl.style !== 'solid'}
1268
+ {@const isDrawType = arrowAnimMode === 'draw' || arrowAnimMode === 'undraw' || arrowAnimMode === 'draw-undraw' || arrowAnimMode === 'flow'}
1269
+ {@const baseDashArray = arrowEl.style === 'dashed' ? '10,5' : arrowEl.style === 'dotted' ? '2,5' : 'none'}
1270
+ <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', key: `${pathD}-${arrowAnimMode}-${arrowAnimDuration}-${arrowEl.animation?.loop}-${arrowEl.animation?.direction}-${currentSlideIndex}` }}>
1271
+ <path class="arrow-path" d={pathD} fill="none" stroke={arrowEl.color} stroke-width={arrowEl.strokeWidth} stroke-dasharray={baseDashArray} stroke-linecap="round" stroke-linejoin="round" />
1272
+ {#if arrowEl.showHead !== false}
1273
+ <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;` : ''} />
1274
+ {/if}
1275
+ {#if arrowEl.flowMarkers?.enabled}
1276
+ <FlowMarkers config={arrowEl.flowMarkers} start={arrowEl.startPoint} end={arrowEl.endPoint} controlPoints={cp} />
1277
+ {/if}
1278
+ </svg>
1279
+ {:else if element.type === 'image'}
1280
+ {@const imgEl = element as ImageElement}
1281
+ {@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'}
1282
+ <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'} />
1283
+ {:else if element.type === 'shape'}
1284
+ {@const shapeEl = element as ShapeElement}
1285
+ {@const animFill = animated.fillColor?.current ?? shapeEl.fillColor}
1286
+ {@const animStroke = animated.strokeColor?.current ?? shapeEl.strokeColor}
1287
+ {@const animStrokeWidth = animated.strokeWidth?.current ?? shapeEl.strokeWidth}
1288
+ {@const mState = shapeMorphStates.get(element.id)}
1289
+ {@const morphProgress = animated.shapeMorph?.current ?? 1}
1290
+ {@const effectiveShapeType = mState ? (morphProgress >= 1 ? mState.toType : (morphProgress <= 0 ? mState.fromType : null)) : shapeEl.shapeType}
1291
+ {@const isMorphing = mState && morphProgress > 0 && morphProgress < 1}
1292
+ <svg class="animot-shape-element" viewBox="0 0 {animated.width.current} {animated.height.current}" style:filter={shapeEl.boxShadow?.enabled ? `drop-shadow(${shapeEl.boxShadow.offsetX}px ${shapeEl.boxShadow.offsetY}px ${shapeEl.boxShadow.blur}px ${shapeEl.boxShadow.color})` : 'none'}>
1293
+ {#if isMorphing}
1294
+ {@const w = animated.width.current}
1295
+ {@const h = animated.height.current}
1296
+ {@const sw = animStrokeWidth}
1297
+ <g style:opacity={1 - morphProgress}>{@html renderShape(mState!.fromType, w, h, animated.borderRadius.current, animFill, animStroke, sw, shapeEl.strokeStyle, shapeEl.strokeDashGap)}</g>
1298
+ <g style:opacity={morphProgress}>{@html renderShape(mState!.toType, w, h, animated.borderRadius.current, animFill, animStroke, sw, shapeEl.strokeStyle, shapeEl.strokeDashGap)}</g>
1299
+ {:else}
1300
+ {@html renderShape(effectiveShapeType ?? shapeEl.shapeType, animated.width.current, animated.height.current, animated.borderRadius.current, animFill, animStroke, animStrokeWidth, shapeEl.strokeStyle, shapeEl.strokeDashGap)}
1301
+ {/if}
1302
+ </svg>
1303
+ {:else if element.type === 'counter'}
1304
+ <CounterRenderer element={element as CounterElement} slideId={currentSlide?.id ?? ''} />
1305
+ {:else if element.type === 'chart'}
1306
+ <ChartRenderer element={element as ChartElement} slideId={currentSlide?.id ?? ''} />
1307
+ {:else if element.type === 'icon'}
1308
+ <IconRenderer element={element as IconElement} />
1309
+ {:else if element.type === 'svg'}
1310
+ {@const svgEl = element as SvgElement}
1311
+ {@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 }; })()}
1312
+ {@const svgAnimMode = svgEl.animation?.mode ?? 'none'}
1313
+ {@const svgAnimDur = svgEl.animation?.duration ?? 800}
1314
+ {@const svgAnimLoop = svgEl.animation?.loop ?? false}
1315
+ {@const svgAnimReverse = svgEl.animation?.direction === 'reverse'}
1316
+ <div
1317
+ class="animot-svg-element"
1318
+ use:traceSvgPaths={{
1319
+ enabled: svgAnimMode !== 'none',
1320
+ mode: svgAnimMode as 'none' | 'draw' | 'undraw' | 'draw-undraw' | 'flow',
1321
+ duration: svgAnimDur,
1322
+ loop: svgAnimLoop,
1323
+ reverse: svgAnimReverse,
1324
+ key: `${svgEl.id}-${svgAnimMode}-${svgAnimDur}-${currentSlideIndex}`
1325
+ }}
1326
+ >
1327
+ <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">
1328
+ <g style={svgEl.color ? `fill:${svgEl.color};stroke:${svgEl.color}` : ''}>
1329
+ {@html svgParsed.inner}
1330
+ </g>
1331
+ </svg>
1332
+ </div>
1333
+ {:else if element.type === 'motionPath'}
1334
+ {@const mpEl = element as MotionPathElement}
1335
+ {#if mpEl.showInPresentation}
1336
+ <svg width="100%" height="100%" viewBox="0 0 {animated.width.current} {animated.height.current}" style="position:absolute;top:0;left:0;pointer-events:none;overflow:visible;">
1337
+ <path d={buildPresenterPathD(mpEl.points, mpEl.closed)} stroke={mpEl.pathColor} stroke-width={mpEl.pathWidth} fill="none" stroke-dasharray="8 4" />
1338
+ </svg>
1339
+ {/if}
1340
+ {/if}
1341
+ </div>
1342
+ {/if}
1343
+ {/each}
1344
+ </div>
1345
+ </div>
1346
+
1347
+ {#if arrows}
1348
+ <button class="animot-arrow animot-arrow-left" onclick={() => animateToSlide(currentSlideIndex - 1)} disabled={currentSlideIndex === 0 || isTransitioning} aria-label="Previous slide">
1349
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M15 18l-6-6 6-6"/></svg>
1350
+ </button>
1351
+ <button class="animot-arrow animot-arrow-right" onclick={() => handleNextSlide()} disabled={(!loop && currentSlideIndex === slides.length - 1) || isTransitioning} aria-label="Next slide">
1352
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18l6-6-6-6"/></svg>
1353
+ </button>
1354
+ {/if}
1355
+
1356
+ {#if controls}
1357
+ <div class="animot-controls">
1358
+ <button onclick={() => animateToSlide(currentSlideIndex - 1)} disabled={currentSlideIndex === 0 || isTransitioning} aria-label="Previous">
1359
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>
1360
+ </button>
1361
+ <span class="animot-slide-indicator">{currentSlideIndex + 1} / {slides.length}</span>
1362
+ <button onclick={() => handleNextSlide()} disabled={(!loop && currentSlideIndex === slides.length - 1) || isTransitioning} aria-label="Next">
1363
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
1364
+ </button>
1365
+ <button onclick={() => { isAutoplay = !isAutoplay; if (!isAutoplay) clearAutoplayTimer(); }} class:active={isAutoplay} aria-label={isAutoplay ? 'Pause' : 'Play'}>
1366
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1367
+ {#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}
1368
+ </svg>
1369
+ </button>
1370
+ </div>
1371
+ {/if}
1372
+
1373
+ {#if showProgress}
1374
+ <div class="animot-progress-bar">
1375
+ <div class="animot-progress-fill" style:width="{((currentSlideIndex + 1) / slides.length) * 100}%"></div>
1376
+ </div>
1377
+ {/if}
1378
+ {/if}
1379
+ </div>
1380
+
1381
+ <script module lang="ts">
1382
+ function roundedPolygonPath(pointsStr: string, radius: number): string {
1383
+ const pts = pointsStr.split(/\s+/).map(p => { const [x, y] = p.split(',').map(Number); return { x, y }; });
1384
+ if (pts.length < 3 || radius <= 0) return 'M' + pts.map(p => `${p.x},${p.y}`).join('L') + 'Z';
1385
+ const n = pts.length;
1386
+ const parts: string[] = [];
1387
+ for (let i = 0; i < n; i++) {
1388
+ const prev = pts[(i - 1 + n) % n], curr = pts[i], next = pts[(i + 1) % n];
1389
+ const dx1 = prev.x - curr.x, dy1 = prev.y - curr.y;
1390
+ const dx2 = next.x - curr.x, dy2 = next.y - curr.y;
1391
+ const len1 = Math.sqrt(dx1 * dx1 + dy1 * dy1);
1392
+ const len2 = Math.sqrt(dx2 * dx2 + dy2 * dy2);
1393
+ const r = Math.min(radius, len1 / 2, len2 / 2);
1394
+ const sx = curr.x + (dx1 / len1) * r, sy = curr.y + (dy1 / len1) * r;
1395
+ const ex = curr.x + (dx2 / len2) * r, ey = curr.y + (dy2 / len2) * r;
1396
+ parts.push(i === 0 ? `M${sx},${sy}` : `L${sx},${sy}`);
1397
+ parts.push(`Q${curr.x},${curr.y} ${ex},${ey}`);
1398
+ }
1399
+ parts.push('Z');
1400
+ return parts.join(' ');
1401
+ }
1402
+
1403
+ function renderShape(type: string, w: number, h: number, br: number, fill: string, stroke: string, sw: number, strokeStyle?: string, strokeDashGap?: number): string {
1404
+ let dashAttr = '';
1405
+ if (strokeStyle && strokeStyle !== 'solid') {
1406
+ const s = sw || 1;
1407
+ const gap = strokeDashGap ?? (strokeStyle === 'dashed' ? s * 3 : s * 2);
1408
+ const da = strokeStyle === 'dashed' ? `${s * 3},${gap}` : `${s * 0.1},${gap}`;
1409
+ const lc = strokeStyle === 'dotted' ? 'round' : 'butt';
1410
+ dashAttr = ` stroke-dasharray="${da}" stroke-linecap="${lc}"`;
1411
+ }
1412
+ const polyOrPath = (pts: string) => {
1413
+ if (br > 0) return `<path d="${roundedPolygonPath(pts, br)}" fill="${fill}" stroke="${stroke}" stroke-width="${sw}"${dashAttr}/>`;
1414
+ return `<polygon points="${pts}" fill="${fill}" stroke="${stroke}" stroke-width="${sw}"${dashAttr} stroke-linejoin="round"/>`;
1415
+ };
1416
+ switch (type) {
1417
+ case 'rectangle': return `<rect x="${sw/2}" y="${sw/2}" width="${w-sw}" height="${h-sw}" rx="${br}" ry="${br}" fill="${fill}" stroke="${stroke}" stroke-width="${sw}"${dashAttr}/>`;
1418
+ case 'circle': return `<circle cx="${w/2}" cy="${h/2}" r="${Math.min(w,h)/2-sw/2}" fill="${fill}" stroke="${stroke}" stroke-width="${sw}"${dashAttr}/>`;
1419
+ case 'ellipse': return `<ellipse cx="${w/2}" cy="${h/2}" rx="${w/2-sw/2}" ry="${h/2-sw/2}" fill="${fill}" stroke="${stroke}" stroke-width="${sw}"${dashAttr}/>`;
1420
+ case 'triangle': return polyOrPath(`${w/2},${sw/2} ${sw/2},${h-sw/2} ${w-sw/2},${h-sw/2}`);
1421
+ case 'star': {
1422
+ const cx = w/2, cy = h/2, outerR = Math.min(w,h)/2-sw/2, innerR = outerR*0.4;
1423
+ 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(' ');
1424
+ return polyOrPath(pts);
1425
+ }
1426
+ case 'hexagon': {
1427
+ const cx = w/2, cy = h/2, r = Math.min(w,h)/2-sw/2;
1428
+ 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(' ');
1429
+ return polyOrPath(pts);
1430
+ }
1431
+ default: return '';
1432
+ }
1433
+ }
1434
+ </script>
1435
+
1436
+ <style>
1437
+ /* Universal reset mirrors the animot app's global * reset to prevent
1438
+ host page defaults (margins on p/h1, padding, box-sizing) from leaking in */
1439
+ .animot-presenter :global(*) {
1440
+ margin: 0;
1441
+ padding: 0;
1442
+ box-sizing: border-box;
1443
+ }
1444
+
1445
+ .animot-presenter {
1446
+ position: relative;
1447
+ width: 100%;
1448
+ height: 100%;
1449
+ display: flex;
1450
+ align-items: center;
1451
+ justify-content: center;
1452
+ overflow: hidden;
1453
+ background: transparent;
1454
+ /* Reset inheritable CSS from host page to prevent style leakage */
1455
+ line-height: normal;
1456
+ font-size: 16px;
1457
+ font-weight: 400;
1458
+ font-style: normal;
1459
+ letter-spacing: normal;
1460
+ word-spacing: normal;
1461
+ text-transform: none;
1462
+ text-indent: 0;
1463
+ text-align: left;
1464
+ color: inherit;
1465
+ }
1466
+
1467
+ .animot-canvas-wrapper {
1468
+ display: flex;
1469
+ align-items: center;
1470
+ justify-content: center;
1471
+ }
1472
+
1473
+ .animot-canvas {
1474
+ position: relative;
1475
+ overflow: hidden;
1476
+ }
1477
+
1478
+ .animot-element {
1479
+ position: absolute;
1480
+ box-sizing: border-box;
1481
+ will-change: transform, opacity, left, top, width, height;
1482
+ isolation: isolate;
1483
+ }
1484
+
1485
+ .animot-element.floating {
1486
+ animation-duration: var(--float-speed, 3s);
1487
+ animation-timing-function: ease-in-out;
1488
+ animation-iteration-count: infinite;
1489
+ animation-delay: var(--float-delay, 0s);
1490
+ }
1491
+
1492
+ /* Code */
1493
+ .animot-code-block {
1494
+ width: 100%; height: 100%; overflow: hidden;
1495
+ display: flex; flex-direction: column; box-shadow: 0 8px 32px rgba(0,0,0,0.4);
1496
+ margin: 0; box-sizing: border-box;
1497
+ }
1498
+ .animot-code-block.transparent-bg { background: transparent !important; box-shadow: none; }
1499
+ .animot-code-block.transparent-bg .animot-code-header { background: transparent; border-bottom-color: rgba(255,255,255,0.06); }
1500
+ .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; }
1501
+ .animot-window-controls { display: flex; gap: 8px; align-items: center; flex-shrink: 0; }
1502
+ .macos .animot-control { width: 12px; height: 12px; border-radius: 50%; display: block; }
1503
+ .macos .animot-control.close { background: #ff5f57; }
1504
+ .macos .animot-control.minimize { background: #febc2e; }
1505
+ .macos .animot-control.maximize { background: #28c840; }
1506
+ .windows .animot-window-controls { order: 99; margin-left: auto; gap: 0; }
1507
+ .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); }
1508
+ .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); }
1509
+ .animot-file-icon { flex-shrink: 0; }
1510
+ .animot-filename { color: rgba(255,255,255,0.55); font-size: 12px; line-height: 18px; }
1511
+ .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; }
1512
+ .animot-copy-code-btn:hover { background: rgba(255,255,255,0.12); color: rgba(255,255,255,0.8); }
1513
+ .animot-copy-code-btn svg { width: 14px; height: 14px; flex-shrink: 0; }
1514
+ .animot-copy-code-btn .animot-check-icon { display: none; }
1515
+ .animot-copy-code-btn .animot-copied-label { display: none; }
1516
+ .animot-copy-code-btn.copied .animot-copy-icon { display: none; }
1517
+ .animot-copy-code-btn.copied .animot-copy-label { display: none; }
1518
+ .animot-copy-code-btn.copied .animot-check-icon { display: block; color: #4ade80; }
1519
+ .animot-copy-code-btn.copied .animot-copied-label { display: inline; color: #4ade80; }
1520
+ .animot-copy-code-btn.animot-floating { position: absolute; top: 8px; right: 8px; z-index: 2; }
1521
+ .animot-code-block:hover .animot-copy-code-btn { opacity: 1; }
1522
+ .animot-code-content { flex: 1; overflow: hidden; position: relative; }
1523
+ .animot-highlighted-code { width: 100%; height: 100%; }
1524
+ .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; }
1525
+ .animot-highlighted-code :global(code) { font-family: inherit; font-size: inherit; font-weight: inherit; }
1526
+ .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; }
1527
+
1528
+ /* Text */
1529
+ .animot-text-element { width: 100%; height: 100%; display: flex; align-items: center; white-space: pre-wrap; word-wrap: break-word; }
1530
+ .animot-typewriter-cursor { animation: animot-blink 0.7s infinite; font-weight: 100; }
1531
+ @keyframes animot-blink { 0%, 50% { opacity: 1; } 51%, 100% { opacity: 0; } }
1532
+
1533
+ /* Arrow */
1534
+ .animot-arrow-element { width: 100%; height: 100%; }
1535
+ .arrow-animate-draw .arrow-path { stroke-dashoffset: var(--path-len, 1000); animation: animot-arrow-draw var(--arrow-anim-duration, 500ms) ease-out forwards; }
1536
+ .arrow-animate-undraw .arrow-path { stroke-dashoffset: 0; animation: animot-arrow-undraw var(--arrow-anim-duration, 500ms) ease-out forwards; }
1537
+ .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; }
1538
+ .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); }
1539
+ .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); }
1540
+ .arrow-animate-undraw .arrow-head, .arrow-head-undraw { opacity: 1; animation: animot-arrow-head-disappear var(--arrow-anim-duration, 500ms) ease-out forwards; }
1541
+ .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; }
1542
+ .arrow-animate-grow { transform-origin: left center; animation: animot-arrow-grow var(--arrow-anim-duration, 500ms) ease-out forwards; }
1543
+ /* loop: replay continuously while slide is shown */
1544
+ .arrow-anim-loop .arrow-path, .arrow-anim-loop .arrow-head { animation-iteration-count: infinite !important; }
1545
+ /* reverse: flip start ↔ end */
1546
+ .arrow-anim-reverse .arrow-path, .arrow-anim-reverse .arrow-head { animation-direction: reverse !important; }
1547
+ @keyframes animot-arrow-draw { to { stroke-dashoffset: 0; } }
1548
+ @keyframes animot-arrow-undraw { from { stroke-dashoffset: 0; } to { stroke-dashoffset: var(--path-len, 1000); } }
1549
+ @keyframes animot-arrow-draw-undraw { 0% { stroke-dashoffset: var(--path-len, 1000); } 50% { stroke-dashoffset: 0; } 100% { stroke-dashoffset: var(--path-len, 1000); } }
1550
+ @keyframes animot-arrow-head-appear { from { opacity: 0; } to { opacity: 1; } }
1551
+ @keyframes animot-arrow-head-disappear { 0% { opacity: 1; } 70% { opacity: 1; } 100% { opacity: 0; } }
1552
+ @keyframes animot-arrow-head-draw-undraw { 0% { opacity: 0; } 35% { opacity: 1; } 65% { opacity: 1; } 100% { opacity: 0; } }
1553
+ @keyframes animot-arrow-grow { from { transform: scaleX(0); opacity: 0; } to { transform: scaleX(1); opacity: 1; } }
1554
+
1555
+ /* Image */
1556
+ .animot-image-element { width: 100%; height: 100%; display: block; }
1557
+
1558
+ /* Shape */
1559
+ .animot-shape-element { width: 100%; height: 100%; display: block; overflow: visible; }
1560
+
1561
+ /* Transitions */
1562
+ .animot-canvas { --transition-duration: 500ms; transition: transform calc(var(--transition-duration) * 0.4) ease, opacity calc(var(--transition-duration) * 0.4) ease; }
1563
+ .animot-canvas.transition-fade-out { opacity: 0; }
1564
+ .animot-canvas.transition-fade-in { animation: animot-fadeIn calc(var(--transition-duration) * 0.6) ease forwards; }
1565
+ .animot-canvas.transition-slide-left-out.forward { transform: translateX(-100%); opacity: 0; }
1566
+ .animot-canvas.transition-slide-left-in.forward { animation: animot-slideInFromRight calc(var(--transition-duration) * 0.6) ease forwards; }
1567
+ .animot-canvas.transition-slide-left-out.backward { transform: translateX(100%); opacity: 0; }
1568
+ .animot-canvas.transition-slide-left-in.backward { animation: animot-slideInFromLeft calc(var(--transition-duration) * 0.6) ease forwards; }
1569
+ .animot-canvas.transition-slide-right-out.forward { transform: translateX(100%); opacity: 0; }
1570
+ .animot-canvas.transition-slide-right-in.forward { animation: animot-slideInFromLeft calc(var(--transition-duration) * 0.6) ease forwards; }
1571
+ .animot-canvas.transition-slide-up-out { transform: translateY(-100%); opacity: 0; }
1572
+ .animot-canvas.transition-slide-up-in { animation: animot-slideInFromBottom calc(var(--transition-duration) * 0.6) ease forwards; }
1573
+ .animot-canvas.transition-slide-down-out { transform: translateY(100%); opacity: 0; }
1574
+ .animot-canvas.transition-slide-down-in { animation: animot-slideInFromTop calc(var(--transition-duration) * 0.6) ease forwards; }
1575
+ .animot-canvas.transition-zoom-in-out { transform: scale(0.5); opacity: 0; }
1576
+ .animot-canvas.transition-zoom-in-in { animation: animot-zoomIn calc(var(--transition-duration) * 0.6) ease forwards; }
1577
+ .animot-canvas.transition-zoom-out-out { transform: scale(1.5); opacity: 0; }
1578
+ .animot-canvas.transition-zoom-out-in { animation: animot-zoomOut calc(var(--transition-duration) * 0.6) ease forwards; }
1579
+ .animot-canvas.transition-flip-out { transform: perspective(1000px) rotateY(90deg); opacity: 0; }
1580
+ .animot-canvas.transition-flip-in { animation: animot-flipIn calc(var(--transition-duration) * 0.6) ease forwards; }
1581
+
1582
+ @keyframes animot-fadeIn { from { opacity: 0; } to { opacity: 1; } }
1583
+ @keyframes animot-slideInFromRight { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
1584
+ @keyframes animot-slideInFromLeft { from { transform: translateX(-100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
1585
+ @keyframes animot-slideInFromBottom { from { transform: translateY(100%); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
1586
+ @keyframes animot-slideInFromTop { from { transform: translateY(-100%); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
1587
+ @keyframes animot-zoomIn { from { transform: scale(0.5); opacity: 0; } to { transform: scale(1); opacity: 1; } }
1588
+ @keyframes animot-zoomOut { from { transform: scale(1.5); opacity: 0; } to { transform: scale(1); opacity: 1; } }
1589
+ @keyframes animot-flipIn { from { transform: perspective(1000px) rotateY(-90deg); opacity: 0; } to { transform: perspective(1000px) rotateY(0); opacity: 1; } }
1590
+
1591
+ /* Flip-X transition */
1592
+ .animot-canvas.transition-flip-x-out { transform: perspective(1000px) rotateX(90deg); opacity: 0; }
1593
+ .animot-canvas.transition-flip-x-in { animation: animot-flipXIn calc(var(--transition-duration) * 0.6) ease forwards; }
1594
+ @keyframes animot-flipXIn { from { transform: perspective(1000px) rotateX(-90deg); opacity: 0; } to { transform: perspective(1000px) rotateX(0); opacity: 1; } }
1595
+
1596
+ /* Flip-Y transition */
1597
+ .animot-canvas.transition-flip-y-out { transform: perspective(1000px) rotateY(90deg); opacity: 0; }
1598
+ .animot-canvas.transition-flip-y-in { animation: animot-flipYIn calc(var(--transition-duration) * 0.6) ease forwards; }
1599
+ @keyframes animot-flipYIn { from { transform: perspective(1000px) rotateY(-90deg); opacity: 0; } to { transform: perspective(1000px) rotateY(0); opacity: 1; } }
1600
+
1601
+ /* SVG element */
1602
+ .animot-svg-element { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; }
1603
+ .animot-svg-element :global(svg) { width: 100%; height: 100%; }
1604
+
1605
+ /* Controls */
1606
+ .animot-controls {
1607
+ position: absolute; bottom: 12px; left: 50%; transform: translateX(-50%);
1608
+ display: flex; align-items: center; gap: 8px; padding: 8px 16px;
1609
+ background: rgba(0,0,0,0.7); backdrop-filter: blur(10px); border-radius: 10px;
1610
+ opacity: 0; transition: opacity 0.3s ease 0.15s; z-index: 100;
1611
+ }
1612
+ .animot-presenter:hover .animot-controls, .animot-menu-visible .animot-controls { opacity: 1; transition-delay: 0s; }
1613
+ .animot-controls button {
1614
+ display: flex; align-items: center; justify-content: center;
1615
+ width: 32px; height: 32px; border-radius: 6px; border: none; cursor: pointer;
1616
+ background: rgba(255,255,255,0.1); color: white; transition: background 0.2s;
1617
+ }
1618
+ .animot-controls button:hover:not(:disabled) { background: rgba(255,255,255,0.2); }
1619
+ .animot-controls button:disabled { opacity: 0.3; cursor: not-allowed; }
1620
+ .animot-controls button.active { background: rgba(99,102,241,0.6); }
1621
+ .animot-controls button svg { width: 16px; height: 16px; }
1622
+ .animot-slide-indicator { font-size: 12px; color: white; min-width: 50px; text-align: center; font-family: system-ui, sans-serif; }
1623
+
1624
+ /* Arrows */
1625
+ .animot-arrow {
1626
+ position: absolute; top: 50%; transform: translateY(-50%);
1627
+ width: 40px; height: 40px; border-radius: 50%; border: none; cursor: pointer;
1628
+ background: rgba(0,0,0,0.5); color: white; display: flex; align-items: center; justify-content: center;
1629
+ opacity: 0; transition: opacity 0.3s 0.15s; z-index: 100;
1630
+ /* Extra padding extends the hover hit area beyond the visible button */
1631
+ padding: 0; margin: 0;
1632
+ }
1633
+ .animot-presenter:hover .animot-arrow { opacity: 1; transition-delay: 0s; }
1634
+ .animot-presenter:hover .animot-arrow:disabled { opacity: 0.3; cursor: not-allowed; }
1635
+ .animot-arrow:hover:not(:disabled) { background: rgba(0,0,0,0.7); }
1636
+ .animot-arrow svg { width: 20px; height: 20px; }
1637
+ .animot-arrow-left { left: 8px; }
1638
+ .animot-arrow-right { right: 8px; }
1639
+
1640
+ /* Progress bar */
1641
+ .animot-progress-bar {
1642
+ position: absolute; bottom: 0; left: 0; right: 0; height: 3px;
1643
+ background: rgba(255,255,255,0.1); z-index: 100;
1644
+ opacity: 0; transition: opacity 0.3s 0.15s;
1645
+ }
1646
+ .animot-presenter:hover .animot-progress-bar { opacity: 1; transition-delay: 0s; }
1647
+ .animot-progress-fill { height: 100%; background: linear-gradient(135deg, #7c3aed, #ec4899); transition: width 0.6s ease; }
1648
+
1649
+ /* Loading / Error */
1650
+ .animot-loading { display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; }
1651
+ .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; }
1652
+ @keyframes animot-spin { to { transform: rotate(360deg); } }
1653
+ .animot-error { color: #ef4444; padding: 20px; text-align: center; font-family: system-ui, sans-serif; }
1654
+ </style>