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:
parent
2570e4add9
commit
8a39505ee6
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue