Skip to content

Commit bae4fce

Browse files
shantanu patilclaude
authored andcommitted
fix: svg-pan-zoom in fullscreen modal and re-init after close
- Replace CSS transform zoom in FullScreenModal with real svg-pan-zoom so scroll-to-zoom and drag-to-pan work inside expanded view - Add "Scroll to zoom, drag to pan" hint in modal header - Properly destroy and re-initialize inline svg-pan-zoom when fullscreen modal closes (fixes pan-zoom dying after expand/close cycle) - Store pan-zoom instance in ref for clean destroy/recreate lifecycle - Wider modal (max-w-6xl) with 500px min-height for better viewing - Add fit-to-view button with corner arrows icon in modal toolbar Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 274ebfc commit bae4fce

1 file changed

Lines changed: 122 additions & 67 deletions

File tree

src/components/Mermaid.tsx

Lines changed: 122 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -578,14 +578,16 @@ interface MermaidProps {
578578
zoomingEnabled?: boolean;
579579
}
580580

581-
// Full screen modal component for the diagram
581+
// Full screen modal component for the diagram with svg-pan-zoom
582582
const FullScreenModal: React.FC<{
583583
isOpen: boolean;
584584
onClose: () => void;
585-
children: React.ReactNode;
586-
}> = ({ isOpen, onClose, children }) => {
585+
svgHtml: string;
586+
}> = ({ isOpen, onClose, svgHtml }) => {
587587
const modalRef = useRef<HTMLDivElement>(null);
588-
const [zoom, setZoom] = useState(1);
588+
const svgContainerRef = useRef<HTMLDivElement>(null);
589+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
590+
const panZoomRef = useRef<any>(null);
589591

590592
// Close on Escape key
591593
useEffect(() => {
@@ -621,61 +623,104 @@ const FullScreenModal: React.FC<{
621623
};
622624
}, [isOpen, onClose]);
623625

624-
// Reset zoom when modal opens
626+
// Initialize svg-pan-zoom in the modal
625627
useEffect(() => {
626-
if (isOpen) {
627-
setZoom(1);
628-
}
629-
}, [isOpen]);
628+
if (!isOpen || !svgContainerRef.current) return;
629+
630+
const initPanZoom = async () => {
631+
const svgElement = svgContainerRef.current?.querySelector('svg');
632+
if (!svgElement) return;
633+
634+
svgElement.style.maxWidth = 'none';
635+
svgElement.style.width = '100%';
636+
svgElement.style.height = '100%';
637+
638+
try {
639+
const svgPanZoom = (await import('svg-pan-zoom')).default;
640+
panZoomRef.current = svgPanZoom(svgElement, {
641+
zoomEnabled: true,
642+
controlIconsEnabled: true,
643+
fit: true,
644+
center: true,
645+
minZoom: 0.1,
646+
maxZoom: 10,
647+
zoomScaleSensitivity: 0.3,
648+
});
649+
} catch (error) {
650+
console.error('Failed to load svg-pan-zoom in modal:', error);
651+
}
652+
};
653+
654+
setTimeout(() => { void initPanZoom(); }, 200);
655+
656+
return () => {
657+
if (panZoomRef.current) {
658+
try { panZoomRef.current.destroy(); } catch { /* ignore */ }
659+
panZoomRef.current = null;
660+
}
661+
};
662+
}, [isOpen, svgHtml]);
663+
664+
const handleZoomIn = () => { panZoomRef.current?.zoomIn(); };
665+
const handleZoomOut = () => { panZoomRef.current?.zoomOut(); };
666+
const handleReset = () => { panZoomRef.current?.resetZoom(); panZoomRef.current?.resetPan(); panZoomRef.current?.fit(); panZoomRef.current?.center(); };
630667

631668
if (!isOpen) return null;
632669

