@staticcanvas/typeflow 0.34.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.
Files changed (178) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +637 -0
  3. package/branding/dist/ico/typeflow-logo-16x16.ico +0 -0
  4. package/branding/dist/ico/typeflow-logo-256x256.ico +0 -0
  5. package/branding/dist/ico/typeflow-logo-32x32.ico +0 -0
  6. package/branding/dist/ico/typeflow-logo-48x48.ico +0 -0
  7. package/branding/dist/png/typeflow-logo-1024x1024.png +0 -0
  8. package/branding/dist/png/typeflow-logo-128x128.png +0 -0
  9. package/branding/dist/png/typeflow-logo-16x16.png +0 -0
  10. package/branding/dist/png/typeflow-logo-256x256.png +0 -0
  11. package/branding/dist/png/typeflow-logo-512x512.png +0 -0
  12. package/branding/dist/png/typeflow-logo-64x64.png +0 -0
  13. package/branding/dist/webp/typeflow-logo-1024x1024.webp +0 -0
  14. package/branding/dist/webp/typeflow-logo-128x128.webp +0 -0
  15. package/branding/dist/webp/typeflow-logo-16x16.webp +0 -0
  16. package/branding/dist/webp/typeflow-logo-256x256.webp +0 -0
  17. package/branding/dist/webp/typeflow-logo-32x32.webp +0 -0
  18. package/branding/dist/webp/typeflow-logo-48x48.webp +0 -0
  19. package/branding/dist/webp/typeflow-logo-512x512.webp +0 -0
  20. package/branding/dist/webp/typeflow-logo-64x64.webp +0 -0
  21. package/branding/gen-logo.ps1 +14 -0
  22. package/branding/typeflow-logo.svg +162 -0
  23. package/dist/typeflow/LICENSE +21 -0
  24. package/dist/typeflow/README.md +231 -0
  25. package/dist/typeflow/branding/dist/ico/typeflow-logo-16x16.ico +0 -0
  26. package/dist/typeflow/branding/dist/ico/typeflow-logo-256x256.ico +0 -0
  27. package/dist/typeflow/branding/dist/ico/typeflow-logo-32x32.ico +0 -0
  28. package/dist/typeflow/branding/dist/ico/typeflow-logo-48x48.ico +0 -0
  29. package/dist/typeflow/branding/dist/png/typeflow-logo-1024x1024.png +0 -0
  30. package/dist/typeflow/branding/dist/png/typeflow-logo-128x128.png +0 -0
  31. package/dist/typeflow/branding/dist/png/typeflow-logo-16x16.png +0 -0
  32. package/dist/typeflow/branding/dist/png/typeflow-logo-256x256.png +0 -0
  33. package/dist/typeflow/branding/dist/png/typeflow-logo-512x512.png +0 -0
  34. package/dist/typeflow/branding/dist/png/typeflow-logo-64x64.png +0 -0
  35. package/dist/typeflow/branding/dist/webp/typeflow-logo-1024x1024.webp +0 -0
  36. package/dist/typeflow/branding/dist/webp/typeflow-logo-128x128.webp +0 -0
  37. package/dist/typeflow/branding/dist/webp/typeflow-logo-16x16.webp +0 -0
  38. package/dist/typeflow/branding/dist/webp/typeflow-logo-256x256.webp +0 -0
  39. package/dist/typeflow/branding/dist/webp/typeflow-logo-32x32.webp +0 -0
  40. package/dist/typeflow/branding/dist/webp/typeflow-logo-48x48.webp +0 -0
  41. package/dist/typeflow/branding/dist/webp/typeflow-logo-512x512.webp +0 -0
  42. package/dist/typeflow/branding/dist/webp/typeflow-logo-64x64.webp +0 -0
  43. package/dist/typeflow/branding/gen-logo.ps1 +14 -0
  44. package/dist/typeflow/branding/typeflow-logo.svg +162 -0
  45. package/dist/typeflow/dist/typeflow-caret.cjs +47 -0
  46. package/dist/typeflow/dist/typeflow-caret.cjs.map +1 -0
  47. package/dist/typeflow/dist/typeflow-caret.esm.js +47 -0
  48. package/dist/typeflow/dist/typeflow-caret.esm.js.map +1 -0
  49. package/dist/typeflow/dist/typeflow-caret.esm.min.js +2 -0
  50. package/dist/typeflow/dist/typeflow-caret.esm.min.js.map +1 -0
  51. package/dist/typeflow/dist/typeflow-caret.js +47 -0
  52. package/dist/typeflow/dist/typeflow-caret.js.map +1 -0
  53. package/dist/typeflow/dist/typeflow-caret.min.js +2 -0
  54. package/dist/typeflow/dist/typeflow-caret.min.js.map +1 -0
  55. package/dist/typeflow/dist/typeflow-caret.mjs +47 -0
  56. package/dist/typeflow/dist/typeflow-caret.mjs.map +1 -0
  57. package/dist/typeflow/dist/typeflow-debug.cjs +109 -0
  58. package/dist/typeflow/dist/typeflow-debug.cjs.map +1 -0
  59. package/dist/typeflow/dist/typeflow-debug.esm.js +109 -0
  60. package/dist/typeflow/dist/typeflow-debug.esm.js.map +1 -0
  61. package/dist/typeflow/dist/typeflow-debug.esm.min.js +2 -0
  62. package/dist/typeflow/dist/typeflow-debug.esm.min.js.map +1 -0
  63. package/dist/typeflow/dist/typeflow-debug.js +109 -0
  64. package/dist/typeflow/dist/typeflow-debug.js.map +1 -0
  65. package/dist/typeflow/dist/typeflow-debug.min.js +2 -0
  66. package/dist/typeflow/dist/typeflow-debug.min.js.map +1 -0
  67. package/dist/typeflow/dist/typeflow-debug.mjs +109 -0
  68. package/dist/typeflow/dist/typeflow-debug.mjs.map +1 -0
  69. package/dist/typeflow/dist/typeflow-extchars.cjs +66 -0
  70. package/dist/typeflow/dist/typeflow-extchars.cjs.map +1 -0
  71. package/dist/typeflow/dist/typeflow-extchars.esm.js +66 -0
  72. package/dist/typeflow/dist/typeflow-extchars.esm.js.map +1 -0
  73. package/dist/typeflow/dist/typeflow-extchars.esm.min.js +2 -0
  74. package/dist/typeflow/dist/typeflow-extchars.esm.min.js.map +1 -0
  75. package/dist/typeflow/dist/typeflow-extchars.js +66 -0
  76. package/dist/typeflow/dist/typeflow-extchars.js.map +1 -0
  77. package/dist/typeflow/dist/typeflow-extchars.min.js +2 -0
  78. package/dist/typeflow/dist/typeflow-extchars.min.js.map +1 -0
  79. package/dist/typeflow/dist/typeflow-extchars.mjs +66 -0
  80. package/dist/typeflow/dist/typeflow-extchars.mjs.map +1 -0
  81. package/dist/typeflow/dist/typeflow-keystroke.cjs +47 -0
  82. package/dist/typeflow/dist/typeflow-keystroke.cjs.map +1 -0
  83. package/dist/typeflow/dist/typeflow-keystroke.esm.js +47 -0
  84. package/dist/typeflow/dist/typeflow-keystroke.esm.js.map +1 -0
  85. package/dist/typeflow/dist/typeflow-keystroke.esm.min.js +2 -0
  86. package/dist/typeflow/dist/typeflow-keystroke.esm.min.js.map +1 -0
  87. package/dist/typeflow/dist/typeflow-keystroke.js +47 -0
  88. package/dist/typeflow/dist/typeflow-keystroke.js.map +1 -0
  89. package/dist/typeflow/dist/typeflow-keystroke.min.js +2 -0
  90. package/dist/typeflow/dist/typeflow-keystroke.min.js.map +1 -0
  91. package/dist/typeflow/dist/typeflow-keystroke.mjs +47 -0
  92. package/dist/typeflow/dist/typeflow-keystroke.mjs.map +1 -0
  93. package/dist/typeflow/dist/typeflow-metrics.cjs +76 -0
  94. package/dist/typeflow/dist/typeflow-metrics.cjs.map +1 -0
  95. package/dist/typeflow/dist/typeflow-metrics.esm.js +76 -0
  96. package/dist/typeflow/dist/typeflow-metrics.esm.js.map +1 -0
  97. package/dist/typeflow/dist/typeflow-metrics.esm.min.js +2 -0
  98. package/dist/typeflow/dist/typeflow-metrics.esm.min.js.map +1 -0
  99. package/dist/typeflow/dist/typeflow-metrics.js +76 -0
  100. package/dist/typeflow/dist/typeflow-metrics.js.map +1 -0
  101. package/dist/typeflow/dist/typeflow-metrics.min.js +2 -0
  102. package/dist/typeflow/dist/typeflow-metrics.min.js.map +1 -0
  103. package/dist/typeflow/dist/typeflow-metrics.mjs +76 -0
  104. package/dist/typeflow/dist/typeflow-metrics.mjs.map +1 -0
  105. package/dist/typeflow/dist/typeflow-parallel.cjs +3 -0
  106. package/dist/typeflow/dist/typeflow-parallel.cjs.map +1 -0
  107. package/dist/typeflow/dist/typeflow-parallel.esm.js +3 -0
  108. package/dist/typeflow/dist/typeflow-parallel.esm.js.map +1 -0
  109. package/dist/typeflow/dist/typeflow-parallel.esm.min.js +2 -0
  110. package/dist/typeflow/dist/typeflow-parallel.esm.min.js.map +1 -0
  111. package/dist/typeflow/dist/typeflow-parallel.js +3 -0
  112. package/dist/typeflow/dist/typeflow-parallel.js.map +1 -0
  113. package/dist/typeflow/dist/typeflow-parallel.min.js +2 -0
  114. package/dist/typeflow/dist/typeflow-parallel.min.js.map +1 -0
  115. package/dist/typeflow/dist/typeflow-parallel.mjs +3 -0
  116. package/dist/typeflow/dist/typeflow-parallel.mjs.map +1 -0
  117. package/dist/typeflow/dist/typeflow-webaudio.cjs +81 -0
  118. package/dist/typeflow/dist/typeflow-webaudio.cjs.map +1 -0
  119. package/dist/typeflow/dist/typeflow-webaudio.esm.js +81 -0
  120. package/dist/typeflow/dist/typeflow-webaudio.esm.js.map +1 -0
  121. package/dist/typeflow/dist/typeflow-webaudio.esm.min.js +2 -0
  122. package/dist/typeflow/dist/typeflow-webaudio.esm.min.js.map +1 -0
  123. package/dist/typeflow/dist/typeflow-webaudio.js +81 -0
  124. package/dist/typeflow/dist/typeflow-webaudio.js.map +1 -0
  125. package/dist/typeflow/dist/typeflow-webaudio.min.js +2 -0
  126. package/dist/typeflow/dist/typeflow-webaudio.min.js.map +1 -0
  127. package/dist/typeflow/dist/typeflow-webaudio.mjs +81 -0
  128. package/dist/typeflow/dist/typeflow-webaudio.mjs.map +1 -0
  129. package/dist/typeflow/dist/typeflow.cjs +277 -0
  130. package/dist/typeflow/dist/typeflow.cjs.map +1 -0
  131. package/dist/typeflow/dist/typeflow.esm.js +277 -0
  132. package/dist/typeflow/dist/typeflow.esm.js.map +1 -0
  133. package/dist/typeflow/dist/typeflow.esm.min.js +2 -0
  134. package/dist/typeflow/dist/typeflow.esm.min.js.map +1 -0
  135. package/dist/typeflow/dist/typeflow.js +277 -0
  136. package/dist/typeflow/dist/typeflow.js.map +1 -0
  137. package/dist/typeflow/dist/typeflow.min.js +2 -0
  138. package/dist/typeflow/dist/typeflow.min.js.map +1 -0
  139. package/dist/typeflow/dist/typeflow.mjs +277 -0
  140. package/dist/typeflow/dist/typeflow.mjs.map +1 -0
  141. package/dist/typeflow/jsr.jsonc +27 -0
  142. package/dist/typeflow/package.json +79 -0
  143. package/dist/typeflow/src/typeflow-audio-presets.js +137 -0
  144. package/dist/typeflow/src/typeflow-caret.d.ts +65 -0
  145. package/dist/typeflow/src/typeflow-caret.js +303 -0
  146. package/dist/typeflow/src/typeflow-debug.d.ts +34 -0
  147. package/dist/typeflow/src/typeflow-debug.js +223 -0
  148. package/dist/typeflow/src/typeflow-extchars.d.ts +28 -0
  149. package/dist/typeflow/src/typeflow-extchars.js +132 -0
  150. package/dist/typeflow/src/typeflow-keystroke.d.ts +28 -0
  151. package/dist/typeflow/src/typeflow-keystroke.js +220 -0
  152. package/dist/typeflow/src/typeflow-metrics.d.ts +38 -0
  153. package/dist/typeflow/src/typeflow-metrics.js +556 -0
  154. package/dist/typeflow/src/typeflow-parallel.d.ts +36 -0
  155. package/dist/typeflow/src/typeflow-parallel.js +157 -0
  156. package/dist/typeflow/src/typeflow-webaudio.d.ts +83 -0
  157. package/dist/typeflow/src/typeflow-webaudio.js +517 -0
  158. package/dist/typeflow/src/typeflow.d.ts +328 -0
  159. package/dist/typeflow/src/typeflow.js +2119 -0
  160. package/jsr.jsonc +27 -0
  161. package/package.json +133 -0
  162. package/src/typeflow-audio-presets.js +137 -0
  163. package/src/typeflow-caret.d.ts +65 -0
  164. package/src/typeflow-caret.js +303 -0
  165. package/src/typeflow-debug.d.ts +34 -0
  166. package/src/typeflow-debug.js +223 -0
  167. package/src/typeflow-extchars.d.ts +28 -0
  168. package/src/typeflow-extchars.js +132 -0
  169. package/src/typeflow-keystroke.d.ts +28 -0
  170. package/src/typeflow-keystroke.js +220 -0
  171. package/src/typeflow-metrics.d.ts +38 -0
  172. package/src/typeflow-metrics.js +556 -0
  173. package/src/typeflow-parallel.d.ts +36 -0
  174. package/src/typeflow-parallel.js +157 -0
  175. package/src/typeflow-webaudio.d.ts +83 -0
  176. package/src/typeflow-webaudio.js +517 -0
  177. package/src/typeflow.d.ts +328 -0
  178. package/src/typeflow.js +2119 -0
