animot-presenter 0.5.5 → 0.5.6

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
@@ -107,10 +107,13 @@ export interface CodeElement extends BaseElement {
107
107
  headerRadius?: number;
108
108
  tabRadius?: number;
109
109
  }
110
- export type TextAnimationMode = 'instant' | 'typewriter' | 'fade-words';
110
+ export type TextAnimationMode = 'instant' | 'typewriter' | 'fade-words' | 'fade-letters' | 'handwriting' | 'bounce-in';
111
111
  export interface TextAnimationConfig {
112
112
  mode: TextAnimationMode;
113
113
  typewriterSpeed: number;
114
+ duration?: number;
115
+ stagger?: number;
116
+ loop?: boolean;
114
117
  }
115
118
  export interface TextElement extends BaseElement {
116
119
  type: 'text';
@@ -316,13 +319,20 @@ export interface ConfettiConfig {
316
319
  startVelocity: number;
317
320
  scalar: number;
318
321
  }
322
+ export interface GradientStop {
323
+ color: string;
324
+ position: number;
325
+ }
319
326
  export interface CanvasBackground {
320
327
  type: 'solid' | 'gradient' | 'image' | 'transparent';
321
328
  color?: string;
322
329
  gradient?: {
323
- type: 'linear' | 'radial';
330
+ type: 'linear' | 'radial' | 'conic';
324
331
  angle?: number;
325
332
  colors: string[];
333
+ stops?: GradientStop[];
334
+ radialShape?: 'circle' | 'ellipse';
335
+ radialPosition?: string;
326
336
  };
327
337
  image?: string;
328
338
  particles?: ParticlesConfig;
@@ -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
+ };
@@ -0,0 +1,348 @@
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 function textAnimate(node, params) {
20
+ let raf = 0;
21
+ let originalContent = null;
22
+ let activeMode = null;
23
+ function clearAnim() {
24
+ if (raf)
25
+ cancelAnimationFrame(raf);
26
+ raf = 0;
27
+ }
28
+ function restore() {
29
+ clearAnim();
30
+ // Replace whatever we injected with the original content. Doing this on
31
+ // every (re)start keeps the DOM clean when the user toggles modes.
32
+ if (originalContent !== null)
33
+ node.textContent = originalContent;
34
+ }
35
+ function effectiveDuration() {
36
+ const requested = Math.max(50, params.duration || 800);
37
+ if (params.slideDuration && params.slideDuration > 0 && params.loop) {
38
+ return params.slideDuration / Math.max(1, Math.round(params.slideDuration / requested));
39
+ }
40
+ return requested;
41
+ }
42
+ /** Wraps each character of `text` in a <span class="ta-char">. Spaces are
43
+ * also wrapped (with a special class) so the per-letter animation runs
44
+ * uniformly across word boundaries while still preserving spacing. */
45
+ function wrapChars(text) {
46
+ const frag = document.createElement('span');
47
+ frag.className = 'ta-wrap';
48
+ for (const ch of text) {
49
+ const s = document.createElement('span');
50
+ s.className = ch === ' ' ? 'ta-char ta-space' : 'ta-char';
51
+ s.textContent = ch === ' ' ? ' ' : ch; // nbsp so spaces render as fixed-width in inline-block
52
+ s.style.display = 'inline-block';
53
+ frag.appendChild(s);
54
+ }
55
+ return frag;
56
+ }
57
+ function runFadeLetters() {
58
+ // Source the text from params.content, not node.textContent — the host
59
+ // template intentionally renders nothing when an action mode is active
60
+ // (so we don't get a flash of unstyled text), which means the DOM is
61
+ // empty when this runs. Reading textContent here would yield ''.
62
+ originalContent = params.content ?? '';
63
+ node.innerHTML = '';
64
+ const wrap = wrapChars(originalContent);
65
+ node.appendChild(wrap);
66
+ const chars = Array.from(wrap.querySelectorAll('.ta-char'));
67
+ const stagger = params.stagger ?? Math.max(20, Math.min(60, 600 / Math.max(1, chars.length)));
68
+ const perLetter = 280;
69
+ const total = stagger * chars.length + perLetter;
70
+ const dur = effectiveDuration();
71
+ const totalEffective = params.loop ? dur : total;
72
+ for (const c of chars)
73
+ c.style.opacity = '0';
74
+ const start = performance.now();
75
+ function step(now) {
76
+ const elapsed = (now - start) % (params.loop ? totalEffective : Number.POSITIVE_INFINITY);
77
+ let allDone = true;
78
+ for (let i = 0; i < chars.length; i++) {
79
+ const localStart = i * stagger;
80
+ const t = Math.min(1, Math.max(0, (elapsed - localStart) / perLetter));
81
+ if (t < 1)
82
+ allDone = false;
83
+ chars[i].style.opacity = String(t);
84
+ }
85
+ if (params.loop || !allDone)
86
+ raf = requestAnimationFrame(step);
87
+ else
88
+ raf = 0;
89
+ }
90
+ raf = requestAnimationFrame(step);
91
+ }
92
+ function runBounceIn() {
93
+ // Source the text from params.content, not node.textContent — the host
94
+ // template intentionally renders nothing when an action mode is active
95
+ // (so we don't get a flash of unstyled text), which means the DOM is
96
+ // empty when this runs. Reading textContent here would yield ''.
97
+ originalContent = params.content ?? '';
98
+ node.innerHTML = '';
99
+ const wrap = wrapChars(originalContent);
100
+ node.appendChild(wrap);
101
+ const chars = Array.from(wrap.querySelectorAll('.ta-char'));
102
+ const stagger = params.stagger ?? Math.max(30, Math.min(80, 800 / Math.max(1, chars.length)));
103
+ const perLetter = 380;
104
+ for (const c of chars) {
105
+ c.style.opacity = '0';
106
+ c.style.transform = 'translateY(0.4em) scale(0.6)';
107
+ c.style.transformOrigin = '50% 80%';
108
+ }
109
+ // `let` because the loop branch resets `start = now` to begin a new cycle.
110
+ let start = performance.now();
111
+ function step(now) {
112
+ const elapsed = now - start;
113
+ let allDone = true;
114
+ for (let i = 0; i < chars.length; i++) {
115
+ const t = Math.min(1, Math.max(0, (elapsed - i * stagger) / perLetter));
116
+ if (t < 1)
117
+ allDone = false;
118
+ // Spring-ish easing — overshoots slightly then settles.
119
+ const eased = 1 - Math.pow(1 - t, 3);
120
+ const overshoot = Math.sin(t * Math.PI) * 0.1;
121
+ const scale = 0.6 + (1 - 0.6) * eased + overshoot;
122
+ const translate = (1 - eased) * 0.4;
123
+ chars[i].style.opacity = String(eased);
124
+ chars[i].style.transform = `translateY(${translate}em) scale(${scale})`;
125
+ }
126
+ if (!allDone)
127
+ raf = requestAnimationFrame(step);
128
+ else if (params.loop) {
129
+ // Reset and loop — a small pause at the end keeps it readable.
130
+ const totalCycle = effectiveDuration();
131
+ if (now - start >= totalCycle) {
132
+ for (const c of chars) {
133
+ c.style.opacity = '0';
134
+ c.style.transform = 'translateY(0.4em) scale(0.6)';
135
+ }
136
+ start = now;
137
+ }
138
+ raf = requestAnimationFrame(step);
139
+ }
140
+ else {
141
+ raf = 0;
142
+ }
143
+ }
144
+ raf = requestAnimationFrame(step);
145
+ }
146
+ function runHandwriting() {
147
+ // Real per-character handwriting: each glyph is its own <text> element
148
+ // with its own stroke-dasharray. We measure char positions from a single
149
+ // hidden <text> first (the only reliable way to get kerning/spacing
150
+ // matching what the browser would draw) then build per-glyph elements
151
+ // laid out in absolute SVG coordinates. Each glyph is revealed in turn
152
+ // with a small overlap so the effect reads like a continuous pen, not
153
+ // a typewriter.
154
+ originalContent = params.content ?? '';
155
+ node.innerHTML = '';
156
+ if (!originalContent)
157
+ return;
158
+ const color = params.color ?? '#ffffff';
159
+ const fs = params.fontSize ?? 48;
160
+ const ff = params.fontFamily ?? 'inherit';
161
+ const fw = params.fontWeight ?? 400;
162
+ const fst = params.fontStyle ?? 'normal';
163
+ const align = params.textAlign ?? 'left';
164
+ const svgNS = 'http://www.w3.org/2000/svg';
165
+ const svg = document.createElementNS(svgNS, 'svg');
166
+ svg.setAttribute('width', '100%');
167
+ svg.setAttribute('height', '100%');
168
+ const w = Math.max(1, node.clientWidth);
169
+ const h = Math.max(1, node.clientHeight);
170
+ svg.setAttribute('viewBox', `0 0 ${w} ${h}`);
171
+ svg.setAttribute('preserveAspectRatio', 'xMidYMid meet');
172
+ svg.style.overflow = 'visible';
173
+ node.appendChild(svg);
174
+ // Step 1 — measure char positions from a hidden full-string render.
175
+ const measure = document.createElementNS(svgNS, 'text');
176
+ measure.setAttribute('x', '0');
177
+ measure.setAttribute('y', '50%');
178
+ measure.setAttribute('dominant-baseline', 'middle');
179
+ measure.setAttribute('text-anchor', 'start');
180
+ measure.setAttribute('visibility', 'hidden');
181
+ measure.style.fontSize = `${fs}px`;
182
+ measure.style.fontFamily = ff;
183
+ measure.style.fontWeight = String(fw);
184
+ measure.style.fontStyle = fst;
185
+ measure.textContent = originalContent;
186
+ svg.appendChild(measure);
187
+ const charPositions = [];
188
+ const charLengths = [];
189
+ let totalWidth = 0;
190
+ try {
191
+ const t = measure;
192
+ totalWidth = t.getComputedTextLength();
193
+ for (let i = 0; i < originalContent.length; i++) {
194
+ const pt = t.getStartPositionOfChar(i);
195
+ charPositions.push(pt.x);
196
+ const nextX = i + 1 < originalContent.length
197
+ ? t.getStartPositionOfChar(i + 1).x
198
+ : pt.x + (totalWidth - pt.x);
199
+ charLengths.push(Math.max(1, nextX - pt.x));
200
+ }
201
+ }
202
+ catch {
203
+ // Fallback: even spacing across host width.
204
+ totalWidth = w * 0.9;
205
+ const stepW = totalWidth / Math.max(1, originalContent.length);
206
+ for (let i = 0; i < originalContent.length; i++) {
207
+ charPositions.push(i * stepW);
208
+ charLengths.push(stepW);
209
+ }
210
+ }
211
+ // Honor textAlign by shifting the whole row in absolute coordinates.
212
+ const baseX = align === 'center' ? (w - totalWidth) / 2
213
+ : align === 'right' ? (w - totalWidth)
214
+ : 0;
215
+ svg.removeChild(measure);
216
+ // Step 2 — build per-character <text> elements.
217
+ const glyphs = [];
218
+ for (let i = 0; i < originalContent.length; i++) {
219
+ const ch = originalContent[i];
220
+ const t = document.createElementNS(svgNS, 'text');
221
+ t.setAttribute('x', String(baseX + charPositions[i]));
222
+ t.setAttribute('y', '50%');
223
+ t.setAttribute('dominant-baseline', 'middle');
224
+ t.setAttribute('text-anchor', 'start');
225
+ t.setAttribute('fill', 'transparent');
226
+ t.setAttribute('stroke', color);
227
+ t.setAttribute('stroke-width', '1');
228
+ t.setAttribute('stroke-linecap', 'round');
229
+ t.setAttribute('stroke-linejoin', 'round');
230
+ t.style.fontSize = `${fs}px`;
231
+ t.style.fontFamily = ff;
232
+ t.style.fontWeight = String(fw);
233
+ t.style.fontStyle = fst;
234
+ t.textContent = ch;
235
+ svg.appendChild(t);
236
+ // Spaces have no visible stroke — skip dashoffset math, they reveal
237
+ // instantly. For everything else, we don't know the exact outline
238
+ // length per glyph, so we pick a generous upper bound that comfortably
239
+ // exceeds any realistic glyph contour at this font size, then use an
240
+ // explicit *huge* gap so the dash pattern can't repeat. Without the
241
+ // huge gap, complex letters (g, B, &, ampersand-heavy scripts) whose
242
+ // outline is longer than our `len` estimate would show a second dash
243
+ // cycle peeking through before the animation reaches them.
244
+ const len = ch === ' ' ? 0 : Math.max(40, fs * 4 + charLengths[i] * 2.5);
245
+ const gap = len * 4;
246
+ t.style.strokeDasharray = `${len || 1} ${gap || 1}`;
247
+ t.style.strokeDashoffset = String(len || 1);
248
+ glyphs.push({ el: t, len });
249
+ }
250
+ const dur = effectiveDuration();
251
+ // Per-char duration with overlap. overlap=0.6 means each char starts after
252
+ // the previous is 60% drawn — looks like a continuous pen rather than a
253
+ // typewriter (overlap=1) or strict per-letter sequence (overlap=0).
254
+ const n = originalContent.length;
255
+ const overlap = 0.6;
256
+ // Solve: stagger * (n - 1) + perChar = dur, where stagger = perChar * overlap.
257
+ // → perChar * (overlap * (n - 1) + 1) = dur.
258
+ const perChar = dur / Math.max(1, overlap * (n - 1) + 1);
259
+ const stagger = perChar * overlap;
260
+ let start = performance.now();
261
+ function step(now) {
262
+ const elapsed = now - start;
263
+ let allDone = true;
264
+ for (let i = 0; i < glyphs.length; i++) {
265
+ const g = glyphs[i];
266
+ if (g.len === 0)
267
+ continue; // space — already invisible
268
+ const localStart = i * stagger;
269
+ const t = Math.min(1, Math.max(0, (elapsed - localStart) / perChar));
270
+ if (t < 1)
271
+ allDone = false;
272
+ const eased = 1 - Math.pow(1 - t, 3);
273
+ g.el.style.strokeDashoffset = String(g.len * (1 - eased));
274
+ // Cross-fade fill in over the last 30% of the glyph's draw so the
275
+ // finished letter reads cleanly. Otherwise thin script fonts look
276
+ // hollow and don't match the rest of the slide's text.
277
+ if (t > 0.7) {
278
+ const fillT = (t - 0.7) / 0.3;
279
+ g.el.setAttribute('fill', color);
280
+ g.el.setAttribute('fill-opacity', String(fillT));
281
+ }
282
+ }
283
+ if (!allDone) {
284
+ raf = requestAnimationFrame(step);
285
+ }
286
+ else if (params.loop) {
287
+ for (const g of glyphs) {
288
+ g.el.style.strokeDashoffset = String(g.len || 1);
289
+ g.el.setAttribute('fill', 'transparent');
290
+ g.el.removeAttribute('fill-opacity');
291
+ }
292
+ start = now;
293
+ raf = requestAnimationFrame(step);
294
+ }
295
+ else {
296
+ raf = 0;
297
+ }
298
+ }
299
+ raf = requestAnimationFrame(step);
300
+ }
301
+ function run() {
302
+ restore();
303
+ activeMode = params.mode;
304
+ if (!params.enabled)
305
+ return;
306
+ switch (params.mode) {
307
+ case 'fade-letters':
308
+ runFadeLetters();
309
+ break;
310
+ case 'bounce-in':
311
+ runBounceIn();
312
+ break;
313
+ case 'handwriting':
314
+ runHandwriting();
315
+ break;
316
+ default: /* other modes handled elsewhere */ break;
317
+ }
318
+ }
319
+ queueMicrotask(run);
320
+ if (typeof window !== 'undefined') {
321
+ const reg = (window.__svgAnimRestart ||= []);
322
+ reg.push(run);
323
+ node.__svgAnimRestart = run;
324
+ }
325
+ let lastKey = params.key;
326
+ return {
327
+ update(p) {
328
+ const keyChanged = p.key !== lastKey || p.mode !== activeMode;
329
+ params = p;
330
+ if (keyChanged) {
331
+ lastKey = p.key;
332
+ queueMicrotask(run);
333
+ }
334
+ },
335
+ destroy() {
336
+ restore();
337
+ if (typeof window !== 'undefined') {
338
+ const reg = window.__svgAnimRestart;
339
+ const fn = node.__svgAnimRestart;
340
+ if (reg && fn) {
341
+ const idx = reg.indexOf(fn);
342
+ if (idx >= 0)
343
+ reg.splice(idx, 1);
344
+ }
345
+ }
346
+ }
347
+ };
348
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "animot-presenter",
3
- "version": "0.5.5",
3
+ "version": "0.5.6",
4
4
  "description": "Embed animated presentations anywhere. Works with vanilla JS, React, Vue, Angular, Svelte, and any frontend framework. Morphing animations, code highlighting, charts, particles, and more.",
5
5
  "type": "module",
6
6
  "svelte": "./dist/index.js",