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:
1 parent
65005eedf2
commit
18c68235f4
12 files changed
+109
-15
No files matched your search
@@ -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")}
|
||||
|
||||
@@ -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>'
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user