[feature-107] create generic component for virtual scrolling

This commit is contained in:
MathieuG-P
2024-05-28 22:10:02 +02:00
parent 0196206ec1
commit 7562b07832
10 changed files with 178 additions and 177 deletions
@@ -1,13 +1,11 @@
import { MapsManagerService } from "renderer/services/maps-manager.service";
import { BSVersion } from "shared/bs-version.interface";
import { forwardRef, useCallback, useContext, useEffect, useImperativeHandle, useRef, useState } from "react";
import { forwardRef, useCallback, useContext, useEffect, useImperativeHandle, useState } from "react";
import { BsmLocalMap } from "shared/models/maps/bsm-local-map.interface";
import { Subscription, BehaviorSubject } from "rxjs";
import { MapFilter, MapTag } from "shared/models/maps/beat-saver.model";
import { MapsDownloaderService } from "renderer/services/maps-downloader.service";
import { VariableSizeList } from "react-window";
import { MapsRow } from "./maps-row.component";
import { debounceTime, last, tap } from "rxjs/operators";
import { last, tap } from "rxjs/operators";
import { useTranslation } from "renderer/hooks/use-translation.hook";
import BeatConflict from "../../../../../assets/images/apngs/beat-conflict.png";
import { BsmImage } from "../../shared/bsm-image.component";
@@ -21,6 +19,8 @@ import { BsContentLoader } from "renderer/components/shared/bs-content-loader.co
import { InstalledMapsContext } from "../maps-playlists-panel.component";
import { useObservable } from "renderer/hooks/use-observable.hook";
import equal from "fast-deep-equal";
import { VirtualScroll } from "renderer/components/shared/virtual-scroll/virtual-scroll.component";
import { MapItem, extractMapDiffs } from "./map-item.component";
type Props = {
version: BSVersion;
@@ -37,14 +37,10 @@ export const LocalMapsListPanel = forwardRef<unknown, Props>(({ version, classNa
const t = useTranslation();
const ref = useRef(null);
const {maps$, setMaps} = useContext(InstalledMapsContext);
const maps = useObservable(() => maps$, undefined);
const [subs] = useState<Subscription[]>([]);
const [selectedMaps$] = useState(new BehaviorSubject<BsmLocalMap[]>([]));
const [itemPerRow, setItemPerRow] = useState(2);
const [listHeight, setListHeight] = useState(0);
const [selectedMaps, setSelectedMaps] = useState<BsmLocalMap[]>([]);
const isActiveOnce = useChangeUntilEqual(isActive, { untilEqual: true });
const [linked, setLinked] = useState(false);
@@ -54,20 +50,20 @@ export const LocalMapsListPanel = forwardRef<unknown, Props>(({ version, classNa
forwardRef,
() => ({
deleteMaps() {
const mapsToDelete = selectedMaps$.value.length === 0 ? maps : selectedMaps$.value;
const mapsToDelete = selectedMaps.length === 0 ? maps : selectedMaps;
mapsManager.deleteMaps(mapsToDelete, version).then(res => {
if (!res) {
return;
}
removeMapsFromList(mapsToDelete);
selectedMaps$.next([]);
setSelectedMaps([]);
});
},
exportMaps() {
mapsManager.exportMaps(version, selectedMaps$.value);
mapsManager.exportMaps(version, selectedMaps);
}
}),
[selectedMaps$.value, maps, version]
[selectedMaps, maps, version]
);
useOnUpdate(() => {
@@ -104,36 +100,6 @@ export const LocalMapsListPanel = forwardRef<unknown, Props>(({ version, classNa
}, [isActiveOnce, version, maps])
useEffect(() => {
if (!isActiveOnce) {
return;
}
const updateItemPerRow = (listWidth: number) => {
const newPerRow = Math.min(Math.floor(listWidth / 400), 3);
if (newPerRow === itemPerRow) {
return;
}
setItemPerRow(newPerRow);
};
const heightObserver$ = new BehaviorSubject(0);
const observer = new ResizeObserver(() => {
updateItemPerRow(ref.current?.clientWidth || 0);
heightObserver$.next(ref.current?.clientHeight || 0);
});
observer.observe(ref.current);
const sub = heightObserver$.pipe(debounceTime(100)).subscribe(setListHeight);
return () => {
observer.disconnect();
sub.unsubscribe();
};
}, [isActiveOnce, itemPerRow]);
const loadMaps = () => {
setMaps(null);
loadPercent$.next(0);
@@ -157,8 +123,8 @@ export const LocalMapsListPanel = forwardRef<unknown, Props>(({ version, classNa
const filtredMaps = maps.filter(map => !mapsToRemove.some(toDeleteMaps => map.hash === toDeleteMaps.hash));
setMaps(filtredMaps);
const filtredSelectedMaps = selectedMaps$.value.filter(map => !mapsToRemove.some(toDeleteMaps => map.hash === toDeleteMaps.hash));
selectedMaps$.next(filtredSelectedMaps);
const filtredSelectedMaps = selectedMaps.filter(map => !mapsToRemove.some(toDeleteMaps => map.hash === toDeleteMaps.hash));
setSelectedMaps(filtredSelectedMaps);
};
const handleDelete = useCallback(
@@ -175,7 +141,7 @@ export const LocalMapsListPanel = forwardRef<unknown, Props>(({ version, classNa
const onMapSelected = useCallback(
(map: BsmLocalMap) => {
const mapsCopy = [...selectedMaps$.value];
const mapsCopy = [...selectedMaps];
if (mapsCopy.some(selectedMap => selectedMap.hash === map.hash)) {
const i = mapsCopy.findIndex(selectedMap => selectedMap.hash === map.hash);
mapsCopy.splice(i, 1);
@@ -183,9 +149,9 @@ export const LocalMapsListPanel = forwardRef<unknown, Props>(({ version, classNa
mapsCopy.push(map);
}
selectedMaps$.next(mapsCopy);
setSelectedMaps(mapsCopy)
},
[selectedMaps$.value]
[selectedMaps]
);
const isMapFitFilter = (map: BsmLocalMap): boolean => {
@@ -356,35 +322,17 @@ export const LocalMapsListPanel = forwardRef<unknown, Props>(({ version, classNa
return true;
};
const preppedMaps: BsmLocalMap[][] = (() => {
const preppedMaps: BsmLocalMap[] = (() => {
if (!maps) {
return [];
}
const res: BsmLocalMap[][] = [];
let mapsRow: BsmLocalMap[] = [];
for (const map of maps) {
if (!isMapFitFilter(map)) {
continue;
}
if (mapsRow.length === itemPerRow) {
res.push(mapsRow);
mapsRow = [];
}
mapsRow.push(map);
}
if (mapsRow.length) {
res.push(mapsRow);
}
return res;
return maps.filter(isMapFitFilter);
})();
if (!maps) {
return (
<div ref={ref} className={className}>
<div className={className}>
<BsContentLoader className="h-full flex flex-col items-center justify-center flex-wrap gap-1 text-gray-800 dark:text-gray-200" value$={loadPercent$} text="modals.download-maps.loading-maps" />
</div>
);
@@ -392,7 +340,7 @@ export const LocalMapsListPanel = forwardRef<unknown, Props>(({ version, classNa
if (!maps.length) {
return (
<div ref={ref} className={className}>
<div className={className}>
<div className="h-full flex flex-col items-center justify-center flex-wrap gap-1 text-gray-800 dark:text-gray-200">
<BsmImage className="h-32" image={BeatConflict} />
<span className="font-bold">{t("pages.version-viewer.maps.tabs.maps.empty-maps.text")}</span>
@@ -412,10 +360,34 @@ export const LocalMapsListPanel = forwardRef<unknown, Props>(({ version, classNa
}
return (
<div ref={ref} className={className}>
<VariableSizeList className="scrollbar-default" width="100%" height={listHeight} itemSize={() => 108} itemCount={preppedMaps.length} itemData={preppedMaps} layout="vertical" style={{ scrollbarGutter: "stable both-edges" }} itemKey={(i, data) => data[i].map(map => map.hash).join()}>
{props => <MapsRow maps={props.data[props.index]} style={props.style} selectedMaps$={selectedMaps$} onMapSelect={onMapSelected} onMapDelete={handleDelete} />}
</VariableSizeList>
</div>
<VirtualScroll
classNames={{ mainDiv: className, rows: "gap-x-2 px-2 py-2" }}
itemHeight={108}
maxColumns={3}
minItemWidth={400}
items={preppedMaps}
renderItem={map => (
<MapItem
key={map.path}
hash={map.hash}
title={map.rawInfo._songName}
coverUrl={map.coverUrl}
songUrl={map.songUrl}
autor={map.rawInfo._levelAuthorName}
songAutor={map.rawInfo._songAuthorName}
bpm={map.rawInfo._beatsPerMinute}
duration={map.songDetails?.duration}
selected={selectedMaps.some(selectedMap => selectedMap.hash === map.hash)}
diffs={extractMapDiffs({ rawMapInfo: map.rawInfo, songDetails: map.songDetails })}
mapId={map.songDetails?.id}
ranked={map.songDetails?.ranked}
autorId={map.songDetails?.uploader.id}
likes={map.songDetails?.upVotes}
createdAt={map.songDetails?.uploadedAt}
onDelete={handleDelete}
onSelected={onMapSelected}
callBackParam={map}
/>
)}/>
);
});
@@ -20,7 +20,7 @@ import { GlowEffect } from "../../shared/glow-effect.component";
import { useDelayedState } from "renderer/hooks/use-delayed-state.hook";
import { useService } from "renderer/hooks/use-service.hook";
import Tippy from "@tippyjs/react";
import { SongDetailDiffCharactertistic, SongDiffName } from "shared/models/maps";
import { BsvMapDetail, RawMapInfoData, SongDetailDiffCharactertistic, SongDetails, SongDiffName } from "shared/models/maps";
import { useConstant } from "renderer/hooks/use-constant.hook";
import { CalendarDateTime, getLocalTimeZone } from "@internationalized/date";
@@ -73,8 +73,6 @@ export const MapItem = memo(({ hash, title, autor, songAutor, coverUrl, songUrl,
const songPlaying = useObservable(() => audioPlayer.playing$.pipe(map(playing => playing && audioPlayer.src === songUrl)));
console.log("aaa", autorId);
const MAP_DIFFICULTIES = useConstant(() => Object.values(SongDiffName))
const previewUrl = mapId ? `https://allpoland.github.io/ArcViewer/?id=${mapId}` : null;
const mapUrl = mapId ? `https://beatsaver.com/maps/${mapId}` : null;
@@ -340,3 +338,35 @@ export const MapItem = memo(({ hash, title, autor, songAutor, coverUrl, songUrl,
</motion.li>
);
}, equal);
export function extractMapDiffs({rawMapInfo, songDetails, bsvMap}: {rawMapInfo?: RawMapInfoData, songDetails?: SongDetails, bsvMap?: BsvMapDetail}): Map<SongDetailDiffCharactertistic, ParsedMapDiff[]> {
const res = new Map<SongDetailDiffCharactertistic, ParsedMapDiff[]>();
if (bsvMap && bsvMap.versions?.at(0)?.diffs) {
bsvMap.versions.at(0).diffs.forEach(diff => {
const arr = res.get(diff.characteristic) || [];
arr.push({ name: diff.difficulty, type: diff.difficulty, stars: diff.stars });
res.set(diff.characteristic, arr);
});
return res;
}
if (songDetails?.difficulties) {
songDetails?.difficulties.forEach(diff => {
const arr = res.get(diff.characteristic) || [];
const diffName = rawMapInfo._difficultyBeatmapSets.find(set => set._beatmapCharacteristicName === diff.characteristic)._difficultyBeatmaps.find(rawDiff => rawDiff._difficulty === diff.difficulty)?._customData?._difficultyLabel || diff.difficulty;
arr.push({ name: diffName, type: diff.difficulty, stars: diff.stars });
res.set(diff.characteristic, arr);
});
return res;
}
rawMapInfo._difficultyBeatmapSets.forEach(set => {
set._difficultyBeatmaps.forEach(diff => {
const arr = res.get(set._beatmapCharacteristicName) || [];
arr.push({ name: diff._customData?._difficultyLabel || diff._difficulty, type: diff._difficulty, stars: null });
res.set(set._beatmapCharacteristicName, arr);
});
});
return res;
}
@@ -1,87 +0,0 @@
import { CSSProperties, memo } from "react";
import { useObservable } from "renderer/hooks/use-observable.hook";
import { distinctUntilChanged, map } from "rxjs/operators";
import { BehaviorSubject } from "rxjs";
import { BsmLocalMap } from "shared/models/maps/bsm-local-map.interface";
import { ParsedMapDiff, MapItem } from "./map-item.component";
import equal from "fast-deep-equal/es6";
import { SongDetailDiffCharactertistic, SongDetails } from "shared/models/maps/song-details-cache/song-details-cache.model";
import { BsvMapDetail, RawMapInfoData } from "shared/models/maps";
type Props = {
maps: BsmLocalMap[];
style?: CSSProperties;
onMapDelete: (map: BsmLocalMap) => void;
onMapSelect: (map: BsmLocalMap) => void;
selectedMaps$: BehaviorSubject<BsmLocalMap[]>;
};
export const MapsRow = memo(({ maps, style, selectedMaps$, onMapSelect, onMapDelete }: Props) => {
const selectedMaps = useObservable<BsmLocalMap[]>(() => selectedMaps$.pipe(
map(selectedMaps => selectedMaps.filter(selected => maps.some(map => map.hash === selected.hash))),
distinctUntilChanged(equal),
), []);
const renderMapItem = (map: BsmLocalMap) => {
return <MapItem
key={map.path}
hash={map.hash}
title={map.rawInfo._songName}
coverUrl={map.coverUrl}
songUrl={map.songUrl}
autor={map.rawInfo._levelAuthorName}
songAutor={map.rawInfo._songAuthorName}
bpm={map.rawInfo._beatsPerMinute}
duration={map.songDetails?.duration}
selected={selectedMaps.some(selected => selected.hash === map.hash)}
diffs={extractMapDiffs({ rawMapInfo: map.rawInfo, songDetails: map.songDetails })}
mapId={map.songDetails?.id}
ranked={map.songDetails?.ranked}
autorId={map.songDetails?.uploader.id}
likes={map.songDetails?.upVotes}
createdAt={map.songDetails?.uploadedAt}
onDelete={onMapDelete}
onSelected={onMapSelect}
callBackParam={map}
/>;
};
return (
<ul className="h-fit w-full flex flex-nowrap basis-0 gap-x-2 p-2" style={style}>
{maps?.map(renderMapItem)}
</ul>
);
}, equal);
export function extractMapDiffs({rawMapInfo, songDetails, bsvMap}: {rawMapInfo?: RawMapInfoData, songDetails?: SongDetails, bsvMap?: BsvMapDetail}): Map<SongDetailDiffCharactertistic, ParsedMapDiff[]> {
const res = new Map<SongDetailDiffCharactertistic, ParsedMapDiff[]>();
if (bsvMap && bsvMap.versions?.at(0)?.diffs) {
bsvMap.versions.at(0).diffs.forEach(diff => {
const arr = res.get(diff.characteristic) || [];
arr.push({ name: diff.difficulty, type: diff.difficulty, stars: diff.stars });
res.set(diff.characteristic, arr);
});
return res;
}
if (songDetails?.difficulties) {
songDetails?.difficulties.forEach(diff => {
const arr = res.get(diff.characteristic) || [];
const diffName = rawMapInfo._difficultyBeatmapSets.find(set => set._beatmapCharacteristicName === diff.characteristic)._difficultyBeatmaps.find(rawDiff => rawDiff._difficulty === diff.difficulty)?._customData?._difficultyLabel || diff.difficulty;
arr.push({ name: diffName, type: diff.difficulty, stars: diff.stars });
res.set(diff.characteristic, arr);
});
return res;
}
rawMapInfo._difficultyBeatmapSets.forEach(set => {
set._difficultyBeatmaps.forEach(diff => {
const arr = res.get(set._beatmapCharacteristicName) || [];
arr.push({ name: diff._customData?._difficultyLabel || diff._difficulty, type: diff._difficulty, stars: null });
res.set(set._beatmapCharacteristicName, arr);
});
});
return res;
}
@@ -1,7 +1,7 @@
import { motion } from "framer-motion";
import { useCallback, useEffect, useRef, useState } from "react";
import { FilterPanel } from "renderer/components/maps-playlists-panel/maps/filter-panel.component";
import { MapItem } from "renderer/components/maps-playlists-panel/maps/map-item.component";
import { MapItem, extractMapDiffs } from "renderer/components/maps-playlists-panel/maps/map-item.component";
import { BsmButton } from "renderer/components/shared/bsm-button.component";
import { BsmDropdownButton } from "renderer/components/shared/bsm-dropdown-button.component";
import { BsmSelect, BsmSelectOption } from "renderer/components/shared/bsm-select.component";
@@ -19,7 +19,6 @@ import { useTranslation } from "renderer/hooks/use-translation.hook";
import { OsDiagnosticService } from "renderer/services/os-diagnostic.service";
import { BsmLocalMap } from "shared/models/maps/bsm-local-map.interface";
import { useService } from "renderer/hooks/use-service.hook";
import { extractMapDiffs } from "renderer/components/maps-playlists-panel/maps/maps-row.component";
import { useConstant } from "renderer/hooks/use-constant.hook";
import { getLocalTimeZone, parseAbsolute, toCalendarDateTime } from "@internationalized/date";
@@ -6,8 +6,7 @@ import { Observable } from "rxjs";
import { useObservable } from "renderer/hooks/use-observable.hook";
import { ModalComponent, ModalExitCode } from "renderer/services/modale.service";
import { useState } from "react";
import { MapItem } from "renderer/components/maps-playlists-panel/maps/map-item.component";
import { extractMapDiffs } from "renderer/components/maps-playlists-panel/maps/maps-row.component";
import { MapItem, extractMapDiffs } from "renderer/components/maps-playlists-panel/maps/map-item.component";
import { useOnUpdate } from "renderer/hooks/use-on-update.hook";
import { useService } from "renderer/hooks/use-service.hook";
import { BeatSaverService } from "renderer/services/thrird-partys/beat-saver.service";
@@ -4,8 +4,7 @@ import { Observable, combineLatest, lastValueFrom, map, switchMap } from "rxjs"
import { BsmLocalMap } from "shared/models/maps/bsm-local-map.interface"
import { BSVersion } from "shared/bs-version.interface";
import { useObservable } from "renderer/hooks/use-observable.hook";
import { MapItem } from "renderer/components/maps-playlists-panel/maps/map-item.component";
import { extractMapDiffs } from "renderer/components/maps-playlists-panel/maps/maps-row.component";
import { MapItem, extractMapDiffs } from "renderer/components/maps-playlists-panel/maps/map-item.component";
import { useService } from "renderer/hooks/use-service.hook";
import { AudioPlayerService } from "renderer/services/audio-player.service";
import { BsmImage } from "renderer/components/shared/bsm-image.component";
@@ -18,8 +18,6 @@ export function PlaylistHeaderState({isPlaylistInQueue$, isPlaylistDownloading$,
const isPlaylistDownloading = useObservable(() => isPlaylistDownloading$, false);
const isMissingMaps = useObservable(() => isMissingMaps$, false);
console.log(isPlaylistInQueue, isPlaylistDownloading, isMissingMaps);
const renderHeaderContent = () => {
if(isPlaylistDownloading) {
@@ -0,0 +1,24 @@
import equal from "fast-deep-equal";
import { CSSProperties, memo } from "react";
import { cn } from "renderer/helpers/css-class.helpers";
type Props<T> = {
className?: string;
style?: CSSProperties;
items: T[];
renderItem: (item: T) => JSX.Element;
}
function VirtualRowComponent<T>({ className, style, items, renderItem }: Props<T>) {
return (
<ul className={cn("h-fit w-full flex flex-nowrap", className)} style={style}>
{items.map((item) => (
renderItem(item)
))}
</ul>
);
}
const typedMemo: <T, P>(c: T, propsAreEqual?: (prevProps: Readonly<P>, nextProps: Readonly<P>) => boolean) => T = memo;
export const VirtualRow = typedMemo(VirtualRowComponent, equal);
@@ -0,0 +1,71 @@
import { useLayoutEffect, useRef, useState } from "react";
import { VariableSizeList } from "react-window";
import { cn } from "renderer/helpers/css-class.helpers";
import { useOnUpdate } from "renderer/hooks/use-on-update.hook";
import { VirtualRow } from "./virtual-row.component";
import { splitIntoChunk } from "shared/helpers/array.helpers";
import { useConstant } from "renderer/hooks/use-constant.hook";
import { BehaviorSubject, debounceTime } from "rxjs";
import { useObservable } from "renderer/hooks/use-observable.hook";
type ClassNames = {
mainDiv?: string;
variableList?: string;
rows?: string;
}
type Props<T = unknown> = {
className?: string;
classNames?: ClassNames;
minItemWidth: number;
maxColumns: number;
minColumns?: number;
itemHeight: number;
items: T[];
renderItem: (item: T) => JSX.Element;
}
export function VirtualScroll<T = unknown>({ className, classNames, minItemWidth, maxColumns, minColumns, itemHeight, items, renderItem}: Props<T>) {
console.log("omg les classes", className);
const ref = useRef(null);
const [itemPerRow, setItemPerRow] = useState(1);
const [itemsToRender, setItemsToRender] = useState<T[][]>([]);
const listHeight$ = useConstant(() => new BehaviorSubject<number>(0));
const listHeight = useObservable(() => listHeight$.pipe(debounceTime(100)), 0);
useLayoutEffect(() => {
const updateItemPerRow = (listWidth: number) => {
if (!listWidth) return;
const calculatedColumns = Math.floor(listWidth / minItemWidth);
const newColumns = Math.max((minColumns || 1), Math.min(maxColumns, calculatedColumns));
setItemPerRow(() => newColumns);
};
const observer = new ResizeObserver(() => {
updateItemPerRow(ref.current?.clientWidth || 0);
listHeight$.next(ref.current?.clientHeight || 0);
console.log("list height", ref.current?.clientHeight);
});
observer.observe(ref.current);
return () => observer.disconnect();
}, [minItemWidth, maxColumns]);
useOnUpdate(() => {
const splitedItems = splitIntoChunk(items, itemPerRow);
setItemsToRender(() => splitedItems);
}, [itemPerRow, items])
return (
<div ref={ref} className={cn(className, classNames?.mainDiv)}>
<VariableSizeList className={cn("scrollbar-default", classNames?.variableList)} width="100%" height={listHeight} layout="vertical" itemCount={itemsToRender.length} itemKey={i => i} itemSize={() => itemHeight} itemData={itemsToRender} style={{ scrollbarGutter: "stable both-edges" }}>
{props => <VirtualRow key={props.index} items={props.data[props.index]} renderItem={renderItem} className={classNames?.rows} style={props.style}/>}
</VariableSizeList>
</div>
)
}
-4
View File
@@ -1,4 +0,0 @@
export interface ApiResult<T = unknown> {
status: number;
data: T;
}