Files
MorphDoc/apps/web/src/App.tsx
T

443 lines
12 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 {
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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
function escapeStyleContent(value: string) {
return value.replace(/<\/style/gi, "<\\/style");
}
function buildPreviewDocument(
result: RenderedMarkdown,
themeCss: string
) {
return `<!doctype html>
<html lang="${escapeHtml(result.metadata.language || "zh-CN")}">
<head>
<meta charset="UTF-8" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'none'; style-src 'unsafe-inline'; font-src 'self' data:; img-src 'self' data:;"
/>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>${escapeHtml(result.metadata.title || "Markdown 预览")}</title>
<style>${escapeStyleContent(previewBaseCss)}</style>
<style>${escapeStyleContent(highlightCss)}</style>
<style>${escapeStyleContent(katexCss)}</style>
<style>${escapeStyleContent(themeCss)}</style>
</head>
<body>
${result.articleHtml}
</body>
</html>`;
}
export function App() {
const [markdown, setMarkdown] = useState(sampleMarkdown);
const [fileName, setFileName] = useState("示例文档.md");
const [result, setResult] = useState<RenderedMarkdown | null>(null);
const [themes, setThemes] = useState<ThemeSummary[]>([]);
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<HTMLIFrameElement>(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<HTMLElement>(
".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<HTMLInputElement>) {
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 (
<main className="workspace">
<header className="topbar">
<div>
<p className="eyebrow">内网文档工具</p>
<h1>Markdown PDF 导出器</h1>
</div>
<div className="topbar-actions">
<label className="file-button">
选择 Markdown
<input
type="file"
accept=".md,.markdown,text/markdown,text/plain"
onChange={handleFileChange}
/>
</label>
<button type="button" disabled title="下一阶段实现">
导出 PDF
</button>
</div>
</header>
<section className="editor-layout">
<aside className="editor-panel" aria-label="Markdown 编辑区">
<div className="panel-heading">
<div>
<span className="panel-kicker">源文件</span>
<strong>{fileName}</strong>
</div>
<span className={`render-status${error ? " is-error" : ""}`}>
{error || status}
</span>
</div>
<textarea
aria-label="Markdown 内容"
value={markdown}
spellCheck={false}
onChange={(event) => setMarkdown(event.target.value)}
/>
</aside>
<section className="preview-panel" aria-label="文档预览">
<div className="panel-heading">
<div>
<span className="panel-kicker">实时预览</span>
<strong>{result?.metadata.title || "未命名文档"}</strong>
</div>
<label className="theme-control">
<span className="sr-only">预览主题</span>
<select
aria-label="预览主题"
value={themeId}
onChange={(event) => setThemeId(event.target.value)}
>
{themes.length === 0 ? (
<option value={themeId}>正在加载主题…</option>
) : (
themes.map((theme) => (
<option key={theme.id} value={theme.id}>
{theme.name}
</option>
))
)}
</select>
{selectedTheme?.source === "local" ? (
<span className="local-theme-mark">本地</span>
) : null}
</label>
</div>
<div className="preview-scroll">
<div
className={`paper${
selectedTheme?.source === "local" ? " is-local-theme" : ""
}`}
>
{previewDocument ? (
<iframe
ref={previewFrameRef}
className="preview-frame"
title="文档内容预览"
sandbox="allow-same-origin"
srcDoc={previewDocument}
onLoad={() => void handlePreviewLoad()}
/>
) : (
<div className="preview-loading">正在生成文档预览…</div>
)}
</div>
</div>
</section>
</section>
</main>
);
}