release: v0.4.5 — Mermaid 图表渲染支持

新增 Mermaid 图表渲染:
- npm 安装 mermaid@10 作为项目依赖
- markdown.ts 新增 rehypeMermaid 插件:将 mermaid 代码块
  转换为 .me-mermaid 容器(与 MetonaEditor 内置输出一致)
- Editor.tsx 初始化 mermaid (startOnLoad: false)
- 绑定 afterRender 事件 → mermaid.run() 渲染 SVG 图表
- 首次加载 setTimeout 兜底渲染

技术实现:
- rehypeMermaid 插件在 rehypeHighlight 之后运行
- 查找 pre > code.language-mermaid 节点
- 替换为 div.me-mermaid > div.mermaid 容器
- 保持 unified 管线完整(图片路径修复 + sanitize + highlight)
- mermaid.run({ querySelector: '.me-mermaid .mermaid' })

质量保证:
- TypeScript 严格模式: 0 errors
- 单元测试: 98/98 passed (16 markdown + 82 others)
This commit is contained in:
thzxx
2026-07-25 16:41:14 +08:00
parent 2e7baa1073
commit d6ae9e3c8e
4 changed files with 58 additions and 2 deletions
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "marklite",
"version": "0.4.4",
"version": "0.4.5",
"description": "Lightweight Markdown Editor for Windows",
"main": "./dist/main/index.js",
"scripts": {
@@ -31,6 +31,7 @@
"@metona-team/metona-editor": "0.2.4",
"@metona-team/metona-toast": "0.2.1",
"dexie": "^4.0.11",
"mermaid": "^10.9.6",
"nanoid": "^5.1.5",
"react": "^18.3.1",
"react-dom": "^18.3.1",
+12
View File
@@ -1,6 +1,7 @@
import React, { useEffect, useRef } from 'react'
import MeEditor from '@metona-team/metona-editor'
import type { MarkdownEditor } from '@metona-team/metona-editor'
import mermaid from 'mermaid'
import { useTabStore } from '../../stores/tabStore'
import { setMetonaEditorGetter, useEditorStore } from '../../stores/editorStore'
import { settingsRepository } from '../../db/settingsRepository'
@@ -11,6 +12,9 @@ import type { ThemeMode } from '../../types/settings'
type EditMode = 'edit' | 'split' | 'preview'
type ThemeName = 'light' | 'dark' | 'auto' | 'warm' | string
// v0.4.5: Mermaid 初始化(全局一次性配置)
mermaid.initialize({ startOnLoad: false, theme: 'default' })
interface EditorProps {
themeMode: ThemeMode
onAppSave?: () => void
@@ -147,6 +151,14 @@ export const Editor = React.memo(function Editor({ themeMode, onAppSave }: Edito
setMetonaEditorGetter(() => editor)
currentContentRef.current = activeTab?.content ?? ''
// v0.4.5: 绑定 afterRender → 触发 Mermaid 图表渲染
const renderMermaid = () => {
try { mermaid.run({ querySelector: '.me-mermaid .mermaid' }) } catch { /* 容错 */ }
}
editor.on('afterRender', renderMermaid)
// 首次渲染后延迟触发一次
setTimeout(renderMermaid, 300)
return () => {
editor.destroy()
editorRef.current = null
+43
View File
@@ -6,6 +6,7 @@ import rehypeRaw from 'rehype-raw'
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize'
import rehypeStringify from 'rehype-stringify'
import rehypeHighlight from 'rehype-highlight'
import { visit } from 'unist-util-visit'
import type { Element, Root } from 'hast'
// 简单的路径解析(Electron renderer 中没有 path 模块)
@@ -71,6 +72,47 @@ function rehypeFixImages(filePath: string | null): Plugin<[], Root> {
}
}
// 自定义 rehype 插件:将 mermaid 代码块转为 .me-mermaid 容器
// 在 rehypeHighlight 之后运行,此时 code 元素已有 language-mermaid 类名。
// 输出结构需与 MetonaEditor 内置解析器一致,以便 mermaid.run() 统一查询。
function rehypeMermaid(): Plugin<[], Root> {
return () => (tree: Root) => {
visit(tree, 'element', (node: Element, index, parent) => {
if (node.tagName !== 'pre') return
if (parent === null || parent === undefined || index === undefined) return
const code = node.children.find(
(child): child is Element => child.type === 'element' && child.tagName === 'code'
)
if (!code) return
const className: string[] = (code.properties?.className as string[] | undefined) ?? []
if (!className.some(c => c === 'language-mermaid' || c === 'mermaid')) return
// 提取代码文本内容
const text = (code.children ?? [])
.filter(c => c.type === 'text')
.map(c => (c as { type: 'text'; value: string }).value)
.join('')
// 替换为 mermaid 渲染容器(与 MetonaEditor 内置输出一致)
const mermaidContainer: Element = {
type: 'element',
tagName: 'div',
properties: { className: ['me-mermaid'] },
children: [{
type: 'element',
tagName: 'div',
properties: { className: ['mermaid'] },
children: [{ type: 'text', value: text }]
}]
}
parent.children[index] = mermaidContainer
})
}
}
// PF-01: Markdown处理器LRU缓存
const MAX_CACHE_SIZE = 20
const processorCache = new Map<string, ReturnType<typeof buildProcessor>>()
@@ -102,6 +144,7 @@ function buildProcessor(filePath: string | null) {
strip: ['script', 'on*', 'javascript:'],
})
.use(rehypeHighlight)
.use(rehypeMermaid())
.use(rehypeStringify)
}
+1 -1
View File
@@ -1,5 +1,5 @@
// 共享常量 — 主进程和渲染进程共用
export const APP_VERSION = 'v0.4.4'
export const APP_VERSION = 'v0.4.5'
export const MAX_FILE_SIZE = 20 * 1024 * 1024 // 20MB
export const ALLOWED_EXTENSIONS = ['.md', '.markdown', '.txt'] as const
export const SKIP_DIRS = new Set([