fix(web): simplify Mermaid presentation

This commit is contained in:
tianma-if committed 2026-08-21 10:13:34 +08:00
1 parent 3743cf4adb
commit 6b4dc287df
8 files changed
+154 -186

No files matched your search

+103 -96
View File
@@ -1,9 +1,10 @@
import { useEffect, useState } from "react";
import { NodeViewContent, NodeViewWrapper, type NodeViewProps } from "@tiptap/react";
import { useTranslation } from "react-i18next";
import { Check, CircleAlert, Code2, Copy, Maximize2, Workflow } from "lucide-react";
import { Check, CircleAlert, Code2, Copy, Maximize2 } from "lucide-react";
import { MERMAID_THEME_PALETTES, useMermaidTheme } from "./ThemeProvider";
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 { getOfficialMermaidThemeVariables } from "@/lib/mermaid-theme";
@@ -34,7 +35,7 @@ const loadBeautifulMermaid = () => {
export const MermaidCodeBlock = ({ editor, node }: NodeViewProps) => {
const { t } = useTranslation();
const { mermaidRenderer, mermaidTheme, setMermaidRenderer } = useMermaidTheme();
const { mermaidTheme } = useMermaidTheme();
const language = typeof node.attrs.language === "string" ? node.attrs.language.toLowerCase() : "plaintext";
const source = node.textContent.trim();
const isMermaid = language === "mermaid";
@@ -90,7 +91,6 @@ export const MermaidCodeBlock = ({ editor, node }: NodeViewProps) => {
return renderedSvg;
});
const renderPromise = renderMermaidWithFallback({
renderer: mermaidRenderer,
renderBeautiful,
renderOfficial,
});
@@ -114,7 +114,7 @@ export const MermaidCodeBlock = ({ editor, node }: NodeViewProps) => {
cancelled = true;
window.clearTimeout(timer);
};
}, [isMermaid, mermaidRenderer, mermaidTheme, source]);
}, [isMermaid, mermaidTheme, source]);
return (
<NodeViewWrapper
@@ -124,99 +124,106 @@ export const MermaidCodeBlock = ({ editor, node }: NodeViewProps) => {
data-language={language}
>
{isMermaid ? (
<div className="edgeever-mermaid-toolbar" contentEditable={false}>
<button
type="button"
className="edgeever-mermaid-tool-button"
aria-label={t(mermaidRenderer === "mermaid"
? "editorToolbar.mermaidUseOrthogonalLayout"
: "editorToolbar.mermaidUseStandardLayout")}
title={t(mermaidRenderer === "mermaid"
? "editorToolbar.mermaidUseOrthogonalLayout"
: "editorToolbar.mermaidUseStandardLayout")}
aria-pressed={mermaidRenderer === "beautiful"}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setMermaidRenderer(mermaidRenderer === "mermaid" ? "beautiful" : "mermaid");
}}
onMouseDown={(event) => event.preventDefault()}
>
<Workflow aria-hidden="true" />
</button>
<button
type="button"
className="edgeever-mermaid-tool-button"
data-state={copyState}
aria-label={t(copyState === "copied" ? "editorToolbar.codeCopied" : copyState === "error" ? "editorToolbar.codeCopyFailed" : "editorToolbar.copyCode")}
title={t(copyState === "copied" ? "editorToolbar.codeCopied" : copyState === "error" ? "editorToolbar.codeCopyFailed" : "editorToolbar.copyCode")}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
void handleCopy();
}}
onMouseDown={(event) => event.preventDefault()}
>
{copyState === "copied"
? <Check aria-hidden="true" />
: copyState === "error"
? <CircleAlert aria-hidden="true" />
: <Copy aria-hidden="true" />}
</button>
{editor.isEditable && (
<button
type="button"
className="edgeever-mermaid-tool-button"
aria-label={t(sourceVisible ? "editorToolbar.mermaidHideSource" : "editorToolbar.mermaidShowSource")}
title={t(sourceVisible ? "editorToolbar.mermaidHideSource" : "editorToolbar.mermaidShowSource")}
aria-pressed={sourceVisible}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setSourceVisible((visible) => !visible);
}}
onMouseDown={(event) => event.preventDefault()}
>
<Code2 aria-hidden="true" />
</button>
)}
{svg && (
<button
type="button"
className="edgeever-mermaid-tool-button"
aria-label={t("editorToolbar.mermaidOpenViewer")}
title={t("editorToolbar.mermaidOpenViewer")}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setViewerOpen(true);
}}
onMouseDown={(event) => event.preventDefault()}
>
<Maximize2 aria-hidden="true" />
</button>
)}
</div>
<TooltipProvider delayDuration={0} skipDelayDuration={0}>
<div className="edgeever-mermaid-toolbar" contentEditable={false}>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="edgeever-mermaid-tool-button"
data-state={copyState}
aria-label={t(copyState === "copied" ? "editorToolbar.codeCopied" : copyState === "error" ? "editorToolbar.codeCopyFailed" : "editorToolbar.copyCode")}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
void handleCopy();
}}
onMouseDown={(event) => event.preventDefault()}
>
{copyState === "copied"
? <Check aria-hidden="true" />
: copyState === "error"
? <CircleAlert aria-hidden="true" />
: <Copy aria-hidden="true" />}
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
{t(copyState === "copied" ? "editorToolbar.codeCopied" : copyState === "error" ? "editorToolbar.codeCopyFailed" : "editorToolbar.copyCode")}
</TooltipContent>
</Tooltip>
{editor.isEditable && (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="edgeever-mermaid-tool-button"
aria-label={t(sourceVisible ? "editorToolbar.mermaidHideSource" : "editorToolbar.mermaidShowSource")}
aria-pressed={sourceVisible}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setSourceVisible((visible) => !visible);
}}
onMouseDown={(event) => event.preventDefault()}
>
<Code2 aria-hidden="true" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
{t(sourceVisible ? "editorToolbar.mermaidHideSource" : "editorToolbar.mermaidShowSource")}
</TooltipContent>
</Tooltip>
)}
{svg && (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="edgeever-mermaid-tool-button"
aria-label={t("editorToolbar.mermaidOpenViewer")}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setViewerOpen(true);
}}
onMouseDown={(event) => event.preventDefault()}
>
<Maximize2 aria-hidden="true" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">{t("editorToolbar.mermaidOpenViewer")}</TooltipContent>
</Tooltip>
)}
</div>
</TooltipProvider>
) : (
<button
type="button"
className="edgeever-code-copy-button"
contentEditable={false}
aria-label={t(copyState === "copied" ? "editorToolbar.codeCopied" : copyState === "error" ? "editorToolbar.codeCopyFailed" : "editorToolbar.copyCode")}
title={t(copyState === "copied" ? "editorToolbar.codeCopied" : copyState === "error" ? "editorToolbar.codeCopyFailed" : "editorToolbar.copyCode")}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
void handleCopy();
}}
onMouseDown={(event) => event.preventDefault()}
>
{copyState === "copied"
? t("editorToolbar.codeCopied")
: copyState === "error"
? t("editorToolbar.codeCopyFailed")
: t("editorToolbar.copyCode")}
</button>
<TooltipProvider delayDuration={0} skipDelayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="edgeever-code-copy-button"
contentEditable={false}
aria-label={t(copyState === "copied" ? "editorToolbar.codeCopied" : copyState === "error" ? "editorToolbar.codeCopyFailed" : "editorToolbar.copyCode")}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
void handleCopy();
}}
onMouseDown={(event) => event.preventDefault()}
>
{copyState === "copied"
? t("editorToolbar.codeCopied")
: copyState === "error"
? t("editorToolbar.codeCopyFailed")
: t("editorToolbar.copyCode")}
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
{t(copyState === "copied" ? "editorToolbar.codeCopied" : copyState === "error" ? "editorToolbar.codeCopyFailed" : "editorToolbar.copyCode")}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{isMermaid && (
<div
+17 -11
View File
@@ -8,6 +8,7 @@ import {
normalizeMermaidSvgForViewer,
resolveMermaidViewerBackground,
} from "@/lib/mermaid-svg";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
interface MermaidViewerProps {
closeLabel: string;
@@ -60,17 +61,22 @@ export const MermaidViewer = ({
if (!open || !objectUrl) return null;
const resetButton = (
<button
key="reset-mermaid-zoom"
type="button"
className="edgeever-mermaid-viewer-reset"
disabled={zoom <= 1}
aria-label={resetZoomLabel}
title={resetZoomLabel}
onClick={() => zoomRef.current?.changeZoom(1)}
>
<RotateCcw aria-hidden="true" />
</button>
<TooltipProvider key="reset-mermaid-zoom" delayDuration={0} skipDelayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="edgeever-mermaid-viewer-reset"
disabled={zoom <= 1}
aria-label={resetZoomLabel}
onClick={() => zoomRef.current?.changeZoom(1)}
>
<RotateCcw aria-hidden="true" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">{resetZoomLabel}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
return (
+1 -20
View File
@@ -49,9 +49,6 @@ export const MERMAID_THEME_PALETTES: Record<MermaidThemeName, MermaidThemePalett
"one-dark": { bg: "#282c34", fg: "#abb2bf", line: "#4b5263", accent: "#c678dd", muted: "#5c6370" },
};
export const MERMAID_RENDERERS = ["mermaid", "beautiful"] as const;
export type MermaidRenderer = (typeof MERMAID_RENDERERS)[number];
export const EDITOR_THEME_NAMES = [
"default",
"minimal-emerald",
@@ -117,8 +114,6 @@ interface AppearanceThemeContextValue {
interface MermaidThemeContextValue {
mermaidTheme: MermaidThemeName;
setMermaidTheme: (theme: MermaidThemeName) => void;
mermaidRenderer: MermaidRenderer;
setMermaidRenderer: (renderer: MermaidRenderer) => void;
}
interface EditorThemeContextValue {
@@ -136,7 +131,6 @@ interface ThemeProviderProps {
const THEME_STORAGE_KEY = "edgeever.theme";
const MERMAID_THEME_STORAGE_KEY = "edgeever.mermaid-theme";
const MERMAID_RENDERER_STORAGE_KEY = "edgeever.mermaid-renderer";
const EDITOR_THEME_STORAGE_KEY = "edgeever.editor-theme";
const CUSTOM_EDITOR_THEME_STORAGE_KEY = "edgeever.custom-editor-theme";
const CUSTOM_EDITOR_THEMES_STORAGE_KEY = "edgeever.custom-editor-themes";
@@ -173,11 +167,6 @@ export const getStoredMermaidTheme = (): MermaidThemeName => {
return MERMAID_THEME_NAMES.includes(stored as MermaidThemeName) ? stored as MermaidThemeName : "zinc-light";
};
export const getStoredMermaidRenderer = (): MermaidRenderer => {
const stored = readLocalStorageItem(MERMAID_RENDERER_STORAGE_KEY);
return MERMAID_RENDERERS.includes(stored as MermaidRenderer) ? stored as MermaidRenderer : "beautiful";
};
export const getStoredEditorTheme = (): string => {
return readLocalStorageItem(EDITOR_THEME_STORAGE_KEY) || "default";
};
@@ -267,7 +256,6 @@ export const ThemeProvider = ({ children }: ThemeProviderProps) => {
const [preference, setPreferenceState] = useState<ThemePreference>(getStoredThemePreference);
const [resolvedTheme, setResolvedTheme] = useState<ResolvedTheme>(() => resolveTheme(preference));
const [mermaidTheme, setMermaidThemeState] = useState<MermaidThemeName>(getStoredMermaidTheme);
const [mermaidRenderer, setMermaidRendererState] = useState<MermaidRenderer>(getStoredMermaidRenderer);
const [editorTheme, setEditorThemeState] = useState<string>(getStoredEditorTheme);
const [customEditorThemes, setCustomEditorThemesState] = useState<CustomEditorTheme[]>(getStoredCustomEditorThemes);
@@ -323,11 +311,6 @@ export const ThemeProvider = ({ children }: ThemeProviderProps) => {
window.localStorage.setItem(MERMAID_THEME_STORAGE_KEY, nextTheme);
}, []);
const setMermaidRenderer = useCallback((nextRenderer: MermaidRenderer) => {
setMermaidRendererState(nextRenderer);
window.localStorage.setItem(MERMAID_RENDERER_STORAGE_KEY, nextRenderer);
}, []);
const setEditorTheme = useCallback((nextTheme: string) => {
setEditorThemeState(nextTheme);
window.localStorage.setItem(EDITOR_THEME_STORAGE_KEY, nextTheme);
@@ -346,10 +329,8 @@ export const ThemeProvider = ({ children }: ThemeProviderProps) => {
() => ({
mermaidTheme,
setMermaidTheme,
mermaidRenderer,
setMermaidRenderer,
}),
[mermaidRenderer, mermaidTheme, setMermaidRenderer, setMermaidTheme]
[mermaidTheme, setMermaidTheme]
);
const editorValue = useMemo(
@@ -0,0 +1,18 @@
import { describe, expect, test } from "bun:test";
import { readFileSync } from "node:fs";
const readSource = (relativePath) => readFileSync(new URL(relativePath, import.meta.url), "utf8");
describe("Mermaid tooltips", () => {
test("uses shared tooltips instead of native title attributes", () => {
const codeBlock = readSource("./MermaidCodeBlock.tsx");
const viewer = readSource("./MermaidViewer.tsx");
expect(codeBlock).toContain("TooltipProvider");
expect(codeBlock).toContain("TooltipTrigger");
expect(codeBlock).toContain("TooltipContent");
expect(viewer).toContain("TooltipContent");
expect(codeBlock).not.toMatch(/\btitle=/);
expect(viewer).not.toMatch(/\btitle=/);
});
});
+11 -43
View File
@@ -1,7 +1,6 @@
import { afterEach, describe, expect, test } from "bun:test";
import { describe, expect, test } from "bun:test";
import { renderMermaidSVG } from "beautiful-mermaid";
import mermaid from "mermaid";
import { getStoredMermaidRenderer } from "../components/ThemeProvider";
import { renderMermaidWithFallback } from "./mermaid-renderer";
const GANTT_SOURCE = `gantt
@@ -9,49 +8,19 @@ const GANTT_SOURCE = `gantt
section Web
Compatibility fallback :fallback, 2026-07-30, 1d`;
const originalWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
const restoreWindow = () => {
if (originalWindow) {
Object.defineProperty(globalThis, "window", originalWindow);
} else {
delete globalThis.window;
}
};
afterEach(() => {
restoreWindow();
});
describe("Mermaid renderer fallback", () => {
test("defaults to the compact renderer when no browser preference exists", () => {
delete globalThis.window;
expect(getStoredMermaidRenderer()).toBe("beautiful");
});
test("defaults to the compact renderer when window exists without localStorage", () => {
globalThis.window = {
location: { hostname: "notes.example.com" },
};
expect(getStoredMermaidRenderer()).toBe("beautiful");
});
test("defaults to the compact renderer when localStorage is empty or blocked", () => {
globalThis.window = {
localStorage: {
getItem: () => null,
test("always uses the compact renderer when it supports the diagram", async () => {
let officialRenderCount = 0;
const svg = await renderMermaidWithFallback({
renderBeautiful: () => '<svg data-renderer="beautiful"></svg>',
renderOfficial: () => {
officialRenderCount += 1;
return '<svg data-renderer="mermaid"></svg>';
},
};
expect(getStoredMermaidRenderer()).toBe("beautiful");
});
globalThis.window = {
localStorage: {
getItem: () => {
throw new Error("blocked");
},
},
};
expect(getStoredMermaidRenderer()).toBe("beautiful");
expect(officialRenderCount).toBe(0);
expect(svg).toContain('data-renderer="beautiful"');
});
test("uses the official renderer for a valid Gantt diagram unsupported by beautiful-mermaid", async () => {
@@ -59,7 +28,6 @@ describe("Mermaid renderer fallback", () => {
let officialRenderCount = 0;
const svg = await renderMermaidWithFallback({
renderer: "beautiful",
renderBeautiful: () => renderMermaidSVG(GANTT_SOURCE),
renderOfficial: () => {
officialRenderCount += 1;
+4 -12
View File
@@ -1,23 +1,15 @@
export type MermaidRendererName = "mermaid" | "beautiful";
interface MermaidRenderFallbackOptions {
renderer: MermaidRendererName;
renderBeautiful: () => string | Promise<string>;
renderOfficial: () => string | Promise<string>;
}
export const renderMermaidWithFallback = async ({
renderer,
renderBeautiful,
renderOfficial,
}: MermaidRenderFallbackOptions) => {
if (renderer === "beautiful") {
try {
return await renderBeautiful();
} catch {
return renderOfficial();
}
try {
return await renderBeautiful();
} catch {
return renderOfficial();
}
return renderOfficial();
};
-2
View File
@@ -1104,8 +1104,6 @@ export const enUS = {
mermaidSource: "Mermaid source",
mermaidShowSource: "Show Mermaid source",
mermaidHideSource: "Hide Mermaid source",
mermaidUseStandardLayout: "Switch to standard layout",
mermaidUseOrthogonalLayout: "Switch to orthogonal layout",
mermaidOpenViewer: "Open Mermaid diagram viewer",
mermaidViewer: "Mermaid diagram viewer",
mermaidZoomIn: "Zoom in diagram",
-2
View File
@@ -1104,8 +1104,6 @@ export const zhCN = {
mermaidSource: "Mermaid 源码",
mermaidShowSource: "显示 Mermaid 源码",
mermaidHideSource: "隐藏 Mermaid 源码",
mermaidUseStandardLayout: "切换到标准布局",
mermaidUseOrthogonalLayout: "切换到正交布局",
mermaidOpenViewer: "放大查看 Mermaid 图表",
mermaidViewer: "Mermaid 图表查看器",
mermaidZoomIn: "放大图表",