add Mermaid 流程图的支持
This commit is contained in:
parent
1a02300026
commit
5cf1777c00
@ -34,6 +34,7 @@
|
|||||||
"katex": "^0.16.21",
|
"katex": "^0.16.21",
|
||||||
"lucide-react": "^0.474.0",
|
"lucide-react": "^0.474.0",
|
||||||
"marked": "^15.0.6",
|
"marked": "^15.0.6",
|
||||||
|
"mermaid": "^11.4.1",
|
||||||
"next": "14.1.0",
|
"next": "14.1.0",
|
||||||
"next-themes": "^0.4.4",
|
"next-themes": "^0.4.4",
|
||||||
"prismjs": "^1.29.0",
|
"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 { useLocalStorage } from '@/hooks/use-local-storage'
|
||||||
import { codeThemes, type CodeThemeId } from '@/config/code-themes'
|
import { codeThemes, type CodeThemeId } from '@/config/code-themes'
|
||||||
import '@/styles/code-themes.css'
|
import '@/styles/code-themes.css'
|
||||||
|
import { copyHandler } from './utils/copy-handler'
|
||||||
|
|
||||||
// 计算阅读时间(假设每分钟阅读300字)
|
// 计算阅读时间(假设每分钟阅读300字)
|
||||||
const calculateReadingTime = (text: string): string => {
|
const calculateReadingTime = (text: string): string => {
|
||||||
@ -185,46 +186,15 @@ export default function WechatEditor() {
|
|||||||
|
|
||||||
// 处理复制
|
// 处理复制
|
||||||
const handleCopy = useCallback(async () => {
|
const handleCopy = useCallback(async () => {
|
||||||
try {
|
// 获取预览容器中的实际内容
|
||||||
const htmlContent = getPreviewContent()
|
const previewContainer = document.querySelector('.preview-content')
|
||||||
const tempDiv = document.createElement('div')
|
if (!previewContainer) {
|
||||||
tempDiv.innerHTML = htmlContent
|
return copyHandler(window.getSelection(), previewContent, { toast })
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}, [previewContent, toast, getPreviewContent])
|
// 使用实际渲染后的内容
|
||||||
|
return copyHandler(window.getSelection(), previewContainer.innerHTML, { toast })
|
||||||
|
}, [previewContent, toast])
|
||||||
|
|
||||||
// 手动保存
|
// 手动保存
|
||||||
const handleSave = useCallback(() => {
|
const handleSave = useCallback(() => {
|
||||||
|
@ -2,8 +2,9 @@ import { cn } from '@/lib/utils'
|
|||||||
import { PREVIEW_SIZES, type PreviewSize } from '../constants'
|
import { PREVIEW_SIZES, type PreviewSize } from '../constants'
|
||||||
import { Loader2, ZoomIn, ZoomOut, Maximize2, Minimize2 } from 'lucide-react'
|
import { Loader2, ZoomIn, ZoomOut, Maximize2, Minimize2 } from 'lucide-react'
|
||||||
import { templates } from '@/config/wechat-templates'
|
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 { type CodeThemeId } from '@/config/code-themes'
|
||||||
|
import { initMermaid } from '@/lib/markdown/mermaid-init'
|
||||||
import '@/styles/code-themes.css'
|
import '@/styles/code-themes.css'
|
||||||
|
|
||||||
interface EditorPreviewProps {
|
interface EditorPreviewProps {
|
||||||
@ -28,6 +29,107 @@ export function EditorPreview({
|
|||||||
const [zoom, setZoom] = useState(100)
|
const [zoom, setZoom] = useState(100)
|
||||||
const [isFullscreen, setIsFullscreen] = useState(false)
|
const [isFullscreen, setIsFullscreen] = useState(false)
|
||||||
const isScrolling = useRef<boolean>(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 = () => {
|
const handleZoomIn = () => {
|
||||||
setZoom(prev => Math.min(prev + 10, 200))
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={previewRef}
|
ref={previewRef}
|
||||||
@ -57,7 +170,7 @@ export function EditorPreview({
|
|||||||
selectedTemplate && templates.find(t => t.id === selectedTemplate)?.styles,
|
selectedTemplate && templates.find(t => t.id === selectedTemplate)?.styles,
|
||||||
`code-theme-${codeTheme}`
|
`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="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>
|
<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" />
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
<span className="text-sm text-muted-foreground">正在生成预览...</span>
|
<span className="text-sm text-muted-foreground">正在生成预览...</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : PreviewContent}
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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
|
text: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 自定义 Mermaid 块的 Token 类型
|
||||||
|
interface MermaidBlockToken extends Tokens.Generic {
|
||||||
|
type: 'mermaidBlock'
|
||||||
|
raw: string
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
export class MarkdownRenderer {
|
export class MarkdownRenderer {
|
||||||
private renderer: typeof marked.Renderer.prototype
|
private renderer: typeof marked.Renderer.prototype
|
||||||
private options: RendererOptions
|
private options: RendererOptions
|
||||||
@ -21,6 +28,7 @@ export class MarkdownRenderer {
|
|||||||
this.renderer = new marked.Renderer()
|
this.renderer = new marked.Renderer()
|
||||||
this.initializeRenderer()
|
this.initializeRenderer()
|
||||||
this.initializeLatexExtension()
|
this.initializeLatexExtension()
|
||||||
|
this.initializeMermaidExtension()
|
||||||
}
|
}
|
||||||
|
|
||||||
private initializeLatexExtension() {
|
private initializeLatexExtension() {
|
||||||
@ -69,6 +77,64 @@ export class MarkdownRenderer {
|
|||||||
marked.use({ extensions: [latexBlockTokenizer] })
|
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() {
|
private initializeRenderer() {
|
||||||
// 重写 text 方法来处理行内 LaTeX 公式
|
// 重写 text 方法来处理行内 LaTeX 公式
|
||||||
this.renderer.text = (token: Tokens.Text | Tokens.Escape) => {
|
this.renderer.text = (token: Tokens.Text | Tokens.Escape) => {
|
||||||
|
@ -111,6 +111,7 @@ export interface RendererOptions {
|
|||||||
thead?: StyleOptions
|
thead?: StyleOptions
|
||||||
footnotes?: StyleOptions
|
footnotes?: StyleOptions
|
||||||
latex?: StyleOptions
|
latex?: StyleOptions
|
||||||
|
mermaid?: StyleOptions
|
||||||
}
|
}
|
||||||
inline?: {
|
inline?: {
|
||||||
strong?: StyleOptions
|
strong?: StyleOptions
|
||||||
|
Loading…
Reference in New Issue
Block a user