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

Allow users to preview generated images (#2918)

closes #2916 <!-- devin-review-badge-begin --> --- <a href="https://app.devin.ai/review/dyad-sh/dyad/pull/2918" 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 Opus 4.6 <noreply@anthropic.com>
上级 df4b87eb
......@@ -15,15 +15,20 @@
- text: claude-opus-4-5
- img
- text: less than a minute ago
- img
- text: (1 files changed)
- button "Copy Request ID":
- img
- text: ""
- paragraph: tc=local-agent/generate-image
- paragraph: I'll generate a hero image for your landing page.
- button "Image Generation A modern, minimal hero illustration of a rocket launching from a laptop screen, flat design style, blue and purple gradient background, clean lines":
- button "Image Generation A modern, minimal hero illustration of a rocket launching from a laptop screen, flat design style, blue and purple gradient background, clean lines View generated image":
- img
- text: ""
- img
- button "View generated image":
- img "A modern, minimal hero illustration of a rocket launching from a laptop screen, flat design style, blue and purple gradient background, clean lines"
- img
- paragraph: I've generated the hero image and saved it to your project. You can find it in the .dyad/media directory.
- button "Copy":
- img
......
import type React from "react";
import { useEffect, useRef, useState } from "react";
import { FileText, Image, X, ExternalLink } from "lucide-react";
import { useEffect, useState } from "react";
import { ExternalLink, FileText, Image } from "lucide-react";
import { DyadCard, DyadCardHeader, DyadBadge } from "./DyadCardPrimitives";
import { ipc } from "@/ipc/types";
import { toast } from "sonner";
import { ImageLightbox, openFile } from "./ImageLightbox";
export type AttachmentSize = "sm" | "md" | "lg";
......@@ -26,16 +25,6 @@ interface DyadAttachmentProps {
};
}
async function openFile(filePath: string) {
if (filePath) {
try {
await ipc.system.openFilePath(filePath);
} catch {
toast.error("Could not open file. It may have been moved or deleted.");
}
}
}
export const DyadAttachment: React.FC<DyadAttachmentProps> = ({
node,
size = "md",
......@@ -50,35 +39,23 @@ export const DyadAttachment: React.FC<DyadAttachmentProps> = ({
const accentColor =
attachmentType === "upload-to-codebase" ? "blue" : "green";
const [imageError, setImageError] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
const [isLightboxOpen, setIsLightboxOpen] = useState(false);
// Reset error state when the image URL changes (e.g., new attachment rendered)
useEffect(() => {
setImageError(false);
}, [url]);
const closeButtonRef = useRef<HTMLButtonElement>(null);
// Lock body scroll and auto-focus close button when lightbox opens
useEffect(() => {
if (!isExpanded) return;
document.body.style.overflow = "hidden";
closeButtonRef.current?.focus();
return () => {
document.body.style.overflow = "";
};
}, [isExpanded]);
if (isImage && !imageError && url) {
return (
<>
<div
className={`relative ${SIZE_CLASSES[size]} rounded-lg overflow-hidden border border-border/60 cursor-pointer hover:brightness-90 transition-all`}
onClick={() => setIsExpanded(true)}
onClick={() => setIsLightboxOpen(true)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setIsExpanded(true);
setIsLightboxOpen(true);
}
}}
role="button"
......@@ -93,49 +70,17 @@ export const DyadAttachment: React.FC<DyadAttachmentProps> = ({
onError={() => setImageError(true)}
/>
</div>
{isExpanded && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70"
onClick={() => setIsExpanded(false)}
onKeyDown={(e) => {
if (e.key === "Escape") {
setIsExpanded(false);
}
}}
role="dialog"
aria-modal="true"
aria-label={`Expanded image: ${name}`}
>
<div className="absolute top-4 right-4 flex items-center gap-2">
{filePath && (
<button
className="p-2 rounded-full bg-white/10 hover:bg-white/20 text-white cursor-pointer transition-colors"
onClick={(e) => {
e.stopPropagation();
openFile(filePath);
}}
title="Open file"
aria-label="Open file"
>
<ExternalLink size={20} />
</button>
)}
<button
ref={closeButtonRef}
className="p-2 rounded-full bg-white/10 hover:bg-white/20 text-white cursor-pointer transition-colors"
onClick={() => setIsExpanded(false)}
aria-label="Close"
>
<X size={20} />
</button>
</div>
<img
src={url}
{isLightboxOpen && (
<ImageLightbox
imageUrl={url}
alt={name}
className="max-w-[90vw] max-h-[90vh] object-contain rounded-lg"
onClick={(e) => e.stopPropagation()}
filePath={filePath || undefined}
onClose={() => setIsLightboxOpen(false)}
onError={() => {
setImageError(true);
setIsLightboxOpen(false);
}}
/>
</div>
)}
</>
);
......
import type React from "react";
import { useState, type ReactNode } from "react";
import { ImageIcon } from "lucide-react";
import { useEffect, useState, type ReactNode } from "react";
import { Eye, ImageIcon } from "lucide-react";
import { useAtomValue } from "jotai";
import { CustomTagState } from "./stateTypes";
import {
DyadCard,
......@@ -10,6 +11,8 @@ import {
DyadStateIndicator,
DyadCardContent,
} from "./DyadCardPrimitives";
import { ImageLightbox } from "./ImageLightbox";
import { currentAppAtom } from "@/atoms/appAtoms";
interface DyadImageGenerationNode {
properties: {
......@@ -29,19 +32,48 @@ export const DyadImageGeneration: React.FC<DyadImageGenerationProps> = ({
node,
}) => {
const [isExpanded, setIsExpanded] = useState(false);
const [isLightboxOpen, setIsLightboxOpen] = useState(false);
const [imageError, setImageError] = useState(false);
const prompt = node?.properties?.prompt ?? "";
const imagePath = node?.properties?.path ?? "";
useEffect(() => {
setImageError(false);
}, [imagePath]);
const state = node?.properties?.state;
const inProgress = state === "pending";
const aborted = state === "aborted";
const app = useAtomValue(currentAppAtom);
const appPath = app?.resolvedPath ?? app?.path ?? "";
const normalizedImagePath = imagePath.split("\\").join("/");
const hasTraversal = normalizedImagePath
.split("/")
.some((seg: string) => seg === "..");
const imageUrl =
appPath && normalizedImagePath && !hasTraversal
? `dyad-media://media/${encodeURIComponent(appPath)}/${normalizedImagePath
.split("/")
.map(encodeURIComponent)
.join("/")}`
: "";
const absolutePath =
appPath && normalizedImagePath && !hasTraversal
? `${appPath}/${normalizedImagePath}`
: undefined;
const canViewImage =
state === "finished" && !!imagePath && !!imageUrl && !imageError;
return (
<>
<DyadCard
state={state}
accentColor="violet"
isExpanded={isExpanded}
onClick={() => setIsExpanded(!isExpanded)}
>
<div className="flex items-start">
<div className="flex-1 min-w-0">
<DyadCardHeader icon={<ImageIcon size={15} />} accentColor="violet">
<DyadBadge color="violet">Image Generation</DyadBadge>
{!isExpanded && prompt && (
......@@ -50,12 +82,18 @@ export const DyadImageGeneration: React.FC<DyadImageGenerationProps> = ({
</span>
)}
{inProgress && (
<DyadStateIndicator state="pending" pendingLabel="Generating..." />
<DyadStateIndicator
state="pending"
pendingLabel="Generating..."
/>
)}
{aborted && (
<DyadStateIndicator state="aborted" abortedLabel="Did not finish" />
<DyadStateIndicator
state="aborted"
abortedLabel="Did not finish"
/>
)}
<div className="ml-auto">
<div className="ml-auto flex items-center gap-1">
<DyadExpandIcon isExpanded={isExpanded} />
</div>
</DyadCardHeader>
......@@ -66,7 +104,9 @@ export const DyadImageGeneration: React.FC<DyadImageGenerationProps> = ({
<span className="text-xs font-medium text-muted-foreground">
Prompt:
</span>
<div className="italic mt-0.5 text-foreground">{prompt}</div>
<div className="italic mt-0.5 text-foreground">
{prompt}
</div>
</div>
)}
{imagePath && (
......@@ -79,9 +119,50 @@ export const DyadImageGeneration: React.FC<DyadImageGenerationProps> = ({
</div>
</div>
)}
{children && <div className="mt-0.5 text-foreground">{children}</div>}
{children && (
<div className="mt-0.5 text-foreground">{children}</div>
)}
</div>
</DyadCardContent>
</div>
{canViewImage && (
<button
onClick={(e) => {
e.stopPropagation();
setIsLightboxOpen(true);
}}
className="group/thumb shrink-0 m-2 rounded-xl overflow-hidden transition-shadow cursor-pointer shadow-sm hover:shadow-xl relative"
title="View generated image"
aria-label="View generated image"
>
<img
src={imageUrl}
alt={prompt || "Generated image"}
className="h-20 w-20 object-cover rounded-xl"
onError={() => setImageError(true)}
/>
<div className="absolute inset-0 bg-black/0 group-hover/thumb:bg-black/40 transition-colors rounded-xl flex items-center justify-center">
<Eye
size={20}
className="text-white opacity-0 group-hover/thumb:opacity-100 transition-opacity"
/>
</div>
</button>
)}
</div>
</DyadCard>
{isLightboxOpen && imageUrl && (
<ImageLightbox
imageUrl={imageUrl}
alt={prompt || "Generated image"}
filePath={absolutePath}
onClose={() => setIsLightboxOpen(false)}
onError={() => {
setImageError(true);
setIsLightboxOpen(false);
}}
/>
)}
</>
);
};
import type React from "react";
import { useEffect, useRef } from "react";
import { ExternalLink, X } from "lucide-react";
import { ipc } from "@/ipc/types";
import { toast } from "sonner";
interface ImageLightboxProps {
imageUrl: string;
alt: string;
filePath?: string;
onClose: () => void;
onError?: () => void;
}
export async function openFile(filePath: string) {
if (!filePath) return;
try {
await ipc.system.openFilePath(filePath);
} catch (error) {
console.error("Failed to open file:", error);
toast.error("Could not open file. It may have been moved or deleted.");
}
}
export const ImageLightbox: React.FC<ImageLightboxProps> = ({
imageUrl,
alt,
filePath,
onClose,
onError,
}) => {
const closeButtonRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
document.body.style.overflow = "hidden";
closeButtonRef.current?.focus();
return () => {
document.body.style.overflow = "";
};
}, []);
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70"
onClick={onClose}
tabIndex={-1}
onKeyDown={(e) => {
if (e.key === "Escape") {
onClose();
}
}}
role="dialog"
aria-modal="true"
aria-label={`Expanded image: ${alt}`}
>
<div className="absolute top-4 right-4 flex items-center gap-2">
{filePath && (
<button
className="p-2 rounded-full bg-white/10 hover:bg-white/20 text-white cursor-pointer transition-colors"
onClick={(e) => {
e.stopPropagation();
openFile(filePath);
}}
title="Open file"
aria-label="Open file"
>
<ExternalLink size={20} />
</button>
)}
<button
ref={closeButtonRef}
className="p-2 rounded-full bg-white/10 hover:bg-white/20 text-white cursor-pointer transition-colors"
onClick={(e) => {
e.stopPropagation();
onClose();
}}
aria-label="Close"
>
<X size={20} />
</button>
</div>
<img
src={imageUrl}
alt={alt}
className="max-w-[90vw] max-h-[90vh] object-contain rounded-lg"
onClick={(e) => e.stopPropagation()}
onError={onError}
/>
</div>
);
};
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论