From 51517fecbfdd35a6b74eff83adeb431be439c007 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pierre=20de=20la=20Martini=C3=A8re?= Date: Tue, 7 Jan 2025 19:48:01 +0100 Subject: [PATCH] Migrate playlist drag-and-drop to dnd-kit --- src/components/TrackRow.module.css | 25 ---- src/components/TrackRow.tsx | 116 +++++++------------ src/components/TracksList.tsx | 180 ++++++++++++++++++----------- src/routes/playlist-details.tsx | 21 ++-- src/stores/PlaylistsAPI.ts | 33 +----- 5 files changed, 166 insertions(+), 209 deletions(-) diff --git a/src/components/TrackRow.module.css b/src/components/TrackRow.module.css index 7cacab497..6e971474f 100644 --- a/src/components/TrackRow.module.css +++ b/src/components/TrackRow.module.css @@ -32,29 +32,4 @@ border-top-color: rgba(255 255 255 / 0.2); } } - - &.reordered { - opacity: 0.5; - } - - &.isReorderedOver { - &::after { - pointer-events: none; - position: absolute; - z-index: 1; - display: block; - width: 100%; - content: ""; - height: 2px; - background-color: var(--main-color); - } - - &.isAbove::after { - top: -2px; - } - - &.isBelow::after { - bottom: -2px; - } - } } diff --git a/src/components/TrackRow.tsx b/src/components/TrackRow.tsx index 398546e97..186e96c52 100644 --- a/src/components/TrackRow.tsx +++ b/src/components/TrackRow.tsx @@ -1,10 +1,12 @@ +import { useSortable } from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; import cx from 'classnames'; import type React from 'react'; -import { useCallback, useState } from 'react'; import type { Track } from '../generated/typings'; import useFormattedDuration from '../hooks/useFormattedDuration'; import PlayingIndicator from './PlayingIndicator'; + import styles from './TrackRow.module.css'; import cellStyles from './TracksListHeader.module.css'; @@ -12,40 +14,28 @@ type Props = { selected: boolean; track: Track; index: number; - isPlaying: boolean; - onDoubleClick: (trackID: string) => void; - onMouseDown: ( + isPlaying?: boolean; + onDoubleClick?: (trackID: string) => void; + onMouseDown?: ( event: React.MouseEvent, trackID: string, index: number, ) => void; - onContextMenu: (event: React.MouseEvent, index: number) => void; - onClick: ( + onContextMenu?: (event: React.MouseEvent, index: number) => void; + onClick?: ( event: React.MouseEvent | React.KeyboardEvent, trackID: string, ) => void; - draggable?: boolean; - reordered?: boolean; - onDragStart?: () => void; - onDragOver?: (trackID: string, position: 'above' | 'below') => void; - onDragEnd?: () => void; - onDrop?: (targetTrackID: string, position: 'above' | 'below') => void; style?: React.CSSProperties; }; export default function TrackRow(props: Props) { - const [reorderOver, setReorderOver] = useState(false); - const [reorderPosition, setReorderPosition] = useState< - 'above' | 'below' | null - >(null); - const { track, index, selected, draggable, - reordered, onMouseDown, onClick, onContextMenu, @@ -54,80 +44,56 @@ export default function TrackRow(props: Props) { const duration = useFormattedDuration(track.duration); - // TODO: migrate to react-dnd - const onDragStart = useCallback( - (event: React.DragEvent) => { - if (props.onDragStart) { - event.dataTransfer.setData('text/plain', props.track.id); - event.dataTransfer.dropEffect = 'move'; - event.dataTransfer.effectAllowed = 'move'; - - props.onDragStart(); - } + // Drag-and-Drop for playlists + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ + id: props.track.id, + disabled: !draggable, + data: { + type: 'playlist-track', + index, }, - [props], - ); - - const onDragOver = useCallback((event: React.DragEvent) => { - event.preventDefault(); - - const relativePosition = - event.nativeEvent.offsetY / event.currentTarget.offsetHeight; - const dragPosition = relativePosition < 0.5 ? 'above' : 'below'; - - setReorderOver(true); - setReorderPosition(dragPosition); - }, []); - - const onDragLeave = useCallback(() => { - setReorderOver(false); - setReorderPosition(null); - }, []); - - const onDrop = useCallback(() => { - const { onDrop } = props; - - if (reorderPosition && onDrop) { - onDrop(props.track.id, reorderPosition); - } + }); - setReorderOver(false); - setReorderPosition(null); - }, [props, reorderPosition]); + const style: React.CSSProperties | undefined = transform + ? { + transform: CSS.Translate.toString(transform), + transition, + zIndex: 2, + visibility: isDragging ? 'hidden' : 'visible', + } + : undefined; const trackClasses = cx(styles.track, { [styles.selected]: selected, - [styles.reordered]: reordered, - [styles.isReorderedOver]: reorderOver, - [styles.isAbove]: reorderPosition === 'above', - [styles.isBelow]: reorderPosition === 'below', [styles.even]: index % 2 === 0, }); return (
onDoubleClick(props.track.id)} - onMouseDown={(e) => onMouseDown(e, track.id, index)} - onClick={(e) => onClick(e, props.track.id)} + onDoubleClick={() => onDoubleClick?.(props.track.id)} + onMouseDown={(e) => onMouseDown?.(e, track.id, index)} + onClick={(e) => onClick?.(e, props.track.id)} onKeyDown={(e) => { if (e.key === 'Enter') { - onClick(e, track.id); + onClick?.(e, track.id); } }} - onContextMenu={(e) => onContextMenu(e, index)} - // biome-ignore lint/a11y/useSemanticElements: Accessibility over semantics - role="option" + onContextMenu={(e) => onContextMenu?.(e, index)} aria-selected={selected} - tabIndex={-1} // we do not want trackrows to be focusable by the keyboard - draggable={draggable} - onDragStart={(draggable && onDragStart) || undefined} - onDragOver={(draggable && onDragOver) || undefined} - onDragLeave={(draggable && onDragLeave) || undefined} - onDrop={(draggable && onDrop) || undefined} - onDragEnd={(draggable && props.onDragEnd) || undefined} - style={props.style} {...(props.isPlaying ? { 'data-is-playing': true } : {})} + // dnd-related props: + ref={setNodeRef} + {...listeners} + {...attributes} + style={{ ...style, ...props.style }} // memo that >
{props.isPlaying ? : null} diff --git a/src/components/TracksList.tsx b/src/components/TracksList.tsx index fef8fe5a8..ccc61f361 100644 --- a/src/components/TracksList.tsx +++ b/src/components/TracksList.tsx @@ -1,3 +1,14 @@ +import { + DndContext, + type DragEndEvent, + DragOverlay, + type DragStartEvent, +} from '@dnd-kit/core'; +import { restrictToVerticalAxis } from '@dnd-kit/modifiers'; +import { + SortableContext, + verticalListSortingStrategy, +} from '@dnd-kit/sortable'; import { useVirtualizer } from '@tanstack/react-virtual'; import { Menu, @@ -27,6 +38,7 @@ import styles from './TracksList.module.css'; const ROW_HEIGHT = 30; const ROW_HEIGHT_COMPACT = 24; +const DND_MODIFIERS = [restrictToVerticalAxis]; // -------------------------------------------------------------------------- // TrackList @@ -40,12 +52,7 @@ type Props = { playlists: Playlist[]; currentPlaylist?: string; reorderable?: boolean; - onReorder?: ( - playlistID: string, - tracksIDs: Set, - targetTrackID: string, - position: 'above' | 'below', - ) => void; + onReorder?: (tracks: Track[]) => void; }; export default function TracksList(props: Props) { @@ -61,9 +68,6 @@ export default function TracksList(props: Props) { } = props; const [selectedTracks, setSelectedTracks] = useState>(new Set()); - const [reorderedTracks, setReorderedTracks] = useState | null>( - new Set(), - ); const navigate = useNavigate(); const invalidate = useInvalidate(); @@ -214,21 +218,42 @@ export default function TracksList(props: Props) { ); /** - * Playlists re-order events handlers + * Playlist tracks re-order events handlers */ - const onReorderStart = useCallback( - () => setReorderedTracks(selectedTracks), - [selectedTracks], + const [dragOverlay, setDragOverlay] = useState(null); + + const onDragStart = useCallback( + (event: DragStartEvent) => { + const track = tracks[event.active.data.current?.index]; + + setDragOverlay(track); + }, + [tracks], ); - const onReorderEnd = useCallback(() => setReorderedTracks(null), []); - const onDrop = useCallback( - async (targetTrackID: string, position: 'above' | 'below') => { - if (onReorder && currentPlaylist && reorderedTracks) { - onReorder(currentPlaylist, reorderedTracks, targetTrackID, position); + const onDragEnd = useCallback( + (event: DragEndEvent) => { + const { + active, // dragged item + over, // on which item it was dropped + } = event; + + // The item was dropped either nowhere, or on the same item + if (over == null || active.id === over.id || !onReorder) { + return; } + + const activeIndex = tracks.findIndex((track) => track.id === active.id); + const overIndex = tracks.findIndex((track) => track.id === over.id); + + const newTracks = [...tracks]; + + const movedTrack = newTracks.splice(activeIndex, 1)[0]; // Remove active track + newTracks.splice(overIndex, 0, movedTrack); // Move it to where the user dropped it + + onReorder(newTracks); }, - [currentPlaylist, onReorder, reorderedTracks], + [onReorder, tracks], ); /** @@ -446,56 +471,75 @@ export default function TracksList(props: Props) { ); return ( -
- - {/* Scrollable element */} -
- - - {/* The large inner element to hold all of the items */} -
- {/* Only the visible items in the virtualizer, manually positioned to be in view */} - {virtualizer.getVirtualItems().map((virtualItem) => { - const track = tracks[virtualItem.index]; - return ( - - ); - })} + +
+ + {/* Scrollable element */} +
+ + + {/* The large inner element to hold all of the items */} +
+ + {/* Only the visible items in the virtualizer, manually positioned to be in view */} + {virtualizer.getVirtualItems().map((virtualItem) => { + const track = tracks[virtualItem.index]; + return ( + + ); + })} + + {dragOverlay ? ( + + ) : null} + + +
-
+ ); } diff --git a/src/routes/playlist-details.tsx b/src/routes/playlist-details.tsx index 76319799d..7717f2032 100644 --- a/src/routes/playlist-details.tsx +++ b/src/routes/playlist-details.tsx @@ -9,6 +9,7 @@ import { import TracksList from '../components/TracksList'; import * as ViewMessage from '../elements/ViewMessage'; +import type { Track } from '../generated/typings'; import useFilteredTracks from '../hooks/useFilteredTracks'; import useInvalidate from '../hooks/useInvalidate'; import usePlayingTrackID from '../hooks/usePlayingTrackID'; @@ -30,21 +31,13 @@ export default function ViewPlaylistDetails() { const filteredTracks = useFilteredTracks(playlistTracks); const onReorder = useCallback( - async ( - playlistID: string, - tracksIDs: Set, - targetTrackID: string, - position: 'above' | 'below', - ) => { - await PlaylistsAPI.reorderTracks( - playlistID, - Array.from(tracksIDs), - targetTrackID, - position, - ); - invalidate(); + async (tracks: Track[]) => { + if (playlistID != null) { + await PlaylistsAPI.reorderTracks(playlistID, tracks); + invalidate(); + } }, - [invalidate], + [invalidate, playlistID], ); if (playlistTracks.length === 0) { diff --git a/src/stores/PlaylistsAPI.ts b/src/stores/PlaylistsAPI.ts index a3831776f..f8f0cd9ac 100644 --- a/src/stores/PlaylistsAPI.ts +++ b/src/stores/PlaylistsAPI.ts @@ -1,4 +1,4 @@ -import type { Playlist } from '../generated/typings'; +import type { Playlist, Track } from '../generated/typings'; import database from '../lib/database'; import { logAndNotifyError } from '../lib/utils'; @@ -128,35 +128,14 @@ async function duplicate(playlistID: string): Promise { */ async function reorderTracks( playlistID: string, - tracksIDs: string[], - targetTrackID: string, - position: 'above' | 'below', + tracks: Track[], ): Promise { - if (tracksIDs.includes(targetTrackID)) return; - try { - const playlist: Playlist = await database.getPlaylist(playlistID); - - // Remove the current track - const newTracks = playlist.tracks.filter((id) => !tracksIDs.includes(id)); - - // Find where to insert the selected tracks - let targetIndex = newTracks.indexOf(targetTrackID); - - if (targetIndex === -1) { - throw new Error( - `Could not find targetTrackID in the playlist "${playlist.name}"`, - ); - } - - if (position === 'above') { - targetIndex -= 1; - } - - newTracks.splice(targetIndex + 1, 0, ...tracksIDs); - // Save and reload the playlist - await database.setPlaylistTracks(playlistID, newTracks); + await database.setPlaylistTracks( + playlistID, + tracks.map((track) => track.id), + ); } catch (err) { logAndNotifyError(err); }