feat: 实现 Chromium PDF 导出与精确预览
This commit is contained in:
+237
-15
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user