Merge branch 'feat/multiselect-perf' into main
This commit is contained in:
commit
febfb638b9
@ -587,6 +587,14 @@ const RekordboxReader: React.FC = () => {
|
|||||||
borderColor="gray.700"
|
borderColor="gray.700"
|
||||||
overflowY="auto"
|
overflowY="auto"
|
||||||
bg="gray.900"
|
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}
|
{playlistManager}
|
||||||
<ResizeHandle onMouseDown={handleResizeStart} />
|
<ResizeHandle onMouseDown={handleResizeStart} />
|
||||||
@ -621,7 +629,14 @@ const RekordboxReader: React.FC = () => {
|
|||||||
_hover={{ color: "blue.300", bg: "whiteAlpha.200" }}
|
_hover={{ color: "blue.300", bg: "whiteAlpha.200" }}
|
||||||
/>
|
/>
|
||||||
</DrawerHeader>
|
</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}
|
{playlistManager}
|
||||||
</DrawerBody>
|
</DrawerBody>
|
||||||
</DrawerContent>
|
</DrawerContent>
|
||||||
@ -636,7 +651,14 @@ const RekordboxReader: React.FC = () => {
|
|||||||
overflow="hidden"
|
overflow="hidden"
|
||||||
>
|
>
|
||||||
{/* Song List */}
|
{/* 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
|
<PaginatedSongList
|
||||||
songs={songs}
|
songs={songs}
|
||||||
onAddToPlaylist={handleAddSongsToPlaylist}
|
onAddToPlaylist={handleAddSongsToPlaylist}
|
||||||
@ -676,15 +698,12 @@ const RekordboxReader: React.FC = () => {
|
|||||||
bg="gray.900"
|
bg="gray.900"
|
||||||
minH={0}
|
minH={0}
|
||||||
sx={{
|
sx={{
|
||||||
'&::-webkit-scrollbar': {
|
scrollbarColor: 'var(--chakra-colors-gray-700) var(--chakra-colors-gray-900)',
|
||||||
width: '8px',
|
scrollbarWidth: 'thin',
|
||||||
borderRadius: '8px',
|
'&::-webkit-scrollbar': { width: '8px', backgroundColor: 'var(--chakra-colors-gray-900)' },
|
||||||
backgroundColor: '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-thumb': {
|
'&::-webkit-scrollbar-track': { backgroundColor: 'var(--chakra-colors-gray-900)' },
|
||||||
backgroundColor: 'gray.700',
|
|
||||||
borderRadius: '8px',
|
|
||||||
},
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SongDetails song={selectedSong} />
|
<SongDetails song={selectedSong} />
|
||||||
|
|||||||
@ -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 {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Flex,
|
Flex,
|
||||||
@ -58,17 +58,29 @@ const SongItem = memo<{
|
|||||||
onRowDragOver?: (e: React.DragEvent) => void;
|
onRowDragOver?: (e: React.DragEvent) => void;
|
||||||
onRowDrop?: (e: React.DragEvent) => void;
|
onRowDrop?: (e: React.DragEvent) => void;
|
||||||
onRowDragStartCapture?: (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
|
// Memoize the formatted duration to prevent recalculation
|
||||||
const formattedDuration = useMemo(() => formatDuration(song.totalTime || ''), [song.totalTime]);
|
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(() => {
|
const handleClick = useCallback(() => {
|
||||||
onSelect(song);
|
onSelect(song);
|
||||||
}, [onSelect, song]);
|
}, [onSelect, song]);
|
||||||
|
|
||||||
const handleCheckboxClick = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleCheckboxClick = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onToggleSelection(song.id);
|
setLocalChecked(e.target.checked);
|
||||||
}, [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) => {
|
const handlePlayClick = useCallback((e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@ -103,7 +115,7 @@ const SongItem = memo<{
|
|||||||
)}
|
)}
|
||||||
{showCheckbox && (
|
{showCheckbox && (
|
||||||
<Checkbox
|
<Checkbox
|
||||||
isChecked={isSelected}
|
isChecked={localChecked}
|
||||||
onChange={handleCheckboxClick}
|
onChange={handleCheckboxClick}
|
||||||
mr={3}
|
mr={3}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
@ -176,6 +188,7 @@ export const PaginatedSongList: React.FC<PaginatedSongListProps> = memo(({
|
|||||||
const [dragHoverIndex, setDragHoverIndex] = useState<number | null>(null);
|
const [dragHoverIndex, setDragHoverIndex] = useState<number | null>(null);
|
||||||
const [endDropHover, setEndDropHover] = useState<boolean>(false);
|
const [endDropHover, setEndDropHover] = useState<boolean>(false);
|
||||||
const [isReorderDragging, setIsReorderDragging] = useState<boolean>(false);
|
const [isReorderDragging, setIsReorderDragging] = useState<boolean>(false);
|
||||||
|
const lastSelectedIndexRef = useRef<number | null>(null);
|
||||||
|
|
||||||
// Store current values in refs to avoid stale closures
|
// Store current values in refs to avoid stale closures
|
||||||
const hasMoreRef = useRef(hasMore);
|
const hasMoreRef = useRef(hasMore);
|
||||||
@ -211,31 +224,60 @@ export const PaginatedSongList: React.FC<PaginatedSongListProps> = memo(({
|
|||||||
// const allPlaylists = useMemo(() => getAllPlaylists(playlists), [playlists, getAllPlaylists]);
|
// const allPlaylists = useMemo(() => getAllPlaylists(playlists), [playlists, getAllPlaylists]);
|
||||||
|
|
||||||
const toggleSelection = useCallback((songId: string) => {
|
const toggleSelection = useCallback((songId: string) => {
|
||||||
setSelectedSongs(prev => {
|
startTransition(() => {
|
||||||
const newSelection = new Set(prev);
|
setSelectedSongs(prev => {
|
||||||
if (newSelection.has(songId)) {
|
const newSelection = new Set(prev);
|
||||||
newSelection.delete(songId);
|
if (newSelection.has(songId)) {
|
||||||
} else {
|
newSelection.delete(songId);
|
||||||
newSelection.add(songId);
|
} else {
|
||||||
}
|
newSelection.add(songId);
|
||||||
return newSelection;
|
}
|
||||||
|
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(() => {
|
const toggleSelectAll = useCallback(() => {
|
||||||
setSelectedSongs(prev => {
|
startTransition(() => {
|
||||||
const noneSelected = prev.size === 0;
|
setSelectedSongs(prev => {
|
||||||
const allSelected = prev.size === songs.length && songs.length > 0;
|
const noneSelected = prev.size === 0;
|
||||||
if (noneSelected) {
|
const allSelected = prev.size === songs.length && songs.length > 0;
|
||||||
// Select all from empty state
|
if (noneSelected) {
|
||||||
|
return new Set(songs.map(s => s.id));
|
||||||
|
}
|
||||||
|
if (allSelected) {
|
||||||
|
return new Set();
|
||||||
|
}
|
||||||
return new Set(songs.map(s => s.id));
|
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]);
|
}, [songs]);
|
||||||
|
|
||||||
@ -460,6 +502,25 @@ export const PaginatedSongList: React.FC<PaginatedSongListProps> = memo(({
|
|||||||
overflowY="auto"
|
overflowY="auto"
|
||||||
mt={2}
|
mt={2}
|
||||||
id="song-list-container"
|
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 position="relative" height={`${rowVirtualizer.getTotalSize()}px`}>
|
||||||
<Box onDragEnd={handleDragEnd}>
|
<Box onDragEnd={handleDragEnd}>
|
||||||
@ -484,6 +545,8 @@ export const PaginatedSongList: React.FC<PaginatedSongListProps> = memo(({
|
|||||||
onSelect={handleSongSelect}
|
onSelect={handleSongSelect}
|
||||||
onToggleSelection={toggleSelection}
|
onToggleSelection={toggleSelection}
|
||||||
showCheckbox={selectedSongs.size > 0 || depth === 0}
|
showCheckbox={selectedSongs.size > 0 || depth === 0}
|
||||||
|
index={index}
|
||||||
|
onCheckboxToggle={handleCheckboxToggle}
|
||||||
onPlaySong={onPlaySong}
|
onPlaySong={onPlaySong}
|
||||||
showDropIndicatorTop={dragHoverIndex === index}
|
showDropIndicatorTop={dragHoverIndex === index}
|
||||||
onDragStart={handleDragStart}
|
onDragStart={handleDragStart}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user