add Mermaid 流程图的支持

This commit is contained in:
tianyaxiang 2025-02-02 17:45:05 +08:00
parent 1a02300026
commit 5cf1777c00
8 changed files with 1548 additions and 50 deletions

View File

@ -34,6 +34,7 @@
"katex": "^0.16.21",
"lucide-react": "^0.474.0",
"marked": "^15.0.6",
"mermaid": "^11.4.1",
"next": "14.1.0",
"next-themes": "^0.4.4",
"prismjs": "^1.29.0",

File diff suppressed because it is too large Load Diff

View File

@ -18,6 +18,7 @@ import { Copy, Clock, Type, Trash2 } from 'lucide-react'
import { useLocalStorage } from '@/hooks/use-local-storage'
import { codeThemes, type CodeThemeId } from '@/config/code-themes'
import '@/styles/code-themes.css'
import { copyHandler } from './utils/copy-handler'
// 计算阅读时间假设每分钟阅读300字
const calculateReadingTime = (text: string): string => {
@ -185,46 +186,15 @@ export default function WechatEditor() {
// 处理复制
const handleCopy = useCallback(async () => {
try {
const htmlContent = getPreviewContent()
const tempDiv = document.createElement('div')
tempDiv.innerHTML = htmlContent
const plainText = tempDiv.textContent || tempDiv.innerText
await navigator.clipboard.write([
new ClipboardItem({
'text/html': new Blob([htmlContent], { type: 'text/html' }),
'text/plain': new Blob([plainText], { type: 'text/plain' })
})
])
toast({
title: "复制成功",
description: "已复制预览内容",
duration: 2000
})
return true
} catch (err) {
console.error('Copy error:', err)
try {
await navigator.clipboard.writeText(previewContent)
toast({
title: "复制成功",
description: "已复制预览内容(仅文本)",
duration: 2000
})
return true
} catch (fallbackErr) {
toast({
variant: "destructive",
title: "复制失败",
description: "无法访问剪贴板,请检查浏览器权限",
action: <ToastAction altText="重试"></ToastAction>,
})
return false
}
// 获取预览容器中的实际内容
const previewContainer = document.querySelector('.preview-content')
if (!previewContainer) {
return copyHandler(window.getSelection(), previewContent, { toast })
}
}, [previewContent, toast, getPreviewContent])
// 使用实际渲染后的内容
return copyHandler(window.getSelection(), previewContainer.innerHTML, { toast })
}, [previewContent, toast])
// 手动保存
const handleSave = useCallback(() => {

View File

@ -2,8 +2,9 @@ import { cn } from '@/lib/utils'
import { PREVIEW_SIZES, type PreviewSize } from '../constants'
import { Loader2, ZoomIn, ZoomOut, Maximize2, Minimize2 } from 'lucide-react'
import { templates } from '@/config/wechat-templates'
import { useState, useRef, useEffect } from 'react'
import { useState, useRef, useEffect, useMemo } from 'react'
import { type CodeThemeId } from '@/config/code-themes'
import { initMermaid } from '@/lib/markdown/mermaid-init'
import '@/styles/code-themes.css'
interface EditorPreviewProps {
@ -28,6 +29,107 @@ export function EditorPreview({
const [zoom, setZoom] = useState(100)
const [isFullscreen, setIsFullscreen] = useState(false)
const isScrolling = useRef<boolean>(false)
const contentRef = useRef<string>('')
const renderTimeoutRef = useRef<number>()
const stableKeyRef = useRef(`preview-${Date.now()}`)
// Add useEffect to handle content changes and Mermaid initialization
useEffect(() => {
if (!isConverting && previewContent) {
// Clear any pending render timeout
if (renderTimeoutRef.current) {
window.clearTimeout(renderTimeoutRef.current)
}
// Set a new timeout to render after content has settled
renderTimeoutRef.current = window.setTimeout(() => {
requestAnimationFrame(() => {
initMermaid().catch(error => {
console.error('Failed to initialize mermaid:', error)
})
})
}, 100) // Wait for 100ms after last content change
}
// Cleanup timeout on unmount
return () => {
if (renderTimeoutRef.current) {
window.clearTimeout(renderTimeoutRef.current)
}
}
}, [isConverting, previewContent])
// Add useEffect to handle theme changes
useEffect(() => {
if (document.querySelector('div.mermaid')) {
if (renderTimeoutRef.current) {
window.clearTimeout(renderTimeoutRef.current)
}
renderTimeoutRef.current = window.setTimeout(() => {
requestAnimationFrame(() => {
initMermaid().catch(error => {
console.error('Failed to initialize mermaid after theme change:', error)
})
})
}, 100)
}
}, [codeTheme])
// Add useEffect to handle copy events
useEffect(() => {
const handleCopy = async (e: ClipboardEvent) => {
const selection = window.getSelection()
if (!selection) return
const selectedNode = selection.anchorNode?.parentElement
if (!selectedNode) return
// 检查是否在 mermaid 图表内
const mermaidElement = selectedNode.closest('.mermaid')
if (mermaidElement) {
e.preventDefault()
// 获取渲染后的 SVG 元素
const svgElement = mermaidElement.querySelector('svg')
if (svgElement) {
try {
// 创建一个临时的 div 来包含 SVG
const container = document.createElement('div')
container.appendChild(svgElement.cloneNode(true))
// 准备 HTML 和纯文本格式
const htmlContent = container.innerHTML
const plainText = mermaidElement.querySelector('.mermaid-source')?.textContent || ''
// 尝试复制为 HTML保留图表效果
await navigator.clipboard.write([
new ClipboardItem({
'text/html': new Blob([htmlContent], { type: 'text/html' }),
'text/plain': new Blob([plainText], { type: 'text/plain' })
})
])
} catch (error) {
// 如果复制 HTML 失败,退回到复制源代码
console.error('Failed to copy as HTML:', error)
const sourceText = mermaidElement.querySelector('.mermaid-source')?.textContent || ''
if (e.clipboardData) {
e.clipboardData.setData('text/plain', sourceText)
}
}
} else {
// 如果找不到 SVG退回到复制源代码
const sourceText = mermaidElement.querySelector('.mermaid-source')?.textContent || ''
if (e.clipboardData) {
e.clipboardData.setData('text/plain', sourceText)
}
}
}
}
document.addEventListener('copy', handleCopy)
return () => document.removeEventListener('copy', handleCopy)
}, [])
const handleZoomIn = () => {
setZoom(prev => Math.min(prev + 10, 200))
@ -47,6 +149,17 @@ export function EditorPreview({
}
}
// 使用 memo 包装预览内容
const PreviewContent = useMemo(() => (
<div className={cn(
"preview-content py-4",
"prose prose-slate dark:prose-invert max-w-none",
selectedTemplate && templates.find(t => t.id === selectedTemplate)?.styles
)}>
<div className="px-6" dangerouslySetInnerHTML={{ __html: previewContent }} />
</div>
), [previewContent, selectedTemplate])
return (
<div
ref={previewRef}
@ -57,7 +170,7 @@ export function EditorPreview({
selectedTemplate && templates.find(t => t.id === selectedTemplate)?.styles,
`code-theme-${codeTheme}`
)}
key={codeTheme}
key={stableKeyRef.current}
>
<div className="bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-b flex items-center justify-between z-10 sticky top-0 left-0 right-0">
<div className="text-sm text-muted-foreground px-2 py-1"></div>
@ -138,15 +251,7 @@ export function EditorPreview({
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
<span className="text-sm text-muted-foreground">...</span>
</div>
) : (
<div className={cn(
"preview-content py-4",
"prose prose-slate dark:prose-invert max-w-none",
selectedTemplate && templates.find(t => t.id === selectedTemplate)?.styles
)}>
<div className="px-6" dangerouslySetInnerHTML={{ __html: previewContent }} />
</div>
)}
) : PreviewContent}
</div>
</div>
</div>

View File

@ -0,0 +1,198 @@
import React from "react"
declare global {
interface Window {
mermaid: {
init: (config: any, nodes: NodeListOf<Element>) => Promise<void>
}
}
}
type ToastFunction = {
(props: {
title?: string
description?: string
action?: React.ReactElement
duration?: number
variant?: "default" | "destructive"
}): void
}
interface CopyHandlerOptions {
toast: ToastFunction
}
/**
* Mermaid
*/
async function handleMermaidCopy(mermaidElement: Element): Promise<boolean> {
const svgElement = mermaidElement.querySelector('svg')
if (!svgElement) return false
// 获取原始的 Mermaid 代码
const originalCode = mermaidElement.getAttribute('data-source') || ''
// 创建一个临时的 canvas 来转换 SVG 为图片
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
if (!ctx) throw new Error('Failed to get canvas context')
// 设置 canvas 尺寸为 SVG 的实际尺寸
const svgRect = svgElement.getBoundingClientRect()
canvas.width = svgRect.width
canvas.height = svgRect.height
// 创建图片对象
const img = new Image()
const svgData = new XMLSerializer().serializeToString(svgElement)
const svgBlob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' })
const url = URL.createObjectURL(svgBlob)
// 等待图片加载
await new Promise((resolve, reject) => {
img.onload = resolve
img.onerror = reject
img.src = url
})
// 绘制图片到 canvas
ctx.fillStyle = '#FFFFFF'
ctx.fillRect(0, 0, canvas.width, canvas.height)
ctx.drawImage(img, 0, 0)
// 转换为 PNG
const pngBlob = await new Promise<Blob>((resolve) => {
canvas.toBlob((blob) => resolve(blob!), 'image/png', 1.0)
})
// 清理
URL.revokeObjectURL(url)
// 复制为图片、HTML 和原始文本
await navigator.clipboard.write([
new ClipboardItem({
'image/png': pngBlob,
'text/html': new Blob([svgElement.outerHTML], { type: 'text/html' }),
'text/plain': new Blob([originalCode], { type: 'text/plain' })
})
])
return true
}
/**
*
*/
async function handlePreviewCopy(previewContent: string): Promise<boolean> {
// 创建临时容器来处理内容
const tempDiv = document.createElement('div')
tempDiv.innerHTML = previewContent
// 等待所有 Mermaid 图表渲染完成
const mermaidElements = tempDiv.querySelectorAll('.mermaid')
if (mermaidElements.length > 0) {
try {
// 确保 mermaid 已经初始化
if (typeof window.mermaid !== 'undefined') {
// 重新初始化所有图表
await window.mermaid.init(undefined, mermaidElements)
}
} catch (error) {
console.error('Mermaid rendering error:', error)
}
}
// 处理所有的 Mermaid 图表
const mermaidDiagrams = tempDiv.querySelectorAll('.mermaid svg')
mermaidDiagrams.forEach(svg => {
const container = svg.closest('.mermaid')
if (container) {
// 保存原始的 SVG 内容
const originalSvg = svg.cloneNode(true)
container.innerHTML = ''
container.appendChild(originalSvg)
}
})
// 获取处理后的 HTML 内容
const htmlContent = tempDiv.innerHTML
const plainText = tempDiv.textContent || tempDiv.innerText
// 复制到剪贴板
await navigator.clipboard.write([
new ClipboardItem({
'text/html': new Blob([htmlContent], { type: 'text/html' }),
'text/plain': new Blob([plainText], { type: 'text/plain' })
})
])
return true
}
/**
*
*/
export async function copyHandler(
selection: Selection | null,
previewContent: string,
options: CopyHandlerOptions
): Promise<boolean> {
const { toast } = options
try {
// 检查是否有选中的 Mermaid 图表
if (selection && !selection.isCollapsed) {
const selectedNode = selection.anchorNode?.parentElement
if (selectedNode) {
const mermaidElement = selectedNode.closest('.mermaid')
if (mermaidElement) {
const success = await handleMermaidCopy(mermaidElement)
if (success) {
toast({
title: "复制成功",
description: "已复制图表(支持粘贴为图片或源代码)",
duration: 2000
})
return true
}
}
}
}
// 复制整个预览内容
const success = await handlePreviewCopy(previewContent)
if (success) {
toast({
title: "复制成功",
description: "已复制预览内容",
duration: 2000
})
return true
}
return false
} catch (err) {
console.error('Copy error:', err)
try {
await navigator.clipboard.writeText(previewContent)
toast({
title: "复制成功",
description: "已复制预览内容(仅文本)",
duration: 2000
})
return true
} catch (fallbackErr) {
toast({
variant: "destructive",
title: "复制失败",
description: "无法访问剪贴板,请检查浏览器权限",
action: (
<button onClick={() => window.location.reload()} className="hover:bg-secondary">
</button>
)
})
return false
}
}
}

View File

@ -0,0 +1,177 @@
import mermaid from 'mermaid'
import type { MermaidConfig } from 'mermaid'
let initialized = false
// Initialize mermaid with default configuration
const config: MermaidConfig = {
startOnLoad: false,
theme: document.documentElement.classList.contains('dark') ? 'dark' : 'default',
securityLevel: 'loose' as const,
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Microsoft YaHei", sans-serif',
themeVariables: {
'fontSize': '16px',
'fontFamily': '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Microsoft YaHei", sans-serif',
'primaryColor': document.documentElement.classList.contains('dark') ? '#7c3aed' : '#4f46e5',
'primaryTextColor': document.documentElement.classList.contains('dark') ? '#fff' : '#000',
'primaryBorderColor': document.documentElement.classList.contains('dark') ? '#7c3aed' : '#4f46e5',
'lineColor': document.documentElement.classList.contains('dark') ? '#666' : '#999',
'textColor': document.documentElement.classList.contains('dark') ? '#fff' : '#333'
},
pie: {
textPosition: 0.75,
useMaxWidth: true
}
}
// 缓存已渲染的图表
const renderedDiagrams = new Map<string, string>()
// Generate a valid ID for mermaid diagrams
function generateMermaidId() {
return `mermaid-${Math.floor(Math.random() * 100000)}`
}
// Clean and format mermaid definition
function cleanMermaidDefinition(text: string): string {
return text
.replace(/&quot;/g, '"')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&nbsp;/g, ' ')
.replace(/[""]/g, '"') // 替换中文引号
.replace(//g, ':') // 替换中文冒号
.split('\n')
.map(line => line.trim())
.filter(line => line) // 移除空行
.join('\n')
.trim()
}
// Process pie chart definition
function processPieChart(definition: string): string {
// 如果已经有 showData直接返回
if (definition.includes('showData')) {
return definition
}
// 移除开头的 pie 并添加 showData
const lines = definition.split('\n')
if (lines[0].trim() === 'pie') {
lines[0] = 'pie showData'
} else {
lines.unshift('pie showData')
}
return lines.join('\n')
}
// Function to initialize Mermaid diagrams
export async function initMermaid() {
try {
// 确保只初始化一次
if (!initialized) {
mermaid.initialize(config)
initialized = true
}
// 查找所有 mermaid 图表
const mermaidDivs = Array.from(document.querySelectorAll('div.mermaid'))
// 处理每个图表
for (const element of mermaidDivs) {
try {
const graphDefinition = element.textContent || ''
if (!graphDefinition.trim()) continue
const cleanDefinition = cleanMermaidDefinition(graphDefinition)
const cacheKey = cleanDefinition
// 检查缓存
const cachedSvg = renderedDiagrams.get(cacheKey)
if (cachedSvg && element.getAttribute('data-source') === cleanDefinition) {
// 如果有缓存且内容没变,直接使用缓存
const container = document.createElement('div')
container.style.width = '100%'
container.style.display = 'flex'
container.style.justifyContent = 'center'
container.style.margin = '1em 0'
container.innerHTML = cachedSvg
// 保存原始内容用于复制
const originalContent = document.createElement('div')
originalContent.style.display = 'none'
originalContent.className = 'mermaid-source'
originalContent.textContent = `\`\`\`mermaid
${graphDefinition.trim()}
\`\`\``
element.innerHTML = ''
element.appendChild(originalContent)
element.appendChild(container)
element.setAttribute('data-processed', 'true')
element.setAttribute('data-source', cleanDefinition)
continue
}
// 创建容器
const container = document.createElement('div')
container.style.width = '100%'
container.style.display = 'flex'
container.style.justifyContent = 'center'
container.style.margin = '1em 0'
try {
// 处理不同类型的图表
let finalDefinition = cleanDefinition
if (cleanDefinition.includes('pie')) {
finalDefinition = processPieChart(cleanDefinition)
}
// 渲染图表
const id = generateMermaidId()
const { svg } = await mermaid.render(id, finalDefinition)
// 缓存渲染结果
renderedDiagrams.set(cacheKey, svg)
// 保存原始内容用于复制
const originalContent = document.createElement('div')
originalContent.style.display = 'none'
originalContent.className = 'mermaid-source'
originalContent.textContent = `\`\`\`mermaid
${graphDefinition.trim()}
\`\`\``
// 更新 DOM
container.innerHTML = svg
element.innerHTML = ''
element.appendChild(originalContent)
element.appendChild(container)
element.setAttribute('data-processed', 'true')
element.setAttribute('data-source', finalDefinition)
// 添加复制事件处理
container.addEventListener('copy', (e) => {
e.preventDefault()
const sourceText = element.querySelector('.mermaid-source')?.textContent || ''
if (e.clipboardData) {
e.clipboardData.setData('text/plain', sourceText)
}
})
} catch (renderError) {
console.error('Mermaid render error:', renderError)
console.log('Graph definition:', cleanDefinition)
element.innerHTML = `<pre><code class="language-mermaid">${cleanDefinition}</code></pre>`
element.setAttribute('data-processed', 'true')
element.setAttribute('data-source', cleanDefinition)
}
} catch (error) {
console.error('Mermaid processing error:', error)
}
}
} catch (error) {
console.error('Mermaid initialization error:', error)
}
}

View File

@ -12,6 +12,13 @@ interface LatexBlockToken extends Tokens.Generic {
text: string
}
// 自定义 Mermaid 块的 Token 类型
interface MermaidBlockToken extends Tokens.Generic {
type: 'mermaidBlock'
raw: string
text: string
}
export class MarkdownRenderer {
private renderer: typeof marked.Renderer.prototype
private options: RendererOptions
@ -21,6 +28,7 @@ export class MarkdownRenderer {
this.renderer = new marked.Renderer()
this.initializeRenderer()
this.initializeLatexExtension()
this.initializeMermaidExtension()
}
private initializeLatexExtension() {
@ -69,6 +77,64 @@ export class MarkdownRenderer {
marked.use({ extensions: [latexBlockTokenizer] })
}
private initializeMermaidExtension() {
// 添加 Mermaid 块的 tokenizer
const mermaidBlockTokenizer: TokenizerAndRendererExtension = {
name: 'mermaidBlock',
level: 'block',
start(src: string) {
// 支持两种格式:```mermaid 和 ``` 后面跟 mermaid 内容
return src.match(/^```(?:mermaid\s*$|[\s\n]*pie\s+|[\s\n]*graph\s+|[\s\n]*sequenceDiagram\s+|[\s\n]*gantt\s+|[\s\n]*classDiagram\s+|[\s\n]*flowchart\s+)/)?.index
},
tokenizer(src: string) {
// 匹配两种格式
const rule = /^```(?:mermaid\s*\n)?([\s\S]*?)\n*```(?:\s*\n|$)/
const match = rule.exec(src)
if (match) {
const content = match[1].trim()
// 检查内容是否是 mermaid 图表
if (content.match(/^(?:pie\s+|graph\s+|sequenceDiagram\s+|gantt\s+|classDiagram\s+|flowchart\s+)/)) {
// 如果是饼图,添加 showData 选项
const processedContent = content.startsWith('pie')
? `pie showData\n${content.replace(/^pie\s*/, '').trim()}`
: content
return {
type: 'mermaidBlock',
raw: match[0],
tokens: [],
text: processedContent
}
}
}
},
renderer: (token) => {
try {
const mermaidStyle = (this.options.block?.mermaid || {})
const style = {
...mermaidStyle,
display: 'block',
margin: '1em 0',
textAlign: 'center',
background: 'transparent' // 确保背景透明以适应主题
}
const styleStr = cssPropertiesToString(style)
// Generate unique ID for the diagram
const id = `mermaid-${Math.random().toString(36).substring(2)}`
// Since we can't use async/await in the renderer, we'll return a div that will be rendered by client-side JavaScript
return `<div${styleStr ? ` style="${styleStr}"` : ''} class="mermaid">${token.text}</div>`
} catch (error) {
console.error('Mermaid rendering error:', error)
return `<pre><code class="language-mermaid">${token.text}</code></pre>`
}
}
}
// 注册扩展
marked.use({ extensions: [mermaidBlockTokenizer] })
}
private initializeRenderer() {
// 重写 text 方法来处理行内 LaTeX 公式
this.renderer.text = (token: Tokens.Text | Tokens.Escape) => {

View File

@ -111,6 +111,7 @@ export interface RendererOptions {
thead?: StyleOptions
footnotes?: StyleOptions
latex?: StyleOptions
mermaid?: StyleOptions
}
inline?: {
strong?: StyleOptions