feat: add selectable beautiful diagram themes
This commit is contained in:
1 parent
84f7788a2f
commit
06707affbb
8 files changed
+169
-39
No files matched your search
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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[\\/]/,
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "跟随系统",
|
||||
|
||||
Reference in new issue
Block a user