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:
@@ -29,6 +29,7 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { ImagePreviewDialog } from "@/components/ui/image-preview-dialog"
|
||||
import { cn, randomUUID } from "@/lib/utils"
|
||||
import { matchShortcutEvent } from "@/lib/keyboard-shortcuts"
|
||||
import { useShortcutSettings } from "@/hooks/use-shortcut-settings"
|
||||
@@ -303,6 +304,9 @@ export function MessageInput({
|
||||
})
|
||||
const [attachments, setAttachments] = useState<InputAttachment[]>([])
|
||||
const [isDragActive, setIsDragActive] = useState(false)
|
||||
const [previewAttachmentId, setPreviewAttachmentId] = useState<string | null>(
|
||||
null
|
||||
)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const lastDomDropAtRef = useRef(0)
|
||||
@@ -394,6 +398,13 @@ export function MessageInput({
|
||||
),
|
||||
[attachments]
|
||||
)
|
||||
const previewAttachment = useMemo(
|
||||
() =>
|
||||
previewAttachmentId
|
||||
? (imageAttachments.find((a) => a.id === previewAttachmentId) ?? null)
|
||||
: null,
|
||||
[previewAttachmentId, imageAttachments]
|
||||
)
|
||||
const resourceAttachments = useMemo(
|
||||
() =>
|
||||
attachments.filter(
|
||||
@@ -1274,6 +1285,11 @@ export function MessageInput({
|
||||
<div
|
||||
key={attachment.id}
|
||||
className="relative shrink-0 overflow-hidden rounded-md border border-border/70 bg-muted/30"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPreviewAttachmentId(attachment.id)}
|
||||
className="cursor-pointer transition-opacity hover:opacity-80"
|
||||
>
|
||||
<Image
|
||||
src={`data:${attachment.mimeType};base64,${attachment.data}`}
|
||||
@@ -1283,6 +1299,7 @@ export function MessageInput({
|
||||
unoptimized
|
||||
className="h-14 w-14 object-cover"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeAttachment(attachment.id)}
|
||||
@@ -1378,6 +1395,18 @@ export function MessageInput({
|
||||
{t("dropFilesToAttach")}
|
||||
</div>
|
||||
)}
|
||||
<ImagePreviewDialog
|
||||
src={
|
||||
previewAttachment
|
||||
? `data:${previewAttachment.mimeType};base64,${previewAttachment.data}`
|
||||
: ""
|
||||
}
|
||||
alt={previewAttachment?.name ?? ""}
|
||||
open={previewAttachment !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setPreviewAttachmentId(null)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import Image from "next/image"
|
||||
import type { UserImageDisplay } from "@/lib/adapters/ai-elements-adapter"
|
||||
import { ImagePreviewDialog } from "@/components/ui/image-preview-dialog"
|
||||
|
||||
interface UserImageAttachmentsProps {
|
||||
images: UserImageDisplay[]
|
||||
@@ -12,15 +14,24 @@ export function UserImageAttachments({
|
||||
images,
|
||||
className,
|
||||
}: UserImageAttachmentsProps) {
|
||||
const [previewIndex, setPreviewIndex] = useState<number | null>(null)
|
||||
|
||||
if (images.length === 0) return null
|
||||
|
||||
const previewImage =
|
||||
previewIndex !== null && previewIndex < images.length
|
||||
? images[previewIndex]
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{images.map((image, index) => (
|
||||
<div
|
||||
<button
|
||||
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
|
||||
src={`data:${image.mime_type};base64,${image.data}`}
|
||||
@@ -30,9 +41,21 @@ export function UserImageAttachments({
|
||||
unoptimized
|
||||
className="h-14 w-14 object-cover"
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<ImagePreviewDialog
|
||||
src={
|
||||
previewImage
|
||||
? `data:${previewImage.mime_type};base64,${previewImage.data}`
|
||||
: ""
|
||||
}
|
||||
alt={previewImage?.name ?? ""}
|
||||
open={previewImage !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setPreviewIndex(null)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
58
src/components/ui/image-preview-dialog.tsx
Normal file
58
src/components/ui/image-preview-dialog.tsx
Normal 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 }
|
||||
Reference in New Issue
Block a user