diff --git a/package-lock.json b/package-lock.json
index d5779f8..8aa4785 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -12,6 +12,7 @@
"@heroicons/react": "^2.1.3",
"@next/bundle-analyzer": "^14.1.4",
"@nextui-org/react": "^2.2.10",
+ "@tanstack/react-virtual": "^3.2.0",
"bcrypt": "^5.1.1",
"clsx": "^2.1.0",
"db-migrate": "^0.11.14",
@@ -32,7 +33,6 @@
"react-icons": "^5.0.1",
"react-resizable": "^3.0.5",
"react-swipeable": "^7.0.1",
- "react-virtualized": "^9.22.5",
"sass": "^1.72.0",
"tailwind-merge": "^2.2.2",
"tailwindcss-text-fill-stroke": "^2.0.0-beta.1",
@@ -3586,6 +3586,31 @@
"node": ">=6"
}
},
+ "node_modules/@tanstack/react-virtual": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.2.0.tgz",
+ "integrity": "sha512-OEdMByf2hEfDa6XDbGlZN8qO6bTjlNKqjM3im9JG+u3mCL8jALy0T/67oDI001raUUPh1Bdmfn4ZvPOV5knpcg==",
+ "dependencies": {
+ "@tanstack/virtual-core": "3.2.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
+ }
+ },
+ "node_modules/@tanstack/virtual-core": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.2.0.tgz",
+ "integrity": "sha512-P5XgYoAw/vfW65byBbJQCw+cagdXDT/qH6wmABiLt4v4YBT2q2vqCOhihe+D1Nt325F/S/0Tkv6C5z0Lv+VBQQ==",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ }
+ },
"node_modules/@tsconfig/node10": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.9.tgz",
@@ -5353,7 +5378,8 @@
"node_modules/csstype": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
- "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="
+ "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
+ "devOptional": true
},
"node_modules/cuint": {
"version": "0.2.2",
@@ -5753,15 +5779,6 @@
"node": ">=6.0.0"
}
},
- "node_modules/dom-helpers": {
- "version": "5.2.1",
- "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
- "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
- "dependencies": {
- "@babel/runtime": "^7.8.7",
- "csstype": "^3.0.2"
- }
- },
"node_modules/dot-prop": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-6.0.1.tgz",
@@ -9957,11 +9974,6 @@
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
},
- "node_modules/react-lifecycles-compat": {
- "version": "3.0.4",
- "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz",
- "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA=="
- },
"node_modules/react-remove-scroll-bar": {
"version": "2.3.5",
"resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.5.tgz",
@@ -10041,31 +10053,6 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
}
},
- "node_modules/react-virtualized": {
- "version": "9.22.5",
- "resolved": "https://registry.npmjs.org/react-virtualized/-/react-virtualized-9.22.5.tgz",
- "integrity": "sha512-YqQMRzlVANBv1L/7r63OHa2b0ZsAaDp1UhVNEdUaXI8A5u6hTpA5NYtUueLH2rFuY/27mTGIBl7ZhqFKzw18YQ==",
- "dependencies": {
- "@babel/runtime": "^7.7.2",
- "clsx": "^1.0.4",
- "dom-helpers": "^5.1.3",
- "loose-envify": "^1.4.0",
- "prop-types": "^15.7.2",
- "react-lifecycles-compat": "^3.0.4"
- },
- "peerDependencies": {
- "react": "^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0",
- "react-dom": "^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0"
- }
- },
- "node_modules/react-virtualized/node_modules/clsx": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz",
- "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==",
- "engines": {
- "node": ">=6"
- }
- },
"node_modules/read": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/read/-/read-1.0.7.tgz",
diff --git a/package.json b/package.json
index e05d59a..122b237 100644
--- a/package.json
+++ b/package.json
@@ -19,6 +19,7 @@
"@heroicons/react": "^2.1.3",
"@next/bundle-analyzer": "^14.1.4",
"@nextui-org/react": "^2.2.10",
+ "@tanstack/react-virtual": "^3.2.0",
"bcrypt": "^5.1.1",
"clsx": "^2.1.0",
"db-migrate": "^0.11.14",
@@ -39,7 +40,6 @@
"react-icons": "^5.0.1",
"react-resizable": "^3.0.5",
"react-swipeable": "^7.0.1",
- "react-virtualized": "^9.22.5",
"sass": "^1.72.0",
"tailwind-merge": "^2.2.2",
"tailwindcss-text-fill-stroke": "^2.0.0-beta.1",
diff --git a/src/app/(with-header)/chuni/userbox/userbox.tsx b/src/app/(with-header)/chuni/userbox/userbox.tsx
index 7f81fc4..e371890 100644
--- a/src/app/(with-header)/chuni/userbox/userbox.tsx
+++ b/src/app/(with-header)/chuni/userbox/userbox.tsx
@@ -165,13 +165,13 @@ export const ChuniUserbox = ({ profile, userboxItems }: ChuniUserboxProps) => {
renderItem(n, getImageUrl(`chuni/name-plate/${n.imagePath}`), 'w-full sm:text-lg', 'px-2 pb-1')}
selectedItem={equipped.namePlate} onSelected={i => equipItem('namePlate', i)}>
Change Nameplate
}
selectedItem={equipped.trophy} onSelected={i => equipItem('trophy', i)}>
Change Trophy
@@ -209,7 +209,7 @@ export const ChuniUserbox = ({ profile, userboxItems }: ChuniUserboxProps) => {
{(['avatarHead', 'avatarFace', 'avatarWear', 'avatarSkin', 'avatarItem', 'avatarBack'] as const).map(k => ((k !== 'avatarSkin' || userboxItems.avatarSkin.length > 1) &&
equipItem(k, i)} items={userboxItems[k]} selectedItem={equipped[k]}
renderItem={i => renderItem(i, getImageUrl(`chuni/avatar/${i.iconPath}`)) }>
@@ -252,8 +252,8 @@ export const ChuniUserbox = ({ profile, userboxItems }: ChuniUserboxProps) => {
{ voicePreview }
{ voicePreview }
@@ -304,7 +304,7 @@ export const ChuniUserbox = ({ profile, userboxItems }: ChuniUserboxProps) => {
i && equipItem('mapIcon', i)} selectedItem={equipped.mapIcon}
displayMode="grid" modalSize="full" rowSize={210} colSize={175} items={userboxItems.mapIcon} gap={6}
- className="w-full sm:w-auto mb-4" modalId="map-icon"
+ className="w-full sm:w-auto mb-4" modalId="map-icon" itemId="id"
renderItem={i => renderItem(i, getImageUrl(`chuni/map-icon/${i.imagePath}`))}>
Change Map Icon
diff --git a/src/components/select-modal.tsx b/src/components/select-modal.tsx
index 3821ea2..1305b29 100644
--- a/src/components/select-modal.tsx
+++ b/src/components/select-modal.tsx
@@ -4,16 +4,17 @@ import { Modal, ModalBody, ModalContent, ModalFooter, ModalHeader, ModalProps }
import { ReactNode, useEffect, useMemo, useRef, useState } from 'react';
import { Button, ButtonProps, Input } from '@nextui-org/react';
import { SearchIcon } from '@nextui-org/shared-icons';
-import { AutoSizer, CellMeasurer, CellMeasurerCache, List } from 'react-virtualized';
import { useDebounceCallback } from 'usehooks-ts';
-import { CellMeasurerChildProps } from 'react-virtualized/dist/es/CellMeasurer';
import { useRouter } from 'next/navigation';
import { useWindowListener } from '@/helpers/use-window-listener';
import { useReloaded } from './client-providers';
+import { useVirtualizer } from '@tanstack/react-virtual';
+type Data
= {
+ name?: string | null,
+} & { [K in I]: any };
-
-export type SelectModalProps = {
+export type SelectModalProps> = {
isOpen: boolean,
onSelected: (item: D | null | undefined) => void,
selectedItem: D | null | undefined,
@@ -21,30 +22,121 @@ export type SelectModalProps (ReactNode | ((props: Pick) => ReactNode)),
+ renderItem: (item: D) => ReactNode,
gap?: number,
onSelectionChanged?: (item: D) => void,
- footer?: ReactNode
+ footer?: ReactNode,
+ itemId: I
} & (T extends 'grid' ? {
colSize: number
} : {
colSize?: never
-});
-
-const SelectModal = ({ footer, onSelectionChanged, gap, selectedItem, renderItem, displayMode, items, isOpen, onSelected, modalSize, colSize, rowSize }: SelectModalProps) => {
- const measurementCache = useMemo(() => {
- return new CellMeasurerCache({
- defaultHeight: rowSize,
- fixedWidth: true,
- minHeight: Math.ceil(rowSize / 3),
- keyMapper: () => window.innerWidth
});
- }, [rowSize]);
- const listRef = useRef(null);
+const SelectModalList = >({ onSelectionChanged, setSelected, gap, rowSize, renderItem, items, selected, itemId }:
+ Pick, 'itemId' | 'onSelectionChanged' | 'gap' | 'rowSize' | 'renderItem' | 'items'> & { selected?: D | null, setSelected: (d: D) => void }) => {
+ const listRef = useRef(null);
+ const lastHeight = useRef(rowSize);
+
+ const virtualizer = useVirtualizer({
+ count: items.length,
+ getScrollElement: () => listRef.current,
+ estimateSize: () => lastHeight.current,
+ overscan: 5,
+ scrollingDelay: 0,
+ measureElement: el => {
+ return lastHeight.current = el.clientHeight;
+ }
+ });
+
+ const buttonStyle = { height: `calc(100% - ${gap ?? 0}px)` };
+
+ return (
+
+ {virtualizer.getVirtualItems().map(item => {
+ const data = items[item.index];
+ const child = renderItem(data);
+
+ return (
+
+
)
+ })}
+
+
)
+};
+
+const SelectModalGrid = >({ onSelectionChanged, setSelected, gap, rowSize, renderItem, items, selected, colSize, itemId }:
+ Pick, 'itemId' | 'onSelectionChanged' | 'gap' | 'rowSize' | 'renderItem' | 'items' | 'colSize'> & { selected?: D | null, setSelected: (d: D) => void; }) => {
+ const listRef = useRef(null);
+ const lastHeight = useRef(rowSize);
+ const [width, setWidth] = useState(0)
+
+ const itemsPerRow = Math.max(1, Math.floor(width / colSize));
+ const rowCount = Math.ceil(items.length / itemsPerRow);
+
+ const virtualizer = useVirtualizer({
+ count: rowCount,
+ getScrollElement: () => listRef.current,
+ estimateSize: () => lastHeight.current,
+ overscan: 5,
+ scrollingDelay: 0,
+ measureElement: el => {
+ return lastHeight.current = el.clientHeight;
+ }
+ });
+
+ const buttonStyle = {
+ maxWidth: `${colSize}px`,
+ aspectRatio: `${colSize}/${rowSize}`
+ };
+ const rowStyle = { gap: `${gap ?? 0}px`, paddingBottom: `${gap ?? 0}px` };
+
+ useEffect(() => setWidth(listRef.current?.clientWidth ?? 0), []);
+ useWindowListener('resize', () => setWidth(listRef.current?.clientWidth ?? 0));
+
+ return (
+
+ {virtualizer.getVirtualItems().map(item => {
+ const row = items.slice(item.index * itemsPerRow, (item.index + 1) * itemsPerRow);
+ const children = row.map((item, i) => (
));
+
+ return (
+ { children }
+ {item.index === rowCount - 1 ? [...new Array(itemsPerRow - children.length)].map((_, i) =>
+
) : null }
+
)
+ })}
+
+
)
+};
+
+const SelectModal = >({ footer, onSelectionChanged, gap, selectedItem, renderItem, displayMode, items, isOpen, onSelected, modalSize, colSize, rowSize, itemId }: SelectModalProps) => {
const [selected, setSelected] = useState(selectedItem);
const [filteredItems, setFilteredItems] = useState(items);
- const [gridRowCount, setGridRowCount] = useState(0);
const outputSelected = useRef(null);
useEffect(() => {
@@ -55,118 +147,21 @@ const SelectModal = {
- listRef.current?.recomputeRowHeights();
- }, [colSize, rowSize]);
-
const filter = useDebounceCallback((query: string) => {
const lowerQuery = query.toLowerCase();
setFilteredItems(items.filter(({ name }) => name?.toLowerCase().includes(lowerQuery)));
}, 100);
- const recompute = useDebounceCallback(() => {
- listRef.current?.recomputeRowHeights();
- }, 150);
-
- useEffect(() => {
- if (!isOpen) return;
-
- let prevWidth = -1;
- const cb = () => {
- if (prevWidth !== window.innerWidth) {
- prevWidth = window.innerWidth;
- recompute();
- }
- };
- window.addEventListener('resize', cb);
-
- return () => {
- window.removeEventListener('resize', cb);
- };
- }, [isOpen, recompute]);
-
- const containerStyle = { pointerEvents: 'auto' } as const;
-
const renderedContent = useMemo(() => {
if (!isOpen) return null;
- if (displayMode === 'list') {
- const buttonStyle = { height: `calc(100% - ${gap ?? 0}px)` };
+ if (displayMode === 'list')
+ return
- return (
-
- {({ height, width }) => ( {
- const child = renderItem(filteredItems[index]);
-
- return (
- {({ measure, registerChild }) => {
- return (
-
-
)
- }}
- );
- }}
- />)}
-
-
);
- }
-
- const buttonStyle = { maxWidth: `${colSize}px`,
- aspectRatio: `${colSize}/${rowSize}`,
- height: `calc(100% - ${gap ?? 0}px)` };
- const rowStyle = { gap: `${gap ?? 0}px` };
-
- return (
- {(({ width }) => {
- const itemsPerRow = Math.max(1, Math.floor(width / colSize!));
- const rowCount = Math.ceil(filteredItems.length / itemsPerRow);
- setTimeout(() => setGridRowCount(rowCount));
-
- return (
-
- {({ height }) => ( (
- {({ measure, registerChild }) => {
- const children = filteredItems.slice(index * itemsPerRow, (index + 1) * itemsPerRow)
- .map((item, i) => {
- const res = renderItem(item);
-
- return ()
- });
-
- return
- {children}
- { index === rowCount - 1 ? [...new Array(itemsPerRow - children.length)].map((_, i) =>
-
) : null }
-
;
- }}
- )} />)}
-
-
);
- })}
- )
- }, [displayMode, filteredItems, colSize, rowSize, selected, isOpen, gridRowCount, gap, onSelectionChanged]);
+ return
+ }, [displayMode, filteredItems, colSize, rowSize, selected, isOpen, gap]);
return ( {
onSelected(outputSelected.current);
@@ -199,13 +194,12 @@ const SelectModal = )
};
-export type SelectModalButtonProps = Omit &
- Pick, 'modalSize' | 'displayMode' | 'colSize' | 'rowSize' | 'items' | 'renderItem' | 'selectedItem' | 'onSelected' | 'gap' | 'onSelectionChanged' | 'footer'> &
- { modalId: string };
-export const SelectModalButton = (props: SelectModalButtonProps) => {
+export const SelectModalButton = >(props: Omit &
+ Pick, 'itemId' | 'modalSize' | 'displayMode' | 'colSize' | 'rowSize' | 'items' | 'renderItem' | 'selectedItem' | 'onSelected' | 'gap' | 'onSelectionChanged' | 'footer'> &
+ { modalId: string; }) => {
const router = useRouter();
const [isOpen, setOpen] = useState(false);
- const { modalId, footer, onSelectionChanged, gap, onSelected, selectedItem, renderItem, items, colSize, rowSize, displayMode, modalSize } = props;
+ const { modalId, footer, onSelectionChanged, gap, onSelected, selectedItem, renderItem, items, colSize, rowSize, displayMode, modalSize, itemId } = props;
const historyPushed = useRef(false);
const reloaded = useReloaded();
@@ -223,7 +217,7 @@ export const SelectModalButton =
{
setOpen(false);
onSelected(item);
diff --git a/src/components/window-scroller-grid.tsx b/src/components/window-scroller-grid.tsx
index 61efdaa..a0489d7 100644
--- a/src/components/window-scroller-grid.tsx
+++ b/src/components/window-scroller-grid.tsx
@@ -1,5 +1,6 @@
-import { ReactNode, useEffect, useRef } from 'react';
-import { AutoSizer, List, WindowScroller } from 'react-virtualized';
+import { ReactNode, useRef } from 'react';
+import { useWindowVirtualizer } from '@tanstack/react-virtual';
+import { useResizeObserver } from 'usehooks-ts';
type WindowScrollerGridProps = {
rowSize: number,
@@ -9,27 +10,38 @@ type WindowScrollerGridProps = {
};
export const WindowScrollerGrid = ({ rowSize, colSize, items, children }: WindowScrollerGridProps) => {
- const listRef = useRef(null);
+ const listRef = useRef(null);
- useEffect(() => {
- listRef.current?.recomputeRowHeights(0);
- }, [rowSize, colSize, items]);
+ const { width = 0 } = useResizeObserver({
+ ref: listRef
+ });
- return ({({ height, isScrolling, onChildScroll, scrollTop }) =>
- (
- {({ width }) => {
- const itemsPerRow = Math.max(1, Math.floor(width / colSize));
- const rowCount = Math.ceil(items.length / itemsPerRow);
+ const itemsPerRow = Math.max(1, Math.floor(width / colSize));
+ const rowCount = Math.ceil(items.length / itemsPerRow);
- return (
- (
- {items.slice(index * itemsPerRow, (index + 1) * itemsPerRow).map((item, i) => (
- {children(item)}
-
))}
-
)
- } />)
- }}
-
)
- })
+ const virtualizer = useWindowVirtualizer({
+ count: rowCount,
+ estimateSize: () => rowSize,
+ scrollMargin: listRef.current?.offsetTop ?? 0,
+ overscan: 5,
+ scrollingDelay: 0
+ });
+
+ return (
+ {width > 0 &&
+ {virtualizer.getVirtualItems().map(item => {
+ const row = items.slice(item.index * itemsPerRow, (item.index + 1) * itemsPerRow);
+ return (
+ {row.map((item, i) => (
+ {children(item)}
+
))}
+
);
+ })}
+
}
+
);
};