feat: 实现 Web 与 Desktop DOCX 导出交互
This commit is contained in:
+196
-19
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user