mirror of
https://github.com/jeffvli/feishin.git
synced 2026-06-18 09:24:19 +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 { 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,15 +624,17 @@ export const SidebarPlaylistList = () => {
|
|||||||
</Group>
|
</Group>
|
||||||
</PlaylistRootAccordionControl>
|
</PlaylistRootAccordionControl>
|
||||||
<Accordion.Panel>
|
<Accordion.Panel>
|
||||||
<PlaylistFolderViews
|
<PlaylistFolderDragExpandProvider expandedSet={expandedSet} setMany={setMany}>
|
||||||
{...folderViewState}
|
<PlaylistFolderViews
|
||||||
allPlaylists={playlistItems?.items ?? []}
|
{...folderViewState}
|
||||||
expandedSet={expandedSet}
|
allPlaylists={playlistItems?.items ?? []}
|
||||||
navigation={navigation}
|
expandedSet={expandedSet}
|
||||||
onContextMenu={handleContextMenu}
|
navigation={navigation}
|
||||||
onReorder={handleReorder}
|
onContextMenu={handleContextMenu}
|
||||||
onToggleFolder={toggle}
|
onReorder={handleReorder}
|
||||||
/>
|
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,15 +810,17 @@ export const SidebarSharedPlaylistList = () => {
|
|||||||
</Group>
|
</Group>
|
||||||
</Accordion.Control>
|
</Accordion.Control>
|
||||||
<Accordion.Panel>
|
<Accordion.Panel>
|
||||||
<PlaylistFolderViews
|
<PlaylistFolderDragExpandProvider expandedSet={expandedSet} setMany={setMany}>
|
||||||
{...folderViewState}
|
<PlaylistFolderViews
|
||||||
allPlaylists={playlistItems?.items ?? []}
|
{...folderViewState}
|
||||||
expandedSet={expandedSet}
|
allPlaylists={playlistItems?.items ?? []}
|
||||||
navigation={navigation}
|
expandedSet={expandedSet}
|
||||||
onContextMenu={handleContextMenu}
|
navigation={navigation}
|
||||||
onReorder={handleReorder}
|
onContextMenu={handleContextMenu}
|
||||||
onToggleFolder={toggle}
|
onReorder={handleReorder}
|
||||||
/>
|
onToggleFolder={toggle}
|
||||||
|
/>
|
||||||
|
</PlaylistFolderDragExpandProvider>
|
||||||
</Accordion.Panel>
|
</Accordion.Panel>
|
||||||
</Accordion.Item>
|
</Accordion.Item>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user