@@ -0,0 +1,2119 @@
1
+ /**
2
+ * TypeFlow - A lightweight, interruptible, framework-agnostic typewriter library
3
+ * @version 2.3.0
4
+ * @license MIT
5
+ *
6
+ * Zero dependencies. Safe by default (no innerHTML string concatenation).
7
+ * Vanilla-first, with Intl.Segmenter support, multi-style reveals, and integrations for React, Vue, Svelte, Solid.js, Alpine.js, and Web Components.
8
+ */
9
+
10
+ // ============================================================
11
+ // Constants
12
+ // ============================================================
13
+
14
+ const SCRAMBLE_CHARSETS = {
15
+ matrix: '01X_#*~<>[]{}',
16
+ ascii: '!@#$%^&*()_+-=[]{}|;:,.<>?',
17
+ 'ascii-extended': '¡¢£¤¥¦§¨©ª«¬®¯°±²³´µ¶·¸¹º»¼½¾¿',
18
+ blocks: ' ▂▃▄▅▆▇█░▒▓',
19
+ binary: '01',
20
+ hex: '0123456789ABCDEF',
21
+ braille: '⠁⠃⠇⠏⠟⠿⡿⣿',
22
+ runic: 'ᚠᚡᚢᚣᚤᚥᚦᚧᚨᚩᚪᚫᚬᚭᚮᚯ',
23
+ cyber: '!<>-_\\/[]{}—=+*^?#________',
24
+ };
25
+
26
+ const PRESETS = {
27
+ cyberpunk: {
28
+ typeStyle: 'scramble',
29
+ scrambleCharset: 'matrix',
30
+ scrambleRounds: 2,
31
+ speed: 40,
32
+ cursor: '|',
33
+ cursorBlink: true,
34
+ audio: 'synth',
35
+ },
36
+ terminal: {
37
+ typeStyle: 'char',
38
+ speed: 32,
39
+ cursor: '█',
40
+ cursorBlink: true,
41
+ audio: 'mechanical',
42
+ },
43
+ writer: {
44
+ naturalCadence: true,
45
+ speed: 52,
46
+ speedVariance: 22,
47
+ cursor: '|',
48
+ cursorBlink: true,
49
+ audio: 'mechanical',
50
+ },
51
+ matrix: {
52
+ typeStyle: 'scramble',
53
+ scrambleCharset: 'matrix',
54
+ scrambleRounds: 3,
55
+ speed: 30,
56
+ cursor: '█',
57
+ cursorBlink: true,
58
+ audio: 'synth',
59
+ },
60
+ blocks: {
61
+ typeStyle: 'scramble',
62
+ scrambleCharset: 'blocks',
63
+ scrambleRounds: 2,
64
+ speed: 45,
65
+ cursor: '█',
66
+ cursorBlink: true,
67
+ },
68
+ subtle: {
69
+ speed: 45,
70
+ cursor: '|',
71
+ cursorBlink: true,
72
+ eraseStyle: 'fade',
73
+ respectReducedMotion: true,
74
+ },
75
+ };
76
+
77
+ const TYPE_DEFAULTS = {
78
+ speed: 50,
79
+ delay: 0,
80
+ speedVariance: 0,
81
+ naturalCadence: false,
82
+ granularity: 'grapheme',
83
+ cursor: '|',
84
+ cursorBlink: true,
85
+ html: false,
86
+ mode: 'replace',
87
+ direction: 'left',
88
+ typeStyle: 'char',
89
+ scrambleCharset: 'cyber',
90
+ scrambleGlyphs: '!<>-_\\/[]{}—=+*^?#________',
91
+ scrambleRounds: 2,
92
+ audio: null,
93
+ respectReducedMotion: true,
94
+ };
95
+
96
+ const ERASE_DEFAULTS = {
97
+ ...TYPE_DEFAULTS,
98
+ speed: 25,
99
+ cursorBlink: false,
100
+ eraseStyle: 'end',
101
+ preserveBaseline: true,
102
+ };
103
+
104
+ const VOID_ELEMENTS = new Set([
105
+ 'br',
106
+ 'hr',
107
+ 'img',
108
+ 'input',
109
+ 'wbr',
110
+ 'area',
111
+ 'base',
112
+ 'col',
113
+ 'embed',
114
+ 'link',
115
+ 'meta',
116
+ 'source',
117
+ 'track',
118
+ ]);
119
+
120
+ const DEFAULT_ALLOWED_TAGS = new Set([
121
+ 'b',
122
+ 'strong',
123
+ 'i',
124
+ 'em',
125
+ 'mark',
126
+ 'span',
127
+ 'small',
128
+ 'sub',
129
+ 'sup',
130
+ 'u',
131
+ 'br',
132
+ 'a',
133
+ 'code',
134
+ ]);
135
+
136
+ const DEFAULT_ALLOWED_ATTRS = {
137
+ a: new Set(['href', 'title', 'target', 'rel']),
138
+ span: new Set(['class']),
139
+ mark: new Set(['class']),
140
+ '*': new Set(['class']),
141
+ };
142
+
143
+ const CURSOR_BLINK_INTERVAL = 530;
144
+ const DEFAULT_FADE_DURATION = 250;
145
+ const DEFAULT_WORD_FADE_DURATION = 280;
146
+ const DEFAULT_WORD_STAGGER = 70;
147
+ // Shared spatial-motion tokens reused by every `float-in-*` and `zoom-in-space`
148
+ // reveal/erase variant so distance, depth, and perspective stay consistent.
149
+ const DEFAULT_MOTION_DISTANCE = 24;
150
+ const DEFAULT_MOTION_DEPTH = 160;
151
+ const DEFAULT_MOTION_SCALE = 0.82;
152
+ const DEFAULT_MOTION_PERSPECTIVE = 600;
153
+ const DEFAULT_MOTION_EASING = 'ease';
154
+
155
+ const WORD_MOTION_STYLES = new Set([
156
+ 'word-fade',
157
+ 'float-in-top',
158
+ 'float-in-bottom',
159
+ 'float-in-left',
160
+ 'float-in-right',
161
+ 'zoom-in-space',
162
+ ]);
163
+
164
+ // ============================================================
165
+ // Web Audio Micro-Synthesizer
166
+ // ============================================================
167
+
168
+ let audioCtx = null;
169
+ function getAudioContext() {
170
+ if (typeof window === 'undefined') return null;
171
+ const AudioCtx = window.AudioContext || window.webkitAudioContext;
172
+ if (!AudioCtx) return null;
173
+ if (!audioCtx) {
174
+ try {
175
+ audioCtx = new AudioCtx();
176
+ } catch {
177
+ return null;
178
+ }
179
+ }
180
+ if (audioCtx && audioCtx.state === 'suspended') {
181
+ audioCtx.resume().catch(() => {});
182
+ }
183
+ return audioCtx;
184
+ }
185
+
186
+ function playKeystrokeSound(audioOption) {
187
+ if (!audioOption) return;
188
+ if (typeof audioOption === 'function') {
189
+ try {
190
+ audioOption();
191
+ } catch {
192
+ // Audio callbacks are optional and must not interrupt typing.
193
+ }
194
+ return;
195
+ }
196
+ const type = typeof audioOption === 'string' ? audioOption : 'mechanical';
197
+ try {
198
+ const ctx = getAudioContext();
199
+ if (!ctx) return;
200
+ const now = ctx.currentTime;
201
+ const osc = ctx.createOscillator();
202
+ const gain = ctx.createGain();
203
+
204
+ if (type === 'beep') {
205
+ osc.type = 'sine';
206
+ osc.frequency.setValueAtTime(880, now);
207
+ gain.gain.setValueAtTime(0.03, now);
208
+ gain.gain.exponentialRampToValueAtTime(0.001, now + 0.035);
209
+ osc.connect(gain);
210
+ gain.connect(ctx.destination);
211
+ osc.start(now);
212
+ osc.stop(now + 0.035);
213
+ } else if (type === 'synth') {
214
+ osc.type = 'sawtooth';
215
+ osc.frequency.setValueAtTime(340, now);
216
+ osc.frequency.exponentialRampToValueAtTime(140, now + 0.04);
217
+ gain.gain.setValueAtTime(0.025, now);
218
+ gain.gain.exponentialRampToValueAtTime(0.001, now + 0.04);
219
+ osc.connect(gain);
220
+ gain.connect(ctx.destination);
221
+ osc.start(now);
222
+ osc.stop(now + 0.04);
223
+ } else {
224
+ // 'mechanical' switch click
225
+ osc.type = 'triangle';
226
+ osc.frequency.setValueAtTime(1100 + Math.random() * 300, now);
227
+ gain.gain.setValueAtTime(0.035, now);
228
+ gain.gain.exponentialRampToValueAtTime(0.001, now + 0.02);
229
+ osc.connect(gain);
230
+ gain.connect(ctx.destination);
231
+ osc.start(now);
232
+ osc.stop(now + 0.02);
233
+ }
234
+ } catch {
235
+ // Audio playback is best effort and may be unavailable in the browser.
236
+ }
237
+ }
238
+
239
+ function stopKeystrokeSound(audioOption) {
240
+ try {
241
+ audioOption.stop();
242
+ } catch {
243
+ // Optional audio cleanup must not interrupt controller cancellation.
244
+ }
245
+ }
246
+
247
+ // ============================================================
248
+ // Intl.Segmenter Helper & Unit Slicing
249
+ // ============================================================
250
+
251
+ function segmentText(text, granularity = 'grapheme') {
252
+ if (typeof Intl !== 'undefined' && Intl.Segmenter) {
253
+ const segmenter = new Intl.Segmenter(undefined, { granularity });
254
+ return Array.from(segmenter.segment(text), (s) => s.segment);
255
+ }
256
+ if (granularity === 'word') {
257
+ const matches = text.match(/\s+|\S+/g);
258
+ return matches ? matches : [text];
259
+ }
260
+ return Array.from(text);
261
+ }
262
+
263
+ const SEGMENTED_NODE_CACHE = new WeakMap();
264
+ function segmentNodeText(node, granularity = 'grapheme') {
265
+ const text = node.textContent ?? '';
266
+ let entry = SEGMENTED_NODE_CACHE.get(node);
267
+ if (!entry || entry.text !== text) {
268
+ entry = { text, values: new Map() };
269
+ SEGMENTED_NODE_CACHE.set(node, entry);
270
+ }
271
+ if (!entry.values.has(granularity)) entry.values.set(granularity, segmentText(text, granularity));
272
+ return entry.values.get(granularity);
273
+ }
274
+
275
+ function resolveScrambleGlyphs(glyphs, charset) {
276
+ if (charset && SCRAMBLE_CHARSETS[charset]) return SCRAMBLE_CHARSETS[charset];
277
+ if (typeof glyphs === 'string' && SCRAMBLE_CHARSETS[glyphs]) return SCRAMBLE_CHARSETS[glyphs];
278
+ return glyphs ?? SCRAMBLE_CHARSETS.cyber;
279
+ }
280
+
281
+ function getRandomGlyph(glyphs) {
282
+ return glyphs[Math.floor(Math.random() * glyphs.length)];
283
+ }
284
+
285
+ // ============================================================
286
+ // Sanitization
287
+ // ============================================================
288
+
289
+ function isSafeUrl(value) {
290
+ const v = value.trim().toLowerCase();
291
+ return !v.startsWith('javascript:') && !v.startsWith('data:') && !v.startsWith('vbscript:');
292
+ }
293
+
294
+ function sanitizeElement(el, allowedTags, allowedAttrs) {
295
+ const tag = el.tagName.toLowerCase();
296
+ if (!allowedTags.has(tag)) return null;
297
+
298
+ const clean = document.createElement(tag);
299
+ const allowedForTag = allowedAttrs[tag] ?? new Set();
300
+ const allowedGlobal = allowedAttrs['*'] ?? new Set();
301
+
302
+ for (const attr of Array.from(el.attributes)) {
303
+ const name = attr.name.toLowerCase();
304
+ if (name.startsWith('on')) continue;
305
+ if (!allowedForTag.has(name) && !allowedGlobal.has(name)) continue;
306
+ if ((name === 'href' || name === 'src') && !isSafeUrl(attr.value)) continue;
307
+ clean.setAttribute(name, attr.value);
308
+ }
309
+
310
+ if (tag === 'a' && clean.getAttribute('target') === '_blank') {
311
+ clean.setAttribute('rel', 'noopener noreferrer');
312
+ }
313
+
314
+ return clean;
315
+ }
316
+
317
+ function buildSourceTree(input, plainText, allowedTags, allowedAttrs) {
318
+ const fragment = document.createDocumentFragment();
319
+ if (plainText) {
320
+ fragment.appendChild(document.createTextNode(input));
321
+ return fragment;
322
+ }
323
+
324
+ const template = document.createElement('template');
325
+ template.innerHTML = input;
326
+
327
+ const walk = (source, target) => {
328
+ source.childNodes.forEach((child) => {
329
+ if (child.nodeType === Node.TEXT_NODE) {
330
+ target.appendChild(document.createTextNode(child.textContent ?? ''));
331
+ } else if (child.nodeType === Node.ELEMENT_NODE) {
332
+ const clean = sanitizeElement(child, allowedTags, allowedAttrs);
333
+ if (clean) {
334
+ target.appendChild(clean);
335
+ walk(child, clean);
336
+ } else {
337
+ walk(child, target);
338
+ }
339
+ }
340
+ });
341
+ };
342
+
343
+ walk(template.content, fragment);
344
+ return fragment;
345
+ }
346
+
347
+ // ============================================================
348
+ // Reveal Engines (Universal Window Slice)
349
+ // ============================================================
350
+
351
+ function countTextUnits(node, granularity = 'grapheme') {
352
+ if (node.nodeType === 3) {
353
+ return segmentNodeText(node, granularity).length;
354
+ }
355
+ let total = 0;
356
+ node.childNodes.forEach((child) => (total += countTextUnits(child, granularity)));
357
+ return total;
358
+ }
359
+
360
+ function cloneRevealWindow(node, start, end, granularity = 'grapheme', revealOpts = {}) {
361
+ if (node.nodeType === 3) {
362
+ const units = segmentNodeText(node, granularity);
363
+ const len = units.length;
364
+ if (end <= 0 || start >= len) {
365
+ return { clone: null, start: Math.max(0, start - len), end: Math.max(0, end - len) };
366
+ }
367
+ const s = Math.max(0, start);
368
+ const e = Math.min(len, end);
369
+
370
+ const doc = node?.ownerDocument || (typeof document !== 'undefined' ? document : null);
371
+ if (!doc) return { clone: null, start, end };
372
+
373
+ if (revealOpts?.scrambleActive && e > s && units[e - 1]?.trim()) {
374
+ const frag = doc.createDocumentFragment();
375
+ if (e - 1 > s) {
376
+ frag.appendChild(doc.createTextNode(units.slice(s, e - 1).join('')));
377
+ }
378
+ const scrambleSpan = doc.createElement('span');
379
+ scrambleSpan.className = 'tf-scramble';
380
+ if (revealOpts.scrambleColor) {
381
+ scrambleSpan.style.color = revealOpts.scrambleColor;
382
+ }
383
+ if (revealOpts.scrambleGradient) {
384
+ scrambleSpan.style.backgroundImage = revealOpts.scrambleGradient;
385
+ scrambleSpan.style.webkitBackgroundImage = revealOpts.scrambleGradient;
386
+ scrambleSpan.style.webkitBackgroundClip = 'text';
387
+ scrambleSpan.style.backgroundClip = 'text';
388
+ scrambleSpan.style.webkitTextFillColor = 'transparent';
389
+ scrambleSpan.style.display = 'inline-block';
390
+ }
391
+ scrambleSpan.textContent = getRandomGlyph(revealOpts.scrambleGlyphs);
392
+ frag.appendChild(scrambleSpan);
393
+ return {
394
+ clone: frag,
395
+ start: Math.max(0, start - len),
396
+ end: Math.max(0, end - len),
397
+ };
398
+ }
399
+
400
+ const trailLen =
401
+ typeof revealOpts.trailFade === 'number'
402
+ ? revealOpts.trailFade
403
+ : typeof revealOpts.trailingFadeLength === 'number'
404
+ ? revealOpts.trailingFadeLength
405
+ : revealOpts.trailFade
406
+ ? 4
407
+ : 0;
408
+
409
+ if (trailLen > 0 && e - s >= 1 && !revealOpts.isComplete) {
410
+ const frag = doc.createDocumentFragment();
411
+ const solidEnd = Math.max(s, e - trailLen);
412
+ if (solidEnd > s) {
413
+ frag.appendChild(doc.createTextNode(units.slice(s, solidEnd).join('')));
414
+ }
415
+ const trailCount = e - solidEnd;
416
+ const minOp =
417
+ typeof revealOpts.trailingFadeMinOpacity === 'number'
418
+ ? revealOpts.trailingFadeMinOpacity
419
+ : typeof revealOpts.trailMinOpacity === 'number'
420
+ ? revealOpts.trailMinOpacity
421
+ : 0.18;
422
+ for (let i = 0; i < trailCount; i++) {
423
+ const charIdx = solidEnd + i;
424
+ const charSpan = doc.createElement('span');
425
+ charSpan.className = 'tf-trail';
426
+ const progress = trailCount <= 1 ? 1 : (i + 1) / trailCount;
427
+ const opacity = minOp + (1.0 - minOp) * progress;
428
+ charSpan.style.opacity = opacity.toFixed(3);
429
+ charSpan.textContent = units[charIdx];
430
+ frag.appendChild(charSpan);
431
+ }
432
+ return {
433
+ clone: frag,
434
+ start: Math.max(0, start - len),
435
+ end: Math.max(0, end - len),
436
+ };
437
+ }
438
+
439
+ const sliced = units.slice(s, e).join('');
440
+ return {
441
+ clone: doc.createTextNode(sliced),
442
+ start: Math.max(0, start - len),
443
+ end: Math.max(0, end - len),
444
+ };
445
+ }
446
+ if (node.nodeType === 1) {
447
+ const el = node;
448
+ const tag = el.tagName.toLowerCase();
449
+ if (VOID_ELEMENTS.has(tag)) {
450
+ if (end <= 0) return { clone: null, start, end };
451
+ return { clone: el.cloneNode(false), start, end };
452
+ }
453
+ const clone = el.cloneNode(false);
454
+ let curStart = start;
455
+ let curEnd = end;
456
+ let appended = false;
457
+ node.childNodes.forEach((child) => {
458
+ const result = cloneRevealWindow(child, curStart, curEnd, granularity, revealOpts);
459
+ if (result.clone) {
460
+ clone.appendChild(result.clone);
461
+ appended = true;
462
+ }
463
+ curStart = result.start;
464
+ curEnd = result.end;
465
+ });
466
+ return { clone: appended ? clone : null, start: curStart, end: curEnd };
467
+ }
468
+ return { clone: null, start, end };
469
+ }
470
+
471
+ function revealWindowFragment(source, start, end, granularity = 'grapheme', revealOpts = {}) {
472
+ const doc = source?.ownerDocument || (typeof document !== 'undefined' ? document : null);
473
+ if (!doc) return null;
474
+ const out = doc.createDocumentFragment();
475
+ let curStart = start;
476
+ let curEnd = end;
477
+ source.childNodes.forEach((child) => {
478
+ const result = cloneRevealWindow(child, curStart, curEnd, granularity, revealOpts);
479
+ if (result.clone) out.appendChild(result.clone);
480
+ curStart = result.start;
481
+ curEnd = result.end;
482
+ });
483
+ return out;
484
+ }
485
+
486
+ function calculateWindowBounds(count, totalUnits, direction, eraseStyle, isErasing) {
487
+ if (isErasing) {
488
+ if (eraseStyle === 'start') {
489
+ return { start: totalUnits - count, end: totalUnits };
490
+ }
491
+ if (eraseStyle === 'center') {
492
+ const s = Math.max(0, Math.floor((totalUnits - count) / 2));
493
+ return {
494
+ start: s,
495
+ end: Math.min(totalUnits, s + count),
496
+ };
497
+ }
498
+ return { start: 0, end: count };
499
+ }
500
+
501
+ if (direction === 'right') {
502
+ return { start: totalUnits - count, end: totalUnits };
503
+ }
504
+ if (direction === 'center') {
505
+ const s = Math.max(0, Math.floor((totalUnits - count) / 2));
506
+ return {
507
+ start: s,
508
+ end: Math.min(totalUnits, s + count),
509
+ };
510
+ }
511
+ return { start: 0, end: count };
512
+ }
513
+
514
+ // ============================================================
515
+ // Core Engine
516
+ // ============================================================
517
+
518
+ function resolveTarget(target) {
519
+ const el = resolveMaybeTarget(target);
520
+ if (!el) {
521
+ const desc = target === null ? 'null' : target === undefined ? 'undefined' : String(target);
522
+ throw new Error(`TypeFlow: target element not found (${desc})`);
523
+ }
524
+ return el;
525
+ }
526
+
527
+ function resolveMaybeTarget(target) {
528
+ return typeof target === 'string' ? document.querySelector(target) : target;
529
+ }
530
+
531
+ function normalizeDirection(dir) {
532
+ if (!dir) return 'left';
533
+ const s = String(dir).toLowerCase();
534
+ if (s === 'right' || s === 'rtl' || s === 'end' || s === 'reverse') return 'right';
535
+ if (s === 'center' || s === 'middle' || s === 'expand' || s === 'both') return 'center';
536
+ return 'left';
537
+ }
538
+
539
+ function normalizeTypeStyle(style) {
540
+ if (!style) return 'char';
541
+ const s = String(style).toLowerCase();
542
+ if (s === 'word' || s === 'words' || s === 'stream') return 'word';
543
+ if (s === 'scramble' || s === 'matrix' || s === 'decrypt' || s === 'cyber' || s === 'random')
544
+ return 'scramble';
545
+ if (s === 'fade-trail' || s === 'trail' || s === 'trailing' || s === 'soft') return 'fade-trail';
546
+ if (s === 'word-fade' || s === 'fade-words' || s === 'wordfade') return 'word-fade';
547
+ if (s === 'float-in-top' || s === 'float-top' || s === 'float-down') return 'float-in-top';
548
+ if (s === 'float-in-bottom' || s === 'float-bottom' || s === 'float-up') return 'float-in-bottom';
549
+ if (s === 'float-in-left' || s === 'float-left') return 'float-in-left';
550
+ if (s === 'float-in-right' || s === 'float-right') return 'float-in-right';
551
+ if (s === 'zoom-in-space' || s === 'zoom-in' || s === 'zoom-space') return 'zoom-in-space';
552
+ return 'char';
553
+ }
554
+
555
+ function normalizeEraseStyle(style) {
556
+ if (!style) return 'end';
557
+ const s = String(style).toLowerCase();
558
+ if (s === 'start' || s === 'erosion' || s === 'left' || s === 'forward') return 'start';
559
+ if (s === 'center' || s === 'middle' || s === 'shrink' || s === 'split') return 'center';
560
+ if (s === 'scramble' || s === 'matrix' || s === 'decrypt' || s === 'cyber' || s === 'random')
561
+ return 'scramble';
562
+ if (s === 'fade-trail' || s === 'trail' || s === 'trailing' || s === 'soft') return 'fade-trail';
563
+ if (s === 'fade' || s === 'dissolve' || s === 'crossfade') return 'fade';
564
+ if (s === 'word-fade' || s === 'fade-words' || s === 'wordfade') return 'word-fade';
565
+ if (s === 'float-in-top' || s === 'float-top' || s === 'float-down') return 'float-in-top';
566
+ if (s === 'float-in-bottom' || s === 'float-bottom' || s === 'float-up') return 'float-in-bottom';
567
+ if (s === 'float-in-left' || s === 'float-left') return 'float-in-left';
568
+ if (s === 'float-in-right' || s === 'float-right') return 'float-in-right';
569
+ if (s === 'zoom-in-space' || s === 'zoom-in' || s === 'zoom-space') return 'zoom-in-space';
570
+ if (s === 'instant' || s === 'clear' || s === 'reset' || s === 'immediate') return 'instant';
571
+ return 'end';
572
+ }
573
+
574
+ function normalizeWordOrder(order) {
575
+ const value = String(order ?? 'left-to-right').toLowerCase();
576
+ if (value === 'right-to-left' || value === 'reverse' || value === 'rtl') return 'right-to-left';
577
+ if (value === 'random' || value === 'shuffle') return 'random';
578
+ return 'left-to-right';
579
+ }
580
+
581
+ function createSeededRandom(seed) {
582
+ if (seed === undefined || seed === null) return Math.random;
583
+ const text = String(seed);
584
+ let state = 2166136261;
585
+ for (let index = 0; index < text.length; index += 1) {
586
+ state ^= text.charCodeAt(index);
587
+ state = Math.imul(state, 16777619);
588
+ }
589
+ return () => {
590
+ state += 0x6d2b79f5;
591
+ let value = state;
592
+ value = Math.imul(value ^ (value >>> 15), value | 1);
593
+ value ^= value + Math.imul(value ^ (value >>> 7), value | 61);
594
+ return ((value ^ (value >>> 14)) >>> 0) / 4294967296;
595
+ };
596
+ }
597
+
598
+ function orderWordIndexes(length, order, seed) {
599
+ const indexes = Array.from({ length }, (_, index) => index);
600
+ if (normalizeWordOrder(order) === 'right-to-left') return indexes.reverse();
601
+ if (normalizeWordOrder(order) !== 'random') return indexes;
602
+
603
+ const random = createSeededRandom(seed);
604
+ for (let index = indexes.length - 1; index > 0; index -= 1) {
605
+ const swapIndex = Math.floor(random() * (index + 1));
606
+ [indexes[index], indexes[swapIndex]] = [indexes[swapIndex], indexes[index]];
607
+ }
608
+ return indexes;
609
+ }
610
+
611
+ function segmentWords(text) {
612
+ if (typeof Intl !== 'undefined' && Intl.Segmenter) {
613
+ return Array.from(new Intl.Segmenter(undefined, { granularity: 'word' }).segment(text));
614
+ }
615
+ return (text.match(/\s+|\S+/g) ?? []).map((segment) => ({
616
+ segment,
617
+ isWordLike: !/^\s+$/u.test(segment),
618
+ }));
619
+ }
620
+
621
+ /**
622
+ * Resolves the hidden/visible transform pair for a spatial word-motion style.
623
+ * Returns `null` for the plain `word-fade` style, which is opacity-only and
624
+ * needs no transform (so words keep `display: inline` and normal text flow).
625
+ */
626
+ function getWordMotionTransforms(motionKind, options) {
627
+ const distance = Math.max(0, Number(options.wordMotionDistance ?? DEFAULT_MOTION_DISTANCE) || 0);
628
+ const depth = Math.max(0, Number(options.wordMotionDepth ?? DEFAULT_MOTION_DEPTH) || 0);
629
+ const scale = Math.min(
630
+ 1,
631
+ Math.max(0.01, Number(options.wordMotionScale ?? DEFAULT_MOTION_SCALE) || DEFAULT_MOTION_SCALE)
632
+ );
633
+
634
+ switch (motionKind) {
635
+ case 'float-in-top':
636
+ return { hidden: `translateY(-${distance}px)`, visible: 'translateY(0)' };
637
+ case 'float-in-bottom':
638
+ return { hidden: `translateY(${distance}px)`, visible: 'translateY(0)' };
639
+ case 'float-in-left':
640
+ return { hidden: `translateX(-${distance}px)`, visible: 'translateX(0)' };
641
+ case 'float-in-right':
642
+ return { hidden: `translateX(${distance}px)`, visible: 'translateX(0)' };
643
+ case 'zoom-in-space':
644
+ return {
645
+ hidden: `translateZ(-${depth}px) scale(${scale})`,
646
+ visible: 'translateZ(0) scale(1)',
647
+ };
648
+ default:
649
+ return null;
650
+ }
651
+ }
652
+
653
+ function buildWordMotionTree(source, layout = 'opacity', motionKind = 'word-fade') {
654
+ const doc = source?.ownerDocument || (typeof document !== 'undefined' ? document : null);
655
+ if (!doc) return { fragment: null, words: [] };
656
+ const fragment = doc.createDocumentFragment();
657
+ const words = [];
658
+ // Transform has no effect on `display: inline` boxes, so transformed
659
+ // variants promote each word wrapper to `inline-block`.
660
+ const needsTransform = motionKind !== 'word-fade';
661
+
662
+ const walk = (sourceNode, targetNode) => {
663
+ sourceNode.childNodes.forEach((child) => {
664
+ if (child.nodeType === Node.TEXT_NODE) {
665
+ for (const part of segmentWords(child.textContent ?? '')) {
666
+ if (/^\s+$/u.test(part.segment)) {
667
+ targetNode.appendChild(doc.createTextNode(part.segment));
668
+ continue;
669
+ }
670
+
671
+ if (
672
+ part.isWordLike === false &&
673
+ words.length > 0 &&
674
+ words.at(-1).parentNode === targetNode
675
+ ) {
676
+ words.at(-1).appendChild(doc.createTextNode(part.segment));
677
+ continue;
678
+ }
679
+
680
+ const wrapper = doc.createElement('span');
681
+ wrapper.className = 'tf-word-fade';
682
+ wrapper.dataset.tfWordIndex = String(words.length);
683
+ wrapper.textContent = part.segment;
684
+ wrapper.style.display = needsTransform ? 'inline-block' : 'inline';
685
+ wrapper.style.opacity = '0';
686
+ wrapper.style.willChange = needsTransform ? 'opacity, transform' : 'opacity';
687
+ wrapper.setAttribute('aria-hidden', 'true');
688
+ if (layout === 'placeholder') wrapper.style.visibility = 'hidden';
689
+ words.push(wrapper);
690
+ targetNode.appendChild(wrapper);
691
+ }
692
+ } else if (child.nodeType === Node.ELEMENT_NODE) {
693
+ const clone = child.cloneNode(false);
694
+ targetNode.appendChild(clone);
695
+ walk(child, clone);
696
+ }
697
+ });
698
+ };
699
+
700
+ walk(source, fragment);
701
+ return { fragment, words };
702
+ }
703
+
704
+ function prefersReducedMotion() {
705
+ return (
706
+ typeof window !== 'undefined' &&
707
+ typeof window.matchMedia === 'function' &&
708
+ window.matchMedia('(prefers-reduced-motion: reduce)').matches
709
+ );
710
+ }
711
+
712
+ function calculateCadencePause(text, count) {
713
+ if (count <= 0 || count > text.length) return 0;
714
+ const char = text[count - 1];
715
+ if (char === '.' || char === '!' || char === '?') return 220;
716
+ if (char === ',' || char === ';' || char === ':') return 110;
717
+ if (char === ' ') return 40;
718
+ return 0;
719
+ }
720
+
721
+ /**
722
+ * Builds a `<span class="tf-cursor">` blinking-cursor element. Shared by the
723
+ * character/window renderer, word-motion renderer, and the {@link Caret} API.
724
+ */
725
+ function createCursorSpan(cursorChar, isHidden = false) {
726
+ const span = document.createElement('span');
727
+ span.className = 'tf-cursor';
728
+ span.setAttribute('aria-hidden', 'true');
729
+ span.textContent = cursorChar;
730
+ if (isHidden) span.style.visibility = 'hidden';
731
+ return span;
732
+ }
733
+
734
+ /**
735
+ * Core animation engine that manages all active TypeFlow instances.
736
+ *
737
+ * Each target element may have at most one active animation at a time.
738
+ * Starting a new animation on an already-animating element stops the previous
739
+ * one before beginning the new one.
740
+ *
741
+ * Accessed via the exported singleton {@link TypeFlow}.
742
+ *
743
+ * @class
744
+ */
745
+ class TypeFlowEngine {
746
+ active = new Map();
747
+ baselines = new WeakMap();
748
+ cursorIntervals = new Map();
749
+
750
+ clearCursorInterval(el) {
751
+ const interval = this.cursorIntervals.get(el);
752
+ if (interval) {
753
+ clearInterval(interval);
754
+ this.cursorIntervals.delete(el);
755
+ }
756
+ }
757
+
758
+ animate(el, sourceTree, totalUnits, options, startCount, endCount, resultText, extra = {}) {
759
+ this.clearCursorInterval(el);
760
+ this.active.get(el)?.stop();
761
+ if (options.ariaLive) el.setAttribute('aria-live', options.ariaLive);
762
+
763
+ const prefix = extra.prefix ?? [];
764
+ const blinkOnFinish = extra.blinkOnFinish ?? false;
765
+ const direction = endCount >= startCount ? 1 : -1;
766
+ const speed = Math.max(0, Number(options.speed ?? TYPE_DEFAULTS.speed) || 0);
767
+ const granularity = options.granularity ?? TYPE_DEFAULTS.granularity;
768
+ const isScramble = options.typeStyle === 'scramble' || options.eraseStyle === 'scramble';
769
+ const scrambleGlyphs = resolveScrambleGlyphs(
770
+ options.scrambleGlyphs,
771
+ options.scrambleCharset ?? options.scramblePreset
772
+ );
773
+
774
+ let settled = false;
775
+ let timeoutId = null;
776
+ let resolveFn = null;
777
+ const incrementalPlainText =
778
+ extra.plainText === true &&
779
+ direction === 1 &&
780
+ options.direction === 'left' &&
781
+ !isScramble &&
782
+ options.typeStyle !== 'fade-trail' &&
783
+ options.trailFade === undefined &&
784
+ options.trailingFadeLength === undefined;
785
+ // Plain-text source trees always contain one text node, including empty input.
786
+ const plainUnits = incrementalPlainText
787
+ ? segmentNodeText(sourceTree.firstChild, granularity)
788
+ : null;
789
+ const incrementalTextNode = plainUnits ? document.createTextNode('') : null;
790
+ let incrementalCursor = null;
791
+ let lastIncrementalCount = 0;
792
+
793
+ const clearTimers = () => {
794
+ if (timeoutId !== null) clearTimeout(timeoutId);
795
+ this.clearCursorInterval(el);
796
+ timeoutId = null;
797
+ };
798
+
799
+ const render = (unitCount, cursorChar, scrambleActive = false, cursorHidden = false) => {
800
+ if (incrementalTextNode && !scrambleActive) {
801
+ if (unitCount === lastIncrementalCount + 1)
802
+ incrementalTextNode.data += plainUnits[lastIncrementalCount] ?? '';
803
+ else if (unitCount !== lastIncrementalCount)
804
+ incrementalTextNode.data = plainUnits.slice(0, unitCount).join('');
805
+ lastIncrementalCount = unitCount;
806
+ if (!incrementalTextNode.parentNode) el.replaceChildren(...prefix, incrementalTextNode);
807
+ if (cursorChar) {
808
+ if (!incrementalCursor) incrementalCursor = createCursorSpan(cursorChar, cursorHidden);
809
+ incrementalCursor.textContent = cursorChar;
810
+ incrementalCursor.style.visibility = cursorHidden ? 'hidden' : 'visible';
811
+ if (!incrementalCursor.parentNode) el.appendChild(incrementalCursor);
812
+ } else if (incrementalCursor?.parentNode) {
813
+ incrementalCursor.remove();
814
+ }
815
+ return;
816
+ }
817
+ const bounds = calculateWindowBounds(
818
+ unitCount,
819
+ totalUnits,
820
+ options.direction ?? 'left',
821
+ options.eraseStyle ?? 'end',
822
+ extra.isErasing ?? false
823
+ );
824
+ const isTrailFade =
825
+ (options.trailFade !== null && options.trailFade !== undefined) ||
826
+ (options.trailingFadeLength !== null && options.trailingFadeLength !== undefined) ||
827
+ options.typeStyle === 'fade-trail' ||
828
+ options.eraseStyle === 'fade-trail';
829
+ const trailLen =
830
+ typeof options.trailFade === 'number'
831
+ ? options.trailFade
832
+ : typeof options.trailingFadeLength === 'number'
833
+ ? options.trailingFadeLength
834
+ : isTrailFade
835
+ ? (options.trailingFadeLength ?? 4)
836
+ : 0;
837
+ const trailMinOpacity =
838
+ typeof options.trailingFadeMinOpacity === 'number'
839
+ ? options.trailingFadeMinOpacity
840
+ : typeof options.trailMinOpacity === 'number'
841
+ ? options.trailMinOpacity
842
+ : 0.18;
843
+
844
+ el.replaceChildren(
845
+ ...prefix,
846
+ revealWindowFragment(sourceTree, bounds.start, bounds.end, granularity, {
847
+ scrambleActive,
848
+ scrambleGlyphs,
849
+ scrambleColor: options.scrambleColor,
850
+ scrambleGradient: options.scrambleGradient,
851
+ trailFade: trailLen,
852
+ trailingFadeLength: trailLen,
853
+ trailingFadeMinOpacity: trailMinOpacity,
854
+ isComplete: unitCount === endCount && !scrambleActive,
855
+ })
856
+ );
857
+ if (cursorChar) {
858
+ el.appendChild(createCursorSpan(cursorChar, cursorHidden));
859
+ }
860
+ };
861
+
862
+ const settle = (completed) => {
863
+ if (settled) return;
864
+ settled = true;
865
+ clearTimers();
866
+ this.active.delete(el);
867
+ if (completed) {
868
+ if (blinkOnFinish && options.cursor && options.cursorBlink) {
869
+ let visible = true;
870
+ render(endCount, options.cursor, false, false);
871
+ const intervalId = setInterval(() => {
872
+ visible = !visible;
873
+ render(endCount, options.cursor, false, !visible);
874
+ }, CURSOR_BLINK_INTERVAL);
875
+ intervalId.unref?.();
876
+ this.cursorIntervals.set(el, intervalId);
877
+ } else {
878
+ render(endCount, '', false);
879
+ }
880
+ options.onComplete?.(resultText);
881
+ } else {
882
+ this.clearCursorInterval(el);
883
+ stopKeystrokeSound(options.audio);
884
+ options.onInterrupt?.();
885
+ }
886
+ resolveFn?.({ completed, text: resultText });
887
+ };
888
+
889
+ const controller = {
890
+ stop: () => settle(false),
891
+ promise: new Promise((resolve) => {
892
+ resolveFn = resolve;
893
+ }),
894
+ };
895
+ this.active.set(el, controller);
896
+
897
+ const shouldReduceMotion = options.respectReducedMotion === true && prefersReducedMotion();
898
+
899
+ if (shouldReduceMotion) {
900
+ settle(true);
901
+ return controller;
902
+ }
903
+
904
+ let scrambleStep = 0;
905
+ const scrambleRounds = options.scrambleRounds ?? TYPE_DEFAULTS.scrambleRounds;
906
+
907
+ const step = (count) => {
908
+ if (options.audio) playKeystrokeSound(options.audio);
909
+
910
+ if (isScramble && scrambleStep < scrambleRounds && count !== endCount) {
911
+ render(count, options.cursor ?? '', true);
912
+ scrambleStep++;
913
+ timeoutId = setTimeout(() => step(count), Math.max(10, speed / 2));
914
+ return;
915
+ }
916
+ scrambleStep = 0;
917
+
918
+ render(count, options.cursor ?? '', false);
919
+ if (count === endCount) {
920
+ settle(true);
921
+ return;
922
+ }
923
+
924
+ const jitter = options.speedVariance ? (Math.random() * 2 - 1) * options.speedVariance : 0;
925
+ const cadencePause = options.naturalCadence ? calculateCadencePause(resultText, count) : 0;
926
+ const delayMs = Math.max(0, speed + jitter + cadencePause);
927
+
928
+ timeoutId = setTimeout(() => step(count + direction), delayMs);
929
+ };
930
+
931
+ options.onStart?.();
932
+ timeoutId = setTimeout(() => step(startCount), options.delay ?? 0);
933
+ return controller;
934
+ }
935
+
936
+ animateWordMotion(el, sourceTree, options, extra = {}) {
937
+ this.clearCursorInterval(el);
938
+ this.active.get(el)?.stop();
939
+ if (options.ariaLive) el.setAttribute('aria-live', options.ariaLive);
940
+
941
+ const prefix = extra.prefix ?? [];
942
+ const isErasing = extra.isErasing === true;
943
+ const motionKind = extra.motionKind ?? 'word-fade';
944
+ const layout = options.wordLayout === 'placeholder' ? 'placeholder' : 'opacity';
945
+ const duration = Math.max(
946
+ 0,
947
+ Number(options.wordFadeDuration ?? DEFAULT_WORD_FADE_DURATION) || 0
948
+ );
949
+ const stagger = Math.max(
950
+ 0,
951
+ Number(options.wordStagger ?? options.speed ?? DEFAULT_WORD_STAGGER) || 0
952
+ );
953
+ const transforms = getWordMotionTransforms(motionKind, options);
954
+ const needsPerspective = motionKind === 'zoom-in-space';
955
+ const { fragment, words } = buildWordMotionTree(sourceTree, layout, motionKind);
956
+ const order = orderWordIndexes(words.length, options.wordOrder, options.wordSeed);
957
+ order.forEach((wordIndex, sequenceIndex) => {
958
+ words[wordIndex].dataset.tfWordOrder = String(sequenceIndex);
959
+ });
960
+ const timers = new Set();
961
+ let frameId = null;
962
+ let settled = false;
963
+ let resolveFn = null;
964
+
965
+ const easing =
966
+ typeof options.wordMotionEasing === 'string' && options.wordMotionEasing.trim()
967
+ ? options.wordMotionEasing.trim()
968
+ : DEFAULT_MOTION_EASING;
969
+
970
+ words.forEach((word) => {
971
+ word.style.transition = transforms
972
+ ? `opacity ${duration}ms ${easing}, transform ${duration}ms ${easing}`
973
+ : `opacity ${duration}ms ${easing}`;
974
+ if (transforms) word.style.transform = transforms.hidden;
975
+ if (isErasing) {
976
+ word.style.opacity = '1';
977
+ word.style.visibility = 'visible';
978
+ word.removeAttribute('aria-hidden');
979
+ if (transforms) word.style.transform = transforms.visible;
980
+ }
981
+ });
982
+ if (needsPerspective) {
983
+ el.style.perspective = `${Math.max(0, Number(options.wordMotionPerspective ?? DEFAULT_MOTION_PERSPECTIVE) || 0)}px`;
984
+ el.style.transformStyle = 'preserve-3d';
985
+ }
986
+ el.replaceChildren(...prefix, fragment);
987
+ el.setAttribute('aria-busy', 'true');
988
+
989
+ const schedule = (callback, delay) => {
990
+ const id = setTimeout(() => {
991
+ timers.delete(id);
992
+ callback();
993
+ }, delay);
994
+ timers.add(id);
995
+ };
996
+ const clearScheduledWork = () => {
997
+ timers.forEach((id) => clearTimeout(id));
998
+ timers.clear();
999
+ if (frameId !== null && typeof cancelAnimationFrame === 'function')
1000
+ cancelAnimationFrame(frameId);
1001
+ frameId = null;
1002
+ };
1003
+ const appendCursor = () => {
1004
+ if (!options.cursor) return;
1005
+ const cursor = createCursorSpan(options.cursor);
1006
+ el.appendChild(cursor);
1007
+ if (options.cursorBlink) {
1008
+ let visible = true;
1009
+ const intervalId = setInterval(() => {
1010
+ visible = !visible;
1011
+ cursor.style.visibility = visible ? 'visible' : 'hidden';
1012
+ }, CURSOR_BLINK_INTERVAL);
1013
+ intervalId.unref?.();
1014
+ this.cursorIntervals.set(el, intervalId);
1015
+ }
1016
+ };
1017
+ const settle = (completed) => {
1018
+ if (settled) return;
1019
+ settled = true;
1020
+ clearScheduledWork();
1021
+ this.active.delete(el);
1022
+ el.removeAttribute('aria-busy');
1023
+ if (needsPerspective) {
1024
+ el.style.removeProperty('perspective');
1025
+ el.style.removeProperty('transform-style');
1026
+ }
1027
+
1028
+ if (completed) {
1029
+ if (isErasing) {
1030
+ el.replaceChildren(...prefix);
1031
+ options.onComplete?.('');
1032
+ } else {
1033
+ el.replaceChildren(...prefix, sourceTree.cloneNode(true));
1034
+ appendCursor();
1035
+ options.onComplete?.(extra.resultText ?? '');
1036
+ }
1037
+ } else {
1038
+ // Word-motion lays out the complete source before animating it, so an
1039
+ // interrupted operation can restore semantic source nodes without
1040
+ // losing partially constructed text or retaining presentation spans.
1041
+ el.replaceChildren(...prefix, sourceTree.cloneNode(true));
1042
+ stopKeystrokeSound(options.audio);
1043
+ options.onInterrupt?.();
1044
+ }
1045
+ resolveFn?.({ completed, text: isErasing ? '' : (extra.resultText ?? '') });
1046
+ };
1047
+ const controller = {
1048
+ stop: () => settle(false),
1049
+ promise: new Promise((resolve) => {
1050
+ resolveFn = resolve;
1051
+ }),
1052
+ };
1053
+ this.active.set(el, controller);
1054
+
1055
+ if (options.respectReducedMotion === true && prefersReducedMotion()) {
1056
+ settle(true);
1057
+ return controller;
1058
+ }
1059
+
1060
+ options.onStart?.();
1061
+ const begin = () => {
1062
+ frameId = requestAnimationFrame(() => {
1063
+ frameId = null;
1064
+ if (words.length === 0) {
1065
+ settle(true);
1066
+ return;
1067
+ }
1068
+ let sequenceIndex = 0;
1069
+ const revealNextWord = () => {
1070
+ const word = words[order[sequenceIndex]];
1071
+ if (options.audio) playKeystrokeSound(options.audio);
1072
+ if (isErasing) {
1073
+ word.style.opacity = '0';
1074
+ if (transforms) word.style.transform = transforms.hidden;
1075
+ word.setAttribute('aria-hidden', 'true');
1076
+ } else {
1077
+ word.style.visibility = 'visible';
1078
+ word.removeAttribute('aria-hidden');
1079
+ word.style.opacity = '1';
1080
+ if (transforms) word.style.transform = transforms.visible;
1081
+ }
1082
+
1083
+ sequenceIndex += 1;
1084
+ if (sequenceIndex < words.length) schedule(revealNextWord, stagger);
1085
+ else schedule(() => settle(true), duration);
1086
+ };
1087
+ revealNextWord();
1088
+ });
1089
+ };
1090
+
1091
+ schedule(begin, Math.max(0, Number(options.delay) || 0));
1092
+ return controller;
1093
+ }
1094
+
1095
+ eraseFade(el, erasableLive, baseline, options) {
1096
+ this.clearCursorInterval(el);
1097
+ this.active.get(el)?.stop();
1098
+
1099
+ const wrapper = document.createElement('span');
1100
+ erasableLive.forEach((n) => wrapper.appendChild(n));
1101
+ el.replaceChildren(...baseline, wrapper);
1102
+
1103
+ const duration = options.duration ?? DEFAULT_FADE_DURATION;
1104
+ let settled = false;
1105
+ let timeoutId = null;
1106
+ let resolveFn = null;
1107
+
1108
+ const settle = (completed) => {
1109
+ if (settled) return;
1110
+ settled = true;
1111
+ if (timeoutId !== null) clearTimeout(timeoutId);
1112
+ this.clearCursorInterval(el);
1113
+ this.active.delete(el);
1114
+ if (completed) {
1115
+ el.replaceChildren(...baseline);
1116
+ options.onComplete?.('');
1117
+ } else {
1118
+ stopKeystrokeSound(options.audio);
1119
+ options.onInterrupt?.();
1120
+ }
1121
+ resolveFn?.({ completed, text: '' });
1122
+ };
1123
+
1124
+ const controller = {
1125
+ stop: () => settle(false),
1126
+ promise: new Promise((resolve) => {
1127
+ resolveFn = resolve;
1128
+ }),
1129
+ };
1130
+ this.active.set(el, controller);
1131
+
1132
+ if (options.respectReducedMotion && prefersReducedMotion()) {
1133
+ settle(true);
1134
+ return controller;
1135
+ }
1136
+
1137
+ requestAnimationFrame(() => {
1138
+ wrapper.style.transition = `opacity ${duration}ms ease`;
1139
+ wrapper.style.opacity = '0';
1140
+ });
1141
+ timeoutId = setTimeout(() => settle(true), duration);
1142
+ return controller;
1143
+ }
1144
+
1145
+ instantClear(el, baseline, options) {
1146
+ this.clearCursorInterval(el);
1147
+ this.active.get(el)?.stop();
1148
+ el.replaceChildren(...baseline);
1149
+ options.onComplete?.('');
1150
+ return { stop: () => {}, promise: Promise.resolve({ completed: true, text: '' }) };
1151
+ }
1152
+
1153
+ /**
1154
+ * Types text or sanitized HTML into the target element character by character
1155
+ * (or word by word when `typeStyle: 'word'`).
1156
+ *
1157
+ * Any animation currently running on `target` is stopped before the new one
1158
+ * starts. If `prefers-reduced-motion: reduce` is active and
1159
+ * `respectReducedMotion` is `true` (the default), the full text is rendered
1160
+ * immediately without animation.
1161
+ *
1162
+ * @param {Element | string} target
1163
+ * A DOM element or a CSS selector string resolved via `document.querySelector`.
1164
+ * @param {string} text
1165
+ * The text or HTML string to type. When `config.html` is `false` (default)
1166
+ * the string is treated as plain text and no parsing is performed.
1167
+ * @param {Partial<TypeFlowConfig>} [config={}]
1168
+ * Optional configuration overrides. See {@link TypeFlowConfig} for the full
1169
+ * option set. Merged with {@link TYPE_DEFAULTS}.
1170
+ * @returns {TypeFlowController}
1171
+ * A controller object with a `stop()` method and a `promise` that resolves
1172
+ * to `{ completed: boolean, text: string }`.
1173
+ *
1174
+ * @example
1175
+ * const controller = TypeFlow.type('#headline', 'Hello <strong>World</strong>', {
1176
+ * html: true,
1177
+ * speed: 45,
1178
+ * cursor: '|',
1179
+ * cursorBlink: true,
1180
+ * onComplete: (text) => console.log('Done:', text),
1181
+ * });
1182
+ *
1183
+ * // Abort mid-animation
1184
+ * button.addEventListener('click', () => controller.stop());
1185
+ */
1186
+ type(target, text, config = {}) {
1187
+ const el = resolveTarget(target);
1188
+ const options = { ...TYPE_DEFAULTS, ...config };
1189
+ if (Object.prototype.hasOwnProperty.call(config, 'scrambleGlyphs')) {
1190
+ options.scrambleCharset = undefined;
1191
+ }
1192
+ options.direction = normalizeDirection(options.direction);
1193
+ options.typeStyle = normalizeTypeStyle(options.typeStyle);
1194
+ if (options.typeStyle === 'word') options.granularity = 'word';
1195
+
1196
+ const allowedTags = new Set([...DEFAULT_ALLOWED_TAGS, ...(options.allowedTags ?? [])]);
1197
+ const allowedAttrs = { ...DEFAULT_ALLOWED_ATTRS };
1198
+ if (options.allowedAttributes) {
1199
+ for (const [tag, attrs] of Object.entries(options.allowedAttributes)) {
1200
+ allowedAttrs[tag] = new Set([...(allowedAttrs[tag] ?? []), ...attrs]);
1201
+ }
1202
+ }
1203
+
1204
+ let sourceText = text === null ? '' : text === undefined ? '' : String(text);
1205
+ if (options.html && options.sanitizer) sourceText = options.sanitizer(sourceText);
1206
+ const sourceTree = buildSourceTree(sourceText, !options.html, allowedTags, allowedAttrs);
1207
+ const totalUnits = countTextUnits(sourceTree, options.granularity);
1208
+
1209
+ const prefix = options.mode === 'append' ? Array.from(el.childNodes) : [];
1210
+ if (options.mode === 'append') this.baselines.set(el, prefix);
1211
+ else this.baselines.delete(el);
1212
+
1213
+ if (WORD_MOTION_STYLES.has(options.typeStyle)) {
1214
+ return this.animateWordMotion(el, sourceTree, options, {
1215
+ prefix,
1216
+ resultText: sourceText,
1217
+ isErasing: false,
1218
+ motionKind: options.typeStyle,
1219
+ });
1220
+ }
1221
+
1222
+ return this.animate(el, sourceTree, totalUnits, options, 0, totalUnits, sourceText, {
1223
+ prefix,
1224
+ blinkOnFinish: true,
1225
+ isErasing: false,
1226
+ plainText: !options.html,
1227
+ });
1228
+ }
1229
+
1230
+ erase(target, config = {}) {
1231
+ const el = resolveTarget(target);
1232
+ const options = { ...ERASE_DEFAULTS, ...config };
1233
+ if (Object.prototype.hasOwnProperty.call(config, 'scrambleGlyphs')) {
1234
+ options.scrambleCharset = undefined;
1235
+ }
1236
+ options.eraseStyle = normalizeEraseStyle(options.eraseStyle);
1237
+
1238
+ const baseline = options.preserveBaseline ? (this.baselines.get(el) ?? []) : [];
1239
+ const allChildren = Array.from(el.childNodes);
1240
+ const erasableLive = baseline.length
1241
+ ? allChildren.filter((n) => !baseline.includes(n))
1242
+ : allChildren;
1243
+
1244
+ if (options.eraseStyle === 'instant') return this.instantClear(el, baseline, options);
1245
+ if (options.eraseStyle === 'fade') return this.eraseFade(el, erasableLive, baseline, options);
1246
+
1247
+ const sourceTree = document.createDocumentFragment();
1248
+ erasableLive.forEach((n) => sourceTree.appendChild(n.cloneNode(true)));
1249
+ const totalUnits = countTextUnits(sourceTree, options.granularity);
1250
+
1251
+ if (WORD_MOTION_STYLES.has(options.eraseStyle)) {
1252
+ return this.animateWordMotion(el, sourceTree, options, {
1253
+ prefix: baseline,
1254
+ isErasing: true,
1255
+ motionKind: options.eraseStyle,
1256
+ });
1257
+ }
1258
+
1259
+ return this.animate(el, sourceTree, totalUnits, options, totalUnits, 0, '', {
1260
+ prefix: baseline,
1261
+ isErasing: true,
1262
+ });
1263
+ }
1264
+
1265
+ /**
1266
+ * Smart Diff Morphing: compares the existing text content of `target` with `nextText`,
1267
+ * erases only the mismatched trailing characters, and types the replacement text.
1268
+ *
1269
+ * @param {Element | string} target - Target element or CSS selector string.
1270
+ * @param {string} nextText - Destination text.
1271
+ * @param {Partial<TypeFlowConfig>} [options={}] - Animation options.
1272
+ * @returns {Promise<{ completed: boolean, text: string }>}
1273
+ */
1274
+ morph(target, nextText, options = {}) {
1275
+ const el = resolveTarget(target);
1276
+ const currentText = el.textContent ?? '';
1277
+ const targetText = nextText === null ? '' : nextText === undefined ? '' : String(nextText);
1278
+
1279
+ let prefixLen = 0;
1280
+ const max = Math.min(currentText.length, targetText.length);
1281
+ while (prefixLen < max && currentText[prefixLen] === targetText[prefixLen]) {
1282
+ prefixLen++;
1283
+ }
1284
+
1285
+ const eraseCount = currentText.length - prefixLen;
1286
+ const appendText = targetText.slice(prefixLen);
1287
+
1288
+ const steps = [];
1289
+ if (eraseCount > 0) {
1290
+ steps.push(
1291
+ seq.call(() => {
1292
+ const sourceTree = document.createDocumentFragment();
1293
+ sourceTree.appendChild(document.createTextNode(currentText));
1294
+ const totalUnits = currentText.length;
1295
+ return this.animate(
1296
+ el,
1297
+ sourceTree,
1298
+ totalUnits,
1299
+ {
1300
+ ...ERASE_DEFAULTS,
1301
+ ...options,
1302
+ speed: options.eraseSpeed ?? options.speed ?? ERASE_DEFAULTS.speed,
1303
+ },
1304
+ totalUnits,
1305
+ prefixLen,
1306
+ currentText.slice(0, prefixLen),
1307
+ { prefix: [], isErasing: true }
1308
+ ).promise;
1309
+ })
1310
+ );
1311
+ }
1312
+ if (appendText.length > 0) {
1313
+ steps.push(
1314
+ seq.type(el, appendText, {
1315
+ ...options,
1316
+ mode: 'append',
1317
+ })
1318
+ );
1319
+ }
1320
+
1321
+ return this.sequence(steps);
1322
+ }
1323
+
1324
+ /**
1325
+ * Creates a streaming chunk consumer for real-time LLM token ingestion.
1326
+ *
1327
+ * @param {Element | string} target - Target element or CSS selector.
1328
+ * @param {Partial<TypeFlowConfig> & { onChunk?: (chunk: string, fullText: string) => void }} [options={}]
1329
+ * @returns {{ push: (chunk: string) => void, stop: () => void, clear: () => void }}
1330
+ */
1331
+ stream(target, options = {}) {
1332
+ const el = resolveTarget(target);
1333
+ let queue = [];
1334
+ let isProcessing = false;
1335
+
1336
+ const processNext = () => {
1337
+ if (queue.length === 0) {
1338
+ isProcessing = false;
1339
+ return;
1340
+ }
1341
+ isProcessing = true;
1342
+ const chunk = queue.shift();
1343
+ this.type(el, chunk, {
1344
+ ...options,
1345
+ mode: 'append',
1346
+ onComplete: (text) => {
1347
+ options.onChunk?.(chunk, text);
1348
+ processNext();
1349
+ },
1350
+ });
1351
+ };
1352
+
1353
+ return {
1354
+ push: (chunk) => {
1355
+ if (chunk !== null && chunk !== undefined && String(chunk).length > 0) {
1356
+ queue.push(String(chunk));
1357
+ if (!isProcessing) processNext();
1358
+ }
1359
+ },
1360
+ stop: () => {
1361
+ queue = [];
1362
+ this.stop(el);
1363
+ isProcessing = false;
1364
+ },
1365
+ clear: () => {
1366
+ queue = [];
1367
+ this.stop(el);
1368
+ isProcessing = false;
1369
+ el.replaceChildren();
1370
+ },
1371
+ };
1372
+ }
1373
+
1374
+ /**
1375
+ * Returns a complete pre-configured options object for common typewriter profiles.
1376
+ *
1377
+ * @param {'cyberpunk' | 'terminal' | 'writer' | 'matrix' | 'blocks' | 'subtle'} name
1378
+ * @param {Partial<TypeFlowConfig>} [overrides={}]
1379
+ * @returns {TypeFlowConfig}
1380
+ */
1381
+ preset(name, overrides = {}) {
1382
+ const base = PRESETS[name] ?? PRESETS.writer;
1383
+ return { ...base, ...overrides };
1384
+ }
1385
+
1386
+ /**
1387
+ * Executes an ordered list of animation steps sequentially, waiting for each
1388
+ * step to complete before advancing to the next one.
1389
+ *
1390
+ * Supported step kinds (created via the {@link seq} helper):
1391
+ * - `seq.type(target, text, config)` — Types text into a target element.
1392
+ * - `seq.erase(target, config)` — Erases content from a target element.
1393
+ * - `seq.pause(duration)` — Waits `duration` milliseconds.
1394
+ * - `seq.call(fn)` — Invokes an async or sync function.
1395
+ * - `seq.visible(target, threshold)` — Waits until the target is visible.
1396
+ *
1397
+ * If any step's animation is interrupted (via `controller.stop()`), the
1398
+ * entire sequence halts immediately.
1399
+ *
1400
+ * @param {Array<SequenceStep>} steps
1401
+ * An ordered array of step descriptors. Use the {@link seq} helper to
1402
+ * construct them.
1403
+ * @param {{ repeat?: number, target?: Element | string }} [seqConfig={}]
1404
+ * Optional sequence-level configuration.
1405
+ * - `repeat` — Number of times to repeat the full sequence. Use `Infinity`
1406
+ * for indefinite repetition. Defaults to `1`.
1407
+ * - `target` — A default target element applied to steps that do not
1408
+ * specify one individually.
1409
+ * @returns {TypeFlowController}
1410
+ * A controller with `stop()` and a `promise` resolving to
1411
+ * `{ completed: boolean, text: '' }`.
1412
+ *
1413
+ * @example
1414
+ * import { TypeFlow, seq } from '@staticcanvas/typeflow';
1415
+ *
1416
+ * TypeFlow.sequence([
1417
+ * seq.type('#output', 'Step 1 complete.'),
1418
+ * seq.pause(600),
1419
+ * seq.call(() => triggerSoundEffect()),
1420
+ * seq.erase('#output'),
1421
+ * seq.type('#output', 'Step 2 starting...'),
1422
+ * ], { repeat: 1 });
1423
+ */
1424
+ sequence(steps, seqConfig = {}) {
1425
+ if (!Array.isArray(steps) || steps.length === 0) {
1426
+ return { stop: () => {}, promise: Promise.resolve({ completed: true, text: '' }) };
1427
+ }
1428
+
1429
+ let isInterrupted = false;
1430
+ let currentSub = null;
1431
+ let cancelPause = null;
1432
+ let cancelObserver = null;
1433
+
1434
+ const resolveStepTarget = (step) => {
1435
+ const target = step.target ?? seqConfig.target;
1436
+ if (!target) {
1437
+ throw new Error(
1438
+ 'TypeFlow: a sequence step is missing a target, and no default `target` was set on the sequence config.'
1439
+ );
1440
+ }
1441
+ return target;
1442
+ };
1443
+
1444
+ const controller = {
1445
+ stop: () => {
1446
+ if (isInterrupted) return;
1447
+ isInterrupted = true;
1448
+ currentSub?.stop();
1449
+ cancelPause?.();
1450
+ cancelObserver?.();
1451
+ },
1452
+ promise: null,
1453
+ };
1454
+
1455
+ const runOnce = async () => {
1456
+ for (const step of steps) {
1457
+ if (isInterrupted) return false;
1458
+
1459
+ if (step.kind === 'type') {
1460
+ currentSub = this.type(resolveStepTarget(step), step.text, step.config);
1461
+ if (!(await currentSub.promise).completed) return false;
1462
+ } else if (step.kind === 'erase') {
1463
+ currentSub = this.erase(resolveStepTarget(step), step.config);
1464
+ if (!(await currentSub.promise).completed) return false;
1465
+ } else if (step.kind === 'pause') {
1466
+ const finishedNaturally = await new Promise((resolve) => {
1467
+ const id = setTimeout(() => resolve(true), step.duration);
1468
+ cancelPause = () => {
1469
+ clearTimeout(id);
1470
+ resolve(false);
1471
+ };
1472
+ });
1473
+ cancelPause = null;
1474
+ if (!finishedNaturally) return false;
1475
+ } else if (step.kind === 'visible') {
1476
+ const targetEl = resolveTarget(resolveStepTarget(step));
1477
+ const isVisible = await new Promise((resolve) => {
1478
+ if (typeof IntersectionObserver === 'undefined') {
1479
+ resolve(true);
1480
+ return;
1481
+ }
1482
+ const observer = new IntersectionObserver(
1483
+ (entries) => {
1484
+ if (entries.some((e) => e.isIntersecting)) {
1485
+ observer.disconnect();
1486
+ resolve(true);
1487
+ }
1488
+ },
1489
+ { threshold: step.threshold ?? 0.1 }
1490
+ );
1491
+ cancelObserver = () => {
1492
+ observer.disconnect();
1493
+ resolve(false);
1494
+ };
1495
+ observer.observe(targetEl);
1496
+ });
1497
+ cancelObserver = null;
1498
+ if (!isVisible) return false;
1499
+ } else if (step.kind === 'call') {
1500
+ try {
1501
+ await step.fn();
1502
+ } catch (err) {
1503
+ console.error('TypeFlow: error in seq.call step:', err);
1504
+ }
1505
+ }
1506
+
1507
+ if (isInterrupted) return false;
1508
+ }
1509
+ return true;
1510
+ };
1511
+
1512
+ controller.promise = (async () => {
1513
+ const repeat = seqConfig.repeat ?? 1;
1514
+ for (let i = 0; repeat === Infinity || i < repeat; i++) {
1515
+ if (!(await runOnce())) return { completed: false, text: '' };
1516
+ }
1517
+ return { completed: true, text: '' };
1518
+ })();
1519
+
1520
+ return controller;
1521
+ }
1522
+
1523
+ /**
1524
+ * Cyclically types and erases an array of strings in sequence.
1525
+ *
1526
+ * Internally delegates to {@link TypeFlowEngine#sequence}, constructing a
1527
+ * step list of: `type → pause (holdDuration) → erase → pause (pauseBetween)`
1528
+ * for each word.
1529
+ *
1530
+ * @param {Element | string} target
1531
+ * A DOM element or a CSS selector string.
1532
+ * @param {string[]} words
1533
+ * An ordered array of strings to rotate through.
1534
+ * @param {RotateConfig} [config={}]
1535
+ * Optional rotation configuration.
1536
+ * @param {Partial<TypeFlowConfig>} [config.typeConfig={}]
1537
+ * Config options applied to every `type` step.
1538
+ * @param {Partial<TypeFlowConfig>} [config.eraseConfig={}]
1539
+ * Config options applied to every `erase` step.
1540
+ * @param {number} [config.holdDuration=1200]
1541
+ * Milliseconds to hold each fully-typed word before erasing.
1542
+ * @param {number} [config.pauseBetween=200]
1543
+ * Milliseconds to wait after erasing before typing the next word.
1544
+ * @param {number | typeof Infinity} [config.repeat=Infinity]
1545
+ * Number of full rotation cycles. Use `Infinity` for indefinite rotation.
1546
+ * @returns {TypeFlowController}
1547
+ * A controller with `stop()` and a resolving `promise`.
1548
+ *
1549
+ * @example
1550
+ * TypeFlow.rotate('#rotating', ['Fast', 'Lightweight', 'Reliable'], {
1551
+ * holdDuration: 1200,
1552
+ * pauseBetween: 200,
1553
+ * repeat: Infinity,
1554
+ * });
1555
+ */
1556
+ rotate(target, words, config = {}) {
1557
+ const {
1558
+ typeConfig = {},
1559
+ eraseConfig = {},
1560
+ holdDuration = 1200,
1561
+ pauseBetween = 200,
1562
+ repeat = Infinity,
1563
+ } = config;
1564
+
1565
+ const steps = [];
1566
+ words.forEach((word) => {
1567
+ steps.push({ kind: 'type', target, text: word, config: typeConfig });
1568
+ steps.push({ kind: 'pause', duration: holdDuration });
1569
+ steps.push({ kind: 'erase', target, config: eraseConfig });
1570
+ steps.push({ kind: 'pause', duration: pauseBetween });
1571
+ });
1572
+
1573
+ return this.sequence(steps, { repeat });
1574
+ }
1575
+
1576
+ /**
1577
+ * Staggers typing across multiple DOM elements with an offset delay between each item.
1578
+ *
1579
+ * @param {Element[] | NodeList | string} targets
1580
+ * A CSS selector string matching multiple elements, or an array / NodeList of elements.
1581
+ * @param {string | string[] | ((el: Element, index: number) => string)} [textOrTexts]
1582
+ * A string, an array of strings (one per target), or a resolver callback.
1583
+ * If omitted, reads each element's existing textContent.
1584
+ * @param {Partial<TypeFlowConfig> & { stagger?: number }} [config={}]
1585
+ * TypeFlow options merged with `stagger` offset delay in ms (default: `150`).
1586
+ * @returns {TypeFlowController}
1587
+ *
1588
+ * @example
1589
+ * TypeFlow.stagger('.prompt-line', [
1590
+ * 'npm install @staticcanvas/typeflow',
1591
+ * 'npm run build',
1592
+ * 'typeflow start --port=3000',
1593
+ * 'Ready on http://localhost:3000'
1594
+ * ], {
1595
+ * speed: 30,
1596
+ * stagger: 250,
1597
+ * cursor: '|'
1598
+ * });
1599
+ */
1600
+ stagger(targets, textOrTexts, config = {}) {
1601
+ let elements = [];
1602
+ if (typeof targets === 'string') {
1603
+ elements =
1604
+ typeof document !== 'undefined' ? Array.from(document.querySelectorAll(targets)) : [];
1605
+ } else if (Array.isArray(targets)) {
1606
+ elements = targets
1607
+ .map((t) => {
1608
+ try {
1609
+ return resolveTarget(t);
1610
+ } catch {
1611
+ return null;
1612
+ }
1613
+ })
1614
+ .filter(Boolean);
1615
+ } else if (targets && typeof targets[Symbol.iterator] === 'function') {
1616
+ elements = Array.from(targets)
1617
+ .map((t) => {
1618
+ try {
1619
+ return resolveTarget(t);
1620
+ } catch {
1621
+ return null;
1622
+ }
1623
+ })
1624
+ .filter(Boolean);
1625
+ } else {
1626
+ try {
1627
+ const single = resolveTarget(targets);
1628
+ if (single) elements = [single];
1629
+ } catch {
1630
+ // Ignore invalid targets while resolving a stagger collection.
1631
+ }
1632
+ }
1633
+
1634
+ if (elements.length === 0) {
1635
+ return { stop: () => {}, promise: Promise.resolve({ completed: true, text: '' }) };
1636
+ }
1637
+
1638
+ const staggerDelay = config.stagger ?? 150;
1639
+ const controllers = [];
1640
+ let stopped = false;
1641
+
1642
+ const promises = elements.map((el, index) => {
1643
+ let text = '';
1644
+ if (typeof textOrTexts === 'function') {
1645
+ text = textOrTexts(el, index);
1646
+ } else if (Array.isArray(textOrTexts)) {
1647
+ text = textOrTexts[index] !== undefined ? textOrTexts[index] : '';
1648
+ } else if (typeof textOrTexts === 'string') {
1649
+ text = textOrTexts;
1650
+ } else {
1651
+ text = el.textContent || '';
1652
+ }
1653
+
1654
+ const itemDelay = (config.delay ?? 0) + index * staggerDelay;
1655
+ const itemConfig = { ...config, delay: itemDelay };
1656
+ const ctrl = this.type(el, text, itemConfig);
1657
+ controllers.push(ctrl);
1658
+ return ctrl.promise;
1659
+ });
1660
+
1661
+ const combinedPromise = Promise.all(promises).then((results) => {
1662
+ const allCompleted = results.every((r) => r.completed);
1663
+ return {
1664
+ completed: !stopped && allCompleted,
1665
+ text: results.map((r) => r.text).join('\n'),
1666
+ };
1667
+ });
1668
+
1669
+ return {
1670
+ stop: () => {
1671
+ stopped = true;
1672
+ controllers.forEach((c) => c.stop());
1673
+ },
1674
+ promise: combinedPromise,
1675
+ };
1676
+ }
1677
+
1678
+ /**
1679
+ * Observes a DOM subtree for mutations and invokes `callback` whenever
1680
+ * elements matching `childSelector` are added to the tree. Also invokes the
1681
+ * callback immediately for any pre-existing matching elements inside
1682
+ * `container`.
1683
+ *
1684
+ * Useful for animating dynamically injected content (e.g. chat messages,
1685
+ * feed items, server-streamed responses) without polling.
1686
+ *
1687
+ * @param {Element | string} container
1688
+ * The root element whose subtree is observed.
1689
+ * @param {string} childSelector
1690
+ * A CSS selector string. Newly added elements matching this selector
1691
+ * will trigger `callback`.
1692
+ * @param {(el: Element) => void} callback
1693
+ * Function invoked with each matching element. Runs synchronously for
1694
+ * pre-existing matches and on the microtask after each mutation.
1695
+ * @returns {{ disconnect: () => void }}
1696
+ * An object with a `disconnect()` method that stops observation.
1697
+ *
1698
+ * @example
1699
+ * const watcher = TypeFlow.watch('#feed', '.item-title', (el) => {
1700
+ * const text = el.textContent;
1701
+ * el.textContent = '';
1702
+ * TypeFlow.type(el, text, { speed: 20 });
1703
+ * });
1704
+ *
1705
+ * // Stop observing when the container is destroyed
1706
+ * watcher.disconnect();
1707
+ */
1708
+ watch(container, childSelector, callback) {
1709
+ const root = resolveTarget(container);
1710
+ root.querySelectorAll(childSelector).forEach(callback);
1711
+
1712
+ const observer = new MutationObserver((mutations) => {
1713
+ for (const mutation of mutations) {
1714
+ mutation.addedNodes.forEach((node) => {
1715
+ if (node.nodeType !== Node.ELEMENT_NODE) return;
1716
+ const el = node;
1717
+ if (el.matches(childSelector)) callback(el);
1718
+ el.querySelectorAll(childSelector).forEach(callback);
1719
+ });
1720
+ }
1721
+ });
1722
+
1723
+ observer.observe(root, { childList: true, subtree: true });
1724
+ return { disconnect: () => observer.disconnect() };
1725
+ }
1726
+
1727
+ /**
1728
+ * Layout stabilization helper that measures a list of rotating candidate
1729
+ * phrases and locks the target container's inline dimensions to prevent
1730
+ * Cumulative Layout Shift (CLS).
1731
+ *
1732
+ * @param {Element | string} target
1733
+ * A DOM element or a CSS selector string.
1734
+ * @param {string[]} candidates
1735
+ * An array of strings/phrases that will rotate inside this container.
1736
+ * @returns {void}
1737
+ *
1738
+ * @example
1739
+ * TypeFlow.fit('#slogan', ['short', 'very long phrase that wraps']);
1740
+ */
1741
+ fit(target, candidates = []) {
1742
+ const el = resolveTarget(target);
1743
+ if (!Array.isArray(candidates) || !candidates.length || typeof document === 'undefined') return;
1744
+
1745
+ const probe = document.createElement('span');
1746
+ probe.style.cssText =
1747
+ 'position:absolute;visibility:hidden;opacity:0;pointer-events:none;white-space:nowrap;';
1748
+ const cs =
1749
+ typeof window !== 'undefined' && window.getComputedStyle ? window.getComputedStyle(el) : null;
1750
+ if (cs) {
1751
+ probe.style.font = cs.font;
1752
+ probe.style.fontSize = cs.fontSize;
1753
+ probe.style.fontWeight = cs.fontWeight;
1754
+ probe.style.letterSpacing = cs.letterSpacing;
1755
+ probe.style.textTransform = cs.textTransform;
1756
+ }
1757
+ document.body.appendChild(probe);
1758
+
1759
+ let maxW = 0;
1760
+ candidates.forEach((phrase) => {
1761
+ probe.textContent = String(phrase).replace(/<[^>]*>/g, '');
1762
+ maxW = Math.max(maxW, probe.getBoundingClientRect().width);
1763
+ });
1764
+ probe.remove();
1765
+
1766
+ if (maxW > 0) {
1767
+ el.style.display = 'inline-block';
1768
+ el.style.minWidth = `${Math.ceil(maxW)}px`;
1769
+ }
1770
+ }
1771
+
1772
+ /**
1773
+ * Stops any animation currently running on `target`. If no animation is
1774
+ * active on the element this method is a no-op.
1775
+ *
1776
+ * Equivalent to calling `controller.stop()` on the controller returned by
1777
+ * the originating `type()`, `erase()`, `sequence()`, or `rotate()` call.
1778
+ *
1779
+ * @param {Element | string} target - Target element or CSS selector string.
1780
+ * @returns {void}
1781
+ *
1782
+ * @example
1783
+ * TypeFlow.type('#el', 'Long text...');
1784
+ * // Stop from another handler without keeping a reference to the controller
1785
+ * TypeFlow.stop('#el');
1786
+ */
1787
+ stop(target) {
1788
+ const el = resolveTarget(target);
1789
+ if (!el) return;
1790
+ this.clearCursorInterval(el);
1791
+ this.active.get(el)?.stop();
1792
+ }
1793
+
1794
+ /**
1795
+ * Stops all animations currently managed by this engine instance.
1796
+ *
1797
+ * @returns {void}
1798
+ *
1799
+ * @example
1800
+ * // Clean up all running animations on page unload
1801
+ * window.addEventListener('pagehide', () => TypeFlow.stopAll());
1802
+ */
1803
+ stopAll() {
1804
+ this.cursorIntervals.forEach((id) => clearInterval(id));
1805
+ this.cursorIntervals.clear();
1806
+ Array.from(this.active.values()).forEach((c) => c.stop());
1807
+ }
1808
+
1809
+ /**
1810
+ * Returns `true` when `target` has an active animation managed by this
1811
+ * engine instance.
1812
+ *
1813
+ * @param {Element | string} target - Target element or CSS selector string.
1814
+ * @returns {boolean}
1815
+ *
1816
+ * @example
1817
+ * if (!TypeFlow.isTyping('#el')) {
1818
+ * TypeFlow.type('#el', 'Now available.');
1819
+ * }
1820
+ */
1821
+ isTyping(target) {
1822
+ const el = resolveMaybeTarget(target);
1823
+ return !!el && this.active.has(el);
1824
+ }
1825
+ }
1826
+
1827
+ // ============================================================
1828
+ // Vanilla Public API (Singleton)
1829
+ // ============================================================
1830
+
1831
+ /**
1832
+ * The global singleton instance of {@link TypeFlowEngine}.
1833
+ *
1834
+ * Import this in ESM environments:
1835
+ * ```javascript
1836
+ * import { TypeFlow } from '@staticcanvas/typeflow';
1837
+ * ```
1838
+ *
1839
+ * In UMD/script-tag environments the singleton is available as `window.TypeFlow`.
1840
+ *
1841
+ * @type {TypeFlowEngine}
1842
+ */
1843
+ export const TypeFlow = new TypeFlowEngine();
1844
+ export default TypeFlow;
1845
+
1846
+ /**
1847
+ * Factory helpers for constructing {@link TypeFlowEngine#sequence} step
1848
+ * descriptors. Use these instead of building plain objects manually to ensure
1849
+ * correct `kind` values and type safety.
1850
+ *
1851
+ * @namespace seq
1852
+ *
1853
+ * @example
1854
+ * import { TypeFlow, seq } from '@staticcanvas/typeflow';
1855
+ *
1856
+ * TypeFlow.sequence([
1857
+ * seq.type('#out', 'Hello.'),
1858
+ * seq.pause(500),
1859
+ * seq.erase('#out'),
1860
+ * ]);
1861
+ */
1862
+ export const seq = {
1863
+ /**
1864
+ * Creates a `type` step that types `text` into `target`.
1865
+ * @param {Element | string} target
1866
+ * @param {string} [text='']
1867
+ * @param {Partial<TypeFlowConfig>} [config]
1868
+ * @returns {SequenceStep}
1869
+ */
1870
+ type: (target, text = '', config) => ({ kind: 'type', target, text, config }),
1871
+
1872
+ /**
1873
+ * Creates an `erase` step that erases content from `target`.
1874
+ * @param {Element | string} target
1875
+ * @param {Partial<TypeFlowConfig>} [config]
1876
+ * @returns {SequenceStep}
1877
+ */
1878
+ erase: (target, config) => ({ kind: 'erase', target, config }),
1879
+
1880
+ /**
1881
+ * Creates a `pause` step that waits `duration` milliseconds.
1882
+ * @param {number} duration - Wait time in milliseconds.
1883
+ * @returns {SequenceStep}
1884
+ */
1885
+ pause: (duration) => ({ kind: 'pause', duration }),
1886
+
1887
+ /**
1888
+ * Creates a `visible` step that waits until `target` enters the viewport.
1889
+ * @param {Element | string} target
1890
+ * @param {number} [threshold=0.1] - Intersection threshold (0–1).
1891
+ * @returns {SequenceStep}
1892
+ */
1893
+ visible: (target, threshold) => ({ kind: 'visible', target, threshold }),
1894
+
1895
+ /**
1896
+ * Creates a `call` step that invokes `fn`. May be async.
1897
+ * @param {() => void | Promise<void>} fn
1898
+ * @returns {SequenceStep}
1899
+ */
1900
+ call: (fn) => ({ kind: 'call', fn }),
1901
+ };
1902
+
1903
+ if (typeof window !== 'undefined') {
1904
+ window.TypeFlow = TypeFlow;
1905
+ window.TypeFlowSeq = seq;
1906
+ }
1907
+
1908
+ // ============================================================
1909
+ // React Adapter
1910
+ // ============================================================
1911
+
1912
+ export function createTypeFlowReact(React) {
1913
+ return function useTypeFlow(text, config = {}) {
1914
+ const ref = React.useRef(null);
1915
+ React.useEffect(() => {
1916
+ if (!ref.current) return;
1917
+ const controller = TypeFlow.type(ref.current, text, config);
1918
+ return () => controller.stop();
1919
+ }, [text]);
1920
+ return ref;
1921
+ };
1922
+ }
1923
+
1924
+ export function createTypeFlowSequenceReact(React) {
1925
+ return function useTypeFlowSequence(buildSteps, deps) {
1926
+ React.useEffect(() => {
1927
+ const controller = TypeFlow.sequence(buildSteps());
1928
+ return () => controller.stop();
1929
+ }, deps);
1930
+ };
1931
+ }
1932
+
1933
+ // ============================================================
1934
+ // Vue Adapter
1935
+ // ============================================================
1936
+
1937
+ export function createTypeFlowVue(Vue) {
1938
+ return function useTypeFlow(getText, config = {}) {
1939
+ const el = Vue.ref(null);
1940
+ let controller = null;
1941
+ Vue.watch(
1942
+ getText,
1943
+ () => {
1944
+ if (!el.value) return;
1945
+ controller = TypeFlow.type(el.value, getText(), config);
1946
+ },
1947
+ { immediate: true }
1948
+ );
1949
+ Vue.onBeforeUnmount(() => controller?.stop());
1950
+ return el;
1951
+ };
1952
+ }
1953
+
1954
+ export function createTypeFlowSequenceVue(Vue) {
1955
+ return function useTypeFlowSequence(buildSteps) {
1956
+ let controller = null;
1957
+ const run = () => {
1958
+ controller?.stop();
1959
+ controller = TypeFlow.sequence(buildSteps());
1960
+ };
1961
+ Vue.onBeforeUnmount(() => controller?.stop());
1962
+ return run;
1963
+ };
1964
+ }
1965
+
1966
+ // ============================================================
1967
+ // Svelte Adapter (Action)
1968
+ // ============================================================
1969
+
1970
+ export function createTypeFlowSvelte() {
1971
+ return function typeflowAction(node, params) {
1972
+ let controller = null;
1973
+
1974
+ const start = (currentParams) => {
1975
+ controller?.stop();
1976
+ const text = typeof currentParams === 'string' ? currentParams : currentParams.text;
1977
+ const config = typeof currentParams === 'object' ? currentParams.config : {};
1978
+ controller = TypeFlow.type(node, text, config);
1979
+ };
1980
+
1981
+ start(params);
1982
+
1983
+ return {
1984
+ update(newParams) {
1985
+ start(newParams);
1986
+ },
1987
+ destroy() {
1988
+ controller?.stop();
1989
+ },
1990
+ };
1991
+ };
1992
+ }
1993
+
1994
+ // ============================================================
1995
+ // Solid.js Adapter
1996
+ // ============================================================
1997
+
1998
+ export function createTypeFlowSolid(Solid) {
1999
+ return function useTypeFlow(getText, config = {}) {
2000
+ let el;
2001
+ let controller = null;
2002
+
2003
+ Solid.createEffect(() => {
2004
+ const text = typeof getText === 'function' ? getText() : getText;
2005
+ if (!el) return;
2006
+ controller?.stop();
2007
+ controller = TypeFlow.type(el, text, config);
2008
+ });
2009
+
2010
+ Solid.onCleanup(() => {
2011
+ controller?.stop();
2012
+ });
2013
+
2014
+ return (element) => {
2015
+ el = element;
2016
+ };
2017
+ };
2018
+ }
2019
+
2020
+ // ============================================================
2021
+ // Alpine.js Adapter
2022
+ // ============================================================
2023
+
2024
+ export function createTypeFlowAlpine(Alpine) {
2025
+ if (!Alpine || typeof Alpine.directive !== 'function') {
2026
+ throw new Error(
2027
+ '[TypeFlow] createTypeFlowAlpine requires an Alpine instance with directive() support.'
2028
+ );
2029
+ }
2030
+
2031
+ Alpine.directive('typeflow', (el, { expression }, { evaluateLater, effect, cleanup }) => {
2032
+ const evaluate = evaluateLater(expression);
2033
+ let controller = null;
2034
+
2035
+ effect(() => {
2036
+ evaluate((value) => {
2037
+ controller?.stop();
2038
+ if (value === null || value === undefined) return;
2039
+ const text =
2040
+ typeof value === 'object' && value !== null ? (value.text ?? '') : String(value);
2041
+ const config = typeof value === 'object' && value !== null ? (value.config ?? {}) : {};
2042
+ controller = TypeFlow.type(el, text, config);
2043
+ });
2044
+ });
2045
+
2046
+ if (typeof cleanup === 'function') {
2047
+ cleanup(() => controller?.stop());
2048
+ }
2049
+ });
2050
+ }
2051
+
2052
+ // ============================================================
2053
+ // Web Components / Custom Element Adapter
2054
+ // ============================================================
2055
+
2056
+ export function defineTypeFlowElement(
2057
+ customElementsRegistry = typeof customElements !== 'undefined' ? customElements : null
2058
+ ) {
2059
+ if (!customElementsRegistry || customElementsRegistry.get('type-flow')) return;
2060
+
2061
+ class TypeFlowCustomElement extends HTMLElement {
2062
+ #controller = null;
2063
+
2064
+ static get observedAttributes() {
2065
+ return [
2066
+ 'text',
2067
+ 'speed',
2068
+ 'direction',
2069
+ 'cursor',
2070
+ 'type-style',
2071
+ 'erase-style',
2072
+ 'scramble-charset',
2073
+ 'audio',
2074
+ ];
2075
+ }
2076
+
2077
+ connectedCallback() {
2078
+ this.#render();
2079
+ }
2080
+
2081
+ disconnectedCallback() {
2082
+ this.#controller?.stop();
2083
+ }
2084
+
2085
+ attributeChangedCallback(_name, oldValue, newValue) {
2086
+ if (this.isConnected && oldValue !== newValue) {
2087
+ this.#render();
2088
+ }
2089
+ }
2090
+
2091
+ #render() {
2092
+ this.#controller?.stop();
2093
+ const text = this.getAttribute('text') ?? this.textContent ?? '';
2094
+ const speed = this.hasAttribute('speed') ? Number(this.getAttribute('speed')) : 40;
2095
+ const direction = this.getAttribute('direction') || 'left';
2096
+ const cursor = this.getAttribute('cursor') ?? '|';
2097
+ const typeStyle = this.getAttribute('type-style') || 'char';
2098
+ const eraseStyle = this.getAttribute('erase-style') || 'end';
2099
+ const scrambleCharset = this.getAttribute('scramble-charset') || 'matrix';
2100
+ const audio = this.getAttribute('audio') || undefined;
2101
+
2102
+ this.#controller = TypeFlow.type(this, text, {
2103
+ speed,
2104
+ direction,
2105
+ cursor,
2106
+ typeStyle,
2107
+ eraseStyle,
2108
+ scrambleCharset,
2109
+ audio,
2110
+ });
2111
+ }
2112
+
2113
+ stop() {
2114
+ this.#controller?.stop();
2115
+ }
2116
+ }
2117
+
2118
+ customElementsRegistry.define('type-flow', TypeFlowCustomElement);
2119
+ }