mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-09 20:29:36 +02:00
add selection / dnd state
This commit is contained in:
@@ -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({
|
||||||
|
|||||||
+5
-5
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user