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

816 lines
24 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 CSSProperties,
type ChangeEvent,
lazy,
Suspense,
useCallback,
useEffect,
useMemo,
useRef,
useState
} from "react";
import {
getPaperDimensionsMm,
millimetersToCssPixels,
type ExportConfig
} from "@md-to-pdf/core";
import { ExportSettingsDrawer } from "./ExportSettingsDrawer";
import { PreviewZoomControl } from "./PreviewZoomControl";
import {
loadExportConfig,
resetExportConfig,
saveExportConfig
} from "./export-settings";
import {
createPagedPreviewRenderRequest,
isPagedPreviewFrameMessage,
type PagedPreviewPayload
} from "./paged-preview";
import {
getNearestPageNumber,
PREVIEW_SCROLLBAR_WIDTH_PX
} from "./preview-page";
import {
createPdfExportCacheKey,
downloadPdf,
getCachedPdfExport,
getOrRequestPdfExport,
type CachedPdfExport,
type PdfExportRequest
} from "./pdf-export";
import { getSyncedScrollTop } from "./scroll-sync";
import {
loadPreviewZoom,
savePreviewZoom
} from "./preview-zoom";
const PrecisePdfPreview = lazy(async () => {
const module = await import("./PrecisePdfPreview");
return { default: module.PrecisePdfPreview };
});
type PreviewMode = "quick" | "precise";
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。
`;
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 [exportConfig, setExportConfig] =
useState<ExportConfig>(loadExportConfig);
const [themeCss, setThemeCss] = useState("");
const [settingsOpen, setSettingsOpen] = useState(false);
const [status, setStatus] = useState("正在准备预览…");
const [renderError, setRenderError] = useState("");
const [themeError, setThemeError] = useState("");
const [mermaidError, setMermaidError] = useState("");
const [paginationError, setPaginationError] = useState("");
const [pdfError, setPdfError] = useState("");
const [exportingPdf, setExportingPdf] = useState(false);
const [previewMode, setPreviewMode] =
useState<PreviewMode>("quick");
const [previewZoom, setPreviewZoom] = useState(loadPreviewZoom);
const [pdfCache, setPdfCache] = useState<CachedPdfExport>();
const [previewFrameReady, setPreviewFrameReady] = useState(false);
const [previewPagination, setPreviewPagination] = useState({
current: 0,
total: 0
});
const editorRef = useRef<HTMLTextAreaElement>(null);
const previewScrollRef = useRef<HTMLDivElement>(null);
const previewFrameRef = useRef<HTMLIFrameElement>(null);
const previewRequestIdRef = useRef(0);
const pdfRequestKeyRef = useRef("");
const previewModeRef = useRef<PreviewMode>("quick");
const scrollSyncFrameRef = useRef<number | undefined>(undefined);
const scrollSyncOriginRef = useRef<
"editor" | "preview" | undefined
>(undefined);
const error =
renderError ||
themeError ||
mermaidError ||
paginationError ||
pdfError;
const themeId = exportConfig.themeId;
const selectedTheme = themes.find((theme) => theme.id === themeId);
const paperDimensions = getPaperDimensionsMm(
exportConfig.paper.format,
exportConfig.paper.orientation
);
const previewScale = previewZoom / 100;
const naturalPaperWidth =
millimetersToCssPixels(paperDimensions.width) +
PREVIEW_SCROLLBAR_WIDTH_PX;
const scaledPaperWidth = naturalPaperWidth * previewScale;
const paperStyle = {
width: `${scaledPaperWidth}px`,
minWidth: `${scaledPaperWidth}px`
} satisfies CSSProperties;
const previewFrameStyle = {
width: `${100 / previewScale}%`,
height: `${100 / previewScale}%`,
transform: `scale(${previewScale})`,
transformOrigin: "top left"
} satisfies CSSProperties;
const previewPayload = useMemo<PagedPreviewPayload | undefined>(
() =>
result && themeCss
? {
articleHtml: result.articleHtml,
fileName,
metadata: result.metadata,
features: result.features,
themeCss,
exportConfig
}
: undefined,
[exportConfig, fileName, result, themeCss]
);
const pdfRequest = useMemo<PdfExportRequest>(
() => ({
markdown,
fileName,
language: "zh-CN",
exportConfig
}),
[exportConfig, fileName, markdown]
);
const pdfCacheKey = useMemo(
() => createPdfExportCacheKey(pdfRequest),
[pdfRequest]
);
const cachedPdf = getCachedPdfExport(pdfCache, pdfCacheKey);
previewModeRef.current = previewMode;
pdfRequestKeyRef.current = pdfCacheKey;
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);
setExportConfig((currentConfig) => {
if (
availableThemes.some(
(theme) => theme.id === currentConfig.themeId
)
) {
return currentConfig;
}
const preferredTheme =
availableThemes.find((theme) => theme.id === "typora-github") ??
availableThemes.find((theme) => theme.id === "typora-like") ??
availableThemes[0];
return preferredTheme
? { ...currentConfig, themeId: preferredTheme.id }
: currentConfig;
});
})
.catch((reason: unknown) => {
if (!controller.signal.aborted) {
setThemeError(
reason instanceof Error ? reason.message : "无法加载主题清单"
);
}
});
return () => controller.abort();
}, []);
useEffect(() => {
try {
saveExportConfig(exportConfig);
} catch {
setRenderError("无法保存导出设置,当前设置仅在本次页面中有效");
}
}, [exportConfig]);
useEffect(() => {
savePreviewZoom(previewZoom);
const frame = window.requestAnimationFrame(() => {
synchronizeScroll("editor");
updatePreviewCurrentPage();
});
return () => window.cancelAnimationFrame(frame);
}, [previewZoom]);
useEffect(() => {
if (pdfCache && pdfCache.key !== pdfCacheKey) {
setPdfCache(undefined);
setPreviewPagination({ current: 0, total: 0 });
}
}, [pdfCache, pdfCacheKey]);
useEffect(() => {
const controller = new AbortController();
setPreviewFrameReady(false);
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]);
useEffect(() => {
function handlePreviewFrameMessage(event: MessageEvent<unknown>) {
const frame = previewFrameRef.current;
if (
event.origin !== window.location.origin ||
event.source !== frame?.contentWindow ||
!isPagedPreviewFrameMessage(event.data)
) {
return;
}
if (event.data.type === "ready") {
setPreviewFrameReady(true);
return;
}
if (event.data.requestId !== previewRequestIdRef.current) {
return;
}
if (event.data.type === "rendering") {
setPaginationError("");
setMermaidError("");
setPreviewPagination({ current: 0, total: 0 });
setStatus("正在分页…");
return;
}
if (event.data.type === "error") {
setPaginationError(event.data.message);
setStatus("分页失败");
return;
}
setMermaidError(
event.data.mermaidErrors.length > 0
? `Mermaid${event.data.mermaidErrors.join("")}`
: ""
);
setStatus(`预览已分页(${event.data.pageCount} 页)`);
setPreviewPagination({
current: event.data.pageCount > 0 ? 1 : 0,
total: event.data.pageCount
});
window.requestAnimationFrame(() => {
synchronizeScroll("editor");
window.requestAnimationFrame(updatePreviewCurrentPage);
});
}
window.addEventListener("message", handlePreviewFrameMessage);
return () =>
window.removeEventListener("message", handlePreviewFrameMessage);
}, []);
useEffect(() => {
const frameWindow = previewFrameRef.current?.contentWindow;
if (!frameWindow || !previewFrameReady) {
return;
}
function handlePreviewScroll() {
synchronizeScroll("preview");
updatePreviewCurrentPage();
}
frameWindow.addEventListener("scroll", handlePreviewScroll, {
passive: true
});
return () =>
frameWindow.removeEventListener("scroll", handlePreviewScroll);
}, [previewFrameReady]);
useEffect(() => {
return () => {
if (scrollSyncFrameRef.current !== undefined) {
window.cancelAnimationFrame(scrollSyncFrameRef.current);
}
};
}, []);
useEffect(() => {
const frameWindow = previewFrameRef.current?.contentWindow;
if (!frameWindow || !previewFrameReady || !previewPayload) {
return;
}
previewRequestIdRef.current += 1;
frameWindow.postMessage(
createPagedPreviewRenderRequest(
previewRequestIdRef.current,
previewPayload
),
window.location.origin
);
}, [previewFrameReady, previewPayload]);
function getPreviewScroller() {
if (previewModeRef.current === "precise") {
return previewScrollRef.current;
}
return previewFrameRef.current?.contentDocument
?.scrollingElement as HTMLElement | null;
}
function synchronizeScroll(origin: "editor" | "preview") {
const source =
origin === "editor" ? editorRef.current : getPreviewScroller();
const target =
origin === "editor" ? getPreviewScroller() : editorRef.current;
if (!source || !target) {
return;
}
const activeOrigin = scrollSyncOriginRef.current;
if (activeOrigin && activeOrigin !== origin) {
return;
}
scrollSyncOriginRef.current = origin;
target.scrollTop = getSyncedScrollTop(source, target);
if (scrollSyncFrameRef.current !== undefined) {
window.cancelAnimationFrame(scrollSyncFrameRef.current);
}
scrollSyncFrameRef.current = window.requestAnimationFrame(() => {
scrollSyncOriginRef.current = undefined;
scrollSyncFrameRef.current = undefined;
});
}
function updatePreviewCurrentPage() {
if (previewModeRef.current === "precise") {
updatePrecisePreviewCurrentPage();
return;
}
const frame = previewFrameRef.current;
const frameDocument = frame?.contentDocument;
const scrollElement = getPreviewScroller();
if (!frame || !frameDocument || !scrollElement) {
return;
}
const viewportCenter =
scrollElement.scrollTop + frame.clientHeight / 2;
const pages = Array.from(
frameDocument.querySelectorAll<HTMLElement>(".pagedjs_page")
).map((page) => {
const rect = page.getBoundingClientRect();
return {
top: rect.top + scrollElement.scrollTop,
bottom: rect.bottom + scrollElement.scrollTop
};
});
const current = getNearestPageNumber(viewportCenter, pages);
setPreviewPagination((pagination) =>
pagination.current === current &&
pagination.total === pages.length
? pagination
: {
current,
total: pages.length
}
);
}
function updatePrecisePreviewCurrentPage() {
const scrollElement = previewScrollRef.current;
if (!scrollElement) {
return;
}
const viewportCenter =
scrollElement.scrollTop + scrollElement.clientHeight / 2;
const pages = Array.from(
scrollElement.querySelectorAll<HTMLElement>(
".precise-pdf-page"
)
).map((page) => ({
top: page.offsetTop,
bottom: page.offsetTop + page.offsetHeight
}));
const current = getNearestPageNumber(viewportCenter, pages);
setPreviewPagination((pagination) =>
pagination.current === current &&
pagination.total === pages.length
? pagination
: {
current,
total: pages.length
}
);
}
const handlePrecisePageCount = useCallback((pageCount: number) => {
setPreviewPagination({
current: pageCount > 0 ? 1 : 0,
total: pageCount
});
window.requestAnimationFrame(updatePrecisePreviewCurrentPage);
}, []);
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 = "";
}
}
async function generatePdf(download: boolean) {
if (exportingPdf) {
return;
}
setExportingPdf(true);
setPdfError("");
setStatus(
download ? "正在生成 PDF…" : "正在生成精确预览…"
);
const requestedKey = pdfCacheKey;
try {
const resolved = await getOrRequestPdfExport(
pdfRequest,
pdfCache
);
if (pdfRequestKeyRef.current !== requestedKey) {
setStatus("文档已更新,已丢弃过期 PDF");
return;
}
setPdfCache(resolved);
const exported = resolved.result;
if (download) {
downloadPdf(exported.blob, exported.fileName);
}
const pageDescription =
exported.pageCount === undefined
? ""
: `${exported.pageCount} 页)`;
const mermaidDescription =
exported.mermaidErrorCount > 0
? `${exported.mermaidErrorCount} 个 Mermaid 图表渲染失败`
: "";
setStatus(
`${download ? "PDF 已导出" : "精确预览已生成"}${pageDescription}${mermaidDescription}`
);
} catch (reason) {
setPdfError(
reason instanceof Error ? reason.message : "PDF 导出失败"
);
setStatus(download ? "PDF 导出失败" : "精确预览失败");
} finally {
setExportingPdf(false);
}
}
function handlePreviewModeChange(mode: PreviewMode) {
setPreviewMode(mode);
setPreviewFrameReady(false);
setPreviewPagination({ current: 0, total: 0 });
previewScrollRef.current?.scrollTo({ top: 0 });
if (mode === "precise" && !cachedPdf) {
void generatePdf(false);
}
}
function handlePreviewScroll() {
if (previewModeRef.current !== "precise") {
return;
}
synchronizeScroll("preview");
updatePrecisePreviewCurrentPage();
}
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" onClick={() => setSettingsOpen(true)}>
导出设置
</button>
<button
type="button"
disabled={exportingPdf || !markdown.trim()}
onClick={() => void generatePdf(true)}
>
{exportingPdf ? "正在导出…" : "导出 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
ref={editorRef}
aria-label="Markdown 内容"
value={markdown}
spellCheck={false}
onChange={(event) => setMarkdown(event.target.value)}
onScroll={() => synchronizeScroll("editor")}
/>
</aside>
<section className="preview-panel" aria-label="文档预览">
<div className="panel-heading">
<div>
<span className="panel-kicker">实时预览</span>
<strong>{result?.metadata.title || "未命名文档"}</strong>
</div>
<span className="preview-heading-actions">
{previewPagination.total > 0 ? (
<span className="preview-page-indicator" aria-live="polite">
{previewPagination.current} / {previewPagination.total}
</span>
) : null}
<span
className="preview-mode-control"
role="group"
aria-label="预览模式"
>
<button
type="button"
className={
previewMode === "quick" ? "is-active" : ""
}
aria-pressed={previewMode === "quick"}
onClick={() => handlePreviewModeChange("quick")}
>
快速
</button>
<button
type="button"
className={
previewMode === "precise" ? "is-active" : ""
}
aria-pressed={previewMode === "precise"}
disabled={exportingPdf}
onClick={() => handlePreviewModeChange("precise")}
>
精确
</button>
</span>
<label className="theme-control">
<span className="sr-only">预览主题</span>
<select
aria-label="预览主题"
value={themeId}
onChange={(event) =>
setExportConfig((currentConfig) => ({
...currentConfig,
themeId: 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>
</span>
</div>
<div className="preview-toolbar">
<PreviewZoomControl
value={previewZoom}
onChange={setPreviewZoom}
/>
</div>
<div
ref={previewScrollRef}
className="preview-scroll"
onScroll={handlePreviewScroll}
>
{previewMode === "quick" ? (
<div className="paper" style={paperStyle}>
{previewPayload ? (
<iframe
ref={previewFrameRef}
className="preview-frame"
style={previewFrameStyle}
title="文档内容预览"
sandbox="allow-same-origin allow-scripts"
src="/preview-frame.html"
/>
) : (
<div className="preview-loading">
正在生成文档预览…
</div>
)}
</div>
) : exportingPdf && !cachedPdf ? (
<div className="precise-pdf-status">
正在使用固定版本 Chromium 生成精确预览…
</div>
) : cachedPdf ? (
<Suspense
fallback={
<div className="precise-pdf-status">
正在加载 PDF 查看器…
</div>
}
>
<PrecisePdfPreview
blob={cachedPdf.blob}
zoomPercent={previewZoom}
onPageCount={handlePrecisePageCount}
/>
</Suspense>
) : (
<div className="precise-pdf-status">
<span>当前内容尚未生成精确预览</span>
<button
type="button"
onClick={() => void generatePdf(false)}
>
生成精确预览
</button>
</div>
)}
</div>
</section>
</section>
{settingsOpen ? (
<ExportSettingsDrawer
config={exportConfig}
onChange={setExportConfig}
onClose={() => setSettingsOpen(false)}
onReset={() => setExportConfig(resetExportConfig())}
/>
) : null}
</main>
);
}