animot-presenter 0.5.4 → 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.
- package/dist/AnimotPresenter.svelte +8 -4
- package/dist/FlowMarkers.svelte +59 -8
- package/dist/FlowMarkers.svelte.d.ts +4 -0
- package/dist/cdn/animot-presenter.css +1 -1
- package/dist/cdn/animot-presenter.esm.js +4547 -4330
- package/dist/cdn/animot-presenter.min.js +9 -9
- package/dist/engine/utils.d.ts +11 -2
- package/dist/engine/utils.js +16 -4
- package/dist/types.d.ts +14 -2
- package/dist/utils/arrow-clip-draw.d.ts +4 -0
- package/dist/utils/arrow-clip-draw.js +15 -2
- package/dist/utils/text-animate.d.ts +47 -0
- package/dist/utils/text-animate.js +348 -0
- package/package.json +84 -84
package/dist/engine/utils.d.ts
CHANGED
|
@@ -15,13 +15,22 @@ export declare function computeFloatSpeed(cfg: {
|
|
|
15
15
|
speed: number;
|
|
16
16
|
speedRandomness?: number;
|
|
17
17
|
}, seed: string): number;
|
|
18
|
-
|
|
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
|
-
}
|
|
34
|
+
}
|
|
35
|
+
export declare function getBackgroundStyle(bg: BgInput): string;
|
|
36
|
+
export {};
|
package/dist/engine/utils.js
CHANGED
|
@@ -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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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';
|
|
@@ -203,7 +206,9 @@ export interface ShapeElement extends BaseElement {
|
|
|
203
206
|
type: 'shape';
|
|
204
207
|
shapeType: ShapeType;
|
|
205
208
|
fillColor: string;
|
|
209
|
+
fillOpacity?: number;
|
|
206
210
|
strokeColor: string;
|
|
211
|
+
strokeOpacity?: number;
|
|
207
212
|
strokeWidth: number;
|
|
208
213
|
strokeStyle?: StrokeStyle;
|
|
209
214
|
strokeDashGap?: number;
|
|
@@ -314,13 +319,20 @@ export interface ConfettiConfig {
|
|
|
314
319
|
startVelocity: number;
|
|
315
320
|
scalar: number;
|
|
316
321
|
}
|
|
322
|
+
export interface GradientStop {
|
|
323
|
+
color: string;
|
|
324
|
+
position: number;
|
|
325
|
+
}
|
|
317
326
|
export interface CanvasBackground {
|
|
318
327
|
type: 'solid' | 'gradient' | 'image' | 'transparent';
|
|
319
328
|
color?: string;
|
|
320
329
|
gradient?: {
|
|
321
|
-
type: 'linear' | 'radial';
|
|
330
|
+
type: 'linear' | 'radial' | 'conic';
|
|
322
331
|
angle?: number;
|
|
323
332
|
colors: string[];
|
|
333
|
+
stops?: GradientStop[];
|
|
334
|
+
radialShape?: 'circle' | 'ellipse';
|
|
335
|
+
radialPosition?: string;
|
|
324
336
|
};
|
|
325
337
|
image?: string;
|
|
326
338
|
particles?: ParticlesConfig;
|
|
@@ -20,6 +20,10 @@ export interface ArrowClipDrawParams {
|
|
|
20
20
|
endY: number;
|
|
21
21
|
loop?: boolean;
|
|
22
22
|
reverse?: boolean;
|
|
23
|
+
/** Slide loop duration in ms. When provided AND loop=true (or mode='flow'),
|
|
24
|
+
* the effective animation duration is rounded so an integer number fits in
|
|
25
|
+
* slide_duration — guarantees seamless GIF loop. */
|
|
26
|
+
slideDuration?: number;
|
|
23
27
|
key?: unknown;
|
|
24
28
|
}
|
|
25
29
|
export declare function arrowClipDraw(node: SVGSVGElement, params: ArrowClipDrawParams): {
|
|
@@ -59,7 +59,13 @@ export function arrowClipDraw(node, params) {
|
|
|
59
59
|
}
|
|
60
60
|
const clipFull = clip(100); // fully hidden (100% inset on hide side)
|
|
61
61
|
const clipNone = clip(0);
|
|
62
|
-
|
|
62
|
+
// Round duration so an integer number of cycles fits in slide_duration.
|
|
63
|
+
// Without this, GIF loop boundary shows the arrow mid-draw → snap to
|
|
64
|
+
// invisible → re-draw, which the user perceives as a reset.
|
|
65
|
+
const requested = Math.max(50, params.duration);
|
|
66
|
+
const dur = params.slideDuration && params.slideDuration > 0 && (params.loop || params.mode === 'flow')
|
|
67
|
+
? params.slideDuration / Math.max(1, Math.round(params.slideDuration / requested))
|
|
68
|
+
: requested;
|
|
63
69
|
const start = performance.now();
|
|
64
70
|
const m = params.mode;
|
|
65
71
|
// FLOW: marching ants — animate dashoffset continuously, keep base pattern.
|
|
@@ -156,10 +162,17 @@ export function arrowClipDraw(node, params) {
|
|
|
156
162
|
reg.push(run);
|
|
157
163
|
node.__svgAnimRestart = run;
|
|
158
164
|
}
|
|
165
|
+
let lastKey = params.key;
|
|
159
166
|
return {
|
|
160
167
|
update(p) {
|
|
168
|
+
// Only restart when the explicit key actually changed (avoids constant
|
|
169
|
+
// resets from Svelte's per-render fresh params object).
|
|
170
|
+
const keyChanged = p.key !== lastKey;
|
|
161
171
|
params = p;
|
|
162
|
-
|
|
172
|
+
if (keyChanged) {
|
|
173
|
+
lastKey = p.key;
|
|
174
|
+
queueMicrotask(run);
|
|
175
|
+
}
|
|
163
176
|
},
|
|
164
177
|
destroy() {
|
|
165
178
|
reset();
|
|
@@ -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
|
+
}
|