yarn-spinner-runner-ts 0.1.2-b → 0.1.3

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 (63) hide show
  1. package/README.md +5 -2
  2. package/dist/compile/compiler.js +4 -4
  3. package/dist/compile/compiler.js.map +1 -1
  4. package/dist/compile/ir.d.ts +3 -0
  5. package/dist/index.d.ts +3 -0
  6. package/dist/index.js +3 -0
  7. package/dist/index.js.map +1 -1
  8. package/dist/markup/parser.d.ts +3 -0
  9. package/dist/markup/parser.js +332 -0
  10. package/dist/markup/parser.js.map +1 -0
  11. package/dist/markup/types.d.ts +17 -0
  12. package/dist/markup/types.js +2 -0
  13. package/dist/markup/types.js.map +1 -0
  14. package/dist/model/ast.d.ts +3 -0
  15. package/dist/parse/parser.js +57 -8
  16. package/dist/parse/parser.js.map +1 -1
  17. package/dist/react/DialogueExample.js +4 -3
  18. package/dist/react/DialogueExample.js.map +1 -1
  19. package/dist/react/DialogueScene.d.ts +2 -1
  20. package/dist/react/DialogueScene.js +95 -26
  21. package/dist/react/DialogueScene.js.map +1 -1
  22. package/dist/react/DialogueView.d.ts +2 -1
  23. package/dist/react/DialogueView.js +5 -4
  24. package/dist/react/DialogueView.js.map +1 -1
  25. package/dist/react/MarkupRenderer.d.ts +8 -0
  26. package/dist/react/MarkupRenderer.js +64 -0
  27. package/dist/react/MarkupRenderer.js.map +1 -0
  28. package/dist/react/TypingText.d.ts +3 -1
  29. package/dist/react/TypingText.js +16 -40
  30. package/dist/react/TypingText.js.map +1 -1
  31. package/dist/runtime/results.d.ts +3 -0
  32. package/dist/runtime/runner.d.ts +1 -0
  33. package/dist/runtime/runner.js +151 -14
  34. package/dist/runtime/runner.js.map +1 -1
  35. package/dist/tests/markup.test.d.ts +1 -0
  36. package/dist/tests/markup.test.js +46 -0
  37. package/dist/tests/markup.test.js.map +1 -0
  38. package/dist/tests/nodes_lines.test.js +25 -1
  39. package/dist/tests/nodes_lines.test.js.map +1 -1
  40. package/dist/tests/options.test.js +30 -1
  41. package/dist/tests/options.test.js.map +1 -1
  42. package/docs/actor-transition.md +34 -0
  43. package/docs/markup.md +34 -19
  44. package/docs/scenes-actors-setup.md +1 -0
  45. package/package.json +1 -1
  46. package/src/compile/compiler.ts +4 -4
  47. package/src/compile/ir.ts +3 -2
  48. package/src/index.ts +3 -0
  49. package/src/markup/parser.ts +372 -0
  50. package/src/markup/types.ts +22 -0
  51. package/src/model/ast.ts +17 -13
  52. package/src/parse/parser.ts +60 -8
  53. package/src/react/DialogueExample.tsx +4 -2
  54. package/src/react/DialogueScene.tsx +143 -44
  55. package/src/react/DialogueView.tsx +19 -5
  56. package/src/react/MarkupRenderer.tsx +110 -0
  57. package/src/react/TypingText.tsx +25 -30
  58. package/src/react/dialogue.css +26 -13
  59. package/src/runtime/results.ts +3 -1
  60. package/src/runtime/runner.ts +158 -14
  61. package/src/tests/markup.test.ts +62 -0
  62. package/src/tests/nodes_lines.test.ts +35 -1
  63. package/src/tests/options.test.ts +39 -1
@@ -1,4 +1,6 @@
1
1
  import { lex, Token } from "./lexer.js";
