Spaces:
Running
Running
"use client"; | |
import { useUpdateEffect } from "react-use"; | |
import { useMemo, useState, useEffect } from "react"; | |
import classNames from "classnames"; | |
import { toast } from "sonner"; | |
import { cn } from "@/lib/utils"; | |
import { GridPattern } from "@/components/magic-ui/grid-pattern"; | |
import { htmlTagToText } from "@/lib/html-tag-to-text"; | |
import { errorDetectorScript, ERROR_DETECTOR_ID } from "@/lib/error-detector"; | |
import type { PreviewError } from "@/types/preview-error"; | |
export const Preview = ({ | |
html, | |
isResizing, | |
isAiWorking, | |
ref, | |
device, | |
currentTab, | |
iframeRef, | |
isEditableModeEnabled, | |
onClickElement, | |
onErrors, | |
}: { | |
html: string; | |
isResizing: boolean; | |
isAiWorking: boolean; | |
ref: React.RefObject<HTMLDivElement | null>; | |
iframeRef?: React.RefObject<HTMLIFrameElement | null>; | |
device: "desktop" | "mobile"; | |
currentTab: string; | |
isEditableModeEnabled?: boolean; | |
onClickElement?: (element: HTMLElement) => void; | |
onErrors?: (errors: PreviewError[]) => void; | |
}) => { | |
const [hoveredElement, setHoveredElement] = useState<HTMLElement | null>( | |
null, | |
); | |
// Listen for error messages from the iframe | |
useEffect(() => { | |
const handleMessage = (event: MessageEvent) => { | |
if (event.data?.type === "PREVIEW_ERRORS" && onErrors) { | |
onErrors(event.data.errors); | |
} | |
}; | |
window.addEventListener("message", handleMessage); | |
return () => window.removeEventListener("message", handleMessage); | |
}, [onErrors]); | |
// Defensive reset: Clear errors when HTML changes | |
useUpdateEffect(() => { | |
onErrors?.([]); | |
}, [html]); | |
// add event listener to the iframe to track hovered elements | |
const handleMouseOver = (event: MouseEvent) => { | |
if (iframeRef?.current) { | |
const iframeDocument = iframeRef.current.contentDocument; | |
if (iframeDocument) { | |
const targetElement = event.target as HTMLElement; | |
if ( | |
hoveredElement !== targetElement && | |
targetElement !== iframeDocument.body | |
) { | |
setHoveredElement(targetElement); | |
targetElement.classList.add("hovered-element"); | |
} else { | |
return setHoveredElement(null); | |
} | |
} | |
} | |
}; | |
const handleMouseOut = () => { | |
setHoveredElement(null); | |
}; | |
const handleClick = (event: MouseEvent) => { | |
if (iframeRef?.current) { | |
const iframeDocument = iframeRef.current.contentDocument; | |
if (iframeDocument) { | |
const targetElement = event.target as HTMLElement; | |
if (targetElement !== iframeDocument.body) { | |
onClickElement?.(targetElement); | |
} | |
} | |
} | |
}; | |
useUpdateEffect(() => { | |
const cleanupListeners = () => { | |
if (iframeRef?.current?.contentDocument) { | |
const iframeDocument = iframeRef.current.contentDocument; | |
iframeDocument.removeEventListener("mouseover", handleMouseOver); | |
iframeDocument.removeEventListener("mouseout", handleMouseOut); | |
iframeDocument.removeEventListener("click", handleClick); | |
} | |
}; | |
if (iframeRef?.current) { | |
const iframeDocument = iframeRef.current.contentDocument; | |
if (iframeDocument) { | |
// Clean up existing listeners first | |
cleanupListeners(); | |
if (isEditableModeEnabled) { | |
iframeDocument.addEventListener("mouseover", handleMouseOver); | |
iframeDocument.addEventListener("mouseout", handleMouseOut); | |
iframeDocument.addEventListener("click", handleClick); | |
} | |
} | |
} | |
// Clean up when component unmounts or dependencies change | |
return cleanupListeners; | |
}, [iframeRef, isEditableModeEnabled]); | |
const selectedElement = useMemo(() => { | |
if (!isEditableModeEnabled) return null; | |
if (!hoveredElement) return null; | |
return hoveredElement; | |
}, [hoveredElement, isEditableModeEnabled]); | |
// Inject error detection script into the HTML | |
const htmlWithErrorDetection = useMemo(() => { | |
if (!html) return ""; | |
// First, remove any existing error detector script to prevent duplicates | |
const cleanedHtml = html.replace( | |
new RegExp( | |
`<script[^>]*id="${ERROR_DETECTOR_ID}"[^>]*>[\\s\\S]*?</script>`, | |
"gi", | |
), | |
"", | |
); | |
// Create the script tag with proper ID | |
const scriptTag = `<script id="${ERROR_DETECTOR_ID}">${errorDetectorScript}</script>`; | |
// If html already has a </head> tag, inject script before it | |
if (cleanedHtml.includes("</head>")) { | |
return cleanedHtml.replace("</head>", `${scriptTag}</head>`); | |
} | |
// If html already has a <body> tag, inject script after it | |
else if (cleanedHtml.includes("<body")) { | |
return cleanedHtml.replace(/<body([^>]*)>/, `<body$1>${scriptTag}`); | |
} | |
// Otherwise, wrap the content with proper HTML structure | |
else { | |
return `<!DOCTYPE html><html><head>${scriptTag}</head><body>${cleanedHtml}</body></html>`; | |
} | |
}, [html]); | |
return ( | |
<div | |
ref={ref} | |
className={classNames( | |
"w-full border-l border-gray-900 h-full relative z-0 flex items-center justify-center", | |
{ | |
"lg:p-4": currentTab !== "preview", | |
"max-lg:h-0": currentTab === "chat", | |
"max-lg:h-full": currentTab === "preview", | |
}, | |
)} | |
onClick={(e) => { | |
if (isAiWorking) { | |
e.preventDefault(); | |
e.stopPropagation(); | |
toast.warning("Please wait for the AI to finish working."); | |
} | |
}} | |
> | |
<GridPattern | |
x={-1} | |
y={-1} | |
strokeDasharray={"4 2"} | |
className={cn( | |
"[mask-image:radial-gradient(900px_circle_at_center,white,transparent)]", | |
)} | |
/> | |
{!isAiWorking && hoveredElement && selectedElement && ( | |
<div | |
className="cursor-pointer absolute bg-sky-500/10 border-[2px] border-dashed border-sky-500 rounded-r-lg rounded-b-lg p-3 z-10 pointer-events-none" | |
style={{ | |
top: | |
selectedElement.getBoundingClientRect().top + | |
(currentTab === "preview" ? 0 : 24), | |
left: | |
selectedElement.getBoundingClientRect().left + | |
(currentTab === "preview" ? 0 : 24), | |
width: selectedElement.getBoundingClientRect().width, | |
height: selectedElement.getBoundingClientRect().height, | |
}} | |
> | |
<span className="bg-sky-500 rounded-t-md text-sm text-neutral-100 px-2 py-0.5 -translate-y-7 absolute top-0 left-0"> | |
{htmlTagToText(selectedElement.tagName.toLowerCase())} | |
</span> | |
</div> | |
)} | |
<iframe | |
id="preview-iframe" | |
ref={iframeRef} | |
title="output" | |
className={classNames( | |
"w-full select-none transition-all duration-200 bg-black h-full", | |
{ | |
"pointer-events-none": isResizing || isAiWorking, | |
"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]": | |
currentTab !== "preview" && device === "desktop", | |
}, | |
)} | |
srcDoc={htmlWithErrorDetection} | |
onLoad={() => { | |
// Clear errors on fresh load as extra safety | |
onErrors?.([]); | |
if (iframeRef?.current?.contentWindow?.document?.body) { | |
iframeRef.current.contentWindow.document.body.scrollIntoView({ | |
block: isAiWorking ? "end" : "start", | |
inline: "nearest", | |
behavior: isAiWorking ? "instant" : "smooth", | |
}); | |
} | |
}} | |
/> | |
</div> | |
); | |
}; | |