UNPKG

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
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