diff --git a/src/App.tsx b/src/App.tsx index 9a33738..b0850f1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,16 +1,15 @@ import { Component, createSignal, onMount } from 'solid-js'; import { useLocation } from '@solidjs/router'; -import { parseMarkdown } from './markdown'; // 导入组件以注册自定义元素 import './components'; +import { Article } from './components'; const App: Component = () => { const location = useLocation(); - const [content, setContent] = createSignal(''); const [currentPath, setCurrentPath] = createSignal(''); - onMount(async () => { + onMount(() => { // 根据路由加载对应的 markdown 文件 let path = decodeURIComponent(location.pathname.slice(1)); if (!path) { @@ -23,17 +22,6 @@ const App: Component = () => { path = `${path}.md`; } setCurrentPath(path); - try { - const response = await fetch(path); - if (response.ok) { - const text = await response.text(); - setContent(text); - } else { - setContent('# 欢迎使用 TTRPG Tools\n\n没有找到对应的内容文件。'); - } - } catch (error) { - setContent('# 欢迎使用 TTRPG Tools\n\n这是一个 TTRPG 文档工具箱。\n\n## 功能\n\n- 使用 `:md-dice[2d6+d8]` 插入骰子组件\n- 使用 `:md-table[./data.csv]` 插入表格组件'); - } }); return ( @@ -44,9 +32,7 @@ const App: Component = () => {
-
-
-
+
); diff --git a/src/components/Article.tsx b/src/components/Article.tsx new file mode 100644 index 0000000..9ddda4a --- /dev/null +++ b/src/components/Article.tsx @@ -0,0 +1,57 @@ +import { Component, createSignal, onMount, onCleanup, Show } from 'solid-js'; +import { parseMarkdown } from '../markdown'; +import { fetchData } from '../data-loader'; + +export interface ArticleProps { + src: string; + onLoaded?: () => void; + onError?: (error: Error) => void; +} + +/** + * Article 组件 + * 用于将特定 src 位置的 md 文件显示为 markdown 文章 + */ +export const Article: Component = (props) => { + const [content, setContent] = createSignal(''); + const [loading, setLoading] = createSignal(true); + const [error, setError] = createSignal(null); + + let articleRef: HTMLArticleElement | undefined; + + onMount(async () => { + setLoading(true); + try { + const text = await fetchData(props.src); + setContent(text); + setLoading(false); + props.onLoaded?.(); + } catch (err) { + const errorObj = err instanceof Error ? err : new Error(String(err)); + setError(errorObj); + setLoading(false); + props.onError?.(errorObj); + } + }); + + onCleanup(() => { + // 清理时清空内容,触发内部组件的销毁 + setContent(''); + }); + + return ( +
+ +
加载中...
+
+ +
加载失败:{error()?.message}
+
+ +
+ +
+ ); +}; + +export default Article; diff --git a/src/components/index.ts b/src/components/index.ts index f12a7aa..36ae1f9 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -1,6 +1,11 @@ // 导入以注册自定义元素 import './dice'; import './table'; +import './md-link'; + +// 导出组件 +export { Article } from './Article'; +export type { ArticleProps } from './Article'; // 导出类型 export type { DiceProps } from './dice'; diff --git a/src/components/md-link.tsx b/src/components/md-link.tsx new file mode 100644 index 0000000..316bdbb --- /dev/null +++ b/src/components/md-link.tsx @@ -0,0 +1,72 @@ +import { customElement, noShadowDOM } from "solid-element"; +import { createSignal, onCleanup } from "solid-js"; +import { render } from "solid-js/web"; +import { Article } from "./Article"; + +customElement("md-link", {}, (props, { element }) => { + noShadowDOM(); + + const [showArticle, setShowArticle] = createSignal(false); + let articleContainer: HTMLDivElement | undefined; + let disposeArticle: (() => void) | null = null; + + // 从 element 的 textContent 获取链接目标 + const linkSrc = element?.textContent?.trim() || ""; + + // 查找包含此元素的 p 标签 + const parentP = element?.closest("p"); + + const toggleArticle = () => { + if (!parentP) return; + + if (showArticle()) { + // 隐藏文章 + if (articleContainer) { + if (disposeArticle) { + disposeArticle(); + disposeArticle = null; + } + articleContainer.remove(); + articleContainer = undefined; + } + setShowArticle(false); + } else { + // 显示文章 + articleContainer = document.createElement("div"); + articleContainer.classList.add("md-link-article"); + articleContainer.classList.add("mt-4", "ml-4", "border-l-2", "border-gray-200", "pl-4"); + parentP.after(articleContainer); + + // 渲染 Article 组件 + disposeArticle = render(() => ( +
console.log("Article loaded:", linkSrc)} + onError={(err) => console.error("Article error:", err)} + /> + ), articleContainer); + + setShowArticle(true); + } + }; + + onCleanup(() => { + if (disposeArticle) { + disposeArticle(); + disposeArticle = null; + } + }); + + return ( + { + e.preventDefault(); + toggleArticle(); + }} + class="text-blue-600 hover:text-blue-800 hover:underline" + > + {linkSrc} + + ); +}); diff --git a/src/data-loader/index.ts b/src/data-loader/index.ts new file mode 100644 index 0000000..b3a66e6 --- /dev/null +++ b/src/data-loader/index.ts @@ -0,0 +1,72 @@ +/** + * 数据加载索引接口 + */ +export interface DataIndex { + [key: string]: string; +} + +/** + * 获取数据索引 + * 在 dev 环境使用 import.meta.glob 创建索引 + * 在 cli 环境检索目录创建并注入 + */ +export function getDataIndex(): DataIndex { + // @ts-ignore - import.meta.glob 在构建时会被处理 + if (typeof import.meta !== 'undefined' && import.meta.glob) { + // Dev 环境:使用 import.meta.glob 动态导入 + // @ts-ignore + const modules = import.meta.glob('../../content/**/*', { as: 'raw', eager: false }); + const index: DataIndex = {}; + for (const [path, importer] of Object.entries(modules)) { + const normalizedPath = path.replace('/src/', '/'); + index[normalizedPath] = normalizedPath; + } + return index; + } + + // CLI 环境:返回空索引,由 CLI 注入 + return {}; +} + +/** + * 设置全局数据索引(CLI 环境使用) + */ +export function setDataIndex(index: DataIndex) { + (window as any).__TTRPG_DATA_INDEX__ = index; +} + +/** + * 从全局索引获取数据 + */ +function getIndexedData(path: string): string | null { + const index = (window as any).__TTRPG_DATA_INDEX__ as DataIndex | undefined; + if (index && index[path]) { + return index[path]; + } + return null; +} + +/** + * 异步加载数据 + * @param path 数据路径 + * @returns 数据内容 + */ +export async function fetchData(path: string): Promise { + // 首先尝试从索引获取 + const indexedData = getIndexedData(path); + if (indexedData) { + return indexedData; + } + + // 索引不存在时,使用 fetch 加载 + try { + const response = await fetch(path); + if (!response.ok) { + throw new Error(`Failed to fetch: ${path}`); + } + return await response.text(); + } catch (error) { + console.error('fetchData error:', error); + throw error; + } +}