diff --git a/src/renderer/src/hooks/useMermaid.ts b/src/renderer/src/hooks/useMermaid.ts index c8efbedf58..3bc5de12c0 100644 --- a/src/renderer/src/hooks/useMermaid.ts +++ b/src/renderer/src/hooks/useMermaid.ts @@ -1,42 +1,30 @@ import { useTheme } from '@renderer/context/ThemeProvider' +import { EventEmitter } from '@renderer/services/EventService' import { ThemeMode } from '@renderer/types' import { loadScript, runAsyncFunction } from '@renderer/utils' -import { useEffect } from 'react' - -import { useRuntime } from './useRuntime' +import { useEffect, useRef } from 'react' export const useMermaid = () => { const { theme } = useTheme() - const { generating } = useRuntime() + const mermaidLoaded = useRef(false) useEffect(() => { runAsyncFunction(async () => { if (!window.mermaid) { - await loadScript('https://unpkg.com/mermaid@11.4.0/dist/mermaid.min.js') + await loadScript('https://unpkg.com/mermaid@11.6.0/dist/mermaid.min.js') + } + + if (!mermaidLoaded.current) { + await window.mermaid.initialize({ + startOnLoad: false, + theme: theme === ThemeMode.dark ? 'dark' : 'default' + }) + mermaidLoaded.current = true + EventEmitter.emit('mermaid-loaded') } - window.mermaid.initialize({ - startOnLoad: true, - theme: theme === ThemeMode.dark ? 'dark' : 'default' - }) }) }, [theme]) - useEffect(() => { - if (!window.mermaid || generating) return - - const renderMermaid = () => { - const mermaidElements = document.querySelectorAll('.mermaid') - mermaidElements.forEach((element) => { - if (!element.querySelector('svg')) { - element.removeAttribute('data-processed') - } - }) - window.mermaid.contentLoaded() - } - - setTimeout(renderMermaid, 100) - }, [generating]) - useEffect(() => { const handleWheel = (e: WheelEvent) => { if (e.ctrlKey || e.metaKey) { diff --git a/src/renderer/src/pages/home/Markdown/Mermaid.tsx b/src/renderer/src/pages/home/Markdown/Mermaid.tsx index 3027853c9f..e8ffdbae46 100644 --- a/src/renderer/src/pages/home/Markdown/Mermaid.tsx +++ b/src/renderer/src/pages/home/Markdown/Mermaid.tsx @@ -1,6 +1,8 @@ import { useTheme } from '@renderer/context/ThemeProvider' +import { EventEmitter } from '@renderer/services/EventService' import { ThemeMode } from '@renderer/types' -import React, { useEffect, useRef } from 'react' +import { debounce, isEmpty } from 'lodash' +import React, { useCallback, useEffect, useRef } from 'react' import MermaidPopup from './MermaidPopup' @@ -12,20 +14,44 @@ const Mermaid: React.FC = ({ chart }) => { const { theme } = useTheme() const mermaidRef = useRef(null) - useEffect(() => { - if (mermaidRef.current && window.mermaid) { + const renderMermaidBase = useCallback(async () => { + if (!mermaidRef.current || !window.mermaid || isEmpty(chart)) return + + try { mermaidRef.current.innerHTML = chart mermaidRef.current.removeAttribute('data-processed') - if (window.mermaid.initialize) { - window.mermaid.initialize({ - startOnLoad: true, - theme: theme === ThemeMode.dark ? 'dark' : 'default' - }) - } - window.mermaid.contentLoaded() + + await window.mermaid.initialize({ + startOnLoad: true, + theme: theme === ThemeMode.dark ? 'dark' : 'default' + }) + + await window.mermaid.run({ nodes: [mermaidRef.current] }) + } catch (error) { + console.error('Failed to render mermaid chart:', error) } }, [chart, theme]) + const renderMermaid = useCallback(debounce(renderMermaidBase, 1000), [renderMermaidBase]) + + useEffect(() => { + renderMermaid() + // Make sure to cancel any pending debounced calls when unmounting + return () => renderMermaid.cancel() + }, [renderMermaid]) + + useEffect(() => { + setTimeout(renderMermaidBase, 0) + }, []) + + useEffect(() => { + const removeListener = EventEmitter.on('mermaid-loaded', renderMermaid) + return () => { + removeListener() + renderMermaid.cancel() + } + }, [renderMermaid]) + const onPreview = () => { MermaidPopup.show({ chart }) } diff --git a/src/renderer/src/pages/home/Markdown/MermaidPopup.tsx b/src/renderer/src/pages/home/Markdown/MermaidPopup.tsx index 55b3db34b4..6dda41c5c0 100644 --- a/src/renderer/src/pages/home/Markdown/MermaidPopup.tsx +++ b/src/renderer/src/pages/home/Markdown/MermaidPopup.tsx @@ -1,4 +1,7 @@ import { TopView } from '@renderer/components/TopView' +import { useTheme } from '@renderer/context/ThemeProvider' +import { ThemeMode } from '@renderer/types' +import { runAsyncFunction } from '@renderer/utils' import { download } from '@renderer/utils/download' import { Button, Modal, Space, Tabs } from 'antd' import { useEffect, useState } from 'react' @@ -16,6 +19,7 @@ interface Props extends ShowParams { const PopupContainer: React.FC = ({ resolve, chart }) => { const [open, setOpen] = useState(true) const { t } = useTranslation() + const { theme } = useTheme() const mermaidId = `mermaid-popup-${Date.now()}` const [activeTab, setActiveTab] = useState('preview') const [scale, setScale] = useState(1) @@ -97,19 +101,21 @@ const PopupContainer: React.FC = ({ resolve, chart }) => { if (!element) return const timestamp = Date.now() + const backgroundColor = theme === ThemeMode.dark ? '#1F1F1F' : '#fff' + const svgElement = element.querySelector('svg') + + if (!svgElement) return if (format === 'svg') { - const svgElement = element.querySelector('svg') - if (!svgElement) return + // Add background color to SVG + svgElement.style.backgroundColor = backgroundColor + const svgData = new XMLSerializer().serializeToString(svgElement) const blob = new Blob([svgData], { type: 'image/svg+xml' }) const url = URL.createObjectURL(blob) download(url, `mermaid-diagram-${timestamp}.svg`) URL.revokeObjectURL(url) } else if (format === 'png') { - const svgElement = element.querySelector('svg') - if (!svgElement) return - const canvas = document.createElement('canvas') const ctx = canvas.getContext('2d') const img = new Image() @@ -119,6 +125,9 @@ const PopupContainer: React.FC = ({ resolve, chart }) => { const width = viewBox[2] || svgElement.clientWidth || svgElement.getBoundingClientRect().width const height = viewBox[3] || svgElement.clientHeight || svgElement.getBoundingClientRect().height + // Add background color to SVG before converting to image + svgElement.style.backgroundColor = backgroundColor + const svgData = new XMLSerializer().serializeToString(svgElement) const svgBase64 = `data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(svgData)))}` @@ -129,6 +138,9 @@ const PopupContainer: React.FC = ({ resolve, chart }) => { if (ctx) { ctx.scale(scale, scale) + // Fill background + ctx.fillStyle = backgroundColor + ctx.fillRect(0, 0, width, height) ctx.drawImage(img, 0, 0, width, height) } @@ -142,6 +154,7 @@ const PopupContainer: React.FC = ({ resolve, chart }) => { } img.src = svgBase64 } + svgElement.style.backgroundColor = 'transparent' } catch (error) { console.error('Download failed:', error) } @@ -153,8 +166,30 @@ const PopupContainer: React.FC = ({ resolve, chart }) => { } useEffect(() => { - window?.mermaid?.contentLoaded() - }, []) + runAsyncFunction(async () => { + if (!window.mermaid) return + + try { + const element = document.getElementById(mermaidId) + if (!element) return + + // Clear previous content + element.innerHTML = chart + element.removeAttribute('data-processed') + + await window.mermaid.initialize({ + startOnLoad: false, + theme: theme === ThemeMode.dark ? 'dark' : 'default' + }) + + await window.mermaid.run({ + nodes: [element] + }) + } catch (error) { + console.error('Failed to render mermaid chart in popup:', error) + } + }) + }, [activeTab, theme, mermaidId, chart]) return (