diff --git a/apps/web/package.json b/apps/web/package.json index 2618021..451e787 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -14,6 +14,7 @@ "highlight.js": "^11.11.1", "katex": "^0.16.28", "mermaid": "^11.12.3", + "pagedjs": "^0.4.3", "react": "^19.2.0", "react-dom": "^19.2.0" }, diff --git a/apps/web/preview-frame.html b/apps/web/preview-frame.html new file mode 100644 index 0000000..9fb11b7 --- /dev/null +++ b/apps/web/preview-frame.html @@ -0,0 +1,16 @@ + + + + + + + 分页预览 + + +
+ + + diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 61ee793..62c5d4f 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -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("&", "&") - .replaceAll("<", "<") - .replaceAll(">", ">") - .replaceAll('"', """); -} - -function escapeStyleContent(value: string) { - return value.replace(/<\/style/gi, "<\\/style"); -} - -function buildPreviewDocument( - result: RenderedMarkdown, - themeCss: string -) { - const previewThemeCss = enablePrintMediaForPreview(themeCss); - - return ` - - - - - - ${escapeHtml(result.metadata.title || "Markdown 预览")} - - - - - - - - ${result.articleHtml} - -`; -} - 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(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( () => - 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) { + 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( - ".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) { const file = event.target.files?.[0]; @@ -532,14 +421,14 @@ export function App() {
- {previewDocument ? ( + {previewPayload ? (