44 */
55
66import { Avatar , Button , Dropdown , Space , Tooltip } from "antd" ;
7- import { useLayoutEffect , useMemo , useRef , useState } from "react" ;
7+ import { useCallback , useLayoutEffect , useMemo , useRef , useState } from "react" ;
88
99import { CSS , useActions , useTypedRedux } from "@cocalc/frontend/app-framework" ;
1010import { Icon , TimeAgo } from "@cocalc/frontend/components" ;
1111import { trunc } from "@cocalc/util/misc" ;
1212import { COLORS } from "@cocalc/util/theme" ;
1313import { useBookmarkedProjects } from "./use-bookmarked-projects" ;
14- import { blendBackgroundColor , sortProjectsLastEdited } from "./util" ;
14+ import { sortProjectsLastEdited } from "./util" ;
1515
1616const DROPDOWN_WIDTH = 100 ; // Width reserved for dropdown button + buffer
1717
1818const STARRED_BAR_STYLE : CSS = {
1919 overflow : "hidden" ,
20+ overflowX : "hidden" ,
21+ width : "100%" ,
22+ position : "relative" ,
2023} as const ;
2124
2225const STARRED_BUTTON_STYLE : CSS = {
@@ -56,95 +59,131 @@ export function StarredProjectsBar() {
5659 } , [ bookmarkedProjects , project_map ] ) ;
5760
5861 // State for tracking how many projects can be shown
59- const [ visibleCount , setVisibleCount ] = useState < number > (
60- starredProjects . length ,
61- ) ;
62+ const [ visibleCount , setVisibleCount ] = useState < number > ( 0 ) ;
63+ const [ measurementPhase , setMeasurementPhase ] = useState < boolean > ( true ) ;
64+ const [ containerHeight , setContainerHeight ] = useState < number > ( 0 ) ;
6265 const containerRef = useRef < HTMLDivElement > ( null ) ;
6366 const spaceRef = useRef < HTMLDivElement > ( null ) ;
67+ const measurementContainerRef = useRef < HTMLDivElement > ( null ) ;
6468 const buttonWidthsRef = useRef < number [ ] > ( [ ] ) ;
65- const [ measurementComplete , setMeasurementComplete ] = useState ( false ) ;
6669
67- // Reset measurement when projects change
68- useLayoutEffect ( ( ) => {
69- setMeasurementComplete ( false ) ;
70- setVisibleCount ( starredProjects . length ) ;
71- } , [ starredProjects ] ) ;
70+ // Calculate how many buttons fit based on measured widths
71+ const calculateVisibleCount = useCallback ( ( ) => {
72+ if ( ! containerRef . current ) return ;
7273
73- // Measure buttons on first render and when projects change
74- useLayoutEffect ( ( ) => {
75- if (
76- ! spaceRef . current ||
77- starredProjects . length === 0 ||
78- measurementComplete
79- ) {
74+ // First pass: measure without dropdown space
75+ let cumulativeWidth = 0 ;
76+ let countWithoutDropdown = 0 ;
77+
78+ for ( let i = 0 ; i < buttonWidthsRef . current . length ; i ++ ) {
79+ const buttonWidth = buttonWidthsRef . current [ i ] ;
80+ const spacing = i > 0 ? 8 : 0 ;
81+ cumulativeWidth += buttonWidth + spacing ;
82+
83+ if ( cumulativeWidth <= containerRef . current . offsetWidth ) {
84+ countWithoutDropdown ++ ;
85+ } else {
86+ break ;
87+ }
88+ }
89+
90+ // If all projects fit, no dropdown needed
91+ if ( countWithoutDropdown >= starredProjects . length ) {
92+ setVisibleCount ( starredProjects . length ) ;
8093 return ;
8194 }
8295
83- // Measure all button widths
84- const buttons = spaceRef . current . querySelectorAll < HTMLElement > (
85- ".starred-project-button" ,
86- ) ;
96+ // If not all fit, recalculate with dropdown space reserved
97+ const availableWidth = containerRef . current . offsetWidth - DROPDOWN_WIDTH ;
98+ cumulativeWidth = 0 ;
99+ let countWithDropdown = 0 ;
100+
101+ for ( let i = 0 ; i < buttonWidthsRef . current . length ; i ++ ) {
102+ const buttonWidth = buttonWidthsRef . current [ i ] ;
103+ const spacing = i > 0 ? 8 : 0 ;
104+ cumulativeWidth += buttonWidth + spacing ;
87105
88- if ( buttons . length === starredProjects . length ) {
89- buttonWidthsRef . current = Array . from ( buttons ) . map (
90- ( button ) => button . offsetWidth ,
91- ) ;
92- setMeasurementComplete ( true ) ;
106+ if ( cumulativeWidth <= availableWidth ) {
107+ countWithDropdown ++ ;
108+ } else {
109+ break ;
110+ }
93111 }
94- } , [ starredProjects , measurementComplete ] ) ;
95112
96- // Calculate how many buttons fit based on measured widths
113+ // Show at least 1 project, or all if they fit
114+ const finalCount = countWithDropdown === 0 ? 1 : countWithDropdown ;
115+
116+ // Only update state if the value actually changed
117+ setVisibleCount ( ( prev ) => ( prev !== finalCount ? finalCount : prev ) ) ;
118+ } , [ starredProjects . length ] ) ;
119+
120+ // Reset measurement phase when projects change
97121 useLayoutEffect ( ( ) => {
98- if (
99- ! containerRef . current ||
100- ! measurementComplete ||
101- buttonWidthsRef . current . length === 0
102- ) {
122+ setMeasurementPhase ( true ) ;
123+ setVisibleCount ( 0 ) ;
124+ } , [ starredProjects ] ) ;
125+
126+ // Measure button widths from hidden container and calculate visible count
127+ useLayoutEffect ( ( ) => {
128+ if ( ! measurementPhase || starredProjects . length === 0 ) {
103129 return ;
104130 }
105131
106- const calculateVisibleCount = ( ) => {
107- if ( ! containerRef . current ) return ;
108- const availableWidth = containerRef . current . offsetWidth - DROPDOWN_WIDTH ;
109-
110- let cumulativeWidth = 0 ;
111- let count = 0 ;
132+ // Use requestAnimationFrame to ensure buttons are fully laid out before measuring
133+ const frameId = requestAnimationFrame ( ( ) => {
134+ if ( ! measurementContainerRef . current ) {
135+ setMeasurementPhase ( false ) ;
136+ return ;
137+ }
112138
113- for ( let i = 0 ; i < buttonWidthsRef . current . length ; i ++ ) {
114- const buttonWidth = buttonWidthsRef . current [ i ] ;
115- // Account for Space component's gap (8px for "small" size)
116- const spacing = i > 0 ? 8 : 0 ;
117- cumulativeWidth += buttonWidth + spacing ;
139+ const buttons =
140+ measurementContainerRef . current . querySelectorAll < HTMLElement > (
141+ ".starred-project-button" ,
142+ ) ;
118143
119- if ( cumulativeWidth <= availableWidth ) {
120- count ++ ;
121- } else {
122- break ;
123- }
144+ // Capture the height of the measurement container to prevent height collapse
145+ const height = measurementContainerRef . current . offsetHeight ;
146+ if ( height > 0 ) {
147+ setContainerHeight ( height ) ;
124148 }
125149
126- // Show at least 1 project if there's any space, or all if they all fit
127- const newVisibleCount = count === 0 ? 1 : count ;
128-
129- // Only show dropdown if there are actually hidden projects
130- if ( newVisibleCount >= starredProjects . length ) {
131- setVisibleCount ( starredProjects . length ) ;
150+ if ( buttons && buttons . length === starredProjects . length ) {
151+ buttonWidthsRef . current = Array . from ( buttons ) . map (
152+ ( button ) => button . offsetWidth ,
153+ ) ;
154+ // Calculate visible count immediately after measuring
155+ calculateVisibleCount ( ) ;
132156 } else {
133- setVisibleCount ( newVisibleCount ) ;
157+ // If measurement failed, show all projects
158+ setVisibleCount ( starredProjects . length ) ;
134159 }
135- } ;
136160
137- // Initial calculation
138- calculateVisibleCount ( ) ;
161+ // Always exit measurement phase once we've attempted to measure
162+ setMeasurementPhase ( false ) ;
163+ } ) ;
164+
165+ return ( ) => cancelAnimationFrame ( frameId ) ;
166+ } , [ starredProjects , calculateVisibleCount , measurementPhase ] ) ;
167+
168+ // Set up ResizeObserver to recalculate visible count on container resize
169+ useLayoutEffect ( ( ) => {
170+ if ( ! containerRef . current || buttonWidthsRef . current . length === 0 ) {
171+ return ;
172+ }
139173
140- // Recalculate on resize
174+ // Recalculate on resize with debounce to prevent flicker
175+ let timeoutId : NodeJS . Timeout ;
141176 const resizeObserver = new ResizeObserver ( ( ) => {
142- calculateVisibleCount ( ) ;
177+ clearTimeout ( timeoutId ) ;
178+ timeoutId = setTimeout ( calculateVisibleCount , 16 ) ; // ~60fps
143179 } ) ;
144180
145181 resizeObserver . observe ( containerRef . current ) ;
146- return ( ) => resizeObserver . disconnect ( ) ;
147- } , [ measurementComplete , starredProjects . length ] ) ;
182+ return ( ) => {
183+ resizeObserver . disconnect ( ) ;
184+ clearTimeout ( timeoutId ) ;
185+ } ;
186+ } , [ calculateVisibleCount ] ) ;
148187
149188 const handleProjectClick = (
150189 project_id : string ,
@@ -159,8 +198,7 @@ export function StarredProjectsBar() {
159198 return null ; // Hide bar if no starred projects
160199 }
161200
162- // Split projects into visible and overflow
163- const visibleProjects = starredProjects . slice ( 0 , visibleCount ) ;
201+ // Get overflow projects for the dropdown menu
164202 const overflowProjects = starredProjects . slice ( visibleCount ) ;
165203
166204 const renderTooltipContent = ( project : any ) => {
@@ -188,14 +226,16 @@ export function StarredProjectsBar() {
188226 } ;
189227
190228 // Helper to render a project button
191- function renderProjectButton ( project : any , showTooltip : boolean = true ) {
192- // Create background color with faint hint of project color
193- const backgroundColor = blendBackgroundColor ( project . color , "white" , true ) ;
194-
229+ function renderProjectButton (
230+ project : any ,
231+ showTooltip : boolean = true ,
232+ visibility ?: "hidden" | "visible" ,
233+ ) {
195234 const buttonStyle = {
196235 ...STARRED_BUTTON_STYLE ,
197- backgroundColor,
198- } ;
236+ ...( project . color && { borderColor : project . color , borderWidth : 2 } ) ,
237+ ...( visibility && { visibility } ) ,
238+ } as const ;
199239
200240 const button = (
201241 < Button
@@ -255,7 +295,7 @@ export function StarredProjectsBar() {
255295 overflow : "hidden" ,
256296 textOverflow : "ellipsis" ,
257297 whiteSpace : "nowrap" ,
258- paddingLeft : "5px"
298+ paddingLeft : "5px" ,
259299 } }
260300 >
261301 { project . avatar_image_tiny ? (
@@ -270,22 +310,57 @@ export function StarredProjectsBar() {
270310 } ) ) ;
271311
272312 return (
273- < div ref = { containerRef } style = { STARRED_BAR_STYLE } >
313+ < div
314+ ref = { containerRef }
315+ style = { {
316+ ...STARRED_BAR_STYLE ,
317+ minHeight : containerHeight > 0 ? `${ containerHeight } px` : undefined ,
318+ } }
319+ >
320+ { /* Hidden measurement container - rendered off-screen so it doesn't cause visual flicker */ }
321+ { measurementPhase && (
322+ < div
323+ ref = { measurementContainerRef }
324+ style = { {
325+ position : "fixed" ,
326+ visibility : "hidden" ,
327+ width : containerRef . current ?. offsetWidth ?? "100%" ,
328+ display : "flex" ,
329+ gap : "8px" ,
330+ pointerEvents : "none" ,
331+ top : - 9999 ,
332+ left : - 9999 ,
333+ } }
334+ >
335+ { starredProjects . map ( ( project ) =>
336+ renderProjectButton ( project , false , "visible" ) ,
337+ ) }
338+ </ div >
339+ ) }
340+
341+ { /* Actual visible content - only rendered after measurement phase */ }
274342 < Space size = "small" ref = { spaceRef } >
275- { /* Show all buttons during initial measurement, then only visible ones */ }
276- { ( ! measurementComplete ? starredProjects : visibleProjects ) . map (
277- ( project ) => renderProjectButton ( project ) ,
278- ) }
279- { measurementComplete && overflowProjects . length > 0 && (
280- < Dropdown
281- menu = { { items : overflowMenuItems } }
282- placement = "bottomRight"
283- trigger = { [ "click" ] }
284- >
285- < Button icon = { < Icon name = "ellipsis" /> } >
286- +{ overflowProjects . length }
287- </ Button >
288- </ Dropdown >
343+ { ! measurementPhase && (
344+ < >
345+ { starredProjects
346+ . slice ( 0 , visibleCount )
347+ . map ( ( project ) => renderProjectButton ( project ) ) }
348+ { /* Show overflow dropdown if there are hidden projects */ }
349+ { overflowProjects . length > 0 && (
350+ < Dropdown
351+ menu = { { items : overflowMenuItems } }
352+ placement = "bottomRight"
353+ trigger = { [ "click" ] }
354+ >
355+ < Button
356+ icon = { < Icon name = "ellipsis" /> }
357+ style = { { backgroundColor : "white" , marginLeft : "auto" } }
358+ >
359+ +{ overflowProjects . length }
360+ </ Button >
361+ </ Dropdown >
362+ ) }
363+ </ >
289364 ) }
290365 </ Space >
291366 </ div >
0 commit comments