实现完整的文件管理器框选功能

核心功能:
- 鼠标拖拽框选多文件,支持实时视觉反馈
- 智能碰撞检测算法,精确识别文件交集
- Windows风格交互:框选后点击空白处取消选择
- 区分点击和拖拽:距离小于5px视为点击操作

技术实现:
- 状态管理:isSelecting, selectionStart, selectionRect
- 事件处理:mousedown/mousemove/mouseup完整链路
- 坐标计算:支持滚动容器的相对定位
- 防冲突:justFinishedSelecting标志避免误触

交互优化:
- 蓝色半透明选择框,z-index确保最前显示
- data-file-path属性用于元素识别
- 全局事件监听,鼠标移出容器也能正常结束
- 50ms延迟重置,确保事件处理顺序正确

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
ZacharyZcR
2025-09-16 19:54:20 +08:00
parent 3fa3c9e352
commit 12733685d7

View File

@@ -199,6 +199,7 @@ export function FileManagerGrid({
const [isSelecting, setIsSelecting] = useState(false);
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 [justFinishedSelecting, setJustFinishedSelecting] = useState(false);
// 导航历史管理
const [navigationHistory, setNavigationHistory] = useState<string[]>([currentPath]);
@@ -284,6 +285,161 @@ export function FileManagerGrid({
e.stopPropagation();
}, []);
// 框选功能实现
const handleMouseDown = useCallback((e: React.MouseEvent) => {
// 只在空白区域开始框选,避免干扰文件点击
if (e.target === e.currentTarget && e.button === 0) {
e.preventDefault();
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const startX = e.clientX - rect.left;
const startY = e.clientY - rect.top;
setIsSelecting(true);
setSelectionStart({ x: startX, y: startY });
setSelectionRect({ x: startX, y: startY, width: 0, height: 0 });
// 重置刚完成框选的标志,准备新的框选
setJustFinishedSelecting(false);
}
}, []);
const handleMouseMove = useCallback((e: React.MouseEvent) => {
if (isSelecting && selectionStart && gridRef.current) {
const rect = gridRef.current.getBoundingClientRect();
const currentX = e.clientX - rect.left;
const currentY = e.clientY - rect.top;
const x = Math.min(selectionStart.x, currentX);
const y = Math.min(selectionStart.y, currentY);
const width = Math.abs(currentX - selectionStart.x);
const height = Math.abs(currentY - selectionStart.y);
setSelectionRect({ x, y, width, height });
// 检测与文件项的交集,进行实时选择
if (gridRef.current) {
const fileElements = gridRef.current.querySelectorAll('[data-file-path]');
const selectedPaths: string[] = [];
fileElements.forEach((element) => {
const elementRect = element.getBoundingClientRect();
const containerRect = gridRef.current!.getBoundingClientRect();
// 简化坐标计算 - 直接使用相对于容器的坐标
const relativeElementRect = {
left: elementRect.left - containerRect.left,
top: elementRect.top - containerRect.top,
right: elementRect.right - containerRect.left,
bottom: elementRect.bottom - containerRect.top,
};
// 选择框坐标
const selectionBox = {
left: x,
top: y,
right: x + width,
bottom: y + height,
};
// 检查是否相交
const intersects = !(
relativeElementRect.right < selectionBox.left ||
relativeElementRect.left > selectionBox.right ||
relativeElementRect.bottom < selectionBox.top ||
relativeElementRect.top > selectionBox.bottom
);
if (intersects) {
const filePath = element.getAttribute('data-file-path');
if (filePath) {
selectedPaths.push(filePath);
console.log('Selected file:', filePath);
}
}
});
console.log('Total selected paths:', selectedPaths.length);
// 更新选中的文件
const newSelection = files.filter(file => selectedPaths.includes(file.path));
console.log('New selection:', newSelection.map(f => f.name));
onSelectionChange(newSelection);
}
}
}, [isSelecting, selectionStart, files, onSelectionChange]);
const handleMouseUp = useCallback((e: React.MouseEvent) => {
if (isSelecting) {
setIsSelecting(false);
setSelectionStart(null);
setSelectionRect(null);
// 只有当移动距离足够大时才认为是框选,否则是点击
const startPos = selectionStart;
if (startPos) {
const rect = gridRef.current?.getBoundingClientRect();
if (rect) {
const endX = e.clientX - rect.left;
const endY = e.clientY - rect.top;
const distance = Math.sqrt(Math.pow(endX - startPos.x, 2) + Math.pow(endY - startPos.y, 2));
if (distance > 5) {
// 真正的框选,设置标志防止立即清空
setJustFinishedSelecting(true);
setTimeout(() => {
setJustFinishedSelecting(false);
}, 50);
} else {
// 只是点击不设置标志让handleGridClick正常处理
setJustFinishedSelecting(false);
}
}
}
}
}, [isSelecting, selectionStart]);
// 全局鼠标事件监听,确保在容器外也能结束框选
useEffect(() => {
const handleGlobalMouseUp = (e: MouseEvent) => {
if (isSelecting) {
setIsSelecting(false);
setSelectionStart(null);
setSelectionRect(null);
// 全局mouseup说明是拖拽框选设置标志
setJustFinishedSelecting(true);
setTimeout(() => {
setJustFinishedSelecting(false);
}, 50);
}
};
const handleGlobalMouseMove = (e: MouseEvent) => {
if (isSelecting && selectionStart && gridRef.current) {
const rect = gridRef.current.getBoundingClientRect();
const currentX = e.clientX - rect.left;
const currentY = e.clientY - rect.top;
const x = Math.min(selectionStart.x, currentX);
const y = Math.min(selectionStart.y, currentY);
const width = Math.abs(currentX - selectionStart.x);
const height = Math.abs(currentY - selectionStart.y);
setSelectionRect({ x, y, width, height });
}
};
if (isSelecting) {
document.addEventListener('mouseup', handleGlobalMouseUp);
document.addEventListener('mousemove', handleGlobalMouseMove);
return () => {
document.removeEventListener('mouseup', handleGlobalMouseUp);
document.removeEventListener('mousemove', handleGlobalMouseMove);
};
}
}, [isSelecting, selectionStart]);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
@@ -348,7 +504,8 @@ export function FileManagerGrid({
// 空白区域点击取消选择
const handleGridClick = (event: React.MouseEvent) => {
if (event.target === event.currentTarget) {
// 如果刚完成框选,不要清空选择
if (event.target === event.currentTarget && !isSelecting && !justFinishedSelecting) {
onSelectionChange([]);
}
};
@@ -485,6 +642,9 @@ export function FileManagerGrid({
isDragging && "bg-blue-500/10 border-2 border-dashed border-blue-500"
)}
onClick={handleGridClick}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
@@ -530,6 +690,7 @@ export function FileManagerGrid({
return (
<div
key={file.path}
data-file-path={file.path}
className={cn(
"group p-3 rounded-lg cursor-pointer transition-all",
"hover:bg-dark-hover border-2 border-transparent",
@@ -606,6 +767,7 @@ export function FileManagerGrid({
return (
<div
key={file.path}
data-file-path={file.path}
className={cn(
"flex items-center gap-3 p-2 rounded cursor-pointer transition-all",
"hover:bg-dark-hover",
@@ -689,6 +851,19 @@ export function FileManagerGrid({
})}
</div>
)}
{/* 框选矩形 */}
{isSelecting && selectionRect && (
<div
className="absolute pointer-events-none border-2 border-blue-500 bg-blue-500/10 z-50"
style={{
left: selectionRect.x,
top: selectionRect.y,
width: selectionRect.width,
height: selectionRect.height,
}}
/>
)}
</div>
</div>