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

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.
@@ -1,297 +1,312 @@
1
- import React, { useRef, useEffect, useState } from "react";
2
- import { DialogueScene } from "./DialogueScene.js";
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";
7
- // Note: CSS is imported in the browser demo entry point (examples/browser/main.tsx)
8
- // This prevents Node.js from trying to resolve CSS imports during tests
9
-
10
- import type { IRProgram } from "../compile/ir.js";
11
-
12
- export interface DialogueViewProps {
13
- program: IRProgram;
14
- startNode?: string;
15
- className?: string;
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)
31
- }
32
-
33
- // Helper to parse CSS string into object
34
- function parseCss(cssStr: string | undefined): React.CSSProperties {
35
- if (!cssStr) return {};
36
- const styles: React.CSSProperties = {};
37
- // Improved parser: handles quoted values and commas
38
- // Split by semicolon, but preserve quoted strings
39
- const rules: string[] = [];
40
- let currentRule = "";
41
- let inQuotes = false;
42
- let quoteChar = "";
43
-
44
- for (let i = 0; i < cssStr.length; i++) {
45
- const char = cssStr[i];
46
- if ((char === '"' || char === "'") && !inQuotes) {
47
- inQuotes = true;
48
- quoteChar = char;
49
- currentRule += char;
50
- } else if (char === quoteChar && inQuotes) {
51
- inQuotes = false;
52
- quoteChar = "";
53
- currentRule += char;
54
- } else if (char === ";" && !inQuotes) {
55
- rules.push(currentRule.trim());
56
- currentRule = "";
57
- } else {
58
- currentRule += char;
59
- }
60
- }
61
- if (currentRule.trim()) {
62
- rules.push(currentRule.trim());
63
- }
64
-
65
- rules.forEach((rule) => {
66
- if (!rule) return;
67
- const colonIndex = rule.indexOf(":");
68
- if (colonIndex === -1) return;
69
- const prop = rule.slice(0, colonIndex).trim();
70
- const value = rule.slice(colonIndex + 1).trim();
71
- if (prop && value) {
72
- // Convert kebab-case to camelCase
73
- const camelProp = prop.replace(/-([a-z])/g, (g) => g[1].toUpperCase());
74
- // Remove quotes from value if present, and strip !important (React doesn't support it)
75
- let cleanValue = value.trim();
76
- if (cleanValue.endsWith("!important")) {
77
- cleanValue = cleanValue.slice(0, -10).trim();
78
- }
79
- if (cleanValue.startsWith('"') && cleanValue.endsWith('"')) {
80
- cleanValue = cleanValue.slice(1, -1);
81
- } else if (cleanValue.startsWith("'") && cleanValue.endsWith("'")) {
82
- cleanValue = cleanValue.slice(1, -1);
83
- }
84
- (styles as any)[camelProp] = cleanValue;
85
- }
86
- });
87
- return styles;
88
- }
89
-
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
- });
112
- const sceneName = result?.type === "text" || result?.type === "options" ? result.scene : undefined;
113
- const speaker = result?.type === "text" ? result.speaker : undefined;
114
- const sceneCollection = scenes || { scenes: {} };
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
-
152
- if (!result) {
153
- return (
154
- <div className={`yd-empty ${className || ""}`}>
155
- <p>Dialogue ended or not started.</p>
156
- </div>
157
- );
158
- }
159
-
160
- if (result.type === "text") {
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
-
200
- return (
201
- <div className="yd-container">
202
- <DialogueScene
203
- sceneName={sceneName}
204
- speaker={speaker}
205
- scenes={sceneCollection}
206
- actorTransitionDuration={actorTransitionDuration}
207
- />
208
- <div
209
- className={`yd-dialogue-box ${result.isDialogueEnd ? "yd-text-box-end" : ""} ${className || ""}`}
210
- style={nodeStyles} // Only apply dynamic node CSS
211
- onClick={handleClick}
212
- >
213
- <div className="yd-text-box">
214
- {result.speaker && (
215
- <div className="yd-speaker">
216
- {result.speaker}
217
- </div>
218
- )}
219
- <p className={`yd-text ${result.speaker ? "yd-text-with-speaker" : ""}`}>
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
- )}
234
- </p>
235
- {shouldShowContinue && (
236
- <div className="yd-continue">
237
-
238
- </div>
239
- )}
240
- </div>
241
- </div>
242
- </div>
243
- );
244
- }
245
-
246
- if (result.type === "options") {
247
- const nodeStyles = parseCss(result.nodeCss);
248
- return (
249
- <div className="yd-container">
250
- <DialogueScene
251
- sceneName={sceneName}
252
- speaker={speaker}
253
- scenes={sceneCollection}
254
- actorTransitionDuration={actorTransitionDuration}
255
- />
256
- <div className={`yd-options-container ${className || ""}`}>
257
- <div className="yd-options-box" style={nodeStyles}>
258
- <div className="yd-options-title">Choose an option:</div>
259
- <div className="yd-options-list">
260
- {result.options.map((option, index) => {
261
- const optionStyles = parseCss(option.css);
262
- return (
263
- <button
264
- key={index}
265
- className="yd-option-button"
266
- onClick={() => advance(index)}
267
- style={optionStyles} // Only apply dynamic option CSS
268
- >
269
- <MarkupRenderer text={option.text} markup={option.markup} />
270
- </button>
271
- );
272
- })}
273
- </div>
274
- </div>
275
- </div>
276
- </div>
277
- );
278
- }
279
-
280
- // Command result - auto-advance
281
- if (result.type === "command") {
282
- // Auto-advance commands after a brief moment
283
- React.useEffect(() => {
284
- const timer = setTimeout(() => advance(), 50);
285
- return () => clearTimeout(timer);
286
- }, [result.command, advance]);
287
-
288
- return (
289
- <div className={`yd-command ${className || ""}`}>
290
- <p>Executing: {result.command}</p>
291
- </div>
292
- );
293
- }
294
-
295
- return null;
296
- }
297
-
1
+ import React, { useRef, useEffect, useState } from "react";
2
+ import { DialogueScene } from "./DialogueScene.js";
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";
7
+ // Note: CSS is imported in the browser demo entry point (examples/browser/main.tsx)
8
+ // This prevents Node.js from trying to resolve CSS imports during tests
9
+
10
+ import type { IRProgram } from "../compile/ir.js";
11
+
12
+ export interface DialogueViewProps {
13
+ program: IRProgram;
14
+ startNode?: string;
15
+ className?: string;
16
+ scenes?: SceneCollection;
17
+ actorTransitionDuration?: number;
18
+ // Custom functions and callbacks
19
+ functions?: Record<string, (...args: unknown[]) => unknown>;
20
+ variables?: Record<string, unknown>;
21
+ onStoryEnd?: (info: { variables: Readonly<Record<string, unknown>>; storyEnd: true }) => void;
22
+ // Typing animation options
23
+ enableTypingAnimation?: boolean;
24
+ typingSpeed?: number;
25
+ showTypingCursor?: boolean;
26
+ cursorCharacter?: string;
27
+ // Auto-advance after typing completes
28
+ autoAdvanceAfterTyping?: boolean;
29
+ autoAdvanceDelay?: number; // Delay in ms after typing completes before auto-advancing
30
+ // Pause before advance
31
+ pauseBeforeAdvance?: number; // Delay in ms before advancing when clicking (0 = no pause)
32
+ }
33
+
34
+ // Helper to parse CSS string into object
35
+ function parseCss(cssStr: string | undefined): React.CSSProperties {
36
+ if (!cssStr) return {};
37
+ const styles: React.CSSProperties = {};
38
+ // Improved parser: handles quoted values and commas
39
+ // Split by semicolon, but preserve quoted strings
40
+ const rules: string[] = [];
41
+ let currentRule = "";
42
+ let inQuotes = false;
43
+ let quoteChar = "";
44
+
45
+ for (let i = 0; i < cssStr.length; i++) {
46
+ const char = cssStr[i];
47
+ if ((char === '"' || char === "'") && !inQuotes) {
48
+ inQuotes = true;
49
+ quoteChar = char;
50
+ currentRule += char;
51
+ } else if (char === quoteChar && inQuotes) {
52
+ inQuotes = false;
53
+ quoteChar = "";
54
+ currentRule += char;
55
+ } else if (char === ";" && !inQuotes) {
56
+ rules.push(currentRule.trim());
57
+ currentRule = "";
58
+ } else {
59
+ currentRule += char;
60
+ }
61
+ }
62
+ if (currentRule.trim()) {
63
+ rules.push(currentRule.trim());
64
+ }
65
+
66
+ rules.forEach((rule) => {
67
+ if (!rule) return;
68
+ const colonIndex = rule.indexOf(":");
69
+ if (colonIndex === -1) return;
70
+ const prop = rule.slice(0, colonIndex).trim();
71
+ const value = rule.slice(colonIndex + 1).trim();
72
+ if (prop && value) {
73
+ // Convert kebab-case to camelCase
74
+ const camelProp = prop.replace(/-([a-z])/g, (g) => g[1].toUpperCase());
75
+ // Remove quotes from value if present, and strip !important (React doesn't support it)
76
+ let cleanValue = value.trim();
77
+ if (cleanValue.endsWith("!important")) {
78
+ cleanValue = cleanValue.slice(0, -10).trim();
79
+ }
80
+ if (cleanValue.startsWith('"') && cleanValue.endsWith('"')) {
81
+ cleanValue = cleanValue.slice(1, -1);
82
+ } else if (cleanValue.startsWith("'") && cleanValue.endsWith("'")) {
83
+ cleanValue = cleanValue.slice(1, -1);
84
+ }
85
+ (styles as any)[camelProp] = cleanValue;
86
+ }
87
+ });
88
+ return styles;
89
+ }
90
+
91
+ export function DialogueView({
92
+ program,
93
+ startNode = "Start",
94
+ className,
95
+ scenes,
96
+ actorTransitionDuration = 350,
97
+ functions,
98
+ variables,
99
+ onStoryEnd,
100
+ enableTypingAnimation = false,
101
+ typingSpeed = 50, // Characters per second (50 cps = ~20ms per character)
102
+ showTypingCursor = true,
103
+ cursorCharacter = "|",
104
+ autoAdvanceAfterTyping = false,
105
+ autoAdvanceDelay = 500,
106
+ pauseBeforeAdvance = 0,
107
+ }: DialogueViewProps) {
108
+ const { result, advance, runner } = useYarnRunner(program, {
109
+ startAt: startNode,
110
+ functions,
111
+ variables,
112
+ });
113
+ const sceneName = result?.type === "text" || result?.type === "options" ? result.scene : undefined;
114
+ const speaker = result?.type === "text" ? result.speaker : undefined;
115
+ const sceneCollection = scenes || { scenes: {} };
116
+
117
+ const [typingComplete, setTypingComplete] = useState(false);
118
+ const [currentTextKey, setCurrentTextKey] = useState(0);
119
+ const [skipTyping, setSkipTyping] = useState(false);
120
+ const advanceTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
121
+ const storyEndTriggeredRef = useRef(false);
122
+
123
+ useEffect(() => {
124
+ storyEndTriggeredRef.current = false;
125
+ }, [program, startNode]);
126
+
127
+ useEffect(() => {
128
+ if (!result || result.type !== "command") {
129
+ return;
130
+ }
131
+ const timer = setTimeout(() => advance(), 50);
132
+ return () => clearTimeout(timer);
133
+ }, [result, advance]);
134
+
135
+ useEffect(() => {
136
+ if (!onStoryEnd || !result || storyEndTriggeredRef.current) {
137
+ return;
138
+ }
139
+ if (!result.isDialogueEnd) {
140
+ return;
141
+ }
142
+ if (result.type === "options") {
143
+ return;
144
+ }
145
+
146
+ storyEndTriggeredRef.current = true;
147
+ const variablesSnapshot = Object.freeze({ ...(runner?.getVariables?.() ?? {}) });
148
+ onStoryEnd({ storyEnd: true, variables: variablesSnapshot });
149
+ }, [result, onStoryEnd, runner]);
150
+
151
+ // Reset typing completion when text changes
152
+ useEffect(() => {
153
+ if (result?.type === "text") {
154
+ setTypingComplete(false);
155
+ setSkipTyping(false);
156
+ setCurrentTextKey((prev) => prev + 1); // Force re-render of TypingText
157
+ }
158
+ // Cleanup any pending advance timeouts when text changes
159
+ return () => {
160
+ if (advanceTimeoutRef.current) {
161
+ clearTimeout(advanceTimeoutRef.current);
162
+ advanceTimeoutRef.current = null;
163
+ }
164
+ };
165
+ }, [result?.type === "text" ? result.text : null]);
166
+
167
+ // Handle auto-advance after typing completes
168
+ useEffect(() => {
169
+ if (
170
+ autoAdvanceAfterTyping &&
171
+ typingComplete &&
172
+ result?.type === "text" &&
173
+ !result.isDialogueEnd
174
+ ) {
175
+ const timer = setTimeout(() => {
176
+ advance();
177
+ }, autoAdvanceDelay);
178
+ return () => clearTimeout(timer);
179
+ }
180
+ }, [autoAdvanceAfterTyping, typingComplete, result, advance, autoAdvanceDelay]);
181
+
182
+ if (!result) {
183
+ return (
184
+ <div className={`yd-empty ${className || ""}`}>
185
+ <p>Dialogue ended or not started.</p>
186
+ </div>
187
+ );
188
+ }
189
+
190
+ if (result.type === "text") {
191
+ const nodeStyles = parseCss(result.nodeCss);
192
+ const displayText = result.text || "\u00A0";
193
+ const shouldShowContinue = !result.isDialogueEnd && !enableTypingAnimation;
194
+
195
+ const handleClick = () => {
196
+ if (result.isDialogueEnd) return;
197
+
198
+ // If typing is in progress, skip it; otherwise advance
199
+ if (enableTypingAnimation && !typingComplete) {
200
+ // Skip typing animation
201
+ setSkipTyping(true);
202
+ setTypingComplete(true);
203
+ } else {
204
+ // Clear any pending timeout
205
+ if (advanceTimeoutRef.current) {
206
+ clearTimeout(advanceTimeoutRef.current);
207
+ advanceTimeoutRef.current = null;
208
+ }
209
+
210
+ // Apply pause before advance if configured
211
+ if (pauseBeforeAdvance > 0) {
212
+ advanceTimeoutRef.current = setTimeout(() => {
213
+ advance();
214
+ advanceTimeoutRef.current = null;
215
+ }, pauseBeforeAdvance);
216
+ } else {
217
+ advance();
218
+ }
219
+ }
220
+ };
221
+
222
+ return (
223
+ <div className="yd-container">
224
+ <DialogueScene
225
+ sceneName={sceneName}
226
+ speaker={speaker}
227
+ scenes={sceneCollection}
228
+ actorTransitionDuration={actorTransitionDuration}
229
+ />
230
+ <div
231
+ className={`yd-dialogue-box ${result.isDialogueEnd ? "yd-text-box-end" : ""} ${className || ""}`}
232
+ style={nodeStyles} // Only apply dynamic node CSS
233
+ onClick={handleClick}
234
+ >
235
+ <div className="yd-text-box">
236
+ {result.speaker && (
237
+ <div className="yd-speaker">
238
+ {result.speaker}
239
+ </div>
240
+ )}
241
+ <p className={`yd-text ${result.speaker ? "yd-text-with-speaker" : ""}`}>
242
+ {enableTypingAnimation ? (
243
+ <TypingText
244
+ key={currentTextKey}
245
+ text={displayText}
246
+ markup={result.markup}
247
+ typingSpeed={typingSpeed}
248
+ showCursor={showTypingCursor}
249
+ cursorCharacter={cursorCharacter}
250
+ disabled={skipTyping}
251
+ onComplete={() => setTypingComplete(true)}
252
+ />
253
+ ) : (
254
+ <MarkupRenderer text={displayText} markup={result.markup} />
255
+ )}
256
+ </p>
257
+ {shouldShowContinue && (
258
+ <div className="yd-continue">
259
+
260
+ </div>
261
+ )}
262
+ </div>
263
+ </div>
264
+ </div>
265
+ );
266
+ }
267
+
268
+ if (result.type === "options") {
269
+ const nodeStyles = parseCss(result.nodeCss);
270
+ return (
271
+ <div className="yd-container">
272
+ <DialogueScene
273
+ sceneName={sceneName}
274
+ speaker={speaker}
275
+ scenes={sceneCollection}
276
+ actorTransitionDuration={actorTransitionDuration}
277
+ />
278
+ <div className={`yd-options-container ${className || ""}`}>
279
+ <div className="yd-options-box" style={nodeStyles}>
280
+ <div className="yd-options-title">Choose an option:</div>
281
+ <div className="yd-options-list">
282
+ {result.options.map((option, index) => {
283
+ const optionStyles = parseCss(option.css);
284
+ return (
285
+ <button
286
+ key={index}
287
+ className="yd-option-button"
288
+ onClick={() => advance(index)}
289
+ style={optionStyles} // Only apply dynamic option CSS
290
+ >
291
+ <MarkupRenderer text={option.text} markup={option.markup} />
292
+ </button>
293
+ );
294
+ })}
295
+ </div>
296
+ </div>
297
+ </div>
298
+ </div>
299
+ );
300
+ }
301
+
302
+ // Command result - auto-advance
303
+ if (result.type === "command") {
304
+ return (
305
+ <div className={`yd-command ${className || ""}`}>
306
+ <p>Executing: {result.command}</p>
307
+ </div>
308
+ );
309
+ }
310
+
311
+ return null;
312
+ }
@@ -1,7 +1,7 @@
1
1
  import React from "react";
2
2
  import type { MarkupParseResult, MarkupWrapper } from "../markup/types.js";
3
3
 
4
- const DEFAULT_HTML_TAGS = new Set(["b", "em", "small", "strong", "sub", "sup", "ins", "del", "mark"]);
4
+ const DEFAULT_HTML_TAGS = new Set(["b", "em", "small", "strong", "sub", "sup", "ins", "del", "mark", "br"]);
5
5
 
6
6
  interface RenderPiece {
7
7
  text: string;
@@ -107,4 +107,3 @@ function createWrapperElement(
107
107
  function sanitizeClassName(name: string): string {
108
108
  return name.toLowerCase().replace(/[^a-z0-9_-]/g, "-");
109
109
  }
110
-