1- import React , {
1+ import {
22 useCallback ,
33 useContext ,
44 useEffect ,
@@ -43,20 +43,21 @@ function groupByModule(declarations: Declaration[]): ModuleGroup[] {
4343 ) ;
4444}
4545
46- const HEADER_HEIGHT = 28 ;
47- const HEADER_GAP = 8 ;
48- const ITEM_HEIGHT = 28 ;
46+ const ROW_HEIGHT = 28 ;
47+ const STATIC_BEFORE = 19 ;
48+ const STATIC_AFTER = 60 ;
4949
5050export const DeclarationsSidebar = ( { onNavigate } : { onNavigate ?: ( ) => void } ) => {
5151 const { declarations, game } = useContext ( DeclarationsContext ) ;
5252 const { filter, setFilter } = useContext ( SidebarFilterContext ) ;
5353 const { module : activeModule = "" , scope = "" } = useParams ( ) ;
5454 const [ collapsed , setCollapsed ] = useState < Set < string > > ( ( ) => new Set ( ) ) ;
5555 const [ hydrated , setHydrated ] = useState ( false ) ;
56- const parentRef = useRef < HTMLUListElement > ( null ) ;
56+ const parentRef = useRef < HTMLDivElement > ( null ) ;
5757 const wrapperRef = useRef < HTMLDivElement > ( null ) ;
5858 const activeStickyIndexRef = useRef ( 0 ) ;
5959 const navigatedFromSidebarRef = useRef ( false ) ;
60+ const isInitialMount = useRef ( true ) ;
6061
6162 const groups = useMemo ( ( ) => {
6263 let filtered = declarations ;
@@ -67,28 +68,21 @@ export const DeclarationsSidebar = ({ onNavigate }: { onNavigate?: () => void })
6768 return groupByModule ( filtered ) ;
6869 } , [ declarations , filter ] ) ;
6970
70- const rows = useMemo ( ( ) => {
71- const result : SidebarRow [ ] = [ ] ;
71+ const { rows, stickyIndexes } = useMemo ( ( ) => {
72+ const rows : SidebarRow [ ] = [ ] ;
73+ const stickyIndexes : number [ ] = [ ] ;
7274 for ( const g of groups ) {
73- result . push ( { type : "header" , module : g . module , count : g . items . length } ) ;
75+ stickyIndexes . push ( rows . length ) ;
76+ rows . push ( { type : "header" , module : g . module , count : g . items . length } ) ;
7477 if ( ! collapsed . has ( g . module ) ) {
7578 for ( const d of g . items ) {
76- result . push ( { type : "item" , declaration : d } ) ;
79+ rows . push ( { type : "item" , declaration : d } ) ;
7780 }
7881 }
7982 }
80- return result ;
83+ return { rows , stickyIndexes } ;
8184 } , [ groups , collapsed ] ) ;
8285
83- const stickyIndexes = useMemo (
84- ( ) =>
85- rows . reduce < number [ ] > ( ( acc , row , i ) => {
86- if ( row . type === "header" ) acc . push ( i ) ;
87- return acc ;
88- } , [ ] ) ,
89- [ rows ] ,
90- ) ;
91-
9286 const rangeExtractor = useCallback (
9387 ( range : Range ) => {
9488 let active = 0 ;
@@ -100,9 +94,11 @@ export const DeclarationsSidebar = ({ onNavigate }: { onNavigate?: () => void })
10094 }
10195 activeStickyIndexRef . current = active ;
10296
103- const next = new Set ( [ active , ...defaultRangeExtractor ( range ) ] ) ;
104-
105- return [ ...next ] . sort ( ( a , b ) => a - b ) ;
97+ const result = defaultRangeExtractor ( range ) ;
98+ if ( result [ 0 ] > active ) {
99+ result . unshift ( active ) ;
100+ }
101+ return result ;
106102 } ,
107103 [ stickyIndexes ] ,
108104 ) ;
@@ -115,47 +111,40 @@ export const DeclarationsSidebar = ({ onNavigate }: { onNavigate?: () => void })
115111 ) ;
116112 } , [ rows , scope , activeModule ] ) ;
117113
114+ const [ initialOffset ] = useState ( ( ) => {
115+ const target = activeIndex >= 0 ? activeIndex : 0 ;
116+ return Math . max ( 0 , target - STATIC_BEFORE ) * ROW_HEIGHT ;
117+ } ) ;
118+
119+ const setParentRef = useCallback (
120+ ( el : HTMLDivElement | null ) => {
121+ parentRef . current = el ;
122+ if ( el && initialOffset > 0 ) {
123+ el . scrollTop = initialOffset ;
124+ }
125+ } ,
126+ [ initialOffset ] ,
127+ ) ;
128+
118129 const virtualizer = useVirtualizer ( {
119130 count : rows . length ,
120131 getScrollElement : ( ) => parentRef . current ,
121- estimateSize : ( index ) => {
122- if ( rows [ index ] . type === "header" ) {
123- return HEADER_HEIGHT + ( index > 0 ? HEADER_GAP : 0 ) ;
124- }
125- return ITEM_HEIGHT ;
126- } ,
132+ estimateSize : ( ) => ROW_HEIGHT ,
127133 overscan : 20 ,
128134 rangeExtractor,
135+ initialOffset,
129136 } ) ;
130137
131- const didHydrationScroll = useRef ( false ) ;
132138 useLayoutEffect ( ( ) => {
133139 setHydrated ( true ) ;
134140 } , [ ] ) ;
135141
136- const STATIC_COUNT = 50 ;
137142 const staticRows = useMemo ( ( ) => {
138143 if ( hydrated ) return [ ] ;
139- const center = activeIndex >= 0 ? activeIndex : 0 ;
140- const half = Math . floor ( STATIC_COUNT / 2 ) ;
141- let start = Math . max ( 0 , center - half ) ;
142- const end = Math . min ( rows . length , start + STATIC_COUNT ) ;
143- start = Math . max ( 0 , end - STATIC_COUNT ) ;
144-
145- const slice = rows . slice ( start , end ) ;
146-
147- // If the first row is an item, prepend its group header
148- if ( slice . length > 0 && slice [ 0 ] . type === "item" ) {
149- for ( let i = start - 1 ; i >= 0 ; i -- ) {
150- if ( rows [ i ] . type === "header" ) {
151- slice . unshift ( rows [ i ] ) ;
152- break ;
153- }
154- }
155- }
156-
157- return slice ;
158- } , [ hydrated , rows , activeIndex ] ) ;
144+ const start = initialOffset / ROW_HEIGHT ;
145+ const end = Math . min ( rows . length , start + STATIC_BEFORE + STATIC_AFTER + 1 ) ;
146+ return rows . slice ( start , end ) ;
147+ } , [ hydrated , rows , initialOffset ] ) ;
159148
160149 const toggleModule = useCallback ( ( module : string ) => {
161150 setCollapsed ( ( prev ) => {
@@ -182,22 +171,17 @@ export const DeclarationsSidebar = ({ onNavigate }: { onNavigate?: () => void })
182171 } ) ;
183172 } , [ activeModule , scope ] ) ;
184173
185- // Scroll to active item on hydration (before paint)
186- useLayoutEffect ( ( ) => {
187- if ( ! didHydrationScroll . current && hydrated && activeIndex >= 0 ) {
188- didHydrationScroll . current = true ;
189- virtualizer . scrollToIndex ( activeIndex , { align : "center" } ) ;
190- }
191- } , [ hydrated , activeIndex , virtualizer ] ) ;
192-
193174 // Scroll to active item on navigation (skip if the click came from the sidebar)
194175 useEffect ( ( ) => {
176+ if ( isInitialMount . current ) {
177+ isInitialMount . current = false ;
178+ return ;
179+ }
195180 if ( ! scope || ! activeModule ) return ;
196181 if ( navigatedFromSidebarRef . current ) {
197182 navigatedFromSidebarRef . current = false ;
198183 return ;
199184 }
200- if ( ! didHydrationScroll . current ) return ; // handled by layoutEffect above
201185 if ( activeIndex >= 0 ) {
202186 virtualizer . scrollToIndex ( activeIndex , { align : "center" } ) ;
203187 }
@@ -234,8 +218,8 @@ export const DeclarationsSidebar = ({ onNavigate }: { onNavigate?: () => void })
234218 />
235219 </ SidebarHeader >
236220 { hydrated ? (
237- < SidebarList ref = { parentRef } >
238- < div style = { { height : virtualizer . getTotalSize ( ) , position : "relative" } } >
221+ < SidebarList ref = { setParentRef } >
222+ < SidebarUl style = { { height : virtualizer . getTotalSize ( ) , position : "relative" } } >
239223 { virtualizer . getVirtualItems ( ) . map ( ( virtualRow ) => {
240224 const row = rows [ virtualRow . index ] ;
241225 const isHeader = row . type === "header" ;
@@ -251,23 +235,16 @@ export const DeclarationsSidebar = ({ onNavigate }: { onNavigate?: () => void })
251235 top : 0 ,
252236 left : 0 ,
253237 width : "100%" ,
254- height : virtualRow . size ,
238+ height : ROW_HEIGHT ,
255239 } }
256240 >
257241 { isHeader ? (
258- < div
259- style = { {
260- paddingTop : virtualRow . index > 0 && ! isActiveSticky ? HEADER_GAP : 0 ,
261- background : "inherit" ,
262- } }
242+ < SidebarGroupHeader
243+ data-collapsed = { collapsed . has ( row . module ) || undefined }
244+ onClick = { ( ) => toggleModule ( row . module ) }
263245 >
264- < SidebarGroupHeader
265- data-collapsed = { collapsed . has ( row . module ) || undefined }
266- onClick = { ( ) => toggleModule ( row . module ) }
267- >
268- { row . module } ({ row . count } )
269- </ SidebarGroupHeader >
270- </ div >
246+ { row . module } ({ row . count } )
247+ </ SidebarGroupHeader >
271248 ) : (
272249 < DeclarationSidebarElement
273250 declaration = { row . declaration }
@@ -277,32 +254,25 @@ export const DeclarationsSidebar = ({ onNavigate }: { onNavigate?: () => void })
277254 </ li >
278255 ) ;
279256 } ) }
280- </ div >
257+ </ SidebarUl >
281258 </ SidebarList >
282259 ) : (
283260 < SidebarList >
284- { staticRows . map ( ( row , i ) => {
285- if ( row . type === "header" ) {
286- return (
287- < li
288- key = { `h-${ row . module } ` }
289- style = { {
290- height : HEADER_HEIGHT + ( i > 0 ? HEADER_GAP : 0 ) ,
291- paddingTop : i > 0 ? HEADER_GAP : 0 ,
292- } }
293- >
261+ < SidebarUl >
262+ { staticRows . map ( ( row ) =>
263+ row . type === "header" ? (
264+ < li key = { `h-${ row . module } ` } style = { { height : ROW_HEIGHT } } >
294265 < SidebarGroupHeader >
295266 { row . module } ({ row . count } )
296267 </ SidebarGroupHeader >
297268 </ li >
298- ) ;
299- }
300- return (
301- < li key = { `${ row . declaration . module } -${ row . declaration . name } ` } >
302- < DeclarationSidebarElement declaration = { row . declaration } />
303- </ li >
304- ) ;
305- } ) }
269+ ) : (
270+ < li key = { `${ row . declaration . module } -${ row . declaration . name } ` } >
271+ < DeclarationSidebarElement declaration = { row . declaration } />
272+ </ li >
273+ ) ,
274+ ) }
275+ </ SidebarUl >
306276 </ SidebarList >
307277 ) }
308278 </ SidebarWrapper >
@@ -338,9 +308,12 @@ const SidebarSearchInput = styled(SearchInput)`
338308 background: var(--background);
339309` ;
340310
341- const SidebarList = styled . ul `
311+ const SidebarList = styled . div `
342312 flex: 1;
343313 overflow: auto;
314+ ` ;
315+
316+ const SidebarUl = styled . ul `
344317 margin: 0;
345318 padding: 0;
346319 list-style: none;
0 commit comments