Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 50 additions & 28 deletions src/components/message/generated-images-block.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,11 @@ import { useTranslations } from "next-intl"
import type { UserImageDisplay } from "@/lib/adapters/ai-elements-adapter"
import type { ToolCallStatus } from "@/lib/types"
import { ImagePreviewDialog } from "@/components/ui/image-preview-dialog"
import { ImageActions } from "./image-actions"
import { copyImageToClipboard } from "@/lib/copy-image"
import { downloadImage } from "@/lib/image-download"
import { toErrorMessage } from "@/lib/app-error"
import { toast } from "sonner"
import { cn } from "@/lib/utils"

interface GeneratedImagesBlockProps {
Expand Down Expand Up @@ -89,6 +92,21 @@ export const GeneratedImagesBlock = memo(function GeneratedImagesBlock({
[t]
)

const handleCopy = useCallback(
async (img: UserImageDisplay) => {
try {
await copyImageToClipboard({
data: img.data,
mime_type: img.mime_type,
})
toast.success(t("copiedImage"))
} catch (err) {
toast.error(t("copyImageFailed", { message: toErrorMessage(err) }))
}
},
[t]
)

const trimmedPrompt =
typeof revisedPrompt === "string" ? revisedPrompt.trim() : ""

Expand All @@ -112,34 +130,36 @@ export const GeneratedImagesBlock = memo(function GeneratedImagesBlock({
) : null}

{image ? (
<div className="group relative inline-block shrink-0 overflow-hidden rounded-md border border-border/70 bg-muted/30">
<button
type="button"
onClick={() => setPreviewOpen(true)}
className="block cursor-pointer transition-opacity hover:opacity-80"
>
<Image
src={`data:${image.mime_type};base64,${image.data}`}
alt={image.name}
width={256}
height={256}
unoptimized
className="h-auto max-h-64 w-auto max-w-full object-contain"
/>
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
void handleDownload(image)
}}
className="absolute right-1 top-1 rounded-full bg-background/80 p-1 text-foreground/80 opacity-0 shadow-sm transition-opacity hover:bg-background hover:text-foreground group-hover:opacity-100 focus-visible:opacity-100"
aria-label={t("downloadImage")}
title={t("downloadImage")}
>
<Download className="h-3.5 w-3.5" />
</button>
</div>
<ImageActions image={image}>
<div className="group relative inline-block shrink-0 overflow-hidden rounded-md border border-border/70 bg-muted/30">
<button
type="button"
onClick={() => setPreviewOpen(true)}
className="block cursor-pointer transition-opacity hover:opacity-80"
>
<Image
src={`data:${image.mime_type};base64,${image.data}`}
alt={image.name}
width={256}
height={256}
unoptimized
className="h-auto max-h-64 w-auto max-w-full object-contain"
/>
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
void handleDownload(image)
}}
className="absolute right-1 top-1 rounded-full bg-background/80 p-1 text-foreground/80 opacity-0 shadow-sm transition-opacity hover:bg-background hover:text-foreground group-hover:opacity-100 focus-visible:opacity-100"
aria-label={t("downloadImage")}
title={t("downloadImage")}
>
<Download className="h-3.5 w-3.5" />
</button>
</div>
</ImageActions>
) : isFailed ? (
<div
className="flex h-64 w-64 max-w-full shrink-0 items-center justify-center rounded-md border border-dashed border-destructive/40 bg-destructive/5 text-xs text-destructive"
Expand Down Expand Up @@ -172,6 +192,8 @@ export const GeneratedImagesBlock = memo(function GeneratedImagesBlock({
onOpenChange={(open) => setPreviewOpen(open)}
onDownload={image ? () => void handleDownload(image) : undefined}
downloadLabel={t("downloadImage")}
onCopy={image ? () => void handleCopy(image) : undefined}
copyLabel={t("copyImage")}
/>
</div>
)
Expand Down
86 changes: 86 additions & 0 deletions src/components/message/image-actions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
"use client"

import { type ReactNode, useCallback } from "react"
import { Copy, Download } from "lucide-react"
import { useTranslations } from "next-intl"
import { toast } from "sonner"
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
} from "@/components/ui/context-menu"
import { copyImageToClipboard } from "@/lib/copy-image"
import { downloadImage } from "@/lib/image-download"
import { toErrorMessage } from "@/lib/app-error"
import type { UserImageDisplay } from "@/lib/adapters/ai-elements-adapter"

/**
* Right-click menu on a transcript image: Copy image / Download image.
*
* The conversation panel wraps the whole transcript in its own context
* menu, so this trigger stops the event from bubbling — same contract as
* `FileReferenceActions`. Right-clicking the image is about the image;
* right-clicking anywhere else still gets the conversation menu.
*/
export function ImageActions({
image,
children,
}: {
image: UserImageDisplay
children: ReactNode
}) {
const t = useTranslations("Folder.chat.messageList")

const handleCopy = useCallback(async () => {
try {
await copyImageToClipboard({
data: image.data,
mime_type: image.mime_type,
})
toast.success(t("copiedImage"))
} catch (err) {
toast.error(t("copyImageFailed", { message: toErrorMessage(err) }))
}
}, [image, t])

const handleDownload = useCallback(async () => {
try {
await downloadImage({
data: image.data,
mime_type: image.mime_type,
suggestedName: image.name,
})
} catch (err) {
window.alert(t("downloadFailed", { message: toErrorMessage(err) }))
}
}, [image, t])

return (
<ContextMenu>
<ContextMenuTrigger asChild>
<div
data-image-actions=""
// Radix's own handler still runs on this element; only the
// ancestor conversation-panel trigger is cut off.
onContextMenu={(event) => event.stopPropagation()}
onPointerDown={(event) => {
if (event.pointerType !== "mouse") event.stopPropagation()
}}
>
{children}
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onSelect={() => void handleCopy()}>
<Copy className="h-4 w-4" />
{t("copyImage")}
</ContextMenuItem>
<ContextMenuItem onSelect={() => void handleDownload()}>
<Download className="h-4 w-4" />
{t("downloadImage")}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)
}
80 changes: 51 additions & 29 deletions src/components/message/user-image-attachments.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,11 @@ import { Download } from "lucide-react"
import { useTranslations } from "next-intl"
import type { UserImageDisplay } from "@/lib/adapters/ai-elements-adapter"
import { ImagePreviewDialog } from "@/components/ui/image-preview-dialog"
import { ImageActions } from "./image-actions"
import { copyImageToClipboard } from "@/lib/copy-image"
import { downloadImage } from "@/lib/image-download"
import { toErrorMessage } from "@/lib/app-error"
import { toast } from "sonner"

interface UserImageAttachmentsProps {
images: UserImageDisplay[]
Expand Down Expand Up @@ -37,6 +40,21 @@ export function UserImageAttachments({
[t]
)

const handleCopy = useCallback(
async (image: UserImageDisplay) => {
try {
await copyImageToClipboard({
data: image.data,
mime_type: image.mime_type,
})
toast.success(t("copiedImage"))
} catch (err) {
toast.error(t("copyImageFailed", { message: toErrorMessage(err) }))
}
},
[t]
)

if (images.length === 0) return null

const previewImage =
Expand All @@ -48,37 +66,39 @@ export function UserImageAttachments({
<div className={className}>
<div className="flex flex-wrap gap-1.5">
{images.map((image, index) => (
<div
<ImageActions
key={`${image.uri ?? image.name}-${index}`}
className="group relative overflow-hidden rounded-md border border-border/70 bg-muted/30"
image={image}
>
<button
type="button"
onClick={() => setPreviewIndex(index)}
className="block cursor-pointer transition-opacity hover:opacity-80"
>
<Image
src={`data:${image.mime_type};base64,${image.data}`}
alt={image.name}
width={56}
height={56}
unoptimized
className="h-14 w-14 object-cover"
/>
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
void handleDownload(image)
}}
className="absolute right-0.5 top-0.5 rounded-full bg-background/80 p-0.5 text-foreground/80 opacity-0 shadow-sm transition-opacity hover:bg-background hover:text-foreground group-hover:opacity-100 focus-visible:opacity-100"
aria-label={t("downloadImage")}
title={t("downloadImage")}
>
<Download className="h-3 w-3" />
</button>
</div>
<div className="group relative overflow-hidden rounded-md border border-border/70 bg-muted/30">
<button
type="button"
onClick={() => setPreviewIndex(index)}
className="block cursor-pointer transition-opacity hover:opacity-80"
>
<Image
src={`data:${image.mime_type};base64,${image.data}`}
alt={image.name}
width={56}
height={56}
unoptimized
className="h-14 w-14 object-cover"
/>
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
void handleDownload(image)
}}
className="absolute right-0.5 top-0.5 rounded-full bg-background/80 p-0.5 text-foreground/80 opacity-0 shadow-sm transition-opacity hover:bg-background hover:text-foreground group-hover:opacity-100 focus-visible:opacity-100"
aria-label={t("downloadImage")}
title={t("downloadImage")}
>
<Download className="h-3 w-3" />
</button>
</div>
</ImageActions>
))}
</div>
<ImagePreviewDialog
Expand All @@ -96,6 +116,8 @@ export function UserImageAttachments({
previewImage ? () => void handleDownload(previewImage) : undefined
}
downloadLabel={t("downloadImage")}
onCopy={previewImage ? () => void handleCopy(previewImage) : undefined}
copyLabel={t("copyImage")}
/>
</div>
)
Expand Down
26 changes: 25 additions & 1 deletion src/components/ui/image-preview-dialog.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"use client"

import { Dialog as DialogPrimitive } from "radix-ui"
import { Download, X } from "lucide-react"
import { Copy, Download, X } from "lucide-react"
import { cn } from "@/lib/utils"

interface ImagePreviewDialogProps {
Expand All @@ -16,6 +16,8 @@ interface ImagePreviewDialogProps {
*/
onDownload?: () => void
downloadLabel?: string
onCopy?: () => void
copyLabel?: string
}

function ImagePreviewDialog({
Expand All @@ -25,6 +27,8 @@ function ImagePreviewDialog({
onOpenChange,
onDownload,
downloadLabel,
onCopy,
copyLabel,
}: ImagePreviewDialogProps) {
return (
<DialogPrimitive.Root open={open} onOpenChange={onOpenChange}>
Expand All @@ -44,6 +48,20 @@ function ImagePreviewDialog({
{alt}
</DialogPrimitive.Title>
<div className="absolute right-4 top-4 z-10 flex items-center gap-2">
{onCopy && (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onCopy()
}}
className="rounded-full bg-background/60 p-1.5 text-foreground/80 hover:bg-background/80 hover:text-foreground"
aria-label={copyLabel ?? "Copy image"}
title={copyLabel ?? "Copy image"}
>
<Copy className="h-5 w-5" />
</button>
)}
{onDownload && (
<button
type="button"
Expand Down Expand Up @@ -73,6 +91,12 @@ function ImagePreviewDialog({
src={src}
alt={alt}
onClick={(e) => e.stopPropagation()}
onContextMenu={(e) => {
if (!onCopy) return
e.preventDefault()
e.stopPropagation()
onCopy()
}}
className="max-h-[90vh] max-w-[90vw] rounded-lg object-contain"
/>
)}
Expand Down
3 changes: 3 additions & 0 deletions src/i18n/messages/ar.json
Original file line number Diff line number Diff line change
Expand Up @@ -2930,6 +2930,9 @@
"copyMessage": "نسخ",
"copied": "تم النسخ",
"downloadImage": "تنزيل الصورة",
"copyImage": "Copy image",
"copiedImage": "Image copied",
"copyImageFailed": "Could not copy image: {message}",
"downloadFailed": "فشل التنزيل: {message}",
"imageGeneration": "إنشاء الصورة",
"imageGenerationPending": "جارٍ إنشاء الصورة…",
Expand Down
3 changes: 3 additions & 0 deletions src/i18n/messages/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -2930,6 +2930,9 @@
"copyMessage": "Kopieren",
"copied": "Kopiert",
"downloadImage": "Bild herunterladen",
"copyImage": "Copy image",
"copiedImage": "Image copied",
"copyImageFailed": "Could not copy image: {message}",
"downloadFailed": "Download fehlgeschlagen: {message}",
"imageGeneration": "Bildgenerierung",
"imageGenerationPending": "Bild wird generiert…",
Expand Down
3 changes: 3 additions & 0 deletions src/i18n/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -2930,6 +2930,9 @@
"copyMessage": "Copy",
"copied": "Copied",
"downloadImage": "Download image",
"copyImage": "Copy image",
"copiedImage": "Image copied",
"copyImageFailed": "Could not copy image: {message}",
"downloadFailed": "Download failed: {message}",
"imageGeneration": "Image generation",
"imageGenerationPending": "Generating image…",
Expand Down
Loading
Loading