Enhanced mindmap visualization with professional UI

🎯 Major Features Added:
- Snap to grid functionality (20x20 grid, default enabled)
- Tidy Up button for instant node reorganization
- Copy node values with one-click clipboard integration
- HTML rendering toggle (render/raw modes for HTML content)
- Accordion-style collapsible panels (Controls & Legend)
- Automatic fitView on fullscreen toggle with smooth animations

🎨 UI/UX Improvements:
- Professional accordion layout with exclusive panel opening
- Consistent button alignment and styling across all controls
- Legend moved to top-right with icon+color indicators
- Vertical button stack: Controls → Legend → Tidy Up → Fullscreen
- Smooth transitions and hover effects throughout
- Clean, uncluttered interface with folded panels by default

🔧 Technical Enhancements:
- Fixed ResizeObserver errors with proper error handling
- Optimized React rendering with memo and useCallback
- Debounced DOM updates to prevent infinite loops
- React Flow instance management for programmatic control
- Removed redundant Raw Input button for cleaner interface

🚀 Performance & Stability:
- Error boundary implementation for ResizeObserver issues
- Proper cleanup of event listeners and timeouts
- Memoized components to prevent unnecessary re-renders
- Smooth 300ms animations for all state transitions
This commit is contained in:
dwindown
2025-09-21 12:33:39 +07:00
parent 82d14622ac
commit d3ca407777
9 changed files with 1750 additions and 21 deletions

View File

