feat: 完善导出设置与打印预览

This commit is contained in:
SkyJourney
2026-07-26 01:36:51 +08:00
parent ec14f7970a
commit 52cf816683
20 changed files with 1552 additions and 146 deletions
+149 -30
View File
@@ -1,12 +1,26 @@
import {
type CSSProperties,
type ChangeEvent,
useEffect,
useMemo,
useRef,
useState
} from "react";
import {
getPaperDimensionsMm,
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";
interface RenderedMarkdown {
articleHtml: string;
@@ -89,12 +103,48 @@ 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;
@@ -103,12 +153,7 @@ let mermaidRenderSequence = 0;
async function loadMermaid() {
if (!mermaidPromise) {
mermaidPromise = import("mermaid").then(({ default: mermaid }) => {
mermaid.initialize({
startOnLoad: false,
securityLevel: "strict",
theme: "neutral",
fontFamily: "Segoe UI, Microsoft YaHei, sans-serif"
});
mermaid.initialize(createMermaidSiteConfig());
return mermaid;
});
}
@@ -132,6 +177,8 @@ function buildPreviewDocument(
result: RenderedMarkdown,
themeCss: string
) {
const previewThemeCss = enablePrintMediaForPreview(themeCss);
return `<!doctype html>
<html lang="${escapeHtml(result.metadata.language || "zh-CN")}">
<head>
@@ -145,7 +192,8 @@ function buildPreviewDocument(
<style>${escapeStyleContent(previewBaseCss)}</style>
<style>${escapeStyleContent(highlightCss)}</style>
<style>${escapeStyleContent(katexCss)}</style>
<style>${escapeStyleContent(themeCss)}</style>
<style>${escapeStyleContent(previewThemeCss)}</style>
<style>${escapeStyleContent(previewGeometryCss)}</style>
</head>
<body>
${result.articleHtml}
@@ -158,8 +206,10 @@ export function App() {
const [fileName, setFileName] = useState("示例文档.md");
const [result, setResult] = useState<RenderedMarkdown | null>(null);
const [themes, setThemes] = useState<ThemeSummary[]>([]);
const [themeId, setThemeId] = useState("typora-like");
const [exportConfig, setExportConfig] =
useState<ExportConfig>(loadExportConfig);
const [themeCss, setThemeCss] = useState("");
const [settingsOpen, setSettingsOpen] = useState(false);
const [status, setStatus] = useState("正在准备预览…");
const [renderError, setRenderError] = useState("");
const [themeError, setThemeError] = useState("");
@@ -167,7 +217,21 @@ export function App() {
const previewFrameRef = useRef<HTMLIFrameElement>(null);
const error = renderError || themeError || mermaidError;
const themeId = exportConfig.themeId;
const selectedTheme = themes.find((theme) => theme.id === themeId);
const paperDimensions = getPaperDimensionsMm(
exportConfig.paper.format,
exportConfig.paper.orientation
);
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
} satisfies CSSProperties;
const previewDocument = useMemo(
() =>
result && themeCss ? buildPreviewDocument(result, themeCss) : undefined,
@@ -188,13 +252,22 @@ export function App() {
})
.then(({ themes: availableThemes }) => {
setThemes(availableThemes);
const preferredTheme =
availableThemes.find((theme) => theme.id === "typora-github") ??
availableThemes.find((theme) => theme.id === "typora-like") ??
availableThemes[0];
if (preferredTheme) {
setThemeId(preferredTheme.id);
}
setExportConfig((currentConfig) => {
if (
availableThemes.some(
(theme) => theme.id === currentConfig.themeId
)
) {
return currentConfig;
}
const preferredTheme =
availableThemes.find((theme) => theme.id === "typora-github") ??
availableThemes.find((theme) => theme.id === "typora-like") ??
availableThemes[0];
return preferredTheme
? { ...currentConfig, themeId: preferredTheme.id }
: currentConfig;
});
})
.catch((reason: unknown) => {
if (!controller.signal.aborted) {
@@ -207,6 +280,14 @@ export function App() {
return () => controller.abort();
}, []);
useEffect(() => {
try {
saveExportConfig(exportConfig);
} catch {
setRenderError("无法保存导出设置,当前设置仅在本次页面中有效");
}
}, [exportConfig]);
useEffect(() => {
const controller = new AbortController();
setThemeCss("");
@@ -306,16 +387,42 @@ export function App() {
if (nodes.length > 0) {
try {
const mermaid = await loadMermaid();
for (const node of nodes) {
const definition = node.textContent ?? "";
mermaidRenderSequence += 1;
const { svg, bindFunctions } = await mermaid.render(
`mermaid-preview-${mermaidRenderSequence}`,
definition
);
node.innerHTML = svg;
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");
bindFunctions?.(node);
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(
@@ -362,6 +469,9 @@ export function App() {
onChange={handleFileChange}
/>
</label>
<button type="button" onClick={() => setSettingsOpen(true)}>
</button>
<button type="button" disabled title="下一阶段实现">
PDF
</button>
@@ -398,7 +508,12 @@ export function App() {
<select
aria-label="预览主题"
value={themeId}
onChange={(event) => setThemeId(event.target.value)}
onChange={(event) =>
setExportConfig((currentConfig) => ({
...currentConfig,
themeId: event.target.value
}))
}
>
{themes.length === 0 ? (
<option value={themeId}></option>
@@ -416,11 +531,7 @@ export function App() {
</label>
</div>
<div className="preview-scroll">
<div
className={`paper${
selectedTheme?.source === "local" ? " is-local-theme" : ""
}`}
>
<div className="paper" style={paperStyle}>
{previewDocument ? (
<iframe
ref={previewFrameRef}
@@ -437,6 +548,14 @@ export function App() {
</div>
</section>
</section>
{settingsOpen ? (
<ExportSettingsDrawer
config={exportConfig}
onChange={setExportConfig}
onClose={() => setSettingsOpen(false)}
onReset={() => setExportConfig(resetExportConfig())}
/>
) : null}
</main>
);
}