685 lines
24 KiB
JavaScript
685 lines
24 KiB
JavaScript
import React, { useMemo, useCallback } from 'react';
|
|
import ReactFlow, {
|
|
Controls,
|
|
MiniMap,
|
|
Background,
|
|
useNodesState,
|
|
useEdgesState,
|
|
addEdge,
|
|
ConnectionLineType,
|
|
Panel,
|
|
Handle,
|
|
Position,
|
|
} from 'reactflow';
|
|
import 'reactflow/dist/style.css';
|
|
import {
|
|
Braces,
|
|
List,
|
|
Type,
|
|
Hash,
|
|
ToggleLeft,
|
|
FileText,
|
|
Zap,
|
|
Copy,
|
|
Eye,
|
|
Code,
|
|
Maximize,
|
|
Minimize,
|
|
Sparkles,
|
|
Settings,
|
|
ChevronDown,
|
|
ChevronUp
|
|
} from 'lucide-react';
|
|
|
|
// Custom node component for different data types
|
|
const CustomNode = ({ data, selected }) => {
|
|
const [renderHtml, setRenderHtml] = React.useState(true);
|
|
|
|
// Check if value contains HTML
|
|
const isHtmlContent = data.value && typeof data.value === 'string' &&
|
|
(data.value.includes('<') && data.value.includes('>'));
|
|
|
|
// Copy value to clipboard
|
|
const copyValue = async () => {
|
|
if (data.value) {
|
|
try {
|
|
await navigator.clipboard.writeText(String(data.value));
|
|
} catch (err) {
|
|
console.error('Failed to copy:', err);
|
|
}
|
|
}
|
|
};
|
|
const getIcon = () => {
|
|
switch (data.type) {
|
|
case 'object':
|
|
return <Braces className="h-4 w-4" />;
|
|
case 'array':
|
|
return <List className="h-4 w-4" />;
|
|
case 'string':
|
|
return <Type className="h-4 w-4" />;
|
|
case 'number':
|
|
return <Hash className="h-4 w-4" />;
|
|
case 'boolean':
|
|
return <ToggleLeft className="h-4 w-4" />;
|
|
case 'null':
|
|
return <Zap className="h-4 w-4" />;
|
|
default:
|
|
return <FileText className="h-4 w-4" />;
|
|
}
|
|
};
|
|
|
|
const getNodeColor = () => {
|
|
switch (data.type) {
|
|
case 'object':
|
|
return 'bg-blue-100 border-blue-300 text-blue-800 dark:bg-blue-900/20 dark:border-blue-700 dark:text-blue-300';
|
|
case 'array':
|
|
return 'bg-green-100 border-green-300 text-green-800 dark:bg-green-900/20 dark:border-green-700 dark:text-green-300';
|
|
case 'string':
|
|
return 'bg-purple-100 border-purple-300 text-purple-800 dark:bg-purple-900/20 dark:border-purple-700 dark:text-purple-300';
|
|
case 'number':
|
|
return 'bg-orange-100 border-orange-300 text-orange-800 dark:bg-orange-900/20 dark:border-orange-700 dark:text-orange-300';
|
|
case 'boolean':
|
|
return 'bg-yellow-100 border-yellow-300 text-yellow-800 dark:bg-yellow-900/20 dark:border-yellow-700 dark:text-yellow-300';
|
|
case 'null':
|
|
return 'bg-gray-100 border-gray-300 text-gray-800 dark:bg-gray-900/20 dark:border-gray-700 dark:text-gray-300';
|
|
default:
|
|
return 'bg-gray-100 border-gray-300 text-gray-800 dark:bg-gray-900/20 dark:border-gray-700 dark:text-gray-300';
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className={`px-3 py-2 shadow-md rounded-md border-2 ${getNodeColor()} min-w-24 max-w-64 relative text-xs`}>
|
|
{/* Input handle (left side) */}
|
|
<Handle
|
|
type="target"
|
|
position={Position.Left}
|
|
style={{
|
|
background: '#555',
|
|
width: 6,
|
|
height: 6,
|
|
border: '1px solid #fff',
|
|
opacity: selected ? 1 : 0.3,
|
|
}}
|
|
/>
|
|
|
|
{/* Top-right controls - HTML render toggle only */}
|
|
{isHtmlContent && (
|
|
<div className="absolute -top-1 -right-1 flex">
|
|
<button
|
|
onClick={() => setRenderHtml(true)}
|
|
className={`px-1.5 py-0.5 text-xs rounded-l ${
|
|
renderHtml
|
|
? 'bg-blue-500 text-white'
|
|
: 'bg-gray-200 text-gray-600 hover:bg-gray-300'
|
|
}`}
|
|
title="Render HTML"
|
|
>
|
|
<Eye className="h-3 w-3" />
|
|
</button>
|
|
<button
|
|
onClick={() => setRenderHtml(false)}
|
|
className={`px-1.5 py-0.5 text-xs rounded-r ${
|
|
!renderHtml
|
|
? 'bg-blue-500 text-white'
|
|
: 'bg-gray-200 text-gray-600 hover:bg-gray-300'
|
|
}`}
|
|
title="Show raw HTML"
|
|
>
|
|
<Code className="h-3 w-3" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex items-start space-x-2 group">
|
|
<div className="flex-shrink-0 flex flex-col items-center space-y-1">
|
|
<div className="mt-0.5">
|
|
{getIcon()}
|
|
</div>
|
|
{/* Copy button positioned below icon */}
|
|
{data.value && (
|
|
<button
|
|
onClick={copyValue}
|
|
className="bg-blue-500 hover:bg-blue-600 text-white rounded-full p-1 opacity-80 hover:opacity-100 transition-all shadow-md"
|
|
title="Copy value to clipboard"
|
|
>
|
|
<Copy className="h-2.5 w-2.5" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="font-medium text-xs break-words">
|
|
{data.label}
|
|
</div>
|
|
{data.value !== undefined && (
|
|
<div className="text-xs opacity-75 mt-1 leading-relaxed">
|
|
{isHtmlContent && renderHtml ? (
|
|
<div
|
|
className="break-words"
|
|
dangerouslySetInnerHTML={{ __html: String(data.value) }}
|
|
/>
|
|
) : (
|
|
<div className="break-words whitespace-pre-wrap">
|
|
{String(data.value)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
{data.count !== undefined && (
|
|
<div className="text-xs opacity-75 mt-1">
|
|
{data.count} items
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Output handle (right side) */}
|
|
<Handle
|
|
type="source"
|
|
position={Position.Right}
|
|
style={{
|
|
background: '#555',
|
|
width: 6,
|
|
height: 6,
|
|
border: '1px solid #fff',
|
|
opacity: selected ? 1 : 0.3,
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const nodeTypes = {
|
|
custom: CustomNode,
|
|
};
|
|
|
|
const MindmapView = React.memo(({ data }) => {
|
|
// User preferences state
|
|
const [edgeType, setEdgeType] = React.useState('default'); // bezier as default
|
|
const [layoutCompact, setLayoutCompact] = React.useState(true);
|
|
const [edgeColor, setEdgeColor] = React.useState('#9ca3af'); // gray-400 default
|
|
const [isFullscreen, setIsFullscreen] = React.useState(false);
|
|
const [snapToGrid, setSnapToGrid] = React.useState(true);
|
|
const [activePanel, setActivePanel] = React.useState(null); // 'controls', 'legend', or null
|
|
// Convert JSON data to React Flow nodes and edges
|
|
const { nodes: initialNodes, edges: initialEdges } = useMemo(() => {
|
|
const nodes = [];
|
|
const edges = [];
|
|
let nodeId = 0;
|
|
const nodeInfo = new Map(); // Store node info for positioning
|
|
|
|
// First pass: create nodes and collect structure info
|
|
const createNodeStructure = (value, key, parentId = null, level = 0) => {
|
|
const currentId = `node-${nodeId++}`;
|
|
|
|
// Determine node type and properties
|
|
let nodeType, nodeLabel, nodeValue, nodeCount;
|
|
|
|
if (value === null) {
|
|
nodeType = 'null';
|
|
nodeLabel = key || 'null';
|
|
nodeValue = 'null';
|
|
} else if (typeof value === 'boolean') {
|
|
nodeType = 'boolean';
|
|
nodeLabel = key || 'boolean';
|
|
nodeValue = String(value);
|
|
} else if (typeof value === 'number') {
|
|
nodeType = 'number';
|
|
nodeLabel = key || 'number';
|
|
nodeValue = String(value);
|
|
} else if (typeof value === 'string') {
|
|
nodeType = 'string';
|
|
nodeLabel = key || 'string';
|
|
nodeValue = value; // No truncation
|
|
} else if (Array.isArray(value)) {
|
|
nodeType = 'array';
|
|
nodeLabel = key || 'Array';
|
|
nodeCount = value.length;
|
|
} else if (typeof value === 'object') {
|
|
nodeType = 'object';
|
|
nodeLabel = key || 'Object';
|
|
nodeCount = Object.keys(value).length;
|
|
}
|
|
|
|
// Store node info
|
|
nodeInfo.set(currentId, {
|
|
id: currentId,
|
|
parentId,
|
|
level,
|
|
type: nodeType,
|
|
label: nodeLabel,
|
|
value: nodeValue,
|
|
count: nodeCount,
|
|
children: []
|
|
});
|
|
|
|
// Add to parent's children
|
|
if (parentId && nodeInfo.has(parentId)) {
|
|
nodeInfo.get(parentId).children.push(currentId);
|
|
}
|
|
|
|
// Create edge from parent
|
|
if (parentId) {
|
|
edges.push({
|
|
id: `edge-${parentId}-${currentId}`,
|
|
source: parentId,
|
|
target: currentId,
|
|
type: edgeType,
|
|
style: {
|
|
stroke: edgeColor,
|
|
strokeWidth: 2,
|
|
},
|
|
markerEnd: {
|
|
type: 'arrowclosed',
|
|
color: edgeColor,
|
|
},
|
|
});
|
|
}
|
|
|
|
// Recursively create child nodes
|
|
if (Array.isArray(value)) {
|
|
value.forEach((item, idx) => {
|
|
createNodeStructure(item, `[${idx}]`, currentId, level + 1);
|
|
});
|
|
} else if (typeof value === 'object' && value !== null) {
|
|
Object.entries(value).forEach(([childKey, childValue]) => {
|
|
createNodeStructure(childValue, childKey, currentId, level + 1);
|
|
});
|
|
}
|
|
|
|
return currentId;
|
|
};
|
|
|
|
// Estimate node height based on content
|
|
const estimateNodeHeight = (node) => {
|
|
const baseHeight = 40; // Base node height
|
|
const lineHeight = 16; // Approximate line height
|
|
|
|
if (node.value && typeof node.value === 'string') {
|
|
// Estimate lines needed for text wrapping (assuming ~30 chars per line)
|
|
const estimatedLines = Math.ceil(node.value.length / 30);
|
|
return baseHeight + (estimatedLines - 1) * lineHeight;
|
|
}
|
|
|
|
return baseHeight;
|
|
};
|
|
|
|
// Second pass: calculate positions with vertical centering and collision avoidance
|
|
const calculatePositions = (nodeId, startY = 0) => {
|
|
const node = nodeInfo.get(nodeId);
|
|
if (!node) return startY;
|
|
|
|
const baseSpacing = layoutCompact ? 120 : 180;
|
|
const nodeHeight = estimateNodeHeight(node);
|
|
const minSpacing = Math.max(baseSpacing, nodeHeight + 20);
|
|
|
|
if (node.children.length === 0) {
|
|
// Leaf node
|
|
const x = node.level * (layoutCompact ? 250 : 350);
|
|
nodes.push({
|
|
id: nodeId,
|
|
type: 'custom',
|
|
position: { x, y: startY },
|
|
data: {
|
|
label: node.label,
|
|
type: node.type,
|
|
value: node.value,
|
|
count: node.count,
|
|
},
|
|
});
|
|
return startY + minSpacing;
|
|
} else {
|
|
// Parent node - calculate children positions first
|
|
let childStartY = startY;
|
|
let childEndY = startY;
|
|
const childPositions = [];
|
|
|
|
node.children.forEach(childId => {
|
|
const childNode = nodeInfo.get(childId);
|
|
const childHeight = estimateNodeHeight(childNode);
|
|
const childSpacing = Math.max(baseSpacing, childHeight + 20);
|
|
|
|
childEndY = calculatePositions(childId, childStartY);
|
|
childPositions.push({ start: childStartY, end: childEndY - childSpacing });
|
|
childStartY = childEndY;
|
|
});
|
|
|
|
// Center parent between first and last child, but ensure minimum spacing
|
|
let centerY;
|
|
if (childPositions.length > 0) {
|
|
const firstChildY = childPositions[0].start;
|
|
const lastChildY = childPositions[childPositions.length - 1].end;
|
|
centerY = (firstChildY + lastChildY) / 2;
|
|
|
|
// Ensure parent doesn't overlap with any child
|
|
const parentHeight = nodeHeight;
|
|
const parentTop = centerY - parentHeight / 2;
|
|
const parentBottom = centerY + parentHeight / 2;
|
|
|
|
// Check for overlaps and adjust if needed
|
|
for (const childPos of childPositions) {
|
|
if (parentBottom > childPos.start && parentTop < childPos.end) {
|
|
// Overlap detected, move parent up
|
|
centerY = childPos.start - parentHeight / 2 - 10;
|
|
break;
|
|
}
|
|
}
|
|
} else {
|
|
centerY = startY;
|
|
}
|
|
|
|
const x = node.level * (layoutCompact ? 250 : 350);
|
|
|
|
nodes.push({
|
|
id: nodeId,
|
|
type: 'custom',
|
|
position: { x, y: centerY },
|
|
data: {
|
|
label: node.label,
|
|
type: node.type,
|
|
value: node.value,
|
|
count: node.count,
|
|
},
|
|
});
|
|
|
|
return childEndY;
|
|
}
|
|
};
|
|
|
|
if (data && Object.keys(data).length > 0) {
|
|
const rootId = createNodeStructure(data, 'root', null, 0);
|
|
calculatePositions(rootId, 0);
|
|
} else {
|
|
// Empty state
|
|
nodes.push({
|
|
id: 'empty',
|
|
type: 'custom',
|
|
position: { x: 100, y: 100 },
|
|
data: {
|
|
label: 'No Data',
|
|
type: 'null',
|
|
value: 'Add data to see mindmap',
|
|
},
|
|
});
|
|
}
|
|
|
|
return { nodes, edges };
|
|
}, [data, edgeType, layoutCompact, edgeColor]);
|
|
|
|
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
|
|
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
|
|
|
|
// React Flow instance ref for fitView
|
|
const reactFlowInstance = React.useRef(null);
|
|
|
|
// Memoize the tidy up function to prevent unnecessary re-renders
|
|
const tidyUpNodes = useCallback(() => {
|
|
setTimeout(() => {
|
|
setNodes(initialNodes);
|
|
}, 0);
|
|
}, [initialNodes, setNodes]);
|
|
|
|
// Accordion panel toggles
|
|
const toggleControls = () => {
|
|
setActivePanel(activePanel === 'controls' ? null : 'controls');
|
|
};
|
|
|
|
const toggleLegend = () => {
|
|
setActivePanel(activePanel === 'legend' ? null : 'legend');
|
|
};
|
|
|
|
// Toggle fullscreen with fitView
|
|
const toggleFullscreen = () => {
|
|
setIsFullscreen(!isFullscreen);
|
|
// Trigger fitView after a short delay to allow DOM to update
|
|
setTimeout(() => {
|
|
if (reactFlowInstance.current) {
|
|
reactFlowInstance.current.fitView({
|
|
padding: 0.1,
|
|
minZoom: 0.5,
|
|
maxZoom: 1.5,
|
|
duration: 300
|
|
});
|
|
}
|
|
}, 100);
|
|
};
|
|
|
|
const onConnect = useCallback(
|
|
(params) => setEdges((eds) => addEdge(params, eds)),
|
|
[setEdges]
|
|
);
|
|
|
|
|
|
// Update nodes when data changes with debouncing to prevent ResizeObserver errors
|
|
React.useEffect(() => {
|
|
const timeoutId = setTimeout(() => {
|
|
setNodes(initialNodes);
|
|
setEdges(initialEdges);
|
|
}, 0);
|
|
|
|
return () => clearTimeout(timeoutId);
|
|
}, [initialNodes, initialEdges, setNodes, setEdges]);
|
|
|
|
// Suppress ResizeObserver errors
|
|
React.useEffect(() => {
|
|
const handleResizeObserverError = (e) => {
|
|
if (e.message === 'ResizeObserver loop completed with undelivered notifications.') {
|
|
e.stopImmediatePropagation();
|
|
}
|
|
};
|
|
|
|
window.addEventListener('error', handleResizeObserverError);
|
|
return () => window.removeEventListener('error', handleResizeObserverError);
|
|
}, []);
|
|
|
|
return (
|
|
<div className={`w-full border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-800 ${
|
|
isFullscreen
|
|
? 'fixed inset-0 z-50 rounded-none'
|
|
: 'h-[600px]'
|
|
}`}>
|
|
<ReactFlow
|
|
nodes={nodes}
|
|
edges={edges}
|
|
onNodesChange={onNodesChange}
|
|
onEdgesChange={onEdgesChange}
|
|
onConnect={onConnect}
|
|
onInit={(instance) => { reactFlowInstance.current = instance; }}
|
|
nodeTypes={nodeTypes}
|
|
connectionLineType={ConnectionLineType.SmoothStep}
|
|
fitView
|
|
fitViewOptions={{
|
|
padding: 0.1,
|
|
minZoom: 0.5,
|
|
maxZoom: 1.5,
|
|
}}
|
|
minZoom={0.3}
|
|
maxZoom={2}
|
|
snapToGrid={snapToGrid}
|
|
snapGrid={[20, 20]}
|
|
proOptions={{ hideAttribution: true }}
|
|
defaultEdgeOptions={{
|
|
type: edgeType,
|
|
style: { stroke: edgeColor, strokeWidth: 2 },
|
|
markerEnd: { type: 'arrowclosed', color: edgeColor }
|
|
}}
|
|
>
|
|
<Controls />
|
|
<MiniMap
|
|
nodeColor={(node) => {
|
|
switch (node.data.type) {
|
|
case 'object': return '#3b82f6';
|
|
case 'array': return '#10b981';
|
|
case 'string': return '#8b5cf6';
|
|
case 'number': return '#f59e0b';
|
|
case 'boolean': return '#eab308';
|
|
case 'null': return '#6b7280';
|
|
default: return '#6b7280';
|
|
}
|
|
}}
|
|
maskColor="rgb(240, 240, 240, 0.6)"
|
|
/>
|
|
<Background variant="dots" gap={12} size={1} />
|
|
{/* Top Right Accordion Panel Stack */}
|
|
<Panel position="top-right">
|
|
<div className="space-y-2">
|
|
{/* Controls Toggle Button */}
|
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700">
|
|
<button
|
|
onClick={toggleControls}
|
|
className="w-full flex items-center justify-between p-3 text-xs font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors rounded-lg"
|
|
>
|
|
<div className="flex items-center space-x-2">
|
|
<Settings className="h-4 w-4" />
|
|
<span>Controls</span>
|
|
</div>
|
|
{activePanel === 'controls' ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Controls Panel Content - Accordion Style */}
|
|
{activePanel === 'controls' && (
|
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 p-4 transition-all duration-200 ease-in-out">
|
|
<div className="space-y-3">
|
|
<div>
|
|
<label className="text-xs text-gray-600 dark:text-gray-400 block mb-1">Edge Type</label>
|
|
<select
|
|
value={edgeType}
|
|
onChange={(e) => setEdgeType(e.target.value)}
|
|
className="text-xs border border-gray-300 dark:border-gray-600 rounded px-2 py-1 bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 w-full"
|
|
>
|
|
<option value="default">Bezier</option>
|
|
<option value="straight">Straight</option>
|
|
<option value="step">Step</option>
|
|
<option value="smoothstep">Smooth Step</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="text-xs text-gray-600 dark:text-gray-400 block mb-1">Line Color</label>
|
|
<select
|
|
value={edgeColor}
|
|
onChange={(e) => setEdgeColor(e.target.value)}
|
|
className="text-xs border border-gray-300 dark:border-gray-600 rounded px-2 py-1 bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 w-full"
|
|
>
|
|
<option value="#9ca3af">Gray (Default)</option>
|
|
<option value="#6366f1">Blue</option>
|
|
<option value="#10b981">Green</option>
|
|
<option value="#f59e0b">Orange</option>
|
|
<option value="#ef4444">Red</option>
|
|
<option value="#8b5cf6">Purple</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div className="flex items-center space-x-2">
|
|
<input
|
|
type="checkbox"
|
|
id="compact"
|
|
checked={layoutCompact}
|
|
onChange={(e) => setLayoutCompact(e.target.checked)}
|
|
className="text-xs"
|
|
/>
|
|
<label htmlFor="compact" className="text-xs text-gray-600 dark:text-gray-400">Compact Layout</label>
|
|
</div>
|
|
|
|
<div className="flex items-center space-x-2">
|
|
<input
|
|
type="checkbox"
|
|
id="snapToGrid"
|
|
checked={snapToGrid}
|
|
onChange={(e) => setSnapToGrid(e.target.checked)}
|
|
className="text-xs"
|
|
/>
|
|
<label htmlFor="snapToGrid" className="text-xs text-gray-600 dark:text-gray-400">Snap to Grid</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Legend Toggle Button */}
|
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700">
|
|
<button
|
|
onClick={toggleLegend}
|
|
className="w-full flex items-center justify-between p-3 text-xs font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors rounded-lg"
|
|
>
|
|
<div className="flex items-center space-x-2">
|
|
<FileText className="h-4 w-4" />
|
|
<span>Legend</span>
|
|
</div>
|
|
{activePanel === 'legend' ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Legend Panel Content - Accordion Style */}
|
|
{activePanel === 'legend' && (
|
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 p-4 transition-all duration-200 ease-in-out">
|
|
<div className="space-y-2">
|
|
<div className="flex items-center space-x-2">
|
|
<div className="w-4 h-4 bg-blue-100 border-2 border-blue-300 rounded flex items-center justify-center">
|
|
<Braces className="h-2.5 w-2.5 text-blue-600" />
|
|
</div>
|
|
<span className="text-xs text-gray-600 dark:text-gray-400">Object</span>
|
|
</div>
|
|
<div className="flex items-center space-x-2">
|
|
<div className="w-4 h-4 bg-green-100 border-2 border-green-300 rounded flex items-center justify-center">
|
|
<List className="h-2.5 w-2.5 text-green-600" />
|
|
</div>
|
|
<span className="text-xs text-gray-600 dark:text-gray-400">Array</span>
|
|
</div>
|
|
<div className="flex items-center space-x-2">
|
|
<div className="w-4 h-4 bg-purple-100 border-2 border-purple-300 rounded flex items-center justify-center">
|
|
<Type className="h-2.5 w-2.5 text-purple-600" />
|
|
</div>
|
|
<span className="text-xs text-gray-600 dark:text-gray-400">String</span>
|
|
</div>
|
|
<div className="flex items-center space-x-2">
|
|
<div className="w-4 h-4 bg-orange-100 border-2 border-orange-300 rounded flex items-center justify-center">
|
|
<Hash className="h-2.5 w-2.5 text-orange-600" />
|
|
</div>
|
|
<span className="text-xs text-gray-600 dark:text-gray-400">Number</span>
|
|
</div>
|
|
<div className="flex items-center space-x-2">
|
|
<div className="w-4 h-4 bg-yellow-100 border-2 border-yellow-300 rounded flex items-center justify-center">
|
|
<ToggleLeft className="h-2.5 w-2.5 text-yellow-600" />
|
|
</div>
|
|
<span className="text-xs text-gray-600 dark:text-gray-400">Boolean</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Tidy Up Button */}
|
|
<div className="bg-green-500 hover:bg-green-600 rounded-lg shadow-lg transition-colors">
|
|
<button
|
|
onClick={tidyUpNodes}
|
|
className="w-full flex items-center justify-between p-3 text-xs font-medium text-white transition-colors rounded-lg"
|
|
title="Tidy Up Nodes"
|
|
>
|
|
<div className="flex items-center space-x-2">
|
|
<Sparkles className="h-4 w-4" />
|
|
<span>Tidy Up</span>
|
|
</div>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Fullscreen Button */}
|
|
<div className="bg-blue-500 hover:bg-blue-600 rounded-lg shadow-lg transition-colors">
|
|
<button
|
|
onClick={toggleFullscreen}
|
|
className="w-full flex items-center justify-between p-3 text-xs font-medium text-white transition-colors rounded-lg"
|
|
title={isFullscreen ? 'Exit Fullscreen' : 'Enter Fullscreen'}
|
|
>
|
|
<div className="flex items-center space-x-2">
|
|
{isFullscreen ? <Minimize className="h-4 w-4" /> : <Maximize className="h-4 w-4" />}
|
|
<span>{isFullscreen ? 'Exit Fullscreen' : 'Fullscreen'}</span>
|
|
</div>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</Panel>
|
|
</ReactFlow>
|
|
</div>
|
|
);
|
|
});
|
|
|
|
export default MindmapView;
|