diff --git a/e2e-tests/annotator.spec.ts b/e2e-tests/annotator.spec.ts new file mode 100644 index 0000000..b535e1b --- /dev/null +++ b/e2e-tests/annotator.spec.ts @@ -0,0 +1,75 @@ +import { testSkipIfWindows } from "./helpers/test_helper"; +import { expect } from "@playwright/test"; +import fs from "fs"; + +testSkipIfWindows( + "annotator - capture and submit screenshot", + async ({ po }) => { + await po.setUpDyadPro({ autoApprove: true }); + + // Create a basic app + await po.sendPrompt("basic"); + + // Click the annotator button to activate annotator mode + await po.clickPreviewAnnotatorButton(); + + // Wait for annotator mode to be active + await po.waitForAnnotatorMode(); + + // Submit the screenshot to chat + await po.clickAnnotatorSubmit(); + + await expect(po.getChatInput()).toContainText( + "Please update the UI based on these screenshots", + ); + + // Verify the screenshot was attached to chat context + await po.sendPrompt("[dump]"); + + // Wait for the LLM response containing the dump path to appear in the UI + // before attempting to extract it from the messages list + await po.page.waitForSelector("text=/\\[\\[dyad-dump-path=.*\\]\\]/"); + + // Get the dump file path from the messages list + const messagesListText = await po.page + .getByTestId("messages-list") + .textContent(); + const dumpPathMatch = messagesListText?.match( + /\[\[dyad-dump-path=([^\]]+)\]\]/, + ); + + if (!dumpPathMatch) { + throw new Error("No dump path found in messages list"); + } + + const dumpFilePath = dumpPathMatch[1]; + const dumpContent = fs.readFileSync(dumpFilePath, "utf-8"); + const parsedDump = JSON.parse(dumpContent); + + // Get the last message from the dump + const messages = parsedDump.body.messages; + const lastMessage = messages[messages.length - 1]; + + expect(lastMessage).toBeTruthy(); + expect(lastMessage.content).toBeTruthy(); + + // The content is an array with text and image parts + expect(Array.isArray(lastMessage.content)).toBe(true); + + // Find the text part and verify it mentions the PNG attachment + const textPart = lastMessage.content.find( + (part: any) => part.type === "text", + ); + expect(textPart).toBeTruthy(); + expect(textPart.text).toMatch(/annotated-screenshot-.*\.png/); + expect(textPart.text).toMatch(/image\/png/); + + // Find the image part and verify it has the correct structure + const imagePart = lastMessage.content.find( + (part: any) => part.type === "image_url", + ); + expect(imagePart).toBeTruthy(); + expect(imagePart.image_url).toBeTruthy(); + expect(imagePart.image_url.url).toMatch(/^data:image\/png;base64,/); + }, +); diff --git a/e2e-tests/helpers/test_helper.ts b/e2e-tests/helpers/test_helper.ts index 6abe9ba..2fcdf1d 100644 --- a/e2e-tests/helpers/test_helper.ts +++ b/e2e-tests/helpers/test_helper.ts @@ -553,6 +553,22 @@ export class PageObject { await this.page.getByTestId("preview-open-browser-button").click(); } + async clickPreviewAnnotatorButton() { + await this.page + .getByTestId("preview-annotator-button") + .click({ timeout: Timeout.EXTRA_LONG }); + } + + async waitForAnnotatorMode() { + // Wait for the annotator toolbar to be visible + await expect(this.page.getByRole("button", { name: "Select" })).toBeVisible( + { timeout: Timeout.MEDIUM }, + ); + } + + async clickAnnotatorSubmit() { + await this.page.getByRole("button", { name: "Add to Chat" }).click(); + } locateLoadingAppPreview() { return this.page.getByText("Preparing app preview..."); } diff --git a/forge.config.ts b/forge.config.ts index 8748e0c..9024217 100644 --- a/forge.config.ts +++ b/forge.config.ts @@ -32,6 +32,9 @@ const ignore = (file: string) => { if (file.startsWith("/node_modules/stacktrace-js/dist")) { return false; } + if (file.startsWith("/node_modules/html-to-image")) { + return false; + } if (file.startsWith("/node_modules/better-sqlite3")) { return false; } diff --git a/package-lock.json b/package-lock.json index 32846ea..1ca63b1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -69,17 +69,21 @@ "framer-motion": "^12.6.3", "geist": "^1.3.1", "glob": "^11.0.2", + "html-to-image": "^1.11.13", "isomorphic-git": "^1.30.1", "jotai": "^2.12.2", "kill-port": "^2.0.1", + "konva": "^10.0.12", "lexical": "^0.33.1", "lexical-beautiful-mentions": "^0.1.47", "lucide-react": "^0.487.0", "monaco-editor": "^0.52.2", "openai": "^4.91.1", + "perfect-freehand": "^1.2.2", "posthog-js": "^1.236.3", - "react": "^19.2.1", - "react-dom": "^19.2.1", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-konva": "^19.2.1", "react-markdown": "^10.1.0", "react-resizable-panels": "^2.1.7", "react-shiki": "^0.9.0", @@ -7061,6 +7065,15 @@ "@types/react": "^19.0.0" } }, + "node_modules/@types/react-reconciler": { + "version": "0.32.3", + "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.32.3.tgz", + "integrity": "sha512-cMi5ZrLG7UtbL7LTK6hq9w/EZIRk4Mf1Z5qHoI+qBh7/WkYkFXQ7gOto2yfUvPzF5ERMAhaXS5eTQ2SAnHjLzA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*" + } + }, "node_modules/@types/responselike": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz", @@ -12885,6 +12898,43 @@ "dev": true, "license": "ISC" }, + "node_modules/html-dom-parser": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/html-dom-parser/-/html-dom-parser-5.1.1.tgz", + "integrity": "sha512-+o4Y4Z0CLuyemeccvGN4bAO20aauB2N9tFEAep5x4OW34kV4PTarBHm6RL02afYt2BMKcr0D2Agep8S3nJPIBg==", + "license": "MIT", + "dependencies": { + "domhandler": "5.0.3", + "htmlparser2": "10.0.0" + } + }, + "node_modules/html-react-parser": { + "version": "5.2.6", + "resolved": "https://registry.npmjs.org/html-react-parser/-/html-react-parser-5.2.6.tgz", + "integrity": "sha512-qcpPWLaSvqXi+TndiHbCa+z8qt0tVzjMwFGFBAa41ggC+ZA5BHaMIeMJla9g3VSp4SmiZb9qyQbmbpHYpIfPOg==", + "license": "MIT", + "dependencies": { + "domhandler": "5.0.3", + "html-dom-parser": "5.1.1", + "react-property": "2.0.2", + "style-to-js": "1.1.17" + }, + "peerDependencies": { + "@types/react": "0.14 || 15 || 16 || 17 || 18 || 19", + "react": "0.14 || 15 || 16 || 17 || 18 || 19" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/html-to-image": { + "version": "1.11.13", + "resolved": "https://registry.npmjs.org/html-to-image/-/html-to-image-1.11.13.tgz", + "integrity": "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==", + "license": "MIT" + }, "node_modules/html-url-attributes": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", @@ -13798,6 +13848,27 @@ "url": "https://github.com/sponsors/dmonad" } }, + "node_modules/its-fine": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/its-fine/-/its-fine-2.0.0.tgz", + "integrity": "sha512-KLViCmWx94zOvpLwSlsx6yOCeMhZYaxrJV87Po5k/FoZzcPSahvK5qJ7fYhS61sZi5ikmh2S3Hz55A2l3U69ng==", + "license": "MIT", + "dependencies": { + "@types/react-reconciler": "^0.28.9" + }, + "peerDependencies": { + "react": "^19.0.0" + } + }, + "node_modules/its-fine/node_modules/@types/react-reconciler": { + "version": "0.28.9", + "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.28.9.tgz", + "integrity": "sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*" + } + }, "node_modules/jackspeak": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.1.1.tgz", @@ -14010,6 +14081,26 @@ "integrity": "sha512-jyVd+kU2X+mWKMmGhx4fpWbPsjvD53k9ivqetutVW/BQ+WIZoDoP4d8vUMGezV6saZsiNoW2f9GIhg9Dondohg==", "license": "MIT" }, + "node_modules/konva": { + "version": "10.0.12", + "resolved": "https://registry.npmjs.org/konva/-/konva-10.0.12.tgz", + "integrity": "sha512-DHmkeG5FbW6tLCkbMQTi1ihWycfzljrn0V7umUUuewxx7aoINcI71ksgBX9fTPNXhlsK4/JoMgKwI/iCde+BRw==", + "funding": [ + { + "type": "patreon", + "url": "https://www.patreon.com/lavrton" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/konva" + }, + { + "type": "github", + "url": "https://github.com/sponsors/lavrton" + } + ], + "license": "MIT" + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -17170,6 +17261,12 @@ "dev": true, "license": "MIT" }, + "node_modules/perfect-freehand": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/perfect-freehand/-/perfect-freehand-1.2.2.tgz", + "integrity": "sha512-eh31l019WICQ03pkF3FSzHxB8n07ItqIQ++G5UV8JX0zVOXzgTGCqnRR0jJ2h9U8/2uW4W4mtGJELt9kEV0CFQ==", + "license": "MIT" + }, "node_modules/pg-int8": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", @@ -17797,6 +17894,37 @@ "license": "MIT", "peer": true }, + "node_modules/react-konva": { + "version": "19.2.1", + "resolved": "https://registry.npmjs.org/react-konva/-/react-konva-19.2.1.tgz", + "integrity": "sha512-sqZWCzQGpdMrU5aeunR0oxUY8UeCPbU8gnAYxMtAn6BT4coeSpiATKOctsoxRu6F56TAcF+s0c6Lul9ansNqQA==", + "funding": [ + { + "type": "patreon", + "url": "https://www.patreon.com/lavrton" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/konva" + }, + { + "type": "github", + "url": "https://github.com/sponsors/lavrton" + } + ], + "license": "MIT", + "dependencies": { + "@types/react-reconciler": "^0.32.3", + "its-fine": "^2.0.0", + "react-reconciler": "0.33.0", + "scheduler": "0.27.0" + }, + "peerDependencies": { + "konva": "^8.0.1 || ^7.2.5 || ^9.0.0 || ^10.0.0", + "react": "^19.2.0", + "react-dom": "^19.2.0" + } + }, "node_modules/react-markdown": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", @@ -17824,6 +17952,27 @@ "react": ">=18" } }, + "node_modules/react-property": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/react-property/-/react-property-2.0.2.tgz", + "integrity": "sha512-+PbtI3VuDV0l6CleQMsx2gtK0JZbZKbpdu5ynr+lbsuvtmgbNcS3VM0tuY2QjFNOcWxvXeHjDpy42RO+4U2rug==", + "license": "MIT" + }, + "node_modules/react-reconciler": { + "version": "0.33.0", + "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.33.0.tgz", + "integrity": "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "peerDependencies": { + "react": "^19.2.0" + } + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", diff --git a/package.json b/package.json index 6af2c63..e6e216e 100644 --- a/package.json +++ b/package.json @@ -145,17 +145,21 @@ "framer-motion": "^12.6.3", "geist": "^1.3.1", "glob": "^11.0.2", + "html-to-image": "^1.11.13", "isomorphic-git": "^1.30.1", "jotai": "^2.12.2", "kill-port": "^2.0.1", + "konva": "^10.0.12", "lexical": "^0.33.1", "lexical-beautiful-mentions": "^0.1.47", "lucide-react": "^0.487.0", "monaco-editor": "^0.52.2", "openai": "^4.91.1", + "perfect-freehand": "^1.2.2", "posthog-js": "^1.236.3", - "react": "^19.2.1", - "react-dom": "^19.2.1", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-konva": "^19.2.1", "react-markdown": "^10.1.0", "react-resizable-panels": "^2.1.7", "react-shiki": "^0.9.0", diff --git a/src/atoms/chatAtoms.ts b/src/atoms/chatAtoms.ts index bccb010..684a5a5 100644 --- a/src/atoms/chatAtoms.ts +++ b/src/atoms/chatAtoms.ts @@ -1,4 +1,4 @@ -import type { Message } from "@/ipc/ipc_types"; +import type { FileAttachment, Message } from "@/ipc/ipc_types"; import { atom } from "jotai"; import type { ChatSummary } from "@/lib/schemas"; @@ -20,3 +20,5 @@ export const chatsLoadingAtom = atom(false); // Used for scrolling to the bottom of the chat messages (per chat) export const chatStreamCountByIdAtom = atom>(new Map()); export const recentStreamChatIdsAtom = atom>(new Set()); + +export const attachmentsAtom = atom([]); diff --git a/src/atoms/previewAtoms.ts b/src/atoms/previewAtoms.ts index 92ff10e..934abe9 100644 --- a/src/atoms/previewAtoms.ts +++ b/src/atoms/previewAtoms.ts @@ -15,6 +15,9 @@ export const currentComponentCoordinatesAtom = atom<{ export const previewIframeRefAtom = atom(null); +export const annotatorModeAtom = atom(false); + +export const screenshotDataUrlAtom = atom(null); export const pendingVisualChangesAtom = atom>( new Map(), ); diff --git a/src/components/preview_panel/AnnotatorOnlyForPro.tsx b/src/components/preview_panel/AnnotatorOnlyForPro.tsx new file mode 100644 index 0000000..08f307c --- /dev/null +++ b/src/components/preview_panel/AnnotatorOnlyForPro.tsx @@ -0,0 +1,53 @@ +import { Lock, ArrowLeft } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { IpcClient } from "@/ipc/ipc_client"; + +interface AnnotatorOnlyForProProps { + onGoBack: () => void; +} + +export const AnnotatorOnlyForPro = ({ onGoBack }: AnnotatorOnlyForProProps) => { + const handleGetPro = () => { + IpcClient.getInstance().openExternalUrl("https://dyad.sh/pro"); + }; + + return ( +
+ {/* Go Back Button */} + + + {/* Centered Content */} +
+ {/* Lock Icon */} + + + {/* Message */} +

+ Annotator is a Pro Feature +

+

+ Unlock the ability to annotate screenshots and enhance your workflow + with Dyad Pro. +

+ + {/* Get Pro Button */} + +
+
+ ); +}; diff --git a/src/components/preview_panel/AnnotatorToolbar.tsx b/src/components/preview_panel/AnnotatorToolbar.tsx new file mode 100644 index 0000000..57142ef --- /dev/null +++ b/src/components/preview_panel/AnnotatorToolbar.tsx @@ -0,0 +1,214 @@ +import { + MousePointer2, + Pencil, + Type, + Trash2, + Undo, + Redo, + Check, + X, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { ToolbarColorPicker } from "./ToolbarColorPicker"; + +interface AnnotatorToolbarProps { + tool: "select" | "draw" | "text"; + color: string; + selectedId: string | null; + historyStep: number; + historyLength: number; + onToolChange: (tool: "select" | "draw" | "text") => void; + onColorChange: (color: string) => void; + onDelete: () => void; + onUndo: () => void; + onRedo: () => void; + onSubmit: () => void; + onDeactivate: () => void; + hasSubmitHandler: boolean; +} + +export const AnnotatorToolbar = ({ + tool, + color, + selectedId, + historyStep, + historyLength, + onToolChange, + onColorChange, + onDelete, + onUndo, + onRedo, + onSubmit, + onDeactivate, + hasSubmitHandler, +}: AnnotatorToolbarProps) => { + return ( +
+ + {/* Tool Selection Buttons */} +
+ + + + + +

Select

+
+
+ + + + + + +

Draw

+
+
+ + + + + + +

Text

+
+
+ + + +
+ +
+
+ +

Color

+
+
+ +
+ + + + + + +

Delete Selected

+
+
+ +
+ + + + + + +

Undo

+
+
+ + + + + + +

Redo

+
+
+ +
+ + + + + + +

Add to Chat

+
+
+ + + + + +

Close Annotator

+
+
+
+ +
+ ); +}; diff --git a/src/components/preview_panel/DraggableTextInput.tsx b/src/components/preview_panel/DraggableTextInput.tsx new file mode 100644 index 0000000..9dcacff --- /dev/null +++ b/src/components/preview_panel/DraggableTextInput.tsx @@ -0,0 +1,156 @@ +import React, { useState, useRef, useEffect } from "react"; +import { X } from "lucide-react"; + +interface DraggableTextInputProps { + input: { + id: string; + x: number; + y: number; + adjustedX: number; + adjustedY: number; + value: string; + }; + index: number; + totalInputs: number; + scale: number; + onMove: ( + id: string, + x: number, + y: number, + adjustedX: number, + adjustedY: number, + ) => void; + onChange: (id: string, value: string) => void; + onKeyDown: (id: string, e: React.KeyboardEvent, index: number) => void; + onRemove: (id: string) => void; + spanRef: React.MutableRefObject; + inputRef: React.MutableRefObject; + color: string; +} + +export const DraggableTextInput = ({ + input, + index, + totalInputs, + scale, + onMove, + onChange, + onKeyDown, + onRemove, + spanRef, + inputRef, + color, +}: DraggableTextInputProps) => { + const [isDragging, setIsDragging] = useState(false); + const dragOffset = useRef({ x: 0, y: 0 }); + + useEffect(() => { + const handleMouseMove = (e: MouseEvent) => { + if (isDragging) { + const newX = e.clientX - dragOffset.current.x; + const newY = e.clientY - dragOffset.current.y; + // Calculate adjusted coordinates for the canvas + const adjustedX = newX / scale; + const adjustedY = newY / scale; + onMove(input.id, newX, newY, adjustedX, adjustedY); + } + }; + + const handleMouseUp = () => { + setIsDragging(false); + }; + + if (isDragging) { + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + + return () => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + }; + } + }, [isDragging, input.id, onMove, scale]); + + return ( +
+
+ {/* Drag Handle */} +
{ + setIsDragging(true); + dragOffset.current = { + x: e.clientX - input.x, + y: e.clientY - input.y, + }; + e.preventDefault(); + e.stopPropagation(); + }} + title="Drag to move" + > + {/* Grip dots icon - smaller and more subtle */} + + + + + + + + +
+ + { + if (e) spanRef.current[index] = e; + }} + className=" + absolute + invisible + whitespace-pre + text-base + font-normal + " + > + onChange(input.id, e.target.value)} + onKeyDown={(e) => onKeyDown(input.id, e, index)} + className="pl-8 pr-8 py-2 bg-[var(--background)] border-2 rounded-md shadow-lg text-gray-900 dark:text-gray-100 focus:outline-none min-w-[200px] cursor-text" + style={{ borderColor: color }} + placeholder="Type text..." + ref={(e) => { + if (e) inputRef.current[index] = e; + }} + /> + + {/* Close Button - Rightmost */} + +
+
+ ); +}; diff --git a/src/components/preview_panel/PreviewIframe.tsx b/src/components/preview_panel/PreviewIframe.tsx index a43d3e8..6d0485d 100644 --- a/src/components/preview_panel/PreviewIframe.tsx +++ b/src/components/preview_panel/PreviewIframe.tsx @@ -23,6 +23,7 @@ import { Monitor, Tablet, Smartphone, + Pen, } from "lucide-react"; import { selectedChatIdAtom } from "@/atoms/chatAtoms"; import { CopyErrorMessage } from "@/components/CopyErrorMessage"; @@ -36,12 +37,13 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { useStreamChat } from "@/hooks/useStreamChat"; -import { useUserBudgetInfo } from "@/hooks/useUserBudgetInfo"; import { selectedComponentsPreviewAtom, visualEditingSelectedComponentAtom, currentComponentCoordinatesAtom, previewIframeRefAtom, + annotatorModeAtom, + screenshotDataUrlAtom, pendingVisualChangesAtom, } from "@/atoms/previewAtoms"; import { ComponentSelection } from "@/ipc/ipc_types"; @@ -61,6 +63,11 @@ import { useRunApp } from "@/hooks/useRunApp"; import { useShortcut } from "@/hooks/useShortcut"; import { cn } from "@/lib/utils"; import { normalizePath } from "../../../shared/normalizePath"; +import { showError } from "@/lib/toast"; +import { AnnotatorOnlyForPro } from "./AnnotatorOnlyForPro"; +import { useAttachments } from "@/hooks/useAttachments"; +import { useUserBudgetInfo } from "@/hooks/useUserBudgetInfo"; +import { Annotator } from "@/pro/ui/components/Annotator/Annotator"; import { VisualEditingToolbar } from "./VisualEditingToolbar"; interface ErrorBannerProps { @@ -193,12 +200,32 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => { const setPreviewIframeRef = useSetAtom(previewIframeRefAtom); const iframeRef = useRef(null); const [isPicking, setIsPicking] = useState(false); + const [annotatorMode, setAnnotatorMode] = useAtom(annotatorModeAtom); + const [screenshotDataUrl, setScreenshotDataUrl] = useAtom( + screenshotDataUrlAtom, + ); + + const { addAttachments } = useAttachments(); const setPendingChanges = useSetAtom(pendingVisualChangesAtom); // AST Analysis State const [isDynamicComponent, setIsDynamicComponent] = useState(false); const [hasStaticText, setHasStaticText] = useState(false); + // Device mode state + type DeviceMode = "desktop" | "tablet" | "mobile"; + const [deviceMode, setDeviceMode] = useState("desktop"); + const [isDevicePopoverOpen, setIsDevicePopoverOpen] = useState(false); + + // Device configurations + const deviceWidthConfig = { + tablet: 768, + mobile: 375, + }; + + //detect if the user is using Mac + const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0; + const analyzeComponent = async (componentId: string) => { if (!componentId || !selectedAppId) return; @@ -283,21 +310,9 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => { console.error("Failed to get element styles:", error); } }; - - // Device mode state - type DeviceMode = "desktop" | "tablet" | "mobile"; - const [deviceMode, setDeviceMode] = useState("desktop"); - const [isDevicePopoverOpen, setIsDevicePopoverOpen] = useState(false); - - // Device configurations - const deviceWidthConfig = { - tablet: 768, - mobile: 375, - }; - - //detect if the user is using Mac - const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0; - + useEffect(() => { + setAnnotatorMode(false); + }, []); // Reset visual editing state when app changes or component unmounts useEffect(() => { return () => { @@ -423,6 +438,16 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => { return; } + if (event.data?.type === "dyad-screenshot-response") { + if (event.data.success && event.data.dataUrl) { + setScreenshotDataUrl(event.data.dataUrl); + setAnnotatorMode(true); + } else { + showError(event.data.error); + } + return; + } + const { type, payload } = event.data as { type: | "window-error" @@ -558,6 +583,22 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => { } }; + // Function to handle annotator button click + const handleAnnotatorClick = () => { + if (annotatorMode) { + setAnnotatorMode(false); + return; + } + if (iframeRef.current?.contentWindow) { + iframeRef.current.contentWindow.postMessage( + { + type: "dyad-take-screenshot", + }, + "*", + ); + } + }; + // Activate component selector using a shortcut useShortcut( "c", @@ -675,203 +716,239 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => { return (
- {/* Browser-style header */} -
- {/* Navigation Buttons */} -
- - - - - - -

- {isPicking - ? "Deactivate component selector" - : "Select component"} -

-

{isMac ? "⌘ + ⇧ + C" : "Ctrl + ⇧ + C"}

-
-
-
- - - -
- - {/* Address Bar with Routes Dropdown - using shadcn/ui dropdown-menu */} -
- - -
- - {navigationHistory[currentHistoryPosition] - ? new URL(navigationHistory[currentHistoryPosition]) - .pathname - : "/"} - - -
-
- - {availableRoutes.length > 0 ? ( - availableRoutes.map((route) => ( - navigateToRoute(route.path)} - className="flex justify-between" - > - {route.label} - - {route.path} - - - )) - ) : ( - Loading routes... - )} - -
-
- - {/* Action Buttons */} -
- - - - {/* Device Mode Button */} - - - - - e.preventDefault()} - onInteractOutside={(e) => e.preventDefault()} - > - - { - if (value) { - setDeviceMode(value as DeviceMode); - setIsDevicePopoverOpen(false); + {/* Browser-style header - hide when annotator is active */} + {!annotatorMode && ( +
+ {/* Navigation Buttons */} +
+ + + +
-
+ data-testid="preview-pick-element-button" + > + + + + +

+ {isPicking + ? "Deactivate component selector" + : "Select component"} +

+

{isMac ? "⌘ + ⇧ + C" : "Ctrl + ⇧ + C"}

+
+ +
+ + + + + + +

+ {annotatorMode + ? "Annotator mode active" + : "Activate annotator"} +

+
+
+
+ + + +
-
+ {/* Address Bar with Routes Dropdown - using shadcn/ui dropdown-menu */} +
+ + +
+ + {navigationHistory[currentHistoryPosition] + ? new URL(navigationHistory[currentHistoryPosition]) + .pathname + : "/"} + + +
+
+ + {availableRoutes.length > 0 ? ( + availableRoutes.map((route) => ( + navigateToRoute(route.path)} + className="flex justify-between" + > + {route.label} + + {route.path} + + + )) + ) : ( + + Loading routes... + + )} + +
+
+ + {/* Action Buttons */} +
+ + + + {/* Device Mode Button */} + + + + + e.preventDefault()} + onInteractOutside={(e) => e.preventDefault()} + > + + { + if (value) { + setDeviceMode(value as DeviceMode); + setIsDevicePopoverOpen(false); + } + }} + variant="outline" + > + {/* Tooltips placed inside items instead of wrapping + to avoid asChild prop merging that breaks highlighting */} + + + + + + + + +

Desktop

+
+
+
+ + + + + + + + +

Tablet

+
+
+
+ + + + + + + + +

Mobile

+
+
+
+
+
+
+
+
+
+ )} + +
setErrorMessage(undefined)} @@ -899,32 +976,59 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => { deviceMode !== "desktop" && "flex justify-center", )} > -