feat(frontend): shift-click range selection with optimistic checkbox feedback

This commit is contained in:
Geert Rademakes 2025-08-13 15:59:25 +02:00
parent 54b22d5cc5
commit 1d290bdfa6

View File

@ -58,7 +58,9 @@ const SongItem = memo<{
onRowDragOver?: (e: React.DragEvent) => void;
onRowDrop?: (e: React.DragEvent) => void;
onRowDragStartCapture?: (e: React.DragEvent) => void;
}>(({ song, isSelected, isHighlighted, onSelect, onToggleSelection, showCheckbox, onPlaySong, showDropIndicatorTop, onDragStart, onRowDragOver, onRowDrop, onRowDragStartCapture }) => {
index: number;
onCheckboxToggle?: (index: number, checked: boolean, shift: boolean) => void;
}>(({ song, isSelected, isHighlighted, onSelect, onToggleSelection, showCheckbox, onPlaySong, showDropIndicatorTop, onDragStart, onRowDragOver, onRowDrop, onRowDragStartCapture, index, onCheckboxToggle }) => {
// Memoize the formatted duration to prevent recalculation
const formattedDuration = useMemo(() => formatDuration(song.totalTime || ''), [song.totalTime]);
// Local optimistic selection for instant visual feedback
@ -73,8 +75,12 @@ const SongItem = memo<{
const handleCheckboxClick = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
e.stopPropagation();
setLocalChecked(e.target.checked);
onToggleSelection(song.id);
}, [onToggleSelection, song.id]);
if (onCheckboxToggle) {
onCheckboxToggle(index, e.target.checked, (e as any).nativeEvent?.shiftKey === true || (e as any).shiftKey === true);
} else {
onToggleSelection(song.id);
}
}, [onCheckboxToggle, index, onToggleSelection, song.id]);
const handlePlayClick = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
@ -182,6 +188,7 @@ export const PaginatedSongList: React.FC<PaginatedSongListProps> = memo(({
const [dragHoverIndex, setDragHoverIndex] = useState<number | null>(null);
const [endDropHover, setEndDropHover] = useState<boolean>(false);
const [isReorderDragging, setIsReorderDragging] = useState<boolean>(false);
const lastSelectedIndexRef = useRef<number | null>(null);
// Store current values in refs to avoid stale closures
const hasMoreRef = useRef(hasMore);
@ -228,6 +235,32 @@ export const PaginatedSongList: React.FC<PaginatedSongListProps> = memo(({
});
}, []);
// Range selection using shift-click between last selected and current
const toggleSelectionRange = useCallback((fromIndex: number, toIndex: number, checked: boolean) => {
if (fromIndex === null || toIndex === null) return;
const [start, end] = fromIndex < toIndex ? [fromIndex, toIndex] : [toIndex, fromIndex];
setSelectedSongs(prev => {
const next = new Set(prev);
for (let i = start; i <= end; i++) {
const id = songs[i]?.id;
if (!id) continue;
if (checked) next.add(id); else next.delete(id);
}
return next;
});
}, [songs]);
const handleCheckboxToggle = useCallback((index: number, checked: boolean, shift: boolean) => {
const song = songs[index];
if (!song) return;
if (shift && lastSelectedIndexRef.current !== null) {
toggleSelectionRange(lastSelectedIndexRef.current, index, checked);
} else {
toggleSelection(song.id);
}
lastSelectedIndexRef.current = index;
}, [songs, toggleSelection, toggleSelectionRange]);
const toggleSelectAll = useCallback(() => {
setSelectedSongs(prev => {
const noneSelected = prev.size === 0;
@ -490,6 +523,8 @@ export const PaginatedSongList: React.FC<PaginatedSongListProps> = memo(({
onSelect={handleSongSelect}
onToggleSelection={toggleSelection}
showCheckbox={selectedSongs.size > 0 || depth === 0}
index={index}
onCheckboxToggle={handleCheckboxToggle}
onPlaySong={onPlaySong}
showDropIndicatorTop={dragHoverIndex === index}
onDragStart={handleDragStart}