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

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

View File

@@ -144,6 +144,9 @@ const BeautifierTool = () => {
};
const beautifyHtml = (text) => {
// Declare formatted variable outside try/catch block
let formatted = '';
try {
// Clean input text first
let cleanText = text.trim();
@@ -154,14 +157,9 @@ const BeautifierTool = () => {
// 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'];
// 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;
const tab = ' ';
// Better HTML parsing
let formatted = ''; // Better HTML parsing
const tokens = cleanText.match(/<\/?[^>]+>|[^<]+/g) || [];
for (let i = 0; i < tokens.length; i++) {
@@ -203,7 +201,7 @@ const BeautifierTool = () => {
return formatted.trim();
} catch (err) {
// Fallback to simple formatting if advanced parsing fails
let formatted = '';
formatted = '';
let indent = 0;
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;