add drag over expand/collapse behavior for playlist folders

This commit is contained in:
jeffvli
2026-05-18 19:15:28 -07:00
parent 3d1095dbd8
commit e7b65c8e86
4 changed files with 205 additions and 43 deletions
@@ -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 | PlaylistFolderDragExpandContextValue>(
null,
);
interface PlaylistFolderDragExpandProviderProps {
children: ReactNode;
expandedSet: Set<string>;
setMany: (paths: string[], shouldExpand: boolean) => void;
}
export const PlaylistFolderDragExpandProvider = ({
children,
expandedSet,
setMany,
}: PlaylistFolderDragExpandProviderProps) => {
const autoExpandedRef = useRef<Set<string>>(new Set());
const activeHoveredRef = useRef<null | string>(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 (
<PlaylistFolderDragExpandContext.Provider value={value}>
{children}
</PlaylistFolderDragExpandContext.Provider>
);
};
const usePlaylistFolderExpandDrop = (folderPath: string) => {
const separator = useSidebarPlaylistFolderSeparator();
const dragExpand = useContext(PlaylistFolderDragExpandContext);
return useDragDrop<HTMLDivElement>({
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 (
<div className={styles.folder} ref={ref}>
{children}
</div>
);
};
// 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<HTMLButtonElement>({
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 (
<div className={styles.folder} key={`folder:${group.name}`}>
<PlaylistFolder folderPath={group.name} key={`folder:${group.name}`}>
<PlaylistFolderHeader
allPlaylists={allPlaylists}
folderPath={group.name}
@@ -760,7 +908,7 @@ export const PlaylistFolderTree = ({
/>
))}
</PlaylistFolderCollapse>
</div>
</PlaylistFolder>
);
})}
</>
@@ -800,7 +948,7 @@ export const PlaylistFolderTreeView = ({
const isOpen = expandedSet.has(node.path);
return (
<div className={styles.folder} key={`folder:${node.path}`}>
<PlaylistFolder folderPath={node.path} key={`folder:${node.path}`}>
<PlaylistFolderHeader
allPlaylists={allPlaylists}
folderPath={node.path}
@@ -817,7 +965,7 @@ export const PlaylistFolderTreeView = ({
</div>
))}
</PlaylistFolderCollapse>
</div>
</PlaylistFolder>
);
};
@@ -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;
}
@@ -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 = () => {
</Group>
</PlaylistRootAccordionControl>
<Accordion.Panel>
<PlaylistFolderViews
{...folderViewState}
allPlaylists={playlistItems?.items ?? []}
expandedSet={expandedSet}
navigation={navigation}
onContextMenu={handleContextMenu}
onReorder={handleReorder}
onToggleFolder={toggle}
/>
<PlaylistFolderDragExpandProvider expandedSet={expandedSet} setMany={setMany}>
<PlaylistFolderViews
{...folderViewState}
allPlaylists={playlistItems?.items ?? []}
expandedSet={expandedSet}
navigation={navigation}
onContextMenu={handleContextMenu}
onReorder={handleReorder}
onToggleFolder={toggle}
/>
</PlaylistFolderDragExpandProvider>
</Accordion.Panel>
</Accordion.Item>
);
@@ -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 = () => {
</Group>
</Accordion.Control>
<Accordion.Panel>
<PlaylistFolderViews
{...folderViewState}
allPlaylists={playlistItems?.items ?? []}
expandedSet={expandedSet}
navigation={navigation}
onContextMenu={handleContextMenu}
onReorder={handleReorder}
onToggleFolder={toggle}
/>
<PlaylistFolderDragExpandProvider expandedSet={expandedSet} setMany={setMany}>
<PlaylistFolderViews
{...folderViewState}
allPlaylists={playlistItems?.items ?? []}
expandedSet={expandedSet}
navigation={navigation}
onContextMenu={handleContextMenu}
onReorder={handleReorder}
onToggleFolder={toggle}
/>
</PlaylistFolderDragExpandProvider>
</Accordion.Panel>
</Accordion.Item>
);
+6 -2
View File
@@ -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 = <TElement extends HTMLElement>({
},
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) => {