Skip to content

Commit 25e5c0d

Browse files
author
Bogdan Tsechoev
committed
Merge branch 'bot_ui_mermaid_diagram_improvements' into 'master'
Bot UI: Mermaid diagram gestures, save button and other improvements See merge request postgres-ai/database-lab!905
2 parents 42b6de2 + ee687ee commit 25e5c0d

File tree

5 files changed

+330
-9
lines changed

5 files changed

+330
-9
lines changed

ui/cspell.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,12 @@
193193
"citus",
194194
"pgvector",
195195
"partman",
196-
"fstype"
196+
"fstype",
197+
"pgsql",
198+
"sqlalchemy",
199+
"tsql",
200+
"TSQL",
201+
"sparql",
202+
"SPARQL"
197203
]
198204
}

ui/packages/platform/src/pages/Bot/Messages/Message/CodeBlock.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import ExpandMoreIcon from "@material-ui/icons/ExpandMore";
77
import FileCopyOutlinedIcon from '@material-ui/icons/FileCopyOutlined';
88
import CheckCircleOutlineIcon from '@material-ui/icons/CheckCircleOutline';
99
import CodeIcon from '@material-ui/icons/Code';
10+
import { formatLanguageName } from "../../utils";
1011

1112
const useStyles = makeStyles((theme) => ({
1213
container: {
@@ -131,7 +132,7 @@ export const CodeBlock = memo(({ value, language }: CodeBlockProps) => {
131132
className={classes.summaryText}
132133
>
133134
<CodeIcon className={classes.summaryTextIcon} />
134-
{expanded ? 'Hide' : 'Show'} code block ({codeLines.length} LOC)
135+
{expanded ? 'Hide' : 'Show'}{language ? ` ${formatLanguageName(language)}` : ''} code block ({codeLines.length} LOC)
135136
</Typography>
136137
</AccordionSummary>
137138
<AccordionDetails className={classes.details}>
Lines changed: 152 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,161 @@
1-
import React, { useEffect } from 'react';
1+
import React, { useCallback, useEffect, useRef, useState } from 'react';
22
import mermaid from 'mermaid';
3+
import { makeStyles } from "@material-ui/core";
4+
import { MermaidDiagramControls } from "./MermaidDiagramControls";
5+
import cn from "classnames";
36

47
type MermaidDiagramProps = {
58
chart: string
69
}
710

11+
const useStyles = makeStyles(
12+
() => ({
13+
container: {
14+
position: 'relative',
15+
width: '100%',
16+
overflow: 'hidden'
17+
},
18+
mermaid: {
19+
minHeight: 300,
20+
},
21+
}))
22+
23+
mermaid.initialize({ startOnLoad: true, er: { diagramPadding: 20, useMaxWidth: false } });
24+
825
export const MermaidDiagram = React.memo((props: MermaidDiagramProps) => {
926
const { chart } = props;
10-
mermaid.initialize({ startOnLoad: true });
27+
28+
const classes = useStyles();
29+
30+
// Consolidated state management
31+
const [diagramState, setDiagramState] = useState({
32+
scale: 1,
33+
position: { x: 0, y: 0 },
34+
dragging: false,
35+
startPosition: { x: 0, y: 0 },
36+
});
37+
38+
const [isDiagramValid, setDiagramValid] = useState<boolean | null>(null);
39+
const [diagramError, setDiagramError] = useState<string | null>(null)
40+
41+
const diagramRef = useRef<HTMLDivElement>(null);
42+
1143
useEffect(() => {
12-
mermaid.contentLoaded();
13-
}, [chart]);
14-
return <div className="mermaid">{chart}</div>;
15-
})
44+
let isMounted = true;
45+
if (isDiagramValid === null || chart) {
46+
mermaid.parse(chart)
47+
.then(() => {
48+
if (isMounted) {
49+
setDiagramValid(true);
50+
mermaid.contentLoaded();
51+
}
52+
})
53+
.catch((e) => {
54+
if (isMounted) {
55+
setDiagramValid(false);
56+
setDiagramError(e.message)
57+
console.error('Diagram contains errors:', e.message);
58+
}
59+
});
60+
}
61+
62+
return () => {
63+
isMounted = false;
64+
};
65+
}, [chart, isDiagramValid]);
66+
67+
const handleZoomIn = useCallback(() => {
68+
setDiagramState((prev) => ({
69+
...prev,
70+
scale: Math.min(prev.scale + 0.1, 2),
71+
}));
72+
}, []);
73+
74+
const handleZoomOut = useCallback(() => {
75+
setDiagramState((prev) => ({
76+
...prev,
77+
scale: Math.max(prev.scale - 0.1, 0.8),
78+
}));
79+
}, []);
80+
81+
const handleMouseDown = useCallback((event: React.MouseEvent) => {
82+
setDiagramState((prev) => ({
83+
...prev,
84+
dragging: true,
85+
startPosition: { x: event.clientX - prev.position.x, y: event.clientY - prev.position.y },
86+
}));
87+
}, []);
88+
89+
const handleMouseMove = useCallback((event: React.MouseEvent) => {
90+
if (diagramState.dragging) {
91+
setDiagramState((prev) => ({
92+
...prev,
93+
position: { x: event.clientX - prev.startPosition.x, y: event.clientY - prev.startPosition.y },
94+
}));
95+
}
96+
}, [diagramState.dragging]);
97+
98+
const handleMouseUp = useCallback(() => {
99+
setDiagramState((prev) => ({ ...prev, dragging: false }));
100+
}, []);
101+
102+
const handleTouchStart = useCallback((event: React.TouchEvent) => {
103+
const touch = event.touches[0];
104+
setDiagramState((prev) => ({
105+
...prev,
106+
dragging: true,
107+
startPosition: { x: touch.clientX - prev.position.x, y: touch.clientY - prev.position.y },
108+
}));
109+
}, []);
110+
111+
const handleTouchMove = useCallback((event: React.TouchEvent) => {
112+
if (diagramState.dragging) {
113+
const touch = event.touches[0];
114+
setDiagramState((prev) => ({
115+
...prev,
116+
position: { x: touch.clientX - prev.startPosition.x, y: touch.clientY - prev.startPosition.y },
117+
}));
118+
}
119+
}, [diagramState.dragging]);
120+
121+
const handleTouchEnd = useCallback(() => {
122+
setDiagramState((prev) => ({ ...prev, dragging: false }));
123+
}, []);
124+
125+
if (isDiagramValid === null) {
126+
return <p>Validating diagram...</p>;
127+
}
128+
129+
if (isDiagramValid) {
130+
return (
131+
<div className={classes.container}>
132+
<div
133+
className={cn("mermaid", classes.mermaid)}
134+
ref={diagramRef}
135+
style={{
136+
transform: `scale(${diagramState.scale}) translate(${diagramState.position.x}px, ${diagramState.position.y}px)`,
137+
transformOrigin: '0 0',
138+
cursor: diagramState.dragging ? 'grabbing' : 'grab',
139+
}}
140+
onMouseDown={handleMouseDown}
141+
onMouseMove={handleMouseMove}
142+
onMouseUp={handleMouseUp}
143+
onMouseLeave={handleMouseUp}
144+
onTouchStart={handleTouchStart}
145+
onTouchMove={handleTouchMove}
146+
onTouchEnd={handleTouchEnd}
147+
>
148+
{chart}
149+
</div>
150+
<MermaidDiagramControls
151+
handleZoomIn={handleZoomIn}
152+
handleZoomOut={handleZoomOut}
153+
diagramRef={diagramRef}
154+
sourceCode={chart}
155+
/>
156+
</div>
157+
);
158+
} else {
159+
return <p>{diagramError}</p>;
160+
}
161+
});
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import IconButton from "@material-ui/core/IconButton";
2+
import { ZoomInRounded, ZoomOutRounded, SaveAltRounded, FileCopyOutlined } from "@material-ui/icons";
3+
import { makeStyles } from "@material-ui/core";
4+
import React, { useCallback } from "react";
5+
import Divider from "@material-ui/core/Divider";
6+
7+
const useStyles = makeStyles(
8+
() => ({
9+
container: {
10+
display: 'flex',
11+
flexDirection: 'column',
12+
alignItems: 'center',
13+
14+
position: 'absolute',
15+
bottom: 20,
16+
right: 10,
17+
zIndex: 2,
18+
},
19+
controlButtons: {
20+
display: 'flex',
21+
flexDirection: 'column',
22+
alignItems: 'center',
23+
24+
border: '1px solid rgba(0, 0, 0, 0.12)',
25+
borderRadius: 8,
26+
27+
background: 'white',
28+
29+
"& .MuiIconButton-root": {
30+
fontSize: '1.5rem',
31+
color: 'rgba(0, 0, 0, 0.72)',
32+
padding: 8,
33+
'&:hover': {
34+
color: 'rgba(0, 0, 0, 0.95)',
35+
},
36+
'&:first-child': {
37+
borderRadius: '8px 8px 0 0',
38+
},
39+
'&:last-child': {
40+
borderRadius: ' 0 0 8px 8px',
41+
}
42+
}
43+
},
44+
divider: {
45+
width: 'calc(100% - 8px)',
46+
},
47+
actionButton: {
48+
fontSize: '1.5rem',
49+
color: 'rgba(0, 0, 0, 0.72)',
50+
padding: 8,
51+
marginBottom: 8,
52+
'&:hover': {
53+
color: 'rgba(0, 0, 0, 0.95)',
54+
},
55+
}
56+
}))
57+
58+
59+
type MermaidDiagramControlsProps = {
60+
handleZoomIn: () => void,
61+
handleZoomOut: () => void,
62+
diagramRef: React.RefObject<HTMLDivElement>,
63+
sourceCode: string
64+
}
65+
66+
export const MermaidDiagramControls = (props: MermaidDiagramControlsProps) => {
67+
const { sourceCode, handleZoomOut, handleZoomIn, diagramRef } = props;
68+
const classes = useStyles();
69+
70+
const handleSaveClick = useCallback(() => {
71+
if (diagramRef.current) {
72+
const svgElement = diagramRef.current.querySelector('svg');
73+
if (svgElement) {
74+
const svgData = new XMLSerializer().serializeToString(svgElement);
75+
const svgBlob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' });
76+
const url = URL.createObjectURL(svgBlob);
77+
78+
const link = document.createElement('a');
79+
link.href = url;
80+
link.download = 'er-diagram.svg';
81+
document.body.appendChild(link);
82+
link.click();
83+
document.body.removeChild(link);
84+
85+
URL.revokeObjectURL(url);
86+
}
87+
}
88+
}, []);
89+
90+
const handleCopyClick = async () => {
91+
if ('clipboard' in navigator) {
92+
await navigator.clipboard.writeText(sourceCode);
93+
}
94+
}
95+
96+
return (
97+
<div className={classes.container}>
98+
<IconButton
99+
title="Copy contents"
100+
aria-label="Copy contents"
101+
className={classes.actionButton}
102+
onClick={handleCopyClick}
103+
>
104+
<FileCopyOutlined />
105+
</IconButton>
106+
<IconButton
107+
title="Download as SVG"
108+
aria-label="Download diagram as SVG"
109+
className={classes.actionButton}
110+
onClick={handleSaveClick}
111+
>
112+
<SaveAltRounded />
113+
</IconButton>
114+
115+
<div className={classes.controlButtons}>
116+
<IconButton
117+
onClick={handleZoomIn}
118+
title="Zoom In"
119+
aria-label="Zoom In"
120+
>
121+
<ZoomInRounded />
122+
</IconButton>
123+
<Divider className={classes.divider} />
124+
<IconButton
125+
onClick={handleZoomOut}
126+
title="Zoom Out"
127+
aria-label="Zoom Out"
128+
>
129+
<ZoomOutRounded />
130+
</IconButton>
131+
</div>
132+
</div>
133+
)
134+
}

ui/packages/platform/src/pages/Bot/utils.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,4 +57,38 @@ export const createMessageFragment = (messages: DebugMessage[]): DocumentFragmen
5757
});
5858

