@smoove/core 0.1.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/LICENSE +21 -0
- package/README.md +80 -0
- package/dist/animation/color.d.ts +9 -0
- package/dist/animation/color.d.ts.map +1 -0
- package/dist/animation/color.js +224 -0
- package/dist/animation/color.js.map +1 -0
- package/dist/animation/easing.d.ts +22 -0
- package/dist/animation/easing.d.ts.map +1 -0
- package/dist/animation/easing.js +59 -0
- package/dist/animation/easing.js.map +1 -0
- package/dist/animation/interpolate-colors.d.ts +7 -0
- package/dist/animation/interpolate-colors.d.ts.map +1 -0
- package/dist/animation/interpolate-colors.js +19 -0
- package/dist/animation/interpolate-colors.js.map +1 -0
- package/dist/animation/interpolate.d.ts +12 -0
- package/dist/animation/interpolate.d.ts.map +1 -0
- package/dist/animation/interpolate.js +65 -0
- package/dist/animation/interpolate.js.map +1 -0
- package/dist/audio/asset.d.ts +21 -0
- package/dist/audio/asset.d.ts.map +1 -0
- package/dist/audio/asset.js +2 -0
- package/dist/audio/asset.js.map +1 -0
- package/dist/audio/audio-driver.d.ts +34 -0
- package/dist/audio/audio-driver.d.ts.map +1 -0
- package/dist/audio/audio-driver.js +2 -0
- package/dist/audio/audio-driver.js.map +1 -0
- package/dist/audio/audio-for-preview.d.ts +12 -0
- package/dist/audio/audio-for-preview.d.ts.map +1 -0
- package/dist/audio/audio-for-preview.js +70 -0
- package/dist/audio/audio-for-preview.js.map +1 -0
- package/dist/audio/audio-for-rendering.d.ts +16 -0
- package/dist/audio/audio-for-rendering.d.ts.map +1 -0
- package/dist/audio/audio-for-rendering.js +32 -0
- package/dist/audio/audio-for-rendering.js.map +1 -0
- package/dist/audio/audio-source-browser.d.ts +30 -0
- package/dist/audio/audio-source-browser.d.ts.map +1 -0
- package/dist/audio/audio-source-browser.js +139 -0
- package/dist/audio/audio-source-browser.js.map +1 -0
- package/dist/audio/audio-source.d.ts +38 -0
- package/dist/audio/audio-source.d.ts.map +1 -0
- package/dist/audio/audio-source.js +2 -0
- package/dist/audio/audio-source.js.map +1 -0
- package/dist/audio/index.d.ts +45 -0
- package/dist/audio/index.d.ts.map +1 -0
- package/dist/audio/index.js +143 -0
- package/dist/audio/index.js.map +1 -0
- package/dist/audio/mixer.d.ts +43 -0
- package/dist/audio/mixer.d.ts.map +1 -0
- package/dist/audio/mixer.js +51 -0
- package/dist/audio/mixer.js.map +1 -0
- package/dist/audio/types.d.ts +31 -0
- package/dist/audio/types.d.ts.map +1 -0
- package/dist/audio/types.js +2 -0
- package/dist/audio/types.js.map +1 -0
- package/dist/block.d.ts +16 -0
- package/dist/block.d.ts.map +1 -0
- package/dist/block.js +173 -0
- package/dist/block.js.map +1 -0
- package/dist/composition.d.ts.map +1 -0
- package/dist/composition.js.map +1 -0
- package/dist/easing.d.ts +22 -0
- package/dist/easing.d.ts.map +1 -0
- package/dist/easing.js +59 -0
- package/dist/easing.js.map +1 -0
- package/dist/emitter.d.ts +7 -0
- package/dist/emitter.d.ts.map +1 -0
- package/dist/emitter.js +29 -0
- package/dist/emitter.js.map +1 -0
- package/dist/engine/composition.d.ts +182 -0
- package/dist/engine/composition.d.ts.map +1 -0
- package/dist/engine/composition.js +527 -0
- package/dist/engine/composition.js.map +1 -0
- package/dist/engine/emitter.d.ts +7 -0
- package/dist/engine/emitter.d.ts.map +1 -0
- package/dist/engine/emitter.js +29 -0
- package/dist/engine/emitter.js.map +1 -0
- package/dist/engine/environment.d.ts +30 -0
- package/dist/engine/environment.d.ts.map +1 -0
- package/dist/engine/environment.js +27 -0
- package/dist/engine/environment.js.map +1 -0
- package/dist/engine/offsets.d.ts +39 -0
- package/dist/engine/offsets.d.ts.map +1 -0
- package/dist/engine/offsets.js +54 -0
- package/dist/engine/offsets.js.map +1 -0
- package/dist/engine/runtime-defaults.d.ts +50 -0
- package/dist/engine/runtime-defaults.d.ts.map +1 -0
- package/dist/engine/runtime-defaults.js +77 -0
- package/dist/engine/runtime-defaults.js.map +1 -0
- package/dist/engine/sequence.d.ts +44 -0
- package/dist/engine/sequence.d.ts.map +1 -0
- package/dist/engine/sequence.js +101 -0
- package/dist/engine/sequence.js.map +1 -0
- package/dist/engine/series.d.ts +40 -0
- package/dist/engine/series.d.ts.map +1 -0
- package/dist/engine/series.js +58 -0
- package/dist/engine/series.js.map +1 -0
- package/dist/engine/signal.d.ts +10 -0
- package/dist/engine/signal.d.ts.map +1 -0
- package/dist/engine/signal.js +39 -0
- package/dist/engine/signal.js.map +1 -0
- package/dist/environment.d.ts.map +1 -0
- package/dist/environment.js.map +1 -0
- package/dist/flex-engine.d.ts +31 -0
- package/dist/flex-engine.d.ts.map +1 -0
- package/dist/flex-engine.js +186 -0
- package/dist/flex-engine.js.map +1 -0
- package/dist/flex-types.d.ts +165 -0
- package/dist/flex-types.d.ts.map +1 -0
- package/dist/flex-types.js +2 -0
- package/dist/flex-types.js.map +1 -0
- package/dist/flex.d.ts +26 -0
- package/dist/flex.d.ts.map +1 -0
- package/dist/flex.js +179 -0
- package/dist/flex.js.map +1 -0
- package/dist/image.d.ts +10 -0
- package/dist/image.d.ts.map +1 -0
- package/dist/image.js +156 -0
- package/dist/image.js.map +1 -0
- package/dist/index.d.ts +34 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +28 -0
- package/dist/index.js.map +1 -0
- package/dist/interpolate-colors.d.ts +7 -0
- package/dist/interpolate-colors.d.ts.map +1 -0
- package/dist/interpolate-colors.js +240 -0
- package/dist/interpolate-colors.js.map +1 -0
- package/dist/interpolate.d.ts +12 -0
- package/dist/interpolate.d.ts.map +1 -0
- package/dist/interpolate.js +65 -0
- package/dist/interpolate.js.map +1 -0
- package/dist/layout/block.d.ts +54 -0
- package/dist/layout/block.d.ts.map +1 -0
- package/dist/layout/block.js +186 -0
- package/dist/layout/block.js.map +1 -0
- package/dist/layout/contract.d.ts +41 -0
- package/dist/layout/contract.d.ts.map +1 -0
- package/dist/layout/contract.js +8 -0
- package/dist/layout/contract.js.map +1 -0
- package/dist/layout/flex/engine.d.ts +31 -0
- package/dist/layout/flex/engine.d.ts.map +1 -0
- package/dist/layout/flex/engine.js +186 -0
- package/dist/layout/flex/engine.js.map +1 -0
- package/dist/layout/flex/flex.d.ts +31 -0
- package/dist/layout/flex/flex.d.ts.map +1 -0
- package/dist/layout/flex/flex.js +175 -0
- package/dist/layout/flex/flex.js.map +1 -0
- package/dist/layout/flex/mixin.d.ts +52 -0
- package/dist/layout/flex/mixin.d.ts.map +1 -0
- package/dist/layout/flex/mixin.js +114 -0
- package/dist/layout/flex/mixin.js.map +1 -0
- package/dist/layout/flex/types.d.ts +31 -0
- package/dist/layout/flex/types.d.ts.map +1 -0
- package/dist/layout/flex/types.js +2 -0
- package/dist/layout/flex/types.js.map +1 -0
- package/dist/layout/image.d.ts +46 -0
- package/dist/layout/image.d.ts.map +1 -0
- package/dist/layout/image.js +198 -0
- package/dist/layout/image.js.map +1 -0
- package/dist/layout/shapes.d.ts +72 -0
- package/dist/layout/shapes.d.ts.map +1 -0
- package/dist/layout/shapes.js +29 -0
- package/dist/layout/shapes.js.map +1 -0
- package/dist/layout/text/font.d.ts +92 -0
- package/dist/layout/text/font.d.ts.map +1 -0
- package/dist/layout/text/font.js +166 -0
- package/dist/layout/text/font.js.map +1 -0
- package/dist/layout/text/geometry.d.ts +31 -0
- package/dist/layout/text/geometry.d.ts.map +1 -0
- package/dist/layout/text/geometry.js +81 -0
- package/dist/layout/text/geometry.js.map +1 -0
- package/dist/layout/text/text.d.ts +98 -0
- package/dist/layout/text/text.d.ts.map +1 -0
- package/dist/layout/text/text.js +676 -0
- package/dist/layout/text/text.js.map +1 -0
- package/dist/layout/text/types.d.ts +103 -0
- package/dist/layout/text/types.d.ts.map +1 -0
- package/dist/layout/text/types.js +2 -0
- package/dist/layout/text/types.js.map +1 -0
- package/dist/media/audio/asset.d.ts +21 -0
- package/dist/media/audio/asset.d.ts.map +1 -0
- package/dist/media/audio/asset.js +2 -0
- package/dist/media/audio/asset.js.map +1 -0
- package/dist/media/audio/audio-driver.d.ts +34 -0
- package/dist/media/audio/audio-driver.d.ts.map +1 -0
- package/dist/media/audio/audio-driver.js +2 -0
- package/dist/media/audio/audio-driver.js.map +1 -0
- package/dist/media/audio/audio-for-preview.d.ts +47 -0
- package/dist/media/audio/audio-for-preview.d.ts.map +1 -0
- package/dist/media/audio/audio-for-preview.js +232 -0
- package/dist/media/audio/audio-for-preview.js.map +1 -0
- package/dist/media/audio/audio-for-rendering.d.ts +16 -0
- package/dist/media/audio/audio-for-rendering.d.ts.map +1 -0
- package/dist/media/audio/audio-for-rendering.js +32 -0
- package/dist/media/audio/audio-for-rendering.js.map +1 -0
- package/dist/media/audio/audio-source-browser.d.ts +30 -0
- package/dist/media/audio/audio-source-browser.d.ts.map +1 -0
- package/dist/media/audio/audio-source-browser.js +139 -0
- package/dist/media/audio/audio-source-browser.js.map +1 -0
- package/dist/media/audio/audio-source-mediabunny.d.ts +45 -0
- package/dist/media/audio/audio-source-mediabunny.d.ts.map +1 -0
- package/dist/media/audio/audio-source-mediabunny.js +79 -0
- package/dist/media/audio/audio-source-mediabunny.js.map +1 -0
- package/dist/media/audio/audio-source.d.ts +38 -0
- package/dist/media/audio/audio-source.d.ts.map +1 -0
- package/dist/media/audio/audio-source.js +2 -0
- package/dist/media/audio/audio-source.js.map +1 -0
- package/dist/media/audio/index.d.ts +57 -0
- package/dist/media/audio/index.d.ts.map +1 -0
- package/dist/media/audio/index.js +170 -0
- package/dist/media/audio/index.js.map +1 -0
- package/dist/media/audio/mixer.d.ts +43 -0
- package/dist/media/audio/mixer.d.ts.map +1 -0
- package/dist/media/audio/mixer.js +51 -0
- package/dist/media/audio/mixer.js.map +1 -0
- package/dist/media/audio/shared-audio-context.d.ts +30 -0
- package/dist/media/audio/shared-audio-context.d.ts.map +1 -0
- package/dist/media/audio/shared-audio-context.js +68 -0
- package/dist/media/audio/shared-audio-context.js.map +1 -0
- package/dist/media/audio/types.d.ts +31 -0
- package/dist/media/audio/types.d.ts.map +1 -0
- package/dist/media/audio/types.js +2 -0
- package/dist/media/audio/types.js.map +1 -0
- package/dist/media/media-marker.d.ts +26 -0
- package/dist/media/media-marker.d.ts.map +1 -0
- package/dist/media/media-marker.js +26 -0
- package/dist/media/media-marker.js.map +1 -0
- package/dist/media/media-time.d.ts +17 -0
- package/dist/media/media-time.d.ts.map +1 -0
- package/dist/media/media-time.js +17 -0
- package/dist/media/media-time.js.map +1 -0
- package/dist/media/video/driver.d.ts +31 -0
- package/dist/media/video/driver.d.ts.map +1 -0
- package/dist/media/video/driver.js +3 -0
- package/dist/media/video/driver.js.map +1 -0
- package/dist/media/video/index.d.ts +66 -0
- package/dist/media/video/index.d.ts.map +1 -0
- package/dist/media/video/index.js +372 -0
- package/dist/media/video/index.js.map +1 -0
- package/dist/media/video/types.d.ts +36 -0
- package/dist/media/video/types.d.ts.map +1 -0
- package/dist/media/video/types.js +2 -0
- package/dist/media/video/types.js.map +1 -0
- package/dist/media/video/video-for-preview.d.ts +17 -0
- package/dist/media/video/video-for-preview.d.ts.map +1 -0
- package/dist/media/video/video-for-preview.js +30 -0
- package/dist/media/video/video-for-preview.js.map +1 -0
- package/dist/media/video/video-for-rendering.d.ts +17 -0
- package/dist/media/video/video-for-rendering.d.ts.map +1 -0
- package/dist/media/video/video-for-rendering.js +59 -0
- package/dist/media/video/video-for-rendering.js.map +1 -0
- package/dist/media/video/video-source-browser.d.ts +37 -0
- package/dist/media/video/video-source-browser.d.ts.map +1 -0
- package/dist/media/video/video-source-browser.js +171 -0
- package/dist/media/video/video-source-browser.js.map +1 -0
- package/dist/media/video/video-source-mediabunny.d.ts +69 -0
- package/dist/media/video/video-source-mediabunny.d.ts.map +1 -0
- package/dist/media/video/video-source-mediabunny.js +236 -0
- package/dist/media/video/video-source-mediabunny.js.map +1 -0
- package/dist/media/video/video-source.d.ts +48 -0
- package/dist/media/video/video-source.d.ts.map +1 -0
- package/dist/media/video/video-source.js +2 -0
- package/dist/media/video/video-source.js.map +1 -0
- package/dist/media-marker.d.ts +19 -0
- package/dist/media-marker.d.ts.map +1 -0
- package/dist/media-marker.js +19 -0
- package/dist/media-marker.js.map +1 -0
- package/dist/media-time.d.ts +17 -0
- package/dist/media-time.d.ts.map +1 -0
- package/dist/media-time.js +17 -0
- package/dist/media-time.js.map +1 -0
- package/dist/signal.d.ts +10 -0
- package/dist/signal.d.ts.map +1 -0
- package/dist/signal.js +39 -0
- package/dist/signal.js.map +1 -0
- package/dist/text.d.ts +91 -0
- package/dist/text.d.ts.map +1 -0
- package/dist/text.js +704 -0
- package/dist/text.js.map +1 -0
- package/dist/video/driver.d.ts +31 -0
- package/dist/video/driver.d.ts.map +1 -0
- package/dist/video/driver.js +3 -0
- package/dist/video/driver.js.map +1 -0
- package/dist/video/index.d.ts +45 -0
- package/dist/video/index.d.ts.map +1 -0
- package/dist/video/index.js +276 -0
- package/dist/video/index.js.map +1 -0
- package/dist/video/types.d.ts +35 -0
- package/dist/video/types.d.ts.map +1 -0
- package/dist/video/types.js +2 -0
- package/dist/video/types.js.map +1 -0
- package/dist/video/video-for-preview.d.ts +12 -0
- package/dist/video/video-for-preview.d.ts.map +1 -0
- package/dist/video/video-for-preview.js +73 -0
- package/dist/video/video-for-preview.js.map +1 -0
- package/dist/video/video-for-rendering.d.ts +16 -0
- package/dist/video/video-for-rendering.d.ts.map +1 -0
- package/dist/video/video-for-rendering.js +45 -0
- package/dist/video/video-for-rendering.js.map +1 -0
- package/dist/video/video-source-browser.d.ts +37 -0
- package/dist/video/video-source-browser.d.ts.map +1 -0
- package/dist/video/video-source-browser.js +171 -0
- package/dist/video/video-source-browser.js.map +1 -0
- package/dist/video/video-source.d.ts +48 -0
- package/dist/video/video-source.d.ts.map +1 -0
- package/dist/video/video-source.js +2 -0
- package/dist/video/video-source.js.map +1 -0
- package/package.json +42 -0
package/dist/text.js
ADDED
|
@@ -0,0 +1,704 @@
|
|
|
1
|
+
import Konva from "konva";
|
|
2
|
+
import { stringToArray } from "konva/lib/shapes/Text.js";
|
|
3
|
+
import { parseSize } from "./flex-engine.js";
|
|
4
|
+
import { TICK_MARK } from "./media-marker.js";
|
|
5
|
+
const ELLIPSIS = "…";
|
|
6
|
+
const BIG = 1e9;
|
|
7
|
+
/** Keys consumed by the wrapper; everything else is forwarded to the Group. */
|
|
8
|
+
const TEXT_KEYS = [
|
|
9
|
+
"text",
|
|
10
|
+
"maxWidth",
|
|
11
|
+
"maxHeight",
|
|
12
|
+
"fontSize",
|
|
13
|
+
"fontFamily",
|
|
14
|
+
"fontStyle",
|
|
15
|
+
"fill",
|
|
16
|
+
"align",
|
|
17
|
+
"lineHeight",
|
|
18
|
+
"letterSpacing",
|
|
19
|
+
"padding",
|
|
20
|
+
"wrap",
|
|
21
|
+
"ellipsis",
|
|
22
|
+
"fitText",
|
|
23
|
+
"maxLines",
|
|
24
|
+
"trimBy",
|
|
25
|
+
"typewriter",
|
|
26
|
+
"highlights",
|
|
27
|
+
"flexGrow",
|
|
28
|
+
"flexShrink",
|
|
29
|
+
"flexBasis",
|
|
30
|
+
"alignSelf",
|
|
31
|
+
"margin",
|
|
32
|
+
];
|
|
33
|
+
function pickKonvaConfig(config) {
|
|
34
|
+
const out = { ...config };
|
|
35
|
+
for (const k of TEXT_KEYS)
|
|
36
|
+
delete out[k];
|
|
37
|
+
const w = parseSize(config.width);
|
|
38
|
+
const h = parseSize(config.height);
|
|
39
|
+
out.width = w?.kind === "px" ? w.value : undefined;
|
|
40
|
+
out.height = h?.kind === "px" ? h.value : undefined;
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Timeline-aware text node. Wraps a {@link Konva.Text} and layers on CSS-like
|
|
45
|
+
* fitting (maxWidth/maxHeight/fitText/maxLines, ellipsis + word/letter trim), a
|
|
46
|
+
* built-in typewriter reveal, and per-range highlighter marks. Like the other
|
|
47
|
+
* layout primitives it `extends Konva.Group`, exposing intrinsic text size to
|
|
48
|
+
* the flex engine via {@link _measureForFlex}.
|
|
49
|
+
*/
|
|
50
|
+
export class Text extends Konva.Group {
|
|
51
|
+
_text;
|
|
52
|
+
/** Clipping wrapper around `_text` — reveals a char range without re-wrapping. */
|
|
53
|
+
_textClip;
|
|
54
|
+
_hl;
|
|
55
|
+
_hlText;
|
|
56
|
+
_fade;
|
|
57
|
+
_cursor = null;
|
|
58
|
+
_fullText;
|
|
59
|
+
_fullChars;
|
|
60
|
+
/** char index marking the end of each word (== start of the next), for word-mode reveal. */
|
|
61
|
+
_wordEnds;
|
|
62
|
+
/** word index each char belongs to, for word-mode timing. */
|
|
63
|
+
_charWord;
|
|
64
|
+
_resolvedFontSize;
|
|
65
|
+
_localFrame = 0;
|
|
66
|
+
_geo = null;
|
|
67
|
+
_inLayout = false;
|
|
68
|
+
constructor(config) {
|
|
69
|
+
super(pickKonvaConfig(config));
|
|
70
|
+
this._fullText = config.text ?? "";
|
|
71
|
+
this._fullChars = stringToArray(this._fullText);
|
|
72
|
+
const { wordEnds, charWord } = buildWordIndex(this._fullChars);
|
|
73
|
+
this._wordEnds = wordEnds;
|
|
74
|
+
this._charWord = charWord;
|
|
75
|
+
this._hl = new Konva.Group({ listening: false });
|
|
76
|
+
this._hlText = new Konva.Group({ listening: false });
|
|
77
|
+
this._fade = new Konva.Group({ listening: false });
|
|
78
|
+
this._textClip = new Konva.Group({ listening: false });
|
|
79
|
+
this._text = new Konva.Text({
|
|
80
|
+
text: this._fullText,
|
|
81
|
+
fontSize: config.fontSize,
|
|
82
|
+
fontFamily: config.fontFamily,
|
|
83
|
+
fontStyle: config.fontStyle,
|
|
84
|
+
fill: config.fill,
|
|
85
|
+
align: config.align,
|
|
86
|
+
lineHeight: config.lineHeight,
|
|
87
|
+
letterSpacing: config.letterSpacing,
|
|
88
|
+
padding: config.padding,
|
|
89
|
+
wrap: config.wrap ?? "word",
|
|
90
|
+
listening: false,
|
|
91
|
+
});
|
|
92
|
+
this._textClip.add(this._text);
|
|
93
|
+
// Highlights behind, then glyphs (clipped for reveal), then colored-run
|
|
94
|
+
// overlay + fade overlay on top.
|
|
95
|
+
super.add(this._hl);
|
|
96
|
+
super.add(this._textClip);
|
|
97
|
+
super.add(this._hlText);
|
|
98
|
+
super.add(this._fade);
|
|
99
|
+
this.setAttrs({
|
|
100
|
+
flexGrow: config.flexGrow,
|
|
101
|
+
flexShrink: config.flexShrink,
|
|
102
|
+
flexBasis: config.flexBasis,
|
|
103
|
+
alignSelf: config.alignSelf,
|
|
104
|
+
margin: config.margin,
|
|
105
|
+
flexWidth: config.width,
|
|
106
|
+
flexHeight: config.height,
|
|
107
|
+
maxWidth: config.maxWidth,
|
|
108
|
+
maxHeight: config.maxHeight,
|
|
109
|
+
maxLines: config.maxLines,
|
|
110
|
+
trimBy: config.trimBy ?? "word",
|
|
111
|
+
ellipsis: config.ellipsis,
|
|
112
|
+
fitText: config.fitText,
|
|
113
|
+
typewriter: config.typewriter,
|
|
114
|
+
highlights: config.highlights,
|
|
115
|
+
});
|
|
116
|
+
this._resolvedFontSize = this._text.fontSize();
|
|
117
|
+
if (config.typewriter)
|
|
118
|
+
this.setAttr(TICK_MARK, true);
|
|
119
|
+
this.on("widthChange heightChange", () => {
|
|
120
|
+
if (!this._inLayout)
|
|
121
|
+
this._layoutText();
|
|
122
|
+
});
|
|
123
|
+
this._layoutText();
|
|
124
|
+
}
|
|
125
|
+
/** Replace the text content. Re-runs fit + clamp + layout. */
|
|
126
|
+
setText(text) {
|
|
127
|
+
this._fullText = text;
|
|
128
|
+
this._fullChars = stringToArray(text);
|
|
129
|
+
const { wordEnds, charWord } = buildWordIndex(this._fullChars);
|
|
130
|
+
this._wordEnds = wordEnds;
|
|
131
|
+
this._charWord = charWord;
|
|
132
|
+
this._layoutText();
|
|
133
|
+
return this;
|
|
134
|
+
}
|
|
135
|
+
// ----- flex integration -------------------------------------------------
|
|
136
|
+
/**
|
|
137
|
+
* @internal Intrinsic size for the flex engine. Lays out the FULL text at the
|
|
138
|
+
* given available width (honoring fitText / maxWidth / maxHeight / maxLines).
|
|
139
|
+
* Height is the full text height when reserving, else the height of just the
|
|
140
|
+
* lines revealed so far — so a `reserveHeight: false` typewriter grows the box
|
|
141
|
+
* one line at a time and pushes siblings down.
|
|
142
|
+
*/
|
|
143
|
+
_measureForFlex(avail) {
|
|
144
|
+
const boxW = this._boxWidth(Number.isFinite(avail) && avail > 0 ? avail : BIG);
|
|
145
|
+
const boxH = this._boxHeight();
|
|
146
|
+
const savedText = this._text.text();
|
|
147
|
+
this._text.text(this._fullText);
|
|
148
|
+
this._applyFit(boxW, boxH);
|
|
149
|
+
this._clamp(boxW, boxH);
|
|
150
|
+
const m = this._measure(boxW === BIG ? BIG : boxW);
|
|
151
|
+
const pad = this._text.padding() ?? 0;
|
|
152
|
+
const width = Math.min(m.longest + pad * 2, boxW);
|
|
153
|
+
const tw = this.attrs.typewriter;
|
|
154
|
+
const reserve = tw ? (tw.reserveHeight ?? true) : true;
|
|
155
|
+
let height = m.height;
|
|
156
|
+
if (!reserve) {
|
|
157
|
+
this._text.width(boxW === BIG ? width : boxW);
|
|
158
|
+
this._geo = this._buildGeometry();
|
|
159
|
+
height = this._visibleLines(this._revealCount(this._localFrame)) * this._geo.lineH + pad * 2;
|
|
160
|
+
}
|
|
161
|
+
this._text.text(savedText);
|
|
162
|
+
return { width, height };
|
|
163
|
+
}
|
|
164
|
+
/** @internal Re-layout glyphs, highlights, cursor and fade for the current frame. */
|
|
165
|
+
_layoutText() {
|
|
166
|
+
if (this._inLayout)
|
|
167
|
+
return;
|
|
168
|
+
this._inLayout = true;
|
|
169
|
+
try {
|
|
170
|
+
this._relayout();
|
|
171
|
+
}
|
|
172
|
+
finally {
|
|
173
|
+
this._inLayout = false;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
_relayout() {
|
|
177
|
+
const boxW = this._boxWidth(this.width() > 0 ? this.width() : BIG);
|
|
178
|
+
const boxH = this._boxHeight();
|
|
179
|
+
// Always lay out the FULL text so line breaks are fixed: a typewriter then
|
|
180
|
+
// reveals a char range by CLIPPING, so a word never re-wraps to a new line
|
|
181
|
+
// as its later letters are typed.
|
|
182
|
+
this._text.text(this._fullText);
|
|
183
|
+
this._applyFit(boxW, boxH);
|
|
184
|
+
this._clamp(boxW, boxH);
|
|
185
|
+
const m = this._measure(boxW === BIG ? BIG : boxW);
|
|
186
|
+
const pad = this._text.padding() ?? 0;
|
|
187
|
+
const finalW = boxW === BIG ? m.longest + pad * 2 : boxW;
|
|
188
|
+
this._text.width(finalW);
|
|
189
|
+
this._text.position({ x: 0, y: 0 });
|
|
190
|
+
// Recompute the geometry of the full text for overlays + reveal clipping.
|
|
191
|
+
this._geo = this._buildGeometry();
|
|
192
|
+
const reveal = this._revealCount(this._localFrame);
|
|
193
|
+
const tw = this.attrs.typewriter;
|
|
194
|
+
const reserve = tw ? (tw.reserveHeight ?? true) : true;
|
|
195
|
+
// When not reserving, the box grows one whole line at a time (never mid-word).
|
|
196
|
+
const ownH = reserve ? m.height : this._visibleLines(reveal) * this._geo.lineH + pad * 2;
|
|
197
|
+
if (this.attrs.flexWidth === undefined && this.attrs.maxWidth === undefined) {
|
|
198
|
+
this.width(finalW);
|
|
199
|
+
}
|
|
200
|
+
if (this.attrs.flexHeight === undefined)
|
|
201
|
+
this.height(ownH);
|
|
202
|
+
this._layoutHighlights();
|
|
203
|
+
this._layoutReveal(reveal);
|
|
204
|
+
this._layoutCursor(reveal);
|
|
205
|
+
}
|
|
206
|
+
/** Number of wrapped lines that contain at least one revealed char. */
|
|
207
|
+
_visibleLines(reveal) {
|
|
208
|
+
const geo = this._geo;
|
|
209
|
+
if (!geo)
|
|
210
|
+
return 1;
|
|
211
|
+
let vis = 0;
|
|
212
|
+
geo.ranges.forEach((r, li) => {
|
|
213
|
+
if (r.start < reveal)
|
|
214
|
+
vis = li + 1;
|
|
215
|
+
});
|
|
216
|
+
return Math.max(1, vis);
|
|
217
|
+
}
|
|
218
|
+
/** Set the reveal clip on `_textClip` (covers char range [0, end), null = all). */
|
|
219
|
+
_setReveal(end) {
|
|
220
|
+
if (end === null) {
|
|
221
|
+
this._textClip.clipFunc(undefined);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
const geo = this._geo;
|
|
225
|
+
if (!geo)
|
|
226
|
+
return;
|
|
227
|
+
this._textClip.clipFunc((ctx) => {
|
|
228
|
+
for (const s of this._segmentRects(geo, 0, end)) {
|
|
229
|
+
ctx.rect(s.x, geo.pad + s.li * geo.lineH, s.w, geo.lineH);
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
// ----- typewriter -------------------------------------------------------
|
|
234
|
+
/** @internal Called by Sequence each frame. */
|
|
235
|
+
_kmTick(localFrame) {
|
|
236
|
+
this._localFrame = localFrame;
|
|
237
|
+
this._layoutText();
|
|
238
|
+
}
|
|
239
|
+
/** @internal Called by Sequence when the sequence leaves range. */
|
|
240
|
+
_kmDeactivate() {
|
|
241
|
+
// No teardown needed — reveal is a pure function of the frame, so the next
|
|
242
|
+
// activation re-derives state. Reset to frame 0 for a clean replay.
|
|
243
|
+
this._localFrame = 0;
|
|
244
|
+
}
|
|
245
|
+
/** How many chars are revealed at `frame` (full length when no typewriter). */
|
|
246
|
+
_revealCount(frame) {
|
|
247
|
+
const tw = this.attrs.typewriter;
|
|
248
|
+
if (!tw)
|
|
249
|
+
return this._fullChars.length;
|
|
250
|
+
// Reveal starts at `startFrame` (lets one Sequence stagger many typewriters).
|
|
251
|
+
const f = frame - (tw.startFrame ?? 0);
|
|
252
|
+
if (f < 0)
|
|
253
|
+
return 0;
|
|
254
|
+
const word = tw.mode === "word";
|
|
255
|
+
const unitCount = word ? this._wordEnds.length : this._fullChars.length;
|
|
256
|
+
if (unitCount === 0)
|
|
257
|
+
return 0;
|
|
258
|
+
let units;
|
|
259
|
+
if (tw.durationInFrames && tw.durationInFrames > 0) {
|
|
260
|
+
const p = clamp(f / tw.durationInFrames, 0, 1);
|
|
261
|
+
units = Math.round(p * unitCount);
|
|
262
|
+
}
|
|
263
|
+
else {
|
|
264
|
+
const perStep = tw.step ?? 1;
|
|
265
|
+
units = Math.floor((f + 1) * perStep);
|
|
266
|
+
}
|
|
267
|
+
units = clamp(units, 0, unitCount);
|
|
268
|
+
if (!word)
|
|
269
|
+
return units;
|
|
270
|
+
return units <= 0 ? 0 : (this._wordEnds[units - 1] ?? this._fullChars.length);
|
|
271
|
+
}
|
|
272
|
+
/** Frame at which the unit owning char `k` first appears (for fade ramps). */
|
|
273
|
+
_unitRevealFrame(k) {
|
|
274
|
+
const tw = this.attrs.typewriter;
|
|
275
|
+
const word = tw.mode === "word";
|
|
276
|
+
const unitIndex = word ? (this._charWord[k] ?? 0) : k;
|
|
277
|
+
const unitCount = word ? this._wordEnds.length : this._fullChars.length;
|
|
278
|
+
const start = tw.startFrame ?? 0;
|
|
279
|
+
if (tw.durationInFrames && tw.durationInFrames > 0) {
|
|
280
|
+
return start + (unitIndex / Math.max(1, unitCount)) * tw.durationInFrames;
|
|
281
|
+
}
|
|
282
|
+
const perStep = tw.step ?? 1;
|
|
283
|
+
return start + (unitIndex + 1) / perStep - 1;
|
|
284
|
+
}
|
|
285
|
+
// ----- fitting / clamping ----------------------------------------------
|
|
286
|
+
_boxWidth(fallback) {
|
|
287
|
+
const explicit = parseSize(this.attrs.flexWidth);
|
|
288
|
+
if (explicit?.kind === "px")
|
|
289
|
+
return explicit.value;
|
|
290
|
+
const maxW = this.attrs.maxWidth;
|
|
291
|
+
if (typeof maxW === "number")
|
|
292
|
+
return maxW;
|
|
293
|
+
return fallback;
|
|
294
|
+
}
|
|
295
|
+
_boxHeight() {
|
|
296
|
+
const explicit = parseSize(this.attrs.flexHeight);
|
|
297
|
+
if (explicit?.kind === "px")
|
|
298
|
+
return explicit.value;
|
|
299
|
+
const maxH = this.attrs.maxHeight;
|
|
300
|
+
if (typeof maxH === "number")
|
|
301
|
+
return maxH;
|
|
302
|
+
return BIG;
|
|
303
|
+
}
|
|
304
|
+
/** Binary-search the largest font size in [min,max] for which the FULL text fits. */
|
|
305
|
+
_applyFit(boxW, boxH) {
|
|
306
|
+
const fit = this.attrs.fitText;
|
|
307
|
+
if (!fit) {
|
|
308
|
+
this._resolvedFontSize = this._text.fontSize();
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
const saved = this._text.text();
|
|
312
|
+
this._text.text(this._fullText);
|
|
313
|
+
const lineHeight = this._text.lineHeight();
|
|
314
|
+
const pad = this._text.padding() ?? 0;
|
|
315
|
+
const maxLines = this.attrs.maxLines;
|
|
316
|
+
const step = fit.step ?? 0.5;
|
|
317
|
+
const noWrap = (this.attrs.wrap ?? this._text.wrap()) === "none";
|
|
318
|
+
const fits = (fs) => {
|
|
319
|
+
this._text.fontSize(fs);
|
|
320
|
+
// For wrap:"none" measure the NATURAL line width (unconstrained) — measuring
|
|
321
|
+
// at boxW makes Konva truncate the line, hiding the overflow from the search.
|
|
322
|
+
const m = this._measure(noWrap || boxW === BIG ? BIG : boxW);
|
|
323
|
+
// Use the SAME floor-based line capacity as _clamp so a font that just
|
|
324
|
+
// fits here can't later be trimmed (which would add an unwanted ellipsis).
|
|
325
|
+
const lineH = fs * lineHeight;
|
|
326
|
+
const cap = boxH === BIG ? Number.POSITIVE_INFINITY : Math.floor((boxH - pad * 2) / lineH);
|
|
327
|
+
const limit = Math.min(maxLines ?? Number.POSITIVE_INFINITY, cap);
|
|
328
|
+
const widthOK = boxW === BIG || m.longest <= boxW + 0.5;
|
|
329
|
+
return widthOK && m.lines <= limit;
|
|
330
|
+
};
|
|
331
|
+
let lo = fit.min;
|
|
332
|
+
let hi = fit.max;
|
|
333
|
+
if (!fits(lo)) {
|
|
334
|
+
// Even the smallest size overflows — use it and let clamp trim+ellipsis.
|
|
335
|
+
this._resolvedFontSize = lo;
|
|
336
|
+
this._text.fontSize(lo);
|
|
337
|
+
this._text.text(saved);
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
while (hi - lo > step) {
|
|
341
|
+
const mid = (lo + hi) / 2;
|
|
342
|
+
if (fits(mid))
|
|
343
|
+
lo = mid;
|
|
344
|
+
else
|
|
345
|
+
hi = mid;
|
|
346
|
+
}
|
|
347
|
+
this._resolvedFontSize = lo;
|
|
348
|
+
this._text.fontSize(lo);
|
|
349
|
+
this._text.text(saved);
|
|
350
|
+
}
|
|
351
|
+
/** Trim the visible text + add an ellipsis to satisfy wrap:none / maxLines / maxHeight. */
|
|
352
|
+
_clamp(boxW, boxH) {
|
|
353
|
+
const wrap = this.attrs.wrap ?? this._text.wrap();
|
|
354
|
+
const ellipsis = this.attrs.ellipsis;
|
|
355
|
+
const wantEllipsis = ellipsis !== false;
|
|
356
|
+
const chars = stringToArray(this._text.text());
|
|
357
|
+
const pad = this._text.padding() ?? 0;
|
|
358
|
+
const trimBy = this.attrs.trimBy ?? "word";
|
|
359
|
+
// wrap:"none" + a finite width → single line, trim to width.
|
|
360
|
+
if (wrap === "none" && boxW !== BIG) {
|
|
361
|
+
// Fits as-is? (don't reserve ellipsis room here, or a fitted line gets cut.)
|
|
362
|
+
if (this._charWidth(chars.join("")) <= boxW - pad * 2)
|
|
363
|
+
return;
|
|
364
|
+
const target = boxW - pad * 2 - (wantEllipsis ? this._charWidth(ELLIPSIS) : 0);
|
|
365
|
+
const k = this._largest(chars.length, (n) => this._charWidth(chars.slice(0, n).join("")) <= target);
|
|
366
|
+
const cut = trimBy === "word" ? snapToWord(chars, k) : k;
|
|
367
|
+
this._text.text(chars.slice(0, cut).join("").replace(/\s+$/, "") + (wantEllipsis ? ELLIPSIS : ""));
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
// Otherwise figure out the max number of lines allowed by maxLines + maxHeight.
|
|
371
|
+
const lineH = this._resolvedFontSize * this._text.lineHeight();
|
|
372
|
+
let maxLines = this.attrs.maxLines ?? Number.POSITIVE_INFINITY;
|
|
373
|
+
if (boxH !== BIG) {
|
|
374
|
+
const fitLines = Math.floor((boxH - pad * 2) / lineH);
|
|
375
|
+
maxLines = Math.min(maxLines, Math.max(0, fitLines));
|
|
376
|
+
}
|
|
377
|
+
if (!Number.isFinite(maxLines))
|
|
378
|
+
return;
|
|
379
|
+
if (this._measure(boxW === BIG ? BIG : boxW).lines <= maxLines)
|
|
380
|
+
return;
|
|
381
|
+
// Binary-search the longest char prefix that wraps into <= maxLines lines
|
|
382
|
+
// (with room for the ellipsis), then optionally snap back to a word boundary.
|
|
383
|
+
const fitsLines = (n) => {
|
|
384
|
+
this._text.text(chars.slice(0, n).join("") + (wantEllipsis ? ELLIPSIS : ""));
|
|
385
|
+
return this._measure(boxW === BIG ? BIG : boxW).lines <= maxLines;
|
|
386
|
+
};
|
|
387
|
+
const k = this._largest(chars.length, fitsLines);
|
|
388
|
+
const cut = trimBy === "word" ? snapToWord(chars, k) : k;
|
|
389
|
+
this._text.text(chars.slice(0, cut).join("").replace(/\s+$/, "") + (wantEllipsis ? ELLIPSIS : ""));
|
|
390
|
+
}
|
|
391
|
+
/** Largest n in [0,max] satisfying the monotone predicate. */
|
|
392
|
+
_largest(max, ok) {
|
|
393
|
+
let lo = 0;
|
|
394
|
+
let hi = max;
|
|
395
|
+
while (lo < hi) {
|
|
396
|
+
const mid = (lo + hi + 1) >> 1;
|
|
397
|
+
if (ok(mid))
|
|
398
|
+
lo = mid;
|
|
399
|
+
else
|
|
400
|
+
hi = mid - 1;
|
|
401
|
+
}
|
|
402
|
+
return lo;
|
|
403
|
+
}
|
|
404
|
+
// ----- measurement / geometry ------------------------------------------
|
|
405
|
+
_charWidth(s) {
|
|
406
|
+
if (!s)
|
|
407
|
+
return 0;
|
|
408
|
+
// Use measureSize (which sets the canvas font itself) rather than the
|
|
409
|
+
// internal _getTextWidth, which relies on a SHARED dummy context whose font
|
|
410
|
+
// is left at whatever the last-measured Konva.Text set. Clip/overlay clipFuncs
|
|
411
|
+
// run at draw time, long after another node may have changed that font, so
|
|
412
|
+
// _getTextWidth there returns widths at the wrong size. measureSize is
|
|
413
|
+
// self-contained. Add letterSpacing the same way _getTextWidth does.
|
|
414
|
+
const ls = this._text.letterSpacing() || 0;
|
|
415
|
+
return this._text.measureSize(s).width + ls * stringToArray(s).length;
|
|
416
|
+
}
|
|
417
|
+
/** Run Konva's wrapping for the current text at `boxW`, returning line metrics. */
|
|
418
|
+
_measure(boxW) {
|
|
419
|
+
const prev = this._text.attrs.width;
|
|
420
|
+
this._text.width(boxW);
|
|
421
|
+
this._text._setTextData();
|
|
422
|
+
const arr = this._text.textArr;
|
|
423
|
+
const lines = arr.length || 1;
|
|
424
|
+
const longest = arr.reduce((mx, l) => Math.max(mx, l.width), 0);
|
|
425
|
+
const lineH = this._text.fontSize() * this._text.lineHeight();
|
|
426
|
+
const pad = this._text.padding() ?? 0;
|
|
427
|
+
this._text.setAttr("width", prev);
|
|
428
|
+
return { lines, longest, height: lines * lineH + pad * 2 };
|
|
429
|
+
}
|
|
430
|
+
/** Map Konva's wrapped lines back to char offsets in the displayed text. */
|
|
431
|
+
_buildGeometry() {
|
|
432
|
+
this._text._setTextData();
|
|
433
|
+
const arr = this._text.textArr;
|
|
434
|
+
const chars = stringToArray(this._text.text());
|
|
435
|
+
const ranges = [];
|
|
436
|
+
let i = 0;
|
|
437
|
+
for (const ln of arr) {
|
|
438
|
+
const glyphs = stringToArray(ln.text);
|
|
439
|
+
// Skip the single space Konva consumes at a soft wrap (never the line's own glyphs).
|
|
440
|
+
while (i < chars.length &&
|
|
441
|
+
chars[i] === " " &&
|
|
442
|
+
(glyphs.length === 0 || chars[i] !== glyphs[0])) {
|
|
443
|
+
i++;
|
|
444
|
+
}
|
|
445
|
+
const start = i;
|
|
446
|
+
i += glyphs.length;
|
|
447
|
+
ranges.push({ start, end: i, width: ln.width });
|
|
448
|
+
if (chars[i] === "\n")
|
|
449
|
+
i++;
|
|
450
|
+
}
|
|
451
|
+
return {
|
|
452
|
+
chars,
|
|
453
|
+
ranges,
|
|
454
|
+
lineH: this._text.fontSize() * this._text.lineHeight(),
|
|
455
|
+
pad: this._text.padding() ?? 0,
|
|
456
|
+
textAreaW: this._text.width() - (this._text.padding() ?? 0) * 2,
|
|
457
|
+
align: this.attrs.align ?? this._text.align(),
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
/** Left offset of a line within the text area, per alignment (matches Konva). */
|
|
461
|
+
_alignOffset(geo, lineWidth) {
|
|
462
|
+
if (geo.align === "center")
|
|
463
|
+
return (geo.textAreaW - lineWidth) / 2;
|
|
464
|
+
if (geo.align === "right")
|
|
465
|
+
return geo.textAreaW - lineWidth;
|
|
466
|
+
return 0;
|
|
467
|
+
}
|
|
468
|
+
/** Per-line rectangles covering the global char range [lo,hi). */
|
|
469
|
+
_segmentRects(geo, lo, hi) {
|
|
470
|
+
const out = [];
|
|
471
|
+
geo.ranges.forEach((r, li) => {
|
|
472
|
+
const segLo = Math.max(lo, r.start);
|
|
473
|
+
const segHi = Math.min(hi, r.end);
|
|
474
|
+
if (segHi <= segLo)
|
|
475
|
+
return;
|
|
476
|
+
const base = geo.pad + this._alignOffset(geo, r.width);
|
|
477
|
+
const x = base + this._charWidth(geo.chars.slice(r.start, segLo).join(""));
|
|
478
|
+
const w = this._charWidth(geo.chars.slice(segLo, segHi).join(""));
|
|
479
|
+
out.push({ x, w, li, isStart: segLo === lo, isEnd: segHi === hi });
|
|
480
|
+
});
|
|
481
|
+
return out;
|
|
482
|
+
}
|
|
483
|
+
// ----- highlights -------------------------------------------------------
|
|
484
|
+
_layoutHighlights() {
|
|
485
|
+
this._hl.destroyChildren();
|
|
486
|
+
this._hlText.destroyChildren();
|
|
487
|
+
const geo = this._geo;
|
|
488
|
+
const highlights = this.attrs.highlights;
|
|
489
|
+
if (!geo || !highlights)
|
|
490
|
+
return;
|
|
491
|
+
for (const h of highlights) {
|
|
492
|
+
const lo = h.start;
|
|
493
|
+
const full = Math.max(0, h.end - h.start);
|
|
494
|
+
const hi = h.start + Math.round(full * (h.progress ?? 1));
|
|
495
|
+
if (hi <= lo)
|
|
496
|
+
continue;
|
|
497
|
+
const segs = this._segmentRects(geo, lo, hi);
|
|
498
|
+
const markH = h.height === "line" || h.height == null ? geo.lineH : Math.min(h.height, geo.lineH);
|
|
499
|
+
for (const s of segs) {
|
|
500
|
+
let x = s.x;
|
|
501
|
+
let w = s.w;
|
|
502
|
+
if (s.isStart && h.paddingStart) {
|
|
503
|
+
x -= h.paddingStart;
|
|
504
|
+
w += h.paddingStart;
|
|
505
|
+
}
|
|
506
|
+
if (s.isEnd && h.paddingEnd)
|
|
507
|
+
w += h.paddingEnd;
|
|
508
|
+
// Clamp radii so a large value (e.g. for a pill) renders cleanly rather
|
|
509
|
+
// than overshooting the mark's height/width.
|
|
510
|
+
const maxR = Math.min(markH / 2, w / 2);
|
|
511
|
+
const rS = s.isStart ? Math.min(h.cornerRadiusStart ?? 0, maxR) : 0;
|
|
512
|
+
const rE = s.isEnd ? Math.min(h.cornerRadiusEnd ?? 0, maxR) : 0;
|
|
513
|
+
this._hl.add(new Konva.Rect({
|
|
514
|
+
x,
|
|
515
|
+
y: geo.pad + s.li * geo.lineH + (geo.lineH - markH) / 2,
|
|
516
|
+
width: w,
|
|
517
|
+
height: markH,
|
|
518
|
+
fill: h.background,
|
|
519
|
+
cornerRadius: [rS, rE, rE, rS],
|
|
520
|
+
listening: false,
|
|
521
|
+
}));
|
|
522
|
+
}
|
|
523
|
+
// Optional text-color override: overlay a colored copy clipped to the run.
|
|
524
|
+
if (h.color) {
|
|
525
|
+
const overlay = this._clippedClone(h.color, geo.chars.join(""), 1, (ctx) => {
|
|
526
|
+
for (const s of segs) {
|
|
527
|
+
const w = s.w + (s.isEnd && h.paddingEnd ? h.paddingEnd : 0);
|
|
528
|
+
ctx.rect(s.x, geo.pad + s.li * geo.lineH, w, geo.lineH);
|
|
529
|
+
}
|
|
530
|
+
});
|
|
531
|
+
this._hlText.add(overlay);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
_fillColor() {
|
|
536
|
+
const f = this._text.fill();
|
|
537
|
+
return typeof f === "string" && f ? f : "#000";
|
|
538
|
+
}
|
|
539
|
+
/**
|
|
540
|
+
* A clipped, non-interactive copy of `_text` (different fill/opacity). Shapes
|
|
541
|
+
* can't clip, so the clone lives inside a clipping Group.
|
|
542
|
+
*/
|
|
543
|
+
_clippedClone(fill, text, opacity, clip) {
|
|
544
|
+
const g = new Konva.Group({ listening: false, opacity });
|
|
545
|
+
g.clipFunc(clip);
|
|
546
|
+
g.add(new Konva.Text({
|
|
547
|
+
text,
|
|
548
|
+
fontSize: this._text.fontSize(),
|
|
549
|
+
fontFamily: this._text.fontFamily(),
|
|
550
|
+
fontStyle: this._text.fontStyle(),
|
|
551
|
+
align: this._text.align(),
|
|
552
|
+
lineHeight: this._text.lineHeight(),
|
|
553
|
+
letterSpacing: this._text.letterSpacing(),
|
|
554
|
+
padding: this._text.padding(),
|
|
555
|
+
wrap: this._text.wrap(),
|
|
556
|
+
width: this._text.width(),
|
|
557
|
+
fill,
|
|
558
|
+
listening: false,
|
|
559
|
+
}));
|
|
560
|
+
return g;
|
|
561
|
+
}
|
|
562
|
+
// ----- typewriter reveal + fade ----------------------------------------
|
|
563
|
+
/**
|
|
564
|
+
* Reveal `[0, reveal)` by clipping `_text` (no re-wrapping). With fade on, the
|
|
565
|
+
* solid clip stops at `commit` and the `[commit, reveal)` window is drawn as
|
|
566
|
+
* per-char overlays ramping their opacity.
|
|
567
|
+
*/
|
|
568
|
+
_layoutReveal(reveal) {
|
|
569
|
+
this._fade.destroyChildren();
|
|
570
|
+
const geo = this._geo;
|
|
571
|
+
const tw = this.attrs.typewriter;
|
|
572
|
+
if (!geo || !tw) {
|
|
573
|
+
// Not a typewriter: show the whole text, no clip.
|
|
574
|
+
this._setReveal(null);
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
if (!tw.fade) {
|
|
578
|
+
this._setReveal(reveal);
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
const fadeDur = (typeof tw.fade === "object" ? tw.fade.durationInFrames : undefined) ?? 4;
|
|
582
|
+
const easing = typeof tw.fade === "object" ? tw.fade.easing : undefined;
|
|
583
|
+
const full = geo.chars.join("");
|
|
584
|
+
// commit = chars whose fade has fully completed → drawn solid (clipped).
|
|
585
|
+
const commit = this._revealCount(this._localFrame - fadeDur);
|
|
586
|
+
this._setReveal(commit);
|
|
587
|
+
for (let k = commit; k < reveal; k++) {
|
|
588
|
+
const start = this._unitRevealFrame(k);
|
|
589
|
+
let o = clamp((this._localFrame - start) / fadeDur, 0, 1);
|
|
590
|
+
if (easing)
|
|
591
|
+
o = easing(o);
|
|
592
|
+
if (o <= 0)
|
|
593
|
+
continue;
|
|
594
|
+
const glyph = this._clippedClone(this._fillColor(), full, o, (ctx) => {
|
|
595
|
+
for (const s of this._segmentRects(geo, k, k + 1)) {
|
|
596
|
+
ctx.rect(s.x, geo.pad + s.li * geo.lineH, s.w, geo.lineH);
|
|
597
|
+
}
|
|
598
|
+
});
|
|
599
|
+
this._fade.add(glyph);
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
// ----- cursor -----------------------------------------------------------
|
|
603
|
+
_layoutCursor(reveal) {
|
|
604
|
+
const geo = this._geo;
|
|
605
|
+
const tw = this.attrs.typewriter;
|
|
606
|
+
const cursor = tw?.cursor;
|
|
607
|
+
if (!geo || !cursor) {
|
|
608
|
+
this._cursor?.visible(false);
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
const opts = typeof cursor === "object" ? cursor : {};
|
|
612
|
+
// Optionally drop the caret once the whole message has been revealed (e.g. a
|
|
613
|
+
// finished chat bubble shouldn't keep a blinking caret).
|
|
614
|
+
const notStarted = reveal <= 0 && this._localFrame < (tw.startFrame ?? 0);
|
|
615
|
+
if ((opts.hideWhenDone && reveal >= this._fullChars.length) || notStarted) {
|
|
616
|
+
this._cursor?.visible(false);
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
if (!this._cursor) {
|
|
620
|
+
this._cursor = new Konva.Rect({ listening: false });
|
|
621
|
+
super.add(this._cursor);
|
|
622
|
+
}
|
|
623
|
+
const { x, y } = this._caretPos(geo, reveal);
|
|
624
|
+
const width = opts.width ?? 2;
|
|
625
|
+
this._cursor.setAttrs({
|
|
626
|
+
x,
|
|
627
|
+
y,
|
|
628
|
+
width,
|
|
629
|
+
height: geo.lineH,
|
|
630
|
+
fill: opts.color ?? this._fillColor(),
|
|
631
|
+
});
|
|
632
|
+
const blink = opts.blink ?? true;
|
|
633
|
+
if (blink) {
|
|
634
|
+
const period = opts.blinkPeriodInFrames ?? 16;
|
|
635
|
+
this._cursor.visible(Math.floor(this._localFrame / period) % 2 === 0);
|
|
636
|
+
}
|
|
637
|
+
else {
|
|
638
|
+
this._cursor.visible(true);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
/** Pixel position of the caret sitting just after char `idx`. */
|
|
642
|
+
_caretPos(geo, idx) {
|
|
643
|
+
let r;
|
|
644
|
+
let li = 0;
|
|
645
|
+
for (let i = 0; i < geo.ranges.length; i++) {
|
|
646
|
+
const cur = geo.ranges[i];
|
|
647
|
+
if (cur && cur.start <= idx) {
|
|
648
|
+
r = cur;
|
|
649
|
+
li = i;
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
if (!r)
|
|
653
|
+
return { x: geo.pad, y: geo.pad };
|
|
654
|
+
const localEnd = Math.min(idx, r.end);
|
|
655
|
+
const x = geo.pad +
|
|
656
|
+
this._alignOffset(geo, r.width) +
|
|
657
|
+
this._charWidth(geo.chars.slice(r.start, localEnd).join(""));
|
|
658
|
+
return { x, y: geo.pad + li * geo.lineH };
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
// ----- helpers ------------------------------------------------------------
|
|
662
|
+
function clamp(v, lo, hi) {
|
|
663
|
+
return v < lo ? lo : v > hi ? hi : v;
|
|
664
|
+
}
|
|
665
|
+
/** Pull back index `k` to the end of the previous word (last whitespace). */
|
|
666
|
+
function snapToWord(chars, k) {
|
|
667
|
+
for (let i = k - 1; i > 0; i--) {
|
|
668
|
+
if (chars[i] === " " || chars[i] === "\n")
|
|
669
|
+
return i;
|
|
670
|
+
}
|
|
671
|
+
return k;
|
|
672
|
+
}
|
|
673
|
+
/**
|
|
674
|
+
* Word boundaries over `chars`: `wordEnds[j]` is the char index just past word
|
|
675
|
+
* j (== start of word j+1, trailing whitespace included), and `charWord[k]` is
|
|
676
|
+
* the word index char k belongs to.
|
|
677
|
+
*/
|
|
678
|
+
function buildWordIndex(chars) {
|
|
679
|
+
const wordEnds = [];
|
|
680
|
+
const charWord = new Array(chars.length).fill(0);
|
|
681
|
+
let word = 0;
|
|
682
|
+
let inWord = false;
|
|
683
|
+
for (let i = 0; i < chars.length; i++) {
|
|
684
|
+
const c = chars[i];
|
|
685
|
+
const space = c === " " || c === "\n" || c === "\t";
|
|
686
|
+
charWord[i] = word;
|
|
687
|
+
if (!space) {
|
|
688
|
+
inWord = true;
|
|
689
|
+
}
|
|
690
|
+
else if (inWord && space) {
|
|
691
|
+
// End the word at the first trailing space; absorb the run into it.
|
|
692
|
+
wordEnds.push(i + 1);
|
|
693
|
+
word++;
|
|
694
|
+
inWord = false;
|
|
695
|
+
}
|
|
696
|
+
else {
|
|
697
|
+
// Leading/extra whitespace stays with the upcoming word.
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
if (inWord)
|
|
701
|
+
wordEnds.push(chars.length);
|
|
702
|
+
return { wordEnds, charWord };
|
|
703
|
+
}
|
|
704
|
+
//# sourceMappingURL=text.js.map
|