Fix HTML Preview Tool critical bugs: PreviewFrame ref and click handler

- Fixed missing ref={previewFrameRef} on first PreviewFrame component (line 336)
  * This was causing 'PreviewFrame API not available' errors during save operations
  * Both fullscreen and normal mode PreviewFrame instances now have proper ref connection

- Fixed click handler attachment bug in setupInspectModeStyles function
  * Click handler was being skipped when styles were already injected
  * Now always attaches click handler when inspect mode is activated
  * Added proper cleanup to prevent duplicate event listeners

- Fixed variable scope issues in PreviewFrame.fresh.js
  * styleElement and cursorStyleElement now properly scoped for cleanup function
  * Added references to existing elements when styles already present

- Removed unused variables and fixed eslint warnings
  * Removed unused indentSize variable in BeautifierTool.js
  * Removed unused onSave and onDomUpdate props in PreviewFrame.fresh.js
  * Fixed unnecessary escape character in script tag

These fixes restore the Enhanced Option A DOM manipulation architecture:
- Inspector sidebar should now appear when clicking elements in inspect mode
- Save functionality should work without 'PreviewFrame ref not available' errors
- Live editing of element properties should work through PreviewFrame API
- Iframe refresh prevention during inspector operations maintained
This commit is contained in:
dwindown
2025-08-03 22:04:25 +07:00
parent 7afb83753c
commit e1bc8d193d
18 changed files with 5339 additions and 10 deletions

View File

@@ -0,0 +1,13 @@
---
trigger: always_on
---
keep look the issue globally, not narrow. we are done chasing symtomp with narrow sight, we have things to be achieved:
A. main goal: having a working HTML Preview with element inspector and editor feature, and
B. sub goal: implementing the "stable option A DOM Manipulation" properly to reach the main goal (A)
In every reported issue, check if that prevent us to achieved the sub goal. Failing sub goal means fail to reach the main goal. So pivot everything to make a success sub goal, to achieve main goal.
I believe promised sub goal is the way to get succeed on the main goal.
Avoid any looping thought

View File

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

View File

