Spaces:
Paused
Paused
| "use client"; | |
| import { useUpdateEffect } from "react-use"; | |
| import { useMemo, useState, useRef, useEffect, forwardRef, useCallback } from "react"; | |
| import classNames from "classnames"; | |
| import { cn } from "@/lib/utils"; | |
| import { GridPattern } from "@/components/magic-ui/grid-pattern"; | |
| import { htmlTagToText } from "@/lib/html-tag-to-text"; | |
| export const Preview = forwardRef< | |
| HTMLDivElement, | |
| { | |
| html: string; | |
| isResizing: boolean; | |
| isAiWorking: boolean; | |
| device: "desktop" | "mobile"; | |
| currentTab: string; | |
| iframeRef?: React.RefObject<HTMLIFrameElement | null>; | |
| isEditableModeEnabled?: boolean; | |
| onClickElement?: (element: HTMLElement) => void; | |
| } | |
| >(({ | |
| html, | |
| isResizing, | |
| isAiWorking, | |
| device, | |
| currentTab, | |
| iframeRef, | |
| isEditableModeEnabled, | |
| onClickElement, | |
| }, ref) => { | |
| const [hoveredElement, setHoveredElement] = useState<HTMLElement | null>(null); | |
| const [isLoading, setIsLoading] = useState(false); | |
| const [displayHtml, setDisplayHtml] = useState(html); | |
| const htmlUpdateTimeoutRef = useRef<NodeJS.Timeout | null>(null); | |
| const prevHtmlRef = useRef(html); | |
| const internalIframeRef = useRef<HTMLIFrameElement>(null); | |
| // Add iframe key for force refresh when needed | |
| const [iframeKey, setIframeKey] = useState(0); | |
| // Use internal ref if external ref not provided | |
| const currentIframeRef = iframeRef || internalIframeRef; | |
| // Force refresh iframe if it seems stuck | |
| const forceRefresh = useCallback(() => { | |
| console.log('🔄 Force refreshing iframe'); | |
| setIframeKey(prev => prev + 1); | |
| }, []); | |
| // Monitor for stuck updates and force refresh if needed | |
| useEffect(() => { | |
| if (html !== displayHtml && !isAiWorking) { | |
| const timeout = setTimeout(() => { | |
| if (html !== displayHtml) { | |
| console.log('⚠️ Preview seems stuck, force refreshing'); | |
| setDisplayHtml(html); | |
| forceRefresh(); | |
| } | |
| }, 2000); | |
| return () => clearTimeout(timeout); | |
| } | |
| }, [html, displayHtml, isAiWorking, forceRefresh]); | |
| // Debug logging for initial state and prop changes | |
| useEffect(() => { | |
| console.log('🚀 Preview component mounted/updated with HTML:', { | |
| htmlLength: html.length, | |
| displayHtmlLength: displayHtml.length, | |
| htmlPreview: html.substring(0, 200) + '...', | |
| isAiWorking, | |
| device, | |
| currentTab | |
| }); | |
| }, [html, displayHtml, isAiWorking, device, currentTab]); | |
| // CRITICAL: Reliable HTML update logic with debugging | |
| useEffect(() => { | |
| console.log('🔄 Preview update triggered:', { | |
| htmlLength: html.length, | |
| isAiWorking, | |
| displayHtmlLength: displayHtml.length, | |
| htmlChanged: html !== displayHtml, | |
| prevHtmlLength: prevHtmlRef.current.length, | |
| htmlPreview: html.substring(0, 100) + '...', | |
| displayPreview: displayHtml.substring(0, 100) + '...' | |
| }); | |
| // ALWAYS update when HTML prop changes - this is critical | |
| if (html !== prevHtmlRef.current) { | |
| console.log('📝 HTML prop changed! Forcing update:', { | |
| from: prevHtmlRef.current.length, | |
| to: html.length, | |
| isAiWorking, | |
| willUseDelay: isAiWorking | |
| }); | |
| // Clear any pending timeout to prevent conflicts | |
| if (htmlUpdateTimeoutRef.current) { | |
| clearTimeout(htmlUpdateTimeoutRef.current); | |
| console.log('⏰ Cleared pending timeout'); | |
| } | |
| // Update ref immediately | |
| prevHtmlRef.current = html; | |
| // For AI working (streaming), add minimal smoothness | |
| if (isAiWorking && html.length > 0) { | |
| console.log('🤖 AI working - adding minimal delay for smoothness'); | |
| setIsLoading(true); | |
| htmlUpdateTimeoutRef.current = setTimeout(() => { | |
| console.log('⚡ Applying delayed HTML update:', html.length); | |
| setDisplayHtml(html); | |
| setIsLoading(false); | |
| }, 100); // Very short delay for minimal smoothness | |
| } else { | |
| // Immediate update for manual changes or when AI stops | |
| console.log('⚡ Applying immediate HTML update:', html.length); | |
| setDisplayHtml(html); | |
| setIsLoading(false); | |
| } | |
| } else if (html !== displayHtml) { | |
| // Edge case: displayHtml is out of sync | |
| console.log('🔧 Fixing displayHtml sync issue'); | |
| setDisplayHtml(html); | |
| } | |
| console.log('✅ Preview update completed'); | |
| }, [html, isAiWorking, displayHtml]); | |
| // Enhanced smooth transitions via CSS injection | |
| useEffect(() => { | |
| const iframe = currentIframeRef.current; | |
| if (!iframe) return; | |
| const injectSmoothTransitions = () => { | |
| const doc = iframe.contentDocument; | |
| if (!doc) { | |
| console.warn('⚠️ Iframe document not available for smooth transitions'); | |
| return; | |
| } | |
| // Ensure document structure exists | |
| if (!doc.head) { | |
| console.warn('⚠️ Iframe document head not available for smooth transitions'); | |
| return; | |
| } | |
| // Check if document is ready | |
| if (doc.readyState !== 'complete' && doc.readyState !== 'interactive') { | |
| console.log('⏳ Document not ready, will retry smooth transitions injection'); | |
| setTimeout(injectSmoothTransitions, 50); | |
| return; | |
| } | |
| const existingStyle = doc.getElementById('smooth-transitions'); | |
| if (existingStyle) { | |
| console.log('🎨 Smooth transitions already injected, updating...'); | |
| existingStyle.remove(); | |
| } | |
| try { | |
| const style = doc.createElement('style'); | |
| style.id = 'smooth-transitions'; | |
| style.textContent = ` | |
| /* Enhanced smooth transitions for zero-flash updates */ | |
| * { | |
| transition: opacity 0.2s cubic-bezier(0.4, 0, 0.2, 1), | |
| background-color 0.2s ease, | |
| color 0.2s ease, | |
| border-color 0.2s ease, | |
| transform 0.2s ease !important; | |
| } | |
| /* Prevent flash during content updates */ | |
| body { | |
| transition: opacity 0.15s ease !important; | |
| will-change: opacity; | |
| } | |
| /* Smooth content updates */ | |
| .content-updating { | |
| animation: contentUpdate 0.3s cubic-bezier(0.4, 0, 0.2, 1); | |
| } | |
| @keyframes contentUpdate { | |
| 0% { | |
| opacity: 0.9; | |
| transform: translateY(1px); | |
| } | |
| 100% { | |
| opacity: 1; | |
| transform: translateY(0); | |
| } | |
| } | |
| /* New element entrance */ | |
| .element-entering { | |
| animation: elementEnter 0.4s cubic-bezier(0.4, 0, 0.2, 1); | |
| } | |
| @keyframes elementEnter { | |
| 0% { | |
| opacity: 0; | |
| transform: translateY(8px) scale(0.98); | |
| } | |
| 100% { | |
| opacity: 1; | |
| transform: translateY(0) scale(1); | |
| } | |
| } | |
| /* Subtle glow for changed content */ | |
| .content-changed { | |
| animation: contentGlow 0.6s ease-in-out; | |
| } | |
| @keyframes contentGlow { | |
| 0%, 100% { | |
| box-shadow: none; | |
| background-color: transparent; | |
| } | |
| 30% { | |
| box-shadow: 0 0 20px rgba(59, 130, 246, 0.2); | |
| background-color: rgba(59, 130, 246, 0.05); | |
| } | |
| } | |
| /* Optimize rendering performance */ | |
| * { | |
| backface-visibility: hidden; | |
| -webkit-font-smoothing: antialiased; | |
| } | |
| `; | |
| doc.head.appendChild(style); | |
| console.log('✨ Enhanced smooth transitions injected into iframe'); | |
| } catch (error) { | |
| console.error('❌ Failed to inject smooth transitions:', error); | |
| } | |
| }; | |
| // Inject on iframe load and when content changes | |
| const handleLoad = () => { | |
| // Use multiple timeouts to ensure document is ready | |
| setTimeout(injectSmoothTransitions, 10); | |
| setTimeout(injectSmoothTransitions, 50); | |
| setTimeout(injectSmoothTransitions, 100); | |
| }; | |
| iframe.addEventListener('load', handleLoad); | |
| // Also try to inject immediately if iframe is already loaded | |
| if (iframe.contentDocument?.readyState === 'complete') { | |
| injectSmoothTransitions(); | |
| } else { | |
| // Try again after a short delay | |
| setTimeout(() => { | |
| if (iframe.contentDocument?.readyState === 'complete') { | |
| injectSmoothTransitions(); | |
| } | |
| }, 100); | |
| } | |
| return () => { | |
| iframe.removeEventListener('load', handleLoad); | |
| }; | |
| }, [currentIframeRef, iframeKey]); | |
| // Enhanced content updating animations | |
| useEffect(() => { | |
| const iframe = currentIframeRef.current; | |
| if (!iframe) return; | |
| const addContentUpdateAnimation = () => { | |
| const doc = iframe.contentDocument; | |
| if (!doc || !doc.body) return; | |
| const body = doc.body; | |
| // Remove any existing animation classes | |
| body.classList.remove('content-updating', 'content-changed'); | |
| // Add animation class | |
| body.classList.add('content-updating'); | |
| console.log('🎬 Applied content update animation'); | |
| // Remove class after animation | |
| const timeout = setTimeout(() => { | |
| body.classList.remove('content-updating'); | |
| }, 300); | |
| return () => { | |
| clearTimeout(timeout); | |
| body.classList.remove('content-updating', 'content-changed'); | |
| }; | |
| }; | |
| // Small delay to ensure iframe content is ready | |
| const timeout = setTimeout(addContentUpdateAnimation, 50); | |
| return () => { | |
| clearTimeout(timeout); | |
| }; | |
| }, [displayHtml, currentIframeRef]); | |
| // Cleanup timeout on unmount | |
| useEffect(() => { | |
| return () => { | |
| if (htmlUpdateTimeoutRef.current) { | |
| clearTimeout(htmlUpdateTimeoutRef.current); | |
| } | |
| }; | |
| }, []); | |
| // Event handlers for editable mode | |
| const handleMouseOver = (event: MouseEvent) => { | |
| if (currentIframeRef?.current) { | |
| const iframeDocument = currentIframeRef.current.contentDocument; | |
| if (iframeDocument) { | |
| const targetElement = event.target as HTMLElement; | |
| if ( | |
| hoveredElement !== targetElement && | |
| targetElement !== iframeDocument.body | |
| ) { | |
| console.log("🎯 Edit mode: Element hovered", { | |
| tagName: targetElement.tagName, | |
| id: targetElement.id || 'no-id', | |
| className: targetElement.className || 'no-class' | |
| }); | |
| // Remove previous hover class | |
| if (hoveredElement) { | |
| hoveredElement.classList.remove("hovered-element"); | |
| } | |
| setHoveredElement(targetElement); | |
| targetElement.classList.add("hovered-element"); | |
| } else { | |
| return setHoveredElement(null); | |
| } | |
| } | |
| } | |
| }; | |
| const handleMouseOut = () => { | |
| setHoveredElement(null); | |
| }; | |
| const handleClick = (event: MouseEvent) => { | |
| console.log("🖱️ Edit mode: Click detected in iframe", { | |
| target: event.target, | |
| tagName: (event.target as HTMLElement)?.tagName, | |
| isBody: event.target === currentIframeRef?.current?.contentDocument?.body, | |
| hasOnClickElement: !!onClickElement | |
| }); | |
| if (currentIframeRef?.current) { | |
| const iframeDocument = currentIframeRef.current.contentDocument; | |
| if (iframeDocument) { | |
| const targetElement = event.target as HTMLElement; | |
| if (targetElement !== iframeDocument.body) { | |
| console.log("✅ Edit mode: Valid element clicked, calling onClickElement", { | |
| tagName: targetElement.tagName, | |
| id: targetElement.id || 'no-id', | |
| className: targetElement.className || 'no-class', | |
| textContent: targetElement.textContent?.substring(0, 50) + '...' | |
| }); | |
| // Prevent default behavior to avoid navigation | |
| event.preventDefault(); | |
| event.stopPropagation(); | |
| onClickElement?.(targetElement); | |
| } else { | |
| console.log("⚠️ Edit mode: Body clicked, ignoring"); | |
| } | |
| } else { | |
| console.error("❌ Edit mode: No iframe document available on click"); | |
| } | |
| } else { | |
| console.error("❌ Edit mode: No iframe ref available on click"); | |
| } | |
| }; | |
| // Setup event listeners for editable mode | |
| useUpdateEffect(() => { | |
| const cleanupListeners = () => { | |
| if (currentIframeRef?.current?.contentDocument) { | |
| const iframeDocument = currentIframeRef.current.contentDocument; | |
| iframeDocument.removeEventListener("mouseover", handleMouseOver); | |
| iframeDocument.removeEventListener("mouseout", handleMouseOut); | |
| iframeDocument.removeEventListener("click", handleClick); | |
| console.log("🧹 Edit mode: Cleaned up iframe event listeners"); | |
| } | |
| }; | |
| const setupListeners = () => { | |
| try { | |
| if (!currentIframeRef?.current) { | |
| console.log("⚠️ Edit mode: No iframe ref available"); | |
| return; | |
| } | |
| const iframeDocument = currentIframeRef.current.contentDocument; | |
| if (!iframeDocument) { | |
| console.log("⚠️ Edit mode: No iframe content document available"); | |
| return; | |
| } | |
| // Clean up existing listeners first | |
| cleanupListeners(); | |
| if (isEditableModeEnabled) { | |
| console.log("🎯 Edit mode: Setting up iframe event listeners"); | |
| iframeDocument.addEventListener("mouseover", handleMouseOver); | |
| iframeDocument.addEventListener("mouseout", handleMouseOut); | |
| iframeDocument.addEventListener("click", handleClick); | |
| console.log("✅ Edit mode: Event listeners added successfully"); | |
| } else { | |
| console.log("🔇 Edit mode: Disabled, no listeners added"); | |
| } | |
| } catch (error) { | |
| console.error("❌ Edit mode: Error setting up listeners:", error); | |
| } | |
| }; | |
| // Add a small delay to ensure iframe is fully loaded | |
| const timeoutId = setTimeout(setupListeners, 100); | |
| // Clean up when component unmounts or dependencies change | |
| return () => { | |
| clearTimeout(timeoutId); | |
| cleanupListeners(); | |
| }; | |
| }, [currentIframeRef, isEditableModeEnabled]); | |
| const selectedElement = useMemo(() => { | |
| if (!isEditableModeEnabled) return null; | |
| if (!hoveredElement) return null; | |
| return hoveredElement; | |
| }, [hoveredElement, isEditableModeEnabled]); | |
| return ( | |
| <div | |
| ref={ref} | |
| className={classNames( | |
| "bg-white overflow-hidden relative flex-1 h-full", | |
| { | |
| "cursor-wait": isLoading && isAiWorking, | |
| } | |
| )} | |
| onClick={(e) => { | |
| e.stopPropagation(); | |
| }} | |
| > | |
| <GridPattern | |
| width={20} | |
| height={20} | |
| x={-1} | |
| y={-1} | |
| strokeDasharray={"4 2"} | |
| className={cn( | |
| "[mask-image:radial-gradient(300px_circle_at_center,white,transparent)] z-0 absolute inset-0 h-full w-full fill-neutral-100 stroke-neutral-100" | |
| )} | |
| /> | |
| {/* Simplified loading overlay */} | |
| {isLoading && isAiWorking && ( | |
| <div className="absolute inset-0 bg-black/5 backdrop-blur-[0.5px] transition-all duration-300 z-20 flex items-center justify-center"> | |
| <div className="bg-neutral-800/95 rounded-lg px-4 py-2 text-sm text-neutral-300 border border-neutral-700 shadow-lg"> | |
| <div className="flex items-center gap-2"> | |
| <div className="w-2 h-2 bg-blue-500 rounded-full animate-pulse"></div> | |
| Updating preview... | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| {/* Selected element indicator */} | |
| {!isAiWorking && hoveredElement && selectedElement && ( | |
| <div className="absolute bottom-4 left-4 z-30"> | |
| <div className="bg-neutral-800/90 rounded-lg px-3 py-2 text-sm text-neutral-300 border border-neutral-700 shadow-lg"> | |
| <span className="font-medium"> | |
| {htmlTagToText(selectedElement.tagName.toLowerCase())} | |
| </span> | |
| {selectedElement.id && ( | |
| <span className="ml-2 text-neutral-400">#{selectedElement.id}</span> | |
| )} | |
| </div> | |
| </div> | |
| )} | |
| {/* Reliable iframe with force refresh capability */} | |
| <iframe | |
| key={iframeKey} | |
| id="preview-iframe" | |
| ref={currentIframeRef} | |
| title="output" | |
| className={classNames( | |
| "w-full select-none h-full transition-all duration-200 ease-out", | |
| { | |
| "pointer-events-none": isResizing || isAiWorking, | |
| "opacity-95 scale-[0.999]": isLoading && isAiWorking, | |
| "opacity-100 scale-100": !isLoading || !isAiWorking, | |
| "bg-black": true, | |
| "lg:max-w-md lg:mx-auto lg:!rounded-[42px] lg:border-[8px] lg:border-neutral-700 lg:shadow-2xl lg:h-[80dvh] lg:max-h-[996px]": | |
| device === "mobile", | |
| "lg:border-[8px] lg:border-neutral-700 lg:shadow-2xl lg:rounded-[24px]": | |
| device === "desktop", | |
| } | |
| )} | |
| srcDoc={displayHtml} | |
| onLoad={() => { | |
| console.log('🎯 Preview iframe loaded:', { | |
| displayHtmlLength: displayHtml.length, | |
| iframeKey, | |
| hasContent: displayHtml.length > 0 | |
| }); | |
| setIsLoading(false); | |
| }} | |
| onError={(e) => { | |
| console.error('❌ Iframe loading error:', e); | |
| setIsLoading(false); | |
| }} | |
| /> | |
| </div> | |
| ); | |
| }); | |
| Preview.displayName = "Preview"; | |