Files
ttrpg-tools/src/components/Article.tsx
T

148 lines
4.3 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,
ParentProps,
createContext,
useContext,
createEffect,
onCleanup,
Show,
createResource,
createSignal,
} from "solid-js";
import { parseMarkdown } from "../markdown";
import { extractSection } from "../data-loader";
import mermaid from "mermaid";
import { getIndexedData } from "../data-loader/file-index";
import { useNavigateWithParams } from "./useNavigateWithParams";
export interface ArticleProps {
src: string;
section?: string; // 指定要显示的标题(不含 #
onLoaded?: () => void;
onError?: (error: Error) => void;
class?: string; // 额外的 class 用于样式控制
scrollToHash?: boolean; // 是否自动滚动到 hash
}
// ---------------------------------------------------------------------------
// Article DOM context lets child components react to the content container
// ---------------------------------------------------------------------------
const ArticleDomCtx = createContext<() => HTMLDivElement | undefined>();
/** Access the article's content DOM element from a child component. */
export function useArticleDom(): () => HTMLDivElement | undefined {
const ctx = useContext(ArticleDomCtx);
if (!ctx) throw new Error("useArticleDom must be used inside an <Article>");
return ctx;
}
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 & ParentProps> = (props) => {
const navigate = useNavigateWithParams();
const [content, { refetch }] = createResource(
() => ({ src: props.src, section: props.section }),
fetchArticleContent,
);
const [contentDom, setContentDom] = createSignal<HTMLDivElement>();
createEffect(() => {
const data = content();
if (data) {
props.onLoaded?.();
// 内容加载完成后,渲染 mermaid 图表
void mermaid.run();
// 内容渲染后检查 hash 并滚动
scrollToHash(window.location.hash);
}
});
// Intercept markdown <a> links to use client-side navigation with
// preserved URL search params.
createEffect(() => {
const dom = contentDom();
if (!dom) return;
const onClick = (e: MouseEvent) => {
const anchor = (e.target as HTMLElement).closest("a[href]");
if (!anchor) return;
const href = anchor.getAttribute("href");
if (!href) return;
// Only intercept same-origin navigation links (not external, not anchors)
if (href.startsWith("http") || href.startsWith("//")) return;
if (href.startsWith("#")) return;
e.preventDefault();
navigate(href);
};
dom.addEventListener("click", onClick);
onCleanup(() => dom.removeEventListener("click", onClick));
});
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()}>
<ArticleDomCtx.Provider value={contentDom}>
<div
class="relative"
ref={setContentDom}
innerHTML={parseMarkdown(content()!, props.src)}
/>
{props.children}
</ArticleDomCtx.Provider>
</Show>
</article>
);
};
export default Article;