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

Add image selection and swapping in the visual editor (#2717)

closes #2634 <!-- devin-review-badge-begin --> --- <a href="https://app.devin.ai/review/dyad-sh/dyad/pull/2717" target="_blank"> <picture> <source media="(prefers-color-scheme: dark)" srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1"> <img src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1" alt="Open with Devin"> </picture> </a> <!-- devin-review-badge-end --> --------- Co-authored-by: 's avatarClaude <noreply@anthropic.com> Co-authored-by: 's avatarMohamed Aziz Mejri <mohamedazizmejri@Mohameds-Mac-mini.local>
上级 daa8bce5
OK, I'm going to write an app with an image now...
<dyad-write path="src/pages/Index.tsx" description="write-description">
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">Welcome to Your Blank App</h1>
<img src="/placeholder.svg" alt="Hero image" className="mx-auto mb-4 w-64 h-64" />
<p className="text-xl text-gray-600">
Start building your amazing project here!
</p>
</div>
<MadeWithDyad />
</div>
);
};
export default Index;
</dyad-write>
And it's done!
=== src/pages/Index.tsx ===
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">Welcome to Your Blank App</h1>
<img
src="https://example.com/new-hero.png"
alt="Hero image"
className="mx-auto mb-4 w-64 h-64" />
<p className="text-xl text-gray-600">Start building your amazing project here!
</p>
</div>
<MadeWithDyad />
</div>
);
};
export default Index;
\ No newline at end of file
......@@ -140,6 +140,80 @@ testSkipIfWindows("edit text of the selected component", async ({ po }) => {
});
});
testSkipIfWindows("swap image via URL", async ({ po }) => {
await po.setUpDyadPro();
await po.sendPrompt("tc=image-basic");
await po.approveProposal();
// Wait for the app to rebuild with the new code
await po.previewPanel.clickPreviewPickElement();
// Wait for the image element to appear in the iframe after rebuild
const heroImage = po.previewPanel
.getPreviewIframeElement()
.contentFrame()
.getByRole("img", { name: "Hero image" });
await expect(heroImage).toBeVisible({ timeout: Timeout.LONG });
// Select the image element in the preview
await heroImage.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 the Swap Image button to open the image popover
const swapImageButton = po.page.getByRole("button", { name: "Swap Image" });
await expect(swapImageButton).toBeVisible({ timeout: Timeout.LONG });
await swapImageButton.click();
// Wait for the Image Source popover to appear
const imagePopover = po.page
.locator('[role="dialog"]')
.filter({ hasText: "Image Source" });
await expect(imagePopover).toBeVisible({
timeout: Timeout.LONG,
});
// Enter a new image URL
const urlInput = po.page.getByLabel("Image URL");
await urlInput.fill("https://example.com/new-hero.png");
// Click Apply to submit the new URL
await po.page.getByRole("button", { name: "Apply" }).click();
// Close the popover
await po.page.keyboard.press("Escape");
// 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.toastNotifications.waitForToastWithText(
"Visual changes saved to source files",
);
// Verify that the changes are applied to the codebase
await po.snapshotAppFiles({
name: "visual-editing-swap-image",
files: ["src/pages/Index.tsx"],
});
});
testSkipIfWindows("discard changes", async ({ po }) => {
await po.setUpDyadPro();
await po.sendPrompt("tc=basic");
......
......@@ -150,7 +150,12 @@ describe("handleDeleteBranch", () => {
});
it("throws generic error when branch only exists on remote for non-GitHub app", async () => {
const nonGithubApp = { id: 1, path: "test-app", githubOrg: null, githubRepo: null };
const nonGithubApp = {
id: 1,
path: "test-app",
githubOrg: null,
githubRepo: null,
};
vi.mocked(db.query.apps.findFirst).mockResolvedValue(nonGithubApp as any);
vi.mocked(gitListBranches).mockResolvedValue(["main"]);
vi.mocked(gitListRemoteBranches).mockResolvedValue(["main", "feature"]);
......
import { useState, useRef, useEffect } from "react";
import { ImageIcon, Upload, Link, Check } from "lucide-react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { StylePopover } from "./StylePopover";
import { VALID_IMAGE_MIME_TYPES } from "@/ipc/types/visual-editing";
export interface ImageUploadData {
fileName: string;
base64Data: string;
mimeType: string;
}
interface ImageSwapPopoverProps {
currentSrc: string;
isDynamicImage?: boolean;
onSwap: (newSrc: string, uploadData?: ImageUploadData) => void;
}
export function ImageSwapPopover({
currentSrc,
isDynamicImage,
onSwap,
}: ImageSwapPopoverProps) {
const [mode, setMode] = useState<"url" | "upload">("url");
const [urlValue, setUrlValue] = useState(currentSrc);
const [selectedFileName, setSelectedFileName] = useState<string | null>(null);
const [fileError, setFileError] = useState<string | null>(null);
const [urlError, setUrlError] = useState<string | null>(null);
const [appliedSrc, setAppliedSrc] = useState(currentSrc);
const fileInputRef = useRef<HTMLInputElement>(null);
// Sync state when a different component is selected
useEffect(() => {
setUrlValue(currentSrc);
setAppliedSrc(currentSrc);
setSelectedFileName(null);
setFileError(null);
setUrlError(null);
}, [currentSrc]);
const handleUrlSubmit = () => {
const trimmed = urlValue.trim();
if (!trimmed) {
setUrlError("Please enter a URL.");
return;
}
// Accept absolute URLs (http/https/protocol-relative) and root-relative paths
if (
!/^https?:\/\//i.test(trimmed) &&
!trimmed.startsWith("//") &&
!trimmed.startsWith("/")
) {
setUrlError(
"Please enter a valid URL (https://...) or an absolute path (/...).",
);
return;
}
setUrlError(null);
setAppliedSrc(trimmed);
onSwap(trimmed);
};
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
if (!(VALID_IMAGE_MIME_TYPES as readonly string[]).includes(file.type)) {
setFileError("Unsupported file type. Please use JPG, PNG, GIF, or WebP.");
return;
}
if (file.size > 7.5 * 1024 * 1024) {
setFileError(
"Image is too large (max 7.5 MB). Please choose a smaller file.",
);
return;
}
setFileError(null);
setSelectedFileName(file.name);
const reader = new FileReader();
reader.onload = () => {
const base64DataUrl = reader.result as string;
// The handler will generate the final unique filename.
// We just need a placeholder path for the pending change.
const sanitizedName = file.name.replace(/[^a-zA-Z0-9._-]/g, "_");
const newSrc = `/images/${sanitizedName}`;
setAppliedSrc(newSrc);
onSwap(newSrc, {
fileName: file.name,
base64Data: base64DataUrl,
mimeType: file.type,
});
};
reader.onerror = () => {
setFileError(
"Failed to read the file. Please try again or choose a different file.",
);
setSelectedFileName(null);
};
reader.readAsDataURL(file);
// Clear input so same file can be selected again
e.target.value = "";
};
return (
<StylePopover
icon={<ImageIcon size={16} />}
title="Image Source"
tooltip="Swap Image"
>
<div className="space-y-3">
{isDynamicImage && (
<p className="text-xs text-yellow-600 dark:text-yellow-400">
This image has a dynamic source. Swapping will replace it with a
static value.
</p>
)}
{/* Mode toggle tabs */}
<Tabs
value={mode}
onValueChange={(val) => setMode(val as "url" | "upload")}
>
<TabsList className="w-full h-8">
<TabsTrigger value="url" className="flex-1 gap-1 text-xs">
<Link size={12} />
URL
</TabsTrigger>
<TabsTrigger value="upload" className="flex-1 gap-1 text-xs">
<Upload size={12} />
Upload
</TabsTrigger>
</TabsList>
</Tabs>
{mode === "url" ? (
<div className="space-y-2">
<Label htmlFor="image-url" className="text-xs">
Image URL
</Label>
<Input
id="image-url"
type="text"
placeholder="https://example.com/image.png"
className="h-8 text-xs"
value={urlValue}
aria-invalid={!!urlError}
aria-describedby={urlError ? "image-url-error" : undefined}
onChange={(e) => {
setUrlValue(e.target.value);
setUrlError(null);
}}
onKeyDown={(e) => {
if (e.key === "Enter") handleUrlSubmit();
}}
/>
{urlError && (
<p
id="image-url-error"
role="alert"
className="text-xs text-red-500"
>
{urlError}
</p>
)}
<Button size="sm" onClick={handleUrlSubmit} className="w-full">
<Check size={14} className="mr-1" />
Apply
</Button>
</div>
) : (
<div className="space-y-2">
<Label className="text-xs">Upload Image</Label>
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/gif,image/webp"
className="hidden"
onChange={handleFileSelect}
/>
<Button
size="sm"
variant="outline"
onClick={() => fileInputRef.current?.click()}
className="w-full"
>
<Upload size={14} className="mr-1 shrink-0" />
<span className="truncate">
{selectedFileName || "Choose File"}
</span>
</Button>
{fileError && (
<p role="alert" className="text-xs text-red-500">
{fileError}
</p>
)}
<p className="text-xs text-gray-500 dark:text-gray-400">
Supports: JPG, PNG, GIF, WebP
</p>
</div>
)}
{/* Current source display */}
<div className="pt-2 border-t border-border">
<Label className="text-xs text-gray-500 dark:text-gray-400">
Current source
</Label>
<p className="text-xs font-mono truncate mt-1" title={appliedSrc}>
{appliedSrc || "none"}
</p>
</div>
</div>
</StylePopover>
);
}
......@@ -51,6 +51,7 @@ import {
} from "@/atoms/previewAtoms";
import { isChatPanelHiddenAtom } from "@/atoms/viewAtoms";
import { ComponentSelection } from "@/ipc/types";
import { mergePendingChange } from "@/ipc/types/visual-editing";
import {
Popover,
PopoverContent,
......@@ -238,6 +239,9 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
// AST Analysis State
const [isDynamicComponent, setIsDynamicComponent] = useState(false);
const [hasStaticText, setHasStaticText] = useState(false);
const [hasImage, setHasImage] = useState(false);
const [isDynamicImage, setIsDynamicImage] = useState(false);
const [currentImageSrc, setCurrentImageSrc] = useState("");
// Device mode state
const deviceMode: DeviceMode = settings?.previewDeviceMode ?? "desktop";
......@@ -262,6 +266,9 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
});
setIsDynamicComponent(result.isDynamic);
setHasStaticText(result.hasStaticText);
setHasImage(result.hasImage);
setIsDynamicImage(result.isDynamicImage || false);
setCurrentImageSrc(result.imageSrc || "");
// Automatically enable text editing if component has static text
if (result.hasStaticText && iframeRef.current?.contentWindow) {
......@@ -280,6 +287,9 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
console.error("Failed to analyze component", err);
setIsDynamicComponent(false);
setHasStaticText(false);
setHasImage(false);
setIsDynamicImage(false);
setCurrentImageSrc("");
}
};
......@@ -301,15 +311,19 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
const updated = new Map(prev);
const existing = updated.get(componentId);
updated.set(componentId, {
componentId: componentId,
updated.set(
componentId,
mergePendingChange(existing, {
componentId,
componentName:
existing?.componentName || visualEditingSelectedComponent?.name || "",
existing?.componentName ||
visualEditingSelectedComponent?.name ||
"",
relativePath: filePath,
lineNumber: lineNumber,
styles: existing?.styles || {},
lineNumber,
textContent: text,
});
}),
);
return updated;
});
......@@ -542,6 +556,35 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
return;
}
if (event.data?.type === "dyad-image-load-error") {
showError("Image failed to load. Please check the URL and try again.");
// Remove the broken image from pending changes
const { elementId } = event.data;
if (elementId) {
setPendingChanges((prev) => {
const updated = new Map(prev);
const existing = updated.get(elementId);
if (existing?.imageSrc) {
const hasStyles =
existing.styles && Object.keys(existing.styles).length > 0;
if (!hasStyles && !existing.textContent) {
// No other changes, remove entirely
updated.delete(elementId);
} else {
// Keep the entry but remove image data
updated.set(elementId, {
...existing,
imageSrc: undefined,
imageUpload: undefined,
});
}
}
return updated;
});
}
return;
}
if (event.data?.type === "dyad-component-coordinates-updated") {
if (event.data.coordinates) {
setCurrentComponentCoordinates(event.data.coordinates);
......@@ -1381,6 +1424,9 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
iframeRef={iframeRef}
isDynamic={isDynamicComponent}
hasStaticText={hasStaticText}
hasImage={hasImage}
isDynamicImage={isDynamicImage}
currentImageSrc={currentImageSrc}
/>
)}
</>
......
......@@ -18,6 +18,8 @@ import { StylePopover } from "./StylePopover";
import { ColorPicker } from "@/components/ui/ColorPicker";
import { NumberInput } from "@/components/ui/NumberInput";
import { rgbToHex, processNumericValue } from "@/utils/style-utils";
import { ImageSwapPopover, type ImageUploadData } from "./ImageSwapPopover";
import { mergePendingChange } from "@/ipc/types/visual-editing";
const FONT_WEIGHT_OPTIONS = [
{ value: "", label: "Default" },
......@@ -59,6 +61,9 @@ interface VisualEditingToolbarProps {
iframeRef: React.RefObject<HTMLIFrameElement | null>;
isDynamic: boolean;
hasStaticText: boolean;
hasImage: boolean;
isDynamicImage: boolean;
currentImageSrc: string;
}
export function VisualEditingToolbar({
......@@ -66,6 +71,9 @@ export function VisualEditingToolbar({
iframeRef,
isDynamic,
hasStaticText,
hasImage,
isDynamicImage,
currentImageSrc,
}: VisualEditingToolbarProps) {
const coordinates = useAtomValue(currentComponentCoordinatesAtom);
const [currentMargin, setCurrentMargin] = useState({ x: "", y: "" });
......@@ -161,14 +169,16 @@ export function VisualEditingToolbar({
newStyles.text = { ...existing?.styles?.text, ...styles.text };
}
updated.set(selectedComponent.id, {
updated.set(
selectedComponent.id,
mergePendingChange(existing, {
componentId: selectedComponent.id,
componentName: selectedComponent.name,
relativePath: selectedComponent.relativePath,
lineNumber: selectedComponent.lineNumber,
styles: newStyles,
textContent: existing?.textContent || "",
});
}),
);
return updated;
});
};
......@@ -306,6 +316,43 @@ export function VisualEditingToolbar({
}
};
const handleImageSwap = (newSrc: string, uploadData?: ImageUploadData) => {
// 1. Send preview to iframe
if (iframeRef.current?.contentWindow && selectedComponent) {
iframeRef.current.contentWindow.postMessage(
{
type: "modify-dyad-image-src",
data: {
elementId: selectedComponent.id,
runtimeId: selectedComponent.runtimeId,
src: uploadData ? uploadData.base64Data : newSrc,
},
},
"*",
);
}
// 2. Store in pending changes
if (selectedComponent) {
setPendingChanges((prev) => {
const updated = new Map(prev);
const existing = updated.get(selectedComponent.id);
updated.set(
selectedComponent.id,
mergePendingChange(existing, {
componentId: selectedComponent.id,
componentName: selectedComponent.name,
relativePath: selectedComponent.relativePath,
lineNumber: selectedComponent.lineNumber,
imageSrc: newSrc,
imageUpload: uploadData,
}),
);
return updated;
});
}
};
if (!selectedComponent || !coordinates) return null;
const toolbarTop = coordinates.top + coordinates.height + 4;
......@@ -521,6 +568,14 @@ export function VisualEditingToolbar({
</div>
</StylePopover>
)}
{hasImage && (
<ImageSwapPopover
currentSrc={currentImageSrc}
isDynamicImage={isDynamicImage}
onSwap={handleImageSwap}
/>
)}
</>
)}
</div>
......
import { z } from "zod";
import { defineContract, createClient } from "../contracts/core";
// =============================================================================
// Visual Editing Constants
// =============================================================================
export const VALID_IMAGE_MIME_TYPES = [
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
] as const;
// =============================================================================
// Visual Editing Schemas
// =============================================================================
......@@ -51,6 +62,14 @@ export const VisualEditingChangeSchema = z.object({
.optional(),
}),
textContent: z.string().optional(),
imageSrc: z.string().optional(),
imageUpload: z
.object({
fileName: z.string(),
base64Data: z.string(),
mimeType: z.string(),
})
.optional(),
});
export type VisualEditingChange = z.infer<typeof VisualEditingChangeSchema>;
......@@ -76,8 +95,37 @@ export type AnalyseComponentParams = z.infer<
export const AnalyseComponentResultSchema = z.object({
isDynamic: z.boolean(),
hasStaticText: z.boolean(),
hasImage: z.boolean(),
imageSrc: z.string().optional(),
isDynamicImage: z.boolean().optional(),
});
/**
* Merges a partial update into an existing pending change entry,
* preserving all unrelated fields (styles, textContent, imageSrc, imageUpload).
*/
export function mergePendingChange(
existing: VisualEditingChange | undefined,
partial: Partial<VisualEditingChange> &
Pick<
VisualEditingChange,
"componentId" | "componentName" | "relativePath" | "lineNumber"
>,
): VisualEditingChange {
return {
componentId: partial.componentId,
componentName: partial.componentName,
relativePath: partial.relativePath,
lineNumber: partial.lineNumber,
styles: partial.styles ?? existing?.styles ?? {},
textContent:
"textContent" in partial ? partial.textContent : existing?.textContent,
imageSrc: "imageSrc" in partial ? partial.imageSrc : existing?.imageSrc,
imageUpload:
"imageUpload" in partial ? partial.imageUpload : existing?.imageUpload,
};
}
// =============================================================================
// Visual Editing Contracts
// =============================================================================
......
......@@ -457,6 +457,27 @@ export async function gitAdd({ path, filepath }: GitFileParams): Promise<void> {
}
}
export async function gitResetFile({
path,
filepath,
}: GitFileParams): Promise<void> {
const normalizedFilepath = normalizePath(filepath);
const settings = readSettings();
if (settings.enableNativeGit) {
await execOrThrow(
["reset", "HEAD", "--", normalizedFilepath],
path,
`Failed to unstage file '${normalizedFilepath}'`,
);
} else {
await git.resetIndex({
fs,
dir: path,
filepath: normalizedFilepath,
});
}
}
export async function gitReset({ path }: GitBaseParams): Promise<void> {
const settings = readSettings();
if (settings.enableNativeGit) {
......
......@@ -10,23 +10,36 @@ import {
stylesToTailwind,
extractClassPrefixes,
} from "../../../../utils/style-utils";
import { gitAdd, gitCommit } from "../../../../ipc/utils/git_utils";
import {
gitAdd,
gitCommit,
gitResetFile,
} from "../../../../ipc/utils/git_utils";
import { safeJoin } from "@/ipc/utils/path_utils";
import {
AnalyseComponentParams,
ApplyVisualEditingChangesParams,
} from "@/ipc/types";
import { VALID_IMAGE_MIME_TYPES } from "@/ipc/types/visual-editing";
import { DYAD_MEDIA_DIR_NAME } from "@/ipc/utils/media_path_utils";
import { ensureDyadGitignored } from "@/ipc/handlers/gitignoreUtils";
import {
transformContent,
analyzeComponent,
} from "../../utils/visual_editing_utils";
import { normalizePath } from "../../../../../shared/normalizePath";
// Client allows 7.5 MB raw; base64 expands by ~4/3 plus data URL prefix
const MAX_IMAGE_SIZE = Math.ceil((7.5 * 1024 * 1024) / 3) * 4 + 100; // ~10,485,860
export function registerVisualEditingHandlers() {
ipcMain.handle(
"apply-visual-editing-changes",
async (_event, params: ApplyVisualEditingChangesParams) => {
const { appId, changes } = params;
// Track written image files and staged git paths for cleanup on failure
const writtenImagePaths: string[] = [];
const stagedGitPaths: { appPath: string; filepath: string }[] = [];
try {
if (changes.length === 0) return;
......@@ -40,11 +53,93 @@ export function registerVisualEditingHandlers() {
}
const appPath = getDyadAppPath(app.path);
// Validate all image uploads upfront before making any changes
const imageValidationErrors: string[] = [];
for (const change of changes) {
if (change.imageUpload) {
const { fileName, base64Data, mimeType } = change.imageUpload;
if (
!(VALID_IMAGE_MIME_TYPES as readonly string[]).includes(mimeType)
) {
imageValidationErrors.push(
`"${fileName}": Unsupported image type (${mimeType}). Allowed types: JPEG, PNG, GIF, WebP.`,
);
}
if (base64Data.length > MAX_IMAGE_SIZE) {
imageValidationErrors.push(
`"${fileName}": The image is too large (max 7.5 MB). Please choose a smaller file.`,
);
}
}
}
if (imageValidationErrors.length > 0) {
throw new Error(
imageValidationErrors.length === 1
? imageValidationErrors[0]
: `Multiple image issues:\n${imageValidationErrors.join("\n")}`,
);
}
// Write validated image files to public directory
for (const change of changes) {
if (change.imageUpload) {
const { fileName, base64Data } = change.imageUpload;
const sanitizedFileName = fileName.replace(/[^a-zA-Z0-9._-]/g, "_");
const timestamp = Date.now();
const finalFileName = `${timestamp}-${sanitizedFileName}`;
const buffer = Buffer.from(
base64Data.replace(/^data:[^;]+;base64,/, ""),
"base64",
);
// Save to .dyad/media as a staging copy
const mediaDir = path.join(appPath, DYAD_MEDIA_DIR_NAME);
await fsPromises.mkdir(mediaDir, { recursive: true });
await fsPromises.writeFile(
path.join(mediaDir, finalFileName),
buffer,
);
await ensureDyadGitignored(appPath);
// Save to public/images for the app to serve
const publicImagesDir = path.join(appPath, "public", "images");
await fsPromises.mkdir(publicImagesDir, { recursive: true });
const destPath = path.join(publicImagesDir, finalFileName);
await fsPromises.writeFile(destPath, buffer);
writtenImagePaths.push(destPath);
writtenImagePaths.push(path.join(mediaDir, finalFileName));
change.imageSrc = `/images/${finalFileName}`;
if (fs.existsSync(path.join(appPath, ".git"))) {
const imageFilepath = normalizePath(
path.join("public", "images", finalFileName),
);
await gitAdd({
path: appPath,
filepath: imageFilepath,
});
stagedGitPaths.push({ appPath, filepath: imageFilepath });
}
}
}
const fileChanges = new Map<
string,
Map<
number,
{ classes: string[]; prefixes: string[]; textContent?: string }
{
classes: string[];
prefixes: string[];
textContent?: string;
imageSrc?: string;
}
>
>();
......@@ -62,6 +157,9 @@ export function registerVisualEditingHandlers() {
...(change.textContent !== undefined && {
textContent: change.textContent,
}),
...(change.imageSrc !== undefined && {
imageSrc: change.imageSrc,
}),
});
}
......@@ -86,7 +184,26 @@ export function registerVisualEditingHandlers() {
}
}
} catch (error) {
throw new Error(`Failed to apply visual editing changes: ${error}`);
// Unstage any image files that were git-added before the failure
for (const { appPath, filepath } of stagedGitPaths) {
try {
await gitResetFile({ path: appPath, filepath });
} catch {
// Ignore cleanup errors
}
}
// Clean up any image files written before the failure
for (const filePath of writtenImagePaths) {
try {
await fsPromises.unlink(filePath);
} catch {
// Ignore cleanup errors
}
}
if (error instanceof Error) {
throw error;
}
throw new Error(String(error));
}
},
);
......@@ -100,7 +217,7 @@ export function registerVisualEditingHandlers() {
const line = parseInt(lineStr, 10);
if (!filePath || isNaN(line)) {
return { isDynamic: false, hasStaticText: false };
return { isDynamic: false, hasStaticText: false, hasImage: false };
}
// Get the app to find its path
......@@ -118,7 +235,7 @@ export function registerVisualEditingHandlers() {
return analyzeComponent(content, line);
} catch (error) {
console.error("Failed to analyze component:", error);
return { isDynamic: false, hasStaticText: false };
return { isDynamic: false, hasStaticText: false, hasImage: false };
}
},
);
......
......@@ -614,4 +614,162 @@ function Component(): JSX.Element {
expect(result.hasStaticText).toBe(true);
});
});
describe("image detection", () => {
it("should detect an <img> element with static src", () => {
const content = `
function Component() {
return <img src="/images/hero.png" alt="Hero" />;
}`;
const result = analyzeComponent(content, 3);
expect(result.hasImage).toBe(true);
expect(result.imageSrc).toBe("/images/hero.png");
});
it("should detect an <img> element with src in expression container", () => {
const content = `
function Component() {
return <img src={"https://example.com/photo.jpg"} alt="Photo" />;
}`;
const result = analyzeComponent(content, 3);
expect(result.hasImage).toBe(true);
expect(result.imageSrc).toBe("https://example.com/photo.jpg");
});
it("should detect an <img> child inside a wrapper div", () => {
const content = `
function Component() {
return <div className="image-wrapper"><img src="/images/photo.png" alt="Photo" /></div>;
}`;
const result = analyzeComponent(content, 3);
expect(result.hasImage).toBe(true);
expect(result.imageSrc).toBe("/images/photo.png");
});
it("should return hasImage false for non-image elements", () => {
const content = `
function Component() {
return <div>Hello World</div>;
}`;
const result = analyzeComponent(content, 3);
expect(result.hasImage).toBe(false);
expect(result.imageSrc).toBeUndefined();
});
it("should detect an <img> with no src attribute", () => {
const content = `
function Component() {
return <img alt="No source" />;
}`;
const result = analyzeComponent(content, 3);
expect(result.hasImage).toBe(true);
expect(result.imageSrc).toBeUndefined();
});
it("should return hasImage false when element not found", () => {
const content = `
function Component() {
return <div>Hello</div>;
}`;
const result = analyzeComponent(content, 999);
expect(result.hasImage).toBe(false);
});
});
});
describe("transformContent - image src", () => {
it("should update src attribute on <img> element", () => {
const content = `
function Component() {
return <img src="/images/old.png" alt="Photo" />;
}`;
const changes = new Map([
[3, { classes: [], prefixes: [], imageSrc: "/images/new.png" }],
]);
const result = transformContent(content, changes);
expect(result).not.toContain("/images/old.png");
expect(result).toContain("/images/new.png");
});
it("should add src attribute when none exists on <img>", () => {
const content = `
function Component() {
return <img alt="Photo" />;
}`;
const changes = new Map([
[3, { classes: [], prefixes: [], imageSrc: "/images/added.png" }],
]);
const result = transformContent(content, changes);
expect(result).toContain("/images/added.png");
});
it("should update src on child <img> inside a wrapper", () => {
const content = `
function Component() {
return <div><img src="/old.png" alt="Old" /></div>;
}`;
const changes = new Map([
[3, { classes: [], prefixes: [], imageSrc: "/new.png" }],
]);
const result = transformContent(content, changes);
expect(result).not.toContain("/old.png");
expect(result).toContain("/new.png");
});
it("should replace expression-based src with string literal", () => {
const content = `
function Component() {
return <img src={"https://example.com/old.jpg"} alt="Photo" />;
}`;
const changes = new Map([
[
3,
{
classes: [],
prefixes: [],
imageSrc: "https://cdn.example.com/new.jpg",
},
],
]);
const result = transformContent(content, changes);
expect(result).toContain("https://cdn.example.com/new.jpg");
expect(result).not.toContain("https://example.com/old.jpg");
});
it("should apply both image src and class changes together", () => {
const content = `
function Component() {
return <img src="/old.png" className="w-full" alt="Photo" />;
}`;
const changes = new Map([
[
3,
{
classes: ["rounded-lg"],
prefixes: ["rounded-"],
imageSrc: "/new.png",
},
],
]);
const result = transformContent(content, changes);
expect(result).toContain("/new.png");
expect(result).not.toContain("/old.png");
expect(result).toContain("rounded-lg");
});
});
......@@ -6,11 +6,36 @@ interface ContentChange {
classes: string[];
prefixes: string[];
textContent?: string;
imageSrc?: string;
}
interface ComponentAnalysis {
isDynamic: boolean;
hasStaticText: boolean;
hasImage: boolean;
imageSrc?: string;
isDynamicImage?: boolean;
}
/**
* Extracts the static src value from a JSX opening element's attributes.
* Handles both StringLiteral and JSXExpressionContainer wrapping a StringLiteral.
*/
function extractStaticSrc(openingElement: any): string | undefined {
const srcAttr = openingElement.attributes.find(
(attr: any) => attr.type === "JSXAttribute" && attr.name?.name === "src",
);
if (!srcAttr?.value) return undefined;
if (srcAttr.value.type === "StringLiteral") {
return srcAttr.value.value;
}
if (
srcAttr.value.type === "JSXExpressionContainer" &&
srcAttr.value.expression.type === "StringLiteral"
) {
return srcAttr.value.expression.value;
}
return undefined;
}
/**
......@@ -222,6 +247,55 @@ export function transformContent(
];
}
}
// Handle image source change
if (change.imageSrc !== undefined) {
const tagName = path.node.openingElement.name;
// Determine which element to update (self or descendant <img>)
let targetElement: any = null;
if (tagName.type === "JSXIdentifier" && tagName.name === "img") {
targetElement = path.node.openingElement;
} else {
// Recursively search for the first <img> descendant
path.traverse({
JSXElement(innerPath) {
if (
innerPath.node.openingElement.name.type === "JSXIdentifier" &&
innerPath.node.openingElement.name.name === "img"
) {
targetElement = innerPath.node.openingElement;
innerPath.stop();
}
},
});
}
if (targetElement) {
const srcAttr = targetElement.attributes.find(
(attr: any) =>
attr.type === "JSXAttribute" && attr.name?.name === "src",
);
if (srcAttr) {
// Replace the value with a string literal
srcAttr.value = {
type: "StringLiteral",
value: change.imageSrc,
};
} else {
// Add src attribute
targetElement.attributes.push({
type: "JSXAttribute",
name: { type: "JSXIdentifier", name: "src" },
value: {
type: "StringLiteral",
value: change.imageSrc,
},
});
}
}
}
}
},
});
......@@ -286,7 +360,7 @@ export function analyzeComponent(
walk(ast);
if (!foundElement) {
return { isDynamic: false, hasStaticText: false };
return { isDynamic: false, hasStaticText: false, hasImage: false };
}
let dynamic = false;
......@@ -357,5 +431,63 @@ export function analyzeComponent(
staticText = true;
}
return { isDynamic: dynamic, hasStaticText: staticText };
// Check for image elements
let hasImage = false;
let imageSrc: string | undefined;
let isDynamicImage = false;
const tagName = foundElement.openingElement.name;
// Check if the element itself is an <img>
if (tagName.type === "JSXIdentifier" && tagName.name === "img") {
hasImage = true;
imageSrc = extractStaticSrc(foundElement.openingElement);
// If there's a src attribute but extractStaticSrc returned undefined, it's dynamic
const hasSrcAttr = foundElement.openingElement.attributes.some(
(attr: any) => attr.type === "JSXAttribute" && attr.name?.name === "src",
);
if (hasSrcAttr && !imageSrc) {
isDynamicImage = true;
}
}
// Recursively check descendants for <img> elements
if (!hasImage && foundElement) {
const findImg = (node: any): void => {
if (!node || hasImage) return;
if (
node.type === "JSXElement" &&
node.openingElement.name.type === "JSXIdentifier" &&
node.openingElement.name.name === "img"
) {
hasImage = true;
imageSrc = extractStaticSrc(node.openingElement);
const hasSrcAttr = node.openingElement.attributes.some(
(attr: any) =>
attr.type === "JSXAttribute" && attr.name?.name === "src",
);
if (hasSrcAttr && !imageSrc) {
isDynamicImage = true;
}
return;
}
if (Array.isArray(node.children)) {
for (const child of node.children) {
findImg(child);
if (hasImage) return;
}
}
};
findImg(foundElement);
}
return {
isDynamic: dynamic,
hasStaticText: staticText,
hasImage,
imageSrc,
isDynamicImage,
};
}
......@@ -215,6 +215,56 @@
}
}
/**
* Detects if an element is a non-interactive overlay (e.g. a gradient div
* with absolute positioning covering its parent). When such an element is
* the click target it blocks selection of the meaningful content underneath.
* Returns the parent dyad-tagged element if the current one is an overlay,
* or the element itself otherwise.
*/
function skipOverlayElement(el) {
if (!el || !el.parentElement) return el;
// Never skip content-bearing elements
const tag = el.tagName.toLowerCase();
if (
tag === "img" ||
tag === "video" ||
tag === "canvas" ||
tag === "svg" ||
tag === "iframe"
) {
return el;
}
const style = getComputedStyle(el);
// Only consider absolutely/fixed positioned elements
if (style.position !== "absolute" && style.position !== "fixed") return el;
// Don't skip scrollable containers (e.g. message lists with overflow-y-auto)
if (style.overflowY === "auto" || style.overflowY === "scroll") return el;
// Must cover a large portion of its parent (inset-0 pattern)
const parentRect = el.parentElement.getBoundingClientRect();
const elRect = el.getBoundingClientRect();
if (parentRect.width === 0 || parentRect.height === 0) return el;
const widthRatio = elRect.width / parentRect.width;
const heightRatio = elRect.height / parentRect.height;
// 98% accounts for sub-pixel rounding from borders/box-sizing while
// being tight enough to only match true inset-0 overlays.
if (widthRatio < 0.98 || heightRatio < 0.98) return el;
// This looks like an overlay — walk up to the parent with a dyad-id
let parent = el.parentElement;
while (parent && !parent.dataset.dyadId) parent = parent.parentElement;
return parent || el;
}
// Helper function to check if mouse is over the toolbar
function isMouseOverToolbar(mouseX, mouseY) {
if (!componentCoordinates) return false;
......@@ -328,6 +378,7 @@
let el = e.target;
while (el && !el.dataset.dyadId) el = el.parentElement;
if (el) el = skipOverlayElement(el);
const hoveredItem = overlays.find((item) => item.el === el);
......
......@@ -244,6 +244,64 @@
);
}
function handleModifyImageSrc(data) {
const { elementId, runtimeId, src } = data;
const element = findElementByDyadId(elementId, runtimeId);
if (!element) return;
// Find the <img> element (self or child)
let imgEl = null;
if (element.tagName === "IMG") {
imgEl = element;
} else {
imgEl = element.querySelector("img");
}
if (imgEl) {
// Cancel previous listeners to prevent stale error/load events on rapid swaps
if (imgEl._dyadAbort) imgEl._dyadAbort.abort();
const controller = new AbortController();
imgEl._dyadAbort = controller;
const sendCoordinates = () => {
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,
},
},
"*",
);
};
imgEl.addEventListener("load", sendCoordinates, {
once: true,
signal: controller.signal,
});
imgEl.addEventListener(
"error",
() => {
sendCoordinates();
window.parent.postMessage(
{
type: "dyad-image-load-error",
elementId,
src,
},
"*",
);
},
{ once: true, signal: controller.signal },
);
imgEl.src = src;
}
}
/* ---------- message bridge -------------------------------------------- */
window.addEventListener("message", (e) => {
......@@ -267,6 +325,9 @@
case "get-dyad-text-content":
handleGetTextContent(data);
break;
case "modify-dyad-image-src":
handleModifyImageSrc(data);
break;
case "cleanup-all-text-editing":
// Clean up all text editing states
textEditingState.forEach((state) => {
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论