From e1bc8d193d7844f8e502ef678f009dff9f4e441a Mon Sep 17 00:00:00 2001 From: dwindown Date: Sun, 3 Aug 2025 22:04:25 +0700 Subject: [PATCH] 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 --- .windsurf/rules/developer-tools.md | 13 + src/App.js | 2 + src/components/Layout.js | 5 +- src/pages/Base64Tool.js | 2 +- src/pages/BeautifierTool.js | 12 +- src/pages/HtmlPreviewTool.js | 451 ++++++ src/pages/HtmlPreviewTool.js.backup | 461 ++++++ src/pages/components/CodeInputs.js | 140 ++ src/pages/components/ElementEditor.js | 253 ++++ src/pages/components/InspectorSidebar.js | 65 + src/pages/components/PreviewFrame.backup.js | 674 +++++++++ .../components/PreviewFrame.experimental.js | 720 +++++++++ src/pages/components/PreviewFrame.fresh.js | 852 +++++++++++ src/pages/components/PreviewFrame.js | 1283 +++++++++++++++++ src/pages/components/PreviewServer.js | 105 ++ src/pages/components/Toolbar.js | 132 ++ src/styles/device-frames.css | 178 +++ test.html | 1 + 18 files changed, 5339 insertions(+), 10 deletions(-) create mode 100644 .windsurf/rules/developer-tools.md create mode 100644 src/pages/HtmlPreviewTool.js create mode 100644 src/pages/HtmlPreviewTool.js.backup create mode 100644 src/pages/components/CodeInputs.js create mode 100644 src/pages/components/ElementEditor.js create mode 100644 src/pages/components/InspectorSidebar.js create mode 100644 src/pages/components/PreviewFrame.backup.js create mode 100644 src/pages/components/PreviewFrame.experimental.js create mode 100644 src/pages/components/PreviewFrame.fresh.js create mode 100644 src/pages/components/PreviewFrame.js create mode 100644 src/pages/components/PreviewServer.js create mode 100644 src/pages/components/Toolbar.js create mode 100644 src/styles/device-frames.css create mode 100644 test.html diff --git a/.windsurf/rules/developer-tools.md b/.windsurf/rules/developer-tools.md new file mode 100644 index 00000000..412d103b --- /dev/null +++ b/.windsurf/rules/developer-tools.md @@ -0,0 +1,13 @@ +--- +trigger: always_on +--- + +keep look the issue globally, not narrow. we are done chasing symtomp with narrow sight, we have things to be achieved: +A. main goal: having a working HTML Preview with element inspector and editor feature, and +B. sub goal: implementing the "stable option A DOM Manipulation" properly to reach the main goal (A) + +In every reported issue, check if that prevent us to achieved the sub goal. Failing sub goal means fail to reach the main goal. So pivot everything to make a success sub goal, to achieve main goal. + +I believe promised sub goal is the way to get succeed on the main goal. + +Avoid any looping thought \ No newline at end of file diff --git a/src/App.js b/src/App.js index ffbbe3a0..1cc3178c 100644 --- a/src/App.js +++ b/src/App.js @@ -9,6 +9,7 @@ import Base64Tool from './pages/Base64Tool'; import CsvJsonTool from './pages/CsvJsonTool'; import BeautifierTool from './pages/BeautifierTool'; import DiffTool from './pages/DiffTool'; +import HtmlPreviewTool from './pages/HtmlPreviewTool'; import './index.css'; function App() { @@ -24,6 +25,7 @@ function App() { } /> } /> } /> + } /> diff --git a/src/components/Layout.js b/src/components/Layout.js index 06dbd852..4a283392 100644 --- a/src/components/Layout.js +++ b/src/components/Layout.js @@ -1,6 +1,6 @@ import React, { useState, useEffect, useRef } from 'react'; import { Link, useLocation } from 'react-router-dom'; -import { Code2, Home, ChevronDown, Menu, X, Database, FileText, Link as LinkIcon, Hash, FileSpreadsheet, Wand2, GitCompare } from 'lucide-react'; +import { Code2, Home, ChevronDown, Menu, X, Database, FileText, Link as LinkIcon, Hash, FileSpreadsheet, Wand2, GitCompare, Code } from 'lucide-react'; import ThemeToggle from './ThemeToggle'; const Layout = ({ children }) => { @@ -40,7 +40,8 @@ const Layout = ({ children }) => { { path: '/base64', name: 'Base64 Tool', icon: Hash, description: 'Base64 encode/decode' }, { path: '/csv-json', name: 'CSV/JSON Tool', icon: FileSpreadsheet, description: 'Convert CSV โ†” JSON' }, { path: '/beautifier', name: 'Beautifier Tool', icon: Wand2, description: 'Beautify/minify code' }, - { path: '/diff', name: 'Diff Tool', icon: GitCompare, description: 'Compare text differences' } + { path: '/diff', name: 'Diff Tool', icon: GitCompare, description: 'Compare text differences' }, + { path: '/html-preview', name: 'HTML Preview', icon: Code, description: 'Render HTML, CSS, JS' } ]; return ( diff --git a/src/pages/Base64Tool.js b/src/pages/Base64Tool.js index 9a55c9e0..f441c418 100644 --- a/src/pages/Base64Tool.js +++ b/src/pages/Base64Tool.js @@ -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'; diff --git a/src/pages/BeautifierTool.js b/src/pages/BeautifierTool.js index b502952d..f27df38a 100644 --- a/src/pages/BeautifierTool.js +++ b/src/pages/BeautifierTool.js @@ -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 = ' '; diff --git a/src/pages/HtmlPreviewTool.js b/src/pages/HtmlPreviewTool.js new file mode 100644 index 00000000..e162c97b --- /dev/null +++ b/src/pages/HtmlPreviewTool.js @@ -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(' { + 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(' { + // 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 ( +
+ {/* Main content area */} +
+ {/* Left sidebar - Code inputs */} + {showSidebar && ( +
+
+

Code Editor

+
+
+ +
+
+ )} + + {/* Center - Preview */} +
+
+ +
+
+ + {/* Inspector Sidebar */} + {inspectedElementInfo && ( + + )} +
+ + {/* Bottom toolbar */} + +
+ ); + } + + return ( + +
+ {/* Left column - Code inputs */} +
+ +
+ + {/* Middle column - Preview */} +
+
+ +
+
+ + {/* ENHANCED OPTION A: Inspector Sidebar */} + {inspectedElementInfo && ( +
+ +
+ )} +
+ + {/* Bottom toolbar */} +
+ +
+
+ ); +}; + +export default HtmlPreviewTool; diff --git a/src/pages/HtmlPreviewTool.js.backup b/src/pages/HtmlPreviewTool.js.backup new file mode 100644 index 00000000..3d802ed1 --- /dev/null +++ b/src/pages/HtmlPreviewTool.js.backup @@ -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(' { + 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 ( + + {/* Left Sidebar - Code Input */} +
+
+ + +
+ + +
+
+ {showCss && ( +
+ + +
+ )} + {showJs && ( +
+ + +
+ )} +
+ + {/* Preview Area */} +
+ {!isFullscreen && ( +
+

Preview

+
+ )} +
+
+ +
+
+ + {/* Preview Tools - Bottom Toolbar */} +
+
+ {isFullscreen && ( + + )} +
+
+ + {isFullscreen && ( + + )} + {Object.entries(devices).map(([key, { icon: DeviceIcon }]) => ( + + ))} +
+
+ +
+
+
+ + {isFullscreen && inspectedElementInfo && ( +
+
+

Element Editor

+ +
+ +
+ )} + + } + /> + ); +}; + +// ################################################################################## +// # 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}`; + } + }; + + // 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

Loading editor...

; + + const otherAttributes = Object.keys(edited).filter( + key => key !== 'tagName' && key !== 'id' && key !== 'className' && key !== 'innerText' + ); + + return ( +
+ {['tagName', 'id', 'className'].map(field => ( +
+ + 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" + /> +
+ ))} +
+ +