@@ -10,6 +10,7 @@ import CsvJsonTool from './pages/CsvJsonTool';
import BeautifierTool from './pages/BeautifierTool';
import DiffTool from './pages/DiffTool';
import TextLengthTool from './pages/TextLengthTool';
import ObjectEditor from './pages/ObjectEditor';
import './index.css';
@@ -27,6 +28,7 @@ function App() {
<Route path="/beautifier" element={<BeautifierTool />} />
<Route path="/diff" element={<DiffTool />} />
<Route path="/text-length" element={<TextLengthTool />} />
<Route path="/object-editor" element={<ObjectEditor />} />
</Routes>
</Layout>

View File

@@ -1,6 +1,6 @@
import React, { useState, useEffect, useRef } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { Home, Hash, FileText, FileSpreadsheet, Wand2, GitCompare, Menu, X, Database, LinkIcon, Code2, ChevronDown, Type } from 'lucide-react';
import { Home, Hash, FileText, FileSpreadsheet, Wand2, GitCompare, Menu, X, Database, LinkIcon, Code2, ChevronDown, Type, Edit3 } from 'lucide-react';
import ThemeToggle from './ThemeToggle';
import ToolSidebar from './ToolSidebar';
@@ -35,8 +35,7 @@ const Layout = ({ children }) => {
}, [location.pathname]);
const tools = [
{ path: '/json', name: 'JSON Tool', icon: FileText, description: 'Format & validate JSON' },
{ path: '/serialize', name: 'Serialize Tool', icon: Database, description: 'PHP serialize/unserialize' },
{ path: '/object-editor', name: 'Object Editor', icon: Edit3, description: 'Visual editor for JSON & PHP objects' },
{ path: '/url', name: 'URL Tool', icon: LinkIcon, description: 'URL encode/decode' },
{ path: '/base64', name: 'Base64 Tool', icon: Hash, description: 'Base64 encode/decode' },
{ path: '/csv-json', name: 'CSV/JSON Tool', icon: FileSpreadsheet, description: 'Convert CSV ↔ JSON' },

View File

@@ -0,0 +1,687 @@
import React, { useMemo, useCallback } from 'react';
import ReactFlow, {
Node,
Edge,
Controls,
MiniMap,
Background,
useNodesState,
useEdgesState,
addEdge,
ConnectionLineType,
Panel,
Handle,
Position,
} from 'reactflow';
import 'reactflow/dist/style.css';
import {
Braces,
List,
Type,
Hash,
ToggleLeft,
Calendar,
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;

View File

@@ -1,10 +1,24 @@
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import { Plus, Minus, ChevronDown, ChevronRight, Type, Hash, ToggleLeft, List, Braces } from 'lucide-react';
const StructuredEditor = ({ onDataChange, initialData = {} }) => {
const [data, setData] = useState(initialData);
const [expandedNodes, setExpandedNodes] = useState(new Set(['root']));
// Update internal data when initialData prop changes
useEffect(() => {
console.log('📥 INITIAL DATA CHANGED:', {
keys: Object.keys(initialData),
hasData: Object.keys(initialData).length > 0,
data: initialData
});
setData(initialData);
// Expand root node if there's data
if (Object.keys(initialData).length > 0) {
setExpandedNodes(new Set(['root']));
}
}, [initialData]);
const updateData = (newData) => {
console.log('📊 DATA UPDATE:', { keys: Object.keys(newData), totalProps: JSON.stringify(newData).length });
setData(newData);

View File

@@ -1,6 +1,6 @@
import React, { useState } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { Search, FileText, Database, LinkIcon, Hash, FileSpreadsheet, Wand2, GitCompare, Home, ChevronLeft, ChevronRight, Type } from 'lucide-react';
import { Search, FileText, Database, LinkIcon, Hash, FileSpreadsheet, Wand2, GitCompare, Home, ChevronLeft, ChevronRight, Type, Edit3 } from 'lucide-react';
const ToolSidebar = () => {
const location = useLocation();
@@ -9,8 +9,7 @@ const ToolSidebar = () => {
const tools = [
{ path: '/', name: 'Home', icon: Home, description: 'Back to homepage' },
{ path: '/json', name: 'JSON Tool', icon: FileText, description: 'Format & validate JSON' },
{ path: '/serialize', name: 'Serialize Tool', icon: Database, description: 'PHP serialize/unserialize' },
{ path: '/object-editor', name: 'Object Editor', icon: Edit3, description: 'Visual editor for JSON & PHP objects' },
{ path: '/url', name: 'URL Tool', icon: LinkIcon, description: 'URL encode/decode' },
{ path: '/base64', name: 'Base64 Tool', icon: Hash, description: 'Base64 encode/decode' },
{ path: '/csv-json', name: 'CSV/JSON Tool', icon: FileSpreadsheet, description: 'Convert CSV ↔ JSON' },

View File

@@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { Search, Code, Link2, FileText, Hash, RefreshCw, GitCompare, Database, Type } from 'lucide-react';
import { Search, Code, Link2, FileText, Hash, RefreshCw, GitCompare, Database, Type, Edit3 } from 'lucide-react';
import ToolCard from '../components/ToolCard';
const Home = () => {
@@ -7,18 +7,11 @@ const Home = () => {
const tools = [
{
icon: Code,
title: 'JSON Encoder/Decoder',
description: 'Format, validate, and minify JSON data with syntax highlighting',
path: '/json',
tags: ['JSON', 'Format', 'Validate']
},
{
icon: Database,
title: 'Serialize Encoder/Decoder',
description: 'Encode and decode serialized data (PHP serialize format)',
path: '/serialize',
tags: ['PHP', 'Serialize', 'Unserialize']
icon: Edit3,
title: 'Object Editor',
description: 'Visual editor for JSON and PHP serialized objects with format conversion',
path: '/object-editor',
tags: ['Visual', 'JSON', 'PHP', 'Objects', 'Editor']
},
{
icon: Link2,

506
src/pages/ObjectEditor.js Normal file
View File

@@ -0,0 +1,506 @@
import React, { useState, useRef } from 'react';
import { Edit3, Upload, FileText, Download, Copy, Map } from 'lucide-react';
import ToolLayout from '../components/ToolLayout';
import CopyButton from '../components/CopyButton';
import StructuredEditor from '../components/StructuredEditor';
import MindmapView from '../components/MindmapView';
const ObjectEditor = () => {
const [structuredData, setStructuredData] = useState({});
const [showInput, setShowInput] = useState(false);
const [inputText, setInputText] = useState('');
const [inputFormat, setInputFormat] = useState('');
const [inputValid, setInputValid] = useState(false);
const [error, setError] = useState('');
const [viewMode, setViewMode] = useState('visual'); // 'visual', 'mindmap'
const [outputs, setOutputs] = useState({
jsonPretty: '',
jsonMinified: '',
serialized: ''
});
const fileInputRef = useRef(null);
// PHP serialize implementation (reused from SerializeTool)
const phpSerialize = (data) => {
if (data === null) return 'N;';
if (typeof data === 'boolean') return data ? 'b:1;' : 'b:0;';
if (typeof data === 'number') {
return Number.isInteger(data) ? `i:${data};` : `d:${data};`;
}
if (typeof data === 'string') {
const escapedData = data.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
const byteLength = new TextEncoder().encode(escapedData).length;
return `s:${byteLength}:"${escapedData}";`;
}
if (Array.isArray(data)) {
let result = `a:${data.length}:{`;
data.forEach((item, index) => {
result += phpSerialize(index) + phpSerialize(item);
});
result += '}';
return result;
}
if (typeof data === 'object') {
const keys = Object.keys(data);
let result = `a:${keys.length}:{`;
keys.forEach(key => {
result += phpSerialize(key) + phpSerialize(data[key]);
});
result += '}';
return result;
}
return 'N;';
};
// PHP unserialize implementation (reused from SerializeTool)
const phpUnserialize = (str) => {
let index = 0;
const parseValue = () => {
if (index >= str.length) {
throw new Error('Unexpected end of string');
}
const type = str[index];
if (type === 'N') {
index += 2;
return null;
}
if (str[index + 1] !== ':') {
throw new Error(`Expected ':' after type '${type}' at position ${index + 1}`);
}
index += 2;
switch (type) {
case 'b':
const boolVal = str[index] === '1';
index += 2;
return boolVal;
case 'i':
let intStr = '';
while (index < str.length && str[index] !== ';') {
intStr += str[index++];
}
if (index >= str.length) {
throw new Error('Unexpected end of string while parsing integer');
}
index++;
return parseInt(intStr);
case 'd':
let floatStr = '';
while (index < str.length && str[index] !== ';') {
floatStr += str[index++];
}
if (index >= str.length) {
throw new Error('Unexpected end of string while parsing float');
}
index++;
return parseFloat(floatStr);
case 's':
let lenStr = '';
while (index < str.length && str[index] !== ':') {
lenStr += str[index++];
}
if (index >= str.length) {
throw new Error('Unexpected end of string while parsing string length');
}
index++;
if (str[index] !== '"') {
throw new Error(`Expected '"' at position ${index}`);
}
index++;
const byteLength = parseInt(lenStr);
if (isNaN(byteLength) || byteLength < 0) {
throw new Error(`Invalid string length: ${lenStr}`);
}
if (byteLength === 0) {
if (index + 1 >= str.length || str[index] !== '"' || str[index + 1] !== ';') {
throw new Error(`Expected '";' after empty string at position ${index}`);
}
index += 2;
return '';
}
const startIndex = index;
let endQuotePos = -1;
for (let i = startIndex; i < str.length - 1; i++) {
if (str[i] === '"' && str[i + 1] === ';') {
endQuotePos = i;
break;
}
}
if (endQuotePos === -1) {
throw new Error(`Could not find closing '";' for string starting at position ${startIndex}`);
}
const stringVal = str.substring(startIndex, endQuotePos);
index = endQuotePos + 2;
return stringVal;
case 'a':
let arrayLenStr = '';
while (index < str.length && str[index] !== ':') {
arrayLenStr += str[index++];
}
if (index >= str.length) {
throw new Error('Unexpected end of string while parsing array length');
}
index++;
if (str[index] !== '{') {
throw new Error(`Expected '{' at position ${index}`);
}
index++;
const arrayLength = parseInt(arrayLenStr);
if (isNaN(arrayLength) || arrayLength < 0) {
throw new Error(`Invalid array length: ${arrayLenStr}`);
}
const result = {};
let isArray = true;
for (let i = 0; i < arrayLength; i++) {
const key = parseValue();
const value = parseValue();
result[key] = value;
if (typeof key !== 'number' || key !== i) {
isArray = false;
}
}
if (index >= str.length || str[index] !== '}') {
throw new Error(`Expected '}' at position ${index}`);
}
index++;
if (isArray && arrayLength > 0) {
const arr = [];
for (let i = 0; i < arrayLength; i++) {
arr[i] = result[i];
}
return arr;
}
return result;
default:
throw new Error(`Unknown type: '${type}' at position ${index - 2}`);
}
};
try {
const result = parseValue();
if (index < str.length) {
console.warn(`Warning: Trailing data after parsing: "${str.substring(index)}"`);
}
return result;
} catch (error) {
throw new Error(`Parse error at position ${index}: ${error.message}`);
}
};
// Auto-detect input format
const detectInputFormat = (input) => {
if (!input.trim()) return { format: '', valid: false, data: null };
// Try JSON first
try {
const jsonData = JSON.parse(input);
return { format: 'JSON', valid: true, data: jsonData };
} catch {}
// Try PHP serialize
try {
const serializedData = phpUnserialize(input);
return { format: 'PHP Serialized', valid: true, data: serializedData };
} catch {}
return { format: 'Unknown', valid: false, data: null };
};
// Handle input text change
const handleInputChange = (value) => {
setInputText(value);
const detection = detectInputFormat(value);
setInputFormat(detection.format);
setInputValid(detection.valid);
if (detection.valid) {
console.log('🎯 SETTING STRUCTURED DATA:', detection.data);
setStructuredData(detection.data);
setError('');
} else if (value.trim()) {
setError('Invalid format. Please enter valid JSON or PHP serialized data.');
} else {
setError('');
}
};
// Handle structured data change from visual editor
const handleStructuredDataChange = (newData) => {
setStructuredData(newData);
generateOutputs(newData);
};
// Generate all output formats
const generateOutputs = (data) => {
try {
const jsonPretty = JSON.stringify(data, null, 2);
const jsonMinified = JSON.stringify(data);
const serialized = phpSerialize(data);
setOutputs({
jsonPretty,
jsonMinified,
serialized
});
} catch (err) {
console.error('Error generating outputs:', err);
}
};
// Handle file import
const handleFileImport = (event) => {
const file = event.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = (e) => {
const content = e.target.result;
setInputText(content);
handleInputChange(content);
setShowInput(true);
};
reader.readAsText(file);
}
};
// Load sample data
const loadSample = () => {
const sample = {
"user": {
"name": "John Doe",
"age": 30,
"email": "john@example.com",
"preferences": {
"theme": "dark",
"notifications": true
},
"tags": ["developer", "javascript", "react"]
},
"settings": {
"language": "en",
"timezone": "UTC"
}
};
setStructuredData(sample);
generateOutputs(sample);
};
// Initialize outputs when component mounts or data changes
React.useEffect(() => {
generateOutputs(structuredData);
}, [structuredData]);
return (
<ToolLayout
title="Object Editor"
description="Visual editor for JSON and PHP serialized objects with format conversion"
icon={Edit3}
>
{/* Input Controls */}
<div className="flex flex-wrap gap-3 mb-6">
<button
onClick={() => setShowInput(!showInput)}
className="flex items-center space-x-2 tool-button"
>
<FileText className="h-4 w-4" />
<span>{showInput ? 'Hide Input' : 'Input Data'}</span>
</button>
<button
onClick={() => fileInputRef.current?.click()}
className="flex items-center space-x-2 tool-button-secondary"
>
<Upload className="h-4 w-4" />
<span>Import File</span>
</button>
<button
onClick={loadSample}
className="flex items-center space-x-2 tool-button-secondary"
>
<Edit3 className="h-4 w-4" />
<span>Load Sample</span>
</button>
<input
ref={fileInputRef}
type="file"
accept=".json,.txt"
onChange={handleFileImport}
className="hidden"
/>
</div>
{/* View Mode Toggle */}
<div className="flex bg-gray-100 dark:bg-gray-800 rounded-lg p-1 mb-6 w-fit">
<button
onClick={() => setViewMode('visual')}
className={`flex items-center space-x-2 px-4 py-2 rounded-md font-medium transition-colors ${
viewMode === 'visual'
? 'bg-white dark:bg-gray-700 text-primary-600 shadow-sm'
: 'text-gray-600 dark:text-gray-400'
}`}
>
<Edit3 className="h-4 w-4" />
<span>Visual Editor</span>
</button>
<button
onClick={() => setViewMode('mindmap')}
className={`flex items-center space-x-2 px-4 py-2 rounded-md font-medium transition-colors ${
viewMode === 'mindmap'
? 'bg-white dark:bg-gray-700 text-primary-600 shadow-sm'
: 'text-gray-600 dark:text-gray-400'
}`}
>
<Map className="h-4 w-4" />
<span>Mindmap View</span>
</button>
</div>
{/* Input Section */}
{showInput && (
<div className="mb-6 space-y-2">
<div className="flex justify-between items-center">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Input Data
</label>
{inputFormat && (
<span className={`text-xs px-2 py-1 rounded ${
inputValid
? 'bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-300'
: 'bg-red-100 text-red-800 dark:bg-red-900/20 dark:text-red-300'
}`}>
{inputFormat} {inputValid ? '✓' : '✗'}
</span>
)}
</div>
<textarea
value={inputText}
onChange={(e) => handleInputChange(e.target.value)}
placeholder="Paste JSON or PHP serialized data here..."
className="tool-input h-32"
/>
{error && (
<p className="text-sm text-red-600 dark:text-red-400">
{error}
</p>
)}
</div>
)}
{/* Main Editor Area */}
<div className="mb-6">
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-4">
{viewMode === 'visual' && 'Visual Editor'}
{viewMode === 'mindmap' && 'Mindmap Visualization'}
</h3>
{viewMode === 'visual' && (
<div className="min-h-96 border border-gray-200 dark:border-gray-700 rounded-lg">
<StructuredEditor
initialData={structuredData}
onDataChange={handleStructuredDataChange}
/>
</div>
)}
{viewMode === 'mindmap' && (
<MindmapView data={structuredData} />
)}
</div>
{/* Output Actions */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{/* JSON Pretty */}
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
JSON (Pretty)
</label>
<div className="relative">
<textarea
value={outputs.jsonPretty}
readOnly
placeholder="JSON pretty format will appear here..."
className="tool-input h-48 bg-gray-50 dark:bg-gray-800 text-sm font-mono"
/>
{outputs.jsonPretty && <CopyButton text={outputs.jsonPretty} />}
</div>
</div>
{/* JSON Minified */}
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
JSON (Minified)
</label>
<div className="relative">
<textarea
value={outputs.jsonMinified}
readOnly
placeholder="JSON minified format will appear here..."
className="tool-input h-48 bg-gray-50 dark:bg-gray-800 text-sm font-mono"
/>
{outputs.jsonMinified && <CopyButton text={outputs.jsonMinified} />}
</div>
</div>
{/* PHP Serialized */}
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
PHP Serialized
</label>
<div className="relative">
<textarea
value={outputs.serialized}
readOnly
placeholder="PHP serialized format will appear here..."
className="tool-input h-48 bg-gray-50 dark:bg-gray-800 text-sm font-mono"
/>
{outputs.serialized && <CopyButton text={outputs.serialized} />}
</div>
</div>
</div>
{/* Usage Tips */}
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-md p-4 mt-6">
<h4 className="text-blue-800 dark:text-blue-200 font-medium mb-2">Usage Tips</h4>
<ul className="text-blue-700 dark:text-blue-300 text-sm space-y-1">
<li> <strong>Visual Editor:</strong> Create and modify object structures with forms</li>
<li> <strong>Mindmap View:</strong> Visualize complex JSON structures as interactive diagrams</li>
<li> <strong>Input Data:</strong> Paste JSON/PHP serialized data with auto-detection in the input field</li>
<li> Import data from files or use the sample data to get started</li>
<li> Export your data in any format: JSON pretty, minified, or PHP serialized</li>
<li> Use copy buttons to quickly copy any output format</li>
</ul>
</div>
</ToolLayout>
);
};
export default ObjectEditor;