feat(actions): simplify action corner api through the toolbarService (#5033)

This commit is contained in:
Alireza authored and GitHub committed 2025-05-15 12:08:12 -04:00
1 parent f71b88e639
commit 7c15bb8901
246 files changed
+7930 -4270

No files matched your search

@@ -50,7 +50,9 @@ export interface MenuProps {
showHeaderDivider?: boolean;
activePanelIndex?: number;
onVisibilityChange?: (isVisible: boolean) => void;
horizontalDirection?: HorizontalDirection;
// New props that can be used as alternatives to horizontalDirection and verticalDirection
align?: 'start' | 'end' | 'center';
side?: 'top' | 'bottom' | 'left' | 'right';
children: ReactNode;
}
type MenuContextProps = {
@@ -58,6 +60,7 @@ type MenuContextProps = {
hideMenu: () => void;
addItemPanel: (index: number, label: string) => void;
horizontalDirection: HorizontalDirection;
verticalDirection?: VerticalDirection;
activePanelIndex: number;
};
@@ -76,9 +79,24 @@ const Menu = (props: MenuProps) => {
preventHideMenu,
menuClassName,
menuStyle,
horizontalDirection = HorizontalDirection.LeftToRight,
align,
side,
} = props;
// Derive horizontalDirection and verticalDirection from align and side if provided
let horizontalDirection = HorizontalDirection.LeftToRight;
let verticalDirection = VerticalDirection.BottomToTop;
if (align !== undefined) {
horizontalDirection =
align === 'start' ? HorizontalDirection.LeftToRight : HorizontalDirection.RightToLeft;
}
if (side !== undefined) {
verticalDirection =
side === 'bottom' ? VerticalDirection.TopToBottom : VerticalDirection.BottomToTop;
}
const [isMenuVisible, setIsMenuVisible] = useState(isVisible);
// The menuPath is an array consisting of this top Menu and every SubMenu
@@ -151,6 +169,7 @@ const Menu = (props: MenuProps) => {
addItemPanel,
activePanelIndex: currentMenuActivePanelIndex,
horizontalDirection,
verticalDirection,
}}
>
{isMenuVisible && (
@@ -70,20 +70,7 @@ const CinePlayer: React.FC<CinePlayerProps> = ({
);
return (
<div className={className}>
{isDynamic && dynamicInfo && (
<Numeric.Container
mode="singleRange"
min={1}
max={dynamicInfo.numDimensionGroups}
step={1}
value={dynamicInfo.dimensionGroupNumber}
onChange={val => handleDimensionGroupNumberChange(val as number)}
className="mb-3 w-full"
>
<Numeric.SingleRange showNumberInput={false} />
</Numeric.Container>
)}
<div className={`absolute top-10 left-1/2 z-50 -translate-x-1/2 ${className}`}>
<div className={'bg-muted inline-flex select-none items-center gap-2 rounded-md px-2 py-2'}>
<Button
variant="ghost"
@@ -139,7 +126,7 @@ const CinePlayer: React.FC<CinePlayerProps> = ({
</Button>
</PopoverTrigger>
<PopoverContent
side="top"
side="bottom"
align="center"
className="cine-fps-range-popover z-50 w-auto p-2"
sideOffset={8}
@@ -171,6 +158,20 @@ const CinePlayer: React.FC<CinePlayerProps> = ({
<Icons.Close />
</Button>
</div>
{isDynamic && dynamicInfo && (
<Numeric.Container
mode="singleRange"
min={1}
max={dynamicInfo.numDimensionGroups}
step={1}
value={dynamicInfo.dimensionGroupNumber}
onChange={val => handleDimensionGroupNumberChange(val as number)}
className="mt-3 w-full"
>
<Numeric.SingleRange showNumberInput={false} />
</Numeric.Container>
)}
</div>
);
};
@@ -12,7 +12,6 @@ interface DoubleSliderProps {
defaultValue?: [number, number];
onValueChange?: (value: [number, number]) => void;
showNumberInputs?: boolean;
lockMode?: boolean;
}
const DoubleSlider = React.forwardRef<HTMLDivElement, DoubleSliderProps>(
@@ -25,19 +24,10 @@ const DoubleSlider = React.forwardRef<HTMLDivElement, DoubleSliderProps>(
step = 1,
defaultValue = [min, max],
showNumberInputs = false,
lockMode = false,
},
ref
) => {
const [value, setValue] = React.useState<[number, number]>(defaultValue);
const trackRef = React.useRef<HTMLDivElement>(null);
const dragStateRef = React.useRef<{
startX: number;
startValue: [number, number];
rangeWidth: number;
trackLeft: number;
trackWidth: number;
} | null>(null);
const prevDefaultValueRef = React.useRef<[number, number] | null>(null);
@@ -60,172 +50,8 @@ const DoubleSlider = React.forwardRef<HTMLDivElement, DoubleSliderProps>(
return Math.round(num * inverse) / inverse;
};
const handleRangePointerDown = (event: React.PointerEvent) => {
if (!lockMode) {
return;
}
event.preventDefault();
const rect = trackRef.current!.getBoundingClientRect();
dragStateRef.current = {
startX: event.clientX,
startValue: [...value] as [number, number],
rangeWidth: value[1] - value[0],
trackLeft: rect.left,
trackWidth: rect.width,
};
window.addEventListener('pointermove', handleRangePointerMove);
window.addEventListener('pointerup', handleRangePointerUp, { once: true });
};
const handleRangePointerMove = (event: PointerEvent) => {
const state = dragStateRef.current;
if (!state) {
return;
}
const { startX, startValue, rangeWidth, trackWidth } = state;
const dxPx = event.clientX - startX;
const pct = dxPx / trackWidth;
const delta = pct * (max - min);
let newLeft = startValue[0] + delta;
let newRight = newLeft + rangeWidth;
// clamp to bounds
if (newLeft < min) {
newLeft = min;
newRight = min + rangeWidth;
} else if (newRight > max) {
newRight = max;
newLeft = max - rangeWidth;
}
const clampedLeft = roundToStep(newLeft);
const clampedRight = roundToStep(newRight);
setValue([clampedLeft, clampedRight]);
onValueChange?.([clampedLeft, clampedRight]);
};
const handleRangePointerUp = () => {
dragStateRef.current = null;
window.removeEventListener('pointermove', handleRangePointerMove);
};
const handleSliderChange = React.useCallback(
(newValue: number[]) => {
// Default behavior (no lock mode)
if (!lockMode) {
const clampedValue: [number, number] = [
roundToStep(Math.max(min, Math.min(newValue[0], max))),
roundToStep(Math.min(max, Math.max(newValue[1], min))),
];
setValue(clampedValue);
onValueChange?.(clampedValue);
return;
}
// Lock mode behavior
// First, determine if this is a symmetric expansion/contraction or a range shift
// by checking which thumb(s) moved
const isLeftThumbMoved = newValue[0] !== value[0];
const isRightThumbMoved = newValue[1] !== value[1];
// Case 1: Left thumb moved, right thumb needs to move symmetrically (opposite)
if (isLeftThumbMoved && !isRightThumbMoved) {
const delta = newValue[0] - value[0];
const centerPoint = (value[0] + value[1]) / 2;
// Symmetric movement: move right thumb in opposite direction
const newRight = value[1] - delta;
// Ensure values stay within bounds
const clampedLeft = roundToStep(Math.max(min, Math.min(newValue[0], max)));
const clampedRight = roundToStep(Math.min(max, Math.max(newRight, min)));
// Special case: If right would go out of bounds, adjust left too to maintain center
if (newRight !== clampedRight) {
// Calculate new delta given the clamped right value
const adjustedDelta = value[1] - clampedRight;
// Apply same delta to left but in opposite direction
const adjustedLeft = value[0] + adjustedDelta;
const finalClampedLeft = roundToStep(Math.max(min, Math.min(adjustedLeft, max)));
const finalValues: [number, number] = [finalClampedLeft, clampedRight];
setValue(finalValues);
onValueChange?.(finalValues);
return;
}
const finalValues: [number, number] = [clampedLeft, clampedRight];
setValue(finalValues);
onValueChange?.(finalValues);
return;
}
// Case 2: Right thumb moved, left thumb needs to move symmetrically (opposite)
if (!isLeftThumbMoved && isRightThumbMoved) {
const delta = newValue[1] - value[1];
const centerPoint = (value[0] + value[1]) / 2;
// Symmetric movement: move left thumb in opposite direction
const newLeft = value[0] - delta;
// Ensure values stay within bounds
const clampedRight = roundToStep(Math.min(max, Math.max(newValue[1], min)));
const clampedLeft = roundToStep(Math.max(min, Math.min(newLeft, max)));
// Special case: If left would go out of bounds, adjust right too to maintain center
if (newLeft !== clampedLeft) {
// Calculate new delta given the clamped left value
const adjustedDelta = value[0] - clampedLeft;
// Apply same delta to right but in opposite direction
const adjustedRight = value[1] + adjustedDelta;
const finalClampedRight = roundToStep(Math.min(max, Math.max(adjustedRight, min)));
const finalValues: [number, number] = [clampedLeft, finalClampedRight];
setValue(finalValues);
onValueChange?.(finalValues);
return;
}
const finalValues: [number, number] = [clampedLeft, clampedRight];
setValue(finalValues);
onValueChange?.(finalValues);
return;
}
// Case 3: Both thumbs moved (range shift) - maintain the same distance between thumbs
if (isLeftThumbMoved && isRightThumbMoved) {
const rangeWidth = value[1] - value[0];
// Calculate how much the left thumb moved
const leftDelta = newValue[0] - value[0];
// New values maintaining the same range width
let newLeft = newValue[0];
let newRight = newLeft + rangeWidth;
// Handle bounds checking
if (newRight > max) {
newRight = max;
newLeft = newRight - rangeWidth;
}
if (newLeft < min) {
newLeft = min;
newRight = newLeft + rangeWidth;
}
const clampedValues: [number, number] = [
roundToStep(Math.max(min, Math.min(newLeft, max))),
roundToStep(Math.min(max, Math.max(newRight, min))),
];
setValue(clampedValues);
onValueChange?.(clampedValues);
return;
}
// Fallback to default behavior
const clampedValue: [number, number] = [
roundToStep(Math.max(min, Math.min(newValue[0], max))),
roundToStep(Math.min(max, Math.max(newValue[1], min))),
@@ -233,86 +59,25 @@ const DoubleSlider = React.forwardRef<HTMLDivElement, DoubleSliderProps>(
setValue(clampedValue);
onValueChange?.(clampedValue);
},
[min, max, onValueChange, step, value, lockMode]
[min, max, onValueChange, step]
);
const handleInputChange = React.useCallback(
(index: 0 | 1, inputValue: string) => {
const newValue = parseFloat(inputValue);
if (!isNaN(newValue)) {
// Default behavior (no lock mode)
if (!lockMode) {
const clampedValue: [number, number] = [...value];
clampedValue[index] = roundToStep(Math.min(Math.max(newValue, min), max));
if (index === 0 && clampedValue[0] > clampedValue[1]) {
clampedValue[1] = clampedValue[0];
} else if (index === 1 && clampedValue[1] < clampedValue[0]) {
clampedValue[0] = clampedValue[1];
}
setValue(clampedValue);
onValueChange?.(clampedValue);
return;
}
// Lock mode behavior
const centerPoint = (value[0] + value[1]) / 2;
const rangeWidth = value[1] - value[0];
const clampedValue: [number, number] = [...value];
// Calculate new value with constraints
const boundedNewValue = roundToStep(Math.min(Math.max(newValue, min), max));
if (index === 0) {
// Left thumb changed
const delta = boundedNewValue - value[0];
// Symmetric change: move right thumb in opposite direction
const newRight = value[1] - delta;
const boundedNewRight = roundToStep(Math.min(Math.max(newRight, min), max));
// If right would go out of bounds, adjust left too
if (newRight !== boundedNewRight) {
// Adjust to maintain the same width but respect bounds
if (boundedNewRight === max) {
clampedValue[0] = roundToStep(Math.max(max - rangeWidth, min));
clampedValue[1] = max;
} else if (boundedNewRight === min) {
clampedValue[0] = min;
clampedValue[1] = roundToStep(Math.min(min + rangeWidth, max));
}
} else {
clampedValue[0] = boundedNewValue;
clampedValue[1] = boundedNewRight;
}
} else {
// Right thumb changed
const delta = boundedNewValue - value[1];
// Symmetric change: move left thumb in opposite direction
const newLeft = value[0] - delta;
const boundedNewLeft = roundToStep(Math.min(Math.max(newLeft, min), max));
// If left would go out of bounds, adjust right too
if (newLeft !== boundedNewLeft) {
// Adjust to maintain the same width but respect bounds
if (boundedNewLeft === min) {
clampedValue[0] = min;
clampedValue[1] = roundToStep(Math.min(min + rangeWidth, max));
} else if (boundedNewLeft === max) {
clampedValue[0] = roundToStep(Math.max(max - rangeWidth, min));
clampedValue[1] = max;
}
} else {
clampedValue[0] = boundedNewLeft;
clampedValue[1] = boundedNewValue;
}
clampedValue[index] = roundToStep(Math.min(Math.max(newValue, min), max));
if (index === 0 && clampedValue[0] > clampedValue[1]) {
clampedValue[1] = clampedValue[0];
} else if (index === 1 && clampedValue[1] < clampedValue[0]) {
clampedValue[0] = clampedValue[1];
}
setValue(clampedValue);
onValueChange?.(clampedValue);
}
},
[value, min, max, onValueChange, step, lockMode]
[value, min, max, onValueChange, step]
);
const formatValue = (val: number) => {
@@ -344,14 +109,8 @@ const DoubleSlider = React.forwardRef<HTMLDivElement, DoubleSliderProps>(
value={value}
onValueChange={handleSliderChange}
>
<SliderPrimitive.Track
ref={trackRef}
className="bg-primary/30 relative h-1 w-full grow overflow-hidden rounded-full"
>
<SliderPrimitive.Range
className={cn('bg-primary absolute h-full', lockMode && 'cursor-grab')}
onPointerDown={handleRangePointerDown}
/>
<SliderPrimitive.Track className="bg-primary/30 relative h-1 w-full grow overflow-hidden rounded-full">
<SliderPrimitive.Range className="bg-primary absolute h-full" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="border-background bg-primary focus-visible:ring-ring block h-4 w-4 rounded-full border-2 shadow transition-colors focus-visible:outline-none focus-visible:ring-1 disabled:pointer-events-none disabled:opacity-50" />
<SliderPrimitive.Thumb className="border-background bg-primary focus-visible:ring-ring block h-4 w-4 rounded-full border-2 shadow transition-colors focus-visible:outline-none focus-visible:ring-1 disabled:pointer-events-none disabled:opacity-50" />
@@ -1,5 +1,4 @@
import React, { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import classNames from 'classnames';
import {
DropdownMenu,
@@ -8,7 +7,9 @@ import {
DropdownMenuItem,
Icons,
Button,
ToolButton,
} from '../';
import { IconPresentationProvider } from '@ohif/ui-next';
import NavBar from '../NavBar';
@@ -51,72 +52,77 @@ function Header({
};
return (
<NavBar
isSticky={isSticky}
{...props}
<IconPresentationProvider
size="large"
IconContainer={ToolButton}
>
<div className="relative h-[48px] items-center">
<div className="absolute left-0 top-1/2 flex -translate-y-1/2 items-center">
<div
className={classNames(
'mr-3 inline-flex items-center',
isReturnEnabled && 'cursor-pointer'
)}
onClick={onClickReturn}
data-cy="return-to-work-list"
>
{isReturnEnabled && <Icons.ArrowLeft className="text-primary ml-1 h-7 w-7" />}
<div className="ml-1">
{WhiteLabeling?.createLogoComponentFn?.(React, props) || <Icons.OHIFLogo />}
<NavBar
isSticky={isSticky}
{...props}
>
<div className="relative h-[48px] items-center">
<div className="absolute left-0 top-1/2 flex -translate-y-1/2 items-center">
<div
className={classNames(
'mr-3 inline-flex items-center',
isReturnEnabled && 'cursor-pointer'
)}
onClick={onClickReturn}
data-cy="return-to-work-list"
>
{isReturnEnabled && <Icons.ArrowLeft className="text-primary ml-1 h-7 w-7" />}
<div className="ml-1">
{WhiteLabeling?.createLogoComponentFn?.(React, props) || <Icons.OHIFLogo />}
</div>
</div>
</div>
<div className="absolute top-1/2 left-[250px] h-8 -translate-y-1/2">{Secondary}</div>
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 transform">
<div className="flex items-center justify-center space-x-2">{children}</div>
</div>
<div className="absolute right-0 top-1/2 flex -translate-y-1/2 select-none items-center">
{UndoRedo}
<div className="border-primary-dark mx-1.5 h-[25px] border-r"></div>
{PatientInfo}
<div className="border-primary-dark mx-1.5 h-[25px] border-r"></div>
<div className="flex-shrink-0">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="text-primary hover:bg-primary-dark mt-2 h-full w-full"
>
<Icons.GearSettings />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{menuOptions.map((option, index) => {
const IconComponent = option.icon
? Icons[option.icon as keyof typeof Icons]
: null;
return (
<DropdownMenuItem
key={index}
onSelect={option.onClick}
className="flex items-center gap-2 py-2"
>
{IconComponent && (
<span className="flex h-4 w-4 items-center justify-center">
<Icons.ByName name={IconComponent.name} />
</span>
)}
<span className="flex-1">{option.title}</span>
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
<div className="absolute top-1/2 left-[250px] h-8 -translate-y-1/2">{Secondary}</div>
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 transform">
<div className="flex items-center justify-center space-x-2">{children}</div>
</div>
<div className="absolute right-0 top-1/2 flex -translate-y-1/2 select-none items-center">
{UndoRedo}
<div className="border-primary-dark mx-1.5 h-[25px] border-r"></div>
{PatientInfo}
<div className="border-primary-dark mx-1.5 h-[25px] border-r"></div>
<div className="flex-shrink-0">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="text-primary hover:bg-primary-dark mt-2 h-full w-full"
>
<Icons.GearSettings />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{menuOptions.map((option, index) => {
const IconComponent = option.icon
? Icons[option.icon as keyof typeof Icons]
: null;
return (
<DropdownMenuItem
key={index}
onSelect={option.onClick}
className="flex items-center gap-2 py-2"
>
{IconComponent && (
<span className="flex h-4 w-4 items-center justify-center">
<Icons.ByName name={IconComponent.name} />
</span>
)}
<span className="flex-1">{option.title}</span>
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
</NavBar>
</NavBar>
</IconPresentationProvider>
);
}
@@ -79,6 +79,9 @@ import OrientationSwitch from './Sources/OrientationSwitch';
import LayerBackground from './Sources/LayerBackground';
import LayerForeground from './Sources/LayerForeground';
import LayerSegmentation from './Sources/LayerSegmentation';
import WindowLevelAdvanced from './Sources/WindowLevelAdvanced';
import Opacity from './Sources/Opacity';
import Threshold from './Sources/Threshold';
import {
Tool3DRotate,
ToolAngle,
@@ -583,6 +586,9 @@ export const Icons = {
OHIFLogoColorDarkBackground,
Magnifier,
Pencil,
WindowLevelAdvanced,
Opacity,
Threshold,
//
//
//
@@ -3,16 +3,24 @@ import type { IconProps } from '../types';
export const Close = (props: IconProps) => (
<svg
width="12"
height="12"
viewBox="0 0 12 12"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M12 1.05 10.95 0 6 4.95 1.05 0 0 1.05 4.95 6 0 10.95 1.05 12 6 7.05 10.95 12 12 10.95 7.05 6z"
fill="currentColor"
fillRule="evenodd"
d="M8 8L16.19 16.19"
stroke="currentColor"
strokeWidth="1.25"
strokeLinecap="round"
/>
<path
d="M16.1904 8L8.00043 16.19"
stroke="currentColor"
strokeWidth="1.25"
strokeLinecap="round"
/>
</svg>
);
@@ -0,0 +1,106 @@
import React from 'react';
import type { IconProps } from '../types';
export const Opacity = (props: IconProps) => (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<rect
x="5.5"
y="6"
width="13"
height="13"
rx="2.5"
stroke="currentColor"
/>
<g opacity="0.6">
<rect
x="15.5"
y="15.5"
width="1.5"
height="1.5"
rx="0.75"
fill="currentColor"
/>
<rect
x="12.75"
y="15.5"
width="1.5"
height="1.5"
rx="0.75"
fill="currentColor"
/>
<rect
x="10"
y="15.5"
width="1.5"
height="1.5"
rx="0.75"
fill="currentColor"
/>
<rect
x="7.25"
y="15.5"
width="1.5"
height="1.5"
rx="0.75"
fill="currentColor"
/>
<rect
x="15.5"
y="13"
width="1.5"
height="1.5"
rx="0.75"
fill="currentColor"
/>
<rect
x="12.75"
y="13"
width="1.5"
height="1.5"
rx="0.75"
fill="currentColor"
/>
<rect
x="10"
y="13"
width="1.5"
height="1.5"
rx="0.75"
fill="currentColor"
/>
<rect
x="15.5"
y="10.5"
width="1.5"
height="1.5"
rx="0.75"
fill="currentColor"
/>
<rect
x="12.75"
y="10.5"
width="1.5"
height="1.5"
rx="0.75"
fill="currentColor"
/>
<rect
x="15.5"
y="8"
width="1.5"
height="1.5"
rx="0.75"
fill="currentColor"
/>
</g>
</svg>
);
export default Opacity;
@@ -0,0 +1,38 @@
import React from 'react';
import type { IconProps } from '../types';
export const Threshold = (props: IconProps) => (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M18 17L15 17"
stroke="currentColor"
strokeWidth="1.25"
strokeLinecap="round"
/>
<path
d="M13 17H6"
stroke="currentColor"
strokeWidth="1.25"
strokeLinecap="round"
/>
<path
d="M13 19L13 15"
stroke="currentColor"
strokeWidth="1.25"
strokeLinecap="round"
/>
<path
d="M8.49512 13.0464C8.10547 13.0464 7.8689 12.7959 7.8689 12.3784V7.72119H6.52832C6.21753 7.72119 5.99023 7.49854 5.99023 7.19702C5.99023 6.89087 6.21753 6.66821 6.52832 6.66821H10.4666C10.782 6.66821 11.0046 6.88623 11.0046 7.19702C11.0046 7.50317 10.782 7.72119 10.4666 7.72119H9.12134V12.3784C9.12134 12.7913 8.88013 13.0464 8.49512 13.0464Z"
fill="currentColor"
/>
</svg>
);
export default Threshold;
@@ -0,0 +1,30 @@
import React from 'react';
import type { IconProps } from '../types';
export const WindowLevelAdvanced = (props: IconProps) => (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M16.8555 8.05566L7.85547 17.0557"
stroke="currentColor"
strokeWidth="1.25"
strokeLinecap="round"
/>
<path
d="M6.30518 10.4448L5.06152 5.99316C5.02197 5.84375 5 5.68115 5 5.58447C5 5.2417 5.24609 5 5.59766 5C5.91846 5 6.11621 5.21533 6.21729 5.66797L7.07422 9.41211H7.13135L8.04541 5.66357C8.15527 5.20215 8.34863 5 8.7002 5C9.05615 5 9.25391 5.21094 9.36377 5.66357L10.2778 9.41211H10.335L11.1699 5.66797C11.2666 5.22412 11.4731 5 11.7983 5C12.1455 5 12.3872 5.2373 12.3872 5.57568C12.3872 5.67676 12.3696 5.83496 12.3433 5.93604L11.0996 10.4448C10.9722 10.9019 10.77 11.082 10.4053 11.082C10.0229 11.082 9.79004 10.8887 9.66699 10.4185L8.72656 6.86328H8.67822L7.73779 10.4448C7.62354 10.8755 7.38623 11.082 7.02148 11.082C6.66113 11.082 6.44141 10.9282 6.30518 10.4448Z"
fill="currentColor"
/>
<path
d="M18.2935 18.5503C18.2935 18.8535 18.0737 19.0557 17.7617 19.0557H15.2832C14.7515 19.0557 14.4922 18.8052 14.4922 18.2954V13.6548C14.4922 13.2505 14.7075 13.0132 15.0854 13.0132C15.459 13.0132 15.6787 13.2505 15.6787 13.6548V18.0449H17.7617C18.0737 18.0449 18.2935 18.2515 18.2935 18.5503Z"
fill="currentColor"
/>
</svg>
);
export default WindowLevelAdvanced;
@@ -29,7 +29,6 @@ interface NumericMetaContextValue {
min: number;
max: number;
step: number;
lockMode?: boolean;
}
const NumericMetaContext = createContext<NumericMetaContextValue | null>(null);
@@ -48,7 +47,6 @@ interface NumericMetaContainerProps {
max?: number;
step?: number;
className?: string;
lockMode?: boolean; // Add lock mode prop for doubleRange
}
function NumericMetaContainer({
@@ -62,7 +60,6 @@ function NumericMetaContainer({
max = 100,
step = 1,
className,
lockMode = false,
children,
}: PropsWithChildren<NumericMetaContainerProps>) {
// Calculate default values based on min and max
@@ -118,7 +115,6 @@ function NumericMetaContainer({
min,
max,
step,
lockMode,
}}
>
<div className={cn('flex flex-col', className)}>{children}</div>
@@ -240,7 +236,7 @@ function DoubleRange({ showNumberInputs, className }: DoubleRangeProps) {
throw new Error('DoubleRange must be used inside <Numeric.Container>.');
}
const { mode, doubleValue, setDoubleValue, min, max, step, lockMode } = ctx;
const { mode, doubleValue, setDoubleValue, min, max, step } = ctx;
const handleSliderChange = useCallback(
(values: [number, number]) => {
@@ -262,7 +258,6 @@ function DoubleRange({ showNumberInputs, className }: DoubleRangeProps) {
defaultValue={doubleValue}
onValueChange={handleSliderChange}
showNumberInputs={showNumberInputs}
lockMode={lockMode}
/>
</div>
);
@@ -119,7 +119,13 @@ const SelectItem = React.forwardRef<
<CheckIcon className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
{typeof children === 'string' ? (
<SelectPrimitive.ItemText>
<span className="truncate">{children}</span>
</SelectPrimitive.ItemText>
) : (
children
)}
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
@@ -3,6 +3,7 @@ import { Tooltip, TooltipTrigger, TooltipContent } from '../Tooltip';
import { Icons } from '../Icons';
import { Button } from '../Button';
import { cn } from '../../lib/utils';
import { useIconPresentation } from '../../contextProviders/IconPresentationProvider';
const baseClasses = '!rounded-lg inline-flex items-center justify-center';
const defaultClasses = 'bg-transparent text-foreground/80 hover:bg-background hover:text-highlight';
@@ -19,6 +20,10 @@ const sizeClasses = {
buttonSizeClass: 'w-8 h-8',
iconSizeClass: 'h-6 w-6',
},
tiny: {
buttonSizeClass: 'w-6 h-6',
iconSizeClass: 'h-4 w-4',
},
};
interface ToolButtonProps {
@@ -33,6 +38,7 @@ interface ToolButtonProps {
commands?: Record<string, unknown>;
onInteraction?: (details: { itemId: string; commands?: Record<string, unknown> }) => void;
className?: string;
children?: React.ReactNode;
}
function ToolButton(props: ToolButtonProps) {
@@ -48,8 +54,10 @@ function ToolButton(props: ToolButtonProps) {
commands,
onInteraction,
className,
children,
} = props;
const { className: iconClassName } = useIconPresentation();
const { buttonSizeClass, iconSizeClass } = sizeClasses[size] || sizeClasses.default;
const buttonClasses = cn(
@@ -90,10 +98,12 @@ function ToolButton(props: ToolButtonProps) {
aria-label={defaultTooltip}
disabled={disabled}
>
<Icons.ByName
name={icon}
className={iconSizeClass}
/>
{children || (
<Icons.ByName
name={icon}
className={iconClassName || iconSizeClass}
/>
)}
</Button>
</span>
</TooltipTrigger>
@@ -141,7 +141,6 @@ const ToolButtonListItem = React.forwardRef<
ToolButtonListItemProps
>(({ className, children, icon, disabledText, tooltip, disabled, ...props }, ref) => {
const defaultTooltip = tooltip || (typeof children === 'string' ? children : undefined);
const hasTooltip = defaultTooltip || disabledText;
const menuItem = (
<DropdownMenuItem
@@ -13,6 +13,10 @@ export enum ViewportActionCornersLocations {
topRight,
bottomLeft,
bottomRight,
topMiddle,
bottomMiddle,
leftMiddle,
rightMiddle,
}
const commonClasses = 'pointer-events-auto flex items-center';
@@ -27,11 +31,28 @@ const locationClasses = {
),
[ViewportActionCornersLocations.bottomLeft]: classNames(
commonClasses,
'absolute bottom-[4px] left-[0px] pl-[4px]'
'absolute bottom-[3px] left-[0px] pl-[4px]'
),
[ViewportActionCornersLocations.bottomRight]: classNames(
commonClasses,
'absolute bottom-[4px] right-[0px] right-viewport-scrollbar'
'absolute bottom-[3px] right-[16px] right-viewport-scrollbar'
),
[ViewportActionCornersLocations.topMiddle]: classNames(
commonClasses,
// Todo: to place on right side of the viewport orientation label
'absolute top-[25px] left-1/2 -translate-x-1/2'
),
[ViewportActionCornersLocations.bottomMiddle]: classNames(
commonClasses,
'absolute bottom-[3px] left-1/2 -translate-x-1/2'
),
[ViewportActionCornersLocations.leftMiddle]: classNames(
commonClasses,
'absolute left-[20px] top-[calc(50%+2px)] -translate-y-1/2 pl-[4px]'
),
[ViewportActionCornersLocations.rightMiddle]: classNames(
commonClasses,
'absolute right-[16px] top-1/2 -translate-y-1/2 right-viewport-scrollbar'
),
};
@@ -45,6 +66,10 @@ function Container({ children }: { children: ReactNode }) {
[ViewportActionCornersLocations.topRight]: null,
[ViewportActionCornersLocations.bottomLeft]: null,
[ViewportActionCornersLocations.bottomRight]: null,
[ViewportActionCornersLocations.topMiddle]: null,
[ViewportActionCornersLocations.bottomMiddle]: null,
[ViewportActionCornersLocations.leftMiddle]: null,
[ViewportActionCornersLocations.rightMiddle]: null,
});
const registerCorner = useCallback(
@@ -127,10 +152,30 @@ function BottomRight({ children }: { children: ReactNode }) {
return <Corner location={ViewportActionCornersLocations.bottomRight}>{children}</Corner>;
}
function TopMiddle({ children }: { children: ReactNode }) {
return <Corner location={ViewportActionCornersLocations.topMiddle}>{children}</Corner>;
}
function BottomMiddle({ children }: { children: ReactNode }) {
return <Corner location={ViewportActionCornersLocations.bottomMiddle}>{children}</Corner>;
}
function LeftMiddle({ children }: { children: ReactNode }) {
return <Corner location={ViewportActionCornersLocations.leftMiddle}>{children}</Corner>;
}
function RightMiddle({ children }: { children: ReactNode }) {
return <Corner location={ViewportActionCornersLocations.rightMiddle}>{children}</Corner>;
}
export const ViewportActionCorners = {
Container,
TopLeft,
TopRight,
BottomLeft,
BottomRight,
TopMiddle,
BottomMiddle,
LeftMiddle,
RightMiddle,
};
@@ -14,7 +14,7 @@
top: 2.15rem;
}
.overlay-bottom {
bottom: 1rem;
bottom: 2.15rem;
}
.overlay-text {
@@ -0,0 +1,88 @@
import React, { createContext, useContext, ReactNode, ComponentType } from 'react';
import { cn } from '../utils';
import { Button } from '../components/Button';
export type IconSizeType = 'tiny' | 'small' | 'medium' | 'large' | number;
interface IconSizeContextType {
size: IconSizeType;
getSizeValue: (size?: IconSizeType) => number | string;
getSizeClassName: (size?: IconSizeType, additionalClasses?: string) => string;
IconContainer: ComponentType<any>;
containerProps: {
variant: string;
size: string;
[key: string]: any;
};
className: string;
}
const sizeMap = {
tiny: 16,
small: 20,
medium: 24,
large: 28,
};
export const getSizeValue = (size: IconSizeType = 'medium'): number | string => {
if (typeof size === 'number') {
return size;
}
return sizeMap[size] || sizeMap.medium;
};
export const getSizeClassName = (size: IconSizeType = 'medium', additionalClasses = ''): string => {
const sizeValue = getSizeValue(size);
return cn(`h-[${sizeValue}px] w-[${sizeValue}px]`, additionalClasses);
};
const defaultContext: IconSizeContextType = {
size: 'medium',
getSizeValue,
getSizeClassName,
IconContainer: Button,
containerProps: {
variant: 'ghost',
size: 'icon',
},
className: '',
};
export const IconSizeContext = createContext<IconSizeContextType>(defaultContext);
interface IconPresentationProviderProps {
size: IconSizeType;
children: ReactNode;
IconContainer?: ComponentType<any>;
containerProps?: {
variant?: string;
size?: string;
[key: string]: any;
};
}
export const IconPresentationProvider = ({
size,
children,
IconContainer = Button,
containerProps = {},
}: IconPresentationProviderProps) => {
const className = getSizeClassName(size);
const mergedProps = {
variant: 'ghost',
size: 'icon',
...containerProps,
};
const contextValue = {
size,
getSizeValue,
getSizeClassName,
IconContainer,
containerProps: mergedProps,
className,
};
return <IconSizeContext.Provider value={contextValue}>{children}</IconSizeContext.Provider>;
};
export const useIconPresentation = () => useContext(IconSizeContext);
@@ -1,550 +0,0 @@
import React, {
createContext,
useCallback,
useContext,
useEffect,
useReducer,
ReactNode,
useMemo,
} from 'react';
import { ViewportActionCornersLocations } from '../components/Viewport/ViewportActionCorners';
import { ActionComponentInfo, AlignAndSide } from '../types';
const DEFAULT_STATE = {
viewports: {} as Record<string, Record<ViewportActionCornersLocations, ActionComponentInfo[]>>,
};
/**
* API for managing viewport action corner items.
* These methods control the state and behavior of the corner items themselves,
* not the content they might affect in the viewport.
*/
interface ViewportActionCornersApi {
getState: () => typeof DEFAULT_STATE;
addComponent: (component: ActionComponentInfo) => void;
addComponents: (components: Array<ActionComponentInfo>) => void;
clear: (viewportId: string) => void;
getAlignAndSide: (location: ViewportActionCornersLocations) => AlignAndSide;
lockItem: (viewportId: string, itemId: string) => void;
unlockItem: (viewportId: string, itemId: string) => void;
toggleLock: (viewportId: string, itemId: string) => void;
isItemLocked: (viewportId: string, itemId: string) => boolean;
showItem: (viewportId: string, itemId: string) => void;
hideItem: (viewportId: string, itemId: string) => void;
toggleVisibility: (viewportId: string, itemId: string) => void;
isItemVisible: (viewportId: string, itemId: string) => boolean;
openItem: (viewportId: string, itemId: string) => void;
closeItem: (viewportId: string, itemId: string) => void;
closeAllItems: (viewportId: string) => void;
isItemOpen: (viewportId: string, itemId: string) => boolean;
}
export const ViewportActionCornersContext = createContext<
[typeof DEFAULT_STATE, ViewportActionCornersApi]
>([DEFAULT_STATE, {} as ViewportActionCornersApi]);
interface ViewportActionCornersProviderProps {
children: ReactNode;
service: any;
}
export function ViewportActionCornersProvider({
children,
service,
}: ViewportActionCornersProviderProps) {
const viewportActionCornersReducer = (state = DEFAULT_STATE, action) => {
switch (action.type) {
case 'ADD_COMPONENT': {
const { viewportId, id, component, location, indexPriority = 0 } = action.payload;
const newState = { ...state };
if (!newState.viewports[viewportId]) {
newState.viewports[viewportId] = {
[ViewportActionCornersLocations.topLeft]: [],
[ViewportActionCornersLocations.topRight]: [],
[ViewportActionCornersLocations.bottomLeft]: [],
[ViewportActionCornersLocations.bottomRight]: [],
};
}
if (!newState.viewports[viewportId][location]) {
newState.viewports[viewportId][location] = [];
}
const componentInfo = {
id,
component,
indexPriority,
isOpen: false,
isVisible: true,
isLocked: false,
};
const components = [...newState.viewports[viewportId][location]];
const index = components.findIndex(item => item.indexPriority > indexPriority);
if (index === -1) {
components.push(componentInfo);
} else {
components.splice(index, 0, componentInfo);
}
newState.viewports[viewportId][location] = components;
return newState;
}
case 'ADD_COMPONENTS': {
const components = action.payload;
let newState = { ...state };
components.forEach(component => {
newState = viewportActionCornersReducer(newState, {
type: 'ADD_COMPONENT',
payload: component,
});
});
return newState;
}
case 'CLEAR': {
const viewportId = action.payload;
const newState = { ...state };
if (newState.viewports[viewportId]) {
const newViewports = { ...newState.viewports };
delete newViewports[viewportId];
newState.viewports = newViewports;
}
return newState;
}
case 'SET_LOCKED': {
const { viewportId, itemId, lockedStatus } = action.payload;
const newState = { ...state };
if (!newState.viewports[viewportId]) {
return state;
}
const viewportCopy = { ...newState.viewports[viewportId] };
Object.keys(viewportCopy).forEach(locationKey => {
const location = Number(locationKey) as ViewportActionCornersLocations;
const components = [...viewportCopy[location]];
const itemIndex = components.findIndex(item => item.id === itemId);
if (itemIndex !== -1) {
const updatedItem = { ...components[itemIndex], isLocked: lockedStatus };
components[itemIndex] = updatedItem;
viewportCopy[location] = components;
}
});
newState.viewports[viewportId] = viewportCopy;
return newState;
}
case 'SET_VISIBLE': {
const { viewportId, itemId, visibleStatus } = action.payload;
const newState = { ...state };
if (!newState.viewports[viewportId]) {
return state;
}
const viewportCopy = { ...newState.viewports[viewportId] };
Object.keys(viewportCopy).forEach(locationKey => {
const location = Number(locationKey) as ViewportActionCornersLocations;
const components = [...viewportCopy[location]];
const itemIndex = components.findIndex(item => item.id === itemId);
if (itemIndex !== -1) {
const updatedItem = { ...components[itemIndex], isVisible: visibleStatus };
components[itemIndex] = updatedItem;
viewportCopy[location] = components;
}
});
newState.viewports[viewportId] = viewportCopy;
return newState;
}
case 'OPEN_ITEM': {
const { viewportId, itemId } = action.payload;
const newState = { ...state };
if (!newState.viewports[viewportId]) {
return state;
}
const viewportCopy = { ...newState.viewports[viewportId] };
// Update isOpen flag for the component with matching id in any location
Object.keys(viewportCopy).forEach(locationKey => {
const location = Number(locationKey) as ViewportActionCornersLocations;
const components = [...viewportCopy[location]];
const itemIndex = components.findIndex(item => item.id === itemId);
if (itemIndex !== -1) {
const updatedItem = { ...components[itemIndex], isOpen: true };
components[itemIndex] = updatedItem;
viewportCopy[location] = components;
}
});
newState.viewports[viewportId] = viewportCopy;
return newState;
}
case 'CLOSE_ITEM': {
const { viewportId, itemId } = action.payload;
const newState = { ...state };
if (!newState.viewports[viewportId]) {
return state;
}
const viewportCopy = { ...newState.viewports[viewportId] };
// Update isOpen flag for the component with matching id in any location
Object.keys(viewportCopy).forEach(locationKey => {
const location = Number(locationKey) as ViewportActionCornersLocations;
const components = [...viewportCopy[location]];
const itemIndex = components.findIndex(item => item.id === itemId);
if (itemIndex !== -1) {
const updatedItem = { ...components[itemIndex], isOpen: false };
components[itemIndex] = updatedItem;
viewportCopy[location] = components;
}
});
newState.viewports[viewportId] = viewportCopy;
return newState;
}
case 'CLOSE_ALL_ITEMS': {
const viewportId = action.payload;
const newState = { ...state };
if (!newState.viewports[viewportId]) {
return state;
}
const viewportCopy = { ...newState.viewports[viewportId] };
// Set isOpen to false for all components in the viewport
Object.keys(viewportCopy).forEach(locationKey => {
const location = Number(locationKey) as ViewportActionCornersLocations;
const components = viewportCopy[location].map(item => ({
...item,
isOpen: false,
}));
viewportCopy[location] = components;
});
newState.viewports[viewportId] = viewportCopy;
return newState;
}
default:
return state;
}
};
const [state, dispatch] = useReducer(viewportActionCornersReducer, DEFAULT_STATE);
const getState = useCallback(() => {
return state;
}, [state]);
const addComponent = useCallback(
(component: ActionComponentInfo) => {
dispatch({
type: 'ADD_COMPONENT',
payload: component,
});
},
[dispatch]
);
const addComponents = useCallback(
(components: Array<ActionComponentInfo>) => {
dispatch({
type: 'ADD_COMPONENTS',
payload: components,
});
},
[dispatch]
);
const clear = useCallback(
(viewportId: string) => {
dispatch({
type: 'CLEAR',
payload: viewportId,
});
},
[dispatch]
);
const lockItem = useCallback(
(viewportId: string, itemId: string) => {
dispatch({
type: 'SET_LOCKED',
payload: { viewportId, itemId, lockedStatus: true },
});
},
[dispatch]
);
const unlockItem = useCallback(
(viewportId: string, itemId: string) => {
dispatch({
type: 'SET_LOCKED',
payload: { viewportId, itemId, lockedStatus: false },
});
},
[dispatch]
);
const toggleLock = useCallback(
(viewportId: string, itemId: string) => {
const currentLocked = isItemLocked(viewportId, itemId);
dispatch({
type: 'SET_LOCKED',
payload: { viewportId, itemId, lockedStatus: !currentLocked },
});
},
[dispatch]
);
const isItemLocked = useCallback(
(viewportId: string, itemId: string) => {
if (!state.viewports[viewportId]) {
return false; // Default to unlocked if viewport doesn't exist
}
for (const locationKey in state.viewports[viewportId]) {
const location = Number(locationKey) as ViewportActionCornersLocations;
const components = state.viewports[viewportId][location];
const item = components.find(item => item.id === itemId);
if (item && item.isLocked === true) {
return true;
}
}
return false; // Default to unlocked if item not found or isLocked is undefined
},
[state.viewports]
);
const showItem = useCallback(
(viewportId: string, itemId: string) => {
dispatch({
type: 'SET_VISIBLE',
payload: { viewportId, itemId, visibleStatus: true },
});
},
[dispatch]
);
const hideItem = useCallback(
(viewportId: string, itemId: string) => {
dispatch({
type: 'SET_VISIBLE',
payload: { viewportId, itemId, visibleStatus: false },
});
},
[dispatch]
);
const toggleVisibility = useCallback(
(viewportId: string, itemId: string) => {
const currentVisible = isItemVisible(viewportId, itemId);
dispatch({
type: 'SET_VISIBLE',
payload: { viewportId, itemId, visibleStatus: !currentVisible },
});
},
[dispatch]
);
const isItemVisible = useCallback(
(viewportId: string, itemId: string) => {
if (!state.viewports[viewportId]) {
return true; // Default to visible if viewport doesn't exist
}
for (const locationKey in state.viewports[viewportId]) {
const location = Number(locationKey) as ViewportActionCornersLocations;
const components = state.viewports[viewportId][location];
const item = components.find(item => item.id === itemId);
if (item && item.isVisible === false) {
return false;
}
}
return true; // Default to visible if item not found or isVisible is undefined
},
[state.viewports]
);
const openItem = useCallback(
(viewportId: string, itemId: string) => {
dispatch({
type: 'OPEN_ITEM',
payload: { viewportId, itemId },
});
},
[dispatch]
);
const closeItem = useCallback(
(viewportId: string, itemId: string) => {
dispatch({
type: 'CLOSE_ITEM',
payload: { viewportId, itemId },
});
},
[dispatch]
);
const closeAllItems = useCallback(
(viewportId: string) => {
dispatch({
type: 'CLOSE_ALL_ITEMS',
payload: viewportId,
});
},
[dispatch]
);
const isItemOpen = useCallback(
(viewportId: string, itemId: string) => {
if (!state.viewports[viewportId]) {
return false;
}
for (const locationKey in state.viewports[viewportId]) {
const location = Number(locationKey) as ViewportActionCornersLocations;
const components = state.viewports[viewportId][location];
const item = components.find(item => item.id === itemId);
if (item && item.isOpen === true) {
return true;
}
}
return false;
},
[state.viewports]
);
const getAlignAndSide = useCallback(
(location: ViewportActionCornersLocations) => {
return service.getAlignAndSide(location);
},
[service]
);
const api = useMemo(() => {
return {
getState,
addComponent,
addComponents,
clear,
getAlignAndSide,
lockItem,
unlockItem,
toggleLock,
isItemLocked,
showItem,
hideItem,
toggleVisibility,
isItemVisible,
openItem,
closeItem,
closeAllItems,
isItemOpen,
};
}, [
getState,
addComponent,
addComponents,
clear,
getAlignAndSide,
lockItem,
unlockItem,
toggleLock,
isItemLocked,
showItem,
hideItem,
toggleVisibility,
isItemVisible,
openItem,
closeItem,
closeAllItems,
isItemOpen,
]);
useEffect(() => {
if (service && service.setServiceImplementation) {
const implementation = {
getState,
addComponent,
addComponents,
clear,
lockItem,
unlockItem,
toggleLock,
isItemLocked,
showItem,
hideItem,
toggleVisibility,
isItemVisible,
openItem,
closeItem,
closeAllItems,
isItemOpen,
};
service.setServiceImplementation(implementation);
}
}, [
service,
getState,
addComponent,
addComponents,
clear,
lockItem,
unlockItem,
toggleLock,
isItemLocked,
showItem,
hideItem,
toggleVisibility,
isItemVisible,
openItem,
closeItem,
closeAllItems,
isItemOpen,
]);
return (
<ViewportActionCornersContext.Provider value={[state, api]}>
{children}
</ViewportActionCornersContext.Provider>
);
}
// Custom hook to use the ViewportActionCorners context
export const useViewportActionCorners = () => useContext(ViewportActionCornersContext);
@@ -9,10 +9,6 @@ import { UserAuthenticationProvider, useUserAuthentication } from './UserAuthent
import { ImageViewerContext, ImageViewerProvider, useImageViewer } from './ImageViewerProvider';
import DragAndDropProvider from './DragAndDropProvider';
import CineProvider, { useCine } from './CineProvider';
import {
ViewportActionCornersProvider,
useViewportActionCorners,
} from './ViewportActionCornersProvider';
export { useNotification, NotificationProvider };
export { ViewportGridContext, ViewportGridProvider, useViewportGrid };
@@ -24,4 +20,4 @@ export { UserAuthenticationProvider, useUserAuthentication };
export { ImageViewerContext, ImageViewerProvider, useImageViewer };
export { DragAndDropProvider };
export { CineProvider, useCine };
export { ViewportActionCornersProvider, useViewportActionCorners };
export { IconPresentationProvider, useIconPresentation } from './IconPresentationProvider';