feat: implement excel-style drag-to-select and shift-click in Dimensions and filters

This commit is contained in:
Christian Vidal Wolf
2026-04-16 12:01:59 +02:00
parent 19ca75c1b9
commit cbfd5f573d
2 changed files with 113 additions and 12 deletions
+17 -3
View File
@@ -27,6 +27,7 @@ export function ColumnFilterPopover({
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const [dragStart, setDragStart] = useState<number | null>(null); const [dragStart, setDragStart] = useState<number | null>(null);
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null); const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
const [lastClickedIndex, setLastClickedIndex] = useState<number | null>(null);
const listRef = React.useRef<HTMLDivElement>(null); const listRef = React.useRef<HTMLDivElement>(null);
const filteredValues = useMemo(() => { const filteredValues = useMemo(() => {
@@ -134,10 +135,23 @@ export function ColumnFilterPopover({
tabIndex={0} tabIndex={0}
onMouseDown={(e) => { e.preventDefault(); handleMouseDown(idx); }} onMouseDown={(e) => { e.preventDefault(); handleMouseDown(idx); }}
onMouseEnter={() => handleMouseEnter(idx)} onMouseEnter={() => handleMouseEnter(idx)}
onClick={(e) => { if (!isDragging) onToggle(val); }} onClick={(e) => {
onKeyDown={(e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); onToggle(val); } }} 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( 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" isDragSelected ? "bg-blue-600/40" : "hover:bg-slate-700/50"
)} )}
> >
+96 -9
View File
@@ -55,6 +55,53 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
const [lineFilter, setLineFilter] = useState<string[]>([]); const [lineFilter, setLineFilter] = useState<string[]>([]);
const [classFilter, setClassFilter] = useState<string[]>([]); const [classFilter, setClassFilter] = useState<string[]>([]);
const [openFilter, setOpenFilter] = useState<'line' | 'class' | null>(null); const [openFilter, setOpenFilter] = useState<'line' | 'class' | null>(null);
const [isDragging, setIsDragging] = useState(false);
const [dragStart, setDragStart] = useState<number | null>(null);
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
const [lastClickedIndex, setLastClickedIndex] = useState<number | null>(null);
const [activeClusterKey, setActiveClusterKey] = useState<string | null>(null);
const isDraggingRef = React.useRef(false);
const dragStartRef = React.useRef<number | null>(null);
const hoveredIndexRef = React.useRef<number | null>(null);
const activeClusterKeyRef = React.useRef<string | null>(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 groups = useMemo(() => {
const groupMap = new Map<string, { row: ExcelRow; index: number }[]>(); const groupMap = new Map<string, { row: ExcelRow; index: number }[]>();
@@ -492,24 +539,64 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
</div> </div>
<div className="divide-y divide-violet-500/10"> <div className="divide-y divide-violet-500/10">
{allClusterRows.map(({ row, index }) => { {allClusterRows.map(({ row, index }, rowIdx) => {
const isSelected = selection.has(index); const isSelected = selection.has(index);
const isInDragRange = isDragging && activeClusterKey === clusterKey && dragStart !== null && hoveredIndex !== null &&
((rowIdx >= dragStart && rowIdx <= hoveredIndex) || (rowIdx <= dragStart && rowIdx >= hoveredIndex));
return ( return (
<div <div
key={index} key={index}
className={cn( className={cn(
"flex items-center gap-3 px-4 py-2.5 hover:bg-violet-500/5 cursor-pointer transition-colors", "flex items-center gap-3 px-4 py-2.5 hover:bg-violet-500/5 cursor-pointer transition-colors select-none",
isSelected && "bg-violet-500/10" isSelected && "bg-violet-500/10",
isInDragRange && "bg-violet-500/20 ring-1 ring-violet-500/30"
)} )}
onClick={() => setClusterSelections(prev => { onMouseDown={(e) => {
const current = new Set(prev[clusterKey] ?? []); // Only handle left click
if (current.has(index)) current.delete(index); else current.add(index); if (e.button !== 0) return;
return { ...prev, [clusterKey]: current }; 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);
}
}}
> >
<input <input
type="checkbox" type="checkbox"
checked={isSelected} checked={isSelected || isInDragRange}
readOnly readOnly
className="rounded border-slate-600 bg-slate-700 text-violet-600 focus:ring-violet-500 shrink-0 pointer-events-none" className="rounded border-slate-600 bg-slate-700 text-violet-600 focus:ring-violet-500 shrink-0 pointer-events-none"
/> />