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

Visual editor (Pro only) (#1828)

<!-- This is an auto-generated description by cubic. --> --- ## Summary by cubic Prototype visual editing mode for the preview app. Toggle the mode, pick elements (single or multiple), and edit margin, padding, border, background, static text, and text styles with live updates, then save changes back to code. - **New Features** - Pen tool button to enable/disable visual editing in the preview and toggle single/multi select; pro-only. - Inline toolbar anchored to the selected element for Margin (X/Y), Padding (X/Y), Border (width/radius/color), Background color, Edit Text (when static), and Text Style (font size/weight/color/font family). - Reads computed styles from the iframe and applies changes in real time; auto-appends px; overlay updates on scroll/resize. - Save/Discard dialog batches edits and writes Tailwind classes to source files via IPC; uses AST/recast to update className and text, replacing conflicting classes by prefix; supports multiple components. - New visual editor worker to get/apply styles and enable inline text editing via postMessage; selector client updated for coordinates streaming and highlight/deselect. - Proxy injects the visual editor client; new atoms track selected component, coordinates, and pending changes; component analysis flags dynamic styling and static text. - Uses runtimeId to correctly target and edit duplicate components. - **Dependencies** - Added @babel/parser for AST-based text updates. - Added recast for safer code transformations. <sup>Written for commit cdd50d33387a29103864f4743ae7570d64d61e93. Summary will update automatically on new commits.</sup> <!-- End of auto-generated description by cubic. -->
上级 c174778d
This is a simple basic response
=== src/pages/Index.tsx ===
// Update this page (the content is just a fallback if you fail to update the page)
import { MadeWithDyad } from "@/components/made-with-dyad";
const Index = () => {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-100">
<div className="text-center">
<h1 className="text-4xl font-bold mx-[20px] my-[10px]">Welcome to Your Blank App</h1>
<p className="text-xl text-gray-600">Start building your amazing project here!
</p>
</div>
<MadeWithDyad />
</div>
);
};
export default Index;
\ No newline at end of file
=== src/pages/Index.tsx ===
// Update this page (the content is just a fallback if you fail to update the page)
import { MadeWithDyad } from "@/components/made-with-dyad";
const Index = () => {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-100">
<div className="text-center">
<h1 className="text-4xl font-bold mb-4">Hello from E2E Test</h1>
<p className="text-xl text-gray-600">Start building your amazing project here!
</p>
</div>
<MadeWithDyad />
</div>
);
};
export default Index;
\ No newline at end of file
import { expect } from "@playwright/test";
import { testSkipIfWindows, Timeout } from "./helpers/test_helper";
const fs = require("fs");
const path = require("path");
testSkipIfWindows("edit style of one selected component", async ({ po }) => {
await po.setUpDyadPro();
await po.sendPrompt("tc=basic");
await po.clickTogglePreviewPanel();
await po.clickPreviewPickElement();
// Select a component
await po
.getPreviewIframeElement()
.contentFrame()
.getByRole("heading", { name: "Welcome to Your Blank App" })
.click();
// Wait for the toolbar to appear (check for the Margin button which is always visible)
const marginButton = po.page.getByRole("button", { name: "Margin" });
await expect(marginButton).toBeVisible({
timeout: Timeout.MEDIUM,
});
// Ensure the toolbar has proper coordinates before clicking
await expect(async () => {
const box = await marginButton.boundingBox();
expect(box).not.toBeNull();
expect(box!.y).toBeGreaterThan(0);
}).toPass({ timeout: Timeout.MEDIUM });
// Click on margin button to open the margin popover
await marginButton.click();
// Wait for the popover to fully open by checking for the popover content container
const marginDialog = po.page
.locator('[role="dialog"]')
.filter({ hasText: "Margin" });
await expect(marginDialog).toBeVisible({
timeout: Timeout.LONG,
});
// Edit margin - set horizontal margin
const marginXInput = po.page.getByLabel("Horizontal");
await marginXInput.fill("20");
// Edit margin - set vertical margin
const marginYInput = po.page.getByLabel("Vertical");
await marginYInput.fill("10");
// Close the popover by clicking outside or pressing escape
await po.page.keyboard.press("Escape");
// Check if the changes are applied to UI by verifying the visual changes dialog appears
await expect(po.page.getByText(/\d+ component[s]? modified/)).toBeVisible({
timeout: Timeout.MEDIUM,
});
// Save the changes
await po.page.getByRole("button", { name: "Save Changes" }).click();
// Wait for the success toast
await po.waitForToastWithText("Visual changes saved to source files");
// Verify that the changes are applied to codebase
await po.snapshotAppFiles({
name: "visual-editing-single-component-margin",
files: ["src/pages/Index.tsx"],
});
});
testSkipIfWindows("edit text of the selected component", async ({ po }) => {
await po.setUpDyadPro();
await po.sendPrompt("tc=basic");
await po.clickTogglePreviewPanel();
await po.clickPreviewPickElement();
// Click on component that contains static text
await po
.getPreviewIframeElement()
.contentFrame()
.getByRole("heading", { name: "Welcome to Your Blank App" })
.click();
// Wait for the toolbar to appear (check for the Margin button which is always visible)
await expect(po.page.getByRole("button", { name: "Margin" })).toBeVisible({
timeout: Timeout.MEDIUM,
});
// Get the iframe and access the content
const iframe = po.getPreviewIframeElement();
const frame = iframe.contentFrame();
// Find the heading element in the iframe
const heading = frame.getByRole("heading", {
name: "Welcome to Your Blank App",
});
await heading.dblclick();
// Wait for contentEditable to be enabled
await expect(async () => {
const isEditable = await heading.evaluate(
(el) => (el as HTMLElement).isContentEditable,
);
expect(isEditable).toBe(true);
}).toPass({ timeout: Timeout.MEDIUM });
// Clear the existing text and type new text
await heading.press("Meta+A");
await heading.type("Hello from E2E Test");
// Click outside to finish editing
await frame.locator("body").click({ position: { x: 10, y: 10 } });
// Verify the changes are applied in the UI
await expect(frame.getByText("Hello from E2E Test")).toBeVisible({
timeout: Timeout.MEDIUM,
});
// Verify the visual changes dialog appears
await expect(po.page.getByText(/\d+ component[s]? modified/)).toBeVisible({
timeout: Timeout.MEDIUM,
});
// Save the changes
await po.page.getByRole("button", { name: "Save Changes" }).click();
// Wait for the success toast
await po.waitForToastWithText("Visual changes saved to source files");
// Verify that the changes are applied to the codebase
await po.snapshotAppFiles({
name: "visual-editing-text-content",
files: ["src/pages/Index.tsx"],
});
});
testSkipIfWindows("discard changes", async ({ po }) => {
await po.setUpDyadPro();
await po.sendPrompt("tc=basic");
await po.clickTogglePreviewPanel();
await po.clickPreviewPickElement();
// Select a component
await po
.getPreviewIframeElement()
.contentFrame()
.getByRole("heading", { name: "Welcome to Your Blank App" })
.click();
// Wait for the toolbar to appear (check for the Margin button which is always visible)
const marginButton = po.page.getByRole("button", { name: "Margin" });
await expect(marginButton).toBeVisible({
timeout: Timeout.MEDIUM,
});
// Ensure the toolbar has proper coordinates before clicking
await expect(async () => {
const box = await marginButton.boundingBox();
expect(box).not.toBeNull();
expect(box!.y).toBeGreaterThan(0);
}).toPass({ timeout: Timeout.MEDIUM });
// Click on margin button to open the margin popover
await marginButton.click();
// Wait for the popover to fully open by checking for the popover content container
const marginDialog = po.page
.locator('[role="dialog"]')
.filter({ hasText: "Margin" });
await expect(marginDialog).toBeVisible({
timeout: Timeout.LONG,
});
// Edit margin
const marginXInput = po.page.getByLabel("Horizontal");
await marginXInput.fill("30");
const marginYInput = po.page.getByLabel("Vertical");
await marginYInput.fill("30");
// Close the popover
await po.page.keyboard.press("Escape");
// Wait for the popover to close
await expect(marginDialog).not.toBeVisible({
timeout: Timeout.MEDIUM,
});
// Check if the changes are applied to UI
await expect(po.page.getByText(/\d+ component[s]? modified/)).toBeVisible({
timeout: Timeout.MEDIUM,
});
// Take a snapshot of the app files before discarding
const appPathBefore = await po.getCurrentAppPath();
const appFileBefore = fs.readFileSync(
path.join(appPathBefore, "src", "pages", "Index.tsx"),
"utf-8",
);
// Discard the changes
await po.page.getByRole("button", { name: "Discard" }).click();
// Verify the visual changes dialog is gone
await expect(po.page.getByText(/\d+ component[s]? modified/)).not.toBeVisible(
{ timeout: Timeout.MEDIUM },
);
// Verify that the changes are NOT applied to codebase
const appFileAfter = fs.readFileSync(
path.join(appPathBefore, "src", "pages", "Index.tsx"),
"utf-8",
);
// The file content should be the same as before
expect(appFileAfter).toBe(appFileBefore);
});
...@@ -18,6 +18,7 @@ ...@@ -18,6 +18,7 @@
"@ai-sdk/openai-compatible": "^1.0.8", "@ai-sdk/openai-compatible": "^1.0.8",
"@ai-sdk/provider-utils": "^3.0.3", "@ai-sdk/provider-utils": "^3.0.3",
"@ai-sdk/xai": "^2.0.16", "@ai-sdk/xai": "^2.0.16",
"@babel/parser": "^7.28.5",
"@biomejs/biome": "^1.9.4", "@biomejs/biome": "^1.9.4",
"@dyad-sh/supabase-management-js": "v1.0.1", "@dyad-sh/supabase-management-js": "v1.0.1",
"@lexical/react": "^0.33.1", "@lexical/react": "^0.33.1",
...@@ -81,6 +82,7 @@ ...@@ -81,6 +82,7 @@
"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.5.2", "react-shiki": "^0.5.2",
"recast": "^0.23.11",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"shell-env": "^4.0.1", "shell-env": "^4.0.1",
"shiki": "^3.2.1", "shiki": "^3.2.1",
...@@ -638,9 +640,9 @@ ...@@ -638,9 +640,9 @@
} }
}, },
"node_modules/@babel/helper-validator-identifier": { "node_modules/@babel/helper-validator-identifier": {
"version": "7.27.1", "version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
"integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
...@@ -669,12 +671,12 @@ ...@@ -669,12 +671,12 @@
} }
}, },
"node_modules/@babel/parser": { "node_modules/@babel/parser": {
"version": "7.28.4", "version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.4.tgz", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz",
"integrity": "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==", "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/types": "^7.28.4" "@babel/types": "^7.28.5"
}, },
"bin": { "bin": {
"parser": "bin/babel-parser.js" "parser": "bin/babel-parser.js"
...@@ -755,13 +757,13 @@ ...@@ -755,13 +757,13 @@
} }
}, },
"node_modules/@babel/types": { "node_modules/@babel/types": {
"version": "7.28.4", "version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.4.tgz", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz",
"integrity": "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==", "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/helper-string-parser": "^7.27.1", "@babel/helper-string-parser": "^7.27.1",
"@babel/helper-validator-identifier": "^7.27.1" "@babel/helper-validator-identifier": "^7.28.5"
}, },
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
...@@ -8002,6 +8004,18 @@ ...@@ -8002,6 +8004,18 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/ast-types": {
"version": "0.16.1",
"resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz",
"integrity": "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.1"
},
"engines": {
"node": ">=4"
}
},
"node_modules/async-function": { "node_modules/async-function": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz",
...@@ -11450,6 +11464,19 @@ ...@@ -11450,6 +11464,19 @@
"url": "https://opencollective.com/eslint" "url": "https://opencollective.com/eslint"
} }
}, },
"node_modules/esprima": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
"integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
"license": "BSD-2-Clause",
"bin": {
"esparse": "bin/esparse.js",
"esvalidate": "bin/esvalidate.js"
},
"engines": {
"node": ">=4"
}
},
"node_modules/esquery": { "node_modules/esquery": {
"version": "1.6.0", "version": "1.6.0",
"resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz",
...@@ -18462,6 +18489,22 @@ ...@@ -18462,6 +18489,22 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/recast": {
"version": "0.23.11",
"resolved": "https://registry.npmjs.org/recast/-/recast-0.23.11.tgz",
"integrity": "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==",
"license": "MIT",
"dependencies": {
"ast-types": "^0.16.1",
"esprima": "~4.0.0",
"source-map": "~0.6.1",
"tiny-invariant": "^1.3.3",
"tslib": "^2.0.1"
},
"engines": {
"node": ">= 4"
}
},
"node_modules/rechoir": { "node_modules/rechoir": {
"version": "0.8.0", "version": "0.8.0",
"resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz", "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz",
...@@ -19721,7 +19764,6 @@ ...@@ -19721,7 +19764,6 @@
"version": "0.6.1", "version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"dev": true,
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
......
...@@ -94,6 +94,7 @@ ...@@ -94,6 +94,7 @@
"@ai-sdk/openai-compatible": "^1.0.8", "@ai-sdk/openai-compatible": "^1.0.8",
"@ai-sdk/provider-utils": "^3.0.3", "@ai-sdk/provider-utils": "^3.0.3",
"@ai-sdk/xai": "^2.0.16", "@ai-sdk/xai": "^2.0.16",
"@babel/parser": "^7.28.5",
"@biomejs/biome": "^1.9.4", "@biomejs/biome": "^1.9.4",
"@dyad-sh/supabase-management-js": "v1.0.1", "@dyad-sh/supabase-management-js": "v1.0.1",
"@lexical/react": "^0.33.1", "@lexical/react": "^0.33.1",
...@@ -157,6 +158,7 @@ ...@@ -157,6 +158,7 @@
"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.5.2", "react-shiki": "^0.5.2",
"recast": "^0.23.11",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"shell-env": "^4.0.1", "shell-env": "^4.0.1",
"shiki": "^3.2.1", "shiki": "^3.2.1",
......
import { describe, it, expect } from "vitest";
import { stylesToTailwind } from "../utils/style-utils";
describe("convertSpacingToTailwind", () => {
describe("margin conversion", () => {
it("should convert equal margins on all sides", () => {
const result = stylesToTailwind({
margin: { left: "16px", right: "16px", top: "16px", bottom: "16px" },
});
expect(result).toEqual(["m-[16px]"]);
});
it("should convert equal horizontal margins", () => {
const result = stylesToTailwind({
margin: { left: "16px", right: "16px" },
});
expect(result).toEqual(["mx-[16px]"]);
});
it("should convert equal vertical margins", () => {
const result = stylesToTailwind({
margin: { top: "16px", bottom: "16px" },
});
expect(result).toEqual(["my-[16px]"]);
});
});
describe("padding conversion", () => {
it("should convert equal padding on all sides", () => {
const result = stylesToTailwind({
padding: { left: "20px", right: "20px", top: "20px", bottom: "20px" },
});
expect(result).toEqual(["p-[20px]"]);
});
it("should convert equal horizontal padding", () => {
const result = stylesToTailwind({
padding: { left: "12px", right: "12px" },
});
expect(result).toEqual(["px-[12px]"]);
});
it("should convert equal vertical padding", () => {
const result = stylesToTailwind({
padding: { top: "8px", bottom: "8px" },
});
expect(result).toEqual(["py-[8px]"]);
});
});
describe("combined margin and padding", () => {
it("should handle both margin and padding", () => {
const result = stylesToTailwind({
margin: { left: "16px", right: "16px" },
padding: { top: "8px", bottom: "8px" },
});
expect(result).toContain("mx-[16px]");
expect(result).toContain("py-[8px]");
expect(result).toHaveLength(2);
});
});
describe("edge cases: equal horizontal and vertical spacing", () => {
it("should consolidate px = py to p when values match", () => {
const result = stylesToTailwind({
padding: { left: "16px", right: "16px", top: "16px", bottom: "16px" },
});
// When all four sides are equal, should use p-[]
expect(result).toEqual(["p-[16px]"]);
});
it("should consolidate mx = my to m when values match (but not all four sides)", () => {
const result = stylesToTailwind({
margin: { left: "20px", right: "20px", top: "20px", bottom: "20px" },
});
// When all four sides are equal, should use m-[]
expect(result).toEqual(["m-[20px]"]);
});
it("should not consolidate when px != py", () => {
const result = stylesToTailwind({
padding: { left: "16px", right: "16px", top: "8px", bottom: "8px" },
});
expect(result).toContain("px-[16px]");
expect(result).toContain("py-[8px]");
expect(result).toHaveLength(2);
});
it("should not consolidate when mx != my", () => {
const result = stylesToTailwind({
margin: { left: "20px", right: "20px", top: "10px", bottom: "10px" },
});
expect(result).toContain("mx-[20px]");
expect(result).toContain("my-[10px]");
expect(result).toHaveLength(2);
});
it("should handle case where left != right", () => {
const result = stylesToTailwind({
padding: { left: "16px", right: "12px", top: "8px", bottom: "8px" },
});
expect(result).toContain("pl-[16px]");
expect(result).toContain("pr-[12px]");
expect(result).toContain("py-[8px]");
expect(result).toHaveLength(3);
});
it("should handle case where top != bottom", () => {
const result = stylesToTailwind({
margin: { left: "20px", right: "20px", top: "10px", bottom: "15px" },
});
expect(result).toContain("mx-[20px]");
expect(result).toContain("mt-[10px]");
expect(result).toContain("mb-[15px]");
expect(result).toHaveLength(3);
});
});
});
import { ComponentSelection } from "@/ipc/ipc_types"; import { ComponentSelection, VisualEditingChange } from "@/ipc/ipc_types";
import { atom } from "jotai"; import { atom } from "jotai";
export const selectedComponentsPreviewAtom = atom<ComponentSelection[]>([]); export const selectedComponentsPreviewAtom = atom<ComponentSelection[]>([]);
export const visualEditingSelectedComponentAtom =
atom<ComponentSelection | null>(null);
export const currentComponentCoordinatesAtom = atom<{
top: number;
left: number;
width: number;
height: number;
} | null>(null);
export const previewIframeRefAtom = atom<HTMLIFrameElement | null>(null); export const previewIframeRefAtom = atom<HTMLIFrameElement | null>(null);
export const pendingVisualChangesAtom = atom<Map<string, VisualEditingChange>>(
new Map(),
);
...@@ -16,6 +16,7 @@ import { ...@@ -16,6 +16,7 @@ import {
ChevronsDownUp, ChevronsDownUp,
ChartColumnIncreasing, ChartColumnIncreasing,
SendHorizontalIcon, SendHorizontalIcon,
Lock,
} from "lucide-react"; } from "lucide-react";
import type React from "react"; import type React from "react";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
...@@ -65,11 +66,16 @@ import { ChatErrorBox } from "./ChatErrorBox"; ...@@ -65,11 +66,16 @@ import { ChatErrorBox } from "./ChatErrorBox";
import { import {
selectedComponentsPreviewAtom, selectedComponentsPreviewAtom,
previewIframeRefAtom, previewIframeRefAtom,
visualEditingSelectedComponentAtom,
currentComponentCoordinatesAtom,
pendingVisualChangesAtom,
} from "@/atoms/previewAtoms"; } from "@/atoms/previewAtoms";
import { SelectedComponentsDisplay } from "./SelectedComponentDisplay"; import { SelectedComponentsDisplay } from "./SelectedComponentDisplay";
import { useCheckProblems } from "@/hooks/useCheckProblems"; import { useCheckProblems } from "@/hooks/useCheckProblems";
import { LexicalChatInput } from "./LexicalChatInput"; import { LexicalChatInput } from "./LexicalChatInput";
import { useChatModeToggle } from "@/hooks/useChatModeToggle"; import { useChatModeToggle } from "@/hooks/useChatModeToggle";
import { VisualEditingChangesDialog } from "@/components/preview_panel/VisualEditingChangesDialog";
import { useUserBudgetInfo } from "@/hooks/useUserBudgetInfo";
const showTokenBarAtom = atom(false); const showTokenBarAtom = atom(false);
...@@ -92,7 +98,15 @@ export function ChatInput({ chatId }: { chatId?: number }) { ...@@ -92,7 +98,15 @@ export function ChatInput({ chatId }: { chatId?: number }) {
selectedComponentsPreviewAtom, selectedComponentsPreviewAtom,
); );
const previewIframeRef = useAtomValue(previewIframeRefAtom); const previewIframeRef = useAtomValue(previewIframeRefAtom);
const setVisualEditingSelectedComponent = useSetAtom(
visualEditingSelectedComponentAtom,
);
const setCurrentComponentCoordinates = useSetAtom(
currentComponentCoordinatesAtom,
);
const setPendingVisualChanges = useSetAtom(pendingVisualChangesAtom);
const { checkProblems } = useCheckProblems(appId); const { checkProblems } = useCheckProblems(appId);
const { refreshAppIframe } = useRunApp();
// Use the attachments hook // Use the attachments hook
const { const {
attachments, attachments,
...@@ -124,6 +138,8 @@ export function ChatInput({ chatId }: { chatId?: number }) { ...@@ -124,6 +138,8 @@ export function ChatInput({ chatId }: { chatId?: number }) {
proposal.type === "code-proposal" && proposal.type === "code-proposal" &&
messageId === lastMessage.id; messageId === lastMessage.id;
const { userBudget } = useUserBudgetInfo();
useEffect(() => { useEffect(() => {
if (error) { if (error) {
setShowError(true); setShowError(true);
...@@ -160,7 +176,7 @@ export function ChatInput({ chatId }: { chatId?: number }) { ...@@ -160,7 +176,7 @@ export function ChatInput({ chatId }: { chatId?: number }) {
? selectedComponents ? selectedComponents
: []; : [];
setSelectedComponents([]); setSelectedComponents([]);
setVisualEditingSelectedComponent(null);
// Clear overlays in the preview iframe // Clear overlays in the preview iframe
if (previewIframeRef?.contentWindow) { if (previewIframeRef?.contentWindow) {
previewIframeRef.contentWindow.postMessage( previewIframeRef.contentWindow.postMessage(
...@@ -307,6 +323,58 @@ export function ChatInput({ chatId }: { chatId?: number }) { ...@@ -307,6 +323,58 @@ export function ChatInput({ chatId }: { chatId?: number }) {
/> />
)} )}
{userBudget ? (
<VisualEditingChangesDialog
iframeRef={
previewIframeRef
? { current: previewIframeRef }
: { current: null }
}
onReset={() => {
// Exit component selection mode and visual editing
setSelectedComponents([]);
setVisualEditingSelectedComponent(null);
setCurrentComponentCoordinates(null);
setPendingVisualChanges(new Map());
refreshAppIframe();
// Deactivate component selector in iframe
if (previewIframeRef?.contentWindow) {
previewIframeRef.contentWindow.postMessage(
{ type: "deactivate-dyad-component-selector" },
"*",
);
}
}}
/>
) : (
selectedComponents.length > 0 && (
<div className="border-b border-border p-3 bg-muted/30">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => {
IpcClient.getInstance().openExternalUrl(
"https://dyad.sh/pro",
);
}}
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-primary transition-colors cursor-pointer"
>
<Lock size={16} />
<span className="font-medium">Visual editor (Pro)</span>
</button>
</TooltipTrigger>
<TooltipContent>
Visual editing lets you make UI changes without AI and is
a Pro-only feature
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
)
)}
<SelectedComponentsDisplay /> <SelectedComponentsDisplay />
{/* Use the AttachmentsList component */} {/* Use the AttachmentsList component */}
......
import { import {
selectedComponentsPreviewAtom, selectedComponentsPreviewAtom,
previewIframeRefAtom, previewIframeRefAtom,
visualEditingSelectedComponentAtom,
} from "@/atoms/previewAtoms"; } from "@/atoms/previewAtoms";
import { useAtom, useAtomValue } from "jotai"; import { useAtom, useAtomValue, useSetAtom } from "jotai";
import { Code2, X } from "lucide-react"; import { Code2, X } from "lucide-react";
export function SelectedComponentsDisplay() { export function SelectedComponentsDisplay() {
...@@ -10,11 +11,15 @@ export function SelectedComponentsDisplay() { ...@@ -10,11 +11,15 @@ export function SelectedComponentsDisplay() {
selectedComponentsPreviewAtom, selectedComponentsPreviewAtom,
); );
const previewIframeRef = useAtomValue(previewIframeRefAtom); const previewIframeRef = useAtomValue(previewIframeRefAtom);
const setVisualEditingSelectedComponent = useSetAtom(
visualEditingSelectedComponentAtom,
);
const handleRemoveComponent = (index: number) => { const handleRemoveComponent = (index: number) => {
const componentToRemove = selectedComponents[index]; const componentToRemove = selectedComponents[index];
const newComponents = selectedComponents.filter((_, i) => i !== index); const newComponents = selectedComponents.filter((_, i) => i !== index);
setSelectedComponents(newComponents); setSelectedComponents(newComponents);
setVisualEditingSelectedComponent(null);
// Remove the specific overlay from the iframe // Remove the specific overlay from the iframe
if (previewIframeRef?.contentWindow) { if (previewIframeRef?.contentWindow) {
...@@ -30,7 +35,7 @@ export function SelectedComponentsDisplay() { ...@@ -30,7 +35,7 @@ export function SelectedComponentsDisplay() {
const handleClearAll = () => { const handleClearAll = () => {
setSelectedComponents([]); setSelectedComponents([]);
setVisualEditingSelectedComponent(null);
if (previewIframeRef?.contentWindow) { if (previewIframeRef?.contentWindow) {
previewIframeRef.contentWindow.postMessage( previewIframeRef.contentWindow.postMessage(
{ type: "clear-dyad-component-overlays" }, { type: "clear-dyad-component-overlays" },
......
import { ReactNode } from "react";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
interface StylePopoverProps {
icon: ReactNode;
title: string;
tooltip: string;
children: ReactNode;
side?: "top" | "right" | "bottom" | "left";
}
export function StylePopover({
icon,
title,
tooltip,
children,
side = "bottom",
}: StylePopoverProps) {
return (
<Popover>
<PopoverTrigger asChild>
<button
className="p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 text-[#7f22fe] dark:text-gray-200"
aria-label={tooltip}
>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>{icon}</TooltipTrigger>
<TooltipContent side={side}>
<p>{tooltip}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</button>
</PopoverTrigger>
<PopoverContent side={side} className="w-64">
<div className="space-y-3">
<h4 className="font-medium text-sm" style={{ color: "#7f22fe" }}>
{title}
</h4>
{children}
</div>
</PopoverContent>
</Popover>
);
}
import { useAtom, useAtomValue } from "jotai";
import { pendingVisualChangesAtom } from "@/atoms/previewAtoms";
import { Button } from "@/components/ui/button";
import { IpcClient } from "@/ipc/ipc_client";
import { Check, X } from "lucide-react";
import { useState, useEffect, useRef } from "react";
import { showError, showSuccess } from "@/lib/toast";
import { selectedAppIdAtom } from "@/atoms/appAtoms";
interface VisualEditingChangesDialogProps {
onReset?: () => void;
iframeRef?: React.RefObject<HTMLIFrameElement | null>;
}
export function VisualEditingChangesDialog({
onReset,
iframeRef,
}: VisualEditingChangesDialogProps) {
const [pendingChanges, setPendingChanges] = useAtom(pendingVisualChangesAtom);
const selectedAppId = useAtomValue(selectedAppIdAtom);
const [isSaving, setIsSaving] = useState(false);
const textContentCache = useRef<Map<string, string>>(new Map());
const [allResponsesReceived, setAllResponsesReceived] = useState(false);
const expectedResponsesRef = useRef<Set<string>>(new Set());
const isWaitingForResponses = useRef(false);
// Listen for text content responses
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
if (event.data?.type === "dyad-text-content-response") {
const { componentId, text } = event.data;
if (text !== null) {
textContentCache.current.set(componentId, text);
}
// Mark this response as received
expectedResponsesRef.current.delete(componentId);
// Check if all responses received (only if we're actually waiting)
if (
isWaitingForResponses.current &&
expectedResponsesRef.current.size === 0
) {
setAllResponsesReceived(true);
}
}
};
window.addEventListener("message", handleMessage);
return () => window.removeEventListener("message", handleMessage);
}, []);
// Execute when all responses are received
useEffect(() => {
if (allResponsesReceived && isSaving) {
const applyChanges = async () => {
try {
const changesToSave = Array.from(pendingChanges.values());
// Update changes with cached text content
const updatedChanges = changesToSave.map((change) => {
const cachedText = textContentCache.current.get(change.componentId);
if (cachedText !== undefined) {
return { ...change, textContent: cachedText };
}
return change;
});
await IpcClient.getInstance().applyVisualEditingChanges({
appId: selectedAppId!,
changes: updatedChanges,
});
setPendingChanges(new Map());
textContentCache.current.clear();
showSuccess("Visual changes saved to source files");
onReset?.();
} catch (error) {
console.error("Failed to save visual editing changes:", error);
showError(`Failed to save changes: ${error}`);
} finally {
setIsSaving(false);
setAllResponsesReceived(false);
isWaitingForResponses.current = false;
}
};
applyChanges();
}
}, [
allResponsesReceived,
isSaving,
pendingChanges,
selectedAppId,
onReset,
setPendingChanges,
]);
if (pendingChanges.size === 0) return null;
const handleSave = async () => {
setIsSaving(true);
try {
const changesToSave = Array.from(pendingChanges.values());
if (iframeRef?.current?.contentWindow) {
// Reset state for new request
setAllResponsesReceived(false);
expectedResponsesRef.current.clear();
isWaitingForResponses.current = true;
// Track which components we're expecting responses from
for (const change of changesToSave) {
expectedResponsesRef.current.add(change.componentId);
}
// Request text content for each component
for (const change of changesToSave) {
iframeRef.current.contentWindow.postMessage(
{
type: "get-dyad-text-content",
data: { componentId: change.componentId },
},
"*",
);
}
// If no responses are expected, trigger immediately
if (expectedResponsesRef.current.size === 0) {
setAllResponsesReceived(true);
}
} else {
await IpcClient.getInstance().applyVisualEditingChanges({
appId: selectedAppId!,
changes: changesToSave,
});
setPendingChanges(new Map());
textContentCache.current.clear();
showSuccess("Visual changes saved to source files");
onReset?.();
}
} catch (error) {
console.error("Failed to save visual editing changes:", error);
showError(`Failed to save changes: ${error}`);
setIsSaving(false);
isWaitingForResponses.current = false;
}
};
const handleDiscard = () => {
setPendingChanges(new Map());
onReset?.();
};
return (
<div className="bg-[var(--background)] border-b border-[var(--border)] px-2 lg:px-4 py-1.5 flex flex-col lg:flex-row items-start lg:items-center lg:justify-between gap-1.5 lg:gap-4 flex-wrap">
<p className="text-xs lg:text-sm w-full lg:w-auto">
<span className="font-medium">{pendingChanges.size}</span> component
{pendingChanges.size > 1 ? "s" : ""} modified
</p>
<div className="flex gap-1 lg:gap-2 w-full lg:w-auto flex-wrap">
<Button size="sm" onClick={handleSave} disabled={isSaving}>
<Check size={14} className="mr-1" />
<span>{isSaving ? "Saving..." : "Save Changes"}</span>
</Button>
<Button
size="sm"
variant="outline"
onClick={handleDiscard}
disabled={isSaving}
>
<X size={14} className="mr-1" />
<span>Discard</span>
</Button>
</div>
</div>
);
}
import { Input } from "@/components/ui/input";
interface ColorPickerProps {
id: string;
label?: string;
value: string;
onChange: (value: string) => void;
className?: string;
}
export function ColorPicker({
id,
value,
onChange,
className = "",
}: ColorPickerProps) {
return (
<div className={`flex gap-2 ${className}`}>
<Input
id={id}
type="color"
className="h-8 w-12 p-1 cursor-pointer"
value={value}
onChange={(e) => onChange(e.target.value)}
/>
<Input
type="text"
placeholder="#000000"
className="h-8 text-xs flex-1"
value={value}
onChange={(e) => onChange(e.target.value)}
/>
</div>
);
}
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
interface NumberInputProps {
id: string;
label: string;
value: string;
onChange: (value: string) => void;
placeholder?: string;
step?: string;
min?: string;
className?: string;
}
export function NumberInput({
id,
label,
value,
onChange,
placeholder = "0",
step = "1",
min = "0",
className = "",
}: NumberInputProps) {
return (
<div className={className}>
<Label htmlFor={id} className="text-xs">
{label}
</Label>
<Input
id={id}
type="number"
placeholder={placeholder}
className="mt-1 h-8 text-xs"
value={value.replace(/[^\d.-]/g, "") || ""}
onChange={(e) => onChange(e.target.value)}
step={step}
min={min}
/>
</div>
);
}
...@@ -15,8 +15,14 @@ export function registerProHandlers() { ...@@ -15,8 +15,14 @@ export function registerProHandlers() {
// information and isn't critical to using the app // information and isn't critical to using the app
handle("get-user-budget", async (): Promise<UserBudgetInfo | null> => { handle("get-user-budget", async (): Promise<UserBudgetInfo | null> => {
if (IS_TEST_BUILD) { if (IS_TEST_BUILD) {
// Avoid spamming the API in E2E tests. // Return mock budget data for E2E tests instead of spamming the API
return null; const resetDate = new Date();
resetDate.setDate(resetDate.getDate() + 30); // Reset in 30 days
return {
usedCredits: 100,
totalCredits: 1000,
budgetResetDate: resetDate,
};
} }
logger.info("Attempting to fetch user budget information."); logger.info("Attempting to fetch user budget information.");
......
...@@ -70,6 +70,8 @@ import type { ...@@ -70,6 +70,8 @@ import type {
SupabaseBranch, SupabaseBranch,
SetSupabaseAppProjectParams, SetSupabaseAppProjectParams,
SelectNodeFolderResult, SelectNodeFolderResult,
ApplyVisualEditingChangesParams,
AnalyseComponentParams,
} from "./ipc_types"; } from "./ipc_types";
import type { Template } from "../shared/templates"; import type { Template } from "../shared/templates";
import type { import type {
...@@ -1327,4 +1329,17 @@ export class IpcClient { ...@@ -1327,4 +1329,17 @@ export class IpcClient {
public cancelHelpChat(sessionId: string): void { public cancelHelpChat(sessionId: string): void {
this.ipcRenderer.invoke("help:chat:cancel", sessionId).catch(() => {}); this.ipcRenderer.invoke("help:chat:cancel", sessionId).catch(() => {});
} }
// --- Visual Editing ---
public async applyVisualEditingChanges(
changes: ApplyVisualEditingChangesParams,
): Promise<void> {
await this.ipcRenderer.invoke("apply-visual-editing-changes", changes);
}
public async analyzeComponent(
params: AnalyseComponentParams,
): Promise<{ isDynamic: boolean; hasStaticText: boolean }> {
return this.ipcRenderer.invoke("analyze-component", params);
}
} }
...@@ -32,6 +32,7 @@ import { registerPromptHandlers } from "./handlers/prompt_handlers"; ...@@ -32,6 +32,7 @@ import { registerPromptHandlers } from "./handlers/prompt_handlers";
import { registerHelpBotHandlers } from "./handlers/help_bot_handlers"; import { registerHelpBotHandlers } from "./handlers/help_bot_handlers";
import { registerMcpHandlers } from "./handlers/mcp_handlers"; import { registerMcpHandlers } from "./handlers/mcp_handlers";
import { registerSecurityHandlers } from "./handlers/security_handlers"; import { registerSecurityHandlers } from "./handlers/security_handlers";
import { registerVisualEditingHandlers } from "../pro/main/ipc/handlers/visual_editing_handlers";
export function registerIpcHandlers() { export function registerIpcHandlers() {
// Register all IPC handlers by category // Register all IPC handlers by category
...@@ -69,4 +70,5 @@ export function registerIpcHandlers() { ...@@ -69,4 +70,5 @@ export function registerIpcHandlers() {
registerHelpBotHandlers(); registerHelpBotHandlers();
registerMcpHandlers(); registerMcpHandlers();
registerSecurityHandlers(); registerSecurityHandlers();
registerVisualEditingHandlers();
} }
...@@ -284,6 +284,7 @@ export type UserBudgetInfo = z.infer<typeof UserBudgetInfoSchema>; ...@@ -284,6 +284,7 @@ export type UserBudgetInfo = z.infer<typeof UserBudgetInfoSchema>;
export interface ComponentSelection { export interface ComponentSelection {
id: string; id: string;
name: string; name: string;
runtimeId?: string; // Unique runtime ID for duplicate components
relativePath: string; relativePath: string;
lineNumber: number; lineNumber: number;
columnNumber: number; columnNumber: number;
...@@ -548,3 +549,34 @@ export interface SelectNodeFolderResult { ...@@ -548,3 +549,34 @@ export interface SelectNodeFolderResult {
canceled?: boolean; canceled?: boolean;
selectedPath: string | null; selectedPath: string | null;
} }
export interface VisualEditingChange {
componentId: string;
componentName: string;
relativePath: string;
lineNumber: number;
styles: {
margin?: { left?: string; right?: string; top?: string; bottom?: string };
padding?: { left?: string; right?: string; top?: string; bottom?: string };
dimensions?: { width?: string; height?: string };
border?: { width?: string; radius?: string; color?: string };
backgroundColor?: string;
text?: {
fontSize?: string;
fontWeight?: string;
color?: string;
fontFamily?: string;
};
};
textContent?: string;
}
export interface ApplyVisualEditingChangesParams {
appId: number;
changes: VisualEditingChange[];
}
export interface AnalyseComponentParams {
appId: number;
componentId: string;
}
...@@ -5,6 +5,8 @@ import { contextBridge, ipcRenderer, webFrame } from "electron"; ...@@ -5,6 +5,8 @@ import { contextBridge, ipcRenderer, webFrame } from "electron";
// Whitelist of valid channels // Whitelist of valid channels
const validInvokeChannels = [ const validInvokeChannels = [
"analyze-component",
"apply-visual-editing-changes",
"get-language-models", "get-language-models",
"get-language-models-by-providers", "get-language-models-by-providers",
"create-custom-language-model", "create-custom-language-model",
......
import { ipcMain } from "electron";
import fs from "node:fs";
import { promises as fsPromises } from "node:fs";
import path from "path";
import { db } from "../../../../db";
import { apps } from "../../../../db/schema";
import { eq } from "drizzle-orm";
import { getDyadAppPath } from "../../../../paths/paths";
import {
stylesToTailwind,
extractClassPrefixes,
} from "../../../../utils/style-utils";
import git from "isomorphic-git";
import { gitCommit } from "../../../../ipc/utils/git_utils";
import { safeJoin } from "@/ipc/utils/path_utils";
import {
AnalyseComponentParams,
ApplyVisualEditingChangesParams,
} from "@/ipc/ipc_types";
import {
transformContent,
analyzeComponent,
} from "../../utils/visual_editing_utils";
export function registerVisualEditingHandlers() {
ipcMain.handle(
"apply-visual-editing-changes",
async (_event, params: ApplyVisualEditingChangesParams) => {
const { appId, changes } = params;
try {
if (changes.length === 0) return;
// Get the app to find its path
const app = await db.query.apps.findFirst({
where: eq(apps.id, appId),
});
if (!app) {
throw new Error(`App not found: ${appId}`);
}
const appPath = getDyadAppPath(app.path);
const fileChanges = new Map<
string,
Map<
number,
{ classes: string[]; prefixes: string[]; textContent?: string }
>
>();
// Group changes by file and line
for (const change of changes) {
if (!fileChanges.has(change.relativePath)) {
fileChanges.set(change.relativePath, new Map());
}
const tailwindClasses = stylesToTailwind(change.styles);
const changePrefixes = extractClassPrefixes(tailwindClasses);
fileChanges.get(change.relativePath)!.set(change.lineNumber, {
classes: tailwindClasses,
prefixes: changePrefixes,
...(change.textContent !== undefined && {
textContent: change.textContent,
}),
});
}
// Apply changes to each file
for (const [relativePath, lineChanges] of fileChanges) {
const filePath = safeJoin(appPath, relativePath);
const content = await fsPromises.readFile(filePath, "utf-8");
const transformedContent = transformContent(content, lineChanges);
await fsPromises.writeFile(filePath, transformedContent, "utf-8");
// Check if git repository exists and commit the change
if (fs.existsSync(path.join(appPath, ".git"))) {
await git.add({
fs,
dir: appPath,
filepath: relativePath,
});
await gitCommit({
path: appPath,
message: `Updated ${relativePath}`,
});
}
}
} catch (error) {
throw new Error(`Failed to apply visual editing changes: ${error}`);
}
},
);
ipcMain.handle(
"analyze-component",
async (_event, analyseComponentParams: AnalyseComponentParams) => {
const { appId, componentId } = analyseComponentParams;
try {
const [filePath, lineStr] = componentId.split(":");
const line = parseInt(lineStr, 10);
if (!filePath || isNaN(line)) {
return { isDynamic: false, hasStaticText: false };
}
// Get the app to find its path
const app = await db.query.apps.findFirst({
where: eq(apps.id, appId),
});
if (!app) {
throw new Error(`App not found: ${appId}`);
}
const appPath = getDyadAppPath(app.path);
const fullPath = safeJoin(appPath, filePath);
const content = await fsPromises.readFile(fullPath, "utf-8");
return analyzeComponent(content, line);
} catch (error) {
console.error("Failed to analyze component:", error);
return { isDynamic: false, hasStaticText: false };
}
},
);
}
差异被折叠。
// Style conversion and manipulation utilities
interface SpacingValues {
left?: string;
right?: string;
top?: string;
bottom?: string;
}
interface StyleObject {
margin?: { left?: string; right?: string; top?: string; bottom?: string };
padding?: { left?: string; right?: string; top?: string; bottom?: string };
dimensions?: { width?: string; height?: string };
border?: { width?: string; radius?: string; color?: string };
backgroundColor?: string;
text?: {
fontSize?: string;
fontWeight?: string;
color?: string;
fontFamily?: string;
};
}
/**
* Convert spacing values (margin/padding) to Tailwind classes
*/
function convertSpacingToTailwind(
values: SpacingValues,
prefix: "m" | "p",
): string[] {
const classes: string[] = [];
const { left, right, top, bottom } = values;
const hasHorizontal = left !== undefined && right !== undefined;
const hasVertical = top !== undefined && bottom !== undefined;
// All sides equal
if (
hasHorizontal &&
hasVertical &&
left === right &&
top === bottom &&
left === top
) {
classes.push(`${prefix}-[${left}]`);
} else {
const horizontalValue = hasHorizontal && left === right ? left : null;
const verticalValue = hasVertical && top === bottom ? top : null;
if (
horizontalValue !== null &&
verticalValue !== null &&
horizontalValue === verticalValue
) {
// px = py or mx = my, so use the shorthand for all sides
classes.push(`${prefix}-[${horizontalValue}]`);
} else {
// Horizontal
if (hasHorizontal && left === right) {
classes.push(`${prefix}x-[${left}]`);
} else {
if (left !== undefined) classes.push(`${prefix}l-[${left}]`);
if (right !== undefined) classes.push(`${prefix}r-[${right}]`);
}
// Vertical
if (hasVertical && top === bottom) {
classes.push(`${prefix}y-[${top}]`);
} else {
if (top !== undefined) classes.push(`${prefix}t-[${top}]`);
if (bottom !== undefined) classes.push(`${prefix}b-[${bottom}]`);
}
}
}
return classes;
}
/**
* Convert style object to Tailwind classes
*/
export function stylesToTailwind(styles: StyleObject): string[] {
const classes: string[] = [];
if (styles.margin) {
classes.push(...convertSpacingToTailwind(styles.margin, "m"));
}
if (styles.padding) {
classes.push(...convertSpacingToTailwind(styles.padding, "p"));
}
if (styles.border) {
if (styles.border.width !== undefined)
classes.push(`border-[${styles.border.width}]`);
if (styles.border.radius !== undefined)
classes.push(`rounded-[${styles.border.radius}]`);
if (styles.border.color !== undefined)
classes.push(`border-[${styles.border.color}]`);
}
if (styles.backgroundColor !== undefined) {
classes.push(`bg-[${styles.backgroundColor}]`);
}
if (styles.dimensions) {
if (styles.dimensions.width !== undefined)
classes.push(`w-[${styles.dimensions.width}]`);
if (styles.dimensions.height !== undefined)
classes.push(`h-[${styles.dimensions.height}]`);
}
if (styles.text) {
if (styles.text.fontSize !== undefined)
classes.push(`text-[${styles.text.fontSize}]`);
if (styles.text.fontWeight !== undefined)
classes.push(`font-[${styles.text.fontWeight}]`);
if (styles.text.color !== undefined)
classes.push(`[color:${styles.text.color}]`);
if (styles.text.fontFamily !== undefined) {
// Replace spaces with underscores for Tailwind arbitrary values
const fontFamilyValue = styles.text.fontFamily.replace(/\s/g, "_");
classes.push(`font-[${fontFamilyValue}]`);
}
}
return classes;
}
/**
* Convert RGB color to hex format
*/
export function rgbToHex(rgb: string): string {
if (!rgb || rgb.startsWith("#")) return rgb || "#000000";
const rgbMatch = rgb.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/);
if (rgbMatch) {
const r = parseInt(rgbMatch[1]).toString(16).padStart(2, "0");
const g = parseInt(rgbMatch[2]).toString(16).padStart(2, "0");
const b = parseInt(rgbMatch[3]).toString(16).padStart(2, "0");
return `#${r}${g}${b}`;
}
return rgb || "#000000";
}
/**
* Process value by adding px suffix if it's a plain number
*/
export function processNumericValue(value: string): string {
return /^\d+$/.test(value) ? `${value}px` : value;
}
/**
* Extract prefixes from Tailwind classes
*/
export function extractClassPrefixes(classes: string[]): string[] {
return Array.from(
new Set(
classes.map((cls) => {
// Handle arbitrary properties like [color:...]
const arbitraryMatch = cls.match(/^\[([a-z-]+):/);
if (arbitraryMatch) {
return `[${arbitraryMatch[1]}:`;
}
// Special handling for font-[...] classes
// We need to distinguish between font-weight and font-family
if (cls.startsWith("font-[")) {
const value = cls.match(/^font-\[([^\]]+)\]/);
if (value) {
// If it's numeric (like 400, 700), it's font-weight
// If it contains letters/underscores, it's font-family
const isNumeric = /^\d+$/.test(value[1]);
return isNumeric ? "font-weight-" : "font-family-";
}
}
// Special handling for text-size classes (text-xs, text-sm, text-3xl, etc.)
// to avoid removing text-center, text-left, text-color classes
if (cls.startsWith("text-")) {
// Check if it's a font-size class (ends with size suffix like xs, sm, lg, xl, 2xl, etc.)
const sizeMatch = cls.match(
/^text-(xs|sm|base|lg|xl|2xl|3xl|4xl|5xl|6xl|7xl|8xl|9xl)$/,
);
if (sizeMatch) {
return "text-size-"; // Use a specific prefix for font-size
}
// For arbitrary text sizes like text-[44px]
if (cls.match(/^text-\[[\d.]+[a-z]+\]$/)) {
return "text-size-";
}
}
// Handle regular Tailwind classes
const match = cls.match(/^([a-z]+[-])/);
return match ? match[1] : cls.split("-")[0] + "-";
}),
),
);
}
(() => {
/* ---------- helpers --------------------------------------------------- */
// Track text editing state globally
let textEditingState = new Map(); // componentId -> { originalText, currentText, cleanup }
function findElementByDyadId(dyadId, runtimeId) {
// If runtimeId is provided, try to find element by runtime ID first
if (runtimeId) {
const elementByRuntimeId = document.querySelector(
`[data-dyad-runtime-id="${runtimeId}"]`,
);
if (elementByRuntimeId) {
return elementByRuntimeId;
}
}
// Fall back to finding by dyad-id (will get first match)
const escaped = CSS.escape(dyadId);
return document.querySelector(`[data-dyad-id="${escaped}"]`);
}
function applyStyles(element, styles) {
if (!element || !styles) return;
console.debug(
`[Dyad Visual Editor] Applying styles:`,
styles,
"to element:",
element,
);
const applySpacing = (type, values) => {
if (!values) return;
Object.entries(values).forEach(([side, value]) => {
const cssProperty = `${type}${side.charAt(0).toUpperCase() + side.slice(1)}`;
element.style[cssProperty] = value;
});
};
applySpacing("margin", styles.margin);
applySpacing("padding", styles.padding);
if (styles.border) {
if (styles.border.width !== undefined) {
element.style.borderWidth = styles.border.width;
element.style.borderStyle = "solid";
}
if (styles.border.radius !== undefined) {
element.style.borderRadius = styles.border.radius;
}
if (styles.border.color !== undefined) {
element.style.borderColor = styles.border.color;
}
}
if (styles.backgroundColor !== undefined) {
element.style.backgroundColor = styles.backgroundColor;
}
if (styles.text) {
const textProps = {
fontSize: "fontSize",
fontWeight: "fontWeight",
fontFamily: "fontFamily",
color: "color",
};
Object.entries(textProps).forEach(([key, cssProp]) => {
if (styles.text[key] !== undefined) {
element.style[cssProp] = styles.text[key];
}
});
}
}
/* ---------- message handlers ------------------------------------------ */
function handleGetStyles(data) {
const { elementId, runtimeId } = data;
const element = findElementByDyadId(elementId, runtimeId);
if (element) {
const computedStyle = window.getComputedStyle(element);
const styles = {
margin: {
top: computedStyle.marginTop,
right: computedStyle.marginRight,
bottom: computedStyle.marginBottom,
left: computedStyle.marginLeft,
},
padding: {
top: computedStyle.paddingTop,
right: computedStyle.paddingRight,
bottom: computedStyle.paddingBottom,
left: computedStyle.paddingLeft,
},
border: {
width: computedStyle.borderWidth,
radius: computedStyle.borderRadius,
color: computedStyle.borderColor,
},
backgroundColor: computedStyle.backgroundColor,
text: {
fontSize: computedStyle.fontSize,
fontWeight: computedStyle.fontWeight,
fontFamily: computedStyle.fontFamily,
color: computedStyle.color,
},
};
window.parent.postMessage(
{
type: "dyad-component-styles",
data: styles,
},
"*",
);
}
}
function handleModifyStyles(data) {
const { elementId, runtimeId, styles } = data;
const element = findElementByDyadId(elementId, runtimeId);
if (element) {
applyStyles(element, styles);
// Send updated coordinates after style change
const rect = element.getBoundingClientRect();
window.parent.postMessage(
{
type: "dyad-component-coordinates-updated",
coordinates: {
top: rect.top,
left: rect.left,
width: rect.width,
height: rect.height,
},
},
"*",
);
}
}
function handleEnableTextEditing(data) {
const { componentId, runtimeId } = data;
// Clean up any existing text editing states first
textEditingState.forEach((state, existingId) => {
if (existingId !== componentId) {
state.cleanup();
}
});
const element = findElementByDyadId(componentId, runtimeId);
if (element) {
const originalText = element.innerText;
element.contentEditable = "true";
element.focus();
// Select all text
const range = document.createRange();
range.selectNodeContents(element);
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
// Send updates as user types
const onInput = () => {
const currentText = element.innerText;
// Update tracked state
const state = textEditingState.get(componentId);
if (state) {
state.currentText = currentText;
}
window.parent.postMessage(
{
type: "dyad-text-updated",
componentId,
text: currentText,
},
"*",
);
};
element.addEventListener("input", onInput);
// Prevent click from propagating to selector while editing
const stopProp = (e) => e.stopPropagation();
element.addEventListener("click", stopProp);
// Cleanup function
const cleanup = () => {
element.contentEditable = "false";
element.removeEventListener("input", onInput);
element.removeEventListener("click", stopProp);
// Send final text update
const finalText = element.innerText;
window.parent.postMessage(
{
type: "dyad-text-finalized",
componentId,
text: finalText,
},
"*",
);
textEditingState.delete(componentId);
};
// Store state
textEditingState.set(componentId, {
originalText,
currentText: originalText,
cleanup,
});
}
}
function handleDisableTextEditing(data) {
const { componentId } = data;
const state = textEditingState.get(componentId);
if (state) {
state.cleanup();
}
}
function handleGetTextContent(data) {
const { componentId, runtimeId } = data;
const element = findElementByDyadId(componentId, runtimeId);
const state = textEditingState.get(componentId);
window.parent.postMessage(
{
type: "dyad-text-content-response",
componentId,
text: state ? state.currentText : element ? element.innerText : null,
isEditing: !!state,
},
"*",
);
}
/* ---------- message bridge -------------------------------------------- */
window.addEventListener("message", (e) => {
if (e.source !== window.parent) return;
const { type, data } = e.data;
switch (type) {
case "get-dyad-component-styles":
handleGetStyles(data);
break;
case "modify-dyad-component-styles":
handleModifyStyles(data);
break;
case "enable-dyad-text-editing":
handleEnableTextEditing(data);
break;
case "disable-dyad-text-editing":
handleDisableTextEditing(data);
break;
case "get-dyad-text-content":
handleGetTextContent(data);
break;
case "cleanup-all-text-editing":
// Clean up all text editing states
textEditingState.forEach((state) => {
state.cleanup();
});
break;
}
});
})();
...@@ -38,6 +38,7 @@ let rememberedOrigin = null; // e.g. "http://localhost:5173" ...@@ -38,6 +38,7 @@ 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 dyadVisualEditorClientContent = null;
try { try {
const stackTraceLibPath = path.join( const stackTraceLibPath = path.join(
__dirname, __dirname,
...@@ -83,6 +84,24 @@ try { ...@@ -83,6 +84,24 @@ try {
); );
} }
try {
const dyadVisualEditorClientPath = path.join(
__dirname,
"dyad-visual-editor-client.js",
);
dyadVisualEditorClientContent = fs.readFileSync(
dyadVisualEditorClientPath,
"utf-8",
);
parentPort?.postMessage(
"[proxy-worker] dyad-visual-editor-client.js loaded.",
);
} catch (error) {
parentPort?.postMessage(
`[proxy-worker] Failed to read dyad-visual-editor-client.js: ${error.message}`,
);
}
/* ---------------------- helper: need to inject? ------------------------ */ /* ---------------------- helper: need to inject? ------------------------ */
function needsInjection(pathname) { function needsInjection(pathname) {
// Inject for routes without a file extension (e.g., "/foo", "/foo/bar", "/") // Inject for routes without a file extension (e.g., "/foo", "/foo/bar", "/")
...@@ -124,6 +143,13 @@ function injectHTML(buf) { ...@@ -124,6 +143,13 @@ 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 (dyadVisualEditorClientContent) {
scripts.push(`<script>${dyadVisualEditorClientContent}</script>`);
} else {
scripts.push(
'<script>console.warn("[proxy-worker] dyad visual editor client was not injected.");</script>',
);
}
const allScripts = scripts.join("\n"); const allScripts = scripts.join("\n");
const headRegex = /<head[^>]*>/i; const headRegex = /<head[^>]*>/i;
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论