fix: yarn spinner runner

This commit is contained in:
hypercross 2026-03-02 17:54:40 +08:00
parent 0594fef9ac
commit a02edabc41
3 changed files with 46 additions and 189 deletions

View File

@ -14,7 +14,7 @@ customElement<MdCommanderProps>(
{ placeholder: "", class: "", height: "", commandTemplates: "" }, { placeholder: "", class: "", height: "", commandTemplates: "" },
(props, { element }) => { (props, { element }) => {
noShadowDOM(); noShadowDOM();
const { articlePath, rawSrc } = loadElementSrc(element as any); const { articlePath, rawSrc } = loadElementSrc(element);
// 初始化命令 // 初始化命令
const commands = initializeCommands(props.commands); const commands = initializeCommands(props.commands);

View File

@ -1,19 +1,9 @@
import { customElement, noShadowDOM } from 'solid-element'; import { customElement, noShadowDOM } from 'solid-element';
import { createSignal, createMemo, createResource, createEffect, For, Show } from 'solid-js'; import {createSignal, createResource, For, Show, createEffect} from 'solid-js';
import { parseYarn, compile, YarnRunner } from '../yarn-spinner'; import { parseYarn, compile, YarnRunner } from '../yarn-spinner';
import type { RunnerOptions } from '../yarn-spinner/runtime/runner'; import type { RunnerOptions } from '../yarn-spinner/runtime/runner';
import type { RuntimeResult, TextResult, OptionsResult, CommandResult } from '../yarn-spinner/runtime/results'; import type { RuntimeResult, TextResult, OptionsResult } from '../yarn-spinner/runtime/results';
import {loadElementSrc, resolvePath} from "./utils/path";
export interface YarnSpinnerProps extends RunnerOptions {
startNode?: string;
multipleFiles?: boolean;
}
interface DialogueEntry {
id: number;
result: RuntimeResult;
selectedOptionIndex?: number;
}
// 缓存已加载的 yarn 文件内容 // 缓存已加载的 yarn 文件内容
const yarnCache = new Map<string, string>(); const yarnCache = new Map<string, string>();
@ -35,105 +25,52 @@ async function loadYarnFiles(paths: string[]): Promise<string> {
return contents.join('\n'); return contents.join('\n');
} }
customElement('md-yarn-spinner', { customElement<RunnerOptions>('md-yarn-spinner', {
startNode: undefined as string | undefined, startAt: "start",
multipleFiles: false
}, (props, { element }) => { }, (props, { element }) => {
noShadowDOM(); noShadowDOM();
const [dialogueHistory, setDialogueHistory] = createSignal<DialogueEntry[]>([]); const [dialogueHistory, setDialogueHistory] = createSignal<RuntimeResult[]>([]);
const [currentOptions, setCurrentOptions] = createSignal<OptionsResult | null>(null); const [currentOptions, setCurrentOptions] = createSignal<OptionsResult | null>(null);
const [isEnded, setIsEnded] = createSignal(false); const [isEnded, setIsEnded] = createSignal(false);
const [entryIdCounter, setEntryIdCounter] = createSignal(0);
const [runnerInstance, setRunnerInstance] = createSignal<YarnRunner | null>(null); const [runnerInstance, setRunnerInstance] = createSignal<YarnRunner | null>(null);
// 获取文件路径 // 获取文件路径
const rawSrc = element?.textContent?.trim() || ''; const {articlePath, rawSrc} = loadElementSrc(element);
const articleEl = element?.closest('article[data-src]'); const yarnPaths = (rawSrc || '').split(',')
const articlePath = articleEl?.getAttribute('data-src') || ''; .map((s: string) => resolvePath(articlePath, s.trim()))
.filter(Boolean);
// 隐藏原始文本内容
if (element) {
element.textContent = '';
}
// 解析路径列表(支持多文件)
const yarnPaths = createMemo(() => {
const src = rawSrc;
if (!src) return [];
if (props.multipleFiles) {
return src.split('\n').map((s: string) => s.trim()).filter(Boolean);
}
return [src];
});
// 解析相对路径
const resolvedPaths = createMemo(() => {
return yarnPaths().map((path: string) => {
if (path.startsWith('/')) return path;
if (path.startsWith('http://') || path.startsWith('https://')) return path;
const baseParts = articlePath.split('/').filter(Boolean);
if (baseParts.length > 0 && !articlePath.endsWith('/')) {
baseParts.pop();
}
const relativeParts = path.split('/');
for (const part of relativeParts) {
if (part === '.' || part === '') continue;
else if (part === '..') baseParts.pop();
else baseParts.push(part);
}
return '/' + baseParts.join('/');
});
});
// 加载 yarn 内容 // 加载 yarn 内容
const [yarnContent] = createResource( const [yarnContent] = createResource(() => yarnPaths, loadYarnFiles);
() => resolvedPaths(),
async (paths) => {
if (paths.length === 0) return '';
if (paths.length === 1) {
return await loadYarnFile(paths[0]);
}
return await loadYarnFiles(paths);
}
);
// 创建 runner // 创建 runner
const createRunner = (content: string, startNode?: string) => { const createRunner = () => {
const content = yarnContent();
if (!content) return null; if (!content) return null;
try { try {
const ast = parseYarn(content); const ast = parseYarn(content);
const program = compile(ast); const program = compile(ast);
const runnerOptions: RunnerOptions = { return new YarnRunner(program, props);
startAt: startNode || 'Start',
variables: (props as YarnSpinnerProps).variables,
functions: (props as YarnSpinnerProps).functions,
handleCommand: (props as YarnSpinnerProps).handleCommand,
onStoryEnd: (props as YarnSpinnerProps).onStoryEnd,
};
return new YarnRunner(program, runnerOptions);
} catch (error) { } catch (error) {
console.error('Failed to initialize YarnRunner:', error); console.error('Failed to initialize YarnRunner:', error);
return null; return null;
} }
}; };
// 初始化 runner function advance(index?: number){
const runner = runnerInstance();
if(!runner) return;
runner.advance(index);
processRunnerOutput(runner);
}
createEffect(() => { createEffect(() => {
const content = yarnContent(); if(!yarnContent()) return;
if (content && !runnerInstance()) { setRunnerInstance(createRunner());
const startNode = (props as YarnSpinnerProps).startNode; processRunnerOutput(runnerInstance()!);
const runner = createRunner(content, startNode);
if (runner) {
setRunnerInstance(runner);
}
}
}); });
// 处理 runner 输出 // 处理 runner 输出
@ -141,66 +78,13 @@ customElement('md-yarn-spinner', {
const result = runner.currentResult; const result = runner.currentResult;
if (!result) return; if (!result) return;
const newEntry: DialogueEntry = { if(result.type === 'options'){
id: entryIdCounter(), setCurrentOptions(result);
result, }else{
};
setDialogueHistory(prev => [...prev, newEntry]);
setEntryIdCounter(prev => prev + 1);
if (result.type === 'options') {
setCurrentOptions(result as OptionsResult);
} else if (result.type === 'text' && (result as TextResult).isDialogueEnd) {
// 检查是否是对话结束
const lastOptions = runner.history.slice().reverse().find(r => r.type === 'options') as OptionsResult | undefined;
if (lastOptions && runner.currentResult?.type !== 'options') {
// 之前有选项但没有选择,等待用户输入
setCurrentOptions(lastOptions);
return;
}
}
// 检查故事是否结束
if ((result as TextResult).isDialogueEnd && result.type !== 'options') {
setIsEnded(true);
setCurrentOptions(null); setCurrentOptions(null);
} }
};
// 监听 runner 变化
createEffect(() => {
const runner = runnerInstance();
if (runner && runner.currentResult) {
processRunnerOutput(runner);
}
});
// 选择选项
const selectOption = (index: number) => {
const runner = runnerInstance();
if (!runner || !currentOptions()) return;
// 更新历史记录,标记已选择的选项 setDialogueHistory([...runner.history]);
setDialogueHistory(prev => {
const lastOptionsIndex = prev.slice().reverse().findIndex(e => e.result.type === 'options');
if (lastOptionsIndex === -1) return prev;
const actualIndex = prev.length - 1 - lastOptionsIndex;
return prev.map((entry, i) =>
i === actualIndex
? { ...entry, selectedOptionIndex: index }
: entry
);
});
setCurrentOptions(null);
runner.advance(index);
// 处理选择后的输出
setTimeout(() => {
processRunnerOutput(runner);
}, 0);
}; };
// 重新开始 // 重新开始
@ -208,21 +92,12 @@ customElement('md-yarn-spinner', {
setDialogueHistory([]); setDialogueHistory([]);
setCurrentOptions(null); setCurrentOptions(null);
setIsEnded(false); setIsEnded(false);
setEntryIdCounter(0); setRunnerInstance(createRunner());
processRunnerOutput(runnerInstance()!);
// 重新创建 runner
const content = yarnContent();
if (!content) return;
const runner = createRunner(content, (props as YarnSpinnerProps).startNode);
if (runner) {
setRunnerInstance(runner);
}
}; };
// 渲染对话历史项 // 渲染对话历史项
const renderEntry = (entry: DialogueEntry) => { const renderEntry = (result: RuntimeResult) => {
const result = entry.result;
if (result.type === 'text') { if (result.type === 'text') {
const textResult = result as TextResult; const textResult = result as TextResult;
@ -244,39 +119,11 @@ customElement('md-yarn-spinner', {
); );
} }
if (result.type === 'options') {
const optionsResult = result as OptionsResult;
return (
<div class="dialogue-entry options-entry">
<Show when={entry.selectedOptionIndex !== undefined}>
<div class="selected-option text-gray-600">
{optionsResult.options[entry.selectedOptionIndex!].text}
</div>
</Show>
<Show when={entry.selectedOptionIndex === undefined}>
<div class="options-list flex flex-col gap-2">
<For each={optionsResult.options}>
{(option, index) => (
<button
onClick={() => selectOption(index())}
class="option-button text-left px-4 py-2 bg-blue-50 hover:bg-blue-100
rounded border border-blue-200 transition-colors cursor-pointer"
>
{option.text}
</button>
)}
</For>
</div>
</Show>
</div>
);
}
return null; return null;
}; };
return ( return (
<div class="yarn-spinner w-full max-w-2xl mx-auto border rounded-lg shadow-sm"> <div class="yarn-spinner w-full max-w-2xl mx-auto shadow-sm">
{/* 对话历史 */} {/* 对话历史 */}
<div class="dialogue-history p-4 min-h-[200px] max-h-[60vh] overflow-y-auto bg-gray-50"> <div class="dialogue-history p-4 min-h-[200px] max-h-[60vh] overflow-y-auto bg-gray-50">
<Show when={dialogueHistory().length === 0 && !yarnContent.loading}> <Show when={dialogueHistory().length === 0 && !yarnContent.loading}>
@ -286,7 +133,7 @@ customElement('md-yarn-spinner', {
<div class="text-gray-400 text-center py-8">...</div> <div class="text-gray-400 text-center py-8">...</div>
</Show> </Show>
<For each={dialogueHistory()}> <For each={dialogueHistory()}>
{entry => renderEntry(entry)} {renderEntry}
</For> </For>
</div> </div>
@ -297,7 +144,7 @@ customElement('md-yarn-spinner', {
<For each={currentOptions()?.options || []}> <For each={currentOptions()?.options || []}>
{(option, index) => ( {(option, index) => (
<button <button
onClick={() => selectOption(index())} onClick={() => advance(index())}
class="option-button text-left px-4 py-2 bg-blue-50 hover:bg-blue-100 class="option-button text-left px-4 py-2 bg-blue-50 hover:bg-blue-100
rounded border border-blue-200 transition-colors cursor-pointer" rounded border border-blue-200 transition-colors cursor-pointer"
> >
@ -319,6 +166,14 @@ customElement('md-yarn-spinner', {
> >
🔄 🔄
</button> </button>
<button
onClick={() => advance()}
class="advance-button px-3 py-1 text-sm bg-gray-200 hover:bg-gray-300
rounded transition-colors cursor-pointer"
title="继续"
>
</button>
</div> </div>
</div> </div>
); );

View File

@ -6,6 +6,7 @@
* @returns * @returns
*/ */
export function resolvePath(base: string, relative: string): string { export function resolvePath(base: string, relative: string): string {
if(!relative) return relative;
if (relative.startsWith('/')) { if (relative.startsWith('/')) {
return relative; return relative;
} }
@ -39,7 +40,8 @@ export function resolvePath(base: string, relative: string): string {
} }
export function loadElementSrc(element?: { textContent: string, closest: (arg0: string) => HTMLElement }){ // export function loadElementSrc(element?: { textContent: string, closest: (arg0: string) => HTMLElement }){
export function loadElementSrc(element?: any | HTMLElement){
const rawSrc = element?.textContent; const rawSrc = element?.textContent;
if(element) element.textContent = ""; if(element) element.textContent = "";