816 lines
24 KiB
TypeScript
816 lines
24 KiB
TypeScript
import {
|
||
type CSSProperties,
|
||
type ChangeEvent,
|
||
lazy,
|
||
Suspense,
|
||
useCallback,
|
||
useEffect,
|
||
useMemo,
|
||
useRef,
|
||
useState
|
||
} from "react";
|
||
import {
|
||
getPaperDimensionsMm,
|
||
millimetersToCssPixels,
|
||
type ExportConfig
|
||
} from "@md-to-pdf/core";
|
||
import { ExportSettingsDrawer } from "./ExportSettingsDrawer";
|
||
import { PreviewZoomControl } from "./PreviewZoomControl";
|
||
import {
|
||
loadExportConfig,
|
||
resetExportConfig,
|
||
saveExportConfig
|
||
} from "./export-settings";
|
||
import {
|
||
createPagedPreviewRenderRequest,
|
||
isPagedPreviewFrameMessage,
|
||
type PagedPreviewPayload
|
||
} from "./paged-preview";
|
||
import {
|
||
getNearestPageNumber,
|
||
PREVIEW_SCROLLBAR_WIDTH_PX
|
||
} from "./preview-page";
|
||
import {
|
||
createPdfExportCacheKey,
|
||
downloadPdf,
|
||
getCachedPdfExport,
|
||
getOrRequestPdfExport,
|
||
type CachedPdfExport,
|
||
type PdfExportRequest
|
||
} from "./pdf-export";
|
||
import { getSyncedScrollTop } from "./scroll-sync";
|
||
import {
|
||
loadPreviewZoom,
|
||
savePreviewZoom
|
||
} from "./preview-zoom";
|
||
|
||
const PrecisePdfPreview = lazy(async () => {
|
||
const module = await import("./PrecisePdfPreview");
|
||
return { default: module.PrecisePdfPreview };
|
||
});
|
||
|
||
type PreviewMode = "quick" | "precise";
|
||
|
||
interface RenderedMarkdown {
|
||
articleHtml: string;
|
||
metadata: {
|
||
title: string;
|
||
author: string;
|
||
subject: string;
|
||
keywords: string[];
|
||
language: string;
|
||
};
|
||
features: string[];
|
||
warnings: string[];
|
||
}
|
||
|
||
interface ThemeSummary {
|
||
id: string;
|
||
name: string;
|
||
version: string;
|
||
description: string;
|
||
bundled: boolean;
|
||
source: "bundled" | "local";
|
||
}
|
||
|
||
const sampleMarkdown = `---
|
||
title: Markdown PDF 示例
|
||
author: 内网文档团队
|
||
keywords:
|
||
- Markdown
|
||
- PDF
|
||
---
|
||
|
||
# Markdown PDF 示例
|
||
|
||
这是一份使用 **Typora 风格主题** 渲染的预览文档。
|
||
|
||
## 常用内容
|
||
|
||
- [x] Markdown 实时预览
|
||
- [x] 表格与任务列表
|
||
- [x] 数学公式与 Mermaid
|
||
- [ ] Chromium PDF 导出
|
||
|
||
| 功能 | 当前状态 |
|
||
| --- | --- |
|
||
| HTML 预览 | 已实现 |
|
||
| PDF 下载 | 下一阶段 |
|
||
|
||
行内公式:$E = mc^2$
|
||
|
||
\`\`\`mermaid
|
||
flowchart LR
|
||
A["Markdown"] --> B["统一 HTML"]
|
||
B --> C["主题 CSS"]
|
||
C --> D["网页预览"]
|
||
C --> E["PDF 导出"]
|
||
\`\`\`
|
||
|
||
> 预览和 PDF 将共用同一份 HTML 与 CSS。
|
||
`;
|
||
|
||
export function App() {
|
||
const [markdown, setMarkdown] = useState(sampleMarkdown);
|
||
const [fileName, setFileName] = useState("示例文档.md");
|
||
const [result, setResult] = useState<RenderedMarkdown | null>(null);
|
||
const [themes, setThemes] = useState<ThemeSummary[]>([]);
|
||
const [exportConfig, setExportConfig] =
|
||
useState<ExportConfig>(loadExportConfig);
|
||
const [themeCss, setThemeCss] = useState("");
|
||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||
const [status, setStatus] = useState("正在准备预览…");
|
||
const [renderError, setRenderError] = useState("");
|
||
const [themeError, setThemeError] = useState("");
|
||
const [mermaidError, setMermaidError] = useState("");
|
||
const [paginationError, setPaginationError] = useState("");
|
||
const [pdfError, setPdfError] = useState("");
|
||
const [exportingPdf, setExportingPdf] = useState(false);
|
||
const [previewMode, setPreviewMode] =
|
||
useState<PreviewMode>("quick");
|
||
const [previewZoom, setPreviewZoom] = useState(loadPreviewZoom);
|
||
const [pdfCache, setPdfCache] = useState<CachedPdfExport>();
|
||
const [previewFrameReady, setPreviewFrameReady] = useState(false);
|
||
const [previewPagination, setPreviewPagination] = useState({
|
||
current: 0,
|
||
total: 0
|
||
});
|
||
const editorRef = useRef<HTMLTextAreaElement>(null);
|
||
const previewScrollRef = useRef<HTMLDivElement>(null);
|
||
const previewFrameRef = useRef<HTMLIFrameElement>(null);
|
||
const previewRequestIdRef = useRef(0);
|
||
const pdfRequestKeyRef = useRef("");
|
||
const previewModeRef = useRef<PreviewMode>("quick");
|
||
const scrollSyncFrameRef = useRef<number | undefined>(undefined);
|
||
const scrollSyncOriginRef = useRef<
|
||
"editor" | "preview" | undefined
|
||
>(undefined);
|
||
|
||
const error =
|
||
renderError ||
|
||
themeError ||
|
||
mermaidError ||
|
||
paginationError ||
|
||
pdfError;
|
||
const themeId = exportConfig.themeId;
|
||
const selectedTheme = themes.find((theme) => theme.id === themeId);
|
||
const paperDimensions = getPaperDimensionsMm(
|
||
exportConfig.paper.format,
|
||
exportConfig.paper.orientation
|
||
);
|
||
const previewScale = previewZoom / 100;
|
||
const naturalPaperWidth =
|
||
millimetersToCssPixels(paperDimensions.width) +
|
||
PREVIEW_SCROLLBAR_WIDTH_PX;
|
||
const scaledPaperWidth = naturalPaperWidth * previewScale;
|
||
const paperStyle = {
|
||
width: `${scaledPaperWidth}px`,
|
||
minWidth: `${scaledPaperWidth}px`
|
||
} satisfies CSSProperties;
|
||
const previewFrameStyle = {
|
||
width: `${100 / previewScale}%`,
|
||
height: `${100 / previewScale}%`,
|
||
transform: `scale(${previewScale})`,
|
||
transformOrigin: "top left"
|
||
} satisfies CSSProperties;
|
||
const previewPayload = useMemo<PagedPreviewPayload | undefined>(
|
||
() =>
|
||
result && themeCss
|
||
? {
|
||
articleHtml: result.articleHtml,
|
||
fileName,
|
||
metadata: result.metadata,
|
||
features: result.features,
|
||
themeCss,
|
||
exportConfig
|
||
}
|
||
: undefined,
|
||
[exportConfig, fileName, result, themeCss]
|
||
);
|
||
const pdfRequest = useMemo<PdfExportRequest>(
|
||
() => ({
|
||
markdown,
|
||
fileName,
|
||
language: "zh-CN",
|
||
exportConfig
|
||
}),
|
||
[exportConfig, fileName, markdown]
|
||
);
|
||
const pdfCacheKey = useMemo(
|
||
() => createPdfExportCacheKey(pdfRequest),
|
||
[pdfRequest]
|
||
);
|
||
const cachedPdf = getCachedPdfExport(pdfCache, pdfCacheKey);
|
||
previewModeRef.current = previewMode;
|
||
pdfRequestKeyRef.current = pdfCacheKey;
|
||
|
||
useEffect(() => {
|
||
const controller = new AbortController();
|
||
|
||
void fetch("/api/themes", {
|
||
signal: controller.signal
|
||
})
|
||
.then(async (response) => {
|
||
if (!response.ok) {
|
||
throw new Error("无法加载主题清单");
|
||
}
|
||
return response.json() as Promise<{ themes: ThemeSummary[] }>;
|
||
})
|
||
.then(({ themes: availableThemes }) => {
|
||
setThemes(availableThemes);
|
||
setExportConfig((currentConfig) => {
|
||
if (
|
||
availableThemes.some(
|
||
(theme) => theme.id === currentConfig.themeId
|
||
)
|
||
) {
|
||
return currentConfig;
|
||
}
|
||
const preferredTheme =
|
||
availableThemes.find((theme) => theme.id === "typora-github") ??
|
||
availableThemes.find((theme) => theme.id === "typora-like") ??
|
||
availableThemes[0];
|
||
return preferredTheme
|
||
? { ...currentConfig, themeId: preferredTheme.id }
|
||
: currentConfig;
|
||
});
|
||
})
|
||
.catch((reason: unknown) => {
|
||
if (!controller.signal.aborted) {
|
||
setThemeError(
|
||
reason instanceof Error ? reason.message : "无法加载主题清单"
|
||
);
|
||
}
|
||
});
|
||
|
||
return () => controller.abort();
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
try {
|
||
saveExportConfig(exportConfig);
|
||
} catch {
|
||
setRenderError("无法保存导出设置,当前设置仅在本次页面中有效");
|
||
}
|
||
}, [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(() => {
|
||
const controller = new AbortController();
|
||
setPreviewFrameReady(false);
|
||
setThemeCss("");
|
||
setThemeError("");
|
||
|
||
void fetch(`/api/themes/${encodeURIComponent(themeId)}/css`, {
|
||
signal: controller.signal
|
||
})
|
||
.then(async (response) => {
|
||
if (!response.ok) {
|
||
throw new Error("无法加载主题");
|
||
}
|
||
return response.text();
|
||
})
|
||
.then(setThemeCss)
|
||
.catch((reason: unknown) => {
|
||
if (!controller.signal.aborted) {
|
||
setThemeError(
|
||
reason instanceof Error ? reason.message : "无法加载主题"
|
||
);
|
||
}
|
||
});
|
||
|
||
return () => controller.abort();
|
||
}, [themeId]);
|
||
|
||
useEffect(() => {
|
||
const controller = new AbortController();
|
||
const timer = window.setTimeout(() => {
|
||
setStatus("正在渲染…");
|
||
setRenderError("");
|
||
setMermaidError("");
|
||
|
||
void fetch("/api/render", {
|
||
method: "POST",
|
||
headers: {
|
||
"content-type": "application/json"
|
||
},
|
||
body: JSON.stringify({
|
||
markdown,
|
||
language: "zh-CN"
|
||
}),
|
||
signal: controller.signal
|
||
})
|
||
.then(async (response) => {
|
||
const payload = await response.json();
|
||
if (!response.ok) {
|
||
throw new Error(payload.message ?? "渲染失败");
|
||
}
|
||
return payload as RenderedMarkdown;
|
||
})
|
||
.then((payload) => {
|
||
setResult(payload);
|
||
setStatus("预览已更新");
|
||
})
|
||
.catch((reason: unknown) => {
|
||
if (!controller.signal.aborted) {
|
||
setRenderError(
|
||
reason instanceof Error ? reason.message : "渲染失败"
|
||
);
|
||
setStatus("预览失败");
|
||
}
|
||
});
|
||
}, 250);
|
||
|
||
return () => {
|
||
window.clearTimeout(timer);
|
||
controller.abort();
|
||
};
|
||
}, [markdown]);
|
||
|
||
useEffect(() => {
|
||
function handlePreviewFrameMessage(event: MessageEvent<unknown>) {
|
||
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.requestId !== previewRequestIdRef.current) {
|
||
return;
|
||
}
|
||
|
||
if (event.data.type === "rendering") {
|
||
setPaginationError("");
|
||
setMermaidError("");
|
||
setPreviewPagination({ current: 0, total: 0 });
|
||
setStatus("正在分页…");
|
||
return;
|
||
}
|
||
|
||
if (event.data.type === "error") {
|
||
setPaginationError(event.data.message);
|
||
setStatus("分页失败");
|
||
return;
|
||
}
|
||
|
||
setMermaidError(
|
||
event.data.mermaidErrors.length > 0
|
||
? `Mermaid:${event.data.mermaidErrors.join(";")}`
|
||
: ""
|
||
);
|
||
setStatus(`预览已分页(${event.data.pageCount} 页)`);
|
||
setPreviewPagination({
|
||
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(() => {
|
||
const frameWindow = previewFrameRef.current?.contentWindow;
|
||
if (!frameWindow || !previewFrameReady) {
|
||
return;
|
||
}
|
||
|
||
function handlePreviewScroll() {
|
||
synchronizeScroll("preview");
|
||
updatePreviewCurrentPage();
|
||
}
|
||
|
||
frameWindow.addEventListener("scroll", handlePreviewScroll, {
|
||
passive: true
|
||
});
|
||
return () =>
|
||
frameWindow.removeEventListener("scroll", handlePreviewScroll);
|
||
}, [previewFrameReady]);
|
||
|
||
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
|
||
),
|
||
window.location.origin
|
||
);
|
||
}, [previewFrameReady, previewPayload]);
|
||
|
||
function getPreviewScroller() {
|
||
if (previewModeRef.current === "precise") {
|
||
return previewScrollRef.current;
|
||
}
|
||
return previewFrameRef.current?.contentDocument
|
||
?.scrollingElement as HTMLElement | null;
|
||
}
|
||
|
||
function synchronizeScroll(origin: "editor" | "preview") {
|
||
const source =
|
||
origin === "editor" ? editorRef.current : getPreviewScroller();
|
||
const target =
|
||
origin === "editor" ? getPreviewScroller() : editorRef.current;
|
||
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;
|
||
}
|
||
const frame = previewFrameRef.current;
|
||
const frameDocument = frame?.contentDocument;
|
||
const scrollElement = getPreviewScroller();
|
||
if (!frame || !frameDocument || !scrollElement) {
|
||
return;
|
||
}
|
||
|
||
const viewportCenter =
|
||
scrollElement.scrollTop + frame.clientHeight / 2;
|
||
const pages = Array.from(
|
||
frameDocument.querySelectorAll<HTMLElement>(".pagedjs_page")
|
||
).map((page) => {
|
||
const rect = page.getBoundingClientRect();
|
||
return {
|
||
top: rect.top + scrollElement.scrollTop,
|
||
bottom: rect.bottom + scrollElement.scrollTop
|
||
};
|
||
});
|
||
const current = getNearestPageNumber(viewportCenter, pages);
|
||
|
||
setPreviewPagination((pagination) =>
|
||
pagination.current === current &&
|
||
pagination.total === pages.length
|
||
? pagination
|
||
: {
|
||
current,
|
||
total: pages.length
|
||
}
|
||
);
|
||
}
|
||
|
||
function updatePrecisePreviewCurrentPage() {
|
||
const scrollElement = previewScrollRef.current;
|
||
if (!scrollElement) {
|
||
return;
|
||
}
|
||
const viewportCenter =
|
||
scrollElement.scrollTop + scrollElement.clientHeight / 2;
|
||
const pages = Array.from(
|
||
scrollElement.querySelectorAll<HTMLElement>(
|
||
".precise-pdf-page"
|
||
)
|
||
).map((page) => ({
|
||
top: page.offsetTop,
|
||
bottom: page.offsetTop + page.offsetHeight
|
||
}));
|
||
const current = getNearestPageNumber(viewportCenter, pages);
|
||
|
||
setPreviewPagination((pagination) =>
|
||
pagination.current === current &&
|
||
pagination.total === pages.length
|
||
? pagination
|
||
: {
|
||
current,
|
||
total: pages.length
|
||
}
|
||
);
|
||
}
|
||
|
||
const handlePrecisePageCount = useCallback((pageCount: number) => {
|
||
setPreviewPagination({
|
||
current: pageCount > 0 ? 1 : 0,
|
||
total: pageCount
|
||
});
|
||
window.requestAnimationFrame(updatePrecisePreviewCurrentPage);
|
||
}, []);
|
||
|
||
async function handleFileChange(event: ChangeEvent<HTMLInputElement>) {
|
||
const file = event.target.files?.[0];
|
||
if (!file) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
setMarkdown(await file.text());
|
||
setFileName(file.name);
|
||
} catch {
|
||
setRenderError("无法读取所选 Markdown 文件");
|
||
} finally {
|
||
event.target.value = "";
|
||
}
|
||
}
|
||
|
||
async function generatePdf(download: boolean) {
|
||
if (exportingPdf) {
|
||
return;
|
||
}
|
||
|
||
setExportingPdf(true);
|
||
setPdfError("");
|
||
setStatus(
|
||
download ? "正在生成 PDF…" : "正在生成精确预览…"
|
||
);
|
||
const requestedKey = pdfCacheKey;
|
||
try {
|
||
const resolved = await getOrRequestPdfExport(
|
||
pdfRequest,
|
||
pdfCache
|
||
);
|
||
if (pdfRequestKeyRef.current !== requestedKey) {
|
||
setStatus("文档已更新,已丢弃过期 PDF");
|
||
return;
|
||
}
|
||
setPdfCache(resolved);
|
||
const exported = resolved.result;
|
||
if (download) {
|
||
downloadPdf(exported.blob, exported.fileName);
|
||
}
|
||
const pageDescription =
|
||
exported.pageCount === undefined
|
||
? ""
|
||
: `(${exported.pageCount} 页)`;
|
||
const mermaidDescription =
|
||
exported.mermaidErrorCount > 0
|
||
? `,${exported.mermaidErrorCount} 个 Mermaid 图表渲染失败`
|
||
: "";
|
||
setStatus(
|
||
`${download ? "PDF 已导出" : "精确预览已生成"}${pageDescription}${mermaidDescription}`
|
||
);
|
||
} catch (reason) {
|
||
setPdfError(
|
||
reason instanceof Error ? reason.message : "PDF 导出失败"
|
||
);
|
||
setStatus(download ? "PDF 导出失败" : "精确预览失败");
|
||
} finally {
|
||
setExportingPdf(false);
|
||
}
|
||
}
|
||
|
||
function handlePreviewModeChange(mode: PreviewMode) {
|
||
setPreviewMode(mode);
|
||
setPreviewFrameReady(false);
|
||
setPreviewPagination({ current: 0, total: 0 });
|
||
previewScrollRef.current?.scrollTo({ top: 0 });
|
||
if (mode === "precise" && !cachedPdf) {
|
||
void generatePdf(false);
|
||
}
|
||
}
|
||
|
||
function handlePreviewScroll() {
|
||
if (previewModeRef.current !== "precise") {
|
||
return;
|
||
}
|
||
synchronizeScroll("preview");
|
||
updatePrecisePreviewCurrentPage();
|
||
}
|
||
|
||
return (
|
||
<main className="workspace">
|
||
<header className="topbar">
|
||
<div>
|
||
<p className="eyebrow">内网文档工具</p>
|
||
<h1>Markdown PDF 导出器</h1>
|
||
</div>
|
||
<div className="topbar-actions">
|
||
<label className="file-button">
|
||
选择 Markdown
|
||
<input
|
||
type="file"
|
||
accept=".md,.markdown,text/markdown,text/plain"
|
||
onChange={handleFileChange}
|
||
/>
|
||
</label>
|
||
<button type="button" onClick={() => setSettingsOpen(true)}>
|
||
导出设置
|
||
</button>
|
||
<button
|
||
type="button"
|
||
disabled={exportingPdf || !markdown.trim()}
|
||
onClick={() => void generatePdf(true)}
|
||
>
|
||
{exportingPdf ? "正在导出…" : "导出 PDF"}
|
||
</button>
|
||
</div>
|
||
</header>
|
||
|
||
<section className="editor-layout">
|
||
<aside className="editor-panel" aria-label="Markdown 编辑区">
|
||
<div className="panel-heading">
|
||
<div>
|
||
<span className="panel-kicker">源文件</span>
|
||
<strong>{fileName}</strong>
|
||
</div>
|
||
<span className={`render-status${error ? " is-error" : ""}`}>
|
||
{error || status}
|
||
</span>
|
||
</div>
|
||
<textarea
|
||
ref={editorRef}
|
||
aria-label="Markdown 内容"
|
||
value={markdown}
|
||
spellCheck={false}
|
||
onChange={(event) => setMarkdown(event.target.value)}
|
||
onScroll={() => synchronizeScroll("editor")}
|
||
/>
|
||
</aside>
|
||
|
||
<section className="preview-panel" aria-label="文档预览">
|
||
<div className="panel-heading">
|
||
<div>
|
||
<span className="panel-kicker">实时预览</span>
|
||
<strong>{result?.metadata.title || "未命名文档"}</strong>
|
||
</div>
|
||
<span className="preview-heading-actions">
|
||
{previewPagination.total > 0 ? (
|
||
<span className="preview-page-indicator" aria-live="polite">
|
||
第 {previewPagination.current} / {previewPagination.total} 页
|
||
</span>
|
||
) : null}
|
||
<span
|
||
className="preview-mode-control"
|
||
role="group"
|
||
aria-label="预览模式"
|
||
>
|
||
<button
|
||
type="button"
|
||
className={
|
||
previewMode === "quick" ? "is-active" : ""
|
||
}
|
||
aria-pressed={previewMode === "quick"}
|
||
onClick={() => handlePreviewModeChange("quick")}
|
||
>
|
||
快速
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={
|
||
previewMode === "precise" ? "is-active" : ""
|
||
}
|
||
aria-pressed={previewMode === "precise"}
|
||
disabled={exportingPdf}
|
||
onClick={() => handlePreviewModeChange("precise")}
|
||
>
|
||
精确
|
||
</button>
|
||
</span>
|
||
<label className="theme-control">
|
||
<span className="sr-only">预览主题</span>
|
||
<select
|
||
aria-label="预览主题"
|
||
value={themeId}
|
||
onChange={(event) =>
|
||
setExportConfig((currentConfig) => ({
|
||
...currentConfig,
|
||
themeId: event.target.value
|
||
}))
|
||
}
|
||
>
|
||
{themes.length === 0 ? (
|
||
<option value={themeId}>正在加载主题…</option>
|
||
) : (
|
||
themes.map((theme) => (
|
||
<option key={theme.id} value={theme.id}>
|
||
{theme.name}
|
||
</option>
|
||
))
|
||
)}
|
||
</select>
|
||
{selectedTheme?.source === "local" ? (
|
||
<span className="local-theme-mark">本地</span>
|
||
) : null}
|
||
</label>
|
||
</span>
|
||
</div>
|
||
<div className="preview-toolbar">
|
||
<PreviewZoomControl
|
||
value={previewZoom}
|
||
onChange={setPreviewZoom}
|
||
/>
|
||
</div>
|
||
<div
|
||
ref={previewScrollRef}
|
||
className="preview-scroll"
|
||
onScroll={handlePreviewScroll}
|
||
>
|
||
{previewMode === "quick" ? (
|
||
<div className="paper" style={paperStyle}>
|
||
{previewPayload ? (
|
||
<iframe
|
||
ref={previewFrameRef}
|
||
className="preview-frame"
|
||
style={previewFrameStyle}
|
||
title="文档内容预览"
|
||
sandbox="allow-same-origin allow-scripts"
|
||
src="/preview-frame.html"
|
||
/>
|
||
) : (
|
||
<div className="preview-loading">
|
||
正在生成文档预览…
|
||
</div>
|
||
)}
|
||
</div>
|
||
) : exportingPdf && !cachedPdf ? (
|
||
<div className="precise-pdf-status">
|
||
正在使用固定版本 Chromium 生成精确预览…
|
||
</div>
|
||
) : cachedPdf ? (
|
||
<Suspense
|
||
fallback={
|
||
<div className="precise-pdf-status">
|
||
正在加载 PDF 查看器…
|
||
</div>
|
||
}
|
||
>
|
||
<PrecisePdfPreview
|
||
blob={cachedPdf.blob}
|
||
zoomPercent={previewZoom}
|
||
onPageCount={handlePrecisePageCount}
|
||
/>
|
||
</Suspense>
|
||
) : (
|
||
<div className="precise-pdf-status">
|
||
<span>当前内容尚未生成精确预览</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => void generatePdf(false)}
|
||
>
|
||
生成精确预览
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</section>
|
||
</section>
|
||
{settingsOpen ? (
|
||
<ExportSettingsDrawer
|
||
config={exportConfig}
|
||
onChange={setExportConfig}
|
||
onClose={() => setSettingsOpen(false)}
|
||
onReset={() => setExportConfig(resetExportConfig())}
|
||
/>
|
||
) : null}
|
||
</main>
|
||
);
|
||
}
|