mirror of
https://github.com/Zagrios/bs-manager.git
synced 2026-07-03 14:08:25 +02:00
[feature-107] create generic component for virtual scrolling
This commit is contained in:
+47
-75
@@ -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";
|
||||
|
||||
|
||||
+1
-2
@@ -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";
|
||||
|
||||
+1
-2
@@ -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";
|
||||
|
||||
-2
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export interface ApiResult<T = unknown> {
|
||||
status: number;
|
||||
data: T;
|
||||
}
|
||||
Reference in New Issue
Block a user