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.
This commit is contained in:
parent
cf0b561a49
commit
1df4f843fe
@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useRef } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Button } from '../../components/Button/Button';
|
import { Button } from '../../components/Button/Button';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@ -107,305 +107,305 @@ interface DataRowProps {
|
|||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DataRowComponent: React.FC<DataRowProps> = ({
|
const DataRowComponent = React.forwardRef<HTMLDivElement, DataRowProps>(
|
||||||
number,
|
(
|
||||||
title,
|
{
|
||||||
colorHex,
|
number,
|
||||||
details,
|
title,
|
||||||
onSelect,
|
colorHex,
|
||||||
isLocked,
|
details,
|
||||||
onToggleVisibility,
|
onSelect,
|
||||||
onToggleLocked,
|
isLocked,
|
||||||
onRename,
|
onToggleVisibility,
|
||||||
onDelete,
|
onToggleLocked,
|
||||||
onColor,
|
onRename,
|
||||||
isSelected = false,
|
onDelete,
|
||||||
isVisible = true,
|
onColor,
|
||||||
disableEditing = false,
|
isSelected = false,
|
||||||
className,
|
isVisible = true,
|
||||||
children,
|
disableEditing = false,
|
||||||
}) => {
|
className,
|
||||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
children,
|
||||||
const isTitleLong = title?.length > 25;
|
},
|
||||||
const rowRef = useRef<HTMLDivElement>(null);
|
ref
|
||||||
|
) => {
|
||||||
|
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||||
|
const isTitleLong = title?.length > 25;
|
||||||
|
|
||||||
// Extract Status components from children
|
// Extract Status components from children
|
||||||
const statusComponents = React.Children.toArray(children).filter(
|
const statusComponents = React.Children.toArray(children).filter(
|
||||||
child =>
|
child =>
|
||||||
React.isValidElement(child) &&
|
React.isValidElement(child) &&
|
||||||
child.type &&
|
child.type &&
|
||||||
(child.type as any).displayName?.startsWith('DataRow.Status')
|
(child.type as React.ComponentType).displayName?.startsWith('DataRow.Status')
|
||||||
);
|
);
|
||||||
|
|
||||||
// useEffect(() => {
|
const handleAction = (action: string, e: React.MouseEvent) => {
|
||||||
// if (isSelected && rowRef.current) {
|
e.stopPropagation();
|
||||||
// setTimeout(() => {
|
switch (action) {
|
||||||
// rowRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
case 'Rename':
|
||||||
// }, 200);
|
onRename(e);
|
||||||
// }
|
break;
|
||||||
// }, [isSelected]);
|
case 'Lock':
|
||||||
|
onToggleLocked(e);
|
||||||
|
break;
|
||||||
|
case 'Delete':
|
||||||
|
onDelete(e);
|
||||||
|
break;
|
||||||
|
case 'Color':
|
||||||
|
onColor(e);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleAction = (action: string, e: React.MouseEvent) => {
|
const decodeHTML = (html: string) => {
|
||||||
e.stopPropagation();
|
const txt = document.createElement('textarea');
|
||||||
switch (action) {
|
txt.innerHTML = html;
|
||||||
case 'Rename':
|
return txt.value;
|
||||||
onRename(e);
|
};
|
||||||
break;
|
|
||||||
case 'Lock':
|
|
||||||
onToggleLocked(e);
|
|
||||||
break;
|
|
||||||
case 'Delete':
|
|
||||||
onDelete(e);
|
|
||||||
break;
|
|
||||||
case 'Color':
|
|
||||||
onColor(e);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const decodeHTML = (html: string) => {
|
const renderDetailText = (text: string, indent: number = 0) => {
|
||||||
const txt = document.createElement('textarea');
|
const indentation = ' '.repeat(indent);
|
||||||
txt.innerHTML = html;
|
if (text === '') {
|
||||||
return txt.value;
|
return (
|
||||||
};
|
<div
|
||||||
|
key={`empty-${indent}`}
|
||||||
const renderDetailText = (text: string, indent: number = 0) => {
|
className="h-2"
|
||||||
const indentation = ' '.repeat(indent);
|
></div>
|
||||||
if (text === '') {
|
);
|
||||||
|
}
|
||||||
|
const cleanText = decodeHTML(text);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={`empty-${indent}`}
|
key={cleanText}
|
||||||
className="h-2"
|
className="whitespace-pre-wrap"
|
||||||
></div>
|
>
|
||||||
|
{indentation}
|
||||||
|
<span className="font-medium">{cleanText}</span>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
const cleanText = decodeHTML(text);
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={cleanText}
|
|
||||||
className="whitespace-pre-wrap"
|
|
||||||
>
|
|
||||||
{indentation}
|
|
||||||
<span className="font-medium">{cleanText}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderDetails = (details: string[]) => {
|
const renderDetails = (details: string[]) => {
|
||||||
const visibleLines = details.slice(0, 4);
|
const visibleLines = details.slice(0, 4);
|
||||||
const hiddenLines = details.slice(4);
|
const hiddenLines = details.slice(4);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<div className="cursor-help">
|
<div className="cursor-help">
|
||||||
<div className="flex flex-col space-y-1">
|
<div className="flex flex-col space-y-1">
|
||||||
{visibleLines.map((line, lineIndex) =>
|
{visibleLines.map((line, lineIndex) =>
|
||||||
|
renderDetailText(line, line.startsWith(' ') ? 1 : 0)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{hiddenLines.length > 0 && (
|
||||||
|
<div className="text-muted-foreground mt-1 flex items-center text-sm">
|
||||||
|
<span>...</span>
|
||||||
|
<Icons.Info className="mr-1 h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent
|
||||||
|
side="right"
|
||||||
|
align="start"
|
||||||
|
className="max-w-md"
|
||||||
|
>
|
||||||
|
<div className="text-secondary-foreground flex flex-col space-y-1 text-sm leading-normal">
|
||||||
|
{details.map((line, lineIndex) =>
|
||||||
renderDetailText(line, line.startsWith(' ') ? 1 : 0)
|
renderDetailText(line, line.startsWith(' ') ? 1 : 0)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{hiddenLines.length > 0 && (
|
</TooltipContent>
|
||||||
<div className="text-muted-foreground mt-1 flex items-center text-sm">
|
</Tooltip>
|
||||||
<span>...</span>
|
);
|
||||||
<Icons.Info className="mr-1 h-5 w-5" />
|
};
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent
|
|
||||||
side="right"
|
|
||||||
align="start"
|
|
||||||
className="max-w-md"
|
|
||||||
>
|
|
||||||
<div className="text-secondary-foreground flex flex-col space-y-1 text-sm leading-normal">
|
|
||||||
{details.map((line, lineIndex) =>
|
|
||||||
renderDetailText(line, line.startsWith(' ') ? 1 : 0)
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
|
||||||
ref={rowRef}
|
|
||||||
className={cn('flex flex-col', !isVisible && 'opacity-60', className)}
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
className={`flex items-center ${
|
ref={ref}
|
||||||
isSelected ? 'bg-popover' : 'bg-muted'
|
className={cn('flex flex-col', !isVisible && 'opacity-60', className)}
|
||||||
} group relative cursor-pointer`}
|
|
||||||
onClick={onSelect}
|
|
||||||
data-cy="data-row"
|
|
||||||
>
|
>
|
||||||
{/* Hover Overlay */}
|
<div
|
||||||
<div className="bg-primary/20 pointer-events-none absolute inset-0 opacity-0 transition-opacity group-hover:opacity-100"></div>
|
className={`flex items-center ${
|
||||||
|
isSelected ? 'bg-popover' : 'bg-muted'
|
||||||
|
} group relative cursor-pointer`}
|
||||||
|
onClick={onSelect}
|
||||||
|
data-cy="data-row"
|
||||||
|
>
|
||||||
|
{/* Hover Overlay */}
|
||||||
|
<div className="bg-primary/20 pointer-events-none absolute inset-0 opacity-0 transition-opacity group-hover:opacity-100"></div>
|
||||||
|
|
||||||
{/* Number Box */}
|
{/* Number Box */}
|
||||||
{number !== null && (
|
{number !== null && (
|
||||||
<div
|
<div
|
||||||
className={`flex h-7 max-h-7 w-7 flex-shrink-0 items-center justify-center rounded-l border-r border-black text-base ${
|
className={`flex h-7 max-h-7 w-7 flex-shrink-0 items-center justify-center rounded-l border-r border-black text-base ${
|
||||||
isSelected ? 'bg-highlight text-black' : 'bg-muted text-muted-foreground'
|
isSelected ? 'bg-highlight text-black' : 'bg-muted text-muted-foreground'
|
||||||
} overflow-hidden`}
|
} overflow-hidden`}
|
||||||
>
|
>
|
||||||
{number}
|
{number}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* add some space if there is not segment index */}
|
{/* add some space if there is not segment index */}
|
||||||
{number === null && <div className="ml-1 h-7"></div>}
|
{number === null && <div className="ml-1 h-7"></div>}
|
||||||
{colorHex && (
|
{colorHex && (
|
||||||
<div className="flex h-7 w-5 items-center justify-center">
|
<div className="flex h-7 w-5 items-center justify-center">
|
||||||
<span
|
<span
|
||||||
className="ml-2 h-2 w-2 rounded-full"
|
className="ml-2 h-2 w-2 rounded-full"
|
||||||
style={{ backgroundColor: colorHex }}
|
style={{ backgroundColor: colorHex }}
|
||||||
></span>
|
></span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Label with Conditional Tooltip */}
|
{/* Label with Conditional Tooltip */}
|
||||||
<div className="ml-2 flex-1 overflow-hidden">
|
<div className="ml-2 flex-1 overflow-hidden">
|
||||||
{isTitleLong ? (
|
{isTitleLong ? (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<span
|
<span
|
||||||
className={`cursor-default text-base ${
|
className={`cursor-default text-base ${
|
||||||
isSelected ? 'text-highlight' : 'text-muted-foreground'
|
isSelected ? 'text-highlight' : 'text-muted-foreground'
|
||||||
} [overflow:hidden] [display:-webkit-box] [-webkit-line-clamp:2] [-webkit-box-orient:vertical]`}
|
} [overflow:hidden] [display:-webkit-box] [-webkit-line-clamp:2] [-webkit-box-orient:vertical]`}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent
|
||||||
|
side="top"
|
||||||
|
align="center"
|
||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
</span>
|
</TooltipContent>
|
||||||
</TooltipTrigger>
|
</Tooltip>
|
||||||
<TooltipContent
|
) : (
|
||||||
side="top"
|
<span
|
||||||
align="center"
|
className={`text-base ${
|
||||||
|
isSelected ? 'text-highlight' : 'text-muted-foreground'
|
||||||
|
} [overflow:hidden] [display:-webkit-box] [-webkit-line-clamp:2] [-webkit-box-orient:vertical]`}
|
||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
</TooltipContent>
|
</span>
|
||||||
</Tooltip>
|
)}
|
||||||
) : (
|
</div>
|
||||||
<span
|
|
||||||
className={`text-base ${
|
{/* Actions and Visibility Toggle */}
|
||||||
isSelected ? 'text-highlight' : 'text-muted-foreground'
|
<div className="relative ml-2 flex items-center space-x-1">
|
||||||
} [overflow:hidden] [display:-webkit-box] [-webkit-line-clamp:2] [-webkit-box-orient:vertical]`}
|
{/* Visibility Toggle Icon */}
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className={`h-6 w-6 transition-opacity ${
|
||||||
|
isSelected || !isVisible ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
|
||||||
|
}`}
|
||||||
|
aria-label={isVisible ? 'Hide' : 'Show'}
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onToggleVisibility(e);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{title}
|
{isVisible ? <Icons.Hide className="h-6 w-6" /> : <Icons.Show className="h-6 w-6" />}
|
||||||
</span>
|
</Button>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Actions and Visibility Toggle */}
|
{/* Lock Icon (if needed) */}
|
||||||
<div className="relative ml-2 flex items-center space-x-1">
|
{isLocked && !disableEditing && (
|
||||||
{/* Visibility Toggle Icon */}
|
<Icons.Lock className="text-muted-foreground h-6 w-6" />
|
||||||
<Button
|
)}
|
||||||
size="icon"
|
|
||||||
variant="ghost"
|
|
||||||
className={`h-6 w-6 transition-opacity ${
|
|
||||||
isSelected || !isVisible ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
|
|
||||||
}`}
|
|
||||||
aria-label={isVisible ? 'Hide' : 'Show'}
|
|
||||||
onClick={e => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onToggleVisibility(e);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isVisible ? <Icons.Hide className="h-6 w-6" /> : <Icons.Show className="h-6 w-6" />}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{/* Lock Icon (if needed) */}
|
{/* Status Components */}
|
||||||
{isLocked && !disableEditing && <Icons.Lock className="text-muted-foreground h-6 w-6" />}
|
{statusComponents}
|
||||||
|
|
||||||
{/* Status Components */}
|
{/* Actions Dropdown Menu */}
|
||||||
{statusComponents}
|
{disableEditing && <div className="h-6 w-6"></div>}
|
||||||
|
{!disableEditing && (
|
||||||
{/* Actions Dropdown Menu */}
|
<DropdownMenu onOpenChange={open => setIsDropdownOpen(open)}>
|
||||||
{disableEditing && <div className="h-6 w-6"></div>}
|
<DropdownMenuTrigger asChild>
|
||||||
{!disableEditing && (
|
<Button
|
||||||
<DropdownMenu onOpenChange={open => setIsDropdownOpen(open)}>
|
size="icon"
|
||||||
<DropdownMenuTrigger asChild>
|
variant="ghost"
|
||||||
<Button
|
className={`h-6 w-6 transition-opacity ${
|
||||||
size="icon"
|
isSelected || isDropdownOpen
|
||||||
variant="ghost"
|
? 'opacity-100'
|
||||||
className={`h-6 w-6 transition-opacity ${
|
: 'opacity-0 group-hover:opacity-100'
|
||||||
isSelected || isDropdownOpen
|
}`}
|
||||||
? 'opacity-100'
|
aria-label="Actions"
|
||||||
: 'opacity-0 group-hover:opacity-100'
|
dataCY="actionsMenuTrigger"
|
||||||
}`}
|
onClick={e => e.stopPropagation()} // Prevent row selection on button click
|
||||||
aria-label="Actions"
|
>
|
||||||
dataCY="actionsMenuTrigger"
|
<Icons.More className="h-6 w-6" />
|
||||||
onClick={e => e.stopPropagation()} // Prevent row selection on button click
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent
|
||||||
|
align="end"
|
||||||
|
// this was causing issue for auto focus on input dialog
|
||||||
|
onCloseAutoFocus={e => e.preventDefault()}
|
||||||
>
|
>
|
||||||
<Icons.More className="h-6 w-6" />
|
<>
|
||||||
</Button>
|
<DropdownMenuItem onClick={e => handleAction('Rename', e)}>
|
||||||
</DropdownMenuTrigger>
|
<Icons.Rename className="text-foreground" />
|
||||||
<DropdownMenuContent
|
|
||||||
align="end"
|
|
||||||
// this was causing issue for auto focus on input dialog
|
|
||||||
onCloseAutoFocus={e => e.preventDefault()}
|
|
||||||
>
|
|
||||||
<>
|
|
||||||
<DropdownMenuItem onClick={e => handleAction('Rename', e)}>
|
|
||||||
<Icons.Rename className="text-foreground" />
|
|
||||||
<span
|
|
||||||
className="pl-2"
|
|
||||||
data-cy="Rename"
|
|
||||||
>
|
|
||||||
Rename
|
|
||||||
</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={e => handleAction('Delete', e)}>
|
|
||||||
<Icons.Delete className="text-foreground" />
|
|
||||||
<span
|
|
||||||
className="pl-2"
|
|
||||||
data-cy="Delete"
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
{onColor && (
|
|
||||||
<DropdownMenuItem onClick={e => handleAction('Color', e)}>
|
|
||||||
<Icons.ColorChange className="text-foreground" />
|
|
||||||
<span
|
<span
|
||||||
className="pl-2"
|
className="pl-2"
|
||||||
data-cy="Change Color"
|
data-cy="Rename"
|
||||||
>
|
>
|
||||||
Change Color
|
Rename
|
||||||
</span>
|
</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
<DropdownMenuItem onClick={e => handleAction('Delete', e)}>
|
||||||
<DropdownMenuItem onClick={e => handleAction('Lock', e)}>
|
<Icons.Delete className="text-foreground" />
|
||||||
<Icons.Lock className="text-foreground" />
|
<span
|
||||||
<span
|
className="pl-2"
|
||||||
className="pl-2"
|
data-cy="Delete"
|
||||||
data-cy="LockToggle"
|
>
|
||||||
>
|
Delete
|
||||||
{isLocked ? 'Unlock' : 'Lock'}
|
</span>
|
||||||
</span>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuItem>
|
{onColor && (
|
||||||
</>
|
<DropdownMenuItem onClick={e => handleAction('Color', e)}>
|
||||||
</DropdownMenuContent>
|
<Icons.ColorChange className="text-foreground" />
|
||||||
</DropdownMenu>
|
<span
|
||||||
)}
|
className="pl-2"
|
||||||
</div>
|
data-cy="Change Color"
|
||||||
</div>
|
>
|
||||||
|
Change Color
|
||||||
{/* Details Section */}
|
</span>
|
||||||
{details && (details.primary?.length > 0 || details.secondary?.length > 0) && (
|
</DropdownMenuItem>
|
||||||
<div className="ml-7 px-2 py-2">
|
)}
|
||||||
<div className="text-secondary-foreground flex items-center gap-1 text-base leading-normal">
|
<DropdownMenuItem onClick={e => handleAction('Lock', e)}>
|
||||||
{details.primary?.length > 0 && renderDetails(details.primary)}
|
<Icons.Lock className="text-foreground" />
|
||||||
{details.secondary?.length > 0 && (
|
<span
|
||||||
<div className="text-muted-foreground ml-auto text-sm">
|
className="pl-2"
|
||||||
{renderDetails(details.secondary)}
|
data-cy="LockToggle"
|
||||||
</div>
|
>
|
||||||
|
{isLocked ? 'Unlock' : 'Lock'}
|
||||||
|
</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
{/* Details Section */}
|
||||||
);
|
{details && (details.primary?.length > 0 || details.secondary?.length > 0) && (
|
||||||
};
|
<div className="ml-7 px-2 py-2">
|
||||||
|
<div className="text-secondary-foreground flex items-center gap-1 text-base leading-normal">
|
||||||
|
{details.primary?.length > 0 && renderDetails(details.primary)}
|
||||||
|
{details.secondary?.length > 0 && (
|
||||||
|
<div className="text-muted-foreground ml-auto text-sm">
|
||||||
|
{renderDetails(details.secondary)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
DataRowComponent.displayName = 'DataRow';
|
||||||
|
|
||||||
interface StatusProps {
|
interface StatusProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@ -490,7 +490,7 @@ Status.Success = StatusSuccess;
|
|||||||
Status.Error = StatusError;
|
Status.Error = StatusError;
|
||||||
Status.Info = StatusInfo;
|
Status.Info = StatusInfo;
|
||||||
|
|
||||||
const DataRow = DataRowComponent as React.FC<DataRowProps> & {
|
const DataRow = DataRowComponent as typeof DataRowComponent & {
|
||||||
Status: typeof Status;
|
Status: typeof Status;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import React from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import { ScrollArea, DataRow } from '../../components';
|
import { ScrollArea, DataRow } from '../../components';
|
||||||
import { HoverCard, HoverCardTrigger, HoverCardContent } from '../../components/HoverCard';
|
import { HoverCard, HoverCardTrigger, HoverCardContent } from '../../components/HoverCard';
|
||||||
import { useSegmentationTableContext, useSegmentationExpanded } from './contexts';
|
import { useSegmentationTableContext, useSegmentationExpanded } from './contexts';
|
||||||
@ -23,6 +23,11 @@ export const SegmentationSegments = ({ children = null }: { children?: React.Rea
|
|||||||
let segmentation;
|
let segmentation;
|
||||||
let representation;
|
let representation;
|
||||||
|
|
||||||
|
const activeSegmentRef = React.useRef<{
|
||||||
|
element: HTMLElement | null;
|
||||||
|
index: number | null;
|
||||||
|
}>({ element: null, index: null });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Try to use the SegmentationExpanded context if available
|
// Try to use the SegmentationExpanded context if available
|
||||||
const segmentationInfo = useSegmentationExpanded('SegmentationSegments');
|
const segmentationInfo = useSegmentationExpanded('SegmentationSegments');
|
||||||
@ -38,108 +43,163 @@ export const SegmentationSegments = ({ children = null }: { children?: React.Rea
|
|||||||
}
|
}
|
||||||
|
|
||||||
const segments = Object.values(representation.segments);
|
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 isActiveSegmentation = segmentation.segmentationId === activeSegmentationId;
|
||||||
|
|
||||||
const { ref: scrollableContainerRef, maxHeight } = useDynamicMaxHeight(segments);
|
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) {
|
if (!representation || !segmentation) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollArea
|
<div ref={scrollableContainerRef}>
|
||||||
className={`bg-bkg-low space-y-px`}
|
<ScrollArea
|
||||||
showArrows={true}
|
className={`bg-bkg-low space-y-px`}
|
||||||
>
|
showArrows={
|
||||||
<div
|
scrollableContainerRef?.current
|
||||||
ref={scrollableContainerRef}
|
? scrollableContainerRef?.current?.offsetHeight >= parseFloat(maxHeight)
|
||||||
style={{ maxHeight: maxHeight }}
|
: false
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{segments.map(segment => {
|
<div style={{ maxHeight: maxHeight }}>
|
||||||
if (!segment) {
|
{segments.map(segment => {
|
||||||
return null;
|
if (!segment) {
|
||||||
}
|
return null;
|
||||||
const { segmentIndex, color, visible } = segment as {
|
}
|
||||||
segmentIndex: number;
|
const { segmentIndex, color, visible } = segment as {
|
||||||
color: number[];
|
segmentIndex: number;
|
||||||
visible: boolean;
|
color: number[];
|
||||||
};
|
visible: boolean;
|
||||||
const segmentFromSegmentation = segmentation.segments[segmentIndex];
|
};
|
||||||
|
const segmentFromSegmentation = segmentation.segments[segmentIndex];
|
||||||
|
|
||||||
if (!segmentFromSegmentation) {
|
if (!segmentFromSegmentation) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { locked, active, label, displayText } = segmentFromSegmentation;
|
const { locked, active, label, displayText } = segmentFromSegmentation;
|
||||||
const cssColor = `rgb(${color[0]},${color[1]},${color[2]})`;
|
const cssColor = `rgb(${color[0]},${color[1]},${color[2]})`;
|
||||||
|
|
||||||
const hasStats = segmentFromSegmentation.cachedStats?.namedStats;
|
const hasStats = segmentFromSegmentation.cachedStats?.namedStats;
|
||||||
const DataRowComponent = (
|
|
||||||
<DataRow
|
const segmentRowRef = (element: HTMLElement) => {
|
||||||
key={segmentIndex}
|
if (!active) {
|
||||||
number={showSegmentIndex ? segmentIndex : null}
|
return;
|
||||||
title={label}
|
|
||||||
// details={displayText}
|
|
||||||
description={displayText}
|
|
||||||
colorHex={cssColor}
|
|
||||||
isSelected={active}
|
|
||||||
isVisible={visible}
|
|
||||||
isLocked={locked}
|
|
||||||
disableEditing={disableEditing}
|
|
||||||
className={!isActiveSegmentation ? 'opacity-80' : ''}
|
|
||||||
onColor={() => onSegmentColorClick(segmentation.segmentationId, segmentIndex)}
|
|
||||||
onToggleVisibility={() =>
|
|
||||||
onToggleSegmentVisibility(
|
|
||||||
segmentation.segmentationId,
|
|
||||||
segmentIndex,
|
|
||||||
representation.type
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
onToggleLocked={() => onToggleSegmentLock(segmentation.segmentationId, segmentIndex)}
|
|
||||||
onSelect={() => onSegmentClick(segmentation.segmentationId, segmentIndex)}
|
|
||||||
onRename={() => onSegmentEdit(segmentation.segmentationId, segmentIndex)}
|
|
||||||
onDelete={() => onSegmentDelete(segmentation.segmentationId, segmentIndex)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
return hasStats ? (
|
if (element) {
|
||||||
<HoverCard
|
activeSegmentRef.current = { element, index: segmentIndex };
|
||||||
key={`hover-${segmentIndex}`}
|
} else {
|
||||||
openDelay={300}
|
activeSegmentRef.current = { element: null, index: null };
|
||||||
>
|
}
|
||||||
<HoverCardTrigger asChild>
|
};
|
||||||
<div>{DataRowComponent}</div>
|
|
||||||
</HoverCardTrigger>
|
|
||||||
<HoverCardContent
|
|
||||||
side="left"
|
|
||||||
align="start"
|
|
||||||
className="w-72 border"
|
|
||||||
>
|
|
||||||
<div className="mb-4 flex items-center space-x-2">
|
|
||||||
<div
|
|
||||||
className="h-2.5 w-2.5 flex-shrink-0 rounded-full"
|
|
||||||
style={{ backgroundColor: cssColor }}
|
|
||||||
></div>
|
|
||||||
<h3 className="text-muted-foreground break-words font-semibold">{label}</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<SegmentStatistics
|
const DataRowComponent = (
|
||||||
segment={{
|
<DataRow
|
||||||
...segmentFromSegmentation,
|
ref={segmentRowRef}
|
||||||
|
key={segmentIndex}
|
||||||
|
number={showSegmentIndex ? segmentIndex : null}
|
||||||
|
title={label}
|
||||||
|
// details={displayText}
|
||||||
|
description={displayText}
|
||||||
|
colorHex={cssColor}
|
||||||
|
isSelected={active}
|
||||||
|
isVisible={visible}
|
||||||
|
isLocked={locked}
|
||||||
|
disableEditing={disableEditing}
|
||||||
|
className={!isActiveSegmentation ? 'opacity-80' : ''}
|
||||||
|
onColor={() => onSegmentColorClick(segmentation.segmentationId, segmentIndex)}
|
||||||
|
onToggleVisibility={() =>
|
||||||
|
onToggleSegmentVisibility(
|
||||||
|
segmentation.segmentationId,
|
||||||
segmentIndex,
|
segmentIndex,
|
||||||
}}
|
representation.type
|
||||||
segmentationId={segmentation.segmentationId}
|
)
|
||||||
|
}
|
||||||
|
onToggleLocked={() =>
|
||||||
|
onToggleSegmentLock(segmentation.segmentationId, segmentIndex)
|
||||||
|
}
|
||||||
|
onSelect={() => onSegmentClick(segmentation.segmentationId, segmentIndex)}
|
||||||
|
onRename={() => onSegmentEdit(segmentation.segmentationId, segmentIndex)}
|
||||||
|
onDelete={() => onSegmentDelete(segmentation.segmentationId, segmentIndex)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
return hasStats ? (
|
||||||
|
<HoverCard
|
||||||
|
key={`hover-${segmentIndex}`}
|
||||||
|
openDelay={300}
|
||||||
|
>
|
||||||
|
<HoverCardTrigger asChild>
|
||||||
|
<div>{DataRowComponent}</div>
|
||||||
|
</HoverCardTrigger>
|
||||||
|
<HoverCardContent
|
||||||
|
side="left"
|
||||||
|
align="start"
|
||||||
|
className="w-72 border"
|
||||||
>
|
>
|
||||||
{children}
|
<div className="mb-4 flex items-center space-x-2">
|
||||||
</SegmentStatistics>
|
<div
|
||||||
</HoverCardContent>
|
className="h-2.5 w-2.5 flex-shrink-0 rounded-full"
|
||||||
</HoverCard>
|
style={{ backgroundColor: cssColor }}
|
||||||
) : (
|
></div>
|
||||||
DataRowComponent
|
<h3 className="text-muted-foreground break-words font-semibold">{label}</h3>
|
||||||
);
|
</div>
|
||||||
})}
|
|
||||||
</div>
|
<SegmentStatistics
|
||||||
</ScrollArea>
|
segment={{
|
||||||
|
...segmentFromSegmentation,
|
||||||
|
segmentIndex,
|
||||||
|
}}
|
||||||
|
segmentationId={segmentation.segmentationId}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SegmentStatistics>
|
||||||
|
</HoverCardContent>
|
||||||
|
</HoverCard>
|
||||||
|
) : (
|
||||||
|
DataRowComponent
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,29 @@
|
|||||||
import { useRef, useState, useEffect, RefObject } from 'react';
|
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
|
* Calculates the maximum height for an element based on its position
|
||||||
* relative to the bottom of the viewport.
|
* relative to the bottom of the viewport.
|
||||||
@ -31,20 +55,49 @@ export function useDynamicMaxHeight(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Calculate initially
|
// Two intersection observers to trigger a recalculation when the target element
|
||||||
// Use requestAnimationFrame to ensure layout is stable after initial render
|
// moves up or down. One for moving up and one for moving down.
|
||||||
const rafId = requestAnimationFrame(calculateMaxHeight);
|
// Note that with this approach we don't need to use a resize observer nor
|
||||||
|
// a window resize listener.
|
||||||
|
|
||||||
// Recalculate on window resize
|
// The trick is to use a margin for the IntersectionObserver to detect movement.
|
||||||
window.addEventListener('resize', calculateMaxHeight);
|
// 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
|
// Cleanup listener and requestAnimationFrame on component unmount
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('resize', calculateMaxHeight);
|
moveUpIntersectionObserver.disconnect();
|
||||||
cancelAnimationFrame(rafId);
|
moveDownIntersectionObserver.disconnect();
|
||||||
};
|
};
|
||||||
// Dependencies: buffer, minHeight, and data.
|
// Dependencies: buffer, minHeight, and data.
|
||||||
}, [data, buffer, minHeight]);
|
}, [data, buffer, minHeight, maxHeight]);
|
||||||
|
|
||||||
return { ref, maxHeight };
|
return { ref, maxHeight };
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user