import { type CSSProperties, type ChangeEvent, lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { createPagedDocumentPayload, getPaperDimensionsMm, millimetersToCssPixels, type ExportConfig } from "@md-to-pdf/core"; import { ExportSettingsDrawer } from "./ExportSettingsDrawer"; import { PreviewPageControl } from "./PreviewPageControl"; import { PreviewZoomControl } from "./PreviewZoomControl"; import { loadExportConfig, resetExportConfig, saveExportConfig } from "./export-settings"; import { createPagedPreviewRenderRequest, isPagedPreviewFrameMessage, type PagedPreviewPayload } from "./paged-preview"; import { getNearestPageNumber, getPreviewPageScrollTop } from "./preview-page"; import { createPdfExportCacheKey, downloadPdf, getCachedPdfExport, getOrRequestPdfExport, requestDesktopPdfExport, type CachedPdfExport, type PdfExportRequest } from "./pdf-export"; import { APP_VERSION_LABEL } from "./app-version"; import { getSyncedScrollTop } from "./scroll-sync"; import { loadPreviewZoom, savePreviewZoom } from "./preview-zoom"; import { useMarkdownRender } from "./use-markdown-render"; import { createMarkdownFileName, downloadMarkdown, ensureMarkdownFileName } from "./document-file"; import { type ThemeSummary, useThemeResources } from "./use-theme-resources"; import { sampleMarkdown } from "./sample-markdown"; const PrecisePdfPreview = lazy(async () => { const module = await import("./PrecisePdfPreview"); return { default: module.PrecisePdfPreview }; }); type PreviewMode = "quick" | "precise"; type DocumentKind = "sample" | "new" | "file"; const noImageResources: never[] = []; export function App() { const [markdown, setMarkdown] = useState(sampleMarkdown); const [fileName, setFileName] = useState("示例文档.md"); const [documentKind, setDocumentKind] = useState("sample"); const [savedMarkdown, setSavedMarkdown] = useState(sampleMarkdown); const [exportConfig, setExportConfig] = useState(loadExportConfig); const [settingsOpen, setSettingsOpen] = useState(false); const [moreMenuOpen, setMoreMenuOpen] = useState(false); const [themeRefreshKey, setThemeRefreshKey] = useState(0); const [sourcePanelCollapsed, setSourcePanelCollapsed] = useState(false); const [status, setStatus] = useState("正在准备预览…"); const [appError, setAppError] = useState(""); const [echartsError, setEChartsError] = 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 [quickPreviewContentHeight, setQuickPreviewContentHeight] = useState(); const [previewPagination, setPreviewPagination] = useState({ current: 0, total: 0 }); const editorRef = useRef(null); const markdownFileInputRef = useRef(null); const moreMenuRef = 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 documentDirty = markdown !== savedMarkdown; const documentDirtyRef = useRef(documentDirty); const themeRefreshTargetRef = useRef(undefined); documentDirtyRef.current = documentDirty; useEffect(() => { const bridge = window.mdToPdfDesktop; if (!bridge) { return; } let active = true; const consumeOpenedMarkdown = async () => { if ( documentDirtyRef.current && !window.confirm("当前文档有未保存修改,是否放弃并打开其他文件?") ) { try { await bridge.discardPendingMarkdown(); } catch (reason) { if (active) { setAppError( reason instanceof Error ? reason.message : "无法丢弃待打开的 Markdown 文件" ); } } return; } try { const opened = await bridge.consumePendingMarkdown(); if (!active || !opened) { return; } setAppError(""); setMarkdown(opened.markdown); setFileName(opened.fileName); setDocumentKind("file"); setSavedMarkdown(opened.markdown); setStatus("Markdown 与同目录图片素材已载入"); } catch (reason) { if (active) { setAppError( reason instanceof Error ? reason.message : "无法打开 Markdown 文件" ); } } }; const removeListener = bridge.onMarkdownOpened(() => { void consumeOpenedMarkdown(); }); void consumeOpenedMarkdown(); return () => { active = false; removeListener(); }; }, []); useEffect(() => { if (!moreMenuOpen) { return; } const handlePointerDown = (event: PointerEvent) => { if ( event.target instanceof Node && !moreMenuRef.current?.contains(event.target) ) { setMoreMenuOpen(false); } }; const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") { setMoreMenuOpen(false); } }; window.addEventListener("pointerdown", handlePointerDown); window.addEventListener("keydown", handleKeyDown); return () => { window.removeEventListener("pointerdown", handlePointerDown); window.removeEventListener("keydown", handleKeyDown); }; }, [moreMenuOpen]); const handleThemesLoaded = useCallback( (availableThemes: ThemeSummary[], loadedRefreshKey: number) => { 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; }); if (themeRefreshTargetRef.current === loadedRefreshKey) { themeRefreshTargetRef.current = undefined; setStatus("主题清单已刷新"); } }, [] ); const handleRenderStart = useCallback(() => { setEChartsError(""); setMermaidError(""); }, []); const { error: renderError, result } = useMarkdownRender( markdown, "zh-CN", noImageResources, { onRenderStart: handleRenderStart, onStatusChange: setStatus } ); const { error: themeError, themeCss, themes } = useThemeResources( exportConfig.themeId, handleThemesLoaded, themeRefreshKey ); const error = renderError || themeError || appError || echartsError || mermaidError || paginationError || pdfError; const themeId = exportConfig.themeId; const selectedTheme = themes.find((theme) => theme.id === themeId); const effectiveFileName = documentKind === "new" || !fileName ? createMarkdownFileName(markdown) : fileName; const documentDisplayName = documentKind === "new" ? "新建文档" : fileName; const showUnsavedState = documentKind === "new" || documentDirty; const paperDimensions = getPaperDimensionsMm( exportConfig.paper.format, exportConfig.paper.orientation ); const previewScale = previewZoom / 100; const naturalPaperWidth = millimetersToCssPixels(paperDimensions.width); const scaledPaperWidth = naturalPaperWidth * previewScale; const paperStyle = { width: `${scaledPaperWidth}px`, minWidth: `${scaledPaperWidth}px`, ...(quickPreviewContentHeight ? { height: `${quickPreviewContentHeight * previewScale}px`, minHeight: `${quickPreviewContentHeight * previewScale}px` } : {}) } satisfies CSSProperties; const previewFrameStyle = { width: `${100 / previewScale}%`, height: quickPreviewContentHeight ? `${quickPreviewContentHeight}px` : `${100 / previewScale}%`, transform: `scale(${previewScale})`, transformOrigin: "top left" } satisfies CSSProperties; const previewPayload = useMemo( () => result && themeCss ? createPagedDocumentPayload({ document: result, fileName: effectiveFileName, themeCss, exportConfig }) : undefined, [effectiveFileName, exportConfig, result, themeCss] ); const pdfRequest = useMemo( () => ({ markdown, fileName: effectiveFileName, language: "zh-CN", resources: noImageResources, exportConfig }), [effectiveFileName, exportConfig, markdown] ); const pdfCacheKey = useMemo( () => createPdfExportCacheKey(pdfRequest), [pdfRequest] ); const cachedPdf = getCachedPdfExport(pdfCache, pdfCacheKey); previewModeRef.current = previewMode; pdfRequestKeyRef.current = pdfCacheKey; useEffect(() => { try { saveExportConfig(exportConfig); setAppError(""); } catch { setAppError("无法保存导出设置,当前设置仅在本次页面中有效"); } }, [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(() => { setPreviewFrameReady(false); }, [themeId]); 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(""); setEChartsError(""); setMermaidError(""); setQuickPreviewContentHeight(undefined); setPreviewPagination({ current: 0, total: 0 }); setStatus("正在分页…"); return; } if (event.data.type === "error") { setPaginationError(event.data.message); setStatus("分页失败"); return; } setEChartsError( event.data.echartsErrors.length > 0 ? `ECharts:${event.data.echartsErrors.join(";")}` : "" ); setMermaidError( event.data.mermaidErrors.length > 0 ? `Mermaid:${event.data.mermaidErrors.join(";")}` : "" ); setStatus(`预览已分页(${event.data.pageCount} 页)`); setQuickPreviewContentHeight(event.data.contentHeight); 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(() => { function handleWindowScroll() { if (getOuterPreviewScroller() === document.scrollingElement) { updatePreviewCurrentPage(); } } window.addEventListener("scroll", handleWindowScroll, { passive: true }); return () => window.removeEventListener("scroll", handleWindowScroll); }, [previewMode, quickPreviewContentHeight]); 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 getOuterPreviewScroller() { const previewScroller = previewScrollRef.current; if (!previewScroller) { return null; } return previewScroller.scrollHeight > previewScroller.clientHeight + 1 ? previewScroller : (document.scrollingElement as HTMLElement | null); } function getPreviewScroller() { return getOuterPreviewScroller(); } 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 scrollerViewportTop = scrollElement === document.scrollingElement ? 0 : scrollElement.getBoundingClientRect().top; const frameViewportTop = frame.getBoundingClientRect().top; const viewportCenter = scrollElement.scrollTop + scrollElement.clientHeight / 2; const pages = Array.from( frameDocument.querySelectorAll(".pagedjs_page") ).map((page) => { const rect = page.getBoundingClientRect(); return { top: getPreviewPageScrollTop( scrollElement.scrollTop, frameViewportTop + rect.top * previewScale, scrollerViewportTop ), bottom: getPreviewPageScrollTop( scrollElement.scrollTop, frameViewportTop + rect.bottom * previewScale, scrollerViewportTop ) }; }); const current = getNearestPageNumber(viewportCenter, pages); setPreviewPagination((pagination) => pagination.current === current && pagination.total === pages.length ? pagination : { current, total: pages.length } ); } function updatePrecisePreviewCurrentPage() { const scrollElement = getOuterPreviewScroller(); if (!scrollElement) { return; } const scrollerViewportTop = scrollElement === document.scrollingElement ? 0 : scrollElement.getBoundingClientRect().top; const viewportCenter = scrollElement.scrollTop + scrollElement.clientHeight / 2; const pages = Array.from( scrollElement.querySelectorAll( ".precise-pdf-page" ) ).map((page) => { const rect = page.getBoundingClientRect(); return { top: getPreviewPageScrollTop( scrollElement.scrollTop, rect.top, scrollerViewportTop ), bottom: getPreviewPageScrollTop( scrollElement.scrollTop, rect.bottom, scrollerViewportTop ) }; }); const current = getNearestPageNumber(viewportCenter, pages); setPreviewPagination((pagination) => pagination.current === current && pagination.total === pages.length ? pagination : { current, total: pages.length } ); } function handlePreviewPageChange(pageNumber: number) { const scrollElement = getPreviewScroller(); if (!scrollElement) { return; } let targetTop: number | undefined; if (previewModeRef.current === "precise") { const page = scrollElement.querySelectorAll( ".precise-pdf-page" )[pageNumber - 1]; if (page) { const scrollerViewportTop = scrollElement === document.scrollingElement ? 0 : scrollElement.getBoundingClientRect().top; targetTop = getPreviewPageScrollTop( scrollElement.scrollTop, page.getBoundingClientRect().top, scrollerViewportTop ); } } else { const page = previewFrameRef.current?.contentDocument?.querySelectorAll( ".pagedjs_page" )[pageNumber - 1]; if (page) { const scrollerViewportTop = scrollElement === document.scrollingElement ? 0 : scrollElement.getBoundingClientRect().top; const frameViewportTop = previewFrameRef.current?.getBoundingClientRect().top ?? 0; targetTop = getPreviewPageScrollTop( scrollElement.scrollTop, frameViewportTop + page.getBoundingClientRect().top * previewScale, scrollerViewportTop ); } } if (targetTop === undefined) { return; } setPreviewPagination((pagination) => ({ ...pagination, current: pageNumber })); scrollElement.scrollTo({ top: Math.max(0, targetTop) }); } const handlePrecisePageCount = useCallback((pageCount: number) => { setPreviewPagination({ current: pageCount > 0 ? 1 : 0, total: pageCount }); window.requestAnimationFrame(updatePrecisePreviewCurrentPage); }, []); function confirmDiscardChanges() { return ( !documentDirty || window.confirm("当前文档有未保存修改,是否放弃这些修改?") ); } function handleOpenMarkdown() { if (!confirmDiscardChanges()) { return; } if (window.mdToPdfDesktop) { void handleDesktopOpenMarkdown(); return; } markdownFileInputRef.current?.click(); } async function handleFileChange(event: ChangeEvent) { const file = event.target.files?.[0]; if (!file) { return; } try { setAppError(""); const content = await file.text(); setMarkdown(content); setFileName(file.name); setDocumentKind("file"); setSavedMarkdown(content); setStatus("Markdown 已载入;Web 端不读取本地相对图片"); } catch { setAppError("无法读取所选 Markdown 文件"); } finally { event.target.value = ""; } } async function handleDesktopOpenMarkdown() { try { setAppError(""); const opened = await window.mdToPdfDesktop?.openMarkdown(); if (!opened) { return; } setMarkdown(opened.markdown); setFileName(opened.fileName); setDocumentKind("file"); setSavedMarkdown(opened.markdown); setStatus("Markdown 与同目录图片素材已载入"); } catch (reason) { setAppError( reason instanceof Error ? reason.message : "无法打开 Markdown 文件" ); } } async function handleNewMarkdown() { setMoreMenuOpen(false); if (!confirmDiscardChanges()) { return; } try { setAppError(""); await window.mdToPdfDesktop?.startNewMarkdown(); setMarkdown(""); setFileName(""); setDocumentKind("new"); setSavedMarkdown(""); setStatus("新建文档尚未保存,可直接编辑或导出 PDF"); window.requestAnimationFrame(() => editorRef.current?.focus()); } catch (reason) { setAppError( reason instanceof Error ? reason.message : "无法新建 Markdown" ); } } async function handleSaveMarkdown() { setMoreMenuOpen(false); const suggestedName = ensureMarkdownFileName(fileName, markdown); try { setAppError(""); const bridge = window.mdToPdfDesktop; let savedFileName = suggestedName; if (bridge) { const saved = await bridge.saveMarkdown(suggestedName, markdown); if (!saved) { setStatus("已取消 Markdown 保存"); return; } savedFileName = saved.fileName; } else { downloadMarkdown(markdown, suggestedName); } setFileName(savedFileName); setDocumentKind("file"); setSavedMarkdown(markdown); setStatus(`Markdown 已保存:${savedFileName}`); } catch (reason) { setAppError( reason instanceof Error ? reason.message : "Markdown 保存失败" ); } } async function handleOpenThemeDirectory() { setMoreMenuOpen(false); try { setAppError(""); const themeDirectory = await window.mdToPdfDesktop?.openThemeDirectory(); if (themeDirectory) { setStatus(`自定义主题目录:${themeDirectory}`); } } catch (reason) { setAppError( reason instanceof Error ? reason.message : "无法打开自定义主题目录" ); } } async function handleRefreshThemes() { setMoreMenuOpen(false); try { setAppError(""); await window.mdToPdfDesktop?.refreshThemes(); setStatus("正在刷新主题清单…"); setThemeRefreshKey((key) => { const nextKey = key + 1; themeRefreshTargetRef.current = nextKey; return nextKey; }); } catch (reason) { setAppError( reason instanceof Error ? reason.message : "无法刷新主题清单" ); } } async function generatePdf(download: boolean) { if (exportingPdf) { return; } setExportingPdf(true); setPdfError(""); setStatus( download ? "正在生成 PDF…" : "正在生成精确预览…" ); const requestedKey = pdfCacheKey; try { const resolved = await getOrRequestPdfExport( pdfRequest, pdfCache, previewPayload && window.mdToPdfDesktop ? () => requestDesktopPdfExport(previewPayload) : undefined ); if (pdfRequestKeyRef.current !== requestedKey) { setStatus("文档已更新,已丢弃过期 PDF"); return; } setPdfCache(resolved); const exported = resolved.result; if (download) { const saved = await downloadPdf(exported.blob, exported.fileName); if (!saved) { setStatus("已取消 PDF 保存"); return; } } const pageDescription = exported.pageCount === undefined ? "" : `(${exported.pageCount} 页)`; const mermaidDescription = exported.mermaidErrorCount > 0 ? `,${exported.mermaidErrorCount} 个 Mermaid 图表渲染失败` : ""; const echartsDescription = exported.echartsErrorCount > 0 ? `,${exported.echartsErrorCount} 个 ECharts 图表渲染失败` : ""; setStatus( `${download ? "PDF 已导出" : "精确预览已生成"}${pageDescription}${echartsDescription}${mermaidDescription}` ); } catch (reason) { setPdfError( reason instanceof Error ? reason.message : "PDF 导出失败" ); setStatus(download ? "PDF 导出失败" : "精确预览失败"); } finally { setExportingPdf(false); } } function handlePreviewModeChange(mode: PreviewMode) { if (mode === previewMode) { return; } setPreviewMode(mode); setPreviewFrameReady(false); setQuickPreviewContentHeight(undefined); setPreviewPagination({ current: 0, total: 0 }); previewScrollRef.current?.scrollTo({ top: 0 }); if (mode === "precise" && !cachedPdf) { void generatePdf(false); } } function handlePreviewScroll() { synchronizeScroll("preview"); updatePreviewCurrentPage(); } return (

内网文档工具

Markdown PDF 导出器

{APP_VERSION_LABEL}
源文件
{documentDisplayName} {showUnsavedState ? ( 未保存 ) : null}
{error || status}
{moreMenuOpen ? ( ) : null}