diff --git a/apps/web/package.json b/apps/web/package.json
index 4278644..2618021 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -6,6 +6,7 @@
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
+ "test": "vitest run",
"typecheck": "tsc -b --pretty false"
},
"dependencies": {
@@ -20,6 +21,7 @@
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
- "vite": "^7.2.4"
+ "vite": "^7.2.4",
+ "vitest": "^4.1.10"
}
}
diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx
index 22a0a4d..61ee793 100644
--- a/apps/web/src/App.tsx
+++ b/apps/web/src/App.tsx
@@ -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 `
@@ -145,7 +192,8 @@ function buildPreviewDocument(
-
+
+
${result.articleHtml}
@@ -158,8 +206,10 @@ export function App() {
const [fileName, setFileName] = useState("示例文档.md");
const [result, setResult] = useState(null);
const [themes, setThemes] = useState([]);
- const [themeId, setThemeId] = useState("typora-like");
+ const [exportConfig, setExportConfig] =
+ useState(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(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}
/>
+
@@ -398,7 +508,12 @@ export function App() {