ttrpg-tools/src/components/Article.tsx

109 lines
3.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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