From 1df4f843fe3c377643938563ff9e2e4f930d8537 Mon Sep 17 00:00:00 2001 From: Joe Boccanfuso <109477394+jbocce@users.noreply.github.com> Date: Tue, 21 Oct 2025 11:58:23 -0400 Subject: [PATCH] fix(segmentation): Fixed the manual scrolling of the segments list and implemented automatic scrolling to the active segment. (#5510) The height of the list container is now calculated based on a parent div instead of itself. DataRow component now has a forward ref to allow for automatic scrolling. Added better detection to the useDynamicMaxHeight hook for when to recalculate the max height using intersection observers. The useEffect for scrolling a segment into view used to be excessively called to the point where it was auto scrolling after the user manually scrolled. The auto scroll useEffect is now dependent on the segment index changing which should only happen once per active segment change. --- .../src/components/DataRow/DataRow.tsx | 532 +++++++++--------- .../SegmentationSegments.tsx | 230 +++++--- .../ui-next/src/hooks/useDynamicMaxHeight.ts | 69 ++- 3 files changed, 472 insertions(+), 359 deletions(-) diff --git a/platform/ui-next/src/components/DataRow/DataRow.tsx b/platform/ui-next/src/components/DataRow/DataRow.tsx index e229b43e7..5ff447cd0 100644 --- a/platform/ui-next/src/components/DataRow/DataRow.tsx +++ b/platform/ui-next/src/components/DataRow/DataRow.tsx @@ -1,4 +1,4 @@ -import React, { useState, useRef } from 'react'; +import React, { useState } from 'react'; import { Button } from '../../components/Button/Button'; import { DropdownMenu, @@ -107,305 +107,305 @@ interface DataRowProps { children?: React.ReactNode; } -const DataRowComponent: React.FC = ({ - number, - title, - colorHex, - details, - onSelect, - isLocked, - onToggleVisibility, - onToggleLocked, - onRename, - onDelete, - onColor, - isSelected = false, - isVisible = true, - disableEditing = false, - className, - children, -}) => { - const [isDropdownOpen, setIsDropdownOpen] = useState(false); - const isTitleLong = title?.length > 25; - const rowRef = useRef(null); +const DataRowComponent = React.forwardRef( + ( + { + number, + title, + colorHex, + details, + onSelect, + isLocked, + onToggleVisibility, + onToggleLocked, + onRename, + onDelete, + onColor, + isSelected = false, + isVisible = true, + disableEditing = false, + className, + children, + }, + ref + ) => { + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + const isTitleLong = title?.length > 25; - // Extract Status components from children - const statusComponents = React.Children.toArray(children).filter( - child => - React.isValidElement(child) && - child.type && - (child.type as any).displayName?.startsWith('DataRow.Status') - ); + // Extract Status components from children + const statusComponents = React.Children.toArray(children).filter( + child => + React.isValidElement(child) && + child.type && + (child.type as React.ComponentType).displayName?.startsWith('DataRow.Status') + ); - // useEffect(() => { - // if (isSelected && rowRef.current) { - // setTimeout(() => { - // rowRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' }); - // }, 200); - // } - // }, [isSelected]); + const handleAction = (action: string, e: React.MouseEvent) => { + e.stopPropagation(); + switch (action) { + case 'Rename': + onRename(e); + break; + case 'Lock': + onToggleLocked(e); + break; + case 'Delete': + onDelete(e); + break; + case 'Color': + onColor(e); + break; + } + }; - const handleAction = (action: string, e: React.MouseEvent) => { - e.stopPropagation(); - switch (action) { - case 'Rename': - onRename(e); - break; - case 'Lock': - onToggleLocked(e); - break; - case 'Delete': - onDelete(e); - break; - case 'Color': - onColor(e); - break; - } - }; + const decodeHTML = (html: string) => { + const txt = document.createElement('textarea'); + txt.innerHTML = html; + return txt.value; + }; - const decodeHTML = (html: string) => { - const txt = document.createElement('textarea'); - txt.innerHTML = html; - return txt.value; - }; - - const renderDetailText = (text: string, indent: number = 0) => { - const indentation = ' '.repeat(indent); - if (text === '') { + const renderDetailText = (text: string, indent: number = 0) => { + const indentation = ' '.repeat(indent); + if (text === '') { + return ( +
+ ); + } + const cleanText = decodeHTML(text); return (
+ key={cleanText} + className="whitespace-pre-wrap" + > + {indentation} + {cleanText} + ); - } - const cleanText = decodeHTML(text); - return ( -
- {indentation} - {cleanText} -
- ); - }; + }; - const renderDetails = (details: string[]) => { - const visibleLines = details.slice(0, 4); - const hiddenLines = details.slice(4); + const renderDetails = (details: string[]) => { + const visibleLines = details.slice(0, 4); + const hiddenLines = details.slice(4); - return ( - - -
-
- {visibleLines.map((line, lineIndex) => + return ( + + +
+
+ {visibleLines.map((line, lineIndex) => + renderDetailText(line, line.startsWith(' ') ? 1 : 0) + )} +
+ {hiddenLines.length > 0 && ( +
+ ... + +
+ )} +
+
+ +
+ {details.map((line, lineIndex) => renderDetailText(line, line.startsWith(' ') ? 1 : 0) )}
- {hiddenLines.length > 0 && ( -
- ... - -
- )} -
- - -
- {details.map((line, lineIndex) => - renderDetailText(line, line.startsWith(' ') ? 1 : 0) - )} -
-
- - ); - }; + + + ); + }; - return ( -
+ return (
- {/* Hover Overlay */} -
+
+ {/* Hover Overlay */} +
- {/* Number Box */} - {number !== null && ( -
- {number} -
- )} + {/* Number Box */} + {number !== null && ( +
+ {number} +
+ )} - {/* add some space if there is not segment index */} - {number === null &&
} - {colorHex && ( -
- -
- )} + {/* add some space if there is not segment index */} + {number === null &&
} + {colorHex && ( +
+ +
+ )} - {/* Label with Conditional Tooltip */} -
- {isTitleLong ? ( - - - + {isTitleLong ? ( + + + + {title} + + + {title} - - - + + ) : ( + {title} - - - ) : ( - + )} +
+ + {/* Actions and Visibility Toggle */} +
+ {/* Visibility Toggle Icon */} +
+ {isVisible ? : } + - {/* Actions and Visibility Toggle */} -
- {/* Visibility Toggle Icon */} - + {/* Lock Icon (if needed) */} + {isLocked && !disableEditing && ( + + )} - {/* Lock Icon (if needed) */} - {isLocked && !disableEditing && } + {/* Status Components */} + {statusComponents} - {/* Status Components */} - {statusComponents} - - {/* Actions Dropdown Menu */} - {disableEditing &&
} - {!disableEditing && ( - setIsDropdownOpen(open)}> - - + + e.preventDefault()} > - - - - e.preventDefault()} - > - <> - handleAction('Rename', e)}> - - - Rename - - - handleAction('Delete', e)}> - - - Delete - - - {onColor && ( - handleAction('Color', e)}> - + <> + handleAction('Rename', e)}> + - Change Color + Rename - )} - handleAction('Lock', e)}> - - - {isLocked ? 'Unlock' : 'Lock'} - - - - - - )} -
-
- - {/* Details Section */} - {details && (details.primary?.length > 0 || details.secondary?.length > 0) && ( -
-
- {details.primary?.length > 0 && renderDetails(details.primary)} - {details.secondary?.length > 0 && ( -
- {renderDetails(details.secondary)} -
+ handleAction('Delete', e)}> + + + Delete + + + {onColor && ( + handleAction('Color', e)}> + + + Change Color + + + )} + handleAction('Lock', e)}> + + + {isLocked ? 'Unlock' : 'Lock'} + + + + + )}
- )} -
- ); -}; + + {/* Details Section */} + {details && (details.primary?.length > 0 || details.secondary?.length > 0) && ( +
+
+ {details.primary?.length > 0 && renderDetails(details.primary)} + {details.secondary?.length > 0 && ( +
+ {renderDetails(details.secondary)} +
+ )} +
+
+ )} +
+ ); + } +); + +DataRowComponent.displayName = 'DataRow'; interface StatusProps { children: React.ReactNode; @@ -490,7 +490,7 @@ Status.Success = StatusSuccess; Status.Error = StatusError; Status.Info = StatusInfo; -const DataRow = DataRowComponent as React.FC & { +const DataRow = DataRowComponent as typeof DataRowComponent & { Status: typeof Status; }; diff --git a/platform/ui-next/src/components/SegmentationTable/SegmentationSegments.tsx b/platform/ui-next/src/components/SegmentationTable/SegmentationSegments.tsx index 206d8359b..a46680990 100644 --- a/platform/ui-next/src/components/SegmentationTable/SegmentationSegments.tsx +++ b/platform/ui-next/src/components/SegmentationTable/SegmentationSegments.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useEffect } from 'react'; import { ScrollArea, DataRow } from '../../components'; import { HoverCard, HoverCardTrigger, HoverCardContent } from '../../components/HoverCard'; import { useSegmentationTableContext, useSegmentationExpanded } from './contexts'; @@ -23,6 +23,11 @@ export const SegmentationSegments = ({ children = null }: { children?: React.Rea let segmentation; let representation; + const activeSegmentRef = React.useRef<{ + element: HTMLElement | null; + index: number | null; + }>({ element: null, index: null }); + try { // Try to use the SegmentationExpanded context if available const segmentationInfo = useSegmentationExpanded('SegmentationSegments'); @@ -38,108 +43,163 @@ export const SegmentationSegments = ({ children = null }: { children?: React.Rea } const segments = Object.values(representation.segments); + + // Find the active segment to scroll to it when it changes + const activeSegment = segments.find(segment => { + if (!segment) { + return false; + } + const segmentFromSegmentation = segmentation.segments[segment.segmentIndex]; + return segmentFromSegmentation?.active; + }); + const isActiveSegmentation = segmentation.segmentationId === activeSegmentationId; const { ref: scrollableContainerRef, maxHeight } = useDynamicMaxHeight(segments); + useEffect(() => { + const activeSegmentIndex = activeSegmentRef.current.index; + if (!activeSegmentIndex || activeSegmentIndex !== activeSegment?.segmentIndex) { + return; + } + + const activeSegmentElement = activeSegmentRef.current.element; + + if (!activeSegmentElement) { + return; + } + + // Check if the active segment is already visible. + const activeSegmentElementBounds = activeSegmentElement.getBoundingClientRect(); + const scrollableContainerRect = scrollableContainerRef.current.getBoundingClientRect(); + if ( + activeSegmentElementBounds.top > scrollableContainerRect.top && + activeSegmentElementBounds.bottom < scrollableContainerRect.bottom + ) { + // The active segment is already visible, so we don't need to scroll. + return; + } + + activeSegmentElement.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + }, [activeSegment?.segmentIndex, scrollableContainerRef]); + if (!representation || !segmentation) { return null; } return ( - -
+ = parseFloat(maxHeight) + : false + } > - {segments.map(segment => { - if (!segment) { - return null; - } - const { segmentIndex, color, visible } = segment as { - segmentIndex: number; - color: number[]; - visible: boolean; - }; - const segmentFromSegmentation = segmentation.segments[segmentIndex]; +
+ {segments.map(segment => { + if (!segment) { + return null; + } + const { segmentIndex, color, visible } = segment as { + segmentIndex: number; + color: number[]; + visible: boolean; + }; + const segmentFromSegmentation = segmentation.segments[segmentIndex]; - if (!segmentFromSegmentation) { - return null; - } + if (!segmentFromSegmentation) { + return null; + } - const { locked, active, label, displayText } = segmentFromSegmentation; - const cssColor = `rgb(${color[0]},${color[1]},${color[2]})`; + const { locked, active, label, displayText } = segmentFromSegmentation; + const cssColor = `rgb(${color[0]},${color[1]},${color[2]})`; - const hasStats = segmentFromSegmentation.cachedStats?.namedStats; - const DataRowComponent = ( - onSegmentColorClick(segmentation.segmentationId, segmentIndex)} - onToggleVisibility={() => - onToggleSegmentVisibility( - segmentation.segmentationId, - segmentIndex, - representation.type - ) + const hasStats = segmentFromSegmentation.cachedStats?.namedStats; + + const segmentRowRef = (element: HTMLElement) => { + if (!active) { + return; } - onToggleLocked={() => onToggleSegmentLock(segmentation.segmentationId, segmentIndex)} - onSelect={() => onSegmentClick(segmentation.segmentationId, segmentIndex)} - onRename={() => onSegmentEdit(segmentation.segmentationId, segmentIndex)} - onDelete={() => onSegmentDelete(segmentation.segmentationId, segmentIndex)} - /> - ); - return hasStats ? ( - - -
{DataRowComponent}
-
- -
-
-

{label}

-
+ if (element) { + activeSegmentRef.current = { element, index: segmentIndex }; + } else { + activeSegmentRef.current = { element: null, index: null }; + } + }; - onSegmentColorClick(segmentation.segmentationId, segmentIndex)} + onToggleVisibility={() => + onToggleSegmentVisibility( + segmentation.segmentationId, segmentIndex, - }} - segmentationId={segmentation.segmentationId} + representation.type + ) + } + onToggleLocked={() => + onToggleSegmentLock(segmentation.segmentationId, segmentIndex) + } + onSelect={() => onSegmentClick(segmentation.segmentationId, segmentIndex)} + onRename={() => onSegmentEdit(segmentation.segmentationId, segmentIndex)} + onDelete={() => onSegmentDelete(segmentation.segmentationId, segmentIndex)} + /> + ); + + return hasStats ? ( + + +
{DataRowComponent}
+
+ - {children} -
-
-
- ) : ( - DataRowComponent - ); - })} -
-
+
+
+

{label}

+
+ + + {children} + + + + ) : ( + DataRowComponent + ); + })} +
+
+
); }; diff --git a/platform/ui-next/src/hooks/useDynamicMaxHeight.ts b/platform/ui-next/src/hooks/useDynamicMaxHeight.ts index 431134a7d..84703fa48 100644 --- a/platform/ui-next/src/hooks/useDynamicMaxHeight.ts +++ b/platform/ui-next/src/hooks/useDynamicMaxHeight.ts @@ -1,5 +1,29 @@ import { useRef, useState, useEffect, RefObject } from 'react'; +const _getMovementIntersectionObserver = ({ + callback, + rootMargin, + threshold, +}: { + callback: () => void; + rootMargin: string; + threshold: number[]; +}): IntersectionObserver => { + return new IntersectionObserver( + entries => { + entries.forEach(entry => { + if (entry.isIntersecting) { + callback(); + } + }); + }, + { + threshold, + rootMargin, + } + ); +}; + /** * Calculates the maximum height for an element based on its position * relative to the bottom of the viewport. @@ -31,20 +55,49 @@ export function useDynamicMaxHeight( } }; - // Calculate initially - // Use requestAnimationFrame to ensure layout is stable after initial render - const rafId = requestAnimationFrame(calculateMaxHeight); + // Two intersection observers to trigger a recalculation when the target element + // moves up or down. One for moving up and one for moving down. + // Note that with this approach we don't need to use a resize observer nor + // a window resize listener. - // Recalculate on window resize - window.addEventListener('resize', calculateMaxHeight); + // The trick is to use a margin for the IntersectionObserver to detect movement. + // See more below. + const rootMarginHeight = maxHeight === '100vh' ? `${window.innerHeight}px` : `${maxHeight}`; + + // Note that we use a fine grained threshold because we don't know how + // much it will move and we want any movement to trigger the intersection observer. + const threshold = [0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0]; + + // The trick here is to use the calculated maxHeight as the root margin height + // so that any movement of the target element down (i.e. "out of the" viewport) + // will trigger the intersection observer. + const moveDownIntersectionObserver = _getMovementIntersectionObserver({ + callback: calculateMaxHeight, + rootMargin: `0px 0px ${rootMarginHeight} 0px`, + threshold, + }); + + // The trick here is to use the calculated maxHeight as the negative + // root margin height so that any movement of the target element up + // (i.e. "into the" viewport) will trigger the intersection observer. + const moveUpIntersectionObserver = _getMovementIntersectionObserver({ + callback: calculateMaxHeight, + rootMargin: `0px 0px -${rootMarginHeight} 0px`, + threshold, + }); + + if (ref.current) { + moveUpIntersectionObserver.observe(ref.current); + moveDownIntersectionObserver.observe(ref.current); + } // Cleanup listener and requestAnimationFrame on component unmount return () => { - window.removeEventListener('resize', calculateMaxHeight); - cancelAnimationFrame(rafId); + moveUpIntersectionObserver.disconnect(); + moveDownIntersectionObserver.disconnect(); }; // Dependencies: buffer, minHeight, and data. - }, [data, buffer, minHeight]); + }, [data, buffer, minHeight, maxHeight]); return { ref, maxHeight }; }