Ensures that navigation to markdown files strips the extension, allowing the SPA fallback to handle refreshes instead of requesting the raw file.
156 lines
4.7 KiB
TypeScript
156 lines
4.7 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();
|
||
// Resolve relative hrefs against the current page path so that
|
||
// e.g. "blah.md" on /content/page.md becomes /content/blah
|
||
const resolved = new URL(href, window.location.origin + window.location.pathname);
|
||
let pathname = resolved.pathname;
|
||
// Strip .md extension so refreshes hit the SPA fallback, not the raw file
|
||
if (pathname.endsWith(".md")) {
|
||
pathname = pathname.slice(0, -3);
|
||
}
|
||
navigate(pathname + resolved.hash);
|
||
};
|
||
|
||
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;
|