feat: 完善 Mermaid 配置与预览缩放

This commit is contained in:
SkyJourney
2026-07-26 23:10:22 +08:00
parent 15dd2acfc0
commit d93728bb8b
25 changed files with 870 additions and 56 deletions
+37 -2
View File
@@ -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<PreviewMode>("quick");
const [previewZoom, setPreviewZoom] = useState(loadPreviewZoom);
const [pdfCache, setPdfCache] = useState<CachedPdfExport>();
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<PagedPreviewPayload | undefined>(
() =>
@@ -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() {
</label>
</span>
</div>
<div className="preview-toolbar">
<PreviewZoomControl
value={previewZoom}
onChange={setPreviewZoom}
/>
</div>
<div
ref={previewScrollRef}
className="preview-scroll"
@@ -726,6 +759,7 @@ export function App() {
<iframe
ref={previewFrameRef}
className="preview-frame"
style={previewFrameStyle}
title="文档内容预览"
sandbox="allow-same-origin allow-scripts"
src="/preview-frame.html"
@@ -750,6 +784,7 @@ export function App() {
>
<PrecisePdfPreview
blob={cachedPdf.blob}
zoomPercent={previewZoom}
onPageCount={handlePrecisePageCount}
/>
</Suspense>
+172 -1
View File
@@ -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<FooterConfig["format"], string> = {
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 (
<label className="setting-field stacked">
<span></span>
<input
type="text"
list="mermaid-font-family-options"
maxLength={300}
value={draft}
onBlur={commit}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.currentTarget.blur();
}
}}
/>
<datalist id="mermaid-font-family-options">
{mermaidFontFamilies.map((fontFamily) => (
<option key={fontFamily} value={fontFamily} />
))}
</datalist>
</label>
);
}
function updateHeaderSlot(
header: HeaderConfig,
slot: "left" | "center" | "right",
@@ -193,6 +287,16 @@ export function ExportSettingsDrawer({
});
}
function updateMermaid(mermaid: Partial<MermaidExportConfig>) {
onChange({
...config,
mermaid: {
...config.mermaid,
...mermaid
}
});
}
return (
<div className="settings-layer">
<button
@@ -307,6 +411,73 @@ export function ExportSettingsDrawer({
</div>
</section>
<section className="settings-section">
<h3>Mermaid </h3>
<label className="setting-field">
<span></span>
<select
value={config.mermaid.layout}
onChange={(event) =>
updateMermaid({
layout: event.target
.value as MermaidExportConfig["layout"]
})
}
>
{supportedMermaidLayouts.map((layout) => (
<option key={layout} value={layout}>
{mermaidLayoutLabels[layout]}
</option>
))}
</select>
</label>
<label className="setting-field">
<span></span>
<select
value={config.mermaid.theme}
onChange={(event) =>
updateMermaid({
theme: event.target
.value as MermaidExportConfig["theme"]
})
}
>
{supportedMermaidThemes.map((theme) => (
<option key={theme} value={theme}>
{mermaidThemeLabels[theme]}
</option>
))}
</select>
</label>
<label className="setting-field">
<span></span>
<select
value={config.mermaid.look}
onChange={(event) =>
updateMermaid({
look: event.target
.value as MermaidExportConfig["look"]
})
}
>
{supportedMermaidLooks.map((look) => (
<option key={look} value={look}>
{mermaidLookLabels[look]}
</option>
))}
</select>
</label>
<MermaidFontInput
value={config.mermaid.fontFamily}
onChange={(fontFamily) => updateMermaid({ fontFamily })}
/>
<p className="setting-hint">
Mermaid config frontmatter
styleclassDef themeVariables
Mermaid
</p>
</section>
<section className="settings-section">
<div className="setting-section-title">
<h3></h3>
+9 -2
View File
@@ -32,6 +32,7 @@ interface PdfPageDescriptor {
export interface PrecisePdfPreviewProps {
blob: Blob;
zoomPercent: number;
onPageCount?: (pageCount: number) => void;
}
@@ -39,6 +40,7 @@ interface PrecisePdfPageProps {
document: PDFDocumentProxy;
descriptor: PdfPageDescriptor;
renderQueue: PdfPageRenderQueue;
zoomPercent: number;
}
function useNearViewport(elementRef: React.RefObject<HTMLElement | null>) {
@@ -90,7 +92,8 @@ function useNearViewport(elementRef: React.RefObject<HTMLElement | null>) {
function PrecisePdfPage({
document,
descriptor,
renderQueue
renderQueue,
zoomPercent
}: PrecisePdfPageProps) {
const containerRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
@@ -103,10 +106,12 @@ function PrecisePdfPage({
const nearViewport = useNearViewport(containerRef);
const dimensions = getPdfPageCssDimensions(
descriptor.width,
descriptor.height
descriptor.height,
zoomPercent / 100
);
const pageStyle = {
width: `${dimensions.width}px`,
maxWidth: `${zoomPercent}%`,
aspectRatio: `${descriptor.width} / ${descriptor.height}`
} satisfies CSSProperties;
@@ -293,6 +298,7 @@ function PrecisePdfPage({
export function PrecisePdfPreview({
blob,
zoomPercent,
onPageCount
}: PrecisePdfPreviewProps) {
const [document, setDocument] = useState<PDFDocumentProxy>();
@@ -381,6 +387,7 @@ export function PrecisePdfPreview({
document={document}
descriptor={page}
renderQueue={renderQueue}
zoomPercent={zoomPercent}
/>
))}
</div>
+80
View File
@@ -0,0 +1,80 @@
import { useEffect, useState } from "react";
import {
DEFAULT_PREVIEW_ZOOM,
MAXIMUM_PREVIEW_ZOOM,
MINIMUM_PREVIEW_ZOOM,
normalizePreviewZoom,
parsePreviewZoom,
PREVIEW_ZOOM_SLIDER_STEP
} from "./preview-zoom";
interface PreviewZoomControlProps {
value: number;
onChange: (value: number) => void;
}
export function PreviewZoomControl({
value,
onChange
}: PreviewZoomControlProps) {
const [draft, setDraft] = useState(String(value));
useEffect(() => {
setDraft(String(value));
}, [value]);
function commitDraft() {
const normalized = parsePreviewZoom(draft, value);
setDraft(String(normalized));
onChange(normalized);
}
return (
<div
className="preview-zoom-control"
role="group"
aria-label="预览缩放"
>
<span></span>
<input
className="preview-zoom-slider"
type="range"
aria-label="预览缩放滑块"
min={MINIMUM_PREVIEW_ZOOM}
max={MAXIMUM_PREVIEW_ZOOM}
step={PREVIEW_ZOOM_SLIDER_STEP}
value={value}
onChange={(event) =>
onChange(normalizePreviewZoom(Number(event.target.value)))
}
/>
<label className="preview-zoom-number">
<span className="sr-only"></span>
<input
type="number"
aria-label="预览缩放百分比"
min={MINIMUM_PREVIEW_ZOOM}
max={MAXIMUM_PREVIEW_ZOOM}
step="1"
value={draft}
onBlur={commitDraft}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.currentTarget.blur();
}
}}
/>
<span>%</span>
</label>
<button
type="button"
className="preview-zoom-reset"
disabled={value === DEFAULT_PREVIEW_ZOOM}
onClick={() => onChange(DEFAULT_PREVIEW_ZOOM)}
>
100%
</button>
</div>
);
}
+27 -3
View File
@@ -4,7 +4,9 @@ import {
type ExportConfig
} from "@md-to-pdf/core";
export const EXPORT_CONFIG_STORAGE_KEY = "md-to-pdf.export-config.v2";
export const EXPORT_CONFIG_STORAGE_KEY = "md-to-pdf.export-config.v3";
export const LEGACY_EXPORT_CONFIG_STORAGE_KEY =
"md-to-pdf.export-config.v2";
type SettingsStorage = Pick<Storage, "getItem" | "setItem">;
@@ -18,7 +20,19 @@ export function parseStoredExportConfig(rawValue: string | null): ExportConfig {
}
try {
const parsed = exportConfigSchema.safeParse(JSON.parse(rawValue));
const value: unknown = JSON.parse(rawValue);
const migratedValue =
typeof value === "object" &&
value !== null &&
"version" in value &&
value.version === 2
? {
...value,
version: defaultExportConfig.version,
mermaid: structuredClone(defaultExportConfig.mermaid)
}
: value;
const parsed = exportConfigSchema.safeParse(migratedValue);
return parsed.success ? parsed.data : cloneDefaultExportConfig();
} catch {
return cloneDefaultExportConfig();
@@ -28,7 +42,17 @@ export function parseStoredExportConfig(rawValue: string | null): ExportConfig {
export function loadExportConfig(
storage: SettingsStorage = window.localStorage
) {
return parseStoredExportConfig(storage.getItem(EXPORT_CONFIG_STORAGE_KEY));
const currentValue = storage.getItem(EXPORT_CONFIG_STORAGE_KEY);
if (currentValue) {
return parseStoredExportConfig(currentValue);
}
const legacyValue = storage.getItem(LEGACY_EXPORT_CONFIG_STORAGE_KEY);
const config = parseStoredExportConfig(legacyValue);
if (legacyValue) {
storage.setItem(EXPORT_CONFIG_STORAGE_KEY, JSON.stringify(config));
}
return config;
}
export function saveExportConfig(
+8 -4
View File
@@ -1,3 +1,4 @@
import type { MermaidExportConfig } from "@md-to-pdf/core";
import type { MermaidConfig } from "mermaid";
export const MERMAID_SECURE_CONFIG_KEYS = [
@@ -10,13 +11,16 @@ export const MERMAID_SECURE_CONFIG_KEYS = [
"themeCSS"
];
export function createMermaidSiteConfig(): MermaidConfig {
export function createMermaidSiteConfig(
config: MermaidExportConfig
): MermaidConfig {
return {
startOnLoad: false,
securityLevel: "strict",
theme: "default",
look: "classic",
fontFamily: "Segoe UI, Microsoft YaHei, sans-serif",
layout: config.layout,
theme: config.theme,
look: config.look,
fontFamily: config.fontFamily,
flowchart: {
wrappingWidth: 320
},
+9 -3
View File
@@ -131,9 +131,12 @@ export class PagedDocumentRuntime {
private loadMermaid() {
if (!this.mermaidPromise) {
this.mermaidPromise = import("mermaid").then(
({ default: mermaid }) => {
mermaid.initialize(createMermaidSiteConfig());
this.mermaidPromise = Promise.all([
import("mermaid"),
import("@mermaid-js/layout-elk")
]).then(
([{ default: mermaid }, { default: elkLayouts }]) => {
mermaid.registerLayoutLoaders(elkLayouts);
return mermaid;
}
);
@@ -160,6 +163,9 @@ export class PagedDocumentRuntime {
}
const mermaid = await this.loadMermaid();
mermaid.initialize(
createMermaidSiteConfig(payload.exportConfig.mermaid)
);
const outcomes = await renderMermaidDefinitions(
nodes.map((node) => node.textContent ?? ""),
async (definition) => {
+25 -14
View File
@@ -7,6 +7,10 @@ import {
} from "./paged-preview";
import { resolvePagedRenderTarget } from "./paged-render-target";
import { resolveMermaidOutputMode } from "./mermaid-static-image";
import {
beginPreviewFrameRender,
completePreviewFrameRender
} from "./preview-frame-visibility";
function getPreviewRoot() {
const element = document.querySelector<HTMLElement>("#preview-root");
@@ -37,27 +41,34 @@ async function renderPagedPreview(
return;
}
beginPreviewFrameRender(document.documentElement, requestId);
post({
scope: PAGED_PREVIEW_MESSAGE_SCOPE,
type: "rendering",
requestId
});
const result = await runtime.render(payload, {
target: renderTarget,
mermaidOutput,
shouldContinue: () => requestId === latestRequestId
});
if (!result) {
return;
}
try {
const result = await runtime.render(payload, {
target: renderTarget,
mermaidOutput,
shouldContinue: () => requestId === latestRequestId
});
if (!result) {
return;
}
post({
scope: PAGED_PREVIEW_MESSAGE_SCOPE,
type: "rendered",
requestId,
...result
});
post({
scope: PAGED_PREVIEW_MESSAGE_SCOPE,
type: "rendered",
requestId,
...result
});
} finally {
if (requestId === latestRequestId) {
completePreviewFrameRender(document.documentElement, requestId);
}
}
}
window.__mdToPdfRender = async (
+4 -3
View File
@@ -5,11 +5,12 @@ export const PDF_PAGE_CSS_SCALE =
export function getPdfPageCssDimensions(
widthPoints: number,
heightPoints: number
heightPoints: number,
displayScale = 1
) {
return {
width: widthPoints * PDF_PAGE_CSS_SCALE,
height: heightPoints * PDF_PAGE_CSS_SCALE
width: widthPoints * PDF_PAGE_CSS_SCALE * displayScale,
height: heightPoints * PDF_PAGE_CSS_SCALE * displayScale
};
}
+24
View File
@@ -0,0 +1,24 @@
interface PreviewFrameVisibilityTarget {
dataset: DOMStringMap;
}
export function beginPreviewFrameRender(
target: PreviewFrameVisibilityTarget,
requestId: number
) {
target.dataset.rendering = "true";
target.dataset.renderingRequestId = String(requestId);
}
export function completePreviewFrameRender(
target: PreviewFrameVisibilityTarget,
requestId: number
) {
if (target.dataset.renderingRequestId !== String(requestId)) {
return false;
}
delete target.dataset.rendering;
delete target.dataset.renderingRequestId;
return true;
}
+54
View File
@@ -0,0 +1,54 @@
export const PREVIEW_ZOOM_STORAGE_KEY = "md-to-pdf.preview-zoom.v1";
export const DEFAULT_PREVIEW_ZOOM = 100;
export const MINIMUM_PREVIEW_ZOOM = 50;
export const MAXIMUM_PREVIEW_ZOOM = 400;
export const PREVIEW_ZOOM_SLIDER_STEP = 5;
type PreviewZoomStorage = Pick<Storage, "getItem" | "setItem">;
export function normalizePreviewZoom(value: number) {
if (!Number.isFinite(value)) {
return DEFAULT_PREVIEW_ZOOM;
}
return Math.min(
MAXIMUM_PREVIEW_ZOOM,
Math.max(MINIMUM_PREVIEW_ZOOM, Math.round(value))
);
}
export function parsePreviewZoom(
value: string | null,
fallback = DEFAULT_PREVIEW_ZOOM
) {
if (!value?.trim()) {
return normalizePreviewZoom(fallback);
}
const parsed = Number(value);
return Number.isFinite(parsed)
? normalizePreviewZoom(parsed)
: normalizePreviewZoom(fallback);
}
export function loadPreviewZoom(
storage: PreviewZoomStorage = window.localStorage
) {
try {
return parsePreviewZoom(storage.getItem(PREVIEW_ZOOM_STORAGE_KEY));
} catch {
return DEFAULT_PREVIEW_ZOOM;
}
}
export function savePreviewZoom(
value: number,
storage: PreviewZoomStorage = window.localStorage
) {
try {
storage.setItem(
PREVIEW_ZOOM_STORAGE_KEY,
String(normalizePreviewZoom(value))
);
} catch {
// 预览缩放是非关键偏好,浏览器禁用缓存时保持当前会话值即可。
}
}
+87 -2
View File
@@ -176,6 +176,78 @@ h1 {
max-width: 68%;
}
.preview-toolbar {
display: flex;
flex: none;
min-height: 44px;
align-items: center;
justify-content: flex-end;
padding: 6px 20px;
border-bottom: 1px solid #d9dedb;
background: rgb(255 255 255 / 58%);
}
.preview-zoom-control {
display: flex;
align-items: center;
gap: 9px;
color: #5d7168;
font-size: 0.7rem;
font-variant-numeric: tabular-nums;
}
.preview-zoom-slider {
width: 150px;
accent-color: #4f8a6f;
}
.preview-zoom-number {
display: flex;
width: 68px;
height: 30px;
align-items: center;
border: 1px solid #c9d4ce;
border-radius: 7px;
background: #fff;
}
.preview-zoom-number:focus-within {
border-color: #74a88f;
box-shadow: 0 0 0 3px rgb(116 168 143 / 16%);
}
.preview-zoom-number input {
min-width: 0;
width: 100%;
height: 28px;
padding: 0 2px 0 8px;
border: 0;
outline: 0;
background: transparent;
color: #356c55;
font: inherit;
font-weight: 700;
text-align: right;
}
.preview-zoom-number > span:last-child {
padding-right: 7px;
color: #7a8580;
}
.preview-zoom-reset {
min-height: 30px;
padding: 0 8px;
border-color: #c9d4ce;
background: #f8fbf9;
color: #4f695e;
font-size: 0.68rem;
}
.preview-zoom-reset:disabled {
color: #9ba49f;
}
.preview-page-indicator {
flex: none;
color: #5d7168;
@@ -319,7 +391,7 @@ textarea:focus {
display: flex;
min-width: 100%;
flex-direction: column;
align-items: center;
align-items: safe center;
gap: 24px;
padding: 34px 30px;
}
@@ -327,7 +399,6 @@ textarea:focus {
.precise-pdf-page {
position: relative;
flex: none;
max-width: 100%;
overflow: hidden;
background: #fff;
box-shadow: 0 18px 48px rgb(37 49 43 / 16%);
@@ -775,6 +846,20 @@ textarea:focus {
flex-wrap: wrap;
}
.preview-toolbar {
justify-content: center;
padding-inline: 10px;
}
.preview-zoom-control {
width: 100%;
}
.preview-zoom-slider {
flex: 1;
width: auto;
}
.theme-control select {
max-width: 150px;
}