From e7b65c8e86b328f17ba697ec9c20f639f2c4c0a7 Mon Sep 17 00:00:00 2001 From: jeffvli Date: Mon, 18 May 2026 19:15:28 -0700 Subject: [PATCH] add drag over expand/collapse behavior for playlist folders --- .../components/playlist-folder-tree.tsx | 186 ++++++++++++++++-- .../sidebar-playlist-list.module.css | 11 +- .../components/sidebar-playlist-list.tsx | 43 ++-- src/renderer/hooks/use-drag-drop.tsx | 8 +- 4 files changed, 205 insertions(+), 43 deletions(-) diff --git a/src/renderer/features/sidebar/components/playlist-folder-tree.tsx b/src/renderer/features/sidebar/components/playlist-folder-tree.tsx index b766ecd5c..486bce57f 100644 --- a/src/renderer/features/sidebar/components/playlist-folder-tree.tsx +++ b/src/renderer/features/sidebar/components/playlist-folder-tree.tsx @@ -2,12 +2,15 @@ import clsx from 'clsx'; import { motion } from 'motion/react'; import { ComponentPropsWithoutRef, + createContext, CSSProperties, MouseEvent, ReactElement, ReactNode, useCallback, + useContext, useMemo, + useRef, useState, } from 'react'; import { useTranslation } from 'react-i18next'; @@ -90,6 +93,42 @@ export const isDirectChildFolder = ( return relativePath.length > 0 && !relativePath.includes(separator); }; +export const canDropOnPlaylistFolder = ( + source: DragData, + folderPath: string, + separator: string, +): boolean => { + if (source.type === DragTarget.SIDEBAR_PLAYLIST_FOLDER) { + const sourceFolderPath = + source.id[0] ?? (source.metadata as undefined | { folderName?: string })?.folderName; + if (!sourceFolderPath) return false; + return isValidFolderNest(sourceFolderPath, folderPath, separator); + } + + if (source.itemType === LibraryItem.PLAYLIST) { + const items = source.item as Playlist[] | undefined; + return Array.isArray(items) && items.length > 0; + } + + return false; +}; + +export const canDragOverPlaylistFolder = ( + source: DragData, + folderPath: string, + separator: string, +): boolean => { + if (canDropOnPlaylistFolder(source, folderPath, separator)) { + return true; + } + + return ( + source.itemType !== undefined && + source.type !== DragTarget.PLAYLIST && + (source.operation?.includes(DragOperation.ADD) ?? false) + ); +}; + export const isValidFolderNest = ( sourceFolderPath: string, targetFolderPath: string, @@ -312,12 +351,134 @@ export const PlaylistRootAccordionControl = ({ ); }; +interface PlaylistFolderDragExpandContextValue { + onFolderDragHover: (folderPath: string) => void; + onFolderDragLeave: (folderPath: string) => void; + onFolderDrop: (folderPath: string) => void; +} + +const PlaylistFolderDragExpandContext = createContext( + null, +); + +interface PlaylistFolderDragExpandProviderProps { + children: ReactNode; + expandedSet: Set; + setMany: (paths: string[], shouldExpand: boolean) => void; +} + +export const PlaylistFolderDragExpandProvider = ({ + children, + expandedSet, + setMany, +}: PlaylistFolderDragExpandProviderProps) => { + const autoExpandedRef = useRef>(new Set()); + const activeHoveredRef = useRef(null); + + const collapseAutoExpanded = useCallback( + (paths: string[]) => { + const toCollapse = paths.filter((path) => autoExpandedRef.current.has(path)); + if (toCollapse.length === 0) return; + + for (const path of toCollapse) autoExpandedRef.current.delete(path); + setMany(toCollapse, false); + }, + [setMany], + ); + + const onFolderDragHover = useCallback( + (folderPath: string) => { + const previous = activeHoveredRef.current; + if (previous && previous !== folderPath) { + collapseAutoExpanded([previous]); + } + activeHoveredRef.current = folderPath; + + if (expandedSet.has(folderPath) || autoExpandedRef.current.has(folderPath)) return; + autoExpandedRef.current.add(folderPath); + setMany([folderPath], true); + }, + [collapseAutoExpanded, expandedSet, setMany], + ); + + const onFolderDragLeave = useCallback( + (folderPath: string) => { + if (activeHoveredRef.current === folderPath) { + activeHoveredRef.current = null; + } + collapseAutoExpanded([folderPath]); + }, + [collapseAutoExpanded], + ); + + const onFolderDrop = useCallback((folderPath: string) => { + autoExpandedRef.current.delete(folderPath); + if (activeHoveredRef.current === folderPath) { + activeHoveredRef.current = null; + } + }, []); + + const value = useMemo( + () => ({ onFolderDragHover, onFolderDragLeave, onFolderDrop }), + [onFolderDragHover, onFolderDragLeave, onFolderDrop], + ); + + return ( + + {children} + + ); +}; + +const usePlaylistFolderExpandDrop = (folderPath: string) => { + const separator = useSidebarPlaylistFolderSeparator(); + const dragExpand = useContext(PlaylistFolderDragExpandContext); + + return useDragDrop({ + drop: { + canDrop: (args) => canDragOverPlaylistFolder(args.source, folderPath, separator), + getData: () => ({ + id: [folderPath], + type: DragTarget.SIDEBAR_PLAYLIST_FOLDER, + }), + onDrag: ({ source }) => { + if (!dragExpand) return; + if (!canDragOverPlaylistFolder(source, folderPath, separator)) return; + dragExpand.onFolderDragHover(folderPath); + }, + onDragLeave: () => { + dragExpand?.onFolderDragLeave(folderPath); + }, + onDrop: () => { + dragExpand?.onFolderDrop(folderPath); + }, + }, + isEnabled: Boolean(dragExpand), + }); +}; + +interface PlaylistFolderProps { + children: ReactNode; + folderPath: string; +} + +const PlaylistFolder = ({ children, folderPath }: PlaylistFolderProps) => { + const { ref } = usePlaylistFolderExpandDrop(folderPath); + + return ( +
+ {children} +
+ ); +}; + // Drag-and-drop on folder headers: folders can be dragged, and accept folder or playlist drops. const usePlaylistFolderDrop = (folderPath: string, allPlaylists: Playlist[]) => { const { t } = useTranslation(); const serverId = useCurrentServerId(); const separator = useSidebarPlaylistFolderSeparator(); const updateMutation = useUpdatePlaylist({}); + const dragExpand = useContext(PlaylistFolderDragExpandContext); const handleDrop = useCallback( async (source: DragData) => { @@ -402,7 +563,6 @@ const usePlaylistFolderDrop = (folderPath: string, allPlaylists: Playlist[]) => const { isDraggedOver, isDragging, ref } = useDragDrop({ drag: { - // Folders are virtual; drag data carries the folder path, not playlist items. getId: () => [folderPath], getItem: () => [], metadata: { folderName: folderPath }, @@ -410,20 +570,7 @@ const usePlaylistFolderDrop = (folderPath: string, allPlaylists: Playlist[]) => target: DragTarget.SIDEBAR_PLAYLIST_FOLDER, }, drop: { - canDrop: (args) => { - if (args.source.type === DragTarget.SIDEBAR_PLAYLIST_FOLDER) { - const sourceFolderPath = - args.source.id[0] ?? - (args.source.metadata as undefined | { folderName?: string })?.folderName; - if (!sourceFolderPath) return false; - return isValidFolderNest(sourceFolderPath, folderPath, separator); - } - - // Single playlist rows can also be dropped onto a folder header. - if (args.source.itemType !== LibraryItem.PLAYLIST) return false; - const items = args.source.item as Playlist[] | undefined; - return Array.isArray(items) && items.length > 0; - }, + canDrop: (args) => canDropOnPlaylistFolder(args.source, folderPath, separator), getData: () => ({ id: [folderPath], type: DragTarget.SIDEBAR_PLAYLIST_FOLDER, @@ -435,6 +582,7 @@ const usePlaylistFolderDrop = (folderPath: string, allPlaylists: Playlist[]) => return; }, onDrop: ({ source }) => { + dragExpand?.onFolderDrop(folderPath); void handleDrop(source); }, }, @@ -738,7 +886,7 @@ export const PlaylistFolderTree = ({ const isOpen = expandedSet.has(group.name); return ( -
+ ))} -
+ ); })} @@ -800,7 +948,7 @@ export const PlaylistFolderTreeView = ({ const isOpen = expandedSet.has(node.path); return ( -
+ ))} -
+ ); }; diff --git a/src/renderer/features/sidebar/components/sidebar-playlist-list.module.css b/src/renderer/features/sidebar/components/sidebar-playlist-list.module.css index ff2ac0799..959674a45 100644 --- a/src/renderer/features/sidebar/components/sidebar-playlist-list.module.css +++ b/src/renderer/features/sidebar/components/sidebar-playlist-list.module.css @@ -57,9 +57,14 @@ } -.row-dragged-over { - border-radius: var(--mantine-radius-sm); - box-shadow: 0 0 0 2px var(--theme-colors-primary); +.row-dragged-over::after { + position: absolute; + inset: 0; + z-index: 1; + pointer-events: none; + content: ""; + border: 2px solid var(--theme-colors-primary); + border-radius: var(--theme-radius-md); opacity: 0.8; } diff --git a/src/renderer/features/sidebar/components/sidebar-playlist-list.tsx b/src/renderer/features/sidebar/components/sidebar-playlist-list.tsx index a55cad901..afc17e9cd 100644 --- a/src/renderer/features/sidebar/components/sidebar-playlist-list.tsx +++ b/src/renderer/features/sidebar/components/sidebar-playlist-list.tsx @@ -19,6 +19,7 @@ import { import { usePlayButtonClick } from '/@/renderer/features/shared/hooks/use-play-button-click'; import { collectFolderPaths, + PlaylistFolderDragExpandProvider, PlaylistFolderViews, PlaylistRootAccordionControl, usePlaylistFolderState, @@ -623,15 +624,17 @@ export const SidebarPlaylistList = () => { - + + + ); @@ -771,7 +774,7 @@ export const SidebarSharedPlaylistList = () => { const folderViewState = usePlaylistFolderViewState(playlistItems?.items ?? []); const navigation = usePlaylistNavigationState(); - const { expandedSet, toggle } = usePlaylistFolderState('shared'); + const { expandedSet, setMany, toggle } = usePlaylistFolderState('shared'); const inNavigation = folderViewState.folderView === 'navigation' && navigation.pathStack.length > 0; @@ -807,15 +810,17 @@ export const SidebarSharedPlaylistList = () => { - + + + ); diff --git a/src/renderer/hooks/use-drag-drop.tsx b/src/renderer/hooks/use-drag-drop.tsx index 42c331e50..ea599fbb8 100644 --- a/src/renderer/hooks/use-drag-drop.tsx +++ b/src/renderer/hooks/use-drag-drop.tsx @@ -37,7 +37,7 @@ interface UseDraggableProps { drop?: { canDrop: (args: { source: DragData }) => boolean; getData: () => DragData; - onDrag: (args: { edge: Edge | null }) => void; + onDrag: (args: { edge: Edge | null; source: DragData }) => void; onDragLeave: () => void; onDrop: (args: { edge: Edge | null; self: DragData; source: DragData }) => void; }; @@ -139,10 +139,14 @@ export const useDragDrop = ({ }, onDrag: (args) => { const closestEdgeOfTarget: Edge | null = extractClosestEdge(args.self.data); - drop.onDrag?.({ edge: closestEdgeOfTarget }); + drop.onDrag?.({ + edge: closestEdgeOfTarget, + source: args.source.data as unknown as DragData, + }); setIsDraggedOver(closestEdgeOfTarget); }, onDragLeave: () => { + drop.onDragLeave?.(); setIsDraggedOver(null); }, onDrop: (args) => {