This commit is contained in:
mario
2025-03-07 13:47:44 +07:00
commit c4efec5a14
3358 changed files with 303774 additions and 0 deletions

View File

@@ -0,0 +1,155 @@
import React from 'react';
import PropTypes from 'prop-types';
import detect from 'browser-detect';
import { useTranslation } from 'react-i18next';
import Typography from '../Typography';
import Icon from '../Icon';
const Link = ({ href, children, showIcon = false }) => {
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
>
<Typography
variant="subtitle"
component="p"
color="primaryActive"
className="flex items-center"
>
{children}
{!!showIcon && (
<Icon
name="external-link"
className="ml-2 w-5 text-white"
/>
)}
</Typography>
</a>
);
};
const Row = ({ title, value, link }) => {
return (
<div className="mb-4 flex">
<Typography
variant="subtitle"
component="p"
className="w-48 text-white"
>
{title}
</Typography>
{link ? (
<Link href={link}>{value}</Link>
) : (
<Typography
variant="subtitle"
component="p"
className="w-48 text-white"
>
{value}
</Typography>
)}
</div>
);
};
const AboutModal = ({ buildNumber, versionNumber, commitHash }) => {
const { os, version, name } = detect();
const browser = `${name[0].toUpperCase()}${name.substr(1)} ${version}`;
const { t } = useTranslation('AboutModal');
const renderRowTitle = title => (
<div className="mb-3 border-b-2 border-black pb-3">
<Typography
variant="inherit"
color="primaryLight"
className="text-[16px] font-semibold !leading-[1.2]"
>
{title}
</Typography>
</div>
);
return (
<div>
{renderRowTitle(t('Important links'))}
<div className="mb-8 flex">
<Link
href="https://community.ohif.org/"
showIcon={true}
>
{t('Visit the forum')}
</Link>
<span className="ml-4">
<Link
href="https://github.com/OHIF/Viewers/issues/new/choose"
showIcon={true}
>
{t('Report an issue')}
</Link>
</span>
<span className="ml-4">
<Link
href="https://ohif.org/"
showIcon={true}
>
{t('More details')}
</Link>
</span>
</div>
{renderRowTitle(t('Version information'))}
<div className="flex flex-col">
<Row
title={t('Repository URL')}
value="https://github.com/OHIF/Viewers/"
link="https://github.com/OHIF/Viewers/"
/>
<Row
title={t('Data citation')}
value="https://github.com/OHIF/Viewers/blob/master/DATACITATION.md"
link="https://github.com/OHIF/Viewers/blob/master/DATACITATION.md"
/>
{/* <Row
title={t('Last master commits')}
value="https://github.com/OHIF/Viewers/"
link="https://github.com/OHIF/Viewers/"
/> */}
<Row
title={t('Version number')}
value={versionNumber}
/>
{buildNumber && (
<Row
title={t('Build number')}
value={buildNumber}
/>
)}
{commitHash && (
<Row
title={t('Commit hash')}
value={commitHash}
/>
)}
<Row
title={t('Browser')}
value={browser}
/>
<Row
title={t('OS')}
value={os}
/>
</div>
</div>
);
};
AboutModal.propTypes = {
buildNumber: PropTypes.string,
versionNumber: PropTypes.string,
};
export default AboutModal;

View File

