Enhancing file browser with drag-to-select.

This commit is contained in:
Roland 2025-12-26 11:28:10 -07:00
parent 150ad6ee78
commit bdaccdeca1
No known key found for this signature in database
GPG key ID: 1BA620CE6C9A4680
5 changed files with 525 additions and 21 deletions

View file

@ -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;
}

View file

@ -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 = <TableRow key={path} disabled={disabled}>
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 = <TableRow
key={path}
disabled={disabled}
className={dragClass}
onMouseDown={(e) => handleDragStart(path, e)}
onMouseEnter={() => handleDragMove(path)}
>
<TableCell collapsing>
<Checkbox checked={selectedPaths.indexOf(folder['path']) >= 0} onChange={() => onSelect(folder['path'])}/>
<Checkbox checked={showAsSelected} onChange={() => onSelect(folder['path'])}/>
</TableCell>
<TableCell onClick={() => 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 <TableRow key={path} disabled={disabled}>
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 <TableRow
key={path}
disabled={disabled}
className={dragClass}
onMouseDown={(e) => handleDragStart(path, e)}
onMouseEnter={() => handleDragMove(path)}
>
<TableCell collapsing>
<Checkbox checked={selectedPaths.indexOf(path) >= 0} onChange={() => onSelect(path)}/>
<Checkbox checked={showAsSelected} onChange={() => onSelect(path)}/>
</TableCell>
<TableCell onClick={() => onFileClick(file)} className='file-path'>
{depthIndentation(path)}
@ -398,28 +424,52 @@ export function FileBrowser() {
return <ErrorMessage>Could not fetch files</ErrorMessage>
}
return <>
<div style={{marginBottom: '4em'}}>
return <DragSelectionProvider
selectedPaths={selectedPaths}
setSelectedPaths={setSelectedPaths}
browseFiles={browseFiles}
openFolders={openFolders}
>
<FileBrowserContent
browseFiles={browseFiles}
headers={headers}
onFolderClick={onFolderClick}
setPreviewFile={setPreviewFile}
selectedPaths={selectedPaths}
onSelect={onSelect}
pending={pending}
/>
{footer}
</DragSelectionProvider>
}
function FileBrowserContent({browseFiles, headers, onFolderClick, setPreviewFile, selectedPaths, onSelect, pending}) {
const {isDragging} = useContext(DragSelectionContext);
const containerClassName = isDragging ? 'file-browser-dragging' : '';
return (
<div style={{marginBottom: '4em'}} className={containerClassName}>
<SortableTable
tableProps={{unstackable: true}}
data={browseFiles}
tableHeaders={headers}
defaultSortColumn='path'
rowKey='path'
rowFunc={(i, sortData) => <Path
key={i['key']}
path={i}
onFolderClick={onFolderClick}
onFileClick={(i) => setPreviewFile(i)}
sortData={sortData}
selectedPaths={selectedPaths}
onSelect={onSelect}
disabled={pending}
/>}
rowFunc={(i, sortData) => (
<Path
key={i['key']}
path={i}
onFolderClick={onFolderClick}
onFileClick={(file) => setPreviewFile(file)}
sortData={sortData}
selectedPaths={selectedPaths}
onSelect={onSelect}
disabled={pending}
/>
)}
/>
</div>
{footer}
</>
);
}
export function RenameModal({open, onClose, path, onSubmit, onPending}) {

View file

@ -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(<FileBrowser/>);
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(<FileBrowser/>);
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(<FileBrowser/>);
// 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(<FileBrowser/>);
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(<FileBrowser/>);
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(<FileBrowser/>);
// 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();
});
});
});

View file

@ -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<import('../types/theme').ThemeContextValue>} */
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 (
<DragSelectionContext.Provider value={value}>
{children}
</DragSelectionContext.Provider>
);
}

View file

@ -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;
}