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>
+93
View File
@@ -0,0 +1,93 @@
import { useEffect, useRef, useState } from "react";
export interface ExportCommand {
id: string;
label: string;
description: string;
disabled?: boolean;
disabledReason?: string | undefined;
busy?: boolean;
onSelect(): void | Promise<void>;
}
export interface ExportMenuProps {
commands: ExportCommand[];
}
export function ExportMenu({ commands }: ExportMenuProps) {
const [open, setOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) {
return;
}
const handlePointerDown = (event: PointerEvent) => {
if (
event.target instanceof Node &&
!menuRef.current?.contains(event.target)
) {
setOpen(false);
}
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setOpen(false);
}
};
window.addEventListener("pointerdown", handlePointerDown);
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("pointerdown", handlePointerDown);
window.removeEventListener("keydown", handleKeyDown);
};
}, [open]);
return (
<div className="export-menu more-menu" ref={menuRef}>
<button
type="button"
aria-controls="export-actions-menu"
aria-expanded={open}
aria-haspopup="menu"
onClick={() => setOpen((current) => !current)}
>
</button>
{open ? (
<div
id="export-actions-menu"
className="export-menu-popover more-menu-popover"
role="menu"
aria-label="导出格式"
>
{commands.map((command) => (
<button
type="button"
className="export-menu-command"
role="menuitem"
key={command.id}
disabled={command.disabled}
title={command.disabledReason}
onClick={() => {
setOpen(false);
void command.onSelect();
}}
>
<span>
<strong>
{command.busy
? `正在生成 ${command.label}`
: `导出 ${command.label}`}
</strong>
<small>
{command.disabledReason ?? command.description}
</small>
</span>
</button>
))}
</div>
) : null}
</div>
);
}
+53
View File
@@ -0,0 +1,53 @@
import { useEffect } from "react";
export interface ExportToastState {
kind: "progress" | "success" | "error";
title: string;
message?: string;
}
export interface ExportToastProps {
toast: ExportToastState;
onDismiss(): void;
}
export function ExportToast({
toast,
onDismiss
}: ExportToastProps) {
useEffect(() => {
if (toast.kind !== "success") {
return;
}
const timeout = window.setTimeout(onDismiss, 3_500);
return () => window.clearTimeout(timeout);
}, [onDismiss, toast]);
return (
<aside
className={`export-toast is-${toast.kind}`}
role={toast.kind === "error" ? "alert" : "status"}
aria-live={toast.kind === "error" ? "assertive" : "polite"}
>
<div className="export-toast-content">
<strong>{toast.title}</strong>
{toast.message ? <span>{toast.message}</span> : null}
</div>
{toast.kind !== "progress" ? (
<button
type="button"
className="export-toast-close"
aria-label="关闭导出提示"
onClick={onDismiss}
>
×
</button>
) : null}
{toast.kind === "progress" ? (
<div className="export-toast-progress" aria-hidden="true">
<span />
</div>
) : null}
</aside>
);
}
+144
View File
@@ -0,0 +1,144 @@
import {
DOCX_MIME_TYPE,
type DocxCapability,
type DocxExportRequestInput
} from "@md-to-pdf/core";
import { parseContentDispositionFileName } from "./pdf-export";
export interface DocxExportResult {
blob: Blob;
fileName: string;
warningCount: number;
echartsErrorCount: number;
mermaidErrorCount: number;
}
export interface DesktopDocxExportResult {
saved: boolean;
fileName: string;
warningCount: number;
echartsErrorCount: number;
mermaidErrorCount: number;
}
function parseNumericHeader(value: string | null) {
if (value === null) {
return 0;
}
const parsed = Number.parseInt(value, 10);
return Number.isInteger(parsed) && parsed >= 0 ? parsed : 0;
}
async function readErrorMessage(response: Response) {
try {
const payload = (await response.json()) as {
message?: unknown;
};
if (typeof payload.message === "string") {
return payload.message;
}
} catch {
// 非 JSON 错误响应使用通用提示。
}
return "DOCX 导出失败";
}
export async function getDocxCapability(
fetcher: typeof fetch = fetch
): Promise<DocxCapability> {
const bridge = window.mdToPdfDesktop;
if (bridge) {
return bridge.getDocxCapability();
}
const response = await fetcher("/api/docx/capability");
if (!response.ok) {
throw new Error("无法检测 DOCX 导出能力");
}
return (await response.json()) as DocxCapability;
}
export async function requestDocxExport(
request: DocxExportRequestInput,
fetcher: typeof fetch = fetch
): Promise<DocxExportResult> {
const response = await fetcher("/api/docx", {
method: "POST",
headers: {
"content-type": "application/json"
},
body: JSON.stringify(request)
});
if (!response.ok) {
throw new Error(await readErrorMessage(response));
}
const contentType =
response.headers.get("content-type")?.split(";")[0]?.trim();
if (contentType !== DOCX_MIME_TYPE) {
throw new Error("DOCX 服务返回了无效文件类型");
}
return {
blob: await response.blob(),
fileName: parseContentDispositionFileName(
response.headers.get("content-disposition"),
"document.docx"
),
warningCount: parseNumericHeader(
response.headers.get("x-docx-warning-count")
),
echartsErrorCount: parseNumericHeader(
response.headers.get("x-echarts-error-count")
),
mermaidErrorCount: parseNumericHeader(
response.headers.get("x-mermaid-error-count")
)
};
}
export async function requestDesktopDocxExport(
request: DocxExportRequestInput
): Promise<DesktopDocxExportResult> {
const bridge = window.mdToPdfDesktop;
if (!bridge) {
throw new Error("桌面 DOCX 引擎不可用");
}
const outcome = await bridge.exportDocx(request);
if (!outcome.ok) {
throw new Error(outcome.message);
}
return {
saved: outcome.saved,
fileName: outcome.fileName,
warningCount: outcome.diagnostics.warnings.length,
echartsErrorCount: outcome.diagnostics.echartsErrors.length,
mermaidErrorCount: outcome.diagnostics.mermaidErrors.length
};
}
export function downloadDocx(result: DocxExportResult) {
const url = URL.createObjectURL(result.blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = result.fileName;
anchor.click();
window.setTimeout(() => URL.revokeObjectURL(url), 0);
}
export function createDocxDiagnosticsMessage(result: {
warningCount: number;
echartsErrorCount: number;
mermaidErrorCount: number;
}) {
const messages = [];
if (result.warningCount > 0) {
messages.push(`${result.warningCount} 条提示`);
}
if (result.echartsErrorCount > 0) {
messages.push(`${result.echartsErrorCount} 个 ECharts 图表失败`);
}
if (result.mermaidErrorCount > 0) {
messages.push(`${result.mermaidErrorCount} 个 Mermaid 图表失败`);
}
return messages.length > 0
? messages.join("")
: "纸张、样式和可编辑结构已写入文档";
}
+3 -2
View File
@@ -66,7 +66,8 @@ function parseNumericHeader(value: string | null) {
}
export function parseContentDispositionFileName(
contentDisposition: string | null
contentDisposition: string | null,
fallbackFileName = "document.pdf"
) {
const encoded = contentDisposition?.match(
/filename\*=UTF-8''([^;]+)/i
@@ -82,7 +83,7 @@ export function parseContentDispositionFileName(
return (
contentDisposition
?.match(/filename="([^"]+)"/i)?.[1]
?.trim() || "document.pdf"
?.trim() || fallbackFileName
);
}
+143
View File
@@ -188,6 +188,45 @@ h1 {
background: #edf3f0;
}
.more-menu-popover button:disabled:hover {
background: transparent;
}
.export-menu-popover {
width: 286px;
}
.more-menu-popover .export-menu-command {
min-height: 58px;
padding: 9px 11px;
}
.export-menu-command > span {
display: flex;
min-width: 0;
flex-direction: column;
gap: 3px;
}
.export-menu-command strong {
color: inherit;
font-size: 0.82rem;
}
.export-menu-command small {
overflow: hidden;
color: #74827b;
font-size: 0.68rem;
font-weight: 500;
line-height: 1.35;
text-overflow: ellipsis;
white-space: nowrap;
}
.export-menu-command:disabled small {
color: #929b97;
}
.more-menu-popover .menu-command {
display: flex;
align-items: center;
@@ -254,6 +293,100 @@ h1 {
letter-spacing: 0.06em;
}
.export-toast {
position: fixed;
z-index: 70;
top: 16px;
left: 50%;
display: flex;
width: min(440px, calc(100vw - 32px));
min-height: 62px;
padding: 13px 46px 13px 16px;
overflow: hidden;
border: 1px solid #bfd0c7;
border-radius: 12px;
background: rgb(255 255 255 / 97%);
box-shadow: 0 14px 38px rgb(38 58 50 / 24%);
transform: translateX(-50%);
}
.export-toast.is-success {
border-color: #9dc5b0;
}
.export-toast.is-error {
border-color: #dfaaa7;
background: rgb(255 250 249 / 98%);
}
.export-toast-content {
display: flex;
min-width: 0;
flex-direction: column;
gap: 3px;
}
.export-toast-content strong {
color: #2d493c;
font-size: 0.84rem;
}
.export-toast.is-error .export-toast-content strong {
color: #943d39;
}
.export-toast-content span {
overflow: hidden;
color: #64736b;
font-size: 0.72rem;
line-height: 1.4;
text-overflow: ellipsis;
white-space: nowrap;
}
.export-toast-close {
position: absolute;
top: 9px;
right: 9px;
width: 32px;
min-height: 32px;
padding: 0;
border: 0;
background: transparent;
color: #718078;
font-size: 1.25rem;
line-height: 1;
}
.export-toast-progress {
position: absolute;
right: 0;
bottom: 0;
left: 0;
height: 3px;
overflow: hidden;
background: #e2eae6;
}
.export-toast-progress span {
display: block;
width: 42%;
height: 100%;
border-radius: 999px;
background: #4d8b6e;
animation: export-progress 1.25s ease-in-out infinite;
}
@keyframes export-progress {
from {
transform: translateX(-110%);
}
to {
transform: translateX(340%);
}
}
.editor-layout {
display: grid;
grid-template-columns: minmax(360px, 0.8fr) minmax(520px, 1.2fr);
@@ -1363,6 +1496,11 @@ h1 {
.editor-layout {
transition: none;
}
.export-toast-progress span {
width: 100%;
animation: none;
}
}
@media (max-width: 520px) {
@@ -1381,6 +1519,11 @@ h1 {
overflow-y: auto;
}
.export-menu-popover {
right: 0;
width: min(286px, calc(100vw - 24px));
}
.more-menu-submenu {
position: static;
width: auto;
+24
View File
@@ -1,6 +1,11 @@
/// <reference types="vite/client" />
import type {
DocxCapability,
DocxExportDiagnostics,
DocxExportErrorCode,
DocxExportRequestInput,
DocxGenerationTimings,
DocxMediaCapturePlan,
PagedDocumentPayload,
PagedDocumentRenderResult,
@@ -19,6 +24,21 @@ declare global {
mermaidErrors: string[];
}
type DesktopDocxExportOutcome =
| {
ok: true;
saved: boolean;
fileName: string;
diagnostics: DocxExportDiagnostics;
timings: DocxGenerationTimings;
}
| {
ok: false;
error: DocxExportErrorCode;
message: string;
retryable: boolean;
};
interface DesktopApplicationBridge {
openMarkdown(): Promise<
| {
@@ -77,6 +97,10 @@ declare global {
getThemeCss(themeId: string): Promise<string>;
generatePdf(payload: PagedDocumentPayload): Promise<DesktopPdfResult>;
savePdf(fileName: string, pdf: Uint8Array): Promise<boolean>;
getDocxCapability(): Promise<DocxCapability>;
exportDocx(
request: DocxExportRequestInput
): Promise<DesktopDocxExportOutcome>;
}
interface DesktopPdfRuntimeBridge {