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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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(",")
|
||||
: "纸张、样式和可编辑结构已写入文档";
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Vendored
+24
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user