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:
parent
800129e245
commit
77a22bbfb1
3
libs/pnpm-lock.yaml
generated
3
libs/pnpm-lock.yaml
generated
@ -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
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
212
libs/tiptap-editor/src/__tests__/mermaid.test.ts
Normal file
212
libs/tiptap-editor/src/__tests__/mermaid.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
}
|
||||
|
||||
112
libs/tiptap-editor/src/mermaid.ts
Normal file
112
libs/tiptap-editor/src/mermaid.ts
Normal 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';
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user