feat: 实现真实分页预览

This commit is contained in:
SkyJourney
2026-07-26 07:27:13 +08:00
parent 52cf816683
commit 1d93f31f85
15 changed files with 1614 additions and 269 deletions
+101 -212
View File
@@ -8,19 +8,20 @@ import {
} from "react";
import {
getPaperDimensionsMm,
millimetersToCssPixels,
type ExportConfig
} from "@md-to-pdf/core";
import highlightCss from "highlight.js/styles/github.css?inline";
import katexCss from "katex/dist/katex.min.css?inline";
import { ExportSettingsDrawer } from "./ExportSettingsDrawer";
import {
loadExportConfig,
resetExportConfig,
saveExportConfig
} from "./export-settings";
import { createMermaidSiteConfig } from "./mermaid-config";
import { renderMermaidDefinitions } from "./mermaid-renderer";
import { enablePrintMediaForPreview } from "./preview-styles";
import {
createPagedPreviewRenderRequest,
isPagedPreviewFrameMessage,
type PagedPreviewPayload
} from "./paged-preview";
interface RenderedMarkdown {
articleHtml: string;
@@ -81,126 +82,6 @@ flowchart LR
> 预览和 PDF 将共用同一份 HTML 与 CSS。
`;
const previewBaseCss = `
:root {
color-scheme: light;
background: #fff;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
min-width: 0;
background: #fff;
}
img,
svg {
max-width: 100%;
}
#write table {
width: 100%;
table-layout: auto;
}
#write th,
#write td {
overflow-wrap: anywhere;
}
.mermaid {
display: flex;
justify-content: center;
margin: 1.5em 0;
overflow: auto;
}
.mermaid-error {
display: block;
padding: 0.85em 1em;
border: 1px solid #dc2626;
color: #991b1b;
background: #fef2f2;
white-space: pre-wrap;
}
`;
const previewGeometryCss = `
html,
body {
width: 100% !important;
max-width: none !important;
margin: 0 !important;
padding: 0 !important;
}
#write {
width: 100% !important;
max-width: none !important;
margin: 0 !important;
padding: 0 !important;
}
`;
let mermaidPromise: Promise<(typeof import("mermaid"))["default"]> | undefined;
let mermaidRenderSequence = 0;
async function loadMermaid() {
if (!mermaidPromise) {
mermaidPromise = import("mermaid").then(({ default: mermaid }) => {
mermaid.initialize(createMermaidSiteConfig());
return mermaid;
});
}
return mermaidPromise;
}
function escapeHtml(value: string) {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
function escapeStyleContent(value: string) {
return value.replace(/<\/style/gi, "<\\/style");
}
function buildPreviewDocument(
result: RenderedMarkdown,
themeCss: string
) {
const previewThemeCss = enablePrintMediaForPreview(themeCss);
return `<!doctype html>
<html lang="${escapeHtml(result.metadata.language || "zh-CN")}">
<head>
<meta charset="UTF-8" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'none'; style-src 'unsafe-inline'; font-src 'self' data:; img-src 'self' data:;"
/>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>${escapeHtml(result.metadata.title || "Markdown 预览")}</title>
<style>${escapeStyleContent(previewBaseCss)}</style>
<style>${escapeStyleContent(highlightCss)}</style>
<style>${escapeStyleContent(katexCss)}</style>
<style>${escapeStyleContent(previewThemeCss)}</style>
<style>${escapeStyleContent(previewGeometryCss)}</style>
</head>
<body>
${result.articleHtml}
</body>
</html>`;
}
export function App() {
const [markdown, setMarkdown] = useState(sampleMarkdown);
const [fileName, setFileName] = useState("示例文档.md");
@@ -214,9 +95,14 @@ export function App() {
const [renderError, setRenderError] = useState("");
const [themeError, setThemeError] = useState("");
const [mermaidError, setMermaidError] = useState("");
const [paginationError, setPaginationError] = useState("");
const [previewFrameReady, setPreviewFrameReady] = useState(false);
const [previewHeight, setPreviewHeight] = useState(500);
const previewFrameRef = useRef<HTMLIFrameElement>(null);
const previewRequestIdRef = useRef(0);
const error = renderError || themeError || mermaidError;
const error =
renderError || themeError || mermaidError || paginationError;
const themeId = exportConfig.themeId;
const selectedTheme = themes.find((theme) => theme.id === themeId);
const paperDimensions = getPaperDimensionsMm(
@@ -226,16 +112,21 @@ export function App() {
const paperStyle = {
width: `${paperDimensions.width}mm`,
minWidth: `${paperDimensions.width}mm`,
minHeight: `${paperDimensions.height}mm`,
paddingTop: exportConfig.paper.margins.top,
paddingRight: exportConfig.paper.margins.right,
paddingBottom: exportConfig.paper.margins.bottom,
paddingLeft: exportConfig.paper.margins.left
minHeight: `${paperDimensions.height}mm`
} satisfies CSSProperties;
const previewDocument = useMemo(
const previewPayload = useMemo<PagedPreviewPayload | undefined>(
() =>
result && themeCss ? buildPreviewDocument(result, themeCss) : undefined,
[result, themeCss]
result && themeCss
? {
articleHtml: result.articleHtml,
fileName,
metadata: result.metadata,
features: result.features,
themeCss,
exportConfig
}
: undefined,
[exportConfig, fileName, result, themeCss]
);
useEffect(() => {
@@ -290,6 +181,7 @@ export function App() {
useEffect(() => {
const controller = new AbortController();
setPreviewFrameReady(false);
setThemeCss("");
setThemeError("");
@@ -359,83 +251,80 @@ export function App() {
};
}, [markdown]);
async function handlePreviewLoad() {
const frame = previewFrameRef.current;
const document = frame?.contentDocument;
if (!frame || !document || !result) {
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("");
setStatus("正在分页…");
return;
}
if (event.data.type === "error") {
setPaginationError(event.data.message);
setStatus("分页失败");
return;
}
setPreviewHeight(
Math.max(
event.data.contentHeight,
millimetersToCssPixels(paperDimensions.height)
)
);
setMermaidError(
event.data.mermaidErrors.length > 0
? `Mermaid${event.data.mermaidErrors.join("")}`
: ""
);
setStatus(`预览已分页(${event.data.pageCount} 页)`);
}
window.addEventListener("message", handlePreviewFrameMessage);
return () =>
window.removeEventListener("message", handlePreviewFrameMessage);
}, [paperDimensions.height]);
useEffect(() => {
const frameWindow = previewFrameRef.current?.contentWindow;
if (!frameWindow || !previewFrameReady || !previewPayload) {
return;
}
const resizeFrame = () => {
frame.style.height = `${Math.max(
document.documentElement.scrollHeight,
document.body.scrollHeight,
500
)}px`;
};
resizeFrame();
await document.fonts.ready;
if (result.features.includes("mermaid")) {
const nodes = Array.from(
document.querySelectorAll<HTMLElement>(
".mermaid[data-mermaid-pending]"
)
);
if (nodes.length > 0) {
try {
const mermaid = await loadMermaid();
const outcomes = await renderMermaidDefinitions(
nodes.map((node) => node.textContent ?? ""),
async (definition) => {
mermaidRenderSequence += 1;
return mermaid.render(
`mermaid-preview-${mermaidRenderSequence}`,
definition
);
}
);
const errors: string[] = [];
for (const [index, outcome] of outcomes.entries()) {
const node = nodes[index];
if (!node) {
continue;
}
node.removeAttribute("data-mermaid-pending");
if (outcome.success) {
node.innerHTML = outcome.svg;
outcome.bindFunctions?.(node);
continue;
}
const message =
outcome.error instanceof Error
? outcome.error.message
: "未知错误";
node.classList.add("mermaid-error");
node.textContent = `Mermaid 图表 ${index + 1} 渲染失败:${message}`;
errors.push(`图表 ${index + 1}${message}`);
}
if (errors.length > 0) {
setMermaidError(`Mermaid${errors.join("")}`);
}
} catch (reason: unknown) {
setMermaidError(
reason instanceof Error
? `Mermaid${reason.message}`
: "Mermaid 渲染失败"
);
}
}
}
resizeFrame();
}
previewRequestIdRef.current += 1;
setPreviewHeight(
Math.ceil(millimetersToCssPixels(paperDimensions.height))
);
frameWindow.postMessage(
createPagedPreviewRenderRequest(
previewRequestIdRef.current,
previewPayload
),
window.location.origin
);
}, [
paperDimensions.height,
previewFrameReady,
previewPayload
]);
async function handleFileChange(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
@@ -532,14 +421,14 @@ export function App() {
</div>
<div className="preview-scroll">
<div className="paper" style={paperStyle}>
{previewDocument ? (
{previewPayload ? (
<iframe
ref={previewFrameRef}
className="preview-frame"
title="文档内容预览"
sandbox="allow-same-origin"
srcDoc={previewDocument}
onLoad={() => void handlePreviewLoad()}
sandbox="allow-same-origin allow-scripts"
src="/preview-frame.html"
style={{ height: `${previewHeight}px` }}
/>
) : (
<div className="preview-loading"></div>