mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-01-03 17:18:51 +01:00
feat: add Sandpack-based Mermaid diagram support
This commit is contained in:
parent
32081245da
commit
54db3312c5
7 changed files with 332 additions and 424 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -134,3 +134,4 @@ helm/**/.values.yaml
|
|||
/.openai/
|
||||
/.tabnine/
|
||||
/.codeium
|
||||
CLAUDE.md
|
||||
|
|
|
|||
|
|
@ -1,187 +0,0 @@
|
|||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import mermaid from 'mermaid';
|
||||
import { Button } from '@librechat/client';
|
||||
import { TransformWrapper, TransformComponent, ReactZoomPanPinchRef } from 'react-zoom-pan-pinch';
|
||||
import { ZoomIn, ZoomOut, RefreshCw } from 'lucide-react';
|
||||
|
||||
interface MermaidDiagramProps {
|
||||
content: string;
|
||||
}
|
||||
|
||||
/** Note: this is just for testing purposes, don't actually use this component */
|
||||
const MermaidDiagram: React.FC<MermaidDiagramProps> = ({ content }) => {
|
||||
const mermaidRef = useRef<HTMLDivElement>(null);
|
||||
const transformRef = useRef<ReactZoomPanPinchRef>(null);
|
||||
const [isRendered, setIsRendered] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
theme: 'base',
|
||||
securityLevel: 'sandbox',
|
||||
themeVariables: {
|
||||
background: '#282C34',
|
||||
primaryColor: '#333842',
|
||||
secondaryColor: '#333842',
|
||||
tertiaryColor: '#333842',
|
||||
primaryTextColor: '#ABB2BF',
|
||||
secondaryTextColor: '#ABB2BF',
|
||||
lineColor: '#636D83',
|
||||
fontSize: '16px',
|
||||
nodeBorder: '#636D83',
|
||||
mainBkg: '#282C34',
|
||||
altBackground: '#282C34',
|
||||
textColor: '#ABB2BF',
|
||||
edgeLabelBackground: '#282C34',
|
||||
clusterBkg: '#282C34',
|
||||
clusterBorder: '#636D83',
|
||||
labelBoxBkgColor: '#333842',
|
||||
labelBoxBorderColor: '#636D83',
|
||||
labelTextColor: '#ABB2BF',
|
||||
},
|
||||
flowchart: {
|
||||
curve: 'basis',
|
||||
nodeSpacing: 50,
|
||||
rankSpacing: 50,
|
||||
diagramPadding: 8,
|
||||
htmlLabels: true,
|
||||
useMaxWidth: true,
|
||||
padding: 15,
|
||||
wrappingWidth: 200,
|
||||
},
|
||||
});
|
||||
|
||||
const renderDiagram = async () => {
|
||||
if (mermaidRef.current) {
|
||||
try {
|
||||
const { svg } = await mermaid.render('mermaid-diagram', content);
|
||||
mermaidRef.current.innerHTML = svg;
|
||||
|
||||
const svgElement = mermaidRef.current.querySelector('svg');
|
||||
if (svgElement) {
|
||||
svgElement.style.width = '100%';
|
||||
svgElement.style.height = '100%';
|
||||
|
||||
const pathElements = svgElement.querySelectorAll('path');
|
||||
pathElements.forEach((path) => {
|
||||
path.style.strokeWidth = '1.5px';
|
||||
});
|
||||
|
||||
const rectElements = svgElement.querySelectorAll('rect');
|
||||
rectElements.forEach((rect) => {
|
||||
const parent = rect.parentElement;
|
||||
if (parent && parent.classList.contains('node')) {
|
||||
rect.style.stroke = '#636D83';
|
||||
rect.style.strokeWidth = '1px';
|
||||
} else {
|
||||
rect.style.stroke = 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
setIsRendered(true);
|
||||
} catch (error) {
|
||||
console.error('Mermaid rendering error:', error);
|
||||
mermaidRef.current.innerHTML = 'Error rendering diagram';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
renderDiagram();
|
||||
}, [content]);
|
||||
|
||||
const centerAndFitDiagram = () => {
|
||||
if (transformRef.current && mermaidRef.current) {
|
||||
const { centerView, zoomToElement } = transformRef.current;
|
||||
zoomToElement(mermaidRef.current as HTMLElement);
|
||||
centerView(1, 0);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isRendered) {
|
||||
centerAndFitDiagram();
|
||||
}
|
||||
}, [isRendered]);
|
||||
|
||||
const handlePanning = () => {
|
||||
if (transformRef.current) {
|
||||
const { state, instance } = (transformRef.current as ReactZoomPanPinchRef | undefined) ?? {};
|
||||
if (!state || !instance) {
|
||||
return;
|
||||
}
|
||||
const { scale, positionX, positionY } = state;
|
||||
const { wrapperComponent, contentComponent } = instance;
|
||||
|
||||
if (wrapperComponent && contentComponent) {
|
||||
const wrapperRect = wrapperComponent.getBoundingClientRect();
|
||||
const contentRect = contentComponent.getBoundingClientRect();
|
||||
const maxX = wrapperRect.width - contentRect.width * scale;
|
||||
const maxY = wrapperRect.height - contentRect.height * scale;
|
||||
|
||||
let newX = positionX;
|
||||
let newY = positionY;
|
||||
|
||||
if (newX > 0) {
|
||||
newX = 0;
|
||||
}
|
||||
if (newY > 0) {
|
||||
newY = 0;
|
||||
}
|
||||
if (newX < maxX) {
|
||||
newX = maxX;
|
||||
}
|
||||
if (newY < maxY) {
|
||||
newY = maxY;
|
||||
}
|
||||
|
||||
if (newX !== positionX || newY !== positionY) {
|
||||
instance.setTransformState(scale, newX, newY);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative h-screen w-screen cursor-move bg-[#282C34] p-5">
|
||||
<TransformWrapper
|
||||
ref={transformRef}
|
||||
initialScale={1}
|
||||
minScale={0.1}
|
||||
maxScale={4}
|
||||
limitToBounds={false}
|
||||
centerOnInit={true}
|
||||
initialPositionY={0}
|
||||
wheel={{ step: 0.1 }}
|
||||
panning={{ velocityDisabled: true }}
|
||||
alignmentAnimation={{ disabled: true }}
|
||||
onPanning={handlePanning}
|
||||
>
|
||||
{({ zoomIn, zoomOut }) => (
|
||||
<>
|
||||
<TransformComponent
|
||||
wrapperStyle={{ width: '100%', height: '100%', overflow: 'hidden' }}
|
||||
>
|
||||
<div
|
||||
ref={mermaidRef}
|
||||
style={{ width: 'auto', height: 'auto', minWidth: '100%', minHeight: '100%' }}
|
||||
/>
|
||||
</TransformComponent>
|
||||
<div className="absolute bottom-2 right-2 flex space-x-2">
|
||||
<Button onClick={() => zoomIn(0.1)} variant="outline" size="icon">
|
||||
<ZoomIn className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button onClick={() => zoomOut(0.1)} variant="outline" size="icon">
|
||||
<ZoomOut className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button onClick={centerAndFitDiagram} variant="outline" size="icon">
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</TransformWrapper>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MermaidDiagram;
|
||||
|
|
@ -16,7 +16,6 @@ import { langSubset, preprocessLaTeX } from '~/utils';
|
|||
import { unicodeCitation } from '~/components/Web';
|
||||
import { code, a, p } from './MarkdownComponents';
|
||||
import store from '~/store';
|
||||
|
||||
type TContentProps = {
|
||||
content: string;
|
||||
isLatestMessage: boolean;
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { useRecoilValue } from 'recoil';
|
|||
import { useToastContext } from '@librechat/client';
|
||||
import { PermissionTypes, Permissions } from 'librechat-data-provider';
|
||||
import CodeBlock from '~/components/Messages/Content/CodeBlock';
|
||||
import SandpackMermaidDiagram from './SandpackMermaidDiagram';
|
||||
import useHasAccess from '~/hooks/Roles/useHasAccess';
|
||||
import { useFileDownload } from '~/data-provider';
|
||||
import { useCodeBlockContext } from '~/Providers';
|
||||
|
|
@ -24,6 +25,7 @@ export const code: React.ElementType = memo(({ className, children }: TCodeProps
|
|||
const match = /language-(\w+)/.exec(className ?? '');
|
||||
const lang = match && match[1];
|
||||
const isMath = lang === 'math';
|
||||
const isMermaid = lang === 'mermaid';
|
||||
const isSingleLine = typeof children === 'string' && children.split('\n').length === 1;
|
||||
|
||||
const { getNextIndex, resetCounter } = useCodeBlockContext();
|
||||
|
|
@ -35,6 +37,8 @@ export const code: React.ElementType = memo(({ className, children }: TCodeProps
|
|||
|
||||
if (isMath) {
|
||||
return <>{children}</>;
|
||||
} else if (isMermaid && typeof children === 'string') {
|
||||
return <SandpackMermaidDiagram content={children} fallbackToCodeBlock />;
|
||||
} else if (isSingleLine) {
|
||||
return (
|
||||
<code onDoubleClick={handleDoubleClick} className={className}>
|
||||
|
|
@ -56,9 +60,12 @@ export const code: React.ElementType = memo(({ className, children }: TCodeProps
|
|||
export const codeNoExecution: React.ElementType = memo(({ className, children }: TCodeProps) => {
|
||||
const match = /language-(\w+)/.exec(className ?? '');
|
||||
const lang = match && match[1];
|
||||
const isMermaid = lang === 'mermaid';
|
||||
|
||||
if (lang === 'math') {
|
||||
return children;
|
||||
} else if (isMermaid && typeof children === 'string') {
|
||||
return <SandpackMermaidDiagram content={children} fallbackToCodeBlock />;
|
||||
} else if (typeof children === 'string' && children.split('\n').length === 1) {
|
||||
return (
|
||||
<code onDoubleClick={handleDoubleClick} className={className}>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,297 @@
|
|||
import React, { memo, useMemo, useState, useEffect } from 'react';
|
||||
import { SandpackPreview, SandpackProvider } from '@codesandbox/sandpack-react/unstyled';
|
||||
import { cn } from '~/utils';
|
||||
import { sharedOptions } from '~/utils/artifacts';
|
||||
import CodeBlock from '~/components/Messages/Content/CodeBlock';
|
||||
|
||||
interface SandpackMermaidDiagramProps {
|
||||
content: string;
|
||||
className?: string;
|
||||
fallbackToCodeBlock?: boolean;
|
||||
}
|
||||
|
||||
const mermaidDependencies = {
|
||||
mermaid: '^11.4.1',
|
||||
'react-zoom-pan-pinch': '^3.6.1',
|
||||
'copy-to-clipboard': '^3.3.3',
|
||||
};
|
||||
|
||||
const mermaidTemplate = `
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { TransformWrapper, TransformComponent } from "react-zoom-pan-pinch";
|
||||
import mermaid from "mermaid";
|
||||
import copy from "copy-to-clipboard";
|
||||
|
||||
export default function MermaidDiagram({ content }) {
|
||||
const containerRef = useRef(null);
|
||||
const [svgContent, setSvgContent] = useState("");
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = () => {
|
||||
copy(content, { format: 'text/plain' });
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 3000);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
theme: "default"
|
||||
});
|
||||
|
||||
mermaid.render("mermaid-diagram-" + Math.random().toString(36).substr(2, 9), content)
|
||||
.then(({ svg }) => {
|
||||
// Remove fixed width/height attributes from SVG
|
||||
let processedSvg = svg.replace(/\\s(width|height)="[^"]*"/g, '');
|
||||
|
||||
// Add responsive styling
|
||||
processedSvg = processedSvg.replace(
|
||||
'<svg',
|
||||
'<svg style="width:100%;height:auto;max-width:100%;" preserveAspectRatio="xMidYMid meet"'
|
||||
);
|
||||
|
||||
setSvgContent(processedSvg);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Mermaid error:", err);
|
||||
// Show the mermaid code with error message when parsing fails
|
||||
const errorHtml =
|
||||
'<div style="padding: 20px; font-family: monospace;">' +
|
||||
'<div style="color: #dc2626; margin-bottom: 10px; font-weight: bold;">' +
|
||||
'Mermaid Syntax Error: ' + (err.message || 'Invalid diagram syntax') +
|
||||
'</div>' +
|
||||
'<pre style="background: #f3f4f6; padding: 15px; border-radius: 8px; overflow-x: auto; white-space: pre-wrap; word-wrap: break-word; color: #374151;">' +
|
||||
content.replace(/</g, '<').replace(/>/g, '>') +
|
||||
'</pre>' +
|
||||
'</div>';
|
||||
setSvgContent(errorHtml);
|
||||
});
|
||||
}, [content]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} style={{ width: "100%", height: "100%", position: "relative", overflow: "hidden" }}>
|
||||
<TransformWrapper
|
||||
initialScale={0.3}
|
||||
minScale={0.2}
|
||||
maxScale={4}
|
||||
centerOnInit={true}
|
||||
centerZoomedOut={true}
|
||||
wheel={{ step: 0.1 }}
|
||||
doubleClick={{ disabled: true }}
|
||||
alignmentAnimation={{ sizeX: 0, sizeY: 0 }}
|
||||
>
|
||||
{({ zoomIn, zoomOut, resetTransform }) => (
|
||||
<>
|
||||
<TransformComponent
|
||||
wrapperStyle={{
|
||||
width: "100%",
|
||||
height: "100%"
|
||||
}}
|
||||
contentStyle={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center"
|
||||
}}
|
||||
>
|
||||
<div
|
||||
dangerouslySetInnerHTML={{ __html: svgContent }}
|
||||
style={{
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center"
|
||||
}}
|
||||
/>
|
||||
</TransformComponent>
|
||||
<div style={{ position: "absolute", top: "10px", right: "10px", display: "flex", gap: "8px", zIndex: 50 }}>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
style={{
|
||||
padding: "8px",
|
||||
background: "#ffffff",
|
||||
border: "1px solid #d1d5db",
|
||||
borderRadius: "6px",
|
||||
cursor: "pointer",
|
||||
color: "#374151",
|
||||
boxShadow: "0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06)",
|
||||
transition: "all 0.2s",
|
||||
width: "36px",
|
||||
height: "36px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center"
|
||||
}}
|
||||
onMouseEnter={(e) => { e.target.style.background = "#f3f4f6"; e.target.style.boxShadow = "0 2px 4px 0 rgba(0, 0, 0, 0.1)"; }}
|
||||
onMouseLeave={(e) => { e.target.style.background = "#ffffff"; e.target.style.boxShadow = "0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06)"; }}
|
||||
title={copied ? "Copied!" : "Copy"}
|
||||
>
|
||||
{copied ? (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M20 6L9 17L4 12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" stroke="currentColor" strokeWidth="2"/>
|
||||
<path d="M5 15H4C2.89543 15 2 14.1046 2 13V4C2 2.89543 2.89543 2 4 2H13C14.1046 2 15 2.89543 15 4V5" stroke="currentColor" strokeWidth="2"/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => zoomOut()}
|
||||
style={{
|
||||
padding: "8px",
|
||||
background: "rgba(255, 255, 255, 0.9)",
|
||||
border: "1px solid #e5e7eb",
|
||||
borderRadius: "6px",
|
||||
cursor: "pointer",
|
||||
fontSize: "18px",
|
||||
fontWeight: "500",
|
||||
color: "#374151",
|
||||
boxShadow: "0 1px 2px 0 rgba(0, 0, 0, 0.05)",
|
||||
transition: "all 0.2s",
|
||||
width: "40px",
|
||||
height: "40px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center"
|
||||
}}
|
||||
onMouseEnter={(e) => e.target.style.background = "#f9fafb"}
|
||||
onMouseLeave={(e) => e.target.style.background = "rgba(255, 255, 255, 0.9)"}
|
||||
title="Zoom out"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<button
|
||||
onClick={() => zoomIn()}
|
||||
style={{
|
||||
padding: "8px",
|
||||
background: "rgba(255, 255, 255, 0.9)",
|
||||
border: "1px solid #e5e7eb",
|
||||
borderRadius: "6px",
|
||||
cursor: "pointer",
|
||||
fontSize: "18px",
|
||||
fontWeight: "500",
|
||||
color: "#374151",
|
||||
boxShadow: "0 1px 2px 0 rgba(0, 0, 0, 0.05)",
|
||||
transition: "all 0.2s",
|
||||
width: "40px",
|
||||
height: "40px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center"
|
||||
}}
|
||||
onMouseEnter={(e) => e.target.style.background = "#f9fafb"}
|
||||
onMouseLeave={(e) => e.target.style.background = "rgba(255, 255, 255, 0.9)"}
|
||||
title="Zoom in"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<button
|
||||
onClick={() => resetTransform()}
|
||||
style={{
|
||||
padding: "8px 12px",
|
||||
background: "#ffffff",
|
||||
border: "1px solid #d1d5db",
|
||||
borderRadius: "6px",
|
||||
cursor: "pointer",
|
||||
fontSize: "13px",
|
||||
fontWeight: "500",
|
||||
color: "#374151",
|
||||
boxShadow: "0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06)",
|
||||
transition: "all 0.2s"
|
||||
}}
|
||||
onMouseEnter={(e) => { e.target.style.background = "#f3f4f6"; e.target.style.boxShadow = "0 2px 4px 0 rgba(0, 0, 0, 0.1)"; }}
|
||||
onMouseLeave={(e) => { e.target.style.background = "#ffffff"; e.target.style.boxShadow = "0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06)"; }}
|
||||
title="Reset zoom"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</TransformWrapper>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
`;
|
||||
|
||||
const SandpackMermaidDiagram = memo(
|
||||
({ content, className, fallbackToCodeBlock }: SandpackMermaidDiagramProps) => {
|
||||
const mermaidContent = content || 'graph TD\n A[No content provided] --> B[Error]';
|
||||
const [hasError, setHasError] = useState(false);
|
||||
|
||||
const files = useMemo(
|
||||
() => ({
|
||||
'/App.tsx': `import React from 'react';
|
||||
import MermaidDiagram from './MermaidDiagram';
|
||||
|
||||
export default function App() {
|
||||
const content = \`${mermaidContent.replace(/`/g, '\\`')}\`;
|
||||
return <MermaidDiagram content={content} />;
|
||||
}`,
|
||||
'/MermaidDiagram.tsx': mermaidTemplate,
|
||||
}),
|
||||
[mermaidContent],
|
||||
);
|
||||
|
||||
const key = useMemo(() => {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < mermaidContent.length; i++) {
|
||||
hash = (hash << 5) - hash + mermaidContent.charCodeAt(i);
|
||||
}
|
||||
return hash.toString(36);
|
||||
}, [mermaidContent]);
|
||||
|
||||
// Check if mermaid content is valid by attempting to parse it
|
||||
useEffect(() => {
|
||||
if (!fallbackToCodeBlock) return;
|
||||
|
||||
// Simple validation check for common mermaid syntax errors
|
||||
const trimmedContent = mermaidContent.trim();
|
||||
const hasValidStart =
|
||||
/^(graph|flowchart|sequenceDiagram|classDiagram|stateDiagram|erDiagram|journey|gantt|pie|gitGraph)/i.test(
|
||||
trimmedContent,
|
||||
);
|
||||
|
||||
if (!hasValidStart || trimmedContent.includes('Mermaid Syntax Error')) {
|
||||
setHasError(true);
|
||||
}
|
||||
}, [mermaidContent, fallbackToCodeBlock]);
|
||||
|
||||
// If there's an error and fallback is enabled, show as code block
|
||||
if (fallbackToCodeBlock && hasError) {
|
||||
return <CodeBlock lang="mermaid" codeChildren={content} allowExecution={false} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'my-4 overflow-hidden rounded-lg border border-border-light bg-surface-primary',
|
||||
'dark:border-border-heavy dark:bg-surface-primary-alt',
|
||||
className,
|
||||
)}
|
||||
style={{ height: '400px' }}
|
||||
>
|
||||
<SandpackProvider
|
||||
key={key}
|
||||
files={files}
|
||||
options={sharedOptions}
|
||||
template="react-ts"
|
||||
customSetup={{ dependencies: mermaidDependencies }}
|
||||
>
|
||||
<SandpackPreview
|
||||
showOpenInCodeSandbox={false}
|
||||
showRefreshButton={false}
|
||||
style={{ height: '100%' }}
|
||||
/>
|
||||
</SandpackProvider>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
SandpackMermaidDiagram.displayName = 'SandpackMermaidDiagram';
|
||||
|
||||
export default SandpackMermaidDiagram;
|
||||
|
|
@ -2,12 +2,37 @@ import { useMemo } from 'react';
|
|||
import { removeNullishValues } from 'librechat-data-provider';
|
||||
import type { Artifact } from '~/common';
|
||||
import { getKey, getProps, getTemplate, getArtifactFilename } from '~/utils/artifacts';
|
||||
import { getMermaidFiles } from '~/utils/mermaid';
|
||||
|
||||
export default function useArtifactProps({ artifact }: { artifact: Artifact }) {
|
||||
const [fileKey, files] = useMemo(() => {
|
||||
if (getKey(artifact.type ?? '', artifact.language).includes('mermaid')) {
|
||||
return ['App.tsx', getMermaidFiles(artifact.content ?? '')];
|
||||
const mermaidFiles = {
|
||||
'App.tsx': `import React from 'react';
|
||||
import MermaidDiagram from './MermaidDiagram';
|
||||
|
||||
export default function App() {
|
||||
const content = \`${(artifact.content ?? '').replace(/`/g, '\\`')}\`;
|
||||
return <MermaidDiagram content={content} />;
|
||||
}`,
|
||||
'MermaidDiagram.tsx': `import React, { useEffect, useRef } from 'react';
|
||||
import mermaid from 'mermaid';
|
||||
|
||||
export default function MermaidDiagram({ content }) {
|
||||
const ref = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (ref.current) {
|
||||
mermaid.initialize({ startOnLoad: true, theme: 'default' });
|
||||
mermaid.render('mermaid-diagram', content).then(({ svg }) => {
|
||||
ref.current.innerHTML = svg;
|
||||
});
|
||||
}
|
||||
}, [content]);
|
||||
|
||||
return <div ref={ref} style={{ width: '100%', height: '100%' }} />;
|
||||
}`,
|
||||
};
|
||||
return ['App.tsx', mermaidFiles];
|
||||
}
|
||||
|
||||
const fileKey = getArtifactFilename(artifact.type ?? '', artifact.language);
|
||||
|
|
|
|||
|
|
@ -1,234 +0,0 @@
|
|||
import dedent from 'dedent';
|
||||
|
||||
const mermaid = dedent(`import React, { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
TransformWrapper,
|
||||
TransformComponent,
|
||||
ReactZoomPanPinchRef,
|
||||
} from "react-zoom-pan-pinch";
|
||||
import mermaid from "mermaid";
|
||||
import { ZoomIn, ZoomOut, RefreshCw } from "lucide-react";
|
||||
import { Button } from "/components/ui/button";
|
||||
|
||||
interface MermaidDiagramProps {
|
||||
content: string;
|
||||
}
|
||||
|
||||
const MermaidDiagram: React.FC<MermaidDiagramProps> = ({ content }) => {
|
||||
const mermaidRef = useRef<HTMLDivElement>(null);
|
||||
const transformRef = useRef<ReactZoomPanPinchRef>(null);
|
||||
const [isRendered, setIsRendered] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
theme: "base",
|
||||
themeVariables: {
|
||||
background: "#282C34",
|
||||
primaryColor: "#333842",
|
||||
secondaryColor: "#333842",
|
||||
tertiaryColor: "#333842",
|
||||
primaryTextColor: "#ABB2BF",
|
||||
secondaryTextColor: "#ABB2BF",
|
||||
lineColor: "#636D83",
|
||||
fontSize: "16px",
|
||||
nodeBorder: "#636D83",
|
||||
mainBkg: '#282C34',
|
||||
altBackground: '#282C34',
|
||||
textColor: '#ABB2BF',
|
||||
edgeLabelBackground: '#282C34',
|
||||
clusterBkg: '#282C34',
|
||||
clusterBorder: "#636D83",
|
||||
labelBoxBkgColor: "#333842",
|
||||
labelBoxBorderColor: "#636D83",
|
||||
labelTextColor: "#ABB2BF",
|
||||
},
|
||||
flowchart: {
|
||||
curve: "basis",
|
||||
nodeSpacing: 50,
|
||||
rankSpacing: 50,
|
||||
diagramPadding: 8,
|
||||
htmlLabels: true,
|
||||
useMaxWidth: true,
|
||||
padding: 15,
|
||||
wrappingWidth: 200,
|
||||
},
|
||||
});
|
||||
|
||||
const renderDiagram = async () => {
|
||||
if (mermaidRef.current) {
|
||||
try {
|
||||
const { svg } = await mermaid.render("mermaid-diagram", content);
|
||||
mermaidRef.current.innerHTML = svg;
|
||||
|
||||
const svgElement = mermaidRef.current.querySelector("svg");
|
||||
if (svgElement) {
|
||||
svgElement.style.width = "100%";
|
||||
svgElement.style.height = "100%";
|
||||
|
||||
const pathElements = svgElement.querySelectorAll("path");
|
||||
pathElements.forEach((path) => {
|
||||
path.style.strokeWidth = "1.5px";
|
||||
});
|
||||
|
||||
const rectElements = svgElement.querySelectorAll("rect");
|
||||
rectElements.forEach((rect) => {
|
||||
const parent = rect.parentElement;
|
||||
if (parent && parent.classList.contains("node")) {
|
||||
rect.style.stroke = "#636D83";
|
||||
rect.style.strokeWidth = "1px";
|
||||
} else {
|
||||
rect.style.stroke = "none";
|
||||
}
|
||||
});
|
||||
}
|
||||
setIsRendered(true);
|
||||
} catch (error) {
|
||||
console.error("Mermaid rendering error:", error);
|
||||
mermaidRef.current.innerHTML = "Error rendering diagram";
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
renderDiagram();
|
||||
}, [content]);
|
||||
|
||||
const centerAndFitDiagram = () => {
|
||||
if (transformRef.current && mermaidRef.current) {
|
||||
const { centerView, zoomToElement } = transformRef.current;
|
||||
zoomToElement(mermaidRef.current as HTMLElement);
|
||||
centerView(1, 0);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isRendered) {
|
||||
centerAndFitDiagram();
|
||||
}
|
||||
}, [isRendered]);
|
||||
|
||||
const handlePanning = () => {
|
||||
if (transformRef.current) {
|
||||
const { state, instance } = transformRef.current;
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
const { scale, positionX, positionY } = state;
|
||||
const { wrapperComponent, contentComponent } = instance;
|
||||
|
||||
if (wrapperComponent && contentComponent) {
|
||||
const wrapperRect = wrapperComponent.getBoundingClientRect();
|
||||
const contentRect = contentComponent.getBoundingClientRect();
|
||||
const maxX = wrapperRect.width - contentRect.width * scale;
|
||||
const maxY = wrapperRect.height - contentRect.height * scale;
|
||||
|
||||
let newX = positionX;
|
||||
let newY = positionY;
|
||||
|
||||
if (newX > 0) {
|
||||
newX = 0;
|
||||
}
|
||||
if (newY > 0) {
|
||||
newY = 0;
|
||||
}
|
||||
if (newX < maxX) {
|
||||
newX = maxX;
|
||||
}
|
||||
if (newY < maxY) {
|
||||
newY = maxY;
|
||||
}
|
||||
|
||||
if (newX !== positionX || newY !== positionY) {
|
||||
instance.setTransformState(scale, newX, newY);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative h-screen w-screen cursor-move bg-[#282C34] p-5">
|
||||
<TransformWrapper
|
||||
ref={transformRef}
|
||||
initialScale={1}
|
||||
minScale={0.1}
|
||||
maxScale={10}
|
||||
limitToBounds={false}
|
||||
centerOnInit={true}
|
||||
initialPositionY={0}
|
||||
wheel={{ step: 0.1 }}
|
||||
panning={{ velocityDisabled: true }}
|
||||
alignmentAnimation={{ disabled: true }}
|
||||
onPanning={handlePanning}
|
||||
>
|
||||
{({ zoomIn, zoomOut }) => (
|
||||
<>
|
||||
<TransformComponent
|
||||
wrapperStyle={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={mermaidRef}
|
||||
style={{
|
||||
width: "auto",
|
||||
height: "auto",
|
||||
minWidth: "100%",
|
||||
minHeight: "100%",
|
||||
}}
|
||||
/>
|
||||
</TransformComponent>
|
||||
<div className="absolute bottom-2 right-2 flex space-x-2">
|
||||
<Button onClick={() => zoomIn(0.1)} variant="outline" size="icon">
|
||||
<ZoomIn className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => zoomOut(0.1)}
|
||||
variant="outline"
|
||||
size="icon"
|
||||
>
|
||||
<ZoomOut className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={centerAndFitDiagram}
|
||||
variant="outline"
|
||||
size="icon"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</TransformWrapper>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MermaidDiagram;`);
|
||||
|
||||
const wrapMermaidDiagram = (content: string) => {
|
||||
return dedent(`import React from 'react';
|
||||
import MermaidDiagram from '/components/ui/MermaidDiagram';
|
||||
|
||||
export default App = () => (
|
||||
<MermaidDiagram content={\`${content}\`} />
|
||||
);
|
||||
`);
|
||||
};
|
||||
|
||||
export const getMermaidFiles = (content: string) => {
|
||||
return {
|
||||
'App.tsx': wrapMermaidDiagram(content),
|
||||
'index.tsx': dedent(`import React, { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "./styles.css";
|
||||
|
||||
import App from "./App";
|
||||
|
||||
const root = createRoot(document.getElementById("root"));
|
||||
root.render(<App />);
|
||||
;`),
|
||||
'/components/ui/MermaidDiagram.tsx': mermaid,
|
||||
};
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue