xfy fac247f60a feat(mermaid): 流程图配色对齐 Catppuccin + 容器美化
mermaid.initialize 之前用内置 'default'/'dark' 主题,配色与站点 Catppuccin
调色板完全脱节,图是"外来"的样子;且零 mermaid 专用 CSS,SVG 直接套在通用
pre 容器里,宽图横向滚动、无居中无留白。

改动:
- mermaid.ts:theme 切到 'base'(base 不硬编码颜色,themeVariables 能完全
  控制调色板;'default' 主题硬编码 mainBkg=#ECECFF 会阻断覆盖)。新增 Latte/
  Mocha 两套 themeVariables,节点用 surface 色阶、边框/连线用 subtext 色阶、
  文字用 primary text,hex 取自 themes/*.tmTheme。flowchart 配 basis 平滑曲线
  + diagramPadding 16 + useMaxWidth:true(配合 CSS 消除横向滚动)。
- input.css:新增 .md-content pre[data-mermaid-rendered] 规则,flex 居中 + svg
  max-width:100% 缩放适配;.mermaid-error 加 accent 色左边条提示。
- mermaid.test.ts:theme 断言同步到 'base'+darkMode;新增 themeVariables
  注入测试(钉住 Latte/Mocha 关键 hex)。

范围:纯前端 TS + CSS,零 Rust 改动。
2026-07-17 10:13:20 +08:00

357 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Mermaid 流程图懒加载渲染。
*
* 扫描 `pre > code.language-mermaid` 代码块,在进入视口时动态加载独立 IIFE bundle
* `public/mermaid/mermaid.js`~1MB只在有图且可见时加载不影响无图文章首屏
* 把 mermaid 源码渲染成 SVG 注入到父 <pre>。
*
* bundle 是 IIFE 格式(与项目其他前端库一致),挂全局变量 `window.MermaidRenderer`。
* 故不能用 ES module 的 `import().default`IIFE 无 export——改用动态注入
* `<script src="/mermaid/mermaid.js">` 标签,加载后从 window 取 MermaidRenderer。
*
* 范式照搬 post-content.tsquerySelectorAll + 幂等守卫 + 注入 DOM。
* mermaid 无官方 SSR 支持,纯客户端渲染;服务端 markdown.rs 只产出带
* `language-mermaid` class 的普通代码块,不挂 data 属性。
*
* 主题切换mermaid 把颜色/主题变量烤进 SVG 内联样式,无法靠 CSS 原地改主题。
* 唯一办法是移除旧 SVG → 用新主题重新 render → 插入新 SVG。post_content.rs 的
* use_effect 读 use_resolved_theme() 建立订阅,主题切换时重跑、重调 __initMermaid
* 传入新 theme本模块用 dataset.mermaidTheme 记住上次渲染主题,主题变化时触发重渲染。
*/
import type { ThemeName } from '@yggdrasil/shared';
import { onThemeChange } from './theme-transition';
/** 文章正文容器选择器(与 post_content.rs 的 __initMermaid 调用一致)。 */
const POST_CONTENT_SELECTOR = '.post-content';
type MermaidApi = {
initialize: (config: Record<string, unknown>) => void;
render: (id: string, text: string) => Promise<{ svg: string }>;
};
/**
* Catppuccin themeVariables与站点 --color-paper-* / .tmTheme 调色板对齐)。
*
* mermaid 把颜色烤进 SVG 内联 style无法靠 CSS 原地改主题,故须在 initialize 时注入。
* 用 `theme: 'base'`(非 'default'/'dark'base 主题不硬编码颜色themeVariables 能完全
* 控制调色板;'default' 主题硬编码 mainBkg=#ECECFF 等会阻断覆盖。
*
* 设计哲学:极简卡片化——节点用 surface 色阶(卡片感)、边框/连线用 subtext 色阶(克制)、
* 文字用 primary text。不滥用强调色绿/紫等 accent 留给作者用 classDef 手动强调。
* hex 值取自 themes/Catppuccin Latte.tmTheme 与 Catppuccin Mocha.tmTheme。
*
* 覆盖的字段涵盖 flowchart / sequence / class 三类图(测试文章用到的全部类型)。
*/
/** Latte主题节点 surface 色阶、文字 #4c4f69、连线 subtext1 #5c5f77。 */
const LATTE_VARS = {
background: '#dce0e8', // = --color-paper-code-block图背景与 pre 无缝衔接
// 节点填充surface 色阶递进(主/次/三级),卡片质感
primaryColor: '#e6e9ef',
secondaryColor: '#ccd0da',
tertiaryColor: '#bcc0cc',
mainBkg: '#e6e9ef',
nodeBkg: '#e6e9ef',
secondBkg: '#ccd0da',
// 节点边框surface1 偏冷灰
primaryBorderColor: '#bcc0cc',
secondaryBorderColor: '#acb0be',
tertiaryBorderColor: '#9ca0b0',
nodeBorder: '#bcc0cc',
clusterBorder: '#bcc0cc',
labelBoxBorderColor: '#bcc0cc',
// 文字primary text #4c4f69
primaryTextColor: '#4c4f69',
secondaryTextColor: '#5c5f77',
tertiaryTextColor: '#6c6f85',
textColor: '#4c4f69',
nodeTextColor: '#4c4f69',
titleColor: '#4c4f69',
classText: '#4c4f69',
labelTextColor: '#4c4f69',
// 连线/箭头subtext1 #5c5f77
lineColor: '#5c5f77',
defaultLinkColor: '#5c5f77',
arrowheadColor: '#5c5f77',
// 时序图
actorBkg: '#e6e9ef',
actorBorder: '#bcc0cc',
actorTextColor: '#4c4f69',
actorLineColor: '#5c5f77',
signalColor: '#5c5f77',
signalTextColor: '#4c4f69',
loopTextColor: '#4c4f69',
sequenceNumberColor: '#eff1f5',
activationBkgColor: '#ccd0da',
activationBorderColor: '#bcc0cc',
// 边标签 / 子图背景
edgeLabelBackground: '#eff1f5',
labelBoxBkgColor: '#eff1f5',
clusterBkg: 'rgba(239, 241, 245, 0.5)',
// 注释低饱和黄Latte yellow #df8e1d
noteBkgColor: 'rgba(223, 142, 29, 0.15)',
noteBorderColor: '#df8e1d',
noteTextColor: '#4c4f69',
// 字体:与正文 sans 对齐,中文友好
fontFamily:
"-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans SC', 'PingFang SC', 'Microsoft YaHei', sans-serif",
fontSize: '16px',
} as const;
/** Mocha主题节点 surface 色阶、文字 #cdd6f4、连线 subtext0 #a6adc8。 */
const MOCHA_VARS = {
background: '#313244', // = --color-paper-code-block
primaryColor: '#45475a',
secondaryColor: '#585b70',
tertiaryColor: '#1e1e2e',
mainBkg: '#45475a',
nodeBkg: '#45475a',
secondBkg: '#585b70',
primaryBorderColor: '#585b70',
secondaryBorderColor: '#45475a',
tertiaryBorderColor: '#313244',
nodeBorder: '#585b70',
clusterBorder: '#585b70',
labelBoxBorderColor: '#585b70',
primaryTextColor: '#cdd6f4',
secondaryTextColor: '#bac2de',
tertiaryTextColor: '#a6adc8',
textColor: '#cdd6f4',
nodeTextColor: '#cdd6f4',
titleColor: '#cdd6f4',
classText: '#cdd6f4',
labelTextColor: '#cdd6f4',
lineColor: '#a6adc8',
defaultLinkColor: '#a6adc8',
arrowheadColor: '#a6adc8',
actorBkg: '#45475a',
actorBorder: '#585b70',
actorTextColor: '#cdd6f4',
actorLineColor: '#a6adc8',
signalColor: '#a6adc8',
signalTextColor: '#cdd6f4',
loopTextColor: '#cdd6f4',
sequenceNumberColor: '#1e1e2e',
activationBkgColor: '#585b70',
activationBorderColor: '#6c7086',
edgeLabelBackground: '#1e1e2e',
labelBoxBkgColor: '#1e1e2e',
clusterBkg: 'rgba(30, 30, 46, 0.5)',
noteBkgColor: 'rgba(249, 226, 175, 0.12)',
noteBorderColor: '#f9e2af',
noteTextColor: '#cdd6f4',
fontFamily:
"-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans SC', 'PingFang SC', 'Microsoft YaHei', sans-serif",
fontSize: '16px',
} as const;
/** 按主题返回对应 Catppuccin themeVariables。 */
function themeVarsFor(theme: ThemeName): Record<string, unknown> {
return theme === 'dark' ? { ...MOCHA_VARS } : { ...LATTE_VARS };
}
declare global {
interface Window {
MermaidRenderer?: MermaidApi;
}
}
let mermaidPromise: Promise<MermaidApi> | null = null;
/** render id 自增计数器,保证每次 render 生成唯一 id避开 mermaid 残留节点冲突。 */
let renderCounter = 0;
/**
* 动态加载 mermaid 独立 IIFE bundle 的底层函数(可注入以便测试)。
*
* IIFE bundle 挂在 `window.MermaidRenderer`(非 ES module export故用动态注入
* `<script>` 标签的方式加载,标签 onload 后从 window 取。测试时可通过
* [`_resetMermaidLoader`] 注入 mock。
*/
export let loadMermaidBundle: () => Promise<MermaidApi> = () =>
new Promise((resolve, reject) => {
// 已加载(同页多次调用 / SPA 导航)直接复用。
if (window.MermaidRenderer) {
resolve(window.MermaidRenderer);
return;
}
const script = document.createElement('script');
script.src = '/mermaid/mermaid.js';
script.onload = () => {
if (window.MermaidRenderer) {
resolve(window.MermaidRenderer);
} else {
reject(new Error('mermaid bundle loaded but window.MermaidRenderer undefined'));
}
};
script.onerror = () => reject(new Error('failed to load /mermaid/mermaid.js'));
document.head.appendChild(script);
});
/** 重置加载函数(测试用,重新注入 mock 后必须重置 mermaidPromise 缓存)。 */
export function _resetMermaidLoader(loader?: () => Promise<MermaidApi>): void {
mermaidPromise = null;
if (loader) loadMermaidBundle = loader;
}
/**
* 动态加载 mermaid 独立 bundle单例缓存失败清空允许重试
*/
function loadMermaid(): Promise<MermaidApi> {
if (!mermaidPromise) {
mermaidPromise = loadMermaidBundle().catch((err) => {
mermaidPromise = null;
throw err;
});
}
return mermaidPromise;
}
/**
* 把 mermaid 源码渲染成 SVG 并注入父 <pre>。
*
* - render 用全局自增 id`mermaid-svg-${++renderCounter}`),避免同页多次 render
* 撞上 mermaid 内部残留的 `d`-前缀布局辅助节点mermaid#357与 marker id
* 冲突mermaid#5741
* - 渲染前清空 pre.innerHTML确保旧 SVG 与残留 `d-` 节点先消失再插入新 SVG
* 不让亮/暗两版并存。
* - source 存进 dataset.mermaidSource主题切换重渲染时回取此时 <code> 已被
* SVG 替换textContent 不再可用)。
*/
async function renderBlock(pre: HTMLPreElement, source: string, theme: ThemeName): Promise<void> {
const mermaid = await loadMermaid();
mermaid.initialize({
startOnLoad: false,
// base 主题不硬编码颜色,让 themeVariables 完全控制 Catppuccin 调色板。
theme: 'base',
darkMode: theme === 'dark',
securityLevel: 'strict',
// flowchart平滑曲线 + 边缘留白 + 缩放至容器宽度(配合 CSS 消除横向滚动)。
flowchart: {
curve: 'basis',
diagramPadding: 16,
useMaxWidth: true,
htmlLabels: true,
},
themeVariables: themeVarsFor(theme),
});
const id = `mermaid-svg-${++renderCounter}`;
const { svg } = await mermaid.render(id, source);
// 清空 pre 旧内容(旧 SVG + mermaid 残留的 `d-` 辅助节点),再注入新 SVG。
pre.innerHTML = svg;
pre.dataset.mermaidRendered = 'true';
pre.dataset.mermaidSource = source;
pre.dataset.mermaidTheme = theme;
}
/**
* 为单个 mermaid <pre> 注册 IntersectionObserver进入视口才渲染。
*
* 无 IntersectionObserverSSR / 旧环境)时直接同步渲染。
* rootMargin 200px 让图在接近视口时提前加载,避免滚到才白屏。
*/
function observeBlock(pre: HTMLPreElement, render: () => Promise<void>): void {
if (typeof IntersectionObserver === 'undefined') {
void render();
return;
}
const io = new IntersectionObserver(
(entries) => {
if (entries.some((e) => e.isIntersecting)) {
io.disconnect();
void render();
}
},
{ rootMargin: '200px' },
);
io.observe(pre);
}
/**
* 初始化文章正文里的 mermaid 代码块。
*
* 两条互斥路径(渲染后 <code> 被 SVG 替换,故两选择器命中的 pre 不会重叠):
* 1. 仍含 `<code class="language-mermaid">` 的 pre未渲染→ 缓存源码 +
* IntersectionObserver 进视口懒加载。
* 2. 已渲染pre 内容是 SVGdataset.mermaid-rendered 标记)→ 同主题幂等跳过
* (上下篇切换复用组件实例时 post_content.rs 的 effect 会重调本函数);
* 主题变化则取缓存源码重渲染bundle 已加载,无需 IntersectionObserver
*
* @param selector 文章正文容器选择器(如 '.post-content'
* @param theme 当前生效主题,传给 mermaid 适配暗色
*/
export function initMermaid(selector: string, theme: ThemeName): Promise<void> {
const root = document.querySelector(selector);
if (!root) return Promise.resolve();
// 路径 1未渲染的块<code> 还在)。
const blocks = root.querySelectorAll<HTMLPreElement>('pre > code.language-mermaid');
blocks.forEach((code) => {
const pre = code.parentElement as HTMLPreElement | null;
if (!pre) return;
if (pre.dataset.mermaidRendered) return; // 理论不命中renderBlock 同步替换 innerHTML防御
const source = code.textContent || '';
pre.dataset.mermaidSource = source;
observeBlock(pre, async () => {
try {
await renderBlock(pre, source, theme);
} catch (err) {
// 渲染失败(语法错误 / bundle 加载失败):保留原始源码,加错误标记 class
// 便于用户发现是 mermaid 源写错了。不破坏页面其余内容。
console.error('mermaid render failed:', err);
pre.classList.add('mermaid-error');
}
});
});
// 路径 2已渲染的块<code> 已被 SVG 替换,按 dataset 回找)。无条件执行,
// 覆盖「页面上未渲染块与已渲染块并存」的场景。主题切换时由 onThemeChange 订阅
// 返回其 Promise供 VT callback await让新主题流程图进入 NEW 快照)。
return rerenderExistingBlocks(root, theme);
}
/**
* 对已渲染pre 内容已是 SVG、无 <code>)的块按缓存源码重渲染。
*
* 主题切换重跑 initMermaid 时,已渲染的 pre 里 <code> 已被 SVG 替换,
* `pre > code.language-mermaid` 选择器不再命中。这里用 dataset 标记回找。
*
* 返回聚合 Promise所有需要重渲染的块完成后 resolve。onThemeChange 订阅返回它,
* VT callback await 它以等 mermaid.render 异步完成后再拍 NEW 快照。单块失败不中断
* 聚合catch 吞错 + 加 mermaid-error class
*/
function rerenderExistingBlocks(root: Element, theme: ThemeName): Promise<void> {
const rendered = root.querySelectorAll<HTMLPreElement>('pre[data-mermaid-rendered]');
const tasks: Promise<void>[] = [];
rendered.forEach((pre) => {
if (pre.dataset.mermaidTheme === theme) return;
const source = pre.dataset.mermaidSource;
if (!source) return; // 无缓存源码无法重渲染,保守跳过
tasks.push(
renderBlock(pre, source, theme).catch((err) => {
console.error('mermaid re-render failed:', err);
pre.classList.add('mermaid-error');
}),
);
});
return Promise.all(tasks).then(() => {});
}
/**
* 订阅主题切换:主题变化时重渲染已渲染的 mermaid 块,返回重渲染 Promise。
*
* VT 协调的关键:本 listener 返回 Promise → notifyThemeChange 收集它 → VT callback
* await 它 → 浏览器等 mermaid.render 完成、拍含新主题流程图的 NEW 快照、再播圆形扩散。
* 无 VT(降级 / 跟随系统瞬切)时调用方不等,mermaid 后台重渲染。
*
* 顶层注册(IIFE 加载时),确保任何时刻主题切换都能命中。首次渲染前无已渲染块,
* rerenderExistingBlocks 自然 no-op。
*/
onThemeChange((isDark) => {
const root = document.querySelector(POST_CONTENT_SELECTOR);
if (!root) return;
return rerenderExistingBlocks(root, isDark ? 'dark' : 'light');
});