add Mermaid 流程图的支持
This commit is contained in:
parent
1a02300026
commit
5cf1777c00
@ -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",
|
||||
|
980
pnpm-lock.yaml
980
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
@ -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(() => {
|
||||
|
@ -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>
|
||||
|
198
src/components/editor/utils/copy-handler.tsx
Normal file
198
src/components/editor/utils/copy-handler.tsx
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
177
src/lib/markdown/mermaid-init.ts
Normal file
177
src/lib/markdown/mermaid-init.ts
Normal 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(/"/g, '"')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/ /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)
|
||||
}
|
||||
}
|
@ -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) => {
|
||||
|
@ -111,6 +111,7 @@ export interface RendererOptions {
|
||||
thead?: StyleOptions
|
||||
footnotes?: StyleOptions
|
||||
latex?: StyleOptions
|
||||
mermaid?: StyleOptions
|
||||
}
|
||||
inline?: {
|
||||
strong?: StyleOptions
|
||||
|
Loading…
Reference in New Issue
Block a user