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 | null = null; +/** render id 自增计数器,保证每次 render 生成唯一 id,避开 mermaid 残留节点冲突。 */ +let renderCounter = 0; + /** * 动态加载 mermaid 独立 IIFE bundle 的底层函数(可注入以便测试)。 * @@ -75,6 +83,33 @@ function loadMermaid(): Promise { return mermaidPromise; } +/** + * 把 mermaid 源码渲染成 SVG 并注入父
。
+ *
+ * - 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();