-
Notifications
You must be signed in to change notification settings - Fork 2.2k
Add image selection and swapping in the visual editor #2717
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
a93a622
ef8b445
9ac3291
c4eb7fb
967d57d
e991f51
d171c71
955cd9c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| 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! |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| === 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; |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,207 @@ | ||||||||||||||||||||||||
| 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; | ||||||||||||||||||||||||
| onSwap: (newSrc: string, uploadData?: ImageUploadData) => void; | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| export function ImageSwapPopover({ | ||||||||||||||||||||||||
| currentSrc, | ||||||||||||||||||||||||
| 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 fileInputRef = useRef<HTMLInputElement>(null); | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| // Sync urlValue when a different component is selected | ||||||||||||||||||||||||
| useEffect(() => { | ||||||||||||||||||||||||
| setUrlValue(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); | ||||||||||||||||||||||||
| onSwap(trimmed); | ||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 MEDIUM | error-recovery (1/3 reviewers, validated) Failed image URL is saved as a pending change with no undo path When a user enters a URL and clicks Apply, 💡 Suggestion: Consider removing or marking the pending image change when |
||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => { | ||||||||||||||||||||||||
| const file = e.target.files?.[0]; | ||||||||||||||||||||||||
azizmejri1 marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||||||||||||||||
| 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; | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
azizmejri1 marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||||||||||||||||
| 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(); | ||||||||||||||||||||||||
|
Comment on lines
+77
to
+79
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 MEDIUM | edge-case (3/3 reviewers) No client-side file size limit before reading into memory
💡 Suggestion: Add a
Suggested change
|
||||||||||||||||||||||||
| reader.onload = () => { | ||||||||||||||||||||||||
| const base64DataUrl = reader.result as string; | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| // The backend 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}`; | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| 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); | ||||||||||||||||||||||||
azizmejri1 marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| // 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"> | ||||||||||||||||||||||||
| {/* 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> | ||||||||||||||||||||||||
| ) : ( | ||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 MEDIUM | accessibility (1/3 reviewers, validated) Error messages not announced to screen readers The URL and file error messages are rendered as plain 💡 Suggestion: Add {urlError && <p id="url-error" role="alert" className="text-xs text-red-500">{urlError}</p>}
// and on the Input:
aria-invalid={!!urlError}
aria-describedby={urlError ? "url-error" : undefined} |
||||||||||||||||||||||||
| <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"> | ||||||||||||||||||||||||
| Supports: JPG, PNG, GIF, WebP | ||||||||||||||||||||||||
| </p> | ||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||
| )} | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| {/* Current source display */} | ||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 MEDIUM | dark-mode (1/3 reviewers, validated) No dark mode support in ImageSwapPopover This component uses hardcoded light-mode colors ( 💡 Suggestion: Add |
||||||||||||||||||||||||
| <div className="pt-2 border-t"> | ||||||||||||||||||||||||
| <Label className="text-xs text-gray-500">Current source</Label> | ||||||||||||||||||||||||
| <p className="text-xs font-mono truncate mt-1" title={currentSrc}> | ||||||||||||||||||||||||
| {currentSrc || "none"} | ||||||||||||||||||||||||
| </p> | ||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 MEDIUM | stale-ui-state "Current source" display does not update after swapping
💡 Suggestion: Track a local const [appliedSrc, setAppliedSrc] = useState(currentSrc);
// In useEffect: setAppliedSrc(currentSrc);
// In handlers: setAppliedSrc(newSrc); before calling onSwap(...); |
||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||
| </StylePopover> | ||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -238,6 +238,8 @@ 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 [currentImageSrc, setCurrentImageSrc] = useState(""); | ||
|
|
||
| // Device mode state | ||
| const deviceMode: DeviceMode = settings?.previewDeviceMode ?? "desktop"; | ||
|
|
@@ -262,6 +264,8 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => { | |
| }); | ||
| setIsDynamicComponent(result.isDynamic); | ||
| setHasStaticText(result.hasStaticText); | ||
| setHasImage(result.hasImage); | ||
| setCurrentImageSrc(result.imageSrc || ""); | ||
|
|
||
| // Automatically enable text editing if component has static text | ||
| if (result.hasStaticText && iframeRef.current?.contentWindow) { | ||
|
|
@@ -280,6 +284,8 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => { | |
| console.error("Failed to analyze component", err); | ||
| setIsDynamicComponent(false); | ||
| setHasStaticText(false); | ||
| setHasImage(false); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 MEDIUM | data-integrity handleTextUpdated (line 310) silently drops imageSrc and imageUpload from pending changes The This is the same class of bug that was previously fixed in 💡 Suggestion: Add |
||
| setCurrentImageSrc(""); | ||
| } | ||
| }; | ||
|
|
||
|
|
@@ -542,6 +548,11 @@ 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."); | ||
| return; | ||
| } | ||
|
|
||
| if (event.data?.type === "dyad-component-coordinates-updated") { | ||
| if (event.data.coordinates) { | ||
| setCurrentComponentCoordinates(event.data.coordinates); | ||
|
|
@@ -1368,6 +1379,8 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => { | |
| iframeRef={iframeRef} | ||
| isDynamic={isDynamicComponent} | ||
| hasStaticText={hasStaticText} | ||
| hasImage={hasImage} | ||
| currentImageSrc={currentImageSrc} | ||
| /> | ||
| )} | ||
| </> | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.