From 18c68235f45bfbd6f45bed3eab1e5e8fdacca4bc Mon Sep 17 00:00:00 2001 From: tianma-if Date: Thu, 1 Oct 2026 20:42:03 +0800 Subject: [PATCH] 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. --- apps/web/src/components/MermaidCodeBlock.tsx | 34 +++++++++++++++++-- apps/web/src/lib/mermaid-svg.test.mjs | 24 +++++++++++++ apps/web/src/lib/mermaid-svg.ts | 34 +++++++++++++++++++ apps/web/src/lib/mermaid-theme.test.mjs | 1 + apps/web/src/lib/mermaid-theme.ts | 1 + apps/web/src/lib/note-html-export.ts | 5 +-- apps/web/src/lib/phone-preview-mermaid.ts | 5 +-- apps/web/src/lib/wechat-copy.ts | 5 +-- apps/web/src/note-print.ts | 5 +-- apps/web/src/styles/globals.css | 4 +-- apps/web/src/styles/mermaid-layout.test.mjs | 2 +- .../web/src/styles/mobile-markdown-editor.css | 4 +-- 12 files changed, 109 insertions(+), 15 deletions(-) diff --git a/apps/web/src/components/MermaidCodeBlock.tsx b/apps/web/src/components/MermaidCodeBlock.tsx index 2ab04568..9c453cc8 100644 --- a/apps/web/src/components/MermaidCodeBlock.tsx +++ b/apps/web/src/components/MermaidCodeBlock.tsx @@ -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(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 && (
{ + test("scales drawn node text to 12px without changing the viewBox", () => { + const scaled = scaleMermaidSvg( + '节点', + ); + + 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('')).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( '' diff --git a/apps/web/src/lib/mermaid-svg.ts b/apps/web/src/lib/mermaid-svg.ts index d431c041..8be156ef 100644 --- a/apps/web/src/lib/mermaid-svg.ts +++ b/apps/web/src/lib/mermaid-svg.ts @@ -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 = /]*)>/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 ``; + }); +}; + export interface MermaidSvgPresentation { width: number; height: number; diff --git a/apps/web/src/lib/mermaid-theme.test.mjs b/apps/web/src/lib/mermaid-theme.test.mjs index 884c8def..9344e0c2 100644 --- a/apps/web/src/lib/mermaid-theme.test.mjs +++ b/apps/web/src/lib/mermaid-theme.test.mjs @@ -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"); diff --git a/apps/web/src/lib/mermaid-theme.ts b/apps/web/src/lib/mermaid-theme.ts index 2c310e5c..73e557bd 100644 --- a/apps/web/src/lib/mermaid-theme.ts +++ b/apps/web/src/lib/mermaid-theme.ts @@ -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, diff --git a/apps/web/src/lib/note-html-export.ts b/apps/web/src/lib/note-html-export.ts index 4b169be8..3acaa7f7 100644 --- a/apps/web/src/lib/note-html-export.ts +++ b/apps/web/src/lib/note-html-export.ts @@ -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; diff --git a/apps/web/src/lib/phone-preview-mermaid.ts b/apps/web/src/lib/phone-preview-mermaid.ts index 739e9dde..fce25b5c 100644 --- a/apps/web/src/lib/phone-preview-mermaid.ts +++ b/apps/web/src/lib/phone-preview-mermaid.ts @@ -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(); @@ -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 ( diff --git a/apps/web/src/lib/wechat-copy.ts b/apps/web/src/lib/wechat-copy.ts index b713e385..dbb4ce2e 100644 --- a/apps/web/src/lib/wechat-copy.ts +++ b/apps/web/src/lib/wechat-copy.ts @@ -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) => { diff --git a/apps/web/src/note-print.ts b/apps/web/src/note-print.ts index 4fdc3c2e..79f99d84 100644 --- a/apps/web/src/note-print.ts +++ b/apps/web/src/note-print.ts @@ -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; diff --git a/apps/web/src/styles/globals.css b/apps/web/src/styles/globals.css index 8d87a4a1..8366f8fc 100644 --- a/apps/web/src/styles/globals.css +++ b/apps/web/src/styles/globals.css @@ -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; } diff --git a/apps/web/src/styles/mermaid-layout.test.mjs b/apps/web/src/styles/mermaid-layout.test.mjs index d355631a..be7442b8 100644 --- a/apps/web/src/styles/mermaid-layout.test.mjs +++ b/apps/web/src/styles/mermaid-layout.test.mjs @@ -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, diff --git a/apps/web/src/styles/mobile-markdown-editor.css b/apps/web/src/styles/mobile-markdown-editor.css index cd9f21ff..62c7b723 100644 --- a/apps/web/src/styles/mobile-markdown-editor.css +++ b/apps/web/src/styles/mobile-markdown-editor.css @@ -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; }