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. -->
This commit is contained in:
committed by
GitHub
parent
c174778d5f
commit
352d4330ed
1
e2e-tests/fixtures/engine/basic.md
Normal file
1
e2e-tests/fixtures/engine/basic.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
This is a simple basic response
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
=== 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;
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
=== 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;
|
||||||
219
e2e-tests/visual_editing.spec.ts
Normal file
219
e2e-tests/visual_editing.spec.ts
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
66
package-lock.json
generated
66
package-lock.json
generated
@@ -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 @@
|
|||||||
"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 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"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 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"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 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"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 @@
|
|||||||
"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 @@
|
|||||||
"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 @@
|
|||||||
"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 @@
|
|||||||
"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 @@
|
|||||||
"@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 @@
|
|||||||
"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",
|
||||||
|
|||||||
118
src/__tests__/style-utils.test.ts
Normal file
118
src/__tests__/style-utils.test.ts
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,20 @@
|
|||||||
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 {
|
|||||||
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";
|
|||||||
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 }) {
|
|||||||
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 }) {
|
|||||||
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 }) {
|
|||||||
? 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 }) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{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 */}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
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() {
|
|||||||
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() {
|
|||||||
|
|
||||||
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" },
|
||||||
|
|||||||
@@ -36,9 +36,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,
|
||||||
|
currentComponentCoordinatesAtom,
|
||||||
previewIframeRefAtom,
|
previewIframeRefAtom,
|
||||||
|
pendingVisualChangesAtom,
|
||||||
} from "@/atoms/previewAtoms";
|
} from "@/atoms/previewAtoms";
|
||||||
import { ComponentSelection } from "@/ipc/ipc_types";
|
import { ComponentSelection } from "@/ipc/ipc_types";
|
||||||
import {
|
import {
|
||||||
@@ -57,6 +61,7 @@ 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 { VisualEditingToolbar } from "./VisualEditingToolbar";
|
||||||
|
|
||||||
interface ErrorBannerProps {
|
interface ErrorBannerProps {
|
||||||
error: { message: string; source: "preview-app" | "dyad-app" } | undefined;
|
error: { message: string; source: "preview-app" | "dyad-app" } | undefined;
|
||||||
@@ -167,6 +172,8 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
|
|||||||
const { streamMessage } = useStreamChat();
|
const { streamMessage } = useStreamChat();
|
||||||
const { routes: availableRoutes } = useParseRouter(selectedAppId);
|
const { routes: availableRoutes } = useParseRouter(selectedAppId);
|
||||||
const { restartApp } = useRunApp();
|
const { restartApp } = useRunApp();
|
||||||
|
const { userBudget } = useUserBudgetInfo();
|
||||||
|
const isProMode = !!userBudget;
|
||||||
|
|
||||||
// Navigation state
|
// Navigation state
|
||||||
const [isComponentSelectorInitialized, setIsComponentSelectorInitialized] =
|
const [isComponentSelectorInitialized, setIsComponentSelectorInitialized] =
|
||||||
@@ -175,12 +182,107 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
|
|||||||
const [canGoForward, setCanGoForward] = useState(false);
|
const [canGoForward, setCanGoForward] = useState(false);
|
||||||
const [navigationHistory, setNavigationHistory] = useState<string[]>([]);
|
const [navigationHistory, setNavigationHistory] = useState<string[]>([]);
|
||||||
const [currentHistoryPosition, setCurrentHistoryPosition] = useState(0);
|
const [currentHistoryPosition, setCurrentHistoryPosition] = useState(0);
|
||||||
const [selectedComponentsPreview, setSelectedComponentsPreview] = useAtom(
|
const setSelectedComponentsPreview = useSetAtom(
|
||||||
selectedComponentsPreviewAtom,
|
selectedComponentsPreviewAtom,
|
||||||
);
|
);
|
||||||
|
const [visualEditingSelectedComponent, setVisualEditingSelectedComponent] =
|
||||||
|
useAtom(visualEditingSelectedComponentAtom);
|
||||||
|
const setCurrentComponentCoordinates = useSetAtom(
|
||||||
|
currentComponentCoordinatesAtom,
|
||||||
|
);
|
||||||
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 setPendingChanges = useSetAtom(pendingVisualChangesAtom);
|
||||||
|
|
||||||
|
// AST Analysis State
|
||||||
|
const [isDynamicComponent, setIsDynamicComponent] = useState(false);
|
||||||
|
const [hasStaticText, setHasStaticText] = useState(false);
|
||||||
|
|
||||||
|
const analyzeComponent = async (componentId: string) => {
|
||||||
|
if (!componentId || !selectedAppId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await IpcClient.getInstance().analyzeComponent({
|
||||||
|
appId: selectedAppId,
|
||||||
|
componentId,
|
||||||
|
});
|
||||||
|
setIsDynamicComponent(result.isDynamic);
|
||||||
|
setHasStaticText(result.hasStaticText);
|
||||||
|
|
||||||
|
// Automatically enable text editing if component has static text
|
||||||
|
if (result.hasStaticText && iframeRef.current?.contentWindow) {
|
||||||
|
iframeRef.current.contentWindow.postMessage(
|
||||||
|
{
|
||||||
|
type: "enable-dyad-text-editing",
|
||||||
|
data: {
|
||||||
|
componentId: componentId,
|
||||||
|
runtimeId: visualEditingSelectedComponent?.runtimeId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"*",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to analyze component", err);
|
||||||
|
setIsDynamicComponent(false);
|
||||||
|
setHasStaticText(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTextUpdated = async (data: any) => {
|
||||||
|
const { componentId, text } = data;
|
||||||
|
if (!componentId || !selectedAppId) return;
|
||||||
|
|
||||||
|
// Parse componentId to extract file path and line number
|
||||||
|
const [filePath, lineStr] = componentId.split(":");
|
||||||
|
const lineNumber = parseInt(lineStr, 10);
|
||||||
|
|
||||||
|
if (!filePath || isNaN(lineNumber)) {
|
||||||
|
console.error("Invalid componentId format:", componentId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store text change in pending changes
|
||||||
|
setPendingChanges((prev) => {
|
||||||
|
const updated = new Map(prev);
|
||||||
|
const existing = updated.get(componentId);
|
||||||
|
|
||||||
|
updated.set(componentId, {
|
||||||
|
componentId: componentId,
|
||||||
|
componentName:
|
||||||
|
existing?.componentName || visualEditingSelectedComponent?.name || "",
|
||||||
|
relativePath: filePath,
|
||||||
|
lineNumber: lineNumber,
|
||||||
|
styles: existing?.styles || {},
|
||||||
|
textContent: text,
|
||||||
|
});
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Function to get current styles from selected element
|
||||||
|
const getCurrentElementStyles = () => {
|
||||||
|
if (!iframeRef.current?.contentWindow || !visualEditingSelectedComponent)
|
||||||
|
return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Send message to iframe to get current styles
|
||||||
|
iframeRef.current.contentWindow.postMessage(
|
||||||
|
{
|
||||||
|
type: "get-dyad-component-styles",
|
||||||
|
data: {
|
||||||
|
elementId: visualEditingSelectedComponent.id,
|
||||||
|
runtimeId: visualEditingSelectedComponent.runtimeId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"*",
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to get element styles:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Device mode state
|
// Device mode state
|
||||||
type DeviceMode = "desktop" | "tablet" | "mobile";
|
type DeviceMode = "desktop" | "tablet" | "mobile";
|
||||||
@@ -196,23 +298,30 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
|
|||||||
//detect if the user is using Mac
|
//detect if the user is using Mac
|
||||||
const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0;
|
const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0;
|
||||||
|
|
||||||
|
// Reset visual editing state when app changes or component unmounts
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
// Cleanup on unmount or when app changes
|
||||||
|
setVisualEditingSelectedComponent(null);
|
||||||
|
setPendingChanges(new Map());
|
||||||
|
setCurrentComponentCoordinates(null);
|
||||||
|
};
|
||||||
|
}, [selectedAppId]);
|
||||||
|
|
||||||
// Update iframe ref atom
|
// Update iframe ref atom
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPreviewIframeRef(iframeRef.current);
|
setPreviewIframeRef(iframeRef.current);
|
||||||
}, [iframeRef.current, setPreviewIframeRef]);
|
}, [iframeRef.current, setPreviewIframeRef]);
|
||||||
|
|
||||||
// Deactivate component selector when selection is cleared
|
// Send pro mode status to iframe
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedComponentsPreview || selectedComponentsPreview.length === 0) {
|
if (iframeRef.current?.contentWindow && isComponentSelectorInitialized) {
|
||||||
if (iframeRef.current?.contentWindow) {
|
iframeRef.current.contentWindow.postMessage(
|
||||||
iframeRef.current.contentWindow.postMessage(
|
{ type: "dyad-pro-mode", enabled: isProMode },
|
||||||
{ type: "deactivate-dyad-component-selector" },
|
"*",
|
||||||
"*",
|
);
|
||||||
);
|
|
||||||
}
|
|
||||||
setIsPicking(false);
|
|
||||||
}
|
}
|
||||||
}, [selectedComponentsPreview]);
|
}, [isProMode, isComponentSelectorInitialized]);
|
||||||
|
|
||||||
// Add message listener for iframe errors and navigation events
|
// Add message listener for iframe errors and navigation events
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -224,41 +333,92 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
|
|||||||
|
|
||||||
if (event.data?.type === "dyad-component-selector-initialized") {
|
if (event.data?.type === "dyad-component-selector-initialized") {
|
||||||
setIsComponentSelectorInitialized(true);
|
setIsComponentSelectorInitialized(true);
|
||||||
|
iframeRef.current?.contentWindow?.postMessage(
|
||||||
|
{ type: "dyad-pro-mode", enabled: isProMode },
|
||||||
|
"*",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.data?.type === "dyad-text-updated") {
|
||||||
|
handleTextUpdated(event.data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.data?.type === "dyad-text-finalized") {
|
||||||
|
handleTextUpdated(event.data);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.data?.type === "dyad-component-selected") {
|
if (event.data?.type === "dyad-component-selected") {
|
||||||
console.log("Component picked:", event.data);
|
console.log("Component picked:", event.data);
|
||||||
|
|
||||||
// Parse the single selected component
|
const component = parseComponentSelection(event.data);
|
||||||
const component = event.data.component
|
|
||||||
? parseComponentSelection({
|
|
||||||
type: "dyad-component-selected",
|
|
||||||
id: event.data.component.id,
|
|
||||||
name: event.data.component.name,
|
|
||||||
})
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (!component) return;
|
if (!component) return;
|
||||||
|
|
||||||
// Add to existing components, avoiding duplicates by id
|
// Store the coordinates
|
||||||
|
if (event.data.coordinates && isProMode) {
|
||||||
|
setCurrentComponentCoordinates(event.data.coordinates);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to selected components if not already there
|
||||||
setSelectedComponentsPreview((prev) => {
|
setSelectedComponentsPreview((prev) => {
|
||||||
// Check if this component is already selected
|
const exists = prev.some((c) => {
|
||||||
if (prev.some((c) => c.id === component.id)) {
|
// Check by runtimeId if available otherwise by id
|
||||||
|
// Stored components may have lost their runtimeId after re-renders or reloading the page
|
||||||
|
if (component.runtimeId && c.runtimeId) {
|
||||||
|
return c.runtimeId === component.runtimeId;
|
||||||
|
}
|
||||||
|
return c.id === component.id;
|
||||||
|
});
|
||||||
|
if (exists) {
|
||||||
return prev;
|
return prev;
|
||||||
}
|
}
|
||||||
return [...prev, component];
|
return [...prev, component];
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (isProMode) {
|
||||||
|
// Set as the highlighted component for visual editing
|
||||||
|
setVisualEditingSelectedComponent(component);
|
||||||
|
// Trigger AST analysis
|
||||||
|
analyzeComponent(component.id);
|
||||||
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.data?.type === "dyad-component-deselected") {
|
if (event.data?.type === "dyad-component-deselected") {
|
||||||
const componentId = event.data.componentId;
|
const componentId = event.data.componentId;
|
||||||
if (componentId) {
|
if (componentId) {
|
||||||
|
// Disable text editing for the deselected component
|
||||||
|
if (iframeRef.current?.contentWindow) {
|
||||||
|
iframeRef.current.contentWindow.postMessage(
|
||||||
|
{
|
||||||
|
type: "disable-dyad-text-editing",
|
||||||
|
data: { componentId },
|
||||||
|
},
|
||||||
|
"*",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
setSelectedComponentsPreview((prev) =>
|
setSelectedComponentsPreview((prev) =>
|
||||||
prev.filter((c) => c.id !== componentId),
|
prev.filter((c) => c.id !== componentId),
|
||||||
);
|
);
|
||||||
|
setVisualEditingSelectedComponent((prev) => {
|
||||||
|
const shouldClear = prev?.id === componentId;
|
||||||
|
if (shouldClear) {
|
||||||
|
setCurrentComponentCoordinates(null);
|
||||||
|
}
|
||||||
|
return shouldClear ? null : prev;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.data?.type === "dyad-component-coordinates-updated") {
|
||||||
|
if (event.data.coordinates) {
|
||||||
|
setCurrentComponentCoordinates(event.data.coordinates);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -348,6 +508,7 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
|
|||||||
setErrorMessage,
|
setErrorMessage,
|
||||||
setIsComponentSelectorInitialized,
|
setIsComponentSelectorInitialized,
|
||||||
setSelectedComponentsPreview,
|
setSelectedComponentsPreview,
|
||||||
|
setVisualEditingSelectedComponent,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -366,11 +527,26 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
|
|||||||
}
|
}
|
||||||
}, [appUrl]);
|
}, [appUrl]);
|
||||||
|
|
||||||
|
// Get current styles when component is selected for visual editing
|
||||||
|
useEffect(() => {
|
||||||
|
if (visualEditingSelectedComponent) {
|
||||||
|
getCurrentElementStyles();
|
||||||
|
}
|
||||||
|
}, [visualEditingSelectedComponent]);
|
||||||
|
|
||||||
// Function to activate component selector in the iframe
|
// Function to activate component selector in the iframe
|
||||||
const handleActivateComponentSelector = () => {
|
const handleActivateComponentSelector = () => {
|
||||||
if (iframeRef.current?.contentWindow) {
|
if (iframeRef.current?.contentWindow) {
|
||||||
const newIsPicking = !isPicking;
|
const newIsPicking = !isPicking;
|
||||||
|
if (!newIsPicking) {
|
||||||
|
// Clean up any text editing states when deactivating
|
||||||
|
iframeRef.current.contentWindow.postMessage(
|
||||||
|
{ type: "cleanup-all-text-editing" },
|
||||||
|
"*",
|
||||||
|
);
|
||||||
|
}
|
||||||
setIsPicking(newIsPicking);
|
setIsPicking(newIsPicking);
|
||||||
|
setVisualEditingSelectedComponent(null);
|
||||||
iframeRef.current.contentWindow.postMessage(
|
iframeRef.current.contentWindow.postMessage(
|
||||||
{
|
{
|
||||||
type: newIsPicking
|
type: newIsPicking
|
||||||
@@ -433,6 +609,10 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
|
|||||||
const handleReload = () => {
|
const handleReload = () => {
|
||||||
setReloadKey((prevKey) => prevKey + 1);
|
setReloadKey((prevKey) => prevKey + 1);
|
||||||
setErrorMessage(undefined);
|
setErrorMessage(undefined);
|
||||||
|
// Reset visual editing state
|
||||||
|
setVisualEditingSelectedComponent(null);
|
||||||
|
setPendingChanges(new Map());
|
||||||
|
setCurrentComponentCoordinates(null);
|
||||||
// Optionally, add logic here if you need to explicitly stop/start the app again
|
// Optionally, add logic here if you need to explicitly stop/start the app again
|
||||||
// For now, just changing the key should remount the iframe
|
// For now, just changing the key should remount the iframe
|
||||||
console.debug("Reloading iframe preview for app", selectedAppId);
|
console.debug("Reloading iframe preview for app", selectedAppId);
|
||||||
@@ -737,6 +917,15 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
|
|||||||
src={appUrl}
|
src={appUrl}
|
||||||
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 */}
|
||||||
|
{isProMode && visualEditingSelectedComponent && selectedAppId && (
|
||||||
|
<VisualEditingToolbar
|
||||||
|
selectedComponent={visualEditingSelectedComponent}
|
||||||
|
iframeRef={iframeRef}
|
||||||
|
isDynamic={isDynamicComponent}
|
||||||
|
hasStaticText={hasStaticText}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -745,16 +934,20 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function parseComponentSelection(data: any): ComponentSelection | null {
|
function parseComponentSelection(data: any): ComponentSelection | null {
|
||||||
|
if (!data || data.type !== "dyad-component-selected") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const component = data.component;
|
||||||
if (
|
if (
|
||||||
!data ||
|
!component ||
|
||||||
data.type !== "dyad-component-selected" ||
|
typeof component.id !== "string" ||
|
||||||
typeof data.id !== "string" ||
|
typeof component.name !== "string"
|
||||||
typeof data.name !== "string"
|
|
||||||
) {
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { id, name } = data;
|
const { id, name, runtimeId } = component;
|
||||||
|
|
||||||
// The id is expected to be in the format "filepath:line:column"
|
// The id is expected to be in the format "filepath:line:column"
|
||||||
const parts = id.split(":");
|
const parts = id.split(":");
|
||||||
@@ -783,6 +976,7 @@ function parseComponentSelection(data: any): ComponentSelection | null {
|
|||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
|
runtimeId,
|
||||||
relativePath: normalizePath(relativePath),
|
relativePath: normalizePath(relativePath),
|
||||||
lineNumber,
|
lineNumber,
|
||||||
columnNumber,
|
columnNumber,
|
||||||
|
|||||||
56
src/components/preview_panel/StylePopover.tsx
Normal file
56
src/components/preview_panel/StylePopover.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
179
src/components/preview_panel/VisualEditingChangesDialog.tsx
Normal file
179
src/components/preview_panel/VisualEditingChangesDialog.tsx
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
531
src/components/preview_panel/VisualEditingToolbar.tsx
Normal file
531
src/components/preview_panel/VisualEditingToolbar.tsx
Normal file
@@ -0,0 +1,531 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { X, Move, Square, Palette, Type } from "lucide-react";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { ComponentSelection } from "@/ipc/ipc_types";
|
||||||
|
import { useSetAtom, useAtomValue } from "jotai";
|
||||||
|
import {
|
||||||
|
pendingVisualChangesAtom,
|
||||||
|
selectedComponentsPreviewAtom,
|
||||||
|
currentComponentCoordinatesAtom,
|
||||||
|
visualEditingSelectedComponentAtom,
|
||||||
|
} from "@/atoms/previewAtoms";
|
||||||
|
import { StylePopover } from "./StylePopover";
|
||||||
|
import { ColorPicker } from "@/components/ui/ColorPicker";
|
||||||
|
import { NumberInput } from "@/components/ui/NumberInput";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
import { rgbToHex, processNumericValue } from "@/utils/style-utils";
|
||||||
|
|
||||||
|
const FONT_WEIGHT_OPTIONS = [
|
||||||
|
{ value: "", label: "Default" },
|
||||||
|
{ value: "100", label: "Thin (100)" },
|
||||||
|
{ value: "200", label: "Extra Light (200)" },
|
||||||
|
{ value: "300", label: "Light (300)" },
|
||||||
|
{ value: "400", label: "Normal (400)" },
|
||||||
|
{ value: "500", label: "Medium (500)" },
|
||||||
|
{ value: "600", label: "Semi Bold (600)" },
|
||||||
|
{ value: "700", label: "Bold (700)" },
|
||||||
|
{ value: "800", label: "Extra Bold (800)" },
|
||||||
|
{ value: "900", label: "Black (900)" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const FONT_FAMILY_OPTIONS = [
|
||||||
|
{ value: "", label: "Default" },
|
||||||
|
// Sans-serif (clean, modern)
|
||||||
|
{ value: "Arial, sans-serif", label: "Arial" },
|
||||||
|
{ value: "Inter, sans-serif", label: "Inter" },
|
||||||
|
{ value: "Roboto, sans-serif", label: "Roboto" },
|
||||||
|
// Serif (traditional, elegant)
|
||||||
|
{ value: "Georgia, serif", label: "Georgia" },
|
||||||
|
{ value: "'Times New Roman', Times, serif", label: "Times New Roman" },
|
||||||
|
{ value: "Merriweather, serif", label: "Merriweather" },
|
||||||
|
// Monospace (code, technical)
|
||||||
|
{ value: "'Courier New', Courier, monospace", label: "Courier New" },
|
||||||
|
{ value: "'Fira Code', monospace", label: "Fira Code" },
|
||||||
|
{ value: "Consolas, monospace", label: "Consolas" },
|
||||||
|
// Display/Decorative (bold, distinctive)
|
||||||
|
{ value: "Impact, fantasy", label: "Impact" },
|
||||||
|
{ value: "'Bebas Neue', cursive", label: "Bebas Neue" },
|
||||||
|
// Cursive/Handwriting (casual, friendly)
|
||||||
|
{ value: "'Comic Sans MS', cursive", label: "Comic Sans MS" },
|
||||||
|
{ value: "'Brush Script MT', cursive", label: "Brush Script" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
interface VisualEditingToolbarProps {
|
||||||
|
selectedComponent: ComponentSelection | null;
|
||||||
|
iframeRef: React.RefObject<HTMLIFrameElement | null>;
|
||||||
|
isDynamic: boolean;
|
||||||
|
hasStaticText: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VisualEditingToolbar({
|
||||||
|
selectedComponent,
|
||||||
|
iframeRef,
|
||||||
|
isDynamic,
|
||||||
|
hasStaticText,
|
||||||
|
}: VisualEditingToolbarProps) {
|
||||||
|
const coordinates = useAtomValue(currentComponentCoordinatesAtom);
|
||||||
|
const [currentMargin, setCurrentMargin] = useState({ x: "", y: "" });
|
||||||
|
const [currentPadding, setCurrentPadding] = useState({ x: "", y: "" });
|
||||||
|
const [currentBorder, setCurrentBorder] = useState({
|
||||||
|
width: "",
|
||||||
|
radius: "",
|
||||||
|
color: "#000000",
|
||||||
|
});
|
||||||
|
const [currentBackgroundColor, setCurrentBackgroundColor] =
|
||||||
|
useState("#ffffff");
|
||||||
|
const [currentTextStyles, setCurrentTextStyles] = useState({
|
||||||
|
fontSize: "",
|
||||||
|
fontWeight: "",
|
||||||
|
fontFamily: "",
|
||||||
|
color: "#000000",
|
||||||
|
});
|
||||||
|
const setPendingChanges = useSetAtom(pendingVisualChangesAtom);
|
||||||
|
const setSelectedComponentsPreview = useSetAtom(
|
||||||
|
selectedComponentsPreviewAtom,
|
||||||
|
);
|
||||||
|
const setVisualEditingSelectedComponent = useSetAtom(
|
||||||
|
visualEditingSelectedComponentAtom,
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDeselectComponent = () => {
|
||||||
|
if (!selectedComponent) return;
|
||||||
|
|
||||||
|
setSelectedComponentsPreview((prev) =>
|
||||||
|
prev.filter((c) => c.id !== selectedComponent.id),
|
||||||
|
);
|
||||||
|
setVisualEditingSelectedComponent(null);
|
||||||
|
|
||||||
|
if (iframeRef.current?.contentWindow) {
|
||||||
|
iframeRef.current.contentWindow.postMessage(
|
||||||
|
{
|
||||||
|
type: "remove-dyad-component-overlay",
|
||||||
|
componentId: selectedComponent.id,
|
||||||
|
},
|
||||||
|
"*",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendStyleModification = (styles: {
|
||||||
|
margin?: { left?: string; right?: string; top?: string; bottom?: string };
|
||||||
|
padding?: { left?: string; right?: string; top?: string; bottom?: string };
|
||||||
|
|
||||||
|
border?: { width?: string; radius?: string; color?: string };
|
||||||
|
backgroundColor?: string;
|
||||||
|
text?: { fontSize?: string; fontWeight?: string; color?: string };
|
||||||
|
}) => {
|
||||||
|
if (!iframeRef.current?.contentWindow || !selectedComponent) return;
|
||||||
|
|
||||||
|
iframeRef.current.contentWindow.postMessage(
|
||||||
|
{
|
||||||
|
type: "modify-dyad-component-styles",
|
||||||
|
data: {
|
||||||
|
elementId: selectedComponent.id,
|
||||||
|
runtimeId: selectedComponent.runtimeId,
|
||||||
|
styles,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"*",
|
||||||
|
);
|
||||||
|
|
||||||
|
iframeRef.current.contentWindow.postMessage(
|
||||||
|
{
|
||||||
|
type: "update-dyad-overlay-positions",
|
||||||
|
},
|
||||||
|
"*",
|
||||||
|
);
|
||||||
|
|
||||||
|
setPendingChanges((prev) => {
|
||||||
|
const updated = new Map(prev);
|
||||||
|
const existing = updated.get(selectedComponent.id);
|
||||||
|
const newStyles: any = { ...existing?.styles };
|
||||||
|
|
||||||
|
if (styles.margin) {
|
||||||
|
newStyles.margin = { ...existing?.styles?.margin, ...styles.margin };
|
||||||
|
}
|
||||||
|
if (styles.padding) {
|
||||||
|
newStyles.padding = { ...existing?.styles?.padding, ...styles.padding };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (styles.border) {
|
||||||
|
newStyles.border = { ...existing?.styles?.border, ...styles.border };
|
||||||
|
}
|
||||||
|
if (styles.backgroundColor) {
|
||||||
|
newStyles.backgroundColor = styles.backgroundColor;
|
||||||
|
}
|
||||||
|
if (styles.text) {
|
||||||
|
newStyles.text = { ...existing?.styles?.text, ...styles.text };
|
||||||
|
}
|
||||||
|
|
||||||
|
updated.set(selectedComponent.id, {
|
||||||
|
componentId: selectedComponent.id,
|
||||||
|
componentName: selectedComponent.name,
|
||||||
|
relativePath: selectedComponent.relativePath,
|
||||||
|
lineNumber: selectedComponent.lineNumber,
|
||||||
|
styles: newStyles,
|
||||||
|
textContent: existing?.textContent || "",
|
||||||
|
});
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCurrentElementStyles = () => {
|
||||||
|
if (!iframeRef.current?.contentWindow || !selectedComponent) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
iframeRef.current.contentWindow.postMessage(
|
||||||
|
{
|
||||||
|
type: "get-dyad-component-styles",
|
||||||
|
data: {
|
||||||
|
elementId: selectedComponent.id,
|
||||||
|
runtimeId: selectedComponent.runtimeId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"*",
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to get element styles:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedComponent) {
|
||||||
|
getCurrentElementStyles();
|
||||||
|
}
|
||||||
|
}, [selectedComponent]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (coordinates && iframeRef.current?.contentWindow) {
|
||||||
|
iframeRef.current.contentWindow.postMessage(
|
||||||
|
{
|
||||||
|
type: "update-component-coordinates",
|
||||||
|
coordinates,
|
||||||
|
},
|
||||||
|
"*",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [coordinates, iframeRef]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleMessage = (event: MessageEvent) => {
|
||||||
|
if (event.data?.type === "dyad-component-styles") {
|
||||||
|
const { margin, padding, border, backgroundColor, text } =
|
||||||
|
event.data.data;
|
||||||
|
|
||||||
|
const marginX = margin?.left === margin?.right ? margin.left : "";
|
||||||
|
const marginY = margin?.top === margin?.bottom ? margin.top : "";
|
||||||
|
const paddingX = padding?.left === padding?.right ? padding.left : "";
|
||||||
|
const paddingY = padding?.top === padding?.bottom ? padding.top : "";
|
||||||
|
|
||||||
|
setCurrentMargin({ x: marginX, y: marginY });
|
||||||
|
setCurrentPadding({ x: paddingX, y: paddingY });
|
||||||
|
setCurrentBorder({
|
||||||
|
width: border?.width || "",
|
||||||
|
radius: border?.radius || "",
|
||||||
|
color: rgbToHex(border?.color),
|
||||||
|
});
|
||||||
|
setCurrentBackgroundColor(rgbToHex(backgroundColor) || "#ffffff");
|
||||||
|
setCurrentTextStyles({
|
||||||
|
fontSize: text?.fontSize || "",
|
||||||
|
fontWeight: text?.fontWeight || "",
|
||||||
|
fontFamily: text?.fontFamily || "",
|
||||||
|
color: rgbToHex(text?.color) || "#000000",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("message", handleMessage);
|
||||||
|
return () => window.removeEventListener("message", handleMessage);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSpacingChange = (
|
||||||
|
type: "margin" | "padding",
|
||||||
|
axis: "x" | "y",
|
||||||
|
value: string,
|
||||||
|
) => {
|
||||||
|
const setter = type === "margin" ? setCurrentMargin : setCurrentPadding;
|
||||||
|
setter((prev) => ({ ...prev, [axis]: value }));
|
||||||
|
|
||||||
|
if (value) {
|
||||||
|
const processedValue = processNumericValue(value);
|
||||||
|
const data =
|
||||||
|
axis === "x"
|
||||||
|
? { left: processedValue, right: processedValue }
|
||||||
|
: { top: processedValue, bottom: processedValue };
|
||||||
|
|
||||||
|
sendStyleModification({ [type]: data });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBorderChange = (
|
||||||
|
property: "width" | "radius" | "color",
|
||||||
|
value: string,
|
||||||
|
) => {
|
||||||
|
const newBorder = { ...currentBorder, [property]: value };
|
||||||
|
setCurrentBorder(newBorder);
|
||||||
|
|
||||||
|
if (value) {
|
||||||
|
let processedValue = value;
|
||||||
|
if (property !== "color" && /^\d+$/.test(value)) {
|
||||||
|
processedValue = `${value}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (property === "width" || property === "color") {
|
||||||
|
sendStyleModification({
|
||||||
|
border: {
|
||||||
|
width:
|
||||||
|
property === "width"
|
||||||
|
? processedValue
|
||||||
|
: currentBorder.width || "0px",
|
||||||
|
color: property === "color" ? processedValue : currentBorder.color,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
sendStyleModification({ border: { [property]: processedValue } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTextStyleChange = (
|
||||||
|
property: "fontSize" | "fontWeight" | "fontFamily" | "color",
|
||||||
|
value: string,
|
||||||
|
) => {
|
||||||
|
setCurrentTextStyles((prev) => ({ ...prev, [property]: value }));
|
||||||
|
|
||||||
|
if (value) {
|
||||||
|
let processedValue = value;
|
||||||
|
if (property === "fontSize" && /^\d+$/.test(value)) {
|
||||||
|
processedValue = `${value}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
sendStyleModification({ text: { [property]: processedValue } });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!selectedComponent || !coordinates) return null;
|
||||||
|
|
||||||
|
const toolbarTop = coordinates.top + coordinates.height + 4;
|
||||||
|
const toolbarLeft = coordinates.left;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="absolute bg-[var(--background)] border border-[var(--border)] rounded-md shadow-lg z-50 flex flex-row items-center p-2 gap-1"
|
||||||
|
style={{
|
||||||
|
top: `${toolbarTop}px`,
|
||||||
|
left: `${toolbarLeft}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
onClick={handleDeselectComponent}
|
||||||
|
className="p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 text-[#7f22fe] dark:text-gray-200"
|
||||||
|
aria-label="Deselect Component"
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom">
|
||||||
|
<p>Deselect Component</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
|
||||||
|
{isDynamic ? (
|
||||||
|
<div className="flex items-center px-2 py-1 text-yellow-800 dark:text-yellow-200 rounded text-xs font-medium">
|
||||||
|
<span>This component is styled dynamically</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<StylePopover
|
||||||
|
icon={<Move size={16} />}
|
||||||
|
title="Margin"
|
||||||
|
tooltip="Margin"
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-1 gap-2">
|
||||||
|
<NumberInput
|
||||||
|
id="margin-x"
|
||||||
|
label="Horizontal"
|
||||||
|
value={currentMargin.x}
|
||||||
|
onChange={(v) => handleSpacingChange("margin", "x", v)}
|
||||||
|
placeholder="10"
|
||||||
|
/>
|
||||||
|
<NumberInput
|
||||||
|
id="margin-y"
|
||||||
|
label="Vertical"
|
||||||
|
value={currentMargin.y}
|
||||||
|
onChange={(v) => handleSpacingChange("margin", "y", v)}
|
||||||
|
placeholder="10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</StylePopover>
|
||||||
|
|
||||||
|
<StylePopover
|
||||||
|
icon={
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||||
|
<rect x="7" y="7" width="10" height="10" rx="1" />
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
title="Padding"
|
||||||
|
tooltip="Padding"
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-1 gap-2">
|
||||||
|
<NumberInput
|
||||||
|
id="padding-x"
|
||||||
|
label="Horizontal"
|
||||||
|
value={currentPadding.x}
|
||||||
|
onChange={(v) => handleSpacingChange("padding", "x", v)}
|
||||||
|
placeholder="10"
|
||||||
|
/>
|
||||||
|
<NumberInput
|
||||||
|
id="padding-y"
|
||||||
|
label="Vertical"
|
||||||
|
value={currentPadding.y}
|
||||||
|
onChange={(v) => handleSpacingChange("padding", "y", v)}
|
||||||
|
placeholder="10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</StylePopover>
|
||||||
|
|
||||||
|
<StylePopover
|
||||||
|
icon={<Square size={16} />}
|
||||||
|
title="Border"
|
||||||
|
tooltip="Border"
|
||||||
|
>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<NumberInput
|
||||||
|
id="border-width"
|
||||||
|
label="Width"
|
||||||
|
value={currentBorder.width}
|
||||||
|
onChange={(v) => handleBorderChange("width", v)}
|
||||||
|
placeholder="1"
|
||||||
|
/>
|
||||||
|
<NumberInput
|
||||||
|
id="border-radius"
|
||||||
|
label="Radius"
|
||||||
|
value={currentBorder.radius}
|
||||||
|
onChange={(v) => handleBorderChange("radius", v)}
|
||||||
|
placeholder="4"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="border-color" className="text-xs">
|
||||||
|
Color
|
||||||
|
</Label>
|
||||||
|
<ColorPicker
|
||||||
|
id="border-color"
|
||||||
|
value={currentBorder.color}
|
||||||
|
onChange={(v) => handleBorderChange("color", v)}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</StylePopover>
|
||||||
|
|
||||||
|
<StylePopover
|
||||||
|
icon={<Palette size={16} />}
|
||||||
|
title="Background Color"
|
||||||
|
tooltip="Background"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="bg-color" className="text-xs">
|
||||||
|
Color
|
||||||
|
</Label>
|
||||||
|
<ColorPicker
|
||||||
|
id="bg-color"
|
||||||
|
value={currentBackgroundColor}
|
||||||
|
onChange={(v) => {
|
||||||
|
setCurrentBackgroundColor(v);
|
||||||
|
if (v) sendStyleModification({ backgroundColor: v });
|
||||||
|
}}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</StylePopover>
|
||||||
|
|
||||||
|
{hasStaticText && (
|
||||||
|
<StylePopover
|
||||||
|
icon={<Type size={16} />}
|
||||||
|
title="Text Style"
|
||||||
|
tooltip="Text Style"
|
||||||
|
>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<NumberInput
|
||||||
|
id="font-size"
|
||||||
|
label="Font Size"
|
||||||
|
value={currentTextStyles.fontSize}
|
||||||
|
onChange={(v) => handleTextStyleChange("fontSize", v)}
|
||||||
|
placeholder="16"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="font-weight" className="text-xs">
|
||||||
|
Font Weight
|
||||||
|
</Label>
|
||||||
|
<select
|
||||||
|
id="font-weight"
|
||||||
|
className="mt-1 h-8 text-xs w-full rounded-md border border-input bg-background px-3 py-2"
|
||||||
|
value={currentTextStyles.fontWeight}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleTextStyleChange("fontWeight", e.target.value)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{FONT_WEIGHT_OPTIONS.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="font-family" className="text-xs">
|
||||||
|
Font Family
|
||||||
|
</Label>
|
||||||
|
<select
|
||||||
|
id="font-family"
|
||||||
|
className="mt-1 h-8 text-xs w-full rounded-md border border-input bg-background px-3 py-2"
|
||||||
|
value={currentTextStyles.fontFamily}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleTextStyleChange("fontFamily", e.target.value)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{FONT_FAMILY_OPTIONS.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="text-color" className="text-xs">
|
||||||
|
Text Color
|
||||||
|
</Label>
|
||||||
|
<ColorPicker
|
||||||
|
id="text-color"
|
||||||
|
value={currentTextStyles.color}
|
||||||
|
onChange={(v) => handleTextStyleChange("color", v)}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</StylePopover>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
src/components/ui/ColorPicker.tsx
Normal file
35
src/components/ui/ColorPicker.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
42
src/components/ui/NumberInput.tsx
Normal file
42
src/components/ui/NumberInput.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
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() {
|
|||||||
// 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 {
|
|||||||
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 {
|
|||||||
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";
|
|||||||
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() {
|
|||||||
registerHelpBotHandlers();
|
registerHelpBotHandlers();
|
||||||
registerMcpHandlers();
|
registerMcpHandlers();
|
||||||
registerSecurityHandlers();
|
registerSecurityHandlers();
|
||||||
|
registerVisualEditingHandlers();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
|||||||
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";
|
|||||||
|
|
||||||
// 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",
|
||||||
|
|||||||
125
src/pro/main/ipc/handlers/visual_editing_handlers.ts
Normal file
125
src/pro/main/ipc/handlers/visual_editing_handlers.ts
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
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 };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
617
src/pro/main/utils/visual_editing_utils.test.ts
Normal file
617
src/pro/main/utils/visual_editing_utils.test.ts
Normal file
@@ -0,0 +1,617 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { transformContent, analyzeComponent } from "./visual_editing_utils";
|
||||||
|
|
||||||
|
describe("transformContent", () => {
|
||||||
|
describe("className manipulation", () => {
|
||||||
|
it("should add className attribute when none exists", () => {
|
||||||
|
const content = `
|
||||||
|
function Component() {
|
||||||
|
return <div>Hello</div>;
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const changes = new Map([
|
||||||
|
[3, { classes: ["bg-[#ff0000]", "p-[16px]"], prefixes: ["bg-", "p-"] }],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = transformContent(content, changes);
|
||||||
|
expect(result).toContain('className="bg-[#ff0000] p-[16px]"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should append classes to existing className", () => {
|
||||||
|
const content = `
|
||||||
|
function Component() {
|
||||||
|
return <div className="existing-class">Hello</div>;
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const changes = new Map([
|
||||||
|
[3, { classes: ["bg-[#0000ff]"], prefixes: ["bg-"] }],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = transformContent(content, changes);
|
||||||
|
expect(result).toContain("existing-class");
|
||||||
|
expect(result).toContain("bg-[#0000ff]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should remove classes with matching prefixes", () => {
|
||||||
|
const content = `
|
||||||
|
function Component() {
|
||||||
|
return <div className="bg-[#ff0000] p-[16px] text-[18px]">Hello</div>;
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const changes = new Map([
|
||||||
|
[3, { classes: ["bg-[#0000ff]"], prefixes: ["bg-"] }],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = transformContent(content, changes);
|
||||||
|
expect(result).not.toContain("bg-[#ff0000]");
|
||||||
|
expect(result).toContain("bg-[#0000ff]");
|
||||||
|
expect(result).toContain("p-[16px]");
|
||||||
|
expect(result).toContain("text-[18px]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle font-weight classes correctly", () => {
|
||||||
|
const content = `
|
||||||
|
function Component() {
|
||||||
|
return <div className="font-[600] text-lg">Hello</div>;
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const changes = new Map([
|
||||||
|
[3, { classes: ["font-[700]"], prefixes: ["font-weight-"] }],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = transformContent(content, changes);
|
||||||
|
expect(result).not.toContain("font-[600]");
|
||||||
|
expect(result).toContain("font-[700]");
|
||||||
|
expect(result).toContain("text-lg");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle font-family classes without removing font-weight", () => {
|
||||||
|
const content = `
|
||||||
|
function Component() {
|
||||||
|
return <div className="font-[600] font-[Inter] text-lg">Hello</div>;
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const changes = new Map([
|
||||||
|
[3, { classes: ["font-[Roboto]"], prefixes: ["font-family-"] }],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = transformContent(content, changes);
|
||||||
|
expect(result).toContain("font-[600]");
|
||||||
|
expect(result).not.toContain("font-[Inter]");
|
||||||
|
expect(result).toContain("font-[Roboto]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle text-size classes without removing text-color or text-align", () => {
|
||||||
|
const content = `
|
||||||
|
function Component() {
|
||||||
|
return <div className="text-[18px] text-[center] text-[#ff0000]">Hello</div>;
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const changes = new Map([
|
||||||
|
[3, { classes: ["text-[24px]"], prefixes: ["text-size-"] }],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = transformContent(content, changes);
|
||||||
|
expect(result).not.toContain("text-[18px]");
|
||||||
|
expect(result).toContain("text-[24px]");
|
||||||
|
expect(result).toContain("text-[center]");
|
||||||
|
expect(result).toContain("text-[#ff0000]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle arbitrary text-size values", () => {
|
||||||
|
const content = `
|
||||||
|
function Component() {
|
||||||
|
return <div className="text-[44px] text-center">Hello</div>;
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const changes = new Map([
|
||||||
|
[3, { classes: ["text-[32px]"], prefixes: ["text-size-"] }],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = transformContent(content, changes);
|
||||||
|
expect(result).not.toContain("text-[44px]");
|
||||||
|
expect(result).toContain("text-[32px]");
|
||||||
|
expect(result).toContain("text-center");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should remove mt-, mb-, my- when applying my- prefix", () => {
|
||||||
|
const content = `
|
||||||
|
function Component() {
|
||||||
|
return <div className="mt-[16px] mb-[8px] mx-[24px]">Hello</div>;
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const changes = new Map([
|
||||||
|
[3, { classes: ["my-[20px]"], prefixes: ["my-"] }],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = transformContent(content, changes);
|
||||||
|
expect(result).not.toContain("mt-[16px]");
|
||||||
|
expect(result).not.toContain("mb-[8px]");
|
||||||
|
expect(result).toContain("my-[20px]");
|
||||||
|
expect(result).toContain("mx-[24px]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should remove ml-, mr-, mx- when applying mx- prefix", () => {
|
||||||
|
const content = `
|
||||||
|
function Component() {
|
||||||
|
return <div className="ml-[16px] mr-[8px] my-[24px]">Hello</div>;
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const changes = new Map([
|
||||||
|
[3, { classes: ["mx-[20px]"], prefixes: ["mx-"] }],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = transformContent(content, changes);
|
||||||
|
expect(result).not.toContain("ml-[16px]");
|
||||||
|
expect(result).not.toContain("mr-[8px]");
|
||||||
|
expect(result).toContain("mx-[20px]");
|
||||||
|
expect(result).toContain("my-[24px]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle padding classes similarly to margin", () => {
|
||||||
|
const content = `
|
||||||
|
function Component() {
|
||||||
|
return <div className="pt-[16px] pb-[8px] px-[24px]">Hello</div>;
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const changes = new Map([
|
||||||
|
[3, { classes: ["py-[20px]"], prefixes: ["py-"] }],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = transformContent(content, changes);
|
||||||
|
expect(result).not.toContain("pt-[16px]");
|
||||||
|
expect(result).not.toContain("pb-[8px]");
|
||||||
|
expect(result).toContain("py-[20px]");
|
||||||
|
expect(result).toContain("px-[24px]");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("text content manipulation", () => {
|
||||||
|
it("should update text content for elements with only text", () => {
|
||||||
|
const content = `
|
||||||
|
function Component() {
|
||||||
|
return <div>Old text</div>;
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const changes = new Map([
|
||||||
|
[
|
||||||
|
3,
|
||||||
|
{
|
||||||
|
classes: [],
|
||||||
|
prefixes: [],
|
||||||
|
textContent: "New text",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = transformContent(content, changes);
|
||||||
|
expect(result).not.toContain("Old text");
|
||||||
|
expect(result).toContain("New text");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not update text content when element has nested JSX", () => {
|
||||||
|
const content = `
|
||||||
|
function Component() {
|
||||||
|
return <div>Old text <span>nested</span></div>;
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const changes = new Map([
|
||||||
|
[
|
||||||
|
3,
|
||||||
|
{
|
||||||
|
classes: [],
|
||||||
|
prefixes: [],
|
||||||
|
textContent: "New text",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = transformContent(content, changes);
|
||||||
|
expect(result).toContain("Old text");
|
||||||
|
expect(result).toContain("<span>nested</span>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should update text content and classes together", () => {
|
||||||
|
const content = `
|
||||||
|
function Component() {
|
||||||
|
return <div className="text-[18px]">Old text</div>;
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const changes = new Map([
|
||||||
|
[
|
||||||
|
3,
|
||||||
|
{
|
||||||
|
classes: ["text-[24px]"],
|
||||||
|
prefixes: ["text-size-"],
|
||||||
|
textContent: "New text",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = transformContent(content, changes);
|
||||||
|
expect(result).toContain("text-[24px]");
|
||||||
|
expect(result).not.toContain("text-[18px]");
|
||||||
|
expect(result).toContain("New text");
|
||||||
|
expect(result).not.toContain("Old text");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("spacing edge cases", () => {
|
||||||
|
it("should split m-[] into my-[] when adding mx-[]", () => {
|
||||||
|
const content = `
|
||||||
|
function Component() {
|
||||||
|
return <div className="m-[20px]">Content</div>;
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const changes = new Map([
|
||||||
|
[
|
||||||
|
3,
|
||||||
|
{
|
||||||
|
classes: ["mx-[10px]"],
|
||||||
|
prefixes: ["mx-"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = transformContent(content, changes);
|
||||||
|
expect(result).not.toContain("m-[20px]");
|
||||||
|
expect(result).toContain("my-[20px]");
|
||||||
|
expect(result).toContain("mx-[10px]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should split m-[] into mx-[] when adding my-[]", () => {
|
||||||
|
const content = `
|
||||||
|
function Component() {
|
||||||
|
return <div className="m-[20px]">Content</div>;
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const changes = new Map([
|
||||||
|
[
|
||||||
|
3,
|
||||||
|
{
|
||||||
|
classes: ["my-[10px]"],
|
||||||
|
prefixes: ["my-"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = transformContent(content, changes);
|
||||||
|
expect(result).not.toContain("m-[20px]");
|
||||||
|
expect(result).toContain("mx-[20px]");
|
||||||
|
expect(result).toContain("my-[10px]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should split p-[] into py-[] when adding px-[]", () => {
|
||||||
|
const content = `
|
||||||
|
function Component() {
|
||||||
|
return <div className="p-[16px]">Content</div>;
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const changes = new Map([
|
||||||
|
[
|
||||||
|
3,
|
||||||
|
{
|
||||||
|
classes: ["px-[8px]"],
|
||||||
|
prefixes: ["px-"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = transformContent(content, changes);
|
||||||
|
expect(result).not.toContain("p-[16px]");
|
||||||
|
expect(result).toContain("py-[16px]");
|
||||||
|
expect(result).toContain("px-[8px]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should split p-[] into px-[] when adding py-[]", () => {
|
||||||
|
const content = `
|
||||||
|
function Component() {
|
||||||
|
return <div className="p-[16px]">Content</div>;
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const changes = new Map([
|
||||||
|
[
|
||||||
|
3,
|
||||||
|
{
|
||||||
|
classes: ["py-[8px]"],
|
||||||
|
prefixes: ["py-"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = transformContent(content, changes);
|
||||||
|
expect(result).not.toContain("p-[16px]");
|
||||||
|
expect(result).toContain("px-[16px]");
|
||||||
|
expect(result).toContain("py-[8px]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not add complementary class when both directional classes are added", () => {
|
||||||
|
const content = `
|
||||||
|
function Component() {
|
||||||
|
return <div className="m-[20px]">Content</div>;
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const changes = new Map([
|
||||||
|
[
|
||||||
|
3,
|
||||||
|
{
|
||||||
|
classes: ["mx-[10px]", "my-[15px]"],
|
||||||
|
prefixes: ["mx-", "my-"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = transformContent(content, changes);
|
||||||
|
expect(result).not.toContain("m-[20px]");
|
||||||
|
expect(result).toContain("mx-[10px]");
|
||||||
|
expect(result).toContain("my-[15px]");
|
||||||
|
// Should not have added an extra mx- or my- with the original value
|
||||||
|
expect(result.match(/mx-/g)?.length).toBe(1);
|
||||||
|
expect(result.match(/my-/g)?.length).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("multiple changes", () => {
|
||||||
|
it("should apply changes to multiple lines", () => {
|
||||||
|
const content = `
|
||||||
|
function Component() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-[18px]">Title</h1>
|
||||||
|
<p className="text-[14px]">Paragraph</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const changes = new Map([
|
||||||
|
[5, { classes: ["text-[32px]"], prefixes: ["text-size-"] }],
|
||||||
|
[6, { classes: ["text-[16px]"], prefixes: ["text-size-"] }],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = transformContent(content, changes);
|
||||||
|
expect(result).toContain("text-[32px]");
|
||||||
|
expect(result).not.toContain("text-[18px]");
|
||||||
|
expect(result).toContain("text-[16px]");
|
||||||
|
expect(result).not.toContain("text-[14px]");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("edge cases", () => {
|
||||||
|
it("should handle empty changes map", () => {
|
||||||
|
const content = `
|
||||||
|
function Component() {
|
||||||
|
return <div className="text-[18px]">Hello</div>;
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const changes = new Map();
|
||||||
|
const result = transformContent(content, changes);
|
||||||
|
expect(result).toContain("text-[18px]");
|
||||||
|
expect(result).toContain("Hello");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should preserve code formatting", () => {
|
||||||
|
const content = `
|
||||||
|
function Component() {
|
||||||
|
return (
|
||||||
|
<div className="text-[18px]">
|
||||||
|
Hello
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const changes = new Map([
|
||||||
|
[4, { classes: ["text-[24px]"], prefixes: ["text-size-"] }],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = transformContent(content, changes);
|
||||||
|
expect(result).toContain("text-[24px]");
|
||||||
|
// Recast should preserve overall structure
|
||||||
|
expect(result).toMatch(/return\s*\(/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("analyzeComponent", () => {
|
||||||
|
describe("dynamic styling detection", () => {
|
||||||
|
it("should detect conditional className", () => {
|
||||||
|
const content = `
|
||||||
|
function Component() {
|
||||||
|
return <div className={isActive ? "active" : "inactive"}>Hello</div>;
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const result = analyzeComponent(content, 3);
|
||||||
|
expect(result.isDynamic).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should detect logical expression className", () => {
|
||||||
|
const content = `
|
||||||
|
function Component() {
|
||||||
|
return <div className={isActive && "active"}>Hello</div>;
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const result = analyzeComponent(content, 3);
|
||||||
|
expect(result.isDynamic).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should detect template literal className", () => {
|
||||||
|
const content = `
|
||||||
|
function Component() {
|
||||||
|
return <div className={\`base-class \${dynamicClass}\`}>Hello</div>;
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const result = analyzeComponent(content, 3);
|
||||||
|
expect(result.isDynamic).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should detect identifier className", () => {
|
||||||
|
const content = `
|
||||||
|
function Component() {
|
||||||
|
return <div className={styles.container}>Hello</div>;
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const result = analyzeComponent(content, 3);
|
||||||
|
expect(result.isDynamic).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should detect function call className", () => {
|
||||||
|
const content = `
|
||||||
|
function Component() {
|
||||||
|
return <div className={cn("base", { active: isActive })}>Hello</div>;
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const result = analyzeComponent(content, 3);
|
||||||
|
expect(result.isDynamic).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should detect dynamic style attribute", () => {
|
||||||
|
const content = `
|
||||||
|
function Component() {
|
||||||
|
return <div style={{ color: isActive ? "red" : "blue" }}>Hello</div>;
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const result = analyzeComponent(content, 3);
|
||||||
|
expect(result.isDynamic).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not detect static className", () => {
|
||||||
|
const content = `
|
||||||
|
function Component() {
|
||||||
|
return <div className="static-class">Hello</div>;
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const result = analyzeComponent(content, 3);
|
||||||
|
expect(result.isDynamic).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not detect when no className or style", () => {
|
||||||
|
const content = `
|
||||||
|
function Component() {
|
||||||
|
return <div>Hello</div>;
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const result = analyzeComponent(content, 3);
|
||||||
|
expect(result.isDynamic).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("static text detection", () => {
|
||||||
|
it("should detect static text content", () => {
|
||||||
|
const content = `
|
||||||
|
function Component() {
|
||||||
|
return <div>Static text content</div>;
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const result = analyzeComponent(content, 3);
|
||||||
|
expect(result.hasStaticText).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should detect string literal in expression container", () => {
|
||||||
|
const content = `
|
||||||
|
function Component() {
|
||||||
|
return <div>{"Static text"}</div>;
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const result = analyzeComponent(content, 3);
|
||||||
|
expect(result.hasStaticText).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not detect static text when element has nested JSX", () => {
|
||||||
|
const content = `
|
||||||
|
function Component() {
|
||||||
|
return <div>Text <span>nested</span></div>;
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const result = analyzeComponent(content, 3);
|
||||||
|
expect(result.hasStaticText).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not detect static text when empty", () => {
|
||||||
|
const content = `
|
||||||
|
function Component() {
|
||||||
|
return <div></div>;
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const result = analyzeComponent(content, 3);
|
||||||
|
expect(result.hasStaticText).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should ignore whitespace-only text", () => {
|
||||||
|
const content = `
|
||||||
|
function Component() {
|
||||||
|
return <div> </div>;
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const result = analyzeComponent(content, 3);
|
||||||
|
expect(result.hasStaticText).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not detect static text with dynamic expression", () => {
|
||||||
|
const content = `
|
||||||
|
function Component() {
|
||||||
|
return <div>{dynamicText}</div>;
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const result = analyzeComponent(content, 3);
|
||||||
|
expect(result.hasStaticText).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("combined analysis", () => {
|
||||||
|
it("should detect both dynamic styling and static text", () => {
|
||||||
|
const content = `
|
||||||
|
function Component() {
|
||||||
|
return <div className={isActive ? "active" : "inactive"}>Static text</div>;
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const result = analyzeComponent(content, 3);
|
||||||
|
expect(result.isDynamic).toBe(true);
|
||||||
|
expect(result.hasStaticText).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return false for both when element not found", () => {
|
||||||
|
const content = `
|
||||||
|
function Component() {
|
||||||
|
return <div>Hello</div>;
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const result = analyzeComponent(content, 999);
|
||||||
|
expect(result.isDynamic).toBe(false);
|
||||||
|
expect(result.hasStaticText).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("nested elements", () => {
|
||||||
|
it("should analyze correct element on specified line", () => {
|
||||||
|
const content = `
|
||||||
|
function Component() {
|
||||||
|
return (
|
||||||
|
<div className="w-[100px]">
|
||||||
|
<span className={dynamicClass}>Inner</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const outerResult = analyzeComponent(content, 4);
|
||||||
|
expect(outerResult.isDynamic).toBe(false);
|
||||||
|
expect(outerResult.hasStaticText).toBe(false);
|
||||||
|
|
||||||
|
const innerResult = analyzeComponent(content, 5);
|
||||||
|
expect(innerResult.isDynamic).toBe(true);
|
||||||
|
expect(innerResult.hasStaticText).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("TypeScript support", () => {
|
||||||
|
it("should handle TypeScript syntax", () => {
|
||||||
|
const content = `
|
||||||
|
function Component(): JSX.Element {
|
||||||
|
const props: Props = { active: true };
|
||||||
|
return <div className={props.active ? "active" : "inactive"}>Hello</div>;
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const result = analyzeComponent(content, 4);
|
||||||
|
expect(result.isDynamic).toBe(true);
|
||||||
|
expect(result.hasStaticText).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
361
src/pro/main/utils/visual_editing_utils.ts
Normal file
361
src/pro/main/utils/visual_editing_utils.ts
Normal file
@@ -0,0 +1,361 @@
|
|||||||
|
import { parse } from "@babel/parser";
|
||||||
|
import * as recast from "recast";
|
||||||
|
import traverse from "@babel/traverse";
|
||||||
|
|
||||||
|
interface ContentChange {
|
||||||
|
classes: string[];
|
||||||
|
prefixes: string[];
|
||||||
|
textContent?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ComponentAnalysis {
|
||||||
|
isDynamic: boolean;
|
||||||
|
hasStaticText: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure function that transforms JSX/TSX content by applying style and text changes
|
||||||
|
* @param content - The source code content to transform
|
||||||
|
* @param changes - Map of line numbers to their changes
|
||||||
|
* @returns The transformed source code
|
||||||
|
*/
|
||||||
|
export function transformContent(
|
||||||
|
content: string,
|
||||||
|
changes: Map<number, ContentChange>,
|
||||||
|
): string {
|
||||||
|
// Parse with babel for compatibility with JSX/TypeScript
|
||||||
|
const ast = parse(content, {
|
||||||
|
sourceType: "module",
|
||||||
|
plugins: ["jsx", "typescript"],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Track which lines have been processed to avoid modifying nested elements
|
||||||
|
const processedLines = new Set<number>();
|
||||||
|
|
||||||
|
traverse(ast, {
|
||||||
|
JSXElement(path) {
|
||||||
|
const line = path.node.openingElement.loc?.start.line;
|
||||||
|
|
||||||
|
// Only process if we have changes for this line and haven't processed it yet
|
||||||
|
if (line && changes.has(line) && !processedLines.has(line)) {
|
||||||
|
processedLines.add(line);
|
||||||
|
const change = changes.get(line)!;
|
||||||
|
|
||||||
|
// Check if this element has any nested JSX elements as direct children
|
||||||
|
const hasNestedJSX = path.node.children.some(
|
||||||
|
(child: any) => child.type === "JSXElement",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Skip text content modification if there are nested elements
|
||||||
|
const shouldModifyText =
|
||||||
|
"textContent" in change &&
|
||||||
|
change.textContent !== undefined &&
|
||||||
|
!hasNestedJSX;
|
||||||
|
|
||||||
|
// Update className if there are style changes
|
||||||
|
if (change.classes.length > 0) {
|
||||||
|
const attributes = path.node.openingElement.attributes;
|
||||||
|
let classNameAttr = attributes.find(
|
||||||
|
(attr: any) =>
|
||||||
|
attr.type === "JSXAttribute" && attr.name.name === "className",
|
||||||
|
) as any;
|
||||||
|
|
||||||
|
if (classNameAttr) {
|
||||||
|
// Get existing classes
|
||||||
|
let existingClasses: string[] = [];
|
||||||
|
if (
|
||||||
|
classNameAttr.value &&
|
||||||
|
classNameAttr.value.type === "StringLiteral"
|
||||||
|
) {
|
||||||
|
existingClasses = classNameAttr.value.value
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter out classes with matching prefixes
|
||||||
|
const shouldRemoveClass = (cls: string, prefixes: string[]) => {
|
||||||
|
return prefixes.some((prefix) => {
|
||||||
|
// Handle font-weight vs font-family distinction
|
||||||
|
if (prefix === "font-weight-") {
|
||||||
|
// Remove font-[numeric] classes
|
||||||
|
const match = cls.match(/^font-\[(\d+)\]$/);
|
||||||
|
return match !== null;
|
||||||
|
} else if (prefix === "font-family-") {
|
||||||
|
// Remove font-[non-numeric] classes
|
||||||
|
const match = cls.match(/^font-\[([^\]]+)\]$/);
|
||||||
|
if (match) {
|
||||||
|
// Check if it's NOT purely numeric (i.e., it's a font-family)
|
||||||
|
return !/^\d+$/.test(match[1]);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
} else if (prefix === "text-size-") {
|
||||||
|
// Remove only text-size classes (text-xs, text-3xl, text-[44px], etc.)
|
||||||
|
// but NOT text-center, text-left, text-red-500, etc.
|
||||||
|
const sizeMatch = cls.match(
|
||||||
|
/^text-(xs|sm|base|lg|xl|2xl|3xl|4xl|5xl|6xl|7xl|8xl|9xl)$/,
|
||||||
|
);
|
||||||
|
if (sizeMatch) return true;
|
||||||
|
// Also match arbitrary text sizes like text-[44px]
|
||||||
|
if (cls.match(/^text-\[[\d.]+[a-z]+\]$/)) return true;
|
||||||
|
return false;
|
||||||
|
} else if (prefix === "my-" || prefix === "py-") {
|
||||||
|
// When applying vertical spacing (my/py), remove mt-, mb-, my-/py-, and m-/p- (all sides)
|
||||||
|
const type = prefix[0]; // 'm' or 'p'
|
||||||
|
return (
|
||||||
|
cls.startsWith(`${type}t-`) ||
|
||||||
|
cls.startsWith(`${type}b-`) ||
|
||||||
|
cls.startsWith(`${type}y-`) ||
|
||||||
|
cls.match(new RegExp(`^${type}-\\[`)) // Match m-[...] or p-[...]
|
||||||
|
);
|
||||||
|
} else if (prefix === "mx-" || prefix === "px-") {
|
||||||
|
// When applying horizontal spacing (mx/px), remove ml-, mr-, mx-/px-, and m-/p- (all sides)
|
||||||
|
const type = prefix[0]; // 'm' or 'p'
|
||||||
|
return (
|
||||||
|
cls.startsWith(`${type}l-`) ||
|
||||||
|
cls.startsWith(`${type}r-`) ||
|
||||||
|
cls.startsWith(`${type}x-`) ||
|
||||||
|
cls.match(new RegExp(`^${type}-\\[`)) // Match m-[...] or p-[...]
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// For other prefixes, use simple startsWith
|
||||||
|
return cls.startsWith(prefix);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
let filteredClasses = existingClasses.filter(
|
||||||
|
(cls) => !shouldRemoveClass(cls, change.prefixes),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Special case: When adding mx-/px- or my-/py-, check if we need to preserve complementary spacing
|
||||||
|
// If we're removing m-[value]/p-[value], we should add the complementary directional class
|
||||||
|
// BUT only if we're not already adding both directional classes
|
||||||
|
const addedClasses: string[] = [];
|
||||||
|
|
||||||
|
// Check for each spacing type (margin and padding)
|
||||||
|
["m", "p"].forEach((type) => {
|
||||||
|
const hasDirectionalX = change.prefixes.some(
|
||||||
|
(p) => p === `${type}x-`,
|
||||||
|
);
|
||||||
|
const hasDirectionalY = change.prefixes.some(
|
||||||
|
(p) => p === `${type}y-`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Only process if we're adding at least one directional class for this type
|
||||||
|
if (!hasDirectionalX && !hasDirectionalY) {
|
||||||
|
return; // Skip this type
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find if there was an all-sides class (m-[...] or p-[...])
|
||||||
|
const allSidesClass = existingClasses.find((cls) =>
|
||||||
|
cls.match(new RegExp(`^${type}-\\[([^\\]]+)\\]$`)),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (allSidesClass) {
|
||||||
|
// Remove the omni-directional class from filtered classes
|
||||||
|
filteredClasses = filteredClasses.filter(
|
||||||
|
(cls) => cls !== allSidesClass,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Extract the value
|
||||||
|
const valueMatch = allSidesClass.match(/\[([^\]]+)\]/);
|
||||||
|
if (valueMatch) {
|
||||||
|
const omnidirectionalValue = valueMatch[1];
|
||||||
|
|
||||||
|
// Only add complementary class if we're not adding both directions
|
||||||
|
if (hasDirectionalX && !hasDirectionalY) {
|
||||||
|
// Adding mx-[], so preserve the value as my-[]
|
||||||
|
addedClasses.push(`${type}y-[${omnidirectionalValue}]`);
|
||||||
|
} else if (hasDirectionalY && !hasDirectionalX) {
|
||||||
|
// Adding my-[], so preserve the value as mx-[]
|
||||||
|
addedClasses.push(`${type}x-[${omnidirectionalValue}]`);
|
||||||
|
}
|
||||||
|
// If both are being added, we don't need to preserve anything
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Combine filtered, preserved, and new classes
|
||||||
|
const updatedClasses = [
|
||||||
|
...filteredClasses,
|
||||||
|
...addedClasses,
|
||||||
|
...change.classes,
|
||||||
|
].join(" ");
|
||||||
|
|
||||||
|
// Update the className value
|
||||||
|
classNameAttr.value = {
|
||||||
|
type: "StringLiteral",
|
||||||
|
value: updatedClasses,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// Add className attribute
|
||||||
|
attributes.push({
|
||||||
|
type: "JSXAttribute",
|
||||||
|
name: { type: "JSXIdentifier", name: "className" },
|
||||||
|
value: {
|
||||||
|
type: "StringLiteral",
|
||||||
|
value: change.classes.join(" "),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldModifyText) {
|
||||||
|
// Check if all children are text nodes (no nested JSX elements)
|
||||||
|
const hasOnlyTextChildren = path.node.children.every((child: any) => {
|
||||||
|
// JSXElement means there's a nested component/element
|
||||||
|
if (child.type === "JSXElement") return false;
|
||||||
|
return (
|
||||||
|
child.type === "JSXText" ||
|
||||||
|
(child.type === "JSXExpressionContainer" &&
|
||||||
|
child.expression.type === "StringLiteral")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only replace children if there are no nested JSX elements
|
||||||
|
if (hasOnlyTextChildren) {
|
||||||
|
path.node.children = [
|
||||||
|
{
|
||||||
|
type: "JSXText",
|
||||||
|
value: change.textContent,
|
||||||
|
} as any,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Use recast to generate code with preserved formatting
|
||||||
|
const output = recast.print(ast);
|
||||||
|
return output.code;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Analyzes a JSX/TSX component at a specific line to determine:
|
||||||
|
* - Whether it has dynamic styling (className/style with expressions)
|
||||||
|
* - Whether it contains static text content
|
||||||
|
*/
|
||||||
|
export function analyzeComponent(
|
||||||
|
content: string,
|
||||||
|
line: number,
|
||||||
|
): ComponentAnalysis {
|
||||||
|
const ast = parse(content, {
|
||||||
|
sourceType: "module",
|
||||||
|
plugins: ["jsx", "typescript"],
|
||||||
|
});
|
||||||
|
|
||||||
|
let foundElement: any = null;
|
||||||
|
|
||||||
|
// Simple recursive walker to find JSXElement
|
||||||
|
const walk = (node: any): void => {
|
||||||
|
if (!node) return;
|
||||||
|
|
||||||
|
if (
|
||||||
|
node.type === "JSXElement" &&
|
||||||
|
node.openingElement?.loc?.start.line === line
|
||||||
|
) {
|
||||||
|
foundElement = node;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle arrays (like body of a program or block)
|
||||||
|
if (Array.isArray(node)) {
|
||||||
|
for (const child of node) {
|
||||||
|
walk(child);
|
||||||
|
if (foundElement) return;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle objects
|
||||||
|
for (const key in node) {
|
||||||
|
if (
|
||||||
|
key !== "loc" &&
|
||||||
|
key !== "start" &&
|
||||||
|
key !== "end" &&
|
||||||
|
node[key] &&
|
||||||
|
typeof node[key] === "object"
|
||||||
|
) {
|
||||||
|
walk(node[key]);
|
||||||
|
if (foundElement) return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
walk(ast);
|
||||||
|
|
||||||
|
if (!foundElement) {
|
||||||
|
return { isDynamic: false, hasStaticText: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
let dynamic = false;
|
||||||
|
let staticText = false;
|
||||||
|
|
||||||
|
// Check attributes for dynamic styling
|
||||||
|
if (foundElement.openingElement.attributes) {
|
||||||
|
foundElement.openingElement.attributes.forEach((attr: any) => {
|
||||||
|
if (attr.type === "JSXAttribute" && attr.name && attr.name.name) {
|
||||||
|
const attrName = attr.name.name;
|
||||||
|
if (attrName === "style" || attrName === "className") {
|
||||||
|
if (attr.value && attr.value.type === "JSXExpressionContainer") {
|
||||||
|
const expr = attr.value.expression;
|
||||||
|
// Check for conditional/logical/template
|
||||||
|
if (
|
||||||
|
expr.type === "ConditionalExpression" ||
|
||||||
|
expr.type === "LogicalExpression" ||
|
||||||
|
expr.type === "TemplateLiteral"
|
||||||
|
) {
|
||||||
|
dynamic = true;
|
||||||
|
}
|
||||||
|
// Check for identifiers (variables)
|
||||||
|
if (
|
||||||
|
expr.type === "Identifier" ||
|
||||||
|
expr.type === "MemberExpression"
|
||||||
|
) {
|
||||||
|
dynamic = true;
|
||||||
|
}
|
||||||
|
// Check for CallExpression (function calls)
|
||||||
|
if (expr.type === "CallExpression") {
|
||||||
|
dynamic = true;
|
||||||
|
}
|
||||||
|
// Check for ObjectExpression (inline objects like style={{...}})
|
||||||
|
if (expr.type === "ObjectExpression") {
|
||||||
|
dynamic = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check children for static text
|
||||||
|
let allChildrenAreText = true;
|
||||||
|
let hasText = false;
|
||||||
|
|
||||||
|
if (foundElement.children && foundElement.children.length > 0) {
|
||||||
|
foundElement.children.forEach((child: any) => {
|
||||||
|
if (child.type === "JSXText") {
|
||||||
|
// It's text (could be whitespace)
|
||||||
|
if (child.value.trim().length > 0) hasText = true;
|
||||||
|
} else if (
|
||||||
|
child.type === "JSXExpressionContainer" &&
|
||||||
|
child.expression.type === "StringLiteral"
|
||||||
|
) {
|
||||||
|
hasText = true;
|
||||||
|
} else {
|
||||||
|
// If it's not text (e.g. another Element), mark as not text-only
|
||||||
|
allChildrenAreText = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// No children
|
||||||
|
allChildrenAreText = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasText && allChildrenAreText) {
|
||||||
|
staticText = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isDynamic: dynamic, hasStaticText: staticText };
|
||||||
|
}
|
||||||
199
src/utils/style-utils.ts
Normal file
199
src/utils/style-utils.ts
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
// 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] + "-";
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,9 @@
|
|||||||
let hoverOverlay = null;
|
let hoverOverlay = null;
|
||||||
let hoverLabel = null;
|
let hoverLabel = null;
|
||||||
let currentHoveredElement = null;
|
let currentHoveredElement = null;
|
||||||
|
let highlightedElement = null;
|
||||||
|
let componentCoordinates = null; // Store the last selected component's coordinates
|
||||||
|
let isProMode = false; // Track if pro mode is enabled
|
||||||
//detect if the user is using Mac
|
//detect if the user is using Mac
|
||||||
const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0;
|
const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0;
|
||||||
|
|
||||||
@@ -51,7 +54,7 @@
|
|||||||
return { overlay, label };
|
return { overlay, label };
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateOverlay(el, isSelected = false) {
|
function updateOverlay(el, isSelected = false, isHighlighted = false) {
|
||||||
// If no element, hide hover overlay
|
// If no element, hide hover overlay
|
||||||
if (!el) {
|
if (!el) {
|
||||||
if (hoverOverlay) hoverOverlay.style.display = "none";
|
if (hoverOverlay) hoverOverlay.style.display = "none";
|
||||||
@@ -67,14 +70,19 @@
|
|||||||
overlays.push({ overlay, label, el });
|
overlays.push({ overlay, label, el });
|
||||||
|
|
||||||
const rect = el.getBoundingClientRect();
|
const rect = el.getBoundingClientRect();
|
||||||
|
const borderColor = isHighlighted ? "#00ff00" : "#7f22fe";
|
||||||
|
const backgroundColor = isHighlighted
|
||||||
|
? "rgba(0, 255, 0, 0.05)"
|
||||||
|
: "rgba(127, 34, 254, 0.05)";
|
||||||
|
|
||||||
css(overlay, {
|
css(overlay, {
|
||||||
top: `${rect.top + window.scrollY}px`,
|
top: `${rect.top + window.scrollY}px`,
|
||||||
left: `${rect.left + window.scrollX}px`,
|
left: `${rect.left + window.scrollX}px`,
|
||||||
width: `${rect.width}px`,
|
width: `${rect.width}px`,
|
||||||
height: `${rect.height}px`,
|
height: `${rect.height}px`,
|
||||||
display: "block",
|
display: "block",
|
||||||
border: "3px solid #7f22fe",
|
border: `3px solid ${borderColor}`,
|
||||||
background: "rgba(127, 34, 254, 0.05)",
|
background: backgroundColor,
|
||||||
});
|
});
|
||||||
|
|
||||||
css(label, { display: "none" });
|
css(label, { display: "none" });
|
||||||
@@ -143,6 +151,30 @@
|
|||||||
height: `${rect.height}px`,
|
height: `${rect.height}px`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Send updated coordinates for highlighted or selected component to parent
|
||||||
|
if (highlightedElement) {
|
||||||
|
// Multi-selector mode: send coordinates for the highlighted component
|
||||||
|
const highlightedItem = overlays.find(
|
||||||
|
({ el }) => el === highlightedElement,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (highlightedItem) {
|
||||||
|
const rect = highlightedItem.el.getBoundingClientRect();
|
||||||
|
window.parent.postMessage(
|
||||||
|
{
|
||||||
|
type: "dyad-component-coordinates-updated",
|
||||||
|
coordinates: {
|
||||||
|
top: rect.top,
|
||||||
|
left: rect.left,
|
||||||
|
width: rect.width,
|
||||||
|
height: rect.height,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"*",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearOverlays() {
|
function clearOverlays() {
|
||||||
@@ -156,17 +188,70 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
currentHoveredElement = null;
|
currentHoveredElement = null;
|
||||||
|
highlightedElement = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeOverlayById(componentId) {
|
function removeOverlayById(componentId) {
|
||||||
const index = overlays.findIndex(
|
// Remove all overlays with the same componentId
|
||||||
({ el }) => el.dataset.dyadId === componentId,
|
const indicesToRemove = [];
|
||||||
);
|
overlays.forEach((item, index) => {
|
||||||
if (index !== -1) {
|
if (item.el.dataset.dyadId === componentId) {
|
||||||
const { overlay } = overlays[index];
|
indicesToRemove.push(index);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove in reverse order to maintain correct indices
|
||||||
|
for (let i = indicesToRemove.length - 1; i >= 0; i--) {
|
||||||
|
const { overlay } = overlays[indicesToRemove[i]];
|
||||||
overlay.remove();
|
overlay.remove();
|
||||||
overlays.splice(index, 1);
|
overlays.splice(indicesToRemove[i], 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
highlightedElement &&
|
||||||
|
highlightedElement.dataset.dyadId === componentId
|
||||||
|
) {
|
||||||
|
highlightedElement = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to check if mouse is over the toolbar
|
||||||
|
function isMouseOverToolbar(mouseX, mouseY) {
|
||||||
|
if (!componentCoordinates) return false;
|
||||||
|
|
||||||
|
// Toolbar is positioned at bottom of component: top = coordinates.top + coordinates.height + 4px
|
||||||
|
const toolbarTop =
|
||||||
|
componentCoordinates.top + componentCoordinates.height + 4;
|
||||||
|
const toolbarLeft = componentCoordinates.left;
|
||||||
|
const toolbarHeight = 60;
|
||||||
|
// Add some padding to the width since we don't know exact width
|
||||||
|
const toolbarWidth = componentCoordinates.width || 400;
|
||||||
|
|
||||||
|
return (
|
||||||
|
mouseY >= toolbarTop &&
|
||||||
|
mouseY <= toolbarTop + toolbarHeight &&
|
||||||
|
mouseX >= toolbarLeft &&
|
||||||
|
mouseX <= toolbarLeft + toolbarWidth
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to check if the highlighted component is inside another selected component
|
||||||
|
function isHighlightedComponentChildOfSelected() {
|
||||||
|
if (!highlightedElement) return null;
|
||||||
|
|
||||||
|
const highlightedItem = overlays.find(
|
||||||
|
({ el }) => el === highlightedElement,
|
||||||
|
);
|
||||||
|
if (!highlightedItem) return null;
|
||||||
|
|
||||||
|
// Check if any other selected component contains the highlighted element
|
||||||
|
for (const item of overlays) {
|
||||||
|
if (item.el === highlightedItem.el) continue; // Skip the highlighted component itself
|
||||||
|
if (item.el.contains(highlightedItem.el)) {
|
||||||
|
return item; // Return the parent component
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to show/hide and populate label for a selected overlay
|
// Helper function to show/hide and populate label for a selected overlay
|
||||||
@@ -227,11 +312,43 @@
|
|||||||
|
|
||||||
/* ---------- event handlers -------------------------------------------- */
|
/* ---------- event handlers -------------------------------------------- */
|
||||||
function onMouseMove(e) {
|
function onMouseMove(e) {
|
||||||
|
// Check if mouse is over toolbar - if so, hide the label and treat as if mouse left component
|
||||||
|
if (isMouseOverToolbar(e.clientX, e.clientY)) {
|
||||||
|
if (currentHoveredElement) {
|
||||||
|
const previousItem = overlays.find(
|
||||||
|
(item) => item.el === currentHoveredElement,
|
||||||
|
);
|
||||||
|
if (previousItem) {
|
||||||
|
updateSelectedOverlayLabel(previousItem, false);
|
||||||
|
}
|
||||||
|
currentHoveredElement = null;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let el = e.target;
|
let el = e.target;
|
||||||
while (el && !el.dataset.dyadId) el = el.parentElement;
|
while (el && !el.dataset.dyadId) el = el.parentElement;
|
||||||
|
|
||||||
const hoveredItem = overlays.find((item) => item.el === el);
|
const hoveredItem = overlays.find((item) => item.el === el);
|
||||||
|
|
||||||
|
// Check if the highlighted component is a child of another selected component
|
||||||
|
const parentOfHighlighted = isHighlightedComponentChildOfSelected();
|
||||||
|
|
||||||
|
// If hovering over the highlighted component and it has a parent, hide the parent's label
|
||||||
|
if (
|
||||||
|
hoveredItem &&
|
||||||
|
hoveredItem.el === highlightedElement &&
|
||||||
|
parentOfHighlighted
|
||||||
|
) {
|
||||||
|
// Hide the parent component's label
|
||||||
|
updateSelectedOverlayLabel(parentOfHighlighted, false);
|
||||||
|
// Also clear currentHoveredElement if it's the parent
|
||||||
|
if (currentHoveredElement === parentOfHighlighted.el) {
|
||||||
|
currentHoveredElement = null;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (currentHoveredElement && currentHoveredElement !== el) {
|
if (currentHoveredElement && currentHoveredElement !== el) {
|
||||||
const previousItem = overlays.find(
|
const previousItem = overlays.find(
|
||||||
(item) => item.el === currentHoveredElement,
|
(item) => item.el === currentHoveredElement,
|
||||||
@@ -243,8 +360,8 @@
|
|||||||
|
|
||||||
currentHoveredElement = el;
|
currentHoveredElement = el;
|
||||||
|
|
||||||
// If hovering over a selected component, show its label
|
// If hovering over a selected component, show its label only if it's not highlighted
|
||||||
if (hoveredItem) {
|
if (hoveredItem && hoveredItem.el !== highlightedElement) {
|
||||||
updateSelectedOverlayLabel(hoveredItem, true);
|
updateSelectedOverlayLabel(hoveredItem, true);
|
||||||
if (hoverOverlay) hoverOverlay.style.display = "none";
|
if (hoverOverlay) hoverOverlay.style.display = "none";
|
||||||
}
|
}
|
||||||
@@ -280,29 +397,76 @@
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
const selectedItem = overlays.find((item) => item.el === e.target);
|
const clickedComponentId = state.element.dataset.dyadId;
|
||||||
if (selectedItem) {
|
const selectedItem = overlays.find((item) => item.el === state.element);
|
||||||
removeOverlayById(state.element.dataset.dyadId);
|
|
||||||
|
// If clicking on the currently highlighted component, deselect it
|
||||||
|
if (selectedItem && (highlightedElement === state.element || !isProMode)) {
|
||||||
|
if (state.element.contentEditable === "true") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
removeOverlayById(clickedComponentId);
|
||||||
|
requestAnimationFrame(updateAllOverlayPositions);
|
||||||
|
highlightedElement = null;
|
||||||
|
|
||||||
|
// Only post message once for all elements with the same ID
|
||||||
window.parent.postMessage(
|
window.parent.postMessage(
|
||||||
{
|
{
|
||||||
type: "dyad-component-deselected",
|
type: "dyad-component-deselected",
|
||||||
componentId: state.element.dataset.dyadId,
|
componentId: clickedComponentId,
|
||||||
},
|
},
|
||||||
"*",
|
"*",
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
updateOverlay(state.element, true);
|
// Update only the previously highlighted component
|
||||||
|
if (highlightedElement && highlightedElement !== state.element) {
|
||||||
|
const previousItem = overlays.find(
|
||||||
|
(item) => item.el === highlightedElement,
|
||||||
|
);
|
||||||
|
if (previousItem) {
|
||||||
|
css(previousItem.overlay, {
|
||||||
|
border: `3px solid #7f22fe`,
|
||||||
|
background: "rgba(127, 34, 254, 0.05)",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
requestAnimationFrame(updateAllOverlayPositions);
|
highlightedElement = state.element;
|
||||||
|
|
||||||
|
if (selectedItem && isProMode) {
|
||||||
|
css(selectedItem.overlay, {
|
||||||
|
border: `3px solid #00ff00`,
|
||||||
|
background: "rgba(0, 255, 0, 0.05)",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selectedItem) {
|
||||||
|
updateOverlay(state.element, true, isProMode);
|
||||||
|
requestAnimationFrame(updateAllOverlayPositions);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assign a unique runtime ID to this element if it doesn't have one
|
||||||
|
if (!state.element.dataset.dyadRuntimeId) {
|
||||||
|
state.element.dataset.dyadRuntimeId = `dyad-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rect = state.element.getBoundingClientRect();
|
||||||
window.parent.postMessage(
|
window.parent.postMessage(
|
||||||
{
|
{
|
||||||
type: "dyad-component-selected",
|
type: "dyad-component-selected",
|
||||||
component: {
|
component: {
|
||||||
id: state.element.dataset.dyadId,
|
id: clickedComponentId,
|
||||||
name: state.element.dataset.dyadName,
|
name: state.element.dataset.dyadName,
|
||||||
|
runtimeId: state.element.dataset.dyadRuntimeId,
|
||||||
|
},
|
||||||
|
coordinates: {
|
||||||
|
top: rect.top,
|
||||||
|
left: rect.left,
|
||||||
|
width: rect.width,
|
||||||
|
height: rect.height,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"*",
|
"*",
|
||||||
@@ -362,10 +526,30 @@
|
|||||||
/* ---------- message bridge -------------------------------------------- */
|
/* ---------- message bridge -------------------------------------------- */
|
||||||
window.addEventListener("message", (e) => {
|
window.addEventListener("message", (e) => {
|
||||||
if (e.source !== window.parent) return;
|
if (e.source !== window.parent) return;
|
||||||
|
if (e.data.type === "dyad-pro-mode") {
|
||||||
|
isProMode = e.data.enabled;
|
||||||
|
}
|
||||||
if (e.data.type === "activate-dyad-component-selector") activate();
|
if (e.data.type === "activate-dyad-component-selector") activate();
|
||||||
if (e.data.type === "deactivate-dyad-component-selector") deactivate();
|
if (e.data.type === "deactivate-dyad-component-selector") deactivate();
|
||||||
|
if (e.data.type === "activate-dyad-visual-editing") {
|
||||||
|
activate();
|
||||||
|
}
|
||||||
|
if (e.data.type === "deactivate-dyad-visual-editing") {
|
||||||
|
deactivate();
|
||||||
|
clearOverlays();
|
||||||
|
}
|
||||||
if (e.data.type === "clear-dyad-component-overlays") clearOverlays();
|
if (e.data.type === "clear-dyad-component-overlays") clearOverlays();
|
||||||
if (e.data.type === "remove-dyad-component-overlay") {
|
if (e.data.type === "update-dyad-overlay-positions") {
|
||||||
|
updateAllOverlayPositions();
|
||||||
|
}
|
||||||
|
if (e.data.type === "update-component-coordinates") {
|
||||||
|
// Store component coordinates for toolbar hover detection
|
||||||
|
componentCoordinates = e.data.coordinates;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
e.data.type === "remove-dyad-component-overlay" ||
|
||||||
|
e.data.type === "deselect-dyad-component"
|
||||||
|
) {
|
||||||
if (e.data.componentId) {
|
if (e.data.componentId) {
|
||||||
removeOverlayById(e.data.componentId);
|
removeOverlayById(e.data.componentId);
|
||||||
}
|
}
|
||||||
@@ -380,8 +564,9 @@
|
|||||||
|
|
||||||
document.addEventListener("mouseleave", onMouseLeave, true);
|
document.addEventListener("mouseleave", onMouseLeave, true);
|
||||||
|
|
||||||
// Update overlay positions on window resize
|
// Update overlay positions on window resize and scroll
|
||||||
window.addEventListener("resize", updateAllOverlayPositions);
|
window.addEventListener("resize", updateAllOverlayPositions);
|
||||||
|
window.addEventListener("scroll", updateAllOverlayPositions, true);
|
||||||
|
|
||||||
function initializeComponentSelector() {
|
function initializeComponentSelector() {
|
||||||
if (!document.body) {
|
if (!document.body) {
|
||||||
|
|||||||
278
worker/dyad-visual-editor-client.js
Normal file
278
worker/dyad-visual-editor-client.js
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
(() => {
|
||||||
|
/* ---------- 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"
|
|||||||
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 {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
|||||||
'<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;
|
||||||
|
|||||||
Reference in New Issue
Block a user