tegaki 0.5.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.
package/dist/index.mjs CHANGED
@@ -1,922 +1,3 @@
1
- import { useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState } from "react";
2
- import { layoutWithLines, prepareWithSegments } from "@chenglou/pretext";
3
- import { jsx, jsxs } from "react/jsx-runtime";
4
- //#region src/lib/effects.ts
5
- const defaultEffects = { pressureWidth: true };
6
- const knownEffects = new Set([
7
- "glow",
8
- "wobble",
9
- "pressureWidth",
10
- "taper",
11
- "gradient"
12
- ]);
13
- /**
14
- * Normalizes an effects record into a sorted array of resolved effects.
15
- * Known keys infer the effect name; custom keys read it from the `effect` field.
16
- * Boolean `true` becomes an empty config. `false`/absent entries are skipped.
17
- */
18
- function resolveEffects(effects) {
19
- const merged = {
20
- ...defaultEffects,
21
- ...effects
22
- };
23
- const result = [];
24
- for (const [key, value] of Object.entries(merged)) {
25
- if (value === false || value == null) continue;
26
- let effectName;
27
- let config;
28
- let order;
29
- if (value === true) {
30
- effectName = knownEffects.has(key) ? key : void 0;
31
- if (!effectName) continue;
32
- config = {};
33
- order = 0;
34
- } else {
35
- if (value.enabled === false) continue;
36
- effectName = value.effect ?? (knownEffects.has(key) ? key : void 0);
37
- if (!effectName) continue;
38
- const { effect: _, order: o, enabled: __, ...rest } = value;
39
- config = rest;
40
- order = o ?? 0;
41
- }
42
- result.push({
43
- effect: effectName,
44
- order,
45
- config
46
- });
47
- }
48
- result.sort((a, b) => a.order - b.order);
49
- return result;
50
- }
51
- /** Check if a specific effect is active. */
52
- function findEffect(effects, name) {
53
- return effects.find((e) => e.effect === name);
54
- }
55
- /** Get all instances of a specific effect (for duplicates). */
56
- function findEffects(effects, name) {
57
- return effects.filter((e) => e.effect === name);
58
- }
59
- //#endregion
60
- //#region src/lib/utils.ts
61
- const segmenter = new Intl.Segmenter(void 0, { granularity: "grapheme" });
62
- /** Resolve a CSSLength to pixels. Plain numbers are px, `"Nem"` is N * fontSize. */
63
- function resolveCSSLength(value, fontSize) {
64
- if (typeof value === "number") return value;
65
- return parseFloat(value) * fontSize;
66
- }
67
- function graphemes(text) {
68
- return Array.from(segmenter.segment(text), (s) => s.segment);
69
- }
70
- function coerceToString(value) {
71
- if (value == null || typeof value === "boolean") return "";
72
- if (typeof value === "string") return value;
73
- if (typeof value === "number" || typeof value === "bigint") return String(value);
74
- if (Array.isArray(value)) return value.map(coerceToString).join("");
75
- return "";
76
- }
77
- //#endregion
78
- //#region src/lib/drawGlyph.ts
79
- function parseColor(color) {
80
- const h = color.replace("#", "");
81
- if (h.length === 3) return [
82
- parseInt(h[0] + h[0], 16),
83
- parseInt(h[1] + h[1], 16),
84
- parseInt(h[2] + h[2], 16),
85
- 1
86
- ];
87
- if (h.length === 4) return [
88
- parseInt(h[0] + h[0], 16),
89
- parseInt(h[1] + h[1], 16),
90
- parseInt(h[2] + h[2], 16),
91
- parseInt(h[3] + h[3], 16) / 255
92
- ];
93
- if (h.length === 8) return [
94
- parseInt(h.slice(0, 2), 16),
95
- parseInt(h.slice(2, 4), 16),
96
- parseInt(h.slice(4, 6), 16),
97
- parseInt(h.slice(6, 8), 16) / 255
98
- ];
99
- return [
100
- parseInt(h.slice(0, 2), 16),
101
- parseInt(h.slice(2, 4), 16),
102
- parseInt(h.slice(4, 6), 16),
103
- 1
104
- ];
105
- }
106
- function lerpColor(a, b, t) {
107
- const r = Math.round(a[0] + (b[0] - a[0]) * t);
108
- const g = Math.round(a[1] + (b[1] - a[1]) * t);
109
- const bl = Math.round(a[2] + (b[2] - a[2]) * t);
110
- const al = a[3] + (b[3] - a[3]) * t;
111
- if (al >= 1) return `rgb(${r},${g},${bl})`;
112
- return `rgba(${r},${g},${bl},${al.toFixed(3)})`;
113
- }
114
- function gradientColor(progress, colors, seed) {
115
- if (colors.length === 0) return "#000";
116
- if (colors.length === 1) return colors[0];
117
- const scaledT = ((progress + seed * .1) % 1 + 1) % 1 * (colors.length - 1);
118
- const i = Math.min(Math.floor(scaledT), colors.length - 2);
119
- const frac = scaledT - i;
120
- return lerpColor(parseColor(colors[i]), parseColor(colors[i + 1]), frac);
121
- }
122
- function rainbowColor(progress, saturation, lightness, seed) {
123
- return `hsl(${(progress * 360 + seed * 137.5) % 360}, ${saturation}%, ${lightness}%)`;
124
- }
125
- function hash(x) {
126
- let h = x * 2654435761 | 0;
127
- h = (h >>> 16 ^ h) * 73244475;
128
- h = (h >>> 16 ^ h) * 73244475;
129
- h = h >>> 16 ^ h;
130
- return (h & 2147483647) / 2147483647;
131
- }
132
- function noise1d(x, seed) {
133
- const i = Math.floor(x);
134
- const f = x - i;
135
- const t = f * f * (3 - 2 * f);
136
- return hash(i + seed * 7919) * (1 - t) + hash(i + 1 + seed * 7919) * t;
137
- }
138
- /**
139
- * Draw a single glyph's strokes onto a canvas context, animated up to `localTime`.
140
- * `localTime` is seconds relative to this glyph's start (0 = glyph begins).
141
- */
142
- function drawGlyph(ctx, glyph, pos, localTime, lineCap, color, effects = [], seed = 0, segmentSize) {
143
- const scale = pos.fontSize / pos.unitsPerEm;
144
- const ox = pos.x;
145
- const oy = pos.y;
146
- const glowEffects = findEffects(effects, "glow");
147
- const wobbleEffect = findEffect(effects, "wobble");
148
- const pressureEffect = findEffect(effects, "pressureWidth");
149
- const taperEffect = findEffect(effects, "taper");
150
- const gradientEffect = findEffect(effects, "gradient");
151
- const pressureAmount = pressureEffect ? Math.max(0, Math.min(pressureEffect.config.strength ?? 1, 1)) : 0;
152
- const wobbleAmplitude = wobbleEffect ? wobbleEffect.config.amplitude ?? 1.5 : 0;
153
- const wobbleFrequency = wobbleEffect ? wobbleEffect.config.frequency ?? 8 : 0;
154
- const wobbleMode = wobbleEffect?.config.mode ?? "sine";
155
- const taperStart = taperEffect ? Math.max(0, Math.min(taperEffect.config.startLength ?? .15, 1)) : 0;
156
- const taperEnd = taperEffect ? Math.max(0, Math.min(taperEffect.config.endLength ?? .15, 1)) : 0;
157
- const gradientColors = gradientEffect?.config.colors;
158
- const isRainbow = gradientColors === "rainbow";
159
- const gradientColorStops = Array.isArray(gradientColors) ? gradientColors : void 0;
160
- const gradientSaturation = gradientEffect?.config.saturation ?? 80;
161
- const gradientLightness = gradientEffect?.config.lightness ?? 55;
162
- const wobbleX = (x, y, idx) => {
163
- if (!wobbleEffect) return x;
164
- if (wobbleMode === "noise") return x + wobbleAmplitude * (noise1d(y * .1 + idx * .7, seed) * 2 - 1);
165
- return x + wobbleAmplitude * Math.sin(wobbleFrequency * (y * .01 + idx * .7) + seed);
166
- };
167
- const wobbleY = (x, y, idx) => {
168
- if (!wobbleEffect) return y;
169
- if (wobbleMode === "noise") return y + wobbleAmplitude * (noise1d(x * .1 + idx * .5, seed * 1.3 + 1e3) * 2 - 1);
170
- return y + wobbleAmplitude * Math.cos(wobbleFrequency * (x * .01 + idx * .5) + seed * 1.3);
171
- };
172
- const px = (x) => ox + x * scale;
173
- const py = (y) => oy + (y + pos.ascender) * scale;
174
- const colorAt = (progress) => {
175
- if (isRainbow) return rainbowColor(progress, gradientSaturation, gradientLightness, seed);
176
- if (gradientColorStops) return gradientColor(progress, gradientColorStops, seed);
177
- return color;
178
- };
179
- const hasGradient = !!gradientEffect;
180
- const taperMultiplier = (progress) => {
181
- let m = 1;
182
- if (taperStart > 0 && progress < taperStart) m = Math.min(m, progress / taperStart);
183
- if (taperEnd > 0 && progress > 1 - taperEnd) m = Math.min(m, (1 - progress) / taperEnd);
184
- return m;
185
- };
186
- for (const stroke of glyph.s) {
187
- if (localTime < stroke.d) continue;
188
- const elapsed = localTime - stroke.d;
189
- const progress = Math.min(elapsed / stroke.a, 1);
190
- const pts = stroke.p;
191
- if (pts.length === 0) continue;
192
- const avgWidth = pts.reduce((s, p) => s + p[2], 0) / pts.length;
193
- const baseLineWidth = Math.max(avgWidth, .5) * scale;
194
- if (pts.length === 1) {
195
- if (progress <= 0) continue;
196
- const p = pts[0];
197
- const dotX = px(wobbleX(p[0], p[1], 0));
198
- const dotY = py(wobbleY(p[0], p[1], 0));
199
- let dotWidth = baseLineWidth + (Math.max(p[2], .5) * scale - baseLineWidth) * pressureAmount;
200
- dotWidth *= taperMultiplier(.5);
201
- for (const glow of glowEffects) {
202
- ctx.save();
203
- ctx.shadowBlur = resolveCSSLength(glow.config.radius ?? 8, pos.fontSize);
204
- ctx.shadowColor = glow.config.color ?? color;
205
- ctx.shadowOffsetX = (glow.config.offsetX ?? 0) * scale;
206
- ctx.shadowOffsetY = (glow.config.offsetY ?? 0) * scale;
207
- ctx.fillStyle = glow.config.color ?? color;
208
- ctx.beginPath();
209
- if (lineCap === "round") ctx.arc(dotX, dotY, dotWidth / 2, 0, Math.PI * 2);
210
- else ctx.rect(dotX - dotWidth / 2, dotY - dotWidth / 2, dotWidth, dotWidth);
211
- ctx.fill();
212
- ctx.restore();
213
- }
214
- ctx.fillStyle = colorAt(0);
215
- ctx.beginPath();
216
- if (lineCap === "round") {
217
- ctx.arc(dotX, dotY, dotWidth / 2, 0, Math.PI * 2);
218
- ctx.fill();
219
- } else ctx.fillRect(dotX - dotWidth / 2, dotY - dotWidth / 2, dotWidth, dotWidth);
220
- continue;
221
- }
222
- let totalLen = 0;
223
- for (let j = 1; j < pts.length; j++) {
224
- const dx = pts[j][0] - pts[j - 1][0];
225
- const dy = pts[j][1] - pts[j - 1][1];
226
- totalLen += Math.sqrt(dx * dx + dy * dy);
227
- }
228
- const drawLen = totalLen * progress;
229
- if (drawLen <= 0) continue;
230
- const segments = [];
231
- let accumulated = 0;
232
- for (let j = 1; j < pts.length; j++) {
233
- const prev = pts[j - 1];
234
- const cur = pts[j];
235
- const dx = cur[0] - prev[0];
236
- const dy = cur[1] - prev[1];
237
- const segLen = Math.sqrt(dx * dx + dy * dy);
238
- if (accumulated + segLen <= drawLen) {
239
- segments.push({
240
- x0: px(wobbleX(prev[0], prev[1], j - 1)),
241
- y0: py(wobbleY(prev[0], prev[1], j - 1)),
242
- x1: px(wobbleX(cur[0], cur[1], j)),
243
- y1: py(wobbleY(cur[0], cur[1], j)),
244
- width0: prev[2],
245
- width1: cur[2],
246
- segProgress: (accumulated + segLen / 2) / totalLen
247
- });
248
- accumulated += segLen;
249
- } else {
250
- const remaining = drawLen - accumulated;
251
- const frac = segLen > 0 ? remaining / segLen : 0;
252
- const ix = prev[0] + dx * frac;
253
- const iy = prev[1] + dy * frac;
254
- const iw = prev[2] + (cur[2] - prev[2]) * frac;
255
- segments.push({
256
- x0: px(wobbleX(prev[0], prev[1], j - 1)),
257
- y0: py(wobbleY(prev[0], prev[1], j - 1)),
258
- x1: px(wobbleX(ix, iy, j)),
259
- y1: py(wobbleY(ix, iy, j)),
260
- width0: prev[2],
261
- width1: iw,
262
- segProgress: (accumulated + remaining / 2) / totalLen
263
- });
264
- break;
265
- }
266
- }
267
- if (segments.length === 0) continue;
268
- const coarseSegments = segments.slice();
269
- const resolvedSegmentSize = segmentSize ?? (pressureAmount > 0 || hasGradient || !!wobbleEffect || !!taperEffect ? 2 : void 0);
270
- if (resolvedSegmentSize != null) {
271
- const maxSegLen = resolvedSegmentSize * scale;
272
- const subdivided = [];
273
- for (const seg of segments) {
274
- const dx = seg.x1 - seg.x0;
275
- const dy = seg.y1 - seg.y0;
276
- const len = Math.sqrt(dx * dx + dy * dy);
277
- const count = Math.max(1, Math.ceil(len / maxSegLen));
278
- for (let k = 0; k < count; k++) {
279
- const t0 = k / count;
280
- const t1 = (k + 1) / count;
281
- subdivided.push({
282
- x0: seg.x0 + dx * t0,
283
- y0: seg.y0 + dy * t0,
284
- x1: seg.x0 + dx * t1,
285
- y1: seg.y0 + dy * t1,
286
- width0: seg.width0 + (seg.width1 - seg.width0) * t0,
287
- width1: seg.width0 + (seg.width1 - seg.width0) * t1,
288
- segProgress: seg.segProgress
289
- });
290
- }
291
- }
292
- for (let k = 0; k < subdivided.length; k++) subdivided[k].segProgress = subdivided.length > 1 ? k / (subdivided.length - 1) : 0;
293
- segments.length = 0;
294
- segments.push(...subdivided);
295
- }
296
- const segWidth = (seg) => {
297
- const perPoint = (seg.width0 + seg.width1) / 2 * scale;
298
- return Math.max(baseLineWidth + (perPoint - baseLineWidth) * pressureAmount, .5 * scale) * taperMultiplier(seg.segProgress);
299
- };
300
- const needsPerSegment = pressureAmount > 0 || taperEffect;
301
- const drawStrokePath = () => {
302
- if (needsPerSegment) for (const seg of segments) {
303
- ctx.lineWidth = segWidth(seg);
304
- ctx.beginPath();
305
- ctx.moveTo(seg.x0, seg.y0);
306
- ctx.lineTo(seg.x1, seg.y1);
307
- ctx.stroke();
308
- }
309
- else {
310
- ctx.lineWidth = baseLineWidth;
311
- ctx.beginPath();
312
- ctx.moveTo(segments[0].x0, segments[0].y0);
313
- for (const seg of segments) ctx.lineTo(seg.x1, seg.y1);
314
- ctx.stroke();
315
- }
316
- };
317
- const drawGradientPath = () => {
318
- for (const seg of segments) {
319
- ctx.strokeStyle = colorAt(seg.segProgress);
320
- if (needsPerSegment) ctx.lineWidth = segWidth(seg);
321
- ctx.beginPath();
322
- ctx.moveTo(seg.x0, seg.y0);
323
- ctx.lineTo(seg.x1, seg.y1);
324
- ctx.stroke();
325
- }
326
- };
327
- ctx.lineCap = lineCap;
328
- ctx.lineJoin = "round";
329
- for (const glow of glowEffects) {
330
- ctx.save();
331
- ctx.shadowBlur = resolveCSSLength(glow.config.radius ?? 8, pos.fontSize);
332
- ctx.shadowColor = glow.config.color ?? color;
333
- ctx.shadowOffsetX = (glow.config.offsetX ?? 0) * scale;
334
- ctx.shadowOffsetY = (glow.config.offsetY ?? 0) * scale;
335
- ctx.strokeStyle = glow.config.color ?? color;
336
- ctx.lineWidth = baseLineWidth;
337
- ctx.beginPath();
338
- ctx.moveTo(coarseSegments[0].x0, coarseSegments[0].y0);
339
- for (const seg of coarseSegments) ctx.lineTo(seg.x1, seg.y1);
340
- ctx.stroke();
341
- ctx.restore();
342
- }
343
- if (hasGradient) drawGradientPath();
344
- else {
345
- ctx.strokeStyle = color;
346
- drawStrokePath();
347
- }
348
- }
349
- }
350
- //#endregion
351
- //#region src/lib/drawFallbackGlyph.ts
352
- /**
353
- * Draw a fallback glyph (plain text) with applicable effects (glow, gradient, wobble).
354
- */
355
- function drawFallbackGlyph(ctx, char, x, baseline, fontSize, fontFamily, color, effects = [], seed = 0) {
356
- const glowEffects = findEffects(effects, "glow");
357
- const wobbleEffect = findEffect(effects, "wobble");
358
- const gradientEffect = findEffect(effects, "gradient");
359
- let dx = 0;
360
- let dy = 0;
361
- if (wobbleEffect) {
362
- const amplitude = (wobbleEffect.config.amplitude ?? 1.5) * (fontSize / 100);
363
- const frequency = wobbleEffect.config.frequency ?? 8;
364
- dx = amplitude * Math.sin(frequency * (baseline * .01) + seed);
365
- dy = amplitude * Math.cos(frequency * (x * .01) + seed * 1.3);
366
- }
367
- const drawX = x + dx;
368
- const drawY = baseline + dy;
369
- let fillColor = color;
370
- if (gradientEffect) {
371
- const colors = gradientEffect.config.colors;
372
- if (colors === "rainbow") {
373
- const saturation = gradientEffect.config.saturation ?? 80;
374
- const lightness = gradientEffect.config.lightness ?? 55;
375
- fillColor = `hsl(${seed * 137.5 % 360}, ${saturation}%, ${lightness}%)`;
376
- } else if (Array.isArray(colors) && colors.length > 0) fillColor = colors[Math.floor(seed) % colors.length];
377
- }
378
- ctx.save();
379
- ctx.font = `${fontSize}px ${fontFamily}`;
380
- ctx.textBaseline = "alphabetic";
381
- for (const glow of glowEffects) {
382
- ctx.save();
383
- ctx.shadowBlur = resolveCSSLength(glow.config.radius ?? 8, fontSize);
384
- ctx.shadowColor = glow.config.color ?? color;
385
- ctx.shadowOffsetX = glow.config.offsetX ?? 0;
386
- ctx.shadowOffsetY = glow.config.offsetY ?? 0;
387
- ctx.fillStyle = glow.config.color ?? color;
388
- ctx.fillText(char, drawX, drawY);
389
- ctx.restore();
390
- }
391
- ctx.fillStyle = fillColor;
392
- ctx.fillText(char, drawX, drawY);
393
- ctx.restore();
394
- }
395
- //#endregion
396
- //#region src/lib/textLayout.ts
397
- function computeTextLayout(text, fontFamily, fontSize, lineHeight, maxWidth) {
398
- const fontStr = `${fontSize}px ${fontFamily}`;
399
- const chars = graphemes(text);
400
- const widthCache = /* @__PURE__ */ new Map();
401
- const charWidths = [];
402
- for (const char of chars) {
403
- let w = widthCache.get(char);
404
- if (w === void 0) {
405
- if (char === "\n") w = 0;
406
- else {
407
- const r = layoutWithLines(prepareWithSegments(char, fontStr, { whiteSpace: "pre-wrap" }), Infinity, lineHeight);
408
- w = r.lines.length > 0 ? r.lines[0].width / fontSize : 0;
409
- }
410
- widthCache.set(char, w);
411
- }
412
- charWidths.push(w);
413
- }
414
- const prepared = prepareWithSegments(text, fontStr, { whiteSpace: "pre-wrap" });
415
- const singleLineResult = layoutWithLines(prepared, Infinity, lineHeight);
416
- const intrinsicWidth = Math.max(0, ...singleLineResult.lines.map((l) => l.width)) / fontSize;
417
- const result = layoutWithLines(prepared, maxWidth, lineHeight);
418
- const utf16ToCodePoint = [];
419
- for (let ci = 0; ci < chars.length; ci++) for (let j = 0; j < chars[ci].length; j++) utf16ToCodePoint.push(ci);
420
- const lines = [];
421
- let utf16Offset = 0;
422
- for (const line of result.lines) {
423
- const indices = [];
424
- const seen = /* @__PURE__ */ new Set();
425
- for (let i = 0; i < line.text.length; i++) {
426
- const cpIdx = utf16ToCodePoint[utf16Offset + i];
427
- if (!seen.has(cpIdx)) {
428
- seen.add(cpIdx);
429
- indices.push(cpIdx);
430
- }
431
- }
432
- utf16Offset += line.text.length;
433
- if (utf16Offset < text.length && text[utf16Offset] === "\n") {
434
- const cpIdx = utf16ToCodePoint[utf16Offset];
435
- indices.push(cpIdx);
436
- utf16Offset++;
437
- }
438
- lines.push(indices);
439
- }
440
- if (utf16Offset < text.length) {
441
- const indices = [];
442
- const seen = /* @__PURE__ */ new Set();
443
- for (let i = utf16Offset; i < text.length; i++) {
444
- const cpIdx = utf16ToCodePoint[i];
445
- if (!seen.has(cpIdx)) {
446
- seen.add(cpIdx);
447
- indices.push(cpIdx);
448
- }
449
- }
450
- lines.push(indices);
451
- }
452
- const kernings = [];
453
- const pairCache = /* @__PURE__ */ new Map();
454
- for (let i = 0; i < chars.length - 1; i++) {
455
- const a = chars[i];
456
- const b = chars[i + 1];
457
- if (a === "\n" || b === "\n") {
458
- kernings.push(0);
459
- continue;
460
- }
461
- const pair = `${a}${b}`;
462
- let k = pairCache.get(pair);
463
- if (k === void 0) {
464
- const r = layoutWithLines(prepareWithSegments(pair, fontStr, { whiteSpace: "pre-wrap" }), Infinity, lineHeight);
465
- k = (r.lines.length > 0 ? r.lines[0].width / fontSize : 0) - (widthCache.get(a) ?? 0) - (widthCache.get(b) ?? 0);
466
- if (Math.abs(k) < .001) k = 0;
467
- pairCache.set(pair, k);
468
- }
469
- kernings.push(k);
470
- }
471
- return {
472
- lines,
473
- charWidths,
474
- kernings,
475
- intrinsicWidth
476
- };
477
- }
478
- //#endregion
479
- //#region src/lib/timeline.ts
480
- const DEFAULTS = {
481
- glyphGap: .1,
482
- wordGap: .15,
483
- lineGap: .3,
484
- unknownDuration: .2
485
- };
486
- function computeTimeline(text, font, config) {
487
- const glyphGap = config?.glyphGap ?? DEFAULTS.glyphGap;
488
- const wordGap = config?.wordGap ?? DEFAULTS.wordGap;
489
- const lineGap = config?.lineGap ?? DEFAULTS.lineGap;
490
- const unknownDuration = config?.unknownDuration ?? DEFAULTS.unknownDuration;
491
- const chars = graphemes(text);
492
- const entries = [];
493
- let offset = 0;
494
- for (const char of chars) {
495
- const glyph = font.glyphData[char];
496
- const hasGlyph = !!glyph;
497
- const duration = hasGlyph ? glyph.t ?? 1 : unknownDuration;
498
- entries.push({
499
- char,
500
- offset,
501
- duration,
502
- hasGlyph
503
- });
504
- offset += duration;
505
- if (char === "\n") offset += lineGap;
506
- else if (char === " ") offset += wordGap;
507
- else offset += glyphGap;
508
- }
509
- if (entries.length > 0) {
510
- const lastChar = chars[chars.length - 1];
511
- offset -= lastChar === "\n" ? lineGap : lastChar === " " ? wordGap : glyphGap;
512
- }
513
- return {
514
- entries,
515
- totalDuration: Math.max(0, offset)
516
- };
517
- }
518
- //#endregion
519
- //#region src/lib/TegakiRenderer.tsx
520
- const fontFaceCache = /* @__PURE__ */ new Map();
521
- /**
522
- * Returns a promise that resolves when the font is ready for text measurement.
523
- * - Already loaded (by us or externally): resolves immediately.
524
- * - Currently loading externally: waits for `document.fonts.ready`.
525
- * - Not registered at all: loads it via the FontFace API.
526
- * Returns `null` if the font is already loaded synchronously.
527
- */
528
- /**
529
- * Ensures the bundle's font face is loaded and available for rendering.
530
- * Resolves immediately if the font is already loaded.
531
- */
532
- async function ensureFontFace(bundle) {
533
- await ensureFont(bundle.family, bundle.fontUrl);
534
- }
535
- function ensureFont(family, url) {
536
- if (typeof document === "undefined") return Promise.resolve();
537
- for (const face of document.fonts) if (face.family === family) {
538
- if (face.status === "loaded") return null;
539
- if (face.status === "loading") return face.loaded.then(() => {});
540
- }
541
- let cached = fontFaceCache.get(url);
542
- if (!cached) {
543
- cached = new FontFace(family, `url(${url})`, { featureSettings: "'calt' 0, 'liga' 0" }).load().then((loaded) => {
544
- document.fonts.add(loaded);
545
- });
546
- fontFaceCache.set(url, cached);
547
- }
548
- return cached;
549
- }
550
- const PADDING_H_EM = .2;
551
- const MIN_LINE_HEIGHT_EM = 1.8;
552
- const MIN_PADDING_V_EM = .2;
553
- const CSS_TIME = "--tegaki-time";
554
- const CSS_PROGRESS = "--tegaki-progress";
555
- const CSS_DURATION = "--tegaki-duration";
556
- let cssPropertiesRegistered = false;
557
- function registerCssProperties() {
558
- if (cssPropertiesRegistered) return;
559
- cssPropertiesRegistered = true;
560
- if (typeof CSS !== "undefined" && "registerProperty" in CSS) for (const prop of [
561
- CSS_TIME,
562
- CSS_PROGRESS,
563
- CSS_DURATION
564
- ]) try {
565
- CSS.registerProperty({
566
- name: prop,
567
- syntax: "<number>",
568
- inherits: true,
569
- initialValue: "0"
570
- });
571
- } catch {}
572
- }
573
- function TegakiRenderer({ ref, font, text, children, time: timeProp, onComplete, effects, segmentSize, timing, showOverlay, ...props }) {
574
- registerCssProperties();
575
- const resolvedText = text ?? coerceToString(children);
576
- const resolvedEffects = useMemo(() => resolveEffects(effects), [effects]);
577
- const [seed] = useState(() => Math.random() * 1e3);
578
- const timeControl = timeProp == null ? { mode: "uncontrolled" } : typeof timeProp === "number" ? {
579
- mode: "controlled",
580
- value: timeProp
581
- } : timeProp === "css" ? { mode: "css" } : timeProp;
582
- const isCss = timeControl.mode === "css";
583
- const isControlled = timeControl.mode === "controlled" || isCss;
584
- const controlledTime = timeControl.mode === "controlled" ? timeControl.value : void 0;
585
- const defaultTime = timeControl.mode === "uncontrolled" ? timeControl.initialTime ?? 0 : 0;
586
- const speed = timeControl.mode === "uncontrolled" ? timeControl.speed ?? 1 : 1;
587
- const propPlaying = timeControl.mode === "uncontrolled" ? timeControl.playing ?? true : false;
588
- const loop = timeControl.mode === "uncontrolled" ? timeControl.loop ?? false : false;
589
- const catchUp = timeControl.mode === "uncontrolled" ? timeControl.catchUp ?? 0 : 0;
590
- const onTimeChange = timeControl.mode === "uncontrolled" ? timeControl.onTimeChange : void 0;
591
- const [playingOverride, setPlayingOverride] = useState(void 0);
592
- const playing = playingOverride ?? propPlaying;
593
- const prevPropPlaying = useRef(propPlaying);
594
- if (prevPropPlaying.current !== propPlaying) {
595
- prevPropPlaying.current = propPlaying;
596
- setPlayingOverride(void 0);
597
- }
598
- const [internalTime, setInternalTime] = useState(defaultTime);
599
- const [cssTime, setCssTime] = useState(0);
600
- const currentTime = isCss ? cssTime : isControlled ? controlledTime : internalTime;
601
- const currentTimeRef = useRef(currentTime);
602
- currentTimeRef.current = currentTime;
603
- const playingRef = useRef(playing);
604
- playingRef.current = playing;
605
- const isControlledRef = useRef(isControlled);
606
- isControlledRef.current = isControlled;
607
- const onTimeChangeRef = useRef(onTimeChange);
608
- onTimeChangeRef.current = onTimeChange;
609
- const onCompleteRef = useRef(onComplete);
610
- onCompleteRef.current = onComplete;
611
- const [loadedFont, setLoadedFont] = useState(() => font && ensureFont(font.family, font.fontUrl) === null ? font : null);
612
- const fontReady = !!font && loadedFont === font;
613
- useEffect(() => {
614
- if (!font) {
615
- setLoadedFont(null);
616
- return;
617
- }
618
- const pending = ensureFont(font.family, font.fontUrl);
619
- if (pending === null) {
620
- setLoadedFont(font);
621
- return;
622
- }
623
- let cancelled = false;
624
- pending.then(() => {
625
- if (!cancelled) setLoadedFont(font);
626
- });
627
- return () => {
628
- cancelled = true;
629
- };
630
- }, [font]);
631
- const fontFamily = font?.family;
632
- const emHeight = font ? (font.ascender - font.descender) / font.unitsPerEm : 0;
633
- const rootRef = useRef(null);
634
- const [containerWidth, setContainerWidth] = useState(0);
635
- const [fontSize, setFontSize] = useState(0);
636
- const [lineHeight, setLineHeight] = useState(0);
637
- const [currentColor, setCurrentColor] = useState("");
638
- const timeline = useMemo(() => font && resolvedText ? computeTimeline(resolvedText, font, timing) : {
639
- entries: [],
640
- totalDuration: 0
641
- }, [
642
- resolvedText,
643
- font,
644
- timing
645
- ]);
646
- const totalDurationRef = useRef(timeline.totalDuration);
647
- totalDurationRef.current = timeline.totalDuration;
648
- const smoothedBoostRef = useRef(0);
649
- const prevCompletedRef = useRef(false);
650
- const isComplete = timeline.totalDuration > 0 && currentTime >= timeline.totalDuration;
651
- useEffect(() => {
652
- if (isComplete && !prevCompletedRef.current) {
653
- prevCompletedRef.current = true;
654
- onCompleteRef.current?.();
655
- } else if (!isComplete) prevCompletedRef.current = false;
656
- });
657
- useImperativeHandle(ref, () => ({
658
- getElement: () => rootRef.current,
659
- getCurrentTime: () => currentTimeRef.current,
660
- getDuration: () => totalDurationRef.current,
661
- getIsPlaying: () => playingRef.current,
662
- getIsComplete: () => totalDurationRef.current > 0 && currentTimeRef.current >= totalDurationRef.current,
663
- play: () => {
664
- if (!isControlledRef.current) setPlayingOverride(true);
665
- },
666
- pause: () => {
667
- if (!isControlledRef.current) setPlayingOverride(false);
668
- },
669
- seek: (time) => {
670
- if (!isControlledRef.current) setInternalTime(Math.max(0, Math.min(time, totalDurationRef.current)));
671
- },
672
- restart: () => {
673
- if (!isControlledRef.current) {
674
- setInternalTime(0);
675
- setPlayingOverride(true);
676
- }
677
- }
678
- }), []);
679
- useEffect(() => {
680
- if (!isControlled) onTimeChangeRef.current?.(internalTime);
681
- }, [internalTime, isControlled]);
682
- const [prefersReducedMotion, setPrefersReducedMotion] = useState(() => typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches);
683
- useEffect(() => {
684
- const mql = window.matchMedia("(prefers-reduced-motion: reduce)");
685
- setPrefersReducedMotion(mql.matches);
686
- const onChange = (e) => setPrefersReducedMotion(e.matches);
687
- mql.addEventListener("change", onChange);
688
- return () => mql.removeEventListener("change", onChange);
689
- }, []);
690
- useEffect(() => {
691
- if (prefersReducedMotion && !isControlled && timeline.totalDuration > 0) setInternalTime(timeline.totalDuration);
692
- }, [
693
- prefersReducedMotion,
694
- isControlled,
695
- timeline.totalDuration
696
- ]);
697
- useEffect(() => {
698
- if (isControlled || !playing || !font || !fontReady || prefersReducedMotion) return;
699
- smoothedBoostRef.current = 0;
700
- let lastTs = null;
701
- let raf;
702
- const attackRate = 4;
703
- const releaseRate = loop ? 30 : 2;
704
- const tick = (ts) => {
705
- if (lastTs === null) lastTs = ts;
706
- const dtSec = (ts - lastTs) / 1e3;
707
- lastTs = ts;
708
- setInternalTime((prev) => {
709
- const totalDur = totalDurationRef.current;
710
- if (totalDur === 0 || !loop && prev >= totalDur) return totalDur;
711
- let effectiveSpeed = speed;
712
- if (catchUp > 0) {
713
- const remaining = Math.max(0, totalDur - prev);
714
- const targetBoost = catchUp * Math.max(0, remaining - 2);
715
- const rate = targetBoost > smoothedBoostRef.current ? attackRate : releaseRate;
716
- smoothedBoostRef.current += (targetBoost - smoothedBoostRef.current) * (1 - Math.exp(-rate * dtSec));
717
- effectiveSpeed = speed + smoothedBoostRef.current;
718
- }
719
- let next = prev + dtSec * effectiveSpeed;
720
- if (next >= totalDur) {
721
- next = loop ? next % totalDur : totalDur;
722
- smoothedBoostRef.current = 0;
723
- }
724
- return next;
725
- });
726
- raf = requestAnimationFrame(tick);
727
- };
728
- raf = requestAnimationFrame(tick);
729
- return () => cancelAnimationFrame(raf);
730
- }, [
731
- isControlled,
732
- playing,
733
- speed,
734
- loop,
735
- catchUp,
736
- font,
737
- fontReady,
738
- prefersReducedMotion
739
- ]);
740
- useEffect(() => {
741
- const el = rootRef.current;
742
- if (!el) return;
743
- const ro = new ResizeObserver(([entry]) => {
744
- if (entry) {
745
- setContainerWidth(entry.contentRect.width);
746
- const styles = getComputedStyle(el);
747
- setFontSize(Number.parseFloat(styles.fontSize));
748
- setLineHeight(Number.parseFloat(styles.lineHeight));
749
- setCurrentColor(styles.color);
750
- }
751
- });
752
- ro.observe(el);
753
- return () => ro.disconnect();
754
- }, []);
755
- const sentinelRef = useRef(null);
756
- useEffect(() => {
757
- const el = sentinelRef.current;
758
- if (!el) return;
759
- const onTransition = (e) => {
760
- const styles = getComputedStyle(el);
761
- if (e.propertyName === "font-size" || e.propertyName === "line-height") {
762
- setFontSize(Number.parseFloat(styles.fontSize));
763
- setLineHeight(Number.parseFloat(styles.lineHeight));
764
- }
765
- if (e.propertyName === "color") setCurrentColor(styles.color);
766
- if (e.propertyName === CSS_PROGRESS) setCssTime(Number(styles.getPropertyValue(CSS_PROGRESS)) * totalDurationRef.current);
767
- };
768
- el.addEventListener("transitionend", onTransition);
769
- return () => el.removeEventListener("transitionend", onTransition);
770
- }, []);
771
- const layout = useMemo(() => {
772
- if (!fontReady || !fontFamily || !fontSize || !containerWidth || !resolvedText) return null;
773
- return computeTextLayout(resolvedText, fontFamily, fontSize, lineHeight, containerWidth);
774
- }, [
775
- fontReady,
776
- resolvedText,
777
- fontFamily,
778
- fontSize,
779
- lineHeight,
780
- containerWidth
781
- ]);
782
- const padH = PADDING_H_EM * fontSize;
783
- const padV = fontSize ? Math.max(MIN_PADDING_V_EM * fontSize, (MIN_LINE_HEIGHT_EM * fontSize - lineHeight) / 2) : 0;
784
- const padVCss = `max(0.2em, 0.9em - 0.5lh)`;
785
- const canvasRef = useRef(null);
786
- useLayoutEffect(() => {
787
- const canvas = canvasRef.current;
788
- if (!canvas || !font?.glyphData || !layout || !fontSize) return;
789
- const dpr = window.devicePixelRatio || 1;
790
- const el = rootRef.current;
791
- if (!el) return;
792
- const canvasRect = canvas.getBoundingClientRect();
793
- const w = canvasRect.width;
794
- const h = canvasRect.height;
795
- if (canvas.width !== Math.round(w * dpr) || canvas.height !== Math.round(h * dpr)) {
796
- canvas.width = Math.round(w * dpr);
797
- canvas.height = Math.round(h * dpr);
798
- }
799
- const ctx = canvas.getContext("2d");
800
- if (!ctx) return;
801
- ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
802
- ctx.clearRect(0, 0, w, h);
803
- ctx.translate(padH, padV);
804
- const color = currentColor || getComputedStyle(el).color;
805
- const halfLeading = (lineHeight - emHeight * fontSize) / 2;
806
- const characters = graphemes(resolvedText);
807
- let y = 0;
808
- for (const lineIndices of layout.lines) {
809
- let x = 0;
810
- for (const charIdx of lineIndices) {
811
- const char = characters[charIdx];
812
- if (char === "\n") continue;
813
- const entry = timeline.entries[charIdx];
814
- const charWidth = layout.charWidths[charIdx] ?? 0;
815
- const kerning = layout.kernings[charIdx] ?? 0;
816
- const glyph = font.glyphData[char];
817
- if (glyph && entry.hasGlyph) {
818
- const localTime = Math.max(0, Math.min(currentTime - entry.offset, entry.duration));
819
- const glyphY = y + halfLeading;
820
- drawGlyph(ctx, glyph, {
821
- x,
822
- y: glyphY,
823
- fontSize,
824
- unitsPerEm: font.unitsPerEm,
825
- ascender: font.ascender,
826
- descender: font.descender
827
- }, localTime, font.lineCap, color, resolvedEffects, seed + charIdx, segmentSize);
828
- } else if (!entry.hasGlyph && currentTime >= entry.offset + entry.duration) {
829
- const baseline = y + halfLeading + font.ascender / font.unitsPerEm * fontSize;
830
- drawFallbackGlyph(ctx, char, x, baseline, fontSize, fontFamily, color, resolvedEffects, seed + charIdx);
831
- }
832
- x += (charWidth + kerning) * fontSize;
833
- }
834
- y += lineHeight;
835
- }
836
- }, [
837
- currentTime,
838
- timeline,
839
- layout,
840
- font,
841
- fontFamily,
842
- fontSize,
843
- lineHeight,
844
- resolvedText,
845
- emHeight,
846
- padH,
847
- padV,
848
- currentColor,
849
- resolvedEffects,
850
- seed,
851
- segmentSize
852
- ]);
853
- return /* @__PURE__ */ jsx("div", {
854
- ref: rootRef,
855
- ...props,
856
- style: {
857
- ...props.style,
858
- position: "relative",
859
- maxWidth: "100%",
860
- width: "auto",
861
- height: "auto",
862
- fontFamily,
863
- [CSS_DURATION]: timeline.totalDuration,
864
- [CSS_TIME]: currentTime,
865
- [CSS_PROGRESS]: timeline.totalDuration > 0 ? currentTime / timeline.totalDuration : 0
866
- },
867
- children: /* @__PURE__ */ jsxs("div", {
868
- style: { position: "relative" },
869
- children: [
870
- /* @__PURE__ */ jsx("span", {
871
- ref: sentinelRef,
872
- "aria-hidden": "true",
873
- style: {
874
- position: "absolute",
875
- width: 0,
876
- overflow: "hidden",
877
- pointerEvents: "none",
878
- fontSize: "inherit",
879
- lineHeight: "inherit",
880
- visibility: "hidden",
881
- 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"
882
- },
883
- children: "\xA0"
884
- }),
885
- /* @__PURE__ */ jsx("canvas", {
886
- ref: canvasRef,
887
- "aria-hidden": "true",
888
- style: {
889
- position: "absolute",
890
- inset: `calc(-1 * ${padVCss}) -0.2em`,
891
- width: `calc(100% + 0.4em)`,
892
- height: `calc(100% + 2 * ${padVCss})`,
893
- pointerEvents: "none",
894
- overflow: "visible"
895
- },
896
- children: /* @__PURE__ */ jsx("span", {
897
- style: {
898
- display: "inline-block",
899
- padding: `${padVCss} 0.2em`
900
- },
901
- children: resolvedText
902
- })
903
- }),
904
- /* @__PURE__ */ jsx("div", {
905
- style: {
906
- userSelect: "auto",
907
- whiteSpace: "pre-wrap",
908
- overflowWrap: "break-word",
909
- paddingRight: 1,
910
- WebkitTextFillColor: showOverlay ? void 0 : "transparent",
911
- color: showOverlay ? "rgba(255, 0, 0, 0.4)" : void 0
912
- },
913
- children: resolvedText
914
- })
915
- ]
916
- })
917
- });
918
- }
919
- //#endregion
920
- export { TegakiRenderer, computeTimeline, drawGlyph, ensureFontFace };
921
-
922
- //# sourceMappingURL=index.mjs.map
1
+ import { a as drawGlyph, i as ensureFontFace, n as computeTimeline, r as computeTextLayout, s as resolveEffects, t as TegakiEngine } from "./core-aZknK_0L.mjs";
2
+ import { t as TegakiRenderer } from "./react-Ddx6VfJc.mjs";
3
+ export { TegakiEngine, TegakiRenderer, computeTextLayout, computeTimeline, drawGlyph, ensureFontFace, resolveEffects };