add selection / dnd state

This commit is contained in:
jeffvli
2026-02-08 19:27:29 -08:00
parent a16f43c427
commit b8aa006b1c
3 changed files with 202 additions and 59 deletions
@@ -95,7 +95,7 @@
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
color: var(--theme-colors-foreground-muted); color: var(--theme-colors-foreground-muted);
text-align: left; text-align: center;
white-space: nowrap; white-space: nowrap;
} }
@@ -108,13 +108,13 @@
} }
.row .track-col-duration { .row .track-col-duration {
width: 8rem; width: 4rem;
min-width: 8rem; min-width: 4rem;
max-width: 8rem; max-width: 4rem;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
color: var(--theme-colors-foreground-muted); color: var(--theme-colors-foreground-muted);
text-align: right; text-align: center;
white-space: nowrap; white-space: nowrap;
} }
@@ -134,9 +134,24 @@
max-width: 5.5rem; max-width: 5.5rem;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
text-align: center;
white-space: nowrap; white-space: nowrap;
} }
.track-row-selected {
@mixin dark {
background-color: lighten(var(--theme-colors-surface), 5%);
}
@mixin light {
background-color: darken(var(--theme-colors-surface), 5%);
}
}
.track-row-dragging {
opacity: 0.5;
}
.skeleton-image { .skeleton-image {
width: 100%; width: 100%;
aspect-ratio: 1; aspect-ratio: 1;
@@ -11,21 +11,24 @@ import styles from './item-detail.module.css';
import { ItemCardControls } from '/@/renderer/components/item-card/item-card-controls'; import { ItemCardControls } from '/@/renderer/components/item-card/item-card-controls';
import { ItemImage } from '/@/renderer/components/item-image/item-image'; import { ItemImage } from '/@/renderer/components/item-image/item-image';
import { createExtractRowId } from '/@/renderer/components/item-list/helpers/extract-row-id';
import { useDefaultItemListControls } from '/@/renderer/components/item-list/helpers/item-list-controls'; import { useDefaultItemListControls } from '/@/renderer/components/item-list/helpers/item-list-controls';
import { import {
ItemListStateActions, ItemListStateActions,
ItemListStateItemWithRequiredProperties,
useItemListState, useItemListState,
useItemSelectionState,
} from '/@/renderer/components/item-list/helpers/item-list-state'; } from '/@/renderer/components/item-list/helpers/item-list-state';
import { useItemDragDropState } from '/@/renderer/components/item-list/item-table-list/hooks/use-item-drag-drop-state';
import { ItemControls } from '/@/renderer/components/item-list/types'; import { ItemControls } from '/@/renderer/components/item-list/types';
import { albumQueries } from '/@/renderer/features/albums/api/album-api'; import { albumQueries } from '/@/renderer/features/albums/api/album-api';
import { usePlayer } from '/@/renderer/features/player/context/player-context';
import { useIsMutatingCreateFavorite } from '/@/renderer/features/shared/mutations/create-favorite-mutation'; import { useIsMutatingCreateFavorite } from '/@/renderer/features/shared/mutations/create-favorite-mutation';
import { useIsMutatingDeleteFavorite } from '/@/renderer/features/shared/mutations/delete-favorite-mutation'; import { useIsMutatingDeleteFavorite } from '/@/renderer/features/shared/mutations/delete-favorite-mutation';
import { AppRoute } from '/@/renderer/router/routes'; import { AppRoute } from '/@/renderer/router/routes';
import { Icon } from '/@/shared/components/icon/icon'; import { Icon } from '/@/shared/components/icon/icon';
import { ReadOnlyRating } from '/@/shared/components/read-only-rating/read-only-rating'; import { ReadOnlyRating } from '/@/shared/components/read-only-rating/read-only-rating';
import { Skeleton } from '/@/shared/components/skeleton/skeleton'; import { Skeleton } from '/@/shared/components/skeleton/skeleton';
import { Album, Song } from '/@/shared/types/domain-types'; import { Album, LibraryItem, Song } from '/@/shared/types/domain-types';
interface ItemDetailListProps { interface ItemDetailListProps {
currentPage?: number; currentPage?: number;
@@ -46,59 +49,159 @@ interface RowData {
internalState: ItemListStateActions; internalState: ItemListStateActions;
isMutatingFavorite: boolean; isMutatingFavorite: boolean;
queryClient: ReturnType<typeof useQueryClient>; queryClient: ReturnType<typeof useQueryClient>;
registerSongs: (albumId: string, songs: Song[]) => void;
} }
interface TrackRowProps { interface TrackRowProps {
internalState: ItemListStateActions;
isMutatingFavorite: boolean; isMutatingFavorite: boolean;
onFavoriteClick: (song: Song) => void; onFavoriteClick: (song: Song) => void;
song: Song; song: Song;
} }
const TrackRow = memo(({ isMutatingFavorite, onFavoriteClick, song }: TrackRowProps) => { const TrackRow = memo(
const discAndCol = ({ internalState, isMutatingFavorite, onFavoriteClick, song }: TrackRowProps) => {
`${song.discNumber ?? 1}` + ' - ' + song.trackNumber.toString().padStart(2, '0'); const playerContext = usePlayer();
const { dragRef, isDragging } = useItemDragDropState<HTMLTableRowElement>({
enableDrag: true,
internalState,
isDataRow: true,
item: song,
itemType: LibraryItem.SONG,
playerContext,
});
const discAndCol =
`${song.discNumber ?? 1}` + ' - ' + song.trackNumber.toString().padStart(2, '0');
const isSelected = useItemSelectionState(internalState, song.id);
return ( const handleRowClick = useCallback(
<tr> (e: React.MouseEvent) => {
<td className={styles.trackColNumber} style={{ fontFamily: 'monospace' }}> e.preventDefault();
{discAndCol} e.stopPropagation();
</td> if (e.ctrlKey || e.metaKey) {
<td className={styles.trackColTitle}>{song.name}</td> internalState.toggleSelected(song);
<td className={styles.trackColDuration} style={{ fontFamily: 'monospace' }}> } else if (e.shiftKey) {
{formatDuration(song.duration)} const selectedItems = internalState.getSelected();
</td> const lastSelectedItem = selectedItems[selectedItems.length - 1];
<td className={styles.trackColFavorite}>
<div if (
aria-disabled={isMutatingFavorite} lastSelectedItem &&
onClick={(event) => { typeof lastSelectedItem === 'object' &&
event.stopPropagation(); lastSelectedItem !== null
event.preventDefault(); ) {
onFavoriteClick(song); const data = internalState.getData();
}} const validData = data.filter((d) => d && typeof d === 'object');
onDoubleClick={(event) => { const lastRowId = internalState.extractRowId(lastSelectedItem);
event.stopPropagation(); if (!lastRowId) {
event.preventDefault(); internalState.setSelected([song]);
}} return;
role="button" }
> const lastIndex = internalState.findItemIndex(lastRowId);
<Icon icon="favorite" size="xs" /> const currentIndex = internalState.findItemIndex(song.id);
</div>
</td> if (lastIndex !== -1 && currentIndex !== -1) {
<td className={styles.trackColRating}> const startIndex = Math.min(lastIndex, currentIndex);
<ReadOnlyRating size="md" value={song.userRating} /> const stopIndex = Math.max(lastIndex, currentIndex);
</td> const rangeItems: ItemListStateItemWithRequiredProperties[] = [];
</tr> for (let i = startIndex; i <= stopIndex; i++) {
); const rangeItem = validData[i];
}); if (
rangeItem &&
typeof rangeItem === 'object' &&
'_serverId' in rangeItem &&
'_itemType' in rangeItem
) {
const rangeRowId = internalState.extractRowId(rangeItem);
if (rangeRowId) {
rangeItems.push(
rangeItem as ItemListStateItemWithRequiredProperties,
);
}
}
}
const currentSelected = internalState.getSelected();
const newSelected = [
...currentSelected.filter(
(
selectedItem,
): selectedItem is ItemListStateItemWithRequiredProperties =>
typeof selectedItem === 'object' && selectedItem !== null,
),
];
rangeItems.forEach((rangeItem) => {
const rangeRowId = internalState.extractRowId(rangeItem);
if (
rangeRowId &&
!newSelected.some(
(selected) =>
internalState.extractRowId(selected) === rangeRowId,
)
) {
newSelected.push(rangeItem);
}
});
internalState.setSelected(newSelected);
} else {
internalState.setSelected([song]);
}
} else {
internalState.setSelected([song]);
}
} else {
internalState.setSelected([song]);
}
},
[internalState, song],
);
return (
<tr
className={
isSelected
? styles.trackRowSelected
: isDragging
? styles.trackRowDragging
: undefined
}
onClick={handleRowClick}
ref={dragRef ?? undefined}
>
<td className={styles.trackColNumber} style={{ fontFamily: 'monospace' }}>
{discAndCol}
</td>
<td className={styles.trackColTitle}>{song.name}</td>
<td className={styles.trackColDuration} style={{ fontFamily: 'monospace' }}>
{formatDuration(song.duration)}
</td>
<td className={styles.trackColFavorite}>
<div
aria-disabled={isMutatingFavorite}
onClick={(event) => {
event.stopPropagation();
event.preventDefault();
onFavoriteClick(song);
}}
onDoubleClick={(event) => {
event.stopPropagation();
event.preventDefault();
}}
role="button"
>
<Icon icon="favorite" size="xs" />
</div>
</td>
<td className={styles.trackColRating}>
<ReadOnlyRating size="md" value={song.userRating} />
</td>
</tr>
);
},
);
TrackRow.displayName = 'TrackRow'; TrackRow.displayName = 'TrackRow';
type RowContentProps = Omit<RowComponentProps<RowData>, 'style'>; type RowContentProps = Omit<RowComponentProps<RowData>, 'style'>;
/**
* Inner row content memoized with custom comparator so it does NOT re-render when only
* `style` or `ariaAttributes` change (e.g. on scroll). Only re-renders when data/index/mutation state change.
*/
const RowContent = memo( const RowContent = memo(
({ ({
controls, controls,
@@ -108,6 +211,7 @@ const RowContent = memo(
internalState, internalState,
isMutatingFavorite, isMutatingFavorite,
queryClient, queryClient,
registerSongs,
}: RowContentProps) => { }: RowContentProps) => {
const [showControls, setShowControls] = useState(false); const [showControls, setShowControls] = useState(false);
const item = useMemo(() => { const item = useMemo(() => {
@@ -140,6 +244,12 @@ const RowContent = memo(
); );
}, [songData, item?.id, item?.songCount]); }, [songData, item?.id, item?.songCount]);
useEffect(() => {
if (item?.id && songData?.songs?.length) {
registerSongs(item.id, songData.songs as Song[]);
}
}, [item?.id, registerSongs, songData?.songs]);
const onFavoriteClick = useCallback((song: Song) => { const onFavoriteClick = useCallback((song: Song) => {
// TODO: toggle favorite for song // TODO: toggle favorite for song
void song; void song;
@@ -213,6 +323,7 @@ const RowContent = memo(
<tbody> <tbody>
{songs.map((song) => ( {songs.map((song) => (
<TrackRow <TrackRow
internalState={internalState}
isMutatingFavorite={isMutatingFavorite} isMutatingFavorite={isMutatingFavorite}
key={song.id} key={song.id}
onFavoriteClick={onFavoriteClick} onFavoriteClick={onFavoriteClick}
@@ -232,7 +343,8 @@ const RowContent = memo(
prev.internalState === next.internalState && prev.internalState === next.internalState &&
prev.queryClient === next.queryClient && prev.queryClient === next.queryClient &&
prev.isMutatingFavorite === next.isMutatingFavorite && prev.isMutatingFavorite === next.isMutatingFavorite &&
prev.controls === next.controls, prev.controls === next.controls &&
prev.registerSongs === next.registerSongs,
); );
RowContent.displayName = 'RowContent'; RowContent.displayName = 'RowContent';
@@ -288,14 +400,21 @@ export const ItemDetailList = ({
return dataSource.length; return dataSource.length;
}, [dataSource.length, externalItemCount]); }, [dataSource.length, externalItemCount]);
// Create extract row ID function // Accumulate songs from each row for selection/drag state (keyed by album id)
const extractRowId = useMemo(() => createExtractRowId(), []); const songsByAlbumRef = useRef<Map<string, Song[]>>(new Map());
const registerSongs = useCallback((albumId: string, songs: Song[]) => {
songsByAlbumRef.current.set(albumId, songs);
}, []);
// Create getData function // Flattened songs in album order for ItemListState (selection/drag are per-song)
const getDataFn = useCallback(() => dataSource, [dataSource]); const getDataFn = useCallback(() => {
const map = songsByAlbumRef.current;
return dataSource.flatMap((album) => map.get((album as Album).id) ?? []);
}, [dataSource]);
// Create internal state const extractRowIdSong = useCallback((item: unknown) => (item as Song).id, []);
const internalState = useItemListState(getDataFn, extractRowId);
const internalState = useItemListState(getDataFn, extractRowIdSong);
const handleRowsRendered = useCallback( const handleRowsRendered = useCallback(
(range: { startIndex: number; stopIndex: number }) => { (range: { startIndex: number; stopIndex: number }) => {
@@ -329,8 +448,17 @@ export const ItemDetailList = ({
internalState, internalState,
isMutatingFavorite, isMutatingFavorite,
queryClient, queryClient,
registerSongs,
}), }),
[controls, dataSource, getItem, internalState, isMutatingFavorite, queryClient], [
controls,
dataSource,
getItem,
internalState,
isMutatingFavorite,
queryClient,
registerSongs,
],
); );
const [initialize, osInstance] = useOverlayScrollbars({ const [initialize, osInstance] = useOverlayScrollbars({
@@ -7,8 +7,8 @@ import { useDragDrop } from '/@/renderer/hooks/use-drag-drop';
import { Folder, LibraryItem, QueueSong, Song } from '/@/shared/types/domain-types'; import { Folder, LibraryItem, QueueSong, Song } from '/@/shared/types/domain-types';
import { DragOperation, DragTarget, DragTargetMap } from '/@/shared/types/drag-and-drop'; import { DragOperation, DragTarget, DragTargetMap } from '/@/shared/types/drag-and-drop';
interface DragDropState { interface DragDropState<TElement extends HTMLElement = HTMLDivElement> {
dragRef: null | React.Ref<HTMLDivElement>; dragRef: null | React.Ref<TElement>;
isDraggedOver: 'bottom' | 'top' | null; isDraggedOver: 'bottom' | 'top' | null;
isDragging: boolean; isDragging: boolean;
} }
@@ -23,7 +23,7 @@ interface UseItemDragDropStateProps {
playlistId?: string; playlistId?: string;
} }
export const useItemDragDropState = ({ export const useItemDragDropState = <TElement extends HTMLElement = HTMLDivElement>({
enableDrag, enableDrag,
internalState, internalState,
isDataRow, isDataRow,
@@ -31,14 +31,14 @@ export const useItemDragDropState = ({
itemType, itemType,
playerContext, playerContext,
playlistId, playlistId,
}: UseItemDragDropStateProps): DragDropState => { }: UseItemDragDropStateProps): DragDropState<TElement> => {
const shouldEnableDrag = enableDrag && isDataRow && !!item; const shouldEnableDrag = enableDrag && isDataRow && !!item;
const { const {
isDraggedOver, isDraggedOver,
isDragging: isDraggingLocal, isDragging: isDraggingLocal,
ref: dragRef, ref: dragRef,
} = useDragDrop<HTMLDivElement>({ } = useDragDrop<TElement>({
drag: { drag: {
getId: () => { getId: () => {
if (!item || !isDataRow) { if (!item || !isDataRow) {