Skip to content

Commit a4ab1a7

Browse files
authored
Annotator (#1861)
<!-- This is an auto-generated description by cubic. --> ## Summary by cubic Adds an in-app screenshot annotator to the Preview panel for Pro users so you can capture the current app view, draw or add text, and submit an annotated image to chat. - **New Features** - Pen button in PreviewIframe to toggle annotator; captures a screenshot via worker messaging and displays it in a Konva canvas. - Tools: select, freehand draw, and draggable text; supports undo/redo, delete, and resizing with Transformer. Canvas scales to the container. Includes a color picker. - Submit exports a PNG and attaches it to the chat via useAttachments; prefills the chat input; annotator auto-closes after submit. - Pro-only: non-Pro users see an upsell screen. - State atoms added: annotatorModeAtom, screenshotDataUrlAtom, attachmentsAtom; PreviewIframe now handles dyad-screenshot-response messages. - **Dependencies** - Added konva, react-konva, perfect-freehand, and html-to-image. - Proxy now injects html-to-image and the new dyad-screenshot-client.js for screenshot capture. <sup>Written for commit 580aca2. Summary will update automatically on new commits.</sup> <!-- End of auto-generated description by cubic. -->
1 parent 86e4005 commit a4ab1a7

File tree

17 files changed

+1734
-238
lines changed

17 files changed

+1734
-238
lines changed

e2e-tests/annotator.spec.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { testSkipIfWindows } from "./helpers/test_helper";
2+
import { expect } from "@playwright/test";
3+
import fs from "fs";
4+
5+
testSkipIfWindows(
6+
"annotator - capture and submit screenshot",
7+
async ({ po }) => {
8+
await po.setUpDyadPro({ autoApprove: true });
9+
10+
// Create a basic app
11+
await po.sendPrompt("basic");
12+
13+
// Click the annotator button to activate annotator mode
14+
await po.clickPreviewAnnotatorButton();
15+
16+
// Wait for annotator mode to be active
17+
await po.waitForAnnotatorMode();
18+
19+
// Submit the screenshot to chat
20+
await po.clickAnnotatorSubmit();
21+
22+
await expect(po.getChatInput()).toContainText(
23+
"Please update the UI based on these screenshots",
24+
);
25+
26+
// Verify the screenshot was attached to chat context
27+
await po.sendPrompt("[dump]");
28+
29+
// Wait for the LLM response containing the dump path to appear in the UI
30+
// before attempting to extract it from the messages list
31+
await po.page.waitForSelector("text=/\\[\\[dyad-dump-path=.*\\]\\]/");
32+
33+
// Get the dump file path from the messages list
34+
const messagesListText = await po.page
35+
.getByTestId("messages-list")
36+
.textContent();
37+
const dumpPathMatch = messagesListText?.match(
38+
/\[\[dyad-dump-path=([^\]]+)\]\]/,
39+
);
40+
41+
if (!dumpPathMatch) {
42+
throw new Error("No dump path found in messages list");
43+
}
44+
45+
const dumpFilePath = dumpPathMatch[1];
46+
const dumpContent = fs.readFileSync(dumpFilePath, "utf-8");
47+
const parsedDump = JSON.parse(dumpContent);
48+
49+
// Get the last message from the dump
50+
const messages = parsedDump.body.messages;
51+
const lastMessage = messages[messages.length - 1];
52+
53+
expect(lastMessage).toBeTruthy();
54+
expect(lastMessage.content).toBeTruthy();
55+
56+
// The content is an array with text and image parts
57+
expect(Array.isArray(lastMessage.content)).toBe(true);
58+
59+
// Find the text part and verify it mentions the PNG attachment
60+
const textPart = lastMessage.content.find(
61+
(part: any) => part.type === "text",
62+
);
63+
expect(textPart).toBeTruthy();
64+
expect(textPart.text).toMatch(/annotated-screenshot-.*\.png/);
65+
expect(textPart.text).toMatch(/image\/png/);
66+
67+
// Find the image part and verify it has the correct structure
68+
const imagePart = lastMessage.content.find(
69+
(part: any) => part.type === "image_url",
70+
);
71+
expect(imagePart).toBeTruthy();
72+
expect(imagePart.image_url).toBeTruthy();
73+
expect(imagePart.image_url.url).toMatch(/^data:image\/png;base64,/);
74+
},
75+
);

