Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 94 additions & 58 deletions src/app/core/main/editor/markdown/mermaid-extension.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,9 @@ import { useState, useEffect, useRef, useCallback } from 'react'
import { useTranslations } from 'next-intl'
import mermaid from 'mermaid'
import { Code, Check } from 'lucide-react'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'

import { Button } from '@/components/ui/button'
import { MermaidLightbox } from './mermaid-lightbox'

// Initialize mermaid
mermaid.initialize({
Expand Down Expand Up @@ -53,54 +48,102 @@ function MermaidDiagramView({ node, updateAttributes }: ReactNodeViewProps) {
const t = useTranslations('editor.mermaid')

const [isEditing, setIsEditing] = useState(false)
const [lightboxOpen, setLightboxOpen] = useState(false)
const [code, setCode] = useState(node.attrs.code || '')
const [diagramType, setDiagramType] = useState(node.attrs.type || 'flowchart')
const [svg, setSvg] = useState('')
const [pngUrl, setPngUrl] = useState('')
const [error, setError] = useState<string | null>(null)
const containerRef = useRef<HTMLDivElement>(null)
const codeRef = useRef(code)
codeRef.current = code

const svgToPng = useCallback((svgStr: string): Promise<string> => {
return new Promise((resolve, reject) => {
// 解析 SVG,补全缺失的 width/height(mermaid 有时只有 viewBox)
const parser = new DOMParser()
const doc = parser.parseFromString(svgStr, 'image/svg+xml')
const svgEl = doc.documentElement
let w = parseFloat(svgEl.getAttribute('width') || '0')
let h = parseFloat(svgEl.getAttribute('height') || '0')
if (!w || !h) {
const vb = svgEl.getAttribute('viewBox')?.split(/[\s,]+/)
w = parseFloat(vb?.[2] || '800')
h = parseFloat(vb?.[3] || '600')
svgEl.setAttribute('width', String(w))
svgEl.setAttribute('height', String(h))
}
const fixedSvg = new XMLSerializer().serializeToString(doc)
const url = `data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(fixedSvg)))}`
const img = new Image()
img.onload = () => {
const scale = 2
const pw = (img.naturalWidth || w) * scale
const ph = (img.naturalHeight || h) * scale
const canvas = document.createElement('canvas')
canvas.width = pw
canvas.height = ph
const ctx = canvas.getContext('2d')!
ctx.scale(scale, scale)
ctx.fillStyle = '#ffffff'
ctx.fillRect(0, 0, pw / scale, ph / scale)
ctx.drawImage(img, 0, 0, pw / scale, ph / scale)
resolve(canvas.toDataURL('image/png'))
}
img.onerror = () => reject(new Error('svg load failed'))
img.src = url
})
}, [])

const renderDiagram = useCallback(async () => {
if (!code.trim()) {
setSvg('')
const renderDiagram = useCallback(async (src: string) => {
if (!src.trim()) {
setPngUrl('')
setError(null)
return
}

setError(null)

try {
mermaid.parse(code)
await mermaid.parse(src)
const id = `mermaid-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
const { svg: renderedSvg } = await mermaid.render(id, code)
setSvg(renderedSvg)
const { svg: renderedSvg } = await mermaid.render(id, src)
const png = await svgToPng(renderedSvg)
setPngUrl(png)
} catch (err) {
const message = err instanceof Error ? err.message : t('renderError')
setError(message)
setSvg('')
setPngUrl('')
}
}, [code, t])
}, [t, svgToPng])

// 初始渲染
useEffect(() => {
renderDiagram()
if (node.attrs.code) renderDiagram(node.attrs.code)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])

// 自动识别图表类型
useEffect(() => {
const detected = detectDiagramType(code)
if (detected !== diagramType) {
setDiagramType(detected)
}
if (detected !== diagramType) setDiagramType(detected)
}, [code, diagramType])

// 退出编辑模式后刷新预览
// 编辑模式下实时预览(防抖 600ms)
useEffect(() => {
if (!isEditing) {
renderDiagram()
}
}, [isEditing])
if (!isEditing) return
const src = codeRef.current
const timer = setTimeout(() => renderDiagram(src), 600)
return () => clearTimeout(timer)
}, [code, isEditing, renderDiagram])

// 退出编辑后渲染
useEffect(() => {
if (!isEditing) renderDiagram(codeRef.current)
}, [isEditing, renderDiagram])

const handleUpdate = () => {
updateAttributes({ code, type: diagramType })
const current = codeRef.current
updateAttributes({ code: current, type: diagramType })
setIsEditing(false)
renderDiagram(current)
}

const handleKeyDown = (e: React.KeyboardEvent) => {
Expand All @@ -120,24 +163,29 @@ function MermaidDiagramView({ node, updateAttributes }: ReactNodeViewProps) {

return (
<NodeViewWrapper className="mermaid-diagram-wrapper my-4">
{lightboxOpen && pngUrl && (
<MermaidLightbox pngUrl={pngUrl} onClose={() => setLightboxOpen(false)} />
)}

{/* Preview Mode */}
{!isEditing && (
<div
className="mermaid-preview rounded-lg border border-border bg-card overflow-x-auto cursor-pointer"
onClick={() => setIsEditing(true)}
className={`mermaid-preview relative rounded-lg border border-border bg-white overflow-x-auto ${pngUrl ? 'cursor-zoom-in' : 'cursor-text'}`}
onClick={() => pngUrl ? setLightboxOpen(true) : setIsEditing(true)}
>
{error ? (
<div className="p-4 text-red-500 text-sm">
<p className="font-medium">{t('renderError')}</p>
<p className="mt-1">{error}</p>
<p className="mt-2 text-muted-foreground">{t('clickToEdit')}</p>
</div>
) : svg ? (
) : pngUrl ? (
<div
ref={containerRef}
className="mermaid-svg p-4 flex justify-center"
dangerouslySetInnerHTML={{ __html: svg }}
/>
className="p-4 flex justify-center"
>
<img src={pngUrl} alt="mermaid diagram" className="max-w-full h-auto" />
</div>
) : (
<div className="p-8 text-center text-muted-foreground">
<span>{t('clickToAdd')}</span>
Expand All @@ -152,6 +200,7 @@ function MermaidDiagramView({ node, updateAttributes }: ReactNodeViewProps) {
e.stopPropagation()
setIsEditing(true)
}}
title={t('clickToEdit')}
>
<Code className="size-4" />
</Button>
Expand All @@ -162,38 +211,25 @@ function MermaidDiagramView({ node, updateAttributes }: ReactNodeViewProps) {
{/* Edit Mode */}
{isEditing && (
<div className="mermaid-editor rounded-lg border border-border bg-card">
<div className="flex items-center gap-2 p-2 border-b bg-muted/50">
<Select value={diagramType} onValueChange={setDiagramType}>
<SelectTrigger className="w-35 h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DIAGRAM_TYPES.map((item) => (
<SelectItem key={item.type} value={item.type}>
{getLabel(item.type)}
</SelectItem>
))}
</SelectContent>
</Select>

<div className="flex-1" />

<Button
variant="ghost"
size="icon"
onClick={handleUpdate}
title={t('done')}
>
<div className="flex items-center justify-between px-3 py-1.5 border-b bg-muted/50">
<span className="text-xs text-muted-foreground font-mono">mermaid</span>
<Button variant="ghost" size="icon" onClick={handleUpdate} title={t('done')}>
<Check className="size-4" />
</Button>
</div>

<textarea
autoFocus
value={code}
onChange={(e) => setCode(e.target.value)}
onChange={(e) => {
let val = e.target.value
const fenceMatch = val.match(/^```mermaid\r?\n([\s\S]*?)\r?\n```\s*$/)
if (fenceMatch) val = fenceMatch[1]
setCode(val)
}}
onKeyDown={handleKeyDown}
className="w-full h-48 p-3 font-mono text-sm bg-background resize-y focus:outline-none"
placeholder={t('placeholder')}
placeholder="粘贴 mermaid 代码,自动识别类型并渲染"
spellCheck={false}
/>

Expand Down
158 changes: 158 additions & 0 deletions src/app/core/main/editor/markdown/mermaid-lightbox.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
'use client'

import { useEffect, useRef, useCallback, useState } from 'react'
import { X, Copy, Check } from 'lucide-react'
import { Button } from '@/components/ui/button'

interface MermaidLightboxProps {
pngUrl: string
onClose: () => void
}

export function MermaidLightbox({ pngUrl, onClose }: MermaidLightboxProps) {
const containerRef = useRef<HTMLDivElement>(null)
const imgRef = useRef<HTMLImageElement>(null)
const scaleRef = useRef(1)
const translateRef = useRef({ x: 0, y: 0 })
const isDraggingRef = useRef(false)
const lastPosRef = useRef({ x: 0, y: 0 })
const [copied, setCopied] = useState(false)

const applyTransform = useCallback(() => {
if (!imgRef.current) return
const { x, y } = translateRef.current
imgRef.current.style.transform = `translate(${x}px, ${y}px) scale(${scaleRef.current})`
}, [])

const fitToScreen = useCallback(() => {
if (!imgRef.current || !containerRef.current) return
const imgW = imgRef.current.naturalWidth
const imgH = imgRef.current.naturalHeight
if (!imgW || !imgH) return
const padW = containerRef.current.clientWidth - 80
const padH = containerRef.current.clientHeight - 80
scaleRef.current = Math.min(padW / imgW, padH / imgH, 1)
translateRef.current = { x: 0, y: 0 }
applyTransform()
}, [applyTransform])

// Fit on mount after image loads
const handleImgLoad = useCallback(() => {
fitToScreen()
}, [fitToScreen])

// Escape to close
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [onClose])

// Wheel zoom centered on cursor
useEffect(() => {
const el = containerRef.current
if (!el) return
const handleWheel = (e: WheelEvent) => {
e.preventDefault()
const rect = el.getBoundingClientRect()
const mouseX = e.clientX - rect.left - rect.width / 2
const mouseY = e.clientY - rect.top - rect.height / 2
// 用 deltaY 实际值计算,单步最多缩放 ±15%,避免 trackpad 飞速缩放
const factor = -e.deltaY * 0.003
const clamped = Math.max(-0.2, Math.min(0.2, factor))
const newScale = Math.min(10, Math.max(0.1, scaleRef.current * (1 + clamped)))
const ratio = newScale / scaleRef.current
translateRef.current = {
x: mouseX + (translateRef.current.x - mouseX) * ratio,
y: mouseY + (translateRef.current.y - mouseY) * ratio,
}
scaleRef.current = newScale
applyTransform()
}
el.addEventListener('wheel', handleWheel, { passive: false })
return () => el.removeEventListener('wheel', handleWheel)
}, [applyTransform])

// Drag to pan
const handleMouseDown = useCallback((e: React.MouseEvent) => {
if (e.button !== 0) return
isDraggingRef.current = true
lastPosRef.current = { x: e.clientX, y: e.clientY }
e.preventDefault()
}, [])

const handleMouseMove = useCallback((e: React.MouseEvent) => {
if (!isDraggingRef.current) return
translateRef.current = {
x: translateRef.current.x + (e.clientX - lastPosRef.current.x),
y: translateRef.current.y + (e.clientY - lastPosRef.current.y),
}
lastPosRef.current = { x: e.clientX, y: e.clientY }
applyTransform()
}, [applyTransform])

const handleMouseUp = useCallback(() => {
isDraggingRef.current = false
}, [])

// Copy PNG to clipboard
const handleCopy = useCallback(async () => {
try {
const res = await fetch(pngUrl)
const blob = await res.blob()
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })])
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch (err) {
console.error('Copy failed:', err)
}
}, [pngUrl])

return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm"
onClick={onClose}
>
{/* Controls */}
<div
className="absolute top-4 right-4 flex items-center gap-2 z-10"
onClick={e => e.stopPropagation()}
>
<Button variant="secondary" size="icon" onClick={handleCopy} title="复制为 PNG">
{copied ? <Check className="size-4 text-green-500" /> : <Copy className="size-4" />}
</Button>
<Button variant="secondary" size="icon" onClick={onClose} title="关闭">
<X className="size-4" />
</Button>
</div>

{/* Image area */}
<div
ref={containerRef}
className="w-full h-full flex items-center justify-center overflow-hidden cursor-grab active:cursor-grabbing select-none"
onClick={e => e.stopPropagation()}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
onDoubleClick={fitToScreen}
>
<img
ref={imgRef}
src={pngUrl}
alt="mermaid diagram"
onLoad={handleImgLoad}
draggable={false}
className="rounded shadow-lg"
style={{ willChange: 'transform', transformOrigin: 'center center' }}
/>
</div>

<p className="absolute bottom-4 left-1/2 -translate-x-1/2 text-xs text-white/40 pointer-events-none select-none">
滚轮缩放 · 拖拽平移 · 双击适配
</p>
</div>
)
}
Loading