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.
- package/README.md +5 -2
- package/dist/compile/compiler.js +4 -4
- package/dist/compile/compiler.js.map +1 -1
- package/dist/compile/ir.d.ts +3 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/markup/parser.d.ts +3 -0
- package/dist/markup/parser.js +332 -0
- package/dist/markup/parser.js.map +1 -0
- package/dist/markup/types.d.ts +17 -0
- package/dist/markup/types.js +2 -0
- package/dist/markup/types.js.map +1 -0
- package/dist/model/ast.d.ts +3 -0
- package/dist/parse/parser.js +57 -8
- package/dist/parse/parser.js.map +1 -1
- package/dist/react/DialogueExample.js +4 -3
- package/dist/react/DialogueExample.js.map +1 -1
- package/dist/react/DialogueScene.d.ts +2 -1
- package/dist/react/DialogueScene.js +95 -26
- package/dist/react/DialogueScene.js.map +1 -1
- package/dist/react/DialogueView.d.ts +2 -1
- package/dist/react/DialogueView.js +5 -4
- package/dist/react/DialogueView.js.map +1 -1
- package/dist/react/MarkupRenderer.d.ts +8 -0
- package/dist/react/MarkupRenderer.js +64 -0
- package/dist/react/MarkupRenderer.js.map +1 -0
- package/dist/react/TypingText.d.ts +3 -1
- package/dist/react/TypingText.js +16 -40
- package/dist/react/TypingText.js.map +1 -1
- package/dist/runtime/results.d.ts +3 -0
- package/dist/runtime/runner.d.ts +1 -0
- package/dist/runtime/runner.js +151 -14
- package/dist/runtime/runner.js.map +1 -1
- package/dist/tests/markup.test.d.ts +1 -0
- package/dist/tests/markup.test.js +46 -0
- package/dist/tests/markup.test.js.map +1 -0
- package/dist/tests/nodes_lines.test.js +25 -1
- package/dist/tests/nodes_lines.test.js.map +1 -1
- package/dist/tests/options.test.js +30 -1
- package/dist/tests/options.test.js.map +1 -1
- package/docs/actor-transition.md +34 -0
- package/docs/markup.md +34 -19
- package/docs/scenes-actors-setup.md +1 -0
- package/package.json +1 -1
- package/src/compile/compiler.ts +4 -4
- package/src/compile/ir.ts +3 -2
- package/src/index.ts +3 -0
- package/src/markup/parser.ts +372 -0
- package/src/markup/types.ts +22 -0
- package/src/model/ast.ts +17 -13
- package/src/parse/parser.ts +60 -8
- package/src/react/DialogueExample.tsx +4 -2
- package/src/react/DialogueScene.tsx +143 -44
- package/src/react/DialogueView.tsx +19 -5
- package/src/react/MarkupRenderer.tsx +110 -0
- package/src/react/TypingText.tsx +25 -30
- package/src/react/dialogue.css +26 -13
- package/src/runtime/results.ts +3 -1
- package/src/runtime/runner.ts +158 -14
- package/src/tests/markup.test.ts +62 -0
- package/src/tests/nodes_lines.test.ts +35 -1
- package/src/tests/options.test.ts +39 -1
package/src/parse/parser.ts
CHANGED
|
@@ -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:
|
|
181
|
-
const
|
|
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
|
-
|
|
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 {
|
|
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({
|
|
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
|
-
|
|
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({
|
|
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
|
-
|
|
29
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
|
91
|
-
{
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
|
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
|
|
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
|
+
|
package/src/react/TypingText.tsx
CHANGED
|
@@ -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 [
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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>
|
|
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}`}
|