diff --git a/app/src/App.css b/app/src/App.css index 81b69676..14a8932c 100644 --- a/app/src/App.css +++ b/app/src/App.css @@ -350,3 +350,29 @@ table.ratio-table td.min-width { background-color: #1b1c1d; border-top: 1px solid rgba(255, 255, 255, 0.1); } + +/* Drag selection visual feedback for FileBrowser */ +tr.drag-selecting td { + background-color: rgba(33, 133, 208, 0.15) !important; +} + +.inverted tr.drag-selecting td { + background-color: rgba(33, 133, 208, 0.25) !important; +} + +/* Drag deselection visual feedback (red tint) */ +tr.drag-deselecting td { + background-color: rgba(219, 40, 40, 0.15) !important; +} + +.inverted tr.drag-deselecting td { + background-color: rgba(219, 40, 40, 0.25) !important; +} + +/* Prevent text selection during drag */ +.file-browser-dragging { + user-select: none; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; +} diff --git a/app/src/components/FileBrowser.js b/app/src/components/FileBrowser.js index 2d98025e..e256aa1d 100644 --- a/app/src/components/FileBrowser.js +++ b/app/src/components/FileBrowser.js @@ -22,7 +22,7 @@ import { Toggle, useIsIgnoredDirectory } from "./Common"; -import React, {useEffect, useState} from "react"; +import React, {useContext, useEffect, useState} from "react"; import {useDropzone} from "react-dropzone"; import {deleteFile, ignoreDirectory, makeDirectory, movePaths, renamePath, unignoreDirectory} from "../api"; import _ from 'lodash'; @@ -30,7 +30,7 @@ import {SortableTable} from "./SortableTable"; import {useBrowseFiles, useMediaDirectory, useUploadFile, useWROLMode} from "../hooks/customHooks"; import {FileRowTagIcon, FilesRefreshButton} from "./Files"; import {FilePreviewContext} from "./FilePreview"; -import {SettingsContext, ThemeContext} from "../contexts/contexts"; +import {DragSelectionContext, DragSelectionProvider, SettingsContext, ThemeContext} from "../contexts/contexts"; import {BulkTagModal} from "./BulkTagModal"; import {InlineErrorBoundary} from "./ErrorBoundary"; @@ -67,14 +67,27 @@ export function splitPathParentAndName(path) { } function Folder({folder, onFolderClick, sortData, selectedPaths, onFileClick, onSelect, disabled}) { + const {handleDragStart, handleDragMove, getDragState} = useContext(DragSelectionContext); // Creates a single table row for a folder, or a row for itself and indented rows for its children. let {path, children, is_empty} = folder; const ignored = useIsIgnoredDirectory(path); const pathWithNoTrailingSlash = path.substring(0, path.length - 1); const name = path.substring(pathWithNoTrailingSlash.lastIndexOf('/') + 1); - const f = + const isInSelectedPaths = selectedPaths.indexOf(folder['path']) >= 0; + const {isDragSelecting, isDragDeselecting} = getDragState(path, isInSelectedPaths); + // Preview selection: show checked if selecting, unchecked if deselecting + const showAsSelected = (isInSelectedPaths && !isDragDeselecting) || isDragSelecting; + // Visual highlight class + const dragClass = isDragSelecting ? 'drag-selecting' : (isDragDeselecting ? 'drag-deselecting' : ''); + const f = handleDragStart(path, e)} + onMouseEnter={() => handleDragMove(path)} + > - = 0} onChange={() => onSelect(folder['path'])}/> + onSelect(folder['path'])}/> onFolderClick(path)} className='file-path' colSpan={2} disabled={is_empty}> {depthIndentation(pathWithNoTrailingSlash)} @@ -105,10 +118,23 @@ function Folder({folder, onFolderClick, sortData, selectedPaths, onFileClick, on } function File({file, onFileClick, selectedPaths, onSelect, disabled}) { + const {handleDragStart, handleDragMove, getDragState} = useContext(DragSelectionContext); const {path, size} = file; - return + const isInSelectedPaths = selectedPaths.indexOf(path) >= 0; + const {isDragSelecting, isDragDeselecting} = getDragState(path, isInSelectedPaths); + // Preview selection: show checked if selecting, unchecked if deselecting + const showAsSelected = (isInSelectedPaths && !isDragDeselecting) || isDragSelecting; + // Visual highlight class + const dragClass = isDragSelecting ? 'drag-selecting' : (isDragDeselecting ? 'drag-deselecting' : ''); + return handleDragStart(path, e)} + onMouseEnter={() => handleDragMove(path)} + > - = 0} onChange={() => onSelect(path)}/> + onSelect(path)}/> onFileClick(file)} className='file-path'> {depthIndentation(path)} @@ -398,28 +424,52 @@ export function FileBrowser() { return Could not fetch files } - return <> -
+ return + + {footer} + +} + +function FileBrowserContent({browseFiles, headers, onFolderClick, setPreviewFile, selectedPaths, onSelect, pending}) { + const {isDragging} = useContext(DragSelectionContext); + const containerClassName = isDragging ? 'file-browser-dragging' : ''; + + return ( +
setPreviewFile(i)} - sortData={sortData} - selectedPaths={selectedPaths} - onSelect={onSelect} - disabled={pending} - />} + rowFunc={(i, sortData) => ( + setPreviewFile(file)} + sortData={sortData} + selectedPaths={selectedPaths} + onSelect={onSelect} + disabled={pending} + /> + )} />
- {footer} - + ); } export function RenameModal({open, onClose, path, onSubmit, onPending}) { diff --git a/app/src/components/FileBrowser.test.js b/app/src/components/FileBrowser.test.js index 53541e7e..4b517cec 100644 --- a/app/src/components/FileBrowser.test.js +++ b/app/src/components/FileBrowser.test.js @@ -474,4 +474,229 @@ describe('FileBrowser', () => { expect(mockSetOpenFolders).not.toHaveBeenCalled(); }); }); + + describe('Drag Selection', () => { + const mockBrowseFilesMultiple = [ + {path: 'file1.txt', size: 100}, + {path: 'file2.txt', size: 200}, + {path: 'file3.txt', size: 300}, + ]; + + beforeEach(() => { + useBrowseFiles.mockReturnValue({ + browseFiles: mockBrowseFilesMultiple, + openFolders: [], + setOpenFolders: jest.fn(), + fetchFiles: jest.fn(), + }); + }); + + it('does not enable drag selection on touch devices', async () => { + // Mock matchMedia to simulate touch device + window.matchMedia = jest.fn().mockImplementation(query => ({ + matches: query === '(pointer: coarse)', // true for touch devices + media: query, + onchange: null, + addListener: jest.fn(), + removeListener: jest.fn(), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })); + + renderFileBrowser(); + + const rows = screen.getAllByRole('row'); + // Skip header row (index 0) + const firstRow = rows[1]; + + // Simulate mousedown - on touch device, this should not start drag + await act(async () => { + fireEvent.mouseDown(firstRow, {button: 0, clientX: 100, clientY: 100}); + }); + + // Move mouse and mouseup - items should NOT be selected via drag + await act(async () => { + fireEvent.mouseMove(document, {clientX: 100, clientY: 200}); + fireEvent.mouseUp(document); + }); + + // Verify no items are selected (checkboxes unchecked) + const checkboxes = screen.getAllByRole('checkbox'); + checkboxes.forEach(cb => expect(cb).not.toBeChecked()); + }); + + it('selects rows when dragging across them', async () => { + renderFileBrowser(); + + const rows = screen.getAllByRole('row'); + // Skip header row + const firstDataRow = rows[1]; + const secondDataRow = rows[2]; + const thirdDataRow = rows[3]; + + // Start drag on first row + await act(async () => { + fireEvent.mouseDown(firstDataRow, {button: 0, clientX: 100, clientY: 100}); + }); + + // Move mouse enough to trigger drag threshold (5px) + await act(async () => { + fireEvent.mouseMove(document, {clientX: 100, clientY: 110}); + }); + + // Enter subsequent rows + await act(async () => { + fireEvent.mouseEnter(secondDataRow); + }); + + await act(async () => { + fireEvent.mouseEnter(thirdDataRow); + }); + + // End drag + await act(async () => { + fireEvent.mouseUp(document); + }); + + // All three files should be selected + const checkboxes = screen.getAllByRole('checkbox'); + expect(checkboxes.length).toBe(3); + checkboxes.forEach(cb => expect(cb).toBeChecked()); + }); + + it('adds to existing selection when dragging', async () => { + renderFileBrowser(); + + // First, select the first file via checkbox + const checkboxes = screen.getAllByRole('checkbox'); + await act(async () => { + fireEvent.click(checkboxes[0]); + }); + + // Verify first file is selected + expect(checkboxes[0]).toBeChecked(); + expect(checkboxes[1]).not.toBeChecked(); + expect(checkboxes[2]).not.toBeChecked(); + + const rows = screen.getAllByRole('row'); + const secondDataRow = rows[2]; + const thirdDataRow = rows[3]; + + // Start drag on second row + await act(async () => { + fireEvent.mouseDown(secondDataRow, {button: 0, clientX: 100, clientY: 100}); + }); + + // Move enough to trigger drag + await act(async () => { + fireEvent.mouseMove(document, {clientX: 100, clientY: 110}); + }); + + await act(async () => { + fireEvent.mouseEnter(thirdDataRow); + }); + + await act(async () => { + fireEvent.mouseUp(document); + }); + + // All three should now be selected (first via checkbox, second and third via drag) + const updatedCheckboxes = screen.getAllByRole('checkbox'); + updatedCheckboxes.forEach(cb => expect(cb).toBeChecked()); + }); + + it('does not start drag when clicking on checkbox', async () => { + renderFileBrowser(); + + const checkboxes = screen.getAllByRole('checkbox'); + + // Click checkbox - should toggle selection, not start drag + await act(async () => { + fireEvent.click(checkboxes[0]); + }); + + expect(checkboxes[0]).toBeChecked(); + expect(checkboxes[1]).not.toBeChecked(); + expect(checkboxes[2]).not.toBeChecked(); + }); + + it('does not start drag with right mouse button', async () => { + renderFileBrowser(); + + const rows = screen.getAllByRole('row'); + const firstDataRow = rows[1]; + const secondDataRow = rows[2]; + + // Try to start drag with right button + await act(async () => { + fireEvent.mouseDown(firstDataRow, {button: 2, clientX: 100, clientY: 100}); + }); + + await act(async () => { + fireEvent.mouseMove(document, {clientX: 100, clientY: 110}); + }); + + await act(async () => { + fireEvent.mouseEnter(secondDataRow); + }); + + await act(async () => { + fireEvent.mouseUp(document); + }); + + // No items should be selected + const checkboxes = screen.getAllByRole('checkbox'); + checkboxes.forEach(cb => expect(cb).not.toBeChecked()); + }); + + it('deselects rows when dragging from a selected item', async () => { + renderFileBrowser(); + + // First, select all three files via checkboxes + const checkboxes = screen.getAllByRole('checkbox'); + await act(async () => { + fireEvent.click(checkboxes[0]); + }); + await act(async () => { + fireEvent.click(checkboxes[1]); + }); + await act(async () => { + fireEvent.click(checkboxes[2]); + }); + + // Verify all are selected + expect(checkboxes[0]).toBeChecked(); + expect(checkboxes[1]).toBeChecked(); + expect(checkboxes[2]).toBeChecked(); + + const rows = screen.getAllByRole('row'); + const firstDataRow = rows[1]; + const secondDataRow = rows[2]; + + // Start drag from first row (which is selected) - should trigger deselect mode + await act(async () => { + fireEvent.mouseDown(firstDataRow, {button: 0, clientX: 100, clientY: 100}); + }); + + // Move enough to trigger drag + await act(async () => { + fireEvent.mouseMove(document, {clientX: 100, clientY: 110}); + }); + + await act(async () => { + fireEvent.mouseEnter(secondDataRow); + }); + + await act(async () => { + fireEvent.mouseUp(document); + }); + + // First two should now be deselected, third should remain selected + const updatedCheckboxes = screen.getAllByRole('checkbox'); + expect(updatedCheckboxes[0]).not.toBeChecked(); + expect(updatedCheckboxes[1]).not.toBeChecked(); + expect(updatedCheckboxes[2]).toBeChecked(); + }); + }); }); diff --git a/app/src/contexts/contexts.js b/app/src/contexts/contexts.js index ab350975..db258d60 100644 --- a/app/src/contexts/contexts.js +++ b/app/src/contexts/contexts.js @@ -1,6 +1,27 @@ -import React from "react"; +import React, {useCallback, useEffect, useRef, useState} from "react"; import {createMedia} from "@artsy/fresnel"; +// Touch device detection hook (defined here to avoid circular import with customHooks) +export const useIsTouchDevice = () => { + const [isTouchDevice, setIsTouchDevice] = useState(false); + + useEffect(() => { + // Check if matchMedia is available (not in jsdom test environment) + if (typeof window.matchMedia !== 'function') return; + + const touchQuery = window.matchMedia('(pointer: coarse)'); + // Guard against matchMedia returning null/undefined + if (!touchQuery) return; + + setIsTouchDevice(touchQuery.matches); + const handleChange = (e) => setIsTouchDevice(e.matches); + touchQuery.addEventListener('change', handleChange); + return () => touchQuery.removeEventListener('change', handleChange); + }, []); + + return isTouchDevice; +}; + /** @type {React.Context} */ export const ThemeContext = React.createContext({ theme: 'light', @@ -41,3 +62,167 @@ export const AppMedia = createMedia({ }); export const mediaStyles = AppMedia.createMediaStyle(); export const {Media, MediaContextProvider} = AppMedia; + +// Flatten browseFiles tree into ordered list of paths for drag selection +// Must match the sort order used by SortableTable (default: path alphabetically) +function flattenBrowseFiles(files, openFolders) { + const result = []; + const traverse = (items) => { + if (!items) return; + const itemList = Array.isArray(items) ? items : Object.values(items); + // Sort alphabetically by path (lowercase) to match SortableTable default sort + const sortedItems = [...itemList].sort((a, b) => + a.path.toLowerCase().localeCompare(b.path.toLowerCase()) + ); + sortedItems.forEach(item => { + result.push(item.path); + if (item.children && openFolders && openFolders.includes(item.path)) { + traverse(item.children); + } + }); + }; + traverse(files); + return result; +} + +// Drag Selection Context for FileBrowser +export const DragSelectionContext = React.createContext({ + isDragging: false, + handleDragStart: () => {}, + handleDragMove: () => {}, + getDragState: () => ({isDragSelecting: false, isDragDeselecting: false}), +}); + +export function DragSelectionProvider({children, selectedPaths, setSelectedPaths, browseFiles, openFolders}) { + const DRAG_THRESHOLD = 5; // Pixels to move before considering it a drag + + // Drag selection state + const isTouchDevice = useIsTouchDevice(); + const [isDragging, setIsDragging] = useState(false); + const [dragStartPath, setDragStartPath] = useState(null); + const [dragSelection, setDragSelection] = useState(new Set()); + const [dragMode, setDragMode] = useState('select'); // 'select' or 'deselect' + const allRowPathsRef = useRef([]); + const dragStartPosRef = useRef({x: 0, y: 0}); + + // Update ordered list of visible row paths when files change + useEffect(() => { + if (browseFiles) { + allRowPathsRef.current = flattenBrowseFiles(browseFiles, openFolders); + } + }, [browseFiles, openFolders]); + + // Drag selection handlers + const handleDragStart = useCallback((path, event) => { + // Only start drag on left mouse button, and not on touch devices + if (isTouchDevice || event.button !== 0) return; + + // Don't start drag if clicking on checkbox or inside it + if (event.target.closest('input[type="checkbox"]') || event.target.closest('.ui.checkbox')) return; + + // Determine mode: if starting from selected item, we're deselecting; otherwise selecting + const mode = selectedPaths.includes(path) ? 'deselect' : 'select'; + + dragStartPosRef.current = {x: event.clientX, y: event.clientY}; + setDragStartPath(path); + setDragSelection(new Set([path])); + setDragMode(mode); + }, [isTouchDevice, selectedPaths]); + + const handleDragMove = useCallback((path) => { + if (!dragStartPath) return; + + // Check if we've moved enough to consider it a drag + if (!isDragging) return; + + // Calculate all paths between dragStartPath and current path + const allPaths = allRowPathsRef.current; + const startIndex = allPaths.indexOf(dragStartPath); + const currentIndex = allPaths.indexOf(path); + + if (startIndex === -1 || currentIndex === -1) return; + + const minIndex = Math.min(startIndex, currentIndex); + const maxIndex = Math.max(startIndex, currentIndex); + + // Update drag selection to include all paths in range + setDragSelection(new Set(allPaths.slice(minIndex, maxIndex + 1))); + }, [isDragging, dragStartPath]); + + const handleDragEnd = useCallback(() => { + if (!isDragging && !dragStartPath) return; + + const draggedPaths = Array.from(dragSelection); + if (draggedPaths.length > 0 && isDragging) { + let newSelectedPaths; + if (dragMode === 'deselect') { + // Remove dragged paths from selection + newSelectedPaths = selectedPaths.filter(p => !dragSelection.has(p)); + } else { + // Add dragged paths to selection (use Set to avoid duplicates) + newSelectedPaths = [...new Set([...selectedPaths, ...draggedPaths])]; + } + setSelectedPaths(newSelectedPaths); + } + + // Reset drag state + setIsDragging(false); + setDragStartPath(null); + setDragSelection(new Set()); + setDragMode('select'); + }, [isDragging, dragStartPath, selectedPaths, dragSelection, dragMode, setSelectedPaths]); + + // Global mouse event listeners for drag selection + useEffect(() => { + if (!dragStartPath) return; + + const handleGlobalMouseMove = (e) => { + if (!isDragging && dragStartPath) { + // Check if we've moved enough to start dragging + const dx = e.clientX - dragStartPosRef.current.x; + const dy = e.clientY - dragStartPosRef.current.y; + const distance = Math.sqrt(dx * dx + dy * dy); + + if (distance > DRAG_THRESHOLD) { + setIsDragging(true); + } + } + }; + + const handleGlobalMouseUp = () => handleDragEnd(); + const handleSelectStart = (e) => { + if (isDragging) e.preventDefault(); + }; + + document.addEventListener('mousemove', handleGlobalMouseMove); + document.addEventListener('mouseup', handleGlobalMouseUp); + document.addEventListener('selectstart', handleSelectStart); + + return () => { + document.removeEventListener('mousemove', handleGlobalMouseMove); + document.removeEventListener('mouseup', handleGlobalMouseUp); + document.removeEventListener('selectstart', handleSelectStart); + }; + }, [dragStartPath, isDragging, handleDragEnd, DRAG_THRESHOLD]); + + // Helper function for components to compute their visual drag state + const getDragState = useCallback((path, isSelected) => { + const inDragRange = dragSelection.has(path); + const isDragSelecting = inDragRange && dragMode === 'select'; + const isDragDeselecting = inDragRange && dragMode === 'deselect' && isSelected; + return {isDragSelecting, isDragDeselecting}; + }, [dragSelection, dragMode]); + + const value = { + isDragging, + handleDragStart, + handleDragMove, + getDragState, + }; + + return ( + + {children} + + ); +} diff --git a/app/src/hooks/customHooks.js b/app/src/hooks/customHooks.js index 9cacb7bb..c7f60985 100644 --- a/app/src/hooks/customHooks.js +++ b/app/src/hooks/customHooks.js @@ -49,6 +49,24 @@ import _ from "lodash"; import {TagsSelector} from "../Tags"; import {useForm} from "./useForm"; +export const useIsTouchDevice = () => { + // Detects if the device has a coarse pointer (touch screen). + // Used to disable drag selection on mobile devices. + const [isTouchDevice, setIsTouchDevice] = useState(false); + + useEffect(() => { + const touchQuery = window.matchMedia('(pointer: coarse)'); + setIsTouchDevice(touchQuery.matches); + + const handleChange = (e) => setIsTouchDevice(e.matches); + touchQuery.addEventListener('change', handleChange); + + return () => touchQuery.removeEventListener('change', handleChange); + }, []); + + return isTouchDevice; +}; + const calculatePage = (offset, limit) => { return offset && limit ? Math.round((offset / limit) + 1) : 1; }