From 77a22bbfb16ab63595879867de7631a995dde86f Mon Sep 17 00:00:00 2001 From: xfy Date: Thu, 23 Jul 2026 15:13:07 +0800 Subject: [PATCH] =?UTF-8?q?feat(editor):=20tiptap=20=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E5=9D=97=20mermaid=20=E5=AE=9E=E6=97=B6=E9=A2=84=E8=A7=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mermaid 代码块( ```mermaid )在富文本编辑器里此前只是普通源码,作者看不到 渲染效果。现在像数学公式、脚注一样在编辑器内直接预览:源码下方加预览区, debounce 500ms 后渲染成 SVG,跟随站点 light/dark 主题切换,与前台文章页 渲染视觉完全一致。 一致性保证(核心): - 同一份 mermaid 运行时:编辑器与前台都加载 /mermaid/mermaid.js(11.16.0) - 同一套主题变量:经 @yggdrasil/shared 单一真相源(上一提交下沉) - 同一份渲染配置:theme:'base' + securityLevel:'strict' + flowchart.curve:'basis' - 同一主题切换机制:监听 THEME_CHANGE_EVENT(与 codemirror-editor 同范式) 实现: - mermaid.ts:加载 bundle + renderMermaid(source, theme) 封装,与前台同逻辑 - code-block-view.ts:mermaid 块构造时建预览区;源码变化 debounce 重渲染; renderToken 防竞态(快速改动只保留最新);主题事件重渲染;language 切入/切出 mermaid 时创建/移除预览区;destroy 清理 timer+监听+token - 渲染失败显示错误态(accent 色左边框),源码仍可编辑修正 - 样式对齐前台 pre[data-mermaid-rendered]:flex 居中、SVG 等比缩放、Latte/Mocha 双主题 - 测试 mock mermaid 运行时,覆盖预览区生命周期、debounce、竞态、主题、错误、清理 服务端 markdown.rs / Dioxus.toml / Rust-JS 桥零改动。 --- libs/pnpm-lock.yaml | 3 + libs/tiptap-editor/package.json | 1 + .../src/__tests__/mermaid.test.ts | 212 ++++++++++++++++++ libs/tiptap-editor/src/code-block-view.ts | 124 +++++++++- libs/tiptap-editor/src/mermaid.ts | 112 +++++++++ libs/tiptap-editor/src/style.css | 59 +++++ 6 files changed, 510 insertions(+), 1 deletion(-) create mode 100644 libs/tiptap-editor/src/__tests__/mermaid.test.ts create mode 100644 libs/tiptap-editor/src/mermaid.ts 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: 'flow' }); + 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('flow'); + 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: 'v2' }); + 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( + 'v2', + ); + }); + + it('language 从 python 切到 mermaid:创建预览区并渲染', async () => { + mockRenderMermaid.mockResolvedValue({ svg: 'm' }); + 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: 'm' }); + 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: 'first' }); + }), + ) + .mockResolvedValueOnce({ svg: 'second' }); + + 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: 'first' }); // 第一次才 resolve(过期) + + await vi.advanceTimersByTimeAsync(0); + const preview = view.dom.querySelector('.tiptap-codeblock-mermaid-preview'); + // 第二次结果胜出,first 被丢弃。 + expect(preview?.innerHTML).toBe('second'); + }); + + it('主题切换事件触发重渲染', async () => { + mockRenderMermaid.mockResolvedValue({ svg: 'm' }); + 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: 'm' }); + 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),故用动态注入