yarn-spinner-runner-ts 0.1.2 → 0.1.4-a

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 (84) hide show
  1. package/README.md +102 -88
  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 +13 -10
  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 +18 -4
  23. package/dist/react/DialogueView.js +84 -7
  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 +14 -0
  29. package/dist/react/TypingText.js +78 -0
  30. package/dist/react/TypingText.js.map +1 -0
  31. package/dist/react/useYarnRunner.js +10 -1
  32. package/dist/react/useYarnRunner.js.map +1 -1
  33. package/dist/runtime/commands.js +12 -1
  34. package/dist/runtime/commands.js.map +1 -1
  35. package/dist/runtime/results.d.ts +3 -0
  36. package/dist/runtime/runner.d.ts +7 -0
  37. package/dist/runtime/runner.js +161 -14
  38. package/dist/runtime/runner.js.map +1 -1
  39. package/dist/tests/custom_functions.test.d.ts +1 -0
  40. package/dist/tests/custom_functions.test.js +129 -0
  41. package/dist/tests/custom_functions.test.js.map +1 -0
  42. package/dist/tests/markup.test.d.ts +1 -0
  43. package/dist/tests/markup.test.js +46 -0
  44. package/dist/tests/markup.test.js.map +1 -0
  45. package/dist/tests/nodes_lines.test.js +25 -1
  46. package/dist/tests/nodes_lines.test.js.map +1 -1
  47. package/dist/tests/options.test.js +30 -1
  48. package/dist/tests/options.test.js.map +1 -1
  49. package/dist/tests/story_end.test.d.ts +1 -0
  50. package/dist/tests/story_end.test.js +37 -0
  51. package/dist/tests/story_end.test.js.map +1 -0
  52. package/dist/tests/typing-text.test.d.ts +1 -0
  53. package/dist/tests/typing-text.test.js +12 -0
  54. package/dist/tests/typing-text.test.js.map +1 -0
  55. package/docs/actor-transition.md +34 -0
  56. package/docs/markup.md +34 -19
  57. package/docs/scenes-actors-setup.md +1 -0
  58. package/docs/typing-animation.md +44 -0
  59. package/eslint.config.cjs +3 -0
  60. package/examples/browser/index.html +1 -1
  61. package/examples/browser/main.tsx +0 -2
  62. package/package.json +1 -1
  63. package/src/compile/compiler.ts +4 -4
  64. package/src/compile/ir.ts +3 -2
  65. package/src/index.ts +3 -0
  66. package/src/markup/parser.ts +372 -0
  67. package/src/markup/types.ts +22 -0
  68. package/src/model/ast.ts +17 -13
  69. package/src/parse/parser.ts +60 -8
  70. package/src/react/DialogueExample.tsx +27 -51
  71. package/src/react/DialogueScene.tsx +143 -44
  72. package/src/react/DialogueView.tsx +150 -14
  73. package/src/react/MarkupRenderer.tsx +110 -0
  74. package/src/react/TypingText.tsx +127 -0
  75. package/src/react/dialogue.css +26 -13
  76. package/src/react/useYarnRunner.tsx +13 -1
  77. package/src/runtime/commands.ts +14 -1
  78. package/src/runtime/results.ts +3 -1
  79. package/src/runtime/runner.ts +170 -14
  80. package/src/tests/custom_functions.test.ts +140 -0
  81. package/src/tests/markup.test.ts +62 -0
  82. package/src/tests/nodes_lines.test.ts +35 -1
  83. package/src/tests/options.test.ts +39 -1
  84. package/src/tests/story_end.test.ts +42 -0
@@ -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
-
@@ -1,15 +1,33 @@
1
- import React from "react";
2
- import type { RuntimeResult } from "../runtime/results.js";
1
+ import React, { useRef, useEffect, useState } from "react";
3
2
  import { DialogueScene } from "./DialogueScene.js";
4
3
  import type { SceneCollection } from "../scene/types.js";
4
+ import { TypingText } from "./TypingText.js";
5
+ import { useYarnRunner } from "./useYarnRunner.js";
6
+ import { MarkupRenderer } from "./MarkupRenderer.js";
5
7
  // Note: CSS is imported in the browser demo entry point (examples/browser/main.tsx)
6
8
  // This prevents Node.js from trying to resolve CSS imports during tests
7
9
 