2
+ import { parseMarkup, sliceMarkup } from "../markup/parser.js";
3
+ import type { MarkupParseResult } from "../markup/types.js";
2
4
  import type {
3
5
  YarnDocument,
4
6
  YarnNode,
@@ -177,17 +179,33 @@ class Parser {
177
179
  }
178
180
  if (t.type === "TEXT") {
179
181
  const raw = this.take("TEXT").text;
180
- const { cleanText: text, tags } = this.extractTags(raw);
181
- const speakerMatch = text.match(/^([^:\s][^:]*)\s*:\s*(.*)$/);
182
+ const { cleanText: textWithoutTags, tags } = this.extractTags(raw);
183
+ const markup = parseMarkup(textWithoutTags);
184
+ const speakerMatch = markup.text.match(/^([^:\s][^:]*)\s*:\s*(.*)$/);
182
185
  if (speakerMatch) {
183
- return { type: "Line", speaker: speakerMatch[1].trim(), text: speakerMatch[2], tags } as Line;
186
+ const messageText = speakerMatch[2];
187
+ const messageOffset = markup.text.length - messageText.length;
188
+ const slicedMarkup = sliceMarkup(markup, messageOffset);
189
+ const normalizedMarkup = this.normalizeMarkup(slicedMarkup);
190
+ return {
191
+ type: "Line",
192
+ speaker: speakerMatch[1].trim(),
193
+ text: messageText,
194
+ tags,
195
+ markup: normalizedMarkup,
196
+ } as Line;
184
197
  }
185
198
  // If/Else blocks use inline markup {if ...}
186
- const trimmed = text.trim();
199
+ const trimmed = markup.text.trim();
187
200
  if (trimmed.startsWith("{if ") || trimmed === "{else}" || trimmed.startsWith("{else if ") || trimmed === "{endif}") {
188
- return this.parseIfFromText(text);
201
+ return this.parseIfFromText(markup.text);
189
202
  }
190
- return { type: "Line", text, tags } as Line;
203
+ return {
204
+ type: "Line",
205
+ text: markup.text,
206
+ tags,
207
+ markup: this.normalizeMarkup(markup),
208
+ } as Line;
191
209
  }
192
210
  throw new ParseError(`Unexpected token ${t.type}`);
193
211
  }
