ui(components): migrates to new toolbar ui components and Numeric for Input InputRange and InputDoubleRange (#4728)

Co-authored-by: sedghi <ar.sedghi@gmail.com>
This commit is contained in:
Dan Rukasandsedghi authored and GitHub committed 2025-01-29 13:36:52 -05:00
1 parent ac5ae4a29e
commit a8d84443a9
116 files changed
+2930 -1038

No files matched your search

@@ -38,12 +38,12 @@ export interface ButtonProps
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
({ className, variant, size, asChild = false, ...props }, forwardRef) => {
const Comp = asChild ? Slot : 'button';
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
ref={forwardRef}
{...props}
/>
);
@@ -11,10 +11,22 @@ interface DoubleSliderProps {
step?: number;
defaultValue?: [number, number];
onValueChange?: (value: [number, number]) => void;
showNumberInputs?: boolean;
}
const DoubleSlider = React.forwardRef<HTMLDivElement, DoubleSliderProps>(
({ className, min, max, step = 1, defaultValue = [min, max], onValueChange }, ref) => {
(
{
className,
min,
max,
onValueChange,
step = 1,
defaultValue = [min, max],
showNumberInputs = false,
},
ref
) => {
const [value, setValue] = React.useState<[number, number]>(defaultValue);
const prevDefaultValueRef = React.useRef<[number, number] | null>(null);
@@ -77,16 +89,18 @@ const DoubleSlider = React.forwardRef<HTMLDivElement, DoubleSliderProps>(
ref={ref}
className={cn('flex w-full items-center space-x-2', className)}
>
<Input
type="number"
value={formatValue(value[0])}
onChange={e => handleInputChange(0, e.target.value)}
onBlur={() => handleInputChange(0, value[0].toString())}
className="w-14"
min={min}
max={max}
step={step}
/>
{showNumberInputs && (
<Input
type="number"
value={formatValue(value[0])}
onChange={e => handleInputChange(0, e.target.value)}
onBlur={() => handleInputChange(0, value[0].toString())}
className="w-14"
min={min}
max={max}
step={step}
/>
)}
<SliderPrimitive.Root
className="relative flex h-4 w-full touch-none select-none items-center"
min={min}
@@ -101,16 +115,18 @@ const DoubleSlider = React.forwardRef<HTMLDivElement, DoubleSliderProps>(
<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" />
</SliderPrimitive.Root>
<Input
type="number"
value={formatValue(value[1])}
onChange={e => handleInputChange(1, e.target.value)}
onBlur={() => handleInputChange(1, value[1].toString())}
className="w-14"
min={min}
max={max}
step={step}
/>
{showNumberInputs && (
<Input
type="number"
value={formatValue(value[1])}
onChange={e => handleInputChange(1, e.target.value)}
onBlur={() => handleInputChange(1, value[1].toString())}
className="w-14"
min={min}
max={max}
step={step}
/>
)}
</div>
);
}
@@ -689,6 +689,9 @@ export const Icons = {
'icon-status-alert': (props: IconProps) => Alert(props),
'info-link': (props: IconProps) => InfoLink(props),
'launch-info': (props: IconProps) => LaunchInfo(props),
'old-trash': (props: IconProps) => Trash(props),
'tool-point': (props: IconProps) => ToolCircle(props),
'tool-freehand-line': (props: IconProps) => ToolFreehand(props),
clipboard: (props: IconProps) => Clipboard(props),
/** Adds an icon to the set of icons */
@@ -2447,8 +2447,6 @@ export const ToolSegBrush = (props: IconProps) => (
width="24px"
height="24px"
viewBox="0 0 24 24"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<g
@@ -2490,8 +2488,6 @@ export const ToolSegEraser = (props: IconProps) => (
width="24px"
height="24px"
viewBox="0 0 24 24"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<g
@@ -2592,8 +2588,6 @@ export const ToolSegThreshold = (props: IconProps) => (
width="24px"
height="24px"
viewBox="0 0 24 24"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<g
@@ -3012,10 +3006,11 @@ export const ToolWindowLevel = (props: IconProps) => (
export const ToolWindowRegion = (props: IconProps) => (
<svg
width="28px"
height="28px"
viewBox="0 0 28 28"
width="24px"
height="24px"
viewBox="0 0 24 24"
version="1.1"
{...props}
xmlns="http://www.w3.org/2000/svg"
>
<g
@@ -3029,8 +3024,8 @@ export const ToolWindowRegion = (props: IconProps) => (
id="Rectangle"
x="0"
y="0"
width="28"
height="28"
width="24"
height="24"
></rect>
<path
d="M10.3460449,22 L4,22 C3.44771525,22 3,21.5522847 3,21 L3,4 C3,3.44771525 3.44771525,3 4,3 L21,3 C21.5522847,3 22,3.44771525 22,4 L22,11.3197021"
@@ -3110,11 +3105,9 @@ export const ToolZoom = (props: IconProps) => (
export const ToolBrush = (props: IconProps) => (
<svg
width="28px"
height="28px"
viewBox="0 0 28 28"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
width="24px"
height="24px"
viewBox="0 0 24 24"
{...props}
>
<g
@@ -3128,8 +3121,8 @@ export const ToolBrush = (props: IconProps) => (
id="Rectangle"
x="0"
y="0"
width="28"
height="28"
width="24"
height="24"
></rect>
<path
d="M3.24640621,21.8286833 C3.09173375,21.7551472 2.99513748,21.5971513 3.00018895,21.4259625 C3.00524042,21.2547737 3.11098486,21.1027485 3.26972426,21.0384606 C5.3260304,20.2059201 4.66362518,17.8620247 5.27421252,16.6088957 C6.02197747,15.1026514 7.84114758,14.4766383 9.35746132,15.2037675 C13.9485253,17.4422999 8.48346644,24.3211232 3.24640621,21.8286833 Z"
@@ -3153,11 +3146,9 @@ export const ToolBrush = (props: IconProps) => (
export const ToolEraser = (props: IconProps) => (
<svg
width="28px"
height="28px"
viewBox="0 0 28 28"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
width="24px"
height="24px"
viewBox="0 0 24 24"
{...props}
>
<g
@@ -3171,8 +3162,8 @@ export const ToolEraser = (props: IconProps) => (
id="Rectangle"
x="0"
y="0"
width="28"
height="28"
width="24"
height="24"
></rect>
<path
d="M8.47826087,18.0030772 C7.48731947,17.9495317 6.54439458,17.5591084 5.80565217,16.8964685 L4.1066087,15.1958598 C3.49763625,14.5834856 3.49763625,13.5942339 4.1066087,12.9818598 L13.6317391,3.45672934 C14.2441133,2.84775689 15.233365,2.84775689 15.8457391,3.45672934 L19.8926087,7.5035989 C20.5015811,8.11597307 20.5015811,9.10522473 19.8926087,9.7175989 L12.7153043,16.8949033 C11.9769855,17.5579476 11.0343414,17.9489213 10.0434783,18.0030772 L8.47826087,18.0030772 Z"
@@ -3210,11 +3201,9 @@ export const ToolEraser = (props: IconProps) => (
export const ToolThreshold = (props: IconProps) => (
<svg
width="28px"
height="28px"
viewBox="0 0 28 28"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
width="24px"
height="24px"
viewBox="0 0 24 24"
{...props}
>
<g
@@ -3228,8 +3217,8 @@ export const ToolThreshold = (props: IconProps) => (
id="Rectangle"
x="0"
y="0"
width="28"
height="28"
width="24"
height="24"
></rect>
<g
id="Group"
@@ -3295,11 +3284,9 @@ export const ToolThreshold = (props: IconProps) => (
export const ToolShape = (props: IconProps) => (
<svg
width="28px"
height="28px"
viewBox="0 0 28 28"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
width="24px"
height="24px"
viewBox="0 0 24 24"
{...props}
>
<g
@@ -3313,8 +3300,8 @@ export const ToolShape = (props: IconProps) => (
id="Rectangle"
x="0"
y="0"
width="28"
height="28"
width="24"
height="24"
></rect>
<circle
id="Oval"
@@ -1,3 +1,4 @@
import { Icons } from './Icons';
export { Icons };
export default Icons;
@@ -0,0 +1,277 @@
// Numeric.tsx
import React, { createContext, useContext, useState, useCallback, PropsWithChildren } from 'react';
import { cn } from '../../lib/utils';
import { Input } from '../Input/Input';
import { Slider } from '../Slider/Slider';
import { DoubleSlider } from '../DoubleSlider/DoubleSlider';
interface NumericMetaContextValue {
mode: 'number' | 'singleRange' | 'doubleRange';
singleValue: number;
doubleValue: [number, number];
setSingleValue: (val: number) => void;
setDoubleValue: (vals: [number, number]) => void;
min: number;
max: number;
step: number;
}
const NumericMetaContext = createContext<NumericMetaContextValue | null>(null);
/* -------------------------------------------------------------------------
1) Container
---------------------------------------------------------------------------*/
interface NumericMetaContainerProps {
mode: 'number' | 'singleRange' | 'doubleRange';
value?: number; // for single-value usage
values?: [number, number]; // for double-range usage
onChange?: (val: number | [number, number]) => void;
min?: number;
max?: number;
step?: number;
className?: string;
}
function NumericMetaContainer({
mode,
value = 0,
values = [0, 100],
onChange,
min = 0,
max = 100,
step = 1,
className,
children,
}: PropsWithChildren<NumericMetaContainerProps>) {
// Initialize state with props but don't update automatically
const [internalSingleValue, setInternalSingleValue] = useState<number>(value);
const [internalDoubleValue, setInternalDoubleValue] = useState<[number, number]>(values);
const handleSingleChange = useCallback(
(newVal: number) => {
setInternalSingleValue(newVal);
onChange?.(newVal);
},
[onChange]
);
const handleDoubleChange = useCallback(
(newVals: [number, number]) => {
// Update internal state
setInternalDoubleValue(newVals);
// Notify parent if onChange is provided
onChange?.(newVals);
},
[onChange]
);
return (
<NumericMetaContext.Provider
value={{
mode,
singleValue: internalSingleValue,
doubleValue: internalDoubleValue,
setSingleValue: handleSingleChange,
setDoubleValue: handleDoubleChange,
min,
max,
step,
}}
>
<div className={cn('flex flex-col', className)}>{children}</div>
</NumericMetaContext.Provider>
);
}
/* -------------------------------------------------------------------------
2) Label sub-component
---------------------------------------------------------------------------*/
interface NumericMetaLabelProps {
showValue?: boolean; // optionally show the current numeric value(s)
className?: string;
children: React.ReactNode;
}
function NumericMetaLabel({ children, showValue, className }: NumericMetaLabelProps) {
const ctx = useContext(NumericMetaContext);
if (!ctx) {
throw new Error('NumericMetaLabel must be used inside <Numeric.Container>.');
}
const { mode, singleValue, doubleValue } = ctx;
let displayedValue = '';
let valueClasses = '';
if (mode === 'number' || mode === 'singleRange') {
displayedValue = singleValue.toString();
valueClasses = 'w-10';
} else if (mode === 'doubleRange') {
displayedValue = `[${doubleValue[0]} - ${doubleValue[1]}]`;
}
return (
<div className={cn('text-foreground flex text-base', className)}>
{children}
{showValue && (
<span className={cn('inline-block', valueClasses)}>{`: ${displayedValue}`}</span>
)}
</div>
);
}
/* -------------------------------------------------------------------------
3) SingleRange sub-component
---------------------------------------------------------------------------*/
interface SingleRangeProps {
showNumberInput?: boolean;
sliderClassName?: string;
numberInputClassName?: string;
}
function SingleRange({ showNumberInput, sliderClassName, numberInputClassName }: SingleRangeProps) {
const ctx = useContext(NumericMetaContext);
if (!ctx) {
throw new Error('SingleRange must be used inside <Numeric.Container>.');
}
const { mode, singleValue, setSingleValue, min, max, step } = ctx;
const handleSliderChange = useCallback(
(val: number[]) => {
setSingleValue(val[0]);
},
[setSingleValue]
);
const handleNumberChange = useCallback(
(evt: React.ChangeEvent<HTMLInputElement>) => {
const parsed = parseFloat(evt.target.value);
if (!isNaN(parsed)) {
setSingleValue(Math.max(min, Math.min(parsed, max)));
}
},
[min, max, setSingleValue]
);
if (mode !== 'singleRange') {
return null;
}
return (
<div className="flex flex-1 items-center space-x-2">
<Slider
className={cn('flex-1', sliderClassName)}
value={[singleValue]}
min={min}
max={max}
step={step}
onValueChange={handleSliderChange}
/>
{showNumberInput && (
<Input
type="number"
className={cn('w-[50px] shrink-0', numberInputClassName)}
value={singleValue}
step={step}
min={min}
max={max}
onChange={handleNumberChange}
/>
)}
</div>
);
}
/* -------------------------------------------------------------------------
4) DoubleRange sub-component
---------------------------------------------------------------------------*/
interface DoubleRangeProps {
showNumberInputs?: boolean;
className?: string;
}
function DoubleRange({ showNumberInputs, className }: DoubleRangeProps) {
const ctx = useContext(NumericMetaContext);
if (!ctx) {
throw new Error('DoubleRange must be used inside <Numeric.Container>.');
}
const { mode, doubleValue, setDoubleValue, min, max, step } = ctx;
const handleSliderChange = useCallback(
(values: [number, number]) => {
setDoubleValue(values);
},
[setDoubleValue]
);
if (mode !== 'doubleRange') {
return null;
}
return (
<div className={cn('min-w-0 flex-1', className)}>
<DoubleSlider
min={min}
max={max}
step={step}
defaultValue={doubleValue}
onValueChange={handleSliderChange}
showNumberInputs={showNumberInputs}
/>
</div>
);
}
/* -------------------------------------------------------------------------
5) Basic NumberInput sub-component
---------------------------------------------------------------------------*/
interface NumberInputProps {
className?: string;
}
function NumberInput({ className }: NumberInputProps) {
const ctx = useContext(NumericMetaContext);
if (!ctx) {
throw new Error('NumberInput must be used inside <Numeric.Container>.');
}
const { mode, singleValue, setSingleValue, min, max, step } = ctx;
if (mode !== 'number') {
return null;
}
const handleChange = (evt: React.ChangeEvent<HTMLInputElement>) => {
const val = parseFloat(evt.target.value);
if (!isNaN(val)) {
setSingleValue(Math.max(min, Math.min(val, max)));
}
};
// Calculate width based on max value's length, with a minimum of 3 characters
const maxLength = Math.max(3, max?.toString().length ?? 3);
const calculatedWidth = `${maxLength + 1.5}ch`;
return (
<Input
type="number"
value={singleValue}
step={step}
min={min}
max={max}
onChange={handleChange}
className={cn('min-w-[60px]', `w-[${calculatedWidth}]`, className)}
/>
);
}
export const Numeric = {
Container: NumericMetaContainer,
Label: NumericMetaLabel,
SingleRange,
DoubleRange,
NumberInput,
};
export default Numeric;
@@ -0,0 +1,3 @@
import Numeric from './Numeric';
export default Numeric;
@@ -0,0 +1,42 @@
import React from 'react';
import Numeric from '../Numeric';
import { cn } from '../../lib/utils';
interface RowDoubleRangeProps {
values: [number, number];
onChange: (values: [number, number]) => void;
minValue: number;
maxValue: number;
step: number;
showLabel?: boolean;
label?: string;
className?: string;
}
const RowDoubleRange: React.FC<RowDoubleRangeProps> = ({
values,
onChange,
minValue,
maxValue,
step,
showLabel = false,
label = '',
className,
}) => {
return (
<Numeric.Container
mode="doubleRange"
values={values}
onChange={onChange}
min={minValue}
max={maxValue}
step={step}
className={cn('flex flex-col space-y-2', className)}
>
{showLabel && <Numeric.Label showValue>{label}</Numeric.Label>}
<Numeric.DoubleRange showNumberInputs />
</Numeric.Container>
);
};
export default RowDoubleRange;
@@ -0,0 +1,65 @@
import React from 'react';
import Numeric from '../Numeric';
import { cn } from '../../lib/utils';
interface RowInputRangeProps {
value: number;
onChange: (newValue: number) => void;
minValue?: number;
maxValue?: number;
step?: number;
label?: string;
showLabel?: boolean;
labelPosition?: 'left' | 'right';
allowNumberEdit?: boolean;
showNumberInput?: boolean;
className?: string;
containerClassName?: string;
}
const RowInputRange: React.FC<RowInputRangeProps> = ({
value,
onChange,
minValue = 0,
maxValue = 100,
step = 1,
label = '',
showLabel = false,
labelPosition = 'right',
allowNumberEdit = false,
showNumberInput = true,
className,
containerClassName,
}) => {
const handleChange = (newValue: number | [number, number]) => {
if (typeof newValue === 'number') {
onChange(newValue);
} else {
onChange(newValue[0]);
}
};
const content = (
<Numeric.Container
mode="singleRange"
value={value}
onChange={handleChange}
min={minValue}
max={maxValue}
step={step}
className={cn('flex flex-row items-center space-x-2', className)}
>
{showLabel && label && labelPosition === 'left' && (
<Numeric.Label showValue={showNumberInput}>{label}</Numeric.Label>
)}
<Numeric.SingleRange showNumberInput={allowNumberEdit && showNumberInput} />
{showLabel && label && labelPosition === 'right' && (
<Numeric.Label showValue={showNumberInput}>{label}</Numeric.Label>
)}
</Numeric.Container>
);
return containerClassName ? <div className={containerClassName}>{content}</div> : content;
};
export default RowInputRange;
@@ -0,0 +1,58 @@
import React from 'react';
import { Label } from '../Label';
import { Tabs, TabsList, TabsTrigger } from '../Tabs';
import { cn } from '../../lib/utils';
interface RadioValue {
value: string;
label: string;
}
interface RadioOption {
id: string;
name: string;
value: string;
values: RadioValue[];
commands?: (val: string) => void;
}
interface RowSegmentedControlProps {
option: RadioOption;
className?: string;
}
export const RowSegmentedControl: React.FC<RowSegmentedControlProps> = ({ option, className }) => {
const handleValueChange = (newVal: string) => {
if (option.commands) {
option.commands(newVal);
}
};
return (
<div
className={cn('flex items-center justify-between text-[13px]', className)}
key={option.id}
>
<Label className="mr-2">{option.name}</Label>
<div className="max-w-1/2">
<Tabs
value={option.value}
onValueChange={handleValueChange}
>
<TabsList className="inline-flex space-x-1">
{option.values.map(({ label, value: itemValue }, index) => (
<TabsTrigger
value={itemValue}
key={`button-${option.id}-${index}`}
>
{label}
</TabsTrigger>
))}
</TabsList>
</Tabs>
</div>
</div>
);
};
export default RowSegmentedControl;
@@ -0,0 +1,99 @@
import React from 'react';
import RowInputRange from './RowInputRange';
import RowSegmentedControl from './RowSegmentedControl';
import RowDoubleRange from './RowDoubleRange';
const SETTING_TYPES = {
RANGE: 'range',
RADIO: 'radio',
CUSTOM: 'custom',
DOUBLE_RANGE: 'double-range',
};
function ToolSettings({ options }) {
if (!options) {
return null;
}
if (typeof options === 'function') {
return options();
}
return (
<div className="text-foreground space-y-2 pb-4">
{options?.map(option => {
if (option.condition && option.condition?.({ options }) === false) {
return null;
}
switch (option.type) {
case SETTING_TYPES.RANGE:
return renderRangeSetting(option);
case SETTING_TYPES.RADIO:
return renderRadioSetting(option);
case SETTING_TYPES.DOUBLE_RANGE:
return renderDoubleRangeSetting(option);
case SETTING_TYPES.CUSTOM:
return renderCustomSetting(option);
default:
return null;
}
})}
</div>
);
}
const renderRangeSetting = option => {
return (
<div
className="flex items-center"
key={option.id}
>
<div className="w-1/3 text-[13px]">{option.name}</div>
<div className="w-2/3">
<RowInputRange
minValue={option.min}
maxValue={option.max}
step={option.step}
value={option.value}
onChange={value => option.commands?.(value)}
allowNumberEdit={true}
inputClassName="ml-1 w-4/5 cursor-pointer"
/>
</div>
</div>
);
};
const renderRadioSetting = option => {
return (
<RowSegmentedControl
key={option.id}
option={option}
/>
);
};
function renderDoubleRangeSetting(option) {
return (
<RowDoubleRange
key={option.id}
values={option.value}
onChange={option.commands}
minValue={option.min}
maxValue={option.max}
step={option.step}
showLabel={false}
/>
);
}
const renderCustomSetting = option => {
return (
<div key={option.id}>
{typeof option.children === 'function' ? option.children() : option.children}
</div>
);
};
export default ToolSettings;
@@ -0,0 +1 @@
export { default as ToolSettings } from './ToolSettings';
@@ -1,8 +1,6 @@
import React, { useEffect, useRef } from 'react';
import { ToolboxUI, useToolbox } from '../../';
import { useToolbar } from '@ohif/core';
import { ToolboxUI } from './';
// Migrate this file to the new UI eventually
import { useToolbox } from '@ohif/ui';
/**
* A toolbox is a collection of buttons and commands that they invoke, used to provide
@@ -21,6 +19,8 @@ function Toolbox({
title,
...props
}: withAppTypes) {
// We should move these outside of the platform/ui-next, no file here
// should rely on the managers and services
const { state: toolboxState, api } = useToolbox(buttonSectionId);
const { onInteraction, toolbarButtons } = useToolbar({
servicesManager,
@@ -117,6 +117,9 @@ function Toolbox({
if (items?.length) {
items.forEach(({ options, id }) => {
if (!options) {
return;
}
accumulator[id] = createEnhancedOptions(options, id);
});
} else if (options?.length) {
@@ -144,17 +147,15 @@ function Toolbox({
}, []);
return (
<>
<ToolboxUI
{...props}
title={title}
toolbarButtons={toolbarButtons}
toolboxState={toolboxState}
handleToolSelect={id => api.handleToolSelect(id)}
handleToolOptionChange={handleToolOptionChange}
onInteraction={onInteraction}
/>
</>
<ToolboxUI
{...props}
title={title}
toolbarButtons={toolbarButtons}
toolboxState={toolboxState}
handleToolSelect={id => api.handleToolSelect(id)}
handleToolOptionChange={handleToolOptionChange}
onInteraction={onInteraction}
/>
);
}
@@ -1,9 +1,9 @@
import React, { useEffect, useRef } from 'react';
import { PanelSection } from '../../components';
// Migrate this file to the new UI eventually
import { ToolSettings } from '@ohif/ui';
import classnames from 'classnames';
import { PanelSection } from '../../components';
import { ToolSettings } from '../OHIFToolSettings';
const ItemsPerRow = 4;
function usePrevious(value) {
@@ -55,8 +55,8 @@ function ToolboxUI(props: withAppTypes) {
const render = () => {
return (
<>
<div className="flex flex-col bg-black">
<div className="bg-primary-dark mt-0.5 flex flex-wrap py-2">
<div className="flex flex-col">
<div className="bg-muted mt-0.5 flex flex-wrap space-x-2 py-2 px-1">
{toolbarButtons.map((toolDef, index) => {
if (!toolDef) {
return null;
@@ -81,20 +81,16 @@ function ToolboxUI(props: withAppTypes) {
key={id}
className={classnames({
[toolClasses]: true,
'border-secondary-light flex flex-col items-center justify-center rounded-md border':
true,
})}
>
<div className="flex rounded-md bg-black">
<Component
{...componentProps}
{...props}
id={id}
servicesManager={servicesManager}
onInteraction={onInteraction}
size="toolbox"
/>
</div>
<Component
{...componentProps}
{...props}
id={id}
servicesManager={servicesManager}
onInteraction={onInteraction}
size="toolbox"
/>
</div>
);
})}
@@ -101,7 +101,7 @@ const Thumbnail = ({
<Tooltip>
<TooltipTrigger>
<div className="group">
<Icons.StatusTracking className="text-primary-light h-[20px] w-[20px] group-hover:hidden" />
<Icons.StatusTracking className="text-primary-light h-[15px] w-[15px] group-hover:hidden" />
<Icons.Cancel
className="text-primary-light hidden h-[15px] w-[15px] group-hover:block"
onClick={onClickUntrack}
@@ -216,7 +216,7 @@ const Thumbnail = ({
<Tooltip>
<TooltipTrigger>
<div className="group">
<Icons.StatusTracking className="text-primary-light h-[20px] w-[20px] group-hover:hidden" />
<Icons.StatusTracking className="text-primary-light h-[20px] w-[15px] group-hover:hidden" />
<Icons.Cancel
className="text-primary-light hidden h-[15px] w-[15px] group-hover:block"
onClick={onClickUntrack}
@@ -0,0 +1,110 @@
import React from 'react';
import { Tooltip, TooltipTrigger, TooltipContent } from '../Tooltip';
import { Icons } from '../Icons';
import { Button } from '../Button';
import { cn } from '../../lib/utils';
const baseClasses = '!rounded-lg inline-flex items-center justify-center';
const defaultClasses = 'bg-transparent text-foreground/80 hover:bg-background hover:text-highlight';
const activeClasses = 'bg-highlight text-background hover:!bg-highlight/80';
const disabledClasses =
'text-common-bright hover:bg-primary-dark hover:text-primary-light opacity-40 cursor-not-allowed';
const sizeClasses = {
default: {
buttonSizeClass: 'w-10 h-10',
iconSizeClass: 'h-7 w-7',
},
small: {
buttonSizeClass: 'w-8 h-8',
iconSizeClass: 'h-6 w-6',
},
};
interface ToolButtonProps {
id: string;
icon?: string;
label?: string;
tooltip?: string;
size?: 'default' | 'small';
isActive?: boolean;
disabled?: boolean;
disabledText?: string;
commands?: Record<string, unknown>;
onInteraction?: (details: { itemId: string; commands?: Record<string, unknown> }) => void;
className?: string;
}
function ToolButton(props: ToolButtonProps) {
const {
id,
icon = 'MissingIcon',
label,
tooltip,
size = 'default',
disabled = false,
isActive = false,
disabledText,
commands,
onInteraction,
className,
} = props;
const { buttonSizeClass, iconSizeClass } = sizeClasses[size];
const buttonClasses = cn(
baseClasses,
buttonSizeClass,
disabled ? disabledClasses : isActive ? activeClasses : defaultClasses,
className
);
const defaultTooltip = tooltip || label;
const disabledTooltip = disabled && disabledText ? disabledText : null;
const hasTooltip = defaultTooltip || disabledTooltip;
return (
<Tooltip>
<TooltipTrigger
asChild
className={cn(disabled && 'cursor-not-allowed')}
>
{/* TooltipTrigger is a span since a disabled button does not fire events and the tooltip
will not show. */}
<span
data-cy={id}
data-tool={id}
data-active={isActive}
>
<Button
className={buttonClasses}
onClick={() => {
if (!disabled) {
onInteraction?.({ itemId: id, commands });
}
}}
variant="ghost"
size="icon"
aria-label={hasTooltip ? defaultTooltip : undefined}
disabled={disabled}
>
<Icons.ByName
name={icon}
className={iconSizeClass}
/>
</Button>
</span>
</TooltipTrigger>
<TooltipContent side="bottom">
{hasTooltip && (
<>
<div>{defaultTooltip}</div>
{disabledTooltip && <div className="text-muted-foreground">{disabledTooltip}</div>}
</>
)}
</TooltipContent>
</Tooltip>
);
}
export default ToolButton;
@@ -0,0 +1,207 @@
import React from 'react';
import { Button } from '../Button';
import { Icons } from '../Icons';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '../DropdownMenu';
import { cn } from '../../lib/utils';
import { Tooltip, TooltipTrigger, TooltipContent } from '../Tooltip';
/**
* ToolButtonList Component
* Root component that wraps the default and dropdown sections
* -----------------------------------------------
*/
interface ToolButtonListProps extends React.HTMLAttributes<HTMLDivElement> {
children?: React.ReactNode;
}
const ToolButtonList = React.forwardRef<HTMLDivElement, ToolButtonListProps>(
({ className, children, ...props }, ref) => {
return (
<div
ref={ref}
className={cn('flex items-center', className)}
{...props}
>
{children}
</div>
);
}
);
ToolButtonList.displayName = 'ToolButtonList';
/**
* ToolButtonListDefault Component
* Container for the default/primary tool button
* -----------------------------------------------
*/
interface ToolButtonListDefaultProps extends React.HTMLAttributes<HTMLDivElement> {
children?: React.ReactNode;
tooltip?: string;
disabledText?: string;
disabled?: boolean;
}
const ToolButtonListDefault = React.forwardRef<HTMLDivElement, ToolButtonListDefaultProps>(
({ className, children, tooltip, disabledText, disabled, ...props }, ref) => {
const hasTooltip = tooltip || disabledText;
const defaultContent = (
<div
ref={ref}
className={cn('flex items-center', className)}
{...props}
>
{children}
</div>
);
if (!hasTooltip) {
return defaultContent;
}
return (
<Tooltip>
<TooltipTrigger asChild>
<span>{defaultContent}</span>
</TooltipTrigger>
<TooltipContent side="bottom">
{tooltip && <div>{tooltip}</div>}
{disabledText && disabled && <div className="text-muted-foreground">{disabledText}</div>}
</TooltipContent>
</Tooltip>
);
}
);
ToolButtonListDefault.displayName = 'ToolButtonListDefault';
/**
* ToolButtonListDropDown Component
* Container for the dropdown section with trigger and content
* -----------------------------------------------
*/
interface ToolButtonListDropDownProps {
children: React.ReactNode;
className?: string;
}
const ToolButtonListDropDown = React.forwardRef<HTMLDivElement, ToolButtonListDropDownProps>(
({ children, className, ...props }, ref) => (
<DropdownMenu {...props}>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn(
'text-foreground/80 hover:bg-background hover:text-highlight border-primary',
'inline-flex h-10 w-5 items-center justify-center',
'!rounded-tr-lg !rounded-br-lg !rounded-tl-none !rounded-bl-none',
'bg-transparent',
className
)}
>
<Icons.ByName
name="chevron-down"
className="text-primary h-5 w-5"
/>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
ref={ref}
side="bottom"
align="start"
alignOffset={-40}
>
{children}
</DropdownMenuContent>
</DropdownMenu>
)
);
ToolButtonListDropDown.displayName = 'ToolButtonListDropDown';
/**
* ToolButtonListItem Component
* Individual item in the dropdown menu
* -----------------------------------------------
*/
interface ToolButtonListItemProps extends React.ComponentProps<typeof DropdownMenuItem> {
icon?: string;
children?: React.ReactNode;
className?: string;
disabledText?: string;
tooltip?: string;
}
const ToolButtonListItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuItem>,
ToolButtonListItemProps
>(({ className, children, icon, disabledText, tooltip, disabled, ...props }, ref) => {
const defaultTooltip = tooltip || (typeof children === 'string' ? children : undefined);
const hasTooltip = defaultTooltip || disabledText;
const menuItem = (
<DropdownMenuItem
ref={ref}
className={cn('flex items-center space-x-2', className)}
disabled={disabled}
{...props}
>
{icon && (
<Icons.ByName
name={icon || 'MissingIcon'}
className="h-6 w-6"
/>
)}
{children}
</DropdownMenuItem>
);
// Todo: there is a weird issue where i can't control the duration of the delay
// for the items in this list, causing the tooltip to show up too early in the
// dropdown menu. So i'm just removing the tooltip for list items unless the disabledText is set.
if (!disabled) {
return menuItem;
}
return (
<Tooltip>
<TooltipTrigger asChild>
<span>{menuItem}</span>
</TooltipTrigger>
<TooltipContent side="bottom">
{defaultTooltip && <div>{defaultTooltip}</div>}
{disabledText && disabled && <div className="text-muted-foreground">{disabledText}</div>}
</TooltipContent>
</Tooltip>
);
});
ToolButtonListItem.displayName = 'ToolButtonListItem';
/**
* ToolButtonListDivider Component
* Divider between items in the dropdown menu
* -----------------------------------------------
*/
const ToolButtonListDivider = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('bg-primary h-5 w-px self-center', className)}
{...props}
/>
));
ToolButtonListDivider.displayName = 'ToolButtonListDivider';
export {
ToolButtonList,
ToolButtonListDefault,
ToolButtonListDropDown,
ToolButtonListItem,
ToolButtonListDivider,
};
@@ -0,0 +1,8 @@
export { default as ToolButton } from './ToolButton';
export {
ToolButtonList,
ToolButtonListDefault,
ToolButtonListDropDown,
ToolButtonListItem,
ToolButtonListDivider,
} from './ToolButtonList';
+19 -2
View File
@@ -52,7 +52,9 @@ import { ThumbnailList } from './ThumbnailList';
import { PanelSection } from './PanelSection';
import { DisplaySetMessageListTooltip } from './DisplaySetMessageListTooltip';
import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from './Tooltip';
import { Toolbox, ToolboxUI } from './Toolbox';
import { ToolboxUI, Toolbox } from './OHIFToolbox';
import Numeric from './Numeric';
import {
DropdownMenu,
DropdownMenuTrigger,
@@ -91,8 +93,17 @@ import {
ViewportOverlay,
ViewportGrid,
} from './Viewport';
import {
ToolButton,
ToolButtonList,
ToolButtonListDefault,
ToolButtonListDropDown,
ToolButtonListItem,
ToolButtonListDivider,
} from './ToolButton';
export {
Numeric,
ErrorBoundary,
Button,
buttonVariants,
@@ -157,8 +168,8 @@ export {
ThumbnailList,
PanelSection,
DisplaySetMessageListTooltip,
Toolbox,
ToolboxUI,
Toolbox,
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
@@ -208,4 +219,10 @@ export {
ViewportOverlay,
ViewportGrid,
Clipboard,
ToolButton,
ToolButtonList,
ToolButtonListDefault,
ToolButtonListDropDown,
ToolButtonListItem,
ToolButtonListDivider,
};
@@ -0,0 +1,109 @@
import React, { createContext, useContext, useReducer } from 'react';
export const initialState = {};
export const toolboxReducer = (state, action) => {
const { toolbarSectionId } = action.payload;
if (!state[toolbarSectionId]) {
state[toolbarSectionId] = { activeTool: null, toolOptions: {}, selectedEvent: false };
}
switch (action.type) {
case 'SET_ACTIVE_TOOL':
return {
...state,
[toolbarSectionId]: {
...state[toolbarSectionId],
activeTool: action.payload.activeTool,
selectedEvent: true,
},
};
case 'UPDATE_TOOL_OPTION':
const { toolName, optionName, value } = action.payload;
return {
...state,
[toolbarSectionId]: {
...state[toolbarSectionId],
selectedEvent: false,
toolOptions: {
...state[toolbarSectionId].toolOptions,
[toolName]: state[toolbarSectionId].toolOptions[toolName].map(option =>
option.id === optionName ? { ...option, value } : option
),
},
},
};
case 'INITIALIZE_TOOL_OPTIONS':
// Initialize tool options for each toolbarSectionId
return {
...state,
selectedEvent: false,
[action.toolbarSectionId]: {
...state[action.toolbarSectionId],
toolOptions: action.payload,
},
};
default:
return state;
}
};
const ToolboxContext = createContext();
export const ToolboxProvider = ({ children }) => {
const [state, dispatch] = useReducer(toolboxReducer, initialState);
const handleToolSelect = (toolbarSectionId, toolName) => {
dispatch({
type: 'SET_ACTIVE_TOOL',
payload: { toolbarSectionId, activeTool: toolName },
});
};
const handleToolOptionChange = (toolbarSectionId, toolName, optionName, newValue) => {
dispatch({
type: 'UPDATE_TOOL_OPTION',
payload: { toolbarSectionId, toolName, optionName, value: newValue },
});
};
const initializeToolOptions = (toolbarSectionId, toolOptions) => {
dispatch({
type: 'INITIALIZE_TOOL_OPTIONS',
toolbarSectionId,
payload: toolOptions,
});
};
const api = { handleToolSelect, handleToolOptionChange, initializeToolOptions };
const value = { state, api };
return <ToolboxContext.Provider value={value}>{children}</ToolboxContext.Provider>;
};
/**
* Custom hook for accessing toolbox state and actions for a specific toolbar section.
* You can use this hook to access the state and actions for a specific toolbar section (
* defined by the toolbarSectionId) in your custom toolbar components. This hook
* helps to manage the state and actions for the tools and their options in the toolbar.
*/
export const useToolbox = toolbarSectionId => {
const context = useContext(ToolboxContext);
if (context === undefined) {
throw new Error('useToolbox must be used within a ToolboxProvider');
}
const { state, api } = context;
return {
state: state[toolbarSectionId] || { activeTool: null, toolOptions: {} },
api: {
handleToolSelect: toolName => api.handleToolSelect(toolbarSectionId, toolName),
handleToolOptionChange: (toolName, optionName, value) =>
api.handleToolOptionChange(toolbarSectionId, toolName, optionName, value),
initializeToolOptions: toolOptions =>
api.initializeToolOptions(toolbarSectionId, toolOptions),
},
};
};
@@ -1,5 +1,7 @@
import NotificationProvider, { useNotification } from './NotificationProvider';
import { ViewportGridContext, ViewportGridProvider, useViewportGrid } from './ViewportGridProvider';
import { ToolboxProvider, useToolbox } from './ToolboxContext';
export { useNotification, NotificationProvider };
export { ViewportGridContext, ViewportGridProvider, useViewportGrid };
export { ToolboxProvider, useToolbox };
+24 -3
View File
@@ -32,7 +32,6 @@ import {
ThumbnailList,
PanelSection,
DisplaySetMessageListTooltip,
Toolbox,
ToolboxUI,
DoubleSlider,
Label,
@@ -93,11 +92,24 @@ import {
ViewportActionCornersLocations,
ViewportOverlay,
ViewportGrid,
ToolButton,
ToolButtonList,
ToolButtonListDefault,
ToolButtonListDropDown,
ToolButtonListItem,
ToolButtonListDivider,
Toolbox,
} from './components';
import { DataRow } from './components/DataRow';
import { useNotification, NotificationProvider } from './contextProviders';
import {
useNotification,
NotificationProvider,
useToolbox,
ToolboxProvider,
} from './contextProviders';
import { ViewportGridContext, ViewportGridProvider, useViewportGrid } from './contextProviders';
import * as utils from './utils';
export {
ErrorBoundary,
@@ -143,7 +155,6 @@ export {
ThumbnailList,
PanelSection,
DisplaySetMessageListTooltip,
Toolbox,
ToolboxUI,
Label,
Slider,
@@ -202,4 +213,14 @@ export {
ViewportActionCornersLocations,
ViewportOverlay,
ViewportGrid,
ToolButton,
ToolButtonList,
ToolButtonListDefault,
ToolButtonListDropDown,
ToolButtonListItem,
ToolButtonListDivider,
ToolboxProvider,
Toolbox,
useToolbox,
utils,
};
@@ -0,0 +1,7 @@
const getToggledClassName = isToggled => {
return isToggled
? '!text-primary-active'
: '!text-common-bright hover:!bg-primary-dark hover:text-primary-light';
};
export { getToggledClassName };
+3
View File
@@ -0,0 +1,3 @@
import { getToggledClassName } from './getToggledClassName';
export { getToggledClassName };