From cfb52d3c69ff701f6a2e0298cdd7afce5f8dfbc5 Mon Sep 17 00:00:00 2001 From: Leopoldadmin Date: Mon, 20 Jul 2026 22:55:09 +0000 Subject: [PATCH] Multi-selection: Shift+Click range, Ctrl/Cmd+Click toggle, rubber-band drag, mobile long-press --- frontend/src/components/dms/FileExplorer.tsx | 260 +++++++++++++++++-- 1 file changed, 240 insertions(+), 20 deletions(-) diff --git a/frontend/src/components/dms/FileExplorer.tsx b/frontend/src/components/dms/FileExplorer.tsx index 1d143aa..b663539 100644 --- a/frontend/src/components/dms/FileExplorer.tsx +++ b/frontend/src/components/dms/FileExplorer.tsx @@ -2,9 +2,11 @@ * FileExplorer - middle column file browser with multiple view modes. * Replaces FileGrid. Supports: list, table, icons-sm, icons-md, icons-lg. * 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 { useTranslation } from 'react-i18next'; import type { DmsFile } from '@/api/dms'; @@ -73,6 +75,7 @@ export interface FileExplorerProps { sortOrder: SortOrder; onSortChange: (by: string, order: string) => void; onFileDragStart?: (fileIds: string[]) => void; + onRangeSelect?: (fileIds: string[]) => void; } function SortHeader({ @@ -177,10 +180,25 @@ export function FileExplorer({ sortOrder, onSortChange, onFileDragStart, + onRangeSelect, }: FileExplorerProps) { const { t } = useTranslation(); const [draggingId, setDraggingId] = useState(null); + // Multi-selection state + const lastClickedIndex = useRef(null); + + // Rubber-band state + const containerRef = useRef(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>(new Set()); + const fileElementRefs = useRef>(new Map()); + + // Long-press state + const longPressTimer = useRef | null>(null); + const longPressTriggered = useRef(false); + const sortedFiles = useMemo(() => { const sorted = [...files]; sorted.sort((a, b) => { @@ -217,6 +235,156 @@ export function FileExplorer({ 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(); + 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 && ( +
+ ); + if (loading) { return (
@@ -243,7 +411,15 @@ export function FileExplorer({ // ─── List View ─── if (viewMode === 'list') { return ( -
+
| @@ -252,21 +428,26 @@ export function FileExplorer({
    - {sortedFiles.map((file) => { + {sortedFiles.map((file, index) => { const icon = getFileIcon(file.mime_type); - const isSelected = selectedFileIds.has(file.id); + const isSelected = isFileSelected(file.id); const isActive = selectedFile?.id === file.id; const isDragging = draggingId === file.id; return (
  • { if (el) fileElementRefs.current.set(file.id, el); else fileElementRefs.current.delete(file.id); }} + data-file-id={file.id} className={clsx( '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', )} - onClick={() => onFileClick(file)} + onClick={(e) => handleRowClick(e, file, index)} onDoubleClick={() => onFileDoubleClick(file)} + onTouchStart={() => handleTouchStart(file)} + onTouchEnd={handleTouchEnd} + onTouchMove={handleTouchMove} draggable onDragStart={(e) => handleDragStart(e, file)} onDragEnd={handleDragEnd} @@ -292,6 +473,7 @@ export function FileExplorer({ ); })}
+ {rubberBandRect}
); } @@ -299,7 +481,15 @@ export function FileExplorer({ // ─── Table View ─── if (viewMode === 'table') { return ( -
+
@@ -327,21 +517,26 @@ export function FileExplorer({ - {sortedFiles.map((file) => { + {sortedFiles.map((file, index) => { const icon = getFileIcon(file.mime_type); - const isSelected = selectedFileIds.has(file.id); + const isSelected = isFileSelected(file.id); const isActive = selectedFile?.id === file.id; const isDragging = draggingId === file.id; return ( { if (el) fileElementRefs.current.set(file.id, el); else fileElementRefs.current.delete(file.id); }} + data-file-id={file.id} className={clsx( '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', )} - onClick={() => onFileClick(file)} + onClick={(e) => handleRowClick(e, file, index)} onDoubleClick={() => onFileDoubleClick(file)} + onTouchStart={() => handleTouchStart(file)} + onTouchEnd={handleTouchEnd} + onTouchMove={handleTouchMove} draggable onDragStart={(e) => handleDragStart(e, file)} onDragEnd={handleDragEnd} @@ -375,25 +570,34 @@ export function FileExplorer({ })}
+ {rubberBandRect}
); } // ─── Icon Views ─── - // auto-fill grid with minmax to prevent overflow const gridMinWidth = viewMode === 'icons-sm' ? '80px' : viewMode === 'icons-md' ? '120px' : '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))` }; return ( -
- {sortedFiles.map((file) => { +
+ {sortedFiles.map((file, index) => { const icon = getFileIcon(file.mime_type); - const isSelected = selectedFileIds.has(file.id); + const isSelected = isFileSelected(file.id); const isActive = selectedFile?.id === file.id; const isDragging = draggingId === file.id; @@ -402,13 +606,18 @@ export function FileExplorer({ return (
{ if (el) fileElementRefs.current.set(file.id, el); else fileElementRefs.current.delete(file.id); }} + data-file-id={file.id} className={clsx( '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', )} - onClick={() => onFileClick(file)} + onClick={(e) => handleRowClick(e, file, index)} onDoubleClick={() => onFileDoubleClick(file)} + onTouchStart={() => handleTouchStart(file)} + onTouchEnd={handleTouchEnd} + onTouchMove={handleTouchMove} draggable onDragStart={(e) => handleDragStart(e, file)} onDragEnd={handleDragEnd} @@ -427,6 +636,8 @@ export function FileExplorer({ return (
{ if (el) fileElementRefs.current.set(file.id, el); else fileElementRefs.current.delete(file.id); }} + data-file-id={file.id} className={clsx( 'bg-white rounded-lg border p-2 cursor-pointer flex flex-col items-center text-center overflow-hidden', isActive @@ -436,8 +647,11 @@ export function FileExplorer({ : 'border-secondary-200 hover:border-secondary-300 hover:shadow-sm', isDragging && 'opacity-50', )} - onClick={() => onFileClick(file)} + onClick={(e) => handleRowClick(e, file, index)} onDoubleClick={() => onFileDoubleClick(file)} + onTouchStart={() => handleTouchStart(file)} + onTouchEnd={handleTouchEnd} + onTouchMove={handleTouchMove} draggable onDragStart={(e) => handleDragStart(e, file)} onDragEnd={handleDragEnd} @@ -471,6 +685,8 @@ export function FileExplorer({ return (
{ if (el) fileElementRefs.current.set(file.id, el); else fileElementRefs.current.delete(file.id); }} + data-file-id={file.id} className={clsx( 'bg-white rounded-lg border p-3 cursor-pointer flex flex-col items-center text-center overflow-hidden', isActive @@ -480,8 +696,11 @@ export function FileExplorer({ : 'border-secondary-200 hover:border-secondary-300 hover:shadow-sm', isDragging && 'opacity-50', )} - onClick={() => onFileClick(file)} + onClick={(e) => handleRowClick(e, file, index)} onDoubleClick={() => onFileDoubleClick(file)} + onTouchStart={() => handleTouchStart(file)} + onTouchEnd={handleTouchEnd} + onTouchMove={handleTouchMove} draggable onDragStart={(e) => handleDragStart(e, file)} onDragEnd={handleDragEnd} @@ -523,6 +742,7 @@ export function FileExplorer({
); })} + {rubberBandRect}
); }