feat(toolbar): new Toolbar to enable reactive state synchronization (#3983)
This commit is contained in:
1 parent
79d5c36bda
commit
566b25a544
155 files changed
+6311
-6577
No files matched your search
@@ -3,6 +3,10 @@ import classnames from 'classnames';
|
||||
import { PanelSection, Icon, Tooltip } from '../../components';
|
||||
import ToolSettings from './ToolSettings';
|
||||
|
||||
/**
|
||||
* Use Toolbox component instead of this although it doesn't have "Advanced" in its name
|
||||
* it is better to use it instead of this one
|
||||
*/
|
||||
const AdvancedToolbox = ({ title, items }) => {
|
||||
const [activeItemName, setActiveItemName] = useState(null);
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import React from 'react';
|
||||
import { ButtonGroup, InputRange, ButtonEnums } from '../../components';
|
||||
import { ButtonGroup, InputDoubleRange, InputRange } from '../../components';
|
||||
|
||||
const SETTING_TYPES = {
|
||||
RANGE: 'range',
|
||||
RADIO: 'radio',
|
||||
CUSTOM: 'custom',
|
||||
DOUBLE_RANGE: 'double-range',
|
||||
};
|
||||
|
||||
function ToolSettings({ options }) {
|
||||
@@ -12,73 +13,111 @@ function ToolSettings({ options }) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const getButtons = option => {
|
||||
const buttons = [];
|
||||
|
||||
option.values?.map(({ label, value: optionValue }, index) => {
|
||||
buttons.push({
|
||||
children: label,
|
||||
onClick: () => option.onChange(optionValue),
|
||||
key: `button-${option.id}-${index}`, // A unique key
|
||||
});
|
||||
});
|
||||
|
||||
return buttons;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2 py-2 text-white">
|
||||
{options?.map(option => {
|
||||
if (option.type === SETTING_TYPES.RANGE) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center"
|
||||
key={option.id}
|
||||
>
|
||||
<div className="w-1/3 text-[13px]">{option.name}</div>
|
||||
<div className="w-2/3">
|
||||
<InputRange
|
||||
minValue={option.min}
|
||||
maxValue={option.max}
|
||||
step={option.step}
|
||||
value={option.value}
|
||||
onChange={e => option.onChange(e)}
|
||||
allowNumberEdit={true}
|
||||
showAdjustmentArrows={false}
|
||||
inputClassName="ml-1 w-4/5 cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
if (option.condition && option.condition?.({ options }) === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (option.type === SETTING_TYPES.RADIO) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-between text-[13px]"
|
||||
key={option.id}
|
||||
>
|
||||
<span>{option.name}</span>
|
||||
<div className="max-w-1/2">
|
||||
<ButtonGroup
|
||||
buttons={getButtons(option)}
|
||||
defaultActiveIndex={option.defaultActiveIndex}
|
||||
size={ButtonEnums.size.small}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (option.type === SETTING_TYPES.CUSTOM) {
|
||||
return (
|
||||
<div key={option.id}>
|
||||
{typeof option.children === 'function' ? option.children() : option.children}
|
||||
</div>
|
||||
);
|
||||
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">
|
||||
<InputRange
|
||||
minValue={option.min}
|
||||
maxValue={option.max}
|
||||
step={option.step}
|
||||
value={option.value}
|
||||
onChange={value => option.commands?.(value)}
|
||||
allowNumberEdit={true}
|
||||
showAdjustmentArrows={false}
|
||||
inputClassName="ml-1 w-4/5 cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderRadioSetting = option => {
|
||||
const renderButtons = option => {
|
||||
return option.values?.map(({ label, value: optionValue }, index) => (
|
||||
<button
|
||||
onClick={() => {
|
||||
option.commands?.(optionValue);
|
||||
}}
|
||||
key={`button-${option.id}-${index}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
));
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-between text-[13px]"
|
||||
key={option.id}
|
||||
>
|
||||
<span>{option.name}</span>
|
||||
<div className="max-w-1/2">
|
||||
<ButtonGroup
|
||||
activeIndex={option.values.findIndex(({ value }) => value === option.value) || 0}
|
||||
>
|
||||
{renderButtons(option)}
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderDoubleRangeSetting = option => {
|
||||
return (
|
||||
<div
|
||||
className="flex w-full items-center"
|
||||
key={option.id}
|
||||
>
|
||||
<InputDoubleRange
|
||||
values={option.values}
|
||||
onChange={option.commands}
|
||||
minValue={option.min}
|
||||
maxValue={option.max}
|
||||
step={option.step}
|
||||
showLabel={true}
|
||||
allowNumberEdit={true}
|
||||
showAdjustmentArrows={false}
|
||||
containerClassName="w-full"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderCustomSetting = option => {
|
||||
return (
|
||||
<div key={option.id}>
|
||||
{typeof option.children === 'function' ? option.children() : option.children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ToolSettings;
|
||||
@@ -1,2 +1,4 @@
|
||||
import AdvancedToolbox from './AdvancedToolbox';
|
||||
import ToolSettings from './ToolSettings';
|
||||
export default AdvancedToolbox;
|
||||
export { ToolSettings };
|
||||
@@ -67,9 +67,12 @@ const Button = ({
|
||||
name,
|
||||
className,
|
||||
onClick,
|
||||
dataCY,
|
||||
startIconTooltip = null,
|
||||
endIconTooltip = null,
|
||||
}) => {
|
||||
dataCY = dataCY || `${name}-btn`;
|
||||
|
||||
const startIcon = startIconProp && (
|
||||
<>
|
||||
{React.cloneElement(startIconProp, {
|
||||
@@ -109,7 +112,7 @@ const Button = ({
|
||||
disabled={disabled}
|
||||
ref={buttonElement}
|
||||
onClick={handleOnClick}
|
||||
data-cy={`${name}-btn`}
|
||||
data-cy={dataCY}
|
||||
>
|
||||
{startIconTooltip ? <Tooltip content={startIconTooltip}>{startIcon}</Tooltip> : startIcon}
|
||||
{children}
|
||||
@@ -148,6 +151,8 @@ Button.propTypes = {
|
||||
startIconTooltip: PropTypes.node,
|
||||
/** Tooltip for the end icon */
|
||||
endIconTooltip: PropTypes.node,
|
||||
/** Data attribute for testing */
|
||||
dataCY: PropTypes.string,
|
||||
};
|
||||
|
||||
export default Button;
|
||||
@@ -1,21 +1,25 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect, cloneElement, Children } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classnames from 'classnames';
|
||||
import { ButtonEnums } from '../../components';
|
||||
|
||||
const ButtonGroup = ({
|
||||
buttons,
|
||||
onActiveIndexChange,
|
||||
children,
|
||||
className,
|
||||
orientation = ButtonEnums.orientation.horizontal,
|
||||
defaultActiveIndex = 0,
|
||||
activeIndex: defaultActiveIndex = 0,
|
||||
onActiveIndexChange,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const [activeIndex, setActiveIndex] = useState(defaultActiveIndex);
|
||||
|
||||
const handleButtonClick = (e, index) => {
|
||||
useEffect(() => {
|
||||
setActiveIndex(defaultActiveIndex);
|
||||
}, [defaultActiveIndex]);
|
||||
|
||||
const handleButtonClick = index => {
|
||||
setActiveIndex(index);
|
||||
onActiveIndexChange && onActiveIndexChange(index);
|
||||
buttons[index].onClick && buttons[index].onClick(e);
|
||||
};
|
||||
|
||||
const orientationClasses = {
|
||||
@@ -29,35 +33,40 @@ const ButtonGroup = ({
|
||||
<div
|
||||
className={classnames(
|
||||
wrapperClasses,
|
||||
'border-secondary-light rounded-[5px] border bg-black text-[13px] '
|
||||
'border-secondary-light rounded-[5px] border bg-black text-[13px]'
|
||||
)}
|
||||
>
|
||||
{buttons.map((buttonProps, index) => {
|
||||
const isActive = index === activeIndex;
|
||||
return (
|
||||
<button
|
||||
{...buttonProps}
|
||||
key={index}
|
||||
className={classnames(
|
||||
{Children.map(children, (child, index) => {
|
||||
if (React.isValidElement(child)) {
|
||||
return cloneElement(child, {
|
||||
key: index,
|
||||
className: classnames(
|
||||
'rounded-[4px] px-2 py-1',
|
||||
isActive ? 'bg-customblue-40 text-white' : 'text-primary-active bg-black'
|
||||
)}
|
||||
onClick={e => handleButtonClick(e, index)}
|
||||
/>
|
||||
);
|
||||
index === activeIndex
|
||||
? 'bg-customblue-40 text-white'
|
||||
: 'text-primary-active bg-black',
|
||||
child.props.className,
|
||||
disabled ? 'ohif-disabled' : ''
|
||||
),
|
||||
onClick: e => {
|
||||
child.props.onClick && child.props.onClick(e);
|
||||
handleButtonClick(index);
|
||||
},
|
||||
});
|
||||
}
|
||||
return child;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ButtonGroup.propTypes = {
|
||||
buttons: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
children: PropTypes.node.isRequired,
|
||||
orientation: PropTypes.oneOf(Object.values(ButtonEnums.orientation)),
|
||||
type: PropTypes.oneOf(Object.values(ButtonEnums.type)),
|
||||
size: PropTypes.oneOf(Object.values(ButtonEnums.size)),
|
||||
defaultActiveIndex: PropTypes.number,
|
||||
activeIndex: PropTypes.number,
|
||||
onActiveIndexChange: PropTypes.func,
|
||||
className: PropTypes.string,
|
||||
disabled: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default ButtonGroup;
|
||||
@@ -16,10 +16,6 @@ export const argTypes = {
|
||||
component={ButtonGroup}
|
||||
/>
|
||||
|
||||
export const buttonGroupTemplate = args => {
|
||||
return <ButtonGroup {...args} />;
|
||||
};
|
||||
|
||||
<Heading
|
||||
title="ButtonGroup"
|
||||
componentRelativePath="ButtonGroup/ButtonGroup.tsx"
|
||||
@@ -35,31 +31,16 @@ export const buttonGroupTemplate = args => {
|
||||
ButtonGroup is a container for a group of buttons.
|
||||
|
||||
<Canvas>
|
||||
<Story
|
||||
name="Overview"
|
||||
args={{
|
||||
type: ButtonEnums.type.primary,
|
||||
size: ButtonEnums.size.medium,
|
||||
orientation: ButtonEnums.orientation.horizontal,
|
||||
buttons: [
|
||||
{
|
||||
children: 'Button 1',
|
||||
onClick: () => console.log('Button 1 clicked'),
|
||||
},
|
||||
{
|
||||
children: 'Button 2',
|
||||
onClick: () => console.log('Button 2 clicked'),
|
||||
},
|
||||
{
|
||||
children: 'Button 3',
|
||||
onClick: () => console.log('Button 3 clicked'),
|
||||
},
|
||||
],
|
||||
activeIndex: 0,
|
||||
onActiveIndexChange: index => console.log(index),
|
||||
}}
|
||||
>
|
||||
{buttonGroupTemplate.bind({})}
|
||||
<Story name="Overview">
|
||||
<ButtonGroup
|
||||
orientation={ButtonEnums.orientation.horizontal}
|
||||
activeIndex={0}
|
||||
onActiveIndexChange={index => console.log(index)}
|
||||
>
|
||||
<button onClick={() => console.log('Button 1111 clicked')}>Button 1</button>
|
||||
<button onClick={() => console.log('Button 222 clicked')}>Button 2</button>
|
||||
<button onClick={() => console.log('Button 33 clicked')}>Button 3</button>
|
||||
</ButtonGroup>
|
||||
</Story>
|
||||
</Canvas>
|
||||
|
||||
|
||||
@@ -30,13 +30,11 @@ function Header({
|
||||
|
||||
return (
|
||||
<NavBar
|
||||
className="justify-between border-b-4 border-black"
|
||||
isSticky={isSticky}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex flex-1 justify-between">
|
||||
<div className="flex items-center">
|
||||
{/* // TODO: Should preserve filter/sort
|
||||
// Either injected service? Or context (like react router's `useLocation`?) */}
|
||||
<div className="relative h-[48px] items-center ">
|
||||
<div className="absolute left-0 top-1/2 -translate-y-1/2 ">
|
||||
<div
|
||||
className={classNames(
|
||||
'mr-3 inline-flex items-center',
|
||||
@@ -56,8 +54,11 @@ function Header({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center">{children}</div>
|
||||
<div className="flex items-center">
|
||||
{/* <div className="absolute top-1/2 left-[211px] h-8 w-20 -translate-y-1/2">{future left component}</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 -translate-y-1/2 ">
|
||||
<Dropdown
|
||||
id="options"
|
||||
showDropdownIcon={false}
|
||||
|
||||
@@ -94,7 +94,7 @@ import toolMove from './../../assets/icons/tool-move.svg';
|
||||
import toolWindow from './../../assets/icons/tool-window-level.svg';
|
||||
import toolAnnotate from './../../assets/icons/tool-annotate.svg';
|
||||
import toolBidirectional from './../../assets/icons/tool-bidirectional.svg';
|
||||
import toolElipse from './../../assets/icons/tool-elipse.svg';
|
||||
import toolElipse from './../../assets/icons/tool-ellipse.svg';
|
||||
import toolCircle from './../../assets/icons/tool-circle.svg';
|
||||
import toolLength from './../../assets/icons/tool-length.svg';
|
||||
import toolStackScroll from './../../assets/icons/tool-stack-scroll.svg';
|
||||
@@ -235,7 +235,7 @@ const ICONS = {
|
||||
'tool-window-level': toolWindow,
|
||||
'tool-annotate': toolAnnotate,
|
||||
'tool-bidirectional': toolBidirectional,
|
||||
'tool-elipse': toolElipse,
|
||||
'tool-ellipse': toolElipse,
|
||||
'tool-circle': toolCircle,
|
||||
'tool-length': toolLength,
|
||||
'tool-stack-scroll': toolStackScroll,
|
||||
|
||||
@@ -14,7 +14,7 @@ const roundedClasses = {
|
||||
};
|
||||
|
||||
const disabledClasses = {
|
||||
true: 'cursor-not-allowed',
|
||||
true: 'ohif-disabled',
|
||||
false: '',
|
||||
};
|
||||
|
||||
@@ -43,7 +43,7 @@ const variantClasses = {
|
||||
'border border-primary-main text-white hover:bg-primary-main focus:bg-primary-main hover:border-black focus:border-black',
|
||||
},
|
||||
contained: {
|
||||
default: 'text-black hover:opacity-80 active:opacity-100 focus:opacity-80',
|
||||
default: 'text-common-bright hover:opacity-80 active:opacity-100 focus:opacity-80',
|
||||
primary: 'text-white hover:opacity-80 active:opacity-100 focus:opacity-80',
|
||||
secondary: 'text-white hover:opacity-80 active:opacity-100 focus:opacity-80',
|
||||
white: 'text-black hover:opacity-80 active:opacity-100 focus:opacity-80',
|
||||
@@ -51,30 +51,6 @@ const variantClasses = {
|
||||
},
|
||||
};
|
||||
|
||||
const backgroundClasses = {
|
||||
text: {
|
||||
default: '',
|
||||
primary: '',
|
||||
secondary: '',
|
||||
white: '',
|
||||
black: '',
|
||||
},
|
||||
outlined: {
|
||||
default: 'bg-transparent',
|
||||
primary: 'bg-transparent',
|
||||
secondary: 'bg-transparent',
|
||||
white: 'bg-transparent',
|
||||
black: 'bg-black',
|
||||
},
|
||||
contained: {
|
||||
default: 'bg-primary-light',
|
||||
primary: 'bg-primary-main',
|
||||
secondary: 'bg-secondary-light',
|
||||
white: 'bg-white',
|
||||
black: 'bg-black',
|
||||
},
|
||||
};
|
||||
|
||||
const sizeClasses = {
|
||||
small: 'py-2 px-2 text-base',
|
||||
medium: 'py-3 px-3 text-lg',
|
||||
@@ -87,7 +63,7 @@ const iconSizeClasses = {
|
||||
small: 'w-4 h-4',
|
||||
medium: 'w-5 h-5',
|
||||
large: 'w-6 h-6',
|
||||
toolbar: 'w-5 h-5',
|
||||
toolbar: 'w-[24px] h-[24px]',
|
||||
};
|
||||
|
||||
const fullWidthClasses = {
|
||||
@@ -106,9 +82,7 @@ const IconButton = ({
|
||||
fullWidth,
|
||||
onClick,
|
||||
className,
|
||||
name,
|
||||
id,
|
||||
bgColor,
|
||||
...rest
|
||||
}) => {
|
||||
const buttonElement = useRef(null);
|
||||
@@ -118,7 +92,7 @@ const IconButton = ({
|
||||
onClick(e);
|
||||
};
|
||||
|
||||
const bgColorToUse = bgColor ? bgColor : backgroundClasses[variant][color];
|
||||
const padding = size === 'toolbar' ? '8px' : size === 'toolbox' ? '4px' : null;
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -129,11 +103,10 @@ const IconButton = ({
|
||||
sizeClasses[size],
|
||||
fullWidthClasses[fullWidth],
|
||||
disabledClasses[disabled],
|
||||
bgColorToUse,
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
padding: size === 'toolbar' ? '10px' : null,
|
||||
padding,
|
||||
}}
|
||||
ref={buttonElement}
|
||||
onClick={handleOnClick}
|
||||
@@ -161,7 +134,7 @@ IconButton.defaultProps = {
|
||||
|
||||
IconButton.propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
size: PropTypes.oneOf(['small', 'medium', 'large', 'initial', 'toolbar']),
|
||||
size: PropTypes.oneOf(['small', 'medium', 'large', 'initial', 'toolbar', 'toolbox']),
|
||||
rounded: PropTypes.oneOf(['none', 'small', 'medium', 'large', 'full']),
|
||||
variant: PropTypes.oneOf(['text', 'outlined', 'contained']),
|
||||
color: PropTypes.oneOf(['default', 'primary', 'secondary', 'white', 'black', 'inherit']),
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import React, { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import OutsideClickHandler from 'react-outside-click-handler';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import Icon from '../Icon';
|
||||
import Tooltip from '../Tooltip';
|
||||
import ListMenu from '../ListMenu';
|
||||
|
||||
const baseClasses = {
|
||||
Button: 'flex items-center rounded-md border-transparent cursor-pointer group/button',
|
||||
Primary:
|
||||
// By default border on left, top and bottom for hover effect and only rounded on left side.
|
||||
// Extra padding on right to compensate for no right border.
|
||||
'h-full border-l-2 border-t-2 border-b-2 rounded-tl-md rounded-bl-md group/primary !pl-2 !py-2',
|
||||
Secondary:
|
||||
'h-full flex items-center justify-center rounded-tr-md rounded-br-md w-4 border-2 border-transparent group/secondary',
|
||||
SecondaryIcon: 'w-4 h-full stroke-1',
|
||||
Separator: 'border-l py-2.5',
|
||||
Content: 'absolute z-10 top-0 mt-12',
|
||||
};
|
||||
|
||||
const classes = {
|
||||
Button: ({ isExpanded, primary }) =>
|
||||
classNames(
|
||||
baseClasses.Button,
|
||||
!isExpanded && !primary.isActive && 'hover:!bg-primary-dark hover:border-primary-dark'
|
||||
),
|
||||
Interface: 'h-full flex flex-row items-center',
|
||||
Primary: ({ isActive, isExpanded }) =>
|
||||
classNames(
|
||||
baseClasses.Primary,
|
||||
isActive
|
||||
? isExpanded
|
||||
? 'border-primary-dark !bg-primary-dark hover:border-primary-dark !text-primary-light'
|
||||
: 'border-primary-light bg-primary-light border-2 rounded-md !p-2'
|
||||
: `focus:!text-black focus:!rounded-md focus:!border-primary-light focus:!bg-primary-light
|
||||
${
|
||||
isExpanded
|
||||
? 'border-primary-dark bg-primary-dark !text-primary-light'
|
||||
: 'border-secondary-dark bg-secondary-dark group-hover/button:border-primary-dark group-hover/button:text-primary-light hover:!bg-primary-dark hover:border-primary-dark focus:!text-black'
|
||||
}
|
||||
`
|
||||
),
|
||||
Secondary: ({ isExpanded, primary }) =>
|
||||
classNames(
|
||||
baseClasses.Secondary,
|
||||
isExpanded
|
||||
? 'bg-primary-light !rounded-tr-md !rounded-br-md'
|
||||
: primary.isActive
|
||||
? 'bg-secondary-dark'
|
||||
: 'hover:bg-primary-dark bg-secondary-dark group-hover/button:border-primary-dark'
|
||||
),
|
||||
SecondaryIcon: ({ isExpanded }) =>
|
||||
classNames(
|
||||
baseClasses.SecondaryIcon,
|
||||
isExpanded
|
||||
? 'text-primary-dark'
|
||||
: 'text-primary-active group-hover/secondary:text-primary-light'
|
||||
),
|
||||
Separator: ({ primary, isExpanded, isHovering }) =>
|
||||
classNames(
|
||||
baseClasses.Separator,
|
||||
isHovering || isExpanded || primary.isActive ? 'border-transparent' : 'border-primary-active'
|
||||
),
|
||||
Content: ({ isExpanded }) => classNames(baseClasses.Content, isExpanded ? 'block' : 'hidden'),
|
||||
};
|
||||
|
||||
const SplitButton = ({
|
||||
groupId,
|
||||
primary,
|
||||
secondary,
|
||||
items,
|
||||
renderer,
|
||||
onInteraction,
|
||||
Component,
|
||||
isActive,
|
||||
}) => {
|
||||
const { t } = useTranslation('Buttons');
|
||||
const [state, setState] = useState({ isHovering: false, isExpanded: false });
|
||||
|
||||
const toggleExpanded = () => setState({ ...state, isExpanded: !state.isExpanded });
|
||||
const setHover = hovering => setState({ ...state, isHovering: hovering });
|
||||
const collapse = () => setState({ ...state, isExpanded: false });
|
||||
|
||||
const renderPrimaryButton = () => (
|
||||
<Component
|
||||
key={primary.id}
|
||||
{...primary}
|
||||
isActive={isActive}
|
||||
onInteraction={onInteraction}
|
||||
rounded="none"
|
||||
className={classes.Primary({ isActive, isExpanded: state.isExpanded })}
|
||||
data-tool={primary.id}
|
||||
data-cy={`${groupId}-split-button-primary`}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<OutsideClickHandler
|
||||
onOutsideClick={collapse}
|
||||
disabled={!state.isExpanded}
|
||||
>
|
||||
<div
|
||||
id="SplitButton"
|
||||
className="relative"
|
||||
>
|
||||
<div
|
||||
className={classes.Button({ ...state, primary: { isActive } })}
|
||||
style={{ height: '40px' }}
|
||||
onMouseEnter={() => setHover(true)}
|
||||
onMouseLeave={() => setHover(false)}
|
||||
>
|
||||
<div className={classes.Interface}>
|
||||
<div onClick={collapse}>{renderPrimaryButton()}</div>
|
||||
<div className={classes.Separator({ ...state, primary: { isActive } })}></div>
|
||||
<div
|
||||
className={classes.Secondary({ ...state, primary: { isActive } })}
|
||||
onClick={toggleExpanded}
|
||||
data-cy={`${groupId}-split-button-secondary`}
|
||||
>
|
||||
<Tooltip
|
||||
isDisabled={state.isExpanded || !secondary.tooltip}
|
||||
content={secondary.tooltip}
|
||||
className="h-full"
|
||||
>
|
||||
<Icon
|
||||
name={secondary.icon}
|
||||
className={classes.SecondaryIcon({ ...state })}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={classes.Content({ ...state })}
|
||||
data-cy={`${groupId}-list-menu`}
|
||||
>
|
||||
<ListMenu
|
||||
items={items}
|
||||
onClick={collapse}
|
||||
renderer={args => renderer({ ...args, t })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</OutsideClickHandler>
|
||||
);
|
||||
};
|
||||
|
||||
SplitButton.propTypes = {
|
||||
isToggle: PropTypes.bool,
|
||||
groupId: PropTypes.string.isRequired,
|
||||
primary: PropTypes.object.isRequired,
|
||||
secondary: PropTypes.object.isRequired,
|
||||
items: PropTypes.array.isRequired,
|
||||
renderer: PropTypes.func,
|
||||
isActive: PropTypes.bool,
|
||||
onInteraction: PropTypes.func.isRequired,
|
||||
Component: PropTypes.elementType,
|
||||
};
|
||||
|
||||
SplitButton.defaultProps = {
|
||||
isToggle: false,
|
||||
renderer: null,
|
||||
isActive: false,
|
||||
Component: null,
|
||||
};
|
||||
|
||||
export default SplitButton;
|
||||
@@ -0,0 +1,2 @@
|
||||
import LegacySplitButton from './LegacySplitButton';
|
||||
export default LegacySplitButton;
|
||||
@@ -2,24 +2,25 @@ import React, { useState } from 'react';
|
||||
import classnames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const flex = 'flex flex-row justify-between items-center';
|
||||
const theme = 'bg-indigo-dark text-white';
|
||||
|
||||
const ListMenu = ({ items = [], renderer, onClick }) => {
|
||||
const [selectedIndex, setSelectedIndex] = useState(null);
|
||||
|
||||
const ListItem = ({ item, index, isSelected }) => {
|
||||
const flex = 'flex flex-row justify-between items-center';
|
||||
const theme = 'bg-indigo-dark';
|
||||
|
||||
const onClickHandler = () => {
|
||||
setSelectedIndex(index);
|
||||
onClick({ item, selectedIndex: index });
|
||||
if (item.onClick) {
|
||||
item.onClick({ ...item, index, isSelected });
|
||||
}
|
||||
item.onClick?.({ ...item, index, isSelected });
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classnames(flex, theme, 'cursor-pointer')}
|
||||
className={classnames(flex, theme, {
|
||||
'cursor-pointer': !item.disabled,
|
||||
'ohif-disabled': item.disabled,
|
||||
})}
|
||||
onClick={onClickHandler}
|
||||
data-cy={item.id}
|
||||
>
|
||||
|
||||
@@ -9,12 +9,11 @@ const NavBar = ({ className, children, isSticky }) => {
|
||||
return (
|
||||
<div
|
||||
className={classnames(
|
||||
'bg-secondary-dark z-20 flex flex-row items-center border-b-4 border-black px-1',
|
||||
'bg-secondary-dark z-20 border-black px-1',
|
||||
isSticky && stickyClasses,
|
||||
!isSticky && notStickyClasses,
|
||||
className
|
||||
)}
|
||||
style={{ paddingTop: '4px', paddingBottom: '4px', minHeight: '52px' }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -37,7 +37,7 @@ const SegmentItem = ({
|
||||
>
|
||||
<div
|
||||
className={classnames('bg-primary-dark group/number grid w-[32px] place-items-center', {
|
||||
'bg-primary-light border-primary-light rounded-l-[4px] border text-black': isActive,
|
||||
'!bg-primary-light border-primary-light rounded-l-[4px] border text-black': isActive,
|
||||
'border-primary-dark border': !isActive,
|
||||
})}
|
||||
onMouseEnter={() => setIsNumberBoxHovering(true)}
|
||||
|
||||
@@ -98,10 +98,11 @@ const getTabClassNames = (
|
||||
numColumns: number,
|
||||
numTabs: number,
|
||||
tabIndex: number,
|
||||
isActiveTab: boolean
|
||||
isActiveTab: boolean,
|
||||
isTabDisabled: boolean
|
||||
) =>
|
||||
classnames('h-[28px] mb-[2px] cursor-pointer text-white bg-black', {
|
||||
'hover:text-primary-active': !isActiveTab,
|
||||
'hover:text-primary-active': !isActiveTab && !isTabDisabled,
|
||||
'rounded-l': tabIndex % numColumns === 0,
|
||||
'rounded-r': (tabIndex + 1) % numColumns === 0 || tabIndex === numTabs - 1,
|
||||
});
|
||||
@@ -137,6 +138,15 @@ const createStyleMap = (
|
||||
};
|
||||
};
|
||||
|
||||
const getToolTipContent = (label: string, disabled: boolean) => {
|
||||
return (
|
||||
<>
|
||||
<div>{label}</div>
|
||||
{disabled && <div className="text-white">{'Not available based on current context'}</div>}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const createBaseStyle = (expandedWidth: number) => {
|
||||
return {
|
||||
maxWidth: `${expandedWidth}px`,
|
||||
@@ -218,7 +228,7 @@ const SidePanel = ({
|
||||
<Tooltip
|
||||
position={side === 'left' ? 'right' : 'left'}
|
||||
key={index}
|
||||
content={`${childComponent.label}`}
|
||||
content={getToolTipContent(childComponent.label, childComponent.disabled)}
|
||||
className={classnames(
|
||||
'flex items-center',
|
||||
side === 'left' ? 'justify-end ' : 'justify-start '
|
||||
@@ -229,12 +239,15 @@ const SidePanel = ({
|
||||
data-cy={`${childComponent.name}-btn`}
|
||||
className="text-primary-active hover:cursor-pointer"
|
||||
onClick={() => {
|
||||
updateActiveTabIndex(index);
|
||||
return childComponent.disabled ? null : updateActiveTabIndex(index);
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
name={childComponent.iconName}
|
||||
className="text-primary-active"
|
||||
className={classnames({
|
||||
'text-primary-active': true,
|
||||
'ohif-disabled': childComponent.disabled,
|
||||
})}
|
||||
style={{
|
||||
width: '22px',
|
||||
height: '22px',
|
||||
@@ -279,6 +292,7 @@ const SidePanel = ({
|
||||
style={getGridStyle(side, tabs.length, gridWidth, expandedWidth)}
|
||||
>
|
||||
{tabs.map((tab, tabIndex) => {
|
||||
const { disabled } = tab;
|
||||
return (
|
||||
<React.Fragment key={tabIndex}>
|
||||
{tabIndex % numCols !== 0 && (
|
||||
@@ -294,22 +308,26 @@ const SidePanel = ({
|
||||
<Tooltip
|
||||
position={'bottom'}
|
||||
key={tabIndex}
|
||||
content={`${tab.label}`}
|
||||
content={getToolTipContent(tab.label, disabled)}
|
||||
>
|
||||
<div
|
||||
className={getTabClassNames(
|
||||
numCols,
|
||||
tabs.length,
|
||||
tabIndex,
|
||||
tabIndex === activeTabIndex
|
||||
tabIndex === activeTabIndex,
|
||||
disabled
|
||||
)}
|
||||
style={getTabStyle(tabs.length)}
|
||||
onClick={() => updateActiveTabIndex(tabIndex)}
|
||||
onClick={() => {
|
||||
return disabled ? null : updateActiveTabIndex(tabIndex);
|
||||
}}
|
||||
data-cy={`${tab.name}-btn`}
|
||||
>
|
||||
<div className={getTabIconClassNames(tabs.length, tabIndex === activeTabIndex)}>
|
||||
<Icon
|
||||
name={tab.iconName}
|
||||
className={`${tab.disabled && 'ohif-disabled'}`}
|
||||
style={{
|
||||
width: '22px',
|
||||
height: '22px',
|
||||
|
||||
@@ -11,42 +11,29 @@ import ListMenu from '../ListMenu';
|
||||
const baseClasses = {
|
||||
Button: 'flex items-center rounded-md border-transparent cursor-pointer group/button',
|
||||
Primary:
|
||||
// By default border on left, top and bottom for hover effect and only rounded on left side.
|
||||
// Extra padding on right to compensate for no right border.
|
||||
'h-full border-l-2 border-t-2 border-b-2 rounded-tl-md rounded-bl-md group/primary !pl-2 !py-2',
|
||||
Secondary:
|
||||
'h-full flex items-center justify-center rounded-tr-md rounded-br-md w-4 border-2 border-transparent group/secondary',
|
||||
SecondaryIcon: 'w-4 h-full stroke-1',
|
||||
Separator: 'border-l py-2.5',
|
||||
Separator: 'border-l py-3 ml-0.5',
|
||||
Content: 'absolute z-10 top-0 mt-12',
|
||||
};
|
||||
|
||||
const classes = {
|
||||
Button: ({ isExpanded, primary }) =>
|
||||
Button: ({ isExpanded }) =>
|
||||
classNames(
|
||||
baseClasses.Button,
|
||||
!isExpanded && !primary.isActive && 'hover:!bg-primary-dark hover:border-primary-dark'
|
||||
!isExpanded && 'hover:!bg-primary-dark hover:border-primary-dark'
|
||||
),
|
||||
Interface: 'h-full flex flex-row items-center',
|
||||
Primary: ({ primary, isExpanded }) =>
|
||||
Primary: ({ isExpanded, isActive }) =>
|
||||
classNames(
|
||||
baseClasses.Primary,
|
||||
primary.isActive
|
||||
isActive
|
||||
? isExpanded
|
||||
? 'border-primary-dark !bg-primary-dark hover:border-primary-dark !text-primary-light'
|
||||
: `${
|
||||
primary.isToggle
|
||||
? 'border-secondary-dark bg-secondary-light'
|
||||
: 'border-primary-light bg-primary-light'
|
||||
}
|
||||
border-2 rounded-md !p-2` // Full, rounded border with less right padding when active.
|
||||
: `focus:!text-black focus:!rounded-md focus:!border-primary-light focus:!bg-primary-light
|
||||
${
|
||||
isExpanded
|
||||
? 'border-primary-dark bg-primary-dark !text-primary-light'
|
||||
: 'border-secondary-dark bg-secondary-dark group-hover/button:border-primary-dark group-hover/button:text-primary-light hover:!bg-primary-dark hover:border-primary-dark focus:!text-black'
|
||||
}
|
||||
`
|
||||
: 'border-primary-light bg-primary-light border-2 rounded-md !p-2'
|
||||
: `focus:!text-black focus:!rounded-md focus:!border-primary-light focus:!bg-primary-light ${isExpanded ? 'border-primary-dark bg-primary-dark !text-primary-light' : 'border-secondary-dark bg-secondary-dark group-hover/button:border-primary-dark group-hover/button:text-primary-light hover:!bg-primary-dark hover:border-primary-dark focus:!text-black'}`
|
||||
),
|
||||
Secondary: ({ isExpanded, primary }) =>
|
||||
classNames(
|
||||
@@ -54,13 +41,15 @@ const classes = {
|
||||
isExpanded
|
||||
? 'bg-primary-light !rounded-tr-md !rounded-br-md'
|
||||
: primary.isActive
|
||||
? 'bg-secondary-dark'
|
||||
: 'hover:bg-primary-dark bg-secondary-dark group-hover/button:border-primary-dark'
|
||||
? 'bg-secondary-dark'
|
||||
: 'hover:bg-primary-dark bg-secondary-dark group-hover/button:border-primary-dark'
|
||||
),
|
||||
SecondaryIcon: ({ isExpanded }) =>
|
||||
classNames(
|
||||
baseClasses.SecondaryIcon,
|
||||
isExpanded ? 'text-primary-dark' : 'text-[#348cfd] group-hover/secondary:text-primary-light'
|
||||
isExpanded
|
||||
? 'text-primary-dark'
|
||||
: 'text-primary-active group-hover/secondary:text-primary-light'
|
||||
),
|
||||
Separator: ({ primary, isExpanded, isHovering }) =>
|
||||
classNames(
|
||||
@@ -70,14 +59,40 @@ const classes = {
|
||||
Content: ({ isExpanded }) => classNames(baseClasses.Content, isExpanded ? 'block' : 'hidden'),
|
||||
};
|
||||
|
||||
const DefaultListItemRenderer = props => {
|
||||
const { t, icon, label, className, isActive } = props;
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
'flex h-8 w-full flex-row items-center p-3',
|
||||
'whitespace-pre text-base',
|
||||
className,
|
||||
`${isActive ? 'hover:opacity-80' : 'hover:bg-primary-dark '}`
|
||||
)}
|
||||
>
|
||||
{icon && (
|
||||
<span className="mr-4">
|
||||
<Icon
|
||||
name={icon}
|
||||
className="h-5 w-5"
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
<span className="mr-5">{t?.(label)}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* This is a more generic version of SplitButton without the isActive
|
||||
* and other interaction props
|
||||
*/
|
||||
const SplitButton = ({
|
||||
isToggle,
|
||||
groupId,
|
||||
primary,
|
||||
secondary,
|
||||
items,
|
||||
renderer,
|
||||
isActive,
|
||||
onInteraction,
|
||||
Component,
|
||||
}) => {
|
||||
@@ -88,19 +103,14 @@ const SplitButton = ({
|
||||
const setHover = hovering => setState({ ...state, isHovering: hovering });
|
||||
const collapse = () => setState({ ...state, isExpanded: false });
|
||||
|
||||
const renderPrimaryButton = () => (
|
||||
<Component
|
||||
key={primary.id}
|
||||
{...primary}
|
||||
isActive={isActive}
|
||||
onInteraction={onInteraction}
|
||||
rounded="none"
|
||||
className={classes.Primary({ ...state, primary: { isActive, isToggle } })}
|
||||
data-tool={primary.id}
|
||||
data-cy={`${groupId}-split-button-primary`}
|
||||
/>
|
||||
const listItemRenderer = renderer || DefaultListItemRenderer;
|
||||
const primaryClassNames = classNames(
|
||||
classes.Primary({
|
||||
isExpanded: state.isExpanded,
|
||||
isActive: primary.isActive,
|
||||
}),
|
||||
primary.className
|
||||
);
|
||||
|
||||
return (
|
||||
<OutsideClickHandler
|
||||
onOutsideClick={collapse}
|
||||
@@ -111,16 +121,26 @@ const SplitButton = ({
|
||||
className="relative"
|
||||
>
|
||||
<div
|
||||
className={classes.Button({ ...state, primary: { isActive } })}
|
||||
className={classes.Button({ ...state })}
|
||||
style={{ height: '40px' }}
|
||||
onMouseEnter={() => setHover(true)}
|
||||
onMouseLeave={() => setHover(false)}
|
||||
>
|
||||
<div className={classes.Interface}>
|
||||
<div onClick={collapse}>{renderPrimaryButton()}</div>
|
||||
<div className={classes.Separator({ ...state, primary: { isActive } })}></div>
|
||||
<div onClick={collapse}>
|
||||
<Component
|
||||
key={primary.id}
|
||||
{...primary}
|
||||
onInteraction={onInteraction}
|
||||
rounded="none"
|
||||
className={primaryClassNames}
|
||||
data-tool={primary.id}
|
||||
data-cy={`${groupId}-split-button-primary`}
|
||||
/>
|
||||
</div>
|
||||
<div className={classes.Separator({ ...state, primary })}></div>
|
||||
<div
|
||||
className={classes.Secondary({ ...state, primary: { isActive } })}
|
||||
className={classes.Secondary({ ...state, primary })}
|
||||
onClick={toggleExpanded}
|
||||
data-cy={`${groupId}-split-button-secondary`}
|
||||
>
|
||||
@@ -131,7 +151,7 @@ const SplitButton = ({
|
||||
>
|
||||
<Icon
|
||||
name={secondary.icon}
|
||||
className={classes.SecondaryIcon({ ...state, primary: { isActive } })}
|
||||
className={classes.SecondaryIcon({ ...state })}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
@@ -144,7 +164,7 @@ const SplitButton = ({
|
||||
<ListMenu
|
||||
items={items}
|
||||
onClick={collapse}
|
||||
renderer={args => renderer({ ...args, t })}
|
||||
renderer={args => listItemRenderer({ ...args, t })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -162,6 +182,7 @@ SplitButton.propTypes = {
|
||||
isActive: PropTypes.bool,
|
||||
onInteraction: PropTypes.func.isRequired,
|
||||
Component: PropTypes.elementType,
|
||||
interactionType: PropTypes.oneOf(['action', 'tool', 'toggle']),
|
||||
};
|
||||
|
||||
SplitButton.defaultProps = {
|
||||
|
||||
@@ -2,6 +2,7 @@ import SplitButton from '../SplitButton';
|
||||
|
||||
import { ArgsTable, Story, Canvas, Meta } from '@storybook/addon-docs';
|
||||
import { createComponentTemplate } from '../../../storybook/functions/create-component-story';
|
||||
import ToolbarButton from '../../ToolbarButton';
|
||||
|
||||
export const argTypes = {
|
||||
component: SplitButton,
|
||||
@@ -30,7 +31,7 @@ export const SplitButtonTemplate = args => (
|
||||
|
||||
## Overview
|
||||
|
||||
SplitButton is a component that renders the SplitButtons.
|
||||
SplitButton is a component that renders the SplitButtons
|
||||
|
||||
<Canvas>
|
||||
<Story
|
||||
@@ -63,6 +64,7 @@ SplitButton is a component that renders the SplitButtons.
|
||||
tooltip: 'Bidirectional',
|
||||
},
|
||||
],
|
||||
Component: ToolbarButton,
|
||||
}}
|
||||
>
|
||||
{SplitButtonTemplate.bind({})}
|
||||
|
||||
@@ -64,7 +64,7 @@ const Thumbnail = ({
|
||||
<img
|
||||
src={imageSrc}
|
||||
alt={imageAltText}
|
||||
className="min-h-32 object-none"
|
||||
className="min-h-32 object-contain"
|
||||
crossOrigin="anonymous"
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -8,7 +8,9 @@ import Tooltip from '../Tooltip';
|
||||
import { StringNumber } from '../../types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
function ThumbnailTracked ({
|
||||
// Todo: This class to me feels like it belongs in an extension, not in platform/ui
|
||||
// because it is dealing with mode specific components/information
|
||||
function ThumbnailTracked({
|
||||
displaySetInstanceUID,
|
||||
className,
|
||||
imageSrc,
|
||||
@@ -87,12 +89,12 @@ function ThumbnailTracked ({
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col">
|
||||
<span>
|
||||
<span className="text-white">{isTracked ? t('Series is tracked') : t('Series is untracked')}</span>
|
||||
<span className="text-white">
|
||||
{isTracked ? t('Series is tracked') : t('Series is untracked')}
|
||||
</span>
|
||||
</span>
|
||||
{!!viewportIdentificator.length && (
|
||||
<span>
|
||||
{`${t('Viewport')}: ${viewportIdentificatorLabel}`}
|
||||
</span>
|
||||
<span>{`${t('Viewport')}: ${viewportIdentificatorLabel}`}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -136,7 +138,7 @@ function ThumbnailTracked ({
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
ThumbnailTracked.propTypes = {
|
||||
/**
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classnames from 'classnames';
|
||||
|
||||
import IconButton from '../IconButton';
|
||||
import Icon from '../Icon';
|
||||
import Tooltip from '../Tooltip';
|
||||
|
||||
const ToolbarButton = ({
|
||||
type = 'tool',
|
||||
id,
|
||||
icon,
|
||||
label,
|
||||
@@ -15,47 +13,37 @@ const ToolbarButton = ({
|
||||
onInteraction,
|
||||
dropdownContent,
|
||||
//
|
||||
isActive,
|
||||
className,
|
||||
size,
|
||||
toolTipClassName,
|
||||
...rest
|
||||
//
|
||||
}) => {
|
||||
const classes = {
|
||||
tool: isActive
|
||||
? 'text-black'
|
||||
: 'text-common-bright hover:!bg-primary-dark hover:text-primary-light',
|
||||
toggle: isActive
|
||||
? '!text-[#348CFD]'
|
||||
: 'text-common-bright hover:!bg-primary-dark hover:text-primary-light',
|
||||
action: isActive
|
||||
? 'text-black'
|
||||
: 'text-common-bright hover:!bg-primary-dark hover:text-primary-light',
|
||||
};
|
||||
|
||||
const bgClasses = {
|
||||
toggle: isActive && 'bg-transparent',
|
||||
};
|
||||
|
||||
const activeClass = isActive ? 'active' : '';
|
||||
const shouldShowDropdown = !!isActive && !!dropdownContent;
|
||||
const shouldShowDropdown = !!dropdownContent;
|
||||
const iconEl = icon ? <Icon name={icon} /> : <div>{label || 'Missing icon and label'}</div>;
|
||||
|
||||
const sizeToUse = size ?? 'toolbar';
|
||||
const toolTipClassNameToUse =
|
||||
toolTipClassName !== undefined
|
||||
? toolTipClassName
|
||||
: sizeToUse === 'toolbar'
|
||||
? 'w-[40px] h-[40px]'
|
||||
: 'w-[32px] h-[32px]';
|
||||
|
||||
return (
|
||||
<div key={id}>
|
||||
<Tooltip
|
||||
isSticky={shouldShowDropdown}
|
||||
content={shouldShowDropdown ? dropdownContent : label}
|
||||
tight={shouldShowDropdown}
|
||||
className={toolTipClassNameToUse}
|
||||
>
|
||||
<IconButton
|
||||
variant={isActive ? 'contained' : 'text'}
|
||||
bgColor={bgClasses[type]}
|
||||
size="toolbar"
|
||||
className={classnames(activeClass, classes[type], className)}
|
||||
size={sizeToUse}
|
||||
className={className}
|
||||
onClick={() => {
|
||||
onInteraction({
|
||||
itemId: id,
|
||||
interactionType: type,
|
||||
commands,
|
||||
});
|
||||
}}
|
||||
@@ -73,27 +61,20 @@ const ToolbarButton = ({
|
||||
|
||||
ToolbarButton.defaultProps = {
|
||||
dropdownContent: null,
|
||||
isActive: false,
|
||||
type: 'action',
|
||||
};
|
||||
|
||||
ToolbarButton.propTypes = {
|
||||
/* Influences background/hover styling */
|
||||
type: PropTypes.oneOf(['action', 'toggle', 'tool']),
|
||||
id: PropTypes.string.isRequired,
|
||||
isActive: PropTypes.bool,
|
||||
className: PropTypes.string,
|
||||
commands: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
commandName: PropTypes.string.isRequired,
|
||||
commandOptions: PropTypes.object,
|
||||
})
|
||||
),
|
||||
onInteraction: PropTypes.func.isRequired,
|
||||
commands: PropTypes.oneOfType([PropTypes.array, PropTypes.object, PropTypes.string]),
|
||||
onInteraction: PropTypes.func,
|
||||
icon: PropTypes.string.isRequired,
|
||||
label: PropTypes.string.isRequired,
|
||||
/** Tooltip content can be replaced for a customized content by passing a node to this value. */
|
||||
dropdownContent: PropTypes.oneOfType([PropTypes.node, PropTypes.func]),
|
||||
size: PropTypes.string,
|
||||
toolTipClassName: PropTypes.string,
|
||||
};
|
||||
|
||||
export default ToolbarButton;
|
||||
@@ -0,0 +1,109 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { useToolbar } from '@ohif/core';
|
||||
import { ToolboxUI } from './';
|
||||
import { useToolbox } from '../../contextProviders';
|
||||
|
||||
/**
|
||||
* A toolbox is a collection of buttons and commands that they invoke, used to provide
|
||||
* custom control panels to users. This component is a generic UI component that
|
||||
* interacts with services and commands in a generic fashion. While it might
|
||||
* seem unconventional to import it from the UI and integrate it into the JSX,
|
||||
* it belongs in the UI components as there isn't anything in this component that
|
||||
* couldn't be used for a completely different type of app. It plays a crucial
|
||||
* role in enhancing the app with a toolbox by providing a way to integrate
|
||||
* and display various tools and their corresponding options
|
||||
*/
|
||||
function Toolbox({ servicesManager, buttonSectionId, commandsManager, title, ...props }) {
|
||||
const { state: toolboxState, api } = useToolbox(buttonSectionId);
|
||||
const { onInteraction, toolbarButtons } = useToolbar({
|
||||
servicesManager,
|
||||
buttonSection: buttonSectionId,
|
||||
});
|
||||
|
||||
const prevButtonIdsRef = useRef();
|
||||
|
||||
useEffect(() => {
|
||||
const currentButtonIdsStr = JSON.stringify(
|
||||
toolbarButtons.map(button => {
|
||||
const { id, componentProps } = button;
|
||||
if (componentProps.items?.length) {
|
||||
return componentProps.items.map(item => `${item.id}-${item.disabled}`);
|
||||
}
|
||||
return `${id}-${componentProps.disabled}`;
|
||||
})
|
||||
);
|
||||
|
||||
if (prevButtonIdsRef.current === currentButtonIdsStr) {
|
||||
return;
|
||||
}
|
||||
|
||||
prevButtonIdsRef.current = currentButtonIdsStr;
|
||||
|
||||
const initializeOptionsWithEnhancements = toolbarButtons.reduce(
|
||||
(accumulator, toolbarButton) => {
|
||||
const { id: buttonId, componentProps } = toolbarButton;
|
||||
|
||||
const createEnhancedOptions = (options, parentId) =>
|
||||
options.map(option => {
|
||||
return {
|
||||
...option,
|
||||
value:
|
||||
toolboxState.toolOptions?.[parentId]?.find(prop => prop.id === option.id)?.value ??
|
||||
option.value,
|
||||
commands: value => {
|
||||
api.handleToolOptionChange(parentId, option.id, value);
|
||||
|
||||
const { isArray } = Array;
|
||||
const cmds = isArray(option.commands) ? option.commands : [option.commands];
|
||||
|
||||
cmds.forEach(command => {
|
||||
const isString = typeof command === 'string';
|
||||
const isObject = typeof command === 'object';
|
||||
|
||||
if (isString) {
|
||||
commandsManager.run(command, { value });
|
||||
} else if (isObject) {
|
||||
commandsManager.run({
|
||||
...command,
|
||||
commandOptions: { ...command.commandOptions, ...option, value },
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
if (componentProps.items?.length) {
|
||||
componentProps.items.forEach(item => {
|
||||
accumulator[item.id] = createEnhancedOptions(item.options, item.id);
|
||||
});
|
||||
} else if (componentProps.options?.length) {
|
||||
accumulator[buttonId] = createEnhancedOptions(componentProps.options, buttonId);
|
||||
}
|
||||
|
||||
return accumulator;
|
||||
},
|
||||
{}
|
||||
);
|
||||
|
||||
api.initializeToolOptions(initializeOptionsWithEnhancements);
|
||||
}, [toolbarButtons, api]);
|
||||
|
||||
const handleToolOptionChange = (toolName, optionName, newValue) => {
|
||||
api.handleToolOptionChange(toolName, optionName, newValue);
|
||||
};
|
||||
|
||||
return (
|
||||
<ToolboxUI
|
||||
{...props}
|
||||
title={title}
|
||||
toolbarButtons={toolbarButtons}
|
||||
activeToolOptions={toolboxState.toolOptions?.[toolboxState.activeTool]}
|
||||
handleToolSelect={id => api.handleToolSelect(id)}
|
||||
handleToolOptionChange={handleToolOptionChange}
|
||||
onInteraction={onInteraction}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default Toolbox;
|
||||
@@ -0,0 +1,92 @@
|
||||
import React from 'react';
|
||||
import { PanelSection, ToolSettings, Tooltip } from '../../components';
|
||||
import classnames from 'classnames';
|
||||
|
||||
const ItemsPerRow = 4;
|
||||
|
||||
/**
|
||||
* Just refactoring from the toolbox component to make it more readable
|
||||
*/
|
||||
function ToolboxUI(props) {
|
||||
const {
|
||||
toolbarButtons,
|
||||
handleToolSelect,
|
||||
activeToolOptions,
|
||||
numRows,
|
||||
servicesManager,
|
||||
title,
|
||||
useCollapsedPanel = true,
|
||||
} = props;
|
||||
|
||||
const render = () => {
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col bg-black">
|
||||
<div className="bg-primary-dark mt-0.5 flex flex-wrap py-2">
|
||||
{toolbarButtons.map((toolDef, index) => {
|
||||
if (!toolDef) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { id, Component, componentProps } = toolDef;
|
||||
const isLastRow = Math.floor(index / ItemsPerRow) + 1 === numRows;
|
||||
|
||||
const toolClasses = `ml-1 ${isLastRow ? '' : 'mb-2'}`;
|
||||
|
||||
const onInteraction = ({ itemId, id, commands }) => {
|
||||
handleToolSelect(itemId || id);
|
||||
props.onInteraction({
|
||||
itemId,
|
||||
commands,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
className={classnames({
|
||||
[toolClasses]: true,
|
||||
'flex flex-col items-center justify-center': true,
|
||||
})}
|
||||
>
|
||||
{componentProps.disabled ? (
|
||||
<Tooltip
|
||||
position="bottom"
|
||||
content={componentProps.label}
|
||||
secondaryContent={'Not available on the current viewport'}
|
||||
>
|
||||
<Component
|
||||
{...componentProps}
|
||||
{...props}
|
||||
id={id}
|
||||
servicesManager={servicesManager}
|
||||
onInteraction={onInteraction}
|
||||
size="toolbox"
|
||||
/>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Component
|
||||
{...componentProps}
|
||||
{...props}
|
||||
id={id}
|
||||
servicesManager={servicesManager}
|
||||
onInteraction={onInteraction}
|
||||
size="toolbox"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-primary-dark h-auto px-2">
|
||||
{activeToolOptions && <ToolSettings options={activeToolOptions} />}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return useCollapsedPanel ? <PanelSection title={title}>{render()}</PanelSection> : render();
|
||||
}
|
||||
|
||||
export { ToolboxUI };
|
||||
@@ -0,0 +1,3 @@
|
||||
import { ToolboxUI } from './ToolboxUI';
|
||||
import Toolbox from './Toolbox';
|
||||
export { ToolboxUI, Toolbox };
|
||||
@@ -30,7 +30,16 @@ const arrowPositionStyle = {
|
||||
},
|
||||
};
|
||||
|
||||
const Tooltip = ({ content, isSticky, position, className, tight, children, isDisabled }) => {
|
||||
const Tooltip = ({
|
||||
content,
|
||||
secondaryContent = null,
|
||||
isSticky,
|
||||
position,
|
||||
className,
|
||||
tight,
|
||||
children,
|
||||
isDisabled,
|
||||
}) => {
|
||||
const [isActive, setIsActive] = useState(false);
|
||||
const { t } = useTranslation('Buttons');
|
||||
|
||||
@@ -68,11 +77,14 @@ const Tooltip = ({ content, isSticky, position, className, tight, children, isDi
|
||||
className={classnames(
|
||||
'tooltip-box bg-primary-dark border-secondary-light w-max-content relative inset-x-auto top-full rounded border text-base text-white',
|
||||
{
|
||||
'py-1 px-4': !tight,
|
||||
'py-[6px] px-[8px]': !tight,
|
||||
}
|
||||
)}
|
||||
>
|
||||
{typeof content === 'string' ? t(content) : content}
|
||||
<div>{typeof content === 'string' ? t(content) : content}</div>
|
||||
<div className="text-aqua-pale">
|
||||
{typeof secondaryContent === 'string' ? t(secondaryContent) : secondaryContent}
|
||||
</div>
|
||||
<svg
|
||||
className="text-primary-dark stroke-secondary-light absolute h-4"
|
||||
style={arrowPositionStyle[position]}
|
||||
|
||||
@@ -10,10 +10,24 @@ const classes = {
|
||||
bottomLeft: 'bottom-viewport left-viewport',
|
||||
};
|
||||
|
||||
const ViewportOverlay = ({ topLeft, topRight, bottomRight, bottomLeft, color }) => {
|
||||
export type ViewportOverlayProps = {
|
||||
topLeft: React.ReactNode;
|
||||
topRight: React.ReactNode;
|
||||
bottomRight: React.ReactNode;
|
||||
bottomLeft: React.ReactNode;
|
||||
color?: string;
|
||||
};
|
||||
|
||||
const ViewportOverlay = ({
|
||||
topLeft,
|
||||
topRight,
|
||||
bottomRight,
|
||||
bottomLeft,
|
||||
color = 'text-primary-light',
|
||||
}: ViewportOverlayProps) => {
|
||||
const overlay = 'absolute pointer-events-none viewport-overlay';
|
||||
return (
|
||||
<div className={classnames(color ? color : 'text-primary-light')}>
|
||||
<div className={color}>
|
||||
<div
|
||||
data-cy={'viewport-overlay-top-left'}
|
||||
className={classnames(overlay, classes.topLeft)}
|
||||
|
||||
@@ -79,6 +79,9 @@ import PanelSection from './PanelSection';
|
||||
import AdvancedToolbox from './AdvancedToolbox';
|
||||
import InputDoubleRange from './InputDoubleRange';
|
||||
import LegacyButtonGroup from './LegacyButtonGroup';
|
||||
import LegacySplitButton from './LegacySplitButton';
|
||||
import { ToolSettings } from './AdvancedToolbox';
|
||||
import { Toolbox } from './Toolbox';
|
||||
import InvestigationalUseDialog from './InvestigationalUseDialog';
|
||||
import MeasurementItem from './MeasurementTable/MeasurementItem';
|
||||
|
||||
@@ -165,6 +168,9 @@ export {
|
||||
ViewportPane,
|
||||
ViewportOverlay,
|
||||
WindowLevelMenuItem,
|
||||
LegacySplitButton,
|
||||
ToolSettings,
|
||||
Toolbox,
|
||||
InvestigationalUseDialog,
|
||||
MeasurementItem,
|
||||
};
|
||||
Reference in new issue
Block a user