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/CHANGELOG.md +6 -0
- package/README.md +33 -160
- package/dist/core/index.d.mts +2 -0
- package/dist/core/index.mjs +2 -0
- package/dist/core-aZknK_0L.mjs +1121 -0
- package/dist/core-aZknK_0L.mjs.map +1 -0
- package/dist/index-BUNd9bnS.d.mts +39 -0
- package/dist/index-Bzxy01Zq.d.mts +329 -0
- package/dist/index.d.mts +3 -288
- package/dist/index.mjs +3 -922
- package/dist/react/index.d.mts +3 -0
- package/dist/react/index.mjs +3 -0
- package/dist/react-Ddx6VfJc.mjs +53 -0
- package/dist/react-Ddx6VfJc.mjs.map +1 -0
- package/dist/solid/index.d.mts +19 -0
- package/dist/solid/index.mjs +77 -0
- package/dist/solid/index.mjs.map +1 -0
- package/package.json +55 -4
- package/src/astro/TegakiRenderer.astro +126 -0
- package/src/core/engine.ts +878 -0
- package/src/core/index.ts +14 -0
- package/src/env.d.ts +12 -0
- package/src/index.ts +1 -12
- package/src/lib/css-properties.ts +24 -0
- package/src/lib/font.ts +29 -0
- package/src/react/TegakiRenderer.tsx +98 -0
- package/src/react/index.ts +2 -0
- package/src/solid/TegakiRenderer.tsx +99 -0
- package/src/solid/index.ts +2 -0
- package/src/svelte/TegakiRenderer.svelte +90 -0
- package/src/svelte/index.ts +2 -0
- package/src/vue/TegakiRenderer.vue +95 -0
- package/src/vue/index.ts +2 -0
- package/dist/index.mjs.map +0 -1
- package/src/lib/TegakiRenderer.tsx +0 -629
|
@@ -0,0 +1,878 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CSS_DURATION,
|
|
3
|
+
CSS_PROGRESS,
|
|
4
|
+
CSS_TIME,
|
|
5
|
+
MIN_LINE_HEIGHT_EM,
|
|
6
|
+
MIN_PADDING_V_EM,
|
|
7
|
+
PADDING_H_EM,
|
|
8
|
+
registerCssProperties,
|
|
9
|
+
} from '../lib/css-properties.ts';
|
|
10
|
+
import { drawFallbackGlyph } from '../lib/drawFallbackGlyph.ts';
|
|
11
|
+
import { drawGlyph } from '../lib/drawGlyph.ts';
|
|
12
|
+
import { type ResolvedEffect, resolveEffects } from '../lib/effects.ts';
|
|
13
|
+
import { ensureFont } from '../lib/font.ts';
|
|
14
|
+
import type { TextLayout } from '../lib/textLayout.ts';
|
|
15
|
+
import { computeTextLayout } from '../lib/textLayout.ts';
|
|
16
|
+
import type { Timeline, TimelineConfig, TimelineEntry } from '../lib/timeline.ts';
|
|
17
|
+
import { computeTimeline } from '../lib/timeline.ts';
|
|
18
|
+
import { graphemes } from '../lib/utils.ts';
|
|
19
|
+
import type { TegakiBundle, TegakiEffects } from '../types.ts';
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Time control types (shared with adapters)
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
export type TimeControlMode = {
|
|
26
|
+
controlled: {
|
|
27
|
+
mode: 'controlled';
|
|
28
|
+
/** Current time in seconds. */
|
|
29
|
+
value: number;
|
|
30
|
+
};
|
|
31
|
+
uncontrolled: {
|
|
32
|
+
mode: 'uncontrolled';
|
|
33
|
+
/** Initial time in seconds. Default: `0` */
|
|
34
|
+
initialTime?: number;
|
|
35
|
+
/** Playback speed multiplier. Default: `1` */
|
|
36
|
+
speed?: number;
|
|
37
|
+
/** Whether animation is playing. Default: `true` */
|
|
38
|
+
playing?: boolean;
|
|
39
|
+
/** Loop animation when it reaches the end. Default: `false` */
|
|
40
|
+
loop?: boolean;
|
|
41
|
+
/**
|
|
42
|
+
* Catch-up strength. When positive, playback speeds up when there is a
|
|
43
|
+
* large amount of remaining animation and decays back to normal gradually.
|
|
44
|
+
* `0` disables catch-up (default). Higher values ramp up more aggressively.
|
|
45
|
+
* Typical range: `0.2` – `2`.
|
|
46
|
+
*/
|
|
47
|
+
catchUp?: number;
|
|
48
|
+
/** Called on every frame with the current time. */
|
|
49
|
+
onTimeChange?: (time: number) => void;
|
|
50
|
+
};
|
|
51
|
+
css: {
|
|
52
|
+
mode: 'css';
|
|
53
|
+
};
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* A plain number is shorthand for `{ mode: 'controlled', value: number }`.
|
|
58
|
+
* `'css'` is shorthand for `{ mode: 'css' }`.
|
|
59
|
+
* Omit for uncontrolled mode with default settings.
|
|
60
|
+
*/
|
|
61
|
+
export type TimeControlProp = null | undefined | number | 'css' | TimeControlMode[keyof TimeControlMode];
|
|
62
|
+
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// Engine options
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
export interface TegakiEngineOptions {
|
|
68
|
+
text?: string;
|
|
69
|
+
/** A font bundle, or a registered bundle name (see {@link TegakiEngine.registerBundle}). */
|
|
70
|
+
font?: TegakiBundle | string;
|
|
71
|
+
time?: TimeControlProp;
|
|
72
|
+
effects?: TegakiEffects<Record<string, any>>;
|
|
73
|
+
timing?: TimelineConfig;
|
|
74
|
+
segmentSize?: number;
|
|
75
|
+
showOverlay?: boolean;
|
|
76
|
+
onComplete?: () => void;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
// Render elements
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
export type CreateElementFn<T> = (tag: string, props: Record<string, any>, ...children: (T | string)[]) => T;
|
|
84
|
+
|
|
85
|
+
const PAD_V_CSS = 'max(0.2em, 0.9em - 0.5lh)';
|
|
86
|
+
|
|
87
|
+
function buildElements<T>(options: TegakiEngineOptions, h: CreateElementFn<T>): T {
|
|
88
|
+
const text = options.text ?? '';
|
|
89
|
+
const font = resolveBundle(options.font);
|
|
90
|
+
const fontFamily = font?.family;
|
|
91
|
+
const isCss = options.time === 'css' || (typeof options.time === 'object' && options.time?.mode === 'css');
|
|
92
|
+
const showOverlay = options.showOverlay;
|
|
93
|
+
|
|
94
|
+
const duration = text && font ? computeTimeline(text, font, options.timing).totalDuration : 0;
|
|
95
|
+
const time =
|
|
96
|
+
typeof options.time === 'number'
|
|
97
|
+
? options.time
|
|
98
|
+
: typeof options.time === 'object' && options.time?.mode === 'controlled'
|
|
99
|
+
? options.time.value
|
|
100
|
+
: typeof options.time === 'object' && options.time?.mode === 'uncontrolled'
|
|
101
|
+
? (options.time.initialTime ?? 0)
|
|
102
|
+
: 0;
|
|
103
|
+
const progress = duration > 0 ? time / duration : 0;
|
|
104
|
+
|
|
105
|
+
return h(
|
|
106
|
+
'div',
|
|
107
|
+
{
|
|
108
|
+
'data-tegaki': 'root',
|
|
109
|
+
style: {
|
|
110
|
+
position: 'relative',
|
|
111
|
+
maxWidth: '100%',
|
|
112
|
+
width: 'auto',
|
|
113
|
+
height: 'auto',
|
|
114
|
+
fontFamily: fontFamily ?? undefined,
|
|
115
|
+
[CSS_DURATION]: duration,
|
|
116
|
+
[CSS_TIME]: time,
|
|
117
|
+
[CSS_PROGRESS]: progress,
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
h(
|
|
121
|
+
'div',
|
|
122
|
+
{ style: { position: 'relative' } },
|
|
123
|
+
h('span', {
|
|
124
|
+
'data-tegaki': 'sentinel',
|
|
125
|
+
'aria-hidden': 'true',
|
|
126
|
+
style: {
|
|
127
|
+
position: 'absolute',
|
|
128
|
+
width: 0,
|
|
129
|
+
overflow: 'hidden',
|
|
130
|
+
pointerEvents: 'none',
|
|
131
|
+
fontSize: 'inherit',
|
|
132
|
+
lineHeight: 'inherit',
|
|
133
|
+
visibility: 'hidden',
|
|
134
|
+
transition: isCss
|
|
135
|
+
? `font-size 0.001s, line-height 0.001s, color 0.001s, ${CSS_PROGRESS} 0.001s`
|
|
136
|
+
: 'font-size 0.001s, line-height 0.001s, color 0.001s',
|
|
137
|
+
},
|
|
138
|
+
}),
|
|
139
|
+
h(
|
|
140
|
+
'canvas',
|
|
141
|
+
{
|
|
142
|
+
'data-tegaki': 'canvas',
|
|
143
|
+
'aria-hidden': 'true',
|
|
144
|
+
style: {
|
|
145
|
+
position: 'absolute',
|
|
146
|
+
inset: `calc(-1 * ${PAD_V_CSS}) -0.2em`,
|
|
147
|
+
width: 'calc(100% + 0.4em)',
|
|
148
|
+
height: `calc(100% + 2 * ${PAD_V_CSS})`,
|
|
149
|
+
pointerEvents: 'none',
|
|
150
|
+
overflow: 'visible',
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
h(
|
|
154
|
+
'span',
|
|
155
|
+
{
|
|
156
|
+
'data-tegaki': 'canvas-fallback',
|
|
157
|
+
style: { display: 'inline-block', padding: `${PAD_V_CSS} 0.2em` },
|
|
158
|
+
},
|
|
159
|
+
text,
|
|
160
|
+
),
|
|
161
|
+
),
|
|
162
|
+
h(
|
|
163
|
+
'div',
|
|
164
|
+
{
|
|
165
|
+
'data-tegaki': 'overlay',
|
|
166
|
+
style: {
|
|
167
|
+
userSelect: 'auto',
|
|
168
|
+
whiteSpace: 'pre-wrap',
|
|
169
|
+
overflowWrap: 'break-word',
|
|
170
|
+
paddingRight: 1,
|
|
171
|
+
WebkitTextFillColor: showOverlay ? undefined : 'transparent',
|
|
172
|
+
color: showOverlay ? 'rgba(255, 0, 0, 0.4)' : undefined,
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
text,
|
|
176
|
+
),
|
|
177
|
+
),
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
// DOM createElement helper (for vanilla JS constructor)
|
|
183
|
+
// ---------------------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
function domCreateElement(tag: string, props: Record<string, any>, ...children: (HTMLElement | string)[]): HTMLElement {
|
|
186
|
+
const el = document.createElement(tag);
|
|
187
|
+
for (const [key, value] of Object.entries(props)) {
|
|
188
|
+
if (key === 'style' && typeof value === 'object') {
|
|
189
|
+
for (const [k, v] of Object.entries(value as Record<string, any>)) {
|
|
190
|
+
if (v !== undefined && v !== null) {
|
|
191
|
+
if (k.startsWith('--')) {
|
|
192
|
+
el.style.setProperty(k, String(v));
|
|
193
|
+
} else {
|
|
194
|
+
(el.style as any)[k] = typeof v === 'number' && k !== 'opacity' && k !== 'zIndex' ? `${v}px` : v;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
} else if (key === 'aria-hidden') {
|
|
199
|
+
el.setAttribute('aria-hidden', String(value));
|
|
200
|
+
} else if (key.startsWith('data-')) {
|
|
201
|
+
el.setAttribute(key, String(value));
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
for (const child of children) {
|
|
205
|
+
if (typeof child === 'string') {
|
|
206
|
+
el.appendChild(document.createTextNode(child));
|
|
207
|
+
} else {
|
|
208
|
+
el.appendChild(child);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return el;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
// Helpers
|
|
216
|
+
// ---------------------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
function resolveTimeControl(prop: TimeControlProp): TimeControlMode[keyof TimeControlMode] {
|
|
219
|
+
if (prop == null) return { mode: 'uncontrolled' };
|
|
220
|
+
if (typeof prop === 'number') return { mode: 'controlled', value: prop };
|
|
221
|
+
if (prop === 'css') return { mode: 'css' };
|
|
222
|
+
return prop;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ---------------------------------------------------------------------------
|
|
226
|
+
// TegakiEngine
|
|
227
|
+
// ---------------------------------------------------------------------------
|
|
228
|
+
|
|
229
|
+
function resolveBundle(font: TegakiBundle | string | undefined): TegakiBundle | undefined {
|
|
230
|
+
if (typeof font === 'string') {
|
|
231
|
+
const bundle = TegakiEngine.getBundle(font);
|
|
232
|
+
if (!bundle) throw new Error(`TegakiEngine: no bundle registered for "${font}". Call TegakiEngine.registerBundle() first.`);
|
|
233
|
+
return bundle;
|
|
234
|
+
}
|
|
235
|
+
return font;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export class TegakiEngine {
|
|
239
|
+
// --- Bundle registry ---
|
|
240
|
+
private static _bundles = new Map<string, TegakiBundle>();
|
|
241
|
+
|
|
242
|
+
/** Register a font bundle so it can be referenced by family name. */
|
|
243
|
+
static registerBundle(bundle: TegakiBundle): void {
|
|
244
|
+
TegakiEngine._bundles.set(bundle.family, bundle);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Look up a registered bundle by family name. */
|
|
248
|
+
static getBundle(family: string): TegakiBundle | undefined {
|
|
249
|
+
return TegakiEngine._bundles.get(family);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// --- DOM elements ---
|
|
253
|
+
private _rootEl: HTMLDivElement;
|
|
254
|
+
private _sentinelEl: HTMLSpanElement;
|
|
255
|
+
private _canvasEl: HTMLCanvasElement;
|
|
256
|
+
private _overlayEl: HTMLDivElement;
|
|
257
|
+
private _canvasFallbackEl: HTMLSpanElement;
|
|
258
|
+
|
|
259
|
+
// --- Options ---
|
|
260
|
+
private _text = '';
|
|
261
|
+
private _font: TegakiBundle | null = null;
|
|
262
|
+
private _timeControl: TimeControlMode[keyof TimeControlMode] = { mode: 'uncontrolled' };
|
|
263
|
+
private _effects: Record<string, any> | undefined;
|
|
264
|
+
private _timing: TimelineConfig | undefined;
|
|
265
|
+
private _segmentSize: number | undefined;
|
|
266
|
+
private _showOverlay = false;
|
|
267
|
+
private _onComplete: (() => void) | undefined;
|
|
268
|
+
|
|
269
|
+
// --- Derived / cached ---
|
|
270
|
+
private _resolvedEffects: ResolvedEffect[] = resolveEffects(undefined);
|
|
271
|
+
private _seed: number;
|
|
272
|
+
private _timeline: Timeline = { entries: [] as TimelineEntry[], totalDuration: 0 };
|
|
273
|
+
private _layout: TextLayout | null = null;
|
|
274
|
+
private _fontReady = false;
|
|
275
|
+
|
|
276
|
+
// --- Measured from DOM ---
|
|
277
|
+
private _containerWidth = 0;
|
|
278
|
+
private _fontSize = 0;
|
|
279
|
+
private _lineHeight = 0;
|
|
280
|
+
private _currentColor = '';
|
|
281
|
+
|
|
282
|
+
// --- Playback state ---
|
|
283
|
+
private _internalTime = 0;
|
|
284
|
+
private _cssTime = 0;
|
|
285
|
+
private _playing = true;
|
|
286
|
+
private _smoothedBoost = 0;
|
|
287
|
+
private _lastTs: number | null = null;
|
|
288
|
+
private _rafId = 0;
|
|
289
|
+
private _prevCompleted = false;
|
|
290
|
+
private _prefersReducedMotion = false;
|
|
291
|
+
private _destroyed = false;
|
|
292
|
+
|
|
293
|
+
// --- Observers & listeners ---
|
|
294
|
+
private _resizeObserver: ResizeObserver;
|
|
295
|
+
private _mql: MediaQueryList | null = null;
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Renders the engine's element tree using a framework-provided `createElement` callback.
|
|
299
|
+
* This method requires no DOM and can run during SSR.
|
|
300
|
+
*
|
|
301
|
+
* Each element receives a `data-tegaki` attribute so the engine can adopt
|
|
302
|
+
* pre-rendered elements later via `new TegakiEngine(container, { adopt: true })`.
|
|
303
|
+
*/
|
|
304
|
+
static renderElements<T>(options: TegakiEngineOptions, createElement: CreateElementFn<T>): T {
|
|
305
|
+
return buildElements(options, createElement);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
constructor(container: HTMLElement, options?: TegakiEngineOptions & { adopt?: boolean }) {
|
|
309
|
+
registerCssProperties();
|
|
310
|
+
this._seed = Math.random() * 1000;
|
|
311
|
+
|
|
312
|
+
// --- Resolve DOM elements ---
|
|
313
|
+
if (options?.adopt) {
|
|
314
|
+
// Adopt pre-rendered elements (created by renderElements)
|
|
315
|
+
this._rootEl = container.querySelector('[data-tegaki="root"]') as HTMLDivElement;
|
|
316
|
+
this._sentinelEl = container.querySelector('[data-tegaki="sentinel"]') as HTMLSpanElement;
|
|
317
|
+
this._canvasEl = container.querySelector('[data-tegaki="canvas"]') as HTMLCanvasElement;
|
|
318
|
+
this._canvasFallbackEl = container.querySelector('[data-tegaki="canvas-fallback"]') as HTMLSpanElement;
|
|
319
|
+
this._overlayEl = container.querySelector('[data-tegaki="overlay"]') as HTMLDivElement;
|
|
320
|
+
} else {
|
|
321
|
+
// Create DOM from scratch using the same element structure
|
|
322
|
+
const root = buildElements(options ?? {}, domCreateElement);
|
|
323
|
+
container.appendChild(root);
|
|
324
|
+
this._rootEl = root as unknown as HTMLDivElement;
|
|
325
|
+
this._sentinelEl = root.querySelector('[data-tegaki="sentinel"]') as HTMLSpanElement;
|
|
326
|
+
this._canvasEl = root.querySelector('[data-tegaki="canvas"]') as HTMLCanvasElement;
|
|
327
|
+
this._canvasFallbackEl = root.querySelector('[data-tegaki="canvas-fallback"]') as HTMLSpanElement;
|
|
328
|
+
this._overlayEl = root.querySelector('[data-tegaki="overlay"]') as HTMLDivElement;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// --- ResizeObserver ---
|
|
332
|
+
this._resizeObserver = new ResizeObserver(this._onResize);
|
|
333
|
+
this._resizeObserver.observe(this._rootEl);
|
|
334
|
+
|
|
335
|
+
// --- Sentinel transitions ---
|
|
336
|
+
this._sentinelEl.addEventListener('transitionend', this._onSentinelTransition);
|
|
337
|
+
|
|
338
|
+
// --- Reduced motion ---
|
|
339
|
+
if (typeof window !== 'undefined') {
|
|
340
|
+
this._mql = window.matchMedia('(prefers-reduced-motion: reduce)');
|
|
341
|
+
this._prefersReducedMotion = this._mql.matches;
|
|
342
|
+
this._mql.addEventListener('change', this._onReducedMotionChange);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// --- Initial measurement (must run before update so layout has valid dimensions) ---
|
|
346
|
+
this._measure();
|
|
347
|
+
|
|
348
|
+
// --- Apply initial options ---
|
|
349
|
+
if (options) this.update(options);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// =========================================================================
|
|
353
|
+
// Public API
|
|
354
|
+
// =========================================================================
|
|
355
|
+
|
|
356
|
+
get currentTime(): number {
|
|
357
|
+
const tc = this._timeControl;
|
|
358
|
+
if (tc.mode === 'css') return this._cssTime;
|
|
359
|
+
if (tc.mode === 'controlled') return tc.value;
|
|
360
|
+
return this._internalTime;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
get duration(): number {
|
|
364
|
+
return this._timeline.totalDuration;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
get isPlaying(): boolean {
|
|
368
|
+
return this._playing;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
get isComplete(): boolean {
|
|
372
|
+
return this._timeline.totalDuration > 0 && this.currentTime >= this._timeline.totalDuration;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
get element(): HTMLDivElement {
|
|
376
|
+
return this._rootEl;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
play(): void {
|
|
380
|
+
if (this._timeControl.mode !== 'uncontrolled') return;
|
|
381
|
+
this._playing = true;
|
|
382
|
+
this._evaluatePlayback();
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
pause(): void {
|
|
386
|
+
if (this._timeControl.mode !== 'uncontrolled') return;
|
|
387
|
+
this._playing = false;
|
|
388
|
+
this._evaluatePlayback();
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
seek(time: number): void {
|
|
392
|
+
if (this._timeControl.mode !== 'uncontrolled') return;
|
|
393
|
+
this._internalTime = Math.max(0, Math.min(time, this._timeline.totalDuration));
|
|
394
|
+
this._checkCompletion();
|
|
395
|
+
this._notifyTimeChange();
|
|
396
|
+
this._render();
|
|
397
|
+
this._updateCssProperties();
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
restart(): void {
|
|
401
|
+
if (this._timeControl.mode !== 'uncontrolled') return;
|
|
402
|
+
this._internalTime = 0;
|
|
403
|
+
this._playing = true;
|
|
404
|
+
this._prevCompleted = false;
|
|
405
|
+
this._notifyTimeChange();
|
|
406
|
+
this._evaluatePlayback();
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
update(options: Partial<TegakiEngineOptions>): void {
|
|
410
|
+
if (this._destroyed) return;
|
|
411
|
+
|
|
412
|
+
let dirtyTimeline = false;
|
|
413
|
+
let dirtyLayout = false;
|
|
414
|
+
let dirtyRender = false;
|
|
415
|
+
let dirtyPlayback = false;
|
|
416
|
+
|
|
417
|
+
if ('text' in options && options.text !== this._text) {
|
|
418
|
+
this._text = options.text ?? '';
|
|
419
|
+
dirtyTimeline = true;
|
|
420
|
+
dirtyLayout = true;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
if ('font' in options) {
|
|
424
|
+
const resolved = resolveBundle(options.font) ?? null;
|
|
425
|
+
if (resolved !== this._font) {
|
|
426
|
+
this._loadFont(resolved);
|
|
427
|
+
dirtyTimeline = true;
|
|
428
|
+
dirtyLayout = true;
|
|
429
|
+
dirtyPlayback = true;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
if ('time' in options) {
|
|
434
|
+
const newTc = resolveTimeControl(options.time);
|
|
435
|
+
const oldTc = this._timeControl;
|
|
436
|
+
|
|
437
|
+
// Detect meaningful changes
|
|
438
|
+
const modeChanged = newTc.mode !== oldTc.mode;
|
|
439
|
+
const controlledValueChanged = newTc.mode === 'controlled' && oldTc.mode === 'controlled' && newTc.value !== oldTc.value;
|
|
440
|
+
const uncontrolledChanged =
|
|
441
|
+
newTc.mode === 'uncontrolled' &&
|
|
442
|
+
oldTc.mode === 'uncontrolled' &&
|
|
443
|
+
(newTc.speed !== oldTc.speed || newTc.playing !== oldTc.playing || newTc.loop !== oldTc.loop || newTc.catchUp !== oldTc.catchUp);
|
|
444
|
+
|
|
445
|
+
if (modeChanged || controlledValueChanged || uncontrolledChanged) {
|
|
446
|
+
this._timeControl = newTc;
|
|
447
|
+
|
|
448
|
+
if (newTc.mode === 'uncontrolled') {
|
|
449
|
+
this._playing = newTc.playing ?? true;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
dirtyPlayback = true;
|
|
453
|
+
dirtyRender = true;
|
|
454
|
+
|
|
455
|
+
// Update sentinel transition for css mode
|
|
456
|
+
this._updateSentinelTransition();
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
if ('effects' in options && options.effects !== this._effects) {
|
|
461
|
+
this._effects = options.effects as Record<string, any>;
|
|
462
|
+
this._resolvedEffects = resolveEffects(this._effects);
|
|
463
|
+
dirtyRender = true;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
if ('timing' in options && options.timing !== this._timing) {
|
|
467
|
+
this._timing = options.timing;
|
|
468
|
+
dirtyTimeline = true;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
if ('segmentSize' in options && options.segmentSize !== this._segmentSize) {
|
|
472
|
+
this._segmentSize = options.segmentSize;
|
|
473
|
+
dirtyRender = true;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
if ('showOverlay' in options && options.showOverlay !== this._showOverlay) {
|
|
477
|
+
this._showOverlay = options.showOverlay ?? false;
|
|
478
|
+
this._updateOverlayStyle();
|
|
479
|
+
dirtyRender = true;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
if ('onComplete' in options) {
|
|
483
|
+
this._onComplete = options.onComplete;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// --- Recompute ---
|
|
487
|
+
if (dirtyTimeline) this._recomputeTimeline();
|
|
488
|
+
if (dirtyLayout) this._recomputeLayout();
|
|
489
|
+
if (dirtyPlayback) this._evaluatePlayback();
|
|
490
|
+
if (dirtyRender || dirtyTimeline || dirtyLayout) {
|
|
491
|
+
this._updateDom();
|
|
492
|
+
this._render();
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
destroy(): void {
|
|
497
|
+
this._destroyed = true;
|
|
498
|
+
this._stopLoop();
|
|
499
|
+
this._resizeObserver.disconnect();
|
|
500
|
+
this._sentinelEl.removeEventListener('transitionend', this._onSentinelTransition);
|
|
501
|
+
this._mql?.removeEventListener('change', this._onReducedMotionChange);
|
|
502
|
+
this._rootEl.remove();
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// =========================================================================
|
|
506
|
+
// Internal: DOM updates
|
|
507
|
+
// =========================================================================
|
|
508
|
+
|
|
509
|
+
/** Estimate line-height from font metrics when CSS returns "normal". */
|
|
510
|
+
private _fallbackLineHeight(fontSize: number): number {
|
|
511
|
+
if (this._font) {
|
|
512
|
+
return ((this._font.ascender - this._font.descender) / this._font.unitsPerEm) * fontSize;
|
|
513
|
+
}
|
|
514
|
+
return fontSize * 1.2;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
private _measure(): void {
|
|
518
|
+
const styles = getComputedStyle(this._rootEl);
|
|
519
|
+
this._containerWidth = this._rootEl.getBoundingClientRect().width;
|
|
520
|
+
this._fontSize = Number.parseFloat(styles.fontSize);
|
|
521
|
+
const parsedLh = Number.parseFloat(styles.lineHeight);
|
|
522
|
+
this._lineHeight = Number.isNaN(parsedLh) ? this._fallbackLineHeight(this._fontSize) : parsedLh;
|
|
523
|
+
this._currentColor = styles.color;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
private _updateDom(): void {
|
|
527
|
+
// Font family
|
|
528
|
+
this._rootEl.style.fontFamily = this._font?.family ?? '';
|
|
529
|
+
|
|
530
|
+
// CSS custom properties
|
|
531
|
+
this._updateCssProperties();
|
|
532
|
+
|
|
533
|
+
// Overlay text
|
|
534
|
+
this._overlayEl.textContent = this._text;
|
|
535
|
+
this._canvasFallbackEl.textContent = this._text;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
private _updateCssProperties(): void {
|
|
539
|
+
const time = this.currentTime;
|
|
540
|
+
const dur = this._timeline.totalDuration;
|
|
541
|
+
this._rootEl.style.setProperty(CSS_DURATION, String(dur));
|
|
542
|
+
this._rootEl.style.setProperty(CSS_TIME, String(time));
|
|
543
|
+
this._rootEl.style.setProperty(CSS_PROGRESS, String(dur > 0 ? time / dur : 0));
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
private _updateOverlayStyle(): void {
|
|
547
|
+
if (this._showOverlay) {
|
|
548
|
+
this._overlayEl.style.webkitTextFillColor = '';
|
|
549
|
+
this._overlayEl.style.color = 'rgba(255, 0, 0, 0.4)';
|
|
550
|
+
} else {
|
|
551
|
+
this._overlayEl.style.webkitTextFillColor = 'transparent';
|
|
552
|
+
this._overlayEl.style.color = '';
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
private _updateSentinelTransition(): void {
|
|
557
|
+
const isCss = this._timeControl.mode === 'css';
|
|
558
|
+
this._sentinelEl.style.transition = isCss
|
|
559
|
+
? `font-size 0.001s, line-height 0.001s, color 0.001s, ${CSS_PROGRESS} 0.001s`
|
|
560
|
+
: 'font-size 0.001s, line-height 0.001s, color 0.001s';
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// =========================================================================
|
|
564
|
+
// Internal: Resize & sentinel observers
|
|
565
|
+
// =========================================================================
|
|
566
|
+
|
|
567
|
+
private _onResize = (entries: ResizeObserverEntry[]): void => {
|
|
568
|
+
const entry = entries[0];
|
|
569
|
+
if (!entry) return;
|
|
570
|
+
const newWidth = entry.contentRect.width;
|
|
571
|
+
const styles = getComputedStyle(this._rootEl);
|
|
572
|
+
const newFontSize = Number.parseFloat(styles.fontSize);
|
|
573
|
+
const parsedLh = Number.parseFloat(styles.lineHeight);
|
|
574
|
+
const newLineHeight = Number.isNaN(parsedLh) ? this._fallbackLineHeight(newFontSize) : parsedLh;
|
|
575
|
+
const newColor = styles.color;
|
|
576
|
+
|
|
577
|
+
let changed = false;
|
|
578
|
+
let layoutChanged = false;
|
|
579
|
+
|
|
580
|
+
if (newWidth !== this._containerWidth) {
|
|
581
|
+
this._containerWidth = newWidth;
|
|
582
|
+
layoutChanged = true;
|
|
583
|
+
changed = true;
|
|
584
|
+
}
|
|
585
|
+
if (newFontSize !== this._fontSize) {
|
|
586
|
+
this._fontSize = newFontSize;
|
|
587
|
+
layoutChanged = true;
|
|
588
|
+
changed = true;
|
|
589
|
+
}
|
|
590
|
+
if (newLineHeight !== this._lineHeight) {
|
|
591
|
+
this._lineHeight = newLineHeight;
|
|
592
|
+
layoutChanged = true;
|
|
593
|
+
changed = true;
|
|
594
|
+
}
|
|
595
|
+
if (newColor !== this._currentColor) {
|
|
596
|
+
this._currentColor = newColor;
|
|
597
|
+
changed = true;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
if (layoutChanged) this._recomputeLayout();
|
|
601
|
+
if (changed) this._render();
|
|
602
|
+
};
|
|
603
|
+
|
|
604
|
+
private _onSentinelTransition = (e: TransitionEvent): void => {
|
|
605
|
+
const styles = getComputedStyle(this._sentinelEl);
|
|
606
|
+
let changed = false;
|
|
607
|
+
|
|
608
|
+
if (e.propertyName === 'font-size' || e.propertyName === 'line-height') {
|
|
609
|
+
const newFontSize = Number.parseFloat(styles.fontSize);
|
|
610
|
+
const parsedLh = Number.parseFloat(styles.lineHeight);
|
|
611
|
+
const newLineHeight = Number.isNaN(parsedLh) ? this._fallbackLineHeight(newFontSize) : parsedLh;
|
|
612
|
+
if (newFontSize !== this._fontSize || newLineHeight !== this._lineHeight) {
|
|
613
|
+
this._fontSize = newFontSize;
|
|
614
|
+
this._lineHeight = newLineHeight;
|
|
615
|
+
this._recomputeLayout();
|
|
616
|
+
changed = true;
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
if (e.propertyName === 'color') {
|
|
621
|
+
const newColor = styles.color;
|
|
622
|
+
if (newColor !== this._currentColor) {
|
|
623
|
+
this._currentColor = newColor;
|
|
624
|
+
changed = true;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
if (e.propertyName === CSS_PROGRESS) {
|
|
629
|
+
const rawProgress = Number(styles.getPropertyValue(CSS_PROGRESS));
|
|
630
|
+
this._cssTime = rawProgress * this._timeline.totalDuration;
|
|
631
|
+
changed = true;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
if (changed) this._render();
|
|
635
|
+
};
|
|
636
|
+
|
|
637
|
+
// =========================================================================
|
|
638
|
+
// Internal: Reduced motion
|
|
639
|
+
// =========================================================================
|
|
640
|
+
|
|
641
|
+
private _onReducedMotionChange = (e: MediaQueryListEvent): void => {
|
|
642
|
+
this._prefersReducedMotion = e.matches;
|
|
643
|
+
if (this._prefersReducedMotion && this._timeControl.mode === 'uncontrolled' && this._timeline.totalDuration > 0) {
|
|
644
|
+
this._internalTime = this._timeline.totalDuration;
|
|
645
|
+
}
|
|
646
|
+
this._evaluatePlayback();
|
|
647
|
+
this._render();
|
|
648
|
+
};
|
|
649
|
+
|
|
650
|
+
// =========================================================================
|
|
651
|
+
// Internal: Font loading
|
|
652
|
+
// =========================================================================
|
|
653
|
+
|
|
654
|
+
private _loadFont(font: TegakiBundle | null): void {
|
|
655
|
+
this._font = font;
|
|
656
|
+
this._fontReady = false;
|
|
657
|
+
|
|
658
|
+
if (!font) return;
|
|
659
|
+
|
|
660
|
+
const pending = ensureFont(font.family, font.fontUrl);
|
|
661
|
+
if (pending === null) {
|
|
662
|
+
this._fontReady = true;
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
const currentFont = font;
|
|
667
|
+
pending.then(() => {
|
|
668
|
+
if (this._font === currentFont && !this._destroyed) {
|
|
669
|
+
this._fontReady = true;
|
|
670
|
+
this._recomputeTimeline();
|
|
671
|
+
this._recomputeLayout();
|
|
672
|
+
this._evaluatePlayback();
|
|
673
|
+
this._updateDom();
|
|
674
|
+
this._render();
|
|
675
|
+
}
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// =========================================================================
|
|
680
|
+
// Internal: Recomputation
|
|
681
|
+
// =========================================================================
|
|
682
|
+
|
|
683
|
+
private _recomputeTimeline(): void {
|
|
684
|
+
if (this._font && this._text) {
|
|
685
|
+
this._timeline = computeTimeline(this._text, this._font, this._timing);
|
|
686
|
+
} else {
|
|
687
|
+
this._timeline = { entries: [] as TimelineEntry[], totalDuration: 0 };
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
private _recomputeLayout(): void {
|
|
692
|
+
const fontFamily = this._font?.family;
|
|
693
|
+
if (this._fontReady && fontFamily && this._fontSize && this._containerWidth && this._text) {
|
|
694
|
+
this._layout = computeTextLayout(this._text, fontFamily, this._fontSize, this._lineHeight, this._containerWidth);
|
|
695
|
+
} else {
|
|
696
|
+
this._layout = null;
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// =========================================================================
|
|
701
|
+
// Internal: Playback loop
|
|
702
|
+
// =========================================================================
|
|
703
|
+
|
|
704
|
+
private _evaluatePlayback(): void {
|
|
705
|
+
const tc = this._timeControl;
|
|
706
|
+
const shouldRun = tc.mode === 'uncontrolled' && this._playing && !!this._font && this._fontReady && !this._prefersReducedMotion;
|
|
707
|
+
|
|
708
|
+
if (shouldRun) {
|
|
709
|
+
this._startLoop();
|
|
710
|
+
} else {
|
|
711
|
+
this._stopLoop();
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
private _startLoop(): void {
|
|
716
|
+
if (this._rafId) return;
|
|
717
|
+
this._lastTs = null;
|
|
718
|
+
this._smoothedBoost = 0;
|
|
719
|
+
this._rafId = requestAnimationFrame(this._tick);
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
private _stopLoop(): void {
|
|
723
|
+
if (this._rafId) {
|
|
724
|
+
cancelAnimationFrame(this._rafId);
|
|
725
|
+
this._rafId = 0;
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
private _tick = (ts: number): void => {
|
|
730
|
+
if (this._destroyed) return;
|
|
731
|
+
|
|
732
|
+
if (this._lastTs === null) this._lastTs = ts;
|
|
733
|
+
const dtSec = (ts - this._lastTs) / 1000;
|
|
734
|
+
this._lastTs = ts;
|
|
735
|
+
|
|
736
|
+
const tc = this._timeControl;
|
|
737
|
+
if (tc.mode !== 'uncontrolled') return;
|
|
738
|
+
|
|
739
|
+
const speed = tc.speed ?? 1;
|
|
740
|
+
const loop = tc.loop ?? false;
|
|
741
|
+
const catchUp = tc.catchUp ?? 0;
|
|
742
|
+
const totalDur = this._timeline.totalDuration;
|
|
743
|
+
|
|
744
|
+
if (totalDur === 0 || (!loop && this._internalTime >= totalDur)) {
|
|
745
|
+
this._internalTime = totalDur;
|
|
746
|
+
this._rafId = requestAnimationFrame(this._tick);
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
// Compute effective speed with catch-up
|
|
751
|
+
let effectiveSpeed = speed;
|
|
752
|
+
if (catchUp > 0) {
|
|
753
|
+
const remaining = Math.max(0, totalDur - this._internalTime);
|
|
754
|
+
const excess = Math.max(0, remaining - 2);
|
|
755
|
+
const targetBoost = catchUp * excess;
|
|
756
|
+
const attackRate = 4;
|
|
757
|
+
const releaseRate = loop ? 30 : 2;
|
|
758
|
+
const rate = targetBoost > this._smoothedBoost ? attackRate : releaseRate;
|
|
759
|
+
this._smoothedBoost += (targetBoost - this._smoothedBoost) * (1 - Math.exp(-rate * dtSec));
|
|
760
|
+
effectiveSpeed = speed + this._smoothedBoost;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
let next = this._internalTime + dtSec * effectiveSpeed;
|
|
764
|
+
if (next >= totalDur) {
|
|
765
|
+
next = loop ? next % totalDur : totalDur;
|
|
766
|
+
this._smoothedBoost = 0;
|
|
767
|
+
}
|
|
768
|
+
this._internalTime = next;
|
|
769
|
+
|
|
770
|
+
this._notifyTimeChange();
|
|
771
|
+
this._checkCompletion();
|
|
772
|
+
this._render();
|
|
773
|
+
this._updateCssProperties();
|
|
774
|
+
|
|
775
|
+
this._rafId = requestAnimationFrame(this._tick);
|
|
776
|
+
};
|
|
777
|
+
|
|
778
|
+
private _notifyTimeChange(): void {
|
|
779
|
+
const tc = this._timeControl;
|
|
780
|
+
if (tc.mode === 'uncontrolled' && tc.onTimeChange) {
|
|
781
|
+
tc.onTimeChange(this._internalTime);
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
private _checkCompletion(): void {
|
|
786
|
+
const complete = this._timeline.totalDuration > 0 && this.currentTime >= this._timeline.totalDuration;
|
|
787
|
+
if (complete && !this._prevCompleted) {
|
|
788
|
+
this._prevCompleted = true;
|
|
789
|
+
this._onComplete?.();
|
|
790
|
+
} else if (!complete) {
|
|
791
|
+
this._prevCompleted = false;
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
// =========================================================================
|
|
796
|
+
// Internal: Canvas rendering
|
|
797
|
+
// =========================================================================
|
|
798
|
+
|
|
799
|
+
private _render(): void {
|
|
800
|
+
const canvas = this._canvasEl;
|
|
801
|
+
const font = this._font;
|
|
802
|
+
const layout = this._layout;
|
|
803
|
+
const fontSize = this._fontSize;
|
|
804
|
+
|
|
805
|
+
if (!font?.glyphData || !layout || !fontSize) return;
|
|
806
|
+
|
|
807
|
+
const dpr = window.devicePixelRatio || 1;
|
|
808
|
+
const canvasRect = canvas.getBoundingClientRect();
|
|
809
|
+
const w = canvasRect.width;
|
|
810
|
+
const h = canvasRect.height;
|
|
811
|
+
|
|
812
|
+
const needsResize = canvas.width !== Math.round(w * dpr) || canvas.height !== Math.round(h * dpr);
|
|
813
|
+
if (needsResize) {
|
|
814
|
+
canvas.width = Math.round(w * dpr);
|
|
815
|
+
canvas.height = Math.round(h * dpr);
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
const ctx = canvas.getContext('2d');
|
|
819
|
+
if (!ctx) return;
|
|
820
|
+
|
|
821
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
822
|
+
ctx.clearRect(0, 0, w, h);
|
|
823
|
+
|
|
824
|
+
const padH = PADDING_H_EM * fontSize;
|
|
825
|
+
const lineHeight = this._lineHeight;
|
|
826
|
+
const padV = Math.max(MIN_PADDING_V_EM * fontSize, (MIN_LINE_HEIGHT_EM * fontSize - lineHeight) / 2);
|
|
827
|
+
ctx.translate(padH, padV);
|
|
828
|
+
|
|
829
|
+
const color = this._currentColor || 'black';
|
|
830
|
+
const emHeight = (font.ascender - font.descender) / font.unitsPerEm;
|
|
831
|
+
const emHeightPx = emHeight * fontSize;
|
|
832
|
+
const halfLeading = (lineHeight - emHeightPx) / 2;
|
|
833
|
+
const characters = graphemes(this._text);
|
|
834
|
+
const currentTime = this.currentTime;
|
|
835
|
+
|
|
836
|
+
let y = 0;
|
|
837
|
+
for (const lineIndices of layout.lines) {
|
|
838
|
+
let x = 0;
|
|
839
|
+
for (const charIdx of lineIndices) {
|
|
840
|
+
const char = characters[charIdx]!;
|
|
841
|
+
if (char === '\n') continue;
|
|
842
|
+
const entry = this._timeline.entries[charIdx]!;
|
|
843
|
+
const charWidth = layout.charWidths[charIdx] ?? 0;
|
|
844
|
+
const kerning = layout.kernings[charIdx] ?? 0;
|
|
845
|
+
const glyph = font.glyphData[char];
|
|
846
|
+
|
|
847
|
+
if (glyph && entry.hasGlyph) {
|
|
848
|
+
const localTime = Math.max(0, Math.min(currentTime - entry.offset, entry.duration));
|
|
849
|
+
const glyphY = y + halfLeading;
|
|
850
|
+
drawGlyph(
|
|
851
|
+
ctx,
|
|
852
|
+
glyph,
|
|
853
|
+
{
|
|
854
|
+
x,
|
|
855
|
+
y: glyphY,
|
|
856
|
+
fontSize,
|
|
857
|
+
unitsPerEm: font.unitsPerEm,
|
|
858
|
+
ascender: font.ascender,
|
|
859
|
+
descender: font.descender,
|
|
860
|
+
},
|
|
861
|
+
localTime,
|
|
862
|
+
font.lineCap,
|
|
863
|
+
color,
|
|
864
|
+
this._resolvedEffects,
|
|
865
|
+
this._seed + charIdx,
|
|
866
|
+
this._segmentSize,
|
|
867
|
+
);
|
|
868
|
+
} else if (!entry.hasGlyph && currentTime >= entry.offset + entry.duration) {
|
|
869
|
+
const baseline = y + halfLeading + (font.ascender / font.unitsPerEm) * fontSize;
|
|
870
|
+
drawFallbackGlyph(ctx, char, x, baseline, fontSize, font.family, color, this._resolvedEffects, this._seed + charIdx);
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
x += (charWidth + kerning) * fontSize;
|
|
874
|
+
}
|
|
875
|
+
y += lineHeight;
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
}
|