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
+279
View File
@@ -0,0 +1,279 @@
import highlightCss from "highlight.js/styles/github.css?inline";
import katexCss from "katex/dist/katex.min.css?inline";
import { Previewer } from "pagedjs";
import { createMermaidSiteConfig } from "./mermaid-config";
import { fitOversizedMermaidToPage } from "./mermaid-page-fit";
import { renderMermaidDefinitions } from "./mermaid-renderer";
import "./paged-table-handler";
import {
buildPagedMediaCss,
documentBaseCss,
documentGeometryCss,
formatPageNumber,
isPagedPreviewRenderRequest,
PAGED_PREVIEW_MESSAGE_SCOPE,
type PagedPreviewFrameMessage,
type PagedPreviewPayload
} from "./paged-preview";
function getPreviewRoot() {
const element = document.querySelector<HTMLElement>("#preview-root");
if (!element) {
throw new Error("分页预览容器不存在");
}
return element;
}
const previewRoot = getPreviewRoot();
let latestRequestId = 0;
let renderQueue = Promise.resolve();
let activePreviewer: Previewer | undefined;
let mermaidRenderSequence = 0;
let mermaidPromise: Promise<
(typeof import("mermaid"))["default"]
> | undefined;
function post(message: PagedPreviewFrameMessage) {
window.parent.postMessage(message, window.location.origin);
}
function destroyActivePreviewer() {
if (!activePreviewer) {
return;
}
activePreviewer.chunker.destroy();
activePreviewer.polisher.destroy();
activePreviewer = undefined;
}
function loadMermaid() {
if (!mermaidPromise) {
mermaidPromise = import("mermaid").then(({ default: mermaid }) => {
mermaid.initialize(createMermaidSiteConfig());
return mermaid;
});
}
return mermaidPromise;
}
async function renderMermaid(
container: DocumentFragment,
payload: PagedPreviewPayload
) {
if (!payload.features.includes("mermaid")) {
return [];
}
const nodes = Array.from(
container.querySelectorAll<HTMLElement>(
".mermaid[data-mermaid-pending]"
)
);
if (nodes.length === 0) {
return [];
}
const mermaid = await loadMermaid();
const outcomes = await renderMermaidDefinitions(
nodes.map((node) => node.textContent ?? ""),
async (definition) => {
mermaidRenderSequence += 1;
return mermaid.render(
`mermaid-paged-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}`);
}
return errors;
}
async function waitForImages(container: ParentNode) {
const images = Array.from(container.querySelectorAll("img"));
await Promise.all(
images.map(async (image) => {
if (image.complete) {
return;
}
try {
await image.decode();
} catch {
await new Promise<void>((resolve) => {
image.addEventListener("load", () => resolve(), { once: true });
image.addEventListener("error", () => resolve(), { once: true });
});
}
})
);
}
function stylesheet(css: string, name: string) {
return {
[`${window.location.href}#${name}`]: css
};
}
function applyPageNumbers(
container: ParentNode,
payload: PagedPreviewPayload,
totalPages: number
) {
if (!payload.exportConfig.footer.enabled) {
return;
}
const alignment = payload.exportConfig.footer.alignment;
const pages = Array.from(
container.querySelectorAll<HTMLElement>(".pagedjs_page")
);
for (const [pageIndex, page] of pages.entries()) {
const content = page.querySelector<HTMLElement>(
`.pagedjs_margin-bottom-${alignment} .pagedjs_margin-content`
);
if (!content) {
continue;
}
content.textContent = formatPageNumber(
payload.exportConfig.footer,
pageIndex,
totalPages
);
content.setAttribute("data-page-number-rendered", "true");
}
}
async function renderPagedPreview(
requestId: number,
payload: PagedPreviewPayload
) {
if (requestId !== latestRequestId) {
return;
}
post({
scope: PAGED_PREVIEW_MESSAGE_SCOPE,
type: "rendering",
requestId
});
destroyActivePreviewer();
previewRoot.replaceChildren();
document.documentElement.lang = payload.metadata.language || "zh-CN";
document.title = payload.metadata.title || "Markdown 分页预览";
const template = document.createElement("template");
template.innerHTML = payload.articleHtml;
const content = template.content;
const mermaidErrors = await renderMermaid(content, payload);
fitOversizedMermaidToPage(content, payload.exportConfig);
if (requestId !== latestRequestId) {
return;
}
await document.fonts.ready;
await waitForImages(content);
const previewer = new Previewer();
activePreviewer = previewer;
const flow = await previewer.preview(
content,
[
stylesheet(documentBaseCss, "document-base"),
stylesheet(highlightCss, "highlight"),
stylesheet(katexCss, "katex"),
stylesheet(payload.themeCss, "theme"),
stylesheet(documentGeometryCss, "document-geometry"),
stylesheet(
buildPagedMediaCss(payload.exportConfig, payload),
"paged-media"
)
],
previewRoot
);
applyPageNumbers(previewRoot, payload, flow.total);
if (requestId !== latestRequestId) {
return;
}
const contentHeight = Math.ceil(
Math.max(
document.documentElement.scrollHeight,
document.body.scrollHeight,
previewRoot.scrollHeight
)
);
post({
scope: PAGED_PREVIEW_MESSAGE_SCOPE,
type: "rendered",
requestId,
pageCount: flow.total,
contentHeight,
mermaidErrors
});
}
window.addEventListener("message", (event: MessageEvent<unknown>) => {
if (
event.origin !== window.location.origin ||
event.source !== window.parent ||
!isPagedPreviewRenderRequest(event.data)
) {
return;
}
latestRequestId = Math.max(latestRequestId, event.data.requestId);
const { requestId, payload } = event.data;
renderQueue = renderQueue
.catch(() => undefined)
.then(async () => {
try {
await renderPagedPreview(requestId, payload);
} catch (reason: unknown) {
if (requestId !== latestRequestId) {
return;
}
post({
scope: PAGED_PREVIEW_MESSAGE_SCOPE,
type: "error",
requestId,
message:
reason instanceof Error ? reason.message : "分页预览失败"
});
}
});
});
post({
scope: PAGED_PREVIEW_MESSAGE_SCOPE,
type: "ready"
});