feat: add click-to-preview for image attachments in chat

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
xintaofei
2026-04-02 15:23:48 +08:00
parent 2a3b4b1908
commit f23ed12650
3 changed files with 121 additions and 11 deletions

View File

@@ -29,6 +29,7 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu" } from "@/components/ui/dropdown-menu"
import { ImagePreviewDialog } from "@/components/ui/image-preview-dialog"
import { cn, randomUUID } from "@/lib/utils" import { cn, randomUUID } from "@/lib/utils"
import { matchShortcutEvent } from "@/lib/keyboard-shortcuts" import { matchShortcutEvent } from "@/lib/keyboard-shortcuts"
import { useShortcutSettings } from "@/hooks/use-shortcut-settings" import { useShortcutSettings } from "@/hooks/use-shortcut-settings"
@@ -303,6 +304,9 @@ export function MessageInput({
}) })
const [attachments, setAttachments] = useState<InputAttachment[]>([]) const [attachments, setAttachments] = useState<InputAttachment[]>([])
const [isDragActive, setIsDragActive] = useState(false) const [isDragActive, setIsDragActive] = useState(false)
const [previewAttachmentId, setPreviewAttachmentId] = useState<string | null>(
null
)
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null) const textareaRef = useRef<HTMLTextAreaElement>(null)
const lastDomDropAtRef = useRef(0) const lastDomDropAtRef = useRef(0)
@@ -394,6 +398,13 @@ export function MessageInput({
), ),
[attachments] [attachments]
) )
const previewAttachment = useMemo(
() =>
previewAttachmentId
? (imageAttachments.find((a) => a.id === previewAttachmentId) ?? null)
: null,
[previewAttachmentId, imageAttachments]
)
const resourceAttachments = useMemo( const resourceAttachments = useMemo(
() => () =>
attachments.filter( attachments.filter(
@@ -1275,14 +1286,20 @@ export function MessageInput({
key={attachment.id} key={attachment.id}
className="relative shrink-0 overflow-hidden rounded-md border border-border/70 bg-muted/30" className="relative shrink-0 overflow-hidden rounded-md border border-border/70 bg-muted/30"
> >
<Image <button
src={`data:${attachment.mimeType};base64,${attachment.data}`} type="button"
alt={attachment.name} onClick={() => setPreviewAttachmentId(attachment.id)}
width={56} className="cursor-pointer transition-opacity hover:opacity-80"
height={56} >
unoptimized <Image
className="h-14 w-14 object-cover" src={`data:${attachment.mimeType};base64,${attachment.data}`}
/> alt={attachment.name}
width={56}
height={56}
unoptimized
className="h-14 w-14 object-cover"
/>
</button>
<button <button
type="button" type="button"
onClick={() => removeAttachment(attachment.id)} onClick={() => removeAttachment(attachment.id)}
@@ -1378,6 +1395,18 @@ export function MessageInput({
{t("dropFilesToAttach")} {t("dropFilesToAttach")}
</div> </div>
)} )}
<ImagePreviewDialog
src={
previewAttachment
? `data:${previewAttachment.mimeType};base64,${previewAttachment.data}`
: ""
}
alt={previewAttachment?.name ?? ""}
open={previewAttachment !== null}
onOpenChange={(open) => {
if (!open) setPreviewAttachmentId(null)
}}
/>
</div> </div>
) )
} }

View File

@@ -1,7 +1,9 @@
"use client" "use client"
import { useState } from "react"
import Image from "next/image" import Image from "next/image"
import type { UserImageDisplay } from "@/lib/adapters/ai-elements-adapter" import type { UserImageDisplay } from "@/lib/adapters/ai-elements-adapter"
import { ImagePreviewDialog } from "@/components/ui/image-preview-dialog"
interface UserImageAttachmentsProps { interface UserImageAttachmentsProps {
images: UserImageDisplay[] images: UserImageDisplay[]
@@ -12,15 +14,24 @@ export function UserImageAttachments({
images, images,
className, className,
}: UserImageAttachmentsProps) { }: UserImageAttachmentsProps) {
const [previewIndex, setPreviewIndex] = useState<number | null>(null)
if (images.length === 0) return null if (images.length === 0) return null
const previewImage =
previewIndex !== null && previewIndex < images.length
? images[previewIndex]
: null
return ( return (
<div className={className}> <div className={className}>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{images.map((image, index) => ( {images.map((image, index) => (
<div <button
key={`${image.uri ?? image.name}-${index}`} key={`${image.uri ?? image.name}-${index}`}
className="overflow-hidden rounded-md border border-border/70 bg-muted/30" type="button"
onClick={() => setPreviewIndex(index)}
className="cursor-pointer overflow-hidden rounded-md border border-border/70 bg-muted/30 transition-opacity hover:opacity-80"
> >
<Image <Image
src={`data:${image.mime_type};base64,${image.data}`} src={`data:${image.mime_type};base64,${image.data}`}
@@ -30,9 +41,21 @@ export function UserImageAttachments({
unoptimized unoptimized
className="h-14 w-14 object-cover" className="h-14 w-14 object-cover"
/> />
</div> </button>
))} ))}
</div> </div>
<ImagePreviewDialog
src={
previewImage
? `data:${previewImage.mime_type};base64,${previewImage.data}`
: ""
}
alt={previewImage?.name ?? ""}
open={previewImage !== null}
onOpenChange={(open) => {
if (!open) setPreviewIndex(null)
}}
/>
</div> </div>
) )
} }

View File

@@ -0,0 +1,58 @@
"use client"
import { Dialog as DialogPrimitive } from "radix-ui"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
interface ImagePreviewDialogProps {
src: string
alt: string
open: boolean
onOpenChange: (open: boolean) => void
}
function ImagePreviewDialog({
src,
alt,
open,
onOpenChange,
}: ImagePreviewDialogProps) {
return (
<DialogPrimitive.Root open={open} onOpenChange={onOpenChange}>
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay
className={cn(
"data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0",
"fixed inset-0 z-50 bg-black/80 duration-100 supports-backdrop-filter:backdrop-blur-xs"
)}
/>
<DialogPrimitive.Content
className="fixed inset-0 z-50 flex items-center justify-center outline-none"
aria-describedby={undefined}
onClick={() => onOpenChange(false)}
>
<DialogPrimitive.Title className="sr-only">
{alt}
</DialogPrimitive.Title>
<button
type="button"
onClick={() => onOpenChange(false)}
className="absolute right-4 top-4 z-10 rounded-full bg-background/60 p-1.5 text-foreground/80 hover:bg-background/80 hover:text-foreground"
aria-label="Close"
>
<X className="h-5 w-5" />
</button>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={src}
alt={alt}
onClick={(e) => e.stopPropagation()}
className="max-h-[90vh] max-w-[90vw] rounded-lg object-contain"
/>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
)
}
export { ImagePreviewDialog }