Skip to content

Commit 3894341

Browse files
committed
frontend/projects/starred: reduce flicker by not overflowing, slightly different strategy
1 parent 2388a60 commit 3894341

File tree

1 file changed

+163
-88
lines changed

1 file changed

+163
-88
lines changed

src/packages/frontend/projects/projects-starred.tsx

Lines changed: 163 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,22 @@
44
*/
55

66
import { 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

99
import { CSS, useActions, useTypedRedux } from "@cocalc/frontend/app-framework";
1010
import { Icon, TimeAgo } from "@cocalc/frontend/components";
1111
import { trunc } from "@cocalc/util/misc";
1212
import { COLORS } from "@cocalc/util/theme";
1313
import { useBookmarkedProjects } from "./use-bookmarked-projects";
14-
import { blendBackgroundColor, sortProjectsLastEdited } from "./util";
14+
import { sortProjectsLastEdited } from "./util";
1515

1616
const DROPDOWN_WIDTH = 100; // Width reserved for dropdown button + buffer
1717

1818
const STARRED_BAR_STYLE: CSS = {
1919
overflow: "hidden",
20+
overflowX: "hidden",
21+
width: "100%",
22+
position: "relative",
2023
} as const;
2124

2225
const 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

Comments
 (0)