feat(ViewportActionMenu): window level per viewport / new patient info / colorbars/ 3D presets and 3D volume rendering (#3963)

Co-authored-by: Joe Boccanfuso <109477394+jbocce@users.noreply.github.com>
This commit is contained in:
IbrahimandJoe Boccanfuso authored and GitHub committed 2024-04-02 09:35:30 -04:00
1 parent 07301fc020
commit b7f90e3951
164 files changed
+5887 -932

No files matched your search

@@ -0,0 +1,27 @@
import React from 'react';
import { Icon } from '@ohif/ui';
import DividerItem from './DividerItem';
type BackItemProps = {
backLabel?: string;
onBackClick: () => void;
};
const BackItem = ({ backLabel, onBackClick }: BackItemProps) => {
return (
<>
<div
className="all-in-one-menu-item all-in-one-menu-item-effects"
onClick={onBackClick}
>
<Icon name="content-prev"></Icon>
<div className="pl-2">{backLabel || 'Back to Display Options'}</div>
</div>
<DividerItem></DividerItem>
</>
);
};
export default BackItem;
@@ -0,0 +1,11 @@
import React from 'react';
const DividerItem = () => {
return (
<div className="flex h-3.5 shrink-0 items-center px-2">
<div className="bg-primary-dark h-[2px] w-full"></div>
</div>
);
};
export default DividerItem;
@@ -0,0 +1,13 @@
import React, { ReactNode } from 'react';
type HeaderItemProps = {
children: ReactNode;
};
const HeaderItem = ({ children }: HeaderItemProps) => {
return (
<div className="text-aqua-pale mx-2 flex h-6 shrink-0 items-center text-[11px]">{children}</div>
);
};
export default HeaderItem;
@@ -0,0 +1,82 @@
import React, { useCallback, useEffect, useState } from 'react';
import OutsideClickHandler from 'react-outside-click-handler';
import { MenuProps } from './Menu';
import getIcon from '../Icon/getIcon';
import classNames from 'classnames';
import { AllInOneMenu } from '..';
export interface IconMenuProps extends MenuProps {
icon: string;
iconClassName?: string;
horizontalDirection?: AllInOneMenu.HorizontalDirection;
verticalDirection?: AllInOneMenu.VerticalDirection;
menuKey?: number | string;
}
/**
* An IconMenu allows for a div wrapped icon to be clicked to show and hide
* an AllInOneMenu.Menu. Based on the direction(s) specified, the menu is
* positioned relative to the icon.
*
* HorizontalDirection.LeftToRight - the left edges of the icon and menu are aligned
* HorizontalDirection.RightRoLeft - the right edges of the icon and menu are aligned
* VerticalDirection.TopToBottom - the top edge of the menu appears directly below the bottom edge of the icon
* VerticalDirection.BottomToTop - the bottom edge of the menu appears directly above the top edge of the icon
*
* For example, if an IconMenu were situated in the bottom-left corner of a container,
* it would be best to use BottomToTop and LeftToRight directions for it.
*/
export default function IconMenu({
icon,
iconClassName,
horizontalDirection,
verticalDirection,
children,
backLabel,
menuClassName,
menuStyle,
onVisibilityChange,
menuKey,
}: IconMenuProps) {
const [isMenuVisible, setIsMenuVisible] = useState(false);
const toggleMenuVisibility = useCallback(() => setIsMenuVisible(isVisible => !isVisible), []);
return (
<OutsideClickHandler
onOutsideClick={toggleMenuVisibility}
disabled={!isMenuVisible}
>
<div className="relative">
<div
className={iconClassName}
onClick={toggleMenuVisibility}
>
{getIcon(icon)}
</div>
<AllInOneMenu.Menu
key={menuKey}
isVisible={isMenuVisible}
backLabel={backLabel}
menuClassName={classNames(
menuClassName,
'absolute',
verticalDirection === AllInOneMenu.VerticalDirection.TopToBottom
? 'top-[100%]'
: 'bottom-[100%]',
horizontalDirection === AllInOneMenu.HorizontalDirection.LeftToRight
? 'left-0'
: 'right-0'
)}
menuStyle={menuStyle}
onVisibilityChange={isVis => {
setIsMenuVisible(isVis);
onVisibilityChange?.(isVis);
}}
horizontalDirection={horizontalDirection}
>
{children}
</AllInOneMenu.Menu>
</div>
</OutsideClickHandler>
);
}
@@ -0,0 +1,45 @@
import React, { ReactNode, useCallback, useContext } from 'react';
import { MenuContext } from './Menu';
type ItemProps = {
label: string;
secondaryLabel?: string;
icon?: ReactNode;
onClick?: () => void;
onMouseEnter?: () => void;
onMouseLeave?: () => void;
rightIcon?: ReactNode;
};
const Item = ({
label,
secondaryLabel,
icon,
rightIcon,
onClick,
onMouseEnter,
onMouseLeave,
}: ItemProps) => {
const { hideMenu } = useContext(MenuContext);
const onClickHandler = useCallback(() => {
hideMenu();
onClick?.();
}, [hideMenu, onClick]);
return (
<div
className="all-in-one-menu-item all-in-one-menu-item-effects"
onClick={onClickHandler}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
{icon && <div className="pr-2">{icon}</div>}
<span>{label}</span>
{secondaryLabel != null && <span className="text-aqua-pale ml-[1ch]">{secondaryLabel}</span>}
{rightIcon && <div className="ml-auto">{rightIcon}</div>}
</div>
);
};
export default Item;
@@ -0,0 +1,29 @@
import React, { ReactNode, useContext, useEffect } from 'react';
import { MenuContext } from './Menu';
type ItemPanelProps = {
label?: string;
index?: number;
children: ReactNode;
};
const ItemPanel = ({ label, index = 0, children }: ItemPanelProps) => {
const { addItemPanel, activePanelIndex } = useContext(MenuContext);
useEffect(() => {
addItemPanel(index, label);
}, []);
return (
activePanelIndex === index && (
<div
style={{ scrollbarGutter: 'auto' }}
className="ohif-scrollbar flex flex-col overflow-auto"
>
{children}
</div>
)
);
};
export default ItemPanel;
@@ -0,0 +1,188 @@
import React, { createContext, ReactNode, useCallback, useEffect, useState } from 'react';
import './allInOneMenu.css';
import DividerItem from './DividerItem';
import PanelSelector from './PanelSelector';
import classNames from 'classnames';
import BackItem from './BackItem';
/**
* The vertical direction that the menu will be opened/used with.
*
* A TopToBottom menu would be used for cases where the menu is opened "near"
* the top edge of its container. Likewise a BottomToTop menu would be used
* for cases where the menu is opened "near" the bottom edge of its container.
*
* See IconMenu for more information.
*/
export enum VerticalDirection {
TopToBottom,
BottomToTop,
}
/**
* The horizontal direction that the menu is opened/used with.
* This direction dictates the general direction sub-menus and
* back-to-menus are opened with. For example, a RightToLeft menu
* will have sub-menu items indicated with a left pointing chevron
* and aligned with the left edge of the menu. Similarly back-to items of a
* RightToLeft menu are indicated with a right pointing chevron and
* aligned with the right edge of the menu.
*
* It is also worth noting that a LeftToRight menu would be used for
* cases where a menu is opened "near" the left edge of its container.
* Likewise, a RightToLeft menu would be used for cases where a menu is opened
* "near" the right edge of its container.
*
* See IconMenu for more information.
*/
export enum HorizontalDirection {
LeftToRight,
RightToLeft,
}
export interface MenuProps {
menuStyle?: unknown;
menuClassName?: string;
isVisible?: boolean;
preventHideMenu?: boolean;
backLabel?: string;
headerComponent?: ReactNode;
showHeaderDivider?: boolean;
activePanelIndex?: number;
onVisibilityChange?: (isVisible: boolean) => void;
horizontalDirection?: HorizontalDirection;
children: ReactNode;
}
type MenuContextProps = {
showSubMenu: (subMenuProps: MenuProps) => void;
hideMenu: () => void;
addItemPanel: (index: number, label: string) => void;
horizontalDirection: HorizontalDirection;
activePanelIndex: number;
};
type MenuPathState = {
props: MenuProps;
activePanelIndex: number;
};
export const MenuContext = createContext<MenuContextProps>(null);
const Menu = (props: MenuProps) => {
const {
isVisible,
onVisibilityChange,
activePanelIndex,
preventHideMenu,
menuClassName,
menuStyle,
horizontalDirection = HorizontalDirection.LeftToRight,
} = props;
const [isMenuVisible, setIsMenuVisible] = useState(isVisible);
// The menuPath is an array consisting of this top Menu and every SubMenu
// that has been traversed/opened by the user with the last item in the array
// being the current (sub)menu that is currently visible. This allows for the previously
// viewed menus to be returned to via the Back button at the top of the menu.
const [menuPath, setMenuPath] = useState<Array<MenuPathState>>([
{ props, activePanelIndex: activePanelIndex || 0 },
]);
const [itemPanelLabels, setItemPanelLabels] = useState<Array<string>>([]);
const hideMenu = useCallback(() => {
if (preventHideMenu) {
return;
}
setMenuPath(path => [path[0]]);
setItemPanelLabels([]);
setIsMenuVisible(false);
onVisibilityChange?.(false);
}, [preventHideMenu, onVisibilityChange]);
useEffect(() => {
if (isVisible) {
setIsMenuVisible(isVisible);
onVisibilityChange?.(isVisible);
} else {
hideMenu();
}
}, [hideMenu, isVisible, onVisibilityChange]);
const showSubMenu = useCallback((subMenuProps: MenuProps) => {
setMenuPath(path => {
return [
...path,
{ props: subMenuProps, activePanelIndex: subMenuProps.activePanelIndex || 0 },
];
});
setItemPanelLabels([]);
}, []);
const addItemPanel = useCallback((index, label) => {
setItemPanelLabels(labels => {
return [...labels.slice(0, index), label, ...labels.slice(index + 1, labels.length)];
});
}, []);
const onActivePanelIndexChange = useCallback(index => {
setMenuPath(path => {
return [
...path.slice(0, path.length - 1),
{ ...path[path.length - 1], activePanelIndex: index },
];
});
}, []);
const onBackClick = useCallback(() => {
setMenuPath(path => [...path.slice(0, path.length - 1)]);
setItemPanelLabels([]);
}, []);
const { props: currentMenuProps, activePanelIndex: currentMenuActivePanelIndex } =
menuPath[menuPath.length - 1];
return (
<>
<MenuContext.Provider
value={{
showSubMenu,
hideMenu,
addItemPanel,
activePanelIndex: currentMenuActivePanelIndex,
horizontalDirection,
}}
>
{isMenuVisible && (
<div
className={classNames(
'bg-secondary-dark flex select-none flex-col rounded px-1 py-1.5 text-white opacity-90',
menuClassName
)}
style={menuStyle}
>
{menuPath.length > 1 && (
<BackItem
backLabel={menuPath[menuPath.length - 2].props.backLabel}
onBackClick={onBackClick}
/>
)}
{itemPanelLabels.length > 1 && (
<PanelSelector
panelLabels={itemPanelLabels}
activeIndex={currentMenuActivePanelIndex}
onActiveIndexChange={onActivePanelIndexChange}
></PanelSelector>
)}
{currentMenuProps.headerComponent}
{currentMenuProps.showHeaderDivider && <DividerItem />}
{currentMenuProps.children}
</div>
)}
</MenuContext.Provider>
</>
);
};
export default Menu;
@@ -0,0 +1,31 @@
import React, { ReactNode } from 'react';
import { ButtonGroup } from '../../components';
type PanelSelectorProps = {
panelLabels: Array<ReactNode>;
onActiveIndexChange: (index: number) => void;
activeIndex: number;
};
const PanelSelector = ({ panelLabels, onActiveIndexChange, activeIndex }: PanelSelectorProps) => {
const getButtons = () => {
return panelLabels.map((panelLabel, index) => {
return {
children: panelLabel,
key: index,
};
});
};
return (
<div className="mx-2 my-1 flex justify-center">
<ButtonGroup
buttons={getButtons()}
onActiveIndexChange={onActiveIndexChange}
defaultActiveIndex={activeIndex}
></ButtonGroup>
</div>
);
};
export default PanelSelector;
@@ -0,0 +1,38 @@
import React, { useCallback, useContext } from 'react';
import { MenuContext, MenuProps } from './Menu';
import Icon from '../Icon';
export interface SubMenuProps extends MenuProps {
itemLabel: string;
onClick?: () => void;
itemIcon?: string;
}
const SubMenu = (props: SubMenuProps) => {
const { showSubMenu } = useContext(MenuContext);
const onClickHandler = useCallback(() => {
showSubMenu(props);
props.onClick?.();
}, [showSubMenu, props]);
return (
<div
className="all-in-one-menu-item all-in-one-menu-item-effects flex items-center"
onClick={onClickHandler}
>
{props.itemIcon && (
<Icon
name={props.itemIcon}
width="25px"
height="25px"
className="mr-2"
></Icon>
)}
<div className="mr-auto">{props.itemLabel}</div>
<Icon name="content-next"></Icon>
</div>
);
};
export default SubMenu;
@@ -0,0 +1,129 @@
import { DividerItem, HeaderItem, Item, ItemPanel, Menu, SubMenu } from '..';
import InputRange from '../../InputRange/index.js';
import SwitchButton from '../../SwitchButton/index.js';
import { ArgsTable, Story, Canvas, Meta } from '@storybook/addon-docs';
export const argTypes = {
component: Menu,
title: 'Components/AllInOneMenu',
};
<Meta
title="Components/AllInOneMenu"
component={Menu}
/>
export const AllInOneMenuTemplate = args => (
<div className="w-80">
<Menu {...args}>
<ItemPanel>
<Item
label="Item 1"
key="0"
onClick={() => console.info('Item 1 clicked.')}
></Item>
<Item
label="Item 2"
secondaryLabel="Alt item 2"
key="1"
></Item>
<div
className="all-in-one-menu-item py-1"
style={{ flexBasis: 'content' }}
>
<div>Arbitrary item component:</div>
<InputRange
minValue={1}
maxValue={10}
value={5}
onChange={() => {}}
></InputRange>
</div>
<DividerItem key="2"></DividerItem>
<SubMenu
key="3"
backLabel="Back to Level 2"
itemLabel="Item 3 opens a sub menu"
onClick={() => console.info('Sub menu item clicked.')}
showHeaderDivider={true}
headerComponent={
<div className="all-in-one-menu-item flex w-full justify-center">
<SwitchButton label="Switch item in header" />
</div>
}
>
<ItemPanel
index={0}
label="Panel A"
>
<Item label="Panel A item"></Item>
</ItemPanel>
<ItemPanel
index={1}
label="Panel B"
>
<Item label="Panel B item"></Item>
<SubMenu
itemLabel="Opens a sub, sub menu"
headerComponent={<HeaderItem>Header for scrolling list of items</HeaderItem>}
>
<ItemPanel label="Sub sub Panel">
<Item label="Sub sub menu item 1"></Item>
<Item label="Sub sub menu item 2"></Item>
<Item label="Sub sub menu item 3"></Item>
<Item label="Sub sub menu item 4"></Item>
<Item label="Sub sub menu item 5"></Item>
<Item label="Sub sub menu item 6"></Item>
</ItemPanel>
</SubMenu>
</ItemPanel>
</SubMenu>
</ItemPanel>
</Menu>
</div>
);
<Heading
title="AllInOneMenu"
componentRelativePath="AllInOneMenu/AllInOneMenu.tsx"
/>
- [Overview](#overview)
- [Props](#props)
- [Contribute](#contribute)
## Overview
AllInOneMenu is a component that renders a menu with various menu items, sub-menus and
sub-components. The particular feature of the AllInOneMenu is its ability to render a sub-menu by
replacing the parent menu on screen. It also provides the ability to return to the parent menu with
a back menu item from the sub-menu. Furthermore, each menu level can be split into item panes - that
is several panes of menu items that are switched in and out of view like a tabbed pane.
<Canvas>
<Story
name="Overview"
args={{
className: 'max-h-[210px]',
isVisible: true,
preventHideMenu: true,
backLabel: 'Back to Level 1',
onVisibilityChange: (isVisible) => console.info(`The menu visibility: ${isVisible}`),
}}
>
{AllInOneMenuTemplate.bind({})}
</Story>
</Canvas>
## Props
<ArgsTable of={Menu} />
## Usage
## Contribute
<Footer componentRelativePath="AllInOneMenu/__stories__/AllInOneMenu.stories.mdx" />
@@ -0,0 +1,11 @@
.all-in-one-menu-item {
@apply h-8 px-2 text-[14px] w-full;
display: flex;
align-items: center;
flex-shrink: 0;
line-height: 18px;
}
.all-in-one-menu-item-effects {
@apply cursor-pointer hover:bg-primary-dark hover:rounded;
}
@@ -0,0 +1,19 @@
import Menu, { HorizontalDirection, VerticalDirection } from './Menu';
import DividerItem from './DividerItem';
import HeaderItem from './HeaderItem';
import IconMenu from './IconMenu';
import Item from './Item';
import ItemPanel from './ItemPanel';
import SubMenu from './SubMenu';
export {
Menu,
DividerItem,
HeaderItem,
IconMenu,
Item,
ItemPanel,
SubMenu,
HorizontalDirection,
VerticalDirection,
};
@@ -41,7 +41,7 @@ const ButtonGroup = ({
return cloneElement(child, {
key: index,
className: classnames(
'rounded-[4px] px-2 py-1',
'rounded-[4px] px-2 py-1 text-center',
index === activeIndex
? 'bg-customblue-40 text-white'
: 'text-primary-active bg-black',
+31 -27
View File
@@ -8,6 +8,8 @@ import Svg from '../Svg';
import Icon from '../Icon';
import IconButton from '../IconButton';
import Dropdown from '../Dropdown';
import HeaderPatientInfo from '../HeaderPatientInfo';
import { PatientInfoVisibility } from '../../types/PatientInfoVisibility';
function Header({
children,
@@ -16,6 +18,8 @@ function Header({
onClickReturnButton,
isSticky,
WhiteLabeling,
showPatientInfo = PatientInfoVisibility.VISIBLE_COLLAPSED,
servicesManager,
...props
}): ReactNode {
const { t } = useTranslation('Header');
@@ -34,7 +38,7 @@ function Header({
{...props}
>
<div className="relative h-[48px] items-center ">
<div className="absolute left-0 top-1/2 -translate-y-1/2 ">
<div className="absolute left-0 top-1/2 flex -translate-y-1/2 items-center">
<div
className={classNames(
'mr-3 inline-flex items-center',
@@ -49,7 +53,7 @@ function Header({
className="text-primary-active w-8"
/>
)}
<div className="ml-4">
<div className="ml-1">
{WhiteLabeling?.createLogoComponentFn?.(React, props) || <Svg name="logo-ohif" />}
</div>
</div>
@@ -58,32 +62,30 @@ function Header({
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 transform">
<div className="flex items-center justify-center space-x-2">{children}</div>
</div>
<div className="absolute right-0 top-1/2 -translate-y-1/2 ">
<Dropdown
id="options"
showDropdownIcon={false}
list={menuOptions}
alignment="right"
>
<IconButton
id={'options-settings-icon'}
variant="text"
color="inherit"
size="initial"
className="text-primary-active"
<div className="absolute right-0 top-1/2 flex -translate-y-1/2 select-none items-center">
{(showPatientInfo === PatientInfoVisibility.VISIBLE ||
showPatientInfo === PatientInfoVisibility.VISIBLE_COLLAPSED) && (
<HeaderPatientInfo servicesManager={servicesManager} />
)}
<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"
>
<Icon name="settings" />
</IconButton>
<IconButton
id={'options-chevron-down-icon'}
variant="text"
color="inherit"
size="initial"
className="text-primary-active"
>
<Icon name="chevron-down" />
</IconButton>
</Dropdown>
<IconButton
id={'options-settings-icon'}
variant="text"
color="inherit"
size="initial"
className="text-primary-active hover:bg-primary-dark h-full w-full"
>
<Icon name="icon-settings" />
</IconButton>
</Dropdown>
</div>
</div>
</div>
</NavBar>
@@ -103,6 +105,8 @@ Header.propTypes = {
isSticky: PropTypes.bool,
onClickReturnButton: PropTypes.func,
WhiteLabeling: PropTypes.object,
showPatientInfo: PropTypes.string,
servicesManager: PropTypes.object,
};
Header.defaultProps = {
@@ -0,0 +1,134 @@
import React, { useState, useEffect } from 'react';
import PropTypes from 'prop-types';
import { Icon } from '@ohif/ui';
import { utils } from '@ohif/core';
import { useAppConfig } from '@state';
const { formatDate, formatPN } = utils;
const formatWithEllipsis = (str, maxLength) => {
if (str.length > maxLength) {
return str.substring(0, maxLength) + '...';
}
return str;
};
function usePatientInfo(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 || '',
PatientName: instance.PatientName ? formatPN(instance.PatientName.Alphabetic) : '',
PatientSex: instance.PatientSex || '',
PatientDOB: formatDate(instance.PatientBirthDate) || '',
});
checkMixedPatients(instance.PatientID || '');
};
useEffect(() => {
const subscription = displaySetService.subscribe(
displaySetService.EVENTS.DISPLAY_SETS_ADDED,
() => updatePatientInfo()
);
return () => subscription.unsubscribe();
}, []);
useEffect(() => {
updatePatientInfo();
}, [displaySets]);
return { patientInfo, isMixedPatients };
}
function HeaderPatientInfo({ servicesManager }) {
const [appConfig] = useAppConfig();
const initialExpandedState = appConfig.showPatientInfo === 'visible';
const [expanded, setExpanded] = useState(initialExpandedState);
const { patientInfo, isMixedPatients } = usePatientInfo(servicesManager);
useEffect(() => {
if (isMixedPatients && expanded) {
setExpanded(false);
}
}, [isMixedPatients, expanded]);
const handleOnClick = () => {
if (!isMixedPatients) {
setExpanded(!expanded);
}
};
const formattedPatientName = formatWithEllipsis(patientInfo.PatientName, 27);
const formattedPatientID = formatWithEllipsis(patientInfo.PatientID, 15);
return (
<div
className="align-items-center hover:bg-primary-dark flex cursor-pointer justify-center gap-1 rounded-lg"
onClick={handleOnClick}
>
<Icon
name={isMixedPatients ? 'icon-multiple-patients' : 'icon-patient'}
className="text-primary-active"
/>
<div className="flex flex-col justify-center">
{expanded ? (
<>
<div className="self-start text-[13px] font-bold text-white">
{formattedPatientName}
</div>
<div className="text-aqua-pale flex gap-2 text-[11px]">
<div>{formattedPatientID}</div>
<div>{patientInfo.PatientSex}</div>
<div>{patientInfo.PatientDOB}</div>
</div>
</>
) : (
<div className="text-primary-active self-center text-[13px]">
{' '}
{isMixedPatients ? 'Multiple Patients' : 'Patient'}
</div>
)}
</div>
<Icon
name="icon-chevron-patient"
className={`text-primary-active ${expanded ? 'rotate-180' : ''}`}
/>
</div>
);
}
HeaderPatientInfo.propTypes = {
servicesManager: PropTypes.object.isRequired,
};
export default HeaderPatientInfo;
@@ -0,0 +1,3 @@
import HeaderPatientInfo from './HeaderPatientInfo';
export default HeaderPatientInfo;
+97 -5
View File
@@ -17,6 +17,8 @@ import chevronMenu from './../../assets/icons/chevron-menu.svg';
import chevronNext from './../../assets/icons/chevron-next.svg';
import chevronPrev from './../../assets/icons/chevron-prev.svg';
import chevronRight from './../../assets/icons/chevron-right.svg';
import contentNext from './../../assets/icons/content-next.svg';
import contentPrev from './../../assets/icons/content-prev.svg';
import eyeVisible from './../../assets/icons/eye-visible.svg';
import eyeHidden from './../../assets/icons/eye-hidden.svg';
import exclamation from './../../assets/icons/exclamation.svg';
@@ -83,6 +85,9 @@ import notificationsInfo from './../../assets/icons/notifications-info.svg';
import notificationsWarning from './../../assets/icons/notifications-warning.svg';
import notificationsError from './../../assets/icons/notifications-error.svg';
import notificationsSuccess from './../../assets/icons/notifications-success.svg';
import nextArrow from './../../assets/icons/next-arrow.svg';
import prevArrow from './../../assets/icons/prev-arrow.svg';
import viewportStatusTracked from './../../assets/icons/viewport-status-tracked.svg';
/** Tools */
import toggleDicomOverlay from './../../assets/icons/toggle-dicom-overlay.svg';
@@ -139,12 +144,53 @@ import iconToolEraser from './../../assets/icons/icon-tool-eraser.svg';
import iconToolScissor from './../../assets/icons/icon-tool-scissor.svg';
import iconToolShape from './../../assets/icons/icon-tool-shape.svg';
import iconToolThreshold from './../../assets/icons/icon-tool-threshold.svg';
import viewportWindowLevel from './../../assets/icons/viewport-window-level.svg';
/** Old OHIF */
import oldTrash from './../../assets/icons/old-trash.svg';
import oldPlay from './../../assets/icons/old-play.svg';
import oldStop from './../../assets/icons/old-stop.svg';
/** ColorLut */
import iconColorLUT from './../../assets/icons/icon-color-lut.svg';
/** New Patient Info Toolbar */
import iconChevronPatient from './../../assets/icons/icon-chevron-patient.svg';
import iconPatient from './../../assets/icons/icon-patient.svg';
import iconSettings from './../../assets/icons/icon-settings.svg';
import iconToolbarBack from './../../assets/icons/icon-toolbar-back.svg';
import iconMultiplePatients from './../../assets/icons/icon-multiple-patients.svg';
/** Volume Rendering */
import CTAAA from './../../assets/icons/CT-AAA.png';
import CTAAA2 from './../../assets/icons/CT-AAA2.png';
import CTAir from './../../assets/icons/CT-Air.png';
import CTBone from './../../assets/icons/CT-Bone.png';
import CTBones from './../../assets/icons/CT-Bones.png';
import CTCardiac from './../../assets/icons/CT-Cardiac.png';
import CTCardiac2 from './../../assets/icons/CT-Cardiac2.png';
import CTCardiac3 from './../../assets/icons/CT-Cardiac3.png';
import CTChestContrastEnhanced from './../../assets/icons/CT-Chest-Contrast-Enhanced.png';
import CTChestVessels from './../../assets/icons/CT-Chest-Vessels.png';
import CTCoronaryArteries from './../../assets/icons/CT-Coronary-Arteries.png';
import CTCoronaryArteries2 from './../../assets/icons/CT-Coronary-Arteries-2.png';
import CTCoronaryArteries3 from './../../assets/icons/CT-Coronary-Arteries-3.png';
import CTCroppedVolumeBone from './../../assets/icons/CT-Cropped-Volume-Bone.png';
import CTFat from './../../assets/icons/CT-Fat.png';
import CTLiverVasculature from './../../assets/icons/CT-Liver-Vasculature.png';
import CTLung from './../../assets/icons/CT-Lung.png';
import CTMIP from './../../assets/icons/CT-MIP.png';
import CTMuscle from './../../assets/icons/CT-Muscle.png';
import CTPulmonaryArteries from './../../assets/icons/CT-Pulmonary-Arteries.png';
import CTSoftTissue from './../../assets/icons/CT-Soft-Tissue.png';
import DTIFABrain from './../../assets/icons/DTI-FA-Brain.png';
import MRAngio from './../../assets/icons/MR-Angio.png';
import MRDefault from './../../assets/icons/MR-Default.png';
import MRMIP from './../../assets/icons/MR-MIP.png';
import MRT2Brain from './../../assets/icons/MR-T2-Brain.png';
import VolumeRendering from './../../assets/icons/VolumeRendering.png';
import actionNewDialog from './../../assets/icons/action-new-dialog.svg';
/** LAYOUT */
import layoutAdvanced3DFourUp from './../../assets/icons/layout-advanced-3d-four-up.svg';
@@ -160,7 +206,6 @@ import layoutCommon2x3 from './../../assets/icons/layout-common-2x3.svg';
import iconToolRotate from './../../assets/icons/tool-3d-rotate.svg';
/** New investigational use */
import investigationalUse from './../../assets/icons/illustration-investigational-use.svg';
const ICONS = {
@@ -180,6 +225,8 @@ const ICONS = {
'chevron-next': chevronNext,
'chevron-prev': chevronPrev,
'chevron-right': chevronRight,
'content-next': contentNext,
'content-prev': contentPrev,
'eye-visible': eyeVisible,
'eye-hidden': eyeHidden,
'external-link': externalLink,
@@ -304,12 +351,51 @@ const ICONS = {
'icon-tool-scissor': iconToolScissor,
'icon-tool-shape': iconToolShape,
'icon-tool-threshold': iconToolThreshold,
'next-arrow': nextArrow,
'prev-arrow': prevArrow,
'viewport-status-tracked': viewportStatusTracked,
'viewport-window-level': viewportWindowLevel,
/** Old OHIF */
'old-trash': oldTrash,
'old-play': oldPlay,
'old-stop': oldStop,
/** ColorLut */
'icon-color-lut': iconColorLUT,
/** New Patient Info Toolbar */
'icon-chevron-patient': iconChevronPatient,
'icon-patient': iconPatient,
'icon-settings': iconSettings,
'icon-toolbar-back': iconToolbarBack,
'icon-multiple-patients': iconMultiplePatients,
/** Volume Rendering */
'CT-AAA': CTAAA,
'CT-AAA2': CTAAA2,
'CT-Air': CTAir,
'CT-Bone': CTBone,
'CT-Bones': CTBones,
'CT-Cardiac': CTCardiac,
'CT-Cardiac2': CTCardiac2,
'CT-Cardiac3': CTCardiac3,
'CT-Chest-Contrast-Enhanced': CTChestContrastEnhanced,
'CT-Chest-Vessels': CTChestVessels,
'CT-Coronary-Arteries': CTCoronaryArteries,
'CT-Coronary-Arteries-2': CTCoronaryArteries2,
'CT-Coronary-Arteries-3': CTCoronaryArteries3,
'CT-Cropped-Volume-Bone': CTCroppedVolumeBone,
'CT-Fat': CTFat,
'CT-Liver-Vasculature': CTLiverVasculature,
'CT-Lung': CTLung,
'CT-MIP': CTMIP,
'CT-Muscle': CTMuscle,
'CT-Pulmonary-Arteries': CTPulmonaryArteries,
'CT-Soft-Tissue': CTSoftTissue,
'DTI-FA-Brain': DTIFABrain,
'MR-Angio': MRAngio,
'MR-Default': MRDefault,
'MR-MIP': MRMIP,
'MR-T2-Brain': MRT2Brain,
VolumeRendering: VolumeRendering,
'action-new-dialog': actionNewDialog,
/** LAYOUT */
'layout-advanced-3d-four-up': layoutAdvanced3DFourUp,
'layout-advanced-3d-main': layoutAdvanced3DMain,
@@ -340,11 +426,17 @@ function addIcon(iconName, iconSVG) {
* return `null`
*/
export default function getIcon(key, props) {
if (!key || !ICONS[key]) {
const icon = ICONS[key];
if (!key || !icon) {
return React.createElement('div', null, 'Missing Icon');
}
return React.createElement(ICONS[key], props);
if (typeof icon === 'string' && icon.endsWith('.png')) {
return React.createElement('img', { src: icon, ...props });
} else {
return React.createElement(icon, props);
}
}
export { getIcon, ICONS, addIcon };
@@ -1,13 +1,13 @@
.scroll {
height: 100%;
height: calc(100% - 30px);
padding: 5px;
position: absolute;
right: 0;
top: 0;
top: 30px;
}
.scroll .scroll-holder {
height: calc(100% - 20px);
margin-top: 5px;
height: calc(100%);
margin-bottom: 5px;
position: relative;
width: 12px;
}
@@ -12,6 +12,12 @@ import getMaxDigits from '../../utils/getMaxDigits';
*
*
*/
type Label = {
text: string;
position: number;
};
type InputRangeProps = {
value: number;
onChange: (value: number) => void;
@@ -28,6 +34,7 @@ type InputRangeProps = {
trackColor?: string;
allowNumberEdit?: boolean;
showAdjustmentArrows?: boolean;
labels?: Label[];
};
const InputRange: React.FC<InputRangeProps> = ({
@@ -46,6 +53,7 @@ const InputRange: React.FC<InputRangeProps> = ({
trackColor,
allowNumberEdit = false,
showAdjustmentArrows = true,
labels = [],
}) => {
const [rangeValue, setRangeValue] = useState(value);
@@ -114,6 +122,22 @@ const InputRange: React.FC<InputRangeProps> = ({
{showLabel && labelPosition === 'right' && (
<div style={{ width: labelWidth }}>{LabelOrEditableNumber}</div>
)}
{labels.length > 0 && (
<>
{labels.map((label, index) => {
const position = label.position;
return (
<div
key={index}
className="absolute !m-0"
style={{ left: `calc(${position}%)`, bottom: '-20px' }}
>
<span className="text-aqua-pale text-xs">{label.text}</span>
</div>
);
})}
</>
)}
</div>
</div>
);
+64 -29
View File
@@ -1,6 +1,7 @@
import React from 'react';
import PropTypes from 'prop-types';
import ReactModal from 'react-modal';
import Draggable from 'react-draggable';
import { useModal } from '../../contextProviders';
import Icon from '../Icon';
@@ -20,6 +21,9 @@ const Modal = ({
onClose,
children,
shouldCloseOnOverlayClick,
movable = false,
containerDimensions = null,
contentDimensions = null,
}) => {
const { hide } = useModal();
@@ -27,44 +31,70 @@ const Modal = ({
hide();
};
const renderHeader = () => {
return (
title && (
<header className="bg-primary-dark flex items-center rounded-tl rounded-tr px-[20px] py-[13px]">
<Typography
variant="h6"
color="primaryLight"
className="flex grow !leading-[1.2]"
data-cy="modal-header"
>
{title}
</Typography>
{closeButton && (
<Icon
onClick={onClose}
name="close"
className="text-primary-active cursor-pointer"
/>
)}
</header>
)
const renderHeader = () =>
title && (
<header className="bg-primary-dark drag-handle flex items-center rounded-tl rounded-tr px-[20px] py-[13px]">
<Typography
variant="h6"
color="primaryLight"
className="flex grow !leading-[1.2]"
data-cy="modal-header"
>
{title}
</Typography>
{closeButton && (
<Icon
onClick={onClose}
name="close"
className="text-primary-active cursor-pointer"
/>
)}
</header>
);
};
const modalContent = (
<>
{renderHeader()}
<section
className={
contentDimensions
? `ohif-scrollbar bg-primary-dark overflow-y-auto ${contentDimensions}`
: 'ohif-scrollbar modal-content bg-primary-dark overflow-y-auto rounded-bl rounded-br px-[20px] pt-2 pb-[20px]'
}
>
{children}
</section>
</>
);
return (
<ReactModal
className="relative max-h-full w-11/12 text-white outline-none lg:w-10/12 xl:w-9/12"
overlayClassName="fixed top-0 left-0 right-0 bottom-0 z-50 bg-overlay flex items-start justify-center py-16"
className={
containerDimensions
? `relative text-white outline-none ${containerDimensions}`
: 'relative max-h-full w-11/12 text-white outline-none lg:w-10/12 xl:w-9/12'
}
overlayClassName={
movable
? 'fixed top-0 left-0 right-0 bottom-0 z-50 flex items-center justify-center py-16 pointer-events-none'
: 'fixed top-0 left-0 right-0 bottom-0 z-50 bg-overlay flex items-center justify-center py-16'
}
shouldCloseOnEsc={shouldCloseOnEsc}
onRequestClose={handleClose}
isOpen={isOpen}
title={title}
shouldCloseOnOverlayClick={shouldCloseOnOverlayClick}
>
{renderHeader()}
<section className="ohif-scrollbar modal-content bg-primary-dark overflow-y-auto rounded-bl rounded-br px-[20px] pt-2 pb-[20px]">
{children}
</section>
{movable ? (
<Draggable
handle=".drag-handle"
defaultClassName="bg-primary-dark pointer-events-auto"
>
<div>{modalContent}</div>
</Draggable>
) : (
modalContent
)}
</ReactModal>
);
};
@@ -72,6 +102,9 @@ const Modal = ({
Modal.defaultProps = {
shouldCloseOnEsc: true,
shouldCloseOnOverlayClick: true,
movable: false,
containerDimensions: null,
contentDimensions: null,
};
Modal.propTypes = {
@@ -80,9 +113,11 @@ Modal.propTypes = {
isOpen: PropTypes.bool,
title: PropTypes.string,
onClose: PropTypes.func,
/** The modal's content */
children: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.node), PropTypes.node]).isRequired,
shouldCloseOnOverlayClick: PropTypes.bool,
movable: PropTypes.bool,
containerDimensions: PropTypes.string,
contentDimensions: PropTypes.string,
};
export default Modal;
@@ -149,7 +149,6 @@ StudyBrowser.propTypes = {
componentType: PropTypes.oneOf(['thumbnail', 'thumbnailTracked', 'thumbnailNoImage'])
.isRequired,
isTracked: PropTypes.bool,
viewportIdentificator: PropTypes.arrayOf(PropTypes.string),
/**
* Data the thumbnail should expose to a receiving drop target. Use a matching
* `dragData.type` to identify which targets can receive this draggable item.
@@ -99,7 +99,6 @@ With Studies
displaySetInstanceUID: '0b66675a-a4e3-a718-21f4-035d2d178d62',
},
isTracked: false,
viewportIdentificator: [],
},
],
},
+1 -1
View File
@@ -1,6 +1,6 @@
import React from 'react';
// Svgs
import logoOhif from './../../assets/svgs/logo-ohif.svg';
import logoOhif from './../../assets/svgs/ohif-logo.svg';
const SVGS = {
'logo-ohif': logoOhif,
@@ -0,0 +1,52 @@
import React, { useCallback, useState } from 'react';
import './switchButton.css';
export enum SwitchLabelLocation {
left,
right,
}
export type SwitchButtonProps = {
checked?: boolean;
label?: string;
labelLocation?: SwitchLabelLocation;
onChange?: (checked: boolean) => void;
};
const SwitchButton = ({
label,
checked = false,
onChange,
labelLocation = SwitchLabelLocation.left,
}: SwitchButtonProps) => {
const [isInputChecked, setIsInputChecked] = useState(checked);
const onHandleChange = useCallback(
event => {
setIsInputChecked(event.target.checked);
onChange?.(event.target.checked);
},
[onChange]
);
// Thanks goes to https://codepen.io/lhermann/pen/EBGZRZ for the inspiration to the code below.
return (
<label className="switch-button flex w-full cursor-pointer items-center justify-between text-[14px]">
{label && labelLocation === SwitchLabelLocation.left && <div>{label}</div>}
<div className="relative">
<input
className="absolute hidden"
type="checkbox"
onChange={onHandleChange}
checked={isInputChecked}
/>
<div className="switch-button-outer border-common-bright bg-primary-dark block h-[16px] w-[30px] rounded-full border"></div>
<div className="switch-button-dot bg-common-bright absolute left-[4px] top-[3px] h-[10px] w-[10px] rounded-full transition duration-150 ease-in-out"></div>
</div>
{label && labelLocation === SwitchLabelLocation.right && <div>{label}</div>}
</label>
);
};
export default SwitchButton;
@@ -0,0 +1,58 @@
import SwitchButton, { SwitchLabelLocation } from '../SwitchButton';
import { ArgsTable, Story, Canvas, Meta } from '@storybook/addon-docs';
import { createComponentTemplate } from '../../../storybook/functions/create-component-story';
export const argTypes = {
component: SwitchButton,
title: 'Components/SwitchButton',
};
<Meta
title="Components/SwitchButton"
component={SwitchButton}
/>
export const SwitchButtonTemplate = args => (
<div className="w-[200px]">
<SwitchButton {...args} />
</div>
);
<Heading
title="SwitchButton"
componentRelativePath="SwitchButton/SwitchButton.tsx"
/>
- [Overview](#overview)
- [Props](#props)
- [Contribute](#contribute)
## Overview
A SwitchButton is a checkbox type input/button that can either be on or off (like a light switch).
<Canvas>
<Story
name="Overview"
args={{
label: 'Switch Button Label',
labelLocation: SwitchLabelLocation.left,
onChange: (checked) => console.info(`${checked ? 'The switch is on.' : 'The switch is off.'}`)
}}
>
{SwitchButtonTemplate.bind({})}
</Story>
</Canvas>
## Props
<ArgsTable of={SwitchButton} />
## Usage
## Contribute
<Footer componentRelativePath="SwitchButton/__stories__/SwitchButton.stories.mdx" />
@@ -0,0 +1,3 @@
import SwitchButton, { SwitchLabelLocation } from './SwitchButton';
export default SwitchButton;
export { SwitchLabelLocation };
@@ -0,0 +1,7 @@
.switch-button input:checked ~ .switch-button-outer {
@apply border-primary-light;
}
.switch-button input:checked ~ .switch-button-dot {
@apply bg-primary-light;
transform: translateX(12px);
}
@@ -29,7 +29,6 @@ const ThumbnailList = ({
componentType,
seriesDate,
countIcon,
viewportIdentificator,
isTracked,
canReject,
onReject,
@@ -53,7 +52,6 @@ const ThumbnailList = ({
imageSrc={imageSrc}
imageAltText={imageAltText}
messages={messages}
viewportIdentificator={viewportIdentificator}
isActive={isActive}
onClick={() => onThumbnailClick(displaySetInstanceUID)}
onDoubleClick={() => onThumbnailDoubleClick(displaySetInstanceUID)}
@@ -72,7 +70,6 @@ const ThumbnailList = ({
imageSrc={imageSrc}
imageAltText={imageAltText}
messages={messages}
viewportIdentificator={viewportIdentificator}
isTracked={isTracked}
isActive={isActive}
onClick={() => onThumbnailClick(displaySetInstanceUID)}
@@ -96,7 +93,6 @@ const ThumbnailList = ({
onReject={onReject}
onClick={() => onThumbnailClick(displaySetInstanceUID)}
onDoubleClick={() => onThumbnailDoubleClick(displaySetInstanceUID)}
viewportIdentificator={viewportIdentificator}
isHydratedForDerivedDisplaySet={isHydratedForDerivedDisplaySet}
/>
);
@@ -120,7 +116,6 @@ ThumbnailList.propTypes = {
numInstances: PropTypes.number,
description: PropTypes.string,
componentType: Types.ThumbnailType.isRequired,
viewportIdentificator: Types.StringArray,
isTracked: PropTypes.bool,
/**
* Data the thumbnail should expose to a receiving drop target. Use a matching
@@ -24,43 +24,11 @@ function ThumbnailTracked({
onClick,
onDoubleClick,
onClickUntrack,
viewportIdentificator,
isTracked,
isActive,
}) {
const { t } = useTranslation('ThumbnailTracked');
const trackedIcon = isTracked ? 'circled-checkmark' : 'dotted-circle';
const viewportIdentificatorLabel = viewportIdentificator.join(', ');
const renderViewportLabels = () => {
const MAX_LABELS_PER_COL = 3;
const shouldShowStack = viewportIdentificator.length > MAX_LABELS_PER_COL;
if (shouldShowStack) {
return (
<div>
<div>
{viewportIdentificator.slice(0, MAX_LABELS_PER_COL).map(label => (
<div key={label}>{label}</div>
))}
</div>
<Tooltip
position="right"
content={
<div className="max-w-40 text-left">
{`${t('Viewport')}: ${viewportIdentificatorLabel}`}
</div>
}
>
<Icon
name="tool-more-menu"
className="py-2 text-white"
/>
</Tooltip>
</div>
);
}
return viewportIdentificator.map(label => <div key={label}>{label}</div>);
};
return (
<div
@@ -93,9 +61,6 @@ function ThumbnailTracked({
{isTracked ? t('Series is tracked') : t('Series is untracked')}
</span>
</span>
{!!viewportIdentificator.length && (
<span>{`${t('Viewport')}: ${viewportIdentificatorLabel}`}</span>
)}
</div>
</div>
}
@@ -105,13 +70,6 @@ function ThumbnailTracked({
className="text-primary-light mb-2 w-4"
/>
</Tooltip>
<div
className="text-center text-xl leading-tight text-white"
data-cy={'thumbnail-viewport-labels'}
>
{renderViewportLabels()}
</div>
</div>
{isTracked && (
<div onClick={onClickUntrack}>
@@ -162,7 +120,6 @@ ThumbnailTracked.propTypes = {
onClick: PropTypes.func.isRequired,
onDoubleClick: PropTypes.func.isRequired,
onClickUntrack: PropTypes.func.isRequired,
viewportIdentificator: PropTypes.array,
isTracked: PropTypes.bool,
messages: PropTypes.object,
isActive: PropTypes.bool.isRequired,
@@ -0,0 +1,33 @@
import React from 'react';
import Icon from '../Icon';
import classNames from 'classnames';
const arrowClasses =
'cursor-pointer flex items-center justify-center shrink-0 text-primary-light active:text-white hover:bg-secondary-light/60 rounded';
type ViewportActionArrowsProps = {
onArrowsClick: (direction: number) => void;
className: string;
};
function ViewportActionArrows({ onArrowsClick, className }: ViewportActionArrowsProps) {
return (
// The arrows are only visible when hover over the viewport.
<div className={classNames(className, 'flex')}>
<div className={arrowClasses}>
<Icon
name="prev-arrow"
onClick={() => onArrowsClick(-1)}
/>
</div>
<div className={arrowClasses}>
<Icon
name="next-arrow"
onClick={() => onArrowsClick(1)}
/>
</div>
</div>
);
}
export default ViewportActionArrows;
@@ -0,0 +1,2 @@
import ViewportActionArrows from './ViewportActionArrows';
export default ViewportActionArrows;
@@ -0,0 +1,76 @@
import classNames from 'classnames';
import React from 'react';
import { ViewportActionCornersComponentInfo } from '../../types/ViewportActionCornersTypes';
export enum ViewportActionCornersLocations {
topLeft,
topRight,
bottomLeft,
bottomRight,
}
export type ViewportActionCornersProps = {
cornerComponents: Record<
ViewportActionCornersLocations,
Array<ViewportActionCornersComponentInfo>
>;
};
const commonClasses = 'pointer-events-auto flex items-center gap-1';
const classes = {
[ViewportActionCornersLocations.topLeft]: classNames(
commonClasses,
'absolute top-[4px] left-[0px] pl-[4px]'
),
[ViewportActionCornersLocations.topRight]: classNames(
commonClasses,
'absolute top-[4px] right-[4px] right-viewport-scrollbar'
),
[ViewportActionCornersLocations.bottomLeft]: classNames(
commonClasses,
'absolute bottom-[4px] left-[0px] pl-[4px]'
),
[ViewportActionCornersLocations.bottomRight]: classNames(
commonClasses,
'absolute bottom-[4px] right-[0px] right-viewport-scrollbar'
),
};
/**
* A component that renders various action items/components to each corner of a viewport.
* The position of each corner's components is such that a single row of components are
* rendered absolutely without intersecting the ViewportOverlay component.
* Note that corner components are passed as an object mapping each corner location
* to an array of components for that location. The components in each array are
* rendered from left to right in the order that they appear in the array.
*/
function ViewportActionCorners({ cornerComponents }: ViewportActionCornersProps) {
if (!cornerComponents) {
return null;
}
return (
<div
className="pointer-events-none absolute h-full w-full select-none"
onDoubleClick={event => {
event.preventDefault();
event.stopPropagation();
}}
>
{Object.entries(cornerComponents).map(([location, locationComponents]) => {
return (
<div
key={location}
className={classNames(classes[location])}
>
{locationComponents.map(componentInfo => {
return <div key={componentInfo.id}>{componentInfo.component}</div>;
})}
</div>
);
})}
</div>
);
}
export default ViewportActionCorners;
@@ -0,0 +1,4 @@
import ViewportActionCorners, { ViewportActionCornersLocations } from './ViewportActionCorners';
export { ViewportActionCornersLocations };
export default ViewportActionCorners;
@@ -10,40 +10,13 @@
text-shadow: 1px 1px #000;
pointer-events: none;
}
.ViewportOverlay .top-left {
top: 20px;
left: 20px;
.overlay-top {
top: 28px;
}
.ViewportOverlay .top-center {
top: 20px;
padding-top: 20px;
width: 100%;
text-align: center;
.overlay-bottom {
bottom: 3px;
}
.ViewportOverlay .top-right {
top: 20px;
right: 20px;
text-align: right;
}
.ViewportOverlay .bottom-left {
bottom: 20px;
left: 20px;
}
.ViewportOverlay .bottom-right {
bottom: 20px;
right: 20px;
text-align: right;
}
.ViewportOverlay.controlsVisible .topright,
.ViewportOverlay.controlsVisible .bottomright {
right: calc(20px + 19px);
}
.ViewportOverlay svg {
color: #9ccef9;
fill: #9ccef9;
stroke: #9ccef9;
background-color: transparent;
margin: 2px;
width: 18px;
height: 18px;
.overlay-text {
text-shadow: 0.8px 0.8px 0.5px rgba(0, 0, 0, 0.75);
}
@@ -3,11 +3,16 @@ import classnames from 'classnames';
import './ViewportOverlay.css';
// The overlay-top and overlay-bottom classes are explicitly needed to offset
// the overlays (i.e. via absolute positioning) such the ViewportActionCorners
// have space for its child components.
// ToDo: offset the ViewportOverlay automatically via css to account for the
// space needed for ViewportActionCorners.
const classes = {
topLeft: 'top-viewport left-viewport',
topRight: 'top-viewport right-viewport-scrollbar',
bottomRight: 'bottom-viewport right-viewport-scrollbar',
bottomLeft: 'bottom-viewport left-viewport',
topLeft: 'overlay-top left-viewport',
topRight: 'overlay-top right-viewport-scrollbar',
bottomRight: 'overlay-bottom right-viewport-scrollbar',
bottomLeft: 'overlay-bottom left-viewport',
};
export type ViewportOverlayProps = {
@@ -27,7 +32,14 @@ const ViewportOverlay = ({
}: ViewportOverlayProps) => {
const overlay = 'absolute pointer-events-none viewport-overlay';
return (
<div className={color}>
<div
className={classnames(
color ? color : 'text-aqua-pale',
'text-[13px]',
'leading-5',
'overlay-text'
)}
>
<div
data-cy={'viewport-overlay-top-left'}
className={classnames(overlay, classes.topLeft)}
@@ -37,12 +49,14 @@ const ViewportOverlay = ({
<div
data-cy={'viewport-overlay-top-right'}
className={classnames(overlay, classes.topRight)}
style={{ transform: 'translateX(-8px)' }} // shift right side overlays by 4px for better alignment with ViewportActionCorners' icons
>
{topRight}
</div>
<div
data-cy={'viewport-overlay-bottom-right'}
className={classnames(overlay, classes.bottomRight)}
style={{ transform: 'translateX(-8px)' }} // shift right side overlays by 4px for better alignment with ViewportActionCorners' icons
>
{bottomRight}
</div>
@@ -64,7 +64,7 @@ function ViewportPane({
onScroll={onInteractionHandler}
onWheel={onInteractionHandler}
className={classnames(
'hover:border-primary-light group h-full w-full overflow-hidden rounded-md transition duration-300',
'group h-full w-full overflow-hidden rounded-md transition duration-300',
{
'border-primary-light border-2': isActive,
'border-2 border-transparent': !isActive,
@@ -77,10 +77,10 @@ function ViewportPane({
>
<div
className={classnames(
'h-full w-full overflow-hidden rounded-md group-hover:border-transparent',
'h-full w-full overflow-hidden rounded-md',
{
'border border-transparent': isActive,
'border-secondary-light border': !isActive,
'border-secondary-light group-hover:border-primary-light/70 border': !isActive,
},
className
)}
+13
View File
@@ -73,22 +73,29 @@ import CheckBox from './CheckBox';
import LoadingIndicatorProgress from './LoadingIndicatorProgress';
import LoadingIndicatorTotalPercent from './LoadingIndicatorTotalPercent';
import ViewportActionBar from './ViewportActionBar';
import ViewportActionCorners, { ViewportActionCornersLocations } from './ViewportActionCorners';
import ProgressLoadingBar from './ProgressLoadingBar';
import LegacySidePanel from './LegacySidePanel';
import PanelSection from './PanelSection';
import AdvancedToolbox from './AdvancedToolbox';
import InputDoubleRange from './InputDoubleRange';
import LegacyButtonGroup from './LegacyButtonGroup';
import SwitchButton, { SwitchLabelLocation } from './SwitchButton';
import * as AllInOneMenu from './AllInOneMenu';
import ViewportActionArrows from './ViewportActionArrows';
import HeaderPatientInfo from './HeaderPatientInfo';
import LegacySplitButton from './LegacySplitButton';
import { ToolSettings } from './AdvancedToolbox';
import { Toolbox } from './Toolbox';
import InvestigationalUseDialog from './InvestigationalUseDialog';
import MeasurementItem from './MeasurementTable/MeasurementItem';
import LayoutPreset from './LayoutPreset';
export {
AboutModal,
AdvancedToolbox,
AllInOneMenu,
HotkeyField,
Header,
UserPreferences,
@@ -148,6 +155,8 @@ export {
StudyListTableRow,
StudySummary,
Svg,
SwitchButton,
SwitchLabelLocation,
Table,
TableBody,
TableCell,
@@ -163,12 +172,16 @@ export {
TooltipClipboard,
Typography,
Viewport,
ViewportActionArrows,
ViewportActionBar,
ViewportActionCorners,
ViewportActionCornersLocations,
ViewportDownloadForm,
ViewportGrid,
ViewportPane,
ViewportOverlay,
WindowLevelMenuItem,
HeaderPatientInfo,
LayoutPreset,
LegacySplitButton,
ToolSettings,