refactor: reorg

This commit is contained in:
hypercross 2026-03-03 10:17:39 +08:00
parent 6b77653d27
commit 4280da9fec
3 changed files with 180 additions and 176 deletions

View File

@ -1,105 +1,38 @@
import { customElement, noShadowDOM } from 'solid-element'; import { customElement, noShadowDOM } from 'solid-element';
import {createSignal, createResource, For, Show, createEffect} from 'solid-js'; import { For, Show, createEffect } from 'solid-js';
import { parseYarn, compile, YarnRunner } from '../yarn-spinner'; import type {TextResult, RuntimeResult, OptionsResult} from '../yarn-spinner/runtime/results';
import type { RunnerOptions } from '../yarn-spinner/runtime/runner'; import { createYarnStore } from './stores/yarnStore';
import type { RuntimeResult, TextResult, OptionsResult } from '../yarn-spinner/runtime/results'; import {RunnerOptions} from "../yarn-spinner/runtime/runner";
import {loadElementSrc, resolvePath} from "./utils/path";
// 缓存已加载的 yarn 文件内容 customElement<RunnerOptions>('md-yarn-spinner', {startAt: 'start'}, (props, { element }) => {
const yarnCache = new Map<string, string>();
// 加载 yarn 文件内容
async function loadYarnFile(path: string): Promise<string> {
if (yarnCache.has(path)) {
return yarnCache.get(path)!;
}
const response = await fetch(path);
const content = await response.text();
yarnCache.set(path, content);
return content;
}
// 加载多个 yarn 文件并拼接
async function loadYarnFiles(paths: string[]): Promise<string> {
const contents = await Promise.all(paths.map(path => loadYarnFile(path)));
return contents.join('\n');
}
customElement<RunnerOptions>('md-yarn-spinner', {
startAt: "start",
}, (props, { element }) => {
noShadowDOM(); noShadowDOM();
const [dialogueHistory, setDialogueHistory] = createSignal<RuntimeResult[]>([]); let historyContainer: HTMLDivElement | undefined;
const [currentOptions, setCurrentOptions] = createSignal<OptionsResult | null>(null);
const [isEnded, setIsEnded] = createSignal(false); const { store, advance, restart } = createYarnStore(element as any, props);
const [runnerInstance, setRunnerInstance] = createSignal<YarnRunner | null>(null);
// 滚动到底部
// 获取文件路径 const scrollToBottom = () => {
const {articlePath, rawSrc} = loadElementSrc(element); if (historyContainer) {
const yarnPaths = (rawSrc || '').split(',') setTimeout(() => {
.map((s: string) => resolvePath(articlePath, s.trim())) historyContainer!.scrollTop = historyContainer!.scrollHeight;
.filter(Boolean); }, 0);
// 加载 yarn 内容
const [yarnContent] = createResource(() => yarnPaths, loadYarnFiles);
// 创建 runner
const createRunner = () => {
const content = yarnContent();
if (!content) return null;
try {
const ast = parseYarn(content);
const program = compile(ast);
return new YarnRunner(program, props);
} catch (error) {
console.error('Failed to initialize YarnRunner:', error);
return null;
} }
}; };
function advance(index?: number){ // 监听对话历史变化,自动滚动到底部
const runner = runnerInstance();
if(!runner) return;
if(runner.currentResult?.type !== 'options' && runner.currentResult?.isDialogueEnd) return;
runner.advance(index);
processRunnerOutput(runner);
}
createEffect(() => { createEffect(() => {
if(!yarnContent()) return; store.dialogueHistory;
setRunnerInstance(createRunner()); scrollToBottom();
processRunnerOutput(runnerInstance()!);
}); });
// 处理 runner 输出
const processRunnerOutput = (runner: YarnRunner) => {
const result = runner.currentResult;
if (!result) return;
if(result.type === 'options'){
setCurrentOptions(result);
}else{
setCurrentOptions(null);
}
setDialogueHistory([...runner.history]);
};
// 重新开始
const restart = () => {
setDialogueHistory([]);
setCurrentOptions(null);
setIsEnded(false);
setRunnerInstance(createRunner());
processRunnerOutput(runnerInstance()!);
};
// 渲染对话历史项 // 渲染对话历史项
const renderEntry = (result: RuntimeResult) => { const renderEntry = (result: RuntimeResult | OptionsResult['options'][0]) => {
if(!('type' in result)){
return <div class="dialogue-entry text-entry">
<span class="text"> {"->"} {result.text}</span>
</div>
}
if (result.type === 'text') { if (result.type === 'text') {
const textResult = result as TextResult; const textResult = result as TextResult;
return ( return (
@ -111,7 +44,7 @@ customElement<RunnerOptions>('md-yarn-spinner', {
</div> </div>
); );
} }
if (result.type === 'command') { if (result.type === 'command') {
return ( return (
<div class="dialogue-entry command-entry text-gray-500 italic"> <div class="dialogue-entry command-entry text-gray-500 italic">
@ -119,30 +52,49 @@ customElement<RunnerOptions>('md-yarn-spinner', {
</div> </div>
); );
} }
return null; return null;
}; };
return ( return (
<div class="yarn-spinner w-full max-w-2xl mx-auto shadow-sm relative"> <div class="w-full max-w-2xl mx-auto shadow-sm relative">
{/* 对话历史 */} {/* 对话历史 */}
<div class="dialogue-history p-4 h-64 overflow-y-auto bg-gray-50"> <div
<Show when={dialogueHistory().length === 0 && !yarnContent.loading}> ref={historyContainer}
class="dialogue-history p-4 h-64 overflow-y-auto bg-gray-50"
>
<Show when={store.dialogueHistory.length === 0 && !store.runnerInstance}>
<div class="text-gray-400 text-center py-8"></div> <div class="text-gray-400 text-center py-8"></div>
</Show> </Show>
<Show when={yarnContent.loading}> <For each={store.dialogueHistory}>
<div class="text-gray-400 text-center py-8">...</div>
</Show>
<For each={dialogueHistory()}>
{renderEntry} {renderEntry}
</For> </For>
</div> </div>
{/* 浮动工具栏 */}
<div class="toolbar absolute right-0 rounded-bl-lg shadow-sm flex">
<button
onClick={restart}
class="restart-button px-3 py-1 text-sm bg-gray-200 hover:bg-gray-300
rounded-bl-md transition-colors cursor-pointer"
title="重新开始"
>
🔄
</button>
<button
onClick={() => advance()}
class="advance-button px-3 py-1 text-sm bg-gray-200 hover:bg-gray-300
transition-colors cursor-pointer"
title="继续"
>
</button>
</div>
{/* 当前选项 */} {/* 当前选项 */}
<Show when={currentOptions() && !isEnded()}> <div class="current-options p-4 border-t border-gray-300 bg-white min-h-34">
<div class="current-options p-4 border-t bg-white">
<div class="options-list flex flex-col gap-2"> <div class="options-list flex flex-col gap-2">
<For each={currentOptions()?.options || []}> <For each={store.currentOptions?.options || []}>
{(option, index) => ( {(option, index) => (
<button <button
onClick={() => advance(index())} onClick={() => advance(index())}
@ -155,27 +107,6 @@ customElement<RunnerOptions>('md-yarn-spinner', {
</For> </For>
</div> </div>
</div> </div>
</Show>
{/* 浮动工具栏 */}
<div class="toolbar absolute top-0 right-0 p-2 bg-gray-100 border-t border-l rounded-tl-lg shadow-sm flex gap-2">
<button
onClick={restart}
class="restart-button px-3 py-1 text-sm bg-gray-200 hover:bg-gray-300
rounded transition-colors cursor-pointer"
title="重新开始"
>
🔄
</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>
); );
}); });

View File

@ -0,0 +1,122 @@
import {createEffect, createResource } from "solid-js";
import type {OptionsResult, RuntimeResult} from "../../yarn-spinner/runtime/results";
import {compile, parseYarn, YarnRunner} from "../../yarn-spinner";
import {loadElementSrc, resolvePath} from "../utils/path";
import {createStore} from "solid-js/store";
import {RunnerOptions} from "../../yarn-spinner/runtime/runner";
type YarnSpinnerStore = {
dialogueHistory: (RuntimeResult | OptionsResult['options'][0])[],
currentOptions: OptionsResult | null,
isEnded: boolean,
runnerInstance: YarnRunner | null,
}
export function createYarnStore(element: HTMLElement, props: RunnerOptions){
const [store, setStore] = createStore<YarnSpinnerStore>({
dialogueHistory: [],
currentOptions: null,
isEnded: false,
runnerInstance: null
});
// 获取文件路径
const {articlePath, rawSrc} = loadElementSrc(element);
const yarnPaths = (rawSrc || '').split(',')
.map((s: string) => resolvePath(articlePath, s.trim()))
.filter(Boolean);
// 加载 yarn 内容
const [yarnContent] = createResource(() => yarnPaths, loadYarnFiles);
// 创建 runner
const createRunner = () => {
const content = yarnContent();
if (!content) return null;
try {
const ast = parseYarn(content);
const program = compile(ast);
return new YarnRunner(program, props);
} catch (error) {
console.error('Failed to initialize YarnRunner:', error);
return null;
}
};
function advance(index?: number){
const runner = store.runnerInstance;
if(!runner) return;
if(index === undefined && runner.currentResult?.isDialogueEnd) return;
if(runner.currentResult?.type === 'options'){
if(index === undefined)return;
const option = runner.currentResult.options[index];
setStore('dialogueHistory', [...store.dialogueHistory, option]);
}
runner.advance(index);
processRunnerOutput();
}
createEffect(() => {
if(!yarnContent()) return;
setStore('runnerInstance', createRunner());
requestAnimationFrame(function(){
advance();
});
});
// 处理 runner 输出
const processRunnerOutput = () => {
const runner = store.runnerInstance;
if(!runner)return;
const result = runner.currentResult;
if (!result) return;
if(result.type === 'options'){
setStore('currentOptions', result);
}else{
setStore('currentOptions', null);
}
setStore('dialogueHistory', [...store.dialogueHistory, result]);
};
// 重新开始
const restart = () => {
setStore({
dialogueHistory: [],
currentOptions: null,
isEnded: false,
runnerInstance: createRunner(),
});
processRunnerOutput();
};
return {
store,
advance,
restart,
}
}
// 缓存已加载的 yarn 文件内容
const yarnCache = new Map<string, string>();
// 加载 yarn 文件内容
async function loadYarnFile(path: string): Promise<string> {
if (yarnCache.has(path)) {
return yarnCache.get(path)!;
}
const response = await fetch(path);
const content = await response.text();
yarnCache.set(path, content);
return content;
}
// 加载多个 yarn 文件并拼接
async function loadYarnFiles(paths: string[]): Promise<string> {
const contents = await Promise.all(paths.map(path => loadYarnFile(path)));
return contents.join('\n');
}

View File

@ -80,52 +80,3 @@ icon{
.col-3{ @apply lg:flex-3; } .col-3{ @apply lg:flex-3; }
.col-4{ @apply lg:flex-4; } .col-4{ @apply lg:flex-4; }
.col-5{ @apply lg:flex-5; } .col-5{ @apply lg:flex-5; }
/* yarn-spinner */
.yarn-spinner {
@apply font-sans;
}
.yarn-spinner .dialogue-history {
@apply space-y-3;
}
.yarn-spinner .dialogue-entry {
@apply leading-relaxed;
}
.yarn-spinner .text-entry {
@apply text-gray-800;
}
.yarn-spinner .speaker {
@apply text-blue-600 font-semibold;
}
.yarn-spinner .command-entry {
@apply text-sm text-gray-500 italic;
}
.yarn-spinner .options-entry {
@apply mt-2;
}
.yarn-spinner .selected-option {
@apply text-gray-600 font-medium;
}
.yarn-spinner .option-button {
@apply w-full text-left px-4 py-2.5 bg-blue-50 hover:bg-blue-100
rounded border border-blue-200 transition-colors cursor-pointer
text-gray-700 hover:text-gray-900;
}
.yarn-spinner .toolbar {
@apply bg-gray-50 border-t border-gray-200;
}
.yarn-spinner .restart-button {
@apply px-4 py-1.5 text-sm bg-gray-200 hover:bg-gray-300
rounded transition-colors cursor-pointer text-gray-700;
}