feat: 实现 Chromium PDF 导出与精确预览

This commit is contained in:
SkyJourney
2026-07-26 13:21:17 +08:00
parent d62552384f
commit d1b5880a5b
26 changed files with 3328 additions and 316 deletions
+237 -15
View File
@@ -1,6 +1,9 @@
import {
type CSSProperties,
type ChangeEvent,
lazy,
Suspense,
useCallback,
useEffect,
useMemo,
useRef,
@@ -25,8 +28,23 @@ 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";
const PrecisePdfPreview = lazy(async () => {
const module = await import("./PrecisePdfPreview");
return { default: module.PrecisePdfPreview };
});
type PreviewMode = "quick" | "precise";
interface RenderedMarkdown {
articleHtml: string;
metadata: {
@@ -100,6 +118,11 @@ export function App() {
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 [pdfCache, setPdfCache] = useState<CachedPdfExport>();
const [previewFrameReady, setPreviewFrameReady] = useState(false);
const [previewPagination, setPreviewPagination] = useState({
current: 0,
@@ -109,13 +132,19 @@ export function App() {
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;
renderError ||
themeError ||
mermaidError ||
paginationError ||
pdfError;
const themeId = exportConfig.themeId;
const selectedTheme = themes.find((theme) => theme.id === themeId);
const paperDimensions = getPaperDimensionsMm(
@@ -140,6 +169,22 @@ export function App() {
: 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();
@@ -191,6 +236,13 @@ export function App() {
}
}, [exportConfig]);
useEffect(() => {
if (pdfCache && pdfCache.key !== pdfCacheKey) {
setPdfCache(undefined);
setPreviewPagination({ current: 0, total: 0 });
}
}, [pdfCache, pdfCacheKey]);
useEffect(() => {
const controller = new AbortController();
setPreviewFrameReady(false);
@@ -361,6 +413,9 @@ export function App() {
}, [previewFrameReady, previewPayload]);
function getPreviewScroller() {
if (previewModeRef.current === "precise") {
return previewScrollRef.current;
}
return previewFrameRef.current?.contentDocument
?.scrollingElement as HTMLElement | null;
}
@@ -392,6 +447,10 @@ export function App() {
}
function updatePreviewCurrentPage() {
if (previewModeRef.current === "precise") {
updatePrecisePreviewCurrentPage();
return;
}
const frame = previewFrameRef.current;
const frameDocument = frame?.contentDocument;
const scrollElement = getPreviewScroller();
@@ -423,6 +482,42 @@ export function App() {
);
}
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) {
@@ -439,6 +534,70 @@ export function App() {
}
}
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">
@@ -458,8 +617,12 @@ export function App() {
<button type="button" onClick={() => setSettingsOpen(true)}>
</button>
<button type="button" disabled title="下一阶段实现">
PDF
<button
type="button"
disabled={exportingPdf || !markdown.trim()}
onClick={() => void generatePdf(true)}
>
{exportingPdf ? "正在导出…" : "导出 PDF"}
</button>
</div>
</header>
@@ -497,6 +660,33 @@ export function App() {
{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
@@ -528,20 +718,52 @@ export function App() {
<div
ref={previewScrollRef}
className="preview-scroll"
onScroll={handlePreviewScroll}
>
<div className="paper" style={paperStyle}>
{previewPayload ? (
<iframe
ref={previewFrameRef}
className="preview-frame"
title="文档内容预览"
sandbox="allow-same-origin allow-scripts"
src="/preview-frame.html"
{previewMode === "quick" ? (
<div className="paper" style={paperStyle}>
{previewPayload ? (
<iframe
ref={previewFrameRef}
className="preview-frame"
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}
onPageCount={handlePrecisePageCount}
/>
) : (
<div className="preview-loading"></div>
)}
</div>
</Suspense>
) : (
<div className="precise-pdf-status">
<span></span>
<button
type="button"
onClick={() => void generatePdf(false)}
>
</button>
</div>
)}
</div>
</section>
</section>