Merge branch 'feat/multiselect-perf' into main

This commit is contained in:
Geert Rademakes 2025-08-13 16:09:25 +02:00
commit febfb638b9
2 changed files with 118 additions and 36 deletions

View File

@ -587,6 +587,14 @@ const RekordboxReader: React.FC = () => {
borderColor="gray.700"
overflowY="auto"
bg="gray.900"
sx={{
scrollbarColor: 'var(--chakra-colors-gray-700) var(--chakra-colors-gray-900)',
scrollbarWidth: 'thin',
'&::-webkit-scrollbar': { width: '8px', backgroundColor: 'var(--chakra-colors-gray-900)' },
'&::-webkit-scrollbar-thumb': { backgroundColor: 'var(--chakra-colors-gray-700)', borderRadius: '8px' },
'&::-webkit-scrollbar-thumb:hover': { backgroundColor: 'var(--chakra-colors-gray-600)' },
'&::-webkit-scrollbar-track': { backgroundColor: 'var(--chakra-colors-gray-900)' }
}}
>
{playlistManager}
<ResizeHandle onMouseDown={handleResizeStart} />
@ -621,7 +629,14 @@ const RekordboxReader: React.FC = () => {
_hover={{ color: "blue.300", bg: "whiteAlpha.200" }}
/>
</DrawerHeader>
<DrawerBody p={2}>
<DrawerBody p={2} sx={{
scrollbarColor: 'var(--chakra-colors-gray-700) var(--chakra-colors-gray-900)',
scrollbarWidth: 'thin',
'&::-webkit-scrollbar': { width: '8px', backgroundColor: 'var(--chakra-colors-gray-900)' },
'&::-webkit-scrollbar-thumb': { backgroundColor: 'var(--chakra-colors-gray-700)', borderRadius: '8px' },
'&::-webkit-scrollbar-thumb:hover': { backgroundColor: 'var(--chakra-colors-gray-600)' },
'&::-webkit-scrollbar-track': { backgroundColor: 'var(--chakra-colors-gray-900)' }
}}>
{playlistManager}
</DrawerBody>
</DrawerContent>
@ -636,7 +651,14 @@ const RekordboxReader: React.FC = () => {
overflow="hidden"
>
{/* Song List */}
<Box flex={1} overflowY="auto" minH={0}>
<Box flex={1} overflowY="auto" minH={0} sx={{
scrollbarColor: 'var(--chakra-colors-gray-700) var(--chakra-colors-gray-900)',
scrollbarWidth: 'thin',
'&::-webkit-scrollbar': { width: '8px', backgroundColor: 'var(--chakra-colors-gray-900)' },
'&::-webkit-scrollbar-thumb': { backgroundColor: 'var(--chakra-colors-gray-700)', borderRadius: '8px' },
'&::-webkit-scrollbar-thumb:hover': { backgroundColor: 'var(--chakra-colors-gray-600)' },
'&::-webkit-scrollbar-track': { backgroundColor: 'var(--chakra-colors-gray-900)' }
}}>
<PaginatedSongList
songs={songs}
onAddToPlaylist={handleAddSongsToPlaylist}
@ -676,15 +698,12 @@ const RekordboxReader: React.FC = () => {
bg="gray.900"
minH={0}
sx={{
'&::-webkit-scrollbar': {
width: '8px',
borderRadius: '8px',
backgroundColor: 'gray.900',
},
'&::-webkit-scrollbar-thumb': {
backgroundColor: 'gray.700',
borderRadius: '8px',
},
scrollbarColor: 'var(--chakra-colors-gray-700) var(--chakra-colors-gray-900)',
scrollbarWidth: 'thin',
'&::-webkit-scrollbar': { width: '8px', backgroundColor: 'var(--chakra-colors-gray-900)' },
'&::-webkit-scrollbar-thumb': { backgroundColor: 'var(--chakra-colors-gray-700)', borderRadius: '8px' },
'&::-webkit-scrollbar-thumb:hover': { backgroundColor: 'var(--chakra-colors-gray-600)' },
'&::-webkit-scrollbar-track': { backgroundColor: 'var(--chakra-colors-gray-900)' },
}}
>
<SongDetails song={selectedSong} />

View File

@ -1,4 +1,4 @@
import React, { useState, useRef, useEffect, useCallback, useMemo, memo } from 'react';
import React, { useState, useRef, useEffect, useCallback, useMemo, memo, startTransition } from 'react';
import {
Box,
Flex,
@ -58,17 +58,29 @@ 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
const [localChecked, setLocalChecked] = useState<boolean>(isSelected);
useEffect(() => {
setLocalChecked(isSelected);
}, [isSelected]);
const handleClick = useCallback(() => {
onSelect(song);
}, [onSelect, song]);
const handleCheckboxClick = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
e.stopPropagation();
onToggleSelection(song.id);
}, [onToggleSelection, song.id]);
setLocalChecked(e.target.checked);
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();
@ -103,7 +115,7 @@ const SongItem = memo<{
)}
{showCheckbox && (
<Checkbox
isChecked={isSelected}
isChecked={localChecked}
onChange={handleCheckboxClick}
mr={3}
onClick={(e) => e.stopPropagation()}
@ -176,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);
@ -211,31 +224,60 @@ export const PaginatedSongList: React.FC<PaginatedSongListProps> = memo(({
// const allPlaylists = useMemo(() => getAllPlaylists(playlists), [playlists, getAllPlaylists]);
const toggleSelection = useCallback((songId: string) => {
setSelectedSongs(prev => {
const newSelection = new Set(prev);
if (newSelection.has(songId)) {
newSelection.delete(songId);
} else {
newSelection.add(songId);
}
return newSelection;
startTransition(() => {
setSelectedSongs(prev => {
const newSelection = new Set(prev);
if (newSelection.has(songId)) {
newSelection.delete(songId);
} else {
newSelection.add(songId);
}
return newSelection;
});
});
}, []);
// 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];
startTransition(() => {
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;
const allSelected = prev.size === songs.length && songs.length > 0;
if (noneSelected) {
// Select all from empty state
startTransition(() => {
setSelectedSongs(prev => {
const noneSelected = prev.size === 0;
const allSelected = prev.size === songs.length && songs.length > 0;
if (noneSelected) {
return new Set(songs.map(s => s.id));
}
if (allSelected) {
return new Set();
}
return new Set(songs.map(s => s.id));
}
if (allSelected) {
// Deselect all when everything is selected
return new Set();
}
// Mixed/some selected: clear first, then select all (single state update reflects final state)
return new Set(songs.map(s => s.id));
});
});
}, [songs]);
@ -460,6 +502,25 @@ export const PaginatedSongList: React.FC<PaginatedSongListProps> = memo(({
overflowY="auto"
mt={2}
id="song-list-container"
sx={{
scrollbarColor: 'var(--chakra-colors-gray-700) var(--chakra-colors-gray-900)',
scrollbarWidth: 'thin',
'&::-webkit-scrollbar': {
width: '8px',
height: '8px',
backgroundColor: 'var(--chakra-colors-gray-900)'
},
'&::-webkit-scrollbar-thumb': {
backgroundColor: 'var(--chakra-colors-gray-700)',
borderRadius: '8px',
},
'&::-webkit-scrollbar-thumb:hover': {
backgroundColor: 'var(--chakra-colors-gray-600)'
},
'&::-webkit-scrollbar-track': {
backgroundColor: 'var(--chakra-colors-gray-900)'
}
}}
>
<Box position="relative" height={`${rowVirtualizer.getTotalSize()}px`}>
<Box onDragEnd={handleDragEnd}>
@ -484,6 +545,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}