import { type CSSProperties, type ChangeEvent, lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { EditorView } from "@codemirror/view"; import { createPagedDocumentPayload, getPaperDimensionsMm, millimetersToCssPixels, type DocxCapability, 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 "@md-to-pdf/preview-engine"; 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 { APP_DISPLAY_NAME, APP_SLOGAN } from "./app-brand"; 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 { resolveWebDocumentLinkAction } from "./document-link"; import { type ThemeSummary, useThemeResources } from "./use-theme-resources"; import { sampleMarkdown } from "./sample-markdown"; import { groupThemesByCategory } from "./theme-groups"; import { getBuiltinThemeSample } from "./builtin-theme-samples"; import { builtinTutorials, type BuiltinTutorial } from "./builtin-tutorials"; import { DocumentActionDialog } from "./DocumentActionDialog"; import { getDocumentShortcut } from "./document-shortcuts"; import { applyThemeDefaults, selectTheme } from "./theme-margins"; import { MarkdownEditor, type MarkdownEditorHandle } from "./MarkdownEditor"; import { MarkdownToolbar } from "./MarkdownToolbar"; import { createDocxDiagnosticsMessage, downloadDocx, getDocxCapability, requestDesktopDocxExport, requestDocxExport } from "./docx-export"; import { ExportMenu, type ExportCommand } from "./ExportMenu"; import { ExportToast, type ExportToastState } from "./ExportToast"; const PrecisePdfPreview = lazy(async () => { const module = await import("./PrecisePdfPreview"); return { default: module.PrecisePdfPreview }; }); type PreviewMode = "quick" | "precise" | "continuous"; type DocumentKind = "sample" | "new" | "file"; type PendingDocumentAction = | { type: "new-current"; } | { type: "close"; systemRequest: boolean; }; type DocumentDialogState = | { type: "new-location"; } | { type: "unsaved"; action: PendingDocumentAction; }; const noImageResources: never[] = []; const startsWithBlankMarkdown = Boolean(window.mdToPdfDesktop) && new URLSearchParams(window.location.search).get("new") === "1"; const initialMarkdown = startsWithBlankMarkdown ? "" : sampleMarkdown; const initialFileName = startsWithBlankMarkdown ? "" : "示例文档.md"; const initialDocumentKind: DocumentKind = startsWithBlankMarkdown ? "new" : "sample"; export function App() { const [markdown, setMarkdown] = useState(initialMarkdown); const [editorDocumentVersion, setEditorDocumentVersion] = useState(0); const [editorView, setEditorView] = useState( null ); const [fileName, setFileName] = useState(initialFileName); const [documentKind, setDocumentKind] = useState(initialDocumentKind); const [savedMarkdown, setSavedMarkdown] = useState(initialMarkdown); const [exportConfig, setExportConfig] = useState(loadExportConfig); const [settingsOpen, setSettingsOpen] = useState(false); const [documentDialog, setDocumentDialog] = useState(); const [documentActionBusy, setDocumentActionBusy] = useState(false); const [moreMenuOpen, setMoreMenuOpen] = useState(false); const [moreSubmenuOpen, setMoreSubmenuOpen] = useState< "tutorials" | "samples" | undefined >(); 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 [exportingDocx, setExportingDocx] = useState(false); const [docxCapability, setDocxCapability] = useState(); const [docxCapabilityError, setDocxCapabilityError] = useState(""); const [exportToast, setExportToast] = useState(); 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; const dismissExportToast = useCallback( () => setExportToast(undefined), [] ); function replaceMarkdownDocument(content: string) { setMarkdown(content); setEditorDocumentVersion((version) => version + 1); } useEffect(() => { const bridge = window.mdToPdfDesktop; if (!bridge) { return; } let active = true; const consumeOpenedMarkdown = async ( reason: "open" | "reload" | "replace" = "open" ) => { if ( reason === "open" && 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(""); replaceMarkdownDocument(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((reason) => { void consumeOpenedMarkdown(reason); }); void consumeOpenedMarkdown(); return () => { active = false; removeListener(); }; }, []); useEffect(() => { const bridge = window.mdToPdfDesktop; if (!bridge) { return; } void bridge.setDocumentDirty(documentDirty).catch( (reason: unknown) => { console.warn("无法同步桌面文档修改状态", reason); } ); }, [documentDirty]); useEffect(() => { let active = true; void getDocxCapability() .then((capability) => { if (!active) { return; } setDocxCapability(capability); setDocxCapabilityError(""); }) .catch((reason: unknown) => { if (!active) { return; } setDocxCapabilityError( reason instanceof Error ? reason.message : "无法检测 DOCX 导出能力" ); }); return () => { active = false; }; }, []); useEffect(() => { const bridge = window.mdToPdfDesktop; if (!bridge) { return; } return bridge.onWindowCloseRequested(() => { requestDocumentClose(true); }); }, [documentDialog, documentDirty]); useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { const shortcut = getDocumentShortcut( event, Boolean(window.mdToPdfDesktop) ); if (!shortcut) { return; } event.preventDefault(); if (documentDialog) { return; } if (shortcut === "save") { void handleSaveMarkdown(); } else if (shortcut === "save-as") { void handleSaveMarkdownAs(); } else if (shortcut === "new") { requestNewMarkdown(); } else { requestDocumentClose(false); } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [documentDialog, documentDirty, fileName, markdown]); useEffect(() => { if (!moreMenuOpen) { return; } const handlePointerDown = (event: PointerEvent) => { if (!(event.target instanceof Node)) { return; } if (!moreMenuRef.current?.contains(event.target)) { setMoreMenuOpen(false); setMoreSubmenuOpen(undefined); } }; const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") { setMoreMenuOpen(false); setMoreSubmenuOpen(undefined); } }; 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) => { const currentTheme = availableThemes.find( (theme) => theme.id === currentConfig.themeId ); if (currentTheme) { return applyThemeDefaults(currentConfig, currentTheme); } const preferredTheme = availableThemes.find((theme) => theme.id === "typora-github") ?? availableThemes.find((theme) => theme.id === "typora-like") ?? availableThemes[0]; return preferredTheme ? selectTheme(currentConfig, preferredTheme) : 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 themeGroups = useMemo( () => groupThemesByCategory(themes), [themes] ); const sampleThemeGroups = useMemo( () => groupThemesByCategory( themes.filter((theme) => getBuiltinThemeSample(theme.id)) ), [themes] ); 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.type === "link") { handlePagedPreviewLink(event.data.href); return; } if (event.data.requestId !== previewRequestIdRef.current) { return; } if (event.data.type === "rendering") { setPaginationError(""); setEChartsError(""); setMermaidError(""); setQuickPreviewContentHeight(undefined); setPreviewPagination({ current: 0, total: 0 }); setStatus( event.data.layout === "continuous" ? "正在更新连续预览…" : "正在分页…" ); 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.layout === "continuous" ? "连续预览已更新" : `预览已分页(${event.data.pageCount} 页)` ); setQuickPreviewContentHeight(event.data.contentHeight); setPreviewPagination( event.data.layout === "continuous" ? { current: 0, total: 0 } : { 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, previewMode === "continuous" ? "continuous" : "paged" ), window.location.origin ); }, [previewFrameReady, previewMode, 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 handlePagedPreviewLink(href: string) { const action = resolveWebDocumentLinkAction(href); if (action.type !== "anchor" && window.mdToPdfDesktop) { void window.mdToPdfDesktop.openDocumentLink(href).catch( (reason: unknown) => { setAppError( reason instanceof Error ? reason.message : "无法打开文档链接" ); } ); return; } if (action.type === "open") { window.open( action.href, "_blank", "noopener,noreferrer" ); return; } if (action.type !== "anchor") { return; } const frame = previewFrameRef.current; const frameDocument = frame?.contentDocument; const scrollElement = getPreviewScroller(); if (!frame || !frameDocument || !scrollElement) { return; } const rawAnchorId = action.href.slice(1); const anchorIds = [rawAnchorId]; try { const decodedAnchorId = decodeURIComponent(rawAnchorId); if (decodedAnchorId !== rawAnchorId) { anchorIds.push(decodedAnchorId); } } catch { // 保留原始片段继续查找,兼容不完整的百分号编码。 } const target = anchorIds .map( (anchorId) => frameDocument.getElementById(anchorId) ?? frameDocument.getElementsByName(anchorId).item(0) ) .find((element) => Boolean(element)); if (!target) { return; } const scrollerViewportTop = scrollElement === document.scrollingElement ? 0 : scrollElement.getBoundingClientRect().top; const frameRect = frame.getBoundingClientRect(); const frameScale = frame.offsetWidth > 0 ? frameRect.width / frame.offsetWidth : 1; const targetTop = getPreviewPageScrollTop( scrollElement.scrollTop, frameRect.top + target.getBoundingClientRect().top * frameScale, scrollerViewportTop ); scrollElement.scrollTo({ top: Math.max(0, targetTop) }); window.requestAnimationFrame(updatePreviewCurrentPage); } function synchronizeScroll(origin: "editor" | "preview") { const source = origin === "editor" ? editorRef.current?.getScrollElement() : getPreviewScroller(); const target = origin === "editor" ? getPreviewScroller() : editorRef.current?.getScrollElement(); 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; } if (previewModeRef.current === "continuous") { 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(); replaceMarkdownDocument(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; } replaceMarkdownDocument(opened.markdown); setFileName(opened.fileName); setDocumentKind("file"); setSavedMarkdown(opened.markdown); setStatus("Markdown 与同目录图片素材已载入"); } catch (reason) { setAppError( reason instanceof Error ? reason.message : "无法打开 Markdown 文件" ); } } function requestNewMarkdown() { setMoreMenuOpen(false); setMoreSubmenuOpen(undefined); if (window.mdToPdfDesktop) { setDocumentDialog({ type: "new-location" }); return; } requestCurrentWindowNew(); } function requestCurrentWindowNew() { if (documentDirty) { setDocumentDialog({ type: "unsaved", action: { type: "new-current" } }); return; } setDocumentDialog(undefined); void createMarkdownInCurrentWindow(); } async function createMarkdownInCurrentWindow() { try { setAppError(""); await window.mdToPdfDesktop?.startNewMarkdown(); replaceMarkdownDocument(""); setFileName(""); setDocumentKind("new"); setSavedMarkdown(""); setStatus("新建文档尚未保存,可直接编辑或导出 PDF"); window.requestAnimationFrame(() => editorRef.current?.focus()); } catch (reason) { setAppError( reason instanceof Error ? reason.message : "无法新建 Markdown" ); } } async function createMarkdownInNewWindow() { setDocumentDialog(undefined); try { setAppError(""); await window.mdToPdfDesktop?.createNewWindow(); setStatus("已在新窗口创建空白 Markdown"); } catch (reason) { setAppError( reason instanceof Error ? reason.message : "无法创建新的 Markdown 窗口" ); } } function requestDocumentClose(systemRequest: boolean) { const bridge = window.mdToPdfDesktop; if (!bridge) { return; } if (documentDialog) { if (systemRequest) { void bridge.resolveWindowClose(false); } return; } if (documentDirty) { setDocumentDialog({ type: "unsaved", action: { type: "close", systemRequest } }); return; } void bridge.resolveWindowClose(true); } async function completePendingDocumentAction( action: PendingDocumentAction ) { setDocumentDialog(undefined); if (action.type === "new-current") { await createMarkdownInCurrentWindow(); return; } await window.mdToPdfDesktop?.resolveWindowClose(true); } function cancelDocumentDialog() { const currentDialog = documentDialog; setDocumentDialog(undefined); if ( currentDialog?.type === "unsaved" && currentDialog.action.type === "close" && currentDialog.action.systemRequest ) { void window.mdToPdfDesktop?.resolveWindowClose(false); } } function discardPendingDocumentChanges() { if (documentDialog?.type !== "unsaved") { return; } void completePendingDocumentAction(documentDialog.action); } async function saveBeforePendingDocumentAction() { if (documentDialog?.type !== "unsaved" || documentActionBusy) { return; } const action = documentDialog.action; setDocumentActionBusy(true); try { if (await saveMarkdown()) { await completePendingDocumentAction(action); } } finally { setDocumentActionBusy(false); } } async function handleOpenThemeSample(theme: ThemeSummary) { const sample = getBuiltinThemeSample(theme.id); if (!sample) { setAppError(`找不到“${theme.name}”的内置示例`); return; } setMoreMenuOpen(false); setMoreSubmenuOpen(undefined); if (!confirmDiscardChanges()) { return; } try { setAppError(""); await window.mdToPdfDesktop?.startNewMarkdown(); replaceMarkdownDocument(sample.markdown); setFileName(sample.fileName); setDocumentKind("sample"); setSavedMarkdown(sample.markdown); setExportConfig((currentConfig) => selectTheme( { ...currentConfig, paper: { ...currentConfig.paper, marginMode: "theme" }, pageDecorationsMode: "theme" }, theme ) ); setStatus( `已打开“${theme.name}”主题示例,可编辑后另存为 Markdown` ); window.requestAnimationFrame(() => editorRef.current?.focus()); } catch (reason) { setAppError( reason instanceof Error ? reason.message : "无法打开主题示例" ); } } async function handleOpenTutorial(tutorial: BuiltinTutorial) { const theme = themes.find( (availableTheme) => availableTheme.id === tutorial.themeId ); if (!theme) { setAppError(`找不到教程需要的主题:${tutorial.themeId}`); return; } setMoreMenuOpen(false); setMoreSubmenuOpen(undefined); if (!confirmDiscardChanges()) { return; } try { setAppError(""); await window.mdToPdfDesktop?.startNewMarkdown(); replaceMarkdownDocument(tutorial.markdown); setFileName(tutorial.fileName); setDocumentKind("sample"); setSavedMarkdown(tutorial.markdown); setExportConfig((currentConfig) => selectTheme( { ...currentConfig, paper: { ...currentConfig.paper, marginMode: "theme" }, pageDecorationsMode: "theme" }, theme ) ); setStatus( `已打开“${tutorial.name}”,可编辑后另存为 Markdown` ); window.requestAnimationFrame(() => editorRef.current?.focus()); } catch (reason) { setAppError( reason instanceof Error ? reason.message : "无法打开内置教程" ); } } async function saveMarkdown(forceSaveAs = false) { const suggestedName = ensureMarkdownFileName(fileName, markdown); try { setAppError(""); const bridge = window.mdToPdfDesktop; let savedFileName = suggestedName; if (bridge) { const saved = forceSaveAs ? await bridge.saveMarkdownAs(suggestedName, markdown) : await bridge.saveMarkdown(suggestedName, markdown); if (!saved) { setStatus( forceSaveAs ? "已取消 Markdown 另存为" : "已取消 Markdown 保存" ); return false; } savedFileName = saved.fileName; } else { downloadMarkdown(markdown, suggestedName); } setFileName(savedFileName); setDocumentKind("file"); setSavedMarkdown(markdown); setStatus( forceSaveAs ? `Markdown 已另存为:${savedFileName}` : `Markdown 已保存:${savedFileName}` ); return true; } catch (reason) { setAppError( reason instanceof Error ? reason.message : forceSaveAs ? "Markdown 另存为失败" : "Markdown 保存失败" ); return false; } } async function handleSaveMarkdown() { setMoreMenuOpen(false); setMoreSubmenuOpen(undefined); return saveMarkdown(); } async function handleSaveMarkdownAs() { setMoreMenuOpen(false); setMoreSubmenuOpen(undefined); return saveMarkdown(true); } async function handleOpenThemeDirectory() { setMoreMenuOpen(false); setMoreSubmenuOpen(undefined); 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); setMoreSubmenuOpen(undefined); 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(""); if (download) { setExportToast({ kind: "progress", title: "正在生成 PDF", message: "正在渲染页面、图表和字体,请稍候" }); } else { setStatus("正在生成精确预览…"); } const requestedKey = pdfCacheKey; try { const resolved = await getOrRequestPdfExport( pdfRequest, pdfCache, previewPayload && window.mdToPdfDesktop ? () => requestDesktopPdfExport(previewPayload) : undefined ); if (pdfRequestKeyRef.current !== requestedKey) { if (download) { setExportToast({ kind: "error", title: "PDF 导出已停止", message: "文档已更新,请重新导出" }); } else { setStatus("文档已更新,已丢弃过期 PDF"); } return; } setPdfCache(resolved); const exported = resolved.result; if (download) { const saved = await downloadPdf(exported.blob, exported.fileName); if (!saved) { setExportToast(undefined); return; } } const pageDescription = exported.pageCount === undefined ? "" : `(${exported.pageCount} 页)`; const mermaidDescription = exported.mermaidErrorCount > 0 ? `,${exported.mermaidErrorCount} 个 Mermaid 图表渲染失败` : ""; const echartsDescription = exported.echartsErrorCount > 0 ? `,${exported.echartsErrorCount} 个 ECharts 图表渲染失败` : ""; const resultMessage = `${download ? "PDF 已导出" : "精确预览已生成"}${pageDescription}${echartsDescription}${mermaidDescription}`; if (download) { setExportToast({ kind: "success", title: `PDF 已导出${pageDescription}`, message: echartsDescription || mermaidDescription ? `${echartsDescription}${mermaidDescription}`.replace( /^,/u, "" ) : "文件已保存" }); } else { setStatus(resultMessage); } } catch (reason) { const message = reason instanceof Error ? reason.message : "PDF 导出失败"; if (download) { setExportToast({ kind: "error", title: "PDF 导出失败", message }); } else { setPdfError(message); setStatus("精确预览失败"); } } finally { setExportingPdf(false); } } async function generateDocx() { if ( exportingDocx || docxCapability?.status !== "available" ) { return; } setExportingDocx(true); setExportToast({ kind: "progress", title: "正在生成 DOCX", message: "正在转换样式并将图片和图表渲染为 PNG" }); try { const request = { markdown, fileName: effectiveFileName, language: "zh-CN", resources: noImageResources, exportConfig }; let diagnostics; if (window.mdToPdfDesktop) { const result = await requestDesktopDocxExport(request); if (!result.saved) { setExportToast(undefined); return; } diagnostics = result; } else { const result = await requestDocxExport(request); downloadDocx(result); diagnostics = result; } setExportToast({ kind: "success", title: "DOCX 已导出", message: createDocxDiagnosticsMessage(diagnostics) }); } catch (reason) { setExportToast({ kind: "error", title: "DOCX 导出失败", message: reason instanceof Error ? reason.message : "DOCX 导出失败" }); } finally { setExportingDocx(false); } } function handlePreviewModeChange(mode: PreviewMode) { if (mode === previewMode) { return; } const keepsFrame = previewMode !== "precise" && mode !== "precise"; setPreviewMode(mode); if (!keepsFrame) { 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(); } const docxDisabledReason = !markdown.trim() ? "文档内容为空" : docxCapabilityError ? docxCapabilityError : !docxCapability ? "正在检测 DOCX 导出能力" : docxCapability.status === "available" ? undefined : docxCapability.message; const exportBusy = exportingPdf || exportingDocx; const exportCommands: ExportCommand[] = [ { id: "pdf", label: "PDF", description: "固定版式,适合打印与归档", disabled: exportBusy || !markdown.trim(), disabledReason: !markdown.trim() ? "文档内容为空" : exportBusy ? "已有导出任务正在进行" : undefined, busy: exportingPdf, onSelect: () => generatePdf(true) }, { id: "docx", label: "DOCX", description: "保留样式,可在 Word 与 WPS 中编辑", disabled: exportBusy || Boolean(docxDisabledReason), disabledReason: exportBusy ? "已有导出任务正在进行" : docxDisabledReason, busy: exportingDocx, onSelect: generateDocx } ]; return (
{exportToast ? ( ) : null}

{APP_SLOGAN}

{APP_DISPLAY_NAME}

{APP_VERSION_LABEL}
源文件
{documentDisplayName} {showUnsavedState ? ( 未保存 ) : null}
{error || status}
{moreMenuOpen ? (
实时预览 {result?.metadata.title || "未命名文档"}
{previewPagination.total > 0 ? ( ) : ( )}
{previewMode !== "precise" ? (
{previewPayload ? (