From 634fb1953ca700e263a388b4dc181008d8ab0e3c Mon Sep 17 00:00:00 2001 From: tju-tomorrow Date: Sat, 20 Jun 2026 17:31:42 +0800 Subject: [PATCH 1/3] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E7=B2=98=E8=B4=B4?= =?UTF-8?q?=20ChatGPT=20=E5=86=85=E5=AE=B9=E6=97=B6=E5=87=BA=E7=8E=B0?= =?UTF-8?q?=E9=87=8D=E5=A4=8D=E4=BB=A3=E7=A0=81=E5=9D=97=E7=9A=84=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ChatGPT 代码块 HTML 结构为外层
 嵌套多层 div 再嵌套内层 
,
Tiptap 会将两层 
 各创建一个代码块导致重复。
通过 transformPastedHTML 将嵌套结构展平为单个干净的 

---
 .../main/editor/markdown/tiptap-editor.tsx     | 18 ++++++++++++++++++
 1 file changed, 18 insertions(+)

diff --git a/src/app/core/main/editor/markdown/tiptap-editor.tsx b/src/app/core/main/editor/markdown/tiptap-editor.tsx
index 326764d8..ff34a9c5 100644
--- a/src/app/core/main/editor/markdown/tiptap-editor.tsx
+++ b/src/app/core/main/editor/markdown/tiptap-editor.tsx
@@ -1344,6 +1344,24 @@ export function TipTapEditor({
     content: initialContent,
     contentType: 'markdown',
     editorProps: {
+      transformPastedHTML(html) {
+        const parser = new DOMParser()
+        const doc = parser.parseFromString(html, 'text/html')
+        // ChatGPT 代码块结构:外层 
 → 多层 div → 内层 

+        // Tiptap 会把外层和内层 
 各创建一个代码块,导致重复。
+        // 将每个嵌套结构展平成单个干净的 
。
+        doc.querySelectorAll('pre').forEach(pre => {
+          const innerPre = pre.querySelector('pre')
+          if (!innerPre) return // 已经是干净结构,跳过
+          const code = pre.querySelector('code')
+          const cleanPre = doc.createElement('pre')
+          const cleanCode = doc.createElement('code')
+          cleanCode.textContent = code?.textContent ?? ''
+          cleanPre.appendChild(cleanCode)
+          pre.parentNode?.replaceChild(cleanPre, pre)
+        })
+        return doc.body.innerHTML
+      },
       dragCopies: (event) => {
         if (isEditorDragHandleDraggingRef.current) {
           return false

From 5839de70cc2a9561e89169b8ea7f0920df6c1f70 Mon Sep 17 00:00:00 2001
From: tju-tomorrow 
Date: Sat, 20 Jun 2026 21:31:45 +0800
Subject: [PATCH 2/3] =?UTF-8?q?feat:=20Mermaid=20=E5=9B=BE=E8=A1=A8?=
 =?UTF-8?q?=E6=B8=B2=E6=9F=93=E5=8D=87=E7=BA=A7=20=E2=80=94=E2=80=94=20?=
 =?UTF-8?q?=E7=81=AF=E7=AE=B1=E3=80=81PNG=20=E9=A2=84=E8=A7=88=E3=80=81?=
 =?UTF-8?q?=E8=87=AA=E5=8A=A8=E8=AF=86=E5=88=AB=E7=B1=BB=E5=9E=8B?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

- 图表渲染改为 SVG→PNG(白底、2x 高清),解决深色背景下不可见问题
- 点击图表打开全屏灯箱,支持滚轮缩放(0.1x~10x)、拖拽平移、双击适配
- 灯箱右上角支持复制 PNG 到剪贴板,修复 Tauri webview canvas SecurityError
- 自动检测 mermaid 图表类型,去掉手动选择下拉框
- /斜杠命令从 8 个独立命令合并为单一 Mermaid 命令
- 粘贴时自动剥离 ```mermaid ``` 围栏,直接渲染内部代码
- 修复 mermaid v11 parse() 未 await 导致错误被静默忽略的问题
- 用 codeRef 消除 effect stale closure,确保退出编辑后正确渲染

Co-Authored-By: Claude Sonnet 4.6 
---
 .../editor/markdown/mermaid-extension.tsx     | 152 ++++++++++-------
 .../main/editor/markdown/mermaid-lightbox.tsx | 158 ++++++++++++++++++
 .../markdown/slash-command/suggestion.tsx     |  67 +-------
 3 files changed, 260 insertions(+), 117 deletions(-)
 create mode 100644 src/app/core/main/editor/markdown/mermaid-lightbox.tsx

diff --git a/src/app/core/main/editor/markdown/mermaid-extension.tsx b/src/app/core/main/editor/markdown/mermaid-extension.tsx
index 3d60a79c..b2db597e 100644
--- a/src/app/core/main/editor/markdown/mermaid-extension.tsx
+++ b/src/app/core/main/editor/markdown/mermaid-extension.tsx
@@ -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({
@@ -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(null)
   const containerRef = useRef(null)
+  const codeRef = useRef(code)
+  codeRef.current = code
+
+  const svgToPng = useCallback((svgStr: string): Promise => {
+    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) => {
@@ -120,11 +163,15 @@ function MermaidDiagramView({ node, updateAttributes }: ReactNodeViewProps) {
 
   return (
     
+      {lightboxOpen && pngUrl && (
+         setLightboxOpen(false)} />
+      )}
+
       {/* Preview Mode */}
       {!isEditing && (
         
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 ? (
@@ -132,12 +179,13 @@ function MermaidDiagramView({ node, updateAttributes }: ReactNodeViewProps) {

{error}

{t('clickToEdit')}

- ) : svg ? ( + ) : pngUrl ? (
+ className="p-4 flex justify-center" + > + mermaid diagram +
) : (
{t('clickToAdd')} @@ -152,6 +200,7 @@ function MermaidDiagramView({ node, updateAttributes }: ReactNodeViewProps) { e.stopPropagation() setIsEditing(true) }} + title={t('clickToEdit')} > @@ -162,38 +211,25 @@ function MermaidDiagramView({ node, updateAttributes }: ReactNodeViewProps) { {/* Edit Mode */} {isEditing && (
-
- - -
- -