|
|
"use client"; |
|
|
|
|
|
import { useRef, useState, useEffect } from "react"; |
|
|
import { useUpdateEffect } from "react-use"; |
|
|
import classNames from "classnames"; |
|
|
|
|
|
import { cn } from "@/lib/utils"; |
|
|
import { GridPattern } from "@/components/magic-ui/grid-pattern"; |
|
|
import { useEditor } from "@/hooks/useEditor"; |
|
|
import { useAi } from "@/hooks/useAi"; |
|
|
import { htmlTagToText } from "@/lib/html-tag-to-text"; |
|
|
import { AnimatedBlobs } from "@/components/animated-blobs"; |
|
|
import { AiLoading } from "../ask-ai/loading"; |
|
|
import { defaultHTML } from "@/lib/consts"; |
|
|
import { HistoryNotification } from "../history-notification"; |
|
|
import { api } from "@/lib/api"; |
|
|
import { toast } from "sonner"; |
|
|
|
|
|
export const Preview = ({ isNew }: { isNew: boolean }) => { |
|
|
const { |
|
|
project, |
|
|
device, |
|
|
isLoadingProject, |
|
|
currentTab, |
|
|
currentCommit, |
|
|
setCurrentCommit, |
|
|
currentPageData, |
|
|
pages, |
|
|
setPages, |
|
|
setCurrentPage, |
|
|
isSameHtml, |
|
|
} = useEditor(); |
|
|
const { |
|
|
isEditableModeEnabled, |
|
|
setSelectedElement, |
|
|
isAiWorking, |
|
|
globalAiLoading, |
|
|
} = useAi(); |
|
|
|
|
|
const iframeRef = useRef<HTMLIFrameElement>(null); |
|
|
|
|
|
const [hoveredElement, setHoveredElement] = useState<{ |
|
|
tagName: string; |
|
|
rect: { top: number; left: number; width: number; height: number }; |
|
|
} | null>(null); |
|
|
const [isPromotingVersion, setIsPromotingVersion] = useState(false); |
|
|
const [stableHtml, setStableHtml] = useState<string>(""); |
|
|
const [throttledHtml, setThrottledHtml] = useState<string>(""); |
|
|
const lastUpdateTimeRef = useRef<number>(0); |
|
|
|
|
|
|
|
|
useEffect(() => { |
|
|
if (isNew && currentPageData?.html) { |
|
|
const now = Date.now(); |
|
|
const timeSinceLastUpdate = now - lastUpdateTimeRef.current; |
|
|
|
|
|
|
|
|
if (lastUpdateTimeRef.current === 0 || timeSinceLastUpdate >= 3000) { |
|
|
setThrottledHtml(currentPageData.html); |
|
|
lastUpdateTimeRef.current = now; |
|
|
} else { |
|
|
|
|
|
const timeUntilNextUpdate = 3000 - timeSinceLastUpdate; |
|
|
const timer = setTimeout(() => { |
|
|
setThrottledHtml(currentPageData.html); |
|
|
lastUpdateTimeRef.current = Date.now(); |
|
|
}, timeUntilNextUpdate); |
|
|
return () => clearTimeout(timer); |
|
|
} |
|
|
} |
|
|
}, [isNew, currentPageData?.html]); |
|
|
|
|
|
useEffect(() => { |
|
|
if (!isAiWorking && !globalAiLoading && currentPageData?.html) { |
|
|
setStableHtml(currentPageData.html); |
|
|
} |
|
|
}, [isAiWorking, globalAiLoading, currentPageData?.html]); |
|
|
|
|
|
useEffect(() => { |
|
|
if ( |
|
|
currentPageData?.html && |
|
|
!stableHtml && |
|
|
!isAiWorking && |
|
|
!globalAiLoading |
|
|
) { |
|
|
setStableHtml(currentPageData.html); |
|
|
} |
|
|
}, [currentPageData?.html, stableHtml, isAiWorking, globalAiLoading]); |
|
|
|
|
|
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) { |
|
|
cleanupListeners(); |
|
|
|
|
|
if (isEditableModeEnabled) { |
|
|
iframeDocument.addEventListener("mouseover", handleMouseOver); |
|
|
iframeDocument.addEventListener("mouseout", handleMouseOut); |
|
|
iframeDocument.addEventListener("click", handleClick); |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
return cleanupListeners; |
|
|
}, [iframeRef, isEditableModeEnabled]); |
|
|
|
|
|
const promoteVersion = async () => { |
|
|
setIsPromotingVersion(true); |
|
|
await api |
|
|
.post( |
|
|
`/me/projects/${project?.space_id}/commits/${currentCommit}/promote` |
|
|
) |
|
|
.then((res) => { |
|
|
if (res.data.ok) { |
|
|
setCurrentCommit(null); |
|
|
setPages(res.data.pages); |
|
|
setCurrentPage(res.data.pages[0].path); |
|
|
toast.success("Version promoted successfully"); |
|
|
} |
|
|
}) |
|
|
.catch((err) => { |
|
|
toast.error(err.response.data.error); |
|
|
}); |
|
|
setIsPromotingVersion(false); |
|
|
}; |
|
|
|
|
|
const handleMouseOver = (event: MouseEvent) => { |
|
|
if (iframeRef?.current) { |
|
|
const iframeDocument = iframeRef.current.contentDocument; |
|
|
if (iframeDocument) { |
|
|
const targetElement = event.target as HTMLElement; |
|
|
if ( |
|
|
hoveredElement?.tagName !== targetElement.tagName || |
|
|
hoveredElement?.rect.top !== |
|
|
targetElement.getBoundingClientRect().top || |
|
|
hoveredElement?.rect.left !== |
|
|
targetElement.getBoundingClientRect().left || |
|
|
hoveredElement?.rect.width !== |
|
|
targetElement.getBoundingClientRect().width || |
|
|
hoveredElement?.rect.height !== |
|
|
targetElement.getBoundingClientRect().height |
|
|
) { |
|
|
if (targetElement !== iframeDocument.body) { |
|
|
const rect = targetElement.getBoundingClientRect(); |
|
|
setHoveredElement({ |
|
|
tagName: targetElement.tagName, |
|
|
rect: { |
|
|
top: rect.top, |
|
|
left: rect.left, |
|
|
width: rect.width, |
|
|
height: rect.height, |
|
|
}, |
|
|
}); |
|
|
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) { |
|
|
setSelectedElement(targetElement); |
|
|
} |
|
|
} |
|
|
} |
|
|
}; |
|
|
|
|
|
const handleCustomNavigation = (event: MouseEvent) => { |
|
|
if (iframeRef?.current) { |
|
|
const iframeDocument = iframeRef.current.contentDocument; |
|
|
if (iframeDocument) { |
|
|
const findClosestAnchor = ( |
|
|
element: HTMLElement |
|
|
): HTMLAnchorElement | null => { |
|
|
let current = element; |
|
|
while (current && current !== iframeDocument.body) { |
|
|
if (current.tagName === "A") { |
|
|
return current as HTMLAnchorElement; |
|
|
} |
|
|
current = current.parentElement as HTMLElement; |
|
|
} |
|
|
return null; |
|
|
}; |
|
|
|
|
|
const anchorElement = findClosestAnchor(event.target as HTMLElement); |
|
|
if (anchorElement) { |
|
|
let href = anchorElement.getAttribute("href"); |
|
|
if (href) { |
|
|
event.stopPropagation(); |
|
|
event.preventDefault(); |
|
|
|
|
|
if (href.includes("#") && !href.includes(".html")) { |
|
|
const targetElement = iframeDocument.querySelector(href); |
|
|
if (targetElement) { |
|
|
targetElement.scrollIntoView({ behavior: "smooth" }); |
|
|
} |
|
|
return; |
|
|
} |
|
|
|
|
|
href = href.split(".html")[0] + ".html"; |
|
|
const isPageExist = pages.some((page) => page.path === href); |
|
|
if (isPageExist) { |
|
|
setCurrentPage(href); |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
}; |
|
|
|
|
|
return ( |
|
|
<div |
|
|
className={classNames( |
|
|
"bg-neutral-900/30 w-full h-[calc(100dvh-57px)] flex flex-col items-center justify-center relative z-1 lg:border-l border-neutral-800", |
|
|
{ |
|
|
"max-lg:h-0 overflow-hidden": currentTab === "chat", |
|
|
"max-lg:h-full": currentTab === "preview", |
|
|
} |
|
|
)} |
|
|
> |
|
|
<GridPattern |
|
|
x={-1} |
|
|
y={-1} |
|
|
strokeDasharray={"4 2"} |
|
|
className={cn( |
|
|
"[mask-image:radial-gradient(900px_circle_at_center,white,transparent)] opacity-40" |
|
|
)} |
|
|
/> |
|
|
{!isAiWorking && hoveredElement && isEditableModeEnabled && ( |
|
|
<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: hoveredElement.rect.top, |
|
|
left: hoveredElement.rect.left, |
|
|
width: hoveredElement.rect.width, |
|
|
height: hoveredElement.rect.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(hoveredElement.tagName.toLowerCase())} |
|
|
</span> |
|
|
</div> |
|
|
)} |
|
|
{isLoadingProject ? ( |
|
|
<div className="w-full h-full flex items-center justify-center relative"> |
|
|
<div className="py-10 w-full relative z-1 max-w-3xl mx-auto text-center"> |
|
|
<AiLoading text="Fetching your project..." className="flex-col" /> |
|
|
<AnimatedBlobs /> |
|
|
<AnimatedBlobs /> |
|
|
</div> |
|
|
</div> |
|
|
) : ( |
|
|
<> |
|
|
<iframe |
|
|
id="preview-iframe" |
|
|
ref={iframeRef} |
|
|
className={classNames( |
|
|
"w-full select-none transition-all duration-200 bg-black h-full", |
|
|
{ |
|
|
"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", |
|
|
} |
|
|
)} |
|
|
src={ |
|
|
currentCommit |
|
|
? `https://${project?.space_id?.replaceAll( |
|
|
"/", |
|
|
"-" |
|
|
)}--rev-${currentCommit.slice(0, 7)}.static.hf.space` |
|
|
: undefined |
|
|
} |
|
|
srcDoc={ |
|
|
!currentCommit |
|
|
? isNew |
|
|
? throttledHtml || defaultHTML |
|
|
: stableHtml |
|
|
: undefined |
|
|
} |
|
|
onLoad={ |
|
|
!currentCommit |
|
|
? () => { |
|
|
if (iframeRef?.current?.contentWindow?.document?.body) { |
|
|
iframeRef.current.contentWindow.document.body.scrollIntoView( |
|
|
{ |
|
|
block: isAiWorking ? "end" : "start", |
|
|
inline: "nearest", |
|
|
behavior: isAiWorking ? "instant" : "smooth", |
|
|
} |
|
|
); |
|
|
} |
|
|
// add event listener to all links in the iframe to handle navigation |
|
|
if (iframeRef?.current?.contentWindow?.document) { |
|
|
const links = |
|
|
iframeRef.current.contentWindow.document.querySelectorAll( |
|
|
"a" |
|
|
); |
|
|
links.forEach((link) => { |
|
|
link.addEventListener("click", handleCustomNavigation); |
|
|
}); |
|
|
} |
|
|
} |
|
|
: undefined |
|
|
} |
|
|
sandbox="allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox" |
|
|
allow="accelerometer; ambient-light-sensor; autoplay; battery; camera; clipboard-read; clipboard-write; display-capture; document-domain; encrypted-media; fullscreen; geolocation; gyroscope; layout-animations; legacy-image-formats; magnetometer; microphone; midi; oversized-images; payment; picture-in-picture; publickey-credentials-get; serial; sync-xhr; usb; vr ; wake-lock; xr-spatial-tracking" |
|
|
/> |
|
|
{!isNew && ( |
|
|
<> |
|
|
<div |
|
|
className={classNames( |
|
|
"w-full h-full flex items-center justify-center absolute left-0 top-0 bg-black/40 backdrop-blur-lg transition-all duration-200", |
|
|
{ |
|
|
"opacity-0 pointer-events-none": !globalAiLoading, |
|
|
} |
|
|
)} |
|
|
> |
|
|
<div className="py-10 w-full relative z-1 max-w-3xl mx-auto text-center"> |
|
|
<AiLoading |
|
|
text={ |
|
|
isLoadingProject ? "Fetching your project..." : undefined |
|
|
} |
|
|
className="flex-col" |
|
|
/> |
|
|
<AnimatedBlobs /> |
|
|
<AnimatedBlobs /> |
|
|
</div> |
|
|
</div> |
|
|
<HistoryNotification |
|
|
isVisible={!!currentCommit} |
|
|
isPromotingVersion={isPromotingVersion} |
|
|
onPromoteVersion={promoteVersion} |
|
|
onGoBackToCurrent={() => setCurrentCommit(null)} |
|
|
/> |
|
|
</> |
|
|
)} |
|
|
</> |
|
|
)} |
|
|
</div> |
|
|
); |
|
|
}; |
|
|
|