feat: 完善导出设置与打印预览
This commit is contained in:
+149
-30
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user