From 3894341864113964513d5ab3375c2d08b1fcfa2e Mon Sep 17 00:00:00 2001 From: Harald Schilly Date: Wed, 29 Oct 2025 12:01:15 +0100 Subject: [PATCH 1/3] frontend/projects/starred: reduce flicker by not overflowing, slightly different strategy --- .../frontend/projects/projects-starred.tsx | 251 ++++++++++++------ 1 file changed, 163 insertions(+), 88 deletions(-) diff --git a/src/packages/frontend/projects/projects-starred.tsx b/src/packages/frontend/projects/projects-starred.tsx index e8c07417aa..7e88379bd9 100644 --- a/src/packages/frontend/projects/projects-starred.tsx +++ b/src/packages/frontend/projects/projects-starred.tsx @@ -4,19 +4,22 @@ */ import { Avatar, Button, Dropdown, Space, Tooltip } from "antd"; -import { useLayoutEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react"; import { CSS, useActions, useTypedRedux } from "@cocalc/frontend/app-framework"; import { Icon, TimeAgo } from "@cocalc/frontend/components"; import { trunc } from "@cocalc/util/misc"; import { COLORS } from "@cocalc/util/theme"; import { useBookmarkedProjects } from "./use-bookmarked-projects"; -import { blendBackgroundColor, sortProjectsLastEdited } from "./util"; +import { sortProjectsLastEdited } from "./util"; const DROPDOWN_WIDTH = 100; // Width reserved for dropdown button + buffer const STARRED_BAR_STYLE: CSS = { overflow: "hidden", + overflowX: "hidden", + width: "100%", + position: "relative", } as const; const STARRED_BUTTON_STYLE: CSS = { @@ -56,95 +59,131 @@ export function StarredProjectsBar() { }, [bookmarkedProjects, project_map]); // State for tracking how many projects can be shown - const [visibleCount, setVisibleCount] = useState( - starredProjects.length, - ); + const [visibleCount, setVisibleCount] = useState(0); + const [measurementPhase, setMeasurementPhase] = useState(true); + const [containerHeight, setContainerHeight] = useState(0); const containerRef = useRef(null); const spaceRef = useRef(null); + const measurementContainerRef = useRef(null); const buttonWidthsRef = useRef([]); - const [measurementComplete, setMeasurementComplete] = useState(false); - // Reset measurement when projects change - useLayoutEffect(() => { - setMeasurementComplete(false); - setVisibleCount(starredProjects.length); - }, [starredProjects]); + // Calculate how many buttons fit based on measured widths + const calculateVisibleCount = useCallback(() => { + if (!containerRef.current) return; - // Measure buttons on first render and when projects change - useLayoutEffect(() => { - if ( - !spaceRef.current || - starredProjects.length === 0 || - measurementComplete - ) { + // First pass: measure without dropdown space + let cumulativeWidth = 0; + let countWithoutDropdown = 0; + + for (let i = 0; i < buttonWidthsRef.current.length; i++) { + const buttonWidth = buttonWidthsRef.current[i]; + const spacing = i > 0 ? 8 : 0; + cumulativeWidth += buttonWidth + spacing; + + if (cumulativeWidth <= containerRef.current.offsetWidth) { + countWithoutDropdown++; + } else { + break; + } + } + + // If all projects fit, no dropdown needed + if (countWithoutDropdown >= starredProjects.length) { + setVisibleCount(starredProjects.length); return; } - // Measure all button widths - const buttons = spaceRef.current.querySelectorAll( - ".starred-project-button", - ); + // If not all fit, recalculate with dropdown space reserved + const availableWidth = containerRef.current.offsetWidth - DROPDOWN_WIDTH; + cumulativeWidth = 0; + let countWithDropdown = 0; + + for (let i = 0; i < buttonWidthsRef.current.length; i++) { + const buttonWidth = buttonWidthsRef.current[i]; + const spacing = i > 0 ? 8 : 0; + cumulativeWidth += buttonWidth + spacing; - if (buttons.length === starredProjects.length) { - buttonWidthsRef.current = Array.from(buttons).map( - (button) => button.offsetWidth, - ); - setMeasurementComplete(true); + if (cumulativeWidth <= availableWidth) { + countWithDropdown++; + } else { + break; + } } - }, [starredProjects, measurementComplete]); - // Calculate how many buttons fit based on measured widths + // Show at least 1 project, or all if they fit + const finalCount = countWithDropdown === 0 ? 1 : countWithDropdown; + + // Only update state if the value actually changed + setVisibleCount((prev) => (prev !== finalCount ? finalCount : prev)); + }, [starredProjects.length]); + + // Reset measurement phase when projects change useLayoutEffect(() => { - if ( - !containerRef.current || - !measurementComplete || - buttonWidthsRef.current.length === 0 - ) { + setMeasurementPhase(true); + setVisibleCount(0); + }, [starredProjects]); + + // Measure button widths from hidden container and calculate visible count + useLayoutEffect(() => { + if (!measurementPhase || starredProjects.length === 0) { return; } - const calculateVisibleCount = () => { - if (!containerRef.current) return; - const availableWidth = containerRef.current.offsetWidth - DROPDOWN_WIDTH; - - let cumulativeWidth = 0; - let count = 0; + // Use requestAnimationFrame to ensure buttons are fully laid out before measuring + const frameId = requestAnimationFrame(() => { + if (!measurementContainerRef.current) { + setMeasurementPhase(false); + return; + } - for (let i = 0; i < buttonWidthsRef.current.length; i++) { - const buttonWidth = buttonWidthsRef.current[i]; - // Account for Space component's gap (8px for "small" size) - const spacing = i > 0 ? 8 : 0; - cumulativeWidth += buttonWidth + spacing; + const buttons = + measurementContainerRef.current.querySelectorAll( + ".starred-project-button", + ); - if (cumulativeWidth <= availableWidth) { - count++; - } else { - break; - } + // Capture the height of the measurement container to prevent height collapse + const height = measurementContainerRef.current.offsetHeight; + if (height > 0) { + setContainerHeight(height); } - // Show at least 1 project if there's any space, or all if they all fit - const newVisibleCount = count === 0 ? 1 : count; - - // Only show dropdown if there are actually hidden projects - if (newVisibleCount >= starredProjects.length) { - setVisibleCount(starredProjects.length); + if (buttons && buttons.length === starredProjects.length) { + buttonWidthsRef.current = Array.from(buttons).map( + (button) => button.offsetWidth, + ); + // Calculate visible count immediately after measuring + calculateVisibleCount(); } else { - setVisibleCount(newVisibleCount); + // If measurement failed, show all projects + setVisibleCount(starredProjects.length); } - }; - // Initial calculation - calculateVisibleCount(); + // Always exit measurement phase once we've attempted to measure + setMeasurementPhase(false); + }); + + return () => cancelAnimationFrame(frameId); + }, [starredProjects, calculateVisibleCount, measurementPhase]); + + // Set up ResizeObserver to recalculate visible count on container resize + useLayoutEffect(() => { + if (!containerRef.current || buttonWidthsRef.current.length === 0) { + return; + } - // Recalculate on resize + // Recalculate on resize with debounce to prevent flicker + let timeoutId: NodeJS.Timeout; const resizeObserver = new ResizeObserver(() => { - calculateVisibleCount(); + clearTimeout(timeoutId); + timeoutId = setTimeout(calculateVisibleCount, 16); // ~60fps }); resizeObserver.observe(containerRef.current); - return () => resizeObserver.disconnect(); - }, [measurementComplete, starredProjects.length]); + return () => { + resizeObserver.disconnect(); + clearTimeout(timeoutId); + }; + }, [calculateVisibleCount]); const handleProjectClick = ( project_id: string, @@ -159,8 +198,7 @@ export function StarredProjectsBar() { return null; // Hide bar if no starred projects } - // Split projects into visible and overflow - const visibleProjects = starredProjects.slice(0, visibleCount); + // Get overflow projects for the dropdown menu const overflowProjects = starredProjects.slice(visibleCount); const renderTooltipContent = (project: any) => { @@ -188,14 +226,16 @@ export function StarredProjectsBar() { }; // Helper to render a project button - function renderProjectButton(project: any, showTooltip: boolean = true) { - // Create background color with faint hint of project color - const backgroundColor = blendBackgroundColor(project.color, "white", true); - + function renderProjectButton( + project: any, + showTooltip: boolean = true, + visibility?: "hidden" | "visible", + ) { const buttonStyle = { ...STARRED_BUTTON_STYLE, - backgroundColor, - }; + ...(project.color && { borderColor: project.color, borderWidth: 2 }), + ...(visibility && { visibility }), + } as const; const button = ( - + {!measurementPhase && ( + <> + {starredProjects + .slice(0, visibleCount) + .map((project) => renderProjectButton(project))} + {/* Show overflow dropdown if there are hidden projects */} + {overflowProjects.length > 0 && ( + + + + )} + )} From 7ce51a17382d3e8335033c7b300ec6ea9a8c88ce Mon Sep 17 00:00:00 2001 From: Harald Schilly Date: Wed, 29 Oct 2025 12:05:32 +0100 Subject: [PATCH 2/3] frontend/projects/starred: keep order stable! --- src/packages/frontend/projects/projects-starred.tsx | 7 +++---- src/packages/frontend/projects/use-bookmarked-projects.ts | 8 ++++---- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/packages/frontend/projects/projects-starred.tsx b/src/packages/frontend/projects/projects-starred.tsx index 7e88379bd9..a354588348 100644 --- a/src/packages/frontend/projects/projects-starred.tsx +++ b/src/packages/frontend/projects/projects-starred.tsx @@ -11,7 +11,6 @@ import { Icon, TimeAgo } from "@cocalc/frontend/components"; import { trunc } from "@cocalc/util/misc"; import { COLORS } from "@cocalc/util/theme"; import { useBookmarkedProjects } from "./use-bookmarked-projects"; -import { sortProjectsLastEdited } from "./util"; const DROPDOWN_WIDTH = 100; // Width reserved for dropdown button + buffer @@ -33,7 +32,7 @@ export function StarredProjectsBar() { const { bookmarkedProjects } = useBookmarkedProjects(); const project_map = useTypedRedux("projects", "project_map"); - // Get starred projects sorted by title + // Get starred projects in bookmarked order (newest bookmarked first) const starredProjects = useMemo(() => { if (!bookmarkedProjects || !project_map) return []; @@ -54,8 +53,8 @@ export function StarredProjectsBar() { }) .filter((p) => p != null); - // Sort by last edited, newest first - return projects.sort(sortProjectsLastEdited).reverse(); + // Return projects in their bookmarked order + return projects; }, [bookmarkedProjects, project_map]); // State for tracking how many projects can be shown diff --git a/src/packages/frontend/projects/use-bookmarked-projects.ts b/src/packages/frontend/projects/use-bookmarked-projects.ts index 9c1d1249a1..575dc1f80b 100644 --- a/src/packages/frontend/projects/use-bookmarked-projects.ts +++ b/src/packages/frontend/projects/use-bookmarked-projects.ts @@ -17,7 +17,7 @@ bm.set("projects", ['project_id_1', 'project_id_2']) bm.on('change', (e) => console.log('Bookmark change:', e)) */ -import { sortBy, uniq } from "lodash"; +import { uniq } from "lodash"; import { useEffect, useRef, useState } from "react"; import { redux } from "@cocalc/frontend/app-framework"; @@ -78,7 +78,7 @@ export function useBookmarkedProjects() { // Load initial data from conat const initialBookmarks = conatBookmarks.get(PROJECTS_KEY) ?? []; if (Array.isArray(initialBookmarks)) { - setBookmarkedProjects(sortBy(uniq(initialBookmarks))); + setBookmarkedProjects(uniq(initialBookmarks)); } // Create stable listener function @@ -90,7 +90,7 @@ export function useBookmarkedProjects() { if (changeEvent.key === PROJECTS_KEY) { const remoteBookmarks = (changeEvent.value as BookmarkedProjects) ?? []; - setBookmarkedProjects(sortBy(uniq(remoteBookmarks))); + setBookmarkedProjects(uniq(remoteBookmarks)); } }; @@ -125,7 +125,7 @@ export function useBookmarkedProjects() { } const next = bookmarked - ? sortBy(uniq([...bookmarkedProjects, project_id])) + ? uniq([project_id, ...bookmarkedProjects]) : bookmarkedProjects.filter((p) => p !== project_id); // Update local state immediately for responsive UI From fd81e94e3a1944f161762021bfbccea5fb413d74 Mon Sep 17 00:00:00 2001 From: Harald Schilly Date: Wed, 29 Oct 2025 12:27:40 +0100 Subject: [PATCH 3/3] frontend/projects/starred: dnd sort the visible starred project buttons --- .../frontend/projects/projects-starred.tsx | 303 ++++++++++++------ .../projects/use-bookmarked-projects.ts | 22 ++ 2 files changed, 225 insertions(+), 100 deletions(-) diff --git a/src/packages/frontend/projects/projects-starred.tsx b/src/packages/frontend/projects/projects-starred.tsx index a354588348..3eba337478 100644 --- a/src/packages/frontend/projects/projects-starred.tsx +++ b/src/packages/frontend/projects/projects-starred.tsx @@ -5,6 +5,19 @@ import { Avatar, Button, Dropdown, Space, Tooltip } from "antd"; import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { + DndContext, + DragEndEvent, + MouseSensor, + TouchSensor, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { + SortableContext, + horizontalListSortingStrategy, + useSortable, +} from "@dnd-kit/sortable"; import { CSS, useActions, useTypedRedux } from "@cocalc/frontend/app-framework"; import { Icon, TimeAgo } from "@cocalc/frontend/components"; @@ -27,9 +40,82 @@ const STARRED_BUTTON_STYLE: CSS = { whiteSpace: "nowrap", } as const; +function DraggableProjectButton({ + project, + showTooltip = true, + visibility, + isOverlay = false, + onProjectClick, + renderTooltipContent, +}: { + project: any; + showTooltip?: boolean; + visibility?: "hidden" | "visible"; + isOverlay?: boolean; + onProjectClick: ( + project_id: string, + e: React.MouseEvent, + ) => void; + renderTooltipContent: (project: any) => React.ReactNode; +}) { + const { attributes, listeners, setNodeRef, transform, isDragging } = + useSortable({ id: project.project_id }); + + const buttonStyle = { + ...STARRED_BUTTON_STYLE, + ...(project.color && { borderColor: project.color, borderWidth: 2 }), + ...(visibility && { visibility }), + ...(isDragging && !isOverlay && { opacity: 0.5 }), + transform: transform + ? `translate3d(${transform.x}px, ${transform.y}px, 0)` + : undefined, + } as const; + + const button = ( + + ); + + if (!showTooltip) { + return button; + } + + return ( + + {button} + + ); +} + export function StarredProjectsBar() { const actions = useActions("projects"); - const { bookmarkedProjects } = useBookmarkedProjects(); + const { bookmarkedProjects, setBookmarkedProjectsOrder } = + useBookmarkedProjects(); const project_map = useTypedRedux("projects", "project_map"); // Get starred projects in bookmarked order (newest bookmarked first) @@ -57,6 +143,15 @@ export function StarredProjectsBar() { return projects; }, [bookmarkedProjects, project_map]); + // Drag and drop sensors + const mouseSensor = useSensor(MouseSensor, { + activationConstraint: { distance: 5 }, // 5px to activate drag + }); + const touchSensor = useSensor(TouchSensor, { + activationConstraint: { delay: 100, tolerance: 5 }, + }); + const sensors = useSensors(mouseSensor, touchSensor); + // State for tracking how many projects can be shown const [visibleCount, setVisibleCount] = useState(0); const [measurementPhase, setMeasurementPhase] = useState(true); @@ -184,6 +279,38 @@ export function StarredProjectsBar() { }; }, [calculateVisibleCount]); + const handleDragEnd = useCallback( + (event: DragEndEvent) => { + const { active, over } = event; + + if (!over || active.id === over.id) { + return; + } + + // Find the indices of the dragged and target items + const activeIndex = starredProjects.findIndex( + (p) => p.project_id === active.id, + ); + const overIndex = starredProjects.findIndex( + (p) => p.project_id === over.id, + ); + + if (activeIndex === -1 || overIndex === -1) { + return; + } + + // Create new ordered list + const newProjects = [...starredProjects]; + const [movedProject] = newProjects.splice(activeIndex, 1); + newProjects.splice(overIndex, 0, movedProject); + + // Update bookmarked projects with new order + const newBookmarkedOrder = newProjects.map((p) => p.project_id); + setBookmarkedProjectsOrder(newBookmarkedOrder); + }, + [starredProjects, setBookmarkedProjectsOrder], + ); + const handleProjectClick = ( project_id: string, e: React.MouseEvent, @@ -224,56 +351,6 @@ export function StarredProjectsBar() { ); }; - // Helper to render a project button - function renderProjectButton( - project: any, - showTooltip: boolean = true, - visibility?: "hidden" | "visible", - ) { - const buttonStyle = { - ...STARRED_BUTTON_STYLE, - ...(project.color && { borderColor: project.color, borderWidth: 2 }), - ...(visibility && { visibility }), - } as const; - - const button = ( - - ); - - if (!showTooltip) { - return button; - } - - return ( - - {button} - - ); - } - // Create dropdown menu items for overflow projects const overflowMenuItems = overflowProjects.map((project) => ({ key: project.project_id, @@ -286,7 +363,10 @@ export function StarredProjectsBar() { project.color ? project.color : "transparent" }`, }} - onClick={(e) => handleProjectClick(project.project_id, e as any)} + onClick={(e) => { + e.stopPropagation(); + handleProjectClick(project.project_id, e as any); + }} > p.project_id); + return ( -
0 ? `${containerHeight}px` : undefined, - }} - > - {/* Hidden measurement container - rendered off-screen so it doesn't cause visual flicker */} - {measurementPhase && ( + +
0 ? `${containerHeight}px` : undefined, }} > - {starredProjects.map((project) => - renderProjectButton(project, false, "visible"), + {/* Hidden measurement container - rendered off-screen so it doesn't cause visual flicker */} + {measurementPhase && ( +
+ {starredProjects.map((project) => ( + + ))} +
)} -
- )} - - {/* Actual visible content - only rendered after measurement phase */} - - {!measurementPhase && ( - <> - {starredProjects - .slice(0, visibleCount) - .map((project) => renderProjectButton(project))} - {/* Show overflow dropdown if there are hidden projects */} - {overflowProjects.length > 0 && ( - - - + + {/* Actual visible content - only rendered after measurement phase */} + + {!measurementPhase && ( + <> + {starredProjects.slice(0, visibleCount).map((project) => ( + + ))} + {/* Show overflow dropdown if there are hidden projects */} + {overflowProjects.length > 0 && ( + + + + )} + )} - - )} - -
+ + + + ); } diff --git a/src/packages/frontend/projects/use-bookmarked-projects.ts b/src/packages/frontend/projects/use-bookmarked-projects.ts index 575dc1f80b..9b1940a324 100644 --- a/src/packages/frontend/projects/use-bookmarked-projects.ts +++ b/src/packages/frontend/projects/use-bookmarked-projects.ts @@ -141,6 +141,27 @@ export function useBookmarkedProjects() { } } + function setBookmarkedProjectsOrder( + newBookmarkedProjects: BookmarkedProjects, + ) { + if (!bookmarks || !isInitialized) { + console.warn("Conat bookmarks not yet initialized"); + return; + } + + // Update local state immediately for responsive UI + setBookmarkedProjects(newBookmarkedProjects); + + // Store to conat (this will also trigger the change event for other clients) + try { + bookmarks.set(PROJECTS_KEY, newBookmarkedProjects); + } catch (err) { + console.warn(`conat bookmark storage warning -- ${err}`); + // Revert local state on error + setBookmarkedProjects(bookmarkedProjects); + } + } + function isProjectBookmarked(project_id: string): boolean { return bookmarkedProjects.includes(project_id); } @@ -148,6 +169,7 @@ export function useBookmarkedProjects() { return { bookmarkedProjects, setProjectBookmarked, + setBookmarkedProjectsOrder, isProjectBookmarked, isInitialized, };