yarn-spinner-runner-ts 0.1.2 → 0.1.4

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 (81) 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 +6 -4
  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 +10 -1
  23. package/dist/react/DialogueView.js +68 -5
  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/runtime/commands.js +12 -1
  32. package/dist/runtime/commands.js.map +1 -1
  33. package/dist/runtime/results.d.ts +3 -0
  34. package/dist/runtime/runner.d.ts +7 -0
  35. package/dist/runtime/runner.js +161 -14
  36. package/dist/runtime/runner.js.map +1 -1
  37. package/dist/tests/custom_functions.test.d.ts +1 -0
  38. package/dist/tests/custom_functions.test.js +129 -0
  39. package/dist/tests/custom_functions.test.js.map +1 -0
  40. package/dist/tests/markup.test.d.ts +1 -0
  41. package/dist/tests/markup.test.js +46 -0
  42. package/dist/tests/markup.test.js.map +1 -0
  43. package/dist/tests/nodes_lines.test.js +25 -1
  44. package/dist/tests/nodes_lines.test.js.map +1 -1
  45. package/dist/tests/options.test.js +30 -1
  46. package/dist/tests/options.test.js.map +1 -1
  47. package/dist/tests/story_end.test.d.ts +1 -0
  48. package/dist/tests/story_end.test.js +37 -0
  49. package/dist/tests/story_end.test.js.map +1 -0
  50. package/dist/tests/typing-text.test.d.ts +1 -0
  51. package/dist/tests/typing-text.test.js +12 -0
  52. package/dist/tests/typing-text.test.js.map +1 -0
  53. package/docs/actor-transition.md +34 -0
  54. package/docs/markup.md +34 -19
  55. package/docs/scenes-actors-setup.md +1 -0
  56. package/docs/typing-animation.md +44 -0
  57. package/eslint.config.cjs +3 -0
  58. package/examples/browser/index.html +1 -1
  59. package/examples/browser/main.tsx +0 -2
  60. package/package.json +6 -6
  61. package/src/compile/compiler.ts +4 -4
  62. package/src/compile/ir.ts +3 -2
  63. package/src/index.ts +3 -0
  64. package/src/markup/parser.ts +372 -0
  65. package/src/markup/types.ts +22 -0
  66. package/src/model/ast.ts +17 -13
  67. package/src/parse/parser.ts +60 -8
  68. package/src/react/DialogueExample.tsx +18 -42
  69. package/src/react/DialogueScene.tsx +143 -44
  70. package/src/react/DialogueView.tsx +122 -8
  71. package/src/react/MarkupRenderer.tsx +110 -0
  72. package/src/react/TypingText.tsx +127 -0
  73. package/src/react/dialogue.css +26 -13
  74. package/src/runtime/commands.ts +14 -1
  75. package/src/runtime/results.ts +3 -1
  76. package/src/runtime/runner.ts +170 -14
  77. package/src/tests/custom_functions.test.ts +140 -0
  78. package/src/tests/markup.test.ts +62 -0
  79. package/src/tests/nodes_lines.test.ts +35 -1
  80. package/src/tests/options.test.ts +39 -1
  81. 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,7 +1,9 @@
1
- import React from "react";
1
+ import React, { useRef, useEffect, useState } from "react";
2
2
  import type { RuntimeResult } from "../runtime/results.js";
3
3
  import { DialogueScene } from "./DialogueScene.js";
4
4
  import type { SceneCollection } from "../scene/types.js";
5
+ import { TypingText } from "./TypingText.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,6 +12,17 @@ export interface DialogueViewProps {
10
12
  onAdvance: (optionIndex?: number) => void;
11
13
  className?: string;
12
14
  scenes?: SceneCollection;
15
+ actorTransitionDuration?: number;
16
+ // Typing animation options
17
+ enableTypingAnimation?: boolean;
18
+ typingSpeed?: number;
19
+ showTypingCursor?: boolean;
20
+ cursorCharacter?: string;
21
+ // Auto-advance after typing completes
22
+ autoAdvanceAfterTyping?: boolean;
23
+ autoAdvanceDelay?: number; // Delay in ms after typing completes before auto-advancing
24
+ // Pause before advance
25
+ pauseBeforeAdvance?: number; // Delay in ms before advancing when clicking (0 = no pause)
13
26
  }
