[feature-107] add drag and drop to playlist maps

This commit is contained in:
MathieuG-P
2024-06-21 21:08:41 +02:00
parent 44c63a63c6
commit 33be4de2ff
7 changed files with 303 additions and 19 deletions
@@ -33,6 +33,7 @@ import { getCorrectTextColor } from "renderer/helpers/correct-text-color";
import { BPList } from "shared/models/playlists/playlist.interface";
import { EditPlaylistInfosModal } from "./edit-playlist-infos-modal.component";
import { CrossIcon } from "renderer/components/svgs/icons/cross-icon.component";
import { DraggableVirtualScroll } from "renderer/components/shared/virtual-scroll/draggable-virtual-scroll.component";
type Props = {
version?: BSVersion;
@@ -84,8 +85,6 @@ export const EditPlaylistModal: ModalComponent<BPList, Props> = ({ resolver, opt
const displayablePlaylistMaps = useMemo(() => playlistMaps ? Object.values(playlistMaps).filter(Boolean) : [], [playlistMaps]);
const [base64Cover, setBase64Cover] = useState<string>(undefined);
useOnUpdate(() => {
const keyDown = (e: KeyboardEvent) => keyPressed$.next(e.key);
document.addEventListener("keydown", keyDown);
@@ -205,7 +204,7 @@ export const EditPlaylistModal: ModalComponent<BPList, Props> = ({ resolver, opt
map: localMap,
mapsSource$: localMaps$ as BehaviorSubject<(BsmLocalMap|BsvMapDetail|SongDetails)[]>,
selectedHashs$: availabledHashsSelected$,
noKeyPressedFallBack: () => playlistMaps$.next(Object.assign({[mapHash]: localMap}, playlistMaps$.value))
noKeyPressedFallBack: () => playlistMaps$.next({...playlistMaps$.value, [mapHash]: localMap})
}), isSelected$);
}, []);
@@ -238,7 +237,7 @@ export const EditPlaylistModal: ModalComponent<BPList, Props> = ({ resolver, opt
<VirtualScroll
classNames={{
mainDiv: "bg-theme-1 rounded-md size-full min-w-0",
rows: "my-2.5 px-2.5"
rows: "py-2.5 px-2.5"
}}
items={maps}
itemHeight={110}
@@ -323,7 +322,7 @@ export const EditPlaylistModal: ModalComponent<BPList, Props> = ({ resolver, opt
}).filter(duration => !isNaN(duration));
const totalDuration = durations.reduce((acc, duration) => acc + duration, 0);
return totalDuration > 3600 ? dateFormat(totalDuration * 1000, "h:MM:ss") : dateFormat(totalDuration * 1000, "MM:ss");
return totalDuration > 3600 ? dateFormat(totalDuration * 1000, "H:MM:ss") : dateFormat(totalDuration * 1000, "MM:ss");
}, [playlistMaps]);
const [playlistMinNps, playlistMaxNps] = useMemo(() => {
@@ -377,6 +376,15 @@ export const EditPlaylistModal: ModalComponent<BPList, Props> = ({ resolver, opt
resolver({ exitCode: ModalExitCode.COMPLETED, data: bpList});
};
const handlePlaylistMapDragEnd = useCallback((fromIndex: number, toIndex: number) => {
const playlistMapsArray = Object.values(playlistMaps$.value ?? {});
const newPlaylistMaps = [...playlistMapsArray];
const [removed] = newPlaylistMaps.splice(fromIndex, 1);
newPlaylistMaps.splice(toIndex, 0, removed);
playlistMaps$.next(Object.fromEntries(newPlaylistMaps.map(map => [getHashOfMap(map), map])));
}, []);
return (
<div className="w-screen h-screen max-h-[calc(100vh-2rem)] max-w-[55rem] lg:max-w-[66rem] xl:max-w-[77rem] 2xl:max-w-[88rem] bg-theme-3 p-4 rounded-md relative">
<button className="absolute top-1.5 right-1.5 size-3" onClick={() => resolver({ exitCode: ModalExitCode.CANCELED })}>
@@ -436,9 +444,21 @@ export const EditPlaylistModal: ModalComponent<BPList, Props> = ({ resolver, opt
<FilterPanel className="absolute top-[calc(100%+3px)] origin-top w-[500px] h-fit p-2 rounded-md shadow-md shadow-black -translate-x-[40%]" filter={playlistMapsFilter} onChange={setPlaylistMapsFilter}/>
</BsmDropdownButton>
</div>
{renderList(displayablePlaylistMaps.filter(map => {
return isMapFitFilter({ map, filter: playlistMapsFilter, search: playlistMapsSearch });
}), renderPlaylistMapItem)}
<div className="overflow-hidden size-full">
<DraggableVirtualScroll
classNames={{
mainDiv: "bg-theme-1 rounded-md size-full min-w-0",
rows: "py-2.5 px-2.5"
}}
itemHeight={110}
items={displayablePlaylistMaps.filter(map => {
return isMapFitFilter({ map, filter: playlistMapsFilter, search: playlistMapsSearch });
})}
isDragDisabled={!!Object.keys(playlistMapsFilter).length || !!playlistMapsSearch}
renderItem={renderPlaylistMapItem}
onDragEnd={handlePlaylistMapDragEnd}
/>
</div>
<div className="w-full h-4 flex justify-end gap-3 mt-px">
<div className="h-full flex justify-center items-center gap-0.5">
<MapIcon className='h-full aspect-square mt-0.5'/>
@@ -15,8 +15,6 @@ export function Modal() {
const modals = useObservable(() => modals$);
const currentModal = useObservable<ModalObject>(() =>modals$.pipe(map(modals => modals?.at(-1))));
console.log(currentModal, modals, !!currentModal);
useEffect(() => {
const onEscape = (e: KeyboardEvent) => {
if (e.key !== "Escape") {
@@ -0,0 +1,119 @@
import { CSSProperties, useCallback, useRef } from "react";
import { DragDropContext, Draggable, DraggableProvided, DropResult, Droppable } from "react-beautiful-dnd";
import { ListChildComponentProps, VariableSizeList } from "react-window";
import { cn } from "renderer/helpers/css-class.helpers";
import { useObserveSize } from "renderer/hooks/use-observe-size.hook";
import equal from "fast-deep-equal";
import { typedMemo } from "renderer/helpers/typed-memo";
type DraggableVirtualScrollClassNames = {
mainDiv?: string;
variableList?: string;
rows?: string;
}
type Props<T = unknown> = {
className?: string;
classNames?: DraggableVirtualScrollClassNames;
itemHeight: number;
items: T[];
isDragDisabled?: boolean;
renderItem: (item: T) => JSX.Element;
onDragEnd?: (fromIndex: number, toIndex: number) => void;
}
export function DraggableVirtualScroll<T = unknown>({
className,
classNames,
items,
itemHeight,
isDragDisabled,
renderItem,
onDragEnd
}: Props<T>) {
const ref = useRef(null);
const { height } = useObserveSize({ ref, debounce: 100 });
const handleOnDragEnd = useCallback((result: DropResult) => {
if(!onDragEnd){ return; }
if (!result.destination || result.destination.index === result.source.index) { return; }
onDragEnd(result.source.index, result.destination.index);
}, [onDragEnd]);
const renderDraggedItem = useCallback((renderItem: (item: T) => JSX.Element, itemProps: T, provided: DraggableProvided) => {
return (
<div ref={provided.innerRef} {...provided.draggableProps} {...provided.dragHandleProps} className={cn("list-none outline-none", classNames?.rows)}>
{renderItem(itemProps)}
</div>
)
}, [renderItem]);
const renderRow = useCallback((props: ListChildComponentProps<T[]>) => {
return (
<DraggableRow
item={props.data[props.index]}
renderItem={renderItem}
index={props.index}
className={classNames?.rows}
style={props.style}
isDragDisabled={isDragDisabled}
/>
)
}, [renderItem, isDragDisabled]);
return (
<div ref={ref} className={cn(className, classNames?.mainDiv)}>
<DragDropContext onDragEnd={handleOnDragEnd}>
<Droppable droppableId="idtemp" mode="virtual" renderClone={(provided, _, rubric) => {
return renderDraggedItem(renderItem, items[rubric.source.index], provided)
}}>
{provided => (
<VariableSizeList
outerRef={provided.innerRef}
className={cn("scrollbar-default", classNames?.variableList)}
width="100%" height={height}
layout="vertical"
itemCount={items.length}
itemSize={() => itemHeight}
itemData={items}
style={{ scrollbarGutter: "stable both-edges" }}
direction="vertical"
>
{renderRow}
</VariableSizeList>
)}
</Droppable>
</DragDropContext>
</div>
)
}
type DraggableRowProps<T = unknown> = {
className?: string;
style?: CSSProperties;
item: T;
index: number;
isDragDisabled?: boolean;
renderItem: (item: T) => JSX.Element;
}
function DraggableRowComponent<T>({ item, renderItem, className, style, index, isDragDisabled }: DraggableRowProps<T>) {
const innerRender = useCallback((provided: DraggableProvided) => {
return (
<div ref={provided.innerRef} {...provided.draggableProps} {...provided.dragHandleProps} className={cn("list-none outline-none", className)} style={{ ...style, ...provided.draggableProps.style }}>
{renderItem(item)}
</div>
)
}, [item]);
return (
<Draggable draggableId={index.toString()} index={index} isDragDisabled={isDragDisabled}>
{innerRender}
</Draggable>
)
}
const DraggableRow = typedMemo(DraggableRowComponent, equal);
@@ -42,8 +42,6 @@ export function VirtualScroll<T = unknown>({ className, classNames, minItemWidth
const listHeight$ = useConstant(() => new BehaviorSubject<number>(0));
const listHeight = useObservable(() => listHeight$.pipe(distinctUntilChanged(), debounceTime(100)), 0);
console.log(listHeight);
useLayoutEffect(() => {
const updateItemPerRow = (listWidth: number) => {
if (!listWidth) return;
@@ -101,7 +99,6 @@ export function VirtualScroll<T = unknown>({ className, classNames, minItemWidth
itemData={itemsToRender}
style={{ scrollbarGutter: "stable both-edges" }}
onScroll={handleScroll}
>
{renderRow}
</VariableSizeList>
@@ -0,0 +1,34 @@
import { RefObject, useEffect, useState } from "react";
import { useConstant } from "./use-constant.hook";
import { BehaviorSubject, debounceTime, distinctUntilChanged } from "rxjs";
import { useObservable } from "./use-observable.hook";
type Props = {
ref: RefObject<HTMLElement>;
deps?: unknown[];
debounce?: number;
}
export function useObserveSize(opt: Props): { width: number; height: number } {
const size$ = useConstant(() => new BehaviorSubject({ width: 0, height: 0 }));
const size = useObservable(() => size$.pipe(debounceTime(opt.debounce ?? 0), distinctUntilChanged()), { width: 0, height: 0 });
useEffect(() => {
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (!entry) return;
size$.next({ width: entry.contentRect.width, height: entry.contentRect.height });
});
if (opt.ref?.current) {
observer.observe(opt?.ref?.current);
}
return () => {
observer.disconnect();
};
}, opt.deps ?? []);
return size;
}