Compare commits

...

10 Commits

Author SHA1 Message Date
dwindown
b8164d617e 🔄 Update Object Editor description to mention mindmap visualization 2025-09-21 14:32:30 +07:00
dwindown
e59ebcb5d3 Fix phpSerialize useCallback dependency 2025-09-21 13:37:04 +07:00
dwindown
5ccb1e2421 Fix ESLint warnings for deployment 2025-09-21 13:29:30 +07:00
dwindown
d3ca407777 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
2025-09-21 12:33:39 +07:00
dwindown
82d14622ac Add Text Length Checker tool with comprehensive text analysis features
- Add new TextLengthTool.js with real-time text statistics
- Features: character/word/line/sentence/paragraph counting, reading time estimation
- Add Text Length Checker to navigation (ToolSidebar, Layout, App routing)
- Add Text Length Checker card to homepage
- Fix button styling with flex alignment for better UX
- Route: /text-length with Type icon from lucide-react
2025-09-21 07:09:33 +07:00
dwindown
6f5bdf5f0d Fix PHP serialization and add Long Text type to Visual Editor
- Fixed PHP serialization byte length calculation for escaped strings
- Added Long Text type with textarea for multiline strings in StructuredEditor
- Auto-detects strings with newlines and displays them as textarea
- Improvements apply to both SerializeTool and JsonTool visual editors
- Resolves parse errors with quoted strings and multiline content
2025-08-21 23:45:46 +07:00
dwindown
65cc3bc54d Fix package-lock.json TypeScript version mismatch for deployment 2025-08-21 23:19:22 +07:00
dwindown
22d333d932 fix serialize and json tool to handle boolean type and number type field, and fix the nested rows in array and object type 2025-08-21 23:12:43 +07:00
dwindown
97459ea313 feat: Enhanced developer tools UX with visual improvements
- Fixed StructuredEditor auto-type detection to only trigger on empty fields
- Made array keys readonly with proper index display and full-width value inputs
- Enhanced PHP unserialize to handle empty strings, NULL values, and complex data
- Added JSON to CSV support for single objects as Key-Value format
- Upgraded DiffTool with react-diff-view for professional GitHub-style diffs
- Added theme-synchronized diff colors with proper contrast in light/dark modes
- Implemented red/green text on matching backgrounds for optimal readability
2025-08-07 20:05:11 +07:00
dwindown
bc7e2a8986 Remove abandoned HTML Preview Tool files and fix ESLint warnings
- Removed unused imports (Key, Palette, QrCode) from Layout.js
- Deleted all HTML Preview Tool files from repository
- Removed HtmlPreviewTool import and route from App.js
- Build now completes successfully without ESLint warnings
- Ready for deployment in CI environment
2025-08-04 13:27:19 +07:00
21 changed files with 4492 additions and 8379 deletions

