ui(components): Viewport components added to ui-next (#4649)

Co-authored-by: sedghi <ar.sedghi@gmail.com>
This commit is contained in:
Dan Rukasandsedghi authored and GitHub committed 2025-01-08 14:23:34 -05:00
1 parent 47de38f3ff
commit 230ae9758b
37 files changed
+1617 -21

No files matched your search

+1 -1
View File
@@ -19,7 +19,6 @@ import {
ModalProvider,
ThemeWrapper,
ViewportDialogProvider,
ViewportGridProvider,
CineProvider,
UserAuthenticationProvider,
ToolboxProvider,
@@ -27,6 +26,7 @@ import {
import {
ThemeWrapper as ThemeWrapperNext,
NotificationProvider,
ViewportGridProvider,
TooltipProvider,
} from '@ohif/ui-next';
// Viewer Project
+2 -1
View File
@@ -1,7 +1,8 @@
import React, { useEffect, useCallback, useRef } from 'react';
import { useResizeDetector } from 'react-resize-detector';
import { Types, MeasurementService } from '@ohif/core';
import { ViewportGrid, ViewportPane, useViewportGrid } from '@ohif/ui';
import { ViewportGrid, ViewportPane } from '@ohif/ui';
import { useViewportGrid } from '@ohif/ui-next';
import EmptyViewport from './EmptyViewport';
import classNames from 'classnames';
import { useAppConfig } from '@state';
+1
View File
@@ -29,6 +29,7 @@
"dependencies": {
"@radix-ui/react-accordion": "^1.2.0",
"@radix-ui/react-checkbox": "^1.1.1",
"@radix-ui/react-context-menu": "^2.2.4",
"@radix-ui/react-dialog": "^1.1.1",
"@radix-ui/react-dropdown-menu": "^2.1.1",
"@radix-ui/react-icons": "^1.3.0",
@@ -0,0 +1,187 @@
import * as React from 'react';
import * as ContextMenuPrimitive from '@radix-ui/react-context-menu';
import { cn } from '../../lib/utils';
import { CheckIcon, ChevronRightIcon, DotFilledIcon } from '@radix-ui/react-icons';
const ContextMenu = ContextMenuPrimitive.Root;
const ContextMenuTrigger = ContextMenuPrimitive.Trigger;
const ContextMenuGroup = ContextMenuPrimitive.Group;
const ContextMenuPortal = ContextMenuPrimitive.Portal;
const ContextMenuSub = ContextMenuPrimitive.Sub;
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup;
const ContextMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<ContextMenuPrimitive.SubTrigger
ref={ref}
className={cn(
'focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none',
inset && 'pl-8',
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto h-4 w-4" />
</ContextMenuPrimitive.SubTrigger>
));
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName;
const ContextMenuSubContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.SubContent
ref={ref}
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-lg',
className
)}
{...props}
/>
));
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName;
const ContextMenuContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
ref={ref}
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-md',
className
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
));
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName;
const ContextMenuItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Item
ref={ref}
className={cn(
'focus:bg-accent focus:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
inset && 'pl-8',
className
)}
{...props}
/>
));
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName;
const ContextMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<ContextMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
'focus:bg-accent focus:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<CheckIcon className="h-4 w-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
));
ContextMenuCheckboxItem.displayName = ContextMenuPrimitive.CheckboxItem.displayName;
const ContextMenuRadioItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<ContextMenuPrimitive.RadioItem
ref={ref}
className={cn(
'focus:bg-accent focus:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<DotFilledIcon className="h-4 w-4 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
));
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName;
const ContextMenuLabel = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Label
ref={ref}
className={cn('text-foreground px-2 py-1.5 text-sm font-semibold', inset && 'pl-8', className)}
{...props}
/>
));
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName;
const ContextMenuSeparator = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Separator
ref={ref}
className={cn('bg-border -mx-1 my-1 h-px', className)}
{...props}
/>
));
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName;
const ContextMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn('text-muted-foreground ml-auto text-xs tracking-widest', className)}
{...props}
/>
);
};
ContextMenuShortcut.displayName = 'ContextMenuShortcut';
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
};
@@ -0,0 +1,35 @@
import {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
} from './ContextMenu';
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
};
@@ -0,0 +1,138 @@
import React from 'react';
import PropTypes from 'prop-types';
import classnames from 'classnames';
import { useTranslation } from 'react-i18next';
import { Icons, Tooltip, TooltipTrigger, TooltipContent } from '../../components';
const classes = {
infoHeader: 'text-base text-primary-light',
infoText: 'text-base text-white max-w-24 truncate',
firstRow: 'flex flex-col',
row: 'flex flex-col ml-4',
};
/**
* A small info icon that, when clicked, can reveal additional patient details in a tooltip overlay.
*/
function PatientInfo({
patientName,
patientSex,
patientAge,
MRN,
thickness,
thicknessUnits,
spacing,
scanner,
isOpen,
showPatientInfoRef,
}) {
const { t } = useTranslation('PatientInfo');
// strip leading '0' from age if present
while (patientAge.charAt(0) === '0') {
patientAge = patientAge.substr(1);
}
return (
<div ref={showPatientInfoRef}>
<Tooltip>
<TooltipTrigger asChild>
<Icons.Info className="hover:text-primary-light cursor-pointer text-white" />
</TooltipTrigger>
{isOpen && (
<TooltipContent
side="bottom"
align="end"
>
<div className="flex py-2">
<div className="flex pt-1">
<Icons.InfoLink className="text-primary-main w-4" />
</div>
<div className="ml-2 flex flex-col">
<span
className="text-base font-bold text-white"
title={patientName}
>
{patientName}
</span>
<div className="border-secondary-main mt-4 mb-4 flex border-b pb-4">
<div className={classnames(classes.firstRow)}>
<span className={classnames(classes.infoHeader)}>{t('Sex')}</span>
<span
className={classnames(classes.infoText)}
title={patientSex}
>
{patientSex}
</span>
</div>
<div className={classnames(classes.row)}>
<span className={classnames(classes.infoHeader)}>{t('Age')}</span>
<span
className={classnames(classes.infoText)}
title={patientAge}
>
{patientAge}
</span>
</div>
<div className={classnames(classes.row)}>
<span className={classnames(classes.infoHeader)}>{t('MRN')}</span>
<span
className={classnames(classes.infoText)}
title={MRN}
>
{MRN}
</span>
</div>
</div>
<div className="flex">
<div className={classnames(classes.firstRow)}>
<span className={classnames(classes.infoHeader)}>{t('Thickness')}</span>
<span
className={classnames(classes.infoText)}
title={thickness}
>
{thicknessUnits ? `${thickness}${thicknessUnits}` : `${thickness}`}
</span>
</div>
<div className={classnames(classes.row)}>
<span className={classnames(classes.infoHeader)}>{t('Spacing')}</span>
<span
className={classnames(classes.infoText)}
title={spacing}
>
{spacing}
</span>
</div>
<div className={classnames(classes.row)}>
<span className={classnames(classes.infoHeader)}>{t('Scanner')}</span>
<span
className={classnames(classes.infoText)}
title={scanner}
>
{scanner}
</span>
</div>
</div>
</div>
</div>
</TooltipContent>
)}
</Tooltip>
</div>
);
}
PatientInfo.propTypes = {
patientName: PropTypes.string,
patientSex: PropTypes.string,
patientAge: PropTypes.string,
MRN: PropTypes.string,
thickness: PropTypes.string,
thicknessUnits: PropTypes.string,
spacing: PropTypes.string,
scanner: PropTypes.string,
isOpen: PropTypes.bool,
showPatientInfoRef: PropTypes.object,
};
export { PatientInfo };
@@ -0,0 +1,31 @@
import React from 'react';
import classNames from 'classnames';
import PropTypes from 'prop-types';
import { Icons } from '@ohif/ui-next';
const arrowClasses =
'cursor-pointer flex items-center justify-center shrink-0 text-primary-light active:text-white hover:bg-secondary-light/60 rounded';
/**
* A small set of left/right arrow icons for stepping through slices or series.
*/
function ViewportActionArrows({ onArrowsClick, className }) {
return (
<div className={classNames(className, 'flex')}>
<div className={arrowClasses}>
<Icons.ArrowLeftBold onClick={() => onArrowsClick(-1)} />
</div>
<div className={arrowClasses}>
<Icons.ArrowRightBold onClick={() => onArrowsClick(1)} />
</div>
</div>
);
}
ViewportActionArrows.propTypes = {
onArrowsClick: PropTypes.func.isRequired,
className: PropTypes.string,
};
export { ViewportActionArrows };
@@ -0,0 +1,128 @@
import React, {
MouseEventHandler,
ReactElement,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { Icons } from '@ohif/ui-next';
import PatientInfo from './PatientInfo';
/**
* This is the modern Viewport Action Bar, showing patient info, series date,
* series description, and optional next/prev arrows if there's enough screen width.
*/
type ViewportActionBarProps = {
studyData: any;
onArrowsClick: (arrow: string) => void;
onDoubleClick: MouseEventHandler;
getStatusComponent: () => ReactElement;
};
function ViewportActionBar({
studyData,
onArrowsClick,
onDoubleClick,
getStatusComponent,
}: ViewportActionBarProps): JSX.Element {
const { label, studyDate, seriesDescription, patientInformation } = studyData;
const { patientName, patientSex, patientAge, MRN, thickness, thicknessUnits, spacing, scanner } =
patientInformation;
const [showPatientInfo, setShowPatientInfo] = useState(false);
const showPatientInfoElemRef = useRef<HTMLDivElement>(null);
const { t } = useTranslation();
// handle click outside to close patient info
const handleClickOutside = useCallback(
(evt: MouseEvent) => {
if (
showPatientInfo &&
showPatientInfoElemRef.current &&
!showPatientInfoElemRef.current.contains(evt.target as Node)
) {
setShowPatientInfo(false);
}
},
[showPatientInfoElemRef, showPatientInfo]
);
useEffect(() => {
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [handleClickOutside]);
return (
<div
className="pointer-events-auto flex h-8 shrink-0 select-none items-center overflow-visible whitespace-nowrap px-2 text-base"
onDoubleClick={onDoubleClick}
>
{getStatusComponent()}
{!!label?.length && <span className="text-aqua-pale text-large ml-1">{label}</span>}
<div className="border-secondary-light mx-2 border-l py-2"></div>
<span
data-cy="studyDate"
className="text-white"
>
{studyDate}
</span>
<div className="border-secondary-light mx-2 border-l py-2"></div>
<span className="text-aqua-pale mr-1 overflow-hidden text-ellipsis">{seriesDescription}</span>
{/* Prev/Next icons */}
<Icons.ByName
className="hover:text-primary-light ml-auto mr-2 cursor-pointer text-white"
name="chevron-prev"
onClick={() => onArrowsClick('left')}
/>
<Icons.ByName
className="hover:text-primary-light mr-2 cursor-pointer text-white"
name="chevron-next"
onClick={() => onArrowsClick('right')}
/>
{/* Patient Info */}
<div onClick={() => setShowPatientInfo(!showPatientInfo)}>
<PatientInfo
showPatientInfoRef={showPatientInfoElemRef}
isOpen={showPatientInfo}
patientName={patientName}
patientSex={patientSex}
patientAge={patientAge}
MRN={MRN}
thickness={thickness}
thicknessUnits={thicknessUnits}
spacing={spacing}
scanner={scanner}
/>
</div>
</div>
);
}
ViewportActionBar.propTypes = {
onArrowsClick: PropTypes.func.isRequired,
onDoubleClick: PropTypes.func,
studyData: PropTypes.shape({
label: PropTypes.string.isRequired,
studyDate: PropTypes.string.isRequired,
seriesDescription: PropTypes.string.isRequired,
patientInformation: PropTypes.shape({
patientName: PropTypes.string,
patientSex: PropTypes.string,
patientAge: PropTypes.string,
MRN: PropTypes.string,
thickness: PropTypes.string,
thicknessUnits: PropTypes.string,
spacing: PropTypes.string,
scanner: PropTypes.string,
}),
}).isRequired,
getStatusComponent: PropTypes.func.isRequired,
};
export { ViewportActionBar };
@@ -0,0 +1,31 @@
import React from 'react';
import PropTypes from 'prop-types';
/**
* A button that can trigger commands when clicked.
*/
function ViewportActionButton({ onInteraction, commands, id, children }) {
return (
<div
className="bg-primary-main hover:bg-primary-light ml-1 cursor-pointer rounded px-1.5 hover:text-black"
// Using onMouseUp because onClick wasn't firing if pointer-events are none.
onMouseUp={() => {
onInteraction({
itemId: id,
commands,
});
}}
>
{children}
</div>
);
}
ViewportActionButton.propTypes = {
id: PropTypes.string,
onInteraction: PropTypes.func.isRequired,
commands: PropTypes.array,
children: PropTypes.node,
};
export { ViewportActionButton };
@@ -0,0 +1,67 @@
import React from 'react';
import classNames from 'classnames';
import PropTypes from 'prop-types';
/**
* A small container that can render multiple "corner" items (like icons, status)
* in each corner of the viewport: top-left, top-right, bottom-left, bottom-right.
*/
export enum ViewportActionCornersLocations {
topLeft,
topRight,
bottomLeft,
bottomRight,
}
const commonClasses = 'pointer-events-auto flex items-center gap-1';
const locationClasses = {
[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'
),
};
function ViewportActionCorners({ cornerComponents }) {
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, locationArray]) => (
<div
key={location}
className={locationClasses[location]}
>
{locationArray.map(componentInfo => (
<div key={componentInfo.id}>{componentInfo.component}</div>
))}
</div>
))}
</div>
);
}
ViewportActionCorners.propTypes = {
cornerComponents: PropTypes.object.isRequired,
};
export { ViewportActionCorners };
@@ -0,0 +1,30 @@
import React from 'react';
import PropTypes from 'prop-types';
/**
* A minimal top-level container that organizes multiple <ViewportPane>
* children in a grid. Typically driven by a layout config.
*/
function ViewportGrid({ numRows, numCols, layoutType, children }) {
return (
<div
data-cy="viewport-grid"
style={{
position: 'relative',
height: '100%',
width: '100%',
}}
>
{children}
</div>
);
}
ViewportGrid.propTypes = {
numRows: PropTypes.number.isRequired,
numCols: PropTypes.number.isRequired,
layoutType: PropTypes.string,
children: PropTypes.arrayOf(PropTypes.node).isRequired,
};
export { ViewportGrid };
@@ -0,0 +1,22 @@
.imageViewerViewport.empty ~ .ViewportOverlay {
display: none;
}
.ViewportOverlay {
color: #9ccef9;
}
.ViewportOverlay .overlay-element {
position: absolute;
font-weight: 400;
text-shadow: 1px 1px #000;
pointer-events: none;
}
.overlay-top {
top: 28px;
}
.overlay-bottom {
bottom: 3px;
}
.overlay-text {
text-shadow: 0.8px 0.8px 0.5px rgba(0, 0, 0, 0.75);
}
@@ -0,0 +1,69 @@
import React from 'react';
import classNames from 'classnames';
import PropTypes from 'prop-types';
import './ViewportOverlay.css';
/**
* Renders text overlays (top-left, top-right, bottom-left, bottom-right)
* around the active viewport for metadata or status messages.
*
* The parent is responsible for styling offsets.
*/
const classes = {
topLeft: 'overlay-top left-viewport',
topRight: 'overlay-top right-viewport-scrollbar',
bottomRight: 'overlay-bottom right-viewport-scrollbar',
bottomLeft: 'overlay-bottom left-viewport',
};
function ViewportOverlay({
topLeft,
topRight,
bottomRight,
bottomLeft,
color = 'text-primary-light',
}) {
const overlay = 'absolute pointer-events-none viewport-overlay';
return (
<div className={classNames(color, 'overlay-text text-[13px] leading-5')}>
<div
data-cy="viewport-overlay-top-left"
className={classNames(overlay, classes.topLeft)}
>
{topLeft}
</div>
<div
data-cy="viewport-overlay-top-right"
className={classNames(overlay, classes.topRight)}
style={{ transform: 'translateX(-8px)' }}
>
{topRight}
</div>
<div
data-cy="viewport-overlay-bottom-right"
className={classNames(overlay, classes.bottomRight)}
style={{ transform: 'translateX(-8px)' }}
>
{bottomRight}
</div>
<div
data-cy="viewport-overlay-bottom-left"
className={classNames(overlay, classes.bottomLeft)}
>
{bottomLeft}
</div>
</div>
);
}
ViewportOverlay.propTypes = {
topLeft: PropTypes.node,
topRight: PropTypes.node,
bottomRight: PropTypes.node,
bottomLeft: PropTypes.node,
color: PropTypes.string,
};
export { ViewportOverlay };
@@ -0,0 +1,97 @@
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useDrop } from 'react-dnd';
/**
* The "pane" that encloses a Cornerstone or other type of Viewport. This handles
* drag-and-drop for display sets, activation on click, etc.
*/
function ViewportPane({
children,
className,
customStyle,
isActive,
onDrop,
onDoubleClick,
onInteraction = () => {},
acceptDropsFor,
}) {
let dropElement = null;
const [{ isHovered, isHighlighted }, drop] = useDrop({
accept: acceptDropsFor,
drop: (droppedItem, monitor) => {
if (monitor.canDrop() && monitor.isOver() && onDrop) {
onInteraction();
onDrop(droppedItem);
}
},
collect: monitor => ({
isHighlighted: monitor.canDrop(),
isHovered: monitor.isOver(),
}),
});
const focus = () => {
if (dropElement) {
dropElement.focus();
}
};
const onInteractionHandler = event => {
focus();
onInteraction(event);
};
const refHandler = element => {
drop(element);
dropElement = element;
};
return (
<div
ref={refHandler}
onPointerDown={onInteractionHandler}
onDoubleClick={onDoubleClick}
onClick={onInteractionHandler}
onScroll={onInteractionHandler}
onWheel={onInteractionHandler}
className={classNames(
'group/pane h-full w-full overflow-hidden rounded-md transition duration-300',
{
'border-primary-light border-2': isActive,
'border-2 border-transparent': !isActive,
},
className
)}
style={customStyle}
>
<div
className={classNames(
'h-full w-full overflow-hidden rounded-md',
{
'border border-transparent': isActive,
'border-secondary-light group-hover/pane:border-primary-light/70 border': !isActive,
},
className
)}
>
{children}
</div>
</div>
);
}
ViewportPane.propTypes = {
children: PropTypes.node.isRequired,
className: PropTypes.string,
isActive: PropTypes.bool.isRequired,
acceptDropsFor: PropTypes.string.isRequired,
onDrop: PropTypes.func.isRequired,
onInteraction: PropTypes.func.isRequired,
onDoubleClick: PropTypes.func,
customStyle: PropTypes.object,
};
export { ViewportPane };
@@ -0,0 +1,8 @@
export { ViewportActionButton } from './ViewportActionButton';
export { PatientInfo } from './PatientInfo';
export { ViewportActionBar } from './ViewportActionBar';
export { ViewportActionArrows } from './ViewportActionArrows';
export { ViewportPane } from './ViewportPane';
export { ViewportActionCorners, ViewportActionCornersLocations } from './ViewportActionCorners';
export { ViewportOverlay } from './ViewportOverlay';
export { ViewportGrid } from './ViewportGrid';
+20
View File
@@ -79,6 +79,17 @@ import { StudySummary } from './StudySummary';
import { ErrorBoundary } from './Errorboundary';
import { Header } from './Header';
import { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } from './Card';
import {
ViewportActionButton,
PatientInfo,
ViewportActionBar,
ViewportActionArrows,
ViewportPane,
ViewportActionCorners,
ViewportActionCornersLocations,
ViewportOverlay,
ViewportGrid,
} from './Viewport';
export {
ErrorBoundary,
@@ -183,5 +194,14 @@ export {
CardTitle,
CardDescription,
CardContent,
ViewportActionButton,
PatientInfo,
ViewportActionBar,
ViewportActionArrows,
ViewportPane,
ViewportActionCorners,
ViewportActionCornersLocations,
ViewportOverlay,
ViewportGrid,
Clipboard,
};
@@ -0,0 +1,522 @@
import React, {
createContext,
useCallback,
useContext,
useEffect,
useReducer,
ReactNode,
} from 'react';
import merge from 'lodash.merge';
import PropTypes from 'prop-types';
import { ViewportGridService, utils } from '@ohif/core';
const DEFAULT_STATE: AppTypes.ViewportGrid.State = {
activeViewportId: null,
layout: {
numRows: 0,
numCols: 0,
layoutType: 'grid',
},
// this flag is used to determine if the hanging protocol layout is active
// so that we can inherit the viewport options from the previous state
// otherwise we will not allow that. Basically the issue is that we need
// to be able to come out of the hanging protocol layout and go back to the
// regular layout e.g., if we are in the MPR hanging protocol, and someone use
// 1x1 layout by custom layout selector, there is no way to drag and drop
// a non-reconstructible series to the viewport since it will always
// inherit the hanging protocol layout options (volume viewport),
// so we need to be able to switch back to the regular layout.
isHangingProtocolLayout: false,
// Viewports structure has been changed to Map (previously it was
// tied to the viewportIndex which caused multiple issues. Now we have
// moved completely to viewportId which is unique for each viewport.
viewports: new Map(
Object.entries({
default: {
viewportId: 'default',
displaySetInstanceUIDs: [],
isReady: false,
viewportOptions: {
viewportId: 'default',
},
displaySetSelectors: [],
displaySetOptions: [{}],
x: 0, // left
y: 0, // top
width: 100,
height: 100,
viewportLabel: null,
},
})
),
};
const determineActiveViewportId = (
state: AppTypes.ViewportGrid.State,
newViewports: Map<string, AppTypes.ViewportGrid.Viewport>
) => {
const { activeViewportId } = state;
const currentActiveViewport = state.viewports.get(activeViewportId);
if (!currentActiveViewport) {
// if there is no active viewport, we should just return the first viewport
const firstViewport = newViewports.values().next().value;
return firstViewport.viewportOptions.viewportId;
}
// for the new viewports, we should rank them by the displaySetInstanceUIDs
// they are displaying and the orientation then we can find the active viewport
const currentActiveDisplaySetInstanceUIDs = currentActiveViewport.displaySetInstanceUIDs;
// This doesn't take into account where stack viewport is converting to volumeViewport
// since in stack viewport we don't have a concept of "orientation" as a string
// maybe we should calculate the orientation based on the active imageId
// so that we can compare it with the new viewports (which might be volume viewports)
// and find the best match
const currentOrientation = currentActiveViewport.viewportOptions.orientation;
const filteredNewViewports = Array.from(newViewports.values()).filter(
viewport => viewport.displaySetInstanceUIDs?.length > 0
);
const sortedViewports = Array.from(filteredNewViewports.values()).sort((a, b) => {
// Compare orientations
const aOrientationMatch = a.viewportOptions.orientation === currentOrientation;
const bOrientationMatch = b.viewportOptions.orientation === currentOrientation;
if (aOrientationMatch !== bOrientationMatch) {
return bOrientationMatch - aOrientationMatch;
}
// Compare displaySetInstanceUIDs
const aMatch = a.displaySetInstanceUIDs.some(uid =>
currentActiveDisplaySetInstanceUIDs.includes(uid)
);
const bMatch = b.displaySetInstanceUIDs.some(uid =>
currentActiveDisplaySetInstanceUIDs.includes(uid)
);
if (aMatch !== bMatch) {
return bMatch - aMatch;
}
return 0; // Return 0 if no differences found
});
if (!sortedViewports?.length) {
return null;
}
return sortedViewports[0].viewportId;
};
// Define the API interface
interface ViewportGridApi {
getState: () => AppTypes.ViewportGrid.State;
setActiveViewportId: (index: string) => void;
setDisplaySetsForViewport: (props: any) => void;
setDisplaySetsForViewports: (props: any[]) => void;
setLayout: (layout: AppTypes.ViewportGrid.Layout) => void;
reset: () => void;
set: (gridLayoutState: Partial<AppTypes.ViewportGrid.State>) => void;
getNumViewportPanes: () => number;
setViewportIsReady: (viewportId: string, isReady: boolean) => void;
getGridViewportsReady: () => boolean;
getActiveViewportOptionByKey: (key: string) => any;
setViewportGridSizeChanged: (props: any) => void;
publishViewportsReady: () => void;
}
// Update the context type
export const ViewportGridContext = createContext<[AppTypes.ViewportGrid.State, ViewportGridApi]>([
DEFAULT_STATE,
{} as ViewportGridApi,
]);
// Update the provider props type
interface ViewportGridProviderProps {
children: ReactNode;
service: ViewportGridService;
}
export function ViewportGridProvider({ children, service }: ViewportGridProviderProps) {
const viewportGridReducer = (state: AppTypes.ViewportGrid.State, action) => {
switch (action.type) {
case 'SET_ACTIVE_VIEWPORT_ID': {
return { ...state, ...{ activeViewportId: action.payload } };
}
/**
* Sets the display sets for multiple viewports.
* This is a replacement for the older set display set for viewport (single)
* because the old one had race conditions wherein the viewports could
* render partially in various ways causing exceptions.
*/
case 'SET_DISPLAYSETS_FOR_VIEWPORTS': {
const { payload } = action;
const viewports = new Map(state.viewports);
payload.forEach(updatedViewport => {
const { viewportId, displaySetInstanceUIDs } = updatedViewport;
if (!viewportId) {
throw new Error('ViewportId is required to set display sets for viewport');
}
const previousViewport = viewports.get(viewportId);
// remove options that were meant for one time usage
if (previousViewport?.viewportOptions?.initialImageOptions) {
const { useOnce } = previousViewport.viewportOptions.initialImageOptions;
if (useOnce) {
previousViewport.viewportOptions.initialImageOptions = null;
}
}
// Use the newly provide viewportOptions and display set options
// when provided, and otherwise fall back to the previous ones.
// That allows for easy updates of just the display set.
let viewportOptions = merge(
{},
previousViewport?.viewportOptions,
updatedViewport?.viewportOptions
);
const displaySetOptions = updatedViewport?.displaySetOptions || [];
if (!displaySetOptions.length) {
// Copy all the display set options, assuming a full set of displaySet UID's is provided.
if (state.isHangingProtocolLayout) {
displaySetOptions.push(...(previousViewport.displaySetOptions || []));
}
if (!displaySetOptions.length) {
displaySetOptions.push({});
}
}
// if it is not part of the hanging protocol layout, we should remove the toolGroupId
// and viewportType from the viewportOptions so that it doesn't
// inherit the hanging protocol layout options, only when
// the viewport options is not provided (e.g., when drag and drop)
// otherwise, programmatically set options should be preserved
if (!updatedViewport.viewportOptions && !state.isHangingProtocolLayout) {
viewportOptions = {
viewportId: viewportOptions.viewportId,
};
}
const newViewport = {
...previousViewport,
displaySetInstanceUIDs,
viewportOptions,
displaySetOptions,
// viewportLabel: getViewportLabel(viewports, viewportId),
};
viewportOptions.presentationIds = service.getPresentationIds({
viewport: newViewport,
viewports,
});
viewports.set(viewportId, {
...viewports.get(viewportId),
...newViewport,
});
});
return { ...state, viewports };
}
case 'SET_LAYOUT': {
const {
numCols,
numRows,
layoutOptions,
layoutType = 'grid',
activeViewportId,
findOrCreateViewport,
isHangingProtocolLayout,
} = action.payload;
// If empty viewportOptions, we use numRow and numCols to calculate number of viewports
const hasOptions = layoutOptions?.length;
const viewports = new Map<string, AppTypes.ViewportGrid.Viewport>();
// Options is a temporary state store which can be used by the
// findOrCreate to store state about already found viewports. Typically,
// it will be used to store the display set UID's which are already
// in view so that the find or create can decide which display sets
// haven't been viewed yet, and add them in the appropriate order.
const options = {};
let activeViewportIdToSet = activeViewportId;
for (let row = 0; row < numRows; row++) {
for (let col = 0; col < numCols; col++) {
const position = col + row * numCols;
const layoutOption = layoutOptions[position];
let xPos, yPos, w, h;
if (layoutOptions && layoutOptions[position]) {
({ x: xPos, y: yPos, width: w, height: h } = layoutOptions[position]);
} else {
w = 1 / numCols;
h = 1 / numRows;
xPos = col * w;
yPos = row * h;
}
const colIndex = Math.round(xPos * numCols);
const rowIndex = Math.round(yPos * numRows);
const positionId = layoutOption?.positionId || `${colIndex}-${rowIndex}`;
if (hasOptions && position >= layoutOptions.length) {
continue;
}
const viewport = findOrCreateViewport(position, positionId, options);
if (!viewport) {
continue;
}
viewport.positionId = positionId;
// If the viewport doesn't have a viewportId, we create one
if (!viewport.viewportOptions?.viewportId) {
const randomUID = utils.uuidv4().substring(0, 8);
viewport.viewportOptions = viewport.viewportOptions || {};
viewport.viewportOptions.viewportId = `viewport-${randomUID}`;
}
viewport.viewportId = viewport.viewportOptions.viewportId;
// Create a new viewport object as it is getting updated here
// and it is part of the read only state
viewports.set(viewport.viewportId, viewport);
Object.assign(viewport, {
width: w,
height: h,
x: xPos,
y: yPos,
});
viewport.isReady = false;
if (!viewport.viewportOptions.presentationIds) {
const presentationIds = service.getPresentationIds({
viewport,
viewports,
});
viewport.viewportOptions.presentationIds = presentationIds;
}
}
}
activeViewportIdToSet =
activeViewportIdToSet ?? determineActiveViewportId(state, viewports);
const ret = {
...state,
activeViewportId: activeViewportIdToSet,
layout: {
...state.layout,
numCols,
numRows,
layoutType,
},
viewports,
isHangingProtocolLayout,
};
return ret;
}
case 'RESET': {
return DEFAULT_STATE;
}
case 'SET': {
return {
...state,
...action.payload,
};
}
case 'VIEWPORT_IS_READY': {
const { viewportId, isReady } = action.payload;
const viewports = new Map(state.viewports);
const viewport = viewports.get(viewportId);
if (!viewport) {
return;
}
viewports.set(viewportId, {
...viewport,
isReady,
});
return {
...state,
viewports,
};
}
default:
return action.payload;
}
};
const [viewportGridState, dispatch] = useReducer(viewportGridReducer, DEFAULT_STATE);
const getState = useCallback(() => {
return viewportGridState;
}, [viewportGridState]);
const getActiveViewportOptionByKey = (key: string) => {
const { viewports, activeViewportId } = viewportGridState;
return viewports.get(activeViewportId)?.viewportOptions?.[key];
};
const setActiveViewportId = useCallback(
index => dispatch({ type: 'SET_ACTIVE_VIEWPORT_ID', payload: index }),
[dispatch]
);
const setDisplaySetsForViewports = useCallback(
viewports =>
dispatch({
type: 'SET_DISPLAYSETS_FOR_VIEWPORTS',
payload: viewports,
}),
[dispatch]
);
const setViewportIsReady = useCallback(
(viewportId, isReady) => {
dispatch({
type: 'VIEWPORT_IS_READY',
payload: {
viewportId,
isReady,
},
});
},
[dispatch, viewportGridState]
);
const getGridViewportsReady = useCallback(() => {
const { viewports } = viewportGridState;
const readyViewports = Array.from(viewports.values()).filter(viewport => viewport.isReady);
return readyViewports.length === viewports.size;
}, [viewportGridState]);
const setLayout = useCallback(
({
layoutType,
numRows,
numCols,
layoutOptions = [],
activeViewportId,
findOrCreateViewport,
isHangingProtocolLayout,
}) =>
dispatch({
type: 'SET_LAYOUT',
payload: {
layoutType,
numRows,
numCols,
layoutOptions,
activeViewportId,
findOrCreateViewport,
isHangingProtocolLayout,
},
}),
[dispatch]
);
const reset = useCallback(
() =>
dispatch({
type: 'RESET',
payload: {},
}),
[dispatch]
);
const set = useCallback(
payload =>
dispatch({
type: 'SET',
payload,
}),
[dispatch]
);
const getNumViewportPanes = useCallback(() => {
const { layout, viewports } = viewportGridState;
const { numRows, numCols } = layout;
return Math.min(viewports.size, numCols * numRows);
}, [viewportGridState]);
/**
* Sets the implementation of ViewportGridService that can be used by extensions.
*
* @returns void
*/
useEffect(() => {
if (service) {
service.setServiceImplementation({
getState,
setActiveViewportId,
setDisplaySetsForViewports,
setLayout,
reset,
onModeExit: reset,
set,
getNumViewportPanes,
setViewportIsReady,
getGridViewportsReady,
});
}
}, [
getState,
service,
setActiveViewportId,
setDisplaySetsForViewports,
setLayout,
reset,
set,
getNumViewportPanes,
setViewportIsReady,
getGridViewportsReady,
]);
// run many of the calls through the service itself since we want to publish events
const api = {
getState,
setActiveViewportId: index => service.setActiveViewportId(index),
setDisplaySetsForViewport: props => service.setDisplaySetsForViewports([props]),
setDisplaySetsForViewports: props => service.setDisplaySetsForViewports(props),
setLayout: layout => service.setLayout(layout),
reset: () => service.reset(),
set: gridLayoutState => service.setState(gridLayoutState), // run it through the service itself since we want to publish events
getNumViewportPanes,
setViewportIsReady,
getGridViewportsReady,
getActiveViewportOptionByKey,
setViewportGridSizeChanged: props => service.setViewportGridSizeChanged(props),
publishViewportsReady: () => service.publishViewportsReady(),
};
return (
<ViewportGridContext.Provider value={[viewportGridState, api]}>
{children}
</ViewportGridContext.Provider>
);
}
ViewportGridProvider.propTypes = {
children: PropTypes.any,
service: PropTypes.instanceOf(ViewportGridService).isRequired,
};
// Update the useViewportGrid hook
export const useViewportGrid = (): [AppTypes.ViewportGrid.State, ViewportGridApi] =>
useContext(ViewportGridContext);
@@ -1,3 +1,5 @@
import NotificationProvider, { useNotification } from './NotificationProvider';
import { ViewportGridContext, ViewportGridProvider, useViewportGrid } from './ViewportGridProvider';
export { useNotification, NotificationProvider };
export { ViewportGridContext, ViewportGridProvider, useViewportGrid };
+22
View File
@@ -81,10 +81,20 @@ import {
StudySummary,
ErrorBoundary,
Header,
ViewportActionButton,
PatientInfo,
ViewportActionBar,
ViewportActionArrows,
ViewportPane,
ViewportActionCorners,
ViewportActionCornersLocations,
ViewportOverlay,
ViewportGrid,
} from './components';
import { DataRow } from './components/DataRow';
import { useNotification, NotificationProvider } from './contextProviders';
import { ViewportGridContext, ViewportGridProvider, useViewportGrid } from './contextProviders';
export {
ErrorBoundary,
@@ -104,6 +114,9 @@ export {
// contextProviders
NotificationProvider,
useNotification,
ViewportGridContext,
ViewportGridProvider,
useViewportGrid,
Separator,
Tabs,
TabsContent,
@@ -174,4 +187,13 @@ export {
TooltipContent,
StudySummary,
Header,
ViewportActionButton,
PatientInfo,
ViewportActionBar,
ViewportActionArrows,
ViewportPane,
ViewportActionCorners,
ViewportActionCornersLocations,
ViewportOverlay,
ViewportGrid,
};