14
27
 
15
28
  // Helper to parse CSS string into object
@@ -69,10 +82,58 @@ function parseCss(cssStr: string | undefined): React.CSSProperties {
69
82
  return styles;
70
83
  }
71
84
 
72
- export function DialogueView({ result, onAdvance, className, scenes }: DialogueViewProps) {
85
+ export function DialogueView({
86
+ result,
87
+ onAdvance,
88
+ className,
89
+ scenes,
90
+ actorTransitionDuration = 350,
91
+ enableTypingAnimation = false,
92
+ typingSpeed = 50, // Characters per second (50 cps = ~20ms per character)
93
+ showTypingCursor = true,
94
+ cursorCharacter = "|",
95
+ autoAdvanceAfterTyping = false,
96
+ autoAdvanceDelay = 500,
97
+ pauseBeforeAdvance = 0,
98
+ }: DialogueViewProps) {
73
99
  const sceneName = result?.type === "text" || result?.type === "options" ? result.scene : undefined;
74
100
  const speaker = result?.type === "text" ? result.speaker : undefined;
75
101
  const sceneCollection = scenes || { scenes: {} };
102
+ const [typingComplete, setTypingComplete] = useState(false);
103
+ const [currentTextKey, setCurrentTextKey] = useState(0);
104
+ const [skipTyping, setSkipTyping] = useState(false);
105
+ const advanceTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
106
+
107
+ // Reset typing completion when text changes
108
+ useEffect(() => {
109
+ if (result?.type === "text") {
110
+ setTypingComplete(false);
111
+ setSkipTyping(false);
112
+ setCurrentTextKey((prev) => prev + 1); // Force re-render of TypingText
113
+ }
114
+ // Cleanup any pending advance timeouts when text changes
115
+ return () => {
116
+ if (advanceTimeoutRef.current) {
117
+ clearTimeout(advanceTimeoutRef.current);
118
+ advanceTimeoutRef.current = null;
119
+ }
120
+ };
121
+ }, [result?.type === "text" ? result.text : null]);
122
+
123
+ // Handle auto-advance after typing completes
124
+ useEffect(() => {
125
+ if (
126
+ autoAdvanceAfterTyping &&
127
+ typingComplete &&
128
+ result?.type === "text" &&
129
+ !result.isDialogueEnd
130
+ ) {
131
+ const timer = setTimeout(() => {
132
+ onAdvance();
133
+ }, autoAdvanceDelay);
134
+ return () => clearTimeout(timer);
135
+ }
136
+ }, [autoAdvanceAfterTyping, typingComplete, result, onAdvance, autoAdvanceDelay]);
76
137
 
77
138
  if (!result) {
78
139
  return (
@@ -84,13 +145,48 @@ export function DialogueView({ result, onAdvance, className, scenes }: DialogueV
84
145
 
85
146
  if (result.type === "text") {
86
147
  const nodeStyles = parseCss(result.nodeCss);
148
+ const displayText = result.text || "\u00A0";
149
+ const shouldShowContinue = !result.isDialogueEnd && !enableTypingAnimation;
150
+
151
+ const handleClick = () => {
152
+ if (result.isDialogueEnd) return;
153
+
154
+ // If typing is in progress, skip it; otherwise advance
155
+ if (enableTypingAnimation && !typingComplete) {
156
+ // Skip typing animation
157
+ setSkipTyping(true);
158
+ setTypingComplete(true);
159
+ } else {
160
+ // Clear any pending timeout
161
+ if (advanceTimeoutRef.current) {
162
+ clearTimeout(advanceTimeoutRef.current);
163
+ advanceTimeoutRef.current = null;
164
+ }
165
+
166
+ // Apply pause before advance if configured
167
+ if (pauseBeforeAdvance > 0) {
168
+ advanceTimeoutRef.current = setTimeout(() => {
169
+ onAdvance();
170
+ advanceTimeoutRef.current = null;
171
+ }, pauseBeforeAdvance);
172
+ } else {
173
+ onAdvance();
174
+ }
175
+ }
176
+ };
177
+
87
178
  return (
88
179
  <div className="yd-container">
89
- <DialogueScene sceneName={sceneName} speaker={speaker} scenes={sceneCollection} />
180
+ <DialogueScene
181
+ sceneName={sceneName}
182
+ speaker={speaker}
183
+ scenes={sceneCollection}
184
+ actorTransitionDuration={actorTransitionDuration}
185
+ />
90
186
  <div
91
187
  className={`yd-dialogue-box ${result.isDialogueEnd ? "yd-text-box-end" : ""} ${className || ""}`}
92
188
  style={nodeStyles} // Only apply dynamic node CSS
93
- onClick={() => !result.isDialogueEnd && onAdvance()}
189
+ onClick={handleClick}
94
190
  >
95
191
  <div className="yd-text-box">
96
192
  {result.speaker && (
@@ -99,9 +195,22 @@ export function DialogueView({ result, onAdvance, className, scenes }: DialogueV
99
195
  </div>
100
196
  )}
101
197
  <p className={`yd-text ${result.speaker ? "yd-text-with-speaker" : ""}`}>
102
- {result.text || "\u00A0"}
198
+ {enableTypingAnimation ? (
199
+ <TypingText
200
+ key={currentTextKey}
201
+ text={displayText}
202
+ markup={result.markup}
203
+ typingSpeed={typingSpeed}
204
+ showCursor={showTypingCursor}
205
+ cursorCharacter={cursorCharacter}
206
+ disabled={skipTyping}
207
+ onComplete={() => setTypingComplete(true)}
208
+ />
209
+ ) : (
210
+ <MarkupRenderer text={displayText} markup={result.markup} />
211
+ )}
103
212
  </p>
104
- {!result.isDialogueEnd && (
213
+ {shouldShowContinue && (
105
214
  <div className="yd-continue">
106
215
 
107
216
  </div>
@@ -116,7 +225,12 @@ export function DialogueView({ result, onAdvance, className, scenes }: DialogueV
116
225
  const nodeStyles = parseCss(result.nodeCss);
117
226
  return (
118
227
  <div className="yd-container">
119
- <DialogueScene sceneName={sceneName} speaker={speaker} scenes={sceneCollection} />
228
+ <DialogueScene
229
+ sceneName={sceneName}
230
+ speaker={speaker}
231
+ scenes={sceneCollection}
232
+ actorTransitionDuration={actorTransitionDuration}
233
+ />
120
234
  <div className={`yd-options-container ${className || ""}`}>
121
235
  <div className="yd-options-box" style={nodeStyles}>
122
236
  <div className="yd-options-title">Choose an option:</div>
@@ -130,7 +244,7 @@ export function DialogueView({ result, onAdvance, className, scenes }: DialogueV
130
244
  onClick={() => onAdvance(index)}
131
245
  style={optionStyles} // Only apply dynamic option CSS
132
246
  >
133
- {option.text}
247
+ <MarkupRenderer text={option.text} markup={option.markup} />
134
248
  </button>
135
249
  );
136
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
+
@@ -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
+ }
@@ -43,19 +43,32 @@
43
43
  }
44
44
 
45
45
  /* Actor image */
46
- .yd-actor {
47
- position: absolute;
48
- top: 0;
49
- left: 50%;
50
- transform: translateX(-50%);
51
- max-height: 70%;
52
- max-width: 40%;
53
- object-fit: contain;
54
- z-index: 1;
55
- transition: opacity 0.3s ease-in-out;
56
- opacity: 1;
57
- pointer-events: none;
58
- }
46
+ .yd-actor {
47
+ position: absolute;
48
+ top: 0;
49
+ left: 50%;
50
+ transform: translateX(-50%);
51
+ max-height: 70%;
52
+ max-width: 40%;
53
+ object-fit: contain;
54
+ opacity: 0;
55
+ transition: opacity var(--yd-actor-transition, 0.35s) ease-in-out;
56
+ pointer-events: none;
57
+ z-index: 1;
58
+ will-change: opacity;
59
+ }
60
+
61
+ .yd-actor--current {
62
+ z-index: 2;
63
+ }
64
+
65
+ .yd-actor--previous {
66
+ z-index: 1;
67
+ }
68
+
69
+ .yd-actor--visible {
70
+ opacity: 1;
71
+ }
59
72
 
60
73
  /* Dialogue box container */
61
74
  .yd-dialogue-box {