From 0594fef9ac8a091ca76c06708eb17dc15e93d83d Mon Sep 17 00:00:00 2001 From: hypercross Date: Mon, 2 Mar 2026 16:49:55 +0800 Subject: [PATCH] feat: ai's attempt --- src/components/index.ts | 2 + src/components/md-yarn-spinner.tsx | 325 +++++++++++++++++++++++++++++ src/styles.css | 51 ++++- 3 files changed, 377 insertions(+), 1 deletion(-) create mode 100644 src/components/md-yarn-spinner.tsx diff --git a/src/components/index.ts b/src/components/index.ts index 1e2c003..faa9abf 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -6,6 +6,7 @@ import './md-pins'; import './md-bg'; import './md-deck'; import './md-commander/index'; +import './md-yarn-spinner'; // 导出组件 export { Article } from './Article'; @@ -18,6 +19,7 @@ export { FileTreeNode, HeadingNode } from './FileTree'; export type { DiceProps } from './md-dice'; export type { TableProps } from './md-table'; export type { BgProps } from './md-bg'; +export type { YarnSpinnerProps } from './md-yarn-spinner'; // 导出 md-commander 相关 export type { diff --git a/src/components/md-yarn-spinner.tsx b/src/components/md-yarn-spinner.tsx new file mode 100644 index 0000000..4f262ac --- /dev/null +++ b/src/components/md-yarn-spinner.tsx @@ -0,0 +1,325 @@ +import { customElement, noShadowDOM } from 'solid-element'; +import { createSignal, createMemo, createResource, createEffect, For, Show } from 'solid-js'; +import { parseYarn, compile, YarnRunner } from '../yarn-spinner'; +import type { RunnerOptions } from '../yarn-spinner/runtime/runner'; +import type { RuntimeResult, TextResult, OptionsResult, CommandResult } from '../yarn-spinner/runtime/results'; + +export interface YarnSpinnerProps extends RunnerOptions { + startNode?: string; + multipleFiles?: boolean; +} + +interface DialogueEntry { + id: number; + result: RuntimeResult; + selectedOptionIndex?: number; +} + +// 缓存已加载的 yarn 文件内容 +const yarnCache = new Map(); + +// 加载 yarn 文件内容 +async function loadYarnFile(path: string): Promise { + 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 { + const contents = await Promise.all(paths.map(path => loadYarnFile(path))); + return contents.join('\n'); +} + +customElement('md-yarn-spinner', { + startNode: undefined as string | undefined, + multipleFiles: false +}, (props, { element }) => { + noShadowDOM(); + + const [dialogueHistory, setDialogueHistory] = createSignal([]); + const [currentOptions, setCurrentOptions] = createSignal(null); + const [isEnded, setIsEnded] = createSignal(false); + const [entryIdCounter, setEntryIdCounter] = createSignal(0); + const [runnerInstance, setRunnerInstance] = createSignal(null); + + // 获取文件路径 + const rawSrc = element?.textContent?.trim() || ''; + const articleEl = element?.closest('article[data-src]'); + const articlePath = articleEl?.getAttribute('data-src') || ''; + + // 隐藏原始文本内容 + 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 内容 + const [yarnContent] = createResource( + () => resolvedPaths(), + async (paths) => { + if (paths.length === 0) return ''; + if (paths.length === 1) { + return await loadYarnFile(paths[0]); + } + return await loadYarnFiles(paths); + } + ); + + // 创建 runner + const createRunner = (content: string, startNode?: string) => { + if (!content) return null; + + try { + const ast = parseYarn(content); + const program = compile(ast); + + const runnerOptions: RunnerOptions = { + 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) { + console.error('Failed to initialize YarnRunner:', error); + return null; + } + }; + + // 初始化 runner + createEffect(() => { + const content = yarnContent(); + if (content && !runnerInstance()) { + const startNode = (props as YarnSpinnerProps).startNode; + const runner = createRunner(content, startNode); + if (runner) { + setRunnerInstance(runner); + } + } + }); + + // 处理 runner 输出 + const processRunnerOutput = (runner: YarnRunner) => { + const result = runner.currentResult; + if (!result) return; + + const newEntry: DialogueEntry = { + id: entryIdCounter(), + result, + }; + + 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); + } + }; + + // 监听 runner 变化 + createEffect(() => { + const runner = runnerInstance(); + if (runner && runner.currentResult) { + processRunnerOutput(runner); + } + }); + + // 选择选项 + const selectOption = (index: number) => { + const runner = runnerInstance(); + if (!runner || !currentOptions()) return; + + // 更新历史记录,标记已选择的选项 + 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); + }; + + // 重新开始 + const restart = () => { + setDialogueHistory([]); + setCurrentOptions(null); + setIsEnded(false); + setEntryIdCounter(0); + + // 重新创建 runner + const content = yarnContent(); + if (!content) return; + + const runner = createRunner(content, (props as YarnSpinnerProps).startNode); + if (runner) { + setRunnerInstance(runner); + } + }; + + // 渲染对话历史项 + const renderEntry = (entry: DialogueEntry) => { + const result = entry.result; + + if (result.type === 'text') { + const textResult = result as TextResult; + return ( +
+ + {textResult.speaker}: + + {textResult.text} +
+ ); + } + + if (result.type === 'command') { + return ( +
+ [{result.command}] +
+ ); + } + + if (result.type === 'options') { + const optionsResult = result as OptionsResult; + return ( +
+ +
+ → {optionsResult.options[entry.selectedOptionIndex!].text} +
+
+ +
+ + {(option, index) => ( + + )} + +
+
+
+ ); + } + + return null; + }; + + return ( +
+ {/* 对话历史 */} +
+ +
点击重新开始开始对话
+
+ +
加载对话中...
+
+ + {entry => renderEntry(entry)} + +
+ + {/* 当前选项 */} + +
+
+ + {(option, index) => ( + + )} + +
+
+
+ + {/* 工具栏 */} +
+ +
+
+ ); +}); diff --git a/src/styles.css b/src/styles.css index 7bfd18e..292da36 100644 --- a/src/styles.css +++ b/src/styles.css @@ -79,4 +79,53 @@ icon{ .col-2{ @apply lg:flex-2; } .col-3{ @apply lg:flex-3; } .col-4{ @apply lg:flex-4; } -.col-5{ @apply lg:flex-5; } \ No newline at end of file +.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; +} \ No newline at end of file