diff --git a/src/renderer/components/ui/mermaid-renderer.tsx b/src/renderer/components/ui/mermaid-renderer.tsx index e02a7533..b49da3ae 100644 --- a/src/renderer/components/ui/mermaid-renderer.tsx +++ b/src/renderer/components/ui/mermaid-renderer.tsx @@ -1,4 +1,5 @@ -import { useEffect, useId, useRef, useState } from 'react'; +import { useCallback, useEffect, useId, useRef, useState } from 'react'; +import { Maximize2, Minus, Minimize2, Plus, RotateCcw, X } from 'lucide-react'; import { cn } from '@/lib/utils'; import { useSettingsStore } from '@/stores/settings'; @@ -38,12 +39,23 @@ interface MermaidRendererProps { className?: string; } +const ZOOM_STEP = 0.1; +const MIN_ZOOM = 0.1; + export function MermaidRenderer({ code, className }: MermaidRendererProps) { const theme = useSettingsStore((s) => s.theme); const uniqueId = useId(); const containerRef = useRef(null); const [svg, setSvg] = useState(null); const [error, setError] = useState(null); + const [zoom, setZoom] = useState(1); + const [pan, setPan] = useState({ x: 0, y: 0 }); + const [isDragging, setIsDragging] = useState(false); + const [isFullscreen, setIsFullscreen] = useState(false); + const dragStartRef = useRef({ x: 0, y: 0 }); + const panStartRef = useRef({ x: 0, y: 0 }); + const svgContentRef = useRef(null); + const hasDraggedRef = useRef(false); const resolvedTheme = theme === 'system' @@ -96,6 +108,8 @@ export function MermaidRenderer({ code, className }: MermaidRendererProps) { if (!cancelled) { setSvg(renderedSvg); setError(null); + setZoom(1); + setPan({ x: 0, y: 0 }); } } catch (err) { cleanupMermaidElements(); @@ -115,6 +129,102 @@ export function MermaidRenderer({ code, className }: MermaidRendererProps) { }; }, [code, mermaidTheme, uniqueId]); + const handleZoomIn = useCallback(() => { + setZoom((prev) => prev + ZOOM_STEP); + }, []); + + const handleZoomOut = useCallback(() => { + setZoom((prev) => Math.max(prev - ZOOM_STEP, MIN_ZOOM)); + }, []); + + const handleReset = useCallback(() => { + setZoom(1); + setPan({ x: 0, y: 0 }); + }, []); + + const handleExitFullscreen = useCallback(() => { + setIsFullscreen(false); + setZoom(1); + setPan({ x: 0, y: 0 }); + }, []); + + // Calculate fit view: scale SVG to fit viewport while keeping aspect ratio, centered + const handleEnterFullscreen = useCallback(() => { + setIsFullscreen(true); + requestAnimationFrame(() => { + const svgEl = svgContentRef.current?.querySelector('svg'); + if (!svgEl) return; + + const contentArea = svgContentRef.current?.getBoundingClientRect(); + if (!contentArea || !contentArea.width || !contentArea.height) return; + + // Use getBBox to get the actual content bounding box + const svgRect = svgEl.getBoundingClientRect(); + const padding = 24; + const scaleX = (contentArea.width - padding * 2) / svgRect.width; + const scaleY = (contentArea.height - padding * 2) / svgRect.height; + const fitScale = Math.min(scaleX, scaleY); + + setZoom(Math.round(fitScale * 100) / 100); + setPan({ x: 0, y: 0 }); + }); + }, []); + + const handleWheel = useCallback( + (e: React.WheelEvent) => { + if (!isFullscreen) return; + e.preventDefault(); + const delta = e.deltaY > 0 ? -ZOOM_STEP : ZOOM_STEP; + setZoom((prev) => { + const next = Math.max(prev + delta, MIN_ZOOM); + return Math.round(next * 100) / 100; + }); + }, + [isFullscreen] + ); + + const handleMouseDown = useCallback( + (e: React.MouseEvent) => { + if (!isFullscreen || e.button !== 0) return; + setIsDragging(true); + hasDraggedRef.current = false; + dragStartRef.current = { x: e.clientX, y: e.clientY }; + panStartRef.current = { ...pan }; + }, + [isFullscreen, pan] + ); + + const handleMouseMove = useCallback( + (e: React.MouseEvent) => { + if (!isDragging) return; + const dx = e.clientX - dragStartRef.current.x; + const dy = e.clientY - dragStartRef.current.y; + if (Math.abs(dx) > 2 || Math.abs(dy) > 2) { + hasDraggedRef.current = true; + } + setPan({ x: panStartRef.current.x + dx, y: panStartRef.current.y + dy }); + }, + [isDragging] + ); + + const handleMouseUp = useCallback(() => { + setIsDragging(false); + }, []); + + const handleFullscreenContentClick = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + }, []); + + const handleFullscreenOverlayClick = useCallback( + (e: React.MouseEvent) => { + // Only exit if the user didn't drag (just a click on the overlay background) + if (!hasDraggedRef.current) { + handleExitFullscreen(); + } + }, + [handleExitFullscreen] + ); + if (error) { return (
@@ -145,15 +255,185 @@ export function MermaidRenderer({ code, className }: MermaidRendererProps) { } return ( -
+
+
+
+
+
+ + {/* Zoom controls */} +
+ + + + {zoom !== 1 && ( + + )} +
+ +
+ + {/* Fullscreen overlay */} + {isFullscreen && ( +
+ {/* Header bar */} +
e.stopPropagation()} + > + Mermaid 预览 + +
+ + {/* Fullscreen content */} +
+
+
+
+
+
+ + {/* Fullscreen zoom controls */} +
+ + + + {zoom !== 1 && ( + + )} +
+ +
+
+
)} - // biome-ignore lint/security/noDangerouslySetInnerHtml: mermaid SVG output - dangerouslySetInnerHTML={{ __html: svg }} - /> +
); }