enzostvs HF Staff commited on
Commit
890f017
·
1 Parent(s): f6942c9

update live preview

Browse files
components/editor/index.tsx CHANGED
@@ -17,7 +17,6 @@ import { AskAi } from "./ask-ai";
17
  import { Preview } from "./preview";
18
  import { SaveChangesPopup } from "./save-changes-popup";
19
  import Loading from "../loading";
20
- import { LivePreviewRef } from "./live-preview";
21
  import { Page } from "@/types";
22
 
23
  export const AppEditor = ({
@@ -40,8 +39,7 @@ export const AppEditor = ({
40
  saveChanges,
41
  pages,
42
  } = useEditor(namespace, repoId);
43
- const livePreviewRef = useRef<LivePreviewRef>(null);
44
- const { isAiWorking } = useAi(undefined, livePreviewRef);
45
  const [, copyToClipboard] = useCopyToClipboard();
46
  const [showSavePopup, setShowSavePopup] = useState(false);
47
  const [pagesStorage, , removePagesStorage] = useLocalStorage<Page[]>("pages");
@@ -136,7 +134,7 @@ export const AppEditor = ({
136
  }}
137
  />
138
  </div>
139
- <Preview ref={livePreviewRef} isNew={isNew} />
140
  </main>
141
 
142
  {/* Save Changes Popup */}
 
17
  import { Preview } from "./preview";
18
  import { SaveChangesPopup } from "./save-changes-popup";
19
  import Loading from "../loading";
 
20
  import { Page } from "@/types";
21
 
22
  export const AppEditor = ({
 
39
  saveChanges,
40
  pages,
41
  } = useEditor(namespace, repoId);
42
+ const { isAiWorking } = useAi();
 
43
  const [, copyToClipboard] = useCopyToClipboard();
44
  const [showSavePopup, setShowSavePopup] = useState(false);
45
  const [pagesStorage, , removePagesStorage] = useLocalStorage<Page[]>("pages");
 
134
  }}
135
  />
136
  </div>
137
+ <Preview isNew={isNew} />
138
  </main>
139
 
140
  {/* Save Changes Popup */}
components/editor/live-preview/index.tsx DELETED
@@ -1,165 +0,0 @@
1
- "use client";
2
-
3
- import {
4
- useState,
5
- useEffect,
6
- useRef,
7
- forwardRef,
8
- useImperativeHandle,
9
- } from "react";
10
- import classNames from "classnames";
11
-
12
- import { Button } from "@/components/ui/button";
13
- import { Maximize, Minimize } from "lucide-react";
14
-
15
- interface LivePreviewProps {
16
- currentPageData: { path: string; html: string } | undefined;
17
- isAiWorking: boolean;
18
- defaultHTML: string;
19
- className?: string;
20
- }
21
-
22
- export interface LivePreviewRef {
23
- reset: () => void;
24
- }
25
-
26
- export const LivePreview = forwardRef<LivePreviewRef, LivePreviewProps>(
27
- ({ currentPageData, isAiWorking, defaultHTML, className }, ref) => {
28
- const [isMaximized, setIsMaximized] = useState(false);
29
- const [displayedHtml, setDisplayedHtml] = useState<string>("");
30
- const latestHtmlRef = useRef<string>("");
31
- const displayedHtmlRef = useRef<string>("");
32
- const intervalRef = useRef<NodeJS.Timeout | null>(null);
33
-
34
- const reset = () => {
35
- setIsMaximized(false);
36
- setDisplayedHtml("");
37
- latestHtmlRef.current = "";
38
- displayedHtmlRef.current = "";
39
- if (intervalRef.current) {
40
- clearInterval(intervalRef.current);
41
- intervalRef.current = null;
42
- }
43
- };
44
-
45
- useImperativeHandle(ref, () => ({
46
- reset,
47
- }));
48
-
49
- useEffect(() => {
50
- displayedHtmlRef.current = displayedHtml;
51
- }, [displayedHtml]);
52
-
53
- useEffect(() => {
54
- if (currentPageData?.html && currentPageData.html !== defaultHTML) {
55
- latestHtmlRef.current = currentPageData.html;
56
- }
57
- }, [currentPageData?.html, defaultHTML]);
58
-
59
- useEffect(() => {
60
- if (!currentPageData?.html || currentPageData.html === defaultHTML) {
61
- return;
62
- }
63
-
64
- if (!displayedHtml || !isAiWorking) {
65
- setDisplayedHtml(currentPageData.html);
66
- if (intervalRef.current) {
67
- clearInterval(intervalRef.current);
68
- intervalRef.current = null;
69
- }
70
- return;
71
- }
72
-
73
- if (isAiWorking && !intervalRef.current) {
74
- intervalRef.current = setInterval(() => {
75
- if (
76
- latestHtmlRef.current &&
77
- latestHtmlRef.current !== displayedHtmlRef.current
78
- ) {
79
- setDisplayedHtml(latestHtmlRef.current);
80
- }
81
- }, 3000);
82
- }
83
- }, [currentPageData?.html, defaultHTML, isAiWorking, displayedHtml]);
84
-
85
- useEffect(() => {
86
- if (!isAiWorking && intervalRef.current) {
87
- clearInterval(intervalRef.current);
88
- intervalRef.current = null;
89
- if (latestHtmlRef.current) {
90
- setDisplayedHtml(latestHtmlRef.current);
91
- }
92
- }
93
- }, [isAiWorking]);
94
-
95
- useEffect(() => {
96
- return () => {
97
- if (intervalRef.current) {
98
- clearInterval(intervalRef.current);
99
- intervalRef.current = null;
100
- }
101
- };
102
- }, []);
103
-
104
- if (!displayedHtml) {
105
- return null;
106
- }
107
-
108
- return (
109
- <div
110
- className={classNames(
111
- "absolute z-40 bg-white/95 backdrop-blur-sm border border-neutral-200 shadow-lg transition-all duration-500 ease-out transform scale-100 opacity-100 animate-in slide-in-from-bottom-4 zoom-in-95 rounded-xl",
112
- {
113
- "shadow-green-500/20 shadow-2xl border-green-200": isAiWorking,
114
- },
115
- className
116
- )}
117
- >
118
- <div
119
- className={classNames(
120
- "flex flex-col animate-in fade-in duration-300",
121
- isMaximized ? "w-[90dvw] lg:w-[60dvw] h-[80dvh]" : "w-80 h-96"
122
- )}
123
- >
124
- <div className="flex items-center justify-between p-3 border-b border-neutral-200">
125
- <div className="flex items-center gap-2">
126
- <div className="size-2 bg-green-500 rounded-full animate-pulse shadow-sm shadow-green-500/50"></div>
127
- <span className="text-xs font-medium text-neutral-800">
128
- Live Preview
129
- </span>
130
- {isAiWorking && (
131
- <span className="text-xs text-green-600 font-medium animate-pulse">
132
- • Updating
133
- </span>
134
- )}
135
- </div>
136
- <div className="flex items-center gap-1">
137
- <Button
138
- variant="outline"
139
- size="iconXs"
140
- className="!rounded-md !border-neutral-200 hover:bg-neutral-50"
141
- onClick={() => setIsMaximized(!isMaximized)}
142
- >
143
- {isMaximized ? (
144
- <Minimize className="text-neutral-400 size-3" />
145
- ) : (
146
- <Maximize className="text-neutral-400 size-3" />
147
- )}
148
- </Button>
149
- </div>
150
- </div>
151
- <div className="flex-1 bg-black overflow-hidden relative rounded-b-xl">
152
- <iframe
153
- className="w-full h-full border-0"
154
- srcDoc={displayedHtml}
155
- sandbox="allow-scripts allow-same-origin"
156
- title="Live Preview"
157
- />
158
- </div>
159
- </div>
160
- </div>
161
- );
162
- }
163
- );
164
-
165
- LivePreview.displayName = "LivePreview";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
components/editor/preview/index.tsx CHANGED
@@ -1,6 +1,6 @@
1
  "use client";
2
 
3
- import { useRef, useState, useEffect, forwardRef } from "react";
4
  import { useUpdateEffect } from "react-use";
5
  import classNames from "classnames";
6
 
@@ -12,236 +12,266 @@ import { htmlTagToText } from "@/lib/html-tag-to-text";
12
  import { AnimatedBlobs } from "@/components/animated-blobs";
13
  import { AiLoading } from "../ask-ai/loading";
14
  import { defaultHTML } from "@/lib/consts";
15
- import { Button } from "@/components/ui/button";
16
- import { LivePreview, LivePreviewRef } from "../live-preview";
17
  import { HistoryNotification } from "../history-notification";
18
- import { AlertCircle } from "lucide-react";
19
  import { api } from "@/lib/api";
20
  import { toast } from "sonner";
21
- import Loading from "@/components/loading";
22
 
23
- export const Preview = forwardRef<LivePreviewRef, { isNew: boolean }>(
24
- ({ isNew }, ref) => {
25
- const {
26
- project,
27
- device,
28
- isLoadingProject,
29
- currentTab,
30
- currentCommit,
31
- setCurrentCommit,
32
- currentPageData,
33
- pages,
34
- setPages,
35
- setCurrentPage,
36
- isSameHtml,
37
- } = useEditor();
38
- const {
39
- isEditableModeEnabled,
40
- setSelectedElement,
41
- isAiWorking,
42
- globalAiLoading,
43
- } = useAi();
44
 
45
- const iframeRef = useRef<HTMLIFrameElement>(null);
46
 
47
- const [hoveredElement, setHoveredElement] = useState<{
48
- tagName: string;
49
- rect: { top: number; left: number; width: number; height: number };
50
- } | null>(null);
51
- const [isPromotingVersion, setIsPromotingVersion] = useState(false);
52
- const [stableHtml, setStableHtml] = useState<string>("");
 
 
53
 
54
- useEffect(() => {
55
- if (!isAiWorking && !globalAiLoading && currentPageData?.html) {
56
- setStableHtml(currentPageData.html);
57
- }
58
- }, [isAiWorking, globalAiLoading, currentPageData?.html]);
59
 
60
- useEffect(() => {
61
- if (
62
- currentPageData?.html &&
63
- !stableHtml &&
64
- !isAiWorking &&
65
- !globalAiLoading
66
- ) {
67
- setStableHtml(currentPageData.html);
 
 
 
 
68
  }
69
- }, [currentPageData?.html, stableHtml, isAiWorking, globalAiLoading]);
 
70
 
71
- useUpdateEffect(() => {
72
- const cleanupListeners = () => {
73
- if (iframeRef?.current?.contentDocument) {
74
- const iframeDocument = iframeRef.current.contentDocument;
75
- iframeDocument.removeEventListener("mouseover", handleMouseOver);
76
- iframeDocument.removeEventListener("mouseout", handleMouseOut);
77
- iframeDocument.removeEventListener("click", handleClick);
78
- }
79
- };
 
 
 
 
 
 
 
80
 
81
- if (iframeRef?.current) {
 
 
82
  const iframeDocument = iframeRef.current.contentDocument;
83
- if (iframeDocument) {
84
- cleanupListeners();
 
 
 
85
 
86
- if (isEditableModeEnabled) {
87
- iframeDocument.addEventListener("mouseover", handleMouseOver);
88
- iframeDocument.addEventListener("mouseout", handleMouseOut);
89
- iframeDocument.addEventListener("click", handleClick);
90
- }
 
 
 
 
91
  }
92
  }
 
93
 
94
- return cleanupListeners;
95
- }, [iframeRef, isEditableModeEnabled]);
96
 
97
- const promoteVersion = async () => {
98
- setIsPromotingVersion(true);
99
- await api
100
- .post(
101
- `/me/projects/${project?.space_id}/commits/${currentCommit}/promote`
102
- )
103
- .then((res) => {
104
- if (res.data.ok) {
105
- setCurrentCommit(null);
106
- setPages(res.data.pages);
107
- setCurrentPage(res.data.pages[0].path);
108
- toast.success("Version promoted successfully");
109
- }
110
- })
111
- .catch((err) => {
112
- toast.error(err.response.data.error);
113
- });
114
- setIsPromotingVersion(false);
115
- };
116
 
117
- const handleMouseOver = (event: MouseEvent) => {
118
- if (iframeRef?.current) {
119
- const iframeDocument = iframeRef.current.contentDocument;
120
- if (iframeDocument) {
121
- const targetElement = event.target as HTMLElement;
122
- if (
123
- hoveredElement?.tagName !== targetElement.tagName ||
124
- hoveredElement?.rect.top !==
125
- targetElement.getBoundingClientRect().top ||
126
- hoveredElement?.rect.left !==
127
- targetElement.getBoundingClientRect().left ||
128
- hoveredElement?.rect.width !==
129
- targetElement.getBoundingClientRect().width ||
130
- hoveredElement?.rect.height !==
131
- targetElement.getBoundingClientRect().height
132
- ) {
133
- if (targetElement !== iframeDocument.body) {
134
- const rect = targetElement.getBoundingClientRect();
135
- setHoveredElement({
136
- tagName: targetElement.tagName,
137
- rect: {
138
- top: rect.top,
139
- left: rect.left,
140
- width: rect.width,
141
- height: rect.height,
142
- },
143
- });
144
- targetElement.classList.add("hovered-element");
145
- } else {
146
- return setHoveredElement(null);
147
- }
148
  }
149
  }
150
  }
151
- };
152
- const handleMouseOut = () => {
153
- setHoveredElement(null);
154
- };
155
- const handleClick = (event: MouseEvent) => {
156
- if (iframeRef?.current) {
157
- const iframeDocument = iframeRef.current.contentDocument;
158
- if (iframeDocument) {
159
- const targetElement = event.target as HTMLElement;
160
- if (targetElement !== iframeDocument.body) {
161
- setSelectedElement(targetElement);
162
- }
163
  }
164
  }
165
- };
 
166
 
167
- const handleCustomNavigation = (event: MouseEvent) => {
168
- if (iframeRef?.current) {
169
- const iframeDocument = iframeRef.current.contentDocument;
170
- if (iframeDocument) {
171
- const findClosestAnchor = (
172
- element: HTMLElement
173
- ): HTMLAnchorElement | null => {
174
- let current = element;
175
- while (current && current !== iframeDocument.body) {
176
- if (current.tagName === "A") {
177
- return current as HTMLAnchorElement;
178
- }
179
- current = current.parentElement as HTMLElement;
180
  }
181
- return null;
182
- };
 
 
183
 
184
- const anchorElement = findClosestAnchor(event.target as HTMLElement);
185
- if (anchorElement) {
186
- let href = anchorElement.getAttribute("href");
187
- if (href) {
188
- event.stopPropagation();
189
- event.preventDefault();
190
 
191
- if (href.includes("#") && !href.includes(".html")) {
192
- const targetElement = iframeDocument.querySelector(href);
193
- if (targetElement) {
194
- targetElement.scrollIntoView({ behavior: "smooth" });
195
- }
196
- return;
197
  }
 
 
198
 
199
- href = href.split(".html")[0] + ".html";
200
- const isPageExist = pages.some((page) => page.path === href);
201
- if (isPageExist) {
202
- setCurrentPage(href);
203
- }
204
  }
205
  }
206
  }
207
  }
208
- };
 
209
 
210
- return (
211
- <div
212
- className={classNames(
213
- "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",
214
- {
215
- "max-lg:h-0 overflow-hidden": currentTab === "chat",
216
- "max-lg:h-full": currentTab === "preview",
217
- }
 
 
 
 
 
 
 
 
218
  )}
219
- >
220
- <GridPattern
221
- x={-1}
222
- y={-1}
223
- strokeDasharray={"4 2"}
224
- className={cn(
225
- "[mask-image:radial-gradient(900px_circle_at_center,white,transparent)] opacity-40"
226
- )}
227
- />
228
- {!isAiWorking && hoveredElement && isEditableModeEnabled && (
229
- <div
230
- 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"
231
- style={{
232
- top: hoveredElement.rect.top,
233
- left: hoveredElement.rect.left,
234
- width: hoveredElement.rect.width,
235
- height: hoveredElement.rect.height,
236
- }}
237
- >
238
- <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">
239
- {htmlTagToText(hoveredElement.tagName.toLowerCase())}
240
- </span>
241
  </div>
242
- )}
243
- {isNew && !isLoadingProject && !globalAiLoading && isSameHtml ? (
 
244
  <iframe
 
 
245
  className={classNames(
246
  "w-full select-none transition-all duration-200 bg-black h-full",
247
  {
@@ -249,110 +279,80 @@ export const Preview = forwardRef<LivePreviewRef, { isNew: boolean }>(
249
  device === "mobile",
250
  }
251
  )}
252
- srcDoc={defaultHTML}
253
- />
254
- ) : (isNew && globalAiLoading) || isLoadingProject ? (
255
- <div className="w-full h-full flex items-center justify-center relative">
256
- <div className="py-10 w-full relative z-1 max-w-3xl mx-auto text-center">
257
- <AiLoading
258
- text={isLoadingProject ? "Fetching your project..." : undefined}
259
- className="flex-col"
260
- />
261
- <AnimatedBlobs />
262
- <AnimatedBlobs />
263
- </div>
264
- {!isLoadingProject && (
265
- <LivePreview
266
- ref={ref}
267
- currentPageData={currentPageData}
268
- isAiWorking={isAiWorking}
269
- defaultHTML={defaultHTML}
270
- className="bottom-4 left-4"
271
- />
272
- )}
273
- </div>
274
- ) : (
275
- <>
276
- <iframe
277
- id="preview-iframe"
278
- ref={iframeRef}
279
- className={classNames(
280
- "w-full select-none transition-all duration-200 bg-black h-full",
281
- {
282
- "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]":
283
- device === "mobile",
284
- }
285
- )}
286
- src={
287
- currentCommit
288
- ? `https://${project?.space_id?.replaceAll(
289
- "/",
290
- "-"
291
- )}--rev-${currentCommit.slice(0, 7)}.static.hf.space`
292
- : undefined
293
- }
294
- srcDoc={!currentCommit ? stableHtml : undefined}
295
- onLoad={
296
- !currentCommit
297
- ? () => {
298
- if (iframeRef?.current?.contentWindow?.document?.body) {
299
- iframeRef.current.contentWindow.document.body.scrollIntoView(
300
- {
301
- block: isAiWorking ? "end" : "start",
302
- inline: "nearest",
303
- behavior: isAiWorking ? "instant" : "smooth",
304
- }
305
  );
306
- }
307
- // add event listener to all links in the iframe to handle navigation
308
- if (iframeRef?.current?.contentWindow?.document) {
309
- const links =
310
- iframeRef.current.contentWindow.document.querySelectorAll(
311
- "a"
312
- );
313
- links.forEach((link) => {
314
- link.addEventListener(
315
- "click",
316
- handleCustomNavigation
317
- );
318
- });
319
- }
320
  }
321
- : undefined
322
- }
323
- sandbox="allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox"
324
- 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"
325
- />
326
- <div
327
- className={classNames(
328
- "w-full h-full flex items-center justify-center absolute left-0 top-0 bg-black/40 backdrop-blur-lg transition-all duration-200",
329
- {
330
- "opacity-0 pointer-events-none": !globalAiLoading,
331
- }
332
- )}
333
- >
334
- <div className="py-10 w-full relative z-1 max-w-3xl mx-auto text-center">
335
- <AiLoading
336
- text={
337
- isLoadingProject ? "Fetching your project..." : undefined
338
  }
339
- className="flex-col"
340
- />
341
- <AnimatedBlobs />
342
- <AnimatedBlobs />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
343
  </div>
344
- </div>
345
- <HistoryNotification
346
- isVisible={!!currentCommit}
347
- isPromotingVersion={isPromotingVersion}
348
- onPromoteVersion={promoteVersion}
349
- onGoBackToCurrent={() => setCurrentCommit(null)}
350
- />
351
- </>
352
- )}
353
- </div>
354
- );
355
- }
356
- );
357
-
358
- Preview.displayName = "Preview";
 
1
  "use client";
2
 
3
+ import { useRef, useState, useEffect } from "react";
4
  import { useUpdateEffect } from "react-use";
5
  import classNames from "classnames";
6
 
 
12
  import { AnimatedBlobs } from "@/components/animated-blobs";
13
  import { AiLoading } from "../ask-ai/loading";
14
  import { defaultHTML } from "@/lib/consts";
 
 
15
  import { HistoryNotification } from "../history-notification";
 
16
  import { api } from "@/lib/api";
17
  import { toast } from "sonner";
 
18
 
19
+ export const Preview = ({ isNew }: { isNew: boolean }) => {
20
+ const {
21
+ project,
22
+ device,
23
+ isLoadingProject,
24
+ currentTab,
25
+ currentCommit,
26
+ setCurrentCommit,
27
+ currentPageData,
28
+ pages,
29
+ setPages,
30
+ setCurrentPage,
31
+ isSameHtml,
32
+ } = useEditor();
33
+ const {
34
+ isEditableModeEnabled,
35
+ setSelectedElement,
36
+ isAiWorking,
37
+ globalAiLoading,
38
+ } = useAi();
 
39
 
40
+ const iframeRef = useRef<HTMLIFrameElement>(null);
41
 
42
+ const [hoveredElement, setHoveredElement] = useState<{
43
+ tagName: string;
44
+ rect: { top: number; left: number; width: number; height: number };
45
+ } | null>(null);
46
+ const [isPromotingVersion, setIsPromotingVersion] = useState(false);
47
+ const [stableHtml, setStableHtml] = useState<string>("");
48
+ const [throttledHtml, setThrottledHtml] = useState<string>("");
49
+ const lastUpdateTimeRef = useRef<number>(0);
50
 
51
+ // For new projects, throttle HTML updates to every 3 seconds
52
+ useEffect(() => {
53
+ if (isNew && currentPageData?.html) {
54
+ const now = Date.now();
55
+ const timeSinceLastUpdate = now - lastUpdateTimeRef.current;
56
 
57
+ // If this is the first update or 3 seconds have passed, update immediately
58
+ if (lastUpdateTimeRef.current === 0 || timeSinceLastUpdate >= 3000) {
59
+ setThrottledHtml(currentPageData.html);
60
+ lastUpdateTimeRef.current = now;
61
+ } else {
62
+ // Otherwise, schedule an update for when 3 seconds will have passed
63
+ const timeUntilNextUpdate = 3000 - timeSinceLastUpdate;
64
+ const timer = setTimeout(() => {
65
+ setThrottledHtml(currentPageData.html);
66
+ lastUpdateTimeRef.current = Date.now();
67
+ }, timeUntilNextUpdate);
68
+ return () => clearTimeout(timer);
69
  }
70
+ }
71
+ }, [isNew, currentPageData?.html]);
72
 
73
+ useEffect(() => {
74
+ if (!isAiWorking && !globalAiLoading && currentPageData?.html) {
75
+ setStableHtml(currentPageData.html);
76
+ }
77
+ }, [isAiWorking, globalAiLoading, currentPageData?.html]);
78
+
79
+ useEffect(() => {
80
+ if (
81
+ currentPageData?.html &&
82
+ !stableHtml &&
83
+ !isAiWorking &&
84
+ !globalAiLoading
85
+ ) {
86
+ setStableHtml(currentPageData.html);
87
+ }
88
+ }, [currentPageData?.html, stableHtml, isAiWorking, globalAiLoading]);
89
 
90
+ useUpdateEffect(() => {
91
+ const cleanupListeners = () => {
92
+ if (iframeRef?.current?.contentDocument) {
93
  const iframeDocument = iframeRef.current.contentDocument;
94
+ iframeDocument.removeEventListener("mouseover", handleMouseOver);
95
+ iframeDocument.removeEventListener("mouseout", handleMouseOut);
96
+ iframeDocument.removeEventListener("click", handleClick);
97
+ }
98
+ };
99
 
100
+ if (iframeRef?.current) {
101
+ const iframeDocument = iframeRef.current.contentDocument;
102
+ if (iframeDocument) {
103
+ cleanupListeners();
104
+
105
+ if (isEditableModeEnabled) {
106
+ iframeDocument.addEventListener("mouseover", handleMouseOver);
107
+ iframeDocument.addEventListener("mouseout", handleMouseOut);
108
+ iframeDocument.addEventListener("click", handleClick);
109
  }
110
  }
111
+ }
112
 
113
+ return cleanupListeners;
114
+ }, [iframeRef, isEditableModeEnabled]);
115
 
116
+ const promoteVersion = async () => {
117
+ setIsPromotingVersion(true);
118
+ await api
119
+ .post(
120
+ `/me/projects/${project?.space_id}/commits/${currentCommit}/promote`
121
+ )
122
+ .then((res) => {
123
+ if (res.data.ok) {
124
+ setCurrentCommit(null);
125
+ setPages(res.data.pages);
126
+ setCurrentPage(res.data.pages[0].path);
127
+ toast.success("Version promoted successfully");
128
+ }
129
+ })
130
+ .catch((err) => {
131
+ toast.error(err.response.data.error);
132
+ });
133
+ setIsPromotingVersion(false);
134
+ };
135
 
136
+ const handleMouseOver = (event: MouseEvent) => {
137
+ if (iframeRef?.current) {
138
+ const iframeDocument = iframeRef.current.contentDocument;
139
+ if (iframeDocument) {
140
+ const targetElement = event.target as HTMLElement;
141
+ if (
142
+ hoveredElement?.tagName !== targetElement.tagName ||
143
+ hoveredElement?.rect.top !==
144
+ targetElement.getBoundingClientRect().top ||
145
+ hoveredElement?.rect.left !==
146
+ targetElement.getBoundingClientRect().left ||
147
+ hoveredElement?.rect.width !==
148
+ targetElement.getBoundingClientRect().width ||
149
+ hoveredElement?.rect.height !==
150
+ targetElement.getBoundingClientRect().height
151
+ ) {
152
+ if (targetElement !== iframeDocument.body) {
153
+ const rect = targetElement.getBoundingClientRect();
154
+ setHoveredElement({
155
+ tagName: targetElement.tagName,
156
+ rect: {
157
+ top: rect.top,
158
+ left: rect.left,
159
+ width: rect.width,
160
+ height: rect.height,
161
+ },
162
+ });
163
+ targetElement.classList.add("hovered-element");
164
+ } else {
165
+ return setHoveredElement(null);
 
166
  }
167
  }
168
  }
169
+ }
170
+ };
171
+ const handleMouseOut = () => {
172
+ setHoveredElement(null);
173
+ };
174
+ const handleClick = (event: MouseEvent) => {
175
+ if (iframeRef?.current) {
176
+ const iframeDocument = iframeRef.current.contentDocument;
177
+ if (iframeDocument) {
178
+ const targetElement = event.target as HTMLElement;
179
+ if (targetElement !== iframeDocument.body) {
180
+ setSelectedElement(targetElement);
181
  }
182
  }
183
+ }
184
+ };
185
 
186
+ const handleCustomNavigation = (event: MouseEvent) => {
187
+ if (iframeRef?.current) {
188
+ const iframeDocument = iframeRef.current.contentDocument;
189
+ if (iframeDocument) {
190
+ const findClosestAnchor = (
191
+ element: HTMLElement
192
+ ): HTMLAnchorElement | null => {
193
+ let current = element;
194
+ while (current && current !== iframeDocument.body) {
195
+ if (current.tagName === "A") {
196
+ return current as HTMLAnchorElement;
 
 
197
  }
198
+ current = current.parentElement as HTMLElement;
199
+ }
200
+ return null;
201
+ };
202
 
203
+ const anchorElement = findClosestAnchor(event.target as HTMLElement);
204
+ if (anchorElement) {
205
+ let href = anchorElement.getAttribute("href");
206
+ if (href) {
207
+ event.stopPropagation();
208
+ event.preventDefault();
209
 
210
+ if (href.includes("#") && !href.includes(".html")) {
211
+ const targetElement = iframeDocument.querySelector(href);
212
+ if (targetElement) {
213
+ targetElement.scrollIntoView({ behavior: "smooth" });
 
 
214
  }
215
+ return;
216
+ }
217
 
218
+ href = href.split(".html")[0] + ".html";
219
+ const isPageExist = pages.some((page) => page.path === href);
220
+ if (isPageExist) {
221
+ setCurrentPage(href);
 
222
  }
223
  }
224
  }
225
  }
226
+ }
227
+ };
228
 
229
+ return (
230
+ <div
231
+ className={classNames(
232
+ "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",
233
+ {
234
+ "max-lg:h-0 overflow-hidden": currentTab === "chat",
235
+ "max-lg:h-full": currentTab === "preview",
236
+ }
237
+ )}
238
+ >
239
+ <GridPattern
240
+ x={-1}
241
+ y={-1}
242
+ strokeDasharray={"4 2"}
243
+ className={cn(
244
+ "[mask-image:radial-gradient(900px_circle_at_center,white,transparent)] opacity-40"
245
  )}
246
+ />
247
+ {!isAiWorking && hoveredElement && isEditableModeEnabled && (
248
+ <div
249
+ 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"
250
+ style={{
251
+ top: hoveredElement.rect.top,
252
+ left: hoveredElement.rect.left,
253
+ width: hoveredElement.rect.width,
254
+ height: hoveredElement.rect.height,
255
+ }}
256
+ >
257
+ <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">
258
+ {htmlTagToText(hoveredElement.tagName.toLowerCase())}
259
+ </span>
260
+ </div>
261
+ )}
262
+ {isLoadingProject ? (
263
+ <div className="w-full h-full flex items-center justify-center relative">
264
+ <div className="py-10 w-full relative z-1 max-w-3xl mx-auto text-center">
265
+ <AiLoading text="Fetching your project..." className="flex-col" />
266
+ <AnimatedBlobs />
267
+ <AnimatedBlobs />
268
  </div>
269
+ </div>
270
+ ) : (
271
+ <>
272
  <iframe
273
+ id="preview-iframe"
274
+ ref={iframeRef}
275
  className={classNames(
276
  "w-full select-none transition-all duration-200 bg-black h-full",
277
  {
 
279
  device === "mobile",
280
  }
281
  )}
282
+ src={
283
+ currentCommit
284
+ ? `https://${project?.space_id?.replaceAll(
285
+ "/",
286
+ "-"
287
+ )}--rev-${currentCommit.slice(0, 7)}.static.hf.space`
288
+ : undefined
289
+ }
290
+ srcDoc={
291
+ !currentCommit
292
+ ? isNew
293
+ ? throttledHtml || defaultHTML
294
+ : stableHtml
295
+ : undefined
296
+ }
297
+ onLoad={
298
+ !currentCommit
299
+ ? () => {
300
+ if (iframeRef?.current?.contentWindow?.document?.body) {
301
+ iframeRef.current.contentWindow.document.body.scrollIntoView(
302
+ {
303
+ block: isAiWorking ? "end" : "start",
304
+ inline: "nearest",
305
+ behavior: isAiWorking ? "instant" : "smooth",
306
+ }
307
+ );
308
+ }
309
+ // add event listener to all links in the iframe to handle navigation
310
+ if (iframeRef?.current?.contentWindow?.document) {
311
+ const links =
312
+ iframeRef.current.contentWindow.document.querySelectorAll(
313
+ "a"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
314
  );
315
+ links.forEach((link) => {
316
+ link.addEventListener("click", handleCustomNavigation);
317
+ });
 
 
 
 
 
 
 
 
 
 
 
318
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
319
  }
320
+ : undefined
321
+ }
322
+ sandbox="allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox"
323
+ 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"
324
+ />
325
+ {!isNew && (
326
+ <>
327
+ <div
328
+ className={classNames(
329
+ "w-full h-full flex items-center justify-center absolute left-0 top-0 bg-black/40 backdrop-blur-lg transition-all duration-200",
330
+ {
331
+ "opacity-0 pointer-events-none": !globalAiLoading,
332
+ }
333
+ )}
334
+ >
335
+ <div className="py-10 w-full relative z-1 max-w-3xl mx-auto text-center">
336
+ <AiLoading
337
+ text={
338
+ isLoadingProject ? "Fetching your project..." : undefined
339
+ }
340
+ className="flex-col"
341
+ />
342
+ <AnimatedBlobs />
343
+ <AnimatedBlobs />
344
+ </div>
345
  </div>
346
+ <HistoryNotification
347
+ isVisible={!!currentCommit}
348
+ isPromotingVersion={isPromotingVersion}
349
+ onPromoteVersion={promoteVersion}
350
+ onGoBackToCurrent={() => setCurrentCommit(null)}
351
+ />
352
+ </>
353
+ )}
354
+ </>
355
+ )}
356
+ </div>
357
+ );
358
+ };
 
 
hooks/useAi.ts CHANGED
@@ -9,10 +9,9 @@ import { Page, EnhancedSettings } from "@/types";
9
  import { api } from "@/lib/api";
10
  import { useRouter } from "next/navigation";
11
  import { useUser } from "./useUser";
12
- import { LivePreviewRef } from "@/components/editor/live-preview";
13
  import { isTheSameHtml } from "@/lib/compare-html-diff";
14
 
15
- export const useAi = (onScrollToBottom?: () => void, livePreviewRef?: React.RefObject<LivePreviewRef | null>) => {
16
  const client = useQueryClient();
17
  const audio = useRef<HTMLAudioElement | null>(null);
18
  const { setPages, setCurrentPage, setPrompts, prompts, pages, project, setProject, commits, setCommits, setLastSavedPages, isSameHtml } = useEditor();
@@ -121,10 +120,6 @@ export const useAi = (onScrollToBottom?: () => void, livePreviewRef?: React.RefO
121
  });
122
  if (response.data.ok) {
123
  setIsAiWorking(false);
124
- // Reset live preview when project is created
125
- if (livePreviewRef?.current) {
126
- livePreviewRef.current.reset();
127
- }
128
  router.replace(`/${response.data.space.project.space_id}`);
129
  setProject(response.data.space);
130
  setProjects([...projects, response.data.space]);
@@ -133,9 +128,6 @@ export const useAi = (onScrollToBottom?: () => void, livePreviewRef?: React.RefO
133
  }
134
  } else {
135
  setIsAiWorking(false);
136
- if (livePreviewRef?.current) {
137
- livePreviewRef.current.reset();
138
- }
139
  toast.success("AI responded successfully");
140
  if (audio.current) audio.current.play();
141
  }
@@ -479,10 +471,6 @@ export const useAi = (onScrollToBottom?: () => void, livePreviewRef?: React.RefO
479
  }
480
  setIsAiWorking(false);
481
  setIsThinking(false);
482
- // Reset live preview when request is aborted
483
- if (livePreviewRef?.current) {
484
- livePreviewRef.current.reset();
485
- }
486
  };
487
 
488
  const selectedModel = useMemo(() => {
 
9
  import { api } from "@/lib/api";
10
  import { useRouter } from "next/navigation";
11
  import { useUser } from "./useUser";
 
12
  import { isTheSameHtml } from "@/lib/compare-html-diff";
13
 
14
+ export const useAi = (onScrollToBottom?: () => void) => {
15
  const client = useQueryClient();
16
  const audio = useRef<HTMLAudioElement | null>(null);
17
  const { setPages, setCurrentPage, setPrompts, prompts, pages, project, setProject, commits, setCommits, setLastSavedPages, isSameHtml } = useEditor();
 
120
  });
121
  if (response.data.ok) {
122
  setIsAiWorking(false);
 
 
 
 
123
  router.replace(`/${response.data.space.project.space_id}`);
124
  setProject(response.data.space);
125
  setProjects([...projects, response.data.space]);
 
128
  }
129
  } else {
130
  setIsAiWorking(false);
 
 
 
131
  toast.success("AI responded successfully");
132
  if (audio.current) audio.current.play();
133
  }
 
471
  }
472
  setIsAiWorking(false);
473
  setIsThinking(false);
 
 
 
 
474
  };
475
 
476
  const selectedModel = useMemo(() => {