fix(web): fit wide Mermaid diagrams into the note column

Node text stays between 8px and 12px, so a large architecture chart no longer fills the page. Print, HTML export, and WeChat copy use the same 12px ceiling.
This commit is contained in:
tianma-if committed 2026-10-01 20:42:03 +08:00
1 parent 65005eedf2
commit 18c68235f4
12 files changed
+109 -15

No files matched your search

+32 -2
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { NodeViewContent, NodeViewWrapper, type NodeViewProps } from "@tiptap/react";
import { useTranslation } from "react-i18next";
import { Check, CircleAlert, Code2, Copy, Maximize2 } from "lucide-react";
@@ -7,6 +7,7 @@ import { MermaidViewer } from "./MermaidViewer";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { copyTextToClipboard } from "@/lib/clipboard";
import { renderMermaidWithFallback } from "@/lib/mermaid-renderer";
import { mermaidDisplayScale, scaleMermaidSvg } from "@/lib/mermaid-svg";
import { getOfficialMermaidThemeVariables } from "@/lib/mermaid-theme";
type MermaidModule = typeof import("mermaid")["default"];
@@ -44,6 +45,34 @@ export const MermaidCodeBlock = ({ editor, node }: NodeViewProps) => {
const [viewerOpen, setViewerOpen] = useState(false);
const [renderState, setRenderState] = useState<"idle" | "loading" | "ready" | "error">("idle");
const [copyState, setCopyState] = useState<"idle" | "copied" | "error">("idle");
const previewRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const preview = previewRef.current;
if (!preview || !svg) return;
const applySize = () => {
const root = preview.querySelector("svg");
const viewBox = root?.viewBox?.baseVal;
if (!viewBox?.width || !viewBox.height) return;
const host = preview.parentElement ?? preview;
const previewStyle = getComputedStyle(preview);
const available = host.clientWidth
- Number.parseFloat(previewStyle.paddingLeft)
- Number.parseFloat(previewStyle.paddingRight)
- Number.parseFloat(previewStyle.borderLeftWidth)
- Number.parseFloat(previewStyle.borderRightWidth);
const scale = mermaidDisplayScale(viewBox.width, available);
preview.style.setProperty("--mermaid-display-width", `${Math.round(viewBox.width * scale)}px`);
preview.style.setProperty("--mermaid-display-height", `${Math.round(viewBox.height * scale)}px`);
};
applySize();
const observer = new ResizeObserver(applySize);
observer.observe(preview.parentElement ?? preview);
return () => observer.disconnect();
}, [svg]);
useEffect(() => {
if (copyState === "idle") return;
@@ -75,7 +104,7 @@ export const MermaidCodeBlock = ({ editor, node }: NodeViewProps) => {
transparent: true,
font: "Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, sans-serif",
padding: 24,
}));
})).then(scaleMermaidSvg);
const renderOfficial = () => loadMermaid().then(async (mermaid) => {
mermaid.initialize({
startOnLoad: false,
@@ -228,6 +257,7 @@ export const MermaidCodeBlock = ({ editor, node }: NodeViewProps) => {
)}
{isMermaid && (
<div
ref={previewRef}
className="edgeever-mermaid-preview"
contentEditable={false}
aria-label={t("editorToolbar.mermaidPreview")}
+24
View File
@@ -2,11 +2,35 @@ import { describe, expect, test } from "bun:test";
import { MERMAID_THEME_PALETTES } from "../components/ThemeProvider";
import {
getMermaidSvgPresentation,
MERMAID_NODE_TEXT_PX,
mermaidDisplayScale,
normalizeMermaidSvgForViewer,
resolveMermaidViewerBackground,
scaleMermaidSvg,
} from "./mermaid-svg";
describe("Mermaid SVG presentation", () => {
test("scales drawn node text to 12px without changing the viewBox", () => {
const scaled = scaleMermaidSvg(
'<svg width="1894.44" height="787.4px" viewBox="0 0 1894.44 787.4" style="--bg:#f8fafb"><text font-size="13">节点</text></svg>',
);
expect(MERMAID_NODE_TEXT_PX).toBe(12);
expect(scaled).toContain('width="1748.71"');
expect(scaled).toContain('height="726.83px"');
expect(scaled).toContain('viewBox="0 0 1894.44 787.4"');
expect(scaled).toContain('font-size="13"');
expect(scaleMermaidSvg('<svg width="100%" height="400" viewBox="0 0 800 400"></svg>')).toContain('width="100%"');
});
test("fits a wide diagram to the note column without going below 8px text", () => {
const drawnWidth = 1894;
expect(mermaidDisplayScale(drawnWidth, 1200)).toBeCloseTo(1200 / drawnWidth, 5);
expect(mermaidDisplayScale(drawnWidth, 400)).toBeCloseTo(8 / 13, 5);
expect(mermaidDisplayScale(480, 2000)).toBeCloseTo(12 / 13, 5);
expect(MERMAID_NODE_TEXT_PX).toBe(12);
});
test("uses the viewBox as the authoritative diagram dimensions", () => {
expect(getMermaidSvgPresentation(
'<svg width="400" height="300" viewBox="0 0 1935.6325833333335 1888.952" style="--bg:#FFFFFF;--fg:#27272A"></svg>'
+34
View File
@@ -1,3 +1,37 @@
/** beautiful-mermaid draws node labels at 13px. Wide diagrams shrink into the note column, but node text stays between these sizes. */
export const MERMAID_DRAWN_NODE_TEXT_PX = 13;
export const MERMAID_NODE_TEXT_MIN_PX = 8;
export const MERMAID_NODE_TEXT_MAX_PX = 12;
export const MERMAID_NODE_TEXT_PX = MERMAID_NODE_TEXT_MAX_PX;
export const MERMAID_NODE_TEXT_SCALE = MERMAID_NODE_TEXT_MAX_PX / MERMAID_DRAWN_NODE_TEXT_PX;
export const mermaidDisplayScale = (viewBoxWidth: number, containerWidth: number) => {
const maxScale = MERMAID_NODE_TEXT_MAX_PX / MERMAID_DRAWN_NODE_TEXT_PX;
const minScale = MERMAID_NODE_TEXT_MIN_PX / MERMAID_DRAWN_NODE_TEXT_PX;
if (!Number.isFinite(viewBoxWidth) || viewBoxWidth <= 0) return maxScale;
if (!Number.isFinite(containerWidth) || containerWidth <= 0) return maxScale;
return Math.min(maxScale, Math.max(minScale, containerWidth / viewBoxWidth));
};
const SVG_ROOT_TAG = /<svg\b([^>]*)>/i;
const NUMERIC_LENGTH_ATTRIBUTE = /\b(width|height)\s*=\s*(["'])(\d+(?:\.\d+)?)(px)?\2/gi;
export const scaleMermaidSvg = (
svg: string,
scale = MERMAID_NODE_TEXT_SCALE,
) => {
if (!Number.isFinite(scale) || scale <= 0 || scale === 1) return svg;
return svg.replace(SVG_ROOT_TAG, (_match, attributes: string) => {
const scaled = attributes.replace(NUMERIC_LENGTH_ATTRIBUTE, (_attribute, name: string, quote: string, value: string, unit = "") => {
const length = Number.parseFloat(value);
const next = Math.round(length * scale * 100) / 100;
return `${name}=${quote}${next}${unit}${quote}`;
});
return `<svg${scaled}>`;
});
};
export interface MermaidSvgPresentation {
width: number;
height: number;
+1
View File
@@ -44,6 +44,7 @@ describe("official Mermaid theme variables", () => {
expect(lightVariables.primaryTextColor).toBe(light.fg);
expect(lightVariables.primaryBorderColor).toBe(light.border);
expect(lightVariables.lineColor).toBe(light.line);
expect(lightVariables.fontSize).toBe("12px");
expect(dark.bg).toBe("#191e1b");
expect(dark.surface).toBe("#242b27");
expect(dark.muted).toBe("#d4d4d8");
+1
View File
@@ -17,6 +17,7 @@ export const getOfficialMermaidThemeVariables = (palette: MermaidThemePalette) =
tertiaryColor: surface,
tertiaryTextColor: palette.fg,
tertiaryBorderColor: border,
fontSize: "12px",
textColor: palette.fg,
lineColor: line,
mainBkg: surface,
+3 -2
View File
@@ -1,6 +1,7 @@
import hljs from "highlight.js/lib/common";
import { renderMermaidSVG, THEMES } from "beautiful-mermaid";
import { MERMAID_THEME_PALETTES } from "@/components/ThemeProvider";
import { scaleMermaidSvg } from "@/lib/mermaid-svg";
const WINDOWS_RESERVED_NAME = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i;
const EXPORT_FONT_FAMILY =
@@ -186,13 +187,13 @@ export const renderMermaidBlocksForHtmlExport = async (root: HTMLElement) => {
}
try {
const svg = renderMermaidSVG(source, {
const svg = scaleMermaidSvg(renderMermaidSVG(source, {
...THEMES["zinc-light"],
...MERMAID_THEME_PALETTES["zinc-light"],
transparent: true,
font: EXPORT_FONT_FAMILY,
padding: 24,
});
}));
const figure = document.createElement("figure");
figure.className = "edgeever-html-mermaid";
figure.innerHTML = svg;
+3 -2
View File
@@ -1,5 +1,6 @@
import type { Editor } from "@tiptap/react";
import { MERMAID_THEME_PALETTES, type MermaidThemeName } from "@/components/ThemeProvider";
import { scaleMermaidSvg } from "@/lib/mermaid-svg";
const remapSvgIds = (svg: SVGElement, prefix: string) => {
const renamed = new Map<string, string>();
@@ -32,13 +33,13 @@ const remapSvgIds = (svg: SVGElement, prefix: string) => {
const renderMermaidSvg = async (source: string, theme: MermaidThemeName) => {
const { renderMermaidSVG, THEMES } = await import("beautiful-mermaid");
const palette = MERMAID_THEME_PALETTES[theme] ?? MERMAID_THEME_PALETTES["zinc-light"];
return renderMermaidSVG(source, {
return scaleMermaidSvg(renderMermaidSVG(source, {
...THEMES[theme] ?? THEMES["zinc-light"],
...palette,
transparent: true,
font: "Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, sans-serif",
padding: 16,
});
}));
};
export const embedMermaidForPreview = async (
+3 -2
View File
@@ -14,6 +14,7 @@ import { common, createLowlight } from "lowlight";
import { toCanvas } from "html-to-image";
import { marked } from "marked";
import { MERMAID_THEME_PALETTES } from "@/components/ThemeProvider";
import { scaleMermaidSvg } from "@/lib/mermaid-svg";
import { copyHtmlToClipboard } from "@/lib/clipboard";
import { parseCustomCssToStyles } from "@/lib/css-sandbox";
@@ -334,13 +335,13 @@ const svgToWeChatImage = async (svg: string) => {
const renderMermaidSvg = async (source: string) => {
const { renderMermaidSVG, THEMES } = await import("beautiful-mermaid");
return renderMermaidSVG(source, {
return scaleMermaidSvg(renderMermaidSVG(source, {
...THEMES["zinc-light"],
...MERMAID_THEME_PALETTES["zinc-light"],
transparent: true,
font: "Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, sans-serif",
padding: 24,
});
}));
};
const embedMermaidForWeChat = async (root: HTMLElement, editor?: Editor) => {
+3 -2
View File
@@ -2,6 +2,7 @@ import { Previewer } from "pagedjs";
import hljs from "highlight.js/lib/common";
import { renderMermaidSVG, THEMES } from "beautiful-mermaid";
import { MERMAID_THEME_PALETTES } from "@/components/ThemeProvider";
import { scaleMermaidSvg } from "@/lib/mermaid-svg";
import {
NOTE_PRINT_MESSAGE,
NOTE_PRINT_READY_MESSAGE,
@@ -39,13 +40,13 @@ const renderMermaidBlocks = async (root: HTMLElement) => {
}
try {
const svg = renderMermaidSVG(source, {
const svg = scaleMermaidSvg(renderMermaidSVG(source, {
...THEMES["zinc-light"],
...MERMAID_THEME_PALETTES["zinc-light"],
transparent: true,
font: PRINT_FONT_FAMILY,
padding: 24,
});
}));
const figure = document.createElement("figure");
figure.className = "edgeever-print-mermaid";
figure.innerHTML = svg;
+2 -2
View File
@@ -1793,9 +1793,9 @@ html[data-edgeever-extension-theme] .edgeever-memo-divider {
.ProseMirror .edgeever-mermaid-svg svg {
display: block;
width: auto;
width: var(--mermaid-display-width, auto);
max-width: none;
height: auto;
height: var(--mermaid-display-height, auto);
max-height: none;
flex: none;
}
+1 -1
View File
@@ -12,7 +12,7 @@ describe("Mermaid editor layout", () => {
/\.ProseMirror \.edgeever-mermaid-preview\s*{[^}]*max-height: min\(70vh, 44rem\);[^}]*overflow: auto;/s,
);
expect(globals).toMatch(
/\.ProseMirror \.edgeever-mermaid-svg svg\s*{[^}]*width: auto;[^}]*max-width: none;[^}]*max-height: none;/s,
/\.ProseMirror \.edgeever-mermaid-svg svg\s*{[^}]*width: var\(--mermaid-display-width, auto\);[^}]*max-width: none;[^}]*max-height: none;/s,
);
expect(globals).toMatch(
/\.ProseMirror \.edgeever-mermaid-svg text\s*{[^}]*font-family: var\(--editor-body-font-family, var\(--edgeever-system-font-family\)\);[^}]*font-synthesis: weight;/s,
@@ -771,9 +771,9 @@ html.dark .edgeever-mobile-tiptap-content a:hover {
.edgeever-mobile-tiptap-content .edgeever-mermaid-svg svg {
display: block;
width: auto;
width: var(--mermaid-display-width, auto);
max-width: none;
height: auto;
height: var(--mermaid-display-height, auto);
max-height: none;
flex: none;
}