fix: yarn spinner runner
This commit is contained in:
parent
0594fef9ac
commit
a02edabc41
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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 = "";
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue