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.
- package/dist/AnimotPresenter.svelte +27 -9
- package/dist/cdn/animot-presenter.esm.js +5323 -5293
- package/dist/cdn/animot-presenter.min.js +8 -8
- package/dist/utils/text-animate.d.ts +6 -1
- package/dist/utils/text-animate.js +228 -177
- package/package.json +1 -1
|
@@ -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
|
-
*
|
|
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 === ' ' ? '
|
|
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
|
|
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
|
-
|
|
75
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
110
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
|
177
|
-
const
|
|
178
|
-
|
|
179
|
-
const elapsed =
|
|
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
|
-
|
|
193
|
-
|
|
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
|
-
|
|
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
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
|
|
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
|
-
|
|
256
|
-
|
|
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
|
-
|
|
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
|
-
|
|
291
|
-
|
|
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
|
-
|
|
298
|
-
|
|
299
|
-
const
|
|
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(${
|
|
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(${
|
|
341
|
+
chars[i].style.transform = t < 0.85 ? `translate(${j}em, ${j}em)` : 'none';
|
|
305
342
|
chars[i].style.filter = filter;
|
|
306
343
|
}
|
|
307
|
-
|
|
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
|
-
|
|
333
|
-
const
|
|
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
|
-
|
|
353
|
-
|
|
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
|
-
|
|
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
|
-
|
|
388
|
-
|
|
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
|
-
|
|
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
|
-
|
|
420
|
-
|
|
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
|
-
|
|
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
|
-
|
|
447
|
-
|
|
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 =
|
|
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
|
-
|
|
466
|
-
|
|
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
|
-
|
|
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
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
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
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
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;
|
|
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
|
-
|
|
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
|
};
|