优化文件管理器拖拽体验:实现智能跟随tooltip和统一状态管理

- 统一拖拽状态管理:将分散的draggedFiles、dragOverTarget、isDragging等状态合并为单一DragState
- 实现跟随鼠标的动态tooltip:实时显示拖拽操作提示,根据目标文件类型智能变化
- 支持三种拖拽模式:
  * 拖拽到文件夹显示"移动到xxx"
  * 拖拽到文件显示"与xxx进行diff对比"
  * 拖拽到空白区域显示"拖到窗口外下载"
- 修复边界情况:文件拖拽到自身时忽略操作,避免错误触发
- 使用shadcn设计token统一样式风格
- 移除冗余的DragIndicator组件,简化UI界面
- 添加全局鼠标移动监听,确保tooltip平滑跟随

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
ZacharyZcR
2025-09-17 00:53:15 +08:00
parent d79d435594
commit e0e4e69159
2 changed files with 173 additions and 84 deletions

View File

@@ -11,12 +11,15 @@ import {
Code, Code,
Settings, Settings,
Download, Download,
Upload,
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
MoreHorizontal, MoreHorizontal,
RefreshCw, RefreshCw,
ArrowUp, ArrowUp,
FileSymlink FileSymlink,
Move,
GitCompare
} from "lucide-react"; } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { FileItem } from "../../../types/index.js"; import type { FileItem } from "../../../types/index.js";
@@ -46,6 +49,14 @@ function formatFileSize(bytes?: number): string {
return `${formattedSize} ${units[unitIndex]}`; return `${formattedSize} ${units[unitIndex]}`;
} }
interface DragState {
type: 'none' | 'internal' | 'external';
files: FileItem[];
target?: FileItem;
counter: number;
mousePosition?: { x: number; y: number };
}
interface FileManagerGridProps { interface FileManagerGridProps {
files: FileItem[]; files: FileItem[];
selectedFiles: FileItem[]; selectedFiles: FileItem[];
@@ -57,6 +68,7 @@ interface FileManagerGridProps {
onPathChange: (path: string) => void; onPathChange: (path: string) => void;
onRefresh: () => void; onRefresh: () => void;
onUpload?: (files: FileList) => void; onUpload?: (files: FileList) => void;
onDownload?: (files: FileItem[]) => void;
onContextMenu?: (event: React.MouseEvent, file?: FileItem) => void; onContextMenu?: (event: React.MouseEvent, file?: FileItem) => void;
viewMode?: 'grid' | 'list'; viewMode?: 'grid' | 'list';
onRename?: (file: FileItem, newName: string) => void; onRename?: (file: FileItem, newName: string) => void;
@@ -155,6 +167,7 @@ export function FileManagerGrid({
onPathChange, onPathChange,
onRefresh, onRefresh,
onUpload, onUpload,
onDownload,
onContextMenu, onContextMenu,
viewMode = 'grid', viewMode = 'grid',
onRename, onRename,
@@ -173,13 +186,32 @@ export function FileManagerGrid({
}: FileManagerGridProps) { }: FileManagerGridProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const gridRef = useRef<HTMLDivElement>(null); const gridRef = useRef<HTMLDivElement>(null);
const [isDragging, setIsDragging] = useState(false);
const [dragCounter, setDragCounter] = useState(0);
const [editingName, setEditingName] = useState(''); const [editingName, setEditingName] = useState('');
// 拖拽状态管理 // 统一拖拽状态管理
const [draggedFiles, setDraggedFiles] = useState<FileItem[]>([]); const [dragState, setDragState] = useState<DragState>({
const [dragOverTarget, setDragOverTarget] = useState<FileItem | null>(null); type: 'none',
files: [],
counter: 0
});
// 全局鼠标移动监听 - 用于拖拽tooltip跟随
useEffect(() => {
const handleGlobalMouseMove = (e: MouseEvent) => {
if (dragState.type === 'internal' && dragState.files.length > 0) {
setDragState(prev => ({
...prev,
mousePosition: { x: e.clientX, y: e.clientY }
}));
}
};
if (dragState.type === 'internal' && dragState.files.length > 0) {
document.addEventListener('mousemove', handleGlobalMouseMove);
return () => document.removeEventListener('mousemove', handleGlobalMouseMove);
}
}, [dragState.type, dragState.files.length]);
const editInputRef = useRef<HTMLInputElement>(null); const editInputRef = useRef<HTMLInputElement>(null);
// 开始编辑时设置初始名称 // 开始编辑时设置初始名称
@@ -223,7 +255,13 @@ export function FileManagerGrid({
const handleFileDragStart = (e: React.DragEvent, file: FileItem) => { const handleFileDragStart = (e: React.DragEvent, file: FileItem) => {
// 如果拖拽的文件已选中,则拖拽所有选中的文件 // 如果拖拽的文件已选中,则拖拽所有选中的文件
const filesToDrag = selectedFiles.includes(file) ? selectedFiles : [file]; const filesToDrag = selectedFiles.includes(file) ? selectedFiles : [file];
setDraggedFiles(filesToDrag);
setDragState({
type: 'internal',
files: filesToDrag,
counter: 0,
mousePosition: { x: e.clientX, y: e.clientY }
});
// 设置拖拽数据,添加内部拖拽标识 // 设置拖拽数据,添加内部拖拽标识
const dragData = { const dragData = {
@@ -242,8 +280,8 @@ export function FileManagerGrid({
e.stopPropagation(); e.stopPropagation();
// 只有拖拽到不同文件且不是被拖拽的文件时才设置目标 // 只有拖拽到不同文件且不是被拖拽的文件时才设置目标
if (draggedFiles.length > 0 && !draggedFiles.some(f => f.path === targetFile.path)) { if (dragState.type === 'internal' && !dragState.files.some(f => f.path === targetFile.path)) {
setDragOverTarget(targetFile); setDragState(prev => ({ ...prev, target: targetFile }));
e.dataTransfer.dropEffect = 'move'; e.dataTransfer.dropEffect = 'move';
} }
}; };
@@ -253,8 +291,8 @@ export function FileManagerGrid({
e.stopPropagation(); e.stopPropagation();
// 清除拖拽目标高亮 // 清除拖拽目标高亮
if (dragOverTarget?.path === targetFile.path) { if (dragState.target?.path === targetFile.path) {
setDragOverTarget(null); setDragState(prev => ({ ...prev, target: undefined }));
} }
}; };
@@ -262,9 +300,18 @@ export function FileManagerGrid({
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
setDragOverTarget(null); if (dragState.type !== 'internal' || dragState.files.length === 0) {
setDragState(prev => ({ ...prev, target: undefined }));
return;
}
if (draggedFiles.length === 0) return; // 检查是否拖拽到自身
const isDroppingOnSelf = dragState.files.some(f => f.path === targetFile.path);
if (isDroppingOnSelf) {
console.log('Ignoring drop on self');
setDragState({ type: 'none', files: [], counter: 0 });
return;
}
// 判断拖拽行为: // 判断拖拽行为:
// 1. 文件/文件夹 拖拽到 文件夹 = 移动操作 // 1. 文件/文件夹 拖拽到 文件夹 = 移动操作
@@ -273,23 +320,22 @@ export function FileManagerGrid({
if (targetFile.type === 'directory') { if (targetFile.type === 'directory') {
// 移动操作 // 移动操作
console.log('Moving files to directory:', draggedFiles.map(f => f.name), 'to', targetFile.name); console.log('Moving files to directory:', dragState.files.map(f => f.name), 'to', targetFile.name);
onFileDrop?.(draggedFiles, targetFile); onFileDrop?.(dragState.files, targetFile);
} else if (targetFile.type === 'file' && draggedFiles.length === 1 && draggedFiles[0].type === 'file') { } else if (targetFile.type === 'file' && dragState.files.length === 1 && dragState.files[0].type === 'file') {
// diff对比操作 // diff对比操作
console.log('Comparing files:', draggedFiles[0].name, 'vs', targetFile.name); console.log('Comparing files:', dragState.files[0].name, 'vs', targetFile.name);
onFileDiff?.(draggedFiles[0], targetFile); onFileDiff?.(dragState.files[0], targetFile);
} else { } else {
// 无效操作,给用户提示 // 无效操作,给用户提示
console.log('Invalid drag operation'); console.log('Invalid drag operation');
} }
setDraggedFiles([]); setDragState({ type: 'none', files: [], counter: 0 });
}; };
const handleFileDragEnd = (e: React.DragEvent) => { const handleFileDragEnd = (e: React.DragEvent) => {
setDraggedFiles([]); setDragState({ type: 'none', files: [], counter: 0 });
setDragOverTarget(null);
// 触发系统级拖拽结束检测 // 触发系统级拖拽结束检测
onSystemDragEnd?.(e.nativeEvent); onSystemDragEnd?.(e.nativeEvent);
@@ -360,45 +406,58 @@ export function FileManagerGrid({
e.stopPropagation(); e.stopPropagation();
// 检查是否是内部文件拖拽 // 检查是否是内部文件拖拽
const isInternalDrag = draggedFiles.length > 0; // 如果有内部拖拽的文件,说明是内部拖拽 const isInternalDrag = dragState.type === 'internal';
if (!isInternalDrag) { if (!isInternalDrag) {
// 只有外部文件拖拽才显示上传提示 // 只有外部文件拖拽才显示上传提示
setDragCounter(prev => prev + 1); setDragState(prev => ({
...prev,
type: 'external',
counter: prev.counter + 1
}));
if (e.dataTransfer.items && e.dataTransfer.items.length > 0) { if (e.dataTransfer.items && e.dataTransfer.items.length > 0) {
setIsDragging(true); // External drag detected
} }
} }
}, [draggedFiles]); }, [dragState.type]);
const handleDragLeave = useCallback((e: React.DragEvent) => { const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
// 检查是否是内部文件拖拽 // 检查是否是内部文件拖拽
const isInternalDrag = draggedFiles.length > 0; const isInternalDrag = dragState.type === 'internal';
if (!isInternalDrag) { if (!isInternalDrag && dragState.type === 'external') {
setDragCounter(prev => prev - 1); setDragState(prev => {
if (dragCounter <= 1) { const newCounter = prev.counter - 1;
setIsDragging(false); return {
} ...prev,
counter: newCounter,
type: newCounter <= 0 ? 'none' : 'external'
};
});
} }
}, [dragCounter, draggedFiles]); }, [dragState.type, dragState.counter]);
const handleDragOver = useCallback((e: React.DragEvent) => { const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
// 检查是否是内部文件拖拽 // 检查是否是内部文件拖拽
const isInternalDrag = draggedFiles.length > 0; const isInternalDrag = dragState.type === 'internal';
if (isInternalDrag) { if (isInternalDrag) {
// 更新鼠标位置
setDragState(prev => ({
...prev,
mousePosition: { x: e.clientX, y: e.clientY }
}));
e.dataTransfer.dropEffect = 'move'; e.dataTransfer.dropEffect = 'move';
} else { } else {
e.dataTransfer.dropEffect = 'copy'; e.dataTransfer.dropEffect = 'copy';
} }
}, [draggedFiles]); }, [dragState.type]);
// 滚轮事件处理,确保滚动正常工作 // 滚轮事件处理,确保滚动正常工作
const handleWheel = useCallback((e: React.WheelEvent) => { const handleWheel = useCallback((e: React.WheelEvent) => {
@@ -565,22 +624,22 @@ export function FileManagerGrid({
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
// 检查是否是内部文件拖拽 if (dragState.type === 'internal') {
const isInternalDrag = draggedFiles.length > 0; // 内部拖拽到空白区域:触发下载
console.log('Internal drag to empty area detected, triggering download');
if (isInternalDrag) { if (onDownload && dragState.files.length > 0) {
// 内部拖拽:不处理,因为已经在 handleFileDrop 中处理了 onDownload(dragState.files);
console.log('Internal drag detected, ignoring container drop'); }
} else { } else if (dragState.type === 'external') {
// 外部拖拽:处理文件上传 // 外部拖拽:处理文件上传
setIsDragging(false);
setDragCounter(0);
if (onUpload && e.dataTransfer.files.length > 0) { if (onUpload && e.dataTransfer.files.length > 0) {
onUpload(e.dataTransfer.files); onUpload(e.dataTransfer.files);
} }
} }
}, [onUpload, draggedFiles]);
// 重置拖拽状态
setDragState({ type: 'none', files: [], counter: 0 });
}, [onUpload, onDownload, dragState]);
// 文件选择处理 // 文件选择处理
const handleFileClick = (file: FileItem, event: React.MouseEvent) => { const handleFileClick = (file: FileItem, event: React.MouseEvent) => {
@@ -815,7 +874,7 @@ export function FileManagerGrid({
ref={gridRef} ref={gridRef}
className={cn( className={cn(
"absolute inset-0 p-4 overflow-y-auto thin-scrollbar", "absolute inset-0 p-4 overflow-y-auto thin-scrollbar",
isDragging && "bg-blue-500/10 border-2 border-dashed border-blue-500" dragState.type === 'external' && "bg-muted/20 border-2 border-dashed border-primary"
)} )}
onClick={handleGridClick} onClick={handleGridClick}
onMouseDown={handleMouseDown} onMouseDown={handleMouseDown}
@@ -829,22 +888,37 @@ export function FileManagerGrid({
onContextMenu={(e) => onContextMenu?.(e)} onContextMenu={(e) => onContextMenu?.(e)}
tabIndex={0} tabIndex={0}
> >
{isDragging && ( {/* 拖拽提示覆盖层 */}
<div className="absolute inset-0 flex items-center justify-center bg-blue-500/10 backdrop-blur-sm z-10 pointer-events-none"> {dragState.type === 'external' && (
<div className="text-center"> <div className="absolute inset-0 flex items-center justify-center bg-background/50 backdrop-blur-sm z-10 pointer-events-none animate-in fade-in-0">
<Download className="w-12 h-12 mx-auto mb-2 text-primary" /> <div className="text-center p-8 bg-background/95 border-2 border-dashed border-primary rounded-lg shadow-lg">
<p className="text-lg font-medium text-primary"> <Upload className="w-16 h-16 mx-auto mb-4 text-primary" />
{t("fileManager.dragFilesToUpload")} <p className="text-xl font-semibold text-foreground mb-2">
</p>
<p className="text-sm text-muted-foreground">
</p> </p>
</div> </div>
</div> </div>
)} )}
{files.length === 0 ? ( {files.length === 0 ? (
<div className="h-full flex items-center justify-center"> <div className="h-full flex items-center justify-center p-8">
<div className="text-center text-muted-foreground"> <div className="text-center">
<Folder className="w-16 h-16 mx-auto mb-4 opacity-50" /> <Folder className="w-16 h-16 mx-auto mb-4 text-muted-foreground/50" />
<p>{t("fileManager.emptyFolder")}</p> <p className="text-lg font-medium text-foreground mb-4">{t("fileManager.emptyFolder")}</p>
<div className="space-y-3">
<div className="flex items-center justify-center gap-2 text-sm text-muted-foreground bg-muted/50 rounded-md px-3 py-2">
<Upload className="w-4 h-4" />
</div>
<div className="flex items-center justify-center gap-2 text-sm text-muted-foreground bg-muted/50 rounded-md px-3 py-2">
<Download className="w-4 h-4" />
</div>
</div>
</div> </div>
</div> </div>
) : viewMode === 'grid' ? ( ) : viewMode === 'grid' ? (
@@ -872,8 +946,8 @@ export function FileManagerGrid({
"group p-3 rounded-lg cursor-pointer transition-all", "group p-3 rounded-lg cursor-pointer transition-all",
"hover:bg-accent hover:text-accent-foreground border-2 border-transparent", "hover:bg-accent hover:text-accent-foreground border-2 border-transparent",
isSelected && "bg-primary/20 border-primary", isSelected && "bg-primary/20 border-primary",
dragOverTarget?.path === file.path && "bg-blue-100 border-blue-400 border-dashed", dragState.target?.path === file.path && "bg-muted border-primary border-dashed",
draggedFiles.some(f => f.path === file.path) && "opacity-50" dragState.files.some(f => f.path === file.path) && "opacity-50"
)} )}
title={`${file.name} - Selected: ${isSelected} - SelectedCount: ${selectedFiles.length}`} title={`${file.name} - Selected: ${isSelected} - SelectedCount: ${selectedFiles.length}`}
onClick={(e) => handleFileClick(file, e)} onClick={(e) => handleFileClick(file, e)}
@@ -957,8 +1031,8 @@ export function FileManagerGrid({
"flex items-center gap-3 p-2 rounded cursor-pointer transition-all", "flex items-center gap-3 p-2 rounded cursor-pointer transition-all",
"hover:bg-accent hover:text-accent-foreground", "hover:bg-accent hover:text-accent-foreground",
isSelected && "bg-primary/20", isSelected && "bg-primary/20",
dragOverTarget?.path === file.path && "bg-blue-100 border-blue-400 border-dashed", dragState.target?.path === file.path && "bg-muted border-primary border-dashed",
draggedFiles.some(f => f.path === file.path) && "opacity-50" dragState.files.some(f => f.path === file.path) && "opacity-50"
)} )}
onClick={(e) => handleFileClick(file, e)} onClick={(e) => handleFileClick(file, e)}
onContextMenu={(e) => { onContextMenu={(e) => {
@@ -1072,6 +1146,44 @@ export function FileManagerGrid({
)} )}
</div> </div>
</div> </div>
{/* 拖拽跟随tooltip */}
{dragState.type === 'internal' && dragState.files.length > 0 && dragState.mousePosition && (
<div
className="fixed z-50 pointer-events-none"
style={{
left: dragState.mousePosition.x + 16,
top: dragState.mousePosition.y - 8
}}
>
<div className="bg-background border border-border rounded-md shadow-md px-3 py-2 flex items-center gap-2">
{dragState.target ? (
dragState.target.type === 'directory' ? (
<>
<Move className="w-4 h-4 text-blue-500" />
<span className="text-sm font-medium text-foreground">
{dragState.target.name}
</span>
</>
) : (
<>
<GitCompare className="w-4 h-4 text-purple-500" />
<span className="text-sm font-medium text-foreground">
{dragState.target.name} diff对比
</span>
</>
)
) : (
<>
<Download className="w-4 h-4 text-green-500" />
<span className="text-sm font-medium text-foreground">
({dragState.files.length} )
</span>
</>
)}
</div>
</div>
)}
</div> </div>
); );
} }

View File

@@ -8,7 +8,6 @@ import { FileWindow } from "./components/FileWindow";
import { DiffWindow } from "./components/DiffWindow"; import { DiffWindow } from "./components/DiffWindow";
import { useDragToDesktop } from "../../../hooks/useDragToDesktop"; import { useDragToDesktop } from "../../../hooks/useDragToDesktop";
import { useDragToSystemDesktop } from "../../../hooks/useDragToSystemDesktop"; import { useDragToSystemDesktop } from "../../../hooks/useDragToSystemDesktop";
import { DragIndicator } from "../../../components/DragIndicator";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -1275,6 +1274,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerModernProps) {
onPathChange={setCurrentPath} onPathChange={setCurrentPath}
onRefresh={() => loadDirectory(currentPath)} onRefresh={() => loadDirectory(currentPath)}
onUpload={handleFilesDropped} onUpload={handleFilesDropped}
onDownload={(files) => files.forEach(handleDownloadFile)}
onContextMenu={handleContextMenu} onContextMenu={handleContextMenu}
viewMode={viewMode} viewMode={viewMode}
onRename={handleRenameConfirm} onRename={handleRenameConfirm}
@@ -1322,29 +1322,6 @@ function FileManagerContent({ initialHost, onClose }: FileManagerModernProps) {
onDragToDesktop={() => handleDragToDesktop(contextMenu.files)} onDragToDesktop={() => handleDragToDesktop(contextMenu.files)}
/> />
{/* 拖拽到桌面指示器 */}
<DragIndicator
isVisible={
dragToDesktop.isDownloading ||
dragToDesktop.isDragging ||
systemDrag.isDownloading ||
systemDrag.isDragging
}
isDragging={
systemDrag.isDragging || dragToDesktop.isDragging
}
isDownloading={
systemDrag.isDownloading || dragToDesktop.isDownloading
}
progress={
systemDrag.isDownloading || systemDrag.isDragging
? systemDrag.progress
: dragToDesktop.progress
}
fileName={selectedFiles.length === 1 ? selectedFiles[0]?.name : undefined}
fileCount={selectedFiles.length}
error={systemDrag.error || dragToDesktop.error}
/>
</div> </div>
</div> </div>
); );