diff --git a/src/pages/files/components/FileGridView.tsx b/src/pages/files/components/FileGridView.tsx index bee2c32f668ee9d30ed9a9fcdc6f17e744658301..ccdd9f09dbe2d05f08c3a54ddfd834484b3301e7 100644 --- a/src/pages/files/components/FileGridView.tsx +++ b/src/pages/files/components/FileGridView.tsx @@ -37,6 +37,7 @@ import { FileIcon } from '@/components/file-icon' import { GifThumbnail } from '@/components/gif-thumbnail' import { resolveResourceUrl } from '@/utils/resource-url' import { useFileDragDrop } from '../hooks/useFileDragDrop' +import { useFileSelection } from '../hooks/useFileSelection' import { FileListScrollSentinel } from './FileListScrollSentinel' interface FileGridViewProps { @@ -128,26 +129,11 @@ export function FileGridView({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [dragState.dropTargetName, dragState.draggedItems.length]) - const handleItemClick = (file: FileItem, event: React.MouseEvent) => { - const isMultiSelect = event.ctrlKey || event.metaKey - const newSelectedKeys = [...selectedKeys] - const isCurrentlySelected = selectedKeys.includes(file.id) - - if (isMultiSelect) { - // CTRL + Click: 切换当前项状态 - if (isCurrentlySelected) { - const index = newSelectedKeys.indexOf(file.id) - if (index > -1) newSelectedKeys.splice(index, 1) - } else { - newSelectedKeys.push(file.id) - } - } else { - // 普通左键点击: 仅选中当前项 - newSelectedKeys.splice(0, newSelectedKeys.length, file.id) - } - - onSelectionChange(newSelectedKeys) - } + const { handleSelectClick: handleItemClick } = useFileSelection( + selectedKeys, + fileList, + onSelectionChange + ) const handleDoubleClick = (file: FileItem) => { if (file.isDir) { @@ -189,7 +175,7 @@ export function FileGridView({ data-file-id={file.id} style={{ contentVisibility: 'auto', containIntrinsicSize: '150px 150px' }} className={cn( - 'group relative cursor-pointer rounded-lg p-4 pb-2 text-center transition-all', + 'group relative cursor-pointer rounded-lg p-4 pb-2 text-center transition-all select-none', 'hover:bg-accent', isSelected && 'bg-primary/10 selected', isDragging && 'cursor-move opacity-50', diff --git a/src/pages/files/components/FileListView.tsx b/src/pages/files/components/FileListView.tsx index 906cc353c2df561cc03ae7fe3eac856ecfdee220..d665de9d3882aab039babf6a7500a1a0c6f452b5 100644 --- a/src/pages/files/components/FileListView.tsx +++ b/src/pages/files/components/FileListView.tsx @@ -48,6 +48,7 @@ import { FileIcon } from '@/components/file-icon' import { GifThumbnail } from '@/components/gif-thumbnail' import { resolveResourceUrl } from '@/utils/resource-url' import { useFileDragDrop } from '../hooks/useFileDragDrop' +import { useFileSelection } from '../hooks/useFileSelection' import { FileListScrollSentinel } from './FileListScrollSentinel' export function FileListRowActionIcon() { @@ -169,26 +170,11 @@ export function FileListView({ } } - const handleRowClick = (file: FileItem, event: React.MouseEvent) => { - const isMultiSelect = event.ctrlKey || event.metaKey - const newSelectedKeys = [...selectedKeys] - const isCurrentlySelected = selectedKeys.includes(file.id) - - if (isMultiSelect) { - // CTRL + Click: 切换当前项状态 - if (isCurrentlySelected) { - const index = newSelectedKeys.indexOf(file.id) - if (index > -1) newSelectedKeys.splice(index, 1) - } else { - newSelectedKeys.push(file.id) - } - } else { - // 普通左键点击: 仅选中当前项 - newSelectedKeys.splice(0, newSelectedKeys.length, file.id) - } - - onSelectionChange(newSelectedKeys) - } + const { handleSelectClick: handleRowClick } = useFileSelection( + selectedKeys, + fileList, + onSelectionChange + ) const handleDoubleClick = (file: FileItem) => { if (file.isDir) { @@ -322,7 +308,7 @@ export function FileListView({ data-file-id={file.id} style={{ contentVisibility: 'auto', containIntrinsicSize: '48px' }} className={cn( - 'group min-h-[48px] border-b-0 transition-colors duration-150', + 'group min-h-[48px] border-b-0 transition-colors duration-150 select-none', 'hover:bg-primary/[0.06]', isSelected && !isDropTarget && diff --git a/src/pages/files/hooks/useFileSelection.ts b/src/pages/files/hooks/useFileSelection.ts new file mode 100644 index 0000000000000000000000000000000000000000..52cccec16602e029d9a28b6f319746d4a8717d38 --- /dev/null +++ b/src/pages/files/hooks/useFileSelection.ts @@ -0,0 +1,82 @@ +import { + useCallback, + useEffect, + useRef, + type MouseEvent as ReactMouseEvent, +} from 'react' +import type { FileItem } from '@/types/file' + +/** + * 文件列表点击选择逻辑(普通 / Ctrl / Shift / Ctrl+Shift),供网格与列表视图共用。 + * 对齐资源管理器惯例: + * - 普通点击:重置为单选,并把该项记为锚点 + * - Ctrl/Cmd + 点击:切换当前项状态,并把该项记为锚点 + * - Shift + 点击:选中"锚点 → 当前项"的连续区间(含两端),替换现有选择 + * - Ctrl/Cmd + Shift + 点击:区间并入现有选择 + * 锚点按文件 id 记录、Shift 点击时按当前列表实时计算区间, + * 因此翻页、刷新、排序变化后区间依然正确;锚点不在当前列表时退化为单选。 + * 选中集被清空(点击空白、ESC 等)时锚点一并清除,下次 Shift 点击从头开始。 + */ +export function useFileSelection( + selectedKeys: string[], + fileList: FileItem[], + onSelectionChange: (keys: string[]) => void +) { + const anchorIdRef = useRef(null) + + // 选中集被清空(点击空白、ESC、目录切换等)时同步清除锚点, + // 避免视觉上已无选中时 Shift+点击仍恢复出旧区间的违和行为 + useEffect(() => { + if (selectedKeys.length === 0) { + anchorIdRef.current = null + } + }, [selectedKeys]) + + const handleSelectClick = useCallback( + (file: FileItem, event: ReactMouseEvent) => { + const isToggle = event.ctrlKey || event.metaKey + const isRange = event.shiftKey + const anchorId = anchorIdRef.current + + // Shift 范围选择:从锚点到当前项的连续区间(含两端) + if (isRange && anchorId) { + const fromIndex = fileList.findIndex((f) => f.id === anchorId) + const toIndex = fileList.findIndex((f) => f.id === file.id) + if (fromIndex > -1 && toIndex > -1) { + const [start, end] = + fromIndex <= toIndex ? [fromIndex, toIndex] : [toIndex, fromIndex] + const rangeIds = fileList.slice(start, end + 1).map((f) => f.id) + + if (isToggle) { + // Ctrl+Shift:区间并入现有选择(去重) + const merged = new Set(selectedKeys) + rangeIds.forEach((id) => merged.add(id)) + onSelectionChange([...merged]) + } else { + // Shift:区间替换现有选择 + onSelectionChange(rangeIds) + } + return + } + // 锚点不在当前列表:退化为单选并在下方建立新锚点 + } + + if (isToggle) { + // Ctrl/Cmd + Click:切换当前项状态 + const nextKeys = selectedKeys.includes(file.id) + ? selectedKeys.filter((id) => id !== file.id) + : [...selectedKeys, file.id] + onSelectionChange(nextKeys) + anchorIdRef.current = file.id + return + } + + // 普通左键点击:仅选中当前项 + onSelectionChange([file.id]) + anchorIdRef.current = file.id + }, + [selectedKeys, fileList, onSelectionChange] + ) + + return { handleSelectClick } +}