@mihirsarya/manim-scroll-runtime 0.1.1 → 0.2.0
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/index.d.ts +3 -1
- package/dist/index.js +2 -0
- package/dist/loader.d.ts +3 -0
- package/dist/loader.js +33 -4
- package/dist/native-player.d.ts +65 -0
- package/dist/native-player.js +985 -0
- package/dist/player.d.ts +13 -0
- package/dist/player.js +130 -11
- package/dist/types.d.ts +30 -0
- package/package.json +5 -1
|
@@ -0,0 +1,985 @@
|
|
|
1
|
+
import opentype from "opentype.js";
|
|
2
|
+
const SVG_NS = "http://www.w3.org/2000/svg";
|
|
3
|
+
const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
|
|
4
|
+
// ============================================================================
|
|
5
|
+
// Manim Rate Functions (ported from manim/utils/rate_functions.py)
|
|
6
|
+
// ============================================================================
|
|
7
|
+
/**
|
|
8
|
+
* Sigmoid function used by Manim's smooth rate function.
|
|
9
|
+
*/
|
|
10
|
+
function sigmoid(x) {
|
|
11
|
+
return 1 / (1 + Math.exp(-x));
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Manim's smooth rate function.
|
|
15
|
+
* Uses a sigmoid-based curve for smooth acceleration/deceleration.
|
|
16
|
+
* @param t - Progress value (0 to 1)
|
|
17
|
+
* @param inflection - Controls the steepness of the curve (default: 10)
|
|
18
|
+
*/
|
|
19
|
+
function smooth(t, inflection = 10) {
|
|
20
|
+
if (t <= 0)
|
|
21
|
+
return 0;
|
|
22
|
+
if (t >= 1)
|
|
23
|
+
return 1;
|
|
24
|
+
const error = sigmoid(-inflection / 2);
|
|
25
|
+
const value = (sigmoid(inflection * (t - 0.5)) - error) / (1 - 2 * error);
|
|
26
|
+
return Math.min(Math.max(value, 0), 1);
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Manim's double_smooth rate function.
|
|
30
|
+
* Applies smooth easing to both halves of the animation.
|
|
31
|
+
* Used by DrawBorderThenFill animation.
|
|
32
|
+
* @param t - Progress value (0 to 1)
|
|
33
|
+
*/
|
|
34
|
+
function doubleSmooth(t) {
|
|
35
|
+
if (t <= 0)
|
|
36
|
+
return 0;
|
|
37
|
+
if (t >= 1)
|
|
38
|
+
return 1;
|
|
39
|
+
if (t < 0.5) {
|
|
40
|
+
return 0.5 * smooth(2 * t);
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
return 0.5 * (1 + smooth(2 * t - 1));
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Linear rate function (no easing).
|
|
48
|
+
* Used by Write animation.
|
|
49
|
+
*/
|
|
50
|
+
function linear(t) {
|
|
51
|
+
if (t <= 0)
|
|
52
|
+
return 0;
|
|
53
|
+
if (t >= 1)
|
|
54
|
+
return 1;
|
|
55
|
+
return t;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Manim's integer_interpolate function.
|
|
59
|
+
* Returns (index, subalpha) where index is the current phase
|
|
60
|
+
* and subalpha is the progress within that phase.
|
|
61
|
+
*/
|
|
62
|
+
function integerInterpolate(start, end, alpha) {
|
|
63
|
+
if (alpha >= 1) {
|
|
64
|
+
return [end - 1, 1];
|
|
65
|
+
}
|
|
66
|
+
if (alpha <= 0) {
|
|
67
|
+
return [start, 0];
|
|
68
|
+
}
|
|
69
|
+
const numPhases = end - start;
|
|
70
|
+
const scaledAlpha = alpha * numPhases;
|
|
71
|
+
const index = Math.floor(scaledAlpha);
|
|
72
|
+
const subalpha = scaledAlpha - index;
|
|
73
|
+
return [start + index, subalpha];
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Parse a relative unit string (e.g., "100vh", "-50%") to pixels.
|
|
77
|
+
*/
|
|
78
|
+
function parseRelativeUnit(value, viewportHeight, elementHeight) {
|
|
79
|
+
if (typeof value === "number") {
|
|
80
|
+
return value;
|
|
81
|
+
}
|
|
82
|
+
const trimmed = value.trim();
|
|
83
|
+
if (trimmed.endsWith("vh")) {
|
|
84
|
+
const num = parseFloat(trimmed.slice(0, -2));
|
|
85
|
+
return (num / 100) * viewportHeight;
|
|
86
|
+
}
|
|
87
|
+
if (trimmed.endsWith("%")) {
|
|
88
|
+
const num = parseFloat(trimmed.slice(0, -1));
|
|
89
|
+
return (num / 100) * elementHeight;
|
|
90
|
+
}
|
|
91
|
+
if (trimmed.endsWith("px")) {
|
|
92
|
+
return parseFloat(trimmed.slice(0, -2));
|
|
93
|
+
}
|
|
94
|
+
return parseFloat(trimmed);
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Resolve a ScrollRangeValue to a normalized { start, end } object in pixels.
|
|
98
|
+
*/
|
|
99
|
+
function resolveScrollRange(range, viewportHeight, elementHeight, documentHeight) {
|
|
100
|
+
var _a, _b;
|
|
101
|
+
if (range === undefined || range === "viewport") {
|
|
102
|
+
return {
|
|
103
|
+
start: viewportHeight,
|
|
104
|
+
end: -elementHeight,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
if (range === "element") {
|
|
108
|
+
return {
|
|
109
|
+
start: viewportHeight * 0.8,
|
|
110
|
+
end: viewportHeight * 0.2 - elementHeight,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
if (range === "full") {
|
|
114
|
+
return {
|
|
115
|
+
start: documentHeight - viewportHeight,
|
|
116
|
+
end: 0,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
if (Array.isArray(range)) {
|
|
120
|
+
const [startVal, endVal] = range;
|
|
121
|
+
return {
|
|
122
|
+
start: parseRelativeUnit(startVal, viewportHeight, elementHeight),
|
|
123
|
+
end: parseRelativeUnit(endVal, viewportHeight, elementHeight),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
return {
|
|
127
|
+
start: (_a = range.start) !== null && _a !== void 0 ? _a : viewportHeight,
|
|
128
|
+
end: (_b = range.end) !== null && _b !== void 0 ? _b : -elementHeight,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
function resolveScrollProgress(rect, viewportHeight, range) {
|
|
132
|
+
var _a, _b;
|
|
133
|
+
const documentHeight = document.documentElement.scrollHeight;
|
|
134
|
+
const resolved = resolveScrollRange(range, viewportHeight, rect.height, documentHeight);
|
|
135
|
+
const start = (_a = resolved.start) !== null && _a !== void 0 ? _a : viewportHeight;
|
|
136
|
+
const end = (_b = resolved.end) !== null && _b !== void 0 ? _b : -rect.height;
|
|
137
|
+
const progress = (start - rect.top) / (start - end);
|
|
138
|
+
return clamp(progress, 0, 1);
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Parse SVG path data into individual drawing commands.
|
|
142
|
+
* This is more granular than contour splitting - each line/curve becomes a separate segment.
|
|
143
|
+
* This matches Manim's behavior where each stroke segment is animated independently.
|
|
144
|
+
*/
|
|
145
|
+
function parsePathCommands(pathData) {
|
|
146
|
+
const commands = [];
|
|
147
|
+
// Match SVG path commands: letter followed by numbers (with optional decimals and negatives)
|
|
148
|
+
const regex = /([MmLlHhVvCcSsQqTtAaZz])([^MmLlHhVvCcSsQqTtAaZz]*)/g;
|
|
149
|
+
let match;
|
|
150
|
+
while ((match = regex.exec(pathData)) !== null) {
|
|
151
|
+
const type = match[1];
|
|
152
|
+
const argsStr = match[2].trim();
|
|
153
|
+
// Parse the numeric arguments
|
|
154
|
+
const args = [];
|
|
155
|
+
if (argsStr) {
|
|
156
|
+
// Split on comma or whitespace, handling negative numbers
|
|
157
|
+
const numRegex = /-?[\d.]+(?:e[-+]?\d+)?/gi;
|
|
158
|
+
let numMatch;
|
|
159
|
+
while ((numMatch = numRegex.exec(argsStr)) !== null) {
|
|
160
|
+
args.push(parseFloat(numMatch[0]));
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
commands.push({ type, args });
|
|
164
|
+
}
|
|
165
|
+
return commands;
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Estimate the length of a path segment without creating an SVG element.
|
|
169
|
+
* This is approximate but fast for grouping purposes.
|
|
170
|
+
*/
|
|
171
|
+
function estimateSegmentLength(segment) {
|
|
172
|
+
// Extract start and end points from the segment
|
|
173
|
+
// Segments are in the form: M<x> <y><cmd><...args>
|
|
174
|
+
const moveMatch = segment.match(/^M([-\d.]+)\s+([-\d.]+)/);
|
|
175
|
+
if (!moveMatch)
|
|
176
|
+
return 0;
|
|
177
|
+
const startX = parseFloat(moveMatch[1]);
|
|
178
|
+
const startY = parseFloat(moveMatch[2]);
|
|
179
|
+
// Find the end point based on command type
|
|
180
|
+
let endX = startX, endY = startY;
|
|
181
|
+
if (segment.includes('L')) {
|
|
182
|
+
const lineMatch = segment.match(/L([-\d.]+)\s+([-\d.]+)/);
|
|
183
|
+
if (lineMatch) {
|
|
184
|
+
endX = parseFloat(lineMatch[1]);
|
|
185
|
+
endY = parseFloat(lineMatch[2]);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
else if (segment.includes('C')) {
|
|
189
|
+
// For cubic bezier, get the final point (last 2 numbers)
|
|
190
|
+
const nums = segment.match(/C([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)/);
|
|
191
|
+
if (nums) {
|
|
192
|
+
endX = parseFloat(nums[5]);
|
|
193
|
+
endY = parseFloat(nums[6]);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
else if (segment.includes('Q')) {
|
|
197
|
+
// For quadratic bezier, get the final point (last 2 numbers)
|
|
198
|
+
const nums = segment.match(/Q([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)/);
|
|
199
|
+
if (nums) {
|
|
200
|
+
endX = parseFloat(nums[3]);
|
|
201
|
+
endY = parseFloat(nums[4]);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
// Simple distance estimate (actual curve length is longer, but this is good enough for grouping)
|
|
205
|
+
return Math.sqrt((endX - startX) ** 2 + (endY - startY) ** 2);
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Get the start point of a segment.
|
|
209
|
+
*/
|
|
210
|
+
function getSegmentStart(segment) {
|
|
211
|
+
const match = segment.match(/^M([-\d.]+)\s+([-\d.]+)/);
|
|
212
|
+
if (!match)
|
|
213
|
+
return null;
|
|
214
|
+
return { x: parseFloat(match[1]), y: parseFloat(match[2]) };
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Get the end point of a segment.
|
|
218
|
+
*/
|
|
219
|
+
function getSegmentEnd(segment) {
|
|
220
|
+
const match = segment.match(/^M([-\d.]+)\s+([-\d.]+)/);
|
|
221
|
+
if (!match)
|
|
222
|
+
return null;
|
|
223
|
+
const startX = parseFloat(match[1]);
|
|
224
|
+
const startY = parseFloat(match[2]);
|
|
225
|
+
if (segment.includes('L')) {
|
|
226
|
+
const lineMatch = segment.match(/L([-\d.]+)\s+([-\d.]+)/);
|
|
227
|
+
if (lineMatch) {
|
|
228
|
+
return { x: parseFloat(lineMatch[1]), y: parseFloat(lineMatch[2]) };
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
else if (segment.includes('C')) {
|
|
232
|
+
const nums = segment.match(/C([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)/);
|
|
233
|
+
if (nums) {
|
|
234
|
+
return { x: parseFloat(nums[5]), y: parseFloat(nums[6]) };
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
else if (segment.includes('Q')) {
|
|
238
|
+
const nums = segment.match(/Q([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)/);
|
|
239
|
+
if (nums) {
|
|
240
|
+
return { x: parseFloat(nums[3]), y: parseFloat(nums[4]) };
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return { x: startX, y: startY };
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Combine multiple segments into a single path.
|
|
247
|
+
* Removes redundant M commands when segments are contiguous.
|
|
248
|
+
*/
|
|
249
|
+
function combineSegments(segments) {
|
|
250
|
+
if (segments.length === 0)
|
|
251
|
+
return "";
|
|
252
|
+
if (segments.length === 1)
|
|
253
|
+
return segments[0];
|
|
254
|
+
let combined = segments[0];
|
|
255
|
+
for (let i = 1; i < segments.length; i++) {
|
|
256
|
+
const seg = segments[i];
|
|
257
|
+
// Remove the M command from subsequent segments (they continue from previous endpoint)
|
|
258
|
+
const withoutMove = seg.replace(/^M[-\d.]+\s+[-\d.]+/, "");
|
|
259
|
+
combined += withoutMove;
|
|
260
|
+
}
|
|
261
|
+
return combined;
|
|
262
|
+
}
|
|
263
|
+
// Minimum segment length in pixels to be visible at the start of animation
|
|
264
|
+
const MIN_SEGMENT_LENGTH = 32;
|
|
265
|
+
/**
|
|
266
|
+
* Split an SVG path into individual stroke segments for animation.
|
|
267
|
+
* Each segment is a complete path that can be drawn independently.
|
|
268
|
+
* This matches Manim's behavior where strokes appear one at a time.
|
|
269
|
+
*
|
|
270
|
+
* Adjacent short segments are grouped together to ensure they're visually
|
|
271
|
+
* substantial when they first appear (avoiding tiny dots).
|
|
272
|
+
*/
|
|
273
|
+
function splitPathIntoSegments(pathData) {
|
|
274
|
+
const commands = parsePathCommands(pathData);
|
|
275
|
+
const rawSegments = [];
|
|
276
|
+
let currentX = 0;
|
|
277
|
+
let currentY = 0;
|
|
278
|
+
let startX = 0;
|
|
279
|
+
let startY = 0;
|
|
280
|
+
for (let i = 0; i < commands.length; i++) {
|
|
281
|
+
const cmd = commands[i];
|
|
282
|
+
const type = cmd.type;
|
|
283
|
+
const args = cmd.args;
|
|
284
|
+
switch (type) {
|
|
285
|
+
case 'M': // Absolute moveto
|
|
286
|
+
if (args.length >= 2) {
|
|
287
|
+
currentX = args[0];
|
|
288
|
+
currentY = args[1];
|
|
289
|
+
startX = currentX;
|
|
290
|
+
startY = currentY;
|
|
291
|
+
// Process implicit lineto commands after M
|
|
292
|
+
for (let j = 2; j < args.length; j += 2) {
|
|
293
|
+
const x = args[j];
|
|
294
|
+
const y = args[j + 1];
|
|
295
|
+
rawSegments.push(`M${currentX} ${currentY}L${x} ${y}`);
|
|
296
|
+
currentX = x;
|
|
297
|
+
currentY = y;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
break;
|
|
301
|
+
case 'm': // Relative moveto
|
|
302
|
+
if (args.length >= 2) {
|
|
303
|
+
currentX += args[0];
|
|
304
|
+
currentY += args[1];
|
|
305
|
+
startX = currentX;
|
|
306
|
+
startY = currentY;
|
|
307
|
+
for (let j = 2; j < args.length; j += 2) {
|
|
308
|
+
const x = currentX + args[j];
|
|
309
|
+
const y = currentY + args[j + 1];
|
|
310
|
+
rawSegments.push(`M${currentX} ${currentY}L${x} ${y}`);
|
|
311
|
+
currentX = x;
|
|
312
|
+
currentY = y;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
break;
|
|
316
|
+
case 'L': // Absolute lineto
|
|
317
|
+
for (let j = 0; j < args.length; j += 2) {
|
|
318
|
+
const x = args[j];
|
|
319
|
+
const y = args[j + 1];
|
|
320
|
+
rawSegments.push(`M${currentX} ${currentY}L${x} ${y}`);
|
|
321
|
+
currentX = x;
|
|
322
|
+
currentY = y;
|
|
323
|
+
}
|
|
324
|
+
break;
|
|
325
|
+
case 'l': // Relative lineto
|
|
326
|
+
for (let j = 0; j < args.length; j += 2) {
|
|
327
|
+
const x = currentX + args[j];
|
|
328
|
+
const y = currentY + args[j + 1];
|
|
329
|
+
rawSegments.push(`M${currentX} ${currentY}L${x} ${y}`);
|
|
330
|
+
currentX = x;
|
|
331
|
+
currentY = y;
|
|
332
|
+
}
|
|
333
|
+
break;
|
|
334
|
+
case 'H': // Absolute horizontal lineto
|
|
335
|
+
for (const x of args) {
|
|
336
|
+
rawSegments.push(`M${currentX} ${currentY}L${x} ${currentY}`);
|
|
337
|
+
currentX = x;
|
|
338
|
+
}
|
|
339
|
+
break;
|
|
340
|
+
case 'h': // Relative horizontal lineto
|
|
341
|
+
for (const dx of args) {
|
|
342
|
+
const x = currentX + dx;
|
|
343
|
+
rawSegments.push(`M${currentX} ${currentY}L${x} ${currentY}`);
|
|
344
|
+
currentX = x;
|
|
345
|
+
}
|
|
346
|
+
break;
|
|
347
|
+
case 'V': // Absolute vertical lineto
|
|
348
|
+
for (const y of args) {
|
|
349
|
+
rawSegments.push(`M${currentX} ${currentY}L${currentX} ${y}`);
|
|
350
|
+
currentY = y;
|
|
351
|
+
}
|
|
352
|
+
break;
|
|
353
|
+
case 'v': // Relative vertical lineto
|
|
354
|
+
for (const dy of args) {
|
|
355
|
+
const y = currentY + dy;
|
|
356
|
+
rawSegments.push(`M${currentX} ${currentY}L${currentX} ${y}`);
|
|
357
|
+
currentY = y;
|
|
358
|
+
}
|
|
359
|
+
break;
|
|
360
|
+
case 'C': // Absolute cubic bezier
|
|
361
|
+
for (let j = 0; j < args.length; j += 6) {
|
|
362
|
+
const x1 = args[j], y1 = args[j + 1];
|
|
363
|
+
const x2 = args[j + 2], y2 = args[j + 3];
|
|
364
|
+
const x = args[j + 4], y = args[j + 5];
|
|
365
|
+
rawSegments.push(`M${currentX} ${currentY}C${x1} ${y1} ${x2} ${y2} ${x} ${y}`);
|
|
366
|
+
currentX = x;
|
|
367
|
+
currentY = y;
|
|
368
|
+
}
|
|
369
|
+
break;
|
|
370
|
+
case 'c': // Relative cubic bezier
|
|
371
|
+
for (let j = 0; j < args.length; j += 6) {
|
|
372
|
+
const x1 = currentX + args[j], y1 = currentY + args[j + 1];
|
|
373
|
+
const x2 = currentX + args[j + 2], y2 = currentY + args[j + 3];
|
|
374
|
+
const x = currentX + args[j + 4], y = currentY + args[j + 5];
|
|
375
|
+
rawSegments.push(`M${currentX} ${currentY}C${x1} ${y1} ${x2} ${y2} ${x} ${y}`);
|
|
376
|
+
currentX = x;
|
|
377
|
+
currentY = y;
|
|
378
|
+
}
|
|
379
|
+
break;
|
|
380
|
+
case 'Q': // Absolute quadratic bezier
|
|
381
|
+
for (let j = 0; j < args.length; j += 4) {
|
|
382
|
+
const x1 = args[j], y1 = args[j + 1];
|
|
383
|
+
const x = args[j + 2], y = args[j + 3];
|
|
384
|
+
rawSegments.push(`M${currentX} ${currentY}Q${x1} ${y1} ${x} ${y}`);
|
|
385
|
+
currentX = x;
|
|
386
|
+
currentY = y;
|
|
387
|
+
}
|
|
388
|
+
break;
|
|
389
|
+
case 'q': // Relative quadratic bezier
|
|
390
|
+
for (let j = 0; j < args.length; j += 4) {
|
|
391
|
+
const x1 = currentX + args[j], y1 = currentY + args[j + 1];
|
|
392
|
+
const x = currentX + args[j + 2], y = currentY + args[j + 3];
|
|
393
|
+
rawSegments.push(`M${currentX} ${currentY}Q${x1} ${y1} ${x} ${y}`);
|
|
394
|
+
currentX = x;
|
|
395
|
+
currentY = y;
|
|
396
|
+
}
|
|
397
|
+
break;
|
|
398
|
+
case 'Z':
|
|
399
|
+
case 'z': // Closepath - draw line back to start
|
|
400
|
+
if (currentX !== startX || currentY !== startY) {
|
|
401
|
+
rawSegments.push(`M${currentX} ${currentY}L${startX} ${startY}`);
|
|
402
|
+
}
|
|
403
|
+
currentX = startX;
|
|
404
|
+
currentY = startY;
|
|
405
|
+
break;
|
|
406
|
+
// S, s, T, t, A, a are less common - add if needed
|
|
407
|
+
case 'S': // Smooth cubic bezier
|
|
408
|
+
for (let j = 0; j < args.length; j += 4) {
|
|
409
|
+
const x2 = args[j], y2 = args[j + 1];
|
|
410
|
+
const x = args[j + 2], y = args[j + 3];
|
|
411
|
+
// For smooth curves, control point is reflection of previous
|
|
412
|
+
rawSegments.push(`M${currentX} ${currentY}C${currentX} ${currentY} ${x2} ${y2} ${x} ${y}`);
|
|
413
|
+
currentX = x;
|
|
414
|
+
currentY = y;
|
|
415
|
+
}
|
|
416
|
+
break;
|
|
417
|
+
case 's': // Relative smooth cubic bezier
|
|
418
|
+
for (let j = 0; j < args.length; j += 4) {
|
|
419
|
+
const x2 = currentX + args[j], y2 = currentY + args[j + 1];
|
|
420
|
+
const x = currentX + args[j + 2], y = currentY + args[j + 3];
|
|
421
|
+
rawSegments.push(`M${currentX} ${currentY}C${currentX} ${currentY} ${x2} ${y2} ${x} ${y}`);
|
|
422
|
+
currentX = x;
|
|
423
|
+
currentY = y;
|
|
424
|
+
}
|
|
425
|
+
break;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
// Group adjacent short segments together until they meet minimum length
|
|
429
|
+
const groupedSegments = [];
|
|
430
|
+
let currentGroup = [];
|
|
431
|
+
let currentGroupLength = 0;
|
|
432
|
+
for (let i = 0; i < rawSegments.length; i++) {
|
|
433
|
+
const seg = rawSegments[i];
|
|
434
|
+
const segLength = estimateSegmentLength(seg);
|
|
435
|
+
// Check if this segment connects to the previous one (contiguous)
|
|
436
|
+
let isContiguous = false;
|
|
437
|
+
if (currentGroup.length > 0) {
|
|
438
|
+
const prevEnd = getSegmentEnd(currentGroup[currentGroup.length - 1]);
|
|
439
|
+
const currStart = getSegmentStart(seg);
|
|
440
|
+
if (prevEnd && currStart) {
|
|
441
|
+
const dist = Math.sqrt((currStart.x - prevEnd.x) ** 2 + (currStart.y - prevEnd.y) ** 2);
|
|
442
|
+
isContiguous = dist < 0.1; // Allow tiny floating point differences
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
if (currentGroup.length === 0) {
|
|
446
|
+
// Start a new group
|
|
447
|
+
currentGroup.push(seg);
|
|
448
|
+
currentGroupLength = segLength;
|
|
449
|
+
}
|
|
450
|
+
else if (isContiguous && currentGroupLength < MIN_SEGMENT_LENGTH) {
|
|
451
|
+
// Add to current group (still building up to minimum length)
|
|
452
|
+
currentGroup.push(seg);
|
|
453
|
+
currentGroupLength += segLength;
|
|
454
|
+
}
|
|
455
|
+
else if (isContiguous && segLength < MIN_SEGMENT_LENGTH && currentGroupLength + segLength < MIN_SEGMENT_LENGTH * 3) {
|
|
456
|
+
// This segment is too small on its own, add it to current group
|
|
457
|
+
currentGroup.push(seg);
|
|
458
|
+
currentGroupLength += segLength;
|
|
459
|
+
}
|
|
460
|
+
else {
|
|
461
|
+
// Finalize current group and start a new one
|
|
462
|
+
if (currentGroup.length > 0) {
|
|
463
|
+
groupedSegments.push(combineSegments(currentGroup));
|
|
464
|
+
}
|
|
465
|
+
currentGroup = [seg];
|
|
466
|
+
currentGroupLength = segLength;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
// Don't forget the last group
|
|
470
|
+
if (currentGroup.length > 0) {
|
|
471
|
+
groupedSegments.push(combineSegments(currentGroup));
|
|
472
|
+
}
|
|
473
|
+
return groupedSegments;
|
|
474
|
+
}
|
|
475
|
+
/**
|
|
476
|
+
* NativeTextPlayer - Renders text animation natively in the browser
|
|
477
|
+
* using SVG paths, replicating Manim's Write/DrawBorderThenFill animation.
|
|
478
|
+
*
|
|
479
|
+
* Phase 1 (progress 0 to 0.5): Draw the stroke progressively
|
|
480
|
+
* Phase 2 (progress 0.5 to 1.0): Fill in the text
|
|
481
|
+
*
|
|
482
|
+
* Key difference from naive implementations: we split each character into
|
|
483
|
+
* individual contours (sub-paths) and apply the lag_ratio to ALL contours
|
|
484
|
+
* across all characters. This matches Manim's behavior where outlines
|
|
485
|
+
* appear progressively rather than all at once.
|
|
486
|
+
*/
|
|
487
|
+
export class NativeTextPlayer {
|
|
488
|
+
constructor(options) {
|
|
489
|
+
this.svg = null;
|
|
490
|
+
this.fallbackWrapper = null;
|
|
491
|
+
/** All sub-paths (segments) across all characters, for stroke animation */
|
|
492
|
+
this.subPaths = [];
|
|
493
|
+
/** Fill paths (original closed contours) for the filled state */
|
|
494
|
+
this.fillPaths = [];
|
|
495
|
+
this.isActive = false;
|
|
496
|
+
this.rafId = null;
|
|
497
|
+
this.lastProgress = -1;
|
|
498
|
+
this.pendingDraw = false;
|
|
499
|
+
this.pendingResize = false;
|
|
500
|
+
this.font = null;
|
|
501
|
+
/** Last known font size, used to detect changes for inherited sizing */
|
|
502
|
+
this.lastComputedFontSize = 0;
|
|
503
|
+
// Manim defaults: DrawBorderThenFill stroke_width = 2
|
|
504
|
+
// fontSize: undefined means inherit from parent element
|
|
505
|
+
this.options = {
|
|
506
|
+
color: "#ffffff",
|
|
507
|
+
strokeWidth: 2, // Manim's DrawBorderThenFill default
|
|
508
|
+
...options,
|
|
509
|
+
};
|
|
510
|
+
this.container = options.container;
|
|
511
|
+
}
|
|
512
|
+
async init() {
|
|
513
|
+
var _a, _b;
|
|
514
|
+
// Load font
|
|
515
|
+
if (this.options.fontUrl) {
|
|
516
|
+
this.font = await opentype.load(this.options.fontUrl);
|
|
517
|
+
}
|
|
518
|
+
else {
|
|
519
|
+
// Use a default system font path - for now we'll create simple text paths
|
|
520
|
+
// In production, you'd bundle a default font or use a CDN
|
|
521
|
+
// For now, fallback to creating paths from text metrics
|
|
522
|
+
this.font = null;
|
|
523
|
+
}
|
|
524
|
+
// Track initial font size for inherited sizing
|
|
525
|
+
this.lastComputedFontSize = this.getInheritedFontSize();
|
|
526
|
+
// Create SVG container
|
|
527
|
+
this.svg = document.createElementNS(SVG_NS, "svg");
|
|
528
|
+
this.svg.style.overflow = "visible";
|
|
529
|
+
// Use inline display to flow naturally with surrounding text
|
|
530
|
+
this.svg.style.display = "inline";
|
|
531
|
+
// Vertical alignment will be set after font metrics are calculated
|
|
532
|
+
// Create paths for each character
|
|
533
|
+
await this.createCharacterPaths();
|
|
534
|
+
// Add SVG to container (only if still using SVG mode, not fallback)
|
|
535
|
+
if (this.svg) {
|
|
536
|
+
this.container.appendChild(this.svg);
|
|
537
|
+
}
|
|
538
|
+
// Setup intersection observer
|
|
539
|
+
this.setupObserver();
|
|
540
|
+
// Setup resize handling for responsiveness
|
|
541
|
+
this.setupResizeHandling();
|
|
542
|
+
// Draw initial state (progress = 0)
|
|
543
|
+
this.render(0);
|
|
544
|
+
(_b = (_a = this.options).onReady) === null || _b === void 0 ? void 0 : _b.call(_a);
|
|
545
|
+
}
|
|
546
|
+
async createCharacterPaths() {
|
|
547
|
+
var _a, _b;
|
|
548
|
+
if (!this.svg)
|
|
549
|
+
return;
|
|
550
|
+
const text = this.options.text;
|
|
551
|
+
// If fontSize is not specified, inherit from the container's computed style
|
|
552
|
+
const fontSize = (_a = this.options.fontSize) !== null && _a !== void 0 ? _a : this.getInheritedFontSize();
|
|
553
|
+
const color = this.options.color;
|
|
554
|
+
const strokeWidth = this.options.strokeWidth;
|
|
555
|
+
let currentX = 0;
|
|
556
|
+
const allSubPaths = [];
|
|
557
|
+
const allFillPaths = [];
|
|
558
|
+
let charIndex = 0;
|
|
559
|
+
if (this.font) {
|
|
560
|
+
// Use opentype.js to convert text to paths
|
|
561
|
+
for (const char of text) {
|
|
562
|
+
if (char === " ") {
|
|
563
|
+
// Handle space - just advance x position
|
|
564
|
+
const glyph = this.font.charToGlyph(char);
|
|
565
|
+
const scale = fontSize / this.font.unitsPerEm;
|
|
566
|
+
currentX += (glyph.advanceWidth || this.font.unitsPerEm * 0.3) * scale;
|
|
567
|
+
charIndex++;
|
|
568
|
+
continue;
|
|
569
|
+
}
|
|
570
|
+
const path = this.font.getPath(char, currentX, fontSize, fontSize);
|
|
571
|
+
const pathData = path.toPathData(2);
|
|
572
|
+
if (!pathData || pathData === "M0 0") {
|
|
573
|
+
// Empty glyph, skip
|
|
574
|
+
const glyph = this.font.charToGlyph(char);
|
|
575
|
+
const scale = fontSize / this.font.unitsPerEm;
|
|
576
|
+
currentX += (glyph.advanceWidth || fontSize * 0.5) * scale;
|
|
577
|
+
charIndex++;
|
|
578
|
+
continue;
|
|
579
|
+
}
|
|
580
|
+
// Create fill path (original closed contours) - hidden initially
|
|
581
|
+
// This will be shown during the fill phase
|
|
582
|
+
const fillPath = document.createElementNS(SVG_NS, "path");
|
|
583
|
+
fillPath.setAttribute("d", pathData);
|
|
584
|
+
fillPath.setAttribute("fill", color);
|
|
585
|
+
fillPath.setAttribute("stroke", "none");
|
|
586
|
+
fillPath.style.opacity = "0"; // Hidden initially
|
|
587
|
+
this.svg.appendChild(fillPath);
|
|
588
|
+
allFillPaths.push({ element: fillPath, charIndex });
|
|
589
|
+
// Split the character's path into individual stroke segments
|
|
590
|
+
// Each segment is a single line/curve that can be animated independently
|
|
591
|
+
// This matches Manim's behavior where strokes appear one at a time
|
|
592
|
+
const segments = splitPathIntoSegments(pathData);
|
|
593
|
+
for (const segmentData of segments) {
|
|
594
|
+
const svgPath = document.createElementNS(SVG_NS, "path");
|
|
595
|
+
svgPath.setAttribute("d", segmentData);
|
|
596
|
+
svgPath.setAttribute("fill", "none"); // Segments are strokes only
|
|
597
|
+
svgPath.setAttribute("stroke", color);
|
|
598
|
+
svgPath.setAttribute("stroke-width", String(strokeWidth));
|
|
599
|
+
svgPath.setAttribute("stroke-linecap", "round");
|
|
600
|
+
svgPath.setAttribute("stroke-linejoin", "round");
|
|
601
|
+
this.svg.appendChild(svgPath);
|
|
602
|
+
// Get path length for stroke animation
|
|
603
|
+
const pathLength = svgPath.getTotalLength();
|
|
604
|
+
// Skip very short segments (less than minimum visible length)
|
|
605
|
+
// This should rarely happen now since we group short segments together
|
|
606
|
+
if (pathLength < MIN_SEGMENT_LENGTH / 2) {
|
|
607
|
+
svgPath.remove();
|
|
608
|
+
continue;
|
|
609
|
+
}
|
|
610
|
+
svgPath.style.strokeDasharray = String(pathLength);
|
|
611
|
+
svgPath.style.strokeDashoffset = String(pathLength);
|
|
612
|
+
// Extract starting x-coordinate for sorting
|
|
613
|
+
const segmentStart = getSegmentStart(segmentData);
|
|
614
|
+
const startX = (_b = segmentStart === null || segmentStart === void 0 ? void 0 : segmentStart.x) !== null && _b !== void 0 ? _b : currentX;
|
|
615
|
+
allSubPaths.push({
|
|
616
|
+
element: svgPath,
|
|
617
|
+
pathLength,
|
|
618
|
+
charIndex,
|
|
619
|
+
startX,
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
const glyph = this.font.charToGlyph(char);
|
|
623
|
+
const scale = fontSize / this.font.unitsPerEm;
|
|
624
|
+
const charWidth = (glyph.advanceWidth || fontSize * 0.5) * scale;
|
|
625
|
+
currentX += charWidth;
|
|
626
|
+
charIndex++;
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
else {
|
|
630
|
+
// Fallback: Create simple rectangles or use CSS text
|
|
631
|
+
// This is a simplified fallback when no font is loaded
|
|
632
|
+
// Pass the original fontSize option (may be undefined for inheritance)
|
|
633
|
+
this.createFallbackTextAnimation(text, this.options.fontSize, color);
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
// Sort segments by x-coordinate for left-to-right animation order
|
|
637
|
+
// This matches Manim's behavior where strokes appear progressively left-to-right
|
|
638
|
+
allSubPaths.sort((a, b) => a.startX - b.startX);
|
|
639
|
+
this.subPaths = allSubPaths;
|
|
640
|
+
this.fillPaths = allFillPaths;
|
|
641
|
+
// Update SVG viewBox to fit content
|
|
642
|
+
// The y-coordinate in opentype.js uses baseline as y=0, with glyphs drawn upward (negative y)
|
|
643
|
+
// We need to account for both ascender (above baseline) and descender (below baseline)
|
|
644
|
+
const unitsPerEm = this.font.unitsPerEm;
|
|
645
|
+
const ascender = (this.font.ascender / unitsPerEm) * fontSize;
|
|
646
|
+
const descender = (this.font.descender / unitsPerEm) * fontSize;
|
|
647
|
+
const totalHeight = ascender - descender; // descender is typically negative
|
|
648
|
+
// viewBox: x, y (top-left), width, height
|
|
649
|
+
// y starts at negative ascender to capture everything above baseline
|
|
650
|
+
// The baseline is at y=fontSize in the path coordinates
|
|
651
|
+
const viewBoxY = fontSize - ascender;
|
|
652
|
+
const viewBoxHeight = totalHeight;
|
|
653
|
+
this.svg.setAttribute("viewBox", `0 ${viewBoxY} ${currentX} ${viewBoxHeight}`);
|
|
654
|
+
this.svg.setAttribute("preserveAspectRatio", "xMinYMid meet");
|
|
655
|
+
// Calculate font metrics as ratios for proper sizing
|
|
656
|
+
// ascenderRatio: how much of the font height is above the baseline
|
|
657
|
+
// descenderRatio: how much is below (descender is negative, so we negate it)
|
|
658
|
+
const ascenderRatio = this.font.ascender / unitsPerEm;
|
|
659
|
+
const descenderRatio = -this.font.descender / unitsPerEm;
|
|
660
|
+
const totalHeightRatio = ascenderRatio + descenderRatio;
|
|
661
|
+
// Set explicit width/height based on content
|
|
662
|
+
// The SVG height should match the full font metrics (not just 1em) to avoid scaling
|
|
663
|
+
// This ensures the rendered text is the same size as surrounding text
|
|
664
|
+
if (this.options.fontSize) {
|
|
665
|
+
// Explicit fontSize: use pixel values for accurate sizing
|
|
666
|
+
this.svg.style.height = `${totalHeight}px`;
|
|
667
|
+
this.svg.style.width = `${currentX}px`;
|
|
668
|
+
// Align baseline: move SVG down by descender height
|
|
669
|
+
// With vertical-align: baseline, the SVG's bottom aligns with text baseline,
|
|
670
|
+
// but our internal baseline is descenderHeight above the bottom
|
|
671
|
+
this.svg.style.verticalAlign = `-${descenderRatio * fontSize}px`;
|
|
672
|
+
}
|
|
673
|
+
else {
|
|
674
|
+
// Inherited fontSize: use em units to scale with parent
|
|
675
|
+
// Height is the full font metric height (ascender + descender extent)
|
|
676
|
+
this.svg.style.height = `${totalHeightRatio}em`;
|
|
677
|
+
// Width is the text width relative to the font size
|
|
678
|
+
this.svg.style.width = `${currentX / fontSize}em`;
|
|
679
|
+
// Align baseline: offset by descender ratio
|
|
680
|
+
this.svg.style.verticalAlign = `-${descenderRatio}em`;
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
/**
|
|
684
|
+
* Get the inherited font size from the container's computed style.
|
|
685
|
+
*/
|
|
686
|
+
getInheritedFontSize() {
|
|
687
|
+
const computed = window.getComputedStyle(this.container);
|
|
688
|
+
return parseFloat(computed.fontSize) || 16; // Fallback to browser default
|
|
689
|
+
}
|
|
690
|
+
createFallbackTextAnimation(text, fontSize, color) {
|
|
691
|
+
// Fallback using HTML/CSS when opentype.js font is not available
|
|
692
|
+
// Creates character-by-character opacity animation
|
|
693
|
+
if (!this.svg)
|
|
694
|
+
return;
|
|
695
|
+
// Remove SVG and use a div-based approach
|
|
696
|
+
this.svg.remove();
|
|
697
|
+
this.svg = null;
|
|
698
|
+
const wrapper = document.createElement("span");
|
|
699
|
+
wrapper.style.display = "inline";
|
|
700
|
+
// If fontSize is undefined, inherit from parent; otherwise use the specified value
|
|
701
|
+
wrapper.style.fontSize = fontSize !== undefined ? `${fontSize}px` : "inherit";
|
|
702
|
+
wrapper.style.color = color;
|
|
703
|
+
wrapper.style.fontFamily = "inherit";
|
|
704
|
+
// Use white-space: pre to preserve spaces and ensure proper text flow
|
|
705
|
+
wrapper.style.whiteSpace = "pre";
|
|
706
|
+
let charIndex = 0;
|
|
707
|
+
for (const char of text) {
|
|
708
|
+
const span = document.createElement("span");
|
|
709
|
+
span.textContent = char;
|
|
710
|
+
// Use inline display to flow naturally with surrounding text
|
|
711
|
+
span.style.display = "inline";
|
|
712
|
+
span.style.transition = "none"; // We control the animation manually
|
|
713
|
+
if (char === " ") {
|
|
714
|
+
// Space characters: preserve the space, start fully visible
|
|
715
|
+
// Spaces don't need animation - they're just spacing
|
|
716
|
+
span.style.opacity = "1";
|
|
717
|
+
}
|
|
718
|
+
else {
|
|
719
|
+
// Non-space characters: animate them
|
|
720
|
+
span.style.opacity = "0";
|
|
721
|
+
// Add a subtle scale effect for the "write" feel (only for non-spaces)
|
|
722
|
+
span.style.display = "inline-block";
|
|
723
|
+
span.style.transform = "scale(0.8)";
|
|
724
|
+
span.style.transformOrigin = "center baseline";
|
|
725
|
+
}
|
|
726
|
+
wrapper.appendChild(span);
|
|
727
|
+
// Only add non-space characters to subPaths for animation
|
|
728
|
+
if (char !== " ") {
|
|
729
|
+
this.subPaths.push({
|
|
730
|
+
element: span,
|
|
731
|
+
pathLength: 1,
|
|
732
|
+
charIndex,
|
|
733
|
+
startX: charIndex, // Use charIndex as proxy for x position in fallback mode
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
charIndex++;
|
|
737
|
+
}
|
|
738
|
+
this.fallbackWrapper = wrapper;
|
|
739
|
+
this.container.appendChild(wrapper);
|
|
740
|
+
}
|
|
741
|
+
render(progress) {
|
|
742
|
+
const n = this.subPaths.length;
|
|
743
|
+
if (n === 0)
|
|
744
|
+
return;
|
|
745
|
+
// Manim's Write animation lag_ratio formula: min(4.0 / max(1.0, length), 0.2)
|
|
746
|
+
// This creates a staggered effect where each sub-path starts slightly after the previous.
|
|
747
|
+
// By applying this to ALL sub-paths (contours) across all characters, we replicate
|
|
748
|
+
// Manim's behavior where outlines don't all appear at once.
|
|
749
|
+
const lagRatio = Math.min(4.0 / Math.max(1.0, n), 0.2);
|
|
750
|
+
// Manim's exact formula from Animation.get_sub_alpha():
|
|
751
|
+
// full_length = (num_submobjects - 1) * lag_ratio + 1
|
|
752
|
+
// sub_alpha = clip((alpha * full_length - index * lag_ratio), 0, 1)
|
|
753
|
+
const fullLength = (n - 1) * lagRatio + 1;
|
|
754
|
+
// Track max progress for each character's fill animation
|
|
755
|
+
const charFillProgress = {};
|
|
756
|
+
for (let i = 0; i < n; i++) {
|
|
757
|
+
const subPath = this.subPaths[i];
|
|
758
|
+
const element = subPath.element;
|
|
759
|
+
// Calculate per-subpath progress using Manim's exact formula
|
|
760
|
+
// This ensures proper staggering where each sub-path starts after the previous
|
|
761
|
+
// has progressed by lag_ratio amount
|
|
762
|
+
const rawSubPathProgress = clamp(progress * fullLength - i * lagRatio, 0, 1);
|
|
763
|
+
// Manim's Write animation uses linear rate function (not double_smooth)
|
|
764
|
+
// double_smooth is only used for standalone DrawBorderThenFill
|
|
765
|
+
// For Write (text animation), we use linear progression
|
|
766
|
+
const subPathProgress = linear(rawSubPathProgress);
|
|
767
|
+
// Use Manim's integer_interpolate to determine phase and subalpha
|
|
768
|
+
// Phase 0 (first half): Draw the stroke/outline progressively
|
|
769
|
+
// Phase 1 (second half): Interpolate from outline to filled
|
|
770
|
+
const [phaseIndex, subalpha] = integerInterpolate(0, 2, subPathProgress);
|
|
771
|
+
// Check if this is an SVG path or HTML span (fallback mode)
|
|
772
|
+
if (element instanceof SVGPathElement) {
|
|
773
|
+
const pathLength = subPath.pathLength;
|
|
774
|
+
if (phaseIndex === 0) {
|
|
775
|
+
// Phase 0: Draw stroke progressively (pointwise_become_partial equivalent)
|
|
776
|
+
element.style.strokeDashoffset = String(pathLength * (1 - subalpha));
|
|
777
|
+
element.style.strokeOpacity = "1";
|
|
778
|
+
}
|
|
779
|
+
else {
|
|
780
|
+
// Phase 1: Stroke fully drawn, fade out as fill takes over
|
|
781
|
+
element.style.strokeDashoffset = "0";
|
|
782
|
+
element.style.strokeOpacity = String(1 - subalpha);
|
|
783
|
+
}
|
|
784
|
+
// Track max fill progress for this character
|
|
785
|
+
if (!charFillProgress[subPath.charIndex] || subPathProgress > charFillProgress[subPath.charIndex]) {
|
|
786
|
+
charFillProgress[subPath.charIndex] = subPathProgress;
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
else if (element instanceof HTMLSpanElement) {
|
|
790
|
+
// Fallback mode: Replicate DrawBorderThenFill visually with CSS
|
|
791
|
+
// Since we can't draw strokes on HTML text, we simulate with opacity and transform
|
|
792
|
+
if (phaseIndex === 0) {
|
|
793
|
+
// Phase 0: "Drawing" effect - fade in with slight scale
|
|
794
|
+
const drawProgress = smooth(subalpha);
|
|
795
|
+
element.style.opacity = String(0.3 + 0.3 * drawProgress); // Partial visibility
|
|
796
|
+
element.style.transform = `scale(${0.95 + 0.05 * drawProgress})`;
|
|
797
|
+
// Add a text-stroke effect to simulate outline phase
|
|
798
|
+
element.style.webkitTextStroke = `1px ${this.options.color}`;
|
|
799
|
+
element.style.color = "transparent";
|
|
800
|
+
}
|
|
801
|
+
else {
|
|
802
|
+
// Phase 1: Fill in - full opacity, color fills in
|
|
803
|
+
const fillProgress = smooth(subalpha);
|
|
804
|
+
element.style.opacity = String(0.6 + 0.4 * fillProgress);
|
|
805
|
+
element.style.transform = "scale(1)";
|
|
806
|
+
// Transition from stroke-only to filled
|
|
807
|
+
const strokeOpacity = 1 - fillProgress;
|
|
808
|
+
if (strokeOpacity > 0.01) {
|
|
809
|
+
element.style.webkitTextStroke = `${strokeOpacity}px ${this.options.color}`;
|
|
810
|
+
}
|
|
811
|
+
else {
|
|
812
|
+
element.style.webkitTextStroke = "0";
|
|
813
|
+
}
|
|
814
|
+
element.style.color = this.options.color;
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
// Update fill paths based on character progress
|
|
819
|
+
// Fill starts appearing when a character enters phase 1 (progress > 0.5)
|
|
820
|
+
for (const fillPath of this.fillPaths) {
|
|
821
|
+
const charProgress = charFillProgress[fillPath.charIndex] || 0;
|
|
822
|
+
// Use integer_interpolate to get the fill phase
|
|
823
|
+
const [fillPhase, fillSubalpha] = integerInterpolate(0, 2, charProgress);
|
|
824
|
+
if (fillPhase === 0) {
|
|
825
|
+
// Still in stroke phase, fill hidden
|
|
826
|
+
fillPath.element.style.opacity = "0";
|
|
827
|
+
}
|
|
828
|
+
else {
|
|
829
|
+
// In fill phase, fade in the fill
|
|
830
|
+
fillPath.element.style.opacity = String(fillSubalpha);
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
destroy() {
|
|
835
|
+
var _a, _b;
|
|
836
|
+
this.stop();
|
|
837
|
+
(_a = this.observer) === null || _a === void 0 ? void 0 : _a.disconnect();
|
|
838
|
+
(_b = this.resizeObserver) === null || _b === void 0 ? void 0 : _b.disconnect();
|
|
839
|
+
if (this.resizeHandler) {
|
|
840
|
+
window.removeEventListener("resize", this.resizeHandler);
|
|
841
|
+
this.resizeHandler = undefined;
|
|
842
|
+
}
|
|
843
|
+
if (this.svg) {
|
|
844
|
+
this.svg.remove();
|
|
845
|
+
this.svg = null;
|
|
846
|
+
}
|
|
847
|
+
if (this.fallbackWrapper) {
|
|
848
|
+
this.fallbackWrapper.remove();
|
|
849
|
+
this.fallbackWrapper = null;
|
|
850
|
+
}
|
|
851
|
+
this.subPaths = [];
|
|
852
|
+
this.fillPaths = [];
|
|
853
|
+
}
|
|
854
|
+
setupObserver() {
|
|
855
|
+
this.observer = new IntersectionObserver((entries) => {
|
|
856
|
+
for (const entry of entries) {
|
|
857
|
+
if (entry.isIntersecting) {
|
|
858
|
+
this.start();
|
|
859
|
+
}
|
|
860
|
+
else {
|
|
861
|
+
this.stop();
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
}, { root: null, threshold: 0 });
|
|
865
|
+
this.observer.observe(this.container);
|
|
866
|
+
}
|
|
867
|
+
/**
|
|
868
|
+
* Set up resize handling for responsive behavior:
|
|
869
|
+
* 1. Window resize - recalculate scroll progress (viewport height changes)
|
|
870
|
+
* 2. Container resize - detect font-size changes when using inherited sizing
|
|
871
|
+
*/
|
|
872
|
+
setupResizeHandling() {
|
|
873
|
+
// Window resize handler - recalculate scroll progress
|
|
874
|
+
this.resizeHandler = () => {
|
|
875
|
+
if (!this.pendingResize) {
|
|
876
|
+
this.pendingResize = true;
|
|
877
|
+
requestAnimationFrame(() => {
|
|
878
|
+
this.pendingResize = false;
|
|
879
|
+
// Force recalculation of scroll progress
|
|
880
|
+
this.lastProgress = -1;
|
|
881
|
+
if (this.isActive) {
|
|
882
|
+
this.tick();
|
|
883
|
+
}
|
|
884
|
+
});
|
|
885
|
+
}
|
|
886
|
+
};
|
|
887
|
+
window.addEventListener("resize", this.resizeHandler, { passive: true });
|
|
888
|
+
// ResizeObserver for container - detect font-size changes when inherited
|
|
889
|
+
// Only needed when fontSize is not explicitly set
|
|
890
|
+
if (this.options.fontSize === undefined) {
|
|
891
|
+
this.resizeObserver = new ResizeObserver(() => {
|
|
892
|
+
// Check if computed font size has changed
|
|
893
|
+
const currentFontSize = this.getInheritedFontSize();
|
|
894
|
+
if (Math.abs(currentFontSize - this.lastComputedFontSize) > 0.5) {
|
|
895
|
+
this.lastComputedFontSize = currentFontSize;
|
|
896
|
+
// Rebuild the animation with new font size
|
|
897
|
+
this.rebuildAnimation();
|
|
898
|
+
}
|
|
899
|
+
});
|
|
900
|
+
this.resizeObserver.observe(this.container);
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
/**
|
|
904
|
+
* Rebuild the animation when font size changes (for inherited sizing).
|
|
905
|
+
* This clears and recreates all character paths with the new size.
|
|
906
|
+
*/
|
|
907
|
+
async rebuildAnimation() {
|
|
908
|
+
// Store current progress to restore after rebuild
|
|
909
|
+
const currentProgress = this.lastProgress;
|
|
910
|
+
// Clear existing paths
|
|
911
|
+
if (this.svg) {
|
|
912
|
+
this.svg.remove();
|
|
913
|
+
this.svg = document.createElementNS(SVG_NS, "svg");
|
|
914
|
+
this.svg.style.overflow = "visible";
|
|
915
|
+
this.svg.style.display = "inline";
|
|
916
|
+
}
|
|
917
|
+
if (this.fallbackWrapper) {
|
|
918
|
+
this.fallbackWrapper.remove();
|
|
919
|
+
this.fallbackWrapper = null;
|
|
920
|
+
}
|
|
921
|
+
this.subPaths = [];
|
|
922
|
+
this.fillPaths = [];
|
|
923
|
+
// Recreate paths with new font size
|
|
924
|
+
await this.createCharacterPaths();
|
|
925
|
+
// Re-add SVG to container
|
|
926
|
+
if (this.svg) {
|
|
927
|
+
this.container.appendChild(this.svg);
|
|
928
|
+
}
|
|
929
|
+
// Restore progress
|
|
930
|
+
if (currentProgress >= 0) {
|
|
931
|
+
this.lastProgress = -1; // Force re-render
|
|
932
|
+
this.render(currentProgress);
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
start() {
|
|
936
|
+
if (this.isActive)
|
|
937
|
+
return;
|
|
938
|
+
this.isActive = true;
|
|
939
|
+
this.scrollHandler = () => {
|
|
940
|
+
if (!this.pendingDraw) {
|
|
941
|
+
this.pendingDraw = true;
|
|
942
|
+
this.rafId = requestAnimationFrame(() => {
|
|
943
|
+
this.pendingDraw = false;
|
|
944
|
+
this.tick();
|
|
945
|
+
});
|
|
946
|
+
}
|
|
947
|
+
};
|
|
948
|
+
window.addEventListener("scroll", this.scrollHandler, { passive: true });
|
|
949
|
+
// Initial tick
|
|
950
|
+
this.tick();
|
|
951
|
+
}
|
|
952
|
+
stop() {
|
|
953
|
+
if (!this.isActive)
|
|
954
|
+
return;
|
|
955
|
+
this.isActive = false;
|
|
956
|
+
if (this.rafId !== null) {
|
|
957
|
+
cancelAnimationFrame(this.rafId);
|
|
958
|
+
}
|
|
959
|
+
if (this.scrollHandler) {
|
|
960
|
+
window.removeEventListener("scroll", this.scrollHandler);
|
|
961
|
+
this.scrollHandler = undefined;
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
tick() {
|
|
965
|
+
var _a, _b;
|
|
966
|
+
const rect = this.container.getBoundingClientRect();
|
|
967
|
+
const progress = resolveScrollProgress(rect, window.innerHeight, this.options.scrollRange);
|
|
968
|
+
// Skip if progress hasn't changed significantly (threshold: 0.1%)
|
|
969
|
+
if (Math.abs(progress - this.lastProgress) < 0.001) {
|
|
970
|
+
return;
|
|
971
|
+
}
|
|
972
|
+
this.lastProgress = progress;
|
|
973
|
+
(_b = (_a = this.options).onProgress) === null || _b === void 0 ? void 0 : _b.call(_a, progress);
|
|
974
|
+
this.render(progress);
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
/**
|
|
978
|
+
* Register a native text animation on a container element.
|
|
979
|
+
* This creates scroll-driven text animation without pre-rendered assets.
|
|
980
|
+
*/
|
|
981
|
+
export async function registerNativeAnimation(options) {
|
|
982
|
+
const player = new NativeTextPlayer(options);
|
|
983
|
+
await player.init();
|
|
984
|
+
return () => player.destroy();
|
|
985
|
+
}
|