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. -->
This commit is contained in:
committed by
GitHub
parent
86e4005795
commit
a4ab1a7f84
75
e2e-tests/annotator.spec.ts
Normal file
75
e2e-tests/annotator.spec.ts
Normal file
@@ -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,/);
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -553,6 +553,22 @@ export class PageObject {
|
|||||||
await this.page.getByTestId("preview-open-browser-button").click();
|
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() {
|
locateLoadingAppPreview() {
|
||||||
return this.page.getByText("Preparing app preview...");
|
return this.page.getByText("Preparing app preview...");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ const ignore = (file: string) => {
|
|||||||
if (file.startsWith("/node_modules/stacktrace-js/dist")) {
|
if (file.startsWith("/node_modules/stacktrace-js/dist")) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (file.startsWith("/node_modules/html-to-image")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (file.startsWith("/node_modules/better-sqlite3")) {
|
if (file.startsWith("/node_modules/better-sqlite3")) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
153
package-lock.json
generated
153
package-lock.json
generated
@@ -69,17 +69,21 @@
|
|||||||
"framer-motion": "^12.6.3",
|
"framer-motion": "^12.6.3",
|
||||||
"geist": "^1.3.1",
|
"geist": "^1.3.1",
|
||||||
"glob": "^11.0.2",
|
"glob": "^11.0.2",
|
||||||
|
"html-to-image": "^1.11.13",
|
||||||
"isomorphic-git": "^1.30.1",
|
"isomorphic-git": "^1.30.1",
|
||||||
"jotai": "^2.12.2",
|
"jotai": "^2.12.2",
|
||||||
"kill-port": "^2.0.1",
|
"kill-port": "^2.0.1",
|
||||||
|
"konva": "^10.0.12",
|
||||||
"lexical": "^0.33.1",
|
"lexical": "^0.33.1",
|
||||||
"lexical-beautiful-mentions": "^0.1.47",
|
"lexical-beautiful-mentions": "^0.1.47",
|
||||||
"lucide-react": "^0.487.0",
|
"lucide-react": "^0.487.0",
|
||||||
"monaco-editor": "^0.52.2",
|
"monaco-editor": "^0.52.2",
|
||||||
"openai": "^4.91.1",
|
"openai": "^4.91.1",
|
||||||
|
"perfect-freehand": "^1.2.2",
|
||||||
"posthog-js": "^1.236.3",
|
"posthog-js": "^1.236.3",
|
||||||
"react": "^19.2.1",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.2.1",
|
"react-dom": "^19.0.0",
|
||||||
|
"react-konva": "^19.2.1",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-resizable-panels": "^2.1.7",
|
"react-resizable-panels": "^2.1.7",
|
||||||
"react-shiki": "^0.9.0",
|
"react-shiki": "^0.9.0",
|
||||||
@@ -7061,6 +7065,15 @@
|
|||||||
"@types/react": "^19.0.0"
|
"@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": {
|
"node_modules/@types/responselike": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz",
|
||||||
@@ -12885,6 +12898,43 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/html-url-attributes": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
|
"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"
|
"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": {
|
"node_modules/jackspeak": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.1.1.tgz",
|
||||||
@@ -14010,6 +14081,26 @@
|
|||||||
"integrity": "sha512-jyVd+kU2X+mWKMmGhx4fpWbPsjvD53k9ivqetutVW/BQ+WIZoDoP4d8vUMGezV6saZsiNoW2f9GIhg9Dondohg==",
|
"integrity": "sha512-jyVd+kU2X+mWKMmGhx4fpWbPsjvD53k9ivqetutVW/BQ+WIZoDoP4d8vUMGezV6saZsiNoW2f9GIhg9Dondohg==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/levn": {
|
||||||
"version": "0.4.1",
|
"version": "0.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
||||||
@@ -17170,6 +17261,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/pg-int8": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
|
||||||
@@ -17797,6 +17894,37 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true
|
"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": {
|
"node_modules/react-markdown": {
|
||||||
"version": "10.1.0",
|
"version": "10.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz",
|
||||||
@@ -17824,6 +17952,27 @@
|
|||||||
"react": ">=18"
|
"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": {
|
"node_modules/react-refresh": {
|
||||||
"version": "0.17.0",
|
"version": "0.17.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
||||||
|
|||||||
@@ -145,17 +145,21 @@
|
|||||||
"framer-motion": "^12.6.3",
|
"framer-motion": "^12.6.3",
|
||||||
"geist": "^1.3.1",
|
"geist": "^1.3.1",
|
||||||
"glob": "^11.0.2",
|
"glob": "^11.0.2",
|
||||||
|
"html-to-image": "^1.11.13",
|
||||||
"isomorphic-git": "^1.30.1",
|
"isomorphic-git": "^1.30.1",
|
||||||
"jotai": "^2.12.2",
|
"jotai": "^2.12.2",
|
||||||
"kill-port": "^2.0.1",
|
"kill-port": "^2.0.1",
|
||||||
|
"konva": "^10.0.12",
|
||||||
"lexical": "^0.33.1",
|
"lexical": "^0.33.1",
|
||||||
"lexical-beautiful-mentions": "^0.1.47",
|
"lexical-beautiful-mentions": "^0.1.47",
|
||||||
"lucide-react": "^0.487.0",
|
"lucide-react": "^0.487.0",
|
||||||
"monaco-editor": "^0.52.2",
|
"monaco-editor": "^0.52.2",
|
||||||
"openai": "^4.91.1",
|
"openai": "^4.91.1",
|
||||||
|
"perfect-freehand": "^1.2.2",
|
||||||
"posthog-js": "^1.236.3",
|
"posthog-js": "^1.236.3",
|
||||||
"react": "^19.2.1",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.2.1",
|
"react-dom": "^19.0.0",
|
||||||
|
"react-konva": "^19.2.1",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-resizable-panels": "^2.1.7",
|
"react-resizable-panels": "^2.1.7",
|
||||||
"react-shiki": "^0.9.0",
|
"react-shiki": "^0.9.0",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Message } from "@/ipc/ipc_types";
|
import type { FileAttachment, Message } from "@/ipc/ipc_types";
|
||||||
import { atom } from "jotai";
|
import { atom } from "jotai";
|
||||||
import type { ChatSummary } from "@/lib/schemas";
|
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)
|
// Used for scrolling to the bottom of the chat messages (per chat)
|
||||||
export const chatStreamCountByIdAtom = atom<Map<number, number>>(new Map());
|
export const chatStreamCountByIdAtom = atom<Map<number, number>>(new Map());
|
||||||
export const recentStreamChatIdsAtom = atom<Set<number>>(new Set<number>());
|
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 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>>(
|
export const pendingVisualChangesAtom = atom<Map<string, VisualEditingChange>>(
|
||||||
new Map(),
|
new Map(),
|
||||||
);
|
);
|
||||||
|
|||||||
53
src/components/preview_panel/AnnotatorOnlyForPro.tsx
Normal file
53
src/components/preview_panel/AnnotatorOnlyForPro.tsx
Normal file
@@ -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 (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
};
|
||||||
214
src/components/preview_panel/AnnotatorToolbar.tsx
Normal file
214
src/components/preview_panel/AnnotatorToolbar.tsx
Normal file
@@ -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 (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
};
|
||||||
156
src/components/preview_panel/DraggableTextInput.tsx
Normal file
156
src/components/preview_panel/DraggableTextInput.tsx
Normal file
@@ -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<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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
Monitor,
|
Monitor,
|
||||||
Tablet,
|
Tablet,
|
||||||
Smartphone,
|
Smartphone,
|
||||||
|
Pen,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { selectedChatIdAtom } from "@/atoms/chatAtoms";
|
import { selectedChatIdAtom } from "@/atoms/chatAtoms";
|
||||||
import { CopyErrorMessage } from "@/components/CopyErrorMessage";
|
import { CopyErrorMessage } from "@/components/CopyErrorMessage";
|
||||||
@@ -36,12 +37,13 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { useStreamChat } from "@/hooks/useStreamChat";
|
import { useStreamChat } from "@/hooks/useStreamChat";
|
||||||
import { useUserBudgetInfo } from "@/hooks/useUserBudgetInfo";
|
|
||||||
import {
|
import {
|
||||||
selectedComponentsPreviewAtom,
|
selectedComponentsPreviewAtom,
|
||||||
visualEditingSelectedComponentAtom,
|
visualEditingSelectedComponentAtom,
|
||||||
currentComponentCoordinatesAtom,
|
currentComponentCoordinatesAtom,
|
||||||
previewIframeRefAtom,
|
previewIframeRefAtom,
|
||||||
|
annotatorModeAtom,
|
||||||
|
screenshotDataUrlAtom,
|
||||||
pendingVisualChangesAtom,
|
pendingVisualChangesAtom,
|
||||||
} from "@/atoms/previewAtoms";
|
} from "@/atoms/previewAtoms";
|
||||||
import { ComponentSelection } from "@/ipc/ipc_types";
|
import { ComponentSelection } from "@/ipc/ipc_types";
|
||||||
@@ -61,6 +63,11 @@ import { useRunApp } from "@/hooks/useRunApp";
|
|||||||
import { useShortcut } from "@/hooks/useShortcut";
|
import { useShortcut } from "@/hooks/useShortcut";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { normalizePath } from "../../../shared/normalizePath";
|
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";
|
import { VisualEditingToolbar } from "./VisualEditingToolbar";
|
||||||
|
|
||||||
interface ErrorBannerProps {
|
interface ErrorBannerProps {
|
||||||
@@ -193,12 +200,32 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
|
|||||||
const setPreviewIframeRef = useSetAtom(previewIframeRefAtom);
|
const setPreviewIframeRef = useSetAtom(previewIframeRefAtom);
|
||||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||||
const [isPicking, setIsPicking] = useState(false);
|
const [isPicking, setIsPicking] = useState(false);
|
||||||
|
const [annotatorMode, setAnnotatorMode] = useAtom(annotatorModeAtom);
|
||||||
|
const [screenshotDataUrl, setScreenshotDataUrl] = useAtom(
|
||||||
|
screenshotDataUrlAtom,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { addAttachments } = useAttachments();
|
||||||
const setPendingChanges = useSetAtom(pendingVisualChangesAtom);
|
const setPendingChanges = useSetAtom(pendingVisualChangesAtom);
|
||||||
|
|
||||||
// AST Analysis State
|
// AST Analysis State
|
||||||
const [isDynamicComponent, setIsDynamicComponent] = useState(false);
|
const [isDynamicComponent, setIsDynamicComponent] = useState(false);
|
||||||
const [hasStaticText, setHasStaticText] = useState(false);
|
const [hasStaticText, setHasStaticText] = useState(false);
|
||||||
|
|
||||||
|
// Device mode state
|
||||||
|
type DeviceMode = "desktop" | "tablet" | "mobile";
|
||||||
|
const [deviceMode, setDeviceMode] = useState<DeviceMode>("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) => {
|
const analyzeComponent = async (componentId: string) => {
|
||||||
if (!componentId || !selectedAppId) return;
|
if (!componentId || !selectedAppId) return;
|
||||||
|
|
||||||
@@ -283,21 +310,9 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
|
|||||||
console.error("Failed to get element styles:", error);
|
console.error("Failed to get element styles:", error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
useEffect(() => {
|
||||||
// Device mode state
|
setAnnotatorMode(false);
|
||||||
type DeviceMode = "desktop" | "tablet" | "mobile";
|
}, []);
|
||||||
const [deviceMode, setDeviceMode] = useState<DeviceMode>("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;
|
|
||||||
|
|
||||||
// Reset visual editing state when app changes or component unmounts
|
// Reset visual editing state when app changes or component unmounts
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@@ -423,6 +438,16 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
|
|||||||
return;
|
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 {
|
const { type, payload } = event.data as {
|
||||||
type:
|
type:
|
||||||
| "window-error"
|
| "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
|
// Activate component selector using a shortcut
|
||||||
useShortcut(
|
useShortcut(
|
||||||
"c",
|
"c",
|
||||||
@@ -675,7 +716,8 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
{/* Browser-style header */}
|
{/* Browser-style header - hide when annotator is active */}
|
||||||
|
{!annotatorMode && (
|
||||||
<div className="flex items-center p-2 border-b space-x-2">
|
<div className="flex items-center p-2 border-b space-x-2">
|
||||||
{/* Navigation Buttons */}
|
{/* Navigation Buttons */}
|
||||||
<div className="flex space-x-1">
|
<div className="flex space-x-1">
|
||||||
@@ -690,7 +732,9 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
|
|||||||
: " text-purple-700 hover:bg-purple-200 dark:text-purple-300 dark:hover:bg-purple-900"
|
: " text-purple-700 hover:bg-purple-200 dark:text-purple-300 dark:hover:bg-purple-900"
|
||||||
}`}
|
}`}
|
||||||
disabled={
|
disabled={
|
||||||
loading || !selectedAppId || !isComponentSelectorInitialized
|
loading ||
|
||||||
|
!selectedAppId ||
|
||||||
|
!isComponentSelectorInitialized
|
||||||
}
|
}
|
||||||
data-testid="preview-pick-element-button"
|
data-testid="preview-pick-element-button"
|
||||||
>
|
>
|
||||||
@@ -707,6 +751,36 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
|
|||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
onClick={handleAnnotatorClick}
|
||||||
|
className={`p-1 rounded transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed ${
|
||||||
|
annotatorMode
|
||||||
|
? "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"
|
||||||
|
}`}
|
||||||
|
disabled={
|
||||||
|
loading ||
|
||||||
|
!selectedAppId ||
|
||||||
|
isPicking ||
|
||||||
|
!isComponentSelectorInitialized
|
||||||
|
}
|
||||||
|
data-testid="preview-annotator-button"
|
||||||
|
>
|
||||||
|
<Pen size={16} />
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>
|
||||||
|
{annotatorMode
|
||||||
|
? "Annotator mode active"
|
||||||
|
: "Activate annotator"}
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
<button
|
<button
|
||||||
className="p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed dark:text-gray-300"
|
className="p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed dark:text-gray-300"
|
||||||
disabled={!canGoBack || loading || !selectedAppId}
|
disabled={!canGoBack || loading || !selectedAppId}
|
||||||
@@ -762,7 +836,9 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<DropdownMenuItem disabled>Loading routes...</DropdownMenuItem>
|
<DropdownMenuItem disabled>
|
||||||
|
Loading routes...
|
||||||
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
@@ -870,8 +946,9 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
|
|||||||
</Popover>
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="relative flex-grow ">
|
<div className="relative flex-grow overflow-hidden">
|
||||||
<ErrorBanner
|
<ErrorBanner
|
||||||
error={errorMessage}
|
error={errorMessage}
|
||||||
onDismiss={() => setErrorMessage(undefined)}
|
onDismiss={() => setErrorMessage(undefined)}
|
||||||
@@ -899,6 +976,29 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
|
|||||||
deviceMode !== "desktop" && "flex justify-center",
|
deviceMode !== "desktop" && "flex justify-center",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
{annotatorMode && screenshotDataUrl ? (
|
||||||
|
<div
|
||||||
|
className="w-full h-full bg-white dark:bg-gray-950"
|
||||||
|
style={
|
||||||
|
deviceMode == "desktop"
|
||||||
|
? {}
|
||||||
|
: { width: `${deviceWidthConfig[deviceMode]}px` }
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{userBudget ? (
|
||||||
|
<Annotator
|
||||||
|
screenshotUrl={screenshotDataUrl}
|
||||||
|
onSubmit={addAttachments}
|
||||||
|
handleAnnotatorClick={handleAnnotatorClick}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<AnnotatorOnlyForPro
|
||||||
|
onGoBack={() => setAnnotatorMode(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<iframe
|
<iframe
|
||||||
sandbox="allow-same-origin allow-scripts allow-forms allow-popups allow-modals allow-orientation-lock allow-pointer-lock allow-presentation allow-downloads"
|
sandbox="allow-same-origin allow-scripts allow-forms allow-popups allow-modals allow-orientation-lock allow-pointer-lock allow-presentation allow-downloads"
|
||||||
data-testid="preview-iframe-element"
|
data-testid="preview-iframe-element"
|
||||||
@@ -918,7 +1018,9 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
|
|||||||
allow="clipboard-read; clipboard-write; fullscreen; microphone; camera; display-capture; geolocation; autoplay; picture-in-picture"
|
allow="clipboard-read; clipboard-write; fullscreen; microphone; camera; display-capture; geolocation; autoplay; picture-in-picture"
|
||||||
/>
|
/>
|
||||||
{/* Visual Editing Toolbar */}
|
{/* Visual Editing Toolbar */}
|
||||||
{isProMode && visualEditingSelectedComponent && selectedAppId && (
|
{isProMode &&
|
||||||
|
visualEditingSelectedComponent &&
|
||||||
|
selectedAppId && (
|
||||||
<VisualEditingToolbar
|
<VisualEditingToolbar
|
||||||
selectedComponent={visualEditingSelectedComponent}
|
selectedComponent={visualEditingSelectedComponent}
|
||||||
iframeRef={iframeRef}
|
iframeRef={iframeRef}
|
||||||
@@ -926,6 +1028,8 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
|
|||||||
hasStaticText={hasStaticText}
|
hasStaticText={hasStaticText}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
25
src/components/preview_panel/ToolbarColorPicker.tsx
Normal file
25
src/components/preview_panel/ToolbarColorPicker.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import React, { useState, useRef } from "react";
|
import React, { useRef, useState } from "react";
|
||||||
import type { FileAttachment } from "@/ipc/ipc_types";
|
import type { FileAttachment } from "@/ipc/ipc_types";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { attachmentsAtom } from "@/atoms/chatAtoms";
|
||||||
|
|
||||||
export function useAttachments() {
|
export function useAttachments() {
|
||||||
const [attachments, setAttachments] = useState<FileAttachment[]>([]);
|
const [attachments, setAttachments] = useAtom(attachmentsAtom);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [isDraggingOver, setIsDraggingOver] = useState(false);
|
const [isDraggingOver, setIsDraggingOver] = useState(false);
|
||||||
|
|
||||||
@@ -133,5 +135,6 @@ export function useAttachments() {
|
|||||||
handleDrop,
|
handleDrop,
|
||||||
clearAttachments,
|
clearAttachments,
|
||||||
handlePaste,
|
handlePaste,
|
||||||
|
addAttachments,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
167
src/pro/ui/components/Annotator/AnnotationCanvas.tsx
Normal file
167
src/pro/ui/components/Annotator/AnnotationCanvas.tsx
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
408
src/pro/ui/components/Annotator/Annotator.tsx
Normal file
408
src/pro/ui/components/Annotator/Annotator.tsx
Normal file
@@ -0,0 +1,408 @@
|
|||||||
|
import React, { useState, useRef, useEffect, useMemo } from "react";
|
||||||
|
import { AnnotationCanvas } from "./AnnotationCanvas";
|
||||||
|
import { AnnotatorToolbar } from "@/components/preview_panel/AnnotatorToolbar";
|
||||||
|
import { DraggableTextInput } from "@/components/preview_panel/DraggableTextInput";
|
||||||
|
import { useSetAtom } from "jotai";
|
||||||
|
import { chatInputValueAtom } from "@/atoms/chatAtoms";
|
||||||
|
|
||||||
|
// Types
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Custom Image Hook
|
||||||
|
const useImage = (url: string) => {
|
||||||
|
const [image, setImage] = useState<HTMLImageElement | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
const img = new Image();
|
||||||
|
img.src = url;
|
||||||
|
img.onload = () => setImage(img);
|
||||||
|
}, [url]);
|
||||||
|
return image;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Annotator = ({
|
||||||
|
screenshotUrl,
|
||||||
|
onSubmit,
|
||||||
|
handleAnnotatorClick,
|
||||||
|
}: {
|
||||||
|
screenshotUrl: string;
|
||||||
|
onSubmit?: (
|
||||||
|
file: File[],
|
||||||
|
type?: "chat-context" | "upload-to-codebase",
|
||||||
|
) => void;
|
||||||
|
handleAnnotatorClick: () => void;
|
||||||
|
}) => {
|
||||||
|
const image = useImage(screenshotUrl);
|
||||||
|
const [tool, setTool] = useState<"select" | "draw" | "text">("draw");
|
||||||
|
const [color, setColor] = useState<string>("#7f22fe");
|
||||||
|
const [shapes, setShapes] = useState<Shape[]>([]);
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
const [history, setHistory] = useState<Shape[][]>([]);
|
||||||
|
const [historyStep, setHistoryStep] = useState(0);
|
||||||
|
const spanRef = useRef<HTMLSpanElement[]>([]);
|
||||||
|
const inputRef = useRef<HTMLInputElement[]>([]);
|
||||||
|
const setChatInput = useSetAtom(chatInputValueAtom);
|
||||||
|
|
||||||
|
// Text input state - now supports multiple inputs
|
||||||
|
const [textInputs, setTextInputs] = useState<
|
||||||
|
Array<{
|
||||||
|
id: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
adjustedX: number;
|
||||||
|
adjustedY: number;
|
||||||
|
value: string;
|
||||||
|
color: string;
|
||||||
|
}>
|
||||||
|
>([]);
|
||||||
|
|
||||||
|
// Drawing state
|
||||||
|
const isDrawing = useRef(false);
|
||||||
|
const stageRef = useRef<any>(null);
|
||||||
|
const transformerRef = useRef<any>(null);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Container dimensions
|
||||||
|
const [containerSize, setContainerSize] = useState({ width: 0, height: 0 });
|
||||||
|
|
||||||
|
// Track container size
|
||||||
|
useEffect(() => {
|
||||||
|
if (!containerRef.current) return;
|
||||||
|
|
||||||
|
const resizeObserver = new ResizeObserver((entries) => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
const { width, height } = entry.contentRect;
|
||||||
|
setContainerSize({ width, height });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
resizeObserver.observe(containerRef.current);
|
||||||
|
|
||||||
|
return () => resizeObserver.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Initialize history
|
||||||
|
useEffect(() => {
|
||||||
|
if (history.length === 0) {
|
||||||
|
setHistory([[]]);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Save history
|
||||||
|
const saveHistory = (newShapes: Shape[]) => {
|
||||||
|
const newHistory = history.slice(0, historyStep + 1);
|
||||||
|
newHistory.push(newShapes);
|
||||||
|
setHistory(newHistory);
|
||||||
|
setHistoryStep(newHistory.length - 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!stageRef.current || !onSubmit) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Auto-submit any pending text inputs
|
||||||
|
textInputs.forEach((input) => {
|
||||||
|
if (input.value.trim()) {
|
||||||
|
const newShape: Shape = {
|
||||||
|
id: Date.now().toString(),
|
||||||
|
type: "text",
|
||||||
|
x: input.x + 32,
|
||||||
|
y: input.y + 8,
|
||||||
|
text: input.value,
|
||||||
|
fontSize: 24,
|
||||||
|
color: input.color,
|
||||||
|
};
|
||||||
|
setShapes((prev) => [...prev, newShape]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setTextInputs([]);
|
||||||
|
|
||||||
|
// Wait a tick for state to update
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
// Export the stage as a blob
|
||||||
|
const uri = stageRef.current.toDataURL({ pixelRatio: 2 });
|
||||||
|
|
||||||
|
// Convert data URL to blob
|
||||||
|
const response = await fetch(uri);
|
||||||
|
const blob = await response.blob();
|
||||||
|
|
||||||
|
// Create a File from the blob
|
||||||
|
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||||
|
const file = new File([blob], `annotated-screenshot-${timestamp}.png`, {
|
||||||
|
type: "image/png",
|
||||||
|
});
|
||||||
|
|
||||||
|
onSubmit([file], "chat-context");
|
||||||
|
setChatInput("Please update the UI based on these screenshots");
|
||||||
|
handleAnnotatorClick();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to export annotated image:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUndo = () => {
|
||||||
|
if (historyStep > 0) {
|
||||||
|
setHistoryStep(historyStep - 1);
|
||||||
|
setShapes(history[historyStep - 1]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRedo = () => {
|
||||||
|
if (historyStep < history.length - 1) {
|
||||||
|
setHistoryStep(historyStep + 1);
|
||||||
|
setShapes(history[historyStep + 1]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
if (selectedId) {
|
||||||
|
const newShapes = shapes.filter((s) => s.id !== selectedId);
|
||||||
|
setShapes(newShapes);
|
||||||
|
setSelectedId(null);
|
||||||
|
saveHistory(newShapes);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTextInputChange = (inputId: string, value: string) => {
|
||||||
|
setTextInputs((prev) =>
|
||||||
|
prev.map((i) => (i.id === inputId ? { ...i, value } : i)),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTextInputMove = (
|
||||||
|
inputId: string,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
adjustedX: number,
|
||||||
|
adjustedY: number,
|
||||||
|
) => {
|
||||||
|
setTextInputs((prev) =>
|
||||||
|
prev.map((i) =>
|
||||||
|
i.id === inputId ? { ...i, x, y, adjustedX, adjustedY } : i,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTextInputKeyDown = (
|
||||||
|
inputId: string,
|
||||||
|
e: React.KeyboardEvent,
|
||||||
|
index: number,
|
||||||
|
) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
if (!spanRef.current[index] || !inputRef.current[index]) return;
|
||||||
|
spanRef.current[index].textContent = inputRef.current[index].value || "";
|
||||||
|
const width = spanRef.current[index].offsetWidth + 8; // padding
|
||||||
|
inputRef.current[index].style.width = width + "px";
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
setTextInputs((prev) => prev.filter((i) => i.id !== inputId));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTextInputRemove = (inputId: string) => {
|
||||||
|
setTextInputs((prev) => prev.filter((i) => i.id !== inputId));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseDown = (e: any) => {
|
||||||
|
if (tool === "select") {
|
||||||
|
const clickedOnEmpty = e.target === e.target.getStage();
|
||||||
|
if (clickedOnEmpty) {
|
||||||
|
setSelectedId(null);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pos = e.target.getStage().getPointerPosition();
|
||||||
|
if (!pos) return;
|
||||||
|
|
||||||
|
// Adjust coordinates for scale
|
||||||
|
const adjustedPos = {
|
||||||
|
x: pos.x / scale,
|
||||||
|
y: pos.y / scale,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (tool === "draw") {
|
||||||
|
isDrawing.current = true;
|
||||||
|
const id = Date.now().toString();
|
||||||
|
const newShape: Shape = {
|
||||||
|
id,
|
||||||
|
type: "line",
|
||||||
|
points: [[adjustedPos.x, adjustedPos.y]],
|
||||||
|
color: color,
|
||||||
|
size: 6,
|
||||||
|
isComplete: false,
|
||||||
|
};
|
||||||
|
setShapes([...shapes, newShape]);
|
||||||
|
setSelectedId(null);
|
||||||
|
} else if (tool === "text") {
|
||||||
|
const newInput = {
|
||||||
|
id: Date.now().toString(),
|
||||||
|
x: pos.x,
|
||||||
|
y: pos.y,
|
||||||
|
adjustedX: adjustedPos.x,
|
||||||
|
adjustedY: adjustedPos.y,
|
||||||
|
value: "",
|
||||||
|
color: color,
|
||||||
|
};
|
||||||
|
setTextInputs([...textInputs, newInput]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseMove = (e: any) => {
|
||||||
|
if (tool !== "draw" || !isDrawing.current) return;
|
||||||
|
|
||||||
|
const stage = e.target.getStage();
|
||||||
|
const point = stage.getPointerPosition();
|
||||||
|
if (!point) return;
|
||||||
|
|
||||||
|
// Adjust coordinates for scale
|
||||||
|
const adjustedPoint = {
|
||||||
|
x: point.x / scale,
|
||||||
|
y: point.y / scale,
|
||||||
|
};
|
||||||
|
|
||||||
|
const lastShape = shapes[shapes.length - 1];
|
||||||
|
if (lastShape && lastShape.type === "line") {
|
||||||
|
// Append point
|
||||||
|
const newPoints = [
|
||||||
|
...lastShape.points,
|
||||||
|
[adjustedPoint.x, adjustedPoint.y] as Point,
|
||||||
|
];
|
||||||
|
const updatedShape = { ...lastShape, points: newPoints };
|
||||||
|
|
||||||
|
// Update shapes without saving history yet (performance)
|
||||||
|
const newShapes = shapes.slice(0, -1).concat(updatedShape);
|
||||||
|
setShapes(newShapes);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
if (tool === "draw" && isDrawing.current) {
|
||||||
|
isDrawing.current = false;
|
||||||
|
const lastShape = shapes[shapes.length - 1];
|
||||||
|
if (lastShape && lastShape.type === "line") {
|
||||||
|
const completedShape = { ...lastShape, isComplete: true };
|
||||||
|
const newShapes = shapes.slice(0, -1).concat(completedShape);
|
||||||
|
setShapes(newShapes);
|
||||||
|
saveHistory(newShapes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update transformer selection
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedId && transformerRef.current && stageRef.current) {
|
||||||
|
const node = stageRef.current.findOne("#" + selectedId);
|
||||||
|
if (node) {
|
||||||
|
transformerRef.current.nodes([node]);
|
||||||
|
transformerRef.current.getLayer().batchDraw();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [selectedId, shapes]);
|
||||||
|
|
||||||
|
// Calculate scale to fit image in container
|
||||||
|
const scale = useMemo(() => {
|
||||||
|
if (!image || containerSize.width === 0 || containerSize.height === 0)
|
||||||
|
return 1;
|
||||||
|
|
||||||
|
const scaleX = containerSize.width / image.width;
|
||||||
|
|
||||||
|
// Fit width and allow scrolling for height
|
||||||
|
return scaleX;
|
||||||
|
}, [image, containerSize]);
|
||||||
|
|
||||||
|
// Calculate actual stage dimensions
|
||||||
|
const stageDimensions = useMemo(() => {
|
||||||
|
if (!image)
|
||||||
|
return {
|
||||||
|
width: containerSize.width || 800,
|
||||||
|
height: containerSize.height || 600,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
width: image.width * scale,
|
||||||
|
height: image.height * scale,
|
||||||
|
};
|
||||||
|
}, [image, scale, containerSize]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full h-full flex flex-col bg-gray-50 dark:bg-gray-900">
|
||||||
|
{/* Toolbar */}
|
||||||
|
<AnnotatorToolbar
|
||||||
|
tool={tool}
|
||||||
|
color={color}
|
||||||
|
selectedId={selectedId}
|
||||||
|
historyStep={historyStep}
|
||||||
|
historyLength={history.length}
|
||||||
|
onToolChange={setTool}
|
||||||
|
onColorChange={setColor}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
onUndo={handleUndo}
|
||||||
|
onRedo={handleRedo}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
onDeactivate={handleAnnotatorClick}
|
||||||
|
hasSubmitHandler={!!onSubmit}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Canvas Container - Scrollable */}
|
||||||
|
<div ref={containerRef} className="flex-1 relative overflow-auto">
|
||||||
|
{textInputs.map((input, index) => (
|
||||||
|
<DraggableTextInput
|
||||||
|
key={input.id}
|
||||||
|
input={input}
|
||||||
|
index={index}
|
||||||
|
totalInputs={textInputs.length}
|
||||||
|
scale={scale}
|
||||||
|
onMove={handleTextInputMove}
|
||||||
|
onChange={handleTextInputChange}
|
||||||
|
onKeyDown={handleTextInputKeyDown}
|
||||||
|
onRemove={handleTextInputRemove}
|
||||||
|
spanRef={spanRef}
|
||||||
|
inputRef={inputRef}
|
||||||
|
color={input.color}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<AnnotationCanvas
|
||||||
|
image={image}
|
||||||
|
shapes={shapes}
|
||||||
|
selectedId={selectedId}
|
||||||
|
tool={tool}
|
||||||
|
scale={scale}
|
||||||
|
stageDimensions={stageDimensions}
|
||||||
|
containerSize={containerSize}
|
||||||
|
stageRef={stageRef}
|
||||||
|
transformerRef={transformerRef}
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
onMouseMove={handleMouseMove}
|
||||||
|
onMouseUp={handleMouseUp}
|
||||||
|
onShapeSelect={setSelectedId}
|
||||||
|
onShapeDragEnd={(id, x, y) => {
|
||||||
|
const newShapes = shapes.map((s) =>
|
||||||
|
s.id === id ? { ...s, x, y } : s,
|
||||||
|
);
|
||||||
|
setShapes(newShapes);
|
||||||
|
saveHistory(newShapes);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
56
worker/dyad-screenshot-client.js
Normal file
56
worker/dyad-screenshot-client.js
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
(() => {
|
||||||
|
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 stacktraceJsContent = null;
|
||||||
let dyadShimContent = null;
|
let dyadShimContent = null;
|
||||||
let dyadComponentSelectorClientContent = null;
|
let dyadComponentSelectorClientContent = null;
|
||||||
|
let dyadScreenshotClientContent = null;
|
||||||
|
let htmlToImageContent = null;
|
||||||
let dyadVisualEditorClientContent = 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 {
|
try {
|
||||||
const stackTraceLibPath = path.join(
|
const stackTraceLibPath = path.join(
|
||||||
__dirname,
|
__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 {
|
try {
|
||||||
const dyadVisualEditorClientPath = path.join(
|
const dyadVisualEditorClientPath = path.join(
|
||||||
__dirname,
|
__dirname,
|
||||||
@@ -143,6 +181,26 @@ function injectHTML(buf) {
|
|||||||
'<script>console.warn("[proxy-worker] dyad component selector client was not injected.");</script>',
|
'<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) {
|
if (dyadVisualEditorClientContent) {
|
||||||
scripts.push(`<script>${dyadVisualEditorClientContent}</script>`);
|
scripts.push(`<script>${dyadVisualEditorClientContent}</script>`);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
Reference in New Issue
Block a user