/** * Chat Input Component * Textarea with send button and image upload support. * Enter to send, Shift+Enter for new line. * Supports: file picker, clipboard paste, drag & drop. */ import { useState, useRef, useEffect, useCallback } from 'react'; import { Send, Square, ImagePlus, X } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Textarea } from '@/components/ui/textarea'; export interface ChatAttachment { type: 'image'; mimeType: string; fileName: string; content: string; // base64 preview: string; // data URL for display } interface ChatInputProps { onSend: (text: string, attachments?: ChatAttachment[]) => void; onStop?: () => void; disabled?: boolean; sending?: boolean; } const ACCEPTED_IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/gif', 'image/webp']; const MAX_IMAGE_SIZE = 10 * 1024 * 1024; // 10MB function fileToAttachment(file: File): Promise { return new Promise((resolve, reject) => { if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) { reject(new Error(`Unsupported image type: ${file.type}`)); return; } if (file.size > MAX_IMAGE_SIZE) { reject(new Error('Image too large (max 10MB)')); return; } const reader = new FileReader(); reader.onload = () => { const dataUrl = reader.result as string; // Extract base64 content (remove "data:image/png;base64," prefix) const base64 = dataUrl.split(',')[1]; resolve({ type: 'image', mimeType: file.type, fileName: file.name, content: base64, preview: dataUrl, }); }; reader.onerror = () => reject(new Error('Failed to read file')); reader.readAsDataURL(file); }); } export function ChatInput({ onSend, onStop, disabled = false, sending = false }: ChatInputProps) { const [input, setInput] = useState(''); const [attachments, setAttachments] = useState([]); const textareaRef = useRef(null); const fileInputRef = useRef(null); const isComposingRef = useRef(false); // Auto-resize textarea useEffect(() => { if (textareaRef.current) { textareaRef.current.style.height = 'auto'; textareaRef.current.style.height = `${Math.min(textareaRef.current.scrollHeight, 200)}px`; } }, [input]); const addFiles = useCallback(async (files: FileList | File[]) => { const fileArray = Array.from(files).filter((f) => ACCEPTED_IMAGE_TYPES.includes(f.type)); if (fileArray.length === 0) return; try { const newAttachments = await Promise.all(fileArray.map(fileToAttachment)); setAttachments((prev) => [...prev, ...newAttachments]); } catch (err) { console.error('Failed to process image:', err); } }, []); const removeAttachment = useCallback((index: number) => { setAttachments((prev) => prev.filter((_, i) => i !== index)); }, []); const canSend = (input.trim() || attachments.length > 0) && !disabled && !sending; const canStop = sending && !disabled && !!onStop; const handleSend = useCallback(() => { if (!canSend) return; onSend(input.trim(), attachments.length > 0 ? attachments : undefined); setInput(''); setAttachments([]); if (textareaRef.current) { textareaRef.current.style.height = 'auto'; } }, [input, attachments, canSend, onSend]); const handleStop = useCallback(() => { if (!canStop) return; onStop?.(); }, [canStop, onStop]); const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { const nativeEvent = e.nativeEvent as KeyboardEvent; if (isComposingRef.current || nativeEvent.isComposing || nativeEvent.keyCode === 229) { return; } e.preventDefault(); handleSend(); } }, [handleSend], ); // Handle paste (Ctrl/Cmd+V with image) const handlePaste = useCallback( (e: React.ClipboardEvent) => { const items = e.clipboardData?.items; if (!items) return; const imageFiles: File[] = []; for (const item of Array.from(items)) { if (item.type.startsWith('image/')) { const file = item.getAsFile(); if (file) imageFiles.push(file); } } if (imageFiles.length > 0) { e.preventDefault(); addFiles(imageFiles); } }, [addFiles], ); // Handle drag & drop const [dragOver, setDragOver] = useState(false); const handleDragOver = useCallback((e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); setDragOver(true); }, []); const handleDragLeave = useCallback((e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); setDragOver(false); }, []); const handleDrop = useCallback( (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); setDragOver(false); if (e.dataTransfer?.files) { addFiles(e.dataTransfer.files); } }, [addFiles], ); return (
{/* Image Previews */} {attachments.length > 0 && (
{attachments.map((att, idx) => (
{att.fileName}
))}
)} {/* Input Row */}
{/* Image Upload Button */} { if (e.target.files) { addFiles(e.target.files); e.target.value = ''; } }} /> {/* Textarea */}