2025-07-11 19:16:16 +03:00

314 lines
9.9 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useState, useCallback } from 'react';
import ReactFlow, {
addEdge,
Background,
Controls,
useNodesState,
useEdgesState,
} from 'reactflow';
import 'reactflow/dist/style.css';
import {
fetchDiagramByName,
saveDiagramByName,
listDiagrams,
deleteDiagramByName,
} from '../services/api';
import CustomNode from './CustomNode';
import IconSelector from './IconSelector';
import { toast } from 'react-toastify';
const nodeTypes = {
custom: CustomNode,
};
function Diagram() {
const [nodes, setNodes, onNodesChange] = useNodesState([]);
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
const [selectedNode, setSelectedNode] = useState(null);
const [showForm, setShowForm] = useState(false);
const [newLabel, setNewLabel] = useState('');
const [selectedIcon, setSelectedIcon] = useState('');
const [selectedEdge, setSelectedEdge] = useState(null);
const [diagramName, setDiagramName] = useState(null);
const [diagramList, setDiagramList] = useState([]);
const isIframe = window.self !== window.top;
useEffect(() => {
let isMounted = true;
const load = async () => {
try {
const { diagrams } = await listDiagrams();
if (isMounted) {
setDiagramList(diagrams);
if (diagrams.length > 0) {
setDiagramName((prev) => prev || diagrams[0]);
} else {
setDiagramName(null);
}
}
} catch (err) {
console.error(err);
setDiagramList([]);
setDiagramName(null);
}
};
load();
return () => {
isMounted = false;
};
}, []);
useEffect(() => {
let isMounted = true;
const loadDiagram = async () => {
if (!diagramName) {
setNodes([]);
setEdges([]);
return;
}
try {
const data = await fetchDiagramByName(diagramName);
if (isMounted) {
setNodes(data.nodes || []);
const sanitizedEdges = (data.edges || []).map(({ id, source, target, animated }) => ({
id,
source,
target,
animated: !!animated,
}));
setEdges(sanitizedEdges);
setSelectedNode(null);
setSelectedEdge(null);
}
} catch (err) {
console.error(err);
toast.error(`❌ Failed to load diagram "${diagramName}"`);
setNodes([]);
setEdges([]);
}
};
loadDiagram();
return () => {
isMounted = false;
};
}, [diagramName, setNodes, setEdges]);
const handleSave = async () => {
if (!diagramName) return;
const cleanedEdges = edges.map(({ id, source, target, animated }) => ({
id,
source,
target,
animated: !!animated,
}));
await saveDiagramByName(diagramName, { nodes, edges: cleanedEdges });
toast.success(`✅ Diagram "${diagramName}" saved!`);
};
const handleAddNode = () => {
setShowForm(true);
};
const handleSubmitNode = () => {
const id = (nodes.length + 1).toString();
const label = newLabel || `Node ${id}`;
const icon = selectedIcon || 'https://s3.dvirlabs.com/lab-icons/default.svg';
const newNode = {
id,
type: 'custom',
data: { label, icon },
position: { x: Math.random() * 400, y: Math.random() * 300 },
};
setNodes((nds) => [...nds, newNode]);
toast.success(`🟢 Node "${label}" added`);
setShowForm(false);
setNewLabel('');
setSelectedIcon('');
};
const handleDeleteNode = () => {
if (!selectedNode) return;
setNodes((nds) => nds.filter((n) => n.id !== selectedNode.id));
setEdges((eds) => eds.filter((e) => e.source !== selectedNode.id && e.target !== selectedNode.id));
toast.error(`🗑️ Node "${selectedNode.data.label}" deleted`);
setSelectedNode(null);
};
const onEdgeClick = (_, edge) => {
setSelectedEdge(edge);
setSelectedNode(null);
};
const handleDeleteEdge = () => {
if (!selectedEdge) return;
setEdges((eds) => eds.filter((e) => e.id !== selectedEdge.id));
toast.error(`🗑️ Edge "${selectedEdge.id}" deleted`);
setSelectedEdge(null);
};
const onConnect = useCallback(
(params) => setEdges((eds) => addEdge({ ...params, animated: true }, eds)),
[setEdges]
);
const handleNodeDoubleClick = (event, node) => {
const newLabel = prompt('Enter new name:', node.data.label);
if (newLabel !== null) {
setNodes((nds) =>
nds.map((n) =>
n.id === node.id ? { ...n, data: { ...n.data, label: newLabel } } : n
)
);
toast.info(`✏️ Node renamed to "${newLabel}"`);
}
};
const onNodeClick = (_, node) => {
setSelectedNode(node);
};
const handleDeleteDiagram = async () => {
if (!diagramName || diagramName === 'default') {
toast.warn("❌ Cannot delete 'default' diagram or nothing selected.");
return;
}
const confirmDelete = confirm(`Are you sure you want to delete diagram "${diagramName}"?`);
if (!confirmDelete) return;
try {
await deleteDiagramByName(diagramName);
const updatedList = diagramList.filter(name => name !== diagramName);
setDiagramList(updatedList);
const newSelected = updatedList[0] || null;
setDiagramName(newSelected);
setNodes([]);
setEdges([]);
toast.success(`🗑️ Diagram "${diagramName}" deleted`);
} catch (err) {
toast.error(`❌ Failed to delete diagram "${diagramName}"`);
}
};
return (
<div style={{ width: '100vw', height: '100vh' }}>
<div style={{ position: 'absolute', zIndex: 10, right: 10, top: 10, display: 'flex', alignItems: 'center', gap: '10px', height: '48px' }}>
<div style={{ position: 'relative', display: 'flex', alignItems: 'center' }}>
<select
value={diagramName || ''}
onChange={(e) => setDiagramName(e.target.value)}
style={{
padding: '10px 16px',
height: '48px',
fontSize: '16px',
fontWeight: 'bold',
borderRadius: '12px',
border: 'none',
background: '#111',
color: 'white',
cursor: 'pointer',
appearance: 'none',
width: 180,
paddingRight: 32,
}}
>
{diagramList.map((name) => (
<option key={name} value={name} style={{ color: 'white', font: 'bold 16px Arial' }}>
📌 {name}
</option>
))}
</select>
<div
style={{
position: 'absolute',
right: 12,
top: '50%',
transform: 'translateY(-50%)',
pointerEvents: 'none',
fontSize: 16,
color: 'white',
}}
>
</div>
</div>
{!isIframe && (
<>
<button className="btn" style={{ background: '#d28519' }} onClick={() => {
const newName = prompt("Enter new diagram name:");
if (newName) {
if (!diagramList.includes(newName)) {
const updatedList = [...diagramList, newName];
setDiagramList(updatedList);
}
setDiagramName(newName);
setNodes([]);
setEdges([]);
setSelectedNode(null);
setSelectedEdge(null);
}
}}>🆕 New Diagram</button>
<button className="btn" onClick={handleSave} style={{ background: 'green' }}>💾 Save</button>
<button className="btn" onClick={handleAddNode} style={{ background: 'blue' }}> Add Node</button>
<button className="btn" onClick={handleDeleteNode} style={{ background: selectedNode ? '#b81a1a' : '#ccc', color: selectedNode ? 'white' : '#666', cursor: selectedNode ? 'pointer' : 'not-allowed' }} disabled={!selectedNode}>🗑 Delete Node</button>
<button className="btn" onClick={handleDeleteEdge} style={{ background: selectedEdge ? '#b81a1a' : '#ccc', color: selectedEdge ? 'white' : '#666', cursor: selectedEdge ? 'pointer' : 'not-allowed' }} disabled={!selectedEdge}>🗑 Delete Edge</button>
<button className="btn" onClick={handleDeleteDiagram} style={{ background: 'red' }}>🗑 Delete Diagram</button>
</>
)}
</div>
{showForm && (
<div style={{ position: 'absolute', zIndex: 20, right: 10, top: 80, background: '#e6f0fa', padding: '16px', borderRadius: '12px', boxShadow: '0 4px 8px rgba(0,0,0,0.15)', width: 280 }}>
<h4 style={{ color: '#125ea8', marginBottom: 12, fontSize: 18, display: 'flex', alignItems: 'center', gap: 8 }}>🧩 Add New Node</h4>
<label style={{ fontWeight: 'bold', marginBottom: 4, display: 'block', color: '#333' }}>Label:</label>
<input
type="text"
placeholder="Enter label"
value={newLabel}
onChange={(e) => setNewLabel(e.target.value)}
style={{ padding: '6px', width: '100%', borderRadius: 6, border: '1px solid #ccc', marginBottom: 12 }}
/>
<IconSelector onSelect={setSelectedIcon} />
<div style={{ marginTop: 12 }}>
<button className="btn" onClick={handleSubmitNode}> Add</button>
<button className="btn" style={{ marginLeft: 8, background: '#ccc', color: '#333' }} onClick={() => setShowForm(false)}> Cancel</button>
</div>
</div>
)}
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onNodeClick={onNodeClick}
onNodeDoubleClick={handleNodeDoubleClick}
onEdgeClick={onEdgeClick}
onPaneClick={() => {
setSelectedNode(null);
setSelectedEdge(null);
}}
nodeTypes={nodeTypes}
fitView
>
<Background />
<Controls />
</ReactFlow>
</div>
);
}
export default Diagram;