diff --git a/libs/pnpm-lock.yaml b/libs/pnpm-lock.yaml
index 7169815..c0a7545 100644
--- a/libs/pnpm-lock.yaml
+++ b/libs/pnpm-lock.yaml
@@ -124,6 +124,9 @@ importers:
'@tiptap/suggestion':
specifier: ^3.27.3
version: 3.27.3(@floating-ui/dom@1.7.6)(@tiptap/core@3.27.3(@tiptap/pm@3.27.3))(@tiptap/pm@3.27.3)
+ '@yggdrasil/shared':
+ specifier: workspace:*
+ version: link:../shared
katex:
specifier: ^0.16.22
version: 0.16.47
diff --git a/libs/tiptap-editor/package.json b/libs/tiptap-editor/package.json
index 33e2a1e..cd3dec5 100644
--- a/libs/tiptap-editor/package.json
+++ b/libs/tiptap-editor/package.json
@@ -22,6 +22,7 @@
"@tiptap/pm": "^3.27.3",
"@tiptap/starter-kit": "^3.27.3",
"@tiptap/suggestion": "^3.27.3",
+ "@yggdrasil/shared": "workspace:*",
"katex": "^0.16.22",
"lowlight": "^3.3.0"
}
diff --git a/libs/tiptap-editor/src/__tests__/mermaid.test.ts b/libs/tiptap-editor/src/__tests__/mermaid.test.ts
new file mode 100644
index 0000000..9f30706
--- /dev/null
+++ b/libs/tiptap-editor/src/__tests__/mermaid.test.ts
@@ -0,0 +1,212 @@
+// @vitest-environment happy-dom
+
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+/**
+ * mermaid 代码块预览测试。
+ *
+ * mock `./mermaid` 模块的 renderMermaid/getCurrentTheme/loadMermaidRenderer,
+ * 绕过真实 mermaid 运行时(happy-dom 不支持 SVG 引擎)。重点验证 NodeView 的
+ * 生命周期:预览区创建/移除、debounce、竞态取消、主题重渲染、错误态、清理。
+ */
+
+const mockRenderMermaid = vi.fn();
+const mockGetCurrentTheme = vi.fn().mockReturnValue('light');
+
+vi.mock('../mermaid', () => ({
+ renderMermaid: (...args: unknown[]) => mockRenderMermaid(...args),
+ getCurrentTheme: () => mockGetCurrentTheme(),
+}));
+
+// THEME_CHANGE_EVENT 实际从 @yggdrasil/shared import,需一并 stub(vi.mock 对裸模块名)。
+vi.mock('@yggdrasil/shared', () => ({
+ THEME_CHANGE_EVENT: 'yggdrasil:theme-change',
+ mermaidThemeVarsFor: () => ({}),
+}));
+
+const CODEBLOCK_TYPE = { name: 'codeBlock' };
+
+function mockNode(language: string, textContent = '') {
+ return {
+ type: CODEBLOCK_TYPE,
+ attrs: { language },
+ textContent,
+ } as any;
+}
+
+function mockEditor() {
+ return { storage: {} } as any;
+}
+
+// 动态 import,确保 vi.mock 生效。
+const { CodeBlockNodeView } = await import('../code-block-view');
+
+/** 追踪所有测试创建的 view,afterEach 统一 destroy,避免 window 事件监听器跨测试泄漏。 */
+const views: Array<{ destroy: () => void }> = [];
+function makeView(opts: { node: any; editor: any }) {
+ const view = new CodeBlockNodeView({ ...opts, getPos: undefined } as any);
+ views.push(view);
+ return view;
+}
+
+describe('CodeBlockNodeView mermaid 预览', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ mockRenderMermaid.mockReset();
+ mockGetCurrentTheme.mockReturnValue('light');
+ });
+ afterEach(() => {
+ // 统一清理:destroy 每个 view(移除主题监听 + 清 timer),再切回真实 timer。
+ for (const v of views.splice(0)) v.destroy();
+ vi.useRealTimers();
+ });
+
+ it('mermaid 块:构造后出现预览区,debounce 后渲染 SVG', async () => {
+ mockRenderMermaid.mockResolvedValue({ svg: '' });
+ const view = makeView({
+ node: mockNode('mermaid', 'graph TD\n A-->B'),
+ editor: mockEditor(),
+ } as any);
+
+ // 预览区立即存在,显示加载态。
+ const preview = view.dom.querySelector('.tiptap-codeblock-mermaid-preview');
+ expect(preview).not.toBeNull();
+ expect(preview?.classList.contains('mermaid-loading')).toBe(true);
+
+ // 推进 debounce 500ms,flush 微任务。
+ await vi.advanceTimersByTimeAsync(500);
+ expect(mockRenderMermaid).toHaveBeenCalledWith('graph TD\n A-->B', 'light');
+ expect(preview?.innerHTML).toBe('');
+ expect(preview?.classList.contains('mermaid-loading')).toBe(false);
+ });
+
+ it('非 mermaid 块(python):无预览区', () => {
+ const view = makeView({
+ node: mockNode('python', 'print(1)'),
+ editor: mockEditor(),
+ } as any);
+ expect(view.dom.querySelector('.tiptap-codeblock-mermaid-preview')).toBeNull();
+ });
+
+ it('源码变化(update)触发 debounce 重渲染', async () => {
+ mockRenderMermaid.mockResolvedValue({ svg: '' });
+ const view = makeView({
+ node: mockNode('mermaid', 'graph TD\n A-->B'),
+ editor: mockEditor(),
+ } as any);
+ await vi.advanceTimersByTimeAsync(500); // 首次渲染
+
+ // 源码变化(update 传入新 node)。
+ view.update(mockNode('mermaid', 'graph TD\n A-->C') as any);
+ await vi.advanceTimersByTimeAsync(500);
+ expect(mockRenderMermaid).toHaveBeenLastCalledWith('graph TD\n A-->C', 'light');
+ expect(view.dom.querySelector('.tiptap-codeblock-mermaid-preview')?.innerHTML).toBe(
+ '',
+ );
+ });
+
+ it('language 从 python 切到 mermaid:创建预览区并渲染', async () => {
+ mockRenderMermaid.mockResolvedValue({ svg: '' });
+ const view = makeView({
+ node: mockNode('python', 'x'),
+ editor: mockEditor(),
+ } as any);
+ expect(view.dom.querySelector('.tiptap-codeblock-mermaid-preview')).toBeNull();
+
+ view.update(mockNode('mermaid', 'graph TD\n A-->B') as any);
+ expect(view.dom.querySelector('.tiptap-codeblock-mermaid-preview')).not.toBeNull();
+ await vi.advanceTimersByTimeAsync(500);
+ expect(mockRenderMermaid).toHaveBeenCalled();
+ });
+
+ it('language 从 mermaid 切到 python:移除预览区', async () => {
+ mockRenderMermaid.mockResolvedValue({ svg: '' });
+ const view = makeView({
+ node: mockNode('mermaid', 'graph TD\n A-->B'),
+ editor: mockEditor(),
+ } as any);
+ await vi.advanceTimersByTimeAsync(500);
+
+ view.update(mockNode('python', 'x') as any);
+ expect(view.dom.querySelector('.tiptap-codeblock-mermaid-preview')).toBeNull();
+ });
+
+ it('渲染失败:预览区显示错误 + mermaid-error class', async () => {
+ mockRenderMermaid.mockResolvedValue({ error: 'syntax error' });
+ const view = makeView({
+ node: mockNode('mermaid', 'bad syntax'),
+ editor: mockEditor(),
+ } as any);
+ await vi.advanceTimersByTimeAsync(500);
+
+ const preview = view.dom.querySelector('.tiptap-codeblock-mermaid-preview');
+ expect(preview?.classList.contains('mermaid-error')).toBe(true);
+ expect(preview?.textContent).toContain('syntax error');
+ });
+
+ it('竞态:连续两次源码改动,只保留最后一次渲染结果', async () => {
+ // 第一次渲染 pending(慢),第二次立即 resolve——验证慢的过期结果不覆盖快的。
+ let resolveFirst!: (v: { svg: string }) => void;
+ mockRenderMermaid
+ .mockImplementationOnce(
+ () =>
+ new Promise((r) => {
+ resolveFirst = () => r({ svg: '' });
+ }),
+ )
+ .mockResolvedValueOnce({ svg: '' });
+
+ const view = makeView({
+ node: mockNode('mermaid', 'v1'),
+ editor: mockEditor(),
+ } as any);
+ await vi.advanceTimersByTimeAsync(500); // 触发首次(v1),pending
+
+ // 立即改源码(v2),token 推进,首次结果应被丢弃。
+ view.update(mockNode('mermaid', 'v2') as any);
+ await vi.advanceTimersByTimeAsync(500); // 触发第二次(v2),resolve
+ resolveFirst({ svg: '' }); // 第一次才 resolve(过期)
+
+ await vi.advanceTimersByTimeAsync(0);
+ const preview = view.dom.querySelector('.tiptap-codeblock-mermaid-preview');
+ // 第二次结果胜出,first 被丢弃。
+ expect(preview?.innerHTML).toBe('');
+ });
+
+ it('主题切换事件触发重渲染', async () => {
+ mockRenderMermaid.mockResolvedValue({ svg: '' });
+ makeView({
+ node: mockNode('mermaid', 'graph TD\n A-->B'),
+ editor: mockEditor(),
+ } as any);
+ await vi.advanceTimersByTimeAsync(500);
+
+ mockGetCurrentTheme.mockReturnValue('dark');
+ mockRenderMermaid.mockClear();
+ window.dispatchEvent(new Event('yggdrasil:theme-change'));
+ await vi.advanceTimersByTimeAsync(500);
+
+ expect(mockRenderMermaid).toHaveBeenCalledWith('graph TD\n A-->B', 'dark');
+ });
+
+ it('destroy 清理预览区与主题监听', async () => {
+ mockRenderMermaid.mockResolvedValue({ svg: '' });
+ const removeSpy = vi.spyOn(window, 'removeEventListener');
+ const view = makeView({
+ node: mockNode('mermaid', 'graph TD\n A-->B'),
+ editor: mockEditor(),
+ } as any);
+ await vi.advanceTimersByTimeAsync(500);
+
+ view.destroy();
+ expect(view.dom.querySelector('.tiptap-codeblock-mermaid-preview')).toBeNull();
+ expect(removeSpy).toHaveBeenCalledWith('yggdrasil:theme-change', expect.any(Function));
+
+ // destroy 后主题事件不再触发渲染。
+ mockRenderMermaid.mockClear();
+ window.dispatchEvent(new Event('yggdrasil:theme-change'));
+ await vi.advanceTimersByTimeAsync(500);
+ expect(mockRenderMermaid).not.toHaveBeenCalled();
+ removeSpy.mockRestore();
+ });
+});
diff --git a/libs/tiptap-editor/src/code-block-view.ts b/libs/tiptap-editor/src/code-block-view.ts
index 2f1455e..4877c12 100644
--- a/libs/tiptap-editor/src/code-block-view.ts
+++ b/libs/tiptap-editor/src/code-block-view.ts
@@ -1,7 +1,9 @@
import type { Editor } from '@tiptap/core';
import type { Node as PMNode } from '@tiptap/pm/model';
import type { ViewMutationRecord } from '@tiptap/pm/view';
+import { THEME_CHANGE_EVENT } from '@yggdrasil/shared';
import { extractLang, extractOverridesJson } from './highlight';
+import { getCurrentTheme, renderMermaid } from './mermaid';
import { openRunnableModal } from './slash-command';
/** editor.storage 的 key,宿主(index.ts)在此注入 onRunCode 回调。 */
@@ -47,6 +49,17 @@ export class CodeBlockNodeView {
private code: HTMLElement;
private resultArea: HTMLDivElement | null = null;
+ // ---- mermaid 预览相关(仅 mermaid 代码块启用) ----
+ private mermaidPreview: HTMLDivElement | null = null;
+ /** 源码变化后 debounce 重渲染的 timer id。 */
+ private mermaidDebounceTimer: ReturnType | null = null;
+ /** 自增 token,渲染回调比较它丢弃过期结果(防快速改动的竞态覆盖)。 */
+ private mermaidRenderToken = 0;
+ /** 主题切换监听器引用(destroy 时 removeEventListener)。 */
+ private themeHandler: (() => void) | null = null;
+ /** 上次渲染的源码快照,用于 update() 判断源码是否变化。 */
+ private lastRenderedSource: string | null = null;
+
private getPos: (() => number | undefined) | undefined;
constructor(opts: { node: PMNode; editor: Editor; getPos?: () => number | undefined }) {
@@ -96,6 +109,11 @@ export class CodeBlockNodeView {
// 校验并设置工具栏显隐(无语言且非 runnable 时隐藏)
this.updateToolbarVisibility();
+
+ // mermaid 代码块:创建预览区并触发首次渲染 + 订阅主题切换。
+ if (this.isMermaid()) {
+ this.setupMermaidPreview();
+ }
}
get dom(): HTMLElement {
@@ -114,8 +132,12 @@ export class CodeBlockNodeView {
if (node.type !== this.node.type) return false;
const oldLang = (this.node.attrs.language as string) ?? '';
const newLang = (node.attrs.language as string) ?? '';
+ const langChanged = oldLang !== newLang;
+ const oldSource = this.node.textContent;
this.node = node;
- if (oldLang !== newLang) {
+ const newSource = node.textContent;
+
+ if (langChanged) {
this.langBadge.textContent = extractLang(newLang);
const runnable = isRunnable(node);
this.langBadge.classList.toggle('tiptap-codeblock-lang-editable', runnable);
@@ -133,6 +155,18 @@ export class CodeBlockNodeView {
this.refreshRunButton();
// 刷新工具栏显隐状态(无语言且非 runnable 时隐藏)
this.updateToolbarVisibility();
+
+ // language 切入/切出 mermaid:相应创建/移除预览区。
+ if (this.isMermaid() && !this.mermaidPreview) {
+ this.setupMermaidPreview();
+ } else if (!this.isMermaid() && this.mermaidPreview) {
+ this.teardownMermaidPreview();
+ }
+ }
+
+ // mermaid 块源码变化:debounce 重渲染(language 变化后也重渲一次)。
+ if (this.isMermaid() && this.mermaidPreview && (langChanged || oldSource !== newSource)) {
+ this.scheduleMermaidRender();
}
return true;
}
@@ -162,6 +196,92 @@ export class CodeBlockNodeView {
this.container.classList.toggle('has-toolbar', show);
}
+ /** 当前节点是否为 mermaid 代码块。 */
+ private isMermaid(): boolean {
+ return extractLang((this.node.attrs.language as string) ?? '') === 'mermaid';
+ }
+
+ /**
+ * 创建 mermaid 预览区(挂到 container,pre 之后),触发首次渲染 + 订阅主题切换。
+ * 幂等:已存在则不重复创建。
+ */
+ private setupMermaidPreview(): void {
+ if (this.mermaidPreview) return;
+ const preview = document.createElement('div');
+ preview.classList.add('tiptap-codeblock-mermaid-preview');
+ preview.classList.add('mermaid-loading');
+ preview.setAttribute('contenteditable', 'false');
+ preview.textContent = '渲染中…';
+ // 插到 pre 之后(运行结果区 resultArea 之前,若存在)。
+ this.pre.after(preview);
+ this.mermaidPreview = preview;
+
+ // 订阅主题切换:用新主题重渲染当前块。
+ this.themeHandler = () => {
+ // 主题变了,强制重渲(忽略 lastRenderedSource 的去重)。
+ this.lastRenderedSource = null;
+ this.scheduleMermaidRender();
+ };
+ window.addEventListener(THEME_CHANGE_EVENT, this.themeHandler);
+
+ this.scheduleMermaidRender();
+ }
+
+ /** 移除 mermaid 预览区与主题监听,清理 pending timer。 */
+ private teardownMermaidPreview(): void {
+ if (this.mermaidDebounceTimer !== null) {
+ clearTimeout(this.mermaidDebounceTimer);
+ this.mermaidDebounceTimer = null;
+ }
+ if (this.themeHandler) {
+ window.removeEventListener(THEME_CHANGE_EVENT, this.themeHandler);
+ this.themeHandler = null;
+ }
+ this.mermaidRenderToken += 1; // 使任何 in-flight 渲染失效
+ this.mermaidPreview?.remove();
+ this.mermaidPreview = null;
+ this.lastRenderedSource = null;
+ }
+
+ /** 源码未变则跳过;否则 debounce 500ms 后渲染(避免输入时频繁渲染)。 */
+ private scheduleMermaidRender(): void {
+ if (!this.mermaidPreview) return;
+ const source = this.node.textContent;
+ if (source === this.lastRenderedSource) return;
+
+ if (this.mermaidDebounceTimer !== null) {
+ clearTimeout(this.mermaidDebounceTimer);
+ }
+ this.mermaidDebounceTimer = setTimeout(() => {
+ this.mermaidDebounceTimer = null;
+ void this.renderMermaidPreview();
+ }, 500);
+ }
+
+ /**
+ * 实际渲染:调 renderMermaid,用 renderToken 防竞态(快速改动时只保留最新结果)。
+ * 成功注入 SVG,失败显示错误(源码仍可编辑修正)。
+ */
+ private async renderMermaidPreview(): Promise {
+ if (!this.mermaidPreview) return;
+ const source = this.node.textContent;
+ this.mermaidRenderToken += 1;
+ const token = this.mermaidRenderToken;
+ this.lastRenderedSource = source;
+
+ const result = await renderMermaid(source, getCurrentTheme());
+ // 渲染期间预览区可能被 teardown 或发起新渲染:过期则丢弃。
+ if (!this.mermaidPreview || token !== this.mermaidRenderToken) return;
+
+ this.mermaidPreview.classList.remove('mermaid-loading', 'mermaid-error');
+ if ('svg' in result) {
+ this.mermaidPreview.innerHTML = result.svg;
+ } else {
+ this.mermaidPreview.classList.add('mermaid-error');
+ this.mermaidPreview.textContent = `渲染失败:${result.error}`;
+ }
+ }
+
/**
* 判断 DOM mutation 是否应被 ProseMirror 忽略。
*
@@ -271,6 +391,8 @@ export class CodeBlockNodeView {
}
destroy(): void {
+ // 清理 mermaid 预览资源(timer、主题监听、in-flight token)。
+ this.teardownMermaidPreview();
this.resultArea = null;
this.runBtn = null;
}
diff --git a/libs/tiptap-editor/src/mermaid.ts b/libs/tiptap-editor/src/mermaid.ts
new file mode 100644
index 0000000..d223494
--- /dev/null
+++ b/libs/tiptap-editor/src/mermaid.ts
@@ -0,0 +1,112 @@
+import { mermaidThemeVarsFor, type ThemeName } from '@yggdrasil/shared';
+
+/**
+ * 编辑器侧 mermaid 加载与渲染封装。
+ *
+ * 与前台 yggdrasil-core/src/mermaid.ts 共用同一份运行时(`/mermaid/mermaid.js`,
+ * mermaid 11.16.0,挂 window.MermaidRenderer)与同一套 Catppuccin 主题变量
+ * (经 @yggdrasil/shared 单一真相源),保证「编辑器预览 = 线上文章页」。
+ *
+ * 区别于前台:编辑器是 NodeView 内的同步生命周期里发起异步渲染,需由调用方
+ * (CodeBlockNodeView)管理 debounce、竞态取消与主题重渲染。本模块只提供
+ * 纯粹的「加载 bundle + 把源码渲染成 SVG/error」能力。
+ */
+
+/** mermaid 11 API 子集(项目只用 initialize + render)。 */
+type MermaidApi = {
+ initialize: (config: Record) => void;
+ render: (id: string, text: string) => Promise<{ svg: string }>;
+};
+
+declare global {
+ interface Window {
+ MermaidRenderer?: MermaidApi;
+ }
+}
+
+let mermaidPromise: Promise | null = null;
+let renderCounter = 0;
+
+/**
+ * 动态加载 mermaid 独立 IIFE bundle(`/mermaid/mermaid.js`)。
+ *
+ * bundle 挂 window.MermaidRenderer(IIFE 无 ES export),故用动态注入