import { type ChangeEvent, useEffect, useMemo, useRef, useState } from "react"; import highlightCss from "highlight.js/styles/github.css?inline"; import katexCss from "katex/dist/katex.min.css?inline"; interface RenderedMarkdown { articleHtml: string; metadata: { title: string; author: string; subject: string; keywords: string[]; language: string; }; features: string[]; warnings: string[]; } interface ThemeSummary { id: string; name: string; version: string; description: string; bundled: boolean; source: "bundled" | "local"; } const sampleMarkdown = `--- title: Markdown PDF 示例 author: 内网文档团队 keywords: - Markdown - PDF --- # Markdown PDF 示例 这是一份使用 **Typora 风格主题** 渲染的预览文档。 ## 常用内容 - [x] Markdown 实时预览 - [x] 表格与任务列表 - [x] 数学公式与 Mermaid - [ ] Chromium PDF 导出 | 功能 | 当前状态 | | --- | --- | | HTML 预览 | 已实现 | | PDF 下载 | 下一阶段 | 行内公式:$E = mc^2$ \`\`\`mermaid flowchart LR A["Markdown"] --> B["统一 HTML"] B --> C["主题 CSS"] C --> D["网页预览"] C --> E["PDF 导出"] \`\`\` > 预览和 PDF 将共用同一份 HTML 与 CSS。 `; const previewBaseCss = ` :root { color-scheme: light; background: #fff; } * { box-sizing: border-box; } html, body { margin: 0; min-width: 0; background: #fff; } img, svg { max-width: 100%; } .mermaid { display: flex; justify-content: center; margin: 1.5em 0; overflow: auto; } `; let mermaidPromise: Promise<(typeof import("mermaid"))["default"]> | undefined; let mermaidRenderSequence = 0; async function loadMermaid() { if (!mermaidPromise) { mermaidPromise = import("mermaid").then(({ default: mermaid }) => { mermaid.initialize({ startOnLoad: false, securityLevel: "strict", theme: "neutral", fontFamily: "Segoe UI, Microsoft YaHei, sans-serif" }); return mermaid; }); } return mermaidPromise; } function escapeHtml(value: string) { return value .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """); } function escapeStyleContent(value: string) { return value.replace(/<\/style/gi, "<\\/style"); } function buildPreviewDocument( result: RenderedMarkdown, themeCss: string ) { return ` ${escapeHtml(result.metadata.title || "Markdown 预览")} ${result.articleHtml} `; } export function App() { const [markdown, setMarkdown] = useState(sampleMarkdown); const [fileName, setFileName] = useState("示例文档.md"); const [result, setResult] = useState(null); const [themes, setThemes] = useState([]); const [themeId, setThemeId] = useState("typora-like"); const [themeCss, setThemeCss] = useState(""); const [status, setStatus] = useState("正在准备预览…"); const [renderError, setRenderError] = useState(""); const [themeError, setThemeError] = useState(""); const [mermaidError, setMermaidError] = useState(""); const previewFrameRef = useRef(null); const error = renderError || themeError || mermaidError; const selectedTheme = themes.find((theme) => theme.id === themeId); const previewDocument = useMemo( () => result && themeCss ? buildPreviewDocument(result, themeCss) : undefined, [result, themeCss] ); useEffect(() => { const controller = new AbortController(); void fetch("/api/themes", { signal: controller.signal }) .then(async (response) => { if (!response.ok) { throw new Error("无法加载主题清单"); } return response.json() as Promise<{ themes: ThemeSummary[] }>; }) .then(({ themes: availableThemes }) => { setThemes(availableThemes); const preferredTheme = availableThemes.find((theme) => theme.id === "typora-github") ?? availableThemes.find((theme) => theme.id === "typora-like") ?? availableThemes[0]; if (preferredTheme) { setThemeId(preferredTheme.id); } }) .catch((reason: unknown) => { if (!controller.signal.aborted) { setThemeError( reason instanceof Error ? reason.message : "无法加载主题清单" ); } }); return () => controller.abort(); }, []); useEffect(() => { const controller = new AbortController(); setThemeCss(""); setThemeError(""); void fetch(`/api/themes/${encodeURIComponent(themeId)}/css`, { signal: controller.signal }) .then(async (response) => { if (!response.ok) { throw new Error("无法加载主题"); } return response.text(); }) .then(setThemeCss) .catch((reason: unknown) => { if (!controller.signal.aborted) { setThemeError( reason instanceof Error ? reason.message : "无法加载主题" ); } }); return () => controller.abort(); }, [themeId]); useEffect(() => { const controller = new AbortController(); const timer = window.setTimeout(() => { setStatus("正在渲染…"); setRenderError(""); setMermaidError(""); void fetch("/api/render", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ markdown, language: "zh-CN" }), signal: controller.signal }) .then(async (response) => { const payload = await response.json(); if (!response.ok) { throw new Error(payload.message ?? "渲染失败"); } return payload as RenderedMarkdown; }) .then((payload) => { setResult(payload); setStatus("预览已更新"); }) .catch((reason: unknown) => { if (!controller.signal.aborted) { setRenderError( reason instanceof Error ? reason.message : "渲染失败" ); setStatus("预览失败"); } }); }, 250); return () => { window.clearTimeout(timer); controller.abort(); }; }, [markdown]); async function handlePreviewLoad() { const frame = previewFrameRef.current; const document = frame?.contentDocument; if (!frame || !document || !result) { return; } const resizeFrame = () => { frame.style.height = `${Math.max( document.documentElement.scrollHeight, document.body.scrollHeight, 500 )}px`; }; resizeFrame(); await document.fonts.ready; if (result.features.includes("mermaid")) { const nodes = Array.from( document.querySelectorAll( ".mermaid[data-mermaid-pending]" ) ); if (nodes.length > 0) { try { const mermaid = await loadMermaid(); for (const node of nodes) { const definition = node.textContent ?? ""; mermaidRenderSequence += 1; const { svg, bindFunctions } = await mermaid.render( `mermaid-preview-${mermaidRenderSequence}`, definition ); node.innerHTML = svg; node.removeAttribute("data-mermaid-pending"); bindFunctions?.(node); } } catch (reason: unknown) { setMermaidError( reason instanceof Error ? `Mermaid:${reason.message}` : "Mermaid 渲染失败" ); } } } resizeFrame(); } async function handleFileChange(event: ChangeEvent) { const file = event.target.files?.[0]; if (!file) { return; } try { setMarkdown(await file.text()); setFileName(file.name); } catch { setRenderError("无法读取所选 Markdown 文件"); } finally { event.target.value = ""; } } return (

内网文档工具

Markdown PDF 导出器