@@ -0,0 +1,50 @@
import AboutModal from '../../AboutModal';
import { ArgsTable, Story, Canvas, Meta } from '@storybook/addon-docs';
import {
createComponentTemplate,
createStoryMetaSettings,
} from '../../../storybook/functions/create-component-story';
export const argTypes = {
component: AboutModal,
title: 'Modals/About',
};
<Meta
title="Modals/About"
component={AboutModal}
/>
export const aboutTemplate = args => (
<div className="bg-primary-dark">
<AboutModal {...args} />
</div>
);
<Heading
title="About Modal"
componentRelativePath="AboutModal/AboutModal.tsx"
/>
- [Overview](#overview)
- [Props](#props)
- [Usage](#usage)
- [Contribute](#contribute)
## Overview
OHIF about modal component provides information about the application version and build number
<Canvas>
<Story name="Overview">{aboutTemplate.bind({})}</Story>
</Canvas>
## Props
<ArgsTable of={AboutModal} />
## Usage
## Contribute
<Footer componentRelativePath="AboutModal/__stories__/aboutModal.stories.mdx" />

View File

@@ -0,0 +1,2 @@
import AboutModal from './AboutModal';
export default AboutModal;

View File

@@ -0,0 +1,36 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Button, ButtonEnums } from '../../components';
function ActionButtons({ actions, disabled = false, t }) {
return (
<React.Fragment>
{actions.map((action, index) => (
<Button
key={index}
onClick={action.onClick}
disabled={disabled || action.disabled}
type={ButtonEnums.type.secondary}
size={ButtonEnums.size.small}
className={index > 0 ? 'ml-2' : ''}
>
{t ? t(action.label) : action.label}
</Button>
))}
</React.Fragment>
);
}
ActionButtons.propTypes = {
actions: PropTypes.arrayOf(
PropTypes.shape({
label: PropTypes.string.isRequired,
onClick: PropTypes.func.isRequired,
disabled: PropTypes.bool,
})
).isRequired,
disabled: PropTypes.bool,
};
export default ActionButtons;

View File

@@ -0,0 +1,3 @@
import ActionButtons from './ActionButtons';
export default ActionButtons;

View File

@@ -0,0 +1,71 @@
import React, { useState, useEffect } from 'react';
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);
useEffect(() => {
// see if any of the items are active from the outside
const activeItem = items?.find(item => item.active);
setActiveItemName(activeItem ? activeItem.name : null);
}, [items]);
const activeItemOptions = items?.find(item => item.name === activeItemName)?.options;
return (
<PanelSection
title={title}
childrenClassName="flex-shrink-0"
>
<div className="flex flex-col bg-black">
<div className="bg-primary-dark mt-0.5 flex flex-wrap py-2">
{items?.map(item => {
return (
<Tooltip
position="bottom"
content={<span className="text-white">{item.name}</span>}
key={item.name}
>
<div
className="ml-2 mb-2"
onClick={() => {
if (item.disabled) {
return;
}
setActiveItemName(item.name);
item.onClick(item.name);
}}
>
<div
className={classnames(
'text-primary-active grid h-[40px] w-[40px] place-items-center rounded-md bg-black ',
activeItemName === item.name && 'bg-primary-light text-black',
item.disabled && 'opacity-50',
!item.disabled &&
'hover:bg-primary-light cursor-pointer hover:cursor-pointer hover:text-black'
)}
>
<Icon name={item.icon} />
</div>
</div>
</Tooltip>
);
})}
</div>
<div className="bg-primary-dark h-auto px-2">
<ToolSettings options={activeItemOptions} />
</div>
</div>
</PanelSection>
);
};
AdvancedToolbox.propTypes = {};
export default AdvancedToolbox;

View File

@@ -0,0 +1,128 @@
import React from 'react';
import { ButtonGroup, InputDoubleRange, InputRange } from '../../components';
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="space-y-2 py-2 text-white">
{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">
<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
className="border-secondary-light rounded-md border"
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.value}
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;

View File

@@ -0,0 +1,144 @@
import AdvancedToolbox from '../../AdvancedToolbox';
import { ArgsTable, Story, Canvas, Meta } from '@storybook/addon-docs';
import {
createComponentTemplate,
createStoryMetaSettings,
} from '../../../storybook/functions/create-component-story';
export const argTypes = {
component: AdvancedToolbox,
title: 'Components/AdvancedToolbox',
};
<Meta
title="Components/AdvancedToolbox"
component={AdvancedToolbox}
/>
export const advancedToolboxTemplate = args => (
<div className="bg-primary-dark h-96 w-64">
<AdvancedToolbox {...args} />
</div>
);
<Heading
title="Advanced Toolbox"
componentRelativePath="AdvancedToolbox/AdvancedToolbox.tsx"
/>
- [Overview](#overview)
- [Props](#props)
- [Usage](#usage)
- [Contribute](#contribute)
## Overview
OHIF advanced toolbox which can host set of tools that require more space for customization.
<Canvas>
<Story
name="Overview"
args={{
title: 'Segmentation Tools',
items: [
{
name: 'Brush',
icon: 'icon-tool-brush',
active: false,
onClick: () => console.log('Brush clicked'),
options: [
{
name: 'Radius (mm)',
type: 'range',
min: 1,
max: 10,
value: 5,
step: 1,
onChange: value => console.log('Brush size changed', value),
},
{
name: 'Mode',
type: 'radio',
value: 'Circle',
values: [
{ value: 'Circle', label: 'Circle' },
{ value: 'Sphere', label: 'Sphere' },
{ value: 'Rectangle', label: 'Rectangle' },
],
onChange: value => console.log('Brush mode changed', value),
},
],
},
{
name: 'Eraser',
icon: 'icon-tool-eraser',
onClick: () => console.log('eraser clicked'),
options: [
{
name: 'Mode',
type: 'radio',
value: 'EraserSphere',
values: [
{ value: 'EraserCircle', label: 'Circle' },
{ value: 'EraserSphere', label: 'Sphere' },
],
onChange: value => console.log('Brush mode changed', value),
},
],
},
{
name: 'Threshold',
icon: 'icon-tool-threshold',
active: true,
onClick: () => console.log('eraser clicked'),
options: [
{
name: 'Radius (mm)',
type: 'range',
min: 1,
max: 10,
value: 5,
step: 1,
onChange: value => console.log('Brush size changed', value),
},
{
name: 'Mode',
type: 'radio',
value: 'Circle',
values: [
{ value: 'Circle', label: 'Circle' },
{ value: 'Sphere', label: 'Sphere' },
{ value: 'Rectangle', label: 'Rectangle' },
],
onChange: value => console.log('Brush mode changed', value),
},
{
name: 'custom',
type: 'custom',
children: () => {
return (
<div>
<div>Custom</div>
<input type="text" />
</div>
);
},
},
],
},
],
}}
>
{advancedToolboxTemplate.bind({})}
</Story>
</Canvas>
## Props
<ArgsTable of={AdvancedToolbox} />
## Usage
## Contribute
<Footer componentRelativePath="AdvancedToolbox/__stories__/AdvancedToolbox.stories.mdx" />

View File

@@ -0,0 +1,4 @@
import AdvancedToolbox from './AdvancedToolbox';
import ToolSettings from './ToolSettings';
export default AdvancedToolbox;
export { ToolSettings };

View File

@@ -0,0 +1,27 @@
import React from 'react';
import { Icon } from '@ohif/ui';
import DividerItem from './DividerItem';
type BackItemProps = {
backLabel?: string;
onBackClick: () => void;
};
const BackItem = ({ backLabel, onBackClick }: BackItemProps) => {
return (
<>
<div
className="all-in-one-menu-item all-in-one-menu-item-effects"
onClick={onBackClick}
>
<Icon name="content-prev"></Icon>
<div className="pl-2">{backLabel || 'Back to Display Options'}</div>
</div>
<DividerItem></DividerItem>
</>
);
};
export default BackItem;

View File

@@ -0,0 +1,11 @@
import React from 'react';
const DividerItem = () => {
return (
<div className="flex h-3.5 shrink-0 items-center px-2">
<div className="bg-primary-dark h-[2px] w-full"></div>
</div>
);
};
export default DividerItem;

View File

@@ -0,0 +1,13 @@
import React, { ReactNode } from 'react';
type HeaderItemProps = {
children: ReactNode;
};
const HeaderItem = ({ children }: HeaderItemProps) => {
return (
<div className="text-aqua-pale mx-2 flex h-6 shrink-0 items-center text-[11px]">{children}</div>
);
};
export default HeaderItem;

View File

@@ -0,0 +1,82 @@
import React, { useCallback, useEffect, useState } from 'react';
import OutsideClickHandler from 'react-outside-click-handler';
import { MenuProps } from './Menu';
import getIcon from '../Icon/getIcon';
import classNames from 'classnames';
import { AllInOneMenu } from '..';
export interface IconMenuProps extends MenuProps {
icon: string;
iconClassName?: string;
horizontalDirection?: AllInOneMenu.HorizontalDirection;
verticalDirection?: AllInOneMenu.VerticalDirection;
menuKey?: number | string;
}
/**
* An IconMenu allows for a div wrapped icon to be clicked to show and hide
* an AllInOneMenu.Menu. Based on the direction(s) specified, the menu is
* positioned relative to the icon.
*
* HorizontalDirection.LeftToRight - the left edges of the icon and menu are aligned
* HorizontalDirection.RightRoLeft - the right edges of the icon and menu are aligned
* VerticalDirection.TopToBottom - the top edge of the menu appears directly below the bottom edge of the icon
* VerticalDirection.BottomToTop - the bottom edge of the menu appears directly above the top edge of the icon
*
* For example, if an IconMenu were situated in the bottom-left corner of a container,
* it would be best to use BottomToTop and LeftToRight directions for it.
*/
export default function IconMenu({
icon,
iconClassName,
horizontalDirection,
verticalDirection,
children,
backLabel,
menuClassName,
menuStyle,
onVisibilityChange,
menuKey,
}: IconMenuProps) {
const [isMenuVisible, setIsMenuVisible] = useState(false);
const toggleMenuVisibility = useCallback(() => setIsMenuVisible(isVisible => !isVisible), []);
return (
<OutsideClickHandler
onOutsideClick={toggleMenuVisibility}
disabled={!isMenuVisible}
>
<div className="relative">
<div
className={iconClassName}
onClick={toggleMenuVisibility}
>
{getIcon(icon)}
</div>
<AllInOneMenu.Menu
key={menuKey}
isVisible={isMenuVisible}
backLabel={backLabel}
menuClassName={classNames(
menuClassName,
'absolute',
verticalDirection === AllInOneMenu.VerticalDirection.TopToBottom
? 'top-[100%]'
: 'bottom-[100%]',
horizontalDirection === AllInOneMenu.HorizontalDirection.LeftToRight
? 'left-0'
: 'right-0'
)}
menuStyle={menuStyle}
onVisibilityChange={isVis => {
setIsMenuVisible(isVis);
onVisibilityChange?.(isVis);
}}
horizontalDirection={horizontalDirection}
>
{children}
</AllInOneMenu.Menu>
</div>
</OutsideClickHandler>
);
}

View File

@@ -0,0 +1,45 @@
import React, { ReactNode, useCallback, useContext } from 'react';
import { MenuContext } from './Menu';
type ItemProps = {
label: string;
secondaryLabel?: string;
icon?: ReactNode;
onClick?: () => void;
onMouseEnter?: () => void;
onMouseLeave?: () => void;
rightIcon?: ReactNode;
};
const Item = ({
label,
secondaryLabel,
icon,
rightIcon,
onClick,
onMouseEnter,
onMouseLeave,
}: ItemProps) => {
const { hideMenu } = useContext(MenuContext);
const onClickHandler = useCallback(() => {
hideMenu();
onClick?.();
}, [hideMenu, onClick]);
return (
<div
className="all-in-one-menu-item all-in-one-menu-item-effects"
onClick={onClickHandler}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
{icon && <div className="pr-2">{icon}</div>}
<span>{label}</span>
{secondaryLabel != null && <span className="text-aqua-pale ml-[1ch]">{secondaryLabel}</span>}
{rightIcon && <div className="ml-auto">{rightIcon}</div>}
</div>
);
};
export default Item;

View File

@@ -0,0 +1,29 @@
import React, { ReactNode, useContext, useEffect } from 'react';
import { MenuContext } from './Menu';
type ItemPanelProps = {
label?: string;
index?: number;
children: ReactNode;
};
const ItemPanel = ({ label, index = 0, children }: ItemPanelProps) => {
const { addItemPanel, activePanelIndex } = useContext(MenuContext);
useEffect(() => {
addItemPanel(index, label);
}, []);
return (
activePanelIndex === index && (
<div
style={{ scrollbarGutter: 'auto' }}
className="ohif-scrollbar flex flex-col overflow-auto"
>
{children}
</div>
)
);
};
export default ItemPanel;

View File

@@ -0,0 +1,188 @@
import React, { createContext, ReactNode, useCallback, useEffect, useState } from 'react';
import './allInOneMenu.css';
import DividerItem from './DividerItem';
import PanelSelector from './PanelSelector';
import classNames from 'classnames';
import BackItem from './BackItem';
/**
* The vertical direction that the menu will be opened/used with.
*
* A TopToBottom menu would be used for cases where the menu is opened "near"
* the top edge of its container. Likewise a BottomToTop menu would be used
* for cases where the menu is opened "near" the bottom edge of its container.
*
* See IconMenu for more information.
*/
export enum VerticalDirection {
TopToBottom,
BottomToTop,
}
/**
* The horizontal direction that the menu is opened/used with.
* This direction dictates the general direction sub-menus and
* back-to-menus are opened with. For example, a RightToLeft menu
* will have sub-menu items indicated with a left pointing chevron
* and aligned with the left edge of the menu. Similarly back-to items of a
* RightToLeft menu are indicated with a right pointing chevron and
* aligned with the right edge of the menu.
*
* It is also worth noting that a LeftToRight menu would be used for
* cases where a menu is opened "near" the left edge of its container.
* Likewise, a RightToLeft menu would be used for cases where a menu is opened
* "near" the right edge of its container.
*
* See IconMenu for more information.
*/
export enum HorizontalDirection {
LeftToRight,
RightToLeft,
}
export interface MenuProps {
menuStyle?: unknown;
menuClassName?: string;
isVisible?: boolean;
preventHideMenu?: boolean;
backLabel?: string;
headerComponent?: ReactNode;
showHeaderDivider?: boolean;
activePanelIndex?: number;
onVisibilityChange?: (isVisible: boolean) => void;
horizontalDirection?: HorizontalDirection;
children: ReactNode;
}
type MenuContextProps = {
showSubMenu: (subMenuProps: MenuProps) => void;
hideMenu: () => void;
addItemPanel: (index: number, label: string) => void;
horizontalDirection: HorizontalDirection;
activePanelIndex: number;
};
type MenuPathState = {
props: MenuProps;
activePanelIndex: number;
};
export const MenuContext = createContext<MenuContextProps>(null);
const Menu = (props: MenuProps) => {
const {
isVisible,
onVisibilityChange,
activePanelIndex,
preventHideMenu,
menuClassName,
menuStyle,
horizontalDirection = HorizontalDirection.LeftToRight,
} = props;
const [isMenuVisible, setIsMenuVisible] = useState(isVisible);
// The menuPath is an array consisting of this top Menu and every SubMenu
// that has been traversed/opened by the user with the last item in the array
// being the current (sub)menu that is currently visible. This allows for the previously
// viewed menus to be returned to via the Back button at the top of the menu.
const [menuPath, setMenuPath] = useState<Array<MenuPathState>>([
{ props, activePanelIndex: activePanelIndex || 0 },
]);
const [itemPanelLabels, setItemPanelLabels] = useState<Array<string>>([]);
const hideMenu = useCallback(() => {
if (preventHideMenu) {
return;
}
setMenuPath(path => [path[0]]);
setItemPanelLabels([]);
setIsMenuVisible(false);
onVisibilityChange?.(false);
}, [preventHideMenu, onVisibilityChange]);
useEffect(() => {
if (isVisible) {
setIsMenuVisible(isVisible);
onVisibilityChange?.(isVisible);
} else {
hideMenu();
}
}, [hideMenu, isVisible, onVisibilityChange]);
const showSubMenu = useCallback((subMenuProps: MenuProps) => {
setMenuPath(path => {
return [
...path,
{ props: subMenuProps, activePanelIndex: subMenuProps.activePanelIndex || 0 },
];
});
setItemPanelLabels([]);
}, []);
const addItemPanel = useCallback((index, label) => {
setItemPanelLabels(labels => {
return [...labels.slice(0, index), label, ...labels.slice(index + 1, labels.length)];
});
}, []);
const onActivePanelIndexChange = useCallback(index => {
setMenuPath(path => {
return [
...path.slice(0, path.length - 1),
{ ...path[path.length - 1], activePanelIndex: index },
];
});
}, []);
const onBackClick = useCallback(() => {
setMenuPath(path => [...path.slice(0, path.length - 1)]);
setItemPanelLabels([]);
}, []);
const { props: currentMenuProps, activePanelIndex: currentMenuActivePanelIndex } =
menuPath[menuPath.length - 1];
return (
<>
<MenuContext.Provider
value={{
showSubMenu,
hideMenu,
addItemPanel,
activePanelIndex: currentMenuActivePanelIndex,
horizontalDirection,
}}
>
{isMenuVisible && (
<div
className={classNames(
'bg-secondary-dark flex select-none flex-col rounded px-1 py-1.5 text-white opacity-90',
menuClassName
)}
style={menuStyle}
>
{menuPath.length > 1 && (
<BackItem
backLabel={menuPath[menuPath.length - 2].props.backLabel}
onBackClick={onBackClick}
/>
)}
{itemPanelLabels.length > 1 && (
<PanelSelector
panelLabels={itemPanelLabels}
activeIndex={currentMenuActivePanelIndex}
onActiveIndexChange={onActivePanelIndexChange}
></PanelSelector>
)}
{currentMenuProps.headerComponent}
{currentMenuProps.showHeaderDivider && <DividerItem />}
{currentMenuProps.children}
</div>
)}
</MenuContext.Provider>
</>
);
};
export default Menu;

View File

@@ -0,0 +1,31 @@
import React, { ReactNode } from 'react';
import { ButtonGroup } from '../../components';
type PanelSelectorProps = {
panelLabels: Array<ReactNode>;
onActiveIndexChange: (index: number) => void;
activeIndex: number;
};
const PanelSelector = ({ panelLabels, onActiveIndexChange, activeIndex }: PanelSelectorProps) => {
const getButtons = () => {
return panelLabels.map((panelLabel, index) => {
return {
children: panelLabel,
key: index,
};
});
};
return (
<div className="mx-2 my-1 flex justify-center">
<ButtonGroup
buttons={getButtons()}
onActiveIndexChange={onActiveIndexChange}
defaultActiveIndex={activeIndex}
></ButtonGroup>
</div>
);
};
export default PanelSelector;

View File

@@ -0,0 +1,38 @@
import React, { useCallback, useContext } from 'react';
import { MenuContext, MenuProps } from './Menu';
import Icon from '../Icon';
export interface SubMenuProps extends MenuProps {
itemLabel: string;
onClick?: () => void;
itemIcon?: string;
}
const SubMenu = (props: SubMenuProps) => {
const { showSubMenu } = useContext(MenuContext);
const onClickHandler = useCallback(() => {
showSubMenu(props);
props.onClick?.();
}, [showSubMenu, props]);
return (
<div
className="all-in-one-menu-item all-in-one-menu-item-effects flex items-center"
onClick={onClickHandler}
>
{props.itemIcon && (
<Icon
name={props.itemIcon}
width="25px"
height="25px"
className="mr-2"
></Icon>
)}
<div className="mr-auto">{props.itemLabel}</div>
<Icon name="content-next"></Icon>
</div>
);
};
export default SubMenu;

View File

@@ -0,0 +1,129 @@
import { DividerItem, HeaderItem, Item, ItemPanel, Menu, SubMenu } from '..';
import InputRange from '../../InputRange/index.js';
import SwitchButton from '../../SwitchButton/index.js';
import { ArgsTable, Story, Canvas, Meta } from '@storybook/addon-docs';
export const argTypes = {
component: Menu,
title: 'Components/AllInOneMenu',
};
<Meta
title="Components/AllInOneMenu"
component={Menu}
/>
export const AllInOneMenuTemplate = args => (
<div className="w-80">
<Menu {...args}>
<ItemPanel>
<Item
label="Item 1"
key="0"
onClick={() => console.info('Item 1 clicked.')}
></Item>
<Item
label="Item 2"
secondaryLabel="Alt item 2"
key="1"
></Item>
<div
className="all-in-one-menu-item py-1"
style={{ flexBasis: 'content' }}
>
<div>Arbitrary item component:</div>
<InputRange
minValue={1}
maxValue={10}
value={5}
onChange={() => {}}
></InputRange>
</div>
<DividerItem key="2"></DividerItem>
<SubMenu
key="3"
backLabel="Back to Level 2"
itemLabel="Item 3 opens a sub menu"
onClick={() => console.info('Sub menu item clicked.')}
showHeaderDivider={true}
headerComponent={
<div className="all-in-one-menu-item flex w-full justify-center">
<SwitchButton label="Switch item in header" />
</div>
}
>
<ItemPanel
index={0}
label="Panel A"
>
<Item label="Panel A item"></Item>
</ItemPanel>
<ItemPanel
index={1}
label="Panel B"
>
<Item label="Panel B item"></Item>
<SubMenu
itemLabel="Opens a sub, sub menu"
headerComponent={<HeaderItem>Header for scrolling list of items</HeaderItem>}
>
<ItemPanel label="Sub sub Panel">
<Item label="Sub sub menu item 1"></Item>
<Item label="Sub sub menu item 2"></Item>
<Item label="Sub sub menu item 3"></Item>
<Item label="Sub sub menu item 4"></Item>
<Item label="Sub sub menu item 5"></Item>
<Item label="Sub sub menu item 6"></Item>
</ItemPanel>
</SubMenu>
</ItemPanel>
</SubMenu>
</ItemPanel>
</Menu>
</div>
);
<Heading
title="AllInOneMenu"
componentRelativePath="AllInOneMenu/AllInOneMenu.tsx"
/>
- [Overview](#overview)
- [Props](#props)
- [Contribute](#contribute)
## Overview
AllInOneMenu is a component that renders a menu with various menu items, sub-menus and
sub-components. The particular feature of the AllInOneMenu is its ability to render a sub-menu by
replacing the parent menu on screen. It also provides the ability to return to the parent menu with
a back menu item from the sub-menu. Furthermore, each menu level can be split into item panes - that
is several panes of menu items that are switched in and out of view like a tabbed pane.
<Canvas>
<Story
name="Overview"
args={{
className: 'max-h-[210px]',
isVisible: true,
preventHideMenu: true,
backLabel: 'Back to Level 1',
onVisibilityChange: (isVisible) => console.info(`The menu visibility: ${isVisible}`),
}}
>
{AllInOneMenuTemplate.bind({})}
</Story>
</Canvas>
## Props
<ArgsTable of={Menu} />
## Usage
## Contribute
<Footer componentRelativePath="AllInOneMenu/__stories__/AllInOneMenu.stories.mdx" />

View File

@@ -0,0 +1,11 @@
.all-in-one-menu-item {
@apply h-8 px-2 text-[14px] w-full;
display: flex;
align-items: center;
flex-shrink: 0;
line-height: 18px;
}
.all-in-one-menu-item-effects {
@apply cursor-pointer hover:bg-primary-dark hover:rounded;
}

View File

@@ -0,0 +1,19 @@
import Menu, { HorizontalDirection, VerticalDirection } from './Menu';
import DividerItem from './DividerItem';
import HeaderItem from './HeaderItem';
import IconMenu from './IconMenu';
import Item from './Item';
import ItemPanel from './ItemPanel';
import SubMenu from './SubMenu';
export {
Menu,
DividerItem,
HeaderItem,
IconMenu,
Item,
ItemPanel,
SubMenu,
HorizontalDirection,
VerticalDirection,
};

View File

@@ -0,0 +1,150 @@
import React, { useRef } from 'react';
import PropTypes from 'prop-types';
import classnames from 'classnames';
import * as ButtonEnums from './ButtonEnums';
import Tooltip from '../Tooltip/Tooltip';
const sizeClasses = {
[ButtonEnums.size.small]: 'h-[26px] text-[13px]',
[ButtonEnums.size.medium]: 'h-[32px] text-[14px]',
};
const layoutClasses =
'box-content inline-flex flex-row items-center justify-center gap-[5px] justify center px-[10px] outline-none rounded';
const baseFontTextClasses = 'leading-[1.2] font-sans text-center whitespace-nowrap';
const fontTextClasses = {
[ButtonEnums.type.primary]: classnames(baseFontTextClasses, 'font-semibold'),
[ButtonEnums.type.secondary]: classnames(baseFontTextClasses, 'font-400'),
};
const baseEnabledEffectClasses = 'transition duration-300 ease-in-out focus:outline-none';
const enabledEffectClasses = {
[ButtonEnums.type.primary]: classnames(
baseEnabledEffectClasses,
'hover:bg-customblue-80 active:bg-customblue-40'
),
[ButtonEnums.type.secondary]: classnames(
baseEnabledEffectClasses,
'hover:bg-customblue-50 active:bg-customblue-20'
),
};
const baseEnabledClasses = 'text-white';
const enabledClasses = {
[ButtonEnums.type.primary]: classnames(
'bg-primary-main',
baseEnabledClasses,
enabledEffectClasses[ButtonEnums.type.primary]
),
[ButtonEnums.type.secondary]: classnames(
'bg-customblue-30',
baseEnabledClasses,
enabledEffectClasses[ButtonEnums.type.secondary]
),
};
const disabledClasses = 'bg-inputfield-placeholder text-common-light cursor-default';
const defaults = {
color: 'default',
disabled: false,
rounded: 'small',
size: ButtonEnums.size.medium,
type: ButtonEnums.type.primary,
};
const Button = ({
children = '',
size = defaults.size,
disabled = defaults.disabled,
type = defaults.type,
startIcon: startIconProp,
endIcon: endIconProp,
name,
className,
onClick = () => {},
dataCY,
startIconTooltip = null,
endIconTooltip = null,
}) => {
dataCY = dataCY || `${name}-btn`;
const startIcon = startIconProp && (
<>
{React.cloneElement(startIconProp, {
className: classnames('w-4 h-4 fill-current', startIconProp?.props?.className),
})}
</>
);
const endIcon = endIconProp && (
<>
{React.cloneElement(endIconProp, {
className: classnames('w-4 h-4 fill-current', endIconProp?.props?.className),
})}
</>
);
const buttonElement = useRef(null);
const handleOnClick = e => {
buttonElement.current.blur();
if (!disabled) {
onClick(e);
}
};
const finalClassName = classnames(
layoutClasses,
fontTextClasses[type],
disabled ? disabledClasses : enabledClasses[type],
sizeClasses[size],
children ? 'min-w-[32px]' : '', // minimum width for buttons with text; icon only button does NOT get a minimum width
className
);
return (
<button
className={finalClassName}
disabled={disabled}
ref={buttonElement}
onClick={handleOnClick}
data-cy={dataCY}
>
{startIconTooltip ? <Tooltip content={startIconTooltip}>{startIcon}</Tooltip> : startIcon}
{children}
{endIconTooltip ? <Tooltip content={endIconTooltip}>{endIcon}</Tooltip> : endIcon}
</button>
);
};
Button.propTypes = {
/** What is inside the button, can be text or react component */
children: PropTypes.node,
/** Callback to be called when the button is clicked */
onClick: PropTypes.func.isRequired,
/** Button size */
size: PropTypes.oneOf([ButtonEnums.size.medium, ButtonEnums.size.small]),
/** Whether the button should be disabled */
disabled: PropTypes.bool,
/** Button type */
type: PropTypes.oneOf([ButtonEnums.type.primary, ButtonEnums.type.secondary]),
name: PropTypes.string,
/** Button start icon name - if any icon is specified */
startIcon: PropTypes.node,
/** Button end icon name - if any icon is specified */
endIcon: PropTypes.node,
/** Additional TailwindCSS classnames */
className: PropTypes.string,
/** Tooltip for the start icon */
startIconTooltip: PropTypes.node,
/** Tooltip for the end icon */
endIconTooltip: PropTypes.node,
/** Data attribute for testing */
dataCY: PropTypes.string,
};
export default Button;

View File

@@ -0,0 +1,15 @@
enum type {
primary = 'primary',
secondary = 'secondary',
}
enum size {
medium = 'medium',
small = 'small',
}
enum orientation {
horizontal = 'horizontal',
vertical = 'vertical',
}
export { type, size, orientation };

View File

@@ -0,0 +1,171 @@
import { Button, ButtonEnums } from '../../../components';
import { ArgsTable, Story, Canvas, Meta } from '@storybook/addon-docs';
import {
createComponentTemplate,
createStoryMetaSettings,
} from '../../../storybook/functions/create-component-story';
export const argTypes = {
component: Button,
title: 'Components/Button',
};
<Meta
title="Components/Button"
component={Button}
/>
export const buttonTemplate = createComponentTemplate(Button);
<Heading
title="Button"
componentRelativePath="Button/Button.tsx"
/>
- [Overview](#overview)
- [Props](#props)
- [Usage](#usage)
- [Contribute](#contribute)
## Overview
You can use the button component to create a button. It can be used in different ways, the default
button is a simple button with text.
<Canvas>
<Story
name="Overview"
args={{ children: 'Button', color: 'default' }}
>
{buttonTemplate.bind({})}
</Story>
</Canvas>
## Props
<ArgsTable of={Button} />
## Usage
### Types
There can be different types of buttons: `primary`, and `secondary`.
<Canvas>
<Story name="Types">
<div className="flex space-x-2">
<Button type={ButtonEnums.type.primary}>Primary Button</Button>
<Button type={ButtonEnums.type.secondary}>Secondary Button</Button>
</div>
</Story>
</Canvas>
### Sizes
There are different sizes for the button: `small`, and `medium`. The size refers to the button's
height.
<Canvas>
<Story name="Sizes">
<div className="flex items-center space-x-2">
<Button size={ButtonEnums.size.small}>Small Button</Button>
<Button size={ButtonEnums.size.medium}>Medium Button</Button>
</div>
</Story>
</Canvas>
### Mixing props
You can mix different props together to create a button.
<Canvas>
<Story name="Custom">
<Button
type={ButtonEnums.type.secondary}
size={ButtonEnums.size.small}
>
Small, Secondary Button
</Button>
</Story>
</Canvas>
### Disabled
You can disable the button by setting the `disabled` property to true.
<Canvas>
<Story name="Disabled">
<Button disabled={true}>Disabled Button</Button>
</Story>
</Canvas>
### Start/End Icons
You can add an icon to the start of the button. It accepts an icon component.
<Canvas>
<Story name="Start Icon">
{() => {
// svg icon for github
const Github = () => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="feather feather-github"
>
<path d="M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 0 0-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0 0 20 4.77 5.07 5.07 0 0 0 19.91 1S18.73.65 16 2.48a13.38 13.38 0 0 0-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 0 0 5 4.77a5.44 5.44 0 0 0-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 0 0 9 18.13V22"></path>
</svg>
);
};
return <Button startIcon={<Github />}>Start Icon Button</Button>;
}}
</Story>
</Canvas>
End Icon is the same as start icon, but for the end of the button.
<Canvas>
<Story name="End Icon">
{() => {
// svg icon for github
const Github = () => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="feather feather-github"
>
<path d="M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 0 0-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0 0 20 4.77 5.07 5.07 0 0 0 19.91 1S18.73.65 16 2.48a13.38 13.38 0 0 0-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 0 0 5 4.77a5.44 5.44 0 0 0-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 0 0 9 18.13V22"></path>
</svg>
);
};
return (
<Button
startIcon={<Github />}
endIcon={<Github />}
>
Start and End Icon Button
</Button>
);
}}
</Story>
</Canvas>
## Contribute
<Footer componentRelativePath="Button/__stories__/button.stories.mdx" />

View File

@@ -0,0 +1,5 @@
import Button from './Button';
import * as ButtonEnums from './ButtonEnums';
export { ButtonEnums };
export default Button;

View File

@@ -0,0 +1,105 @@
import React, { useState, useEffect, cloneElement, Children } from 'react';
import PropTypes from 'prop-types';
import classnames from 'classnames';
import { ButtonEnums } from '../../components';
const ButtonGroup = ({
children,
className,
orientation = ButtonEnums.orientation.horizontal,
activeIndex: defaultActiveIndex = 0,
onActiveIndexChange,
separated = false,
disabled = false,
}) => {
const [activeIndex, setActiveIndex] = useState(defaultActiveIndex);
useEffect(() => {
setActiveIndex(defaultActiveIndex);
}, [defaultActiveIndex]);
const handleButtonClick = index => {
setActiveIndex(index);
onActiveIndexChange && onActiveIndexChange(index);
};
const orientationClasses = {
horizontal: 'flex-row',
vertical: 'flex-col',
};
const wrapperClasses = classnames(
`${separated ? '' : 'inline-flex'}`,
orientationClasses[orientation],
className
);
return (
<div
className={classnames(wrapperClasses, ' text-[13px]', {
' rounded-md bg-black': !separated,
})}
>
{!separated && (
<div className="flex h-[32px] w-full">
{Children.map(children, (child, index) => {
if (React.isValidElement(child)) {
return cloneElement(child, {
key: index,
className: classnames(
'rounded-[4px] px-2 py-1',
index === activeIndex
? 'bg-customblue-40 text-white'
: 'text-primary-active bg-black',
child.props.className,
child.props.disabled ? 'ohif-disabled' : ''
),
onClick: e => {
child.props.onClick && child.props.onClick(e);
handleButtonClick(index);
},
});
}
return child;
})}
</div>
)}
{separated && (
<div className="flex space-x-2">
{Children.map(children, (child, index) => {
if (React.isValidElement(child)) {
return cloneElement(child, {
key: index,
className: classnames(
'rounded-[4px] px-2 py-1',
index === activeIndex
? 'bg-customblue-40 text-white'
: 'text-primary-active bg-black border-secondary-light rounded-[5px] border',
child.props.className,
child.props.disabled ? 'ohif-disabled' : ''
),
onClick: e => {
child.props.onClick && child.props.onClick(e);
handleButtonClick(index);
},
});
}
return child;
})}
</div>
)}
</div>
);
};
ButtonGroup.propTypes = {
children: PropTypes.node.isRequired,
orientation: PropTypes.oneOf(Object.values(ButtonEnums.orientation)),
activeIndex: PropTypes.number,
onActiveIndexChange: PropTypes.func,
className: PropTypes.string,
disabled: PropTypes.bool,
separated: PropTypes.bool,
};
export default ButtonGroup;

View File

@@ -0,0 +1,55 @@
import { Button, ButtonGroup, ButtonEnums } from '../../../components';
import { ArgsTable, Story, Canvas, Meta } from '@storybook/addon-docs';
import {
createComponentTemplate,
createStoryMetaSettings,
} from '../../../storybook/functions/create-component-story';
export const argTypes = {
component: ButtonGroup,
title: 'Components/ButtonGroup',
};
<Meta
title="Components/ButtonGroup"
component={ButtonGroup}
/>
<Heading
title="ButtonGroup"
componentRelativePath="ButtonGroup/ButtonGroup.tsx"
/>
- [Overview](#overview)
- [Props](#props)
- [Usage](#usage)
- [Contribute](#contribute)
## Overview
ButtonGroup is a container for a group of buttons.
<Canvas>
<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>
## Props
<ArgsTable of={ButtonGroup} />
## Usage
## Contribute
<Footer componentRelativePath="ButtonGroup/__stories__/buttonGroup.stories.mdx" />

View File

@@ -0,0 +1,2 @@
import ButtonGroup from './ButtonGroup';
export default ButtonGroup;

View File

@@ -0,0 +1,55 @@
import React, { useState, useCallback } from 'react';
import PropTypes from 'prop-types';
import { Icon, Typography } from '../../';
/**
* REACT CheckBox component
* it has two props, checked and onChange
* checked is a boolean value
* onChange is a function that will be called when the checkbox is clicked
*
* CheckBox is a component that allows you to use as a boolean
*/
const CheckBox: React.FC<{
checked: boolean;
onChange: (state) => void;
className?: string;
label: string;
labelClassName?: string;
labelVariant?: string;
}> = ({ checked, onChange, label, labelClassName, labelVariant = 'body', className }) => {
const [isChecked, setIsChecked] = useState(checked);
const handleClick = useCallback(() => {
setIsChecked(!isChecked);
onChange(!isChecked);
}, [isChecked, onChange]);
return (
<div
className={`flex cursor-pointer items-center space-x-1 ${className ? className : ''}`}
onClick={handleClick}
>
{isChecked ? <Icon name="checkbox-checked" /> : <Icon name="checkbox-unchecked" />}
<Typography
variant={labelVariant ?? 'subtitle'}
component="p"
className={labelClassName ?? 'text-white '}
>
{label}
</Typography>
</div>
);
};
CheckBox.propTypes = {
checked: PropTypes.bool,
onChange: PropTypes.func,
label: PropTypes.string,
labelClassName: PropTypes.string,
labelVariant: PropTypes.string,
};
export default CheckBox;

View File

@@ -0,0 +1,49 @@
import CheckBox from '../CheckBox';
import { ArgsTable, Story, Canvas, Meta } from '@storybook/addon-docs';
export const argTypes = {
component: CheckBox,
title: 'Components/CheckBox',
};
<Meta
title="Components/CheckBox"
component={CheckBox}
/>
export const CheckBoxTemplate = args => <CheckBox {...args} />;
<Heading
title="CheckBox"
componentRelativePath="CheckBox/CheckBox.tsx"
/>
- [Overview](#overview)
- [Props](#props)
- [Contribute](#contribute)
## Overview
CheckBox is a component that allows you to use as a boolean value
<Canvas>
<Story
name="Overview"
args={{
checked: false,
onChange: () => console.log('on change callback'),
label: 'checkBoxLabel',
labelClassName: 'text-black text-sm',
}}
>
{CheckBoxTemplate.bind({})}
</Story>
</Canvas>
## Props
<ArgsTable of={CheckBox} />
## Contribute
<Footer componentRelativePath="CheckBox/__stories__/CheckBox.stories.mdx" />

View File

@@ -0,0 +1,3 @@
import CheckBox from './CheckBox';
export default CheckBox;

View File

@@ -0,0 +1,3 @@
.cine-fps-range-tooltip .tooltip.tooltip-top {
bottom: 85% !important;
}

View File

@@ -0,0 +1,191 @@
import React, { useEffect, useState, useCallback } from 'react';
import PropTypes from 'prop-types';
import debounce from 'lodash.debounce';
import Icon from '../Icon';
import Tooltip from '../Tooltip';
import InputRange from '../InputRange';
import './CinePlayer.css';
export type CinePlayerProps = {
className: string;
isPlaying: boolean;
minFrameRate?: number;
maxFrameRate?: number;
stepFrameRate?: number;
frameRate?: number;
onFrameRateChange: (value: number) => void;
onPlayPauseChange: (value: boolean) => void;
onClose: () => void;
updateDynamicInfo?: () => void;
dynamicInfo?: {
timePointIndex: number;
numTimePoints: number;
label?: string;
};
};
const fpsButtonClassNames =
'cursor-pointer text-primary-active active:text-primary-light hover:bg-customblue-300 w-4 flex items-center justify-center';
const CinePlayer: React.FC<CinePlayerProps> = ({
className,
isPlaying = false,
minFrameRate = 1,
maxFrameRate = 90,
stepFrameRate = 1,
frameRate: defaultFrameRate = 24,
onFrameRateChange = () => {},
onPlayPauseChange = () => {},
onClose = () => {},
dynamicInfo = {},
updateDynamicInfo,
}) => {
const isDynamic = !!dynamicInfo?.numTimePoints;
const [frameRate, setFrameRate] = useState(defaultFrameRate);
const debouncedSetFrameRate = useCallback(debounce(onFrameRateChange, 100), [onFrameRateChange]);
const getPlayPauseIconName = () => (isPlaying ? 'icon-pause' : 'icon-play');
const handleSetFrameRate = (frameRate: number) => {
if (frameRate < minFrameRate || frameRate > maxFrameRate) {
return;
}
setFrameRate(frameRate);
debouncedSetFrameRate(frameRate);
};
useEffect(() => {
setFrameRate(defaultFrameRate);
}, [defaultFrameRate]);
const handleTimePointChange = useCallback(
(newIndex: number) => {
if (isDynamic && dynamicInfo) {
// Here, you would update the component's state or context that controls the current time point index
// For demonstration, assuming a hypothetical function that updates the time point index
updateDynamicInfo({
...dynamicInfo,
timePointIndex: newIndex,
});
}
},
[isDynamic, dynamicInfo]
);
return (
<div className={className}>
{isDynamic && dynamicInfo && (
<InputRange
value={dynamicInfo.timePointIndex}
onChange={handleTimePointChange}
minValue={0}
maxValue={dynamicInfo.numTimePoints - 1}
step={1}
containerClassName="mb-3 w-full"
labelClassName="text-xs text-white"
leftColor="#3a3f99"
rightColor="#3a3f99"
trackHeight="4px"
thumbColor="#348cfd"
thumbColorOuter="#000000"
showLabel={false}
/>
)}
<div
className={
'border-secondary-light/60 bg-primary-dark inline-flex select-none items-center gap-2 rounded border px-2 py-2'
}
>
<Icon
name={getPlayPauseIconName()}
className="active:text-primary-light hover:bg-customblue-300 cursor-pointer text-white hover:rounded"
onClick={() => onPlayPauseChange(!isPlaying)}
data-cy={'cine-player-play-pause'}
/>
{isDynamic && dynamicInfo && (
<div className="min-w-16 max-w-44 flex flex-col text-white">
{/* Add Tailwind classes for monospace font and center alignment */}
<div className="text-[11px]">
<span className="w-2 text-white">{dynamicInfo.timePointIndex}</span>{' '}
<span className="text-aqua-pale">{`/${dynamicInfo.numTimePoints}`}</span>
</div>
<div className="text-aqua-pale text-xs">{dynamicInfo.label}</div>
</div>
)}
<div className="border-secondary-light ml-4 flex h-6 items-stretch gap-1 rounded border">
<div
className={`${fpsButtonClassNames} rounded-l`}
onClick={() => handleSetFrameRate(frameRate - 1)}
data-cy={'cine-player-left-arrow'}
>
<Icon name="arrow-left-small" />
</div>
<Tooltip
position="top"
className="group/fps cine-fps-range-tooltip"
tight={true}
content={
<InputRange
containerClassName="h-9 px-2"
inputClassName="w-40"
value={frameRate}
minValue={minFrameRate}
maxValue={maxFrameRate}
step={stepFrameRate}
onChange={handleSetFrameRate}
showLabel={false}
/>
}
>
<div className="flex items-center justify-center gap-1">
<div className="flex-shrink-0 text-center text-sm leading-[22px] text-white">
<span className="inline-block text-right">{`${frameRate} `}</span>
<span className="text-aqua-pale whitespace-nowrap text-xs">{' FPS'}</span>
</div>
</div>
</Tooltip>
<div
className={`${fpsButtonClassNames} rounded-r`}
onClick={() => handleSetFrameRate(frameRate + 1)}
data-cy={'cine-player-right-arrow'}
>
<Icon name="arrow-right-small" />
</div>
</div>
<Icon
name="icon-close"
className="text-primary-active active:text-primary-light hover:bg-customblue-300 cursor-pointer hover:rounded"
onClick={onClose}
data-cy={'cine-player-close'}
/>
</div>
</div>
);
};
CinePlayer.propTypes = {
/** Minimum value for range slider */
minFrameRate: PropTypes.number,
/** Maximum value for range slider */
maxFrameRate: PropTypes.number,
/** Increment range slider can "step" in either direction */
stepFrameRate: PropTypes.number,
frameRate: PropTypes.number,
/** 'true' if playing, 'false' if paused */
isPlaying: PropTypes.bool.isRequired,
onPlayPauseChange: PropTypes.func,
onFrameRateChange: PropTypes.func,
onClose: PropTypes.func,
isDynamic: PropTypes.bool,
dynamicInfo: PropTypes.shape({
timePointIndex: PropTypes.number,
numTimePoints: PropTypes.number,
label: PropTypes.string,
}),
};
export default CinePlayer;

View File

@@ -0,0 +1,57 @@
import { CinePlayer } from '../../../components';
import { ArgsTable, Story, Canvas, Meta } from '@storybook/addon-docs';
export const argTypes = {
component: CinePlayer,
title: 'Components/CinePlayer',
};
<Meta
title="Components/CinePlayer"
component={CinePlayer}
/>
export const CinePlayerTemplate = args => (
<div className="relative h-96 w-96 bg-black">
<div className="absolute left-1/2 bottom-3 -translate-x-1/2">
<CinePlayer {...args} />
</div>
</div>
);
<Heading
title="CinePlayer"
componentRelativePath="CinePlayer/CinePlayer.tsx"
/>
- [Overview](#overview)
- [Props](#props)
- [Contribute](#contribute)
## Overview
CinePlayer is a component that allows you to use as a boolean value
<Canvas>
<Story
name="Overview"
args={{
isDynamic: true,
dynamicInfo: {
timePointIndex: 5,
numTimePoints: 20,
label: 'TemporalPositionIdentifier',
},
}}
>
{CinePlayerTemplate.bind({})}
</Story>
</Canvas>
## Props
<ArgsTable of={CinePlayer} />
## Contribute
<Footer componentRelativePath="CinePlayer/__stories__/CinePlayer.stories.mdx" />

View File

@@ -0,0 +1,2 @@
import CinePlayer from './CinePlayer';
export default CinePlayer;

View File

@@ -0,0 +1,46 @@
import React from 'react';
import PropTypes from 'prop-types';
import Typography from '../Typography';
import Icon from '../Icon';
const ContextMenu = ({ items, ...props }) => {
if (!items) {
return null;
}
return (
<div
data-cy="context-menu"
className="bg-secondary-dark relative z-50 block w-48 rounded"
onContextMenu={e => e.preventDefault()}
>
{items.map((item, index) => (
<div
key={index}
data-cy="context-menu-item"
onClick={() => item.action(item, props)}
style={{ justifyContent: 'space-between' }}
className="hover:bg-primary-dark border-primary-dark flex cursor-pointer items-center border-b px-4 py-3 transition duration-300 last:border-b-0"
>
<Typography>{item.label}</Typography>
{item.iconRight && (
<Icon
name={item.iconRight}
className="inline"
/>
)}
</div>
))}
</div>
);
};
ContextMenu.propTypes = {
items: PropTypes.arrayOf(
PropTypes.shape({
label: PropTypes.string.isRequired,
action: PropTypes.func.isRequired,
})
),
};
export default ContextMenu;

View File

@@ -0,0 +1,65 @@
import ContextMenu from '../ContextMenu';
import { ArgsTable, Story, Canvas, Meta } from '@storybook/addon-docs';
import { createComponentTemplate } from '../../../storybook/functions/create-component-story';
export const argTypes = {
component: ContextMenu,
title: 'Modals/ContextMenu',
};
<Meta
title="Modals/ContextMenu"
component={ContextMenu}
/>
export const contextMenueTemplate = createComponentTemplate(ContextMenu);
<Heading
title="ContextMenu"
componentRelativePath="ContextMenu/ContextMenu.tsx"
/>
- [Overview](#overview)
- [Props](#props)
- [Contribute](#contribute)
## Overview
Context Menu is a component that is used to display a list of options to the user. This component
can be used for use cases such as opening a list of options on user right click.
<Canvas>
<Story
name="Overview"
args={{
items: [
{
label: 'Delete measurement',
actionType: 'Delete',
action: item => {
window.alert(`${item.label} clicked`);
},
value: {},
},
{
label: 'Add Label',
actionType: 'setLabel',
action: item => {
window.alert(`${item.label} clicked`);
},
value: {},
},
],
}}
>
{contextMenueTemplate.bind({})}
</Story>
</Canvas>
## Props
<ArgsTable of={ContextMenu} />
## Contribute
<Footer componentRelativePath="ContextMenu/__stories__/contextMenu.stories.mdx" />

View File

@@ -0,0 +1 @@
export { default } from './ContextMenu';

View File

@@ -0,0 +1,99 @@
/** CONTAINER STYLES **/
.DateRangePickerInput {
@apply flex border-0 bg-transparent;
}
.DateRangePicker_picker {
@apply -mt-1;
}
/** INPUT DIV STYLES **/
.DateInput {
background: transparent;
@apply flex w-auto flex-1;
}
/** INPUT FIELD COMMON STYLES **/
.DateInput_input {
/* used data:image as background-image because svg import with relative url didn't work */
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><g fill="%236b6b6b" fill-rule="evenodd"><path d="M20 20h-4v-4h4v4zm-6-10h-4v4h4v-4zm6 0h-4v4h4v-4zm-12 6h-4v4h4v-4zm6 0h-4v4h4v-4zm-6-6h-4v4h4v-4zm16-8v22h-24v-22h3v1c0 1.103.897 2 2 2s2-.897 2-2v-1h10v1c0 1.103.897 2 2 2s2-.897 2-2v-1h3zm-2 6h-20v14h20v-14zm-2-7c0-.552-.447-1-1-1s-1 .448-1 1v2c0 .552.447 1 1 1s1-.448 1-1v-2zm-14 2c0 .552-.447 1-1 1s-1-.448-1-1v-2c0-.552.447-1 1-1s1 .448 1 1v2z"/></g></svg>');
background-size: 14px;
background-position: 10px center;
@apply bg-no-repeat;
}
.DateInput_input {
@apply border-primary-main mt-2 w-full cursor-pointer appearance-none rounded border-t border-l border-r border-b border-solid bg-black py-2 px-3 pl-8 text-sm font-light leading-tight text-white shadow transition duration-300;
}
.DateInput_input:hover {
@apply border-gray-500;
}
.DateInput_input:focus {
@apply border-gray-500 outline-none;
}
/** FIRST INPUT STYLES **/
.DateInput:first-child .DateInput_input {
@apply rounded-r-none;
}
.DateInput:first-child .DateInput_input:hover,
.DateInput:first-child .DateInput_input:focus {
@apply relative z-10;
}
/** SECOND INPUT STYLES **/
.DateInput:last-child .DateInput_input {
@apply rounded-l-none;
margin-left: -1px;
}
/** ARROW STYLES **/
.DateRangePickerInput_arrow {
@apply hidden;
}
/* SELECT MONTH PICKER */
.DateRangePicker_select {
@apply text-secondary-active border-common-dark cursor-pointer appearance-none rounded border bg-white py-1 pl-2 pr-5 text-base; /* NEEDED FOR ARROW DOWN */
background-image: linear-gradient(45deg, transparent 50%, gray 50%),
linear-gradient(135deg, gray 50%, transparent 50%);
background-position:
calc(100% - 11px) 11px,
calc(100% - 6px) calc(11px);
background-size:
5px 5px,
5px 5px;
background-repeat: no-repeat;
}
/* CALENDAR DAYS */
.CalendarDay {
@apply rounded-full border-0;
}
.CalendarDay:hover,
.CalendarDay__selected,
.CalendarDay__selected:active,
.CalendarDay__selected:hover {
@apply bg-primary-main border-primary-main border-0 text-white;
}
.CalendarDay__blocked_out_of_range:hover {
@apply text-common-dark cursor-not-allowed border-0 bg-white;
}
.CalendarDay__selected_span {
@apply bg-primary-light border-0;
}
/* MONTH NAVIGATION BUTTONS */
.DayPickerNavigation_button__horizontalDefault,
.DayPickerNavigation_button__horizontalDefault:hover {
@apply border-common-dark text-common-dark;
top: 24px;
padding: 3px 9px;
}
.DayPickerNavigation_svg__horizontal {
@apply fill-current;
}

View File

@@ -0,0 +1,178 @@
import React, { useState, useCallback } from 'react';
import PropTypes from 'prop-types';
import moment from 'moment';
import { useTranslation } from 'react-i18next';
/** REACT DATES */
import { DateRangePicker, isInclusivelyBeforeDay } from 'react-dates';
import 'react-dates/initialize';
import 'react-dates/lib/css/_datepicker.css';
import './DateRange.css';
const renderYearsOptions = () => {
const currentYear = moment().year();
const options = [];
for (let i = 0; i < 20; i++) {
const year = currentYear - i;
options.push(
<option
key={year}
value={year}
>
{year}
</option>
);
}
return options;
};
const DateRange = props => {
const { id = '', onChange, startDate = null, endDate = null } = props;
const [focusedInput, setFocusedInput] = useState(null);
const renderYearsOptionsCallback = useCallback(renderYearsOptions, []);
const { t } = useTranslation('DatePicker');
const today = moment();
const lastWeek = moment().subtract(7, 'day');
const lastMonth = moment().subtract(1, 'month');
const studyDatePresets = [
{
text: t('Today'),
start: today,
end: today,
},
{
text: t('Last 7 days'),
start: lastWeek,
end: today,
},
{
text: t('Last 30 days'),
start: lastMonth,
end: today,
},
];
const renderDatePresets = () => {
return (
<div className="PresetDateRangePicker_panel flex justify-between">
{studyDatePresets.map(({ text, start, end }) => {
return (
<button
key={text}
type="button"
className={`bg-primary-main m-0 rounded border-0 py-2 px-3 text-base text-white transition duration-300 hover:opacity-80`}
onClick={() =>
onChange({
startDate: start ? start.format('YYYYMMDD') : undefined,
endDate: end ? end.format('YYYYMMDD') : undefined,
preset: true,
})
}
>
{text}
</button>
);
})}
</div>
);
};
const renderMonthElement = ({ month, onMonthSelect, onYearSelect }) => {
renderMonthElement.propTypes = {
month: PropTypes.object,
onMonthSelect: PropTypes.func,
onYearSelect: PropTypes.func,
};
const handleMonthChange = event => {
onMonthSelect(month, event.target.value);
};
const handleYearChange = event => {
onYearSelect(month, event.target.value);
};
const handleOnBlur = () => {};
return (
<div className="flex justify-center">
<div className="my-0 mx-1">
<select
className="DateRangePicker_select"
value={month.month()}
onChange={handleMonthChange}
onBlur={handleOnBlur}
>
{moment.months().map((label, value) => (
<option
key={value}
value={value}
>
{label}
</option>
))}
</select>
</div>
<div className="my-0 mx-1">
<select
className="DateRangePicker_select"
value={month.year()}
onChange={handleYearChange}
onBlur={handleOnBlur}
>
{renderYearsOptionsCallback()}
</select>
</div>
</div>
);
};
// Moment
const parsedStartDate = startDate ? moment(startDate, 'YYYYMMDD') : null;
const parsedEndDate = endDate ? moment(endDate, 'YYYYMMDD') : null;
return (
<DateRangePicker
/** REQUIRED */
startDate={parsedStartDate}
startDateId={`date-range-${id}-start-date`}
endDate={parsedEndDate}
endDateId={`date-range-${id}-end-date`}
onDatesChange={({ startDate: newStartDate, endDate: newEndDate }) => {
onChange({
startDate: newStartDate ? newStartDate.format('YYYYMMDD') : undefined,
endDate: newEndDate ? newEndDate.format('YYYYMMDD') : undefined,
});
}}
focusedInput={focusedInput}
onFocusChange={updatedVal => setFocusedInput(updatedVal)}
/** OPTIONAL */
renderCalendarInfo={renderDatePresets}
renderMonthElement={renderMonthElement}
startDatePlaceholderText={t('Start Date')}
endDatePlaceholderText={t('End Date')}
phrases={{
closeDatePicker: t('Close'),
clearDates: t('Clear dates'),
}}
isOutsideRange={day => !isInclusivelyBeforeDay(day, moment())}
hideKeyboardShortcutsPanel={true}
numberOfMonths={1}
showClearDates={false}
anchorDirection="left"
/>
);
};
DateRange.propTypes = {
id: PropTypes.string,
/** YYYYMMDD (19921022) */
startDate: PropTypes.string,
/** YYYYMMDD (19921022) */
endDate: PropTypes.string,
/** Callback that received { startDate: string(YYYYMMDD), endDate: string(YYYYMMDD)} */
onChange: PropTypes.func.isRequired,
};
export default DateRange;

View File

@@ -0,0 +1,52 @@
import DateRange from '../DateRange';
import { ArgsTable, Story, Canvas, Meta } from '@storybook/addon-docs';
export const argTypes = {
component: DateRange,
title: 'Components/DateRange',
};
<Meta
title="Components/DateRange"
component={DateRange}
/>
export const DateRangeTemplate = args => (
<div className="h-96">
<DateRange {...args} />
</div>
);
<Heading
title="DateRange"
componentRelativePath="DateRange/DateRange.tsx"
/>
- [Overview](#overview)
- [Props](#props)
- [Contribute](#contribute)
## Overview
DateRange is a component that allows you to select a range of dates.
<Canvas>
<Story
name="Overview"
args={{
id: 'date-range-1',
startDate: '1990-05-01',
endDate: '2022-01-01',
}}
>
{DateRangeTemplate.bind({})}
</Story>
</Canvas>
## Props
<ArgsTable of={DateRange} />
## Contribute
<Footer componentRelativePath="DateRange/__stories__/dateRange.stories.mdx" />

View File

@@ -0,0 +1,3 @@
import DateRange from './DateRange';
export default DateRange;

View File

@@ -0,0 +1,27 @@
import React from 'react';
import classNames from 'classnames';
import PropTypes from 'prop-types';
import Typography from '../Typography';
const Body = ({ text, className }) => {
const theme = 'bg-primary-dark';
return (
<div className={classNames('relative flex-auto', theme, className)}>
<Typography
variant="inherit"
color="initial"
className="text-[14px] !leading-[1.2]"
>
{text}
</Typography>
</div>
);
};
Body.propTypes = {
text: PropTypes.string,
className: PropTypes.string,
};
export default Body;

View File

@@ -0,0 +1,84 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import Footer from './Footer';
import Body from './Body';
import Header from './Header';
import { useEffect } from 'react';
const Dialog = ({
title,
text,
onClose,
noCloseButton,
actions,
onShow,
onSubmit,
header: HeaderComponent = Header,
body: BodyComponent = Body,
footer: FooterComponent = Footer,
value: defaultValue = {},
}) => {
const [value, setValue] = useState(defaultValue);
const theme = 'bg-primary-dark';
const flex = 'flex flex-col';
const border = 'border-0 rounded';
const outline = 'outline-none focus:outline-none';
const position = 'relative';
const width = 'w-full';
const padding = 'px-[20px] pb-[20px] pt-[13px]';
useEffect(() => {
if (onShow) {
onShow();
}
}, [onShow]);
return (
<div className={classNames(theme, flex, border, outline, position, width, padding)}>
<HeaderComponent
title={title}
noCloseButton={noCloseButton}
onClose={onClose}
value={value}
setValue={setValue}
/>
<BodyComponent
text={text}
value={value}
setValue={setValue}
/>
<FooterComponent
actions={actions}
onSubmit={onSubmit}
value={value}
setValue={setValue}
/>
</div>
);
};
Dialog.propTypes = {
title: PropTypes.string,
text: PropTypes.string,
onClose: PropTypes.func,
noCloseButton: PropTypes.bool,
header: PropTypes.oneOfType([PropTypes.node, PropTypes.func]),
body: PropTypes.oneOfType([PropTypes.node, PropTypes.func]),
footer: PropTypes.oneOfType([PropTypes.node, PropTypes.func]),
onSubmit: PropTypes.func.isRequired,
value: PropTypes.object,
actions: PropTypes.arrayOf(
PropTypes.shape({
id: PropTypes.string.isRequired,
text: PropTypes.string.isRequired,
value: PropTypes.any,
type: PropTypes.oneOf(['primary', 'secondary', 'cancel']).isRequired,
})
).isRequired,
onShow: PropTypes.func,
};
export default Dialog;

View File

@@ -0,0 +1,50 @@
import React from 'react';
import classNames from 'classnames';
import PropTypes from 'prop-types';
import Button, { ButtonEnums } from '../Button';
const Footer = ({ actions = [], className, onSubmit = () => {}, value }) => {
const flex = 'flex items-center justify-end';
const padding = 'pt-[20px]';
return (
<div className={classNames(flex, padding, className)}>
{actions?.map((action, index) => {
const isFirst = index === 0;
const onClickHandler = event => onSubmit({ action, value, event });
return (
<Button
key={index}
name={action.text}
className={classNames({ 'ml-2': !isFirst }, action.classes)}
type={action.type}
onClick={onClickHandler}
>
{action.text}
</Button>
);
})}
</div>
);
};
const noop = () => {};
Footer.propTypes = {
className: PropTypes.string,
onSubmit: PropTypes.func.isRequired,
actions: PropTypes.arrayOf(
PropTypes.shape({
id: PropTypes.string.isRequired,
text: PropTypes.string.isRequired,
value: PropTypes.any,
type: PropTypes.oneOf([ButtonEnums.type.primary, ButtonEnums.type.secondary]).isRequired,
classes: PropTypes.arrayOf(PropTypes.string),
})
).isRequired,
};
export default Footer;

View File

@@ -0,0 +1,49 @@
import React from 'react';
import classNames from 'classnames';
import PropTypes from 'prop-types';
import Typography from '../Typography';
import Icon from '../Icon';
const CloseButton = ({ onClick }) => {
return (
<Icon
data-cy="close-button"
onClick={onClick}
name="close"
className="text-primary-active cursor-pointer"
/>
);
};
CloseButton.propTypes = {
onClick: PropTypes.func,
};
const Header = ({ title, noCloseButton = false, onClose }) => {
const theme = 'bg-primary-dark';
const flex = 'flex items-center justify-between';
const padding = 'pb-[20px]';
return (
<div className={classNames(theme, flex, padding)}>
<Typography
variant="h6"
color="primaryLight"
className="!leading-[1.2]"
>
{title}
</Typography>
{!noCloseButton && <CloseButton onClick={onClose} />}
</div>
);
};
Header.propTypes = {
className: PropTypes.string,
title: PropTypes.string,
noCloseButton: PropTypes.bool,
onClose: PropTypes.func,
};
export default Header;

View File

@@ -0,0 +1,73 @@
import Dialog from '../Dialog';
import { ArgsTable, Story, Canvas, Meta } from '@storybook/addon-docs';
import { createComponentTemplate } from '../../../storybook/functions/create-component-story';
export const argTypes = {
component: Dialog,
title: 'Modals/Dialog',
};
<Meta
title="Modals/Dialog"
component={Dialog}
/>
export const DialogTemplate = createComponentTemplate(Dialog);
<Heading
title="Dialog"
componentRelativePath="Dialog/Dialog.tsx"
/>
- [Overview](#overview)
- [Props](#props)
- [Usage](#usage)
- [Contribute](#contribute)
## Overview
Dialog is a modal dialog component which enabled to show a modal dialog.
<Canvas>
<Story name="Overview">{DialogTemplate.bind({})}</Story>
</Canvas>
## Props
<ArgsTable of={Dialog} />
## Usage
It can be used to show a modal dialog to submit a form or show a message.
<Canvas>
<Story
name="Submit"
args={{
title: 'Dialog Title',
text: 'Dialog Text',
onClose: () => {
window.alert('Dialog closed');
},
noCloseButton: false,
actions: [
{
id: 'cancel',
text: 'Cancel',
type: 'cancel',
},
{
id: 'submit',
text: 'Submit',
type: 'primary',
},
],
}}
>
{DialogTemplate.bind({})}
</Story>
</Canvas>
## Contribute
<Footer componentRelativePath="Dialog/__stories__/dialog.stories.mdx" />

View File

@@ -0,0 +1,2 @@
import Dialog from './Dialog';
export default Dialog;

View File

@@ -0,0 +1,72 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import PortalTooltip from '../Tooltip/PortalTooltip';
import Icon from '../Icon';
import { useTranslation } from 'react-i18next';
/**
* Displays a tooltip with a list of messages of a displaySet
* @param param0
* @returns
*/
const DisplaySetMessageListTooltip = ({ messages, id }): React.ReactNode => {
const { t } = useTranslation('Messages');
const [isOpen, setIsOpen] = useState(false);
if (messages?.size()) {
return (
<>
<Icon
id={id}
onMouseOver={() => setIsOpen(true)}
onFocus={() => setIsOpen(true)}
onMouseOut={() => setIsOpen(false)}
onBlur={() => setIsOpen(false)}
name="status-alert-warning"
/>
<PortalTooltip
active={isOpen}
position="right"
arrow="center"
parent={`#${id}`}
>
<div className="bg-primary-dark border-secondary-light max-w-64 rounded border text-left text-base text-white">
<div
className="break-normal text-base font-bold text-blue-300"
style={{
marginLeft: '12px',
marginTop: '12px',
}}
>
{t('Display Set Messages')}
</div>
<ol
style={{
marginLeft: '12px',
marginRight: '12px',
}}
>
{messages.messages.map((message, index) => (
<li
style={{
marginTop: '6px',
marginBottom: '6px',
}}
key={index}
>
{index + 1}. {t(message.id)}
</li>
))}
</ol>
</div>
</PortalTooltip>
</>
);
}
return <></>;
};
DisplaySetMessageListTooltip.propTypes = {
messages: PropTypes.object,
};
export default DisplaySetMessageListTooltip;

View File

@@ -0,0 +1,2 @@
import DisplaySetMessageListTooltip from './DisplaySetMessageListTooltip';
export default DisplaySetMessageListTooltip;

View File

@@ -0,0 +1,221 @@
import React, { useEffect, useCallback, useState, useRef } from 'react';
import PropTypes from 'prop-types';
import classnames from 'classnames';
import ReactDOM from 'react-dom';
import Icon from '../Icon';
import Typography from '../Typography';
const borderStyle = 'border-b last:border-b-0 border-secondary-main';
const Dropdown = ({
id,
children,
showDropdownIcon = true,
list,
itemsClassName,
titleClassName,
showBorders = true,
alignment,
// By default the max characters per line is the longest title
// if you wish to override this, you can pass in a number
maxCharactersPerLine = 20,
}) => {
const [open, setOpen] = useState(false);
const elementRef = useRef(null);
const dropdownRef = useRef(null);
const [coords, setCoords] = useState({ x: 0, y: 0 });
// choose the max characters per line based on the longest title
const longestTitle = list.reduce((acc, item) => {
if (item.title.length > acc) {
return item.title.length;
}
return acc;
}, 0);
maxCharactersPerLine = maxCharactersPerLine ?? longestTitle;
const DropdownItem = useCallback(
({ id, title, icon, onClick }) => {
// Split the title into lines of length maxCharactersPerLine
const lines = [];
for (let i = 0; i < title.length; i += maxCharactersPerLine) {
lines.push(title.substring(i, i + maxCharactersPerLine));
}
return (
<div
key={title}
className={classnames(
'hover:bg-secondary-main flex cursor-pointer items-center px-4 py-2 transition duration-300 ',
titleClassName,
showBorders && borderStyle
)}
onClick={() => {
setOpen(false);
onClick();
}}
data-cy={id}
>
{!!icon && (
<Icon
name={icon}
className="mr-2 w-4 text-white"
/>
)}
<div
style={{
whiteSpace: 'nowrap',
}}
>
{title.length > maxCharactersPerLine && (
<div>
{lines.map((line, index) => (
<Typography
key={index}
className={itemsClassName}
>
{line}
</Typography>
))}
</div>
)}
{title.length <= maxCharactersPerLine && (
<Typography className={itemsClassName}>{title}</Typography>
)}
</div>
</div>
);
},
[maxCharactersPerLine, itemsClassName, titleClassName, showBorders]
);
const renderTitleElement = () => {
return (
<div className="flex items-center">
{children}
{showDropdownIcon && (
<Icon
name="chevron-down"
className="ml-1"
/>
)}
</div>
);
};
const toggleList = () => {
setOpen(s => !s);
};
const handleClick = e => {
if (elementRef.current && !elementRef.current.contains(e.target)) {
setOpen(false);
}
};
useEffect(() => {
if (elementRef.current && dropdownRef.current) {
const triggerRect = elementRef.current.getBoundingClientRect();
const dropdownRect = dropdownRef.current.getBoundingClientRect();
let x, y;
switch (alignment) {
case 'right':
x = triggerRect.right + window.scrollX - dropdownRect.width;
y = triggerRect.bottom + window.scrollY;
break;
case 'left':
x = triggerRect.left + window.scrollX;
y = triggerRect.bottom + window.scrollY;
break;
default:
x = triggerRect.left + window.scrollX;
y = triggerRect.bottom + window.scrollY;
break;
}
setCoords({ x, y });
}
}, [open, alignment, elementRef.current, dropdownRef.current]);
const renderList = () => {
const portalElement = document.getElementById('react-portal');
const listElement = (
<div
className={classnames(
'top-100 border-secondary-main w-max-content absolute mt-2 transform rounded border bg-black shadow transition duration-300',
{
'right-0 origin-top-right': alignment === 'right',
'left-0 origin-top-left': alignment === 'left',
}
)}
ref={dropdownRef}
style={{
position: 'absolute',
top: `${coords.y}px`,
left: open ? `${coords.x}px` : -999999,
zIndex: 9999,
}}
data-cy={`${id}-dropdown`}
>
{list.map((item, idx) => (
<DropdownItem
id={item.id}
title={item.title}
icon={item.icon}
onClick={item.onClick}
key={idx}
/>
))}
</div>
);
return ReactDOM.createPortal(listElement, portalElement);
};
useEffect(() => {
document.addEventListener('click', handleClick);
if (!open) {
document.removeEventListener('click', handleClick);
}
}, [open]);
return (
<div
data-cy="dropdown"
ref={elementRef}
className="relative"
>
<div
className="flex cursor-pointer items-center"
onClick={toggleList}
>
{renderTitleElement()}
</div>
{renderList()}
</div>
);
};
Dropdown.propTypes = {
id: PropTypes.string,
children: PropTypes.node.isRequired,
showDropdownIcon: PropTypes.bool,
titleClassName: PropTypes.string,
/** Items to render in the select's drop down */
list: PropTypes.arrayOf(
PropTypes.shape({
title: PropTypes.string.isRequired,
icon: PropTypes.string,
onClick: PropTypes.func.isRequired,
})
).isRequired,
alignment: PropTypes.oneOf(['left', 'right']),
maxCharactersPerLine: PropTypes.number,
showBorders: PropTypes.bool,
};
export default Dropdown;

View File

@@ -0,0 +1,84 @@
import Dropdown from '../Dropdown';
import { ArgsTable, Story, Canvas, Meta } from '@storybook/addon-docs';
import { createComponentTemplate } from '../../../storybook/functions/create-component-story';
export const argTypes = {
component: Dropdown,
title: 'Components/Dropdown',
};
<Meta
title="Components/Dropdown"
component={Dropdown}
/>
export const DropdownTemplate = args => (
// Todo: this should not set a background
<div className="flex h-32">
<Dropdown {...args} />
</div>
);
<Heading
title="Dropdown"
componentRelativePath="Dropdown/Dropdown.tsx"
/>
- [Overview](#overview)
- [Props](#props)
- [Usage](#usage)
- [Contribute](#contribute)
## Overview
Dropdown is a modal Dropdown component which enabled to show a modal Dropdown.
<Canvas>
<Story
name="Overview"
args={{ id: 'dropdown-d', list: [] }}
>
{DropdownTemplate.bind({})}
</Story>
</Canvas>
## Props
<ArgsTable of={Dropdown} />
## Usage
You can list the items in the dropdown.
<Canvas>
<Story
name="List"
args={{
id: 'dropdown-1',
children: <div className="text-black">Drop Down</div>,
showDropdownIcon: true,
list: [
{
title: 'Item 1',
icon: 'clipboard',
onClick: () => {
alert('Item 1 clicked');
},
},
{
title: 'Item 2',
icon: 'tracked',
onClick: () => {
alert('Item 2 clicked');
},
},
],
}}
>
{DropdownTemplate.bind({})}
</Story>
</Canvas>
## Contribute
<Footer componentRelativePath="Dropdown/__stories__/dropdown.stories.mdx" />

View File

@@ -0,0 +1 @@
export { default } from './Dropdown';

View File

@@ -0,0 +1,32 @@
import React from 'react';
import PropTypes from 'prop-types';
import classnames from 'classnames';
import { useTranslation } from 'react-i18next';
import Icon from '../Icon';
import Typography from '../Typography';
// TODO: Add loading spinner to OHIF + use it here.
const EmptyStudies = ({ className = '' }) => {
const { t } = useTranslation('StudyList');
return (
<div className={classnames('inline-flex flex-col items-center', className)}>
<Icon
name="magnifier"
className="mb-4"
/>
<Typography
className="text-primary-light"
variant="h5"
>
{t('No studies available')}
</Typography>
</div>
);
};
EmptyStudies.propTypes = {
className: PropTypes.string,
};
export default EmptyStudies;

View File

@@ -0,0 +1,2 @@
import EmptyStudies from './EmptyStudies';
export default EmptyStudies;

View File

@@ -0,0 +1,129 @@
import React, { useState } from 'react';
import { ErrorBoundary as ReactErrorBoundary } from 'react-error-boundary';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import i18n from 'i18next';
import Modal from '../Modal';
import Icon from '../Icon';
import IconButton from '../IconButton';
const isProduction = process.env.NODE_ENV === 'production';
const DefaultFallback = ({ error, context, resetErrorBoundary = () => {}, fallbackRoute }) => {
const { t } = useTranslation('ErrorBoundary');
const [showDetails, setShowDetails] = useState(false);
const title = `${t('Something went wrong')}${!isProduction && ` ${t('in')} ${context}`}.`;
const subtitle = t('Sorry, something went wrong there. Try again.');
return (
<div
className="ErrorFallback bg-primary-dark h-full w-full"
role="alert"
>
<p className="text-primary-light text-xl">{title}</p>
<p className="text-primary-light text-base">{subtitle}</p>
{!isProduction && (
<div className="bg-secondary-dark mt-5 space-y-2 rounded-md p-5 font-mono">
<p className="text-primary-light">
{t('Context')}: {context}
</p>
<p className="text-primary-light">
{t('Error Message')}: {error.message}
</p>
<IconButton
variant="contained"
color="inherit"
size="initial"
className="text-primary-active"
onClick={() => setShowDetails(!showDetails)}
>
<React.Fragment>
<div>{t('Stack Trace')}</div>
<Icon
width="15px"
height="15px"
name="chevron-down"
/>
</React.Fragment>
</IconButton>
{showDetails && (
<pre className="text-primary-light whitespace-pre-wrap px-4">Stack: {error.stack}</pre>
)}
</div>
)}
</div>
);
};
DefaultFallback.propTypes = {
error: PropTypes.object.isRequired,
resetErrorBoundary: PropTypes.func,
componentStack: PropTypes.string,
};
const ErrorBoundary = ({
context = 'OHIF',
onReset = () => {},
onError = () => {},
fallbackComponent: FallbackComponent = DefaultFallback,
children,
fallbackRoute = null,
isPage,
}) => {
const [isOpen, setIsOpen] = useState(true);
const onErrorHandler = (error, componentStack) => {
console.error(`${context} Error Boundary`, error, componentStack, context);
onError(error, componentStack, context);
};
const onResetHandler = (...args) => onReset(...args);
const withModal = Component => props => (
<Modal
closeButton
shouldCloseOnEsc
isOpen={isOpen}
title={i18n.t('ErrorBoundary:Something went wrong')}
onClose={() => {
setIsOpen(false);
if (fallbackRoute && typeof window !== 'undefined') {
window.location = fallbackRoute;
}
}}
>
<Component {...props} />
</Modal>
);
const Fallback = isPage ? FallbackComponent : withModal(FallbackComponent);
return (
<ReactErrorBoundary
fallbackRender={props => (
<Fallback
{...props}
context={context}
fallbackRoute={fallbackRoute}
/>
)}
onReset={onResetHandler}
onError={onErrorHandler}
>
{children}
</ReactErrorBoundary>
);
};
ErrorBoundary.propTypes = {
context: PropTypes.string,
onReset: PropTypes.func,
onError: PropTypes.func,
fallbackComponent: PropTypes.oneOfType([PropTypes.node, PropTypes.func]),
children: PropTypes.node.isRequired,
fallbackRoute: PropTypes.string,
};
export default ErrorBoundary;

View File

@@ -0,0 +1,2 @@
import ErrorBoundary from './ErrorBoundary';
export default ErrorBoundary;

View File

@@ -0,0 +1,16 @@
.ExpandableToolbarButton:hover .ExpandableToolbarButton__arrow:after {
content: '';
position: absolute;
bottom: -10px;
border-width: 10px 10px 0;
border-style: solid;
border-color: #5acce6 transparent;
}
.ExpandableToolbarButton .ExpandableToolbarButton__content {
display: none;
}
.ExpandableToolbarButton:hover .ExpandableToolbarButton__content {
display: block;
}

View File

@@ -0,0 +1,86 @@
import React from 'react';
import PropTypes from 'prop-types';
import classnames from 'classnames';
import IconButton from '../IconButton';
import Icon from '../Icon';
import './ExpandableToolbarButton.css';
const ExpandableToolbarButton = ({
type = 'primary',
id = '',
isActive = false,
onClick = () => {},
icon = 'clipboard',
className,
content: Content = null,
contentProps = {},
}) => {
const classes = {
type: {
primary: isActive
? 'text-black'
: 'text-common-bright hover:bg-primary-dark hover:text-primary-light',
secondary: isActive
? 'text-black'
: 'text-white hover:bg-secondary-dark focus:bg-secondary-dark',
},
};
const onChildClickHandler = (...args) => {
onClick(...args);
if (contentProps.onClick) {
contentProps.onClick(...args);
}
};
const onClickHandler = (...args) => {
onClick(...args);
};
return (
<div
key={id}
className="ExpandableToolbarButton"
>
<IconButton
variant={isActive ? 'contained' : 'text'}
className={classnames(
'mx-1',
classes.type[type],
isActive && 'ExpandableToolbarButton__arrow'
)}
onClick={onClickHandler}
key={id}
>
<Icon name={icon} />
</IconButton>
<div className="absolute z-10 pt-4">
<div className={classnames('ExpandableToolbarButton__content w-48', className)}>
<Content
{...contentProps}
onClick={onChildClickHandler}
/>
</div>
</div>
</div>
);
};
const noop = () => {};
ExpandableToolbarButton.propTypes = {
/* Influences background/hover styling */
type: PropTypes.oneOf(['primary', 'secondary']),
id: PropTypes.string.isRequired,
isActive: PropTypes.bool,
onClick: PropTypes.func.isRequired,
icon: PropTypes.string.isRequired,
/** Expandable toolbar button content can be replaced for a customized content by passing a node to this value. */
content: PropTypes.oneOfType([PropTypes.node, PropTypes.func]),
contentProps: PropTypes.object,
};
export default ExpandableToolbarButton;

View File

@@ -0,0 +1,2 @@
import ExpandableToolbarButton from './ExpandableToolbarButton';
export default ExpandableToolbarButton;

View File

@@ -0,0 +1,116 @@
import React, { ReactNode } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import classNames from 'classnames';
import NavBar from '../NavBar';
import Svg from '../Svg';
import Icon from '../Icon';
import IconButton from '../IconButton';
import Dropdown from '../Dropdown';
import HeaderPatientInfo from '../HeaderPatientInfo';
import { PatientInfoVisibility } from '../../types/PatientInfoVisibility';
function Header({
children,
menuOptions,
isReturnEnabled = true,
onClickReturnButton,
isSticky = false,
WhiteLabeling,
showPatientInfo = PatientInfoVisibility.VISIBLE_COLLAPSED,
servicesManager,
Secondary,
appConfig,
...props
}: withAppTypes): ReactNode {
const { t } = useTranslation('Header');
// TODO: this should be passed in as a prop instead and the react-router-dom
// dependency should be dropped
const onClickReturn = () => {
if (isReturnEnabled && onClickReturnButton) {
onClickReturnButton();
}
};
return (
<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 && (
<Icon
name="chevron-left"
className="text-primary-active w-8"
/>
)}
<div className="ml-1">
{WhiteLabeling?.createLogoComponentFn?.(React, props) || <Svg name="logo-ohif" />}
</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">
{showPatientInfo !== PatientInfoVisibility.DISABLED && (
<HeaderPatientInfo
servicesManager={servicesManager}
appConfig={appConfig}
/>
)}
<div className="border-primary-dark mx-1.5 h-[25px] border-r"></div>
<div className="flex-shrink-0">
<Dropdown
id="options"
showDropdownIcon={false}
list={menuOptions}
alignment="right"
>
<IconButton
id={'options-settings-icon'}
variant="text"
color="inherit"
size="initial"
className="text-primary-active hover:bg-primary-dark h-full w-full"
>
<Icon name="icon-settings" />
</IconButton>
</Dropdown>
</div>
</div>
</div>
</NavBar>
);
}
Header.propTypes = {
menuOptions: PropTypes.arrayOf(
PropTypes.shape({
title: PropTypes.string.isRequired,
icon: PropTypes.string,
onClick: PropTypes.func.isRequired,
})
),
children: PropTypes.oneOfType([PropTypes.node, PropTypes.func]),
isReturnEnabled: PropTypes.bool,
isSticky: PropTypes.bool,
onClickReturnButton: PropTypes.func,
WhiteLabeling: PropTypes.object,
showPatientInfo: PropTypes.string,
servicesManager: PropTypes.object,
};
export default Header;

View File

@@ -0,0 +1,57 @@
import Header from '../Header';
import { ArgsTable, Story, Canvas, Meta } from '@storybook/addon-docs';
import { createComponentTemplate } from '../../../storybook/functions/create-component-story';
export const argTypes = {
component: Header,
title: 'Components/Header',
};
<Meta
title="Components/Header"
component={Header}
/>
export const HeaderTemplate = createComponentTemplate(Header);
<Heading
title="Header"
componentRelativePath="Header/Header.tsx"
/>
- [Overview](#overview)
- [Props](#props)
- [Usage](#usage)
- [Contribute](#contribute)
## Overview
Header is a component that renders the header of the application.
<Canvas>
<Story
name="Overview"
args={{
menuOptions: [
{
title: 'About',
icon: 'info',
onClick: () => window.alert('About clicked'),
},
],
isReturnEnabled: true,
isSticky: false,
onClickReturnButton: () => window.alert('Return clicked'),
}}
>
{HeaderTemplate.bind({})}
</Story>
</Canvas>
## Props
<ArgsTable of={Header} />
## Contribute
<Footer componentRelativePath="Header/__stories__/header.stories.mdx" />

View File

@@ -0,0 +1,2 @@
import Header from './Header';
export default Header;

View File

@@ -0,0 +1,135 @@
import React, { useState, useEffect } from 'react';
import PropTypes from 'prop-types';
import { Icon } from '@ohif/ui';
import { utils } from '@ohif/core';
import { PatientInfoVisibility } from '../../types';
const { formatDate, formatPN } = utils;
const formatWithEllipsis = (str, maxLength) => {
if (str?.length > maxLength) {
return str.substring(0, maxLength) + '...';
}
return str;
};
function usePatientInfo(servicesManager: AppTypes.ServicesManager) {
const { displaySetService } = servicesManager.services;
const [patientInfo, setPatientInfo] = useState({
PatientName: '',
PatientID: '',
PatientSex: '',
PatientDOB: '',
});
const [isMixedPatients, setIsMixedPatients] = useState(false);
const displaySets = displaySetService.getActiveDisplaySets();
const checkMixedPatients = PatientID => {
const displaySets = displaySetService.getActiveDisplaySets();
let isMixedPatients = false;
displaySets.forEach(displaySet => {
const instance = displaySet?.instances?.[0] || displaySet?.instance;
if (!instance) {
return;
}
if (instance.PatientID !== PatientID) {
isMixedPatients = true;
}
});
setIsMixedPatients(isMixedPatients);
};
const updatePatientInfo = () => {
const displaySet = displaySets[0];
const instance = displaySet?.instances?.[0] || displaySet?.instance;
if (!instance) {
return;
}
setPatientInfo({
PatientID: instance.PatientID || null,
PatientName: instance.PatientName ? formatPN(instance.PatientName.Alphabetic) : null,
PatientSex: instance.PatientSex || null,
PatientDOB: formatDate(instance.PatientBirthDate) || null,
});
checkMixedPatients(instance.PatientID || null);
};
useEffect(() => {
const subscription = displaySetService.subscribe(
displaySetService.EVENTS.DISPLAY_SETS_ADDED,
() => updatePatientInfo()
);
return () => subscription.unsubscribe();
}, []);
useEffect(() => {
updatePatientInfo();
}, [displaySets]);
return { patientInfo, isMixedPatients };
}
function HeaderPatientInfo({ servicesManager, appConfig }: withAppTypes) {
const initialExpandedState =
appConfig.showPatientInfo === PatientInfoVisibility.VISIBLE ||
appConfig.showPatientInfo === PatientInfoVisibility.VISIBLE_READONLY;
const [expanded, setExpanded] = useState(initialExpandedState);
const { patientInfo, isMixedPatients } = usePatientInfo(servicesManager);
useEffect(() => {
if (isMixedPatients && expanded) {
setExpanded(false);
}
}, [isMixedPatients, expanded]);
const handleOnClick = () => {
if (!isMixedPatients && appConfig.showPatientInfo !== PatientInfoVisibility.VISIBLE_READONLY) {
setExpanded(!expanded);
}
};
const formattedPatientName = formatWithEllipsis(patientInfo.PatientName, 27);
const formattedPatientID = formatWithEllipsis(patientInfo.PatientID, 15);
return (
<div
className="hover:bg-primary-dark flex cursor-pointer items-center justify-center gap-1 rounded-lg"
onClick={handleOnClick}
>
<Icon
name={isMixedPatients ? 'icon-multiple-patients' : 'icon-patient'}
className="text-primary-active"
/>
<div className="flex flex-col justify-center">
{expanded ? (
<>
<div className="self-start text-[13px] font-bold text-white">
{formattedPatientName}
</div>
<div className="text-aqua-pale flex gap-2 text-[11px]">
<div>{formattedPatientID}</div>
<div>{patientInfo.PatientSex}</div>
<div>{patientInfo.PatientDOB}</div>
</div>
</>
) : (
<div className="text-primary-active self-center text-[13px]">
{' '}
{isMixedPatients ? 'Multiple Patients' : 'Patient'}
</div>
)}
</div>
<Icon
name="icon-chevron-patient"
className={`text-primary-active ${expanded ? 'rotate-180' : ''}`}
/>
</div>
);
}
HeaderPatientInfo.propTypes = {
servicesManager: PropTypes.object.isRequired,
};
export default HeaderPatientInfo;

View File

@@ -0,0 +1,3 @@
import HeaderPatientInfo from './HeaderPatientInfo';
export default HeaderPatientInfo;

View File

@@ -0,0 +1,61 @@
import React from 'react';
import PropTypes from 'prop-types';
import Input from '../Input';
import { getKeys, formatKeysForInput } from './utils';
/**
* HotkeyField
* Renders a hotkey input that records keys
*
* @param {object} props component props
* @param {Array[]} props.keys keys to be controlled by this field
* @param {boolean} props.disabled disables the field
* @param {function} props.onChange callback with changed values
* @param {string} props.className input classes
* @param {Array[]} props.modifierKeys
*/
const HotkeyField = ({ disabled = false, keys, onChange, className, modifierKeys, hotkeys }) => {
const inputValue = formatKeysForInput(keys);
const onInputKeyDown = event => {
hotkeys.record(sequence => {
const keys = getKeys({ sequence, modifierKeys });
hotkeys.unpause();
onChange(keys);
});
};
const onFocus = () => {
hotkeys.pause();
hotkeys.startRecording();
};
return (
<Input
readOnly
disabled={disabled}
value={inputValue}
onKeyDown={onInputKeyDown}
onFocus={onFocus}
className={className}
/>
);
};
HotkeyField.propTypes = {
keys: PropTypes.array.isRequired,
onChange: PropTypes.func.isRequired,
className: PropTypes.string,
modifierKeys: PropTypes.array,
disabled: PropTypes.bool,
hotkeys: PropTypes.shape({
initialize: PropTypes.func.isRequired,
pause: PropTypes.func.isRequired,
unpause: PropTypes.func.isRequired,
startRecording: PropTypes.func.isRequired,
record: PropTypes.func.isRequired,
}).isRequired,
};
export default HotkeyField;

View File

@@ -0,0 +1,3 @@
import HotkeyField from './HotkeyField.tsx';
export default HotkeyField;

View File

@@ -0,0 +1,28 @@
/**
* Take the pressed key array and return the readable string for the keys
*
* @param {Array} [keys=[]]
* @returns {string} string representation of an array of keys
*/
const formatKeysForInput = (keys = []) => keys.join('+');
/**
* formats given keys sequence to insert the modifier keys in the first index of the array
* @param {string} sequence keys sequence from MouseTrap Record -> "shift+left"
* @returns {Array} keys in array-format -> ['shift','left']
*/
const getKeys = ({ sequence, modifierKeys }) => {
const keysArray = sequence.join(' ').split('+');
let keys = [];
let modifiers = [];
keysArray.forEach(key => {
if (modifierKeys && modifierKeys.includes(key)) {
modifiers.push(key);
} else {
keys.push(key);
}
});
return [...modifiers, ...keys];
};
export { getKeys, formatKeysForInput };

View File

@@ -0,0 +1,143 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useTranslation } from 'react-i18next';
import HotkeyField from '../HotkeyField';
import Typography from '../Typography';
/* TODO: Move these configs and utils to core? */
import { MODIFIER_KEYS } from './hotkeysConfig';
import { validate, splitHotkeyDefinitionsAndCreateTuples } from './utils';
const HotkeysPreferences = ({
disabled = false,
hotkeyDefinitions,
errors: controlledErrors,
onChange = () => {},
hotkeysModule,
}) => {
const { t } = useTranslation('UserPreferencesModal');
const visibleHotkeys = Object.keys(hotkeyDefinitions)
.filter(key => hotkeyDefinitions[key].isEditable)
.reduce((obj, key) => {
obj[key] = hotkeyDefinitions[key];
return obj;
}, {});
const [errors, setErrors] = useState(controlledErrors);
const splitedHotkeys = splitHotkeyDefinitionsAndCreateTuples(visibleHotkeys);
if (!Object.keys(hotkeyDefinitions).length) {
return t('No hotkeys found');
}
const onHotkeyChangeHandler = (id, definition) => {
const { error } = validate({
commandName: id,
pressedKeys: definition.keys,
hotkeys: hotkeyDefinitions,
});
setErrors(prevState => {
const errors = { ...prevState, [id]: error };
return errors;
});
onChange(id, definition, { ...errors, [id]: error });
};
return (
<div className="flex flex-row justify-center">
<div className="flex w-full flex-row justify-evenly">
{splitedHotkeys.map((hotkeys, index) => {
return (
<div
key={`HotkeyGroup@${index}`}
className="flex flex-row"
>
<div className="flex flex-col p-2 text-right">
{hotkeys.map((hotkey, hotkeyIndex) => {
const [id, definition] = hotkey;
const isFirst = hotkeyIndex === 0;
const error = errors[id];
const onChangeHandler = keys =>
onHotkeyChangeHandler(id, { ...definition, keys });
return (
<div
key={`HotkeyItem@${hotkeyIndex}`}
className="mb-2 flex flex-row justify-end"
>
<div className="flex flex-col items-center">
<Typography
variant="subtitle"
className={classNames(
'text-primary-light w-full pr-6 text-right',
!isFirst && 'hidden'
)}
>
{t('Function')}
</Typography>
<Typography
variant="subtitle"
className={classNames(
'flex h-full flex-row items-center whitespace-nowrap pr-6',
isFirst && 'mt-5'
)}
>
{definition.label}
</Typography>
</div>
<div className="flex flex-col">
<Typography
variant="subtitle"
className={classNames(
'text-primary-light pr-6 pl-0 text-left',
!isFirst && 'hidden'
)}
>
{t('Shortcut')}
</Typography>
<div className={classNames('flex w-32 flex-col', isFirst && 'mt-5')}>
<HotkeyField
disabled={disabled}
keys={definition.keys}
modifierKeys={MODIFIER_KEYS}
onChange={onChangeHandler}
hotkeys={hotkeysModule}
className="h-8 text-lg"
/>
{error && (
<span className="p-2 text-left text-sm text-red-600">{error}</span>
)}
</div>
</div>
</div>
);
})}
</div>
</div>
);
})}
</div>
</div>
);
};
HotkeysPreferences.propTypes = {
onChange: PropTypes.func,
disabled: PropTypes.bool,
hotkeyDefinitions: PropTypes.object.isRequired,
hotkeysModule: PropTypes.shape({
initialize: PropTypes.func.isRequired,
pause: PropTypes.func.isRequired,
unpause: PropTypes.func.isRequired,
startRecording: PropTypes.func.isRequired,
record: PropTypes.func.isRequired,
}).isRequired,
};
export default HotkeysPreferences;

View File

@@ -0,0 +1,87 @@
export const MODIFIER_KEYS = ['ctrl', 'alt', 'shift'];
export const DISALLOWED_COMBINATIONS = {
'': [],
alt: ['space'],
shift: [],
ctrl: [
'f4',
'f5',
'f11',
'w',
'r',
't',
'o',
'p',
'a',
'd',
'f',
'g',
'h',
'j',
'l',
'z',
'x',
'c',
'v',
'b',
'n',
'pagedown',
'pageup',
],
'ctrl+shift': ['q', 'w', 'r', 't', 'p', 'a', 'h', 'v', 'b', 'n'],
};
export const SPECIAL_KEYS = {
8: 'backspace',
9: 'tab',
13: 'return',
16: 'shift',
17: 'ctrl',
18: 'alt',
19: 'pause',
20: 'capslock',
27: 'esc',
32: 'space',
33: 'pageup',
34: 'pagedown',
35: 'end',
36: 'home',
37: 'left',
38: 'up',
39: 'right',
40: 'down',
45: 'insert',
46: 'del',
96: '0',
97: '1',
98: '2',
99: '3',
100: '4',
101: '5',
102: '6',
103: '7',
104: '8',
105: '9',
106: '*',
107: '+',
109: '-',
110: '.',
111: '/',
112: 'f1',
113: 'f2',
114: 'f3',
115: 'f4',
116: 'f5',
117: 'f6',
118: 'f7',
119: 'f8',
120: 'f9',
121: 'f10',
122: 'f11',
123: 'f12',
144: 'numlock',
145: 'scroll',
191: '/',
224: 'meta',
};

View File

@@ -0,0 +1,78 @@
import { MODIFIER_KEYS, DISALLOWED_COMBINATIONS } from './hotkeysConfig';
import i18n from 'i18next';
const formatPressedKeys = pressedKeysArray => pressedKeysArray.join('+');
const findConflictingCommand = (hotkeys, currentCommandName, pressedKeys) => {
let firstConflictingCommand = undefined;
const formatedPressedHotkeys = formatPressedKeys(pressedKeys);
for (const commandName in hotkeys) {
const toolHotkeys = hotkeys[commandName].keys;
const formatedToolHotkeys = formatPressedKeys(toolHotkeys);
if (formatedPressedHotkeys === formatedToolHotkeys && commandName !== currentCommandName) {
firstConflictingCommand = hotkeys[commandName];
break;
}
}
return firstConflictingCommand;
};
const ERROR_MESSAGES = {
MODIFIER: i18n.t('HotkeysValidators:It\'s not possible to define only modifier keys (ctrl, alt and shift) as a shortcut'),
EMPTY: i18n.t('HotkeysValidators:Field can\'t be empty'),
};
// VALIDATORS
const modifierValidator = ({ pressedKeys }) => {
const lastPressedKey = pressedKeys[pressedKeys.length - 1];
// Check if it has a valid modifier
const isModifier = MODIFIER_KEYS.includes(lastPressedKey);
if (isModifier) {
return { error: ERROR_MESSAGES.MODIFIER };
}
};
const emptyValidator = ({ pressedKeys = [] }) => {
if (!pressedKeys.length) {
return { error: ERROR_MESSAGES.EMPTY };
}
};
const conflictingValidator = ({ commandName, pressedKeys, hotkeys }) => {
const conflictingCommand = findConflictingCommand(hotkeys, commandName, pressedKeys);
if (conflictingCommand) {
return {
error: i18n.t('HotkeysValidators:Hotkey is already in use', {action: conflictingCommand.label, pressedKeys: pressedKeys }),
};
}
};
const disallowedValidator = ({ pressedKeys = [] }) => {
const lastPressedKey = pressedKeys[pressedKeys.length - 1];
const modifierCommand = formatPressedKeys(pressedKeys.slice(0, pressedKeys.length - 1));
const disallowedCombination = DISALLOWED_COMBINATIONS[modifierCommand];
const hasDisallowedCombinations = disallowedCombination
? disallowedCombination.includes(lastPressedKey)
: false;
if (hasDisallowedCombinations) {
return {
error: i18n.t('HotkeysValidators:Shortcut combination is not allowed', {pressedKeys: formatPressedKeys(pressedKeys)}),
};
}
};
const hotkeysValidators = [
emptyValidator,
modifierValidator,
conflictingValidator,
disallowedValidator,
];
export { hotkeysValidators };

View File

@@ -0,0 +1,3 @@
import HotkeysPreferences from './HotkeysPreferences.tsx';
export default HotkeysPreferences;

View File

@@ -0,0 +1,47 @@
import { hotkeysValidators } from './hotkeysValidators';
/**
* Split hotkeys definitions and create hotkey related tuples
*
* @param {array} hotkeyDefinitions
* @returns {array} array of tuples consisted of command name and hotkey definition
*/
const splitHotkeyDefinitionsAndCreateTuples = hotkeyDefinitions => {
const splitedHotkeys = [];
const arrayHotkeys = Object.entries(hotkeyDefinitions);
if (arrayHotkeys.length) {
const halfwayThrough = Math.ceil(arrayHotkeys.length / 2);
splitedHotkeys.push(arrayHotkeys.slice(0, halfwayThrough));
splitedHotkeys.push(arrayHotkeys.slice(halfwayThrough, arrayHotkeys.length));
}
return splitedHotkeys;
};
/**
* Validate a hotkey change
*
* @param {Object} arguments
* @param {string} arguments.commandName command name or id
* @param {array} arguments.pressedKeys new keys
* @param {array} arguments.hotkeys current hotkeys
* @returns {Object} {error} validation error
*/
const validate = ({ commandName, pressedKeys, hotkeys }) => {
for (const validator of hotkeysValidators) {
const validation = validator({
commandName,
pressedKeys,
hotkeys,
});
if (validation && validation.error) {
return validation;
}
}
return { error: undefined };
};
export { validate, splitHotkeyDefinitionsAndCreateTuples };

View File

@@ -0,0 +1,14 @@
import React from 'react';
import PropTypes from 'prop-types';
import getIcon from './getIcon';
const Icon = ({ name, ...otherProps }) => {
return <React.Fragment>{getIcon(name, { ...otherProps })}</React.Fragment>;
};
Icon.propTypes = {
name: PropTypes.string.isRequired,
className: PropTypes.string,
};
export default Icon;

View File

@@ -0,0 +1,97 @@
import Icon from '../Icon';
import { ICONS } from '../getIcon';
import { ArgsTable, Story, Canvas, Meta } from '@storybook/addon-docs';
import { createComponentTemplate } from '../../../storybook/functions/create-component-story';
export const argTypes = {
component: Icon,
title: 'Components/Icon',
};
<Meta
title="Components/Icon"
component={Icon}
/>
export const IconTemplate = args => (
// Todo: Icon colors
<div className="h-8 w-8">
<Icon {...args} />
</div>
);
<Heading
title="Icon"
componentRelativePath="Icon/Icon.tsx"
/>
- [Overview](#overview)
- [Props](#props)
- [Usage](#usage)
- [Contribute](#contribute)
## Overview
Icon is a component that renders the Icons.
<Canvas>
<Story
name="Overview"
args={{
name: 'clipboard',
}}
>
{IconTemplate.bind({})}
</Story>
</Canvas>
## Props
<ArgsTable of={Icon} />
## Usage
You can choose the icon from the list of icons. Here we have render all the icons.
<Canvas>
<Story name="All Icons">
{() => {
const icons = Object.keys(ICONS);
return (
<div className="flex flex-wrap">
{icons
.filter(ic => ic !== 'magnifier')
.map(icon => (
<div className="m-4 flex flex-col items-center justify-center">
<div class="h-8 w-8">
<Icon name={icon} />
</div>
<div className="text-gray-600">{icon}</div>
</div>
))}
</div>
);
}}
</Story>
</Canvas>
## Color
Icon colors can be changed by wrapping the icon in a div with the class name
<Canvas>
<Story
name="Colors"
args={{
name: 'clipboard',
className: 'text-red-700',
}}
>
{IconTemplate.bind({})}
</Story>
</Canvas>
## Contribute
<Footer componentRelativePath="Icon/__stories__/icon.stories.mdx" />

View File

@@ -0,0 +1,452 @@
import React from 'react';
import { ReactComponent as arrowDown } from './../../assets/icons/arrow-down.svg';
import { ReactComponent as arrowLeft } from './../../assets/icons/arrow-left.svg';
import { ReactComponent as arrowRight } from './../../assets/icons/arrow-right.svg';
import { ReactComponent as arrowLeftSmall } from './../../assets/icons/arrow-left-small.svg';
import { ReactComponent as arrowRightSmall } from './../../assets/icons/arrow-right-small.svg';
import { ReactComponent as calendar } from './../../assets/icons/calendar.svg';
import { ReactComponent as cancel } from './../../assets/icons/cancel.svg';
import { ReactComponent as clipboard } from './../../assets/icons/clipboard.svg';
import { ReactComponent as close } from './../../assets/icons/closeIcon.svg';
import { ReactComponent as database } from './../../assets/icons/database.svg';
import { ReactComponent as dottedCircle } from './../../assets/icons/dotted-circle.svg';
import { ReactComponent as circledCheckmark } from './../../assets/icons/circled-checkmark.svg';
import { ReactComponent as chevronDown } from './../../assets/icons/chevron-down.svg';
import { ReactComponent as chevronLeft } from './../../assets/icons/chevron-left.svg';
import { ReactComponent as chevronMenu } from './../../assets/icons/chevron-menu.svg';
import { ReactComponent as chevronNext } from './../../assets/icons/chevron-next.svg';
import { ReactComponent as chevronPrev } from './../../assets/icons/chevron-prev.svg';
import { ReactComponent as chevronRight } from './../../assets/icons/chevron-right.svg';
import { ReactComponent as contentNext } from './../../assets/icons/content-next.svg';
import { ReactComponent as contentPrev } from './../../assets/icons/content-prev.svg';
import { ReactComponent as eyeVisible } from './../../assets/icons/eye-visible.svg';
import { ReactComponent as eyeHidden } from './../../assets/icons/eye-hidden.svg';
import { ReactComponent as exclamation } from './../../assets/icons/exclamation.svg';
import { ReactComponent as externalLink } from './../../assets/icons/external-link.svg';
import { ReactComponent as groupLayers } from './../../assets/icons/group-layers.svg';
import { ReactComponent as info } from './../../assets/icons/info.svg';
import { ReactComponent as infoAction } from './../../assets/icons/info-action.svg';
import { ReactComponent as infoLink } from './../../assets/icons/info-link.svg';
import { ReactComponent as launchArrow } from './../../assets/icons/launch-arrow.svg';
import { ReactComponent as launchInfo } from './../../assets/icons/launch-info.svg';
import { ReactComponent as link } from './../../assets/icons/tool-stack-image-sync.svg';
import { ReactComponent as listBullets } from './../../assets/icons/list-bullets.svg';
import { ReactComponent as lock } from './../../assets/icons/lock.svg';
import { ReactComponent as logoOhifSmall } from './../../assets/icons/logo-ohif-small.svg';
import { ReactComponent as logoDarkBackGround } from './../../assets/icons/ohif-logo-color-darkbg.svg';
import { ReactComponent as magnifier } from './../../assets/icons/magnifier.svg';
import { ReactComponent as notificationwarningDiamond } from './../../assets/icons/notificationwarning-diamond.svg';
import { ReactComponent as pencil } from './../../assets/icons/pencil.svg';
import { ReactComponent as powerOff } from './../../assets/icons/power-off.svg';
import { ReactComponent as profile } from './../../assets/icons/profile.svg';
import { ReactComponent as pushLeft } from './../../assets/icons/push-left.svg';
import { ReactComponent as pushRight } from './../../assets/icons/push-right.svg';
import { ReactComponent as settings } from './../../assets/icons/settings.svg';
import { ReactComponent as sidePanelCloseLeft } from './../../assets/icons/side-panel-close-left.svg';
import { ReactComponent as sidePanelCloseRight } from './../../assets/icons/side-panel-close-right.svg';
import { ReactComponent as sorting } from './../../assets/icons/sorting.svg';
import { ReactComponent as sortingActiveDown } from './../../assets/icons/sorting-active-down.svg';
import { ReactComponent as sortingActiveUp } from './../../assets/icons/sorting-active-up.svg';
import { ReactComponent as statusAlertWarning } from './../../assets/icons/status-alert-warning.svg';
import { ReactComponent as statusAlert } from './../../assets/icons/status-alert.svg';
import { ReactComponent as statusLocked } from './../../assets/icons/status-locked.svg';
import { ReactComponent as statusTracked } from './../../assets/icons/status-tracked.svg';
import { ReactComponent as statusUntracked } from './../../assets/icons/status-untracked.svg';
import { ReactComponent as tracked } from './../../assets/icons/tracked.svg';
import { ReactComponent as unlink } from './../../assets/icons/unlink.svg';
import { ReactComponent as checkboxChecked } from './../../assets/icons/checkbox-checked.svg';
import { ReactComponent as checkboxUnchecked } from './../../assets/icons/checkbox-unchecked.svg';
import { ReactComponent as iconAlertOutline } from './../../assets/icons/icons-alert-outline.svg';
import { ReactComponent as iconAlertSmall } from './../../assets/icons/icon-alert-small.svg';
import { ReactComponent as iconClose } from './../../assets/icons/icon-close.svg';
import { ReactComponent as iconClearField } from './../../assets/icons/icon-clear-field.svg';
import { ReactComponent as iconNextInactive } from './../../assets/icons/icon-next-inactive.svg';
import { ReactComponent as iconNext } from './../../assets/icons/icon-next.svg';
import { ReactComponent as iconPlay } from './../../assets/icons/icon-play.svg';
import { ReactComponent as iconPause } from './../../assets/icons/icon-pause.svg';
import { ReactComponent as iconPrevInactive } from './../../assets/icons/icon-prev-inactive.svg';
import { ReactComponent as iconPrev } from './../../assets/icons/icon-prev.svg';
import { ReactComponent as iconSearch } from './../../assets/icons/icon-search.svg';
import { ReactComponent as iconStatusAlert } from './../../assets/icons/icon-status-alert.svg';
import { ReactComponent as iconTransferring } from './../../assets/icons/icon-transferring.svg';
import { ReactComponent as iconUpload } from './../../assets/icons/icon-upload.svg';
import { ReactComponent as navigationPanelRightHide } from './../../assets/icons/navigation-panel-right-hide.svg';
import { ReactComponent as navigationPanelRightReveal } from './../../assets/icons/navigation-panel-right-reveal.svg';
import { ReactComponent as tabLinear } from './../../assets/icons/tab-linear.svg';
import { ReactComponent as tabPatientInfo } from './../../assets/icons/tab-patient-info.svg';
import { ReactComponent as tabROIThreshold } from './../../assets/icons/tab-roi-threshold.svg';
import { ReactComponent as tabSegmentation } from './../../assets/icons/tab-segmentation.svg';
import { ReactComponent as tabStudies } from './../../assets/icons/tab-studies.svg';
import { ReactComponent as uiArrowDown } from './../../assets/icons/ui-arrow-down.svg';
import { ReactComponent as uiArrowUp } from './../../assets/icons/ui-arrow-up.svg';
import { ReactComponent as uiArrowLeft } from './../../assets/icons/ui-arrow-left.svg';
import { ReactComponent as uiArrowRight } from './../../assets/icons/ui-arrow-right.svg';
import { ReactComponent as loadingOHIFMark } from './../../assets/icons/loading-ohif-mark.svg';
import { ReactComponent as notificationsInfo } from './../../assets/icons/notifications-info.svg';
import { ReactComponent as notificationsWarning } from './../../assets/icons/notifications-warning.svg';
import { ReactComponent as notificationsError } from './../../assets/icons/notifications-error.svg';
import { ReactComponent as notificationsSuccess } from './../../assets/icons/notifications-success.svg';
import { ReactComponent as nextArrow } from './../../assets/icons/next-arrow.svg';
import { ReactComponent as prevArrow } from './../../assets/icons/prev-arrow.svg';
import { ReactComponent as viewportStatusTracked } from './../../assets/icons/viewport-status-tracked.svg';
import { ReactComponent as toggleDicomOverlay } from './../../assets/icons/tool-toggle-dicom-overlay.svg';
import { ReactComponent as toolZoom } from './../../assets/icons/tool-zoom.svg';
import { ReactComponent as toolCapture } from './../../assets/icons/tool-capture.svg';
import { ReactComponent as toolLayout } from './../../assets/icons/tool-layout-default.svg';
import { ReactComponent as toolMore } from './../../assets/icons/tool-more-menu.svg';
import { ReactComponent as toolMove } from './../../assets/icons/tool-move.svg';
import { ReactComponent as toolWindow } from './../../assets/icons/tool-window-level.svg';
import { ReactComponent as toolAnnotate } from './../../assets/icons/tool-annotate.svg';
import { ReactComponent as toolBidirectional } from './../../assets/icons/tool-bidirectional.svg';
import { ReactComponent as toolElipse } from './../../assets/icons/tool-measure-elipse.svg';
import { ReactComponent as toolCircle } from './../../assets/icons/tool-circle.svg';
import { ReactComponent as toolLength } from './../../assets/icons/tool-length.svg';
import { ReactComponent as toolStackScroll } from './../../assets/icons/tool-stack-scroll.svg';
import { ReactComponent as toolMagnify } from './../../assets/icons/tool-quick-magnify.svg';
import { ReactComponent as toolFlipHorizontal } from './../../assets/icons/tool-flip-horizontal.svg';
import { ReactComponent as toolInvert } from './../../assets/icons/tool-invert.svg';
import { ReactComponent as toolRotateRight } from './../../assets/icons/tool-rotate-right.svg';
import { ReactComponent as toolCine } from './../../assets/icons/tool-cine.svg';
import { ReactComponent as toolCrosshair } from './../../assets/icons/tool-crosshair.svg';
import { ReactComponent as toolProbe } from './../../assets/icons/focus-frame-target.svg';
import { ReactComponent as toolAngle } from './../../assets/icons/tool-angle.svg';
import { ReactComponent as toolReset } from './../../assets/icons/tool-reset.svg';
import { ReactComponent as toolRectangle } from './../../assets/icons/tool-rectangle.svg';
import { ReactComponent as toolFusionColor } from './../../assets/icons/tool-fusion-color.svg';
import { ReactComponent as toolCreateThreshold } from './../../assets/icons/tool-create-threshold.svg';
import { ReactComponent as toolCalibration } from './../../assets/icons/tool-calibrate.svg';
import { ReactComponent as toolFreehand } from './../../assets/icons/tool-freehand.svg';
import { ReactComponent as toolFreehandPolygon } from './../../assets/icons/tool-freehand-polygon.svg';
import { ReactComponent as toolPolygon } from './../../assets/icons/tool-polygon.svg';
import { ReactComponent as editPatient } from './../../assets/icons/edit-patient.svg';
import { ReactComponent as panelGroupMore } from './../../assets/icons/panel-group-more.svg';
import { ReactComponent as panelGroupOpenClose } from './../../assets/icons/panel-group-open-close.svg';
import { ReactComponent as rowAdd } from './../../assets/icons/row-add.svg';
import { ReactComponent as rowEdit } from './../../assets/icons/row-edit.svg';
import { ReactComponent as rowHidden } from './../../assets/icons/row-hidden.svg';
import { ReactComponent as rowShown } from './../../assets/icons/row-shown.svg';
import { ReactComponent as rowLock } from './../../assets/icons/row-lock.svg';
import { ReactComponent as rowUnlock } from './../../assets/icons/row-unlock.svg';
import { ReactComponent as iconMPR } from './../../assets/icons/icon-mpr-alt.svg';
import { ReactComponent as checkboxDefault } from './../../assets/icons/checkbox-default.svg';
import { ReactComponent as checkboxActive } from './../../assets/icons/checkbox-active.svg';
import { ReactComponent as referenceLines } from './../../assets/icons/tool-reference-lines.svg';
import { ReactComponent as chevronDownNew } from './../../assets/icons/icon-disclosure-close.svg';
import { ReactComponent as chevronLeftNew } from './../../assets/icons/icon-disclosure-open.svg';
import { ReactComponent as settingsBars } from './../../assets/icons/icon-display-settings.svg';
import { ReactComponent as iconAdd } from './../../assets/icons/icon-add.svg';
import { ReactComponent as iconRename } from './../../assets/icons/icon-rename.svg';
import { ReactComponent as iconDelete } from './../../assets/icons/icon-delete.svg';
import { ReactComponent as iconMoreMenu } from './../../assets/icons/icon-more-menu.svg';
import { ReactComponent as iconToolBrush } from './../../assets/icons/tool-seg-brush.svg';
import { ReactComponent as iconToolEraser } from './../../assets/icons/tool-seg-eraser.svg';
import { ReactComponent as iconToolScissor } from './../../assets/icons/icon-tool-scissor.svg';
import { ReactComponent as iconToolShape } from './../../assets/icons/tool-seg-shape.svg';
import { ReactComponent as iconToolThreshold } from './../../assets/icons/tool-seg-threshold.svg';
import { ReactComponent as viewportWindowLevel } from './../../assets/icons/viewport-window-level.svg';
import { ReactComponent as dicomTagBrowser } from './../../assets/icons/tool-dicom-tag-browser.svg';
import { ReactComponent as iconToolFreehandRoi } from './../../assets/icons/tool-freehand-roi.svg';
import { ReactComponent as iconToolLivewire } from './../../assets/icons/tool-magnetic-roi.svg';
import { ReactComponent as iconToolSplineRoi } from './../../assets/icons/tool-spline-roi.svg';
import { ReactComponent as iconToolUltrasoundBidirectional } from './../../assets/icons/tool-ultrasound-bidirectional.svg';
import { ReactComponent as iconToolLoupe } from './../../assets/icons/tool-magnify.svg';
import { ReactComponent as oldTrash } from './../../assets/icons/old-trash.svg';
import { ReactComponent as oldPlay } from './../../assets/icons/old-play.svg';
import { ReactComponent as oldStop } from './../../assets/icons/old-stop.svg';
import { ReactComponent as iconColorLUT } from './../../assets/icons/icon-color-lut.svg';
import { ReactComponent as iconChevronPatient } from './../../assets/icons/icon-chevron-patient.svg';
import { ReactComponent as iconPatient } from './../../assets/icons/icon-patient.svg';
import { ReactComponent as iconSettings } from './../../assets/icons/icon-settings.svg';
import { ReactComponent as iconToolbarBack } from './../../assets/icons/icon-toolbar-back.svg';
import { ReactComponent as iconMultiplePatients } from './../../assets/icons/icon-multiple-patients.svg';
import { ReactComponent as layoutAdvanced3DFourUp } from './../../assets/icons/layout-advanced-3d-four-up.svg';
import { ReactComponent as layoutAdvanced3DMain } from './../../assets/icons/layout-advanced-3d-main.svg';
import { ReactComponent as layoutAdvanced3DOnly } from './../../assets/icons/layout-advanced-3d-only.svg';
import { ReactComponent as layoutAdvanced3DPrimary } from './../../assets/icons/layout-advanced-3d-primary.svg';
import { ReactComponent as layoutAdvancedAxialPrimary } from './../../assets/icons/layout-advanced-axial-primary.svg';
import { ReactComponent as layoutAdvancedMPR } from './../../assets/icons/layout-advanced-mpr.svg';
import { ReactComponent as layoutCommon1x1 } from './../../assets/icons/layout-common-1x1.svg';
import { ReactComponent as layoutCommon1x2 } from './../../assets/icons/layout-common-1x2.svg';
import { ReactComponent as layoutCommon2x2 } from './../../assets/icons/layout-common-2x2.svg';
import { ReactComponent as layoutCommon2x3 } from './../../assets/icons/layout-common-2x3.svg';
import { ReactComponent as iconToolRotate } from './../../assets/icons/tool-3d-rotate.svg';
import { ReactComponent as tab4D } from './../../assets/icons/tab-4d.svg';
import { ReactComponent as investigationalUse } from './../../assets/icons/illustration-investigational-use.svg';
import { ReactComponent as actionNewDialog } from './../../assets/icons/action-new-dialog.svg';
import { ReactComponent as iconToolCobbAngle } from './../../assets/icons/tool-cobb-angle.svg';
import { ReactComponent as iconToolWindowRegion } from './../../assets/icons/tool-window-region.svg';
import CTAAA from './../../assets/icons/CT-AAA.png';
import CTAAA2 from './../../assets/icons/CT-AAA2.png';
import CTAir from './../../assets/icons/CT-Air.png';
import CTBone from './../../assets/icons/CT-Bone.png';
import CTBones from './../../assets/icons/CT-Bones.png';
import CTCardiac from './../../assets/icons/CT-Cardiac.png';
import CTCardiac2 from './../../assets/icons/CT-Cardiac2.png';
import CTCardiac3 from './../../assets/icons/CT-Cardiac3.png';
import CTChestContrastEnhanced from './../../assets/icons/CT-Chest-Contrast-Enhanced.png';
import CTChestVessels from './../../assets/icons/CT-Chest-Vessels.png';
import CTCoronaryArteries from './../../assets/icons/CT-Coronary-Arteries.png';
import CTCoronaryArteries2 from './../../assets/icons/CT-Coronary-Arteries-2.png';
import CTCoronaryArteries3 from './../../assets/icons/CT-Coronary-Arteries-3.png';
import CTCroppedVolumeBone from './../../assets/icons/CT-Cropped-Volume-Bone.png';
import CTFat from './../../assets/icons/CT-Fat.png';
import CTLiverVasculature from './../../assets/icons/CT-Liver-Vasculature.png';
import CTLung from './../../assets/icons/CT-Lung.png';
import CTMIP from './../../assets/icons/CT-MIP.png';
import CTMuscle from './../../assets/icons/CT-Muscle.png';
import CTPulmonaryArteries from './../../assets/icons/CT-Pulmonary-Arteries.png';
import CTSoftTissue from './../../assets/icons/CT-Soft-Tissue.png';
import DTIFABrain from './../../assets/icons/DTI-FA-Brain.png';
import MRAngio from './../../assets/icons/MR-Angio.png';
import MRDefault from './../../assets/icons/MR-Default.png';
import MRMIP from './../../assets/icons/MR-MIP.png';
import MRT2Brain from './../../assets/icons/MR-T2-Brain.png';
import VolumeRendering from './../../assets/icons/VolumeRendering.png';
const ICONS = {
'arrow-down': arrowDown,
'arrow-left': arrowLeft,
'arrow-right': arrowRight,
'arrow-left-small': arrowLeftSmall,
'arrow-right-small': arrowRightSmall,
calendar: calendar,
cancel: cancel,
clipboard: clipboard,
close: close,
database: database,
'dotted-circle': dottedCircle,
'circled-checkmark': circledCheckmark,
'chevron-down': chevronDown,
'chevron-left': chevronLeft,
'chevron-menu': chevronMenu,
'chevron-next': chevronNext,
'chevron-prev': chevronPrev,
'chevron-right': chevronRight,
'content-next': contentNext,
'content-prev': contentPrev,
'eye-visible': eyeVisible,
'eye-hidden': eyeHidden,
'external-link': externalLink,
'group-layers': groupLayers,
info: info,
'icon-alert-outline': iconAlertOutline,
'icon-alert-small': iconAlertSmall,
'icon-clear-field': iconClearField,
'icon-close': iconClose,
'icon-play': iconPlay,
'icon-pause': iconPause,
'icon-search': iconSearch,
'icon-status-alert': iconStatusAlert,
'icon-transferring': iconTransferring,
'info-action': infoAction,
'info-link': infoLink,
'launch-arrow': launchArrow,
'launch-info': launchInfo,
link: link,
'list-bullets': listBullets,
lock: lock,
'logo-ohif-small': logoOhifSmall,
'logo-dark-background': logoDarkBackGround,
magnifier: magnifier,
exclamation: exclamation,
'notificationwarning-diamond': notificationwarningDiamond,
pencil: pencil,
'power-off': powerOff,
profile: profile,
'push-left': pushLeft,
'push-right': pushRight,
settings: settings,
'side-panel-close-left': sidePanelCloseLeft,
'side-panel-close-right': sidePanelCloseRight,
'sorting-active-down': sortingActiveDown,
'sorting-active-up': sortingActiveUp,
'status-alert': statusAlert,
'status-alert-warning': statusAlertWarning,
'status-locked': statusLocked,
'status-tracked': statusTracked,
'status-untracked': statusUntracked,
sorting: sorting,
tracked: tracked,
unlink: unlink,
'panel-group-more': panelGroupMore,
'panel-group-open-close': panelGroupOpenClose,
'row-add': rowAdd,
'row-edit': rowEdit,
'row-hidden': rowHidden,
'row-shown': rowShown,
'row-lock': rowLock,
'row-unlock': rowUnlock,
'checkbox-checked': checkboxChecked,
'checkbox-unchecked': checkboxUnchecked,
'loading-ohif-mark': loadingOHIFMark,
'notifications-info': notificationsInfo,
'notifications-error': notificationsError,
'notifications-success': notificationsSuccess,
'notifications-warning': notificationsWarning,
/** Tools */
'toggle-dicom-overlay': toggleDicomOverlay,
'tool-zoom': toolZoom,
'tool-capture': toolCapture,
'tool-layout': toolLayout,
'tool-more-menu': toolMore,
'tool-move': toolMove,
'tool-window-level': toolWindow,
'tool-annotate': toolAnnotate,
'tool-bidirectional': toolBidirectional,
'tool-ellipse': toolElipse,
'tool-circle': toolCircle,
'tool-length': toolLength,
'tool-stack-scroll': toolStackScroll,
'tool-magnify': toolMagnify,
'tool-flip-horizontal': toolFlipHorizontal,
'tool-invert': toolInvert,
'tool-rotate-right': toolRotateRight,
'tool-cine': toolCine,
'tool-crosshair': toolCrosshair,
'tool-probe': toolProbe,
'tool-angle': toolAngle,
'tool-reset': toolReset,
'tool-rectangle': toolRectangle,
'tool-fusion-color': toolFusionColor,
'tool-create-threshold': toolCreateThreshold,
'tool-calibration': toolCalibration,
'tool-point': toolCircle,
'tool-freehand-line': toolFreehand,
'tool-freehand-polygon': toolFreehandPolygon,
'tool-polygon': toolPolygon,
'tool-3d-rotate': iconToolRotate,
'edit-patient': editPatient,
'icon-mpr': iconMPR,
'icon-next-inactive': iconNextInactive,
'icon-next': iconNext,
'icon-prev-inactive': iconPrevInactive,
'icon-prev': iconPrev,
'icon-upload': iconUpload,
'navigation-panel-right-hide': navigationPanelRightHide,
'navigation-panel-right-reveal': navigationPanelRightReveal,
'tab-linear': tabLinear,
'tab-patient-info': tabPatientInfo,
'tab-roi-threshold': tabROIThreshold,
'tab-segmentation': tabSegmentation,
'tab-studies': tabStudies,
'ui-arrow-down': uiArrowDown,
'ui-arrow-up': uiArrowUp,
'ui-arrow-left': uiArrowLeft,
'ui-arrow-right': uiArrowRight,
'checkbox-default': checkboxDefault,
'checkbox-active': checkboxActive,
'tool-referenceLines': referenceLines,
'chevron-left-new': chevronLeftNew,
'chevron-down-new': chevronDownNew,
'settings-bars': settingsBars,
'icon-rename': iconRename,
'icon-add': iconAdd,
'icon-delete': iconDelete,
'icon-more-menu': iconMoreMenu,
'icon-tool-brush': iconToolBrush,
'icon-tool-eraser': iconToolEraser,
'icon-tool-scissor': iconToolScissor,
'icon-tool-shape': iconToolShape,
'icon-tool-threshold': iconToolThreshold,
'next-arrow': nextArrow,
'prev-arrow': prevArrow,
'viewport-status-tracked': viewportStatusTracked,
'viewport-window-level': viewportWindowLevel,
'dicom-tag-browser': dicomTagBrowser,
/** New Tools */
'icon-tool-freehand-roi': iconToolFreehandRoi,
'icon-tool-livewire': iconToolLivewire,
'icon-tool-spline-roi': iconToolSplineRoi,
'icon-tool-ultrasound-bidirectional': iconToolUltrasoundBidirectional,
'icon-tool-loupe': iconToolLoupe,
'icon-tool-cobb-angle': iconToolCobbAngle,
'icon-tool-window-region': iconToolWindowRegion,
/** Old OHIF */
'old-trash': oldTrash,
'old-play': oldPlay,
'old-stop': oldStop,
/** ColorLut */
'icon-color-lut': iconColorLUT,
/** New Patient Info Toolbar */
'icon-chevron-patient': iconChevronPatient,
'icon-patient': iconPatient,
'icon-settings': iconSettings,
'icon-toolbar-back': iconToolbarBack,
'icon-multiple-patients': iconMultiplePatients,
/** Volume Rendering */
'CT-AAA': CTAAA,
'CT-AAA2': CTAAA2,
'CT-Air': CTAir,
'CT-Bone': CTBone,
'CT-Bones': CTBones,
'CT-Cardiac': CTCardiac,
'CT-Cardiac2': CTCardiac2,
'CT-Cardiac3': CTCardiac3,
'CT-Chest-Contrast-Enhanced': CTChestContrastEnhanced,
'CT-Chest-Vessels': CTChestVessels,
'CT-Coronary-Arteries': CTCoronaryArteries,
'CT-Coronary-Arteries-2': CTCoronaryArteries2,
'CT-Coronary-Arteries-3': CTCoronaryArteries3,
'CT-Cropped-Volume-Bone': CTCroppedVolumeBone,
'CT-Fat': CTFat,
'CT-Liver-Vasculature': CTLiverVasculature,
'CT-Lung': CTLung,
'CT-MIP': CTMIP,
'CT-Muscle': CTMuscle,
'CT-Pulmonary-Arteries': CTPulmonaryArteries,
'CT-Soft-Tissue': CTSoftTissue,
'DTI-FA-Brain': DTIFABrain,
'MR-Angio': MRAngio,
'MR-Default': MRDefault,
'MR-MIP': MRMIP,
'MR-T2-Brain': MRT2Brain,
VolumeRendering: VolumeRendering,
'action-new-dialog': actionNewDialog,
/** LAYOUT */
'layout-advanced-3d-four-up': layoutAdvanced3DFourUp,
'layout-advanced-3d-main': layoutAdvanced3DMain,
'layout-advanced-3d-only': layoutAdvanced3DOnly,
'layout-advanced-3d-primary': layoutAdvanced3DPrimary,
'layout-advanced-axial-primary': layoutAdvancedAxialPrimary,
'layout-advanced-mpr': layoutAdvancedMPR,
'layout-common-1x1': layoutCommon1x1,
'layout-common-1x2': layoutCommon1x2,
'layout-common-2x2': layoutCommon2x2,
'layout-common-2x3': layoutCommon2x3,
'tab-4d': tab4D,
/** New investigational use */
'illustration-investigational-use': investigationalUse,
};
function addIcon(iconName, iconSVG) {
if (ICONS[iconName]) {
console.warn(`Icon ${iconName} already exists.`);
}
ICONS[iconName] = iconSVG;
}
/**
* Return the matching SVG Icon as a React Component.
* Results in an inlined SVG Element. If there's no match,
* return `null`
*/
export default function getIcon(key, props) {
const icon = ICONS[key];
if (!key || !icon) {
return React.createElement('div', null, 'Missing Icon');
}
if (typeof icon === 'string' && icon.endsWith('.png')) {
return React.createElement('img', { src: icon, ...props });
} else {
return React.createElement(icon, props);
}
}
export { getIcon, ICONS, addIcon };

View File

@@ -0,0 +1,2 @@
import Icon from './Icon';
export default Icon;

View File

@@ -0,0 +1,26 @@
import React from 'react';
import IconButton from './IconButton';
import Icon from '../Icon/Icon';
import { ICONS } from '../Icon/getIcon';
export default {
component: IconButton,
title: 'Icons/IconButton',
argTypes: {
iconName: {
control: { type: 'select', options: Object.keys(ICONS) },
},
},
};
const Template = ({ iconName, ...args }) => (
<IconButton {...args}>
<Icon name={iconName} />
</IconButton>
);
export const Default = Template.bind({});
Default.args = {
iconName: 'clipboard',
};

View File

@@ -0,0 +1,139 @@
import React, { useRef } from 'react';
import PropTypes from 'prop-types';
import classnames from 'classnames';
const baseClasses =
'text-center items-center justify-center transition duration-300 ease-in-out outline-none font-bold focus:outline-none';
const roundedClasses = {
none: '',
small: 'rounded',
medium: 'rounded-md',
large: 'rounded-lg',
full: 'rounded-full',
};
const disabledClasses = {
true: 'ohif-disabled',
false: '',
};
const variantClasses = {
text: {
default:
'text-white hover:bg-primary-light hover:text-black active:opacity-80 focus:!bg-primary-light focus:text-black',
primary:
'text-primary-main hover:bg-primary-main hover:text-white active:opacity-80 focus:bg-primary-main focus:text-white',
secondary:
'text-secondary-light hover:bg-secondary-light hover:text-white active:opacity-80 focus:bg-secondary-light focus:text-white',
white:
'text-white hover:bg-white hover:text-black active:opacity-80 focus:bg-white focus:text-black',
black:
'text-black hover:bg-black hover:text-white focus:bg-black focus:text-white active:opacity-80',
},
outlined: {
default:
'border border-primary-light text-white hover:opacity-80 active:opacity-100 focus:opacity-80',
primary:
'border border-primary-main text-primary-main hover:opacity-80 active:opacity-100 focus:opacity-80',
secondary:
'border border-secondary-light text-secondary-light hover:opacity-80 active:opacity-100 focus:opacity-80',
white: 'border border-white text-white hover:opacity-80 active:opacity-100 focus:opacity-80',
black:
'border border-primary-main text-white hover:bg-primary-main focus:bg-primary-main hover:border-black focus:border-black',
},
contained: {
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',
black: 'text-white hover:opacity-80 active:opacity-100 focus:opacity-80',
},
};
const sizeClasses = {
small: 'py-2 px-2 text-base',
medium: 'py-3 px-3 text-lg',
large: 'py-4 px-4 text-xl',
initial: '',
toolbar: 'text-lg',
};
const iconSizeClasses = {
small: 'w-4 h-4',
medium: 'w-5 h-5',
large: 'w-6 h-6',
toolbar: 'w-[28px] h-[28px]',
toolbox: 'w-[24px] h-[24px]',
};
const fullWidthClasses = {
true: 'flex w-full',
false: 'inline-flex',
};
const IconButton = ({
children,
variant = 'contained',
color = 'default',
size = 'medium',
rounded = 'medium',
disabled = false,
type = 'button',
fullWidth = false,
onClick = () => {},
className,
id,
...rest
}) => {
const buttonElement = useRef(null);
const handleOnClick = e => {
buttonElement.current.blur();
onClick(e);
};
const padding = size === 'toolbar' ? '6px' : size === 'toolbox' ? '4px' : null;
return (
<button
className={classnames(
baseClasses,
variantClasses[variant][color],
roundedClasses[rounded],
sizeClasses[size],
fullWidthClasses[fullWidth],
disabledClasses[disabled],
className
)}
style={{
padding,
}}
ref={buttonElement}
onClick={handleOnClick}
type={type}
data-cy={rest['data-cy'] ?? id}
data-tool={rest['data-tool']}
>
{React.cloneElement(children, {
className: classnames(iconSizeClasses[size], 'fill-current'),
})}
</button>
);
};
IconButton.propTypes = {
children: PropTypes.node.isRequired,
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']),
fullWidth: PropTypes.bool,
disabled: PropTypes.bool,
type: PropTypes.string,
id: PropTypes.string,
className: PropTypes.node,
onClick: PropTypes.func,
};
export default IconButton;

View File

@@ -0,0 +1,2 @@
import IconButton from './IconButton';
export default IconButton;

View File

@@ -0,0 +1,106 @@
.scroll {
height: calc(100% - 30px);
padding: 5px;
position: absolute;
right: 0;
top: 30px;
}
.scroll .scroll-holder {
height: calc(100%);
margin-bottom: 5px;
position: relative;
width: 12px;
}
.scroll .scroll-holder .imageSlider {
height: 12px;
left: 12px;
padding: 0;
position: absolute;
top: 0;
transform: rotate(90deg);
transform-origin: top left;
-webkit-appearance: none;
background-color: rgba(0, 0, 0, 0);
}
.scroll .scroll-holder .imageSlider:focus {
outline: none;
}
.scroll .scroll-holder .imageSlider::-moz-focus-outer {
border: none;
}
.scroll .scroll-holder .imageSlider::-webkit-slider-runnable-track {
background-color: rgba(0, 0, 0, 0);
border: none;
cursor: pointer;
height: 5px;
z-index: 6;
}
.scroll .scroll-holder .imageSlider::-moz-range-track {
background-color: rgba(0, 0, 0, 0);
border: none;
cursor: pointer;
height: 2px;
z-index: 6;
}
.scroll .scroll-holder .imageSlider::-ms-track {
animate: 0.2s;
background: transparent;
border: none;
border-width: 15px 0;
color: rgba(0, 0, 0, 0);
cursor: pointer;
height: 12px;
width: 100%;
}
.scroll .scroll-holder .imageSlider::-ms-fill-lower {
background: rgba(0, 0, 0, 0);
}
.scroll .scroll-holder .imageSlider::-ms-fill-upper {
background: rgba(0, 0, 0, 0);
}
.scroll .scroll-holder .imageSlider::-webkit-slider-thumb {
-webkit-appearance: none !important;
background-color: #163239;
border: none;
border-radius: 57px;
cursor: -webkit-grab;
height: 12px;
margin-top: -4px;
width: 39px;
}
.scroll .scroll-holder .imageSlider::-webkit-slider-thumb:active {
background-color: #20a5d6;
cursor: -webkit-grabbing;
}
.scroll .scroll-holder .imageSlider::-moz-range-thumb {
background-color: #163239;
border: none;
border-radius: 57px;
cursor: -moz-grab;
height: 12px;
width: 39px;
z-index: 7;
}
.scroll .scroll-holder .imageSlider::-moz-range-thumb:active {
background-color: #20a5d6;
cursor: -moz-grabbing;
}
.scroll .scroll-holder .imageSlider::-ms-thumb {
background-color: #163239;
border: none;
border-radius: 57px;
cursor: ns-resize;
height: 12px;
width: 39px;
}
.scroll .scroll-holder .imageSlider::-ms-thumb:active {
background-color: #20a5d6;
}
.scroll .scroll-holder .imageSlider::-ms-tooltip {
display: none;
}
@media screen and (-ms-high-contrast: active), (-ms-high-contrast: none) {
.imageSlider {
left: 50px;
}
}

View File

@@ -0,0 +1,75 @@
import React, { PureComponent } from 'react';
import PropTypes from 'prop-types';
import './ImageScrollbar.css';
class ImageScrollbar extends PureComponent {
static propTypes = {
value: PropTypes.number.isRequired,
max: PropTypes.number.isRequired,
height: PropTypes.string.isRequired,
onChange: PropTypes.func.isRequired,
onContextMenu: PropTypes.func,
};
render() {
if (this.props.max === 0) {
return null;
}
this.style = {
width: `${this.props.height}`,
};
const { onContextMenu = e => e.preventDefault() } = this.props;
return (
<div
className="scroll"
onContextMenu={onContextMenu}
>
<div className="scroll-holder">
<input
// adding mousetrap let the mousetrap know about the scrollbar otherwise,
// it will not capture the keyboard event
className="imageSlider mousetrap"
style={this.style}
type="range"
min="0"
max={this.props.max}
step="1"
value={this.props.value}
onChange={this.onChange}
onKeyDown={this.onKeyDown}
/>
</div>
</div>
);
}
onChange = event => {
const intValue = parseInt(event.target.value, 10);
this.props.onChange(intValue);
};
onKeyDown = event => {
// We don't allow direct keyboard up/down input on the
// image sliders since the natural direction is reversed (0 is at the top)
// Store the KeyCodes in an object for readability
const keys = {
DOWN: 40,
UP: 38,
};
// TODO: Enable scroll down / scroll up without depending on ohif-core
if (event.which === keys.DOWN) {
//OHIF.commands.run('scrollDown');
event.preventDefault();
} else if (event.which === keys.UP) {
//OHIF.commands.run('scrollUp');
event.preventDefault();
}
};
}
export default ImageScrollbar;

View File

@@ -0,0 +1,2 @@
import ImageScrollbar from './ImageScrollbar';
export default ImageScrollbar;

View File

@@ -0,0 +1,91 @@
import React from 'react';
import PropTypes from 'prop-types';
import Label from '../Label';
import classnames from 'classnames';
const baseInputClasses =
'shadow transition duration-300 appearance-none border border-inputfield-main focus:border-inputfield-focus focus:outline-none disabled:border-inputfield-disabled rounded w-full py-2 px-3 text-sm text-white placeholder-inputfield-placeholder leading-tight';
const transparentClasses = {
true: 'bg-transparent',
false: 'bg-black',
};
const smallInputClasses = {
true: 'input-small',
false: '',
};
const Input = ({
id,
label,
containerClassName = '',
labelClassName = '',
className = '',
transparent = false,
smallInput = false,
type = 'text',
value,
onChange,
onFocus,
autoFocus,
onKeyPress,
onKeyDown,
readOnly,
disabled,
labelChildren,
...otherProps
}) => {
return (
<div className={classnames('flex flex-1 flex-col', containerClassName)}>
<Label
className={labelClassName}
text={label}
children={labelChildren}
></Label>
<input
data-cy={`input-${id}`}
className={classnames(
label && 'mt-2',
className,
baseInputClasses,
transparentClasses[transparent],
smallInputClasses[smallInput],
{ 'cursor-not-allowed': disabled }
)}
disabled={disabled}
readOnly={readOnly}
autoFocus={autoFocus}
type={type}
value={value}
onChange={onChange}
onFocus={onFocus}
onKeyPress={onKeyPress}
onKeyDown={onKeyDown}
{...otherProps}
/>
</div>
);
};
Input.propTypes = {
id: PropTypes.string,
label: PropTypes.string,
containerClassName: PropTypes.string,
labelClassName: PropTypes.string,
className: PropTypes.string,
transparent: PropTypes.bool,
smallInput: PropTypes.bool,
type: PropTypes.string,
value: PropTypes.any,
onChange: PropTypes.func,
onFocus: PropTypes.func,
autoFocus: PropTypes.bool,
readOnly: PropTypes.bool,
onKeyPress: PropTypes.func,
onKeyDown: PropTypes.func,
disabled: PropTypes.bool,
labelChildren: PropTypes.node,
};
export default Input;

View File

@@ -0,0 +1,94 @@
import Input from '../Input';
import { ArgsTable, Story, Canvas, Meta } from '@storybook/addon-docs';
import { createComponentTemplate } from '../../../storybook/functions/create-component-story';
export const argTypes = {
component: Input,
title: 'Components/Input',
};
<Meta
title="Components/Input"
component={Input}
/>
export const InputTemplate = createComponentTemplate(Input);
<Heading
title="Input"
componentRelativePath="Input/Input.tsx"
/>
- [Overview](#overview)
- [Props](#props)
- [Usage](#usage)
- [Contribute](#contribute)
## Overview
Input is a component that renders the Inputs.
<Canvas>
<Story
name="Overview"
args={{
label: 'Input',
}}
>
{InputTemplate.bind({})}
</Story>
</Canvas>
## Props
<ArgsTable of={Input} />
## Usage
### Transparent
<Canvas>
<Story
name="Transparent"
args={{
transparent: true,
}}
>
{InputTemplate.bind({})}
</Story>
</Canvas>
### Small Input
<Canvas>
<Story
name="Small"
args={{
smallInput: true,
}}
>
{InputTemplate.bind({})}
</Story>
</Canvas>
### Classnames
You can change the appearance of the container and label.
<Canvas>
<Story
name="ClassNames"
args={{
label: 'Input',
containerClassName: 'bg-gray-500',
labelClassName: 'text-yellow-500',
}}
>
{InputTemplate.bind({})}
</Story>
</Canvas>
## Contribute
<Footer componentRelativePath="Input/__stories__/input.stories.mdx" />

View File

@@ -0,0 +1,2 @@
import Input from './Input';
export default Input;

View File

@@ -0,0 +1,59 @@
import React from 'react';
import PropTypes from 'prop-types';
import { DatePickerWithRange } from '@ohif/ui-next';
import InputLabelWrapper from '../InputLabelWrapper';
const InputDateRange = ({
id,
label,
isSortable,
sortDirection,
onLabelClick = () => {},
value = {},
onChange,
}) => {
const { startDate, endDate } = value;
const onClickHandler = event => {
event.preventDefault();
onLabelClick(event);
};
return (
<InputLabelWrapper
label={label}
isSortable={isSortable}
sortDirection={sortDirection}
onLabelClick={onClickHandler}
className="xl:min-w-[284px]"
>
<div className="relative xl:max-w-[246px]">
<DatePickerWithRange
className="mt-2"
id={id}
startDate={startDate}
endDate={endDate}
onChange={onChange}
/>
</div>
</InputLabelWrapper>
);
};
InputDateRange.propTypes = {
id: PropTypes.string,
label: PropTypes.string.isRequired,
isSortable: PropTypes.bool.isRequired,
sortDirection: PropTypes.oneOf(['ascending', 'descending', 'none']).isRequired,
onLabelClick: PropTypes.func.isRequired,
value: PropTypes.shape({
/** YYYYMMDD (19921022) */
startDate: PropTypes.string,
/** YYYYMMDD (19921022) */
endDate: PropTypes.string,
}),
onChange: PropTypes.func.isRequired,
};
export default InputDateRange;

View File

@@ -0,0 +1,2 @@
import InputDateRange from './InputDateRange';
export default InputDateRange;

View File

@@ -0,0 +1,4 @@
.input-range-thumb-design {
@apply bg-primary-light border-primary-dark border-[2px] border-solid;
border-radius: 50%;
}

View File

@@ -0,0 +1,238 @@
import React, { useState, useEffect, useRef } from 'react';
import classNames from 'classnames';
import { InputNumber } from '../../components'; // Import InputNumber component
import './InputDoubleRange.css';
type InputDoubleRangeProps = {
values: [number, number];
onChange: (values: [number, number]) => void;
minValue?: number;
maxValue?: number;
step?: number;
unit?: string;
containerClassName?: string;
inputClassName?: string;
labelClassName?: string;
labelVariant?: string;
showLabel?: boolean;
labelPosition?: 'left' | 'right';
trackColor?: string;
allowNumberEdit?: boolean;
showAdjustmentArrows?: boolean;
allowOutOfRange?: boolean;
};
const InputDoubleRange: React.FC<InputDoubleRangeProps> = ({
values,
onChange,
minValue = 0,
maxValue = 100,
step = 1,
unit = '',
containerClassName = '',
inputClassName = '',
labelClassName = '',
labelVariant = 'body1',
showLabel = false,
labelPosition = 'left',
trackColor = 'primary',
allowNumberEdit = false,
allowOutOfRange = false,
showAdjustmentArrows = false,
}) => {
// Set initial thumb positions as percentages
const initialPercentageStart = Math.round(((values[0] - minValue) / (maxValue - minValue)) * 100);
const initialPercentageEnd = Math.round(((values[1] - minValue) / (maxValue - minValue)) * 100);
const [percentageStart, setPercentageStart] = useState(initialPercentageStart);
const [percentageEnd, setPercentageEnd] = useState(initialPercentageEnd);
const [rangeValue, setRangeValue] = useState(values);
const selectedThumbRef = useRef(null);
const sliderRef = useRef(null);
const updateRangeValues = (newValues, index = null) => {
const updatedRangeValue = Array.isArray(newValues) ? [...newValues] : [...rangeValue];
if (index !== null) {
updatedRangeValue[index] = newValues;
}
const calculatePercentage = value => {
if (value < minValue) {
return 0;
}
if (value > maxValue) {
return 100;
}
return ((value - minValue) / (maxValue - minValue)) * 100;
};
const newPercentageStart = calculatePercentage(updatedRangeValue[0]);
const newPercentageEnd = calculatePercentage(updatedRangeValue[1]);
setRangeValue(updatedRangeValue);
onChange(updatedRangeValue);
setPercentageStart(newPercentageStart);
setPercentageEnd(newPercentageEnd);
};
useEffect(() => {
updateRangeValues(values);
}, [values, minValue, maxValue]);
const LabelOrEditableNumber = (val, index) => {
return allowNumberEdit ? (
// the pl-[2px] class is used to align the thumb so that it doesn't
// go over the label when the value is full, not sure what is wrong
// with the implementation, we need to fix it properly
<div className={index === 1 && 'pl-[2px]'}>
<InputNumber
minValue={minValue}
maxValue={maxValue}
value={val}
onChange={newValue => {
updateRangeValues(newValue, index);
}}
step={step}
labelClassName={classNames(labelClassName ?? 'text-white')}
showAdjustmentArrows={showAdjustmentArrows}
/>
</div>
) : (
<span className={classNames(labelClassName ?? 'text-white')}>
{val}
{unit}
</span>
);
};
useEffect(() => {
return () => {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleGlobalMouseUp);
};
}, []);
const handleGlobalMouseUp = () => {
// Remove global mouse event listeners
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleGlobalMouseUp);
selectedThumbRef.current = null;
};
const handleMouseDown = e => {
const rect = sliderRef.current.getBoundingClientRect();
const x = e.clientX - rect.left;
const percentageClicked = (x / rect.width) * 100;
// Calculate the distances from the clicked point to both thumbs' positions
const distanceToStartThumb = Math.abs(percentageClicked - percentageStart);
const distanceToEndThumb = Math.abs(percentageClicked - percentageEnd);
// Check if the clicked point is within a threshold distance to either thumb
if (distanceToStartThumb < 10) {
selectedThumbRef.current = 0;
} else if (distanceToEndThumb < 10) {
selectedThumbRef.current = 1;
}
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleGlobalMouseUp);
};
const handleMouseMove = e => {
const selectedThumbValue = selectedThumbRef.current;
if (selectedThumbValue === null) {
return;
}
const rect = sliderRef.current.getBoundingClientRect();
const x = e.clientX - rect.left;
const newValue =
Math.round(((x / rect.width) * (maxValue - minValue) + minValue) / step) * step;
if (!allowOutOfRange) {
const clampedValue = Math.min(Math.max(newValue, minValue), maxValue);
const updatedRangeValue = [...rangeValue];
updatedRangeValue[selectedThumbValue] = clampedValue;
setRangeValue(updatedRangeValue);
onChange(updatedRangeValue);
const percentage = Math.round(((clampedValue - minValue) / (maxValue - minValue)) * 100);
if (selectedThumbValue === 0) {
setPercentageStart(percentage);
} else {
setPercentageEnd(percentage);
}
} else {
const updatedRangeValue = [...rangeValue];
updatedRangeValue[selectedThumbValue] = newValue;
setRangeValue(updatedRangeValue);
onChange(updatedRangeValue);
// Update the thumb position
const percentage = Math.round(((newValue - minValue) / (maxValue - minValue)) * 100);
if (percentage < 0) {
if (selectedThumbValue === 0) {
setPercentageStart(0);
} else {
setPercentageEnd(0);
}
} else if (percentage > 100) {
if (selectedThumbValue === 0) {
setPercentageStart(100);
} else {
setPercentageEnd(100);
}
} else {
if (selectedThumbValue === 0) {
setPercentageStart(percentage);
} else {
setPercentageEnd(percentage);
}
}
}
// Update the correct values in the rangeValue array
};
// Calculate the range values percentages for gradient background
const rangeValuePercentageStart = ((rangeValue[0] - minValue) / (maxValue - minValue)) * 100;
const rangeValuePercentageEnd = ((rangeValue[1] - minValue) / (maxValue - minValue)) * 100;
return (
<div className={`flex select-none items-center space-x-2 ${containerClassName ?? ''}`}>
{showLabel && LabelOrEditableNumber(rangeValue[0], 0)}
<div
className="relative flex h-10 w-full items-center"
onMouseDown={handleMouseDown}
ref={sliderRef}
>
<div
className="h-[3px] w-full rounded-lg"
style={{
background: `linear-gradient(to right, #3a3f99 0%, #3a3f99 ${rangeValuePercentageStart}%, #5acce6 ${rangeValuePercentageStart}%, #5acce6 ${rangeValuePercentageEnd}%, #3a3f99 ${rangeValuePercentageEnd}%, #3a3f99 100%)`,
}}
></div>
<div
className="input-range-thumb-design absolute h-3 w-3 cursor-pointer"
style={{
left: `calc(${percentageStart}% - 3px)`,
}}
></div>
<div
className="input-range-thumb-design absolute h-3 w-3 cursor-pointer rounded-full"
style={{ left: `calc(${percentageEnd}% - 3px)` }}
></div>
</div>
{showLabel && LabelOrEditableNumber(rangeValue[1], 1)}
</div>
);
};
export default InputDoubleRange;

View File

@@ -0,0 +1,56 @@
import { InputDoubleRange } from '../../../components';
import { ArgsTable, Story, Canvas, Meta } from '@storybook/addon-docs';
export const argTypes = {
component: InputDoubleRange,
title: 'Components/InputDoubleRange',
};
<Meta
title="Components/InputDoubleRange"
component={InputDoubleRange}
/>
export const RangeInputTemplate = args => (
<div className="w-12">
<InputDoubleRange {...args} />
</div>
);
<Heading
title="InputDoubleRange"
componentRelativePath="InputDoubleRange/InputDoubleRange.tsx"
/>
- [Overview](#overview)
- [Props](#props)
- [Contribute](#contribute)
## Overview
InputDoubleRange is a component that allows you to use as a boolean value
<Canvas>
<Story
name="Overview"
args={{
values: [100, 200],
minValue: 0,
maxValue: 300,
step: 10,
unit: '%',
onChange: () => console.log('input range change'),
labelClassName: 'text-black',
}}
>
{RangeInputTemplate.bind({})}
</Story>
</Canvas>
## Props
<ArgsTable of={InputDoubleRange} />
## Contribute
<Footer componentRelativePath="InputDoubleRange/__stories__/InputDoubleRange.stories.mdx" />

View File

@@ -0,0 +1,3 @@
import InputDoubleRange from './InputDoubleRange';
export default InputDoubleRange;

View File

@@ -0,0 +1,84 @@
import classNames from 'classnames';
import debounce from 'lodash.debounce';
import React, { ReactElement, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import Icon from '../Icon';
type InputFilterTextProps = {
className?: string;
value?: string;
placeholder: string;
onDebounceChange?: (val: string) => void;
onChange?: (val: string) => void;
debounceTime?: number;
};
/**
* A component to use as the input for text to filter by/on. A debounced callback is automatically provided
* so that the filtering in-turn will be debounced. There is also a straight onChange callback when the filter value is
* required immediately and NOT debounced. The debounce time is also configurable.
*/
const InputFilterText = ({
className,
value = '',
placeholder,
onDebounceChange,
onChange,
debounceTime = 200,
}: InputFilterTextProps): ReactElement => {
const [filterValue, setFilterValue] = useState<string>(value);
const searchInputRef = useRef(null);
const debouncedOnChange = useMemo(() => {
return debounce(onDebounceChange || (() => {}), debounceTime);
}, []);
// This allows for the filter value to be updated via the props.
useEffect(() => setFilterValue(value), [value]);
useEffect(() => {
return debouncedOnChange?.cancel();
}, []);
const handleFilterTextChanged = useCallback(value => {
setFilterValue(value);
if (onChange) {
onChange(value);
}
if (onDebounceChange) {
debouncedOnChange(value);
}
}, []);
return (
<label className={classNames('relative', className)}>
<span className="absolute inset-y-0 left-0 flex items-center pl-2">
<Icon name="icon-search"></Icon>
</span>
<input
ref={searchInputRef}
type="text"
className="border-inputfield-main focus:border-inputfield-focus disabled:border-inputfield-disabled placeholder:text-inputfield-placeholder block w-full w-full appearance-none rounded-md border bg-black py-2 px-9 text-base leading-tight shadow transition duration-300 focus:outline-none"
placeholder={placeholder}
onChange={event => handleFilterTextChanged(event.target.value)}
autoComplete="off"
value={filterValue}
></input>
<span className="absolute inset-y-0 right-0 flex items-center pr-2">
<Icon
name="icon-clear-field"
className={classNames('cursor-pointer', filterValue ? '' : 'hidden')}
onClick={() => {
searchInputRef.current.value = '';
handleFilterTextChanged('');
}}
></Icon>
</span>
</label>
);
};
export default InputFilterText;

View File

@@ -0,0 +1,59 @@
import InputFilterText from '../InputFilterText';
import { ArgsTable, Story, Canvas, Meta } from '@storybook/addon-docs';
export const argTypes = {
component: InputFilterText,
title: 'Components/InputFilterText',
};
<Meta
title="Components/InputFilterText"
component={InputFilterText}
/>
export const InputFilterTextTemplate = args => (
<div className="w-80">
<InputFilterText {...args} />
</div>
);
<Heading
title="InputFilterText"
componentRelativePath="InputFilterText/InputFilterText.tsx"
/>
- [Overview](#overview)
- [Props](#props)
- [Contribute](#contribute)
## Overview
InputFilterText is a component that is styled such that it can be used as a text input to filter a
list of textual items. It allows you to enter any text. There are two (optional) callbacks that can
be invoked as the characters of the text are entered: one callback is invoked as each character is
typed and another is debounced so that any filtering can occur once the user has entered a
significant amount of info and pausing by a configurable amount of time in milliseconds. The
component also provides a button on the far right of the component that when clicked will clear the
input text. The button only appears when there is text in the component.
<Canvas>
<Story
name="Overview"
args={{
className: 'w-full text-white',
placeholder: 'Search for...',
onChange: () => console.log('input text changed'),
onDebounceChange: () => console.log('debounce text changed'),
}}
>
{InputFilterTextTemplate.bind({})}
</Story>
</Canvas>
## Props
<ArgsTable of={InputFilterText} />
## Contribute
<Footer componentRelativePath="InputFilterText/__stories__/InputFilterText.stories.mdx" />

View File

@@ -0,0 +1,2 @@
import InputFilterText from './InputFilterText';
export default InputFilterText;

View File

@@ -0,0 +1,163 @@
import React from 'react';
import PropTypes from 'prop-types';
import classnames from 'classnames';
import getGridWidthClass from '../../utils/getGridWidthClass';
import InputText from '../InputText';
import InputDateRange from '../InputDateRange';
import InputMultiSelect from '../InputMultiSelect';
import InputLabelWrapper from '../InputLabelWrapper';
const InputGroup = ({
inputMeta,
values,
onValuesChange,
sorting,
onSortingChange,
isSortingEnabled,
}) => {
const { sortBy, sortDirection } = sorting;
const handleFilterLabelClick = name => {
if (isSortingEnabled) {
let _sortDirection = 'descending';
if (sortBy === name) {
if (sortDirection === 'ascending') {
_sortDirection = 'descending';
} else if (sortDirection === 'descending') {
_sortDirection = 'ascending';
}
}
onSortingChange({
sortBy: _sortDirection !== 'none' ? name : '',
sortDirection: _sortDirection,
});
}
};
const renderFieldInputComponent = ({ name, displayName, inputProps, isSortable, inputType }) => {
const _isSortable = isSortable && isSortingEnabled;
const _sortDirection = sortBy !== name ? 'none' : sortDirection;
const onLabelClick = () => {
handleFilterLabelClick(name);
};
const handleFieldChange = newValue => {
onValuesChange({
...values,
[name]: newValue,
});
};
const handleDateRangeFieldChange = ({ startDate, endDate }) => {
onValuesChange({
...values,
[name]: {
startDate: startDate,
endDate: endDate,
},
});
};
switch (inputType) {
case 'Text':
return (
<InputText
id={name}
key={name}
label={displayName}
isSortable={_isSortable}
sortDirection={_sortDirection}
onLabelClick={onLabelClick}
value={values[name]}
onChange={handleFieldChange}
/>
);
case 'MultiSelect':
return (
<InputMultiSelect
id={name}
key={name}
label={displayName}
isSortable={_isSortable}
sortDirection={_sortDirection}
onLabelClick={onLabelClick}
value={values[name]}
onChange={handleFieldChange}
options={inputProps.options}
/>
);
case 'DateRange':
return (
<InputDateRange
id={name}
key={name}
label={displayName}
isSortable={_isSortable}
sortDirection={_sortDirection}
onLabelClick={onLabelClick}
value={values[name]}
onChange={handleDateRangeFieldChange}
/>
);
case 'None':
return (
<InputLabelWrapper
key={name}
label={displayName}
isSortable={_isSortable}
sortDirection={_sortDirection}
onLabelClick={onLabelClick}
/>
);
default:
break;
}
};
return (
<div className="container relative m-auto flex flex-col">
<div className="flex w-full flex-row">
{inputMeta.map(inputMeta => {
return (
<div
key={inputMeta.name}
className={classnames('pl-4 first:pl-12', getGridWidthClass(inputMeta.gridCol))}
>
{renderFieldInputComponent(inputMeta)}
</div>
);
})}
</div>
</div>
);
};
InputGroup.propTypes = {
inputMeta: PropTypes.arrayOf(
PropTypes.shape({
name: PropTypes.string.isRequired,
displayName: PropTypes.string.isRequired,
inputType: PropTypes.oneOf(['Text', 'MultiSelect', 'DateRange', 'None']).isRequired,
isSortable: PropTypes.bool.isRequired,
gridCol: PropTypes.oneOf([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]).isRequired,
option: PropTypes.arrayOf(
PropTypes.shape({
value: PropTypes.string,
label: PropTypes.string,
})
),
})
).isRequired,
values: PropTypes.object.isRequired,
onValuesChange: PropTypes.func.isRequired,
sorting: PropTypes.shape({
sortBy: PropTypes.string,
sortDirection: PropTypes.oneOf(['ascending', 'descending', 'none']),
}).isRequired,
onSortingChange: PropTypes.func.isRequired,
isSortingEnabled: PropTypes.bool.isRequired,
};
export default InputGroup;

View File

@@ -0,0 +1,2 @@
import InputGroup from './InputGroup';
export default InputGroup;

View File

@@ -0,0 +1,65 @@
import React from 'react';
import PropTypes from 'prop-types';
import classnames from 'classnames';
import Icon from '../Icon';
const baseLabelClassName = 'flex flex-col flex-1 text-white text-lg pl-1 select-none';
const spanClassName = 'flex flex-row items-center cursor-pointer focus:outline-none';
const sortIconMap = {
descending: 'sorting-active-up',
ascending: 'sorting-active-down',
none: 'sorting',
};
const InputLabelWrapper = ({
label,
isSortable,
sortDirection,
onLabelClick,
className = '',
children,
}) => {
const onClickHandler = e => {
if (!isSortable) {
return;
}
onLabelClick(e);
};
return (
<label className={classnames(baseLabelClassName, className)}>
<span
role="button"
className={spanClassName}
onClick={onClickHandler}
onKeyDown={onClickHandler}
tabIndex="0"
>
{label}
{isSortable && (
<Icon
name={sortIconMap[sortDirection]}
className={classnames(
'mx-2 w-2',
sortDirection !== 'none' ? 'text-primary-light' : 'text-primary-main'
)}
/>
)}
</span>
<span>{children}</span>
</label>
);
};
InputLabelWrapper.propTypes = {
label: PropTypes.string.isRequired,
isSortable: PropTypes.bool.isRequired,
sortDirection: PropTypes.oneOf(['ascending', 'descending', 'none']).isRequired,
onLabelClick: PropTypes.func.isRequired,
className: PropTypes.string,
children: PropTypes.node,
};
export default InputLabelWrapper;

View File

@@ -0,0 +1,2 @@
import InputLabelWrapper from './InputLabelWrapper';
export default InputLabelWrapper;

View File

@@ -0,0 +1,72 @@
import React from 'react';
import PropTypes from 'prop-types';
import Select from '../Select';
import InputLabelWrapper from '../InputLabelWrapper';
const InputMultiSelect = ({
id,
label,
isSortable,
sortDirection,
onLabelClick,
value = [],
placeholder = '',
options = [],
onChange,
}) => {
return (
<InputLabelWrapper
label={label}
isSortable={isSortable}
sortDirection={sortDirection}
onLabelClick={onLabelClick}
>
<Select
id={id}
placeholder={placeholder}
className="mt-2"
options={options}
value={value}
isMulti={true}
isClearable={false}
isSearchable={true}
closeMenuOnSelect={false}
hideSelectedOptions={false}
onChange={(selectedOptions, action) => {
switch (action) {
case 'select-option':
case 'remove-value':
case 'deselect-option':
case 'clear':
onChange(selectedOptions);
break;
default:
break;
}
}}
/>
</InputLabelWrapper>
);
};
InputMultiSelect.propTypes = {
id: PropTypes.string,
label: PropTypes.string.isRequired,
isSortable: PropTypes.bool.isRequired,
sortDirection: PropTypes.oneOf(['ascending', 'descending', 'none']).isRequired,
onLabelClick: PropTypes.func.isRequired,
onChange: PropTypes.func.isRequired,
placeholder: PropTypes.string,
/** Array of options to list as options */
options: PropTypes.arrayOf(
PropTypes.shape({
value: PropTypes.string,
label: PropTypes.string,
})
),
/** Array of string values that exist in our list of options */
value: PropTypes.arrayOf(PropTypes.string),
};
export default InputMultiSelect;

Some files were not shown because too many files have changed in this diff Show More