feat: add selectable beautiful diagram themes

This commit is contained in:
tianma-if committed 2026-07-23 10:17:05 +08:00
1 parent 84f7788a2f
commit 06707affbb
8 files changed
+169 -39

No files matched your search

+1
View File
@@ -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"
+60 -35
View File
@@ -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<MermaidModule> | null = null;
let mermaidRenderSequence = 0;
let beautifulMermaidModulePromise: Promise<BeautifulMermaidModule> | 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 (
<NodeViewWrapper
+34 -1
View File
@@ -2,11 +2,31 @@ import { createContext, useContext, useEffect, useMemo, useState, type ReactNode
export type ThemePreference = "system" | "light" | "dark";
export type ResolvedTheme = "light" | "dark";
export const MERMAID_THEME_NAMES = [
"zinc-light",
"zinc-dark",
"tokyo-night",
"tokyo-night-storm",
"tokyo-night-light",
"catppuccin-mocha",
"catppuccin-latte",
"nord",
"nord-light",
"dracula",
"github-light",
"github-dark",
"solarized-light",
"solarized-dark",
"one-dark",
] as const;
export type MermaidThemeName = (typeof MERMAID_THEME_NAMES)[number];
interface ThemeContextValue {
preference: ThemePreference;
resolvedTheme: ResolvedTheme;
setPreference: (preference: ThemePreference) => 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<ThemeContextValue | null>(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<ThemePreference>(getStoredThemePreference);
const [resolvedTheme, setResolvedTheme] = useState<ResolvedTheme>(() => resolveTheme(preference));
const [mermaidTheme, setMermaidThemeState] = useState<MermaidThemeName>(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 <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
@@ -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<AppLocalePreference>(() => getAppLocalePreference());
const handleLocalePreferenceChange = (preference: AppLocalePreference) => {
@@ -69,6 +71,27 @@ export const PreferenceCard = ({
</div>
</div>
<div className="flex min-h-16 flex-col items-start gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="min-w-0">
<div className="text-sm font-semibold text-slate-900">{t("settings.mermaidThemeTitle")}</div>
<div className="mt-0.5 text-xs leading-4 text-slate-500">{t("settings.mermaidThemeDescription")}</div>
</div>
<div className="w-full shrink-0 sm:w-44">
<Select value={mermaidTheme} onValueChange={(value) => setMermaidTheme(value as typeof mermaidTheme)}>
<SelectTrigger aria-label={t("settings.mermaidThemeTitle")} className="h-9 bg-white">
<SelectValue />
</SelectTrigger>
<SelectContent>
{MERMAID_THEME_NAMES.map((theme) => (
<SelectItem key={theme} value={theme}>
{t(`settings.mermaidThemes.${theme}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="flex min-h-16 flex-col items-start gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="min-w-0">
<div className="text-sm font-semibold text-slate-900">{t("settings.imageCompressionTitle")}</div>
+5
View File
@@ -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[\\/]/,
+8 -3
View File
@@ -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=="],
+19
View File
@@ -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",
+19
View File
@@ -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: "跟随系统",