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 }; }