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)} +
))} +
); + })} +
} +
); };