mirror of
https://github.com/jeffvli/feishin.git
synced 2026-06-10 14:22:46 +02:00
add drag over expand/collapse behavior for playlist folders
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user