From cbfd5f573d0ab4e9d25e88bd603470483d36eca7 Mon Sep 17 00:00:00 2001 From: Christian Vidal Wolf Date: Thu, 16 Apr 2026 12:01:59 +0200 Subject: [PATCH] feat: implement excel-style drag-to-select and shift-click in Dimensions and filters --- src/components/ColumnFilterPopover.tsx | 20 ++++- src/components/DimensionsView.tsx | 105 ++++++++++++++++++++++--- 2 files changed, 113 insertions(+), 12 deletions(-) diff --git a/src/components/ColumnFilterPopover.tsx b/src/components/ColumnFilterPopover.tsx index b47da32..1a4da2f 100644 --- a/src/components/ColumnFilterPopover.tsx +++ b/src/components/ColumnFilterPopover.tsx @@ -27,6 +27,7 @@ export function ColumnFilterPopover({ const [isDragging, setIsDragging] = useState(false); const [dragStart, setDragStart] = useState(null); const [hoveredIndex, setHoveredIndex] = useState(null); + const [lastClickedIndex, setLastClickedIndex] = useState(null); const listRef = React.useRef(null); const filteredValues = useMemo(() => { @@ -134,10 +135,23 @@ export function ColumnFilterPopover({ tabIndex={0} onMouseDown={(e) => { e.preventDefault(); handleMouseDown(idx); }} onMouseEnter={() => handleMouseEnter(idx)} - onClick={(e) => { if (!isDragging) onToggle(val); }} - onKeyDown={(e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); onToggle(val); } }} + onClick={(e) => { + if (isDragging) return; + + if (e.shiftKey && lastClickedIndex !== null) { + const start = Math.min(lastClickedIndex, idx); + const end = Math.max(lastClickedIndex, idx); + const itemsToSelect = filteredValues.slice(start, end + 1); + const newSelected = new Set([...selectedValues, ...itemsToSelect]); + onSelectAll(Array.from(newSelected)); + } else { + onToggle(val); + } + setLastClickedIndex(idx); + }} + onKeyDown={(e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); onToggle(val); setLastClickedIndex(idx); } }} className={cn( - "flex items-center gap-2 p-1.5 rounded cursor-pointer group transition-colors", + "flex items-center gap-2 p-1.5 rounded cursor-pointer group transition-colors select-none", isDragSelected ? "bg-blue-600/40" : "hover:bg-slate-700/50" )} > diff --git a/src/components/DimensionsView.tsx b/src/components/DimensionsView.tsx index fd96775..27f0546 100644 --- a/src/components/DimensionsView.tsx +++ b/src/components/DimensionsView.tsx @@ -55,6 +55,53 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat const [lineFilter, setLineFilter] = useState([]); const [classFilter, setClassFilter] = useState([]); const [openFilter, setOpenFilter] = useState<'line' | 'class' | null>(null); + const [isDragging, setIsDragging] = useState(false); + const [dragStart, setDragStart] = useState(null); + const [hoveredIndex, setHoveredIndex] = useState(null); + const [lastClickedIndex, setLastClickedIndex] = useState(null); + const [activeClusterKey, setActiveClusterKey] = useState(null); + + const isDraggingRef = React.useRef(false); + const dragStartRef = React.useRef(null); + const hoveredIndexRef = React.useRef(null); + const activeClusterKeyRef = React.useRef(null); + + React.useEffect(() => { + const handleGlobalMouseUp = () => { + if (isDraggingRef.current && activeClusterKeyRef.current) { + const clusterKey = activeClusterKeyRef.current; + const start = dragStartRef.current; + const end = hoveredIndexRef.current; + + if (start !== null && end !== null) { + const cluster = nearDuplicateClusters.find(c => c.groups[0].key === clusterKey); + if (cluster) { + const allClusterRows = cluster.groups.flatMap(g => g.rows); + const s = Math.min(start, end); + const e = Math.max(start, end); + const indicesToSelect = allClusterRows.slice(s, e + 1).map(r => r.index); + + setClusterSelections(prev => { + const next = new Set(prev[clusterKey] ?? []); + indicesToSelect.forEach(idx => next.add(idx)); + return { ...prev, [clusterKey]: next }; + }); + } + } + } + isDraggingRef.current = false; + dragStartRef.current = null; + hoveredIndexRef.current = null; + activeClusterKeyRef.current = null; + setIsDragging(false); + setDragStart(null); + setHoveredIndex(null); + setActiveClusterKey(null); + }; + + window.addEventListener('mouseup', handleGlobalMouseUp); + return () => window.removeEventListener('mouseup', handleGlobalMouseUp); + }, [nearDuplicateClusters]); const groups = useMemo(() => { const groupMap = new Map(); @@ -492,24 +539,64 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
- {allClusterRows.map(({ row, index }) => { + {allClusterRows.map(({ row, index }, rowIdx) => { const isSelected = selection.has(index); + const isInDragRange = isDragging && activeClusterKey === clusterKey && dragStart !== null && hoveredIndex !== null && + ((rowIdx >= dragStart && rowIdx <= hoveredIndex) || (rowIdx <= dragStart && rowIdx >= hoveredIndex)); + return (
setClusterSelections(prev => { - const current = new Set(prev[clusterKey] ?? []); - if (current.has(index)) current.delete(index); else current.add(index); - return { ...prev, [clusterKey]: current }; - })} + onMouseDown={(e) => { + // Only handle left click + if (e.button !== 0) return; + e.preventDefault(); + isDraggingRef.current = true; + dragStartRef.current = rowIdx; + hoveredIndexRef.current = rowIdx; + activeClusterKeyRef.current = clusterKey; + setIsDragging(true); + setDragStart(rowIdx); + setHoveredIndex(rowIdx); + setActiveClusterKey(clusterKey); + }} + onMouseEnter={() => { + if (isDraggingRef.current && activeClusterKeyRef.current === clusterKey) { + hoveredIndexRef.current = rowIdx; + setHoveredIndex(rowIdx); + } + }} + onClick={(e) => { + if (!isDragging) { + if (e.shiftKey && lastClickedIndex !== null) { + const start = Math.min(lastClickedIndex, rowIdx); + const end = Math.max(lastClickedIndex, rowIdx); + const indicesToSelect = allClusterRows.slice(start, end + 1).map(r => r.index); + + setClusterSelections(prev => { + const next = new Set(prev[clusterKey] ?? []); + indicesToSelect.forEach(idx => next.add(idx)); + return { ...prev, [clusterKey]: next }; + }); + } else { + setClusterSelections(prev => { + const current = new Set(prev[clusterKey] ?? []); + if (current.has(index)) current.delete(index); else current.add(index); + return { ...prev, [clusterKey]: current }; + }); + } + setLastClickedIndex(rowIdx); + } + }} >