148 lines
4.3 KiB
TypeScript
148 lines
4.3 KiB
TypeScript
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;
|