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 { motion } from 'motion/react';
import { import {
ComponentPropsWithoutRef, ComponentPropsWithoutRef,
createContext,
CSSProperties, CSSProperties,
MouseEvent, MouseEvent,
ReactElement, ReactElement,
ReactNode, ReactNode,
useCallback, useCallback,
useContext,
useMemo, useMemo,
useRef,
useState, useState,
} from 'react'; } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -90,6 +93,42 @@ export const isDirectChildFolder = (
return relativePath.length > 0 && !relativePath.includes(separator); 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 = ( export const isValidFolderNest = (
sourceFolderPath: string, sourceFolderPath: string,
targetFolderPath: 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. // Drag-and-drop on folder headers: folders can be dragged, and accept folder or playlist drops.
const usePlaylistFolderDrop = (folderPath: string, allPlaylists: Playlist[]) => { const usePlaylistFolderDrop = (folderPath: string, allPlaylists: Playlist[]) => {
const { t } = useTranslation(); const { t } = useTranslation();
const serverId = useCurrentServerId(); const serverId = useCurrentServerId();
const separator = useSidebarPlaylistFolderSeparator(); const separator = useSidebarPlaylistFolderSeparator();
const updateMutation = useUpdatePlaylist({}); const updateMutation = useUpdatePlaylist({});
const dragExpand = useContext(PlaylistFolderDragExpandContext);
const handleDrop = useCallback( const handleDrop = useCallback(
async (source: DragData) => { async (source: DragData) => {
@@ -402,7 +563,6 @@ const usePlaylistFolderDrop = (folderPath: string, allPlaylists: Playlist[]) =>
const { isDraggedOver, isDragging, ref } = useDragDrop<HTMLButtonElement>({ const { isDraggedOver, isDragging, ref } = useDragDrop<HTMLButtonElement>({
drag: { drag: {
// Folders are virtual; drag data carries the folder path, not playlist items.
getId: () => [folderPath], getId: () => [folderPath],
getItem: () => [], getItem: () => [],
metadata: { folderName: folderPath }, metadata: { folderName: folderPath },
@@ -410,20 +570,7 @@ const usePlaylistFolderDrop = (folderPath: string, allPlaylists: Playlist[]) =>
target: DragTarget.SIDEBAR_PLAYLIST_FOLDER, target: DragTarget.SIDEBAR_PLAYLIST_FOLDER,
}, },
drop: { drop: {
canDrop: (args) => { canDrop: (args) => canDropOnPlaylistFolder(args.source, folderPath, separator),
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;
},
getData: () => ({ getData: () => ({
id: [folderPath], id: [folderPath],
type: DragTarget.SIDEBAR_PLAYLIST_FOLDER, type: DragTarget.SIDEBAR_PLAYLIST_FOLDER,
@@ -435,6 +582,7 @@ const usePlaylistFolderDrop = (folderPath: string, allPlaylists: Playlist[]) =>
return; return;
}, },
onDrop: ({ source }) => { onDrop: ({ source }) => {
dragExpand?.onFolderDrop(folderPath);
void handleDrop(source); void handleDrop(source);
}, },
}, },
@@ -738,7 +886,7 @@ export const PlaylistFolderTree = ({
const isOpen = expandedSet.has(group.name); const isOpen = expandedSet.has(group.name);
return ( return (
<div className={styles.folder} key={`folder:${group.name}`}> <PlaylistFolder folderPath={group.name} key={`folder:${group.name}`}>
<PlaylistFolderHeader <PlaylistFolderHeader
allPlaylists={allPlaylists} allPlaylists={allPlaylists}
folderPath={group.name} folderPath={group.name}
@@ -760,7 +908,7 @@ export const PlaylistFolderTree = ({
/> />
))} ))}
</PlaylistFolderCollapse> </PlaylistFolderCollapse>
</div> </PlaylistFolder>
); );
})} })}
</> </>
@@ -800,7 +948,7 @@ export const PlaylistFolderTreeView = ({
const isOpen = expandedSet.has(node.path); const isOpen = expandedSet.has(node.path);
return ( return (
<div className={styles.folder} key={`folder:${node.path}`}> <PlaylistFolder folderPath={node.path} key={`folder:${node.path}`}>
<PlaylistFolderHeader <PlaylistFolderHeader
allPlaylists={allPlaylists} allPlaylists={allPlaylists}
folderPath={node.path} folderPath={node.path}
@@ -817,7 +965,7 @@ export const PlaylistFolderTreeView = ({
</div> </div>
))} ))}
</PlaylistFolderCollapse> </PlaylistFolderCollapse>
</div> </PlaylistFolder>
); );
}; };
@@ -57,9 +57,14 @@
} }
.row-dragged-over { .row-dragged-over::after {
border-radius: var(--mantine-radius-sm); position: absolute;
box-shadow: 0 0 0 2px var(--theme-colors-primary); 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; opacity: 0.8;
} }
@@ -19,6 +19,7 @@ import {
import { usePlayButtonClick } from '/@/renderer/features/shared/hooks/use-play-button-click'; import { usePlayButtonClick } from '/@/renderer/features/shared/hooks/use-play-button-click';
import { import {
collectFolderPaths, collectFolderPaths,
PlaylistFolderDragExpandProvider,
PlaylistFolderViews, PlaylistFolderViews,
PlaylistRootAccordionControl, PlaylistRootAccordionControl,
usePlaylistFolderState, usePlaylistFolderState,
@@ -623,6 +624,7 @@ export const SidebarPlaylistList = () => {
</Group> </Group>
</PlaylistRootAccordionControl> </PlaylistRootAccordionControl>
<Accordion.Panel> <Accordion.Panel>
<PlaylistFolderDragExpandProvider expandedSet={expandedSet} setMany={setMany}>
<PlaylistFolderViews <PlaylistFolderViews
{...folderViewState} {...folderViewState}
allPlaylists={playlistItems?.items ?? []} allPlaylists={playlistItems?.items ?? []}
@@ -632,6 +634,7 @@ export const SidebarPlaylistList = () => {
onReorder={handleReorder} onReorder={handleReorder}
onToggleFolder={toggle} onToggleFolder={toggle}
/> />
</PlaylistFolderDragExpandProvider>
</Accordion.Panel> </Accordion.Panel>
</Accordion.Item> </Accordion.Item>
); );
@@ -771,7 +774,7 @@ export const SidebarSharedPlaylistList = () => {
const folderViewState = usePlaylistFolderViewState(playlistItems?.items ?? []); const folderViewState = usePlaylistFolderViewState(playlistItems?.items ?? []);
const navigation = usePlaylistNavigationState(); const navigation = usePlaylistNavigationState();
const { expandedSet, toggle } = usePlaylistFolderState('shared'); const { expandedSet, setMany, toggle } = usePlaylistFolderState('shared');
const inNavigation = const inNavigation =
folderViewState.folderView === 'navigation' && navigation.pathStack.length > 0; folderViewState.folderView === 'navigation' && navigation.pathStack.length > 0;
@@ -807,6 +810,7 @@ export const SidebarSharedPlaylistList = () => {
</Group> </Group>
</Accordion.Control> </Accordion.Control>
<Accordion.Panel> <Accordion.Panel>
<PlaylistFolderDragExpandProvider expandedSet={expandedSet} setMany={setMany}>
<PlaylistFolderViews <PlaylistFolderViews
{...folderViewState} {...folderViewState}
allPlaylists={playlistItems?.items ?? []} allPlaylists={playlistItems?.items ?? []}
@@ -816,6 +820,7 @@ export const SidebarSharedPlaylistList = () => {
onReorder={handleReorder} onReorder={handleReorder}
onToggleFolder={toggle} onToggleFolder={toggle}
/> />
</PlaylistFolderDragExpandProvider>
</Accordion.Panel> </Accordion.Panel>
</Accordion.Item> </Accordion.Item>
); );
+6 -2
View File
@@ -37,7 +37,7 @@ interface UseDraggableProps {
drop?: { drop?: {
canDrop: (args: { source: DragData }) => boolean; canDrop: (args: { source: DragData }) => boolean;
getData: () => DragData; getData: () => DragData;
onDrag: (args: { edge: Edge | null }) => void; onDrag: (args: { edge: Edge | null; source: DragData }) => void;
onDragLeave: () => void; onDragLeave: () => void;
onDrop: (args: { edge: Edge | null; self: DragData; source: DragData }) => void; onDrop: (args: { edge: Edge | null; self: DragData; source: DragData }) => void;
}; };
@@ -139,10 +139,14 @@ export const useDragDrop = <TElement extends HTMLElement>({
}, },
onDrag: (args) => { onDrag: (args) => {
const closestEdgeOfTarget: Edge | null = extractClosestEdge(args.self.data); 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); setIsDraggedOver(closestEdgeOfTarget);
}, },
onDragLeave: () => { onDragLeave: () => {
drop.onDragLeave?.();
setIsDraggedOver(null); setIsDraggedOver(null);
}, },
onDrop: (args) => { onDrop: (args) => {