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 @@ ...@@ -15,15 +15,20 @@
- text: claude-opus-4-5 - text: claude-opus-4-5
- img - img
- text: less than a minute ago - text: less than a minute ago
- img
- text: (1 files changed)
- button "Copy Request ID": - button "Copy Request ID":
- img - img
- text: "" - text: ""
- paragraph: tc=local-agent/generate-image - paragraph: tc=local-agent/generate-image
- paragraph: I'll generate a hero image for your landing page. - 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 - img
- text: "" - text: ""
- img - 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. - paragraph: I've generated the hero image and saved it to your project. You can find it in the .dyad/media directory.
- button "Copy": - button "Copy":
- img - img
......
import type React from "react"; import type React from "react";
import { useEffect, useRef, useState } from "react"; import { useEffect, useState } from "react";
import { FileText, Image, X, ExternalLink } from "lucide-react"; import { ExternalLink, FileText, Image } from "lucide-react";
import { DyadCard, DyadCardHeader, DyadBadge } from "./DyadCardPrimitives"; import { DyadCard, DyadCardHeader, DyadBadge } from "./DyadCardPrimitives";
import { ipc } from "@/ipc/types"; import { ImageLightbox, openFile } from "./ImageLightbox";
import { toast } from "sonner";
export type AttachmentSize = "sm" | "md" | "lg"; export type AttachmentSize = "sm" | "md" | "lg";
...@@ -26,16 +25,6 @@ interface DyadAttachmentProps { ...@@ -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> = ({ export const DyadAttachment: React.FC<DyadAttachmentProps> = ({
node, node,
size = "md", size = "md",
...@@ -50,35 +39,23 @@ export const DyadAttachment: React.FC<DyadAttachmentProps> = ({ ...@@ -50,35 +39,23 @@ export const DyadAttachment: React.FC<DyadAttachmentProps> = ({
const accentColor = const accentColor =
attachmentType === "upload-to-codebase" ? "blue" : "green"; attachmentType === "upload-to-codebase" ? "blue" : "green";
const [imageError, setImageError] = useState(false); 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) // Reset error state when the image URL changes (e.g., new attachment rendered)
useEffect(() => { useEffect(() => {
setImageError(false); setImageError(false);
}, [url]); }, [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) { if (isImage && !imageError && url) {
return ( return (
<> <>
<div <div
className={`relative ${SIZE_CLASSES[size]} rounded-lg overflow-hidden border border-border/60 cursor-pointer hover:brightness-90 transition-all`} 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) => { onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") { if (e.key === "Enter" || e.key === " ") {
e.preventDefault(); e.preventDefault();
setIsExpanded(true); setIsLightboxOpen(true);
} }
}} }}
role="button" role="button"
...@@ -93,49 +70,17 @@ export const DyadAttachment: React.FC<DyadAttachmentProps> = ({ ...@@ -93,49 +70,17 @@ export const DyadAttachment: React.FC<DyadAttachmentProps> = ({
onError={() => setImageError(true)} onError={() => setImageError(true)}
/> />
</div> </div>
{isExpanded && ( {isLightboxOpen && (
<div <ImageLightbox
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70" imageUrl={url}
onClick={() => setIsExpanded(false)} alt={name}
onKeyDown={(e) => { filePath={filePath || undefined}
if (e.key === "Escape") { onClose={() => setIsLightboxOpen(false)}
setIsExpanded(false); onError={() => {
} setImageError(true);
setIsLightboxOpen(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}
alt={name}
className="max-w-[90vw] max-h-[90vh] object-contain rounded-lg"
onClick={(e) => e.stopPropagation()}
/>
</div>
)} )}
</> </>
); );
......
import type React from "react"; import type React from "react";
import { useState, type ReactNode } from "react"; import { useEffect, useState, type ReactNode } from "react";
import { ImageIcon } from "lucide-react"; import { Eye, ImageIcon } from "lucide-react";
import { useAtomValue } from "jotai";
import { CustomTagState } from "./stateTypes"; import { CustomTagState } from "./stateTypes";
import { import {
DyadCard, DyadCard,
...@@ -10,6 +11,8 @@ import { ...@@ -10,6 +11,8 @@ import {
DyadStateIndicator, DyadStateIndicator,
DyadCardContent, DyadCardContent,
} from "./DyadCardPrimitives"; } from "./DyadCardPrimitives";
import { ImageLightbox } from "./ImageLightbox";
import { currentAppAtom } from "@/atoms/appAtoms";
interface DyadImageGenerationNode { interface DyadImageGenerationNode {
properties: { properties: {
...@@ -29,59 +32,137 @@ export const DyadImageGeneration: React.FC<DyadImageGenerationProps> = ({ ...@@ -29,59 +32,137 @@ export const DyadImageGeneration: React.FC<DyadImageGenerationProps> = ({
node, node,
}) => { }) => {
const [isExpanded, setIsExpanded] = useState(false); const [isExpanded, setIsExpanded] = useState(false);
const [isLightboxOpen, setIsLightboxOpen] = useState(false);
const [imageError, setImageError] = useState(false);
const prompt = node?.properties?.prompt ?? ""; const prompt = node?.properties?.prompt ?? "";
const imagePath = node?.properties?.path ?? ""; const imagePath = node?.properties?.path ?? "";
useEffect(() => {
setImageError(false);
}, [imagePath]);
const state = node?.properties?.state; const state = node?.properties?.state;
const inProgress = state === "pending"; const inProgress = state === "pending";
const aborted = state === "aborted"; 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 ( return (
<DyadCard <>
state={state} <DyadCard
accentColor="violet" state={state}
isExpanded={isExpanded} accentColor="violet"
onClick={() => setIsExpanded(!isExpanded)} isExpanded={isExpanded}
> onClick={() => setIsExpanded(!isExpanded)}
<DyadCardHeader icon={<ImageIcon size={15} />} accentColor="violet"> >
<DyadBadge color="violet">Image Generation</DyadBadge> <div className="flex items-start">
{!isExpanded && prompt && ( <div className="flex-1 min-w-0">
<span className="text-sm text-muted-foreground italic truncate"> <DyadCardHeader icon={<ImageIcon size={15} />} accentColor="violet">
{prompt} <DyadBadge color="violet">Image Generation</DyadBadge>
</span> {!isExpanded && prompt && (
)} <span className="text-sm text-muted-foreground italic truncate">
{inProgress && ( {prompt}
<DyadStateIndicator state="pending" pendingLabel="Generating..." /> </span>
)} )}
{aborted && ( {inProgress && (
<DyadStateIndicator state="aborted" abortedLabel="Did not finish" /> <DyadStateIndicator
)} state="pending"
<div className="ml-auto"> pendingLabel="Generating..."
<DyadExpandIcon isExpanded={isExpanded} /> />
</div> )}
</DyadCardHeader> {aborted && (
<DyadCardContent isExpanded={isExpanded}> <DyadStateIndicator
<div className="text-sm text-muted-foreground space-y-2"> state="aborted"
{prompt && ( abortedLabel="Did not finish"
<div> />
<span className="text-xs font-medium text-muted-foreground"> )}
Prompt: <div className="ml-auto flex items-center gap-1">
</span> <DyadExpandIcon isExpanded={isExpanded} />
<div className="italic mt-0.5 text-foreground">{prompt}</div> </div>
</div> </DyadCardHeader>
)} <DyadCardContent isExpanded={isExpanded}>
{imagePath && ( <div className="text-sm text-muted-foreground space-y-2">
<div> {prompt && (
<span className="text-xs font-medium text-muted-foreground"> <div>
Saved to: <span className="text-xs font-medium text-muted-foreground">
</span> Prompt:
<div className="mt-0.5 font-mono text-xs text-foreground"> </span>
{imagePath} <div className="italic mt-0.5 text-foreground">
{prompt}
</div>
</div>
)}
{imagePath && (
<div>
<span className="text-xs font-medium text-muted-foreground">
Saved to:
</span>
<div className="mt-0.5 font-mono text-xs text-foreground">
{imagePath}
</div>
</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> </div>
</div> </button>
)} )}
{children && <div className="mt-0.5 text-foreground">{children}</div>}
</div> </div>
</DyadCardContent> </DyadCard>
</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 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论