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