e2e-tests/helpers/test_helper.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -553,6 +553,22 @@ export class PageObject {
553553
await this.page.getByTestId("preview-open-browser-button").click();
554554
}
555555

556+
async clickPreviewAnnotatorButton() {
557+
await this.page
558+
.getByTestId("preview-annotator-button")
559+
.click({ timeout: Timeout.EXTRA_LONG });
560+
}
561+
562+
async waitForAnnotatorMode() {
563+
// Wait for the annotator toolbar to be visible
564+
await expect(this.page.getByRole("button", { name: "Select" })).toBeVisible(
565+
{ timeout: Timeout.MEDIUM },
566+
);
567+
}
568+
569+
async clickAnnotatorSubmit() {
570+
await this.page.getByRole("button", { name: "Add to Chat" }).click();
571+
}
556572
locateLoadingAppPreview() {
557573
return this.page.getByText("Preparing app preview...");
558574
}

forge.config.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ const ignore = (file: string) => {
3232
if (file.startsWith("/node_modules/stacktrace-js/dist")) {
3333
return false;
3434
}
35+
if (file.startsWith("/node_modules/html-to-image")) {
36+
return false;
37+
}
3538
if (file.startsWith("/node_modules/better-sqlite3")) {
3639
return false;
3740
}

package-lock.json

Lines changed: 151 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -145,17 +145,21 @@
145145
"framer-motion": "^12.6.3",
146146
"geist": "^1.3.1",
147147
"glob": "^11.0.2",
148+
"html-to-image": "^1.11.13",
148149
"isomorphic-git": "^1.30.1",
149150
"jotai": "^2.12.2",
150151
"kill-port": "^2.0.1",
152+
"konva": "^10.0.12",
151153
"lexical": "^0.33.1",
152154
"lexical-beautiful-mentions": "^0.1.47",
153155
"lucide-react": "^0.487.0",
154156
"monaco-editor": "^0.52.2",
155157
"openai": "^4.91.1",
158+
"perfect-freehand": "^1.2.2",
156159
"posthog-js": "^1.236.3",
157-
"react": "^19.2.1",
158-
"react-dom": "^19.2.1",
160+
"react": "^19.0.0",
161+
"react-dom": "^19.0.0",
162+
"react-konva": "^19.2.1",
159163
"react-markdown": "^10.1.0",
160164
"react-resizable-panels": "^2.1.7",
161165
"react-shiki": "^0.9.0",

src/atoms/chatAtoms.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { Message } from "@/ipc/ipc_types";
1+
import type { FileAttachment, Message } from "@/ipc/ipc_types";
22
import { atom } from "jotai";
33
import type { ChatSummary } from "@/lib/schemas";
44

@@ -20,3 +20,5 @@ export const chatsLoadingAtom = atom<boolean>(false);
2020
// Used for scrolling to the bottom of the chat messages (per chat)
2121
export const chatStreamCountByIdAtom = atom<Map<number, number>>(new Map());
2222
export const recentStreamChatIdsAtom = atom<Set<number>>(new Set<number>());
23+
24+
export const attachmentsAtom = atom<FileAttachment[]>([]);

src/atoms/previewAtoms.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ export const currentComponentCoordinatesAtom = atom<{
1515

1616
export const previewIframeRefAtom = atom<HTMLIFrameElement | null>(null);
1717

18+
export const annotatorModeAtom = atom<boolean>(false);
19+
20+
export const screenshotDataUrlAtom = atom<string | null>(null);
1821
export const pendingVisualChangesAtom = atom<Map<string, VisualEditingChange>>(
1922
new Map(),
2023
);

0 commit comments

Comments
 (0)