Compare commits
10 Commits
45ddccc2f6
...
b8164d617e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b8164d617e | ||
|
|
e59ebcb5d3 | ||
|
|
5ccb1e2421 | ||
|
|
d3ca407777 | ||
|
|
82d14622ac | ||
|
|
6f5bdf5f0d | ||
|
|
65cc3bc54d | ||
|
|
22d333d932 | ||
|
|
97459ea313 | ||
|
|
bc7e2a8986 |
8125
package-lock.json
generated
8125
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
26
package.json
26
package.json
@@ -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": {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
684
src/components/MindmapView.js
Normal file
684
src/components/MindmapView.js
Normal 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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
112
src/components/ToolSidebar.js
Normal file
112
src/components/ToolSidebar.js
Normal 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;
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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']
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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
506
src/pages/ObjectEditor.js
Normal 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;
|
||||
@@ -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
265
src/pages/TextLengthTool.js
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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">
|
||||
<{inspectedElementInfo.tagName}>
|
||||
</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;
|
||||
@@ -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
114
src/styles/diff-theme.css
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user