8125
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,28 +8,32 @@
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-html": "^6.4.9",
"@codemirror/lang-javascript": "^6.2.4",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/search": "^6.5.11",
"@codemirror/theme-one-dark": "^6.1.3",
"@testing-library/jest-dom": "^5.16.4",
"@testing-library/react": "^13.3.0",
"@testing-library/user-event": "^13.5.0",
"@uiw/react-codemirror": "^4.24.2",
"codemirror": "^5.65.19",
"@codemirror/view": "^6.38.1",
"@testing-library/jest-dom": "^6.8.0",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@uiw/react-codemirror": "^4.25.1",
"codemirror": "^6.0.2",
"diff-match-patch": "^1.0.5",
"js-beautify": "^1.15.4",
"lucide-react": "^0.263.1",
"papaparse": "^5.4.1",
"react": "^18.2.0",
"react-codemirror2": "^8.0.1",
"react-dom": "^18.2.0",
"react-router-dom": "^6.3.0",
"lucide-react": "^0.540.0",
"papaparse": "^5.5.3",
"react": "18.3.1",
"react-diff-view": "^3.3.2",
"react-dom": "18.3.1",
"react-router-dom": "6.26.2",
"react-scripts": "5.0.1",
"reactflow": "^11.11.4",
"serialize-javascript": "^6.0.0",
"web-vitals": "^2.1.4"
},
"devDependencies": {
"autoprefixer": "^10.4.14",
"postcss": "^8.4.24",
"react-scripts": "5.0.1",
"tailwindcss": "^3.3.0"
},
"scripts": {

View File

@@ -9,7 +9,9 @@ import Base64Tool from './pages/Base64Tool';
import CsvJsonTool from './pages/CsvJsonTool';
import BeautifierTool from './pages/BeautifierTool';
import DiffTool from './pages/DiffTool';
import HtmlPreviewTool from './pages/HtmlPreviewTool';
import TextLengthTool from './pages/TextLengthTool';
import ObjectEditor from './pages/ObjectEditor';
import './index.css';
function App() {
@@ -25,7 +27,9 @@ function App() {
<Route path="/csv-json" element={<CsvJsonTool />} />
<Route path="/beautifier" element={<BeautifierTool />} />
<Route path="/diff" element={<DiffTool />} />
<Route path="/html-preview" element={<HtmlPreviewTool />} />
<Route path="/text-length" element={<TextLengthTool />} />
<Route path="/object-editor" element={<ObjectEditor />} />
</Routes>
</Layout>
</Router>

View File

@@ -1,7 +1,8 @@
import React, { useState, useEffect, useRef } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { Home, Hash, FileText, Key, Palette, QrCode, FileSpreadsheet, Wand2, GitCompare, Menu, X, Database, LinkIcon, Code2, ChevronDown } from 'lucide-react';
import { Home, Hash, FileSpreadsheet, Wand2, GitCompare, Menu, X, LinkIcon, Code2, ChevronDown, Type, Edit3 } from 'lucide-react';
import ThemeToggle from './ThemeToggle';
import ToolSidebar from './ToolSidebar';
const Layout = ({ children }) => {
const location = useLocation();
@@ -34,20 +35,22 @@ 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' },
{ path: '/beautifier', name: 'Beautifier Tool', icon: Wand2, description: 'Beautify/minify code' },
{ path: '/diff', name: 'Diff Tool', icon: GitCompare, description: 'Compare text differences' },
{ path: '/text-length', name: 'Text Length Checker', icon: Type, description: 'Analyze text length & stats' },
];
// Check if we're on a tool page (not homepage)
const isToolPage = location.pathname !== '/';
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex flex-col">
{/* Header */}
<header className="sticky top-0 z-50 bg-white dark:bg-gray-800 shadow-sm border-b border-gray-200 dark:border-gray-700">
<header className="sticky top-0 z-50 bg-white dark:bg-gray-800 shadow-sm border-b border-gray-200 dark:border-gray-700 flex-shrink-0">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center h-16">
<Link to="/" className="flex items-center space-x-2">
@@ -58,60 +61,62 @@ const Layout = ({ children }) => {
</Link>
<div className="flex items-center space-x-4">
{/* Desktop Navigation */}
<nav className="hidden md:flex items-center space-x-6">
<Link
to="/"
className={`flex items-center space-x-1 px-3 py-2 rounded-md text-sm font-medium transition-colors ${
isActive('/')
? 'bg-primary-100 text-primary-700 dark:bg-primary-900 dark:text-primary-300'
: 'text-gray-600 hover:text-gray-900 dark:text-gray-300 dark:hover:text-white'
}`}
>
<Home className="h-4 w-4" />
<span>Home</span>
</Link>
{/* Tools Dropdown */}
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
className="flex items-center space-x-1 px-3 py-2 rounded-md text-sm font-medium text-gray-600 hover:text-gray-900 dark:text-gray-300 dark:hover:text-white transition-colors"
{/* Desktop Navigation - only show on homepage */}
{!isToolPage && (
<nav className="hidden md:flex items-center space-x-6">
<Link
to="/"
className={`flex items-center space-x-1 px-3 py-2 rounded-md text-sm font-medium transition-colors ${
isActive('/')
? 'bg-primary-100 text-primary-700 dark:bg-primary-900 dark:text-primary-300'
: 'text-gray-600 hover:text-gray-900 dark:text-gray-300 dark:hover:text-white'
}`}
>
<span>Tools</span>
<ChevronDown className={`h-4 w-4 transition-transform ${
isDropdownOpen ? 'rotate-180' : ''
}`} />
</button>
<Home className="h-4 w-4" />
<span>Home</span>
</Link>
{/* Dropdown Menu */}
{isDropdownOpen && (
<div className="absolute top-full left-0 mt-2 w-64 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 py-2 z-50">
{tools.map((tool) => {
const IconComponent = tool.icon;
return (
<Link
key={tool.path}
to={tool.path}
onClick={() => setIsDropdownOpen(false)}
className={`flex items-center space-x-3 px-4 py-3 text-sm hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors ${
isActive(tool.path)
? 'bg-primary-50 text-primary-700 dark:bg-primary-900 dark:text-primary-300'
: 'text-gray-700 dark:text-gray-300'
}`}
>
<IconComponent className="h-4 w-4" />
<div>
<div className="font-medium">{tool.name}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">{tool.description}</div>
</div>
</Link>
);
})}
</div>
)}
</div>
</nav>
{/* Tools Dropdown */}
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
className="flex items-center space-x-1 px-3 py-2 rounded-md text-sm font-medium text-gray-600 hover:text-gray-900 dark:text-gray-300 dark:hover:text-white transition-colors"
>
<span>Tools</span>
<ChevronDown className={`h-4 w-4 transition-transform ${
isDropdownOpen ? 'rotate-180' : ''
}`} />
</button>
{/* Dropdown Menu */}
{isDropdownOpen && (
<div className="absolute top-full left-0 mt-2 w-64 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 py-2 z-50">
{tools.map((tool) => {
const IconComponent = tool.icon;
return (
<Link
key={tool.path}
to={tool.path}
onClick={() => setIsDropdownOpen(false)}
className={`flex items-center space-x-3 px-4 py-3 text-sm hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors ${
isActive(tool.path)
? 'bg-primary-50 text-primary-700 dark:bg-primary-900 dark:text-primary-300'
: 'text-gray-700 dark:text-gray-300'
}`}
>
<IconComponent className="h-4 w-4" />
<div>
<div className="font-medium">{tool.name}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">{tool.description}</div>
</div>
</Link>
);
})}
</div>
)}
</div>
</nav>
)}
<ThemeToggle />
@@ -147,7 +152,7 @@ const Layout = ({ children }) => {
<div className="border-t border-gray-200 dark:border-gray-700 pt-2 mt-2">
<div className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider px-3 py-1">
Tools
{isToolPage ? 'Switch Tools' : 'Tools'}
</div>
{tools.map((tool) => {
const IconComponent = tool.icon;
@@ -177,18 +182,47 @@ const Layout = ({ children }) => {
)}
{/* Main Content */}
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{children}
</main>
{/* Footer */}
<footer className="bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 mt-16">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="text-center text-gray-600 dark:text-gray-400">
<p>© {new Date().getFullYear()} Dewe Toolsites - Developer Tools.</p>
<div className="flex flex-1">
{/* Tool Sidebar - only show on tool pages */}
{isToolPage && (
<div className="hidden lg:block flex-shrink-0">
<ToolSidebar />
</div>
</div>
</footer>
)}
{/* Main Content Area */}
<main className={`flex-1 flex flex-col ${isToolPage ? 'overflow-hidden' : ''}`}>
{isToolPage ? (
<div className="flex-1 overflow-auto">
<div className="px-4 sm:px-6 lg:px-8 py-8">
{children}
</div>
{/* Footer for tool pages - inside scrollable content */}
<footer className="bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="text-center text-gray-600 dark:text-gray-400">
<p>© {new Date().getFullYear()} Dewe Toolsites - Developer Tools.</p>
</div>
</div>
</footer>
</div>
) : (
<div className="flex-1">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{children}
</div>
{/* Footer for homepage */}
<footer className="bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 mt-16">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="text-center text-gray-600 dark:text-gray-400">
<p>© {new Date().getFullYear()} Dewe Toolsites - Developer Tools.</p>
</div>
</div>
</footer>
</div>
)}
</main>
</div>
</div>
);
};

View File

@@ -0,0 +1,684 @@
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;

View File

@@ -1,11 +1,26 @@
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);
onDataChange(newData);
};
@@ -21,11 +36,25 @@ const StructuredEditor = ({ onDataChange, initialData = {} }) => {
};
const addProperty = (obj, path) => {
const newObj = { ...obj };
const keys = Object.keys(newObj);
console.log('🔧 ADD PROPERTY - Before:', { path, dataKeys: Object.keys(data), objKeys: Object.keys(obj) });
const pathParts = path.split('.');
const newData = { ...data };
let current = newData;
// Navigate to the target object in the full data structure
for (let i = 1; i < pathParts.length; i++) {
current = current[pathParts[i]];
}
// Add new property to the target object
const keys = Object.keys(current);
const newKey = `property${keys.length + 1}`;
newObj[newKey] = '';
updateData(newObj);
current[newKey] = '';
console.log('🔧 ADD PROPERTY - After:', { path, newKey, dataKeys: Object.keys(newData), targetKeys: Object.keys(current) });
updateData(newData);
setExpandedNodes(new Set([...expandedNodes, path]));
};
@@ -69,6 +98,8 @@ const StructuredEditor = ({ onDataChange, initialData = {} }) => {
};
const updateValue = (value, path) => {
console.log('✏️ UPDATE VALUE:', { path, value, currentType: typeof getValue(path) });
const pathParts = path.split('.');
const newData = { ...data };
let current = newData;
@@ -78,22 +109,41 @@ const StructuredEditor = ({ onDataChange, initialData = {} }) => {
}
const key = pathParts[pathParts.length - 1];
const currentValue = current[key];
const currentType = typeof currentValue;
// Auto-detect type
if (value === 'true' || value === 'false') {
// Preserve the current type when updating value
if (currentType === 'boolean') {
current[key] = value === 'true';
} else if (value === 'null') {
current[key] = null;
} else if (!isNaN(value) && value !== '') {
current[key] = Number(value);
} else if (currentType === 'number') {
const numValue = Number(value);
current[key] = isNaN(numValue) ? 0 : numValue;
} else if (currentValue === null) {
current[key] = value === 'null' ? null : value;
} else {
current[key] = value;
// For strings and initial empty values, use auto-detection
if (currentValue === '' || currentValue === undefined) {
if (value === 'true' || value === 'false') {
current[key] = value === 'true';
} else if (value === 'null') {
current[key] = null;
} else if (!isNaN(value) && value !== '' && value.trim() !== '') {
current[key] = Number(value);
} else {
current[key] = value;
}
} else {
current[key] = value;
}
}
console.log('✏️ UPDATE VALUE - Result:', { path, newValue: current[key], newType: typeof current[key] });
updateData(newData);
};
const changeType = (newType, path) => {
console.log('🔄 CHANGE TYPE:', { path, newType, currentValue: getValue(path) });
const pathParts = path.split('.');
const newData = { ...data };
let current = newData;
@@ -103,16 +153,31 @@ const StructuredEditor = ({ onDataChange, initialData = {} }) => {
}
const key = pathParts[pathParts.length - 1];
const currentValue = current[key];
// Try to preserve value when changing types if possible
switch (newType) {
case 'string':
current[key] = '';
case 'longtext':
current[key] = currentValue === null ? '' : currentValue.toString();
break;
case 'number':
current[key] = 0;
if (typeof currentValue === 'string' && !isNaN(currentValue) && currentValue.trim() !== '') {
current[key] = Number(currentValue);
} else if (typeof currentValue === 'boolean') {
current[key] = currentValue ? 1 : 0;
} else {
current[key] = 0;
}
break;
case 'boolean':
current[key] = false;
if (typeof currentValue === 'string') {
current[key] = currentValue.toLowerCase() === 'true';
} else if (typeof currentValue === 'number') {
current[key] = currentValue !== 0;
} else {
current[key] = false;
}
break;
case 'array':
current[key] = [];
@@ -127,10 +192,20 @@ const StructuredEditor = ({ onDataChange, initialData = {} }) => {
current[key] = '';
}
console.log('🔄 CHANGE TYPE - Result:', { path, newValue: current[key], actualType: typeof current[key] });
updateData(newData);
setExpandedNodes(new Set([...expandedNodes, path]));
};
const getValue = (path) => {
const pathParts = path.split('.');
let current = data;
for (let i = 1; i < pathParts.length; i++) {
current = current[pathParts[i]];
}
return current;
};
const renameKey = (oldKey, newKey, path) => {
if (oldKey === newKey || !newKey.trim()) return;
@@ -170,9 +245,19 @@ const StructuredEditor = ({ onDataChange, initialData = {} }) => {
const renderValue = (value, key, path, parentPath) => {
const isExpanded = expandedNodes.has(path);
const canExpand = typeof value === 'object' && value !== null;
// Check if parent is an array by looking at the parent path
const isArrayItem = parentPath !== 'root' && (() => {
const parentPathParts = parentPath.split('.');
let current = data;
for (let i = 1; i < parentPathParts.length; i++) {
current = current[parentPathParts[i]];
}
return Array.isArray(current);
})();
return (
<div key={path} className="ml-4 border-l border-gray-200 dark:border-gray-700 pl-4 mb-2 overflow-hidden">
<div key={path} className="ml-4 border-l border-gray-200 dark:border-gray-700 pl-4 overflow-hidden">
<div className="flex items-center space-x-2 mb-2">
{canExpand && (
<button
@@ -189,45 +274,89 @@ const StructuredEditor = ({ onDataChange, initialData = {} }) => {
{!canExpand && <div className="w-6" />}
<div className="flex flex-col sm:flex-row sm:items-center space-y-2 sm:space-y-0 sm:space-x-2 flex-1">
<div className="flex items-center space-x-2 flex-1">
{getTypeIcon(value)}
<input
type="text"
defaultValue={key}
onBlur={(e) => {
const newKey = e.target.value.trim();
if (newKey && newKey !== key) {
renameKey(key, newKey, path);
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.target.blur(); // Trigger blur to save changes
}
}}
className="px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 min-w-0 flex-1"
placeholder="Property name"
/>
<span className="text-gray-500 hidden sm:inline">:</span>
</div>
<div className="flex items-center space-x-2 flex-1">
{isArrayItem ? (
// Array items: icon + index span (compact)
<>
{getTypeIcon(value)}
<span className="px-2 py-1 text-sm text-gray-600 dark:text-gray-400 font-mono whitespace-nowrap">
[{key}]
</span>
</>
) : (
// Object properties: icon + editable key + colon (compact)
<>
{getTypeIcon(value)}
<input
type="text"
defaultValue={key}
onBlur={(e) => {
const newKey = e.target.value.trim();
if (newKey && newKey !== key) {
renameKey(key, newKey, path);
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.target.blur(); // Trigger blur to save changes
}
}}
className="px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 min-w-0"
placeholder="Property name"
style={{width: '120px'}} // Fixed width for consistency
/>
<span className="text-gray-500 hidden sm:inline">:</span>
</>
)}
{!canExpand ? (
<input
type="text"
value={
value === null ? 'null' :
value === undefined ? '' :
value.toString()
}
onChange={(e) => updateValue(e.target.value, path)}
className="flex-1 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
placeholder="Value"
/>
typeof value === 'boolean' ? (
<div className="flex-1 flex items-center space-x-2">
<button
onClick={() => updateValue((!value).toString(), path)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 ${
value ? 'bg-blue-600' : 'bg-gray-200 dark:bg-gray-600'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
value ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
<span className="text-sm text-gray-600 dark:text-gray-400 font-mono">
{value.toString()}
</span>
</div>
) : (
typeof value === 'string' && value.includes('\n') ? (
<textarea
value={
value === null ? 'null' :
value === undefined ? '' :
value.toString()
}
onChange={(e) => updateValue(e.target.value, path)}
className="flex-1 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 min-w-0 resize-y"
placeholder="Long text value"
rows={3}
/>
) : (
<input
type="text"
value={
value === null ? 'null' :
value === undefined ? '' :
value.toString()
}
onChange={(e) => updateValue(e.target.value, path)}
className="flex-1 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 min-w-0"
placeholder="Value"
/>
)
)
) : (
<span className="text-sm text-gray-600 dark:text-gray-400">
<span className="flex-1 text-sm text-gray-600 dark:text-gray-400">
{Array.isArray(value) ? `Array (${value.length} items)` : `Object (${Object.keys(value).length} properties)`}
</span>
)}
@@ -237,7 +366,7 @@ const StructuredEditor = ({ onDataChange, initialData = {} }) => {
value={
value === null ? 'null' :
value === undefined ? 'string' :
typeof value === 'string' ? 'string' :
typeof value === 'string' ? (value.includes('\n') ? 'longtext' : 'string') :
typeof value === 'number' ? 'number' :
typeof value === 'boolean' ? 'boolean' :
Array.isArray(value) ? 'array' : 'object'
@@ -246,6 +375,7 @@ const StructuredEditor = ({ onDataChange, initialData = {} }) => {
className="px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 min-w-0"
>
<option value="string">String</option>
<option value="longtext">Long Text</option>
<option value="number">Number</option>
<option value="boolean">Boolean</option>
<option value="array">Array</option>

View File

@@ -1,20 +1,10 @@
import React from 'react';
import { ArrowLeft } from 'lucide-react';
import { Link } from 'react-router-dom';
const ToolLayout = ({ title, description, children, icon: Icon }) => {
return (
<div className="max-w-6xl mx-auto">
{/* Header */}
<div className="mb-8">
<Link
to="/"
className="inline-flex items-center text-primary-600 hover:text-primary-700 mb-4"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Tools
</Link>
<div className="flex items-center space-x-3 mb-2">
{Icon && <Icon className="h-8 w-8 text-primary-600" />}
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">

View File

@@ -0,0 +1,112 @@
import React, { useState } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { Search, LinkIcon, Hash, FileSpreadsheet, Wand2, GitCompare, Home, ChevronLeft, ChevronRight, Type, Edit3 } from 'lucide-react';
const ToolSidebar = () => {
const location = useLocation();
const [isCollapsed, setIsCollapsed] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
const tools = [
{ path: '/', name: 'Home', icon: Home, description: 'Back to homepage' },
{ 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' },
{ path: '/beautifier', name: 'Beautifier Tool', icon: Wand2, description: 'Beautify/minify code' },
{ path: '/diff', name: 'Diff Tool', icon: GitCompare, description: 'Compare text differences' },
{ path: '/text-length', name: 'Text Length Checker', icon: Type, description: 'Analyze text length & stats' },
];
const filteredTools = tools.filter(tool =>
tool.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
tool.description.toLowerCase().includes(searchTerm.toLowerCase())
);
const isActive = (path) => location.pathname === path;
return (
<div className={`bg-white dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 transition-all duration-300 sticky top-16 ${
isCollapsed ? 'w-16' : 'w-64'
}`} style={{ height: 'calc(100vh - 4rem)' }}>
<div className="h-full flex flex-col">
{/* Sidebar Header */}
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between">
{!isCollapsed && (
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
Tools
</h2>
)}
<button
onClick={() => setIsCollapsed(!isCollapsed)}
className="p-1 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
>
{isCollapsed ? (
<ChevronRight className="h-4 w-4 text-gray-500" />
) : (
<ChevronLeft className="h-4 w-4 text-gray-500" />
)}
</button>
</div>
{/* Search - only show when not collapsed */}
{!isCollapsed && (
<div className="relative mt-3">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<input
type="text"
placeholder="Search tools..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-9 pr-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-primary-500 focus:border-transparent"
/>
</div>
)}
</div>
{/* Tools List */}
<div className="flex-1 overflow-y-auto py-2">
<nav className="space-y-1 px-2">
{filteredTools.map((tool) => {
const IconComponent = tool.icon;
return (
<Link
key={tool.path}
to={tool.path}
className={`group flex items-center px-3 py-2 text-sm font-medium rounded-md transition-colors ${
isActive(tool.path)
? 'bg-primary-100 text-primary-700 dark:bg-primary-900 dark:text-primary-300'
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-50 dark:text-gray-300 dark:hover:text-white dark:hover:bg-gray-700'
}`}
title={isCollapsed ? tool.name : ''}
>
<IconComponent className={`h-5 w-5 ${isCollapsed ? '' : 'mr-3'} flex-shrink-0`} />
{!isCollapsed && (
<div className="flex-1 min-w-0">
<div className="font-medium truncate">{tool.name}</div>
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">
{tool.description}
</div>
</div>
)}
</Link>
);
})}
</nav>
</div>
{/* Footer */}
{!isCollapsed && (
<div className="p-4 border-t border-gray-200 dark:border-gray-700">
<div className="text-xs text-gray-500 dark:text-gray-400 text-center">
Quick access to all tools
</div>
</div>
)}
</div>
</div>
);
};
export default ToolSidebar;

View File

@@ -45,38 +45,77 @@ const CsvJsonTool = () => {
try {
const data = JSON.parse(input);
if (!Array.isArray(data)) {
setOutput('Error: JSON must be an array of objects');
return;
}
if (data.length === 0) {
setOutput('Error: Empty array');
return;
}
// Get headers from first object
const headers = Object.keys(data[0]);
let csv = '';
// Add headers if enabled
if (hasHeaders) {
csv += headers.join(delimiter) + '\n';
}
// Add data rows
data.forEach(row => {
const values = headers.map(header => {
const value = row[header] || '';
// Escape values containing delimiter or quotes
if (typeof value === 'string' && (value.includes(delimiter) || value.includes('"') || value.includes('\n'))) {
return `"${value.replace(/"/g, '""')}"`;
}
return value;
if (Array.isArray(data)) {
// Handle array of objects (original functionality)
if (data.length === 0) {
setOutput('Error: Empty array');
return;
}
// Get headers from first object
const headers = Object.keys(data[0]);
// Add headers if enabled
if (hasHeaders) {
csv += headers.join(delimiter) + '\n';
}
// Add data rows
data.forEach(row => {
const values = headers.map(header => {
const value = row[header] || '';
// Escape values containing delimiter or quotes
if (typeof value === 'string' && (value.includes(delimiter) || value.includes('"') || value.includes('\n'))) {
return `"${value.replace(/"/g, '""')}"`;
}
return value;
});
csv += values.join(delimiter) + '\n';
});
csv += values.join(delimiter) + '\n';
});
} else if (typeof data === 'object' && data !== null) {
// Handle single object as key-value pairs
// Add headers if enabled
if (hasHeaders) {
csv += `Key${delimiter}Value\n`;
}
// Add key-value rows
Object.entries(data).forEach(([key, value]) => {
// Format the key
let formattedKey = key;
if (typeof key === 'string' && (key.includes(delimiter) || key.includes('"') || key.includes('\n'))) {
formattedKey = `"${key.replace(/"/g, '""')}"`;
}
// Format the value
let formattedValue = '';
if (value === null) {
formattedValue = 'null';
} else if (value === undefined) {
formattedValue = 'undefined';
} else if (typeof value === 'object') {
// Convert objects/arrays to JSON string
formattedValue = JSON.stringify(value);
} else {
formattedValue = String(value);
}
// Escape value if needed
if (typeof formattedValue === 'string' && (formattedValue.includes(delimiter) || formattedValue.includes('"') || formattedValue.includes('\n'))) {
formattedValue = `"${formattedValue.replace(/"/g, '""')}"`;
}
csv += `${formattedKey}${delimiter}${formattedValue}\n`;
});
} else {
setOutput('Error: JSON must be an object or an array of objects');
return;
}
setOutput(csv.trim());
} catch (err) {

View File

@@ -1,70 +1,75 @@
import React, { useState } from 'react';
import React, { useState, useMemo } from 'react';
import { GitCompare, Upload } from 'lucide-react';
import { parseDiff, Diff, Hunk } from 'react-diff-view';
import 'react-diff-view/style/index.css';
import '../styles/diff-theme.css';
import ToolLayout from '../components/ToolLayout';
import CopyButton from '../components/CopyButton';
const DiffTool = () => {
// Enhanced diff tool with react-diff-view and theme support
const [leftText, setLeftText] = useState('');
const [rightText, setRightText] = useState('');
const [diffResult, setDiffResult] = useState('');
const [diffMode, setDiffMode] = useState('unified'); // 'unified' or 'side-by-side'
const [diffMode, setDiffMode] = useState('unified'); // 'unified' or 'split'
// Simple diff implementation
const computeDiff = () => {
// Generate unified diff format for react-diff-view
const diffText = useMemo(() => {
if (!leftText && !rightText) return null;
const leftLines = leftText.split('\n');
const rightLines = rightText.split('\n');
// Create a unified diff format
let diff = `--- Text A\n+++ Text B\n`;
const maxLines = Math.max(leftLines.length, rightLines.length);
let result = '';
let diffCount = 0;
let hunkStart = 1;
let hunkLines = [];
if (diffMode === 'unified') {
result += `--- Text A\n+++ Text B\n`;
for (let i = 0; i < maxLines; i++) {
const leftLine = leftLines[i];
const rightLine = rightLines[i];
for (let i = 0; i < maxLines; i++) {
const leftLine = leftLines[i] || '';
const rightLine = rightLines[i] || '';
if (leftLine !== rightLine) {
diffCount++;
if (leftLine && !rightLine) {
result += `- ${leftLine}\n`;
} else if (!leftLine && rightLine) {
result += `+ ${rightLine}\n`;
} else {
result += `- ${leftLine}\n+ ${rightLine}\n`;
}
} else {
result += ` ${leftLine}\n`;
if (leftLine === rightLine) {
// Unchanged line
if (leftLine !== undefined) {
hunkLines.push(` ${leftLine}`);
}
}
} else {
// Side by side format
result += `${'Text A'.padEnd(50)} | Text B\n`;
result += `${'-'.repeat(50)} | ${'-'.repeat(50)}\n`;
for (let i = 0; i < maxLines; i++) {
const leftLine = leftLines[i] || '';
const rightLine = rightLines[i] || '';
if (leftLine !== rightLine) {
diffCount++;
const leftDisplay = leftLine.padEnd(50);
result += `${leftDisplay} | ${rightLine}\n`;
} else {
const leftDisplay = leftLine.padEnd(50);
result += `${leftDisplay} | ${rightLine}\n`;
} else {
// Changed line
if (leftLine !== undefined) {
hunkLines.push(`-${leftLine}`);
}
if (rightLine !== undefined) {
hunkLines.push(`+${rightLine}`);
}
}
}
if (diffCount === 0) {
result = '✅ No differences found - texts are identical!';
} else {
result = `Found ${diffCount} difference(s):\n\n${result}`;
if (hunkLines.length > 0) {
diff += `@@ -${hunkStart},${leftLines.length} +${hunkStart},${rightLines.length} @@\n`;
diff += hunkLines.join('\n');
}
setDiffResult(result);
return diff;
}, [leftText, rightText]);
// Parse the diff for react-diff-view
const parsedDiff = useMemo(() => {
if (!diffText) return null;
try {
const files = parseDiff(diffText);
return files[0]; // We only have one file diff
} catch (error) {
console.error('Error parsing diff:', error);
return null;
}
}, [diffText]);
const computeDiff = () => {
// This function is now just a trigger since diff is computed in useMemo
// The actual diff computation happens automatically when leftText or rightText changes
};
const handleFileUpload = (side, event) => {
@@ -85,7 +90,6 @@ const DiffTool = () => {
const clearAll = () => {
setLeftText('');
setRightText('');
setDiffResult('');
};
const loadSample = () => {
@@ -144,9 +148,9 @@ const user = {
Unified Diff
</button>
<button
onClick={() => setDiffMode('side-by-side')}
onClick={() => setDiffMode('split')}
className={`px-4 py-2 rounded-md font-medium transition-colors ${
diffMode === 'side-by-side'
diffMode === 'split'
? 'bg-white dark:bg-gray-700 text-primary-600 shadow-sm'
: 'text-gray-600 dark:text-gray-400'
}`}
@@ -228,19 +232,44 @@ const user = {
</div>
</div>
{/* Diff Result */}
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Comparison Result
</label>
<div className="relative">
<textarea
value={diffResult}
readOnly
placeholder="Comparison result will appear here..."
className="tool-input h-96 bg-gray-50 dark:bg-gray-800"
/>
{diffResult && <CopyButton text={diffResult} />}
{parsedDiff && parsedDiff.hunks && parsedDiff.hunks.length > 0 ? (
<div className="border border-gray-300 dark:border-gray-600 rounded-lg overflow-hidden bg-white dark:bg-gray-800 relative">
<div className="diff-container max-h-96 overflow-auto">
<Diff
viewType={diffMode}
diffType="modify"
hunks={parsedDiff.hunks}
renderHunk={(hunk) => (
<Hunk key={hunk.content} hunk={hunk} />
)}
/>
</div>
{diffText && (
<div className="absolute top-2 right-2">
<CopyButton text={diffText} />
</div>
)}
</div>
) : leftText || rightText ? (
<div className="flex items-center justify-center h-32 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg">
<div className="text-center">
<div className="text-green-600 dark:text-green-400 text-lg mb-1"></div>
<p className="text-green-700 dark:text-green-300 font-medium">No differences found</p>
<p className="text-green-600 dark:text-green-400 text-sm">The texts are identical!</p>
</div>
</div>
) : (
<div className="flex items-center justify-center h-32 bg-gray-50 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg">
<p className="text-gray-500 dark:text-gray-400">Enter text in both fields to see the comparison</p>
</div>
)}
</div>
</div>

View File

@@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { Search, Code, Link2, FileText, Hash, RefreshCw, GitCompare, Database } from 'lucide-react';
import { Search, Code, Link2, FileText, Hash, RefreshCw, GitCompare, 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 mindmap visualization',
path: '/object-editor',
tags: ['Visual', 'JSON', 'PHP', 'Objects', 'Editor']
},
{
icon: Link2,
@@ -54,6 +47,13 @@ const Home = () => {
description: 'Compare two texts and highlight differences line by line',
path: '/diff',
tags: ['Diff', 'Compare', 'Text']
},
{
icon: Type,
title: 'Text Length Checker',
description: 'Analyze text length, word count, and other text statistics',
path: '/text-length',
tags: ['Text', 'Length', 'Statistics']
}
];

View File

@@ -1,451 +0,0 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import ToolLayout from '../components/ToolLayout';
import PreviewFrame from './components/PreviewFrame.fresh';
import Toolbar from './components/Toolbar';
import CodeInputs from './components/CodeInputs';
import InspectorSidebar from './components/InspectorSidebar';
import ElementEditor from './components/ElementEditor';
import '../styles/device-frames.css';
const HtmlPreviewTool = () => {
const [htmlInput, setHtmlInput] = useState('');
const [cssInput, setCssInput] = useState('');
const [jsInput, setJsInput] = useState('');
const [selectedDevice, setSelectedDevice] = useState('mobile');
const [inspectMode, setInspectMode] = useState(false);
const [inspectedElementInfo, setInspectedElementInfo] = useState(null);
const [isFullscreen, setIsFullscreen] = useState(false);
const [showSidebar, setShowSidebar] = useState(true);
const [forceRender, setForceRender] = useState(0);
// Separate inspector state to prevent iframe updates during inspector operations
const [inspectorHtmlState, setInspectorHtmlState] = useState('');
const [isInspectorActive, setIsInspectorActive] = useState(false);
// ENHANCED OPTION A: PreviewFrame API reference
const previewFrameRef = useRef(null);
// Debug: Monitor inspectedElementInfo changes and force re-render
useEffect(() => {
console.log('🔍 STATE CHANGE: inspectedElementInfo updated to:', inspectedElementInfo);
if (inspectedElementInfo) {
console.log('🔄 FORCING COMPONENT RE-RENDER for inspector sidebar');
// Force a re-render by updating a dummy state
setForceRender(prev => prev + 1);
}
}, [inspectedElementInfo, forceRender]);
const handleElementClick = useCallback((elementInfo) => {
console.log('🔎 ENHANCED ELEMENT CLICK:', elementInfo);
if (elementInfo) {
console.log('✅ ENHANCED INSPECTOR: Activating with cascade-id:', elementInfo.cascadeId);
setInspectedElementInfo(elementInfo);
setIsInspectorActive(true);
console.log('🎯 ENHANCED INSPECTOR: Sidebar activated, iframe DOM is source of truth');
// Debug: Force re-render check
setTimeout(() => {
console.log('🔍 POST-SET DEBUG: inspectedElementInfo should now be:', elementInfo);
setForceRender(prev => prev + 1); // Force re-render after state is set
}, 10);
}
}, []);
const cleanupInspectorState = useCallback(() => {
console.log('🧹 ENHANCED OPTION A: Cleaning up inspector state without triggering iframe refresh');
console.log('🚨 DEBUG: cleanupInspectorState called - clearing inspectedElementInfo');
console.trace('🔍 STACK TRACE: cleanupInspectorState called from:');
setInspectedElementInfo(null);
setInspectMode(false);
// ENHANCED OPTION A: Don't call setHtmlInput during cleanup
// The iframe DOM cleanup will be handled by PreviewFrame directly
// Only clean up React state, not HTML input
console.log('✅ ENHANCED CLEANUP: Inspector state cleared without iframe refresh');
}, []);
// ESC key handler to deactivate inspect mode
useEffect(() => {
const handleKeyDown = (event) => {
if (event.key === 'Escape' && inspectMode) {
console.log('⌨️ ESC key pressed - deactivating inspect mode');
cleanupInspectorState();
}
};
// Add event listener
document.addEventListener('keydown', handleKeyDown);
// Cleanup
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [inspectMode, cleanupInspectorState]);
useEffect(() => {
// ENHANCED OPTION A: Skip cascade ID injection during inspector operations
if (inspectedElementInfo) {
console.log('🚫 ENHANCED OPTION A: Skipping cascade ID injection during inspector operations');
return;
}
if (!htmlInput.trim()) return;
const isFullDocument = htmlInput.trim().toLowerCase().includes('<html');
console.log('🔧 HTML Processing Start:', { isFullDocument, inputLength: htmlInput.length });
// If it's already a full document, don't modify it unless we need to add cascade IDs
if (isFullDocument) {
const parser = new DOMParser();
const doc = parser.parseFromString(htmlInput, 'text/html');
let modified = false;
let idCounter = 0;
// Count existing cascade IDs
doc.querySelectorAll('[data-cascade-id]').forEach(el => {
const idNum = parseInt(el.getAttribute('data-cascade-id').split('-')[1], 10);
if (!isNaN(idNum) && idNum >= idCounter) {
idCounter = idNum + 1;
}
});
// Add cascade IDs to elements that don't have them
doc.querySelectorAll('*').forEach(el => {
if (!el.hasAttribute('data-cascade-id') &&
el.tagName.toLowerCase() !== 'body' &&
el.tagName.toLowerCase() !== 'html' &&
el.tagName.toLowerCase() !== 'head' &&
el.tagName.toLowerCase() !== 'title' &&
el.tagName.toLowerCase() !== 'meta' &&
el.tagName.toLowerCase() !== 'link' &&
el.tagName.toLowerCase() !== 'style' &&
el.tagName.toLowerCase() !== 'script') {
el.setAttribute('data-cascade-id', `cascade-${idCounter++}`);
modified = true;
}
});
if (modified) {
const newHtml = doc.documentElement.outerHTML;
console.log('✅ Full document processed, cascade IDs added');
console.log('🚨 CASCADE ID INJECTION: About to call setHtmlInput (POTENTIAL IFRAME REFRESH TRIGGER)');
setHtmlInput(newHtml);
}
} else {
// It's a fragment, process normally
const parser = new DOMParser();
const doc = parser.parseFromString(htmlInput, 'text/html');
let modified = false;
let idCounter = 0;
doc.body.querySelectorAll('[data-cascade-id]').forEach(el => {
const idNum = parseInt(el.getAttribute('data-cascade-id').split('-')[1], 10);
if (!isNaN(idNum) && idNum >= idCounter) {
idCounter = idNum + 1;
}
});
doc.body.querySelectorAll('*').forEach(el => {
if (!el.hasAttribute('data-cascade-id') && el.tagName.toLowerCase() !== 'body' && el.tagName.toLowerCase() !== 'html' && el.tagName.toLowerCase() !== 'head') {
el.setAttribute('data-cascade-id', `cascade-${idCounter++}`);
modified = true;
}
});
if (modified) {
const newHtml = doc.body.innerHTML;
console.log('✅ Fragment processed, cascade IDs added');
console.log('🚨 CASCADE ID INJECTION: About to call setHtmlInput (POTENTIAL IFRAME REFRESH TRIGGER)');
setHtmlInput(newHtml);
}
}
}, [htmlInput]);
const createDuplicateInCodeBox = useCallback((elementInfo) => {
const cascadeId = elementInfo.attributes['data-cascade-id'];
if (!cascadeId) {
console.error('❌ Cannot create duplicate: Element is missing data-cascade-id.');
return false;
}
// Use stored iframe DOM that contains the cascade-id, fallback to inspector state
const currentHtml = window.currentIframeDom || inspectorHtmlState || htmlInput;
console.log('🎯 INSPECTOR: Creating duplicates using iframe DOM with cascade-id');
if (window.currentIframeDom) {
console.log('✅ Using current iframe DOM with cascade-id');
} else {
console.log('⚠️ Fallback to inspector state (cascade-id may be missing)');
}
const processHtml = (currentHtml) => {
const parser = new DOMParser();
const doc = parser.parseFromString(currentHtml, 'text/html');
const originalElement = doc.querySelector(`[data-cascade-id="${cascadeId}"]`);
if (!originalElement) {
console.error(`❌ Could not find element with ${cascadeId} in HTML.`);
return currentHtml;
}
const hiddenElement = originalElement.cloneNode(true);
hiddenElement.setAttribute('data-original', 'true');
hiddenElement.style.display = 'none';
const visibleElement = originalElement.cloneNode(true);
visibleElement.setAttribute('data-original', 'false');
originalElement.parentNode.insertBefore(hiddenElement, originalElement);
originalElement.parentNode.insertBefore(visibleElement, originalElement);
originalElement.remove();
console.log(`✅ Successfully created duplicates for ${cascadeId}`);
// Preserve the original HTML structure (full document vs fragment)
const isFragment = !currentHtml.trim().toLowerCase().startsWith('<html');
return isFragment ? doc.body.innerHTML : doc.documentElement.outerHTML;
};
// ENHANCED OPTION A: Process HTML and activate inspector (iframe DOM becomes source of truth)
const processedHtml = processHtml(currentHtml);
setInspectorHtmlState(processedHtml);
setIsInspectorActive(true);
window.isInspectorActive = true;
console.log('🔍 INSPECTOR ACTIVATED: Iframe DOM is now source of truth, refreshes disabled');
return true;
}, []);
const handleRefresh = useCallback(() => {
// Refresh is handled by PreviewFrame component
console.log('🔄 Refreshing preview...');
}, []);
const toggleFullscreen = useCallback((targetDevice = null) => {
setIsFullscreen(prev => {
const newFullscreen = !prev;
// When exiting fullscreen (going to non-fullscreen), always switch to mobile
if (!newFullscreen) {
setSelectedDevice('mobile');
console.log('📱 Exiting fullscreen: Switched to mobile view');
}
return newFullscreen;
});
if (targetDevice) {
setSelectedDevice(targetDevice);
}
}, []);
const toggleSidebar = useCallback(() => {
setShowSidebar(prev => !prev);
}, []);
// ENHANCED OPTION A: Commit iframe DOM changes to HTML input using new API
const saveInspectorChanges = useCallback(() => {
console.log('💾 ENHANCED COMMIT: Using PreviewFrame API to commit changes');
if (!previewFrameRef.current) {
console.error('❌ COMMIT FAILED: PreviewFrame ref not available');
return;
}
try {
// Use Enhanced Option A API to get iframe DOM content
const committedHtml = previewFrameRef.current.getIframeContent();
if (committedHtml) {
// ENHANCED OPTION A: Update HTML input with committed changes
// This is an EXPLICIT SAVE operation, so iframe refresh is expected and correct
setHtmlInput(committedHtml);
console.log('✅ ENHANCED COMMIT: Changes committed successfully');
console.log('📊 COMMIT: HTML updated with iframe DOM content');
// Close inspector and reset state
console.log('🚨 DEBUG: saveInspectorChanges called - clearing inspectedElementInfo');
setInspectedElementInfo(null);
setInspectMode(false);
setIsInspectorActive(false);
console.log('🔄 ENHANCED COMMIT: Inspector closed, iframe will refresh with new content');
} else {
console.error('❌ ENHANCED COMMIT: Failed to extract iframe DOM content');
}
} catch (error) {
console.error('❌ ENHANCED COMMIT ERROR:', error);
}
}, [previewFrameRef]);
// ENHANCED OPTION A: Close inspector and reset state
const closeInspector = useCallback(() => {
console.log('❌ ENHANCED CLOSE: Closing inspector and resetting state');
// Reset all inspector state
console.log('🚨 DEBUG: closeInspector called - clearing inspectedElementInfo');
setInspectedElementInfo(null);
setInspectMode(false);
setIsInspectorActive(false);
setInspectorHtmlState('');
// Use PreviewFrame API to cancel changes if available
if (previewFrameRef?.current?.cancelChanges) {
previewFrameRef.current.cancelChanges();
}
console.log('✅ ENHANCED CLOSE: Inspector closed, iframe DOM reset');
}, [previewFrameRef]);
const closeInspectorLegacy = useCallback(() => {
// ENHANCED OPTION A: Close inspector and clear active flag (legacy)
setIsInspectorActive(false);
setInspectorHtmlState('');
window.isInspectorActive = false;
console.log('❌ INSPECTOR CLOSED: Iframe refreshes re-enabled');
cleanupInspectorState();
}, [cleanupInspectorState]);
if (isFullscreen) {
return (
<div className="fixed inset-0 bg-gray-100 dark:bg-gray-900 z-50 flex flex-col">
{/* Main content area */}
<div className="flex-1 flex overflow-hidden">
{/* Left sidebar - Code inputs */}
{showSidebar && (
<div className="w-96 bg-white dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 flex flex-col">
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Code Editor</h2>
</div>
<div className="flex-1 flex flex-col p-4">
<CodeInputs
htmlInput={htmlInput}
setHtmlInput={setHtmlInput}
cssInput={cssInput}
setCssInput={setCssInput}
jsInput={jsInput}
setJsInput={setJsInput}
isFullscreen={isFullscreen}
/>
</div>
</div>
)}
{/* Center - Preview */}
<div className="flex-1 flex flex-col bg-gray-50 dark:bg-gray-900">
<div className="flex-1 p-4 overflow-hidden">
<PreviewFrame
ref={previewFrameRef}
htmlInput={isInspectorActive ? inspectorHtmlState : htmlInput}
cssInput={cssInput}
jsInput={jsInput}
selectedDevice={selectedDevice}
isInspectModeActive={inspectMode}
onElementClick={handleElementClick}
isFullscreen={isFullscreen}
/>
</div>
</div>
{/* Inspector Sidebar */}
{inspectedElementInfo && (
<InspectorSidebar
inspectedElementInfo={inspectedElementInfo}
htmlInput={htmlInput}
setHtmlInput={setHtmlInput}
onClose={closeInspector}
onSave={saveInspectorChanges}
previewFrameRef={previewFrameRef}
/>
)}
</div>
{/* Bottom toolbar */}
<Toolbar
selectedDevice={selectedDevice}
setSelectedDevice={setSelectedDevice}
isInspectModeActive={inspectMode}
setInspectMode={setInspectMode}
isFullscreen={isFullscreen}
onRefresh={handleRefresh}
onToggleFullscreen={toggleFullscreen}
onToggleSidebar={toggleSidebar}
showSidebar={showSidebar}
cleanupInspectorState={cleanupInspectorState}
inspectedElementInfo={inspectedElementInfo}
/>
</div>
);
}
return (
<ToolLayout title="HTML Preview Tool">
<div className={`flex h-full ${inspectedElementInfo ? 'gap-4' : 'gap-6'}`}>
{/* Left column - Code inputs */}
<div className={`flex flex-col transition-all duration-300 ${
inspectedElementInfo ? 'flex-1' : 'w-1/2'
}`}>
<CodeInputs
htmlInput={htmlInput}
setHtmlInput={setHtmlInput}
cssInput={cssInput}
setCssInput={setCssInput}
jsInput={jsInput}
setJsInput={setJsInput}
isFullscreen={isFullscreen}
/>
</div>
{/* Middle column - Preview */}
<div className={`flex flex-col transition-all duration-300 ${
inspectedElementInfo ? 'flex-1' : 'w-1/2'
}`}>
<div className="flex-1 bg-gray-50 dark:bg-gray-900 rounded-lg overflow-hidden">
<PreviewFrame
ref={previewFrameRef}
htmlInput={htmlInput}
cssInput={cssInput}
jsInput={jsInput}
selectedDevice={selectedDevice}
isInspectModeActive={inspectMode}
onElementClick={handleElementClick}
isFullscreen={isFullscreen}
/>
</div>
</div>
{/* ENHANCED OPTION A: Inspector Sidebar */}
{inspectedElementInfo && (
<div className="w-80 flex flex-col bg-gray-50 dark:bg-gray-900 rounded-lg overflow-hidden">
<InspectorSidebar
inspectedElementInfo={inspectedElementInfo}
htmlInput={htmlInput}
setHtmlInput={setHtmlInput}
onClose={closeInspector}
onSave={saveInspectorChanges}
previewFrameRef={previewFrameRef}
/>
</div>
)}
</div>
{/* Bottom toolbar */}
<div className="mt-6">
<Toolbar
selectedDevice={selectedDevice}
setSelectedDevice={setSelectedDevice}
isInspectModeActive={inspectMode}
setInspectMode={setInspectMode}
isFullscreen={isFullscreen}
onRefresh={handleRefresh}
onToggleFullscreen={toggleFullscreen}
onToggleSidebar={toggleSidebar}
showSidebar={showSidebar}
cleanupInspectorState={cleanupInspectorState}
inspectedElementInfo={inspectedElementInfo}
/>
</div>
</ToolLayout>
);
};
export default HtmlPreviewTool;

View File

@@ -1,461 +0,0 @@
import React, { useState, useEffect, useCallback } from 'react';
import ToolLayout from '../components/ToolLayout';
import PreviewFrame from './components/PreviewFrame';
import Toolbar from './components/Toolbar';
import CodeInputs from './components/CodeInputs';
import InspectorSidebar from './components/InspectorSidebar';
import '../styles/device-frames.css';
const HtmlPreviewTool = () => {
const [htmlInput, setHtmlInput] = useState('');
const [cssInput, setCssInput] = useState('');
const [jsInput, setJsInput] = useState('');
const [selectedDevice, setSelectedDevice] = useState('mobile');
const [inspectMode, setInspectMode] = useState(false);
const [inspectedElementInfo, setInspectedElementInfo] = useState(null);
const [isFullscreen, setIsFullscreen] = useState(false);
const [showSidebar, setShowSidebar] = useState(true);
const cleanupInspectorState = useCallback(() => {
console.log('🧹 Cleaning up inspector state and data-original attributes');
setInspectedElementInfo(null);
setInspectMode(false);
setHtmlInput(currentHtml => {
const parser = new DOMParser();
const doc = parser.parseFromString(currentHtml, 'text/html');
let modified = false;
// Remove hidden backup elements
doc.querySelectorAll('[data-original="true"]').forEach(el => {
el.remove();
modified = true;
});
// Clean attributes from visible elements
doc.querySelectorAll('[data-original="false"]').forEach(el => {
el.removeAttribute('data-original');
modified = true;
});
if (modified) {
const newHtml = doc.body.innerHTML;
console.log('🧹 Cleaned HTML from', currentHtml.length, 'to', newHtml.length, 'chars');
return newHtml;
}
return currentHtml;
});
}, []);
useEffect(() => {
if (inspectedElementInfo) return;
const parser = new DOMParser();
const doc = parser.parseFromString(htmlInput, 'text/html');
let modified = false;
let idCounter = 0;
doc.body.querySelectorAll('[data-cascade-id]').forEach(el => {
const idNum = parseInt(el.getAttribute('data-cascade-id').split('-')[1], 10);
if (!isNaN(idNum) && idNum >= idCounter) {
idCounter = idNum + 1;
}
});
doc.body.querySelectorAll('*').forEach(el => {
if (!el.hasAttribute('data-cascade-id') && el.tagName.toLowerCase() !== 'body' && el.tagName.toLowerCase() !== 'html' && el.tagName.toLowerCase() !== 'head') {
el.setAttribute('data-cascade-id', `cascade-${idCounter++}`);
modified = true;
}
});
if (modified) {
const isFragment = !htmlInput.trim().toLowerCase().startsWith('<html');
const newHtml = isFragment ? doc.body.innerHTML : doc.documentElement.outerHTML;
if (newHtml !== htmlInput) {
setHtmlInput(newHtml);
}
}
}, [htmlInput, inspectedElementInfo]);
const createDuplicateInCodeBox = useCallback((elementInfo) => {
const cascadeId = elementInfo.attributes['data-cascade-id'];
if (!cascadeId) {
console.error('❌ Cannot create duplicate: Element is missing data-cascade-id.');
return false;
}
setHtmlInput(currentHtml => {
const parser = new DOMParser();
const doc = parser.parseFromString(currentHtml, 'text/html');
const originalElement = doc.querySelector(`[data-cascade-id="${cascadeId}"]`);
if (!originalElement) {
console.error(`❌ Could not find element with ${cascadeId} in HTML.`);
return currentHtml;
}
const hiddenElement = originalElement.cloneNode(true);
hiddenElement.setAttribute('data-original', 'true');
hiddenElement.style.display = 'none';
const visibleElement = originalElement.cloneNode(true);
visibleElement.setAttribute('data-original', 'false');
originalElement.parentNode.insertBefore(hiddenElement, originalElement);
originalElement.parentNode.insertBefore(visibleElement, originalElement);
console.log(`✅ Successfully created duplicates for ${cascadeId}`);
return doc.body.innerHTML;
});
return true;
}, []);
const handleElementClick = useCallback((elementInfo) => {
console.log('🔍 Element selected for inspection:', elementInfo);
if (createDuplicateInCodeBox(elementInfo)) {
setInspectedElementInfo(elementInfo);
setInspectMode(false);
}
}, [createDuplicateInCodeBox]);
doc.removeEventListener('click', handleIframeClick, true);
};
}
}
}, [htmlInput, cssInput, jsInput, inspectMode, createDuplicateInCodeBox, cleanupInspectorState]);
const handleRefresh = useCallback(() => {
const handleRefresh = () => {
setHtmlInput(prev => prev);
setCssInput(prev => prev);
setJsInput(prev => prev);
};
const toggleFullscreen = (targetDevice = null) => {
setIsFullscreen(!isFullscreen);
if (!isFullscreen) {
setSidebarCollapsed(false);
if (targetDevice) {
setSelectedDevice(targetDevice);
}
}
};
const toggleSidebar = () => {
setSidebarCollapsed(!sidebarCollapsed);
};
return (
<ToolLayout
title="HTML Preview & Inspector"
mainContent={
<div className={`flex h-full ${isFullscreen ? 'flex-row' : 'flex-col'}`}>
{/* Left Sidebar - Code Input */}
<div className={`flex flex-col space-y-4 transition-all duration-300 ${
isFullscreen
? `${sidebarCollapsed ? 'hidden' : 'w-80'} bg-gray-50 dark:bg-gray-800 p-4 border-r border-gray-200 dark:border-gray-700 overflow-y-auto h-[calc(100vh-4rem)]`
: 'p-4'
}`}>
<div>
<label htmlFor="html-input" className="block text-lg font-medium text-gray-900 dark:text-gray-100 mb-1">HTML Code</label>
<textarea
id="html-input"
className="w-full p-3 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-blue-500 focus:border-blue-500 font-mono text-sm"
rows={isFullscreen ? "15" : (showCss || showJs ? "10" : "30")}
value={htmlInput}
onChange={(e) => setHtmlInput(e.target.value)}
placeholder="<!-- Your HTML code here -->"
></textarea>
<div className="flex gap-2 mt-2">
<button
onClick={() => setShowCss(!showCss)}
className={`px-3 py-1 text-sm rounded-md transition-colors ${showCss ? 'bg-blue-500 text-white hover:bg-blue-600' : 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600'}`}>
{showCss ? 'Hide' : 'Show'} CSS
</button>
<button
onClick={() => setShowJs(!showJs)}
className={`px-3 py-1 text-sm rounded-md transition-colors ${showJs ? 'bg-blue-500 text-white hover:bg-blue-600' : 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600'}`}>
{showJs ? 'Hide' : 'Show'} JS
</button>
</div>
</div>
{showCss && (
<div>
<label htmlFor="css-input" className="block text-lg font-medium text-gray-900 dark:text-gray-100 mb-1">CSS Styles (Optional)</label>
<textarea
id="css-input"
className="w-full p-3 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-blue-500 focus:border-blue-500 font-mono text-sm"
rows="8"
value={cssInput}
onChange={(e) => setCssInput(e.target.value)}
placeholder="/* Your CSS styles here */"
></textarea>
</div>
)}
{showJs && (
<div>
<label htmlFor="js-input" className="block text-lg font-medium text-gray-900 dark:text-gray-100 mb-1">JavaScript (Optional)</label>
<textarea
id="js-input"
className="w-full p-3 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-blue-500 focus:border-blue-500 font-mono text-sm"
rows="8"
value={jsInput}
onChange={(e) => setJsInput(e.target.value)}
placeholder="// Your JavaScript code here"
></textarea>
</div>
)}
</div>
{/* Preview Area */}
<div className={`flex flex-col transition-all duration-300 ${isFullscreen ? `flex-1 h-[calc(100vh-4rem)]` : 'flex-1'}`}>
{!isFullscreen && (
<div className="px-4 pt-4">
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-1">Preview</h3>
</div>
)}
<div className={`flex justify-center items-center bg-gray-100 dark:bg-gray-900 ${isFullscreen ? 'flex-1 rounded-none p-4' : 'flex-grow rounded-md m-4'}`}>
<div
className={`relative transition-all duration-300 ease-in-out shadow-lg`}
style={{
width: isFullscreen ? devices[selectedDevice].width : devices['mobile'].width,
height: isFullscreen ? devices[selectedDevice].height : devices['mobile'].height,
}}
>
<iframe
ref={iframeRef}
title="HTML Preview"
className="w-full h-full border-none bg-white dark:bg-gray-800 rounded-lg"
sandbox="allow-scripts allow-forms allow-popups allow-modals allow-same-origin"
></iframe>
</div>
</div>
{/* Preview Tools - Bottom Toolbar */}
<div className={`flex justify-between items-center p-4 border-t border-gray-200 dark:border-gray-700 ${isFullscreen ? 'fixed bottom-0 left-0 right-0 bg-white dark:bg-gray-900 z-50' : ''}`}>
<div className="flex items-center space-x-2">
{isFullscreen && (
<button onClick={toggleSidebar} className={`p-2 rounded-md ${!sidebarCollapsed ? 'bg-green-500 text-white' : 'bg-gray-200 dark:bg-gray-700'} hover:bg-green-600`} title="Toggle Code Sidebar">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" /></svg>
</button>
)}
</div>
<div className="flex justify-center items-center space-x-2">
<button onClick={handleRefresh} className="p-2 rounded-md bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600" title="Refresh Preview"><RefreshCw size={20} /></button>
{isFullscreen && (
<button onClick={() => setInspectMode(!inspectMode)} className={`p-2 rounded-md ${inspectMode ? 'bg-purple-500 text-white' : 'bg-gray-200 dark:bg-gray-700'} hover:bg-purple-600`} title="Toggle Inspect Mode"><Inspect size={20} /></button>
)}
{Object.entries(devices).map(([key, { icon: DeviceIcon }]) => (
<button
key={key}
onClick={() => isFullscreen ? setSelectedDevice(key) : toggleFullscreen(key)}
className={`p-2 rounded-md ${selectedDevice === key && isFullscreen ? 'bg-blue-500 text-white' : 'bg-gray-200 dark:bg-gray-700'} hover:bg-blue-600`}
title={`${key.charAt(0).toUpperCase() + key.slice(1)} View`}>
<DeviceIcon size={20} />
</button>
))}
</div>
<div className="flex items-center space-x-2">
<button onClick={() => toggleFullscreen()} className={`px-4 py-2 rounded-lg transition-colors flex items-center gap-2 ${isFullscreen ? 'bg-red-600 text-white hover:bg-red-700' : 'bg-blue-600 text-white hover:bg-blue-700'}`}>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
{isFullscreen ? <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> : <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4" />}
</svg>
{isFullscreen ? 'Exit' : 'Fullscreen'}
</button>
</div>
</div>
</div>
{isFullscreen && inspectedElementInfo && (
<div className="w-80 bg-gray-50 dark:bg-gray-800 p-4 border-l border-gray-200 dark:border-gray-700 overflow-y-auto flex-shrink-0 h-[calc(100vh-4rem)]">
<div className="flex justify-between items-center mb-4">
<h4 className="text-lg font-medium text-gray-900 dark:text-gray-100">Element Editor</h4>
<button onClick={cleanupInspectorState} className="text-gray-600 dark:text-gray-400 hover:text-gray-800" title="Close">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
<ElementEditor
key={inspectedElementInfo.attributes['data-cascade-id']}
htmlInput={htmlInput}
setHtmlInput={setHtmlInput}
onClose={cleanupInspectorState}
/>
</div>
)}
</div>
}
/>
);
};
// ##################################################################################
// # ELEMENT EDITOR COMPONENT (REWRITTEN BASED ON USER'S EXACT LOGIC)
// ##################################################################################
const ElementEditor = ({ htmlInput, setHtmlInput, onClose }) => {
const [edited, setEdited] = useState(null);
// On mount, parse the element being edited (the one with data-original="false")
useEffect(() => {
const editableElementRegex = /<([a-zA-Z0-9]+)((?:\s+[\w-]+(?:="[^"]*")?)*?)\s+data-original="false"((?:\s+[\w-]+(?:="[^"]*")?)*?)>(.*?)<\/\1>|<([a-zA-Z0-9]+)((?:\s+[\w-]+(?:="[^"]*")?)*?)\s+data-original="false"((?:\s+[\w-]+(?:="[^"]*")?)*?)\/>/s;
const match = htmlInput.match(editableElementRegex);
if (match) {
const isSelfClosing = !!match[5];
const tagName = isSelfClosing ? match[5] : match[1];
const allAttributesString = (isSelfClosing ? (match[6] || '') + (match[7] || '') : (match[2] || '') + (match[3] || ''));
const innerHTML = isSelfClosing ? '' : match[4] || '';
const attributes = {};
const attrRegex = /([\w-]+)(?:="([^"]*)")?/g;
let attrMatch;
while ((attrMatch = attrRegex.exec(allAttributesString)) !== null) {
attributes[attrMatch[1]] = attrMatch[2] === undefined ? true : attrMatch[2];
}
const tempDiv = document.createElement('div');
tempDiv.innerHTML = innerHTML;
const initialState = {
tagName: tagName,
id: attributes.id || '',
className: attributes.class || '',
innerText: tempDiv.textContent || '',
...Object.fromEntries(Object.entries(attributes).filter(([key]) => key !== 'id' && key !== 'class')),
};
setEdited(initialState);
}
}, [htmlInput]);
// 3.A: On field change, update the element with data-original="false" in the code box
const handleFieldChange = (field, value) => {
const newEditedState = { ...edited, [field]: value };
setEdited(newEditedState);
setHtmlInput(currentHtml => {
const newElementHtml = buildElementHtml(newEditedState);
const editableElementRegex = /<[^>]+data-original="false"[^>]*>.*?<\/[^>]+>|<[^>]+data-original="false"[^>]*\/>/s;
if (!editableElementRegex.test(currentHtml)) {
console.error("Live Update Error: Cannot find element with data-original='false' to replace.");
return currentHtml;
}
return currentHtml.replace(editableElementRegex, newElementHtml);
});
};
const buildElementHtml = (state) => {
const { tagName, id, className, innerText, ...otherAttrs } = state;
const isSelfClosing = ['img', 'input', 'br', 'hr', 'meta', 'link'].includes(tagName.toLowerCase());
let attrs = 'data-original="false"';
if (id) attrs += ` id="${id}"`;
if (className) attrs += ` class="${className}"`;
for (const [key, value] of Object.entries(otherAttrs)) {
if (value === true) {
attrs += ` ${key}`;
} else if (value) {
attrs += ` ${key}="${value}"`;
}
}
if (isSelfClosing) {
return `<${tagName} ${attrs.trim()} />`;
} else {
const tempDiv = document.createElement('div');
tempDiv.innerText = innerText || '';
return `<${tagName} ${attrs.trim()}>${tempDiv.innerHTML}</${tagName}>`;
}
};
// 4. On Save, finalize the changes
const handleSave = () => {
setHtmlInput(currentHtml => {
const pairRegex = /(<[^>]+data-original="true"[^>]*style="display:none;"[^>]*>.*?<\/[^>]+>|<[^>]+data-original="true"[^>]*style="display:none;"[^>]*\/>)\s*(<[^>]+data-original="false"[^>]*>.*?<\/[^>]+>|<[^>]+data-original="false"[^>]*\/>)/s;
const pairMatch = currentHtml.match(pairRegex);
if (!pairMatch) {
console.error("Save Error: Could not find the hidden/visible element pair.");
return currentHtml;
}
const visibleElement = pairMatch[2];
const savedElement = visibleElement.replace(/\s*data-original="false"\s*/, ' ').replace(/\s{2,}/g, ' ');
return currentHtml.replace(pairRegex, savedElement);
});
onClose();
};
// 5. On Cancel, revert the changes
const handleCancel = () => {
setHtmlInput(currentHtml => {
const pairRegex = /(<[^>]+data-original="true"[^>]*style="display:none;"[^>]*>.*?<\/[^>]+>|<[^>]+data-original="true"[^>]*style="display:none;"[^>]*\/>)\s*(<[^>]+data-original="false"[^>]*>.*?<\/[^>]+>|<[^>]+data-original="false"[^>]*\/>)/s;
const pairMatch = currentHtml.match(pairRegex);
if (!pairMatch) {
console.error("Cancel Error: Could not find the hidden/visible element pair.");
return currentHtml;
}
const hiddenElement = pairMatch[1];
const unhiddenElement = hiddenElement
.replace(/\s*data-original="true"\s*/, ' ')
.replace(/\s*style="display:none;"\s*/, ' ')
.replace(/\s{2,}/g, ' ');
return currentHtml.replace(pairRegex, unhiddenElement);
});
onClose();
};
if (!edited) return <p className="dark:text-gray-300">Loading editor...</p>;
const otherAttributes = Object.keys(edited).filter(
key => key !== 'tagName' && key !== 'id' && key !== 'className' && key !== 'innerText'
);
return (
<div className="space-y-4">
{['tagName', 'id', 'className'].map(field => (
<div key={field} className="space-y-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">{field.replace(/([A-Z])/g, ' $1').replace(/^./, str => str.toUpperCase())}</label>
<input
type="text"
value={edited[field] || ''}
onChange={(e) => handleFieldChange(field, e.target.value)}
className="w-full p-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 font-mono text-sm"
/>
</div>
))}
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">Inner Text</label>
<textarea
value={edited.innerText || ''}
onChange={(e) => handleFieldChange('innerText', e.target.value)}
rows="4"
className="w-full p-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 font-mono text-sm"
/>
</div>
{otherAttributes.map(attr => (
<div key={attr} className="space-y-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">{attr}</label>
<input
type="text"
value={edited[attr] === true ? "" : edited[attr] || ''}
placeholder={edited[attr] === true ? "(boolean attribute)" : ""}
onChange={(e) => handleFieldChange(attr, e.target.value)}
className="w-full p-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 font-mono text-sm"
/>
</div>
))}
<div className="flex flex-col space-y-2 pt-4">
<button onClick={handleSave} className="w-full px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 transition-colors">Save Changes</button>
<button onClick={handleCancel} className="w-full px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 transition-colors">Cancel</button>
</div>
</div>
);
};
export default HtmlPreviewTool;

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

@@ -0,0 +1,506 @@
import React, { useState, useRef, useCallback } from 'react';
import { Edit3, Upload, FileText, 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 = useCallback((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 = useCallback((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);
}
}, [phpSerialize]);
// 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, generateOutputs]);
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;

View File

@@ -12,6 +12,7 @@ const SerializeTool = () => {
const [editorMode, setEditorMode] = useState('text'); // 'text' or 'visual'
const [structuredData, setStructuredData] = useState({});
// Simple PHP serialize implementation for common data types
const phpSerialize = (data) => {
if (data === null) return 'N;';
@@ -20,7 +21,11 @@ const SerializeTool = () => {
return Number.isInteger(data) ? `i:${data};` : `d:${data};`;
}
if (typeof data === 'string') {
return `s:${data.length}:"${data}";`;
// Escape quotes and backslashes in the string first
const escapedData = data.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
// PHP serialize requires UTF-8 byte length of the ESCAPED string
const byteLength = new TextEncoder().encode(escapedData).length;
return `s:${byteLength}:"${escapedData}";`;
}
if (Array.isArray(data)) {
let result = `a:${data.length}:{`;
@@ -45,51 +50,145 @@ const SerializeTool = () => {
// Simple PHP unserialize implementation
const phpUnserialize = (str) => {
let index = 0;
const parseValue = () => {
if (index >= str.length) {
throw new Error('Unexpected end of string');
}
const type = str[index];
// Handle NULL case (no colon after N)
if (type === 'N') {
index += 2; // Skip 'N;'
return null;
}
// For all other types, expect colon after type
if (str[index + 1] !== ':') {
throw new Error(`Expected ':' after type '${type}' at position ${index + 1}`);
}
index += 2; // Skip type and ':'
switch (type) {
case 'N':
index++; // Skip ';'
return null;
case 'b':
const boolVal = str[index] === '1';
index += 2; // Skip value and ';'
return boolVal;
case 'i':
let intStr = '';
while (str[index] !== ';') {
while (index < str.length && str[index] !== ';') {
intStr += str[index++];
}
if (index >= str.length) {
throw new Error('Unexpected end of string while parsing integer');
}
index++; // Skip ';'
return parseInt(intStr);
case 'd':
let floatStr = '';
while (str[index] !== ';') {
while (index < str.length && str[index] !== ';') {
floatStr += str[index++];
}
if (index >= str.length) {
throw new Error('Unexpected end of string while parsing float');
}
index++; // Skip ';'
return parseFloat(floatStr);
case 's':
let lenStr = '';
while (str[index] !== ':') {
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++; // Skip ':'
index++; // Skip '"'
const length = parseInt(lenStr);
const stringVal = str.substr(index, length);
index += length + 2; // Skip string and '";'
// Expect opening quote
if (str[index] !== '"') {
throw new Error(`Expected '"' at position ${index}`);
}
index++; // Skip opening '"'
const byteLength = parseInt(lenStr);
console.log(`Parsing string with declared length: ${byteLength}, starting at position: ${index}`);
if (isNaN(byteLength) || byteLength < 0) {
throw new Error(`Invalid string length: ${lenStr}`);
}
// Handle empty strings
if (byteLength === 0) {
// Expect closing quote and semicolon immediately
if (index + 1 >= str.length || str[index] !== '"' || str[index + 1] !== ';') {
throw new Error(`Expected '";' after empty string at position ${index}`);
}
index += 2; // Skip closing '";'
return '';
}
// Find the actual end of the string by looking for the closing quote-semicolon pattern
const startIndex = index;
let endQuotePos = -1;
// Look for the pattern '";' starting from the current position
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}`);
}
// Extract the actual string content
const stringVal = str.substring(startIndex, endQuotePos);
const actualByteLength = new TextEncoder().encode(stringVal).length;
console.log(`String parsing: declared ${byteLength} bytes, actual ${actualByteLength} bytes, content length ${stringVal.length} chars`);
console.log(`Extracted string: "${stringVal.substring(0, 50)}${stringVal.length > 50 ? '...' : ''}"`);
// Move index to after the closing '";'
index = endQuotePos + 2;
console.log(`After string parsing, index is at: ${index}, next chars: "${str.substring(index, index + 5)}"`);
// Warn about byte length mismatch but continue parsing
if (actualByteLength !== byteLength) {
console.warn(`Warning: String byte length mismatch - declared ${byteLength}, actual ${actualByteLength}`);
}
return stringVal;
case 'a':
let arrayLenStr = '';
while (str[index] !== ':') {
while (index < str.length && str[index] !== ':') {
arrayLenStr += str[index++];
}
index += 2; // Skip ':{'
if (index >= str.length) {
throw new Error('Unexpected end of string while parsing array length');
}
index++; // Skip ':'
// Expect opening brace
if (str[index] !== '{') {
throw new Error(`Expected '{' at position ${index}`);
}
index++; // Skip '{'
const arrayLength = parseInt(arrayLenStr);
if (isNaN(arrayLength) || arrayLength < 0) {
throw new Error(`Invalid array length: ${arrayLenStr}`);
}
const result = {};
let isArray = true;
@@ -97,13 +196,20 @@ const SerializeTool = () => {
const key = parseValue();
const value = parseValue();
result[key] = value;
// Check if this looks like a sequential array
if (typeof key !== 'number' || key !== i) {
isArray = false;
}
}
// Expect closing brace
if (index >= str.length || str[index] !== '}') {
throw new Error(`Expected '}' at position ${index}`);
}
index++; // Skip '}'
// Convert to array if all keys are sequential integers
// Convert to array if all keys are sequential integers starting from 0
if (isArray && arrayLength > 0) {
const arr = [];
for (let i = 0; i < arrayLength; i++) {
@@ -113,12 +219,22 @@ const SerializeTool = () => {
}
return result;
default:
throw new Error(`Unknown type: ${type}`);
throw new Error(`Unknown type: '${type}' at position ${index - 2}`);
}
};
return parseValue();
try {
const result = parseValue();
// Check if there's unexpected trailing data
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}`);
}
};
const handleSerialize = () => {
@@ -186,6 +302,42 @@ const SerializeTool = () => {
}
};
// Function to open output in visual editor
const openInVisualEditor = () => {
try {
// Parse the output to validate it's JSON
const parsedData = JSON.parse(output);
// Switch to serialize mode
setMode('serialize');
// Set the input with the output content
setInput(output);
// Set structured data for visual editor
setStructuredData(parsedData);
// Switch to visual editor mode
setEditorMode('visual');
// Clear any errors
setError('');
} catch (err) {
setError('Cannot open in visual editor: Invalid JSON format');
}
};
// Check if output contains valid JSON
const isValidJsonOutput = () => {
if (!output || output.startsWith('Error:')) return false;
try {
JSON.parse(output);
return true;
} catch {
return false;
}
};
const clearAll = () => {
setInput('');
setOutput('');
@@ -278,7 +430,7 @@ const SerializeTool = () => {
Clear All
</button>
</div>
{/* Input/Output Grid */}
<div className={`grid gap-6 ${
mode === 'serialize' && editorMode === 'visual'
@@ -323,9 +475,20 @@ const SerializeTool = () => {
{/* Output */}
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
{mode === 'serialize' ? 'Serialized Output' : 'JSON Output'}
</label>
<div className="flex justify-between items-center">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
{mode === 'serialize' ? 'Serialized Output' : 'JSON Output'}
</label>
{mode === 'unserialize' && isValidJsonOutput() && (
<button
onClick={openInVisualEditor}
className="flex items-center space-x-1 px-3 py-1 text-xs font-medium text-primary-600 dark:text-primary-400 bg-primary-50 dark:bg-primary-900/20 border border-primary-200 dark:border-primary-800 rounded-md hover:bg-primary-100 dark:hover:bg-primary-900/30 transition-colors"
>
<Edit3 className="h-3 w-3" />
<span>View in Visual Editor</span>
</button>
)}
</div>
<div className="relative">
<textarea
value={output}

265
src/pages/TextLengthTool.js Normal file
View File

@@ -0,0 +1,265 @@
import React, { useState, useEffect } from 'react';
import { Type, Copy, RotateCcw } from 'lucide-react';
import ToolLayout from '../components/ToolLayout';
import CopyButton from '../components/CopyButton';
const TextLengthTool = () => {
const [text, setText] = useState('');
const [stats, setStats] = useState({
characters: 0,
charactersNoSpaces: 0,
words: 0,
sentences: 0,
paragraphs: 0,
lines: 0,
bytes: 0
});
const [showDetails, setShowDetails] = useState(false);
// Calculate text statistics
useEffect(() => {
const calculateStats = () => {
if (!text) {
setStats({
characters: 0,
charactersNoSpaces: 0,
words: 0,
sentences: 0,
paragraphs: 0,
lines: 0,
bytes: 0
});
return;
}
// Characters
const characters = text.length;
const charactersNoSpaces = text.replace(/\s/g, '').length;
// Words (split by whitespace and filter empty strings)
const words = text.trim() ? text.trim().split(/\s+/).length : 0;
// Sentences (split by sentence endings)
const sentences = text.trim() ? text.split(/[.!?]+/).filter(s => s.trim().length > 0).length : 0;
// Paragraphs (split by double line breaks or more)
const paragraphs = text.trim() ? text.split(/\n\s*\n/).filter(p => p.trim().length > 0).length : 0;
// Lines (split by line breaks)
const lines = text ? text.split('\n').length : 0;
// Bytes (UTF-8 encoding)
const bytes = new TextEncoder().encode(text).length;
setStats({
characters,
charactersNoSpaces,
words,
sentences,
paragraphs,
lines,
bytes
});
};
calculateStats();
}, [text]);
const clearText = () => {
setText('');
};
const loadSample = () => {
setText(`Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.
Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum! Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium?`);
};
const formatNumber = (num) => {
return num.toLocaleString();
};
const getReadingTime = () => {
// Average reading speed: 200-250 words per minute
const wordsPerMinute = 225;
const minutes = Math.ceil(stats.words / wordsPerMinute);
return minutes === 1 ? '1 minute' : `${minutes} minutes`;
};
const getTypingTime = () => {
// Average typing speed: 40 words per minute
const wordsPerMinute = 40;
const minutes = Math.ceil(stats.words / wordsPerMinute);
return minutes === 1 ? '1 minute' : `${minutes} minutes`;
};
return (
<ToolLayout
title="Text Length Checker"
description="Analyze text length, word count, and other text statistics"
icon={Type}
>
{/* Controls */}
<div className="flex flex-wrap gap-3 mb-6">
<button onClick={loadSample} className="tool-button-secondary">
Load Sample Text
</button>
<button onClick={clearText} className="tool-button-secondary flex items-center whitespace-nowrap">
<RotateCcw className="h-4 w-4 mr-2" />
Clear Text
</button>
<button
onClick={() => setShowDetails(!showDetails)}
className="tool-button-secondary"
>
{showDetails ? 'Hide' : 'Show'} Details
</button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Text Input */}
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Text to Analyze
</label>
<div className="relative">
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Enter or paste your text here to analyze its length and statistics..."
className="tool-input h-96 resize-none"
style={{ minHeight: '400px' }}
/>
{text && <CopyButton text={text} />}
</div>
</div>
{/* Statistics */}
<div className="space-y-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
Text Statistics
</h3>
{/* Main Stats Grid */}
<div className="grid grid-cols-2 gap-4">
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<div className="text-2xl font-bold text-primary-600 dark:text-primary-400">
{formatNumber(stats.characters)}
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">Characters</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<div className="text-2xl font-bold text-primary-600 dark:text-primary-400">
{formatNumber(stats.charactersNoSpaces)}
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">Characters (no spaces)</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<div className="text-2xl font-bold text-green-600 dark:text-green-400">
{formatNumber(stats.words)}
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">Words</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400">
{formatNumber(stats.lines)}
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">Lines</div>
</div>
</div>
{/* Additional Stats (when details are shown) */}
{showDetails && (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<div className="text-xl font-bold text-purple-600 dark:text-purple-400">
{formatNumber(stats.sentences)}
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">Sentences</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<div className="text-xl font-bold text-orange-600 dark:text-orange-400">
{formatNumber(stats.paragraphs)}
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">Paragraphs</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<div className="text-xl font-bold text-red-600 dark:text-red-400">
{formatNumber(stats.bytes)} bytes
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">Size (UTF-8 encoding)</div>
</div>
{/* Reading & Typing Time */}
{stats.words > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<div className="text-lg font-semibold text-blue-800 dark:text-blue-200">
📖 {getReadingTime()}
</div>
<div className="text-sm text-blue-600 dark:text-blue-400">Estimated reading time</div>
</div>
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-4">
<div className="text-lg font-semibold text-green-800 dark:text-green-200">
{getTypingTime()}
</div>
<div className="text-sm text-green-600 dark:text-green-400">Estimated typing time</div>
</div>
</div>
)}
</div>
)}
{/* Copy Statistics */}
{(stats.characters > 0 || stats.words > 0) && (
<div className="mt-4">
<button
onClick={() => {
const statsText = `Text Statistics:
Characters: ${formatNumber(stats.characters)}
Characters (no spaces): ${formatNumber(stats.charactersNoSpaces)}
Words: ${formatNumber(stats.words)}
Lines: ${formatNumber(stats.lines)}
Sentences: ${formatNumber(stats.sentences)}
Paragraphs: ${formatNumber(stats.paragraphs)}
Bytes: ${formatNumber(stats.bytes)}
${stats.words > 0 ? `Reading time: ${getReadingTime()}
Typing time: ${getTypingTime()}` : ''}`;
navigator.clipboard.writeText(statsText);
}}
className="flex items-center space-x-2 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
<Copy className="h-4 w-4" />
<span>Copy Statistics</span>
</button>
</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> Perfect for checking character limits for social media posts, essays, or articles</li>
<li> Real-time counting updates as you type or paste text</li>
<li> Includes reading and typing time estimates based on average speeds</li>
<li> Byte count shows the actual storage size of your text in UTF-8 encoding</li>
<li> Use "Show Details" to see additional statistics like sentences and paragraphs</li>
</ul>
</div>
</ToolLayout>
);
};
export default TextLengthTool;

View File

@@ -1,213 +0,0 @@
import React, { useState, useRef } from 'react';
import { Search, Copy, Download } from 'lucide-react';
import { Controlled as CodeMirror } from 'react-codemirror2';
import 'codemirror/lib/codemirror.css';
import 'codemirror/theme/material.css';
import 'codemirror/mode/xml/xml';
import 'codemirror/mode/css/css';
import 'codemirror/mode/javascript/javascript';
import 'codemirror/addon/search/search';
import 'codemirror/addon/search/searchcursor';
import 'codemirror/addon/dialog/dialog';
import 'codemirror/addon/dialog/dialog.css';
const CodeInputs = ({
htmlInput,
setHtmlInput,
cssInput,
setCssInput,
jsInput,
setJsInput,
isFullscreen
}) => {
const [activeTab, setActiveTab] = useState('html');
const htmlEditorRef = useRef(null);
const cssEditorRef = useRef(null);
const jsEditorRef = useRef(null);
// Handle search functionality
const handleSearch = (editorRef) => {
if (editorRef.current && editorRef.current.editor) {
editorRef.current.editor.execCommand('find');
}
};
// Handle copy functionality
const handleCopy = async (content) => {
try {
await navigator.clipboard.writeText(content);
} catch (err) {
console.error('Failed to copy:', err);
}
};
// Handle export functionality
const handleExport = (content, filename) => {
const blob = new Blob([content], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
// Get current editor ref based on active tab
const getCurrentEditorRef = () => {
switch (activeTab) {
case 'html': return htmlEditorRef;
case 'css': return cssEditorRef;
case 'js': return jsEditorRef;
default: return htmlEditorRef;
}
};
// Get current content based on active tab
const getCurrentContent = () => {
switch (activeTab) {
case 'html': return htmlInput;
case 'css': return cssInput;
case 'js': return jsInput;
default: return htmlInput;
}
};
// Get filename for export based on active tab
const getExportFilename = () => {
switch (activeTab) {
case 'html': return 'code.html';
case 'css': return 'styles.css';
case 'js': return 'script.js';
default: return 'code.txt';
}
};
return (
<div className="flex flex-col h-full">
{/* Tab Navigation */}
<div className="flex border-b border-gray-200 dark:border-gray-700">
{[
{ id: 'html', label: 'HTML' },
{ id: 'css', label: 'CSS' },
{ id: 'js', label: 'JavaScript' }
].map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === tab.id
? 'border-blue-500 text-blue-600 dark:text-blue-400'
: 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'
}`}
>
{tab.label}
</button>
))}
</div>
{/* Action buttons above editor */}
<div className="flex items-center justify-end space-x-2 p-2 bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
<button
onClick={() => handleSearch(getCurrentEditorRef())}
className="flex items-center gap-1 px-2 py-1 text-xs text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 transition-colors"
title="Search"
>
<Search className="w-3 h-3" />
Search
</button>
<button
onClick={() => handleCopy(getCurrentContent())}
className="flex items-center gap-1 px-2 py-1 text-xs text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 transition-colors"
title="Copy"
>
<Copy className="w-3 h-3" />
Copy
</button>
<button
onClick={() => handleExport(getCurrentContent(), getExportFilename())}
className="flex items-center gap-1 px-2 py-1 text-xs text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 transition-colors"
title="Export"
>
<Download className="w-3 h-3" />
Export
</button>
</div>
{/* Code Editor */}
<div className="flex-1">
{activeTab === 'html' && (
<CodeMirror
ref={htmlEditorRef}
value={htmlInput}
onBeforeChange={(editor, data, value) => setHtmlInput(value)}
options={{
mode: 'xml',
theme: 'material',
lineNumbers: true,
lineWrapping: true,
autoCloseTags: true,
matchBrackets: true,
indentUnit: 2,
tabSize: 2,
extraKeys: {
'Ctrl-F': 'findPersistent',
'Cmd-F': 'findPersistent'
}
}}
className="h-full"
/>
)}
{activeTab === 'css' && (
<CodeMirror
ref={cssEditorRef}
value={cssInput}
onBeforeChange={(editor, data, value) => setCssInput(value)}
options={{
mode: 'css',
theme: 'material',
lineNumbers: true,
lineWrapping: true,
autoCloseBrackets: true,
matchBrackets: true,
indentUnit: 2,
tabSize: 2,
extraKeys: {
'Ctrl-F': 'findPersistent',
'Cmd-F': 'findPersistent'
}
}}
className="h-full"
/>
)}
{activeTab === 'js' && (
<CodeMirror
ref={jsEditorRef}
value={jsInput}
onBeforeChange={(editor, data, value) => setJsInput(value)}
options={{
mode: 'javascript',
theme: 'material',
lineNumbers: true,
lineWrapping: true,
autoCloseBrackets: true,
matchBrackets: true,
indentUnit: 2,
tabSize: 2,
extraKeys: {
'Ctrl-F': 'findPersistent',
'Cmd-F': 'findPersistent'
}
}}
className="h-full"
/>
)}
</div>
</div>
);
};
export default CodeInputs;

View File

@@ -1,15 +1,11 @@
import React, { useState, useRef } from 'react';
import { Search, Copy, Download } from 'lucide-react';
import { Controlled as CodeMirror } from 'react-codemirror2';
import 'codemirror/lib/codemirror.css';
import 'codemirror/theme/material.css';
import 'codemirror/mode/xml/xml';
import 'codemirror/mode/css/css';
import 'codemirror/mode/javascript/javascript';
import 'codemirror/addon/search/search';
import 'codemirror/addon/search/searchcursor';
import 'codemirror/addon/dialog/dialog';
import 'codemirror/addon/dialog/dialog.css';
import CodeMirror from '@uiw/react-codemirror';
import { html } from '@codemirror/lang-html';
import { css as cssLang } from '@codemirror/lang-css';
import { javascript } from '@codemirror/lang-javascript';
import { EditorView, keymap } from '@codemirror/view';
import { searchKeymap, openSearchPanel } from '@codemirror/search';
const CodeInputs = ({
htmlInput,
@@ -21,14 +17,15 @@ const CodeInputs = ({
isFullscreen
}) => {
const [activeTab, setActiveTab] = useState('html');
const htmlEditorRef = useRef(null);
const cssEditorRef = useRef(null);
const jsEditorRef = useRef(null);
const htmlViewRef = useRef(null);
const cssViewRef = useRef(null);
const jsViewRef = useRef(null);
// Handle search functionality
const handleSearch = (editorRef) => {
if (editorRef.current && editorRef.current.editor) {
editorRef.current.editor.execCommand('find');
const handleSearch = (viewRef) => {
const view = viewRef.current;
if (view) {
openSearchPanel(view);
}
};
@@ -57,10 +54,10 @@ const CodeInputs = ({
// Get current editor ref based on active tab
const getCurrentEditorRef = () => {
switch (activeTab) {
case 'html': return htmlEditorRef;
case 'css': return cssEditorRef;
case 'js': return jsEditorRef;
default: return htmlEditorRef;
case 'html': return htmlViewRef;
case 'css': return cssViewRef;
case 'js': return jsViewRef;
default: return htmlViewRef;
}
};
@@ -139,69 +136,36 @@ const CodeInputs = ({
<div className="flex-1">
{activeTab === 'html' && (
<CodeMirror
ref={htmlEditorRef}
value={htmlInput}
onBeforeChange={(editor, data, value) => setHtmlInput(value)}
options={{
mode: 'xml',
theme: 'material',
lineNumbers: true,
lineWrapping: true,
autoCloseTags: true,
matchBrackets: true,
indentUnit: 2,
tabSize: 2,
extraKeys: {
'Ctrl-F': 'findPersistent',
'Cmd-F': 'findPersistent'
}
}}
height={isFullscreen ? 'calc(100vh - 210px)' : '380px'}
extensions={[html(), keymap.of(searchKeymap), EditorView.lineWrapping]}
onChange={(value) => setHtmlInput(value)}
onUpdate={(vu) => { if (!htmlViewRef.current) htmlViewRef.current = vu.view; }}
basicSetup={{ lineNumbers: true }}
className="h-full"
/>
)}
{activeTab === 'css' && (
<CodeMirror
ref={cssEditorRef}
value={cssInput}
onBeforeChange={(editor, data, value) => setCssInput(value)}
options={{
mode: 'css',
theme: 'material',
lineNumbers: true,
lineWrapping: true,
autoCloseBrackets: true,
matchBrackets: true,
indentUnit: 2,
tabSize: 2,
extraKeys: {
'Ctrl-F': 'findPersistent',
'Cmd-F': 'findPersistent'
}
}}
height={isFullscreen ? 'calc(100vh - 210px)' : '380px'}
extensions={[cssLang(), keymap.of(searchKeymap), EditorView.lineWrapping]}
onChange={(value) => setCssInput(value)}
onUpdate={(vu) => { if (!cssViewRef.current) cssViewRef.current = vu.view; }}
basicSetup={{ lineNumbers: true }}
className="h-full"
/>
)}
{activeTab === 'js' && (
<CodeMirror
ref={jsEditorRef}
value={jsInput}
onBeforeChange={(editor, data, value) => setJsInput(value)}
options={{
mode: 'javascript',
theme: 'material',
lineNumbers: true,
lineWrapping: true,
autoCloseBrackets: true,
matchBrackets: true,
indentUnit: 2,
tabSize: 2,
extraKeys: {
'Ctrl-F': 'findPersistent',
'Cmd-F': 'findPersistent'
}
}}
height={isFullscreen ? 'calc(100vh - 210px)' : '380px'}
extensions={[javascript({ jsx: true }), keymap.of(searchKeymap), EditorView.lineWrapping]}
onChange={(value) => setJsInput(value)}
onUpdate={(vu) => { if (!jsViewRef.current) jsViewRef.current = vu.view; }}
basicSetup={{ lineNumbers: true }}
className="h-full"
/>
)}

View File

@@ -1,65 +0,0 @@
import React from 'react';
import { X } from 'lucide-react';
import ElementEditor from './ElementEditor';
const InspectorSidebar = ({
inspectedElementInfo,
htmlInput,
setHtmlInput,
onClose,
onSave,
previewFrameRef
}) => {
if (!inspectedElementInfo) return null;
return (
<div className="w-80 bg-white dark:bg-gray-800 border-l border-gray-200 dark:border-gray-700 flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
Inspector
</h3>
<button
onClick={onClose}
className="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Content */}
<div className="flex-1 p-4 overflow-y-auto">
<div className="space-y-4">
{/* Element Info */}
<div className="bg-gray-50 dark:bg-gray-700 p-3 rounded-md">
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Selected Element
</h4>
<p className="text-sm text-gray-600 dark:text-gray-400">
<span className="font-mono bg-gray-200 dark:bg-gray-600 px-1 rounded">
&lt;{inspectedElementInfo.tagName}&gt;
</span>
</p>
</div>
{/* Element Editor */}
<div>
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
Edit Properties
</h4>
<ElementEditor
htmlInput={htmlInput}
setHtmlInput={setHtmlInput}
onClose={onClose}
onSave={onSave}
selectedElementInfo={inspectedElementInfo}
previewFrameRef={previewFrameRef}
/>
</div>
</div>
</div>
</div>
);
};
export default InspectorSidebar;

View File

@@ -1,852 +0,0 @@
import React, { useState, useEffect, useRef, useCallback, forwardRef, useImperativeHandle } from 'react';
// Device Frame CSS - Converted from SCSS
const deviceFrameCSS = `
/* iPhone 14 Pro Device Frame */
.device-iphone-14-pro {
height: 780px;
width: 384px;
transform-origin: center;
position: relative;
margin: 0 auto;
}
.device-iphone-14-pro .device-frame {
background: #010101;
border: 1px solid #2a242f;
border-radius: 61px;
box-shadow: inset 0 0 4px 2px #a8a4b0, inset 0 0 0 5px #342C3F;
height: 780px;
padding: 17px;
width: 384px;
position: relative;
display: flex;
justify-content: center;
align-items: center;
}
.device-iphone-14-pro .device-screen {
border-radius: 56px;
height: 746px;
width: 350px;
overflow: hidden;
scale: 0.75;
min-width: 130%;
height: 130%;
}
.device-iphone-14-pro .device-screen iframe {
width: 130%; /* 100% / 0.75 = 133.33% to compensate for 0.75 scale */
height: 130%;
transform: scale(0.75);
transform-origin: top left;
}
/* Mobile scrollbar styling for iPhone */
.device-iphone-14-pro .device-screen::-webkit-scrollbar {
width: 2px;
}
.device-iphone-14-pro .device-screen::-webkit-scrollbar-track {
background: transparent;
}
.device-iphone-14-pro .device-screen::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.2);
border-radius: 1px;
}
.device-iphone-14-pro .device-stripe::after,
.device-iphone-14-pro .device-stripe::before {
border: solid rgba(1, 1, 1, 0.25);
border-width: 0 7px;
content: "";
height: 7px;
left: 0;
position: absolute;
width: 100%;
z-index: 9;
}
.device-iphone-14-pro .device-stripe::after {
top: 77px;
}
.device-iphone-14-pro .device-stripe::before {
bottom: 77px;
}
.device-iphone-14-pro .device-header {
background: #010101;
border-radius: 18px;
height: 31px;
left: 50%;
margin-left: -54px;
position: absolute;
top: 32px;
width: 108px;
z-index: 10;
}
.device-iphone-14-pro .device-sensors::after,
.device-iphone-14-pro .device-sensors::before {
content: "";
position: absolute;
}
.device-iphone-14-pro .device-sensors::after {
background: #010101;
border-radius: 16px;
height: 30px;
left: 50%;
margin-left: -54px;
top: 33px;
width: 67px;
z-index: 10;
}
.device-iphone-14-pro .device-sensors::before {
background: radial-gradient(farthest-corner at 20% 20%, #6074BF 0, transparent 40%),
radial-gradient(farthest-corner at 80% 80%, #513785 0, #24555E 20%, transparent 50%);
box-shadow: 0 0 1px 1px rgba(255, 255, 255, 0.05);
border-radius: 50%;
height: 8px;
left: 50%;
margin-left: 24px;
top: 44px;
width: 8px;
z-index: 10;
}
.device-iphone-14-pro .device-btns {
background: #2a242f;
border-radius: 1px;
height: 24px;
left: -2px;
position: absolute;
top: 86px;
width: 2px;
}
.device-iphone-14-pro .device-btns::after,
.device-iphone-14-pro .device-btns::before {
background: #2a242f;
border-radius: 1px;
content: "";
height: 46px;
left: 0;
position: absolute;
}
.device-iphone-14-pro .device-btns::after {
top: 45px;
}
.device-iphone-14-pro .device-btns::before {
top: 105px;
}
.device-iphone-14-pro .device-power {
background: #2a242f;
border-radius: 1px;
height: 75px;
right: -2px;
position: absolute;
top: 150px;
width: 2px;
}
/* iPad Pro Device Frame */
.device-ipad-pro {
height: 840px;
width: 600px;
transform-origin: center;
margin-top: 40px;
position: relative;
margin-left: auto;
margin-right: auto;
}
.device-ipad-pro .device-frame {
background: #0d0d0d;
border-radius: 32px;
box-shadow: inset 0 0 0 1px #c1c2c3, inset 0 0 1px 2px #e2e3e4;
height: 800px;
padding: 24px;
width: 576px;
position: relative;
display: flex;
justify-content: center;
align-items: center;
}
.device-ipad-pro .device-screen {
border: 2px solid #0f0f0f;
border-radius: 10px;
overflow: hidden;
min-width: 200%;
height: 200%;
scale: 0.5;
}
.device-ipad-pro .device-screen iframe {
/* Set the iframe to the actual device resolution and scale it down */
width: 834px; /* iPad Pro 11" logical width */
height: 1194px; /* iPad Pro 11" logical height */
transform: scale(0.6331); /* 528px (screen width) / 834px (logical width) */
transform-origin: top left;
background: #fff; /* Ensure bg color for content */
}
/* Mobile scrollbar styling for iPad */
.device-ipad-pro .device-screen::-webkit-scrollbar {
width: 3px;
}
.device-ipad-pro .device-screen::-webkit-scrollbar-track {
background: transparent;
}
.device-ipad-pro .device-screen::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.2);
border-radius: 2px;
}
.device-ipad-pro .device-power {
background: #2a242f;
border-radius: 2px;
height: 2px;
width: 38px;
right: 76px;
top: -2px;
position: absolute;
}
/* Reposition buttons specifically for iPad Pro */
.device-ipad-pro .device-btns {
background: #2a242f;
border-radius: 2px;
height: 30px; /* Volume up */
width: 2px;
right: 22px;
top: 90px;
position: absolute;
}
.device-ipad-pro .device-btns::after {
content: "";
background: #2a242f;
border-radius: 2px;
height: 30px; /* Volume down */
width: 2px;
left: 0;
top: 40px; /* Space between buttons */
position: absolute;
}
.device-ipad-pro .device-btns::before {
display: none; /* Hide the third button from iPhone */
}
.device-ipad-pro .device-sensors::after,
.device-ipad-pro .device-sensors::before {
content: "";
position: absolute;
}
.device-ipad-pro .device-sensors::after {
background: #141414;
border-radius: 16px;
box-shadow: -18px 0 #141414, 64px 0 #141414;
height: 10px;
left: 50%;
margin-left: -28px;
top: 11px;
width: 10px;
}
.device-ipad-pro .device-sensors::before {
background: radial-gradient(farthest-corner at 20% 20%, #6074BF 0, transparent 40%),
radial-gradient(farthest-corner at 80% 80%, #513785 0, #24555E 20%, transparent 50%);
box-shadow: 0 0 1px 1px rgba(255, 255, 255, 0.05);
border-radius: 50%;
height: 6px;
left: 50%;
margin-left: -3px;
top: 13px;
width: 5px;
}
/* Enable smooth scrolling on iOS */
.device-iphone-14-pro .device-screen,
.device-ipad-pro .device-screen {
-webkit-overflow-scrolling: touch; /* smooth momentum scroll on iOS */
overflow-y: auto;
}
/* Mobile custom scrollbar */
.device-iphone-14-pro .device-screen::-webkit-scrollbar,
.device-ipad-pro .device-screen::-webkit-scrollbar {
width: 4px;
height: 4px;
}
.device-iphone-14-pro .device-screen::-webkit-scrollbar-track,
.device-ipad-pro .device-screen::-webkit-scrollbar-track {
background: transparent;
}
.device-iphone-14-pro .device-screen::-webkit-scrollbar-thumb,
.device-ipad-pro .device-screen::-webkit-scrollbar-thumb {
background-color: rgba(0, 0, 0, 0.15);
border-radius: 8px;
border: 1px solid rgba(0, 0, 0, 0.05);
}
/* Optional: Hide scrollbar on larger screens for desktop */
/* This media query hides the scrollbar on desktops where touch scrolling is not needed */
@media (pointer: fine) and (hover: hover) {
.device-iphone-14-pro .device-screen::-webkit-scrollbar,
.device-ipad-pro .device-screen::-webkit-scrollbar {
display: none;
}
}
`;
const injectCascadeIds = (rootElement) => {
if (!rootElement) return;
let idCounter = 0;
const elements = rootElement.querySelectorAll('*');
elements.forEach(el => {
if (!el.hasAttribute('data-cascade-id')) {
el.setAttribute('data-cascade-id', `cascade-${idCounter++}`);
}
});
};
// Inspector mode CSS
const domSelectorCSS = `
/* Hover effect for all elements in inspect mode */
body[cascade-inspect-mode] *:hover {
outline: 2px solid #0066ff !important;
outline-offset: 2px !important;
box-shadow: 0 0 0 2px rgba(0, 102, 255, 0.3) !important;
cursor: crosshair !important;
transition: all 0.1s ease !important;
}
/* Selected element styling */
.cascade-selected {
outline: 3px solid #00cc66 !important;
outline-offset: 3px !important;
box-shadow: 0 0 0 3px rgba(0, 204, 102, 0.3) !important;
position: relative !important;
}
/* Ensure inspector styles override any existing styles */
body[cascade-inspect-mode] * {
cursor: crosshair !important;
}
`;
const cursorCSS = `
/* Additional cursor styling for inspect mode */
body[cascade-inspect-mode] {
cursor: crosshair !important;
}
`;
const PreviewFrame = forwardRef((props, ref) => {
const {
htmlInput,
cssInput,
jsInput,
onElementClick,
isInspectModeActive,
selectedDevice,
isFullscreen
} = props;
const iframeRef = useRef(null);
const [originalScrollPosition, setOriginalScrollPosition] = useState({ x: 0, y: 0 });
const storeScrollPosition = useCallback(() => {
const iframe = iframeRef.current;
if (!iframe?.contentWindow) return;
const scrollX = iframe.contentWindow.scrollX || 0;
const scrollY = iframe.contentWindow.scrollY || 0;
setOriginalScrollPosition({ x: scrollX, y: scrollY });
console.log('📍 SCROLL STORED:', { x: scrollX, y: scrollY });
}, []);
const restoreScrollPosition = useCallback(() => {
const iframe = iframeRef.current;
if (!iframe) return;
const attemptRestore = (retryCount = 0) => {
if (retryCount > 3) {
console.warn('⚠️ SCROLL RESTORE: Max retries reached, giving up');
return;
}
if (iframe.contentWindow && iframe.contentDocument) {
try {
iframe.contentWindow.scrollTo(originalScrollPosition.x, originalScrollPosition.y);
console.log('🔄 SCROLL RESTORED:', originalScrollPosition);
} catch (error) {
console.error('❌ SCROLL RESTORE ERROR:', error);
}
} else {
console.log(`🔄 SCROLL RESTORE: Iframe not ready, retrying... (${retryCount + 1}/3)`);
setTimeout(() => attemptRestore(retryCount + 1), 100);
}
};
attemptRestore();
}, [originalScrollPosition]);
const handleIframeClick = useCallback((e) => {
if (!isInspectModeActive) return;
e.preventDefault();
e.stopPropagation();
const iframe = iframeRef.current;
if (!iframe) return;
const doc = iframe.contentDocument || (iframe.contentWindow && iframe.contentWindow.document);
if (!doc) return;
storeScrollPosition();
const target = e.target;
if (!target) return;
let cascadeId = target.getAttribute('data-cascade-id');
if (!cascadeId) {
cascadeId = `cascade-${Date.now()}`;
target.setAttribute('data-cascade-id', cascadeId);
}
const elementInfo = {
tagName: target.tagName.toLowerCase(),
attributes: {},
textContent: target.textContent,
innerHTML: target.innerHTML,
cascadeId: cascadeId,
};
Array.from(target.attributes).forEach(attr => {
elementInfo.attributes[attr.name] = attr.value;
});
// Ensure className is properly mapped for ElementEditor compatibility
if (target.className) {
elementInfo.attributes.class = target.className;
}
console.log('🔍 ENHANCED SELECTION:', elementInfo);
console.log('🎯 INSPECTOR ACTIVATED: DOM manipulation mode enabled');
setTimeout(() => restoreScrollPosition(), 10);
onElementClick(elementInfo);
}, [isInspectModeActive, onElementClick, storeScrollPosition, restoreScrollPosition]);
const setupInspectModeStyles = useCallback(() => {
const iframe = iframeRef.current;
if (!iframe) return;
const doc = iframe.contentDocument || (iframe.contentWindow && iframe.contentWindow.document);
if (!doc) return;
// Declare variables outside if block for cleanup function access
let styleElement = null;
let cursorStyleElement = null;
// Add inspector styles if not already present
if (!doc.getElementById('dom-selector-styles')) {
styleElement = doc.createElement('style');
styleElement.id = 'dom-selector-styles';
styleElement.textContent = domSelectorCSS;
doc.head.appendChild(styleElement);
// Add cursor styles
cursorStyleElement = doc.createElement('style');
cursorStyleElement.id = 'cursor-styles';
cursorStyleElement.textContent = cursorCSS;
doc.head.appendChild(cursorStyleElement);
console.log('✅ DEBUG: Inspector styles injected');
} else {
console.log('✅ DEBUG: Inspector styles already present');
// Get references to existing elements for cleanup
styleElement = doc.getElementById('dom-selector-styles');
cursorStyleElement = doc.getElementById('cursor-styles');
}
// ALWAYS attach click event listener (this was the bug - it was being skipped)
if (doc.body) {
// Remove any existing listener first to prevent duplicates
doc.body.removeEventListener('click', handleIframeClick, true);
doc.body.addEventListener('click', handleIframeClick, true);
console.log('✅ DEBUG: Click handler attached successfully');
} else {
console.error('❌ DEBUG: No iframe body found, cannot attach click handler');
}
const observer = new MutationObserver((mutationsList) => {
for (const mutation of mutationsList) {
if (mutation.type === 'childList') {
mutation.addedNodes.forEach(node => {
if (node.nodeType === Node.ELEMENT_NODE) {
injectCascadeIds(node);
}
});
}
}
});
// Observe the iframe document for changes
observer.observe(doc.body || doc, { childList: true, subtree: true });
// Cleanup function
return () => {
try {
observer.disconnect();
if (styleElement && styleElement.parentNode) {
styleElement.remove();
}
if (cursorStyleElement && cursorStyleElement.parentNode) {
cursorStyleElement.remove();
}
console.log('🧹 Inspector styles and listeners cleaned up');
} catch (error) {
console.warn('⚠️ Cleanup warning (safe to ignore):', error.message);
}
};
}, [handleIframeClick]);
const generateHtmlContent = useCallback(() => {
// Always generate content - the parent component controls when to refresh
console.log('🔄 GENERATING HTML CONTENT for iframe');
const isFullHtml = htmlInput.trim().toLowerCase().startsWith('<html');
const cssLink = `<link rel="stylesheet" href="https://cdn.tailwindcss.com">`;
const customStyles = `<style>${cssInput}</style>`;
const scripts = `<script>${jsInput}</script>`;
if (isFullHtml) {
let processedHtml = htmlInput;
if (!processedHtml.includes(cssLink)) {
processedHtml = processedHtml.replace('</head>', `${cssLink}</head>`);
}
if (!processedHtml.includes(customStyles)) {
processedHtml = processedHtml.replace('</head>', `${customStyles}</head>`);
}
if (!processedHtml.includes(jsInput)) {
processedHtml = processedHtml.replace('</body>', `${scripts}</body>`);
}
return processedHtml;
} else {
return `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Preview</title>
${cssLink}
${customStyles}
</head>
<body>
${htmlInput}
${scripts}
</body>
</html>
`;
}
}, [htmlInput, cssInput, jsInput]);
useEffect(() => {
const iframe = iframeRef.current;
if (!iframe) return;
// ENHANCED OPTION A: Skip iframe refresh during inspector operations
if (window.isInspectorActive) {
console.log('🚫 ENHANCED OPTION A: Skipping iframe refresh during inspector operations');
return;
}
// Additional guard: Check if inspect mode is active
if (isInspectModeActive) {
console.log('🚫 ENHANCED OPTION A: Skipping iframe refresh - inspect mode is active');
return;
}
console.log('🔄 GENERATING HTML CONTENT for iframe');
const htmlContent = generateHtmlContent();
console.log('🔍 DEBUG: Generated HTML content length:', htmlContent.length);
// Always write content to iframe - Enhanced Option A uses DOM manipulation after content is loaded
const doc = iframe.contentDocument || iframe.contentWindow.document;
doc.open();
doc.write(htmlContent);
doc.close();
// The onload event ensures that the content is fully parsed and the DOM is ready
iframe.onload = () => {
// ENHANCED OPTION A: Inject cascade IDs immediately after content load
const iframeDoc = iframe.contentDocument || iframe.contentWindow?.document;
if (iframeDoc?.body) {
injectCascadeIds(iframeDoc.body);
console.log('🏷️ CASCADE IDS: Injected into fresh iframe content');
}
if (isInspectModeActive) {
console.log('🎨 Applying inspect mode styles to fresh content.');
setupInspectModeStyles();
}
// Restore scroll position only after content is fully loaded
restoreScrollPosition();
};
console.log('🔄 IFRAME REFRESHED: New content written');
console.log('🔍 IFRAME REFRESH TRIGGERED BY:', {
htmlInputLength: htmlInput.length,
cssInputLength: cssInput.length,
jsInputLength: jsInput.length,
selectedDevice,
isFullscreen
});
}, [htmlInput, cssInput, jsInput, selectedDevice, isFullscreen, generateHtmlContent, isInspectModeActive]);
// Dedicated useEffect for inspect mode activation/deactivation
useEffect(() => {
const iframe = iframeRef.current;
if (!iframe) return;
const doc = iframe.contentDocument || iframe.contentWindow?.document;
if (!doc) return;
if (isInspectModeActive) {
console.log('🎯 ACTIVATING inspect mode - setting up click handlers');
setupInspectModeStyles();
} else {
console.log('🚫 DEACTIVATING inspect mode - cleaning up');
try {
// Remove inspect styles
const styleElement = doc.getElementById('inspector-styles');
if (styleElement) {
styleElement.remove();
}
// Remove click handler
if (doc?.body) {
doc.body.removeEventListener('click', handleIframeClick, true);
}
// Remove selected class from any elements
const selectedElements = doc.querySelectorAll('.cascade-selected');
selectedElements.forEach(el => el.classList.remove('cascade-selected'));
} catch (error) {
console.warn('⚠️ Inspect mode cleanup warning (safe to ignore):', error.message);
}
}
}, [isInspectModeActive, setupInspectModeStyles, handleIframeClick]);
useEffect(() => {
const styleId = 'device-frame-styles';
if (!document.getElementById(styleId)) {
const style = document.createElement('style');
style.id = styleId;
style.textContent = deviceFrameCSS;
document.head.appendChild(style);
}
return () => {
const style = document.getElementById(styleId);
if (style) {
style.remove();
}
};
}, []);
const getDeviceWrapper = () => {
console.log('🔧 Device Frame Debug:', { isFullscreen, selectedDevice });
if (!isFullscreen) {
console.log('📱 Non-fullscreen: Using iPhone 14 Pro frame');
return {
wrapperClass: 'flex justify-center items-center w-full h-full',
deviceFrame: 'iphone-14-pro'
};
}
if (selectedDevice === 'desktop') {
console.log('🖥️ Desktop fullscreen: No device frame');
return {
wrapperClass: 'w-full h-full max-w-full overflow-hidden',
deviceFrame: null
};
}
switch (selectedDevice) {
case 'tablet':
console.log('📟 Rendering iPad Pro frame');
return {
wrapperClass: 'flex justify-center items-center w-full h-full',
deviceFrame: 'ipad-pro'
};
case 'mobile':
console.log('📱 Rendering iPhone 14 Pro frame');
return {
wrapperClass: 'flex justify-center items-center w-full h-full',
deviceFrame: 'iphone-14-pro'
};
default:
console.log('❓ Unknown device, no frame');
return {
wrapperClass: 'w-full h-full max-w-full overflow-hidden',
deviceFrame: null,
};
}
};
const { wrapperClass, deviceFrame } = getDeviceWrapper();
useImperativeHandle(ref, () => ({
updateElementStyle: (cascadeId, property, value) => {
try {
const iframe = iframeRef.current;
if (!iframe?.contentDocument) {
console.error('❌ [PreviewFrame] Iframe or contentDocument not available');
return false;
}
const element = iframe.contentDocument.querySelector(`[data-cascade-id="${cascadeId}"]`);
if (element) {
element.style[property] = value;
console.log(`🎨 [PreviewFrame] Updated style '${property}' for cascade-id: ${cascadeId}`);
return true;
} else {
console.error(`❌ [PreviewFrame] Element with cascade-id '${cascadeId}' not found for style update.`);
return false;
}
} catch (error) {
console.error(`❌ [PreviewFrame] Error updating style:`, error);
return false;
}
},
updateElementAttribute: (cascadeId, attribute, value) => {
try {
const iframe = iframeRef.current;
if (!iframe?.contentDocument) {
console.error('❌ [PreviewFrame] Iframe or contentDocument not available');
return false;
}
const element = iframe.contentDocument.querySelector(`[data-cascade-id="${cascadeId}"]`);
if (element) {
if (value === null || value === undefined || value === '') {
element.removeAttribute(attribute);
console.log(`🗑️ [PreviewFrame] Removed attribute '${attribute}' for cascade-id: ${cascadeId}`);
} else {
element.setAttribute(attribute, value);
console.log(`🏷️ [PreviewFrame] Updated attribute '${attribute}' for cascade-id: ${cascadeId}`);
}
return true;
} else {
console.error(`❌ [PreviewFrame] Element with cascade-id '${cascadeId}' not found for attribute update.`);
return false;
}
} catch (error) {
console.error(`❌ [PreviewFrame] Error updating attribute:`, error);
return false;
}
},
updateElementText: (cascadeId, textContent) => {
try {
const iframe = iframeRef.current;
if (!iframe?.contentDocument) {
console.error('❌ [PreviewFrame] Iframe or contentDocument not available');
return false;
}
const element = iframe.contentDocument.querySelector(`[data-cascade-id="${cascadeId}"]`);
if (element) {
element.textContent = textContent;
console.log(`📝 [PreviewFrame] Updated textContent for cascade-id: ${cascadeId}`);
return true;
} else {
console.error(`❌ [PreviewFrame] Element with cascade-id '${cascadeId}' not found for text update.`);
return false;
}
} catch (error) {
console.error(`❌ [PreviewFrame] Error updating text:`, error);
return false;
}
},
updateElementClass: (cascadeId, className) => {
try {
const iframe = iframeRef.current;
if (!iframe?.contentDocument) {
console.error('❌ [PreviewFrame] Iframe or contentDocument not available');
return false;
}
const element = iframe.contentDocument.querySelector(`[data-cascade-id="${cascadeId}"]`);
if (element) {
element.className = className;
console.log(`🎨 [PreviewFrame] Updated className for cascade-id: ${cascadeId}`);
return true;
} else {
console.error(`❌ [PreviewFrame] Element with cascade-id '${cascadeId}' not found for class update.`);
return false;
}
} catch (error) {
console.error(`❌ [PreviewFrame] Error updating class:`, error);
return false;
}
},
getIframeContent: () => {
if (!iframeRef.current || !iframeRef.current.contentWindow) return '';
// Ensure IDs are injected before returning content
const iframeDoc = iframeRef.current.contentWindow.document;
injectCascadeIds(iframeDoc.body);
return iframeDoc.documentElement.outerHTML;
},
// Add other exposed methods here if needed
}), []);
if (deviceFrame) {
return (
<div className={wrapperClass}>
<div className={`device device-${deviceFrame}`}>
<div className="device-frame">
<iframe
ref={iframeRef}
key={`device-${deviceFrame}-${selectedDevice}-${isFullscreen}`}
className="device-screen w-full h-full border-0"
title="HTML Preview"
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-modals"
/>
</div>
<div className="device-stripe"></div>
<div className="device-header"></div>
<div className="device-sensors"></div>
<div className="device-btns"></div>
<div className="device-power"></div>
</div>
</div>
);
}
return (
<div className={`${wrapperClass} bg-white rounded-lg shadow-lg`}>
<iframe
ref={iframeRef}
key={`no-device-${selectedDevice}-${isFullscreen}`}
className="w-full h-full border-0 overflow-hidden"
title="HTML Preview"
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-modals"
style={{ maxWidth: '100%', maxHeight: '100%' }}
/>
</div>
);
});
export default PreviewFrame;

114
src/styles/diff-theme.css Normal file
View File

@@ -0,0 +1,114 @@
/* Theme-aware styles for react-diff-view */
/* Using higher specificity selectors to override library defaults */
/* Light theme (default) */
.diff-container {
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 14px;
line-height: 1.4;
}
/* Target actual react-diff-view classes with high specificity */
.diff-container .diff-code-insert,
.diff-container .diff-line-insert,
.diff-container .diff-code-insert .diff-code-text,
.diff-container .diff-line-insert .diff-code-text {
background-color: #dcfce7 !important;
color: #15803d !important;
}
.diff-container .diff-code-delete,
.diff-container .diff-line-delete,
.diff-container .diff-code-delete .diff-code-text,
.diff-container .diff-line-delete .diff-code-text {
background-color: #fee2e2 !important;
color: #dc2626 !important;
}
.diff-container .diff-code-normal,
.diff-container .diff-line-normal,
.diff-container .diff-code-normal .diff-code-text,
.diff-container .diff-line-normal .diff-code-text {
background-color: #ffffff !important;
color: #1f2937 !important;
}
.diff-container .diff-gutter,
.diff-container .diff-gutter-normal,
.diff-container .diff-gutter-insert,
.diff-container .diff-gutter-delete {
background-color: #f9fafb !important;
color: #6b7280 !important;
border-color: #e5e7eb !important;
}
/* Dark theme with higher specificity */
.dark .diff-container .diff-code-insert,
.dark .diff-container .diff-line-insert,
.dark .diff-container .diff-code-insert .diff-code-text,
.dark .diff-container .diff-line-insert .diff-code-text {
background-color: #064e3b !important;
color: #10b981 !important;
}
.dark .diff-container .diff-code-delete,
.dark .diff-container .diff-line-delete,
.dark .diff-container .diff-code-delete .diff-code-text,
.dark .diff-container .diff-line-delete .diff-code-text {
background-color: #7f1d1d !important;
color: #f87171 !important;
}
.dark .diff-container .diff-code-normal,
.dark .diff-container .diff-line-normal,
.dark .diff-container .diff-code-normal .diff-code-text,
.dark .diff-container .diff-line-normal .diff-code-text {
background-color: #1f2937 !important;
color: #f9fafb !important;
}
.dark .diff-container .diff-gutter,
.dark .diff-container .diff-gutter-normal,
.dark .diff-container .diff-gutter-insert,
.dark .diff-container .diff-gutter-delete {
background-color: #374151 !important;
color: #9ca3af !important;
border-color: #4b5563 !important;
}
/* Additional styling for better appearance */
.diff-container .diff-hunk-header {
background: #f3f4f6;
color: #374151;
font-weight: 600;
padding: 8px 12px;
border-bottom: 1px solid #e5e7eb;
}
.dark .diff-container .diff-hunk-header {
background: #374151;
color: #d1d5db;
border-bottom: 1px solid #4b5563;
}
/* Ensure proper line spacing and alignment */
.diff-container .diff-line {
padding: 2px 8px;
border-left: 4px solid transparent;
}
.diff-container .diff-line-add {
border-left-color: #22c55e;
}
.diff-container .diff-line-delete {
border-left-color: #ef4444;
}
.dark .diff-container .diff-line-add {
border-left-color: #16a34a;
}
.dark .diff-container .diff-line-delete {
border-left-color: #dc2626;
}