Skip to content

Commit 8950dd5

Browse files
committed
fix: restore drawer sublist scroll positions
1 parent 634f005 commit 8950dd5

4 files changed

Lines changed: 161 additions & 7 deletions

File tree

src/components/virtual/VirtualGrid.jsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,9 @@ const List = React.forwardRef(({ context, ...props }, ref) => (
4141
* Header?: React.ComponentType<U>,
4242
* Footer?: React.ComponentType<U>,
4343
* useWindowScroll?: boolean,
44-
* scrollerRef?: import('react-virtuoso').VirtuosoGridProps<T, U & SomeGridProps>['scrollerRef']
44+
* scrollerRef?: import('react-virtuoso').VirtuosoGridProps<T, U & SomeGridProps>['scrollerRef'],
45+
* restoreStateFrom?: import('react-virtuoso').VirtuosoGridProps<T, U & SomeGridProps>['restoreStateFrom'],
46+
* stateChanged?: import('react-virtuoso').VirtuosoGridProps<T, U & SomeGridProps>['stateChanged'],
4547
* }} props
4648
*/
4749
export function VirtualGrid({
@@ -57,6 +59,8 @@ export function VirtualGrid({
5759
Footer,
5860
useWindowScroll,
5961
scrollerRef,
62+
restoreStateFrom,
63+
stateChanged,
6064
}) {
6165
const fullContext = React.useMemo(
6266
() => ({ ...context, xs, sm, md, lg, xl }),
@@ -77,6 +81,8 @@ export function VirtualGrid({
7781
itemContent={children}
7882
useWindowScroll={useWindowScroll}
7983
scrollerRef={scrollerRef}
84+
restoreStateFrom={restoreStateFrom}
85+
stateChanged={stateChanged}
8086
/>
8187
)
8288
}

src/features/drawer/areas/AreaTable.jsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ import { useMemory } from '@store/useMemory'
1818
import { useStorage } from '@store/useStorage'
1919
import { useMapStore } from '@store/useMapStore'
2020

21+
import { useDrawerScrollMemory } from '../hooks/useScrollMemory'
22+
2123
/** @typedef {{ id: string, name: string, lat: number, lon: number }} JumpResult */
2224

2325
import { AreaParent } from './Parent'
@@ -32,6 +34,7 @@ export function ScanAreasTable() {
3234
const trimmedSearch = React.useMemo(() => rawSearch.trim(), [rawSearch])
3335
const { misc, general } = useMemory.getState().config
3436
const jumpZoom = general?.scanAreasZoom || general?.startZoom || 12
37+
const tableScrollMemory = useDrawerScrollMemory('scanAreas:table')
3538
/** @type {[JumpResult[], React.Dispatch<React.SetStateAction<JumpResult[]>>]} */
3639
const [jumpResults, setJumpResults] = React.useState([])
3740
const [jumpLoading, setJumpLoading] = React.useState(false)
@@ -161,6 +164,7 @@ export function ScanAreasTable() {
161164
return (
162165
<TableContainer
163166
component={Paper}
167+
ref={tableScrollMemory.ref}
164168
sx={{
165169
minHeight: 50,
166170
maxHeight: misc.scanAreaMenuHeight || 400,

src/features/drawer/components/SelectorList.jsx

Lines changed: 60 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,12 @@ import { BoolToggle } from '@components/inputs/BoolToggle'
2727
import { GenericSearchMemo } from '@components/inputs/GenericSearch'
2828
import { StandardItem } from '@components/virtual/StandardItem'
2929

30+
import {
31+
getDrawerGridState,
32+
setDrawerGridState,
33+
useDrawerScrollMemory,
34+
} from '../hooks/useScrollMemory'
35+
3036
/**
3137
* @template {keyof import('@rm/types').Available} T
3238
* @typedef {{
@@ -36,14 +42,27 @@ import { StandardItem } from '@components/virtual/StandardItem'
3642
* children?: React.ReactNode,
3743
* label?: string
3844
* height?: React.CSSProperties['height'],
45+
* scrollKey?: string,
46+
* visible?: boolean,
3947
* }} SelectorListProps
4048
* @param {SelectorListProps<keyof import('@rm/types').Available>} props
4149
* @returns
4250
*/
43-
function SelectorList({ category, subCategory, label, height = 400 }) {
51+
function SelectorList({
52+
category,
53+
subCategory,
54+
label,
55+
height = 400,
56+
scrollKey,
57+
visible = true,
58+
}) {
4459
const searchKey = `${category}${
4560
subCategory ? capitalize(subCategory) : ''
4661
}QuickSelect`
62+
const listScrollKey =
63+
scrollKey ||
64+
`selector:${category}:${subCategory || 'default'}:${label || 'default'}`
65+
const drawer = useLayoutStore((s) => s.drawer)
4766
const { available } = useGetAvailable(category)
4867
const { t: tId } = useTranslateById({
4968
quest: subCategory === 'pokemon',
@@ -114,6 +133,32 @@ function SelectorList({ category, subCategory, label, height = 400 }) {
114133
.map((item) => item.id)
115134
}, [translated, search])
116135

136+
const restoreStateFrom = React.useMemo(
137+
() => getDrawerGridState(listScrollKey),
138+
[listScrollKey],
139+
)
140+
const shouldPersistGridState = drawer && visible
141+
const scrollMemory = useDrawerScrollMemory(
142+
listScrollKey,
143+
shouldPersistGridState,
144+
)
145+
146+
const handleStateChanged = React.useCallback(
147+
(state) => {
148+
if (
149+
!shouldPersistGridState ||
150+
!state.viewport.height ||
151+
!state.viewport.width ||
152+
!state.item.height ||
153+
!state.item.width
154+
) {
155+
return
156+
}
157+
setDrawerGridState(listScrollKey, state)
158+
},
159+
[listScrollKey, shouldPersistGridState],
160+
)
161+
117162
/** @param {'enable' | 'disable' | 'advanced'} action */
118163
const setAll = (action) => {
119164
const keys = new Set(items.map((item) => item))
@@ -194,7 +239,13 @@ function SelectorList({ category, subCategory, label, height = 400 }) {
194239
: height
195240
}
196241
>
197-
<VirtualGrid data={items} xs={4}>
242+
<VirtualGrid
243+
data={items}
244+
xs={4}
245+
scrollerRef={scrollMemory.ref}
246+
restoreStateFrom={restoreStateFrom}
247+
stateChanged={handleStateChanged}
248+
>
198249
{(_, key) => <StandardItem id={key} category={category} />}
199250
</VirtualGrid>
200251
</Box>
@@ -208,13 +259,16 @@ export const SelectorListMemo = React.memo(
208259
prev.category === next.category &&
209260
prev.subCategory === next.subCategory &&
210261
prev.label === next.label &&
211-
prev.height === next.height,
262+
prev.height === next.height &&
263+
prev.scrollKey === next.scrollKey &&
264+
prev.visible === next.visible,
212265
)
213266

214267
/** @param {{ children: React.ReactElement[], tabKey: string }} props */
215268
export function MultiSelectorList({ children, tabKey }) {
216269
const { t } = useTranslation()
217270
const [openTab, setOpenTab] = useDeepStore(`tabs.${tabKey}`, 0)
271+
const visibleChildren = children.filter(Boolean)
218272

219273
/** @type {import('@mui/material').TabsProps['onChange']} */
220274
const handleTabChange = React.useCallback(
@@ -226,14 +280,14 @@ export function MultiSelectorList({ children, tabKey }) {
226280
<Box pt={2}>
227281
<AppBar position="static">
228282
<Tabs value={openTab} onChange={handleTabChange}>
229-
{children.map((child) => (
283+
{visibleChildren.map((child) => (
230284
<Tab key={child.key} label={t(child.key)} />
231285
))}
232286
</Tabs>
233287
</AppBar>
234-
{children.filter(Boolean).map((child, index) => (
288+
{visibleChildren.map((child, index) => (
235289
<TabPanel value={openTab} index={index} key={child.key}>
236-
{child}
290+
{React.cloneElement(child, { visible: openTab === index })}
237291
</TabPanel>
238292
))}
239293
</Box>
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
// @ts-check
2+
import * as React from 'react'
3+
4+
/** @type {Map<string, import('react-virtuoso').GridStateSnapshot>} */
5+
const gridStateMemory = new Map()
6+
7+
/** @type {Map<string, number>} */
8+
const scrollTopMemory = new Map()
9+
10+
/**
11+
* @param {string} key
12+
* @returns {import('react-virtuoso').GridStateSnapshot | null}
13+
*/
14+
export function getDrawerGridState(key) {
15+
return gridStateMemory.get(key) || null
16+
}
17+
18+
/**
19+
* @param {string} key
20+
* @param {import('react-virtuoso').GridStateSnapshot} state
21+
*/
22+
export function setDrawerGridState(key, state) {
23+
gridStateMemory.set(key, state)
24+
}
25+
26+
/**
27+
* @template {HTMLElement} T
28+
* @param {string} key
29+
* @param {boolean} [restore]
30+
*/
31+
export function useDrawerScrollMemory(key, restore = true) {
32+
const nodeRef = React.useRef(/** @type {T | null} */ (null))
33+
const rafRef = React.useRef(0)
34+
35+
const handleScroll = React.useCallback(() => {
36+
if (nodeRef.current) {
37+
scrollTopMemory.set(key, nodeRef.current.scrollTop)
38+
}
39+
}, [key])
40+
41+
const restoreScrollTop = React.useCallback(
42+
/** @param {T | null} node */
43+
(node) => {
44+
if (!node || !restore) return
45+
if (rafRef.current) {
46+
window.cancelAnimationFrame(rafRef.current)
47+
}
48+
rafRef.current = window.requestAnimationFrame(() => {
49+
if (nodeRef.current === node) {
50+
node.scrollTop = scrollTopMemory.get(key) || 0
51+
}
52+
})
53+
},
54+
[key, restore],
55+
)
56+
57+
const ref = React.useCallback(
58+
/** @type {React.RefCallback<T>} */ (
59+
(node) => {
60+
if (nodeRef.current) {
61+
nodeRef.current.removeEventListener('scroll', handleScroll)
62+
}
63+
nodeRef.current = node
64+
if (node) {
65+
node.addEventListener('scroll', handleScroll, { passive: true })
66+
restoreScrollTop(node)
67+
}
68+
}
69+
),
70+
[handleScroll, restoreScrollTop],
71+
)
72+
73+
React.useLayoutEffect(() => {
74+
restoreScrollTop(nodeRef.current)
75+
}, [restoreScrollTop])
76+
77+
React.useEffect(
78+
() => () => {
79+
if (rafRef.current) {
80+
window.cancelAnimationFrame(rafRef.current)
81+
}
82+
if (nodeRef.current) {
83+
nodeRef.current.removeEventListener('scroll', handleScroll)
84+
}
85+
},
86+
[handleScroll],
87+
)
88+
89+
return { ref }
90+
}

0 commit comments

Comments
 (0)