ttrpg-tools/src/components/Article.tsx

109 lines
3.0 KiB
TypeScript
Raw Normal View History

import {
Component,
createEffect,
onCleanup,
Show,
createResource,
createMemo,
} from "solid-js";
import { parseMarkdown } from "../markdown";
import { extractSection } from "../data-loader";
import mermaid from "mermaid";
import { getIndexedData } from "../data-loader/file-index";
import { resolvePath } from "./utils/path";
import { useLocation } from "@solidjs/router";
2026-02-26 09:24:26 +08:00
export interface ArticleProps {
src: string;
section?: string; // 指定要显示的标题(不含 #
iconPath?: string; // 图标路径前缀,默认为 "./assets",空字符串表示禁用
2026-02-26 09:24:26 +08:00
onLoaded?: () => void;
onError?: (error: Error) => void;
class?: string; // 额外的 class 用于样式控制
2026-03-22 00:57:35 +08:00
scrollToHash?: boolean; // 是否自动滚动到 hash
2026-02-26 09:24:26 +08:00
}
async function fetchArticleContent(params: {
src: string;
section?: string;
}): Promise<string> {
2026-03-13 15:52:51 +08:00
const text = await getIndexedData(params.src);
2026-02-26 14:51:26 +08:00
// 如果指定了 section提取对应内容
return params.section ? extractSection(text, params.section) : text;
}
2026-03-22 00:57:35 +08:00
/**
* hash
*/
function scrollToHash(hash: string) {
if (!hash) return;
// 移除 # 前缀
const id = hash.startsWith("#") ? hash.slice(1) : hash;
2026-03-22 00:57:35 +08:00
if (!id) return;
2026-03-22 00:57:35 +08:00
// 使用 decodeURIComponent 解码 ID处理中文等特殊字符
const decodedId = decodeURIComponent(id);
2026-03-22 00:57:35 +08:00
// 尝试查找元素
const element = document.getElementById(decodedId);
if (element) {
// 使用 scrollIntoView 滚动到元素
element.scrollIntoView({ behavior: "instant", block: "start" });
2026-03-22 00:57:35 +08:00
return true;
}
return false;
}
2026-02-26 09:24:26 +08:00
/**
* Article
* src md markdown
*/
export const Article: Component<ArticleProps> = (props) => {
2026-03-22 00:57:35 +08:00
const location = useLocation();
2026-02-26 14:51:26 +08:00
const [content, { refetch }] = createResource(
() => ({ src: props.src, section: props.section }),
fetchArticleContent,
2026-02-26 14:51:26 +08:00
);
2026-02-26 09:24:26 +08:00
2026-03-14 10:27:07 +08:00
// 解析 iconPath默认为 "./assets",空字符串表示禁用
const iconPrefix = createMemo(() => {
if (props.iconPath === "") return undefined; // 空字符串禁用图标前缀
2026-03-14 10:27:07 +08:00
return resolvePath(props.src, props.iconPath ?? "./assets");
});
2026-02-26 14:51:26 +08:00
createEffect(() => {
const data = content();
if (data) {
2026-02-26 09:24:26 +08:00
props.onLoaded?.();
2026-03-02 11:03:51 +08:00
// 内容加载完成后,渲染 mermaid 图表
void mermaid.run();
2026-03-22 00:57:35 +08:00
// 内容渲染后检查 hash 并滚动
scrollToHash(location.hash);
2026-02-26 09:24:26 +08:00
}
});
onCleanup(() => {
// 清理时清空内容,触发内部组件的销毁
});
return (
<article class={`prose ${props.class || ""}`} data-src={props.src}>
2026-02-26 14:51:26 +08:00
<Show when={content.loading}>
2026-02-26 09:24:26 +08:00
<div class="text-gray-500">...</div>
</Show>
2026-02-26 14:51:26 +08:00
<Show when={content.error}>
<div class="text-red-500">{content.error?.message}</div>
2026-02-26 09:24:26 +08:00
</Show>
2026-02-26 14:51:26 +08:00
<Show when={!content.loading && !content.error && content()}>
<div
class="relative"
innerHTML={parseMarkdown(content()!, iconPrefix())}
/>
2026-02-26 09:24:26 +08:00
</Show>
</article>
);
};
export default Article;