10
+ import type { IRProgram } from "../compile/ir.js";
11
+
8
12
  export interface DialogueViewProps {
9
- result: RuntimeResult | null;
10
- onAdvance: (optionIndex?: number) => void;
13
+ program: IRProgram;
14
+ startNode?: string;
11
15
  className?: string;
12
16
  scenes?: SceneCollection;
17
+ actorTransitionDuration?: number;
18
+ // Custom functions and callbacks
19
+ functions?: Record<string, (...args: unknown[]) => unknown>;
20
+ onStoryEnd?: (info: { variables: Readonly<Record<string, unknown>>; storyEnd: true }) => void;
21
+ // Typing animation options
22
+ enableTypingAnimation?: boolean;
23
+ typingSpeed?: number;
24
+ showTypingCursor?: boolean;
25
+ cursorCharacter?: string;
26
+ // Auto-advance after typing completes
27
+ autoAdvanceAfterTyping?: boolean;
28
+ autoAdvanceDelay?: number; // Delay in ms after typing completes before auto-advancing
29
+ // Pause before advance
30
+ pauseBeforeAdvance?: number; // Delay in ms before advancing when clicking (0 = no pause)
13
31
  }
14
32
 
15
33
  // Helper to parse CSS string into object
@@ -69,11 +87,68 @@ function parseCss(cssStr: string | undefined): React.CSSProperties {
69
87
  return styles;
70
88
  }
71
89
 
