animot-presenter 0.6.0 → 0.6.2

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.
@@ -11,19 +11,45 @@
11
11
  * Other modes (instant, typewriter, fade-words) are handled elsewhere by the
12
12
  * /present render path — this action no-ops for those.
13
13
  *
14
- * The action registers a restart fn into `window.__svgAnimRestart` so the
14
+ * Each runner builds a `currentSeek(elapsedMs)` closure that snapshots the
15
+ * setup state and computes the visual at any virtual time. The live RAF loop
16
+ * calls it with real elapsed; the MP4/GIF export pipeline calls it via
17
+ * `window.__seekAllTextAnims(virtualMs)` for frame-perfect captures.
18
+ *
19
+ * The action also registers a restart fn into `window.__svgAnimRestart` so the
15
20
  * server-side video export pipeline can reset all animations under the
16
21
  * virtual clock at the start of the slide hold (same pattern as FlowMarkers
17
22
  * and arrowClipDraw).
18
23
  */
24
+ /** Cheap deterministic hash → [0, 1). Used in scramble/glitch so virtual-time
25
+ * seek (export) produces the same intermediate chars / jitter as live RAF.
26
+ * Without determinism, MP4/GIF would scramble different letters every frame. */
27
+ function detRand(seed) {
28
+ const x = Math.sin(seed) * 43758.5453;
29
+ return x - Math.floor(x);
30
+ }
19
31
  export function textAnimate(node, params) {
20
32
  let raf = 0;
21
33
  let originalContent = null;
22
34
  let activeMode = null;
35
+ // Each runner stores a per-frame `seek(elapsed)` function here so external
36
+ // drivers (the MP4/GIF export pipeline's virtual clock) can position the
37
+ // animation at any time without depending on real-time RAF. Cleared on
38
+ // restart/destroy. Real-time playback's RAF loop also calls this seek.
39
+ let currentSeek = null;
40
+ // Stable wrapper registered with the global seek registry. Used by the
41
+ // export pipeline (`__seekAllTextAnims`) to drive all active animations
42
+ // at the same virtual time as the rest of the frame capture.
43
+ const seekWrapper = (elapsedMs) => { if (currentSeek)
44
+ currentSeek(elapsedMs); };
45
+ if (typeof window !== 'undefined') {
46
+ (window.__textAnimSeek ||= []).push(seekWrapper);
47
+ }
23
48
  function clearAnim() {
24
49
  if (raf)
25
50
  cancelAnimationFrame(raf);
26
51
  raf = 0;
52
+ currentSeek = null;
27
53
  }
28
54
  function restore() {
29
55
  clearAnim();
@@ -48,17 +74,52 @@ export function textAnimate(node, params) {
48
74
  for (const ch of text) {
49
75
  const s = document.createElement('span');
50
76
  s.className = ch === ' ' ? 'ta-char ta-space' : 'ta-char';
51
- s.textContent = ch === ' ' ? ' ' : ch; // nbsp so spaces render as fixed-width in inline-block
77
+ s.textContent = ch === ' ' ? ' ' : ch; // nbsp so spaces render as fixed-width in inline-block
52
78
  s.style.display = 'inline-block';
79
+ // Inherit gradient background + clip so per-char wrapping doesn't
80
+ // break gradient text. Parent text-element uses `background-clip:
81
+ // text` + `color: transparent`; without these inherits, char spans
82
+ // would only inherit transparent color and render invisible.
83
+ s.style.backgroundImage = 'inherit';
84
+ s.style.backgroundClip = 'inherit';
85
+ s.style.webkitBackgroundClip = 'inherit';
53
86
  frag.appendChild(s);
54
87
  }
55
88
  return frag;
56
89
  }
90
+ function wrapWords(text) {
91
+ const wrap = document.createElement('span');
92
+ wrap.className = 'ta-wrap';
93
+ const words = [];
94
+ const parts = text.split(/(\s+)/); // keep separators
95
+ for (const p of parts) {
96
+ if (/^\s+$/.test(p)) {
97
+ wrap.appendChild(document.createTextNode(p));
98
+ }
99
+ else if (p.length > 0) {
100
+ const w = document.createElement('span');
101
+ w.className = 'ta-word';
102
+ w.style.display = 'inline-block';
103
+ w.style.backgroundImage = 'inherit';
104
+ w.style.backgroundClip = 'inherit';
105
+ w.style.webkitBackgroundClip = 'inherit';
106
+ w.textContent = p;
107
+ wrap.appendChild(w);
108
+ words.push(w);
109
+ }
110
+ }
111
+ return { wrap, words };
112
+ }
113
+ // ─── Standardized runner pattern ────────────────────────────────────
114
+ //
115
+ // Each runner: (1) sets up state (chars list, durations, etc.), (2) builds
116
+ // `currentSeek(rawElapsed)` — the per-frame applier parameterized by ms,
117
+ // (3) primes initial DOM state, (4) starts a real-time RAF loop that just
118
+ // delegates to currentSeek with `performance.now() - start`.
119
+ //
120
+ // Loop modes wrap `rawElapsed` against the full cycle duration so seek
121
+ // works correctly at any timestamp. One-shot modes use elapsed directly.
57
122
  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
123
  originalContent = params.content ?? '';
63
124
  node.innerHTML = '';
64
125
  const wrap = wrapChars(originalContent);
@@ -67,22 +128,21 @@ export function textAnimate(node, params) {
67
128
  const stagger = params.stagger ?? Math.max(20, Math.min(60, 600 / Math.max(1, chars.length)));
68
129
  const perLetter = 280;
69
130
  const total = stagger * chars.length + perLetter;
70
- const dur = effectiveDuration();
71
- const totalEffective = params.loop ? dur : total;
131
+ const cycle = Math.max(total, effectiveDuration());
72
132
  for (const c of chars)
73
133
  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;
134
+ currentSeek = (raw) => {
135
+ const elapsed = params.loop ? (raw % cycle) : raw;
78
136
  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;
137
+ const t = Math.min(1, Math.max(0, (elapsed - i * stagger) / perLetter));
83
138
  chars[i].style.opacity = String(t);
84
139
  }
85
- if (params.loop || !allDone)
140
+ };
141
+ const start = performance.now();
142
+ function step(now) {
143
+ const elapsed = now - start;
144
+ currentSeek?.(elapsed);
145
+ if (params.loop || elapsed < total)
86
146
  raf = requestAnimationFrame(step);
87
147
  else
88
148
  raf = 0;
@@ -90,10 +150,6 @@ export function textAnimate(node, params) {
90
150
  raf = requestAnimationFrame(step);
91
151
  }
92
152
  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
153
  originalContent = params.content ?? '';
98
154
  node.innerHTML = '';
99
155
  const wrap = wrapChars(originalContent);
@@ -101,21 +157,17 @@ export function textAnimate(node, params) {
101
157
  const chars = Array.from(wrap.querySelectorAll('.ta-char'));
102
158
  const stagger = params.stagger ?? Math.max(30, Math.min(80, 800 / Math.max(1, chars.length)));
103
159
  const perLetter = 380;
160
+ const total = stagger * chars.length + perLetter;
161
+ const cycle = Math.max(total, effectiveDuration());
104
162
  for (const c of chars) {
105
163
  c.style.opacity = '0';
106
164
  c.style.transform = 'translateY(0.4em) scale(0.6)';
107
165
  c.style.transformOrigin = '50% 80%';
108
166
  }
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;
167
+ currentSeek = (raw) => {
168
+ const elapsed = params.loop ? (raw % cycle) : raw;
114
169
  for (let i = 0; i < chars.length; i++) {
115
170
  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
171
  const eased = 1 - Math.pow(1 - t, 3);
120
172
  const overshoot = Math.sin(t * Math.PI) * 0.1;
121
173
  const scale = 0.6 + (1 - 0.6) * eased + overshoot;
@@ -123,46 +175,18 @@ export function textAnimate(node, params) {
123
175
  chars[i].style.opacity = String(eased);
124
176
  chars[i].style.transform = `translateY(${translate}em) scale(${scale})`;
125
177
  }
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
- }
178
+ };
179
+ const start = performance.now();
180
+ function step(now) {
181
+ const elapsed = now - start;
182
+ currentSeek?.(elapsed);
183
+ if (params.loop || elapsed < total)
138
184
  raf = requestAnimationFrame(step);
139
- }
140
- else {
185
+ else
141
186
  raf = 0;
142
- }
143
187
  }
144
188
  raf = requestAnimationFrame(step);
145
189
  }
146
- // ─── Phase 3 runners (mirrored from animot app) ───────────────────
147
- function wrapWords(text) {
148
- const wrap = document.createElement('span');
149
- wrap.className = 'ta-wrap';
150
- const words = [];
151
- const parts = text.split(/(\s+)/);
152
- for (const p of parts) {
153
- if (/^\s+$/.test(p))
154
- wrap.appendChild(document.createTextNode(p));
155
- else if (p.length > 0) {
156
- const w = document.createElement('span');
157
- w.className = 'ta-word';
158
- w.style.display = 'inline-block';
159
- w.textContent = p;
160
- wrap.appendChild(w);
161
- words.push(w);
162
- }
163
- }
164
- return { wrap, words };
165
- }
166
190
  function runScrambleIn() {
167
191
  originalContent = params.content ?? '';
168
192
  node.innerHTML = '';
@@ -173,26 +197,32 @@ export function textAnimate(node, params) {
173
197
  const stagger = params.stagger ?? Math.max(30, Math.min(80, 800 / Math.max(1, chars.length)));
174
198
  const settleMs = 500;
175
199
  const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%&*';
176
- const start = performance.now();
177
- const dur = effectiveDuration();
178
- function step(now) {
179
- const elapsed = (now - start) % (params.loop ? Math.max(stagger * chars.length + settleMs, dur) : Number.POSITIVE_INFINITY);
180
- let allDone = true;
200
+ const total = stagger * chars.length + settleMs;
201
+ const cycle = Math.max(total, effectiveDuration());
202
+ currentSeek = (raw) => {
203
+ const elapsed = params.loop ? (raw % cycle) : raw;
181
204
  for (let i = 0; i < chars.length; i++) {
182
205
  const local = elapsed - i * stagger;
183
206
  if (local < 0) {
184
207
  chars[i].textContent = ' ';
185
- allDone = false;
186
208
  continue;
187
209
  }
188
210
  if (local >= settleMs) {
189
211
  chars[i].textContent = finalText[i];
190
212
  continue;
191
213
  }
192
- allDone = false;
193
- chars[i].textContent = finalText[i] === ' ' ? ' ' : alphabet[(Math.random() * alphabet.length) | 0];
214
+ // Deterministic seed = char index + 60ms bucket → same char on
215
+ // every frame at the same virtual time. Replaces Math.random.
216
+ const bucket = Math.floor(local / 60);
217
+ const idx = Math.floor(detRand(i * 7919 + bucket * 31) * alphabet.length);
218
+ chars[i].textContent = finalText[i] === ' ' ? ' ' : alphabet[idx];
194
219
  }
195
- if (params.loop || !allDone)
220
+ };
221
+ const start = performance.now();
222
+ function step(now) {
223
+ const elapsed = now - start;
224
+ currentSeek?.(elapsed);
225
+ if (params.loop || elapsed < total)
196
226
  raf = requestAnimationFrame(step);
197
227
  else
198
228
  raf = 0;
@@ -209,15 +239,16 @@ export function textAnimate(node, params) {
209
239
  const stagger = params.stagger ?? 60;
210
240
  const spinMs = 600;
211
241
  const reel = '0123456789!@#$%ABCDEFGH';
212
- const start = performance.now();
213
- function step(now) {
214
- const elapsed = now - start;
215
- let allDone = true;
242
+ const total = stagger * chars.length + spinMs;
243
+ const cycle = Math.max(total, effectiveDuration());
244
+ for (const c of chars)
245
+ c.style.transform = 'translateY(-0.3em)';
246
+ currentSeek = (raw) => {
247
+ const elapsed = params.loop ? (raw % cycle) : raw;
216
248
  for (let i = 0; i < chars.length; i++) {
217
249
  const local = elapsed - i * stagger;
218
250
  if (local < 0) {
219
251
  chars[i].textContent = ' ';
220
- allDone = false;
221
252
  continue;
222
253
  }
223
254
  if (local >= spinMs) {
@@ -225,19 +256,21 @@ export function textAnimate(node, params) {
225
256
  chars[i].style.transform = 'translateY(0)';
226
257
  continue;
227
258
  }
228
- allDone = false;
229
259
  const t = local / spinMs;
230
260
  const offsetY = (1 - t) * -0.3;
231
261
  chars[i].textContent = finalText[i] === ' ' ? ' ' : reel[((local / 60) | 0) % reel.length];
232
262
  chars[i].style.transform = `translateY(${offsetY}em)`;
233
263
  }
234
- if (params.loop || !allDone)
264
+ };
265
+ const start = performance.now();
266
+ function step(now) {
267
+ const elapsed = now - start;
268
+ currentSeek?.(elapsed);
269
+ if (params.loop || elapsed < total)
235
270
  raf = requestAnimationFrame(step);
236
271
  else
237
272
  raf = 0;
238
273
  }
239
- for (const c of chars)
240
- c.style.transform = 'translateY(-0.3em)';
241
274
  raf = requestAnimationFrame(step);
242
275
  }
243
276
  function runDrop() {
@@ -248,18 +281,16 @@ export function textAnimate(node, params) {
248
281
  const chars = Array.from(wrap.querySelectorAll('.ta-char'));
249
282
  const stagger = params.stagger ?? Math.max(30, Math.min(90, 700 / Math.max(1, chars.length)));
250
283
  const perLetter = 520;
284
+ const total = stagger * chars.length + perLetter;
285
+ const cycle = Math.max(total, effectiveDuration());
251
286
  for (const c of chars) {
252
287
  c.style.opacity = '0';
253
288
  c.style.transform = 'translateY(-1.2em)';
254
289
  }
255
- const start = performance.now();
256
- function step(now) {
257
- const elapsed = now - start;
258
- let allDone = true;
290
+ currentSeek = (raw) => {
291
+ const elapsed = params.loop ? (raw % cycle) : raw;
259
292
  for (let i = 0; i < chars.length; i++) {
260
293
  const t = Math.min(1, Math.max(0, (elapsed - i * stagger) / perLetter));
261
- if (t < 1)
262
- allDone = false;
263
294
  let b;
264
295
  if (t < 0.5)
265
296
  b = 4 * t * t * t;
@@ -270,7 +301,12 @@ export function textAnimate(node, params) {
270
301
  chars[i].style.opacity = String(Math.min(1, t * 2));
271
302
  chars[i].style.transform = `translateY(${translate}em)`;
272
303
  }
273
- if (!allDone)
304
+ };
305
+ const start = performance.now();
306
+ function step(now) {
307
+ const elapsed = now - start;
308
+ currentSeek?.(elapsed);
309
+ if (params.loop || elapsed < total)
274
310
  raf = requestAnimationFrame(step);
275
311
  else
276
312
  raf = 0;
@@ -284,27 +320,33 @@ export function textAnimate(node, params) {
284
320
  node.appendChild(wrap);
285
321
  const chars = Array.from(wrap.querySelectorAll('.ta-char'));
286
322
  const dur = 1100;
323
+ const cycle = Math.max(dur, effectiveDuration());
287
324
  for (const c of chars) {
288
325
  c.style.opacity = '0';
289
326
  }
290
- const start = performance.now();
291
- function step(now) {
292
- const elapsed = now - start;
293
- let allDone = true;
327
+ currentSeek = (raw) => {
328
+ const elapsed = params.loop ? (raw % cycle) : raw;
294
329
  for (let i = 0; i < chars.length; i++) {
295
330
  const localStart = (i / Math.max(1, chars.length)) * (dur * 0.4);
296
331
  const t = Math.min(1, Math.max(0, (elapsed - localStart) / (dur - localStart)));
297
- if (t < 1)
298
- allDone = false;
299
- const jitter = t < 0.85 ? (Math.random() - 0.5) * 0.08 * (1 - t) : 0;
332
+ // Deterministic jitter — same per-frame seed reproduces in export.
333
+ const bucket = Math.floor(elapsed / 33);
334
+ const j = (detRand(i * 1009 + bucket * 17) - 0.5) * 0.08 * (1 - t);
335
+ const r1 = (detRand(i * 1031 + bucket * 23) - 0.5) * 4;
336
+ const r2 = (detRand(i * 1049 + bucket * 29) - 0.5) * 4;
300
337
  const filter = t < 0.7
301
- ? `drop-shadow(${(Math.random() - 0.5) * 4}px 0 0 #06b6d4) drop-shadow(${(Math.random() - 0.5) * 4}px 0 0 #ec4899)`
338
+ ? `drop-shadow(${r1}px 0 0 #06b6d4) drop-shadow(${r2}px 0 0 #ec4899)`
302
339
  : 'none';
303
340
  chars[i].style.opacity = String(t);
304
- chars[i].style.transform = `translate(${jitter}em, ${jitter}em)`;
341
+ chars[i].style.transform = t < 0.85 ? `translate(${j}em, ${j}em)` : 'none';
305
342
  chars[i].style.filter = filter;
306
343
  }
307
- if (!allDone)
344
+ };
345
+ const start = performance.now();
346
+ function step(now) {
347
+ const elapsed = now - start;
348
+ currentSeek?.(elapsed);
349
+ if (params.loop || elapsed < dur)
308
350
  raf = requestAnimationFrame(step);
309
351
  else {
310
352
  for (const c of chars) {
@@ -317,6 +359,9 @@ export function textAnimate(node, params) {
317
359
  raf = requestAnimationFrame(step);
318
360
  }
319
361
  function runMarquee() {
362
+ // Continuous horizontal scroll. Element overflow must be hidden via
363
+ // the outer .text-element container styles — we just translate the
364
+ // inner wrap leftwards by its own width then snap.
320
365
  originalContent = params.content ?? '';
321
366
  node.innerHTML = '';
322
367
  const wrap = document.createElement('span');
@@ -327,12 +372,14 @@ export function textAnimate(node, params) {
327
372
  node.style.overflow = 'hidden';
328
373
  node.style.whiteSpace = 'nowrap';
329
374
  node.appendChild(wrap);
330
- const start = performance.now();
331
375
  const cycle = effectiveDuration() || 6000;
332
- function step(now) {
333
- const elapsed = (now - start) % cycle;
334
- const pct = elapsed / cycle;
376
+ currentSeek = (raw) => {
377
+ const pct = (raw % cycle) / cycle;
335
378
  wrap.style.transform = `translateX(${-50 * pct}%)`;
379
+ };
380
+ const start = performance.now();
381
+ function step(now) {
382
+ currentSeek?.(now - start);
336
383
  raf = requestAnimationFrame(step);
337
384
  }
338
385
  raf = requestAnimationFrame(step);
@@ -345,23 +392,26 @@ export function textAnimate(node, params) {
345
392
  const chars = Array.from(wrap.querySelectorAll('.ta-char'));
346
393
  const stagger = params.stagger ?? Math.max(20, Math.min(60, 600 / Math.max(1, chars.length)));
347
394
  const perLetter = 600;
395
+ const total = stagger * chars.length + perLetter;
396
+ const cycle = Math.max(total, effectiveDuration());
348
397
  for (const c of chars) {
349
398
  c.style.opacity = '0';
350
399
  c.style.filter = 'blur(12px)';
351
400
  }
352
- const start = performance.now();
353
- function step(now) {
354
- const elapsed = now - start;
355
- let allDone = true;
401
+ currentSeek = (raw) => {
402
+ const elapsed = params.loop ? (raw % cycle) : raw;
356
403
  for (let i = 0; i < chars.length; i++) {
357
404
  const t = Math.min(1, Math.max(0, (elapsed - i * stagger) / perLetter));
358
- if (t < 1)
359
- allDone = false;
360
405
  const eased = 1 - Math.pow(1 - t, 2);
361
406
  chars[i].style.opacity = String(eased);
362
407
  chars[i].style.filter = `blur(${(1 - eased) * 12}px)`;
363
408
  }
364
- if (!allDone)
409
+ };
410
+ const start = performance.now();
411
+ function step(now) {
412
+ const elapsed = now - start;
413
+ currentSeek?.(elapsed);
414
+ if (params.loop || elapsed < total)
365
415
  raf = requestAnimationFrame(step);
366
416
  else {
367
417
  for (const c of chars)
@@ -379,26 +429,29 @@ export function textAnimate(node, params) {
379
429
  const chars = Array.from(wrap.querySelectorAll('.ta-char'));
380
430
  const stagger = params.stagger ?? 50;
381
431
  const perLetter = 450;
432
+ const total = stagger * chars.length + perLetter;
433
+ const cycle = Math.max(total, effectiveDuration());
382
434
  for (const c of chars) {
383
435
  c.style.opacity = '0';
384
436
  c.style.transform = 'scaleY(0.1)';
385
437
  c.style.transformOrigin = '50% 100%';
386
438
  }
387
- const start = performance.now();
388
- function step(now) {
389
- const elapsed = now - start;
390
- let allDone = true;
439
+ currentSeek = (raw) => {
440
+ const elapsed = params.loop ? (raw % cycle) : raw;
391
441
  for (let i = 0; i < chars.length; i++) {
392
442
  const t = Math.min(1, Math.max(0, (elapsed - i * stagger) / perLetter));
393
- if (t < 1)
394
- allDone = false;
395
443
  const eased = 1 - Math.pow(1 - t, 4);
396
444
  const overshoot = t > 0.8 ? Math.sin((t - 0.8) * Math.PI / 0.2) * 0.15 : 0;
397
445
  const sy = 0.1 + (1 - 0.1) * eased + overshoot;
398
446
  chars[i].style.opacity = String(Math.min(1, t * 1.5));
399
447
  chars[i].style.transform = `scaleY(${sy})`;
400
448
  }
401
- if (!allDone)
449
+ };
450
+ const start = performance.now();
451
+ function step(now) {
452
+ const elapsed = now - start;
453
+ currentSeek?.(elapsed);
454
+ if (params.loop || elapsed < total)
402
455
  raf = requestAnimationFrame(step);
403
456
  else
404
457
  raf = 0;
@@ -412,23 +465,26 @@ export function textAnimate(node, params) {
412
465
  node.appendChild(wrap);
413
466
  const stagger = params.stagger ?? 100;
414
467
  const perWord = 500;
468
+ const total = stagger * words.length + perWord;
469
+ const cycle = Math.max(total, effectiveDuration());
415
470
  for (const w of words) {
416
471
  w.style.opacity = '0';
417
472
  w.style.transform = 'translateY(0.6em)';
418
473
  }
419
- const start = performance.now();
420
- function step(now) {
421
- const elapsed = now - start;
422
- let allDone = true;
474
+ currentSeek = (raw) => {
475
+ const elapsed = params.loop ? (raw % cycle) : raw;
423
476
  for (let i = 0; i < words.length; i++) {
424
477
  const t = Math.min(1, Math.max(0, (elapsed - i * stagger) / perWord));
425
- if (t < 1)
426
- allDone = false;
427
478
  const eased = 1 - Math.pow(1 - t, 3);
428
479
  words[i].style.opacity = String(eased);
429
480
  words[i].style.transform = `translateY(${(1 - eased) * 0.6}em)`;
430
481
  }
431
- if (!allDone)
482
+ };
483
+ const start = performance.now();
484
+ function step(now) {
485
+ const elapsed = now - start;
486
+ currentSeek?.(elapsed);
487
+ if (params.loop || elapsed < total)
432
488
  raf = requestAnimationFrame(step);
433
489
  else
434
490
  raf = 0;
@@ -436,6 +492,8 @@ export function textAnimate(node, params) {
436
492
  raf = requestAnimationFrame(step);
437
493
  }
438
494
  function runWave() {
495
+ // Continuous wave bob across letters. Each character oscillates with
496
+ // a sine offset; phase is letter-index based so the wave rolls.
439
497
  originalContent = params.content ?? '';
440
498
  node.innerHTML = '';
441
499
  const wrap = wrapChars(originalContent);
@@ -443,13 +501,16 @@ export function textAnimate(node, params) {
443
501
  const chars = Array.from(wrap.querySelectorAll('.ta-char'));
444
502
  const cycle = effectiveDuration() || 1800;
445
503
  const amp = 0.18;
446
- const start = performance.now();
447
- function step(now) {
448
- const elapsed = (now - start) / cycle;
504
+ currentSeek = (raw) => {
505
+ const t = raw / cycle;
449
506
  for (let i = 0; i < chars.length; i++) {
450
- const phase = elapsed * Math.PI * 2 + (i / Math.max(1, chars.length)) * Math.PI * 1.4;
507
+ const phase = t * Math.PI * 2 + (i / Math.max(1, chars.length)) * Math.PI * 1.4;
451
508
  chars[i].style.transform = `translateY(${Math.sin(phase) * amp}em)`;
452
509
  }
510
+ };
511
+ const start = performance.now();
512
+ function step(now) {
513
+ currentSeek?.(now - start);
453
514
  raf = requestAnimationFrame(step);
454
515
  }
455
516
  raf = requestAnimationFrame(step);
@@ -457,30 +518,35 @@ export function textAnimate(node, params) {
457
518
  function runTypewriterErase() {
458
519
  originalContent = params.content ?? '';
459
520
  const chars = Array.from(originalContent);
460
- const speed = 50;
521
+ const speed = 50; // chars per second
461
522
  const charMs = 1000 / speed;
462
523
  const holdMs = 1200;
463
524
  const eraseMs = chars.length * charMs * 0.6;
464
525
  const cycle = chars.length * charMs + holdMs + eraseMs + 400;
465
- const start = performance.now();
466
- function step(now) {
467
- const t = (now - start) % cycle;
526
+ currentSeek = (raw) => {
527
+ const t = raw % cycle;
468
528
  let shown;
469
- if (t < chars.length * charMs)
529
+ if (t < chars.length * charMs) {
470
530
  shown = Math.floor(t / charMs);
471
- else if (t < chars.length * charMs + holdMs)
531
+ }
532
+ else if (t < chars.length * charMs + holdMs) {
472
533
  shown = chars.length;
534
+ }
473
535
  else if (t < chars.length * charMs + holdMs + eraseMs) {
474
536
  const eraseProgress = (t - chars.length * charMs - holdMs) / eraseMs;
475
537
  shown = Math.max(0, chars.length - Math.floor(eraseProgress * chars.length));
476
538
  }
477
- else
539
+ else {
478
540
  shown = 0;
479
- node.textContent = chars.slice(0, shown).join('') + (Math.floor(now / 500) % 2 === 0 ? '|' : '');
480
- if (params.loop || t < cycle)
481
- raf = requestAnimationFrame(step);
482
- else
483
- raf = 0;
541
+ }
542
+ // Deterministic cursor blink same on/off at the same virtual time.
543
+ const cursor = Math.floor(raw / 500) % 2 === 0 ? '|' : '';
544
+ node.textContent = chars.slice(0, shown).join('') + cursor;
545
+ };
546
+ const start = performance.now();
547
+ function step(now) {
548
+ currentSeek?.(now - start);
549
+ raf = requestAnimationFrame(step);
484
550
  }
485
551
  raf = requestAnimationFrame(step);
486
552
  }
@@ -541,7 +607,6 @@ export function textAnimate(node, params) {
541
607
  }
542
608
  }
543
609
  catch {
544
- // Fallback: even spacing across host width.
545
610
  totalWidth = w * 0.9;
546
611
  const stepW = totalWidth / Math.max(1, originalContent.length);
547
612
  for (let i = 0; i < originalContent.length; i++) {
@@ -549,12 +614,10 @@ export function textAnimate(node, params) {
549
614
  charLengths.push(stepW);
550
615
  }
551
616
  }
552
- // Honor textAlign by shifting the whole row in absolute coordinates.
553
617
  const baseX = align === 'center' ? (w - totalWidth) / 2
554
618
  : align === 'right' ? (w - totalWidth)
555
619
  : 0;
556
620
  svg.removeChild(measure);
557
- // Step 2 — build per-character <text> elements.
558
621
  const glyphs = [];
559
622
  for (let i = 0; i < originalContent.length; i++) {
560
623
  const ch = originalContent[i];
@@ -574,14 +637,6 @@ export function textAnimate(node, params) {
574
637
  t.style.fontStyle = fst;
575
638
  t.textContent = ch;
576
639
  svg.appendChild(t);
577
- // Spaces have no visible stroke — skip dashoffset math, they reveal
578
- // instantly. For everything else, we don't know the exact outline
579
- // length per glyph, so we pick a generous upper bound that comfortably
580
- // exceeds any realistic glyph contour at this font size, then use an
581
- // explicit *huge* gap so the dash pattern can't repeat. Without the
582
- // huge gap, complex letters (g, B, &, ampersand-heavy scripts) whose
583
- // outline is longer than our `len` estimate would show a second dash
584
- // cycle peeking through before the animation reaches them.
585
640
  const len = ch === ' ' ? 0 : Math.max(40, fs * 4 + charLengths[i] * 2.5);
586
641
  const gap = len * 4;
587
642
  t.style.strokeDasharray = `${len || 1} ${gap || 1}`;
@@ -589,53 +644,41 @@ export function textAnimate(node, params) {
589
644
  glyphs.push({ el: t, len });
590
645
  }
591
646
  const dur = effectiveDuration();
592
- // Per-char duration with overlap. overlap=0.6 means each char starts after
593
- // the previous is 60% drawn — looks like a continuous pen rather than a
594
- // typewriter (overlap=1) or strict per-letter sequence (overlap=0).
595
647
  const n = originalContent.length;
596
648
  const overlap = 0.6;
597
- // Solve: stagger * (n - 1) + perChar = dur, where stagger = perChar * overlap.
598
- // → perChar * (overlap * (n - 1) + 1) = dur.
599
649
  const perChar = dur / Math.max(1, overlap * (n - 1) + 1);
600
650
  const stagger = perChar * overlap;
601
- let start = performance.now();
602
- function step(now) {
603
- const elapsed = now - start;
604
- let allDone = true;
651
+ const total = stagger * (n - 1) + perChar;
652
+ const cycle = Math.max(total, dur);
653
+ currentSeek = (raw) => {
654
+ const elapsed = params.loop ? (raw % cycle) : raw;
605
655
  for (let i = 0; i < glyphs.length; i++) {
606
656
  const g = glyphs[i];
607
657
  if (g.len === 0)
608
- continue; // space — already invisible
658
+ continue;
609
659
  const localStart = i * stagger;
610
660
  const t = Math.min(1, Math.max(0, (elapsed - localStart) / perChar));
611
- if (t < 1)
612
- allDone = false;
613
661
  const eased = 1 - Math.pow(1 - t, 3);
614
662
  g.el.style.strokeDashoffset = String(g.len * (1 - eased));
615
- // Cross-fade fill in over the last 30% of the glyph's draw so the
616
- // finished letter reads cleanly. Otherwise thin script fonts look
617
- // hollow and don't match the rest of the slide's text.
618
663
  if (t > 0.7) {
619
664
  const fillT = (t - 0.7) / 0.3;
620
665
  g.el.setAttribute('fill', color);
621
666
  g.el.setAttribute('fill-opacity', String(fillT));
622
667
  }
623
- }
624
- if (!allDone) {
625
- raf = requestAnimationFrame(step);
626
- }
627
- else if (params.loop) {
628
- for (const g of glyphs) {
629
- g.el.style.strokeDashoffset = String(g.len || 1);
668
+ else {
630
669
  g.el.setAttribute('fill', 'transparent');
631
670
  g.el.removeAttribute('fill-opacity');
632
671
  }
633
- start = now;
634
- raf = requestAnimationFrame(step);
635
672
  }
636
- else {
673
+ };
674
+ const start = performance.now();
675
+ function step(now) {
676
+ const elapsed = now - start;
677
+ currentSeek?.(elapsed);
678
+ if (params.loop || elapsed < total)
679
+ raf = requestAnimationFrame(step);
680
+ else
637
681
  raf = 0;
638
- }
639
682
  }
640
683
  raf = requestAnimationFrame(step);
641
684
  }
@@ -706,6 +749,8 @@ export function textAnimate(node, params) {
706
749
  destroy() {
707
750
  restore();
708
751
  if (typeof window !== 'undefined') {
752
+ // Drop both registries (restart + seek) so a destroyed action's
753
+ // closure can be GC'd cleanly.
709
754
  const reg = window.__svgAnimRestart;
710
755
  const fn = node.__svgAnimRestart;
711
756
  if (reg && fn) {
@@ -713,6 +758,12 @@ export function textAnimate(node, params) {
713
758
  if (idx >= 0)
714
759
  reg.splice(idx, 1);
715
760
  }
761
+ const seekReg = window.__textAnimSeek;
762
+ if (seekReg) {
763
+ const idx = seekReg.indexOf(seekWrapper);
764
+ if (idx >= 0)
765
+ seekReg.splice(idx, 1);
766
+ }
716
767
  }
717
768
  }
718
769
  };