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,
|
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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
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