feat(chat): add paste and drag-drop file upload support

- Add clipboard paste handler for images and files (Ctrl+V / Cmd+V)
- Add drag-and-drop zone with visual feedback
- Update placeholder text to inform users about new features
- Improve file upload UX with drop overlay

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-01-10 06:22:59 -08:00
parent 2570e4add9
commit 8a39505ee6
1 changed files with 106 additions and 5 deletions

View File

@ -1,5 +1,5 @@
import { useState, useRef, useEffect, KeyboardEvent, ChangeEvent } from 'react'; import { useState, useRef, useEffect, KeyboardEvent, ChangeEvent, ClipboardEvent, DragEvent } from 'react';
import { Send, Paperclip, X, Image, FileText, Loader2 } from 'lucide-react'; import { Send, Paperclip, X, Image, FileText, Loader2, Upload } from 'lucide-react';
import { clsx } from 'clsx'; import { clsx } from 'clsx';
import { FileAttachment } from '../stores/chatStore'; import { FileAttachment } from '../stores/chatStore';
@ -49,8 +49,10 @@ export function InputArea({
uploadProgress = {}, uploadProgress = {},
}: InputAreaProps) { }: InputAreaProps) {
const [message, setMessage] = useState(''); const [message, setMessage] = useState('');
const [isDragging, setIsDragging] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const dropZoneRef = useRef<HTMLDivElement>(null);
// Auto-resize textarea // Auto-resize textarea
useEffect(() => { useEffect(() => {
@ -105,6 +107,84 @@ export function InputArea({
fileInputRef.current?.click(); fileInputRef.current?.click();
}; };
// 处理文件验证和添加
const processFiles = (files: File[]) => {
const validFiles: File[] = [];
files.forEach((file) => {
if (!ALLOWED_TYPES.includes(file.type)) {
console.warn(`不支持的文件类型: ${file.type}`);
return;
}
if (file.size > MAX_FILE_SIZE) {
console.warn(`文件过大: ${file.name}`);
return;
}
validFiles.push(file);
});
if (validFiles.length > 0) {
onFilesSelected?.(validFiles);
}
};
// 处理粘贴事件
const handlePaste = (e: ClipboardEvent<HTMLTextAreaElement>) => {
const items = e.clipboardData?.items;
if (!items) return;
const files: File[] = [];
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.kind === 'file') {
const file = item.getAsFile();
if (file) {
files.push(file);
}
}
}
if (files.length > 0) {
e.preventDefault(); // 阻止默认粘贴行为
processFiles(files);
}
};
// 拖放事件处理
const handleDragEnter = (e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (!disabled && !isUploading) {
setIsDragging(true);
}
};
const handleDragLeave = (e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
// 只有当离开整个drop zone时才取消高亮
if (dropZoneRef.current && !dropZoneRef.current.contains(e.relatedTarget as Node)) {
setIsDragging(false);
}
};
const handleDragOver = (e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
};
const handleDrop = (e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
if (disabled || isUploading) return;
const files = Array.from(e.dataTransfer?.files || []);
if (files.length > 0) {
processFiles(files);
}
};
const formatFileSize = (bytes: number) => { const formatFileSize = (bytes: number) => {
if (bytes < 1024) return `${bytes} B`; if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
@ -112,7 +192,27 @@ export function InputArea({
}; };
return ( return (
<div className="space-y-3"> <div
ref={dropZoneRef}
className={clsx(
'space-y-3 relative rounded-xl transition-all',
isDragging && 'ring-2 ring-primary-500 ring-offset-2 bg-primary-50',
)}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
{/* 拖放提示层 */}
{isDragging && (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-primary-50/90 rounded-xl border-2 border-dashed border-primary-400">
<div className="flex flex-col items-center gap-2 text-primary-600">
<Upload className="w-8 h-8" />
<span className="font-medium"></span>
</div>
</div>
)}
{/* 文件预览区域 */} {/* 文件预览区域 */}
{pendingFiles.length > 0 && ( {pendingFiles.length > 0 && (
<div className="flex flex-wrap gap-2 px-1"> <div className="flex flex-wrap gap-2 px-1">
@ -208,10 +308,11 @@ export function InputArea({
value={message} value={message}
onChange={(e) => setMessage(e.target.value)} onChange={(e) => setMessage(e.target.value)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onPaste={handlePaste}
placeholder={ placeholder={
pendingFiles.length > 0 pendingFiles.length > 0
? '添加说明文字(可选),或直接发送...' ? '添加说明文字(可选),或直接发送...'
: '输入您的问题...' : '输入您的问题,可粘贴或拖放图片...'
} }
disabled={disabled || isUploading} disabled={disabled || isUploading}
rows={1} rows={1}
@ -244,7 +345,7 @@ export function InputArea({
{/* 提示文字 */} {/* 提示文字 */}
<p className="text-xs text-secondary-400 text-center"> <p className="text-xs text-secondary-400 text-center">
(JPEG, PNG, GIF) (PDF, Word, Excel, ) 50MB (JPEG, PNG, GIF) (PDF, Word, Excel) 50MB
</p> </p>
</div> </div>
); );