633670
return (
634671
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm p-4">
635672
<div
636673
ref={modalRef}
637-
className="bg-card border border-border rounded-lg shadow-lg max-w-5xl max-h-[90vh] w-full overflow-hidden flex flex-col"
674+
className="bg-card border border-border rounded-lg shadow-lg max-w-6xl max-h-[92vh] w-full overflow-hidden flex flex-col"
638675
>
639676
{/* Modal header with controls */}
640677
<div className="flex items-center justify-between p-4 border-b border-border bg-muted/40">
641-
<div className="font-semibold text-foreground">Diagram View</div>
642-
<div className="flex items-center gap-4">
643-
<div className="flex items-center gap-2">
644-
<button
645-
onClick={() => setZoom(Math.max(0.5, zoom - 0.1))}
646-
className="text-foreground hover:bg-accent hover:text-accent-foreground p-2 rounded-md border border-input transition-colors bg-background"
647-
aria-label="Zoom out"
648-
>
649-
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
650-
<circle cx="11" cy="11" r="8"></circle>
651-
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
652-
<line x1="8" y1="11" x2="14" y2="11"></line>
653-
</svg>
654-
</button>
655-
<span className="text-sm text-muted-foreground w-12 text-center">{Math.round(zoom * 100)}%</span>
656-
<button
657-
onClick={() => setZoom(Math.min(2, zoom + 0.1))}
658-
className="text-foreground hover:bg-accent hover:text-accent-foreground p-2 rounded-md border border-input transition-colors bg-background"
659-
aria-label="Zoom in"
660-
>
661-
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
662-
<circle cx="11" cy="11" r="8"></circle>
663-
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
664-
<line x1="11" y1="8" x2="11" y2="14"></line>
665-
<line x1="8" y1="11" x2="14" y2="11"></line>
666-
</svg>
667-
</button>
668-
<button
669-
onClick={() => setZoom(1)}
670-
className="text-foreground hover:bg-accent hover:text-accent-foreground p-2 rounded-md border border-input transition-colors bg-background"
671-
aria-label="Reset zoom"
672-
>
673-
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
674-
<path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"></path>
675-
<path d="M21 3v5h-5"></path>
676-
</svg>
677-
</button>
678-
</div>
678+
<div className="font-semibold text-foreground flex items-center gap-2">
679+
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
680+
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
681+
<line x1="3" y1="9" x2="21" y2="9"></line>
682+
<line x1="9" y1="21" x2="9" y2="9"></line>
683+
</svg>
684+
Diagram View
685+
</div>
686+
<div className="flex items-center gap-2">
687+
<span className="text-xs text-muted-foreground mr-2">Scroll to zoom, drag to pan</span>
688+
<button
689+
onClick={handleZoomOut}
690+
className="text-foreground hover:bg-accent hover:text-accent-foreground p-2 rounded-md border border-input transition-colors bg-background"
691+
aria-label="Zoom out"
692+
>
693+
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
694+
<circle cx="11" cy="11" r="8"></circle>
695+
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
696+
<line x1="8" y1="11" x2="14" y2="11"></line>
697+
</svg>
698+
</button>
699+
<button
700+
onClick={handleZoomIn}
701+
className="text-foreground hover:bg-accent hover:text-accent-foreground p-2 rounded-md border border-input transition-colors bg-background"
702+
aria-label="Zoom in"
703+
>
704+
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
705+
<circle cx="11" cy="11" r="8"></circle>
706+
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
707+
<line x1="11" y1="8" x2="11" y2="14"></line>
708+
<line x1="8" y1="11" x2="14" y2="11"></line>
709+
</svg>
710+
</button>
711+
<button
712+
onClick={handleReset}
713+
className="text-foreground hover:bg-accent hover:text-accent-foreground p-2 rounded-md border border-input transition-colors bg-background"
714+
aria-label="Fit to view"
715+
>
716+
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
717+
<path d="M8 3H5a2 2 0 0 0-2 2v3"></path>
718+
<path d="M21 8V5a2 2 0 0 0-2-2h-3"></path>
719+
<path d="M3 16v3a2 2 0 0 0 2 2h3"></path>
720+
<path d="M16 21h3a2 2 0 0 0 2-2v-3"></path>
721+
</svg>
722+
</button>
723+
<div className="w-px h-6 bg-border mx-1"></div>
679724
<button
680725
onClick={onClose}
681726
className="text-muted-foreground hover:text-foreground hover:bg-accent p-2 rounded-md transition-colors"
@@ -689,18 +734,13 @@ const FullScreenModal: React.FC<{
689734
</div>
690735
</div>
691736

692-
{/* Modal content with zoom */}
693-
<div className="overflow-auto p-6 flex-1 flex items-center justify-center bg-background">
694-
<div
695-
style={{
696-
transform: `scale(${zoom})`,
697-
transformOrigin: 'center center',
698-
transition: 'transform 0.3s ease-out'
699-
}}
700-
>
701-
{children}
702-
</div>
703-
</div>
737+
{/* Modal content with svg-pan-zoom */}
738+
<div
739+
ref={svgContainerRef}
740+
className="flex-1 bg-background mermaid-diagram-container"
741+
style={{ minHeight: '500px' }}
742+
dangerouslySetInnerHTML={{ __html: svgHtml }}
743+
/>
704744
</div>
705745
</div>
706746
);
@@ -731,10 +771,19 @@ const Mermaid: React.FC<MermaidProps> = ({ chart, className = '', zoomingEnabled
731771
return () => observer.disconnect();
732772
}, []);
733773

734-
// Initialize pan-zoom functionality when SVG is rendered
774+
// Initialize pan-zoom functionality when SVG is rendered or modal closes
775+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
776+
const inlinePanZoomRef = useRef<any>(null);
777+
735778
useEffect(() => {
736-
if (svg && zoomingEnabled && containerRef.current) {
779+
if (svg && zoomingEnabled && containerRef.current && !isFullscreen) {
737780
const initializePanZoom = async () => {
781+
// Destroy previous instance if it exists
782+
if (inlinePanZoomRef.current) {
783+
try { inlinePanZoomRef.current.destroy(); } catch { /* ignore */ }
784+
inlinePanZoomRef.current = null;
785+
}
786+
738787
const svgElement = containerRef.current?.querySelector("svg");
739788
if (svgElement) {
740789
// Remove any max-width constraints but keep height responsive
@@ -747,7 +796,7 @@ const Mermaid: React.FC<MermaidProps> = ({ chart, className = '', zoomingEnabled
747796
// Dynamically import svg-pan-zoom only when needed in the browser
748797
const svgPanZoom = (await import("svg-pan-zoom")).default;
749798

750-
svgPanZoom(svgElement, {
799+
inlinePanZoomRef.current = svgPanZoom(svgElement, {
751800
zoomEnabled: true,
752801
controlIconsEnabled: true,
753802
fit: true,
@@ -767,7 +816,14 @@ const Mermaid: React.FC<MermaidProps> = ({ chart, className = '', zoomingEnabled
767816
void initializePanZoom();
768817
}, 150);
769818
}
770-
}, [svg, zoomingEnabled]);
819+
820+
return () => {
821+
if (inlinePanZoomRef.current) {
822+
try { inlinePanZoomRef.current.destroy(); } catch { /* ignore */ }
823+
inlinePanZoomRef.current = null;
824+
}
825+
};
826+
}, [svg, zoomingEnabled, isFullscreen]);
771827

772828
const renderChart = useCallback(async (isMountedRef: { current: boolean }) => {
773829
if (!chart || !isMountedRef.current) return;
@@ -885,9 +941,8 @@ const Mermaid: React.FC<MermaidProps> = ({ chart, className = '', zoomingEnabled
885941
<FullScreenModal
886942
isOpen={isFullscreen}
887943
onClose={() => setIsFullscreen(false)}
888-
>
889-
<div className="mermaid-diagram-container" dangerouslySetInnerHTML={{ __html: svg }} />
890-
</FullScreenModal>
944+
svgHtml={svg}
945+
/>
891946
</>
892947
);
893948
};

0 commit comments

Comments
 (0)