diff --git a/README.md b/README.md
index 5b1dd11..b602136 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,8 @@
- 表格、任务列表、脚注、代码高亮、KaTeX 和 Mermaid;
- iframe 隔离的真实分页预览、双向滚动同步与主题切换;
- Chromium PDF 下载、精确 PDF.js 预览与导出性能观测;
-- 五种纸张、页边距、页眉、页码和浏览器配置缓存;
+- 五种纸张、页边距、页眉、页码、Mermaid 风格和浏览器配置缓存;
+- 快速预览与精确预览共用的 50%~400% 显示缩放;
- 内置 Typora 风格主题,以及仅供本机使用的三套白色 Typora 默认主题导入工具;
- 前端、Fastify、Nginx 和 Playwright Chromium 的 AIO 容器部署。
@@ -39,22 +40,49 @@ npm run dev
编辑区和分页预览使用双向比例滚动同步。在任意一侧滚动时,另一侧会跟随到相同的全文阅读进度,方便定位并修改较长文档。
-## Mermaid 长标签
+预览工具栏支持滑块和手动百分比输入。缩放比例只改变浏览器显示,不改变纸张尺寸、分页结果或最终 PDF,并会单独保存在浏览器中。
-流程图节点标签的默认最大宽度为 320px。单个 Mermaid 区块可以通过 config frontmatter 覆盖:
+## Mermaid 配置与样式
+
+导出设置可以统一控制整篇文档的 Mermaid 布局、主题、外观和字体:
+
+- 布局:Dagre、ELK;
+- 主题:Mermaid 11.16.0 提供的 Default、Base、Dark、Forest、Neutral、Neo 和 Redux 系列;
+- 外观:Classic、Hand-drawn、Neo;
+- 字体:任意本地 CSS 字体族。
+
+未设置时使用 `dagre + default + classic`。单个 Mermaid 区块的 `config` Front Matter 优先于全局导出设置:
````markdown
```mermaid
---
config:
+ layout: elk
+ theme: base
+ look: handDrawn
+ fontFamily: Microsoft YaHei
+ themeVariables:
+ primaryColor: "#dbeafe"
+ primaryBorderColor: "#2563eb"
flowchart:
wrappingWidth: 360
---
flowchart TB
+ classDef important fill:#fee2e2,stroke:#dc2626
A["较长的节点名称
第二行说明"] --> B["下一个节点"]
+ style A fill:#dcfce7,stroke:#16a34a
+ class B important
```
````
+`style`、`classDef`、`linkStyle`、`themeVariables` 和图表专属配置均由 Mermaid 官方渲染器处理。原始 `themeCSS`、`securityLevel` 和资源限制属于安全配置,不能由 Markdown 代码块覆盖。
+
+ELK 使用官方 `@mermaid-js/layout-elk`,只有图表实际请求 ELK 时才加载核心布局代码。
+
+### Mermaid 长标签
+
+流程图节点标签的默认最大宽度为 320px,可以通过上例中的 `flowchart.wrappingWidth` 调整。
+
需要自动换行时可以使用 Mermaid Markdown String:
````markdown
diff --git a/apps/web/package.json b/apps/web/package.json
index ccc580d..a25a9db 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -11,9 +11,10 @@
},
"dependencies": {
"@md-to-pdf/core": "0.1.0",
+ "@mermaid-js/layout-elk": "0.2.2",
"highlight.js": "^11.11.1",
"katex": "^0.16.28",
- "mermaid": "^11.12.3",
+ "mermaid": "11.16.0",
"pagedjs": "^0.4.3",
"pdfjs-dist": "6.1.200",
"react": "^19.2.0",
diff --git a/apps/web/preview-frame.html b/apps/web/preview-frame.html
index 9fb11b7..cf81e5a 100644
--- a/apps/web/preview-frame.html
+++ b/apps/web/preview-frame.html
@@ -7,6 +7,11 @@
content="default-src 'none'; script-src 'self'; style-src 'unsafe-inline'; font-src 'self' data:; img-src 'self' data:; connect-src 'self';"
/>
+
分页预览
diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx
index 4119874..cceccdb 100644
--- a/apps/web/src/App.tsx
+++ b/apps/web/src/App.tsx
@@ -11,9 +11,11 @@ import {
} from "react";
import {
getPaperDimensionsMm,
+ millimetersToCssPixels,
type ExportConfig
} from "@md-to-pdf/core";
import { ExportSettingsDrawer } from "./ExportSettingsDrawer";
+import { PreviewZoomControl } from "./PreviewZoomControl";
import {
loadExportConfig,
resetExportConfig,
@@ -37,6 +39,10 @@ import {
type PdfExportRequest
} from "./pdf-export";
import { getSyncedScrollTop } from "./scroll-sync";
+import {
+ loadPreviewZoom,
+ savePreviewZoom
+} from "./preview-zoom";
const PrecisePdfPreview = lazy(async () => {
const module = await import("./PrecisePdfPreview");
@@ -122,6 +128,7 @@ export function App() {
const [exportingPdf, setExportingPdf] = useState(false);
const [previewMode, setPreviewMode] =
useState("quick");
+ const [previewZoom, setPreviewZoom] = useState(loadPreviewZoom);
const [pdfCache, setPdfCache] = useState();
const [previewFrameReady, setPreviewFrameReady] = useState(false);
const [previewPagination, setPreviewPagination] = useState({
@@ -151,9 +158,20 @@ export function App() {
exportConfig.paper.format,
exportConfig.paper.orientation
);
+ const previewScale = previewZoom / 100;
+ const naturalPaperWidth =
+ millimetersToCssPixels(paperDimensions.width) +
+ PREVIEW_SCROLLBAR_WIDTH_PX;
+ const scaledPaperWidth = naturalPaperWidth * previewScale;
const paperStyle = {
- width: `calc(${paperDimensions.width}mm + ${PREVIEW_SCROLLBAR_WIDTH_PX}px)`,
- minWidth: `calc(${paperDimensions.width}mm + ${PREVIEW_SCROLLBAR_WIDTH_PX}px)`
+ width: `${scaledPaperWidth}px`,
+ minWidth: `${scaledPaperWidth}px`
+ } satisfies CSSProperties;
+ const previewFrameStyle = {
+ width: `${100 / previewScale}%`,
+ height: `${100 / previewScale}%`,
+ transform: `scale(${previewScale})`,
+ transformOrigin: "top left"
} satisfies CSSProperties;
const previewPayload = useMemo(
() =>
@@ -236,6 +254,15 @@ export function App() {
}
}, [exportConfig]);
+ useEffect(() => {
+ savePreviewZoom(previewZoom);
+ const frame = window.requestAnimationFrame(() => {
+ synchronizeScroll("editor");
+ updatePreviewCurrentPage();
+ });
+ return () => window.cancelAnimationFrame(frame);
+ }, [previewZoom]);
+
useEffect(() => {
if (pdfCache && pdfCache.key !== pdfCacheKey) {
setPdfCache(undefined);
@@ -715,6 +742,12 @@ export function App() {
+
diff --git a/apps/web/src/ExportSettingsDrawer.tsx b/apps/web/src/ExportSettingsDrawer.tsx
index b64d82e..cda91da 100644
--- a/apps/web/src/ExportSettingsDrawer.tsx
+++ b/apps/web/src/ExportSettingsDrawer.tsx
@@ -1,10 +1,14 @@
import {
getPaperDimensionsMm,
paperFormatLabels,
+ supportedMermaidLayouts,
+ supportedMermaidLooks,
+ supportedMermaidThemes,
supportedPaperFormats,
type ExportConfig,
type FooterConfig,
- type HeaderConfig
+ type HeaderConfig,
+ type MermaidExportConfig
} from "@md-to-pdf/core";
import { useEffect, useState } from "react";
@@ -30,6 +34,48 @@ const pageNumberFormatLabels: Record
= {
custom: "自定义模板"
};
+const mermaidLayoutLabels: Record<
+ MermaidExportConfig["layout"],
+ string
+> = {
+ dagre: "Dagre",
+ elk: "ELK"
+};
+
+const mermaidThemeLabels: Record<
+ MermaidExportConfig["theme"],
+ string
+> = {
+ default: "Default",
+ base: "Base(可定制基础主题)",
+ dark: "Dark",
+ forest: "Forest",
+ neutral: "Neutral",
+ neo: "Neo",
+ "neo-dark": "Neo Dark",
+ redux: "Redux",
+ "redux-dark": "Redux Dark",
+ "redux-color": "Redux Color",
+ "redux-dark-color": "Redux Dark Color"
+};
+
+const mermaidLookLabels: Record<
+ MermaidExportConfig["look"],
+ string
+> = {
+ classic: "Classic",
+ handDrawn: "Hand-drawn",
+ neo: "Neo"
+};
+
+const mermaidFontFamilies = [
+ "Segoe UI, Microsoft YaHei, sans-serif",
+ "Microsoft YaHei, sans-serif",
+ "Arial, Helvetica, sans-serif",
+ "Georgia, Times New Roman, serif",
+ "Consolas, Microsoft YaHei, monospace"
+] as const;
+
function millimeters(value: string) {
return Number.parseFloat(value.replace(/mm$/i, ""));
}
@@ -99,6 +145,54 @@ function LengthInput({
);
}
+function MermaidFontInput({
+ value,
+ onChange
+}: {
+ value: string;
+ onChange: (value: string) => void;
+}) {
+ const [draft, setDraft] = useState(value);
+
+ useEffect(() => {
+ setDraft(value);
+ }, [value]);
+
+ function commit() {
+ const normalized = draft.trim();
+ if (!normalized) {
+ setDraft(value);
+ return;
+ }
+ setDraft(normalized);
+ onChange(normalized);
+ }
+
+ return (
+
+ );
+}
+
function updateHeaderSlot(
header: HeaderConfig,
slot: "left" | "center" | "right",
@@ -193,6 +287,16 @@ export function ExportSettingsDrawer({
});
}
+ function updateMermaid(mermaid: Partial) {
+ onChange({
+ ...config,
+ mermaid: {
+ ...config.mermaid,
+ ...mermaid
+ }
+ });
+ }
+
return (