实现文件拖拽功能
- 支持文件/文件夹拖拽到文件夹进行移动操作 - 支持文件拖拽到文件进行diff对比(临时实现) - 区分内部文件拖拽和外部文件上传,避免误触发上传界面 - 添加拖拽视觉反馈和状态管理 - 支持批量文件拖拽移动 - 集成撤销历史记录
This commit is contained in:
@@ -68,6 +68,8 @@ interface FileManagerGridProps {
|
|||||||
onCut?: (files: FileItem[]) => void;
|
onCut?: (files: FileItem[]) => void;
|
||||||
onPaste?: () => void;
|
onPaste?: () => void;
|
||||||
onUndo?: () => void;
|
onUndo?: () => void;
|
||||||
|
onFileDrop?: (draggedFiles: FileItem[], targetFile: FileItem) => void;
|
||||||
|
onFileDiff?: (file1: FileItem, file2: FileItem) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getFileIcon = (file: FileItem, viewMode: 'grid' | 'list' = 'grid') => {
|
const getFileIcon = (file: FileItem, viewMode: 'grid' | 'list' = 'grid') => {
|
||||||
@@ -161,13 +163,19 @@ export function FileManagerGrid({
|
|||||||
onCopy,
|
onCopy,
|
||||||
onCut,
|
onCut,
|
||||||
onPaste,
|
onPaste,
|
||||||
onUndo
|
onUndo,
|
||||||
|
onFileDrop,
|
||||||
|
onFileDiff
|
||||||
}: FileManagerGridProps) {
|
}: FileManagerGridProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const gridRef = useRef<HTMLDivElement>(null);
|
const gridRef = useRef<HTMLDivElement>(null);
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
const [dragCounter, setDragCounter] = useState(0);
|
const [dragCounter, setDragCounter] = useState(0);
|
||||||
const [editingName, setEditingName] = useState('');
|
const [editingName, setEditingName] = useState('');
|
||||||
|
|
||||||
|
// 拖拽状态管理
|
||||||
|
const [draggedFiles, setDraggedFiles] = useState<FileItem[]>([]);
|
||||||
|
const [dragOverTarget, setDragOverTarget] = useState<FileItem | null>(null);
|
||||||
const editInputRef = useRef<HTMLInputElement>(null);
|
const editInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
// 开始编辑时设置初始名称
|
// 开始编辑时设置初始名称
|
||||||
@@ -206,6 +214,77 @@ export function FileManagerGrid({
|
|||||||
handleEditCancel();
|
handleEditCancel();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 文件拖拽处理函数
|
||||||
|
const handleFileDragStart = (e: React.DragEvent, file: FileItem) => {
|
||||||
|
// 如果拖拽的文件已选中,则拖拽所有选中的文件
|
||||||
|
const filesToDrag = selectedFiles.includes(file) ? selectedFiles : [file];
|
||||||
|
setDraggedFiles(filesToDrag);
|
||||||
|
|
||||||
|
// 设置拖拽数据,添加内部拖拽标识
|
||||||
|
const dragData = {
|
||||||
|
type: 'internal_files',
|
||||||
|
files: filesToDrag.map(f => f.path)
|
||||||
|
};
|
||||||
|
e.dataTransfer.setData('text/plain', JSON.stringify(dragData));
|
||||||
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileDragOver = (e: React.DragEvent, targetFile: FileItem) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
// 只有拖拽到不同文件且不是被拖拽的文件时才设置目标
|
||||||
|
if (draggedFiles.length > 0 && !draggedFiles.some(f => f.path === targetFile.path)) {
|
||||||
|
setDragOverTarget(targetFile);
|
||||||
|
e.dataTransfer.dropEffect = 'move';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileDragLeave = (e: React.DragEvent, targetFile: FileItem) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
// 清除拖拽目标高亮
|
||||||
|
if (dragOverTarget?.path === targetFile.path) {
|
||||||
|
setDragOverTarget(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileDrop = (e: React.DragEvent, targetFile: FileItem) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
setDragOverTarget(null);
|
||||||
|
|
||||||
|
if (draggedFiles.length === 0) return;
|
||||||
|
|
||||||
|
// 判断拖拽行为:
|
||||||
|
// 1. 文件/文件夹 拖拽到 文件夹 = 移动操作
|
||||||
|
// 2. 单个文件 拖拽到 单个文件 = diff对比
|
||||||
|
// 3. 其他情况 = 无效操作
|
||||||
|
|
||||||
|
if (targetFile.type === 'directory') {
|
||||||
|
// 移动操作
|
||||||
|
console.log('Moving files to directory:', draggedFiles.map(f => f.name), 'to', targetFile.name);
|
||||||
|
onFileDrop?.(draggedFiles, targetFile);
|
||||||
|
} else if (targetFile.type === 'file' && draggedFiles.length === 1 && draggedFiles[0].type === 'file') {
|
||||||
|
// diff对比操作
|
||||||
|
console.log('Comparing files:', draggedFiles[0].name, 'vs', targetFile.name);
|
||||||
|
onFileDiff?.(draggedFiles[0], targetFile);
|
||||||
|
} else {
|
||||||
|
// 无效操作,给用户提示
|
||||||
|
console.log('Invalid drag operation');
|
||||||
|
}
|
||||||
|
|
||||||
|
setDraggedFiles([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileDragEnd = () => {
|
||||||
|
setDraggedFiles([]);
|
||||||
|
setDragOverTarget(null);
|
||||||
|
};
|
||||||
|
|
||||||
const [isSelecting, setIsSelecting] = useState(false);
|
const [isSelecting, setIsSelecting] = useState(false);
|
||||||
const [selectionStart, setSelectionStart] = useState<{ x: number; y: number } | null>(null);
|
const [selectionStart, setSelectionStart] = useState<{ x: number; y: number } | null>(null);
|
||||||
const [selectionRect, setSelectionRect] = useState<{ x: number; y: number; width: number; height: number } | null>(null);
|
const [selectionRect, setSelectionRect] = useState<{ x: number; y: number; width: number; height: number } | null>(null);
|
||||||
@@ -265,29 +344,51 @@ export function FileManagerGrid({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 拖放处理
|
// 拖放处理 - 区分内部文件拖拽和外部文件上传
|
||||||
const handleDragEnter = useCallback((e: React.DragEvent) => {
|
const handleDragEnter = useCallback((e: React.DragEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
|
// 检查是否是内部文件拖拽
|
||||||
|
const isInternalDrag = draggedFiles.length > 0; // 如果有内部拖拽的文件,说明是内部拖拽
|
||||||
|
|
||||||
|
if (!isInternalDrag) {
|
||||||
|
// 只有外部文件拖拽才显示上传提示
|
||||||
setDragCounter(prev => prev + 1);
|
setDragCounter(prev => prev + 1);
|
||||||
if (e.dataTransfer.items && e.dataTransfer.items.length > 0) {
|
if (e.dataTransfer.items && e.dataTransfer.items.length > 0) {
|
||||||
setIsDragging(true);
|
setIsDragging(true);
|
||||||
}
|
}
|
||||||
}, []);
|
}
|
||||||
|
}, [draggedFiles]);
|
||||||
|
|
||||||
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;
|
||||||
|
|
||||||
|
if (!isInternalDrag) {
|
||||||
setDragCounter(prev => prev - 1);
|
setDragCounter(prev => prev - 1);
|
||||||
if (dragCounter <= 1) {
|
if (dragCounter <= 1) {
|
||||||
setIsDragging(false);
|
setIsDragging(false);
|
||||||
}
|
}
|
||||||
}, [dragCounter]);
|
}
|
||||||
|
}, [dragCounter, draggedFiles]);
|
||||||
|
|
||||||
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;
|
||||||
|
|
||||||
|
if (isInternalDrag) {
|
||||||
|
e.dataTransfer.dropEffect = 'move';
|
||||||
|
} else {
|
||||||
|
e.dataTransfer.dropEffect = 'copy';
|
||||||
|
}
|
||||||
|
}, [draggedFiles]);
|
||||||
|
|
||||||
// 滚轮事件处理,确保滚动正常工作
|
// 滚轮事件处理,确保滚动正常工作
|
||||||
const handleWheel = useCallback((e: React.WheelEvent) => {
|
const handleWheel = useCallback((e: React.WheelEvent) => {
|
||||||
@@ -454,13 +555,22 @@ export function FileManagerGrid({
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
|
// 检查是否是内部文件拖拽
|
||||||
|
const isInternalDrag = draggedFiles.length > 0;
|
||||||
|
|
||||||
|
if (isInternalDrag) {
|
||||||
|
// 内部拖拽:不处理,因为已经在 handleFileDrop 中处理了
|
||||||
|
console.log('Internal drag detected, ignoring container drop');
|
||||||
|
} else {
|
||||||
|
// 外部拖拽:处理文件上传
|
||||||
setIsDragging(false);
|
setIsDragging(false);
|
||||||
setDragCounter(0);
|
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]);
|
}
|
||||||
|
}, [onUpload, draggedFiles]);
|
||||||
|
|
||||||
// 文件选择处理
|
// 文件选择处理
|
||||||
const handleFileClick = (file: FileItem, event: React.MouseEvent) => {
|
const handleFileClick = (file: FileItem, event: React.MouseEvent) => {
|
||||||
@@ -747,10 +857,13 @@ export function FileManagerGrid({
|
|||||||
<div
|
<div
|
||||||
key={file.path}
|
key={file.path}
|
||||||
data-file-path={file.path}
|
data-file-path={file.path}
|
||||||
|
draggable={true}
|
||||||
className={cn(
|
className={cn(
|
||||||
"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",
|
||||||
|
draggedFiles.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)}
|
||||||
@@ -759,6 +872,11 @@ export function FileManagerGrid({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onContextMenu?.(e, file);
|
onContextMenu?.(e, file);
|
||||||
}}
|
}}
|
||||||
|
onDragStart={(e) => handleFileDragStart(e, file)}
|
||||||
|
onDragOver={(e) => handleFileDragOver(e, file)}
|
||||||
|
onDragLeave={(e) => handleFileDragLeave(e, file)}
|
||||||
|
onDrop={(e) => handleFileDrop(e, file)}
|
||||||
|
onDragEnd={handleFileDragEnd}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col items-center text-center">
|
<div className="flex flex-col items-center text-center">
|
||||||
{/* 文件图标 */}
|
{/* 文件图标 */}
|
||||||
@@ -824,10 +942,13 @@ export function FileManagerGrid({
|
|||||||
<div
|
<div
|
||||||
key={file.path}
|
key={file.path}
|
||||||
data-file-path={file.path}
|
data-file-path={file.path}
|
||||||
|
draggable={true}
|
||||||
className={cn(
|
className={cn(
|
||||||
"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",
|
||||||
|
draggedFiles.some(f => f.path === file.path) && "opacity-50"
|
||||||
)}
|
)}
|
||||||
onClick={(e) => handleFileClick(file, e)}
|
onClick={(e) => handleFileClick(file, e)}
|
||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => {
|
||||||
@@ -835,6 +956,11 @@ export function FileManagerGrid({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onContextMenu?.(e, file);
|
onContextMenu?.(e, file);
|
||||||
}}
|
}}
|
||||||
|
onDragStart={(e) => handleFileDragStart(e, file)}
|
||||||
|
onDragOver={(e) => handleFileDragOver(e, file)}
|
||||||
|
onDragLeave={(e) => handleFileDragLeave(e, file)}
|
||||||
|
onDrop={(e) => handleFileDrop(e, file)}
|
||||||
|
onDragEnd={handleFileDragEnd}
|
||||||
>
|
>
|
||||||
{/* 文件图标 */}
|
{/* 文件图标 */}
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
|
|||||||
@@ -940,6 +940,147 @@ function FileManagerContent({ initialHost, onClose }: FileManagerModernProps) {
|
|||||||
return candidateName;
|
return candidateName;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 拖拽处理:文件/文件夹拖到文件夹 = 移动操作
|
||||||
|
async function handleFileDrop(draggedFiles: FileItem[], targetFolder: FileItem) {
|
||||||
|
if (!sshSessionId || targetFolder.type !== 'directory') return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ensureSSHConnection();
|
||||||
|
|
||||||
|
let successCount = 0;
|
||||||
|
const movedItems: string[] = [];
|
||||||
|
|
||||||
|
for (const file of draggedFiles) {
|
||||||
|
try {
|
||||||
|
const targetPath = targetFolder.path.endsWith('/')
|
||||||
|
? `${targetFolder.path}${file.name}`
|
||||||
|
: `${targetFolder.path}/${file.name}`;
|
||||||
|
|
||||||
|
// 只有当目标路径与原路径不同时才移动
|
||||||
|
if (file.path !== targetPath) {
|
||||||
|
await moveSSHItem(
|
||||||
|
sshSessionId,
|
||||||
|
file.path,
|
||||||
|
targetPath,
|
||||||
|
currentHost?.id,
|
||||||
|
currentHost?.userId?.toString()
|
||||||
|
);
|
||||||
|
movedItems.push(file.name);
|
||||||
|
successCount++;
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`Failed to move file ${file.name}:`, error);
|
||||||
|
toast.error(`移动 ${file.name} 失败: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (successCount > 0) {
|
||||||
|
// 记录撤销历史
|
||||||
|
const movedFiles = draggedFiles.slice(0, successCount).map((file, index) => {
|
||||||
|
const targetPath = targetFolder.path.endsWith('/')
|
||||||
|
? `${targetFolder.path}${file.name}`
|
||||||
|
: `${targetFolder.path}/${file.name}`;
|
||||||
|
return {
|
||||||
|
originalPath: file.path,
|
||||||
|
targetPath: targetPath,
|
||||||
|
targetName: file.name
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const undoAction: UndoAction = {
|
||||||
|
type: 'cut',
|
||||||
|
description: `拖拽移动了 ${successCount} 个项目到 ${targetFolder.name}`,
|
||||||
|
data: {
|
||||||
|
operation: 'cut',
|
||||||
|
copiedFiles: movedFiles,
|
||||||
|
targetDirectory: targetFolder.path
|
||||||
|
},
|
||||||
|
timestamp: Date.now()
|
||||||
|
};
|
||||||
|
setUndoHistory(prev => [...prev.slice(-9), undoAction]);
|
||||||
|
|
||||||
|
toast.success(`成功移动了 ${successCount} 个项目到 ${targetFolder.name}`);
|
||||||
|
loadDirectory(currentPath);
|
||||||
|
clearSelection(); // 清除选中状态
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Drag move operation failed:', error);
|
||||||
|
toast.error(`移动操作失败: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 拖拽处理:文件拖到文件 = diff对比操作
|
||||||
|
function handleFileDiff(file1: FileItem, file2: FileItem) {
|
||||||
|
if (file1.type !== 'file' || file2.type !== 'file') {
|
||||||
|
toast.error('只能对比两个文件');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sshSessionId) {
|
||||||
|
toast.error(t("fileManager.noSSHConnection"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 在新窗口中打开两个文件进行对比
|
||||||
|
console.log('Opening diff comparison:', file1.name, 'vs', file2.name);
|
||||||
|
|
||||||
|
// 计算第一个窗口位置
|
||||||
|
const offsetX1 = 100;
|
||||||
|
const offsetY1 = 100;
|
||||||
|
|
||||||
|
// 计算第二个窗口位置(偏移)
|
||||||
|
const offsetX2 = 450;
|
||||||
|
const offsetY2 = 120;
|
||||||
|
|
||||||
|
// 创建第一个文件窗口
|
||||||
|
const windowId1 = `file-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
const createWindowComponent1 = (windowId: string) => (
|
||||||
|
<FileWindow
|
||||||
|
windowId={windowId}
|
||||||
|
file={file1}
|
||||||
|
sshSessionId={sshSessionId}
|
||||||
|
sshHost={currentHost}
|
||||||
|
initialX={offsetX1}
|
||||||
|
initialY={offsetY1}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
openWindow({
|
||||||
|
id: windowId1,
|
||||||
|
type: 'file',
|
||||||
|
title: `${file1.name} (对比文件1)`,
|
||||||
|
isMaximized: false,
|
||||||
|
component: createWindowComponent1,
|
||||||
|
zIndex: Date.now()
|
||||||
|
});
|
||||||
|
|
||||||
|
// 稍后打开第二个文件窗口
|
||||||
|
setTimeout(() => {
|
||||||
|
const windowId2 = `file-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
const createWindowComponent2 = (windowId: string) => (
|
||||||
|
<FileWindow
|
||||||
|
windowId={windowId}
|
||||||
|
file={file2}
|
||||||
|
sshSessionId={sshSessionId}
|
||||||
|
sshHost={currentHost}
|
||||||
|
initialX={offsetX2}
|
||||||
|
initialY={offsetY2}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
openWindow({
|
||||||
|
id: windowId2,
|
||||||
|
type: 'file',
|
||||||
|
title: `${file2.name} (对比文件2)`,
|
||||||
|
isMaximized: false,
|
||||||
|
component: createWindowComponent2,
|
||||||
|
zIndex: Date.now() + 1
|
||||||
|
});
|
||||||
|
}, 200);
|
||||||
|
|
||||||
|
toast.success(`正在打开文件对比: ${file1.name} 与 ${file2.name}`);
|
||||||
|
}
|
||||||
|
|
||||||
// 过滤文件并添加新建的临时项目
|
// 过滤文件并添加新建的临时项目
|
||||||
let filteredFiles = files.filter(file =>
|
let filteredFiles = files.filter(file =>
|
||||||
file.name.toLowerCase().includes(searchQuery.toLowerCase())
|
file.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
@@ -1088,6 +1229,8 @@ function FileManagerContent({ initialHost, onClose }: FileManagerModernProps) {
|
|||||||
onCut={handleCutFiles}
|
onCut={handleCutFiles}
|
||||||
onPaste={handlePasteFiles}
|
onPaste={handlePasteFiles}
|
||||||
onUndo={handleUndo}
|
onUndo={handleUndo}
|
||||||
|
onFileDrop={handleFileDrop}
|
||||||
|
onFileDiff={handleFileDiff}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 右键菜单 */}
|
{/* 右键菜单 */}
|
||||||
|
|||||||
Reference in New Issue
Block a user