diff --git a/libs/yggdrasil-core/src/mermaid.test.ts b/libs/yggdrasil-core/src/mermaid.test.ts index f3bdb27..69c03ac 100644 --- a/libs/yggdrasil-core/src/mermaid.test.ts +++ b/libs/yggdrasil-core/src/mermaid.test.ts @@ -117,4 +117,67 @@ describe('initMermaid', () => { expect(root.querySelector('pre')?.classList.contains('mermaid-error')).toBe(true); }); }); + + it('主题切换时重渲染已渲染的块', async () => { + const root = document.createElement('div'); + root.className = 'post-content'; + root.innerHTML = '
graph TD; A-->B';
+ document.body.appendChild(root);
+
+ // 首次渲染(light)
+ window.__initMermaid('.post-content', 'light');
+ await vi.waitFor(() => {
+ expect(root.querySelector('pre')?.dataset.mermaidRendered).toBe('true');
+ });
+ expect(root.querySelector('pre')?.dataset.mermaidTheme).toBe('light');
+ const firstRenderCalls = mockRender.mock.calls.length;
+
+ // 主题切换 → dark:应触发重渲染
+ window.__initMermaid('.post-content', 'dark');
+ await vi.waitFor(() => {
+ expect(mockRender.mock.calls.length).toBeGreaterThan(firstRenderCalls);
+ });
+ expect(mockInitialize).toHaveBeenLastCalledWith(expect.objectContaining({ theme: 'dark' }));
+ expect(root.querySelector('pre')?.dataset.mermaidTheme).toBe('dark');
+ });
+
+ it('主题未变时重渲染路径幂等(同主题跳过)', async () => {
+ const root = document.createElement('div');
+ root.className = 'post-content';
+ root.innerHTML = 'graph TD; A-->B';
+ document.body.appendChild(root);
+
+ window.__initMermaid('.post-content', 'light');
+ await vi.waitFor(() => {
+ expect(root.querySelector('pre')?.dataset.mermaidRendered).toBe('true');
+ });
+ mockRender.mockClear();
+
+ // 同主题再调(模拟上下篇切换复用组件实例、effect 重跑)
+ window.__initMermaid('.post-content', 'light');
+ await new Promise((r) => setTimeout(r, 50));
+ expect(mockRender).not.toHaveBeenCalled();
+ });
+
+ it('主题切换重渲染用唯一 render id(避免 mermaid 残留节点冲突)', async () => {
+ const root = document.createElement('div');
+ root.className = 'post-content';
+ root.innerHTML = 'graph TD; A-->B';
+ document.body.appendChild(root);
+
+ window.__initMermaid('.post-content', 'light');
+ await vi.waitFor(() => {
+ expect(root.querySelector('pre')?.dataset.mermaidRendered).toBe('true');
+ });
+ const firstId = mockRender.mock.calls[0][0];
+
+ window.__initMermaid('.post-content', 'dark');
+ await vi.waitFor(() => {
+ expect(mockRender.mock.calls.length).toBeGreaterThanOrEqual(2);
+ });
+ const secondId = mockRender.mock.calls[mockRender.mock.calls.length - 1][0];
+
+ // 两次 render 的 id 必须不同,否则撞上 mermaid 内部残留的 d-前缀节点(#357)
+ expect(secondId).not.toBe(firstId);
+ });
});
diff --git a/libs/yggdrasil-core/src/mermaid.ts b/libs/yggdrasil-core/src/mermaid.ts
index b7a3d42..e5e1f19 100644
--- a/libs/yggdrasil-core/src/mermaid.ts
+++ b/libs/yggdrasil-core/src/mermaid.ts
@@ -12,6 +12,11 @@
* 范式照搬 post-content.ts:querySelectorAll + 幂等守卫 + 注入 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';
@@ -29,6 +34,9 @@ declare global {
let mermaidPromise: Promise。
+ *
+ * - render 用全局自增 id(`mermaid-svg-${++renderCounter}`),避免同页多次 render
+ * 撞上 mermaid 内部残留的 `d`-前缀布局辅助节点(mermaid#357)与 marker id
+ * 冲突(mermaid#5741)。
+ * - 渲染前清空 pre.innerHTML,确保旧 SVG 与残留 `d-` 节点先消失再插入新 SVG,
+ * 不让亮/暗两版并存。
+ * - source 存进 dataset.mermaidSource,主题切换重渲染时回取(此时 已被
+ * SVG 替换,textContent 不再可用)。
+ */
+async function renderBlock(pre: HTMLPreElement, source: string, theme: ThemeName): Promise {
+ const mermaid = await loadMermaid();
+ mermaid.initialize({
+ startOnLoad: false,
+ theme: theme === 'dark' ? 'dark' : 'default',
+ securityLevel: 'strict',
+ });
+ 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 注册 IntersectionObserver:进入视口才渲染。
*
@@ -101,33 +136,32 @@ function observeBlock(pre: HTMLPreElement, render: () => Promise): void {
/**
* 初始化文章正文里的 mermaid 代码块。
*
+ * 两条互斥路径(渲染后 被 SVG 替换,故两选择器命中的 pre 不会重叠):
+ * 1. 仍含 `` 的 pre(未渲染)→ 缓存源码 +
+ * IntersectionObserver 进视口懒加载。
+ * 2. 已渲染(pre 内容是 SVG,dataset.mermaid-rendered 标记)→ 同主题幂等跳过
+ * (上下篇切换复用组件实例时 post_content.rs 的 effect 会重调本函数);
+ * 主题变化则取缓存源码重渲染(bundle 已加载,无需 IntersectionObserver)。
+ *
* @param selector 文章正文容器选择器(如 '.post-content')
* @param theme 当前生效主题,传给 mermaid 适配暗色
*/
export function initMermaid(selector: string, theme: ThemeName): void {
const root = document.querySelector(selector);
if (!root) return;
- const blocks = root.querySelectorAll('pre > code.language-mermaid');
- if (blocks.length === 0) return;
- blocks.forEach((code, i) => {
+ // 路径 1:未渲染的块( 还在)。
+ const blocks = root.querySelectorAll('pre > code.language-mermaid');
+ blocks.forEach((code) => {
const pre = code.parentElement as HTMLPreElement | null;
if (!pre) return;
- if (pre.dataset.mermaidRendered) return; // 幂等:上下篇切换重复调用不重渲染
+ if (pre.dataset.mermaidRendered) return; // 理论不命中(renderBlock 同步替换 innerHTML),防御
const source = code.textContent || '';
+ pre.dataset.mermaidSource = source;
observeBlock(pre, async () => {
try {
- const mermaid = await loadMermaid();
- mermaid.initialize({
- startOnLoad: false,
- theme: theme === 'dark' ? 'dark' : 'default',
- securityLevel: 'strict',
- });
- const { svg } = await mermaid.render(`mermaid-svg-${i}`, source);
- // 替换整个 内容为 SVG,丢弃 copy 按钮(图不需要复制源码)。
- pre.innerHTML = svg;
- pre.dataset.mermaidRendered = 'true';
+ await renderBlock(pre, source, theme);
} catch (err) {
// 渲染失败(语法错误 / bundle 加载失败):保留原始源码,加错误标记 class
// 便于用户发现是 mermaid 源写错了。不破坏页面其余内容。
@@ -136,4 +170,27 @@ export function initMermaid(selector: string, theme: ThemeName): void {
}
});
});
+
+ // 路径 2:已渲染的块( 已被 SVG 替换,按 dataset 回找)。无条件执行,
+ // 覆盖「页面上未渲染块与已渲染块并存」的场景。
+ rerenderExistingBlocks(root, theme);
+}
+
+/**
+ * 对已渲染(pre 内容已是 SVG、无 )的块按缓存源码重渲染。
+ *
+ * 主题切换重跑 initMermaid 时,已渲染的 pre 里 已被 SVG 替换,
+ * `pre > code.language-mermaid` 选择器不再命中。这里用 dataset 标记回找。
+ */
+function rerenderExistingBlocks(root: Element, theme: ThemeName): void {
+ const rendered = root.querySelectorAll('pre[data-mermaid-rendered]');
+ rendered.forEach((pre) => {
+ if (pre.dataset.mermaidTheme === theme) return;
+ const source = pre.dataset.mermaidSource;
+ if (!source) return; // 无缓存源码无法重渲染,保守跳过
+ void renderBlock(pre, source, theme).catch((err) => {
+ console.error('mermaid re-render failed:', err);
+ pre.classList.add('mermaid-error');
+ });
+ });
}
diff --git a/src/components/post/post_content.rs b/src/components/post/post_content.rs
index a8f7876..6b52c16 100644
--- a/src/components/post/post_content.rs
+++ b/src/components/post/post_content.rs
@@ -137,9 +137,9 @@ pub fn PostContent(content_html: String) -> Element {
let fragments = split_content_fragments(&content_html);
// mermaid 流程图主题需随当前生效主题(light/dark)切换。读 use_resolved_theme()
- // 建立订阅:主题变化时下方 use_effect 重跑,重新调用 __initMermaid(幂等,
- // 已渲染的块由 dataset.mermaidRendered 守卫跳过;主题切换暂不强制重渲染图,
- // 后续如需可在 mermaid.ts 监听 THEME_CHANGE_EVENT)。
+ // 建立订阅:主题变化时下方 use_effect 重跑,重调 __initMermaid 传入新 theme;
+ // mermaid.ts 用 dataset.mermaidTheme 记住上次渲染主题,检测到主题变化时按缓存
+ // 源码重渲染(mermaid 颜色烤进 SVG 内联样式,无法靠 CSS 原地切主题)。
#[cfg(target_arch = "wasm32")]
let resolved_theme = crate::theme::use_resolved_theme();