From 06707affbb3189d1fc6052b4691337668d1277bb Mon Sep 17 00:00:00 2001 From: tianma-if Date: Thu, 23 Jul 2026 10:17:05 +0800 Subject: [PATCH] feat: add selectable beautiful diagram themes --- apps/web/package.json | 1 + apps/web/src/components/MermaidCodeBlock.tsx | 95 ++++++++++++------- apps/web/src/components/ThemeProvider.tsx | 35 ++++++- .../components/settings/PreferenceCard.tsx | 23 +++++ apps/web/vite.config.ts | 5 + bun.lock | 11 ++- packages/shared/src/i18n/en-US.ts | 19 ++++ packages/shared/src/i18n/zh-CN.ts | 19 ++++ 8 files changed, 169 insertions(+), 39 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index d5f39084..22976e91 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -6,6 +6,7 @@ "dependencies": { "@tiptap/extension-code-block-lowlight": "3.27.1", "@tiptap/pm": "3.27.1", + "beautiful-mermaid": "^1.1.3", "highlight.js": "^11.11.1", "lowlight": "^3.3.0", "mermaid": "^11.16.0" diff --git a/apps/web/src/components/MermaidCodeBlock.tsx b/apps/web/src/components/MermaidCodeBlock.tsx index e1799415..315c7d44 100644 --- a/apps/web/src/components/MermaidCodeBlock.tsx +++ b/apps/web/src/components/MermaidCodeBlock.tsx @@ -1,11 +1,14 @@ import { useEffect, useState } from "react"; import { NodeViewContent, NodeViewWrapper, type NodeViewProps } from "@tiptap/react"; import { useTranslation } from "react-i18next"; +import { useTheme } from "./ThemeProvider"; type MermaidModule = typeof import("mermaid")["default"]; +type BeautifulMermaidModule = typeof import("beautiful-mermaid"); let mermaidModulePromise: Promise | null = null; let mermaidRenderSequence = 0; +let beautifulMermaidModulePromise: Promise | null = null; const loadMermaid = () => { if (!mermaidModulePromise) { @@ -17,8 +20,16 @@ const loadMermaid = () => { return mermaidModulePromise; }; +const loadBeautifulMermaid = () => { + if (!beautifulMermaidModulePromise) { + beautifulMermaidModulePromise = import("beautiful-mermaid"); + } + return beautifulMermaidModulePromise; +}; + export const MermaidCodeBlock = ({ editor, node }: NodeViewProps) => { const { t } = useTranslation(); + const { mermaidTheme } = useTheme(); const language = typeof node.attrs.language === "string" ? node.attrs.language.toLowerCase() : "plaintext"; const source = node.textContent.trim(); const isMermaid = language === "mermaid"; @@ -37,42 +48,56 @@ export const MermaidCodeBlock = ({ editor, node }: NodeViewProps) => { const timer = window.setTimeout(() => { setRenderState("loading"); - void loadMermaid() - .then(async (mermaid) => { - const dark = document.documentElement.classList.contains("dark"); - const ink = dark ? "#cbd5e1" : "#26384a"; - const surface = dark ? "#0f172a" : "#ffffff"; - mermaid.initialize({ - startOnLoad: false, - securityLevel: "strict", - suppressErrorRendering: true, - theme: "base", - themeVariables: { - background: "transparent", - primaryColor: surface, - primaryTextColor: ink, - primaryBorderColor: ink, - lineColor: ink, - textColor: ink, - mainBkg: surface, - nodeBorder: ink, - edgeLabelBackground: surface, - actorBkg: surface, - actorBorder: ink, - actorTextColor: ink, - signalColor: ink, - signalTextColor: ink, - }, - }); - const valid = await mermaid.parse(source, { suppressErrors: true }); - if (!valid) { - throw new Error("Invalid Mermaid diagram"); + void loadBeautifulMermaid() + .then(({ renderMermaidSVG, THEMES }) => { + try { + return renderMermaidSVG(source, { + ...THEMES[mermaidTheme], + transparent: true, + font: "Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, sans-serif", + padding: 24, + }); + } catch { + return null; } - - mermaidRenderSequence += 1; - return mermaid.render(`edgeever-mermaid-${mermaidRenderSequence}`, source); }) - .then(({ svg: nextSvg }) => { + .then((beautifulSvg) => { + if (beautifulSvg) return beautifulSvg; + return loadMermaid().then(async (mermaid) => { + const palette = (await loadBeautifulMermaid()).THEMES[mermaidTheme]; + mermaid.initialize({ + startOnLoad: false, + securityLevel: "strict", + suppressErrorRendering: true, + theme: "base", + themeVariables: { + background: palette.bg, + primaryColor: palette.surface ?? palette.bg, + primaryTextColor: palette.fg, + primaryBorderColor: palette.border ?? palette.fg, + lineColor: palette.line ?? palette.fg, + textColor: palette.fg, + mainBkg: palette.bg, + nodeBorder: palette.border ?? palette.fg, + edgeLabelBackground: palette.bg, + actorBkg: palette.surface ?? palette.bg, + actorBorder: palette.border ?? palette.fg, + actorTextColor: palette.fg, + signalColor: palette.line ?? palette.fg, + signalTextColor: palette.fg, + }, + }); + const valid = await mermaid.parse(source, { suppressErrors: true }); + if (!valid) { + throw new Error("Invalid Mermaid diagram"); + } + + mermaidRenderSequence += 1; + const { svg: fallbackSvg } = await mermaid.render(`edgeever-mermaid-${mermaidRenderSequence}`, source); + return fallbackSvg; + }); + }) + .then((nextSvg) => { if (!cancelled) { setSvg(nextSvg); setRenderState("ready"); @@ -90,7 +115,7 @@ export const MermaidCodeBlock = ({ editor, node }: NodeViewProps) => { cancelled = true; window.clearTimeout(timer); }; - }, [isMermaid, source]); + }, [isMermaid, mermaidTheme, source]); return ( void; + mermaidTheme: MermaidThemeName; + setMermaidTheme: (theme: MermaidThemeName) => void; } interface ThemeProviderProps { @@ -14,6 +34,7 @@ interface ThemeProviderProps { } const THEME_STORAGE_KEY = "edgeever.theme"; +const MERMAID_THEME_STORAGE_KEY = "edgeever.mermaid-theme"; const LIGHT_THEME_COLOR = "#f8fafc"; const DARK_THEME_COLOR = "#0f172a"; const ThemeContext = createContext(null); @@ -33,6 +54,12 @@ export const getStoredThemePreference = (): ThemePreference => { return stored === "light" || stored === "dark" || stored === "system" ? stored : "system"; }; +export const getStoredMermaidTheme = (): MermaidThemeName => { + if (typeof window === "undefined") return "zinc-light"; + const stored = window.localStorage.getItem(MERMAID_THEME_STORAGE_KEY); + return MERMAID_THEME_NAMES.includes(stored as MermaidThemeName) ? stored as MermaidThemeName : "zinc-light"; +}; + const applyThemeToDocument = (preference: ThemePreference) => { const resolvedTheme = resolveTheme(preference); const root = document.documentElement; @@ -51,6 +78,7 @@ export const initializeTheme = () => { export const ThemeProvider = ({ children }: ThemeProviderProps) => { const [preference, setPreferenceState] = useState(getStoredThemePreference); const [resolvedTheme, setResolvedTheme] = useState(() => resolveTheme(preference)); + const [mermaidTheme, setMermaidThemeState] = useState(getStoredMermaidTheme); useEffect(() => { setResolvedTheme(applyThemeToDocument(preference)); @@ -71,8 +99,13 @@ export const ThemeProvider = ({ children }: ThemeProviderProps) => { preference, resolvedTheme, setPreference: (nextPreference: ThemePreference) => setPreferenceState(nextPreference), + mermaidTheme, + setMermaidTheme: (nextTheme: MermaidThemeName) => { + setMermaidThemeState(nextTheme); + window.localStorage.setItem(MERMAID_THEME_STORAGE_KEY, nextTheme); + }, }), - [preference, resolvedTheme] + [mermaidTheme, preference, resolvedTheme] ); return {children}; diff --git a/apps/web/src/components/settings/PreferenceCard.tsx b/apps/web/src/components/settings/PreferenceCard.tsx index 371635e9..ad5d4497 100644 --- a/apps/web/src/components/settings/PreferenceCard.tsx +++ b/apps/web/src/components/settings/PreferenceCard.tsx @@ -13,6 +13,7 @@ import { type AppLocalePreference, } from "@/i18n"; import { ShortcutSettingsItem } from "./ShortcutSettingsItem"; +import { MERMAID_THEME_NAMES, useTheme } from "../ThemeProvider"; interface PreferenceCardProps { imageCompressionEnabled: boolean; @@ -28,6 +29,7 @@ export const PreferenceCard = ({ onShortcutSettingsChange, }: PreferenceCardProps) => { const { t } = useTranslation(); + const { mermaidTheme, setMermaidTheme } = useTheme(); const [activeLocalePreference, setActiveLocalePreference] = useState(() => getAppLocalePreference()); const handleLocalePreferenceChange = (preference: AppLocalePreference) => { @@ -69,6 +71,27 @@ export const PreferenceCard = ({ +
+
+
{t("settings.mermaidThemeTitle")}
+
{t("settings.mermaidThemeDescription")}
+
+
+ +
+
+
{t("settings.imageCompressionTitle")}
diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index d0bf9dda..06332be8 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -210,6 +210,11 @@ export default defineConfig({ test: /[\\/](?:@mermaid-js[\\/](?:parser|tiny)|katex|dompurify|stylis|dayjs|@iconify[\\/]utils)(?:@|[\\/])/, priority: 11, }, + { + name: "vendor-beautiful-mermaid", + test: /[\\/](?:beautiful-mermaid|elkjs|entities)(?:@|[\\/])/, + priority: 13, + }, { name: "ui-primitives", test: /src[\\/]components[\\/]ui[\\/]/, diff --git a/bun.lock b/bun.lock index 7089bc54..62e4a639 100644 --- a/bun.lock +++ b/bun.lock @@ -165,6 +165,7 @@ "dependencies": { "@tiptap/extension-code-block-lowlight": "3.27.1", "@tiptap/pm": "3.27.1", + "beautiful-mermaid": "^1.1.3", "highlight.js": "^11.11.1", "lowlight": "^3.3.0", "mermaid": "^11.16.0", @@ -1390,6 +1391,8 @@ "baseline-browser-mapping": ["baseline-browser-mapping@2.10.40", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw=="], + "beautiful-mermaid": ["beautiful-mermaid@1.1.3", "", { "dependencies": { "elkjs": "^0.11.0", "entities": "^7.0.1" } }, "sha512-TItrtrAyHp1vwFfFVYauWGrquouk/6SS21Aq3RsxindSYZODcN4xYrPZD6BiZRU+o5mKJzDPz9MUSMvELdylyg=="], + "big-integer": ["big-integer@1.6.52", "", {}, "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg=="], "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], @@ -1696,6 +1699,8 @@ "electron-to-chromium": ["electron-to-chromium@1.5.379", "", {}, "sha512-v/qV5aV5EUA2pGilzUCq5/eyOloZAqDZBu9UMBIzgPpLlprjSR6zswsWBTv0KpqxLGUAZEwhO95ZCt7srymNVA=="], + "elkjs": ["elkjs@0.11.1", "", {}, "sha512-zxxR9k+rx5ktMwT/FwyLdPCrq7xN6e4VGGHH8hA01vVYKjTFik7nHOxBnAYtrgYUB1RpAiLvA1/U2YraWxyKKg=="], + "emmet": ["emmet@2.4.11", "", { "dependencies": { "@emmetio/abbreviation": "^2.3.3", "@emmetio/css-abbreviation": "^2.1.8" } }, "sha512-23QPJB3moh/U9sT4rQzGgeyyGIrcM+GH5uVYg2C6wZIxAIJq7Ng3QLT79tl8FUwDXhyq9SusfknOrofAKqvgyQ=="], "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], @@ -1708,7 +1713,7 @@ "enhanced-resolve": ["enhanced-resolve@5.21.6", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ=="], - "entities": ["entities@2.0.3", "", {}, "sha512-MyoZ0jgnLvB2X3Lg5HqpFmn1kybDiIfEQmKzTb5apr51Rb+T3KdmMiqa70T+bhGnyv7bQ6WMj2QMHpGMmlrUYQ=="], + "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], "error-stack-parser": ["error-stack-parser@2.1.4", "", { "dependencies": { "stackframe": "^1.3.4" } }, "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ=="], @@ -3552,8 +3557,6 @@ "hosted-git-info/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], - "htmlparser2/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], - "jest-util/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "jest-util/ci-info": ["ci-info@3.9.0", "", {}, "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ=="], @@ -3578,6 +3581,8 @@ "make-dir/semver": ["semver@5.7.2", "", { "bin": { "semver": "bin/semver" } }, "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g=="], + "markdown-it/entities": ["entities@2.0.3", "", {}, "sha512-MyoZ0jgnLvB2X3Lg5HqpFmn1kybDiIfEQmKzTb5apr51Rb+T3KdmMiqa70T+bhGnyv7bQ6WMj2QMHpGMmlrUYQ=="], + "mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], "mermaid/marked": ["marked@16.4.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="], diff --git a/packages/shared/src/i18n/en-US.ts b/packages/shared/src/i18n/en-US.ts index c72aa0d7..c54acd19 100644 --- a/packages/shared/src/i18n/en-US.ts +++ b/packages/shared/src/i18n/en-US.ts @@ -145,6 +145,25 @@ export const enUS = { imageCompressionTitle: "Compress note images", imageCompressionDescription: "Compress large images locally before upload to reduce storage use.", imageCompressionAria: "Compress note images", + mermaidThemeTitle: "Diagram theme", + mermaidThemeDescription: "Choose the visual theme for diagrams in notes.", + mermaidThemes: { + "zinc-light": "Zinc Light", + "zinc-dark": "Zinc Dark", + "tokyo-night": "Tokyo Night", + "tokyo-night-storm": "Tokyo Night Storm", + "tokyo-night-light": "Tokyo Night Light", + "catppuccin-mocha": "Catppuccin Mocha", + "catppuccin-latte": "Catppuccin Latte", + nord: "Nord", + "nord-light": "Nord Light", + dracula: "Dracula", + "github-light": "GitHub Light", + "github-dark": "GitHub Dark", + "solarized-light": "Solarized Light", + "solarized-dark": "Solarized Dark", + "one-dark": "One Dark", + }, themeTitle: "Interface theme", themeDescription: "Choose light, dark, or follow your system setting.", themeSystem: "Follow system", diff --git a/packages/shared/src/i18n/zh-CN.ts b/packages/shared/src/i18n/zh-CN.ts index 0b7921ff..3e39ab33 100644 --- a/packages/shared/src/i18n/zh-CN.ts +++ b/packages/shared/src/i18n/zh-CN.ts @@ -145,6 +145,25 @@ export const zhCN = { imageCompressionTitle: "压缩笔记内图片", imageCompressionDescription: "上传大图时在本地压缩,节省资源占用。", imageCompressionAria: "是否压缩笔记内图片", + mermaidThemeTitle: "图表主题", + mermaidThemeDescription: "选择笔记中图表的视觉主题。", + mermaidThemes: { + "zinc-light": "Zinc 浅色", + "zinc-dark": "Zinc 深色", + "tokyo-night": "Tokyo Night", + "tokyo-night-storm": "Tokyo Night Storm", + "tokyo-night-light": "Tokyo Night 浅色", + "catppuccin-mocha": "Catppuccin Mocha", + "catppuccin-latte": "Catppuccin Latte", + nord: "Nord", + "nord-light": "Nord 浅色", + dracula: "Dracula", + "github-light": "GitHub 浅色", + "github-dark": "GitHub 深色", + "solarized-light": "Solarized 浅色", + "solarized-dark": "Solarized 深色", + "one-dark": "One Dark", + }, themeTitle: "界面主题", themeDescription: "选择浅色、深色,或跟随系统设置。", themeSystem: "跟随系统",