diff --git a/package-lock.json b/package-lock.json index 2bb89b0c..84c34703 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42,6 +42,7 @@ "qrcode.react": "^3.1.0", "query-process": "^0.0.3", "react": "^18.2.0", + "react-beautiful-dnd": "^13.1.1", "react-colorful": "^5.6.1", "react-dom": "^18.2.0", "react-range": "^1.8.14", @@ -82,6 +83,7 @@ "@types/node-fetch": "^2.6.3", "@types/pako": "^2.0.1", "@types/react": "^18.0.33", + "@types/react-beautiful-dnd": "^13.1.8", "@types/react-dom": "^18.0.11", "@types/react-outside-click-handler": "^1.3.1", "@types/react-test-renderer": "^18.0.7", @@ -6565,6 +6567,15 @@ "@types/node": "*" } }, + "node_modules/@types/hoist-non-react-statics": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.5.tgz", + "integrity": "sha512-SbcrWzkKBw2cdwRTwQAswfpB9g9LJWfjtUeW/jvNwbhC8cpmmNYVePa+ncbUe0rGTQ7G3Ff6mYUN2VMfLVr+Sg==", + "dependencies": { + "@types/react": "*", + "hoist-non-react-statics": "^3.3.0" + } + }, "node_modules/@types/html-minifier-terser": { "version": "6.1.0", "dev": true, @@ -6737,7 +6748,6 @@ }, "node_modules/@types/prop-types": { "version": "15.7.5", - "devOptional": true, "license": "MIT" }, "node_modules/@types/qs": { @@ -6752,7 +6762,6 @@ }, "node_modules/@types/react": { "version": "18.0.33", - "devOptional": true, "license": "MIT", "dependencies": { "@types/prop-types": "*", @@ -6760,6 +6769,15 @@ "csstype": "^3.0.2" } }, + "node_modules/@types/react-beautiful-dnd": { + "version": "13.1.8", + "resolved": "https://registry.npmjs.org/@types/react-beautiful-dnd/-/react-beautiful-dnd-13.1.8.tgz", + "integrity": "sha512-E3TyFsro9pQuK4r8S/OL6G99eq7p8v29sX0PM7oT8Z+PJfZvSQTx4zTQbUJ+QZXioAF0e7TGBEcA1XhYhCweyQ==", + "dev": true, + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/react-dom": { "version": "18.0.11", "dev": true, @@ -6776,6 +6794,17 @@ "@types/react": "*" } }, + "node_modules/@types/react-redux": { + "version": "7.1.33", + "resolved": "https://registry.npmjs.org/@types/react-redux/-/react-redux-7.1.33.tgz", + "integrity": "sha512-NF8m5AjWCkert+fosDsN3hAlHzpjSiXlVy9EgQEmLoBhaNXbmyeGs/aj5dQzKuF+/q+S7JQagorGDW8pJ28Hmg==", + "dependencies": { + "@types/hoist-non-react-statics": "^3.3.0", + "@types/react": "*", + "hoist-non-react-statics": "^3.3.0", + "redux": "^4.0.0" + } + }, "node_modules/@types/react-test-renderer": { "version": "18.0.7", "dev": true, @@ -6832,7 +6861,6 @@ }, "node_modules/@types/scheduler": { "version": "0.16.2", - "devOptional": true, "license": "MIT" }, "node_modules/@types/semver": { @@ -9911,6 +9939,14 @@ "version": "4.2.0", "license": "MIT" }, + "node_modules/css-box-model": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/css-box-model/-/css-box-model-1.2.1.tgz", + "integrity": "sha512-a7Vr4Q/kd/aw96bnJG332W9V9LkJO69JRcaCYDUqjp6/z0w6VcZjgAcTbgFxEPfBgdnAwlh3iwu+hLopa+flJw==", + "dependencies": { + "tiny-invariant": "^1.0.6" + } + }, "node_modules/css-color-names": { "version": "0.0.4", "dev": true, @@ -10254,7 +10290,6 @@ }, "node_modules/csstype": { "version": "3.1.1", - "devOptional": true, "license": "MIT" }, "node_modules/damerau-levenshtein": { @@ -13529,6 +13564,14 @@ "@babel/runtime": "^7.7.6" } }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "dependencies": { + "react-is": "^16.7.0" + } + }, "node_modules/hosted-git-info": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", @@ -18747,7 +18790,6 @@ }, "node_modules/prop-types": { "version": "15.8.1", - "dev": true, "license": "MIT", "dependencies": { "loose-envify": "^1.4.0", @@ -19171,6 +19213,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/raf-schd": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.3.tgz", + "integrity": "sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ==" + }, "node_modules/randombytes": { "version": "2.1.0", "dev": true, @@ -19222,6 +19269,24 @@ "node": ">=0.10.0" } }, + "node_modules/react-beautiful-dnd": { + "version": "13.1.1", + "resolved": "https://registry.npmjs.org/react-beautiful-dnd/-/react-beautiful-dnd-13.1.1.tgz", + "integrity": "sha512-0Lvs4tq2VcrEjEgDXHjT98r+63drkKEgqyxdA7qD3mvKwga6a5SscbdLPO2IExotU1jW8L0Ksdl0Cj2AF67nPQ==", + "dependencies": { + "@babel/runtime": "^7.9.2", + "css-box-model": "^1.2.0", + "memoize-one": "^5.1.1", + "raf-schd": "^4.0.2", + "react-redux": "^7.2.0", + "redux": "^4.0.4", + "use-memo-one": "^1.1.1" + }, + "peerDependencies": { + "react": "^16.8.5 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.8.5 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/react-colorful": { "version": "5.6.1", "license": "MIT", @@ -19243,7 +19308,6 @@ }, "node_modules/react-is": { "version": "16.13.1", - "dev": true, "license": "MIT" }, "node_modules/react-range": { @@ -19254,6 +19318,35 @@ "react-dom": "^16.8.0-0 || ^17.0.0-0 || ^18.0.0-0" } }, + "node_modules/react-redux": { + "version": "7.2.9", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-7.2.9.tgz", + "integrity": "sha512-Gx4L3uM182jEEayZfRbI/G11ZpYdNAnBs70lFVMNdHJI76XYtR+7m0MN+eAs7UHBPhWXcnFPaS+9owSCJQHNpQ==", + "dependencies": { + "@babel/runtime": "^7.15.4", + "@types/react-redux": "^7.1.20", + "hoist-non-react-statics": "^3.3.2", + "loose-envify": "^1.4.0", + "prop-types": "^15.7.2", + "react-is": "^17.0.2" + }, + "peerDependencies": { + "react": "^16.8.3 || ^17 || ^18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, + "node_modules/react-redux/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==" + }, "node_modules/react-refresh": { "version": "0.14.0", "dev": true, @@ -19565,6 +19658,14 @@ "dev": true, "license": "MIT" }, + "node_modules/redux": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz", + "integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==", + "dependencies": { + "@babel/runtime": "^7.9.2" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.4", "dev": true, @@ -21425,6 +21526,11 @@ "dev": true, "license": "MIT" }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==" + }, "node_modules/tiny-typed-emitter": { "version": "2.1.0", "license": "MIT" @@ -22116,6 +22222,14 @@ } } }, + "node_modules/use-memo-one": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-memo-one/-/use-memo-one-1.1.3.tgz", + "integrity": "sha512-g66/K7ZQGYrI6dy8GLpVcMsBp4s17xNkYJVSMvTEevGy3nDxHOfE6z8BVE22+5G5x7t3+bhzrlTDB7ObrEE0cQ==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/use-sidecar": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.2.tgz", diff --git a/package.json b/package.json index 860c006d..ef8a5a8a 100644 --- a/package.json +++ b/package.json @@ -155,6 +155,7 @@ "@types/node-fetch": "^2.6.3", "@types/pako": "^2.0.1", "@types/react": "^18.0.33", + "@types/react-beautiful-dnd": "^13.1.8", "@types/react-dom": "^18.0.11", "@types/react-outside-click-handler": "^1.3.1", "@types/react-test-renderer": "^18.0.7", @@ -258,6 +259,7 @@ "qrcode.react": "^3.1.0", "query-process": "^0.0.3", "react": "^18.2.0", + "react-beautiful-dnd": "^13.1.1", "react-colorful": "^5.6.1", "react-dom": "^18.2.0", "react-range": "^1.8.14", diff --git a/src/renderer/components/modal/modal-types/playlist/edit-playlist-modal/edit-playlist-modal.component.tsx b/src/renderer/components/modal/modal-types/playlist/edit-playlist-modal/edit-playlist-modal.component.tsx index e6c55758..e5ea43e6 100644 --- a/src/renderer/components/modal/modal-types/playlist/edit-playlist-modal/edit-playlist-modal.component.tsx +++ b/src/renderer/components/modal/modal-types/playlist/edit-playlist-modal/edit-playlist-modal.component.tsx @@ -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 = ({ resolver, opt const displayablePlaylistMaps = useMemo(() => playlistMaps ? Object.values(playlistMaps).filter(Boolean) : [], [playlistMaps]); - const [base64Cover, setBase64Cover] = useState(undefined); - useOnUpdate(() => { const keyDown = (e: KeyboardEvent) => keyPressed$.next(e.key); document.addEventListener("keydown", keyDown); @@ -205,7 +204,7 @@ export const EditPlaylistModal: ModalComponent = ({ 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 = ({ resolver, opt = ({ 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 = ({ 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 (
- {renderList(displayablePlaylistMaps.filter(map => { - return isMapFitFilter({ map, filter: playlistMapsFilter, search: playlistMapsSearch }); - }), renderPlaylistMapItem)} +
+ { + return isMapFitFilter({ map, filter: playlistMapsFilter, search: playlistMapsSearch }); + })} + isDragDisabled={!!Object.keys(playlistMapsFilter).length || !!playlistMapsSearch} + renderItem={renderPlaylistMapItem} + onDragEnd={handlePlaylistMapDragEnd} + /> +
diff --git a/src/renderer/components/modal/modal.component.tsx b/src/renderer/components/modal/modal.component.tsx index b75298f9..aabf741a 100644 --- a/src/renderer/components/modal/modal.component.tsx +++ b/src/renderer/components/modal/modal.component.tsx @@ -15,8 +15,6 @@ export function Modal() { const modals = useObservable(() => modals$); const currentModal = useObservable(() =>modals$.pipe(map(modals => modals?.at(-1)))); - console.log(currentModal, modals, !!currentModal); - useEffect(() => { const onEscape = (e: KeyboardEvent) => { if (e.key !== "Escape") { diff --git a/src/renderer/components/shared/virtual-scroll/draggable-virtual-scroll.component.tsx b/src/renderer/components/shared/virtual-scroll/draggable-virtual-scroll.component.tsx new file mode 100644 index 00000000..d74fe7e9 --- /dev/null +++ b/src/renderer/components/shared/virtual-scroll/draggable-virtual-scroll.component.tsx @@ -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 = { + className?: string; + classNames?: DraggableVirtualScrollClassNames; + itemHeight: number; + items: T[]; + isDragDisabled?: boolean; + renderItem: (item: T) => JSX.Element; + onDragEnd?: (fromIndex: number, toIndex: number) => void; +} + +export function DraggableVirtualScroll({ + className, + classNames, + items, + itemHeight, + isDragDisabled, + renderItem, + onDragEnd +}: Props) { + + 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 ( +
+ {renderItem(itemProps)} +
+ ) + }, [renderItem]); + + const renderRow = useCallback((props: ListChildComponentProps) => { + return ( + + ) + }, [renderItem, isDragDisabled]); + + return ( +
+ + { + return renderDraggedItem(renderItem, items[rubric.source.index], provided) + }}> + {provided => ( + itemHeight} + itemData={items} + style={{ scrollbarGutter: "stable both-edges" }} + direction="vertical" + > + {renderRow} + + )} + + +
+ ) +} + +type DraggableRowProps = { + className?: string; + style?: CSSProperties; + item: T; + index: number; + isDragDisabled?: boolean; + renderItem: (item: T) => JSX.Element; +} + +function DraggableRowComponent({ item, renderItem, className, style, index, isDragDisabled }: DraggableRowProps) { + + const innerRender = useCallback((provided: DraggableProvided) => { + return ( +
+ {renderItem(item)} +
+ ) + }, [item]); + + return ( + + {innerRender} + + ) + +} + +const DraggableRow = typedMemo(DraggableRowComponent, equal); diff --git a/src/renderer/components/shared/virtual-scroll/virtual-scroll.component.tsx b/src/renderer/components/shared/virtual-scroll/virtual-scroll.component.tsx index 6cc90590..55cf3473 100644 --- a/src/renderer/components/shared/virtual-scroll/virtual-scroll.component.tsx +++ b/src/renderer/components/shared/virtual-scroll/virtual-scroll.component.tsx @@ -42,8 +42,6 @@ export function VirtualScroll({ className, classNames, minItemWidth const listHeight$ = useConstant(() => new BehaviorSubject(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({ className, classNames, minItemWidth itemData={itemsToRender} style={{ scrollbarGutter: "stable both-edges" }} onScroll={handleScroll} - > {renderRow} diff --git a/src/renderer/hooks/use-observe-size.hook.ts b/src/renderer/hooks/use-observe-size.hook.ts new file mode 100644 index 00000000..4ba6a1ce --- /dev/null +++ b/src/renderer/hooks/use-observe-size.hook.ts @@ -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; + 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; +}