Unverified 提交 a4ab1a7f authored 作者: Mohamed Aziz Mejri's avatar Mohamed Aziz Mejri 提交者: GitHub

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 580aca271c5993a0dc7426e36e34393e073bd67b. Summary will update automatically on new commits.</sup> <!-- End of auto-generated description by cubic. -->
上级 86e40057
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,/);
},
);
......@@ -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...");
}
......
......@@ -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;
}
......
......@@ -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",
......
......@@ -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",
......
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<boolean>(false);
// Used for scrolling to the bottom of the chat messages (per chat)
export const chatStreamCountByIdAtom = atom<Map<number, number>>(new Map());
export const recentStreamChatIdsAtom = atom<Set<number>>(new Set<number>());
export const attachmentsAtom = atom<FileAttachment[]>([]);
......@@ -15,6 +15,9 @@ export const currentComponentCoordinatesAtom = atom<{
export const previewIframeRefAtom = atom<HTMLIFrameElement | null>(null);
export const annotatorModeAtom = atom<boolean>(false);
export const screenshotDataUrlAtom = atom<string | null>(null);
export const pendingVisualChangesAtom = atom<Map<string, VisualEditingChange>>(
new Map(),
);
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 (
<div className="w-full h-full bg-background relative">
{/* Go Back Button */}
<button
onClick={onGoBack}
className="absolute top-4 left-4 p-2 hover:bg-accent rounded-md transition-all z-10 group"
aria-label="Go back"
>
<ArrowLeft
size={20}
className="text-foreground/70 group-hover:text-foreground transition-colors"
/>
</button>
{/* Centered Content */}
<div className="flex flex-col items-center justify-center h-full px-8">
{/* Lock Icon */}
<Lock size={72} className="text-primary/60 dark:text-primary/70 mb-8" />
{/* Message */}
<h2 className="text-3xl font-semibold text-foreground mb-4 text-center">
Annotator is a Pro Feature
</h2>
<p className="text-muted-foreground mb-10 text-center max-w-md text-base leading-relaxed">
Unlock the ability to annotate screenshots and enhance your workflow
with Dyad Pro.
</p>
{/* Get Pro Button */}
<Button
onClick={handleGetPro}
size="lg"
className="px-8 shadow-md hover:shadow-lg transition-all"
>
Get Dyad Pro
</Button>
</div>
</div>
);
};
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 (
<div className="flex items-center justify-center p-2 border-b space-x-2">
<TooltipProvider>
{/* Tool Selection Buttons */}
<div className="flex space-x-1">
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => onToolChange("select")}
aria-label="Select"
className={cn(
"p-1 rounded transition-colors duration-200",
tool === "select"
? "bg-purple-500 text-white hover:bg-purple-600 dark:bg-purple-600 dark:hover:bg-purple-700"
: " text-purple-700 hover:bg-purple-200 dark:text-purple-300 dark:hover:bg-purple-900",
)}
>
<MousePointer2 size={16} />
</button>
</TooltipTrigger>
<TooltipContent>
<p>Select</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => onToolChange("draw")}
aria-label="Draw"
className={cn(
"p-1 rounded transition-colors duration-200",
tool === "draw"
? "bg-purple-500 text-white hover:bg-purple-600 dark:bg-purple-600 dark:hover:bg-purple-700"
: " text-purple-700 hover:bg-purple-200 dark:text-purple-300 dark:hover:bg-purple-900",
)}
>
<Pencil size={16} />
</button>
</TooltipTrigger>
<TooltipContent>
<p>Draw</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => onToolChange("text")}
aria-label="Text"
className={cn(
"p-1 rounded transition-colors duration-200",
tool === "text"
? "bg-purple-500 text-white hover:bg-purple-600 dark:bg-purple-600 dark:hover:bg-purple-700"
: "text-purple-700 hover:bg-purple-200 dark:text-purple-300 dark:hover:bg-purple-900",
)}
>
<Type size={16} />
</button>
</TooltipTrigger>
<TooltipContent>
<p>Text</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<div className="p-1 rounded transition-colors duration-200 hover:bg-purple-200 dark:hover:bg-purple-900">
<ToolbarColorPicker color={color} onChange={onColorChange} />
</div>
</TooltipTrigger>
<TooltipContent>
<p>Color</p>
</TooltipContent>
</Tooltip>
<div className="w-px bg-gray-200 dark:bg-gray-700 h-4" />
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={onDelete}
aria-label="Delete"
className="p-1 rounded transition-colors duration-200 text-purple-700 hover:bg-purple-200 dark:text-purple-300 dark:hover:bg-purple-900 disabled:opacity-50 disabled:cursor-not-allowed"
disabled={!selectedId}
>
<Trash2 size={16} />
</button>
</TooltipTrigger>
<TooltipContent>
<p>Delete Selected</p>
</TooltipContent>
</Tooltip>
<div className="w-px bg-gray-200 dark:bg-gray-700 h-4" />
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={onUndo}
aria-label="Undo"
className="p-1 rounded transition-colors duration-200 text-purple-700 hover:bg-purple-200 dark:text-purple-300 dark:hover:bg-purple-900 disabled:opacity-50 disabled:cursor-not-allowed"
disabled={historyStep === 0}
>
<Undo size={16} />
</button>
</TooltipTrigger>
<TooltipContent>
<p>Undo</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={onRedo}
aria-label="Redo"
className="p-1 rounded transition-colors duration-200 text-purple-700 hover:bg-purple-200 dark:text-purple-300 dark:hover:bg-purple-900 disabled:opacity-50 disabled:cursor-not-allowed"
disabled={historyStep === historyLength - 1}
>
<Redo size={16} />
</button>
</TooltipTrigger>
<TooltipContent>
<p>Redo</p>
</TooltipContent>
</Tooltip>
<div className="w-px bg-gray-200 dark:bg-gray-700 h-4" />
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={onSubmit}
aria-label="Add to Chat"
className="p-1 rounded transition-colors duration-200 text-purple-700 hover:bg-purple-200 dark:text-purple-300 dark:hover:bg-purple-900 disabled:opacity-50 disabled:cursor-not-allowed"
disabled={!hasSubmitHandler}
>
<Check size={16} />
</button>
</TooltipTrigger>
<TooltipContent>
<p>Add to Chat</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={onDeactivate}
aria-label="Close Annotator"
className="p-1 rounded transition-colors duration-200 text-purple-700 hover:bg-purple-200 dark:text-purple-300 dark:hover:bg-purple-900"
>
<X size={16} />
</button>
</TooltipTrigger>
<TooltipContent>
<p>Close Annotator</p>
</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
</div>
);
};
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<HTMLSpanElement[]>;
inputRef: React.MutableRefObject<HTMLInputElement[]>;
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 (
<div
className="absolute z-[999]"
style={{
left: `${input.x}px`,
top: `${input.y}px`,
}}
>
<div className="relative">
{/* Drag Handle */}
<div
className="absolute left-2 top-1/2 -translate-y-1/2 cursor-move p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors z-10"
onMouseDown={(e) => {
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 */}
<svg
width="8"
height="12"
viewBox="0 0 8 12"
fill="currentColor"
className="text-gray-400 dark:text-gray-500"
>
<circle cx="2" cy="2" r="1" />
<circle cx="6" cy="2" r="1" />
<circle cx="2" cy="6" r="1" />
<circle cx="6" cy="6" r="1" />
<circle cx="2" cy="10" r="1" />
<circle cx="6" cy="10" r="1" />
</svg>
</div>
<span
ref={(e) => {
if (e) spanRef.current[index] = e;
}}
className="
absolute
invisible
whitespace-pre
text-base
font-normal
"
></span>
<input
autoFocus={index === totalInputs - 1}
type="text"
value={input.value}
onChange={(e) => 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 */}
<button
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 hover:bg-red-100 dark:hover:bg-red-900/30 rounded transition-colors z-10 group"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onRemove(input.id);
}}
title="Remove text input"
type="button"
>
<X className="w-3 h-3 text-gray-400 dark:text-gray-500 group-hover:text-red-600 dark:group-hover:text-red-400" />
</button>
</div>
</div>
);
};
interface ToolbarColorPickerProps {
color: string;
onChange: (color: string) => void;
}
export const ToolbarColorPicker = ({
color,
onChange,
}: ToolbarColorPickerProps) => {
return (
<label
className="h-[16px] w-[16px] rounded-sm cursor-pointer transition-all overflow-hidden block self-center"
style={{ backgroundColor: color }}
title="Choose color"
>
<input
type="color"
value={color}
onChange={(e) => onChange(e.target.value)}
className="opacity-0 w-full h-full"
aria-label="Choose color"
/>
</label>
);
};
import React, { useState, useRef } from "react";
import React, { useRef, useState } from "react";
import type { FileAttachment } from "@/ipc/ipc_types";
import { useAtom } from "jotai";
import { attachmentsAtom } from "@/atoms/chatAtoms";
export function useAttachments() {
const [attachments, setAttachments] = useState<FileAttachment[]>([]);
const [attachments, setAttachments] = useAtom(attachmentsAtom);
const fileInputRef = useRef<HTMLInputElement>(null);
const [isDraggingOver, setIsDraggingOver] = useState(false);
......@@ -133,5 +135,6 @@ export function useAttachments() {
handleDrop,
clearAttachments,
handlePaste,
addAttachments,
};
}
import React from "react";
import {
Stage,
Layer,
Image as KonvaImage,
Path,
Text,
Transformer,
} from "react-konva";
import { getStroke } from "perfect-freehand";
// Helper to convert stroke points to SVG path data
function getSvgPathFromStroke(stroke: number[][]) {
if (!stroke.length) return "";
const d = stroke.reduce(
(acc, [x0, y0], i, arr) => {
const [x1, y1] = arr[(i + 1) % arr.length];
acc.push(x0, y0, (x0 + x1) / 2, (y0 + y1) / 2);
return acc;
},
["M", ...stroke[0], "Q"],
);
d.push("Z");
return d.join(" ");
}
type Point = [number, number];
type Shape =
| {
id: string;
type: "line";
points: Point[];
color: string;
size: number;
isComplete: boolean;
}
| {
id: string;
type: "text";
x: number;
y: number;
text: string;
fontSize: number;
color: string;
};
interface AnnotationCanvasProps {
image: HTMLImageElement | null;
shapes: Shape[];
selectedId: string | null;
tool: "select" | "draw" | "text";
scale: number;
stageDimensions: { width: number; height: number };
containerSize: { width: number; height: number };
stageRef: React.RefObject<any>;
transformerRef: React.RefObject<any>;
onMouseDown: (e: any) => void;
onMouseMove: (e: any) => void;
onMouseUp: () => void;
onShapeSelect: (id: string) => void;
onShapeDragEnd: (id: string, x: number, y: number) => void;
}
export const AnnotationCanvas = ({
image,
shapes,
selectedId,
tool,
scale,
stageDimensions,
containerSize,
stageRef,
transformerRef,
onMouseDown,
onMouseMove,
onMouseUp,
onShapeSelect,
onShapeDragEnd,
}: AnnotationCanvasProps) => {
if (!image || containerSize.width === 0) {
return null;
}
return (
<div className="w-full h-full">
<Stage
width={stageDimensions.width}
height={stageDimensions.height}
onMouseDown={onMouseDown}
onMouseMove={onMouseMove}
onMouseUp={onMouseUp}
onTouchStart={onMouseDown}
onTouchMove={onMouseMove}
onTouchEnd={onMouseUp}
ref={stageRef}
style={{ touchAction: "none" }}
>
<Layer>
<KonvaImage
image={image}
listening={false}
scaleX={scale}
scaleY={scale}
/>
{shapes.map((shape) => {
if (shape.type === "line") {
const stroke = getStroke(shape.points, {
size: shape.size,
thinning: 0.5,
smoothing: 0.5,
streamline: 0.5,
});
const pathData = getSvgPathFromStroke(stroke);
return (
<Path
key={shape.id}
id={shape.id}
data={pathData}
fill={shape.color}
scaleX={scale}
scaleY={scale}
onClick={() => tool === "select" && onShapeSelect(shape.id)}
onTap={() => tool === "select" && onShapeSelect(shape.id)}
draggable={tool === "select"}
/>
);
} else if (shape.type === "text") {
return (
<Text
key={shape.id}
id={shape.id}
x={shape.x}
y={shape.y}
scaleX={scale}
scaleY={scale}
text={shape.text}
fontSize={shape.fontSize * scale}
fill={shape.color}
draggable={tool === "select"}
onClick={() => tool === "select" && onShapeSelect(shape.id)}
onTap={() => tool === "select" && onShapeSelect(shape.id)}
onDragEnd={(e) => {
const node = e.target;
onShapeDragEnd(shape.id, node.x(), node.y());
}}
/>
);
}
return null;
})}
{selectedId && (
<Transformer
ref={transformerRef}
boundBoxFunc={(oldBox, newBox) => {
// Limit resize if needed
if (newBox.width < 5 || newBox.height < 5) {
return oldBox;
}
return newBox;
}}
/>
)}
</Layer>
</Stage>
</div>
);
};
差异被折叠。
(() => {
async function captureScreenshot() {
try {
// Use html-to-image if available
if (typeof htmlToImage !== "undefined") {
return await htmlToImage.toPng(document.body, {
width: document.documentElement.scrollWidth,
height: document.documentElement.scrollHeight,
});
}
throw new Error("html-to-image library not found");
} catch (error) {
console.error("[dyad-screenshot] Failed to capture screenshot:", error);
throw error;
}
}
async function handleScreenshotRequest() {
try {
console.debug("[dyad-screenshot] Capturing screenshot...");
const dataUrl = await captureScreenshot();
console.debug("[dyad-screenshot] Screenshot captured successfully");
// Send success response to parent
window.parent.postMessage(
{
type: "dyad-screenshot-response",
success: true,
dataUrl: dataUrl,
},
"*",
);
} catch (error) {
console.error("[dyad-screenshot] Screenshot capture failed:", error);
// Send error response to parent
window.parent.postMessage(
{
type: "dyad-screenshot-response",
success: false,
error: error.message,
},
"*",
);
}
}
window.addEventListener("message", (event) => {
if (event.source !== window.parent) return;
if (event.data.type === "dyad-take-screenshot") {
handleScreenshotRequest();
}
});
})();
......@@ -38,7 +38,29 @@ let rememberedOrigin = null; // e.g. "http://localhost:5173"
let stacktraceJsContent = null;
let dyadShimContent = null;
let dyadComponentSelectorClientContent = null;
let dyadScreenshotClientContent = null;
let htmlToImageContent = null;
let dyadVisualEditorClientContent = null;
try {
const htmlToImagePath = path.join(
__dirname,
"..",
"node_modules",
"html-to-image",
"dist",
"html-to-image.js",
);
htmlToImageContent = fs.readFileSync(htmlToImagePath, "utf-8");
parentPort?.postMessage(
`[proxy-worker] html-to-image.js loaded from: ${htmlToImagePath}`,
);
} catch (error) {
parentPort?.postMessage(
`[proxy-worker] Failed to read html-to-image.js: ${error.message}`,
);
}
try {
const stackTraceLibPath = path.join(
__dirname,
......@@ -84,6 +106,22 @@ try {
);
}
try {
const dyadScreenshotClientPath = path.join(
__dirname,
"dyad-screenshot-client.js",
);
dyadScreenshotClientContent = fs.readFileSync(
dyadScreenshotClientPath,
"utf-8",
);
parentPort?.postMessage("[proxy-worker] dyad-screenshot-client.js loaded.");
} catch (error) {
parentPort?.postMessage(
`[proxy-worker] Failed to read dyad-screenshot-client.js: ${error.message}`,
);
}
try {
const dyadVisualEditorClientPath = path.join(
__dirname,
......@@ -143,6 +181,26 @@ function injectHTML(buf) {
'<script>console.warn("[proxy-worker] dyad component selector client was not injected.");</script>',
);
}
if (htmlToImageContent) {
scripts.push(`<script>${htmlToImageContent}</script>`);
parentPort?.postMessage(
"[proxy-worker] html-to-image script injected into HTML.",
);
} else {
scripts.push(
'<script>console.error("[proxy-worker] html-to-image was not injected - library not loaded.");</script>',
);
parentPort?.postMessage(
"[proxy-worker] WARNING: html-to-image not injected!",
);
}
if (dyadScreenshotClientContent) {
scripts.push(`<script>${dyadScreenshotClientContent}</script>`);
} else {
scripts.push(
'<script>console.warn("[proxy-worker] dyad screenshot client was not injected.");</script>',
);
}
if (dyadVisualEditorClientContent) {
scripts.push(`<script>${dyadVisualEditorClientContent}</script>`);
} else {
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论