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(null); const [themes, setThemes] = useState([]); const [exportConfig, setExportConfig] = useState(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("quick"); const [previewZoom, setPreviewZoom] = useState(loadPreviewZoom); const [pdfCache, setPdfCache] = useState(); const [previewFrameReady, setPreviewFrameReady] = useState(false); const [previewPagination, setPreviewPagination] = useState({ current: 0, total: 0 }); const editorRef = useRef(null); const previewScrollRef = useRef(null); const previewFrameRef = useRef(null); const previewRequestIdRef = useRef(0); const pdfRequestKeyRef = useRef(""); const previewModeRef = useRef("quick"); const scrollSyncFrameRef = useRef(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( () => result && themeCss ? { articleHtml: result.articleHtml, fileName, metadata: result.metadata, features: result.features, themeCss, exportConfig } : undefined, [exportConfig, fileName, result, themeCss] ); const pdfRequest = useMemo( () => ({ 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) { 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(".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( ".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) { 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 (

内网文档工具

Markdown PDF 导出器