animot-presenter 0.5.5 → 0.5.7

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.
@@ -15,13 +15,22 @@ export declare function computeFloatSpeed(cfg: {
15
15
  speed: number;
16
16
  speedRandomness?: number;
17
17
  }, seed: string): number;
18
- export declare function getBackgroundStyle(bg: {
18
+ interface GradientStop {
19
+ color: string;
20
+ position: number;
21
+ }
22
+ interface BgInput {
19
23
  type: string;
20
24
  color?: string;
21
25
  gradient?: {
22
26
  type: string;
23
27
  angle?: number;
24
28
  colors: string[];
29
+ stops?: GradientStop[];
30
+ radialShape?: 'circle' | 'ellipse';
31
+ radialPosition?: string;
25
32
  };
26
33
  image?: string;
27
- }): string;
34
+ }
35
+ export declare function getBackgroundStyle(bg: BgInput): string;
36
+ export {};
@@ -73,16 +73,28 @@ export function computeFloatSpeed(cfg, seed) {
73
73
  return cfg.speed;
74
74
  return cfg.speed * (1 - r + r * hashFraction(seed, 2));
75
75
  }
76
+ function buildStops(colors, stops) {
77
+ if (stops && stops.length > 0) {
78
+ const sorted = [...stops].sort((a, b) => a.position - b.position);
79
+ return sorted.map((s) => `${s.color} ${s.position}%`).join(', ');
80
+ }
81
+ if (colors.length <= 1)
82
+ return colors[0] ?? '#000';
83
+ return colors.map((c, i) => `${c} ${(i / (colors.length - 1)) * 100}%`).join(', ');
84
+ }
76
85
  export function getBackgroundStyle(bg) {
77
86
  if (bg.type === 'transparent')
78
87
  return 'background: transparent';
79
88
  if (bg.type === 'solid')
80
89
  return `background-color: ${bg.color ?? 'transparent'}`;
81
90
  if (bg.type === 'gradient' && bg.gradient) {
82
- const { type, angle = 135, colors } = bg.gradient;
83
- if (type === 'linear')
84
- return `background: linear-gradient(${angle}deg, ${colors.join(', ')})`;
85
- return `background: radial-gradient(circle, ${colors.join(', ')})`;
91
+ const { type, angle = 135, colors, stops, radialShape = 'circle', radialPosition = 'center' } = bg.gradient;
92
+ const stopList = buildStops(colors, stops);
93
+ if (type === 'conic')
94
+ return `background: conic-gradient(from ${angle}deg at ${radialPosition}, ${stopList})`;
95
+ if (type === 'radial')
96
+ return `background: radial-gradient(${radialShape} at ${radialPosition}, ${stopList})`;
97
+ return `background: linear-gradient(${angle}deg, ${stopList})`;
86
98
  }
87
99
  if (bg.type === 'image' && bg.image)
88
100
  return `background-image: url(${bg.image}); background-size: cover; background-position: center`;
package/dist/types.d.ts CHANGED
@@ -71,6 +71,8 @@ export interface BaseElement {
71
71
  backfaceVisibility?: 'visible' | 'hidden';
72
72
  animationConfig?: ElementAnimationConfig;
73
73
  floatingAnimation?: FloatingAnimationConfig;
74
+ decorations?: DecorationsConfig;
75
+ depth?: number;
74
76
  motionPathId?: string;
75
77
  motionPathConfig?: MotionPathConfig;
76
78
  blur?: number;
@@ -107,10 +109,13 @@ export interface CodeElement extends BaseElement {
107
109
  headerRadius?: number;
108
110
  tabRadius?: number;
109
111
  }
110
- export type TextAnimationMode = 'instant' | 'typewriter' | 'fade-words';
112
+ export type TextAnimationMode = 'instant' | 'typewriter' | 'fade-words' | 'fade-letters' | 'handwriting' | 'bounce-in';
111
113
  export interface TextAnimationConfig {
112
114
  mode: TextAnimationMode;
113
115
  typewriterSpeed: number;
116
+ duration?: number;
117
+ stagger?: number;
118
+ loop?: boolean;
114
119
  }
115
120
  export interface TextElement extends BaseElement {
116
121
  type: 'text';
@@ -198,6 +203,55 @@ export interface ImageElement extends BaseElement {
198
203
  borderRadius?: number;
199
204
  };
200
205
  }
206
+ export interface CameraViewport {
207
+ x: number;
208
+ y: number;
209
+ width: number;
210
+ height: number;
211
+ rotation?: number;
212
+ }
213
+ export interface DecorationsConfig {
214
+ glow?: {
215
+ enabled: boolean;
216
+ color: string;
217
+ intensity?: number;
218
+ speedMs?: number;
219
+ };
220
+ shimmer?: {
221
+ enabled: boolean;
222
+ color?: string;
223
+ angle?: number;
224
+ speedMs?: number;
225
+ widthPct?: number;
226
+ };
227
+ gradientShift?: {
228
+ enabled: boolean;
229
+ colors?: string[];
230
+ speedMs?: number;
231
+ angle?: number;
232
+ };
233
+ rgbSplit?: {
234
+ enabled: boolean;
235
+ offset?: number;
236
+ speedMs?: number;
237
+ };
238
+ }
239
+ export interface VideoElement extends BaseElement {
240
+ type: 'video';
241
+ src: string;
242
+ posterImage?: string;
243
+ startTime?: number;
244
+ endTime?: number;
245
+ volume: number;
246
+ muted: boolean;
247
+ autoplay: boolean;
248
+ loop: boolean;
249
+ playbackRate: number;
250
+ objectFit: 'cover' | 'contain' | 'fill';
251
+ borderRadius: number;
252
+ opacity: number;
253
+ showControls?: boolean;
254
+ }
201
255
  export type StrokeStyle = 'solid' | 'dashed' | 'dotted';
202
256
  export interface ShapeElement extends BaseElement {
203
257
  type: 'shape';
@@ -290,7 +344,7 @@ export interface MotionPathElement extends BaseElement {
290
344
  pathWidth: number;
291
345
  showInPresentation: boolean;
292
346
  }
293
- export type CanvasElement = CodeElement | TextElement | ArrowElement | ImageElement | ShapeElement | CounterElement | ChartElement | IconElement | SvgElement | MotionPathElement;
347
+ export type CanvasElement = CodeElement | TextElement | ArrowElement | ImageElement | VideoElement | ShapeElement | CounterElement | ChartElement | IconElement | SvgElement | MotionPathElement;
294
348
  export type ParticleShape = 'circle' | 'square' | 'star' | 'triangle';
295
349
  export interface ParticlesConfig {
296
350
  enabled: boolean;
@@ -316,13 +370,20 @@ export interface ConfettiConfig {
316
370
  startVelocity: number;
317
371
  scalar: number;
318
372
  }
373
+ export interface GradientStop {
374
+ color: string;
375
+ position: number;
376
+ }
319
377
  export interface CanvasBackground {
320
378
  type: 'solid' | 'gradient' | 'image' | 'transparent';
321
379
  color?: string;
322
380
  gradient?: {
323
- type: 'linear' | 'radial';
381
+ type: 'linear' | 'radial' | 'conic';
324
382
  angle?: number;
325
383
  colors: string[];
384
+ stops?: GradientStop[];
385
+ radialShape?: 'circle' | 'ellipse';
386
+ radialPosition?: string;
326
387
  };
327
388
  image?: string;
328
389
  particles?: ParticlesConfig;
@@ -346,6 +407,7 @@ export interface Slide {
346
407
  canvas: SlideCanvas;
347
408
  transition: TransitionConfig;
348
409
  duration: number;
410
+ camera?: CameraViewport;
349
411
  }
350
412
  export interface ProjectSettings {
351
413
  defaultCanvasWidth: number;
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Decode an animated image (GIF / APNG / animated WebP / animated AVIF) into
3
+ * a list of frames with cumulative timing using the browser's ImageDecoder
4
+ * API. Used by the server-side export pipeline so that animated <img> elements
5
+ * can be rendered frame-accurately under our virtual clock — native <img>
6
+ * playback ignores virtual time, so without this every captured frame would
7
+ * show the GIF stuck at frame 0.
8
+ *
9
+ * Live `/present` keeps using <img> directly; only the export pipeline opts
10
+ * into the canvas-from-decoded-frames path.
11
+ */
12
+ export interface DecodedFrame {
13
+ bitmap: ImageBitmap;
14
+ /** Frame display duration in ms. */
15
+ durationMs: number;
16
+ }
17
+ export interface DecodedAnimatedImage {
18
+ frames: DecodedFrame[];
19
+ totalMs: number;
20
+ width: number;
21
+ height: number;
22
+ }
23
+ /** True if the URL/data-URL looks like an animated container we can decode.
24
+ * We don't try to detect "animated PNG" vs "static PNG" upfront — the decoder
25
+ * call below will return one frame for static images and that's fine. */
26
+ export declare function isLikelyAnimated(src: string): boolean;
27
+ /** Decode every frame of an animated image. Falls back to a single-frame
28
+ * result for static images. Returns null when ImageDecoder is unavailable
29
+ * (e.g. older browsers) so callers can degrade gracefully. */
30
+ export declare function decodeAnimatedImage(src: string): Promise<DecodedAnimatedImage | null>;
31
+ /** Pick the frame that should be visible at `elapsedMs` for a looping animation
32
+ * of total `totalMs`. Returns the index. */
33
+ export declare function frameIndexAt(decoded: DecodedAnimatedImage, elapsedMs: number): number;
34
+ /** Draw the frame visible at `elapsedMs` into the given canvas. */
35
+ export declare function drawFrameAt(canvas: HTMLCanvasElement, decoded: DecodedAnimatedImage, elapsedMs: number): void;
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Decode an animated image (GIF / APNG / animated WebP / animated AVIF) into
3
+ * a list of frames with cumulative timing using the browser's ImageDecoder
4
+ * API. Used by the server-side export pipeline so that animated <img> elements
5
+ * can be rendered frame-accurately under our virtual clock — native <img>
6
+ * playback ignores virtual time, so without this every captured frame would
7
+ * show the GIF stuck at frame 0.
8
+ *
9
+ * Live `/present` keeps using <img> directly; only the export pipeline opts
10
+ * into the canvas-from-decoded-frames path.
11
+ */
12
+ /** True if the URL/data-URL looks like an animated container we can decode.
13
+ * We don't try to detect "animated PNG" vs "static PNG" upfront — the decoder
14
+ * call below will return one frame for static images and that's fine. */
15
+ export function isLikelyAnimated(src) {
16
+ if (!src)
17
+ return false;
18
+ // Data URL — sniff the MIME.
19
+ if (src.startsWith('data:')) {
20
+ const mime = src.slice(5).split(';')[0].toLowerCase();
21
+ return mime === 'image/gif' || mime === 'image/apng' || mime === 'image/webp' || mime === 'image/avif' || mime === 'image/png';
22
+ }
23
+ // Otherwise look at the path extension.
24
+ const lower = src.toLowerCase().split('?')[0];
25
+ return lower.endsWith('.gif') || lower.endsWith('.apng') || lower.endsWith('.webp') || lower.endsWith('.avif');
26
+ }
27
+ async function srcToBlob(src) {
28
+ const res = await fetch(src);
29
+ return await res.blob();
30
+ }
31
+ /** Decode every frame of an animated image. Falls back to a single-frame
32
+ * result for static images. Returns null when ImageDecoder is unavailable
33
+ * (e.g. older browsers) so callers can degrade gracefully. */
34
+ export async function decodeAnimatedImage(src) {
35
+ if (typeof globalThis.ImageDecoder === 'undefined')
36
+ return null;
37
+ try {
38
+ const blob = await srcToBlob(src);
39
+ const decoder = new globalThis.ImageDecoder({ data: blob.stream(), type: blob.type || 'image/gif' });
40
+ await decoder.tracks.ready;
41
+ await decoder.completed;
42
+ const frameCount = decoder.tracks.selectedTrack?.frameCount ?? 1;
43
+ const frames = [];
44
+ let totalMs = 0;
45
+ let width = 0;
46
+ let height = 0;
47
+ for (let i = 0; i < frameCount; i++) {
48
+ const result = await decoder.decode({ frameIndex: i });
49
+ const vf = result.image;
50
+ width = vf.displayWidth || vf.codedWidth || width;
51
+ height = vf.displayHeight || vf.codedHeight || height;
52
+ // duration is in microseconds when present; some decoders return null
53
+ // for the last frame — fall back to a sensible 100ms (~10fps) so we
54
+ // don't divide by zero when seeking.
55
+ const durMs = vf.duration != null ? vf.duration / 1000 : 100;
56
+ const bitmap = await createImageBitmap(vf);
57
+ vf.close?.();
58
+ frames.push({ bitmap, durationMs: durMs });
59
+ totalMs += durMs;
60
+ }
61
+ decoder.close?.();
62
+ return { frames, totalMs, width, height };
63
+ }
64
+ catch (err) {
65
+ console.warn('Failed to decode animated image:', err);
66
+ return null;
67
+ }
68
+ }
69
+ /** Pick the frame that should be visible at `elapsedMs` for a looping animation
70
+ * of total `totalMs`. Returns the index. */
71
+ export function frameIndexAt(decoded, elapsedMs) {
72
+ if (decoded.totalMs <= 0 || decoded.frames.length === 0)
73
+ return 0;
74
+ const t = ((elapsedMs % decoded.totalMs) + decoded.totalMs) % decoded.totalMs;
75
+ let acc = 0;
76
+ for (let i = 0; i < decoded.frames.length; i++) {
77
+ acc += decoded.frames[i].durationMs;
78
+ if (t < acc)
79
+ return i;
80
+ }
81
+ return decoded.frames.length - 1;
82
+ }
83
+ /** Draw the frame visible at `elapsedMs` into the given canvas. */
84
+ export function drawFrameAt(canvas, decoded, elapsedMs) {
85
+ const idx = frameIndexAt(decoded, elapsedMs);
86
+ const frame = decoded.frames[idx];
87
+ if (!frame)
88
+ return;
89
+ // Resize the canvas backing-store to the natural frame size only once;
90
+ // the wrapping CSS scales it to the element box.
91
+ if (canvas.width !== decoded.width)
92
+ canvas.width = decoded.width;
93
+ if (canvas.height !== decoded.height)
94
+ canvas.height = decoded.height;
95
+ const ctx = canvas.getContext('2d');
96
+ if (!ctx)
97
+ return;
98
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
99
+ ctx.drawImage(frame.bitmap, 0, 0);
100
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Cinema-mode camera math.
3
+ *
4
+ * Concept: in cinema mode every slide shares one large "world canvas" (defined
5
+ * by Project.settings.worldWidth/Height). Each slide has a `camera` viewport —
6
+ * a rect in world coordinates that we want to fill the visible canvas. The
7
+ * transition between two slides = animating the camera (pan + zoom + roll).
8
+ *
9
+ * To render: we wrap all elements in a transform that maps the camera rect to
10
+ * the visible canvas dimensions. Element positions stay in world coordinates;
11
+ * only the wrapper transforms.
12
+ *
13
+ * Parallax: each element can declare a `depth` in [-1, +1]. Elements behind
14
+ * the camera (negative depth) appear to move less; elements in front move
15
+ * more. Implemented as an additional translate proportional to camera offset.
16
+ */
17
+ import type { CameraViewport } from '../types';
18
+ export interface CameraTransformInput {
19
+ camera: CameraViewport;
20
+ /** Output viewport — typically the slide canvas dims. */
21
+ viewportWidth: number;
22
+ viewportHeight: number;
23
+ }
24
+ /** Build the CSS transform that maps a camera rect over the world to fill the
25
+ * given output viewport. Returned as a single transform string ready for
26
+ * `style:transform="..."`. */
27
+ export declare function cameraTransform({ camera, viewportWidth, viewportHeight }: CameraTransformInput): string;
28
+ /** Lerp between two camera viewports for transitions. */
29
+ export declare function lerpCamera(a: CameraViewport, b: CameraViewport, t: number): CameraViewport;
30
+ /** Default camera = full world (no zoom, no pan). Used when a slide is in
31
+ * cinema mode but its camera is unset. */
32
+ export declare function defaultCamera(worldWidth: number, worldHeight: number): CameraViewport;
33
+ /** Per-element parallax offset. Elements with depth = 0 move 1:1 with the
34
+ * camera; depth > 0 moves more (closer); depth < 0 moves less (farther). The
35
+ * factor maps depth → multiplier such that depth=1 doubles motion, depth=-1
36
+ * halves it. We compute the offset from the *delta between the camera's
37
+ * current position and a neutral world center* — gives a plausible parallax
38
+ * without needing a separate reference camera per element.
39
+ */
40
+ export declare function parallaxOffset(camera: CameraViewport, depth: number, worldWidth: number, worldHeight: number): {
41
+ x: number;
42
+ y: number;
43
+ };
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Cinema-mode camera math.
3
+ *
4
+ * Concept: in cinema mode every slide shares one large "world canvas" (defined
5
+ * by Project.settings.worldWidth/Height). Each slide has a `camera` viewport —
6
+ * a rect in world coordinates that we want to fill the visible canvas. The
7
+ * transition between two slides = animating the camera (pan + zoom + roll).
8
+ *
9
+ * To render: we wrap all elements in a transform that maps the camera rect to
10
+ * the visible canvas dimensions. Element positions stay in world coordinates;
11
+ * only the wrapper transforms.
12
+ *
13
+ * Parallax: each element can declare a `depth` in [-1, +1]. Elements behind
14
+ * the camera (negative depth) appear to move less; elements in front move
15
+ * more. Implemented as an additional translate proportional to camera offset.
16
+ */
17
+ /** Build the CSS transform that maps a camera rect over the world to fill the
18
+ * given output viewport. Returned as a single transform string ready for
19
+ * `style:transform="..."`. */
20
+ export function cameraTransform({ camera, viewportWidth, viewportHeight }) {
21
+ const sx = viewportWidth / Math.max(1, camera.width);
22
+ const sy = viewportHeight / Math.max(1, camera.height);
23
+ // Uniform scale so circles stay circles. We pick the smaller axis so the
24
+ // camera rect fully fits the viewport (letterboxes the rest); use Math.max
25
+ // instead if "cover" semantics are wanted.
26
+ const s = Math.min(sx, sy);
27
+ const rotation = camera.rotation ?? 0;
28
+ // Order: translate world → camera origin, then scale, then rotate. Reads
29
+ // right-to-left in CSS so we write it scale → translate.
30
+ return `scale(${s}) rotate(${-rotation}deg) translate(${-camera.x}px, ${-camera.y}px)`;
31
+ }
32
+ /** Lerp between two camera viewports for transitions. */
33
+ export function lerpCamera(a, b, t) {
34
+ const lerp = (x, y) => x + (y - x) * t;
35
+ return {
36
+ x: lerp(a.x, b.x),
37
+ y: lerp(a.y, b.y),
38
+ width: lerp(a.width, b.width),
39
+ height: lerp(a.height, b.height),
40
+ rotation: lerp(a.rotation ?? 0, b.rotation ?? 0)
41
+ };
42
+ }
43
+ /** Default camera = full world (no zoom, no pan). Used when a slide is in
44
+ * cinema mode but its camera is unset. */
45
+ export function defaultCamera(worldWidth, worldHeight) {
46
+ return { x: 0, y: 0, width: worldWidth, height: worldHeight, rotation: 0 };
47
+ }
48
+ /** Per-element parallax offset. Elements with depth = 0 move 1:1 with the
49
+ * camera; depth > 0 moves more (closer); depth < 0 moves less (farther). The
50
+ * factor maps depth → multiplier such that depth=1 doubles motion, depth=-1
51
+ * halves it. We compute the offset from the *delta between the camera's
52
+ * current position and a neutral world center* — gives a plausible parallax
53
+ * without needing a separate reference camera per element.
54
+ */
55
+ export function parallaxOffset(camera, depth, worldWidth, worldHeight) {
56
+ if (!depth)
57
+ return { x: 0, y: 0 };
58
+ const cx = camera.x + camera.width / 2;
59
+ const cy = camera.y + camera.height / 2;
60
+ const wx = worldWidth / 2;
61
+ const wy = worldHeight / 2;
62
+ // Tame the multiplier — full ±1 doubling is too much in practice; use 0.4
63
+ // so depth = +1 means 40% extra parallax, depth = -1 means 40% damped.
64
+ const factor = depth * 0.4;
65
+ return { x: -(cx - wx) * factor, y: -(cy - wy) * factor };
66
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Svelte action: applies ambient decorative animations to an element. These
3
+ * sit on top of the morph/transition system — they're continuous loops driven
4
+ * by the same virtual-clock restart registry (__svgAnimRestart) so they reset
5
+ * cleanly on slide enter and align in Flow-mode GIF exports.
6
+ *
7
+ * Effects:
8
+ * • glow — pulsing colored box-shadow halo
9
+ * • shimmer — diagonal highlight stripe sweeping across the element
10
+ * • gradientShift — animates background-position on a multi-color gradient
11
+ * • rgbSplit — chromatic-aberration-style R/B channel offset (drop-shadow)
12
+ */
13
+ import type { DecorationsConfig } from '../types';
14
+ export interface DecorationsParams {
15
+ config?: DecorationsConfig;
16
+ /** Slide loop duration; when present, each effect's cycle is rounded so an
17
+ * integer number fits in slide_duration — guarantees seamless GIF loop. */
18
+ slideDuration?: number;
19
+ /** Bumped by the host when ANY decoration prop changes. Without it the
20
+ * action would silently keep running with stale params. */
21
+ key?: unknown;
22
+ }
23
+ export declare function decorations(node: HTMLElement, params: DecorationsParams): {
24
+ update(p: DecorationsParams): void;
25
+ destroy(): void;
26
+ };
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Svelte action: applies ambient decorative animations to an element. These
3
+ * sit on top of the morph/transition system — they're continuous loops driven
4
+ * by the same virtual-clock restart registry (__svgAnimRestart) so they reset
5
+ * cleanly on slide enter and align in Flow-mode GIF exports.
6
+ *
7
+ * Effects:
8
+ * • glow — pulsing colored box-shadow halo
9
+ * • shimmer — diagonal highlight stripe sweeping across the element
10
+ * • gradientShift — animates background-position on a multi-color gradient
11
+ * • rgbSplit — chromatic-aberration-style R/B channel offset (drop-shadow)
12
+ */
13
+ function effectiveCycle(requested, slideDuration) {
14
+ if (slideDuration && slideDuration > 0) {
15
+ return slideDuration / Math.max(1, Math.round(slideDuration / requested));
16
+ }
17
+ return requested;
18
+ }
19
+ export function decorations(node, params) {
20
+ let raf = 0;
21
+ let shimmerEl = null;
22
+ let originalBoxShadow = '';
23
+ let originalFilter = '';
24
+ let originalBackgroundImage = '';
25
+ let originalBackgroundSize = '';
26
+ let originalBackgroundPosition = '';
27
+ let savedOriginal = false;
28
+ function saveOriginalStyles() {
29
+ if (savedOriginal)
30
+ return;
31
+ originalBoxShadow = node.style.boxShadow;
32
+ originalFilter = node.style.filter;
33
+ originalBackgroundImage = node.style.backgroundImage;
34
+ originalBackgroundSize = node.style.backgroundSize;
35
+ originalBackgroundPosition = node.style.backgroundPosition;
36
+ savedOriginal = true;
37
+ }
38
+ function restoreOriginalStyles() {
39
+ node.style.boxShadow = originalBoxShadow;
40
+ node.style.filter = originalFilter;
41
+ node.style.backgroundImage = originalBackgroundImage;
42
+ node.style.backgroundSize = originalBackgroundSize;
43
+ node.style.backgroundPosition = originalBackgroundPosition;
44
+ if (shimmerEl) {
45
+ shimmerEl.remove();
46
+ shimmerEl = null;
47
+ }
48
+ }
49
+ function reset() {
50
+ if (raf)
51
+ cancelAnimationFrame(raf);
52
+ raf = 0;
53
+ restoreOriginalStyles();
54
+ }
55
+ function run() {
56
+ reset();
57
+ const cfg = params.config;
58
+ if (!cfg)
59
+ return;
60
+ const anyEnabled = cfg.glow?.enabled || cfg.shimmer?.enabled || cfg.gradientShift?.enabled || cfg.rgbSplit?.enabled;
61
+ if (!anyEnabled)
62
+ return;
63
+ saveOriginalStyles();
64
+ // Shimmer needs a positioned overlay — we inject it as a child. The
65
+ // element must already be `position: relative` (or non-static) to clip
66
+ // the overlay correctly; we coerce it here without mutating user state
67
+ // permanently (restoreOriginalStyles puts back any inline style).
68
+ if (cfg.shimmer?.enabled) {
69
+ if (getComputedStyle(node).position === 'static') {
70
+ node.style.position = 'relative';
71
+ }
72
+ shimmerEl = document.createElement('div');
73
+ shimmerEl.className = 'animot-shimmer';
74
+ const angle = cfg.shimmer.angle ?? 110;
75
+ const widthPct = cfg.shimmer.widthPct ?? 25;
76
+ const color = cfg.shimmer.color ?? 'rgba(255, 255, 255, 0.4)';
77
+ Object.assign(shimmerEl.style, {
78
+ position: 'absolute',
79
+ inset: '0',
80
+ pointerEvents: 'none',
81
+ overflow: 'hidden',
82
+ borderRadius: 'inherit',
83
+ background: `linear-gradient(${angle}deg, transparent 0%, transparent ${50 - widthPct / 2}%, ${color} 50%, transparent ${50 + widthPct / 2}%, transparent 100%)`,
84
+ backgroundSize: '300% 300%',
85
+ backgroundPosition: '-100% -100%'
86
+ });
87
+ node.appendChild(shimmerEl);
88
+ }
89
+ // Initial styles for the static parts of each effect.
90
+ if (cfg.gradientShift?.enabled) {
91
+ const colors = cfg.gradientShift.colors ?? ['#7c3aed', '#06b6d4', '#ec4899', '#7c3aed'];
92
+ const angle = cfg.gradientShift.angle ?? 135;
93
+ node.style.backgroundImage = `linear-gradient(${angle}deg, ${colors.join(', ')})`;
94
+ node.style.backgroundSize = '300% 300%';
95
+ }
96
+ const start = performance.now();
97
+ function tick(now) {
98
+ const elapsed = now - start;
99
+ // GLOW — pulsing box-shadow.
100
+ if (cfg.glow?.enabled) {
101
+ const cycle = effectiveCycle(cfg.glow.speedMs ?? 2400, params.slideDuration);
102
+ const phase = (elapsed % cycle) / cycle; // 0–1
103
+ const wave = (Math.sin(phase * Math.PI * 2) + 1) / 2; // 0–1
104
+ const intensity = cfg.glow.intensity ?? 0.6;
105
+ const blur = 12 + wave * 36 * intensity;
106
+ const spread = 2 + wave * 8 * intensity;
107
+ node.style.boxShadow = `0 0 ${blur}px ${spread}px ${cfg.glow.color}`;
108
+ }
109
+ // SHIMMER — sweep the gradient overlay diagonally across the element.
110
+ if (cfg.shimmer?.enabled && shimmerEl) {
111
+ const cycle = effectiveCycle(cfg.shimmer.speedMs ?? 3000, params.slideDuration);
112
+ const phase = (elapsed % cycle) / cycle;
113
+ // Sweep -100% → 200% so the highlight enters from the top-left, leaves bottom-right.
114
+ const pos = -100 + phase * 300;
115
+ shimmerEl.style.backgroundPosition = `${pos}% ${pos}%`;
116
+ }
117
+ // GRADIENT SHIFT — animate background-position on the multi-stop gradient.
118
+ if (cfg.gradientShift?.enabled) {
119
+ const cycle = effectiveCycle(cfg.gradientShift.speedMs ?? 6000, params.slideDuration);
120
+ const phase = (elapsed % cycle) / cycle;
121
+ const pos = phase * 300; // 0% → 300% creates a smooth loop with backgroundSize 300%
122
+ node.style.backgroundPosition = `${pos}% 50%`;
123
+ }
124
+ // RGB SPLIT — chromatic aberration via stacked drop-shadows.
125
+ if (cfg.rgbSplit?.enabled) {
126
+ const offset = cfg.rgbSplit.offset ?? 3;
127
+ let dx = offset;
128
+ if ((cfg.rgbSplit.speedMs ?? 0) > 0) {
129
+ const cycle = effectiveCycle(cfg.rgbSplit.speedMs, params.slideDuration);
130
+ const phase = (elapsed % cycle) / cycle;
131
+ dx = offset * Math.sin(phase * Math.PI * 2);
132
+ }
133
+ node.style.filter = `${originalFilter} drop-shadow(${dx}px 0 0 rgba(255, 0, 64, 0.7)) drop-shadow(${-dx}px 0 0 rgba(0, 200, 255, 0.7))`;
134
+ }
135
+ raf = requestAnimationFrame(tick);
136
+ }
137
+ raf = requestAnimationFrame(tick);
138
+ }
139
+ queueMicrotask(run);
140
+ if (typeof window !== 'undefined') {
141
+ const reg = (window.__svgAnimRestart ||= []);
142
+ reg.push(run);
143
+ node.__decorationsRestart = run;
144
+ }
145
+ let lastKey = params.key;
146
+ return {
147
+ update(p) {
148
+ const keyChanged = p.key !== lastKey;
149
+ params = p;
150
+ if (keyChanged) {
151
+ lastKey = p.key;
152
+ queueMicrotask(run);
153
+ }
154
+ },
155
+ destroy() {
156
+ reset();
157
+ if (typeof window !== 'undefined') {
158
+ const reg = window.__svgAnimRestart;
159
+ const fn = node.__decorationsRestart;
160
+ if (reg && fn) {
161
+ const idx = reg.indexOf(fn);
162
+ if (idx >= 0)
163
+ reg.splice(idx, 1);
164
+ }
165
+ }
166
+ }
167
+ };
168
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Svelte action: animates a text element's content via JS RAF.
3
+ *
4
+ * Implemented modes:
5
+ * • fade-letters — wraps each character in a <span> and fades them in with stagger
6
+ * • bounce-in — same wrapping, scale+translate pop per character
7
+ * • handwriting — re-renders the text inside an <svg><text> with stroke + transparent
8
+ * fill and animates `stroke-dashoffset` so the text appears to be
9
+ * drawn left-to-right (works best with cursive/script fonts)
10
+ *
11
+ * Other modes (instant, typewriter, fade-words) are handled elsewhere by the
12
+ * /present render path — this action no-ops for those.
13
+ *
14
+ * The action registers a restart fn into `window.__svgAnimRestart` so the
15
+ * server-side video export pipeline can reset all animations under the
16
+ * virtual clock at the start of the slide hold (same pattern as FlowMarkers
17
+ * and arrowClipDraw).
18
+ */
19
+ export type TextAnimateMode = 'instant' | 'typewriter' | 'fade-words' | 'fade-letters' | 'handwriting' | 'bounce-in';
20
+ export interface TextAnimateParams {
21
+ enabled: boolean;
22
+ mode: TextAnimateMode;
23
+ content: string;
24
+ /** Total duration in ms for handwriting; for stagger modes it's the per-letter
25
+ * delay budget — actual total = stagger * letterCount + perLetterDuration. */
26
+ duration: number;
27
+ stagger?: number;
28
+ loop?: boolean;
29
+ /** Cosmetic: applied to the inner SVG text in handwriting mode. */
30
+ color?: string;
31
+ fontSize?: number;
32
+ fontFamily?: string;
33
+ fontWeight?: number | string;
34
+ fontStyle?: string;
35
+ textAlign?: 'left' | 'center' | 'right';
36
+ /** Slide loop duration; when present the effective duration aligns to a
37
+ * cycle that fits an integer number of times into slide_duration so GIF
38
+ * loops are seamless in Flow mode. */
39
+ slideDuration?: number;
40
+ /** Bumped by the host component when ANY meaningful prop changes — without
41
+ * this the action would silently keep running with stale values. */
42
+ key?: unknown;
43
+ }
44
+ export declare function textAnimate(node: HTMLElement, params: TextAnimateParams): {
45
+ update(p: TextAnimateParams): void;
46
+ destroy(): void;
47
+ };