feat(editor): tiptap 代码块 mermaid 实时预览

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 桥零改动。
This commit is contained in:
xfy 2026-07-23 15:13:07 +08:00
parent 800129e245
commit 77a22bbfb1
6 changed files with 510 additions and 1 deletions

3
libs/pnpm-lock.yaml generated
View File

@ -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

View File

@ -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"
}

View File

@ -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: '<svg>flow</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('<svg>flow</svg>');
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: '<svg>v2</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(
'<svg>v2</svg>',
);
});
it('language 从 python 切到 mermaid:创建预览区并渲染', async () => {
mockRenderMermaid.mockResolvedValue({ svg: '<svg>m</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: '<svg>m</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: '<svg>first</svg>' });
}),
)
.mockResolvedValueOnce({ svg: '<svg>second</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: '<svg>first</svg>' }); // 第一次才 resolve(过期)
await vi.advanceTimersByTimeAsync(0);
const preview = view.dom.querySelector('.tiptap-codeblock-mermaid-preview');
// 第二次结果胜出,first 被丢弃。
expect(preview?.innerHTML).toBe('<svg>second</svg>');
});
it('主题切换事件触发重渲染', async () => {
mockRenderMermaid.mockResolvedValue({ svg: '<svg>m</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: '<svg>m</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();
});
});

View File

@ -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<typeof setTimeout> | 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<void> {
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;
}

View File

@ -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<string, unknown>) => void;
render: (id: string, text: string) => Promise<{ svg: string }>;
};
declare global {
interface Window {
MermaidRenderer?: MermaidApi;
}
}
let mermaidPromise: Promise<MermaidApi> | null = null;
let renderCounter = 0;
/**
* mermaid IIFE bundle(`/mermaid/mermaid.js`)
*
* bundle window.MermaidRenderer(IIFE ES export), <script>
* ,onload window ,
* mermaid.ts:201-209
*/
export function loadMermaidRenderer(): Promise<MermaidApi> {
if (!mermaidPromise) {
mermaidPromise = new Promise<MermaidApi>((resolve, reject) => {
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);
}).catch((err) => {
// 失败清空缓存,允许下次重试。
mermaidPromise = null;
throw err;
});
}
return mermaidPromise;
}
/** 测试用:重置加载缓存(注入 mock 后必须调)。 */
export function _resetMermaidRendererLoader(): void {
mermaidPromise = null;
}
/**
* mermaid SVG
*
* mermaid.ts:224-238 完全一致:theme:'base' themeVariables
* securityLevel:'strict'flowchart 线 + useMaxWidth
* initialize (),
*
* @returns { svg }, { error }(,)
*/
export async function renderMermaid(
source: string,
theme: ThemeName,
): Promise<{ svg: string } | { error: string }> {
try {
const mermaid = await loadMermaidRenderer();
mermaid.initialize({
startOnLoad: false,
theme: 'base',
darkMode: theme === 'dark',
securityLevel: 'strict',
flowchart: {
curve: 'basis',
diagramPadding: 16,
useMaxWidth: true,
htmlLabels: true,
},
themeVariables: mermaidThemeVarsFor(theme),
});
const id = `tiptap-mermaid-${++renderCounter}`;
const { svg } = await mermaid.render(id, source);
return { svg };
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
return { error: msg };
}
}
/**
* ( documentElement .dark class )
* NodeView
*/
export function getCurrentTheme(): ThemeName {
return document.documentElement.classList.contains('dark') ? 'dark' : 'light';
}

View File

@ -1330,3 +1330,62 @@
background-color: rgba(137, 180, 250, 0.15);
box-shadow: 0 0 0 1px rgba(137, 180, 250, 0.5);
}
/* ---- mermaid 代码块预览区 ----
* 视觉与前台 input.css pre[data-mermaid-rendered] 对齐:flex 居中
* 横向滚动SVG 等比缩放预览区挂在源码 <pre> 之后,源码仍可编辑 */
.tiptap-editor .tiptap-codeblock-mermaid-preview {
display: flex;
justify-content: center;
align-items: center;
overflow-x: auto;
padding: 24px 16px;
/* Latte base 边框/底 */
border-top: 1px solid #ccd0da;
background: #eff1f5;
border-radius: 0 0 6px 6px;
min-height: 48px;
}
.tiptap-editor .tiptap-codeblock-mermaid-preview > svg {
max-width: 100%;
height: auto;
}
/* 加载中:弱色文字 */
.tiptap-editor .tiptap-codeblock-mermaid-preview.mermaid-loading {
/* Latte subtext0 */
color: #6c6f85;
font-size: 0.875em;
font-style: italic;
}
/* 渲染失败:accent 色左边框(与前台 .mermaid-error 一致)+ 错误文字 */
.tiptap-editor .tiptap-codeblock-mermaid-preview.mermaid-error {
justify-content: flex-start;
/* Latte red */
color: #d20f2f;
border-left: 0.2rem solid #d20f2f;
font-size: 0.85em;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
text-align: left;
}
/* Dark theme (Mocha) */
.dark .tiptap-editor .tiptap-codeblock-mermaid-preview {
border-top-color: #313244;
background: #181825;
}
.dark .tiptap-editor .tiptap-codeblock-mermaid-preview.mermaid-loading {
/* Mocha subtext0 */
color: #a6adc8;
}
.dark .tiptap-editor .tiptap-codeblock-mermaid-preview.mermaid-error {
/* Mocha red */
color: #f38ba8;
border-left-color: #f38ba8;
}