tegaki 0.4.0 → 0.6.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.
@@ -0,0 +1,1121 @@
1
+ import { layoutWithLines, prepareWithSegments } from "@chenglou/pretext";
2
+ //#region src/lib/effects.ts
3
+ const defaultEffects = { pressureWidth: true };
4
+ const knownEffects = new Set([
5
+ "glow",
6
+ "wobble",
7
+ "pressureWidth",
8
+ "taper",
9
+ "gradient"
10
+ ]);
11
+ /**
12
+ * Normalizes an effects record into a sorted array of resolved effects.
13
+ * Known keys infer the effect name; custom keys read it from the `effect` field.
14
+ * Boolean `true` becomes an empty config. `false`/absent entries are skipped.
15
+ */
16
+ function resolveEffects(effects) {
17
+ const merged = {
18
+ ...defaultEffects,
19
+ ...effects
20
+ };
21
+ const result = [];
22
+ for (const [key, value] of Object.entries(merged)) {
23
+ if (value === false || value == null) continue;
24
+ let effectName;
25
+ let config;
26
+ let order;
27
+ if (value === true) {
28
+ effectName = knownEffects.has(key) ? key : void 0;
29
+ if (!effectName) continue;
30
+ config = {};
31
+ order = 0;
32
+ } else {
33
+ if (value.enabled === false) continue;
34
+ effectName = value.effect ?? (knownEffects.has(key) ? key : void 0);
35
+ if (!effectName) continue;
36
+ const { effect: _, order: o, enabled: __, ...rest } = value;
37
+ config = rest;
38
+ order = o ?? 0;
39
+ }
40
+ result.push({
41
+ effect: effectName,
42
+ order,
43
+ config
44
+ });
45
+ }
46
+ result.sort((a, b) => a.order - b.order);
47
+ return result;
48
+ }
49
+ /** Check if a specific effect is active. */
50
+ function findEffect(effects, name) {
51
+ return effects.find((e) => e.effect === name);
52
+ }
53
+ /** Get all instances of a specific effect (for duplicates). */
54
+ function findEffects(effects, name) {
55
+ return effects.filter((e) => e.effect === name);
56
+ }
57
+ //#endregion
58
+ //#region src/lib/utils.ts
59
+ const segmenter = new Intl.Segmenter(void 0, { granularity: "grapheme" });
60
+ /** Resolve a CSSLength to pixels. Plain numbers are px, `"Nem"` is N * fontSize. */
61
+ function resolveCSSLength(value, fontSize) {
62
+ if (typeof value === "number") return value;
63
+ return parseFloat(value) * fontSize;
64
+ }
65
+ function graphemes(text) {
66
+ return Array.from(segmenter.segment(text), (s) => s.segment);
67
+ }
68
+ function coerceToString(value) {
69
+ if (value == null || typeof value === "boolean") return "";
70
+ if (typeof value === "string") return value;
71
+ if (typeof value === "number" || typeof value === "bigint") return String(value);
72
+ if (Array.isArray(value)) return value.map(coerceToString).join("");
73
+ return "";
74
+ }
75
+ //#endregion
76
+ //#region src/lib/drawGlyph.ts
77
+ function parseColor(color) {
78
+ const h = color.replace("#", "");
79
+ if (h.length === 3) return [
80
+ parseInt(h[0] + h[0], 16),
81
+ parseInt(h[1] + h[1], 16),
82
+ parseInt(h[2] + h[2], 16),
83
+ 1
84
+ ];
85
+ if (h.length === 4) return [
86
+ parseInt(h[0] + h[0], 16),
87
+ parseInt(h[1] + h[1], 16),
88
+ parseInt(h[2] + h[2], 16),
89
+ parseInt(h[3] + h[3], 16) / 255
90
+ ];
91
+ if (h.length === 8) return [
92
+ parseInt(h.slice(0, 2), 16),
93
+ parseInt(h.slice(2, 4), 16),
94
+ parseInt(h.slice(4, 6), 16),
95
+ parseInt(h.slice(6, 8), 16) / 255
96
+ ];
97
+ return [
98
+ parseInt(h.slice(0, 2), 16),
99
+ parseInt(h.slice(2, 4), 16),
100
+ parseInt(h.slice(4, 6), 16),
101
+ 1
102
+ ];
103
+ }
104
+ function lerpColor(a, b, t) {
105
+ const r = Math.round(a[0] + (b[0] - a[0]) * t);
106
+ const g = Math.round(a[1] + (b[1] - a[1]) * t);
107
+ const bl = Math.round(a[2] + (b[2] - a[2]) * t);
108
+ const al = a[3] + (b[3] - a[3]) * t;
109
+ if (al >= 1) return `rgb(${r},${g},${bl})`;
110
+ return `rgba(${r},${g},${bl},${al.toFixed(3)})`;
111
+ }
112
+ function gradientColor(progress, colors, seed) {
113
+ if (colors.length === 0) return "#000";
114
+ if (colors.length === 1) return colors[0];
115
+ const scaledT = ((progress + seed * .1) % 1 + 1) % 1 * (colors.length - 1);
116
+ const i = Math.min(Math.floor(scaledT), colors.length - 2);
117
+ const frac = scaledT - i;
118
+ return lerpColor(parseColor(colors[i]), parseColor(colors[i + 1]), frac);
119
+ }
120
+ function rainbowColor(progress, saturation, lightness, seed) {
121
+ return `hsl(${(progress * 360 + seed * 137.5) % 360}, ${saturation}%, ${lightness}%)`;
122
+ }
123
+ function hash(x) {
124
+ let h = x * 2654435761 | 0;
125
+ h = (h >>> 16 ^ h) * 73244475;
126
+ h = (h >>> 16 ^ h) * 73244475;
127
+ h = h >>> 16 ^ h;
128
+ return (h & 2147483647) / 2147483647;
129
+ }
130
+ function noise1d(x, seed) {
131
+ const i = Math.floor(x);
132
+ const f = x - i;
133
+ const t = f * f * (3 - 2 * f);
134
+ return hash(i + seed * 7919) * (1 - t) + hash(i + 1 + seed * 7919) * t;
135
+ }
136
+ /**
137
+ * Draw a single glyph's strokes onto a canvas context, animated up to `localTime`.
138
+ * `localTime` is seconds relative to this glyph's start (0 = glyph begins).
139
+ */
140
+ function drawGlyph(ctx, glyph, pos, localTime, lineCap, color, effects = [], seed = 0, segmentSize) {
141
+ const scale = pos.fontSize / pos.unitsPerEm;
142
+ const ox = pos.x;
143
+ const oy = pos.y;
144
+ const glowEffects = findEffects(effects, "glow");
145
+ const wobbleEffect = findEffect(effects, "wobble");
146
+ const pressureEffect = findEffect(effects, "pressureWidth");
147
+ const taperEffect = findEffect(effects, "taper");
148
+ const gradientEffect = findEffect(effects, "gradient");
149
+ const pressureAmount = pressureEffect ? Math.max(0, Math.min(pressureEffect.config.strength ?? 1, 1)) : 0;
150
+ const wobbleAmplitude = wobbleEffect ? wobbleEffect.config.amplitude ?? 1.5 : 0;
151
+ const wobbleFrequency = wobbleEffect ? wobbleEffect.config.frequency ?? 8 : 0;
152
+ const wobbleMode = wobbleEffect?.config.mode ?? "sine";
153
+ const taperStart = taperEffect ? Math.max(0, Math.min(taperEffect.config.startLength ?? .15, 1)) : 0;
154
+ const taperEnd = taperEffect ? Math.max(0, Math.min(taperEffect.config.endLength ?? .15, 1)) : 0;
155
+ const gradientColors = gradientEffect?.config.colors;
156
+ const isRainbow = gradientColors === "rainbow";
157
+ const gradientColorStops = Array.isArray(gradientColors) ? gradientColors : void 0;
158
+ const gradientSaturation = gradientEffect?.config.saturation ?? 80;
159
+ const gradientLightness = gradientEffect?.config.lightness ?? 55;
160
+ const wobbleX = (x, y, idx) => {
161
+ if (!wobbleEffect) return x;
162
+ if (wobbleMode === "noise") return x + wobbleAmplitude * (noise1d(y * .1 + idx * .7, seed) * 2 - 1);
163
+ return x + wobbleAmplitude * Math.sin(wobbleFrequency * (y * .01 + idx * .7) + seed);
164
+ };
165
+ const wobbleY = (x, y, idx) => {
166
+ if (!wobbleEffect) return y;
167
+ if (wobbleMode === "noise") return y + wobbleAmplitude * (noise1d(x * .1 + idx * .5, seed * 1.3 + 1e3) * 2 - 1);
168
+ return y + wobbleAmplitude * Math.cos(wobbleFrequency * (x * .01 + idx * .5) + seed * 1.3);
169
+ };
170
+ const px = (x) => ox + x * scale;
171
+ const py = (y) => oy + (y + pos.ascender) * scale;
172
+ const colorAt = (progress) => {
173
+ if (isRainbow) return rainbowColor(progress, gradientSaturation, gradientLightness, seed);
174
+ if (gradientColorStops) return gradientColor(progress, gradientColorStops, seed);
175
+ return color;
176
+ };
177
+ const hasGradient = !!gradientEffect;
178
+ const taperMultiplier = (progress) => {
179
+ let m = 1;
180
+ if (taperStart > 0 && progress < taperStart) m = Math.min(m, progress / taperStart);
181
+ if (taperEnd > 0 && progress > 1 - taperEnd) m = Math.min(m, (1 - progress) / taperEnd);
182
+ return m;
183
+ };
184
+ for (const stroke of glyph.s) {
185
+ if (localTime < stroke.d) continue;
186
+ const elapsed = localTime - stroke.d;
187
+ const progress = Math.min(elapsed / stroke.a, 1);
188
+ const pts = stroke.p;
189
+ if (pts.length === 0) continue;
190
+ const avgWidth = pts.reduce((s, p) => s + p[2], 0) / pts.length;
191
+ const baseLineWidth = Math.max(avgWidth, .5) * scale;
192
+ if (pts.length === 1) {
193
+ if (progress <= 0) continue;
194
+ const p = pts[0];
195
+ const dotX = px(wobbleX(p[0], p[1], 0));
196
+ const dotY = py(wobbleY(p[0], p[1], 0));
197
+ let dotWidth = baseLineWidth + (Math.max(p[2], .5) * scale - baseLineWidth) * pressureAmount;
198
+ dotWidth *= taperMultiplier(.5);
199
+ for (const glow of glowEffects) {
200
+ ctx.save();
201
+ ctx.shadowBlur = resolveCSSLength(glow.config.radius ?? 8, pos.fontSize);
202
+ ctx.shadowColor = glow.config.color ?? color;
203
+ ctx.shadowOffsetX = (glow.config.offsetX ?? 0) * scale;
204
+ ctx.shadowOffsetY = (glow.config.offsetY ?? 0) * scale;
205
+ ctx.fillStyle = glow.config.color ?? color;
206
+ ctx.beginPath();
207
+ if (lineCap === "round") ctx.arc(dotX, dotY, dotWidth / 2, 0, Math.PI * 2);
208
+ else ctx.rect(dotX - dotWidth / 2, dotY - dotWidth / 2, dotWidth, dotWidth);
209
+ ctx.fill();
210
+ ctx.restore();
211
+ }
212
+ ctx.fillStyle = colorAt(0);
213
+ ctx.beginPath();
214
+ if (lineCap === "round") {
215
+ ctx.arc(dotX, dotY, dotWidth / 2, 0, Math.PI * 2);
216
+ ctx.fill();
217
+ } else ctx.fillRect(dotX - dotWidth / 2, dotY - dotWidth / 2, dotWidth, dotWidth);
218
+ continue;
219
+ }
220
+ let totalLen = 0;
221
+ for (let j = 1; j < pts.length; j++) {
222
+ const dx = pts[j][0] - pts[j - 1][0];
223
+ const dy = pts[j][1] - pts[j - 1][1];
224
+ totalLen += Math.sqrt(dx * dx + dy * dy);
225
+ }
226
+ const drawLen = totalLen * progress;
227
+ if (drawLen <= 0) continue;
228
+ const segments = [];
229
+ let accumulated = 0;
230
+ for (let j = 1; j < pts.length; j++) {
231
+ const prev = pts[j - 1];
232
+ const cur = pts[j];
233
+ const dx = cur[0] - prev[0];
234
+ const dy = cur[1] - prev[1];
235
+ const segLen = Math.sqrt(dx * dx + dy * dy);
236
+ if (accumulated + segLen <= drawLen) {
237
+ segments.push({
238
+ x0: px(wobbleX(prev[0], prev[1], j - 1)),
239
+ y0: py(wobbleY(prev[0], prev[1], j - 1)),
240
+ x1: px(wobbleX(cur[0], cur[1], j)),
241
+ y1: py(wobbleY(cur[0], cur[1], j)),
242
+ width0: prev[2],
243
+ width1: cur[2],
244
+ segProgress: (accumulated + segLen / 2) / totalLen
245
+ });
246
+ accumulated += segLen;
247
+ } else {
248
+ const remaining = drawLen - accumulated;
249
+ const frac = segLen > 0 ? remaining / segLen : 0;
250
+ const ix = prev[0] + dx * frac;
251
+ const iy = prev[1] + dy * frac;
252
+ const iw = prev[2] + (cur[2] - prev[2]) * frac;
253
+ segments.push({
254
+ x0: px(wobbleX(prev[0], prev[1], j - 1)),
255
+ y0: py(wobbleY(prev[0], prev[1], j - 1)),
256
+ x1: px(wobbleX(ix, iy, j)),
257
+ y1: py(wobbleY(ix, iy, j)),
258
+ width0: prev[2],
259
+ width1: iw,
260
+ segProgress: (accumulated + remaining / 2) / totalLen
261
+ });
262
+ break;
263
+ }
264
+ }
265
+ if (segments.length === 0) continue;
266
+ const coarseSegments = segments.slice();
267
+ const resolvedSegmentSize = segmentSize ?? (pressureAmount > 0 || hasGradient || !!wobbleEffect || !!taperEffect ? 2 : void 0);
268
+ if (resolvedSegmentSize != null) {
269
+ const maxSegLen = resolvedSegmentSize * scale;
270
+ const subdivided = [];
271
+ for (const seg of segments) {
272
+ const dx = seg.x1 - seg.x0;
273
+ const dy = seg.y1 - seg.y0;
274
+ const len = Math.sqrt(dx * dx + dy * dy);
275
+ const count = Math.max(1, Math.ceil(len / maxSegLen));
276
+ for (let k = 0; k < count; k++) {
277
+ const t0 = k / count;
278
+ const t1 = (k + 1) / count;
279
+ subdivided.push({
280
+ x0: seg.x0 + dx * t0,
281
+ y0: seg.y0 + dy * t0,
282
+ x1: seg.x0 + dx * t1,
283
+ y1: seg.y0 + dy * t1,
284
+ width0: seg.width0 + (seg.width1 - seg.width0) * t0,
285
+ width1: seg.width0 + (seg.width1 - seg.width0) * t1,
286
+ segProgress: seg.segProgress
287
+ });
288
+ }
289
+ }
290
+ for (let k = 0; k < subdivided.length; k++) subdivided[k].segProgress = subdivided.length > 1 ? k / (subdivided.length - 1) : 0;
291
+ segments.length = 0;
292
+ segments.push(...subdivided);
293
+ }
294
+ const segWidth = (seg) => {
295
+ const perPoint = (seg.width0 + seg.width1) / 2 * scale;
296
+ return Math.max(baseLineWidth + (perPoint - baseLineWidth) * pressureAmount, .5 * scale) * taperMultiplier(seg.segProgress);
297
+ };
298
+ const needsPerSegment = pressureAmount > 0 || taperEffect;
299
+ const drawStrokePath = () => {
300
+ if (needsPerSegment) for (const seg of segments) {
301
+ ctx.lineWidth = segWidth(seg);
302
+ ctx.beginPath();
303
+ ctx.moveTo(seg.x0, seg.y0);
304
+ ctx.lineTo(seg.x1, seg.y1);
305
+ ctx.stroke();
306
+ }
307
+ else {
308
+ ctx.lineWidth = baseLineWidth;
309
+ ctx.beginPath();
310
+ ctx.moveTo(segments[0].x0, segments[0].y0);
311
+ for (const seg of segments) ctx.lineTo(seg.x1, seg.y1);
312
+ ctx.stroke();
313
+ }
314
+ };
315
+ const drawGradientPath = () => {
316
+ for (const seg of segments) {
317
+ ctx.strokeStyle = colorAt(seg.segProgress);
318
+ if (needsPerSegment) ctx.lineWidth = segWidth(seg);
319
+ ctx.beginPath();
320
+ ctx.moveTo(seg.x0, seg.y0);
321
+ ctx.lineTo(seg.x1, seg.y1);
322
+ ctx.stroke();
323
+ }
324
+ };
325
+ ctx.lineCap = lineCap;
326
+ ctx.lineJoin = "round";
327
+ for (const glow of glowEffects) {
328
+ ctx.save();
329
+ ctx.shadowBlur = resolveCSSLength(glow.config.radius ?? 8, pos.fontSize);
330
+ ctx.shadowColor = glow.config.color ?? color;
331
+ ctx.shadowOffsetX = (glow.config.offsetX ?? 0) * scale;
332
+ ctx.shadowOffsetY = (glow.config.offsetY ?? 0) * scale;
333
+ ctx.strokeStyle = glow.config.color ?? color;
334
+ ctx.lineWidth = baseLineWidth;
335
+ ctx.beginPath();
336
+ ctx.moveTo(coarseSegments[0].x0, coarseSegments[0].y0);
337
+ for (const seg of coarseSegments) ctx.lineTo(seg.x1, seg.y1);
338
+ ctx.stroke();
339
+ ctx.restore();
340
+ }
341
+ if (hasGradient) drawGradientPath();
342
+ else {
343
+ ctx.strokeStyle = color;
344
+ drawStrokePath();
345
+ }
346
+ }
347
+ }
348
+ //#endregion
349
+ //#region src/lib/font.ts
350
+ const fontFaceCache = /* @__PURE__ */ new Map();
351
+ /**
352
+ * Ensures the bundle's font face is loaded and available for rendering.
353
+ * Resolves immediately if the font is already loaded.
354
+ */
355
+ async function ensureFontFace(bundle) {
356
+ await ensureFont(bundle.family, bundle.fontUrl);
357
+ }
358
+ function ensureFont(family, url) {
359
+ if (typeof document === "undefined") return Promise.resolve();
360
+ for (const face of document.fonts) if (face.family === family) {
361
+ if (face.status === "loaded") return null;
362
+ if (face.status === "loading") return face.loaded.then(() => {});
363
+ }
364
+ let cached = fontFaceCache.get(url);
365
+ if (!cached) {
366
+ cached = new FontFace(family, `url(${url})`, { featureSettings: "'calt' 0, 'liga' 0" }).load().then((loaded) => {
367
+ document.fonts.add(loaded);
368
+ });
369
+ fontFaceCache.set(url, cached);
370
+ }
371
+ return cached;
372
+ }
373
+ //#endregion
374
+ //#region src/lib/textLayout.ts
375
+ function computeTextLayout(text, fontFamily, fontSize, lineHeight, maxWidth) {
376
+ const fontStr = `${fontSize}px ${fontFamily}`;
377
+ const chars = graphemes(text);
378
+ const widthCache = /* @__PURE__ */ new Map();
379
+ const charWidths = [];
380
+ for (const char of chars) {
381
+ let w = widthCache.get(char);
382
+ if (w === void 0) {
383
+ if (char === "\n") w = 0;
384
+ else {
385
+ const r = layoutWithLines(prepareWithSegments(char, fontStr, { whiteSpace: "pre-wrap" }), Infinity, lineHeight);
386
+ w = r.lines.length > 0 ? r.lines[0].width / fontSize : 0;
387
+ }
388
+ widthCache.set(char, w);
389
+ }
390
+ charWidths.push(w);
391
+ }
392
+ const prepared = prepareWithSegments(text, fontStr, { whiteSpace: "pre-wrap" });
393
+ const singleLineResult = layoutWithLines(prepared, Infinity, lineHeight);
394
+ const intrinsicWidth = Math.max(0, ...singleLineResult.lines.map((l) => l.width)) / fontSize;
395
+ const result = layoutWithLines(prepared, maxWidth, lineHeight);
396
+ const utf16ToCodePoint = [];
397
+ for (let ci = 0; ci < chars.length; ci++) for (let j = 0; j < chars[ci].length; j++) utf16ToCodePoint.push(ci);
398
+ const lines = [];
399
+ let utf16Offset = 0;
400
+ for (const line of result.lines) {
401
+ const indices = [];
402
+ const seen = /* @__PURE__ */ new Set();
403
+ for (let i = 0; i < line.text.length; i++) {
404
+ const cpIdx = utf16ToCodePoint[utf16Offset + i];
405
+ if (!seen.has(cpIdx)) {
406
+ seen.add(cpIdx);
407
+ indices.push(cpIdx);
408
+ }
409
+ }
410
+ utf16Offset += line.text.length;
411
+ if (utf16Offset < text.length && text[utf16Offset] === "\n") {
412
+ const cpIdx = utf16ToCodePoint[utf16Offset];
413
+ indices.push(cpIdx);
414
+ utf16Offset++;
415
+ }
416
+ lines.push(indices);
417
+ }
418
+ if (utf16Offset < text.length) {
419
+ const indices = [];
420
+ const seen = /* @__PURE__ */ new Set();
421
+ for (let i = utf16Offset; i < text.length; i++) {
422
+ const cpIdx = utf16ToCodePoint[i];
423
+ if (!seen.has(cpIdx)) {
424
+ seen.add(cpIdx);
425
+ indices.push(cpIdx);
426
+ }
427
+ }
428
+ lines.push(indices);
429
+ }
430
+ const kernings = [];
431
+ const pairCache = /* @__PURE__ */ new Map();
432
+ for (let i = 0; i < chars.length - 1; i++) {
433
+ const a = chars[i];
434
+ const b = chars[i + 1];
435
+ if (a === "\n" || b === "\n") {
436
+ kernings.push(0);
437
+ continue;
438
+ }
439
+ const pair = `${a}${b}`;
440
+ let k = pairCache.get(pair);
441
+ if (k === void 0) {
442
+ const r = layoutWithLines(prepareWithSegments(pair, fontStr, { whiteSpace: "pre-wrap" }), Infinity, lineHeight);
443
+ k = (r.lines.length > 0 ? r.lines[0].width / fontSize : 0) - (widthCache.get(a) ?? 0) - (widthCache.get(b) ?? 0);
444
+ if (Math.abs(k) < .001) k = 0;
445
+ pairCache.set(pair, k);
446
+ }
447
+ kernings.push(k);
448
+ }
449
+ return {
450
+ lines,
451
+ charWidths,
452
+ kernings,
453
+ intrinsicWidth
454
+ };
455
+ }
456
+ //#endregion
457
+ //#region src/lib/timeline.ts
458
+ const DEFAULTS = {
459
+ glyphGap: .1,
460
+ wordGap: .15,
461
+ lineGap: .3,
462
+ unknownDuration: .2
463
+ };
464
+ function computeTimeline(text, font, config) {
465
+ const glyphGap = config?.glyphGap ?? DEFAULTS.glyphGap;
466
+ const wordGap = config?.wordGap ?? DEFAULTS.wordGap;
467
+ const lineGap = config?.lineGap ?? DEFAULTS.lineGap;
468
+ const unknownDuration = config?.unknownDuration ?? DEFAULTS.unknownDuration;
469
+ const chars = graphemes(text);
470
+ const entries = [];
471
+ let offset = 0;
472
+ for (const char of chars) {
473
+ const glyph = font.glyphData[char];
474
+ const hasGlyph = !!glyph;
475
+ const duration = hasGlyph ? glyph.t ?? 1 : unknownDuration;
476
+ entries.push({
477
+ char,
478
+ offset,
479
+ duration,
480
+ hasGlyph
481
+ });
482
+ offset += duration;
483
+ if (char === "\n") offset += lineGap;
484
+ else if (char === " ") offset += wordGap;
485
+ else offset += glyphGap;
486
+ }
487
+ if (entries.length > 0) {
488
+ const lastChar = chars[chars.length - 1];
489
+ offset -= lastChar === "\n" ? lineGap : lastChar === " " ? wordGap : glyphGap;
490
+ }
491
+ return {
492
+ entries,
493
+ totalDuration: Math.max(0, offset)
494
+ };
495
+ }
496
+ //#endregion
497
+ //#region src/lib/css-properties.ts
498
+ const CSS_TIME = "--tegaki-time";
499
+ const CSS_PROGRESS = "--tegaki-progress";
500
+ const CSS_DURATION = "--tegaki-duration";
501
+ const PADDING_H_EM = .2;
502
+ const MIN_LINE_HEIGHT_EM = 1.8;
503
+ const MIN_PADDING_V_EM = .2;
504
+ let cssPropertiesRegistered = false;
505
+ function registerCssProperties() {
506
+ if (cssPropertiesRegistered) return;
507
+ cssPropertiesRegistered = true;
508
+ if (typeof CSS !== "undefined" && "registerProperty" in CSS) for (const prop of [
509
+ CSS_TIME,
510
+ CSS_PROGRESS,
511
+ CSS_DURATION
512
+ ]) try {
513
+ CSS.registerProperty({
514
+ name: prop,
515
+ syntax: "<number>",
516
+ inherits: true,
517
+ initialValue: "0"
518
+ });
519
+ } catch {}
520
+ }
521
+ //#endregion
522
+ //#region src/lib/drawFallbackGlyph.ts
523
+ /**
524
+ * Draw a fallback glyph (plain text) with applicable effects (glow, gradient, wobble).
525
+ */
526
+ function drawFallbackGlyph(ctx, char, x, baseline, fontSize, fontFamily, color, effects = [], seed = 0) {
527
+ const glowEffects = findEffects(effects, "glow");
528
+ const wobbleEffect = findEffect(effects, "wobble");
529
+ const gradientEffect = findEffect(effects, "gradient");
530
+ let dx = 0;
531
+ let dy = 0;
532
+ if (wobbleEffect) {
533
+ const amplitude = (wobbleEffect.config.amplitude ?? 1.5) * (fontSize / 100);
534
+ const frequency = wobbleEffect.config.frequency ?? 8;
535
+ dx = amplitude * Math.sin(frequency * (baseline * .01) + seed);
536
+ dy = amplitude * Math.cos(frequency * (x * .01) + seed * 1.3);
537
+ }
538
+ const drawX = x + dx;
539
+ const drawY = baseline + dy;
540
+ let fillColor = color;
541
+ if (gradientEffect) {
542
+ const colors = gradientEffect.config.colors;
543
+ if (colors === "rainbow") {
544
+ const saturation = gradientEffect.config.saturation ?? 80;
545
+ const lightness = gradientEffect.config.lightness ?? 55;
546
+ fillColor = `hsl(${seed * 137.5 % 360}, ${saturation}%, ${lightness}%)`;
547
+ } else if (Array.isArray(colors) && colors.length > 0) fillColor = colors[Math.floor(seed) % colors.length];
548
+ }
549
+ ctx.save();
550
+ ctx.font = `${fontSize}px ${fontFamily}`;
551
+ ctx.textBaseline = "alphabetic";
552
+ for (const glow of glowEffects) {
553
+ ctx.save();
554
+ ctx.shadowBlur = resolveCSSLength(glow.config.radius ?? 8, fontSize);
555
+ ctx.shadowColor = glow.config.color ?? color;
556
+ ctx.shadowOffsetX = glow.config.offsetX ?? 0;
557
+ ctx.shadowOffsetY = glow.config.offsetY ?? 0;
558
+ ctx.fillStyle = glow.config.color ?? color;
559
+ ctx.fillText(char, drawX, drawY);
560
+ ctx.restore();
561
+ }
562
+ ctx.fillStyle = fillColor;
563
+ ctx.fillText(char, drawX, drawY);
564
+ ctx.restore();
565
+ }
566
+ //#endregion
567
+ //#region src/core/engine.ts
568
+ const PAD_V_CSS = "max(0.2em, 0.9em - 0.5lh)";
569
+ function buildElements(options, h) {
570
+ const text = options.text ?? "";
571
+ const font = resolveBundle(options.font);
572
+ const fontFamily = font?.family;
573
+ const isCss = options.time === "css" || typeof options.time === "object" && options.time?.mode === "css";
574
+ const showOverlay = options.showOverlay;
575
+ const duration = text && font ? computeTimeline(text, font, options.timing).totalDuration : 0;
576
+ const time = typeof options.time === "number" ? options.time : typeof options.time === "object" && options.time?.mode === "controlled" ? options.time.value : typeof options.time === "object" && options.time?.mode === "uncontrolled" ? options.time.initialTime ?? 0 : 0;
577
+ const progress = duration > 0 ? time / duration : 0;
578
+ return h("div", {
579
+ "data-tegaki": "root",
580
+ style: {
581
+ position: "relative",
582
+ maxWidth: "100%",
583
+ width: "auto",
584
+ height: "auto",
585
+ fontFamily: fontFamily ?? void 0,
586
+ [CSS_DURATION]: duration,
587
+ [CSS_TIME]: time,
588
+ [CSS_PROGRESS]: progress
589
+ }
590
+ }, h("div", { style: { position: "relative" } }, h("span", {
591
+ "data-tegaki": "sentinel",
592
+ "aria-hidden": "true",
593
+ style: {
594
+ position: "absolute",
595
+ width: 0,
596
+ overflow: "hidden",
597
+ pointerEvents: "none",
598
+ fontSize: "inherit",
599
+ lineHeight: "inherit",
600
+ visibility: "hidden",
601
+ transition: isCss ? `font-size 0.001s, line-height 0.001s, color 0.001s, ${CSS_PROGRESS} 0.001s` : "font-size 0.001s, line-height 0.001s, color 0.001s"
602
+ }
603
+ }), h("canvas", {
604
+ "data-tegaki": "canvas",
605
+ "aria-hidden": "true",
606
+ style: {
607
+ position: "absolute",
608
+ inset: `calc(-1 * ${PAD_V_CSS}) -0.2em`,
609
+ width: "calc(100% + 0.4em)",
610
+ height: `calc(100% + 2 * ${PAD_V_CSS})`,
611
+ pointerEvents: "none",
612
+ overflow: "visible"
613
+ }
614
+ }, h("span", {
615
+ "data-tegaki": "canvas-fallback",
616
+ style: {
617
+ display: "inline-block",
618
+ padding: `${PAD_V_CSS} 0.2em`
619
+ }
620
+ }, text)), h("div", {
621
+ "data-tegaki": "overlay",
622
+ style: {
623
+ userSelect: "auto",
624
+ whiteSpace: "pre-wrap",
625
+ overflowWrap: "break-word",
626
+ paddingRight: 1,
627
+ WebkitTextFillColor: showOverlay ? void 0 : "transparent",
628
+ color: showOverlay ? "rgba(255, 0, 0, 0.4)" : void 0
629
+ }
630
+ }, text)));
631
+ }
632
+ function domCreateElement(tag, props, ...children) {
633
+ const el = document.createElement(tag);
634
+ for (const [key, value] of Object.entries(props)) if (key === "style" && typeof value === "object") {
635
+ for (const [k, v] of Object.entries(value)) if (v !== void 0 && v !== null) if (k.startsWith("--")) el.style.setProperty(k, String(v));
636
+ else el.style[k] = typeof v === "number" && k !== "opacity" && k !== "zIndex" ? `${v}px` : v;
637
+ } else if (key === "aria-hidden") el.setAttribute("aria-hidden", String(value));
638
+ else if (key.startsWith("data-")) el.setAttribute(key, String(value));
639
+ for (const child of children) if (typeof child === "string") el.appendChild(document.createTextNode(child));
640
+ else el.appendChild(child);
641
+ return el;
642
+ }
643
+ function resolveTimeControl(prop) {
644
+ if (prop == null) return { mode: "uncontrolled" };
645
+ if (typeof prop === "number") return {
646
+ mode: "controlled",
647
+ value: prop
648
+ };
649
+ if (prop === "css") return { mode: "css" };
650
+ return prop;
651
+ }
652
+ function resolveBundle(font) {
653
+ if (typeof font === "string") {
654
+ const bundle = TegakiEngine.getBundle(font);
655
+ if (!bundle) throw new Error(`TegakiEngine: no bundle registered for "${font}". Call TegakiEngine.registerBundle() first.`);
656
+ return bundle;
657
+ }
658
+ return font;
659
+ }
660
+ var TegakiEngine = class TegakiEngine {
661
+ static _bundles = /* @__PURE__ */ new Map();
662
+ /** Register a font bundle so it can be referenced by family name. */
663
+ static registerBundle(bundle) {
664
+ TegakiEngine._bundles.set(bundle.family, bundle);
665
+ }
666
+ /** Look up a registered bundle by family name. */
667
+ static getBundle(family) {
668
+ return TegakiEngine._bundles.get(family);
669
+ }
670
+ _rootEl;
671
+ _sentinelEl;
672
+ _canvasEl;
673
+ _overlayEl;
674
+ _canvasFallbackEl;
675
+ _text = "";
676
+ _font = null;
677
+ _timeControl = { mode: "uncontrolled" };
678
+ _effects;
679
+ _timing;
680
+ _segmentSize;
681
+ _showOverlay = false;
682
+ _onComplete;
683
+ _resolvedEffects = resolveEffects(void 0);
684
+ _seed;
685
+ _timeline = {
686
+ entries: [],
687
+ totalDuration: 0
688
+ };
689
+ _layout = null;
690
+ _fontReady = false;
691
+ _containerWidth = 0;
692
+ _fontSize = 0;
693
+ _lineHeight = 0;
694
+ _currentColor = "";
695
+ _internalTime = 0;
696
+ _cssTime = 0;
697
+ _playing = true;
698
+ _smoothedBoost = 0;
699
+ _lastTs = null;
700
+ _rafId = 0;
701
+ _prevCompleted = false;
702
+ _prefersReducedMotion = false;
703
+ _destroyed = false;
704
+ _resizeObserver;
705
+ _mql = null;
706
+ /**
707
+ * Renders the engine's element tree using a framework-provided `createElement` callback.
708
+ * This method requires no DOM and can run during SSR.
709
+ *
710
+ * Each element receives a `data-tegaki` attribute so the engine can adopt
711
+ * pre-rendered elements later via `new TegakiEngine(container, { adopt: true })`.
712
+ */
713
+ static renderElements(options, createElement) {
714
+ return buildElements(options, createElement);
715
+ }
716
+ constructor(container, options) {
717
+ registerCssProperties();
718
+ this._seed = Math.random() * 1e3;
719
+ if (options?.adopt) {
720
+ this._rootEl = container.querySelector("[data-tegaki=\"root\"]");
721
+ this._sentinelEl = container.querySelector("[data-tegaki=\"sentinel\"]");
722
+ this._canvasEl = container.querySelector("[data-tegaki=\"canvas\"]");
723
+ this._canvasFallbackEl = container.querySelector("[data-tegaki=\"canvas-fallback\"]");
724
+ this._overlayEl = container.querySelector("[data-tegaki=\"overlay\"]");
725
+ } else {
726
+ const root = buildElements(options ?? {}, domCreateElement);
727
+ container.appendChild(root);
728
+ this._rootEl = root;
729
+ this._sentinelEl = root.querySelector("[data-tegaki=\"sentinel\"]");
730
+ this._canvasEl = root.querySelector("[data-tegaki=\"canvas\"]");
731
+ this._canvasFallbackEl = root.querySelector("[data-tegaki=\"canvas-fallback\"]");
732
+ this._overlayEl = root.querySelector("[data-tegaki=\"overlay\"]");
733
+ }
734
+ this._resizeObserver = new ResizeObserver(this._onResize);
735
+ this._resizeObserver.observe(this._rootEl);
736
+ this._sentinelEl.addEventListener("transitionend", this._onSentinelTransition);
737
+ if (typeof window !== "undefined") {
738
+ this._mql = window.matchMedia("(prefers-reduced-motion: reduce)");
739
+ this._prefersReducedMotion = this._mql.matches;
740
+ this._mql.addEventListener("change", this._onReducedMotionChange);
741
+ }
742
+ this._measure();
743
+ if (options) this.update(options);
744
+ }
745
+ get currentTime() {
746
+ const tc = this._timeControl;
747
+ if (tc.mode === "css") return this._cssTime;
748
+ if (tc.mode === "controlled") return tc.value;
749
+ return this._internalTime;
750
+ }
751
+ get duration() {
752
+ return this._timeline.totalDuration;
753
+ }
754
+ get isPlaying() {
755
+ return this._playing;
756
+ }
757
+ get isComplete() {
758
+ return this._timeline.totalDuration > 0 && this.currentTime >= this._timeline.totalDuration;
759
+ }
760
+ get element() {
761
+ return this._rootEl;
762
+ }
763
+ play() {
764
+ if (this._timeControl.mode !== "uncontrolled") return;
765
+ this._playing = true;
766
+ this._evaluatePlayback();
767
+ }
768
+ pause() {
769
+ if (this._timeControl.mode !== "uncontrolled") return;
770
+ this._playing = false;
771
+ this._evaluatePlayback();
772
+ }
773
+ seek(time) {
774
+ if (this._timeControl.mode !== "uncontrolled") return;
775
+ this._internalTime = Math.max(0, Math.min(time, this._timeline.totalDuration));
776
+ this._checkCompletion();
777
+ this._notifyTimeChange();
778
+ this._render();
779
+ this._updateCssProperties();
780
+ }
781
+ restart() {
782
+ if (this._timeControl.mode !== "uncontrolled") return;
783
+ this._internalTime = 0;
784
+ this._playing = true;
785
+ this._prevCompleted = false;
786
+ this._notifyTimeChange();
787
+ this._evaluatePlayback();
788
+ }
789
+ update(options) {
790
+ if (this._destroyed) return;
791
+ let dirtyTimeline = false;
792
+ let dirtyLayout = false;
793
+ let dirtyRender = false;
794
+ let dirtyPlayback = false;
795
+ if ("text" in options && options.text !== this._text) {
796
+ this._text = options.text ?? "";
797
+ dirtyTimeline = true;
798
+ dirtyLayout = true;
799
+ }
800
+ if ("font" in options) {
801
+ const resolved = resolveBundle(options.font) ?? null;
802
+ if (resolved !== this._font) {
803
+ this._loadFont(resolved);
804
+ dirtyTimeline = true;
805
+ dirtyLayout = true;
806
+ dirtyPlayback = true;
807
+ }
808
+ }
809
+ if ("time" in options) {
810
+ const newTc = resolveTimeControl(options.time);
811
+ const oldTc = this._timeControl;
812
+ const modeChanged = newTc.mode !== oldTc.mode;
813
+ const controlledValueChanged = newTc.mode === "controlled" && oldTc.mode === "controlled" && newTc.value !== oldTc.value;
814
+ const uncontrolledChanged = newTc.mode === "uncontrolled" && oldTc.mode === "uncontrolled" && (newTc.speed !== oldTc.speed || newTc.playing !== oldTc.playing || newTc.loop !== oldTc.loop || newTc.catchUp !== oldTc.catchUp);
815
+ if (modeChanged || controlledValueChanged || uncontrolledChanged) {
816
+ this._timeControl = newTc;
817
+ if (newTc.mode === "uncontrolled") this._playing = newTc.playing ?? true;
818
+ dirtyPlayback = true;
819
+ dirtyRender = true;
820
+ this._updateSentinelTransition();
821
+ }
822
+ }
823
+ if ("effects" in options && options.effects !== this._effects) {
824
+ this._effects = options.effects;
825
+ this._resolvedEffects = resolveEffects(this._effects);
826
+ dirtyRender = true;
827
+ }
828
+ if ("timing" in options && options.timing !== this._timing) {
829
+ this._timing = options.timing;
830
+ dirtyTimeline = true;
831
+ }
832
+ if ("segmentSize" in options && options.segmentSize !== this._segmentSize) {
833
+ this._segmentSize = options.segmentSize;
834
+ dirtyRender = true;
835
+ }
836
+ if ("showOverlay" in options && options.showOverlay !== this._showOverlay) {
837
+ this._showOverlay = options.showOverlay ?? false;
838
+ this._updateOverlayStyle();
839
+ dirtyRender = true;
840
+ }
841
+ if ("onComplete" in options) this._onComplete = options.onComplete;
842
+ if (dirtyTimeline) this._recomputeTimeline();
843
+ if (dirtyLayout) this._recomputeLayout();
844
+ if (dirtyPlayback) this._evaluatePlayback();
845
+ if (dirtyRender || dirtyTimeline || dirtyLayout) {
846
+ this._updateDom();
847
+ this._render();
848
+ }
849
+ }
850
+ destroy() {
851
+ this._destroyed = true;
852
+ this._stopLoop();
853
+ this._resizeObserver.disconnect();
854
+ this._sentinelEl.removeEventListener("transitionend", this._onSentinelTransition);
855
+ this._mql?.removeEventListener("change", this._onReducedMotionChange);
856
+ this._rootEl.remove();
857
+ }
858
+ /** Estimate line-height from font metrics when CSS returns "normal". */
859
+ _fallbackLineHeight(fontSize) {
860
+ if (this._font) return (this._font.ascender - this._font.descender) / this._font.unitsPerEm * fontSize;
861
+ return fontSize * 1.2;
862
+ }
863
+ _measure() {
864
+ const styles = getComputedStyle(this._rootEl);
865
+ this._containerWidth = this._rootEl.getBoundingClientRect().width;
866
+ this._fontSize = Number.parseFloat(styles.fontSize);
867
+ const parsedLh = Number.parseFloat(styles.lineHeight);
868
+ this._lineHeight = Number.isNaN(parsedLh) ? this._fallbackLineHeight(this._fontSize) : parsedLh;
869
+ this._currentColor = styles.color;
870
+ }
871
+ _updateDom() {
872
+ this._rootEl.style.fontFamily = this._font?.family ?? "";
873
+ this._updateCssProperties();
874
+ this._overlayEl.textContent = this._text;
875
+ this._canvasFallbackEl.textContent = this._text;
876
+ }
877
+ _updateCssProperties() {
878
+ const time = this.currentTime;
879
+ const dur = this._timeline.totalDuration;
880
+ this._rootEl.style.setProperty(CSS_DURATION, String(dur));
881
+ this._rootEl.style.setProperty(CSS_TIME, String(time));
882
+ this._rootEl.style.setProperty(CSS_PROGRESS, String(dur > 0 ? time / dur : 0));
883
+ }
884
+ _updateOverlayStyle() {
885
+ if (this._showOverlay) {
886
+ this._overlayEl.style.webkitTextFillColor = "";
887
+ this._overlayEl.style.color = "rgba(255, 0, 0, 0.4)";
888
+ } else {
889
+ this._overlayEl.style.webkitTextFillColor = "transparent";
890
+ this._overlayEl.style.color = "";
891
+ }
892
+ }
893
+ _updateSentinelTransition() {
894
+ const isCss = this._timeControl.mode === "css";
895
+ this._sentinelEl.style.transition = isCss ? `font-size 0.001s, line-height 0.001s, color 0.001s, ${CSS_PROGRESS} 0.001s` : "font-size 0.001s, line-height 0.001s, color 0.001s";
896
+ }
897
+ _onResize = (entries) => {
898
+ const entry = entries[0];
899
+ if (!entry) return;
900
+ const newWidth = entry.contentRect.width;
901
+ const styles = getComputedStyle(this._rootEl);
902
+ const newFontSize = Number.parseFloat(styles.fontSize);
903
+ const parsedLh = Number.parseFloat(styles.lineHeight);
904
+ const newLineHeight = Number.isNaN(parsedLh) ? this._fallbackLineHeight(newFontSize) : parsedLh;
905
+ const newColor = styles.color;
906
+ let changed = false;
907
+ let layoutChanged = false;
908
+ if (newWidth !== this._containerWidth) {
909
+ this._containerWidth = newWidth;
910
+ layoutChanged = true;
911
+ changed = true;
912
+ }
913
+ if (newFontSize !== this._fontSize) {
914
+ this._fontSize = newFontSize;
915
+ layoutChanged = true;
916
+ changed = true;
917
+ }
918
+ if (newLineHeight !== this._lineHeight) {
919
+ this._lineHeight = newLineHeight;
920
+ layoutChanged = true;
921
+ changed = true;
922
+ }
923
+ if (newColor !== this._currentColor) {
924
+ this._currentColor = newColor;
925
+ changed = true;
926
+ }
927
+ if (layoutChanged) this._recomputeLayout();
928
+ if (changed) this._render();
929
+ };
930
+ _onSentinelTransition = (e) => {
931
+ const styles = getComputedStyle(this._sentinelEl);
932
+ let changed = false;
933
+ if (e.propertyName === "font-size" || e.propertyName === "line-height") {
934
+ const newFontSize = Number.parseFloat(styles.fontSize);
935
+ const parsedLh = Number.parseFloat(styles.lineHeight);
936
+ const newLineHeight = Number.isNaN(parsedLh) ? this._fallbackLineHeight(newFontSize) : parsedLh;
937
+ if (newFontSize !== this._fontSize || newLineHeight !== this._lineHeight) {
938
+ this._fontSize = newFontSize;
939
+ this._lineHeight = newLineHeight;
940
+ this._recomputeLayout();
941
+ changed = true;
942
+ }
943
+ }
944
+ if (e.propertyName === "color") {
945
+ const newColor = styles.color;
946
+ if (newColor !== this._currentColor) {
947
+ this._currentColor = newColor;
948
+ changed = true;
949
+ }
950
+ }
951
+ if (e.propertyName === "--tegaki-progress") {
952
+ this._cssTime = Number(styles.getPropertyValue(CSS_PROGRESS)) * this._timeline.totalDuration;
953
+ changed = true;
954
+ }
955
+ if (changed) this._render();
956
+ };
957
+ _onReducedMotionChange = (e) => {
958
+ this._prefersReducedMotion = e.matches;
959
+ if (this._prefersReducedMotion && this._timeControl.mode === "uncontrolled" && this._timeline.totalDuration > 0) this._internalTime = this._timeline.totalDuration;
960
+ this._evaluatePlayback();
961
+ this._render();
962
+ };
963
+ _loadFont(font) {
964
+ this._font = font;
965
+ this._fontReady = false;
966
+ if (!font) return;
967
+ const pending = ensureFont(font.family, font.fontUrl);
968
+ if (pending === null) {
969
+ this._fontReady = true;
970
+ return;
971
+ }
972
+ const currentFont = font;
973
+ pending.then(() => {
974
+ if (this._font === currentFont && !this._destroyed) {
975
+ this._fontReady = true;
976
+ this._recomputeTimeline();
977
+ this._recomputeLayout();
978
+ this._evaluatePlayback();
979
+ this._updateDom();
980
+ this._render();
981
+ }
982
+ });
983
+ }
984
+ _recomputeTimeline() {
985
+ if (this._font && this._text) this._timeline = computeTimeline(this._text, this._font, this._timing);
986
+ else this._timeline = {
987
+ entries: [],
988
+ totalDuration: 0
989
+ };
990
+ }
991
+ _recomputeLayout() {
992
+ const fontFamily = this._font?.family;
993
+ if (this._fontReady && fontFamily && this._fontSize && this._containerWidth && this._text) this._layout = computeTextLayout(this._text, fontFamily, this._fontSize, this._lineHeight, this._containerWidth);
994
+ else this._layout = null;
995
+ }
996
+ _evaluatePlayback() {
997
+ if (this._timeControl.mode === "uncontrolled" && this._playing && !!this._font && this._fontReady && !this._prefersReducedMotion) this._startLoop();
998
+ else this._stopLoop();
999
+ }
1000
+ _startLoop() {
1001
+ if (this._rafId) return;
1002
+ this._lastTs = null;
1003
+ this._smoothedBoost = 0;
1004
+ this._rafId = requestAnimationFrame(this._tick);
1005
+ }
1006
+ _stopLoop() {
1007
+ if (this._rafId) {
1008
+ cancelAnimationFrame(this._rafId);
1009
+ this._rafId = 0;
1010
+ }
1011
+ }
1012
+ _tick = (ts) => {
1013
+ if (this._destroyed) return;
1014
+ if (this._lastTs === null) this._lastTs = ts;
1015
+ const dtSec = (ts - this._lastTs) / 1e3;
1016
+ this._lastTs = ts;
1017
+ const tc = this._timeControl;
1018
+ if (tc.mode !== "uncontrolled") return;
1019
+ const speed = tc.speed ?? 1;
1020
+ const loop = tc.loop ?? false;
1021
+ const catchUp = tc.catchUp ?? 0;
1022
+ const totalDur = this._timeline.totalDuration;
1023
+ if (totalDur === 0 || !loop && this._internalTime >= totalDur) {
1024
+ this._internalTime = totalDur;
1025
+ this._rafId = requestAnimationFrame(this._tick);
1026
+ return;
1027
+ }
1028
+ let effectiveSpeed = speed;
1029
+ if (catchUp > 0) {
1030
+ const remaining = Math.max(0, totalDur - this._internalTime);
1031
+ const targetBoost = catchUp * Math.max(0, remaining - 2);
1032
+ const attackRate = 4;
1033
+ const releaseRate = loop ? 30 : 2;
1034
+ const rate = targetBoost > this._smoothedBoost ? attackRate : releaseRate;
1035
+ this._smoothedBoost += (targetBoost - this._smoothedBoost) * (1 - Math.exp(-rate * dtSec));
1036
+ effectiveSpeed = speed + this._smoothedBoost;
1037
+ }
1038
+ let next = this._internalTime + dtSec * effectiveSpeed;
1039
+ if (next >= totalDur) {
1040
+ next = loop ? next % totalDur : totalDur;
1041
+ this._smoothedBoost = 0;
1042
+ }
1043
+ this._internalTime = next;
1044
+ this._notifyTimeChange();
1045
+ this._checkCompletion();
1046
+ this._render();
1047
+ this._updateCssProperties();
1048
+ this._rafId = requestAnimationFrame(this._tick);
1049
+ };
1050
+ _notifyTimeChange() {
1051
+ const tc = this._timeControl;
1052
+ if (tc.mode === "uncontrolled" && tc.onTimeChange) tc.onTimeChange(this._internalTime);
1053
+ }
1054
+ _checkCompletion() {
1055
+ const complete = this._timeline.totalDuration > 0 && this.currentTime >= this._timeline.totalDuration;
1056
+ if (complete && !this._prevCompleted) {
1057
+ this._prevCompleted = true;
1058
+ this._onComplete?.();
1059
+ } else if (!complete) this._prevCompleted = false;
1060
+ }
1061
+ _render() {
1062
+ const canvas = this._canvasEl;
1063
+ const font = this._font;
1064
+ const layout = this._layout;
1065
+ const fontSize = this._fontSize;
1066
+ if (!font?.glyphData || !layout || !fontSize) return;
1067
+ const dpr = window.devicePixelRatio || 1;
1068
+ const canvasRect = canvas.getBoundingClientRect();
1069
+ const w = canvasRect.width;
1070
+ const h = canvasRect.height;
1071
+ if (canvas.width !== Math.round(w * dpr) || canvas.height !== Math.round(h * dpr)) {
1072
+ canvas.width = Math.round(w * dpr);
1073
+ canvas.height = Math.round(h * dpr);
1074
+ }
1075
+ const ctx = canvas.getContext("2d");
1076
+ if (!ctx) return;
1077
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
1078
+ ctx.clearRect(0, 0, w, h);
1079
+ const padH = PADDING_H_EM * fontSize;
1080
+ const lineHeight = this._lineHeight;
1081
+ const padV = Math.max(MIN_PADDING_V_EM * fontSize, (MIN_LINE_HEIGHT_EM * fontSize - lineHeight) / 2);
1082
+ ctx.translate(padH, padV);
1083
+ const color = this._currentColor || "black";
1084
+ const halfLeading = (lineHeight - (font.ascender - font.descender) / font.unitsPerEm * fontSize) / 2;
1085
+ const characters = graphemes(this._text);
1086
+ const currentTime = this.currentTime;
1087
+ let y = 0;
1088
+ for (const lineIndices of layout.lines) {
1089
+ let x = 0;
1090
+ for (const charIdx of lineIndices) {
1091
+ const char = characters[charIdx];
1092
+ if (char === "\n") continue;
1093
+ const entry = this._timeline.entries[charIdx];
1094
+ const charWidth = layout.charWidths[charIdx] ?? 0;
1095
+ const kerning = layout.kernings[charIdx] ?? 0;
1096
+ const glyph = font.glyphData[char];
1097
+ if (glyph && entry.hasGlyph) {
1098
+ const localTime = Math.max(0, Math.min(currentTime - entry.offset, entry.duration));
1099
+ const glyphY = y + halfLeading;
1100
+ drawGlyph(ctx, glyph, {
1101
+ x,
1102
+ y: glyphY,
1103
+ fontSize,
1104
+ unitsPerEm: font.unitsPerEm,
1105
+ ascender: font.ascender,
1106
+ descender: font.descender
1107
+ }, localTime, font.lineCap, color, this._resolvedEffects, this._seed + charIdx, this._segmentSize);
1108
+ } else if (!entry.hasGlyph && currentTime >= entry.offset + entry.duration) {
1109
+ const baseline = y + halfLeading + font.ascender / font.unitsPerEm * fontSize;
1110
+ drawFallbackGlyph(ctx, char, x, baseline, fontSize, font.family, color, this._resolvedEffects, this._seed + charIdx);
1111
+ }
1112
+ x += (charWidth + kerning) * fontSize;
1113
+ }
1114
+ y += lineHeight;
1115
+ }
1116
+ }
1117
+ };
1118
+ //#endregion
1119
+ export { drawGlyph as a, ensureFontFace as i, computeTimeline as n, coerceToString as o, computeTextLayout as r, resolveEffects as s, TegakiEngine as t };
1120
+
1121
+ //# sourceMappingURL=core-aZknK_0L.mjs.map