fix/remove legacy ui (#5010)
2
.gitignore
vendored
@ -58,3 +58,5 @@ tests/test-results/
|
||||
tests/playwright-report/
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
|
||||
**/.claude/settings.local.json
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
import { ToolbarButton as ToolbarButtonLegacy } from '@ohif/ui';
|
||||
import { ToolButton, utils } from '@ohif/ui-next';
|
||||
|
||||
import ToolbarLayoutSelectorWithServices from './Toolbar/ToolbarLayoutSelector';
|
||||
@ -33,28 +32,6 @@ export default function getToolbarModule({ commandsManager, servicesManager }: w
|
||||
name: 'ohif.toolBoxButton',
|
||||
defaultComponent: ToolBoxButtonWrapper,
|
||||
},
|
||||
// legacy will get removed in the future
|
||||
// legacy will get removed in the future
|
||||
// legacy will get removed in the future
|
||||
// legacy will get removed in the future
|
||||
// legacy will get removed in the future
|
||||
// legacy will get removed in the future
|
||||
{
|
||||
name: 'ohif.radioGroup',
|
||||
defaultComponent: ToolbarButtonLegacy,
|
||||
},
|
||||
{
|
||||
name: 'ohif.buttonGroup',
|
||||
defaultComponent: ToolbarButtonGroupWithServicesLegacy,
|
||||
},
|
||||
{
|
||||
name: 'ohif.divider',
|
||||
defaultComponent: ToolbarDividerLegacy,
|
||||
},
|
||||
{
|
||||
name: 'ohif.splitButton',
|
||||
defaultComponent: ToolbarSplitButtonWithServicesLegacy,
|
||||
},
|
||||
// others
|
||||
{
|
||||
name: 'ohif.layoutSelector',
|
||||
|
||||
@ -18,8 +18,6 @@ import {
|
||||
StudyListTable,
|
||||
StudyListPagination,
|
||||
StudyListFilter,
|
||||
useSessionStorage,
|
||||
InvestigationalUseDialog,
|
||||
Button,
|
||||
ButtonEnums,
|
||||
} from '@ohif/ui';
|
||||
@ -32,8 +30,10 @@ import {
|
||||
TooltipContent,
|
||||
Clipboard,
|
||||
useModal,
|
||||
useSessionStorage,
|
||||
Onboarding,
|
||||
ScrollArea,
|
||||
InvestigationalUseDialog,
|
||||
} from '@ohif/ui-next';
|
||||
|
||||
import { Types } from '@ohif/ui';
|
||||
@ -60,7 +60,6 @@ function WorkList({
|
||||
onRefresh,
|
||||
servicesManager,
|
||||
}: withAppTypes) {
|
||||
const { hotkeyDefinitions, hotkeyDefaults } = hotkeysManager;
|
||||
const { show, hide } = useModal();
|
||||
const { t } = useTranslation();
|
||||
// ~ Modes
|
||||
@ -426,7 +425,7 @@ function WorkList({
|
||||
}}
|
||||
// to={`${mode.routeName}/dicomweb?StudyInstanceUIDs=${studyInstanceUid}`}
|
||||
>
|
||||
{/* TODO revisit the completely rounded style of buttons used for launching a mode from the worklist later - for now use LegacyButton*/}
|
||||
{/* TODO revisit the completely rounded style of buttons used for launching a mode from the worklist later */}
|
||||
<Button
|
||||
type={ButtonEnums.type.primary}
|
||||
size={ButtonEnums.size.medium}
|
||||
|
||||
4
platform/ui-next/src/hooks/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import useSessionStorage from './useSessionStorage';
|
||||
import useDynamicMaxHeight from './useDynamicMaxHeight';
|
||||
|
||||
export { useSessionStorage, useDynamicMaxHeight };
|
||||
@ -48,3 +48,5 @@ export function useDynamicMaxHeight(
|
||||
|
||||
return { ref, maxHeight };
|
||||
}
|
||||
|
||||
export default useDynamicMaxHeight;
|
||||
|
||||
@ -4,3 +4,4 @@ export * from './components';
|
||||
export * from './contextProviders';
|
||||
export * as Types from './types';
|
||||
export { utils, cn };
|
||||
export { useSessionStorage, useDynamicMaxHeight } from './hooks';
|
||||
|
||||
|
Before Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 1.5 KiB |
@ -1,150 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import detect from 'browser-detect';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import Typography from '../Typography';
|
||||
import { Icons } from '@ohif/ui-next';
|
||||
|
||||
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 && <Icons.ExternalLink 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;
|
||||
@ -1,50 +0,0 @@
|
||||
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" />
|
||||
@ -1,2 +0,0 @@
|
||||
import AboutModal from './AboutModal';
|
||||
export default AboutModal;
|
||||
@ -1,36 +0,0 @@
|
||||
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;
|
||||
@ -1,3 +0,0 @@
|
||||
import ActionButtons from './ActionButtons';
|
||||
|
||||
export default ActionButtons;
|
||||
@ -1,72 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import classnames from 'classnames';
|
||||
import { PanelSection, Tooltip } from '../../components';
|
||||
import ToolSettings from './ToolSettings';
|
||||
import { Icons } from '@ohif/ui-next';
|
||||
|
||||
/**
|
||||
* 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'
|
||||
)}
|
||||
>
|
||||
<Icons.ByName 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;
|
||||
@ -1,128 +0,0 @@
|
||||
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;
|
||||
@ -1,144 +0,0 @@
|
||||
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" />
|
||||
@ -1,4 +0,0 @@
|
||||
import AdvancedToolbox from './AdvancedToolbox';
|
||||
import ToolSettings from './ToolSettings';
|
||||
export default AdvancedToolbox;
|
||||
export { ToolSettings };
|
||||
@ -1,27 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Icons } from '@ohif/ui-next';
|
||||
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}
|
||||
>
|
||||
<Icons.ByName name="content-prev"></Icons.ByName>
|
||||
|
||||
<div className="pl-2">{backLabel || 'Back to Display Options'}</div>
|
||||
</div>
|
||||
<DividerItem></DividerItem>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default BackItem;
|
||||
@ -1,11 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
const DividerItem = () => {
|
||||
return (
|
||||
<div className="flex h-3.5 shrink-0 items-center px-0">
|
||||
<div className="bg-background h-px w-full"></div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DividerItem;
|
||||
@ -1,13 +0,0 @@
|
||||
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;
|
||||
@ -1,83 +0,0 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import OutsideClickHandler from 'react-outside-click-handler';
|
||||
import { MenuProps } from './Menu';
|
||||
import classNames from 'classnames';
|
||||
import { AllInOneMenu } from '..';
|
||||
import { Icons } from '@ohif/ui-next';
|
||||
|
||||
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}
|
||||
>
|
||||
<Icons.ByName name={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>
|
||||
);
|
||||
}
|
||||
@ -1,45 +0,0 @@
|
||||
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;
|
||||
@ -1,29 +0,0 @@
|
||||
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;
|
||||
@ -1,188 +0,0 @@
|
||||
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;
|
||||
@ -1,31 +0,0 @@
|
||||
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;
|
||||
@ -1,35 +0,0 @@
|
||||
import React, { useCallback, useContext } from 'react';
|
||||
import { MenuContext, MenuProps } from './Menu';
|
||||
import { Icons } from '@ohif/ui-next';
|
||||
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 && (
|
||||
<Icons.ByName
|
||||
name={props.itemIcon}
|
||||
className="mr-2"
|
||||
></Icons.ByName>
|
||||
)}
|
||||
<div className="mr-auto">{props.itemLabel}</div>
|
||||
<Icons.ByName name="content-next"></Icons.ByName>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SubMenu;
|
||||
@ -1,129 +0,0 @@
|
||||
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" />
|
||||
@ -1,11 +0,0 @@
|
||||
.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;
|
||||
}
|
||||
@ -1,19 +0,0 @@
|
||||
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,
|
||||
};
|
||||
@ -1,59 +0,0 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Typography } from '../../';
|
||||
import { Icons } from '@ohif/ui-next';
|
||||
|
||||
/**
|
||||
* 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 ? (
|
||||
<Icons.ByName name="checkbox-checked" />
|
||||
) : (
|
||||
<Icons.ByName 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;
|
||||
@ -1,49 +0,0 @@
|
||||
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" />
|
||||
@ -1,3 +0,0 @@
|
||||
import CheckBox from './CheckBox';
|
||||
|
||||
export default CheckBox;
|
||||
@ -1,3 +0,0 @@
|
||||
.cine-fps-range-tooltip .tooltip.tooltip-top {
|
||||
bottom: 85% !important;
|
||||
}
|
||||
@ -1,189 +0,0 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import debounce from 'lodash.debounce';
|
||||
|
||||
import Tooltip from '../Tooltip';
|
||||
import InputRange from '../InputRange';
|
||||
import { Icons } from '@ohif/ui-next';
|
||||
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?: {
|
||||
dimensionGroupNumber: number;
|
||||
numDimensionGroups: 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?.numDimensionGroups;
|
||||
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 handleDimensionGroupNumberChange = useCallback(
|
||||
(newGroupNumber: 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,
|
||||
dimensionGroupNumber: newGroupNumber,
|
||||
});
|
||||
}
|
||||
},
|
||||
[isDynamic, dynamicInfo]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{isDynamic && dynamicInfo && (
|
||||
<InputRange
|
||||
value={dynamicInfo.dimensionGroupNumber}
|
||||
onChange={handleDimensionGroupNumberChange}
|
||||
minValue={1}
|
||||
maxValue={dynamicInfo.numDimensionGroups}
|
||||
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'
|
||||
}
|
||||
>
|
||||
<Icons.ByName
|
||||
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.dimensionGroupNumber}</span>{' '}
|
||||
<span className="text-aqua-pale">{`/${dynamicInfo.numDimensionGroups}`}</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'}
|
||||
>
|
||||
<Icons.ChevronLeft />
|
||||
</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'}
|
||||
>
|
||||
<Icons.ChevronRight />
|
||||
</div>
|
||||
</div>
|
||||
<Icons.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({
|
||||
dimensionGroupNumber: PropTypes.number,
|
||||
numDimensionGroups: PropTypes.number,
|
||||
label: PropTypes.string,
|
||||
}),
|
||||
};
|
||||
|
||||
export default CinePlayer;
|
||||
@ -1,57 +0,0 @@
|
||||
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" />
|
||||
@ -1,2 +0,0 @@
|
||||
import CinePlayer from './CinePlayer';
|
||||
export default CinePlayer;
|
||||
@ -1,99 +0,0 @@
|
||||
/** 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;
|
||||
}
|
||||
@ -1,178 +0,0 @@
|
||||
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;
|
||||
@ -1,52 +0,0 @@
|
||||
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" />
|
||||
@ -1,3 +0,0 @@
|
||||
import DateRange from './DateRange';
|
||||
|
||||
export default DateRange;
|
||||
@ -1,27 +0,0 @@
|
||||
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;
|
||||
@ -1,84 +0,0 @@
|
||||
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;
|
||||
@ -1,50 +0,0 @@
|
||||
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;
|
||||
@ -1,48 +0,0 @@
|
||||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import Typography from '../Typography';
|
||||
import { Icons } from '@ohif/ui-next';
|
||||
|
||||
const CloseButton = ({ onClick }) => {
|
||||
return (
|
||||
<Icons.Close
|
||||
data-cy="close-button"
|
||||
onClick={onClick}
|
||||
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;
|
||||
@ -1,73 +0,0 @@
|
||||
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" />
|
||||
@ -1,2 +0,0 @@
|
||||
import Dialog from './Dialog';
|
||||
export default Dialog;
|
||||
@ -1,71 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import PortalTooltip from '../Tooltip/PortalTooltip';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Icons } from '@ohif/ui-next';
|
||||
/**
|
||||
* 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 (
|
||||
<>
|
||||
<Icons.ByName
|
||||
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;
|
||||
@ -1,2 +0,0 @@
|
||||
import DisplaySetMessageListTooltip from './DisplaySetMessageListTooltip';
|
||||
export default DisplaySetMessageListTooltip;
|
||||
@ -1,221 +0,0 @@
|
||||
import React, { useEffect, useCallback, useState, useRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classnames from 'classnames';
|
||||
import ReactDOM from 'react-dom';
|
||||
|
||||
import Typography from '../Typography';
|
||||
import { Icons } from '@ohif/ui-next';
|
||||
|
||||
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 && (
|
||||
<Icons.ByName
|
||||
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 && (
|
||||
<Icons.ByName
|
||||
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;
|
||||
@ -1,84 +0,0 @@
|
||||
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" />
|
||||
@ -1 +0,0 @@
|
||||
export { default } from './Dropdown';
|
||||
@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next';
|
||||
|
||||
import Typography from '../Typography';
|
||||
import { Icons } from '@ohif/ui-next';
|
||||
// TODO: Add loading spinner to OHIF + use it here.
|
||||
|
||||
const EmptyStudies = ({ className = '' }) => {
|
||||
const { t } = useTranslation('StudyList');
|
||||
return (
|
||||
|
||||
@ -1,124 +0,0 @@
|
||||
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 IconButton from '../IconButton';
|
||||
import { Icons } from '@ohif/ui-next';
|
||||
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>
|
||||
<Icons.ChevronOpen />
|
||||
</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;
|
||||
@ -1,2 +0,0 @@
|
||||
import ErrorBoundary from './ErrorBoundary';
|
||||
export default ErrorBoundary;
|
||||
@ -1,16 +0,0 @@
|
||||
.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;
|
||||
}
|
||||
@ -1,85 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classnames from 'classnames';
|
||||
|
||||
import IconButton from '../IconButton';
|
||||
import { Icons } from '@ohif/ui-next';
|
||||
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}
|
||||
>
|
||||
<Icons.ByName 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;
|
||||
@ -1,2 +0,0 @@
|
||||
import ExpandableToolbarButton from './ExpandableToolbarButton';
|
||||
export default ExpandableToolbarButton;
|
||||
@ -1,109 +0,0 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import NavBar from '../NavBar';
|
||||
import IconButton from '../IconButton';
|
||||
import Dropdown from '../Dropdown';
|
||||
import HeaderPatientInfo from '../HeaderPatientInfo';
|
||||
import { PatientInfoVisibility } from '../../types/PatientInfoVisibility';
|
||||
import { Icons } from '@ohif/ui-next';
|
||||
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 && <Icons.ChevronClosed className="text-primary-active w-8" />}
|
||||
<div className="ml-1">
|
||||
{WhiteLabeling?.createLogoComponentFn?.(React, props) || <Icons.OHIFLogo />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute top-1/2 left-[250px] h-8 -translate-y-1/2">{Secondary}</div>
|
||||
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 transform">
|
||||
<div className="flex items-center justify-center space-x-2">{children}</div>
|
||||
</div>
|
||||
<div className="absolute right-0 top-1/2 flex -translate-y-1/2 select-none items-center">
|
||||
{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"
|
||||
>
|
||||
<Icons.ByName 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;
|
||||
@ -1,57 +0,0 @@
|
||||
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" />
|
||||
@ -1,2 +0,0 @@
|
||||
import Header from './Header';
|
||||
export default Header;
|
||||
@ -1,133 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { utils } from '@ohif/core';
|
||||
import { Icons } from '@ohif/ui-next';
|
||||
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) : 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}
|
||||
>
|
||||
<Icons.ByName
|
||||
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>
|
||||
<Icons.ArrowLeft className={`text-primary-active ${expanded ? 'rotate-180' : ''}`} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
HeaderPatientInfo.propTypes = {
|
||||
servicesManager: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
export default HeaderPatientInfo;
|
||||
@ -1,3 +0,0 @@
|
||||
import HeaderPatientInfo from './HeaderPatientInfo';
|
||||
|
||||
export default HeaderPatientInfo;
|
||||
@ -1,61 +0,0 @@
|
||||
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;
|
||||
@ -1,3 +0,0 @@
|
||||
import HotkeyField from './HotkeyField.tsx';
|
||||
|
||||
export default HotkeyField;
|
||||
@ -1,28 +0,0 @@
|
||||
/**
|
||||
* 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 };
|
||||
@ -1,143 +0,0 @@
|
||||
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;
|
||||
@ -1,87 +0,0 @@
|
||||
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',
|
||||
};
|
||||
@ -1,78 +0,0 @@
|
||||
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 };
|
||||
@ -1,3 +0,0 @@
|
||||
import HotkeysPreferences from './HotkeysPreferences.tsx';
|
||||
|
||||
export default HotkeysPreferences;
|
||||
@ -1,47 +0,0 @@
|
||||
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 };
|
||||
@ -1,26 +0,0 @@
|
||||
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',
|
||||
};
|
||||