yarn-spinner-runner-ts
Version:
TypeScript parser, compiler, and runtime for Yarn Spinner 3.x with React adapter [NPM package](https://www.npmjs.com/package/yarn-spinner-runner-ts)
183 lines • 9.07 kB
JavaScript
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useRef, useEffect, useState } from "react";
import { DialogueScene } from "./DialogueScene.js";
import { TypingText } from "./TypingText.js";
import { useYarnRunner } from "./useYarnRunner.js";
import { MarkupRenderer } from "./MarkupRenderer.js";
// Helper to parse CSS string into object
function parseCss(cssStr) {
if (!cssStr)
return {};
const styles = {};
// Improved parser: handles quoted values and commas
// Split by semicolon, but preserve quoted strings
const rules = [];
let currentRule = "";
let inQuotes = false;
let quoteChar = "";
for (let i = 0; i < cssStr.length; i++) {
const char = cssStr[i];
if ((char === '"' || char === "'") && !inQuotes) {
inQuotes = true;
quoteChar = char;
currentRule += char;
}
else if (char === quoteChar && inQuotes) {
inQuotes = false;
quoteChar = "";
currentRule += char;
}
else if (char === ";" && !inQuotes) {
rules.push(currentRule.trim());
currentRule = "";
}
else {
currentRule += char;
}
}
if (currentRule.trim()) {
rules.push(currentRule.trim());
}
rules.forEach((rule) => {
if (!rule)
return;
const colonIndex = rule.indexOf(":");
if (colonIndex === -1)
return;
const prop = rule.slice(0, colonIndex).trim();
const value = rule.slice(colonIndex + 1).trim();
if (prop && value) {
// Convert kebab-case to camelCase
const camelProp = prop.replace(/-([a-z])/g, (g) => g[1].toUpperCase());
// Remove quotes from value if present, and strip !important (React doesn't support it)
let cleanValue = value.trim();
if (cleanValue.endsWith("!important")) {
cleanValue = cleanValue.slice(0, -10).trim();
}
if (cleanValue.startsWith('"') && cleanValue.endsWith('"')) {
cleanValue = cleanValue.slice(1, -1);
}
else if (cleanValue.startsWith("'") && cleanValue.endsWith("'")) {
cleanValue = cleanValue.slice(1, -1);
}
styles[camelProp] = cleanValue;
}
});
return styles;
}
export function DialogueView({ program, startNode = "Start", className, scenes, actorTransitionDuration = 350, functions, variables, onStoryEnd, enableTypingAnimation = false, typingSpeed = 50, // Characters per second (50 cps = ~20ms per character)
showTypingCursor = true, cursorCharacter = "|", autoAdvanceAfterTyping = false, autoAdvanceDelay = 500, pauseBeforeAdvance = 0, }) {
const { result, advance, runner } = useYarnRunner(program, {
startAt: startNode,
functions,
variables,
});
const sceneName = result?.type === "text" || result?.type === "options" ? result.scene : undefined;
const speaker = result?.type === "text" ? result.speaker : undefined;
const sceneCollection = scenes || { scenes: {} };
const [typingComplete, setTypingComplete] = useState(false);
const [currentTextKey, setCurrentTextKey] = useState(0);
const [skipTyping, setSkipTyping] = useState(false);
const advanceTimeoutRef = useRef(null);
const storyEndTriggeredRef = useRef(false);
useEffect(() => {
storyEndTriggeredRef.current = false;
}, [program, startNode]);
useEffect(() => {
if (!result || result.type !== "command") {
return;
}
const timer = setTimeout(() => advance(), 50);
return () => clearTimeout(timer);
}, [result, advance]);
useEffect(() => {
if (!onStoryEnd || !result || storyEndTriggeredRef.current) {
return;
}
if (!result.isDialogueEnd) {
return;
}
if (result.type === "options") {
return;
}
storyEndTriggeredRef.current = true;
const variablesSnapshot = Object.freeze({ ...(runner?.getVariables?.() ?? {}) });
onStoryEnd({ storyEnd: true, variables: variablesSnapshot });
}, [result, onStoryEnd, runner]);
// Reset typing completion when text changes
useEffect(() => {
if (result?.type === "text") {
setTypingComplete(false);
setSkipTyping(false);
setCurrentTextKey((prev) => prev + 1); // Force re-render of TypingText
}
// Cleanup any pending advance timeouts when text changes
return () => {
if (advanceTimeoutRef.current) {
clearTimeout(advanceTimeoutRef.current);
advanceTimeoutRef.current = null;
}
};
}, [result?.type === "text" ? result.text : null]);
// Handle auto-advance after typing completes
useEffect(() => {
if (autoAdvanceAfterTyping &&
typingComplete &&
result?.type === "text" &&
!result.isDialogueEnd) {
const timer = setTimeout(() => {
advance();
}, autoAdvanceDelay);
return () => clearTimeout(timer);
}
}, [autoAdvanceAfterTyping, typingComplete, result, advance, autoAdvanceDelay]);
if (!result) {
return (_jsx("div", { className: `yd-empty ${className || ""}`, children: _jsx("p", { children: "Dialogue ended or not started." }) }));
}
if (result.type === "text") {
const nodeStyles = parseCss(result.nodeCss);
const displayText = result.text || "\u00A0";
const shouldShowContinue = !result.isDialogueEnd && !enableTypingAnimation;
const handleClick = () => {
if (result.isDialogueEnd)
return;
// If typing is in progress, skip it; otherwise advance
if (enableTypingAnimation && !typingComplete) {
// Skip typing animation
setSkipTyping(true);
setTypingComplete(true);
}
else {
// Clear any pending timeout
if (advanceTimeoutRef.current) {
clearTimeout(advanceTimeoutRef.current);
advanceTimeoutRef.current = null;
}
// Apply pause before advance if configured
if (pauseBeforeAdvance > 0) {
advanceTimeoutRef.current = setTimeout(() => {
advance();
advanceTimeoutRef.current = null;
}, pauseBeforeAdvance);
}
else {
advance();
}
}
};
return (_jsxs("div", { className: "yd-container", children: [_jsx(DialogueScene, { sceneName: sceneName, speaker: speaker, scenes: sceneCollection, actorTransitionDuration: actorTransitionDuration }), _jsx("div", { className: `yd-dialogue-box ${result.isDialogueEnd ? "yd-text-box-end" : ""} ${className || ""}`, style: nodeStyles, onClick: handleClick, children: _jsxs("div", { className: "yd-text-box", children: [result.speaker && (_jsx("div", { className: "yd-speaker", children: result.speaker })), _jsx("p", { className: `yd-text ${result.speaker ? "yd-text-with-speaker" : ""}`, children: enableTypingAnimation ? (_jsx(TypingText, { text: displayText, markup: result.markup, typingSpeed: typingSpeed, showCursor: showTypingCursor, cursorCharacter: cursorCharacter, disabled: skipTyping, onComplete: () => setTypingComplete(true) }, currentTextKey)) : (_jsx(MarkupRenderer, { text: displayText, markup: result.markup })) }), shouldShowContinue && (_jsx("div", { className: "yd-continue", children: "\u25BC" }))] }) })] }));
}
if (result.type === "options") {
const nodeStyles = parseCss(result.nodeCss);
return (_jsxs("div", { className: "yd-container", children: [_jsx(DialogueScene, { sceneName: sceneName, speaker: speaker, scenes: sceneCollection, actorTransitionDuration: actorTransitionDuration }), _jsx("div", { className: `yd-options-container ${className || ""}`, children: _jsxs("div", { className: "yd-options-box", style: nodeStyles, children: [_jsx("div", { className: "yd-options-title", children: "Choose an option:" }), _jsx("div", { className: "yd-options-list", children: result.options.map((option, index) => {
const optionStyles = parseCss(option.css);
return (_jsx("button", { className: "yd-option-button", onClick: () => advance(index), style: optionStyles, children: _jsx(MarkupRenderer, { text: option.text, markup: option.markup }) }, index));
}) })] }) })] }));
}
// Command result - auto-advance
if (result.type === "command") {
return (_jsx("div", { className: `yd-command ${className || ""}`, children: _jsxs("p", { children: ["Executing: ", result.command] }) }));
}
return null;
}
//# sourceMappingURL=DialogueView.js.map