@@ -1,6 +1,6 @@
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { Link, useLocation } from 'react-router-dom'; import { Link, useLocation } from 'react-router-dom';
import { Code2, Home, ChevronDown, Menu, X, Database, FileText, Link as LinkIcon, Hash, FileSpreadsheet, Wand2, GitCompare } from 'lucide-react'; import { Code2, Home, ChevronDown, Menu, X, Database, FileText, Link as LinkIcon, Hash, FileSpreadsheet, Wand2, GitCompare, Code } from 'lucide-react';
import ThemeToggle from './ThemeToggle'; import ThemeToggle from './ThemeToggle';
const Layout = ({ children }) => { const Layout = ({ children }) => {
@@ -40,7 +40,8 @@ const Layout = ({ children }) => {
{ path: '/base64', name: 'Base64 Tool', icon: Hash, description: 'Base64 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: '/csv-json', name: 'CSV/JSON Tool', icon: FileSpreadsheet, description: 'Convert CSV ↔ JSON' },
{ path: '/beautifier', name: 'Beautifier Tool', icon: Wand2, description: 'Beautify/minify code' }, { path: '/beautifier', name: 'Beautifier Tool', icon: Wand2, description: 'Beautify/minify code' },
{ path: '/diff', name: 'Diff Tool', icon: GitCompare, description: 'Compare text differences' } { path: '/diff', name: 'Diff Tool', icon: GitCompare, description: 'Compare text differences' },
{ path: '/html-preview', name: 'HTML Preview', icon: Code, description: 'Render HTML, CSS, JS' }
]; ];
return ( return (

View File

@@ -1,5 +1,5 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Hash, Upload, Download } from 'lucide-react'; import { Hash, Upload } from 'lucide-react';
import ToolLayout from '../components/ToolLayout'; import ToolLayout from '../components/ToolLayout';
import CopyButton from '../components/CopyButton'; import CopyButton from '../components/CopyButton';

View File

@@ -144,6 +144,9 @@ const BeautifierTool = () => {
}; };
const beautifyHtml = (text) => { const beautifyHtml = (text) => {
// Declare formatted variable outside try/catch block
let formatted = '';
try { try {
// Clean input text first // Clean input text first
let cleanText = text.trim(); let cleanText = text.trim();
@@ -154,14 +157,9 @@ const BeautifierTool = () => {
// Self-closing tags that don't need closing tags // Self-closing tags that don't need closing tags
const selfClosingTags = ['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr']; const selfClosingTags = ['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr'];
// Inline tags that should stay on same line
const inlineTags = ['a', 'abbr', 'acronym', 'b', 'bdo', 'big', 'br', 'button', 'cite', 'code', 'dfn', 'em', 'i', 'img', 'input', 'kbd', 'label', 'map', 'object', 'q', 'samp', 'script', 'select', 'small', 'span', 'strong', 'sub', 'sup', 'textarea', 'tt', 'var'];
let formatted = '';
let indent = 0; let indent = 0;
const tab = ' '; const tab = ' ';
let formatted = ''; // Better HTML parsing
// Better HTML parsing
const tokens = cleanText.match(/<\/?[^>]+>|[^<]+/g) || []; const tokens = cleanText.match(/<\/?[^>]+>|[^<]+/g) || [];
for (let i = 0; i < tokens.length; i++) { for (let i = 0; i < tokens.length; i++) {
@@ -203,7 +201,7 @@ const BeautifierTool = () => {
return formatted.trim(); return formatted.trim();
} catch (err) { } catch (err) {
// Fallback to simple formatting if advanced parsing fails // Fallback to simple formatting if advanced parsing fails
let formatted = ''; formatted = '';
let indent = 0; let indent = 0;
const tab = ' '; const tab = ' ';

View File

@@ -0,0 +1,451 @@
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 p-4 overflow-y-auto">
<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={`space-y-6 transition-all duration-300 ${
inspectedElementInfo ? 'flex-1' : 'w-1/2'
}`}>
<CodeInputs
htmlInput={htmlInput}
setHtmlInput={setHtmlInput}
cssInput={cssInput}
setCssInput={setCssInput}
jsInput={jsInput}
setJsInput={setJsInput}
isFullscreen={isFullscreen}
/>
</div>
{/* Middle column - Preview */}
<div className={`flex flex-col transition-all duration-300 ${
inspectedElementInfo ? 'flex-1' : 'w-1/2'
}`}>
<div className="flex-1 bg-gray-50 dark:bg-gray-900 rounded-lg overflow-hidden">
<PreviewFrame
ref={previewFrameRef}
htmlInput={htmlInput}
cssInput={cssInput}
jsInput={jsInput}
selectedDevice={selectedDevice}
isInspectModeActive={inspectMode}
onElementClick={handleElementClick}
isFullscreen={isFullscreen}
/>
</div>
</div>
{/* ENHANCED OPTION A: Inspector Sidebar */}
{inspectedElementInfo && (
<div className="w-80 flex flex-col bg-gray-50 dark:bg-gray-900 rounded-lg overflow-hidden">
<InspectorSidebar
inspectedElementInfo={inspectedElementInfo}
htmlInput={htmlInput}
setHtmlInput={setHtmlInput}
onClose={closeInspector}
onSave={saveInspectorChanges}
previewFrameRef={previewFrameRef}
/>
</div>
)}
</div>
{/* Bottom toolbar */}
<div className="mt-6">
<Toolbar
selectedDevice={selectedDevice}
setSelectedDevice={setSelectedDevice}
isInspectModeActive={inspectMode}
setInspectMode={setInspectMode}
isFullscreen={isFullscreen}
onRefresh={handleRefresh}
onToggleFullscreen={toggleFullscreen}
onToggleSidebar={toggleSidebar}
showSidebar={showSidebar}
cleanupInspectorState={cleanupInspectorState}
inspectedElementInfo={inspectedElementInfo}
/>
</div>
</ToolLayout>
);
};
export default HtmlPreviewTool;

View File

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

View File

@@ -0,0 +1,140 @@
import React, { useState } from 'react';
import { ChevronDown, ChevronUp, Maximize2, Minimize2 } from 'lucide-react';
const CodeInputs = ({
htmlInput,
setHtmlInput,
cssInput,
setCssInput,
jsInput,
setJsInput,
isFullscreen
}) => {
const [showCss, setShowCss] = useState(false);
const [showJs, setShowJs] = useState(false);
const [htmlExtended, setHtmlExtended] = useState(true); // Default: HTML box extended
const [cssExtended, setCssExtended] = useState(false);
const [jsExtended, setJsExtended] = useState(false);
const getTextareaHeight = (type, isExtended) => {
if (isFullscreen) {
// In fullscreen, give HTML much more space as main script area
if (type === 'html') {
return isExtended ? 'h-[32rem]' : 'h-96'; // Much taller for HTML in fullscreen to balance with iPhone frame
} else {
return isExtended ? 'h-64' : 'h-32'; // CSS/JS remain smaller
}
}
// In non-fullscreen, give much more space for HTML as main script area
if (type === 'html') {
if (!showCss && !showJs) {
// When CSS/JS hidden, HTML gets maximum space
return isExtended ? 'h-[36rem]' : 'h-[28rem]'; // Much taller for main script to balance with iPhone frame
} else {
// When CSS/JS visible, HTML still gets substantial space
return isExtended ? 'h-[28rem]' : 'h-96';
}
} else {
// CSS and JS boxes remain smaller
return isExtended ? 'h-64' : 'h-32';
}
};
return (
<div className="space-y-4">
{/* HTML Input */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
HTML
</label>
<button
onClick={() => setHtmlExtended(!htmlExtended)}
className="flex items-center space-x-1 px-2 py-1 text-xs bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 rounded-md hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
title={htmlExtended ? 'Minimize HTML box' : 'Maximize HTML box'}
>
{htmlExtended ? <Minimize2 className="w-3 h-3" /> : <Maximize2 className="w-3 h-3" />}
<span>{htmlExtended ? 'Min' : 'Max'}</span>
</button>
</div>
<textarea
value={htmlInput}
onChange={(e) => setHtmlInput(e.target.value)}
placeholder="Enter your HTML code here..."
className={`w-full ${getTextareaHeight('html', htmlExtended)} p-3 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 resize-none focus:ring-2 focus:ring-blue-500 focus:border-transparent`}
/>
{/* Toggle buttons for CSS and JS */}
<div className="flex space-x-2">
<button
onClick={() => setShowCss(!showCss)}
className="flex items-center space-x-1 px-3 py-1 text-xs bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 rounded-md hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
>
{showCss ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
<span>CSS</span>
</button>
<button
onClick={() => setShowJs(!showJs)}
className="flex items-center space-x-1 px-3 py-1 text-xs bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 rounded-md hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
>
{showJs ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
<span>JS</span>
</button>
</div>
</div>
{/* CSS Input */}
{showCss && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
CSS
</label>
<button
onClick={() => setCssExtended(!cssExtended)}
className="flex items-center space-x-1 px-2 py-1 text-xs bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 rounded-md hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
title={cssExtended ? 'Minimize CSS box' : 'Maximize CSS box'}
>
{cssExtended ? <Minimize2 className="w-3 h-3" /> : <Maximize2 className="w-3 h-3" />}
<span>{cssExtended ? 'Min' : 'Max'}</span>
</button>
</div>
<textarea
value={cssInput}
onChange={(e) => setCssInput(e.target.value)}
placeholder="Enter your CSS code here..."
className={`w-full ${getTextareaHeight('css', cssExtended)} p-3 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 resize-none focus:ring-2 focus:ring-blue-500 focus:border-transparent`}
/>
</div>
)}
{/* JS Input */}
{showJs && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
JavaScript
</label>
<button
onClick={() => setJsExtended(!jsExtended)}
className="flex items-center space-x-1 px-2 py-1 text-xs bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 rounded-md hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
title={jsExtended ? 'Minimize JS box' : 'Maximize JS box'}
>
{jsExtended ? <Minimize2 className="w-3 h-3" /> : <Maximize2 className="w-3 h-3" />}
<span>{jsExtended ? 'Min' : 'Max'}</span>
</button>
</div>
<textarea
value={jsInput}
onChange={(e) => setJsInput(e.target.value)}
placeholder="Enter your JavaScript code here..."
className={`w-full ${getTextareaHeight('js', jsExtended)} p-3 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 resize-none focus:ring-2 focus:ring-blue-500 focus:border-transparent`}
/>
</div>
)}
</div>
);
};
export default CodeInputs;

View File

@@ -0,0 +1,253 @@
import React, { useState, useEffect, useRef } from 'react';
const ElementEditor = ({ htmlInput, setHtmlInput, onClose, onSave, previewFrameRef, selectedElementInfo }) => {
console.log('🔍 ELEMENT EDITOR: Received props:', { selectedElementInfo, previewFrameRef: !!previewFrameRef });
const [edited, setEdited] = useState(null);
const textareaRefs = useRef({});
useEffect(() => {
// ENHANCED OPTION A: Use selectedElementInfo directly from props
if (selectedElementInfo) {
const elementInfo = {
tagName: selectedElementInfo.tagName,
innerText: selectedElementInfo.textContent || '',
id: selectedElementInfo.attributes.id || '',
className: selectedElementInfo.attributes.class || '',
cascadeId: selectedElementInfo.cascadeId,
isContainer: selectedElementInfo.attributes.children?.length > 0 || isContainerElement(selectedElementInfo.tagName),
};
// Add all other attributes
Object.entries(selectedElementInfo.attributes).forEach(([name, value]) => {
if (!['id', 'class', 'data-original', 'data-cascade-id'].includes(name)) {
elementInfo[name] = value;
}
});
setEdited(elementInfo);
console.log('🎯 ENHANCED EDITOR: Initialized with selected element:', elementInfo);
} else {
// Clear the editor when no element is selected
setEdited(null);
}
}, [selectedElementInfo]);
// Helper function to detect container elements
const isContainerElement = (tagName) => {
const containerTags = [
'div', 'section', 'article', 'header', 'footer', 'nav', 'main', 'aside',
'ul', 'ol', 'li', 'table', 'thead', 'tbody', 'tr', 'td', 'th',
'form', 'fieldset', 'figure', 'details', 'summary',
// Media elements should not have innerText
'img', 'video', 'audio', 'svg', 'canvas', 'iframe', 'embed', 'object'
];
return containerTags.includes(tagName);
};
// Auto-resize textarea function
const autoResizeTextarea = (textarea) => {
if (textarea) {
textarea.style.height = 'auto';
textarea.style.height = Math.min(textarea.scrollHeight, 200) + 'px'; // Max height 200px
}
};
// Effect to auto-resize textareas when content changes
useEffect(() => {
Object.values(textareaRefs.current).forEach(textarea => {
autoResizeTextarea(textarea);
});
}, [edited]);
// ENHANCED OPTION A: Field change handler using PreviewFrame API
const handleFieldChange = (field, value) => {
console.log(`⌨️ ENHANCED: Field '${field}' changed to '${value}'`);
setEdited(prev => ({ ...prev, [field]: value }));
// Use Enhanced Option A API for direct DOM manipulation
if (previewFrameRef?.current && edited?.cascadeId) {
let success = false;
if (field === 'innerText') {
success = previewFrameRef.current.updateElementText(edited.cascadeId, value);
} else if (field === 'className') {
success = previewFrameRef.current.updateElementClass(edited.cascadeId, value);
} else if (field === 'id' || field === 'src' || field === 'href' || field === 'alt' || field === 'title') {
success = previewFrameRef.current.updateElementAttribute(edited.cascadeId, field, value);
} else {
// For CSS properties, use updateElementStyle
success = previewFrameRef.current.updateElementStyle(edited.cascadeId, field, value);
}
if (success) {
console.log(`✅ ENHANCED UPDATE: ${field} updated in iframe DOM (scroll preserved)`);
} else {
console.error(`❌ ENHANCED UPDATE: Failed to update ${field}`);
}
} else {
console.warn('⚠️ ENHANCED UPDATE: PreviewFrame ref or cascadeId not available');
}
};
const handleSave = () => {
console.log('💾 ENHANCED OPTION A SAVE: Using PreviewFrame API to get iframe content');
try {
// Use Enhanced Option A API to get iframe content
if (previewFrameRef?.current?.getIframeContent) {
const currentIframeHtml = previewFrameRef.current.getIframeContent();
if (currentIframeHtml) {
// Clean up inspector-specific attributes
const cleanedHtml = currentIframeHtml
.replace(/\s*data-selected="true"/g, '')
.replace(/\s*data-cascade-id="[^"]*"/g, '')
.replace(/\s*id="inspector-styles"/g, '')
.replace(/<style[^>]*id="inspector-styles"[^>]*>.*?<\/style>/gs, '')
.replace(/\s{2,}/g, ' ');
// ENHANCED OPTION A: Update htmlInput with the cleaned iframe content
// This is the ONLY allowed setHtmlInput call during inspector operations (explicit save)
setHtmlInput(cleanedHtml);
console.log('✅ ENHANCED SAVE: Successfully extracted and cleaned iframe DOM via API');
} else {
console.warn('⚠️ Save: No content returned from PreviewFrame API');
}
} else {
console.error('❌ Save: PreviewFrame API not available');
}
} catch (error) {
console.error('❌ Enhanced Option A Save failed:', error);
}
// Trigger parent save callback
if (onSave) {
onSave();
}
onClose();
};
// 5. ENHANCED OPTION A: Cancel reverts changes in iframe DOM directly
const handleCancel = () => {
console.log('🔄 ENHANCED CANCEL: Reverting changes in iframe DOM without triggering refresh');
// For Enhanced Option A, we don't need to revert HTML input
// The iframe DOM changes will be discarded when the inspector closes
// Just close the inspector without syncing changes
console.log('✅ ENHANCED CANCEL: Changes discarded, iframe DOM remains stable');
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' && key !== 'isContainer'
);
return (
<div className="space-y-4">
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">Tag Name</label>
<textarea
ref={(el) => textareaRefs.current.tagName = el}
value={edited.tagName || ''}
onChange={(e) => {
handleFieldChange('tagName', e.target.value);
autoResizeTextarea(e.target);
}}
rows="1"
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 resize-none overflow-hidden"
/>
</div>
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">Class</label>
<textarea
ref={(el) => textareaRefs.current.className = el}
value={edited.className || ''}
onChange={(e) => {
handleFieldChange('className', e.target.value);
autoResizeTextarea(e.target);
}}
rows="1"
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 resize-none overflow-hidden"
/>
</div>
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">ID</label>
<textarea
ref={(el) => textareaRefs.current.id = el}
value={edited.id || ''}
onChange={(e) => {
handleFieldChange('id', e.target.value);
autoResizeTextarea(e.target);
}}
rows="1"
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 resize-none overflow-hidden"
/>
</div>
{/* Only show innerText field for non-container elements */}
{!edited.isContainer && (
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Inner Text
<span className="text-xs text-gray-500 ml-2">(text content)</span>
</label>
<textarea
ref={(el) => textareaRefs.current.innerText = el}
value={edited.innerText || ''}
onChange={(e) => {
handleFieldChange('innerText', e.target.value);
autoResizeTextarea(e.target);
}}
rows="2"
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 resize-none overflow-hidden"
/>
</div>
)}
{/* Show container hint for container elements */}
{edited.isContainer && (
<div className="p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-md">
<p className="text-sm text-blue-700 dark:text-blue-300">
📦 This is a container element. Edit its ID, class, or other attributes instead of text content.
</p>
</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>
<textarea
ref={(el) => textareaRefs.current[attr] = el}
value={edited[attr] === true ? "" : edited[attr] || ''}
onChange={(e) => {
handleFieldChange(attr, e.target.value);
autoResizeTextarea(e.target);
}}
rows="1"
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 resize-none overflow-hidden"
/>
</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 ElementEditor;

View File

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

View File

@@ -0,0 +1,674 @@
import React, { useRef, useEffect, useCallback, useMemo, useState } 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;
width: 2px;
}
.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 PreviewFrame = ({
htmlInput,
cssInput,
jsInput,
selectedDevice,
inspectMode,
onElementClick,
isFullscreen
}) => {
const iframeRef = useRef(null);
// Handle iframe click for element selection - defined first to avoid initialization errors
const handleIframeClick = useCallback((e) => {
if (!inspectMode) return;
e.preventDefault();
e.stopPropagation();
const clickedElement = e.target;
const elementInfo = {
tagName: clickedElement.tagName.toLowerCase(),
innerText: clickedElement.innerText || clickedElement.textContent || '',
attributes: {}
};
Array.from(clickedElement.attributes).forEach(attr => {
elementInfo.attributes[attr.name] = attr.value;
});
onElementClick(elementInfo);
}, [inspectMode, onElementClick]);
// Function to setup inspect mode styles and event handlers with MutationObserver
const setupInspectModeStyles = useCallback((iframeDoc) => {
console.log('🎨 PreviewFrame: Setting up robust inspect mode with MutationObserver');
// Remove existing inspect styles and observers
const existingStyle = iframeDoc.getElementById('inspect-mode-styles');
if (existingStyle) existingStyle.remove();
// Clean up any existing observer
if (iframeDoc._inspectObserver) {
iframeDoc._inspectObserver.disconnect();
delete iframeDoc._inspectObserver;
}
// Add inspect mode styles with better hover highlights
const style = iframeDoc.createElement('style');
style.id = 'inspect-mode-styles';
style.textContent = `
/* High specificity selectors for inspect mode */
html * {
cursor: crosshair !important;
pointer-events: auto !important;
}
/* Hover highlights with maximum specificity */
html body *:hover {
outline: 2px solid #3b82f6 !important;
outline-offset: 1px !important;
background-color: rgba(59, 130, 246, 0.1) !important;
transition: all 0.1s ease !important;
box-shadow: 0 0 0 1px rgba(59, 130, 246, 0.3) !important;
}
/* Selected element styles */
html body [data-original="false"] {
outline: 2px solid #10b981 !important;
outline-offset: 1px !important;
background-color: rgba(16, 185, 129, 0.1) !important;
box-shadow: 0 0 0 1px rgba(16, 185, 129, 0.3) !important;
}
/* Selected element hover */
html body [data-original="false"]:hover {
outline: 2px solid #059669 !important;
outline-offset: 1px !important;
background-color: rgba(5, 150, 105, 0.15) !important;
box-shadow: 0 0 0 1px rgba(5, 150, 105, 0.4) !important;
}
/* Prevent text selection during inspect */
html * {
-webkit-user-select: none !important;
-moz-user-select: none !important;
-ms-user-select: none !important;
user-select: none !important;
}
/* Override any existing hover styles */
html body *:hover {
border: none !important;
}
`;
iframeDoc.head.appendChild(style);
// Add event handlers
const preventInteraction = (e) => {
e.preventDefault();
e.stopPropagation();
return false;
};
// Add click handler for element selection
iframeDoc.addEventListener('click', handleIframeClick, true);
// Prevent other interactions during inspect mode
iframeDoc.addEventListener('mousedown', preventInteraction, true);
iframeDoc.addEventListener('mouseup', preventInteraction, true);
iframeDoc.addEventListener('contextmenu', preventInteraction, true);
iframeDoc.addEventListener('dragstart', preventInteraction, true);
iframeDoc.addEventListener('selectstart', preventInteraction, true);
// Setup MutationObserver to reapply styles when DOM changes
const observer = new MutationObserver((mutations) => {
let needsReapply = false;
mutations.forEach((mutation) => {
if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
needsReapply = true;
}
});
if (needsReapply) {
console.log('🔄 PreviewFrame: DOM changed, reapplying inspect styles');
// Reapply styles after a short delay to ensure new elements are rendered
setTimeout(() => {
const currentStyle = iframeDoc.getElementById('inspect-mode-styles');
if (!currentStyle) {
// Style was removed, reapply it
const newStyle = iframeDoc.createElement('style');
newStyle.id = 'inspect-mode-styles';
newStyle.textContent = style.textContent;
iframeDoc.head.appendChild(newStyle);
}
}, 50);
}
});
// Start observing DOM changes
observer.observe(iframeDoc.body, {
childList: true,
subtree: true,
attributes: false
});
// Store observer for cleanup
iframeDoc._inspectObserver = observer;
console.log('✅ PreviewFrame: Robust inspect mode with MutationObserver applied');
}, [handleIframeClick]);
const generateHtmlContent = useCallback(() => {
const scrollbarCSS = `
@media (max-width: 960px) {
html, body {
overflow-y: overlay; /* scrollbar overlays content, no space reserved */
-webkit-overflow-scrolling: touch;
padding-right: 0; /* No padding, since scrollbar overlays */
box-sizing: border-box; /* Use border-box for correct sizing */
scrollbar-width: thin;
scrollbar-color: rgba(0,0,0,0.2) transparent;
}
body::-webkit-scrollbar {
width: 0;
background: transparent;
}
/* Custom scrollbar styles */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background-color: rgba(0,0,0,0.2);
border-radius: 3px;
border: 1px solid rgba(0,0,0,0.1);
}
/* Firefox custom scrollbar */
html {
scrollbar-width: thin;
scrollbar-color: rgba(0,0,0,0.2) transparent;
}
}
`;
const finalCss = `${scrollbarCSS}\n${cssInput || ''}`;
const isFullDocument = htmlInput.trim().toLowerCase().includes('<html');
if (isFullDocument) {
let content = htmlInput;
const headEndIndex = content.toLowerCase().indexOf('</head>');
if (headEndIndex !== -1) {
const styleTag = `\n<style>\n${finalCss}\n</style>\n`;
content = content.slice(0, headEndIndex) + styleTag + content.slice(headEndIndex);
}
if (jsInput && jsInput.trim()) {
const bodyEndIndex = content.toLowerCase().lastIndexOf('</body>');
if (bodyEndIndex !== -1) {
const scriptTag = `\n<script>\n(function() {\n${jsInput}\n})();\n</script>\n`;
content = content.slice(0, bodyEndIndex) + scriptTag + content.slice(bodyEndIndex);
}
}
return content;
} 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>
<style>${finalCss}</style>
</head>
<body>
${htmlInput}
<script>
(function() {
${jsInput || ''}
})();
</script>
</body>
</html>
`;
}
}, [htmlInput, cssInput, jsInput]);
// Effect for loading content and managing inspect mode
useEffect(() => {
const iframe = iframeRef.current;
if (!iframe) return;
const content = generateHtmlContent();
const doc = iframe.contentDocument || iframe.contentWindow.document;
// Write content
doc.open();
doc.write(content);
doc.close();
// Apply inspect mode immediately after writing content
if (inspectMode) {
console.log('🎨 Applying inspect mode styles.');
setupInspectModeStyles(doc);
}
}, [htmlInput, cssInput, jsInput, inspectMode, selectedDevice, isFullscreen, generateHtmlContent, setupInspectModeStyles]);
// Effect for injecting device frame CSS into the main document
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 });
// Non-fullscreen always uses iPhone frame (mobile view)
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'
};
}
// Fullscreen desktop mode: no frame
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();
if (deviceFrame) {
// Render with device frame (iPhone 14 Pro or iPad Pro)
console.log(`🎨 Rendering device frame: device-${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>
);
}
// Render without device frame (desktop or non-fullscreen)
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;

View File

@@ -0,0 +1,720 @@
import React, { useRef, useEffect, useCallback, useMemo, useState } 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;
width: 2px;
}
.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 PreviewFrameExperimental = ({
htmlInput,
cssInput,
jsInput,
selectedDevice,
inspectMode,
onElementClick,
isFullscreen
}) => {
const iframeRef = useRef(null);
const [isInitialized, setIsInitialized] = useState(false);
const [lastJsInput, setLastJsInput] = useState('');
// Handle iframe click for element selection
const handleIframeClick = useCallback((e) => {
if (!inspectMode) return;
e.preventDefault();
e.stopPropagation();
const clickedElement = e.target;
const elementInfo = {
tagName: clickedElement.tagName.toLowerCase(),
innerText: clickedElement.innerText || clickedElement.textContent || '',
attributes: {}
};
Array.from(clickedElement.attributes).forEach(attr => {
elementInfo.attributes[attr.name] = attr.value;
});
onElementClick(elementInfo);
}, [inspectMode, onElementClick]);
// Function to setup inspect mode styles and event handlers
const setupInspectModeStyles = useCallback((iframeDoc) => {
console.log('🎨 PreviewFrame Experimental: Setting up inspect mode');
// Remove existing inspect styles and observers
const existingStyle = iframeDoc.getElementById('inspect-mode-styles');
if (existingStyle) existingStyle.remove();
// Clean up any existing observer
if (iframeDoc._inspectObserver) {
iframeDoc._inspectObserver.disconnect();
delete iframeDoc._inspectObserver;
}
// Add inspect mode styles with better hover highlights
const style = iframeDoc.createElement('style');
style.id = 'inspect-mode-styles';
style.textContent = `
/* High specificity selectors for inspect mode */
html * {
cursor: crosshair !important;
pointer-events: auto !important;
}
/* Hover highlights with maximum specificity */
html body *:hover {
outline: 2px solid #3b82f6 !important;
outline-offset: 1px !important;
background-color: rgba(59, 130, 246, 0.1) !important;
transition: all 0.1s ease !important;
box-shadow: 0 0 0 1px rgba(59, 130, 246, 0.3) !important;
}
/* Selected element styles */
html body [data-selected="true"] {
outline: 2px solid #10b981 !important;
outline-offset: 1px !important;
background-color: rgba(16, 185, 129, 0.1) !important;
box-shadow: 0 0 0 1px rgba(16, 185, 129, 0.3) !important;
}
/* Selected element hover */
html body [data-selected="true"]:hover {
outline: 2px solid #059669 !important;
outline-offset: 1px !important;
background-color: rgba(5, 150, 105, 0.15) !important;
box-shadow: 0 0 0 1px rgba(5, 150, 105, 0.4) !important;
}
/* Prevent text selection during inspect */
html * {
-webkit-user-select: none !important;
-moz-user-select: none !important;
-ms-user-select: none !important;
user-select: none !important;
}
`;
iframeDoc.head.appendChild(style);
// Add event handlers
const preventInteraction = (e) => {
e.preventDefault();
e.stopPropagation();
return false;
};
// Enhanced click handler that marks selected elements
const enhancedClickHandler = (e) => {
if (!inspectMode) return;
e.preventDefault();
e.stopPropagation();
// Remove previous selection
const previousSelected = iframeDoc.querySelector('[data-selected="true"]');
if (previousSelected) {
previousSelected.removeAttribute('data-selected');
}
// Mark new selection
const clickedElement = e.target;
clickedElement.setAttribute('data-selected', 'true');
// Call the original handler
handleIframeClick(e);
};
// Add click handler for element selection
iframeDoc.addEventListener('click', enhancedClickHandler, true);
// Prevent other interactions during inspect mode
iframeDoc.addEventListener('mousedown', preventInteraction, true);
iframeDoc.addEventListener('mouseup', preventInteraction, true);
iframeDoc.addEventListener('contextmenu', preventInteraction, true);
iframeDoc.addEventListener('dragstart', preventInteraction, true);
iframeDoc.addEventListener('selectstart', preventInteraction, true);
console.log('✅ PreviewFrame Experimental: Inspect mode applied');
}, [handleIframeClick, inspectMode]);
// Generate base HTML structure (without user JS)
const generateBaseHtml = useCallback(() => {
const scrollbarCSS = `
/* Custom Scrollbar Styles */
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: #f1f1f1; }
::-webkit-scrollbar-thumb { background: #888; border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: #555; }
html { scrollbar-width: thin; scrollbar-color: #888 #f1f1f1; }
`;
const finalCss = `${scrollbarCSS}\n${cssInput || ''}`;
const isFullDocument = htmlInput.trim().toLowerCase().includes('<html');
if (isFullDocument) {
let content = htmlInput;
const headEndIndex = content.toLowerCase().indexOf('</head>');
if (headEndIndex !== -1) {
const styleTag = `\n<style>\n${finalCss}\n</style>\n`;
content = content.slice(0, headEndIndex) + styleTag + content.slice(headEndIndex);
}
return content;
} 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>
<style>${finalCss}</style>
</head>
<body>
${htmlInput}
</body>
</html>
`;
}
}, [htmlInput, cssInput]);
// Initialize iframe with base content (runs once or when device changes)
const initializeIframe = useCallback(() => {
const iframe = iframeRef.current;
if (!iframe) return;
console.log('🚀 PreviewFrame Experimental: Initializing iframe');
const content = generateBaseHtml();
const doc = iframe.contentDocument || iframe.contentWindow.document;
// Write base content
doc.open();
doc.write(content);
doc.close();
setIsInitialized(true);
setLastJsInput(''); // Reset JS tracking
console.log('✅ PreviewFrame Experimental: Base iframe initialized');
}, [generateBaseHtml]);
// Update only JavaScript content (without full page reload)
const updateJavaScript = useCallback(() => {
if (!isInitialized || !jsInput || jsInput === lastJsInput) return;
const iframe = iframeRef.current;
if (!iframe) return;
console.log('📝 PreviewFrame Experimental: Updating JavaScript only');
const doc = iframe.contentDocument || iframe.contentWindow.document;
// Remove previous script if exists
const existingScript = doc.getElementById('user-script');
if (existingScript) {
existingScript.remove();
}
// Add new script with IIFE wrapper
if (jsInput.trim()) {
const script = doc.createElement('script');
script.id = 'user-script';
script.textContent = `
(function() {
try {
${jsInput}
} catch(e) {
console.error('User script error:', e);
}
})();
`;
doc.body.appendChild(script);
}
setLastJsInput(jsInput);
console.log('✅ PreviewFrame Experimental: JavaScript updated');
}, [isInitialized, jsInput, lastJsInput]);
// Update HTML content (requires partial reload)
const updateHtmlContent = useCallback(() => {
if (!isInitialized) return;
const iframe = iframeRef.current;
if (!iframe) return;
console.log('🔄 PreviewFrame Experimental: Updating HTML content');
const doc = iframe.contentDocument || iframe.contentWindow.document;
// Update body content only (preserve head and scripts)
const isFullDocument = htmlInput.trim().toLowerCase().includes('<html');
if (!isFullDocument) {
doc.body.innerHTML = htmlInput;
// Re-add user script after HTML update
updateJavaScript();
// Re-apply inspect mode if enabled
if (inspectMode) {
setupInspectModeStyles(doc);
}
} else {
// For full documents, we need to reinitialize
initializeIframe();
}
console.log('✅ PreviewFrame Experimental: HTML content updated');
}, [isInitialized, htmlInput, inspectMode, updateJavaScript, setupInspectModeStyles, initializeIframe]);
// Effect for iframe initialization (runs when device changes)
useEffect(() => {
initializeIframe();
}, [selectedDevice, isFullscreen, initializeIframe]);
// Effect for HTML content updates
useEffect(() => {
if (isInitialized) {
updateHtmlContent();
}
}, [htmlInput, cssInput, updateHtmlContent, isInitialized]);
// Effect for JavaScript updates
useEffect(() => {
updateJavaScript();
}, [jsInput, updateJavaScript]);
// Effect for inspect mode
useEffect(() => {
if (!isInitialized) return;
const iframe = iframeRef.current;
if (!iframe) return;
const doc = iframe.contentDocument || iframe.contentWindow.document;
if (inspectMode) {
console.log('🎨 PreviewFrame Experimental: Enabling inspect mode');
setupInspectModeStyles(doc);
} else {
console.log('🎨 PreviewFrame Experimental: Disabling inspect mode');
const existingStyle = doc.getElementById('inspect-mode-styles');
if (existingStyle) existingStyle.remove();
// Remove selected attributes
const selectedElements = doc.querySelectorAll('[data-selected="true"]');
selectedElements.forEach(el => el.removeAttribute('data-selected'));
}
}, [inspectMode, isInitialized, setupInspectModeStyles]);
// Effect for injecting device frame CSS into the main document
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 (Experimental):', { isFullscreen, selectedDevice });
// Non-fullscreen always uses iPhone frame (mobile view)
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'
};
}
// Fullscreen desktop mode: no frame
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();
if (deviceFrame) {
// Render with device frame (iPhone 14 Pro or iPad Pro)
console.log(`🎨 Rendering device frame (Experimental): device-${deviceFrame}`);
return (
<div className={wrapperClass}>
<div className={`device device-${deviceFrame}`}>
<div className="device-frame">
<iframe
ref={iframeRef}
key={`experimental-device-${deviceFrame}-${selectedDevice}-${isFullscreen}`}
className="device-screen w-full h-full border-0"
title="HTML Preview (Experimental)"
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>
);
}
// Render without device frame (desktop or non-fullscreen)
return (
<div className={`${wrapperClass} bg-white rounded-lg shadow-lg`}>
<iframe
ref={iframeRef}
key={`experimental-no-device-${selectedDevice}-${isFullscreen}`}
className="w-full h-full border-0 overflow-hidden"
title="HTML Preview (Experimental)"
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-modals"
style={{ maxWidth: '100%', maxHeight: '100%' }}
/>
</div>
);
};
export default PreviewFrameExperimental;

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,105 @@
import { useState, useCallback } from 'react';
/**
* Preview Server - A completely different approach that avoids iframe JS issues
* Instead of injecting content into iframe, we create blob URLs that serve as actual web pages
*/
class PreviewServer {
constructor() {
this.currentBlobUrl = null;
}
// Create a blob URL that serves as a real web page
createPreviewUrl(htmlInput, cssInput, jsInput) {
// Clean up previous blob URL
if (this.currentBlobUrl) {
URL.revokeObjectURL(this.currentBlobUrl);
}
// Generate complete HTML document
const fullHtml = this.generateCompleteHtml(htmlInput, cssInput, jsInput);
// Create blob with proper MIME type
const blob = new Blob([fullHtml], { type: 'text/html' });
// Create object URL
this.currentBlobUrl = URL.createObjectURL(blob);
return this.currentBlobUrl;
}
generateCompleteHtml(htmlInput, cssInput, jsInput) {
// Check if user provided full HTML document
const isFullDocument = htmlInput.trim().toLowerCase().includes('<!doctype') ||
htmlInput.trim().toLowerCase().includes('<html');
if (isFullDocument) {
// User provided full document - inject CSS and JS appropriately
let modifiedHtml = htmlInput;
// Inject CSS into head if provided
if (cssInput && cssInput.trim()) {
const cssTag = `<style>${cssInput}</style>`;
if (modifiedHtml.includes('</head>')) {
modifiedHtml = modifiedHtml.replace('</head>', `${cssTag}\n</head>`);
} else {
// Add head section if missing
modifiedHtml = modifiedHtml.replace('<html>', '<html>\n<head>\n' + cssTag + '\n</head>');
}
}
// Inject JS before closing body if provided
if (jsInput && jsInput.trim()) {
const jsTag = `<script>${jsInput}</script>`;
if (modifiedHtml.includes('</body>')) {
modifiedHtml = modifiedHtml.replace('</body>', `${jsTag}\n</body>`);
} else {
// Add script at the end
modifiedHtml += `\n${jsTag}`;
}
}
return modifiedHtml;
} else {
// User provided fragment - create complete document
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HTML Preview</title>
${cssInput ? `<style>${cssInput}</style>` : ''}
</head>
<body>
${htmlInput}
${jsInput ? `<script>${jsInput}</script>` : ''}
</body>
</html>`;
}
}
// Clean up resources
cleanup() {
if (this.currentBlobUrl) {
URL.revokeObjectURL(this.currentBlobUrl);
this.currentBlobUrl = null;
}
}
}
// Hook for using the preview server
export const usePreviewServer = () => {
const [previewServer] = useState(() => new PreviewServer());
const createPreviewUrl = useCallback((htmlInput, cssInput, jsInput) => {
return previewServer.createPreviewUrl(htmlInput, cssInput, jsInput);
}, [previewServer]);
const cleanup = useCallback(() => {
previewServer.cleanup();
}, [previewServer]);
return { createPreviewUrl, cleanup };
};
export default PreviewServer;

View File

@@ -0,0 +1,132 @@
import React from 'react';
import { RefreshCw, Monitor, Tablet, Smartphone, Inspect, Maximize2, Minimize2, Code } from 'lucide-react';
const Toolbar = ({
selectedDevice,
setSelectedDevice,
isInspectModeActive,
setInspectMode,
isFullscreen,
onRefresh,
onToggleFullscreen,
onToggleSidebar,
showSidebar,
cleanupInspectorState,
inspectedElementInfo
}) => {
const handleDeviceChange = (device) => {
if (!isFullscreen && (device === 'desktop' || device === 'tablet')) {
onToggleFullscreen(device);
} else {
setSelectedDevice(device);
}
};
const handleInspectToggle = () => {
if (isInspectModeActive) {
// If already in inspect mode, just turn it off
cleanupInspectorState();
} else {
// Activate inspect mode (no need to cleanup when activating)
console.log('🎯 TOOLBAR: Activating inspect mode');
setInspectMode(true);
}
};
return (
<div className="bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 p-4">
<div className="flex items-center justify-between">
{/* Left side - Code toggle (fullscreen only) */}
<div className="flex items-center space-x-2">
{isFullscreen && (
<button
onClick={onToggleSidebar}
className={`p-2 rounded-md transition-colors ${
showSidebar
? 'bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-400'
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600'
}`}
title="Toggle Code Sidebar"
>
<Code className="w-4 h-4" />
</button>
)}
</div>
{/* Center - Preview controls */}
<div className="flex items-center space-x-2">
<button
onClick={onRefresh}
className="p-2 bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 rounded-md hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
title="Refresh Preview"
>
<RefreshCw className="w-4 h-4" />
</button>
<div className="flex bg-gray-100 dark:bg-gray-700 rounded-md p-1">
<button
onClick={() => handleDeviceChange('desktop')}
className={`p-2 rounded transition-colors ${
selectedDevice === 'desktop'
? 'bg-blue-500 text-white'
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600'
}`}
title="Desktop View"
>
<Monitor className="w-4 h-4" />
</button>
<button
onClick={() => handleDeviceChange('tablet')}
className={`p-2 rounded transition-colors ${
selectedDevice === 'tablet'
? 'bg-blue-500 text-white'
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600'
}`}
title="Tablet View"
>
<Tablet className="w-4 h-4" />
</button>
<button
onClick={() => handleDeviceChange('mobile')}
className={`p-2 rounded transition-colors ${
selectedDevice === 'mobile'
? 'bg-blue-500 text-white'
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600'
}`}
title="Mobile View"
>
<Smartphone className="w-4 h-4" />
</button>
</div>
{isFullscreen && (
<button
onClick={handleInspectToggle}
className={`p-2 rounded-md transition-colors ${
isInspectModeActive
? 'bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-400'
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600'
}`}
title="Inspect Element"
>
<Inspect className="w-4 h-4" />
</button>
)}
</div>
{/* Right side - Fullscreen toggle */}
<div className="flex items-center space-x-2">
<button
onClick={() => onToggleFullscreen()}
className="p-2 bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 rounded-md hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
title={isFullscreen ? "Exit Fullscreen" : "Enter Fullscreen"}
>
{isFullscreen ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
</button>
</div>
</div>
</div>
);
};
export default Toolbar;

View File

@@ -0,0 +1,178 @@
/* Device Frames CSS - Inspired by css-device-frames */
/* Mobile Device Frame */
.device-frame-mobile {
position: relative;
width: 375px;
height: 667px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 30px;
padding: 20px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
margin: 0 auto;
}
.device-frame-mobile::before {
content: '';
position: absolute;
top: 8px;
left: 50%;
transform: translateX(-50%);
width: 60px;
height: 6px;
background: rgba(255, 255, 255, 0.3);
border-radius: 3px;
}
.device-frame-mobile::after {
content: '';
position: absolute;
bottom: 8px;
left: 50%;
transform: translateX(-50%);
width: 40px;
height: 40px;
background: rgba(255, 255, 255, 0.1);
border: 2px solid rgba(255, 255, 255, 0.3);
border-radius: 50%;
}
.device-frame-mobile iframe {
width: 100%;
height: calc(100% - 40px);
border: none;
border-radius: 20px;
background: white;
margin-top: 20px;
}
/* Mobile Compact for Non-Fullscreen */
.device-frame-mobile-compact {
position: relative;
width: 375px;
height: 600px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 25px;
padding: 15px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
margin: 0 auto;
}
.device-frame-mobile-compact::before {
content: '';
position: absolute;
top: 6px;
left: 50%;
transform: translateX(-50%);
width: 50px;
height: 4px;
background: rgba(255, 255, 255, 0.3);
border-radius: 2px;
}
.device-frame-mobile-compact::after {
content: '';
position: absolute;
bottom: 6px;
left: 50%;
transform: translateX(-50%);
width: 30px;
height: 30px;
background: rgba(255, 255, 255, 0.1);
border: 2px solid rgba(255, 255, 255, 0.3);
border-radius: 50%;
}
.device-frame-mobile-compact iframe {
width: 100%;
height: calc(100% - 30px);
border: none;
border-radius: 15px;
background: white;
margin-top: 15px;
}
/* Tablet Device Frame */
.device-frame-tablet {
position: relative;
width: 768px;
height: 1024px;
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
border-radius: 40px;
padding: 30px;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.3);
margin: 0 auto;
}
.device-frame-tablet::before {
content: '';
position: absolute;
bottom: 10px;
left: 50%;
transform: translateX(-50%);
width: 50px;
height: 50px;
background: rgba(255, 255, 255, 0.1);
border: 3px solid rgba(255, 255, 255, 0.3);
border-radius: 50%;
}
.device-frame-tablet iframe {
width: 100%;
height: calc(100% - 60px);
border: none;
border-radius: 25px;
background: white;
margin-top: 30px;
}
/* Dark mode adjustments */
.dark .device-frame-mobile,
.dark .device-frame-mobile-compact {
background: linear-gradient(135deg, #434343 0%, #000000 100%);
}
.dark .device-frame-tablet {
background: linear-gradient(135deg, #434343 0%, #000000 100%);
}
.dark .device-frame-mobile::before,
.dark .device-frame-mobile-compact::before,
.dark .device-frame-tablet::before,
.dark .device-frame-mobile::after,
.dark .device-frame-mobile-compact::after,
.dark .device-frame-tablet::after {
background: rgba(255, 255, 255, 0.2);
border-color: rgba(255, 255, 255, 0.4);
}
/* Responsive adjustments */
@media (max-width: 768px) {
.device-frame-mobile,
.device-frame-mobile-compact {
width: 300px;
height: 480px;
border-radius: 20px;
padding: 12px;
}
.device-frame-mobile iframe,
.device-frame-mobile-compact iframe {
border-radius: 12px;
margin-top: 12px;
height: calc(100% - 24px);
}
.device-frame-tablet {
width: 600px;
height: 800px;
border-radius: 30px;
padding: 20px;
}
.device-frame-tablet iframe {
border-radius: 20px;
margin-top: 20px;
height: calc(100% - 40px);
}
}

1
test.html Normal file
View File

@@ -0,0 +1 @@
<!DOCTYPE html><html><head><title>Test Page</title></head><body><h1>Test Heading</h1><p>Test paragraph</p><div class="container">Test div</div></body></html>