@@ -198,7 +216,8 @@ class Parser {
198
216
  while (this.at("OPTION")) {
199
217
  const raw = this.take("OPTION").text;
200
218
  const { cleanText: textWithAttrs, tags } = this.extractTags(raw);
201
- const { text, css } = this.extractCss(textWithAttrs);
219
+ const { text: textWithoutCss, css } = this.extractCss(textWithAttrs);
220
+ const markup = parseMarkup(textWithoutCss);
202
221
  let body: Statement[] = [];
203
222
  if (this.at("INDENT")) {
204
223
  this.take("INDENT");
@@ -206,13 +225,46 @@ class Parser {
206
225
  this.take("DEDENT");
207
226
  while (this.at("EMPTY")) this.i++;
208
227
  }
209
- options.push({ type: "Option", text, body, tags, css });
228
+ options.push({
229
+ type: "Option",
230
+ text: markup.text,
231
+ body,
232
+ tags,
233
+ css,
234
+ markup: this.normalizeMarkup(markup),
235
+ });
210
236
  // Consecutive options belong to the same group; break on non-OPTION
211
237
  while (this.at("EMPTY")) this.i++;
212
238
  }
213
239
  return { type: "OptionGroup", options };
214
240
  }
215
241
 
242
+ private normalizeMarkup(result: MarkupParseResult): MarkupParseResult | undefined {
243
+ if (!result) return undefined;
244
+ if (result.segments.length === 0) {
245
+ return undefined;
246
+ }
247
+ const hasFormatting = result.segments.some(
248
+ (segment) => segment.wrappers.length > 0 || segment.selfClosing
249
+ );
250
+ if (!hasFormatting) {
251
+ return undefined;
252
+ }
253
+ return {
254
+ text: result.text,
255
+ segments: result.segments.map((segment) => ({
256
+ start: segment.start,
257
+ end: segment.end,
258
+ wrappers: segment.wrappers.map((wrapper) => ({
259
+ name: wrapper.name,
260
+ type: wrapper.type,
261
+ properties: { ...wrapper.properties },
262
+ })),
263
+ selfClosing: segment.selfClosing,
264
+ })),
265
+ };
266
+ }
267
+
216
268
  private extractTags(input: string): { cleanText: string; tags?: string[] } {
217
269
  const tags: string[] = [];
218
270
  // Match tags that are space-separated and not part of hex colors or CSS
@@ -9,8 +9,9 @@ import type { SceneCollection } from "../scene/types.js";
9
9
  const DEFAULT_YARN = `title: Start
10
10
  scene: scene1
11
11
  ---
12
+ Narrator: [wave]hello[/wave] [b]hello[/b] baarter
12
13
  Narrator: Welcome to yarn-spinner-ts!
13
- Narrator: This is a dialogue system powered by Yarn Spinner.
14
+ npc: This is a dialogue system powered by Yarn Spinner.
14
15
  Narrator: Click anywhere to continue, or choose an option below.
15
16
  -> Start the adventure &css{backgroundColor: #4a9eff; color: white;}
16
17
  Narrator: Great! Let's begin your journey.
@@ -23,7 +24,7 @@ Narrator: Click anywhere to continue, or choose an option below.
23
24
 
24
25
  title: NextScene
25
26
  ---
26
- blablabla
27
+ npc: blablabla
27
28
  Narrator: You've reached the next scene!
28
29
  Narrator: The dialogue system supports rich features like:
29
30
  Narrator: • Variables and expressions
@@ -114,6 +115,7 @@ export function DialogueExample() {
114
115
  cursorCharacter="$"
115
116
  autoAdvanceAfterTyping={true}
116
117
  autoAdvanceDelay={2000}
118
+ actorTransitionDuration={1000}
117
119
  pauseBeforeAdvance={enableTypingAnimation ? 1000 : 0}
118
120
  />
119
121
  </div>
@@ -1,4 +1,4 @@
1
- import React, { useState, useEffect } from "react";
1
+ import React, { useState, useEffect, useMemo, useRef } from "react";
2
2
  import type { SceneCollection, SceneConfig } from "../scene/types.js";
3
3
  // Note: CSS is imported in the browser demo entry point (examples/browser/main.tsx)
4
4
  // This prevents Node.js from trying to resolve CSS imports during tests
@@ -8,25 +8,59 @@ export interface DialogueSceneProps {
8
8
  speaker?: string;
9
9
  scenes: SceneCollection;
10
10
  className?: string;
11
+ actorTransitionDuration?: number;
11
12
  }
12
13
 
13
14
  /**
14
15
  * Visual scene component that displays background and actor images
15
16
  */
16
- export function DialogueScene({ sceneName, speaker, scenes, className }: DialogueSceneProps) {
17
+ export function DialogueScene({
18
+ sceneName,
19
+ speaker,
20
+ scenes,
21
+ className,
22
+ actorTransitionDuration = 350,
23
+ }: DialogueSceneProps) {
24
+
17
25
  const [currentBackground, setCurrentBackground] = useState<string | null>(null);
18
26
  const [backgroundOpacity, setBackgroundOpacity] = useState(1);
19
27
  const [nextBackground, setNextBackground] = useState<string | null>(null);
20
28
  const [lastSceneName, setLastSceneName] = useState<string | undefined>(undefined);
21
29
  const [lastSpeaker, setLastSpeaker] = useState<string | undefined>(undefined);
30
+ const [activeActor, setActiveActor] = useState<{ name: string; image: string } | null>(null);
31
+ const [previousActor, setPreviousActor] = useState<{ name: string; image: string } | null>(null);
32
+ const [currentActorVisible, setCurrentActorVisible] = useState(false);
33
+ const [previousActorVisible, setPreviousActorVisible] = useState(false);
34
+ const previousActorTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
22
35
 
23
36
  // Get scene config - use last scene if current node has no scene
24
37
  const activeSceneName = sceneName || lastSceneName;
25
38
  const sceneConfig: SceneConfig | undefined = activeSceneName ? scenes.scenes[activeSceneName] : undefined;
26
39
  const backgroundImage = sceneConfig?.background;
27
40
 
28
- // Get all actors from the current scene
29
- const sceneActors = sceneConfig ? Object.keys(sceneConfig.actors) : [];
41
+ const activeSpeakerName = speaker || lastSpeaker;
42
+
43
+ const resolvedActor = useMemo(() => {
44
+ if (!sceneConfig || !activeSpeakerName) {
45
+ return null;
46
+ }
47
+
48
+ const actorEntries = Object.entries(sceneConfig.actors);
49
+ const matchingActor = actorEntries.find(
50
+ ([actorName]) => actorName.toLowerCase() === activeSpeakerName.toLowerCase()
51
+ );
52
+
53
+ if (!matchingActor) {
54
+ return null;
55
+ }
56
+
57
+ const [actorName, actorConfig] = matchingActor;
58
+ if (!actorConfig?.image) {
59
+ return null;
60
+ }
61
+
62
+ return { name: actorName, image: actorConfig.image };
63
+ }, [sceneConfig, activeSpeakerName]);
30
64
 
31
65
  // Track last speaker - update when speaker is provided, keep when undefined
32
66
  useEffect(() => {
@@ -64,18 +98,95 @@ export function DialogueScene({ sceneName, speaker, scenes, className }: Dialogu
64
98
  // Never clear background - keep it until a new one is explicitly set
65
99
  }, [backgroundImage, currentBackground, sceneName, lastSceneName]);
66
100
 
101
+ // Handle actor portrait transitions (cross-fade between speakers)
102
+ useEffect(() => {
103
+ let fadeOutTimeout: ReturnType<typeof setTimeout> | null = null;
104
+
105
+ setActiveActor((currentActor) => {
106
+ const currentImage = currentActor?.image ?? null;
107
+ const currentName = currentActor?.name ?? null;
108
+ const nextImage = resolvedActor?.image ?? null;
109
+ const nextName = resolvedActor?.name ?? null;
110
+
111
+ if (currentImage === nextImage && currentName === nextName) {
112
+ return currentActor;
113
+ }
114
+
115
+ if (currentActor) {
116
+ setPreviousActor(currentActor);
117
+ setPreviousActorVisible(true);
118
+ fadeOutTimeout = setTimeout(() => {
119
+ setPreviousActorVisible(false);
120
+ }, 0);
121
+ } else {
122
+ setPreviousActor(null);
123
+ setPreviousActorVisible(false);
124
+ }
125
+
126
+ setCurrentActorVisible(false);
127
+
128
+ return resolvedActor;
129
+ });
130
+
131
+ return () => {
132
+ if (fadeOutTimeout !== null) {
133
+ clearTimeout(fadeOutTimeout);
134
+ }
135
+ };
136
+ }, [resolvedActor]);
137
+
138
+ useEffect(() => {
139
+ if (!activeActor) {
140
+ return;
141
+ }
142
+
143
+ const fadeInTimeout = setTimeout(() => {
144
+ setCurrentActorVisible(true);
145
+ }, 0);
146
+
147
+ return () => {
148
+ clearTimeout(fadeInTimeout);
149
+ };
150
+ }, [activeActor]);
151
+
152
+ // Remove previous actor once fade-out completes
153
+ useEffect(() => {
154
+ if (!previousActor) {
155
+ return;
156
+ }
157
+
158
+ if (previousActorTimeoutRef.current) {
159
+ clearTimeout(previousActorTimeoutRef.current);
160
+ }
161
+
162
+ previousActorTimeoutRef.current = setTimeout(() => {
163
+ setPreviousActor(null);
164
+ previousActorTimeoutRef.current = null;
165
+ }, actorTransitionDuration);
166
+
167
+ return () => {
168
+ if (previousActorTimeoutRef.current) {
169
+ clearTimeout(previousActorTimeoutRef.current);
170
+ previousActorTimeoutRef.current = null;
171
+ }
172
+ };
173
+ }, [previousActor, actorTransitionDuration]);
174
+
67
175
  // Default background color when no scene
68
176
  const defaultBgColor = "rgba(26, 26, 46, 1)"; // Dark blue-purple
177
+ const handleActorImageError = (actorName: string, imageUrl: string) => () => {
178
+ console.error(`Failed to load actor image for ${actorName}:`, imageUrl);
179
+ };
69
180
 
70
- return (
71
- <div
72
- className={`yd-scene ${className || ""}`}
73
- style={{
74
- backgroundColor: currentBackground ? undefined : defaultBgColor,
75
- backgroundImage: currentBackground ? `url(${currentBackground})` : undefined,
76
- opacity: backgroundOpacity,
77
- }}
78
- >
181
+ const sceneStyle: React.CSSProperties & { ["--yd-actor-transition"]: string } = {
182
+ backgroundColor: currentBackground ? undefined : defaultBgColor,
183
+ backgroundImage: currentBackground ? `url(${currentBackground})` : undefined,
184
+ opacity: backgroundOpacity,
185
+ ["--yd-actor-transition"]: `${Math.max(actorTransitionDuration, 0)}ms`,
186
+ };
187
+
188
+ return (
189
+ <div className={`yd-scene ${className || ""}`} style={sceneStyle}>
79
190
  {/* Next background (during transition) */}
80
191
  {nextBackground && (
81
192
  <div
@@ -87,37 +198,25 @@ export function DialogueScene({ sceneName, speaker, scenes, className }: Dialogu
87
198
  />
88
199
  )}
89
200
 
90
- {/* Actor image - show only the speaking actor, aligned to top */}
91
- {sceneConfig && (speaker || lastSpeaker) && (() => {
92
- // Use current speaker if available, otherwise use last speaker to keep image visible
93
- const activeSpeaker = speaker || lastSpeaker;
94
-
95
- // Type guard: ensure activeSpeaker is defined
96
- if (!activeSpeaker) return null;
97
-
98
- // Find the actor that matches the speaker (case-insensitive)
99
- const speakingActorName = sceneActors.find(
100
- actorName => actorName.toLowerCase() === activeSpeaker.toLowerCase()
101
- );
102
-
103
- if (!speakingActorName) return null;
104
-
105
- const actorConfig = sceneConfig.actors[speakingActorName];
106
- if (!actorConfig?.image) return null;
107
-
108
- return (
109
- <img
110
- key={speakingActorName}
111
- className="yd-actor"
112
- src={actorConfig.image}
113
- alt={speakingActorName}
114
- onError={(e) => {
115
- console.error(`Failed to load actor image for ${speakingActorName}:`, actorConfig.image, e);
116
- }}
117
- />
118
- );
119
- })()}
201
+ {/* Actor portraits with cross-fade */}
202
+ {previousActor && (
203
+ <img
204
+ key={`${previousActor.name}-previous`}
205
+ className={`yd-actor yd-actor--previous ${previousActorVisible ? "yd-actor--visible" : ""}`}
206
+ src={previousActor.image}
207
+ alt={previousActor.name}
208
+ onError={handleActorImageError(previousActor.name, previousActor.image)}
209
+ />
210
+ )}
211
+ {activeActor && (
212
+ <img
213
+ key={`${activeActor.name}-current`}
214
+ className={`yd-actor yd-actor--current ${currentActorVisible ? "yd-actor--visible" : ""}`}
215
+ src={activeActor.image}
216
+ alt={activeActor.name}
217
+ onError={handleActorImageError(activeActor.name, activeActor.image)}
218
+ />
219
+ )}
120
220
  </div>
121
221
  );
122
222
  }
123
-
@@ -3,6 +3,7 @@ import type { RuntimeResult } from "../runtime/results.js";
3
3
  import { DialogueScene } from "./DialogueScene.js";
4
4
  import type { SceneCollection } from "../scene/types.js";
5
5
  import { TypingText } from "./TypingText.js";
6
+ import { MarkupRenderer } from "./MarkupRenderer.js";
6
7
  // Note: CSS is imported in the browser demo entry point (examples/browser/main.tsx)
7
8
  // This prevents Node.js from trying to resolve CSS imports during tests
8
9
 
@@ -11,6 +12,7 @@ export interface DialogueViewProps {
11
12
  onAdvance: (optionIndex?: number) => void;
12
13
  className?: string;
13
14
  scenes?: SceneCollection;
15
+ actorTransitionDuration?: number;
14
16
  // Typing animation options
15
17
  enableTypingAnimation?: boolean;
16
18
  typingSpeed?: number;
@@ -85,6 +87,7 @@ export function DialogueView({
85
87
  onAdvance,
86
88
  className,
87
89
  scenes,
90
+ actorTransitionDuration = 350,
88
91
  enableTypingAnimation = false,
89
92
  typingSpeed = 50, // Characters per second (50 cps = ~20ms per character)
90
93
  showTypingCursor = true,
@@ -143,7 +146,7 @@ export function DialogueView({
143
146
  if (result.type === "text") {
144
147
  const nodeStyles = parseCss(result.nodeCss);
145
148
  const displayText = result.text || "\u00A0";
146
- const shouldShowContinue = !result.isDialogueEnd && !enableTypingAnimation;
149
+ const shouldShowContinue = !result.isDialogueEnd && !enableTypingAnimation;
147
150
 
148
151
  const handleClick = () => {
149
152
  if (result.isDialogueEnd) return;
@@ -174,7 +177,12 @@ export function DialogueView({
174
177
 
175
178
  return (
176
179
  <div className="yd-container">
177
- <DialogueScene sceneName={sceneName} speaker={speaker} scenes={sceneCollection} />
180
+ <DialogueScene
181
+ sceneName={sceneName}
182
+ speaker={speaker}
183
+ scenes={sceneCollection}
184
+ actorTransitionDuration={actorTransitionDuration}
185
+ />
178
186
  <div
179
187
  className={`yd-dialogue-box ${result.isDialogueEnd ? "yd-text-box-end" : ""} ${className || ""}`}
180
188
  style={nodeStyles} // Only apply dynamic node CSS
@@ -191,6 +199,7 @@ export function DialogueView({
191
199
  <TypingText
192
200
  key={currentTextKey}
193
201
  text={displayText}
202
+ markup={result.markup}
194
203
  typingSpeed={typingSpeed}
195
204
  showCursor={showTypingCursor}
196
205
  cursorCharacter={cursorCharacter}
@@ -198,7 +207,7 @@ export function DialogueView({
198
207
  onComplete={() => setTypingComplete(true)}
199
208
  />
200
209
  ) : (
201
- displayText
210
+ <MarkupRenderer text={displayText} markup={result.markup} />
202
211
  )}
203
212
  </p>
204
213
  {shouldShowContinue && (
@@ -216,7 +225,12 @@ export function DialogueView({
216
225
  const nodeStyles = parseCss(result.nodeCss);
217
226
  return (
218
227
  <div className="yd-container">
219
- <DialogueScene sceneName={sceneName} speaker={speaker} scenes={sceneCollection} />
228
+ <DialogueScene
229
+ sceneName={sceneName}
230
+ speaker={speaker}
231
+ scenes={sceneCollection}
232
+ actorTransitionDuration={actorTransitionDuration}
233
+ />
220
234
  <div className={`yd-options-container ${className || ""}`}>
221
235
  <div className="yd-options-box" style={nodeStyles}>
222
236
  <div className="yd-options-title">Choose an option:</div>
@@ -230,7 +244,7 @@ export function DialogueView({
230
244
  onClick={() => onAdvance(index)}
231
245
  style={optionStyles} // Only apply dynamic option CSS
232
246
  >
233
- {option.text}
247
+ <MarkupRenderer text={option.text} markup={option.markup} />
234
248
  </button>
235
249
  );
236
250
  })}
@@ -0,0 +1,110 @@
1
+ import React from "react";
2
+ import type { MarkupParseResult, MarkupWrapper } from "../markup/types.js";
3
+
4
+ const DEFAULT_HTML_TAGS = new Set(["b", "em", "small", "strong", "sub", "sup", "ins", "del", "mark"]);
5
+
6
+ interface RenderPiece {
7
+ text: string;
8
+ wrappers: MarkupWrapper[];
9
+ key: string;
10
+ selfClosing?: boolean;
11
+ }
12
+
13
+ interface MarkupRendererProps {
14
+ text: string;
15
+ markup?: MarkupParseResult;
16
+ length?: number;
17
+ }
18
+
19
+ export function MarkupRenderer({ text, markup, length }: MarkupRendererProps) {
20
+ const maxLength = length ?? text.length;
21
+ if (!markup || markup.segments.length === 0) {
22
+ return <>{text.slice(0, maxLength)}</>;
23
+ }
24
+
25
+ const pieces: RenderPiece[] = [];
26
+ const limit = Math.max(0, Math.min(maxLength, markup.text.length));
27
+
28
+ markup.segments.forEach((segment, index) => {
29
+ if (segment.selfClosing) {
30
+ if (segment.start <= limit) {
31
+ pieces.push({
32
+ text: "",
33
+ wrappers: segment.wrappers,
34
+ selfClosing: true,
35
+ key: `self-${index}`,
36
+ });
37
+ }
38
+ return;
39
+ }
40
+ const start = Math.max(0, Math.min(segment.start, limit));
41
+ const end = Math.max(start, Math.min(segment.end, limit));
42
+ if (end <= start) {
43
+ return;
44
+ }
45
+ const segmentText = markup.text.slice(start, end);
46
+ pieces.push({
47
+ text: segmentText,
48
+ wrappers: segment.wrappers,
49
+ key: `seg-${index}-${start}-${end}`,
50
+ });
51
+ });
52
+
53
+ if (pieces.length === 0) {
54
+ return <>{text.slice(0, maxLength)}</>;
55
+ }
56
+
57
+ return (
58
+ <>
59
+ {pieces.map((piece, pieceIndex) => renderPiece(piece, pieceIndex))}
60
+ </>
61
+ );
62
+ }
63
+
64
+ function renderPiece(piece: RenderPiece, pieceIndex: number): React.ReactNode {
65
+ const baseKey = `${piece.key}-${pieceIndex}`;
66
+
67
+ if (piece.selfClosing) {
68
+ return piece.wrappers.reduceRight<React.ReactNode>(
69
+ (child, wrapper, wrapperIndex) => createWrapperElement(wrapper, `${baseKey}-wrapper-${wrapperIndex}`, child),
70
+ null
71
+ );
72
+ }
73
+
74
+ const content = piece.wrappers.reduceRight<React.ReactNode>(
75
+ (child, wrapper, wrapperIndex) => createWrapperElement(wrapper, `${baseKey}-wrapper-${wrapperIndex}`, child),
76
+ piece.text
77
+ );
78
+
79
+ return <React.Fragment key={baseKey}>{content}</React.Fragment>;
80
+ }
81
+
82
+ function createWrapperElement(
83
+ wrapper: MarkupWrapper,
84
+ key: string,
85
+ children: React.ReactNode
86
+ ): React.ReactElement {
87
+ const tagName = DEFAULT_HTML_TAGS.has(wrapper.name) ? wrapper.name : "span";
88
+ const className =
89
+ wrapper.type === "custom" ? `yd-markup-${sanitizeClassName(wrapper.name)}` : undefined;
90
+
91
+ const dataAttributes: Record<string, string> = {};
92
+ for (const [propertyName, value] of Object.entries(wrapper.properties)) {
93
+ dataAttributes[`data-markup-${propertyName}`] = String(value);
94
+ }
95
+
96
+ return React.createElement(
97
+ tagName,
98
+ {
99
+ key,
100
+ className,
101
+ ...dataAttributes,
102
+ },
103
+ children
104
+ );
105
+ }
106
+
107
+ function sanitizeClassName(name: string): string {
108
+ return name.toLowerCase().replace(/[^a-z0-9_-]/g, "-");
109
+ }
110
+
@@ -1,7 +1,10 @@
1
1
  import React, { useState, useEffect, useRef } from "react";
2
+ import type { MarkupParseResult } from "../markup/types.js";
3
+ import { MarkupRenderer } from "./MarkupRenderer.js";
2
4
 
3
5
  export interface TypingTextProps {
4
6
  text: string;
7
+ markup?: MarkupParseResult;
5
8
  typingSpeed?: number;
6
9
  showCursor?: boolean;
7
10
  cursorCharacter?: string;
@@ -14,6 +17,7 @@ export interface TypingTextProps {
14
17
 
15
18
  export function TypingText({
16
19
  text,
20
+ markup,
17
21
  typingSpeed = 100,
18
22
  showCursor = true,
19
23
  cursorCharacter = "|",
@@ -23,7 +27,7 @@ export function TypingText({
23
27
  onComplete,
24
28
  disabled = false,
25
29
  }: TypingTextProps) {
26
- const [displayedText, setDisplayedText] = useState("");
30
+ const [displayedLength, setDisplayedLength] = useState(disabled ? text.length : 0);
27
31
  const [cursorVisible, setCursorVisible] = useState(true);
28
32
  const cursorIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
29
33
  const typingTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -51,8 +55,7 @@ export function TypingText({
51
55
  // Handle typing animation
52
56
  useEffect(() => {
53
57
  if (disabled) {
54
- // If disabled, show full text immediately
55
- setDisplayedText(text);
58
+ setDisplayedLength(text.length);
56
59
  if (onCompleteRef.current && text.length > 0) {
57
60
  onCompleteRef.current();
58
61
  }
@@ -60,7 +63,7 @@ export function TypingText({
60
63
  }
61
64
 
62
65
  // Reset when text changes
63
- setDisplayedText("");
66
+ setDisplayedLength(0);
64
67
 
65
68
  if (text.length === 0) {
66
69
  if (onCompleteRef.current) {
@@ -72,38 +75,22 @@ export function TypingText({
72
75
  let index = 0;
73
76
  const typeNextCharacter = () => {
74
77
  if (index < text.length) {
75
- index++;
76
- setDisplayedText(text.slice(0, index));
77
-
78
-
79
- // If speed is 0 or very small, type next character immediately (use requestAnimationFrame for smoother animation)
78
+ index += 1;
79
+ setDisplayedLength(index);
80
80
  if (typingSpeed <= 0) {
81
- // Use requestAnimationFrame for instant/smooth rendering
82
- requestAnimationFrame(() => {
83
- typeNextCharacter();
84
- });
81
+ requestAnimationFrame(typeNextCharacter);
85
82
  } else {
86
- typingTimeoutRef.current = setTimeout(() => {
87
- typeNextCharacter();
88
- }, typingSpeed);
89
- }
90
- } else {
91
- if (onCompleteRef.current) {
92
- onCompleteRef.current();
83
+ typingTimeoutRef.current = setTimeout(typeNextCharacter, typingSpeed);
93
84
  }
85
+ } else if (onCompleteRef.current) {
86
+ onCompleteRef.current();
94
87
  }
95
88
  };
96
89
 
97
- // Start typing
98
90
  if (typingSpeed <= 0) {
99
- // Start immediately if speed is 0
100
- requestAnimationFrame(() => {
101
- typeNextCharacter();
102
- });
91
+ requestAnimationFrame(typeNextCharacter);
103
92
  } else {
104
- typingTimeoutRef.current = setTimeout(() => {
105
- typeNextCharacter();
106
- }, typingSpeed);
93
+ typingTimeoutRef.current = setTimeout(typeNextCharacter, typingSpeed);
107
94
  }
108
95
 
109
96
  return () => {
@@ -111,11 +98,19 @@ export function TypingText({
111
98
  clearTimeout(typingTimeoutRef.current);
112
99
  }
113
100
  };
114
- }, [text, disabled]);
101
+ }, [text, disabled, typingSpeed]);
102
+
103
+ const visibleLength = markup ? Math.min(displayedLength, markup.text.length) : Math.min(displayedLength, text.length);
115
104
 
116
105
  return (
117
106
  <span className={className}>
118
- <span>{displayedText}</span>
107
+ <span>
108
+ {markup ? (
109
+ <MarkupRenderer text={text} markup={markup} length={visibleLength} />
110
+ ) : (
111
+ text.slice(0, visibleLength)
112
+ )}
113
+ </span>
119
114
  {showCursor && !disabled && (
120
115
  <span
121
116
  className={`yd-typing-cursor ${cursorClassName}`}