mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 19:01:39 -04:00
Enhancing file browser with drag-to-select.
This commit is contained in:
parent
150ad6ee78
commit
bdaccdeca1
5 changed files with 525 additions and 21 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue