mirror of
https://github.com/Zagrios/bs-manager.git
synced 2026-07-03 14:08:25 +02:00
[feature-107] add drag and drop to playlist maps
This commit is contained in:
+28
-8
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user