feat: 实现 Web 与 Desktop DOCX 导出交互

This commit is contained in:
SkyJourney
2026-07-30 16:05:45 +08:00
parent bf43433d38
commit f2dfc6ef72
28 changed files with 2110 additions and 57 deletions
+196 -19
View File
@@ -14,6 +14,7 @@ import {
createPagedDocumentPayload,
getPaperDimensionsMm,
millimetersToCssPixels,
type DocxCapability,
type ExportConfig
} from "@md-to-pdf/core";
import { ExportSettingsDrawer } from "./ExportSettingsDrawer";
@@ -77,6 +78,21 @@ import {
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");
@@ -141,6 +157,12 @@ export function App() {
const [paginationError, setPaginationError] = useState("");
const [pdfError, setPdfError] = useState("");
const [exportingPdf, setExportingPdf] = useState(false);
const [exportingDocx, setExportingDocx] = useState(false);
const [docxCapability, setDocxCapability] =
useState<DocxCapability>();
const [docxCapabilityError, setDocxCapabilityError] = useState("");
const [exportToast, setExportToast] =
useState<ExportToastState>();
const [previewMode, setPreviewMode] =
useState<PreviewMode>("quick");
const [previewZoom, setPreviewZoom] = useState(loadPreviewZoom);
@@ -168,6 +190,10 @@ export function App() {
const documentDirtyRef = useRef(documentDirty);
const themeRefreshTargetRef = useRef<number | undefined>(undefined);
documentDirtyRef.current = documentDirty;
const dismissExportToast = useCallback(
() => setExportToast(undefined),
[]
);
function replaceMarkdownDocument(content: string) {
setMarkdown(content);
@@ -244,6 +270,31 @@ export function App() {
);
}, [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) {
@@ -1226,9 +1277,15 @@ export function App() {
setExportingPdf(true);
setPdfError("");
setStatus(
download ? "正在生成 PDF…" : "正在生成精确预览…"
);
if (download) {
setExportToast({
kind: "progress",
title: "正在生成 PDF",
message: "正在渲染页面、图表和字体,请稍候"
});
} else {
setStatus("正在生成精确预览…");
}
const requestedKey = pdfCacheKey;
try {
const resolved = await getOrRequestPdfExport(
@@ -1239,7 +1296,15 @@ export function App() {
: undefined
);
if (pdfRequestKeyRef.current !== requestedKey) {
setStatus("文档已更新,已丢弃过期 PDF");
if (download) {
setExportToast({
kind: "error",
title: "PDF 导出已停止",
message: "文档已更新,请重新导出"
});
} else {
setStatus("文档已更新,已丢弃过期 PDF");
}
return;
}
setPdfCache(resolved);
@@ -1247,7 +1312,7 @@ export function App() {
if (download) {
const saved = await downloadPdf(exported.blob, exported.fileName);
if (!saved) {
setStatus("已取消 PDF 保存");
setExportToast(undefined);
return;
}
}
@@ -1263,19 +1328,94 @@ export function App() {
exported.echartsErrorCount > 0
? `${exported.echartsErrorCount} 个 ECharts 图表渲染失败`
: "";
setStatus(
`${download ? "PDF 已导出" : "精确预览已生成"}${pageDescription}${echartsDescription}${mermaidDescription}`
);
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) {
setPdfError(
reason instanceof Error ? reason.message : "PDF 导出失败"
);
setStatus(download ? "PDF 导出失败" : "精确预览失败");
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;
@@ -1299,8 +1439,51 @@ export function App() {
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 (
<main className="workspace">
{exportToast ? (
<ExportToast
toast={exportToast}
onDismiss={dismissExportToast}
/>
) : null}
<header className="topbar">
<div className="topbar-primary">
<div className="topbar-brand">
@@ -1506,13 +1689,7 @@ export function App() {
<button type="button" onClick={() => setSettingsOpen(true)}>
</button>
<button
type="button"
disabled={exportingPdf || !markdown.trim()}
onClick={() => void generatePdf(true)}
>
{exportingPdf ? "正在导出…" : "导出 PDF"}
</button>
<ExportMenu commands={exportCommands} />
</div>
</header>