5959
return fragment;
60-
};
60+
};
61+
62+
export const formatLanguageName = (language: string): string => {
63+
const specificCases: { [key: string]: string } = {
64+
"sql": "SQL",
65+
"pl/pgsql": "PL/pgSQL",
66+
"pl/python": "PL/Python",
67+
"json": "JSON",
68+
"yaml": "YAML",
69+
"html": "HTML",
70+
"xml": "XML",
71+
"css": "CSS",
72+
"csv": "CSV",
73+
"toml": "TOML",
74+
"ini": "INI",
75+
"r": "R",
76+
"php": "PHP",
77+
"sqlalchemy": "SQLAlchemy",
78+
"xslt": "XSLT",
79+
"xsd": "XSD",
80+
"ajax": "AJAX",
81+
"tsql": "TSQL",
82+
"pl/sql": "PL/SQL",
83+
"dax": "DAX",
84+
"sparql": "SPARQL"
85+
};
86+
87+
const normalizedLanguage = language.toLowerCase();
88+
89+
if (specificCases[normalizedLanguage]) {
90+
return specificCases[normalizedLanguage];
91+
} else {
92+
return language.charAt(0).toUpperCase() + language.slice(1).toLowerCase();
93+
}
94+
}

0 commit comments

Comments
 (0)