72
- export function DialogueView({ result, onAdvance, className, scenes }: DialogueViewProps) {
90
+ export function DialogueView({
91
+ program,
92
+ startNode = "Start",
93
+ className,
94
+ scenes,
95
+ actorTransitionDuration = 350,
96
+ functions,
97
+ onStoryEnd,
98
+ enableTypingAnimation = false,
99
+ typingSpeed = 50, // Characters per second (50 cps = ~20ms per character)
100
+ showTypingCursor = true,
101
+ cursorCharacter = "|",
102
+ autoAdvanceAfterTyping = false,
103
+ autoAdvanceDelay = 500,
104
+ pauseBeforeAdvance = 0,
105
+ }: DialogueViewProps) {
106
+ const { result, advance } = useYarnRunner(program, {
107
+ startAt: startNode,
108
+ functions,
109
+ variables: {},
110
+ onStoryEnd,
111
+ });
73
112
  const sceneName = result?.type === "text" || result?.type === "options" ? result.scene : undefined;
74
113
  const speaker = result?.type === "text" ? result.speaker : undefined;
75
114
  const sceneCollection = scenes || { scenes: {} };
76
115
 
116
+ const [typingComplete, setTypingComplete] = useState(false);
117
+ const [currentTextKey, setCurrentTextKey] = useState(0);
118
+ const [skipTyping, setSkipTyping] = useState(false);
119
+ const advanceTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
120
+
121
+ // Reset typing completion when text changes
122
+ useEffect(() => {
123
+ if (result?.type === "text") {
124
+ setTypingComplete(false);
125
+ setSkipTyping(false);
126
+ setCurrentTextKey((prev) => prev + 1); // Force re-render of TypingText
127
+ }
128
+ // Cleanup any pending advance timeouts when text changes
129
+ return () => {
130
+ if (advanceTimeoutRef.current) {
131
+ clearTimeout(advanceTimeoutRef.current);
132
+ advanceTimeoutRef.current = null;
133
+ }
134
+ };
135
+ }, [result?.type === "text" ? result.text : null]);
136
+
137
+ // Handle auto-advance after typing completes
138
+ useEffect(() => {
139
+ if (
140
+ autoAdvanceAfterTyping &&
141
+ typingComplete &&
142
+ result?.type === "text" &&
143
+ !result.isDialogueEnd
144
+ ) {
145
+ const timer = setTimeout(() => {
146
+ advance();
147
+ }, autoAdvanceDelay);
148
+ return () => clearTimeout(timer);
149
+ }
150
+ }, [autoAdvanceAfterTyping, typingComplete, result, advance, autoAdvanceDelay]);
151
+
77
152
  if (!result) {
78
153
  return (
79
154
  <div className={`yd-empty ${className || ""}`}>
@@ -84,13 +159,56 @@ export function DialogueView({ result, onAdvance, className, scenes }: DialogueV
84
159
 
85
160
  if (result.type === "text") {
86
161
  const nodeStyles = parseCss(result.nodeCss);
162
+ const displayText = result.text || "\u00A0";
163
+ const shouldShowContinue = !result.isDialogueEnd && !enableTypingAnimation;
164
+
165
+ // Handle story end and call onStoryEnd if provided
166
+ if (result.isDialogueEnd && onStoryEnd && 'variables' in result) {
167
+ onStoryEnd({
168
+ variables: result.variables as Readonly<Record<string, unknown>>,
169
+ storyEnd: true
170
+ });
171
+ }
172
+
173
+ const handleClick = () => {
174
+ if (result.isDialogueEnd) return;
175
+
176
+ // If typing is in progress, skip it; otherwise advance
177
+ if (enableTypingAnimation && !typingComplete) {
178
+ // Skip typing animation
179
+ setSkipTyping(true);
180
+ setTypingComplete(true);
181
+ } else {
182
+ // Clear any pending timeout
183
+ if (advanceTimeoutRef.current) {
184
+ clearTimeout(advanceTimeoutRef.current);
185
+ advanceTimeoutRef.current = null;
186
+ }
187
+
188
+ // Apply pause before advance if configured
189
+ if (pauseBeforeAdvance > 0) {
190
+ advanceTimeoutRef.current = setTimeout(() => {
191
+ advance();
192
+ advanceTimeoutRef.current = null;
193
+ }, pauseBeforeAdvance);
194
+ } else {
195
+ advance();
196
+ }
197
+ }
198
+ };
199
+
87
200
  return (
88
201
  <div className="yd-container">
89
- <DialogueScene sceneName={sceneName} speaker={speaker} scenes={sceneCollection} />
202
+ <DialogueScene
203
+ sceneName={sceneName}
204
+ speaker={speaker}
205
+ scenes={sceneCollection}
206
+ actorTransitionDuration={actorTransitionDuration}
207
+ />
90
208
  <div
91
209
  className={`yd-dialogue-box ${result.isDialogueEnd ? "yd-text-box-end" : ""} ${className || ""}`}
92
210
  style={nodeStyles} // Only apply dynamic node CSS
93
- onClick={() => !result.isDialogueEnd && onAdvance()}
211
+ onClick={handleClick}
94
212
  >
95
213
  <div className="yd-text-box">
96
214
  {result.speaker && (
@@ -99,9 +217,22 @@ export function DialogueView({ result, onAdvance, className, scenes }: DialogueV
99
217
  </div>
100
218
  )}
101
219
  <p className={`yd-text ${result.speaker ? "yd-text-with-speaker" : ""}`}>
102
- {result.text || "\u00A0"}
220
+ {enableTypingAnimation ? (
221
+ <TypingText
222
+ key={currentTextKey}
223
+ text={displayText}
224
+ markup={result.markup}
225
+ typingSpeed={typingSpeed}
226
+ showCursor={showTypingCursor}
227
+ cursorCharacter={cursorCharacter}
228
+ disabled={skipTyping}
229
+ onComplete={() => setTypingComplete(true)}
230
+ />
231
+ ) : (
232
+ <MarkupRenderer text={displayText} markup={result.markup} />
233
+ )}
103
234
  </p>
104
- {!result.isDialogueEnd && (
235
+ {shouldShowContinue && (
105
236
  <div className="yd-continue">
106
237
 
107
238
  </div>
@@ -116,7 +247,12 @@ export function DialogueView({ result, onAdvance, className, scenes }: DialogueV
116
247
  const nodeStyles = parseCss(result.nodeCss);
117
248
  return (
118
249
  <div className="yd-container">
119
- <DialogueScene sceneName={sceneName} speaker={speaker} scenes={sceneCollection} />
250
+ <DialogueScene
251
+ sceneName={sceneName}
252
+ speaker={speaker}
253
+ scenes={sceneCollection}
254
+ actorTransitionDuration={actorTransitionDuration}
255
+ />
120
256
  <div className={`yd-options-container ${className || ""}`}>
121
257
  <div className="yd-options-box" style={nodeStyles}>
122
258
  <div className="yd-options-title">Choose an option:</div>
@@ -127,10 +263,10 @@ export function DialogueView({ result, onAdvance, className, scenes }: DialogueV
127
263
  <button
128
264
  key={index}
129
265
  className="yd-option-button"
130
- onClick={() => onAdvance(index)}
266
+ onClick={() => advance(index)}
131
267
  style={optionStyles} // Only apply dynamic option CSS
132
268
  >
133
- {option.text}
269
+ <MarkupRenderer text={option.text} markup={option.markup} />
134
270
  </button>
135
271
  );
136
272
  })}
@@ -145,9 +281,9 @@ export function DialogueView({ result, onAdvance, className, scenes }: DialogueV
145
281
  if (result.type === "command") {
146
282
  // Auto-advance commands after a brief moment
147
283
  React.useEffect(() => {
148
- const timer = setTimeout(() => onAdvance(), 50);
284
+ const timer = setTimeout(() => advance(), 50);
149
285
  return () => clearTimeout(timer);
150
- }, [result.command, onAdvance]);
286
+ }, [result.command, advance]);
151
287
 
152
288
  return (
153
289
  <div className={`yd-command ${className || ""}`}>
@@ -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
+
@@ -0,0 +1,127 @@
1
+ import React, { useState, useEffect, useRef } from "react";
2
+ import type { MarkupParseResult } from "../markup/types.js";
3
+ import { MarkupRenderer } from "./MarkupRenderer.js";
4
+
5
+ export interface TypingTextProps {
6
+ text: string;
7
+ markup?: MarkupParseResult;
8
+ typingSpeed?: number;
9
+ showCursor?: boolean;
10
+ cursorCharacter?: string;
11
+ cursorBlinkDuration?: number;
12
+ cursorClassName?: string;
13
+ className?: string;
14
+ onComplete?: () => void;
15
+ disabled?: boolean;
16
+ }
17
+
18
+ export function TypingText({
19
+ text,
20
+ markup,
21
+ typingSpeed = 100,
22
+ showCursor = true,
23
+ cursorCharacter = "|",
24
+ cursorBlinkDuration = 530,
25
+ cursorClassName = "",
26
+ className = "",
27
+ onComplete,
28
+ disabled = false,
29
+ }: TypingTextProps) {
30
+ const [displayedLength, setDisplayedLength] = useState(disabled ? text.length : 0);
31
+ const [cursorVisible, setCursorVisible] = useState(true);
32
+ const cursorIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
33
+ const typingTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
34
+ const onCompleteRef = useRef(onComplete);
35
+
36
+ useEffect(() => {
37
+ onCompleteRef.current = onComplete;
38
+ }, [onComplete]);
39
+
40
+ // Handle cursor blinking
41
+ useEffect(() => {
42
+ if (!showCursor || disabled) {
43
+ return;
44
+ }
45
+ cursorIntervalRef.current = setInterval(() => {
46
+ setCursorVisible((prev) => !prev);
47
+ }, cursorBlinkDuration);
48
+ return () => {
49
+ if (cursorIntervalRef.current) {
50
+ clearInterval(cursorIntervalRef.current);
51
+ }
52
+ };
53
+ }, [showCursor, cursorBlinkDuration, disabled]);
54
+
55
+ // Handle typing animation
56
+ useEffect(() => {
57
+ if (disabled) {
58
+ setDisplayedLength(text.length);
59
+ if (onCompleteRef.current && text.length > 0) {
60
+ onCompleteRef.current();
61
+ }
62
+ return;
63
+ }
64
+
65
+ // Reset when text changes
66
+ setDisplayedLength(0);
67
+
68
+ if (text.length === 0) {
69
+ if (onCompleteRef.current) {
70
+ onCompleteRef.current();
71
+ }
72
+ return;
73
+ }
74
+
75
+ let index = 0;
76
+ const typeNextCharacter = () => {
77
+ if (index < text.length) {
78
+ index += 1;
79
+ setDisplayedLength(index);
80
+ if (typingSpeed <= 0) {
81
+ requestAnimationFrame(typeNextCharacter);
82
+ } else {
83
+ typingTimeoutRef.current = setTimeout(typeNextCharacter, typingSpeed);
84
+ }
85
+ } else if (onCompleteRef.current) {
86
+ onCompleteRef.current();
87
+ }
88
+ };
89
+
90
+ if (typingSpeed <= 0) {
91
+ requestAnimationFrame(typeNextCharacter);
92
+ } else {
93
+ typingTimeoutRef.current = setTimeout(typeNextCharacter, typingSpeed);
94
+ }
95
+
96
+ return () => {
97
+ if (typingTimeoutRef.current) {
98
+ clearTimeout(typingTimeoutRef.current);
99
+ }
100
+ };
101
+ }, [text, disabled, typingSpeed]);
102
+
103
+ const visibleLength = markup ? Math.min(displayedLength, markup.text.length) : Math.min(displayedLength, text.length);
104
+
105
+ return (
106
+ <span className={className}>
107
+ <span>
108
+ {markup ? (
109
+ <MarkupRenderer text={text} markup={markup} length={visibleLength} />
110
+ ) : (
111
+ text.slice(0, visibleLength)
112
+ )}
113
+ </span>
114
+ {showCursor && !disabled && (
115
+ <span
116
+ className={`yd-typing-cursor ${cursorClassName}`}
117
+ style={{
118
+ opacity: cursorVisible ? 1 : 0,
119
+ transition: `opacity ${cursorBlinkDuration / 2}ms ease-in-out`,
120
+ }}
121
+ >
122
+ {cursorCharacter}
123
+ </span>
124
+ )}
125
+ </span>
126
+ );
127
+ }