tegaki 0.9.0 → 0.11.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 CHANGED
@@ -1,5 +1,37 @@
1
1
  # tegaki
2
2
 
3
+ ## 0.11.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [`4b7db41`](https://github.com/KurtGokhan/tegaki/commit/4b7db41fb1c247ed766ff10284e9cdabd4ab0a25) Thanks [@KurtGokhan](https://github.com/KurtGokhan)! - Implement new text layout based on DOM and text ranges
8
+
9
+ ### Patch Changes
10
+
11
+ - [`f3602b0`](https://github.com/KurtGokhan/tegaki/commit/f3602b04970c8cb88ea41e87e63ee4709b086d61) Thanks [@KurtGokhan](https://github.com/KurtGokhan)! - improve line cap detection for CJK fonts
12
+
13
+ - [`28f58c6`](https://github.com/KurtGokhan/tegaki/commit/28f58c67f9eae8e0123a915d0efea03eaccd5e27) Thanks [@KurtGokhan](https://github.com/KurtGokhan)! - fixed a bug with generator that caused it to not load all characters in a font, especially CJK
14
+
15
+ - [`047e5e3`](https://github.com/KurtGokhan/tegaki/commit/047e5e31d3ffabbecf25dd36b5f56d298731c630) Thanks [@KurtGokhan](https://github.com/KurtGokhan)! - Add `duration` and `easing` options to uncontrolled time mode.
16
+
17
+ - `duration` stretches or compresses one iteration to take exactly N seconds, derived from the natural timeline inside the engine. Mutually exclusive with `speed` / `catchUp` at the type level (discriminated union); when both are set at runtime, `duration` takes precedence.
18
+ - `easing: (t: number) => number` maps linear progress (0–1) to displayed progress (0–1). Applied at read-time, so `currentTime`, `onTimeChange`, and the `--tegaki-time` / `--tegaki-progress` CSS custom properties all reflect the eased value. Completion is evaluated against linear progress so overshoot/undershoot curves (e.g. `easeOutBack`) don't trip completion early or late.
19
+ - The web component adapter accepts a `duration` attribute; `easing` is available via the `time` JS property only (it's function-valued).
20
+
21
+ ## 0.10.0
22
+
23
+ ### Minor Changes
24
+
25
+ - [`7198553`](https://github.com/KurtGokhan/tegaki/commit/719855392734a8f1b6056db9f0718ac7a8213527) Thanks [@KurtGokhan](https://github.com/KurtGokhan)! - Add controlled progress mode to allow users to specify the exact progress of the animation that is a value between 0 and 1.
26
+
27
+ ### Patch Changes
28
+
29
+ - [`b326f00`](https://github.com/KurtGokhan/tegaki/commit/b326f00d52b97ef19e0214cb4595bd31cd501cf4) Thanks [@KurtGokhan](https://github.com/KurtGokhan)! - Add delay and loop gap for uncontrolled animations
30
+
31
+ - [`1449890`](https://github.com/KurtGokhan/tegaki/commit/144989014c0d9cdbf80fafbb77af646b96065832) Thanks [@KurtGokhan](https://github.com/KurtGokhan)! - Add docs and example for using Tegaki with Remotion. The example is a simple composition that renders a single text prop, but the same principles apply to more complex compositions and dynamic props.
32
+
33
+ - [`1449890`](https://github.com/KurtGokhan/tegaki/commit/144989014c0d9cdbf80fafbb77af646b96065832) Thanks [@KurtGokhan](https://github.com/KurtGokhan)! - Fix rendering when zoom level was not 100%.
34
+
3
35
  ## 0.9.0
4
36
 
5
37
  ### Minor Changes
@@ -1,2 +1,2 @@
1
- import { A as TegakiMultiEffectName, C as Point, D as TegakiEffectName, E as TegakiEffectConfigs, M as TimedPoint, O as TegakiEffects, S as PathCommand, T as TegakiBundle, _ as BBox, a as TimeControlProp, b as GlyphData, c as TimelineConfig, d as TextLayout, f as computeTextLayout, g as resolveEffects, h as ResolvedEffect, i as TimeControlMode, j as TegakiSingletonEffectName, k as TegakiGlyphData, l as TimelineEntry, m as drawGlyph, n as TegakiEngine, o as createBundle, p as ensureFontFace, r as TegakiEngineOptions, s as Timeline, t as CreateElementFn, u as computeTimeline, v as CSSLength, w as Stroke, x as LineCap, y as FontOutput } from "../index-CZUvsAZl.mjs";
2
- export { BBox, CSSLength, CreateElementFn, FontOutput, GlyphData, LineCap, PathCommand, Point, ResolvedEffect, Stroke, TegakiBundle, TegakiEffectConfigs, TegakiEffectName, TegakiEffects, TegakiEngine, TegakiEngineOptions, TegakiGlyphData, TegakiMultiEffectName, TegakiSingletonEffectName, TextLayout, TimeControlMode, TimeControlProp, TimedPoint, Timeline, TimelineConfig, TimelineEntry, computeTextLayout, computeTimeline, createBundle, drawGlyph, ensureFontFace, resolveEffects };
1
+ import { A as Stroke, C as BBox, D as LineCap, E as GlyphData, F as TegakiGlyphData, I as TegakiMultiEffectName, L as TegakiSingletonEffectName, M as TegakiEffectConfigs, N as TegakiEffectName, O as PathCommand, P as TegakiEffects, R as TimedPoint, S as resolveEffects, T as FontOutput, _ as TextLayout, a as CreateElementFn, b as drawGlyph, c as TimeControlProp, d as registerBundle, f as resolveBundle, g as computeTimeline, h as TimelineEntry, i as TegakiEngine, j as TegakiBundle, k as Point, l as createBundle, m as TimelineConfig, n as buildRootProps, o as TegakiEngineOptions, p as Timeline, r as domCreateElement, s as TimeControlMode, t as buildChildren, u as getBundle, v as computeTextLayout, w as CSSLength, x as ResolvedEffect, y as ensureFontFace } from "../index-CZFVynOK.mjs";
2
+ export { BBox, CSSLength, CreateElementFn, FontOutput, GlyphData, LineCap, PathCommand, Point, ResolvedEffect, Stroke, TegakiBundle, TegakiEffectConfigs, TegakiEffectName, TegakiEffects, TegakiEngine, TegakiEngineOptions, TegakiGlyphData, TegakiMultiEffectName, TegakiSingletonEffectName, TextLayout, TimeControlMode, TimeControlProp, TimedPoint, Timeline, TimelineConfig, TimelineEntry, buildChildren, buildRootProps, computeTextLayout, computeTimeline, createBundle, domCreateElement, drawGlyph, ensureFontFace, getBundle, registerBundle, resolveBundle, resolveEffects };
@@ -1,2 +1,2 @@
1
- import { a as ensureFontFace, c as resolveEffects, i as computeTextLayout, n as createBundle, o as drawGlyph, r as computeTimeline, t as TegakiEngine } from "../core-Y5z-Gwut.mjs";
2
- export { TegakiEngine, computeTextLayout, computeTimeline, createBundle, drawGlyph, ensureFontFace, resolveEffects };
1
+ import { a as createBundle, c as resolveBundle, d as ensureFontFace, f as drawGlyph, i as domCreateElement, l as computeTimeline, m as resolveEffects, n as buildChildren, o as getBundle, r as buildRootProps, s as registerBundle, t as TegakiEngine, u as computeTextLayout } from "../core-I9K3LqxK.mjs";
2
+ export { TegakiEngine, buildChildren, buildRootProps, computeTextLayout, computeTimeline, createBundle, domCreateElement, drawGlyph, ensureFontFace, getBundle, registerBundle, resolveBundle, resolveEffects };
@@ -1,4 +1,3 @@
1
- import { layoutWithLines, prepareWithSegments } from "@chenglou/pretext";
2
1
  //#region src/lib/effects.ts
3
2
  const defaultEffects = { pressureWidth: true };
4
3
  const knownEffects = new Set([
@@ -372,87 +371,91 @@ function ensureFont(family, url) {
372
371
  }
373
372
  //#endregion
374
373
  //#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();
374
+ function computeTextLayout(elOrText, fontSize, fontFamily, lineHeight, maxWidth) {
375
+ if (typeof elOrText === "string") return measureWithTempElement(elOrText, fontFamily, fontSize, lineHeight, maxWidth);
376
+ return measureElement(elOrText, fontSize);
377
+ }
378
+ function measureElement(el, fontSize) {
379
+ const textNode = el.firstChild;
380
+ if (!textNode || textNode.nodeType !== Node.TEXT_NODE) return {
381
+ lines: [],
382
+ charOffsets: [],
383
+ charWidths: []
384
+ };
385
+ const chars = graphemes(textNode.textContent ?? "");
386
+ if (!chars.length) return {
387
+ lines: [],
388
+ charOffsets: [],
389
+ charWidths: []
390
+ };
391
+ const range = document.createRange();
392
+ const charOffsets = [];
379
393
  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
394
  const lines = [];
395
+ let currentLine = [];
396
+ let prevTop = -Infinity;
397
+ let lineStartX = 0;
399
398
  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
- }
399
+ for (let i = 0; i < chars.length; i++) {
400
+ const char = chars[i];
401
+ if (char === "\n") {
402
+ charOffsets.push(0);
403
+ charWidths.push(0);
404
+ currentLine.push(i);
405
+ lines.push(currentLine);
406
+ currentLine = [];
407
+ prevTop = -Infinity;
408
+ utf16Offset += char.length;
409
+ continue;
427
410
  }
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);
411
+ range.setStart(textNode, utf16Offset);
412
+ range.setEnd(textNode, utf16Offset + char.length);
413
+ const rects = range.getClientRects();
414
+ utf16Offset += char.length;
415
+ if (rects.length === 0) {
416
+ charOffsets.push(0);
417
+ charWidths.push(0);
418
+ currentLine.push(i);
437
419
  continue;
438
420
  }
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);
421
+ const rect = rects[0];
422
+ if (currentLine.length > 0 && rect.top - prevTop > fontSize * .25) {
423
+ lines.push(currentLine);
424
+ currentLine = [];
425
+ }
426
+ if (currentLine.length === 0) {
427
+ prevTop = rect.top;
428
+ lineStartX = rect.left;
446
429
  }
447
- kernings.push(k);
430
+ charOffsets.push((rect.left - lineStartX) / fontSize);
431
+ charWidths.push(rect.width / fontSize);
432
+ currentLine.push(i);
448
433
  }
434
+ if (currentLine.length > 0) lines.push(currentLine);
449
435
  return {
450
436
  lines,
451
- charWidths,
452
- kernings,
453
- intrinsicWidth
437
+ charOffsets,
438
+ charWidths
454
439
  };
455
440
  }
441
+ function measureWithTempElement(text, fontFamily, fontSize, lineHeight, maxWidth) {
442
+ const el = document.createElement("div");
443
+ el.style.position = "absolute";
444
+ el.style.left = "-9999px";
445
+ el.style.top = "-9999px";
446
+ el.style.visibility = "hidden";
447
+ el.style.fontFamily = fontFamily;
448
+ el.style.fontSize = `${fontSize}px`;
449
+ el.style.lineHeight = `${lineHeight}px`;
450
+ el.style.whiteSpace = "pre-wrap";
451
+ el.style.overflowWrap = "break-word";
452
+ el.style.width = `${maxWidth}px`;
453
+ el.textContent = text;
454
+ document.body.appendChild(el);
455
+ const result = measureElement(el, fontSize);
456
+ document.body.removeChild(el);
457
+ return result;
458
+ }
456
459
  //#endregion
457
460
  //#region src/lib/timeline.ts
458
461
  const DEFAULTS = {
@@ -494,6 +497,25 @@ function computeTimeline(text, font, config) {
494
497
  };
495
498
  }
496
499
  //#endregion
500
+ //#region src/core/bundle-registry.ts
501
+ const bundles = /* @__PURE__ */ new Map();
502
+ /** Register a font bundle so it can be referenced by family name. */
503
+ function registerBundle(bundle) {
504
+ bundles.set(bundle.family, bundle);
505
+ }
506
+ /** Look up a registered bundle by family name. */
507
+ function getBundle(family) {
508
+ return bundles.get(family);
509
+ }
510
+ function resolveBundle(font) {
511
+ if (typeof font === "string") {
512
+ const bundle = getBundle(font);
513
+ if (!bundle) throw new Error(`TegakiEngine: no bundle registered for "${font}". Call TegakiEngine.registerBundle() first.`);
514
+ return bundle;
515
+ }
516
+ return font;
517
+ }
518
+ //#endregion
497
519
  //#region src/core/createBundle.ts
498
520
  /**
499
521
  * Creates a {@link TegakiBundle} from its constituent parts.
@@ -593,14 +615,17 @@ function drawFallbackGlyph(ctx, char, x, baseline, fontSize, fontFamily, color,
593
615
  ctx.restore();
594
616
  }
595
617
  //#endregion
596
- //#region src/core/engine.ts
618
+ //#region src/core/render-elements.ts
597
619
  const PAD_V_CSS = "max(0.2em, 0.9em - 0.5lh)";
598
620
  function buildRootProps(options) {
599
621
  const text = options.text ?? "";
600
622
  const font = resolveBundle(options.font);
601
623
  const fontFamily = font?.family;
602
624
  const duration = text && font ? computeTimeline(text, font, options.timing).totalDuration : 0;
603
- 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;
625
+ const timeObj = typeof options.time === "object" ? options.time : null;
626
+ const rawTime = typeof options.time === "number" ? options.time : timeObj?.mode === "controlled" ? timeObj.unit === "progress" ? timeObj.value * duration : timeObj.value : timeObj?.mode === "uncontrolled" ? timeObj.initialTime ?? 0 : 0;
627
+ const easing = timeObj?.mode === "uncontrolled" ? timeObj.easing : void 0;
628
+ const time = easing && duration > 0 ? easing(rawTime / duration) * duration : rawTime;
604
629
  const progress = duration > 0 ? time / duration : 0;
605
630
  return {
606
631
  "data-tegaki": "root",
@@ -620,7 +645,10 @@ function buildChildren(options, h) {
620
645
  const text = options.text ?? "";
621
646
  const isCss = options.time === "css" || typeof options.time === "object" && options.time?.mode === "css";
622
647
  const showOverlay = options.showOverlay;
623
- return h("div", { style: { position: "relative" } }, h("span", {
648
+ return h("span", { style: {
649
+ display: "block",
650
+ position: "relative"
651
+ } }, h("span", {
624
652
  "data-tegaki": "sentinel",
625
653
  "aria-hidden": "true",
626
654
  style: {
@@ -650,9 +678,10 @@ function buildChildren(options, h) {
650
678
  display: "inline-block",
651
679
  padding: `${PAD_V_CSS} 0.2em`
652
680
  }
653
- }, text)), h("div", {
681
+ }, text)), h("span", {
654
682
  "data-tegaki": "overlay",
655
683
  style: {
684
+ display: "block",
656
685
  userSelect: "auto",
657
686
  whiteSpace: "pre-wrap",
658
687
  overflowWrap: "break-word",
@@ -673,6 +702,8 @@ function domCreateElement(tag, props, ...children) {
673
702
  else el.appendChild(child);
674
703
  return el;
675
704
  }
705
+ //#endregion
706
+ //#region src/core/engine.ts
676
707
  function resolveTimeControl(prop) {
677
708
  if (prop == null) return { mode: "uncontrolled" };
678
709
  if (typeof prop === "number") return {
@@ -682,23 +713,14 @@ function resolveTimeControl(prop) {
682
713
  if (prop === "css") return { mode: "css" };
683
714
  return prop;
684
715
  }
685
- function resolveBundle(font) {
686
- if (typeof font === "string") {
687
- const bundle = TegakiEngine.getBundle(font);
688
- if (!bundle) throw new Error(`TegakiEngine: no bundle registered for "${font}". Call TegakiEngine.registerBundle() first.`);
689
- return bundle;
690
- }
691
- return font;
692
- }
693
- var TegakiEngine = class TegakiEngine {
694
- static _bundles = /* @__PURE__ */ new Map();
716
+ var TegakiEngine = class {
695
717
  /** Register a font bundle so it can be referenced by family name. */
696
718
  static registerBundle(bundle) {
697
- TegakiEngine._bundles.set(bundle.family, bundle);
719
+ registerBundle(bundle);
698
720
  }
699
721
  /** Look up a registered bundle by family name. */
700
722
  static getBundle(family) {
701
- return TegakiEngine._bundles.get(family);
723
+ return getBundle(family);
702
724
  }
703
725
  _rootEl;
704
726
  _contentEl = null;
@@ -721,6 +743,7 @@ var TegakiEngine = class TegakiEngine {
721
743
  totalDuration: 0
722
744
  };
723
745
  _layout = null;
746
+ _layoutKey = "";
724
747
  _fontReady = false;
725
748
  _containerWidth = 0;
726
749
  _fontSize = 0;
@@ -730,6 +753,8 @@ var TegakiEngine = class TegakiEngine {
730
753
  _cssTime = 0;
731
754
  _playing = true;
732
755
  _smoothedBoost = 0;
756
+ _delayRemaining = 0;
757
+ _loopGapRemaining = 0;
733
758
  _lastTs = null;
734
759
  _rafId = 0;
735
760
  _prevCompleted = false;
@@ -781,7 +806,9 @@ var TegakiEngine = class TegakiEngine {
781
806
  get currentTime() {
782
807
  const tc = this._timeControl;
783
808
  if (tc.mode === "css") return this._cssTime;
784
- if (tc.mode === "controlled") return tc.value;
809
+ if (tc.mode === "controlled") return tc.unit === "progress" ? tc.value * this._timeline.totalDuration : tc.value;
810
+ const totalDur = this._timeline.totalDuration;
811
+ if (tc.easing && totalDur > 0) return tc.easing(this._internalTime / totalDur) * totalDur;
785
812
  return this._internalTime;
786
813
  }
787
814
  get duration() {
@@ -791,7 +818,10 @@ var TegakiEngine = class TegakiEngine {
791
818
  return this._playing;
792
819
  }
793
820
  get isComplete() {
794
- return this._timeline.totalDuration > 0 && this.currentTime >= this._timeline.totalDuration;
821
+ const totalDur = this._timeline.totalDuration;
822
+ if (totalDur === 0) return false;
823
+ if (this._timeControl.mode === "uncontrolled") return this._internalTime >= totalDur;
824
+ return this.currentTime >= totalDur;
795
825
  }
796
826
  get element() {
797
827
  return this._rootEl;
@@ -809,6 +839,8 @@ var TegakiEngine = class TegakiEngine {
809
839
  seek(time) {
810
840
  if (this._timeControl.mode !== "uncontrolled") return;
811
841
  this._internalTime = Math.max(0, Math.min(time, this._timeline.totalDuration));
842
+ this._delayRemaining = 0;
843
+ this._loopGapRemaining = 0;
812
844
  this._checkCompletion();
813
845
  this._notifyTimeChange();
814
846
  this._render();
@@ -819,6 +851,8 @@ var TegakiEngine = class TegakiEngine {
819
851
  this._internalTime = 0;
820
852
  this._playing = true;
821
853
  this._prevCompleted = false;
854
+ this._delayRemaining = this._timeControl.delay ?? 0;
855
+ this._loopGapRemaining = 0;
822
856
  this._notifyTimeChange();
823
857
  this._evaluatePlayback();
824
858
  }
@@ -846,11 +880,19 @@ var TegakiEngine = class TegakiEngine {
846
880
  const newTc = resolveTimeControl(options.time);
847
881
  const oldTc = this._timeControl;
848
882
  const modeChanged = newTc.mode !== oldTc.mode;
849
- const controlledValueChanged = newTc.mode === "controlled" && oldTc.mode === "controlled" && newTc.value !== oldTc.value;
850
- const uncontrolledChanged = newTc.mode === "uncontrolled" && oldTc.mode === "uncontrolled" && (newTc.speed !== oldTc.speed || newTc.playing !== oldTc.playing || newTc.loop !== oldTc.loop || newTc.catchUp !== oldTc.catchUp);
883
+ const controlledValueChanged = newTc.mode === "controlled" && oldTc.mode === "controlled" && (newTc.value !== oldTc.value || newTc.unit !== oldTc.unit);
884
+ const uncontrolledChanged = newTc.mode === "uncontrolled" && oldTc.mode === "uncontrolled" && (newTc.speed !== oldTc.speed || newTc.duration !== oldTc.duration || newTc.playing !== oldTc.playing || newTc.loop !== oldTc.loop || newTc.delay !== oldTc.delay || newTc.loopGap !== oldTc.loopGap || newTc.catchUp !== oldTc.catchUp || newTc.easing !== oldTc.easing);
851
885
  if (modeChanged || controlledValueChanged || uncontrolledChanged) {
852
886
  this._timeControl = newTc;
853
- if (newTc.mode === "uncontrolled") this._playing = newTc.playing ?? true;
887
+ if (newTc.mode === "uncontrolled") {
888
+ this._playing = newTc.playing ?? true;
889
+ const oldDelay = oldTc.mode === "uncontrolled" ? oldTc.delay ?? 0 : 0;
890
+ const newDelay = newTc.delay ?? 0;
891
+ if (modeChanged || oldDelay !== newDelay) {
892
+ this._delayRemaining = newDelay;
893
+ this._loopGapRemaining = 0;
894
+ }
895
+ }
854
896
  dirtyPlayback = true;
855
897
  dirtyRender = true;
856
898
  this._updateSentinelTransition();
@@ -876,12 +918,10 @@ var TegakiEngine = class TegakiEngine {
876
918
  }
877
919
  if ("onComplete" in options) this._onComplete = options.onComplete;
878
920
  if (dirtyTimeline) this._recomputeTimeline();
921
+ if (dirtyRender || dirtyTimeline || dirtyLayout) this._updateDom();
879
922
  if (dirtyLayout) this._recomputeLayout();
880
923
  if (dirtyPlayback) this._evaluatePlayback();
881
- if (dirtyRender || dirtyTimeline || dirtyLayout) {
882
- this._updateDom();
883
- this._render();
884
- }
924
+ if (dirtyRender || dirtyTimeline || dirtyLayout) this._render();
885
925
  }
886
926
  destroy() {
887
927
  this._destroyed = true;
@@ -907,7 +947,7 @@ var TegakiEngine = class TegakiEngine {
907
947
  _updateDom() {
908
948
  this._rootEl.style.fontFamily = this._font?.family ?? "";
909
949
  this._updateCssProperties();
910
- this._overlayEl.textContent = this._text;
950
+ if (this._overlayEl.textContent !== this._text) this._overlayEl.textContent = this._text;
911
951
  this._canvasFallbackEl.textContent = this._text;
912
952
  }
913
953
  _updateCssProperties() {
@@ -1010,9 +1050,9 @@ var TegakiEngine = class TegakiEngine {
1010
1050
  if (this._font === currentFont && !this._destroyed) {
1011
1051
  this._fontReady = true;
1012
1052
  this._recomputeTimeline();
1053
+ this._updateDom();
1013
1054
  this._recomputeLayout();
1014
1055
  this._evaluatePlayback();
1015
- this._updateDom();
1016
1056
  this._render();
1017
1057
  }
1018
1058
  });
@@ -1025,9 +1065,15 @@ var TegakiEngine = class TegakiEngine {
1025
1065
  };
1026
1066
  }
1027
1067
  _recomputeLayout() {
1028
- const fontFamily = this._font?.family;
1029
- if (this._fontReady && fontFamily && this._fontSize && this._containerWidth && this._text) this._layout = computeTextLayout(this._text, fontFamily, this._fontSize, this._lineHeight, this._containerWidth);
1030
- else this._layout = null;
1068
+ if (this._fontReady && this._font?.family && this._fontSize && this._containerWidth && this._text) {
1069
+ const key = `${this._text}\0${this._font.family}\0${this._fontSize}\0${this._lineHeight}\0${this._containerWidth}`;
1070
+ if (key === this._layoutKey) return;
1071
+ this._layoutKey = key;
1072
+ this._layout = computeTextLayout(this._overlayEl, this._fontSize);
1073
+ } else {
1074
+ this._layoutKey = "";
1075
+ this._layout = null;
1076
+ }
1031
1077
  }
1032
1078
  _evaluatePlayback() {
1033
1079
  if (this._timeControl.mode === "uncontrolled" && this._playing && !!this._font && this._fontReady && !this._prefersReducedMotion) this._startLoop();
@@ -1052,28 +1098,59 @@ var TegakiEngine = class TegakiEngine {
1052
1098
  this._lastTs = ts;
1053
1099
  const tc = this._timeControl;
1054
1100
  if (tc.mode !== "uncontrolled") return;
1055
- const speed = tc.speed ?? 1;
1056
1101
  const loop = tc.loop ?? false;
1057
- const catchUp = tc.catchUp ?? 0;
1058
1102
  const totalDur = this._timeline.totalDuration;
1103
+ const durationOverride = tc.duration;
1104
+ const useDuration = durationOverride !== void 0 && durationOverride > 0;
1059
1105
  if (totalDur === 0 || !loop && this._internalTime >= totalDur) {
1060
1106
  this._internalTime = totalDur;
1061
1107
  this._rafId = requestAnimationFrame(this._tick);
1062
1108
  return;
1063
1109
  }
1064
- let effectiveSpeed = speed;
1065
- if (catchUp > 0) {
1066
- const remaining = Math.max(0, totalDur - this._internalTime);
1067
- const targetBoost = catchUp * Math.max(0, remaining - 2);
1068
- const attackRate = 4;
1069
- const releaseRate = loop ? 30 : 2;
1070
- const rate = targetBoost > this._smoothedBoost ? attackRate : releaseRate;
1071
- this._smoothedBoost += (targetBoost - this._smoothedBoost) * (1 - Math.exp(-rate * dtSec));
1072
- effectiveSpeed = speed + this._smoothedBoost;
1110
+ if (this._delayRemaining > 0) {
1111
+ this._delayRemaining = Math.max(0, this._delayRemaining - dtSec);
1112
+ this._rafId = requestAnimationFrame(this._tick);
1113
+ return;
1114
+ }
1115
+ if (this._loopGapRemaining > 0) {
1116
+ this._loopGapRemaining = Math.max(0, this._loopGapRemaining - dtSec);
1117
+ if (this._loopGapRemaining <= 0) {
1118
+ this._internalTime = 0;
1119
+ this._prevCompleted = false;
1120
+ this._smoothedBoost = 0;
1121
+ }
1122
+ this._notifyTimeChange();
1123
+ this._render();
1124
+ this._updateCssProperties();
1125
+ this._rafId = requestAnimationFrame(this._tick);
1126
+ return;
1127
+ }
1128
+ let effectiveSpeed;
1129
+ if (useDuration) effectiveSpeed = totalDur / durationOverride;
1130
+ else {
1131
+ const speed = tc.speed ?? 1;
1132
+ const catchUp = tc.catchUp ?? 0;
1133
+ effectiveSpeed = speed;
1134
+ if (catchUp > 0) {
1135
+ const remaining = Math.max(0, totalDur - this._internalTime);
1136
+ const targetBoost = catchUp * Math.max(0, remaining - 2);
1137
+ const attackRate = 4;
1138
+ const releaseRate = loop ? 30 : 2;
1139
+ const rate = targetBoost > this._smoothedBoost ? attackRate : releaseRate;
1140
+ this._smoothedBoost += (targetBoost - this._smoothedBoost) * (1 - Math.exp(-rate * dtSec));
1141
+ effectiveSpeed = speed + this._smoothedBoost;
1142
+ }
1073
1143
  }
1074
1144
  let next = this._internalTime + dtSec * effectiveSpeed;
1075
1145
  if (next >= totalDur) {
1076
- next = loop ? next % totalDur : totalDur;
1146
+ if (loop) {
1147
+ const loopGap = tc.loopGap ?? 0;
1148
+ if (loopGap > 0) {
1149
+ next = totalDur;
1150
+ this._loopGapRemaining = loopGap;
1151
+ } else if (this._internalTime < totalDur) next = totalDur;
1152
+ else next %= totalDur;
1153
+ } else next = totalDur;
1077
1154
  this._smoothedBoost = 0;
1078
1155
  }
1079
1156
  this._internalTime = next;
@@ -1085,10 +1162,10 @@ var TegakiEngine = class TegakiEngine {
1085
1162
  };
1086
1163
  _notifyTimeChange() {
1087
1164
  const tc = this._timeControl;
1088
- if (tc.mode === "uncontrolled" && tc.onTimeChange) tc.onTimeChange(this._internalTime);
1165
+ if (tc.mode === "uncontrolled" && tc.onTimeChange) tc.onTimeChange(this.currentTime);
1089
1166
  }
1090
1167
  _checkCompletion() {
1091
- const complete = this._timeline.totalDuration > 0 && this.currentTime >= this._timeline.totalDuration;
1168
+ const complete = this.isComplete;
1092
1169
  if (complete && !this._prevCompleted) {
1093
1170
  this._prevCompleted = true;
1094
1171
  this._onComplete?.();
@@ -1101,9 +1178,8 @@ var TegakiEngine = class TegakiEngine {
1101
1178
  const fontSize = this._fontSize;
1102
1179
  if (!font?.glyphData || !layout || !fontSize) return;
1103
1180
  const dpr = window.devicePixelRatio || 1;
1104
- const canvasRect = canvas.getBoundingClientRect();
1105
- const w = canvasRect.width;
1106
- const h = canvasRect.height;
1181
+ const w = canvas.offsetWidth;
1182
+ const h = canvas.offsetHeight;
1107
1183
  if (canvas.width !== Math.round(w * dpr) || canvas.height !== Math.round(h * dpr)) {
1108
1184
  canvas.width = Math.round(w * dpr);
1109
1185
  canvas.height = Math.round(h * dpr);
@@ -1122,36 +1198,29 @@ var TegakiEngine = class TegakiEngine {
1122
1198
  const currentTime = this.currentTime;
1123
1199
  let y = 0;
1124
1200
  for (const lineIndices of layout.lines) {
1125
- let x = 0;
1126
1201
  for (const charIdx of lineIndices) {
1127
1202
  const char = characters[charIdx];
1128
1203
  if (char === "\n") continue;
1129
1204
  const entry = this._timeline.entries[charIdx];
1130
- const charWidth = layout.charWidths[charIdx] ?? 0;
1131
- const kerning = layout.kernings[charIdx] ?? 0;
1205
+ const x = (layout.charOffsets[charIdx] ?? 0) * fontSize;
1132
1206
  const glyph = font.glyphData[char];
1133
1207
  if (glyph && entry.hasGlyph) {
1134
1208
  const localTime = Math.max(0, Math.min(currentTime - entry.offset, entry.duration));
1135
- const glyphY = y + halfLeading;
1136
1209
  drawGlyph(ctx, glyph, {
1137
1210
  x,
1138
- y: glyphY,
1211
+ y: y + halfLeading,
1139
1212
  fontSize,
1140
1213
  unitsPerEm: font.unitsPerEm,
1141
1214
  ascender: font.ascender,
1142
1215
  descender: font.descender
1143
1216
  }, localTime, font.lineCap, color, this._resolvedEffects, this._seed + charIdx, this._segmentSize);
1144
- } else if (!entry.hasGlyph && currentTime >= entry.offset + entry.duration) {
1145
- const baseline = y + halfLeading + font.ascender / font.unitsPerEm * fontSize;
1146
- drawFallbackGlyph(ctx, char, x, baseline, fontSize, font.family, color, this._resolvedEffects, this._seed + charIdx);
1147
- }
1148
- x += (charWidth + kerning) * fontSize;
1217
+ } else if (!entry.hasGlyph && currentTime >= entry.offset + entry.duration) drawFallbackGlyph(ctx, char, x, y + halfLeading + font.ascender / font.unitsPerEm * fontSize, fontSize, font.family, color, this._resolvedEffects, this._seed + charIdx);
1149
1218
  }
1150
1219
  y += lineHeight;
1151
1220
  }
1152
1221
  }
1153
1222
  };
1154
1223
  //#endregion
1155
- export { ensureFontFace as a, resolveEffects as c, computeTextLayout as i, createBundle as n, drawGlyph as o, computeTimeline as r, coerceToString as s, TegakiEngine as t };
1224
+ export { createBundle as a, resolveBundle as c, ensureFontFace as d, drawGlyph as f, domCreateElement as i, computeTimeline as l, resolveEffects as m, buildChildren as n, getBundle as o, coerceToString as p, buildRootProps as r, registerBundle as s, TegakiEngine as t, computeTextLayout as u };
1156
1225
 
1157
- //# sourceMappingURL=core-Y5z-Gwut.mjs.map
1226
+ //# sourceMappingURL=core-I9K3LqxK.mjs.map