Multi-selection: Shift+Click range, Ctrl/Cmd+Click toggle, rubber-band drag, mobile long-press
This commit is contained in:
@@ -2,9 +2,11 @@
|
|||||||
* FileExplorer - middle column file browser with multiple view modes.
|
* FileExplorer - middle column file browser with multiple view modes.
|
||||||
* Replaces FileGrid. Supports: list, table, icons-sm, icons-md, icons-lg.
|
* Replaces FileGrid. Supports: list, table, icons-sm, icons-md, icons-lg.
|
||||||
* Files are draggable for drag-and-drop into folders (SourceTree).
|
* Files are draggable for drag-and-drop into folders (SourceTree).
|
||||||
|
* Multi-selection: Shift+Click (range), Ctrl/Cmd+Click (toggle),
|
||||||
|
* rubber-band drag, mobile long-press.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useMemo, useState } from 'react';
|
import React, { useMemo, useState, useRef, useCallback } from 'react';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import type { DmsFile } from '@/api/dms';
|
import type { DmsFile } from '@/api/dms';
|
||||||
@@ -73,6 +75,7 @@ export interface FileExplorerProps {
|
|||||||
sortOrder: SortOrder;
|
sortOrder: SortOrder;
|
||||||
onSortChange: (by: string, order: string) => void;
|
onSortChange: (by: string, order: string) => void;
|
||||||
onFileDragStart?: (fileIds: string[]) => void;
|
onFileDragStart?: (fileIds: string[]) => void;
|
||||||
|
onRangeSelect?: (fileIds: string[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SortHeader({
|
function SortHeader({
|
||||||
@@ -177,10 +180,25 @@ export function FileExplorer({
|
|||||||
sortOrder,
|
sortOrder,
|
||||||
onSortChange,
|
onSortChange,
|
||||||
onFileDragStart,
|
onFileDragStart,
|
||||||
|
onRangeSelect,
|
||||||
}: FileExplorerProps) {
|
}: FileExplorerProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [draggingId, setDraggingId] = useState<string | null>(null);
|
const [draggingId, setDraggingId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Multi-selection state
|
||||||
|
const lastClickedIndex = useRef<number | null>(null);
|
||||||
|
|
||||||
|
// Rubber-band state
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const rubberBandStartRef = useRef<{ x: number; y: number } | null>(null);
|
||||||
|
const [rubberBand, setRubberBand] = useState<{ startX: number; startY: number; endX: number; endY: number } | null>(null);
|
||||||
|
const [rubberBandSelected, setRubberBandSelected] = useState<Set<string>>(new Set());
|
||||||
|
const fileElementRefs = useRef<Map<string, HTMLElement>>(new Map());
|
||||||
|
|
||||||
|
// Long-press state
|
||||||
|
const longPressTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const longPressTriggered = useRef(false);
|
||||||
|
|
||||||
const sortedFiles = useMemo(() => {
|
const sortedFiles = useMemo(() => {
|
||||||
const sorted = [...files];
|
const sorted = [...files];
|
||||||
sorted.sort((a, b) => {
|
sorted.sort((a, b) => {
|
||||||
@@ -217,6 +235,156 @@ export function FileExplorer({
|
|||||||
setDraggingId(null);
|
setDraggingId(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Click handler with modifier detection ───
|
||||||
|
const handleRowClick = useCallback((e: React.MouseEvent, file: DmsFile, index: number) => {
|
||||||
|
if (longPressTriggered.current) {
|
||||||
|
longPressTriggered.current = false;
|
||||||
|
e.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.shiftKey && lastClickedIndex.current !== null && onRangeSelect) {
|
||||||
|
const start = Math.min(lastClickedIndex.current, index);
|
||||||
|
const end = Math.max(lastClickedIndex.current, index);
|
||||||
|
const rangeIds = sortedFiles.slice(start, end + 1).map(f => f.id);
|
||||||
|
onRangeSelect(rangeIds);
|
||||||
|
} else if (e.ctrlKey || e.metaKey) {
|
||||||
|
onToggleSelect(file.id);
|
||||||
|
lastClickedIndex.current = index;
|
||||||
|
} else {
|
||||||
|
onFileClick(file);
|
||||||
|
lastClickedIndex.current = index;
|
||||||
|
}
|
||||||
|
}, [sortedFiles, onRangeSelect, onToggleSelect, onFileClick]);
|
||||||
|
|
||||||
|
// ─── Long-press handlers for touch / mobile ───
|
||||||
|
const handleTouchStart = useCallback((file: DmsFile) => {
|
||||||
|
longPressTriggered.current = false;
|
||||||
|
longPressTimer.current = setTimeout(() => {
|
||||||
|
longPressTriggered.current = true;
|
||||||
|
onToggleSelect(file.id);
|
||||||
|
if (navigator.vibrate) navigator.vibrate(50);
|
||||||
|
}, 500);
|
||||||
|
}, [onToggleSelect]);
|
||||||
|
|
||||||
|
const handleTouchEnd = useCallback(() => {
|
||||||
|
if (longPressTimer.current) {
|
||||||
|
clearTimeout(longPressTimer.current);
|
||||||
|
longPressTimer.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleTouchMove = useCallback(() => {
|
||||||
|
if (longPressTimer.current) {
|
||||||
|
clearTimeout(longPressTimer.current);
|
||||||
|
longPressTimer.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ─── Rubber-band handlers ───
|
||||||
|
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (target.closest('[data-file-id]')) return;
|
||||||
|
if (target.closest('button') || target.closest('input') || target.tagName === 'TH') return;
|
||||||
|
if (e.button !== 0) return;
|
||||||
|
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const rect = container.getBoundingClientRect();
|
||||||
|
const x = e.clientX - rect.left + container.scrollLeft;
|
||||||
|
const y = e.clientY - rect.top + container.scrollTop;
|
||||||
|
|
||||||
|
rubberBandStartRef.current = { x, y };
|
||||||
|
setRubberBand({ startX: x, startY: y, endX: x, endY: y });
|
||||||
|
setRubberBandSelected(new Set());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleMouseMove = useCallback((e: React.MouseEvent) => {
|
||||||
|
if (!rubberBandStartRef.current) return;
|
||||||
|
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const rect = container.getBoundingClientRect();
|
||||||
|
const x = e.clientX - rect.left + container.scrollLeft;
|
||||||
|
const y = e.clientY - rect.top + container.scrollTop;
|
||||||
|
|
||||||
|
const rb = {
|
||||||
|
startX: rubberBandStartRef.current.x,
|
||||||
|
startY: rubberBandStartRef.current.y,
|
||||||
|
endX: x,
|
||||||
|
endY: y,
|
||||||
|
};
|
||||||
|
setRubberBand(rb);
|
||||||
|
|
||||||
|
const rbLeft = Math.min(rb.startX, rb.endX);
|
||||||
|
const rbTop = Math.min(rb.startY, rb.endY);
|
||||||
|
const rbRight = Math.max(rb.startX, rb.endX);
|
||||||
|
const rbBottom = Math.max(rb.startY, rb.endY);
|
||||||
|
|
||||||
|
const newSelected = new Set<string>();
|
||||||
|
fileElementRefs.current.forEach((el, fileId) => {
|
||||||
|
const elRect = el.getBoundingClientRect();
|
||||||
|
const containerRect = container.getBoundingClientRect();
|
||||||
|
const elLeft = elRect.left - containerRect.left + container.scrollLeft;
|
||||||
|
const elTop = elRect.top - containerRect.top + container.scrollTop;
|
||||||
|
const elRight = elLeft + elRect.width;
|
||||||
|
const elBottom = elTop + elRect.height;
|
||||||
|
|
||||||
|
if (elLeft < rbRight && elRight > rbLeft && elTop < rbBottom && elBottom > rbTop) {
|
||||||
|
newSelected.add(fileId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setRubberBandSelected(newSelected);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleMouseUp = useCallback(() => {
|
||||||
|
if (!rubberBandStartRef.current) return;
|
||||||
|
|
||||||
|
const hasDragged = rubberBand !== null &&
|
||||||
|
(Math.abs(rubberBand.endX - rubberBand.startX) > 3 ||
|
||||||
|
Math.abs(rubberBand.endY - rubberBand.startY) > 3);
|
||||||
|
|
||||||
|
if (hasDragged && rubberBandSelected.size > 0) {
|
||||||
|
onRangeSelect?.(Array.from(rubberBandSelected));
|
||||||
|
} else if (!hasDragged) {
|
||||||
|
onRangeSelect?.([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
rubberBandStartRef.current = null;
|
||||||
|
setRubberBand(null);
|
||||||
|
setRubberBandSelected(new Set());
|
||||||
|
}, [rubberBand, rubberBandSelected, onRangeSelect]);
|
||||||
|
|
||||||
|
const handleMouseLeave = useCallback(() => {
|
||||||
|
if (rubberBandStartRef.current) {
|
||||||
|
rubberBandStartRef.current = null;
|
||||||
|
setRubberBand(null);
|
||||||
|
setRubberBandSelected(new Set());
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Helper: check if file is selected (considering rubber-band)
|
||||||
|
const isFileSelected = (fileId: string) => {
|
||||||
|
if (rubberBand !== null) {
|
||||||
|
return rubberBandSelected.has(fileId);
|
||||||
|
}
|
||||||
|
return selectedFileIds.has(fileId);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Rubber-band rectangle element
|
||||||
|
const rubberBandRect = rubberBand && (
|
||||||
|
<div
|
||||||
|
className="absolute border border-primary-400 bg-primary-100/30 pointer-events-none z-50 rounded-sm"
|
||||||
|
style={{
|
||||||
|
left: Math.min(rubberBand.startX, rubberBand.endX),
|
||||||
|
top: Math.min(rubberBand.startY, rubberBand.endY),
|
||||||
|
width: Math.abs(rubberBand.endX - rubberBand.startX),
|
||||||
|
height: Math.abs(rubberBand.endY - rubberBand.startY),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-12" data-testid="file-explorer-loading">
|
<div className="flex items-center justify-center py-12" data-testid="file-explorer-loading">
|
||||||
@@ -243,7 +411,15 @@ export function FileExplorer({
|
|||||||
// ─── List View ───
|
// ─── List View ───
|
||||||
if (viewMode === 'list') {
|
if (viewMode === 'list') {
|
||||||
return (
|
return (
|
||||||
<div className="overflow-y-auto h-full" data-testid="file-explorer-list">
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className={clsx('overflow-y-auto h-full relative', rubberBand && 'select-none')}
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
onMouseMove={handleMouseMove}
|
||||||
|
onMouseUp={handleMouseUp}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
|
data-testid="file-explorer-list"
|
||||||
|
>
|
||||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-secondary-100 sticky top-0 bg-white z-10">
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-secondary-100 sticky top-0 bg-white z-10">
|
||||||
<SortHeader label={t('dms.sortName')} field="name" sortBy={sortBy} sortOrder={sortOrder} onSortChange={onSortChange} />
|
<SortHeader label={t('dms.sortName')} field="name" sortBy={sortBy} sortOrder={sortOrder} onSortChange={onSortChange} />
|
||||||
<span className="text-secondary-300">|</span>
|
<span className="text-secondary-300">|</span>
|
||||||
@@ -252,21 +428,26 @@ export function FileExplorer({
|
|||||||
<SortHeader label={t('dms.sortDate')} field="date" sortBy={sortBy} sortOrder={sortOrder} onSortChange={onSortChange} />
|
<SortHeader label={t('dms.sortDate')} field="date" sortBy={sortBy} sortOrder={sortOrder} onSortChange={onSortChange} />
|
||||||
</div>
|
</div>
|
||||||
<ul className="divide-y divide-secondary-50">
|
<ul className="divide-y divide-secondary-50">
|
||||||
{sortedFiles.map((file) => {
|
{sortedFiles.map((file, index) => {
|
||||||
const icon = getFileIcon(file.mime_type);
|
const icon = getFileIcon(file.mime_type);
|
||||||
const isSelected = selectedFileIds.has(file.id);
|
const isSelected = isFileSelected(file.id);
|
||||||
const isActive = selectedFile?.id === file.id;
|
const isActive = selectedFile?.id === file.id;
|
||||||
const isDragging = draggingId === file.id;
|
const isDragging = draggingId === file.id;
|
||||||
return (
|
return (
|
||||||
<li key={file.id}>
|
<li key={file.id}>
|
||||||
<div
|
<div
|
||||||
|
ref={(el) => { if (el) fileElementRefs.current.set(file.id, el); else fileElementRefs.current.delete(file.id); }}
|
||||||
|
data-file-id={file.id}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'flex items-center gap-2 px-3 py-2 cursor-pointer motion-safe:transition-colors',
|
'flex items-center gap-2 px-3 py-2 cursor-pointer motion-safe:transition-colors',
|
||||||
isActive ? 'bg-primary-50' : 'hover:bg-secondary-50',
|
isActive ? 'bg-primary-50' : isSelected ? 'bg-primary-50' : 'hover:bg-secondary-50',
|
||||||
isDragging && 'opacity-50',
|
isDragging && 'opacity-50',
|
||||||
)}
|
)}
|
||||||
onClick={() => onFileClick(file)}
|
onClick={(e) => handleRowClick(e, file, index)}
|
||||||
onDoubleClick={() => onFileDoubleClick(file)}
|
onDoubleClick={() => onFileDoubleClick(file)}
|
||||||
|
onTouchStart={() => handleTouchStart(file)}
|
||||||
|
onTouchEnd={handleTouchEnd}
|
||||||
|
onTouchMove={handleTouchMove}
|
||||||
draggable
|
draggable
|
||||||
onDragStart={(e) => handleDragStart(e, file)}
|
onDragStart={(e) => handleDragStart(e, file)}
|
||||||
onDragEnd={handleDragEnd}
|
onDragEnd={handleDragEnd}
|
||||||
@@ -292,6 +473,7 @@ export function FileExplorer({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
|
{rubberBandRect}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -299,7 +481,15 @@ export function FileExplorer({
|
|||||||
// ─── Table View ───
|
// ─── Table View ───
|
||||||
if (viewMode === 'table') {
|
if (viewMode === 'table') {
|
||||||
return (
|
return (
|
||||||
<div className="overflow-auto h-full" data-testid="file-explorer-table">
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className={clsx('overflow-auto h-full relative', rubberBand && 'select-none')}
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
onMouseMove={handleMouseMove}
|
||||||
|
onMouseUp={handleMouseUp}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
|
data-testid="file-explorer-table"
|
||||||
|
>
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="sticky top-0 bg-white border-b border-secondary-200 z-10">
|
<thead className="sticky top-0 bg-white border-b border-secondary-200 z-10">
|
||||||
<tr>
|
<tr>
|
||||||
@@ -327,21 +517,26 @@ export function FileExplorer({
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-secondary-50">
|
<tbody className="divide-y divide-secondary-50">
|
||||||
{sortedFiles.map((file) => {
|
{sortedFiles.map((file, index) => {
|
||||||
const icon = getFileIcon(file.mime_type);
|
const icon = getFileIcon(file.mime_type);
|
||||||
const isSelected = selectedFileIds.has(file.id);
|
const isSelected = isFileSelected(file.id);
|
||||||
const isActive = selectedFile?.id === file.id;
|
const isActive = selectedFile?.id === file.id;
|
||||||
const isDragging = draggingId === file.id;
|
const isDragging = draggingId === file.id;
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={file.id}
|
key={file.id}
|
||||||
|
ref={(el) => { if (el) fileElementRefs.current.set(file.id, el); else fileElementRefs.current.delete(file.id); }}
|
||||||
|
data-file-id={file.id}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'cursor-pointer motion-safe:transition-colors',
|
'cursor-pointer motion-safe:transition-colors',
|
||||||
isActive ? 'bg-primary-50' : 'hover:bg-secondary-50',
|
isActive ? 'bg-primary-50' : isSelected ? 'bg-primary-50' : 'hover:bg-secondary-50',
|
||||||
isDragging && 'opacity-50',
|
isDragging && 'opacity-50',
|
||||||
)}
|
)}
|
||||||
onClick={() => onFileClick(file)}
|
onClick={(e) => handleRowClick(e, file, index)}
|
||||||
onDoubleClick={() => onFileDoubleClick(file)}
|
onDoubleClick={() => onFileDoubleClick(file)}
|
||||||
|
onTouchStart={() => handleTouchStart(file)}
|
||||||
|
onTouchEnd={handleTouchEnd}
|
||||||
|
onTouchMove={handleTouchMove}
|
||||||
draggable
|
draggable
|
||||||
onDragStart={(e) => handleDragStart(e, file)}
|
onDragStart={(e) => handleDragStart(e, file)}
|
||||||
onDragEnd={handleDragEnd}
|
onDragEnd={handleDragEnd}
|
||||||
@@ -375,25 +570,34 @@ export function FileExplorer({
|
|||||||
})}
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
{rubberBandRect}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Icon Views ───
|
// ─── Icon Views ───
|
||||||
// auto-fill grid with minmax to prevent overflow
|
|
||||||
const gridMinWidth =
|
const gridMinWidth =
|
||||||
viewMode === 'icons-sm' ? '80px'
|
viewMode === 'icons-sm' ? '80px'
|
||||||
: viewMode === 'icons-md' ? '120px'
|
: viewMode === 'icons-md' ? '120px'
|
||||||
: '180px';
|
: '180px';
|
||||||
|
|
||||||
const gridClass = `grid gap-3 p-3 overflow-y-auto h-full items-start content-start`;
|
const gridClass = `grid gap-3 p-3 overflow-y-auto h-full items-start content-start relative`;
|
||||||
const gridStyle = { gridTemplateColumns: `repeat(auto-fill, minmax(${gridMinWidth}, 1fr))` };
|
const gridStyle = { gridTemplateColumns: `repeat(auto-fill, minmax(${gridMinWidth}, 1fr))` };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={gridClass} style={gridStyle} data-testid={`file-explorer-${viewMode}`}>
|
<div
|
||||||
{sortedFiles.map((file) => {
|
ref={containerRef}
|
||||||
|
className={clsx(gridClass, rubberBand && 'select-none')}
|
||||||
|
style={gridStyle}
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
onMouseMove={handleMouseMove}
|
||||||
|
onMouseUp={handleMouseUp}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
|
data-testid={`file-explorer-${viewMode}`}
|
||||||
|
>
|
||||||
|
{sortedFiles.map((file, index) => {
|
||||||
const icon = getFileIcon(file.mime_type);
|
const icon = getFileIcon(file.mime_type);
|
||||||
const isSelected = selectedFileIds.has(file.id);
|
const isSelected = isFileSelected(file.id);
|
||||||
const isActive = selectedFile?.id === file.id;
|
const isActive = selectedFile?.id === file.id;
|
||||||
const isDragging = draggingId === file.id;
|
const isDragging = draggingId === file.id;
|
||||||
|
|
||||||
@@ -402,13 +606,18 @@ export function FileExplorer({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={file.id}
|
key={file.id}
|
||||||
|
ref={(el) => { if (el) fileElementRefs.current.set(file.id, el); else fileElementRefs.current.delete(file.id); }}
|
||||||
|
data-file-id={file.id}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'rounded-lg p-1.5 cursor-pointer flex flex-col items-center text-center overflow-hidden',
|
'rounded-lg p-1.5 cursor-pointer flex flex-col items-center text-center overflow-hidden',
|
||||||
isActive ? 'bg-primary-50' : isSelected ? 'bg-primary-50' : 'hover:bg-secondary-50',
|
isActive ? 'bg-primary-50' : isSelected ? 'bg-primary-50 ring-1 ring-primary-300' : 'hover:bg-secondary-50',
|
||||||
isDragging && 'opacity-50',
|
isDragging && 'opacity-50',
|
||||||
)}
|
)}
|
||||||
onClick={() => onFileClick(file)}
|
onClick={(e) => handleRowClick(e, file, index)}
|
||||||
onDoubleClick={() => onFileDoubleClick(file)}
|
onDoubleClick={() => onFileDoubleClick(file)}
|
||||||
|
onTouchStart={() => handleTouchStart(file)}
|
||||||
|
onTouchEnd={handleTouchEnd}
|
||||||
|
onTouchMove={handleTouchMove}
|
||||||
draggable
|
draggable
|
||||||
onDragStart={(e) => handleDragStart(e, file)}
|
onDragStart={(e) => handleDragStart(e, file)}
|
||||||
onDragEnd={handleDragEnd}
|
onDragEnd={handleDragEnd}
|
||||||
@@ -427,6 +636,8 @@ export function FileExplorer({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={file.id}
|
key={file.id}
|
||||||
|
ref={(el) => { if (el) fileElementRefs.current.set(file.id, el); else fileElementRefs.current.delete(file.id); }}
|
||||||
|
data-file-id={file.id}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'bg-white rounded-lg border p-2 cursor-pointer flex flex-col items-center text-center overflow-hidden',
|
'bg-white rounded-lg border p-2 cursor-pointer flex flex-col items-center text-center overflow-hidden',
|
||||||
isActive
|
isActive
|
||||||
@@ -436,8 +647,11 @@ export function FileExplorer({
|
|||||||
: 'border-secondary-200 hover:border-secondary-300 hover:shadow-sm',
|
: 'border-secondary-200 hover:border-secondary-300 hover:shadow-sm',
|
||||||
isDragging && 'opacity-50',
|
isDragging && 'opacity-50',
|
||||||
)}
|
)}
|
||||||
onClick={() => onFileClick(file)}
|
onClick={(e) => handleRowClick(e, file, index)}
|
||||||
onDoubleClick={() => onFileDoubleClick(file)}
|
onDoubleClick={() => onFileDoubleClick(file)}
|
||||||
|
onTouchStart={() => handleTouchStart(file)}
|
||||||
|
onTouchEnd={handleTouchEnd}
|
||||||
|
onTouchMove={handleTouchMove}
|
||||||
draggable
|
draggable
|
||||||
onDragStart={(e) => handleDragStart(e, file)}
|
onDragStart={(e) => handleDragStart(e, file)}
|
||||||
onDragEnd={handleDragEnd}
|
onDragEnd={handleDragEnd}
|
||||||
@@ -471,6 +685,8 @@ export function FileExplorer({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={file.id}
|
key={file.id}
|
||||||
|
ref={(el) => { if (el) fileElementRefs.current.set(file.id, el); else fileElementRefs.current.delete(file.id); }}
|
||||||
|
data-file-id={file.id}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'bg-white rounded-lg border p-3 cursor-pointer flex flex-col items-center text-center overflow-hidden',
|
'bg-white rounded-lg border p-3 cursor-pointer flex flex-col items-center text-center overflow-hidden',
|
||||||
isActive
|
isActive
|
||||||
@@ -480,8 +696,11 @@ export function FileExplorer({
|
|||||||
: 'border-secondary-200 hover:border-secondary-300 hover:shadow-sm',
|
: 'border-secondary-200 hover:border-secondary-300 hover:shadow-sm',
|
||||||
isDragging && 'opacity-50',
|
isDragging && 'opacity-50',
|
||||||
)}
|
)}
|
||||||
onClick={() => onFileClick(file)}
|
onClick={(e) => handleRowClick(e, file, index)}
|
||||||
onDoubleClick={() => onFileDoubleClick(file)}
|
onDoubleClick={() => onFileDoubleClick(file)}
|
||||||
|
onTouchStart={() => handleTouchStart(file)}
|
||||||
|
onTouchEnd={handleTouchEnd}
|
||||||
|
onTouchMove={handleTouchMove}
|
||||||
draggable
|
draggable
|
||||||
onDragStart={(e) => handleDragStart(e, file)}
|
onDragStart={(e) => handleDragStart(e, file)}
|
||||||
onDragEnd={handleDragEnd}
|
onDragEnd={handleDragEnd}
|
||||||
@@ -523,6 +742,7 @@ export function FileExplorer({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{rubberBandRect}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user