feat(studies-panel): New OHIF study panel - under experimental flag (#4254)

This commit is contained in:
Ibrahim authored and GitHub committed 2024-09-11 14:57:19 -04:00
1 parent 4432b6b420
commit 7a96406a11
80 files changed
+4119 -253

No files matched your search

+1
View File
@@ -16,6 +16,7 @@ window.config = {
experimentalStudyBrowserSort: false,
strictZSpacingForVolumeViewport: true,
groupEnabledModesFirst: true,
useExperimentalUI: false,
maxNumRequests: {
interaction: 100,
thumbnail: 75,
+1
View File
@@ -8,6 +8,7 @@ window.config = {
// below flag is for performance reasons, but it might not work for all servers
showWarningMessageForCrossOrigin: true,
showCPUFallbackMessage: true,
useExperimentalUI: false,
showLoadingIndicator: true,
experimentalStudyBrowserSort: false,
strictZSpacingForVolumeViewport: true,
@@ -105,10 +105,13 @@ export default class PanelService extends PubSubService {
entry = panelsData[0].entry;
// stack the content of the panels in one react component
content = () => (
content = props => (
<>
{panelsData.map(({ content: PanelContent }, index) => (
<PanelContent key={index} />
<PanelContent
key={index}
{...props}
/>
))}
</>
);
+1
View File
@@ -27,6 +27,7 @@
".": "./src/index.ts"
},
"dependencies": {
"@radix-ui/react-accordion": "^1.2.0",
"@radix-ui/react-checkbox": "^1.1.1",
"@radix-ui/react-dialog": "^1.1.1",
"@radix-ui/react-icons": "^1.3.0",
@@ -0,0 +1,57 @@
'use client';
import * as React from 'react';
import * as AccordionPrimitive from '@radix-ui/react-accordion';
import { ChevronDownIcon } from '@radix-ui/react-icons';
import { cn } from '../../lib/utils';
const Accordion = AccordionPrimitive.Root;
const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item
ref={ref}
className={cn(className)}
{...props}
/>
));
AccordionItem.displayName = 'AccordionItem';
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
'flex flex-1 items-center justify-between py-[8px] px-[8px] text-sm font-medium transition-all [&[data-state=open]>svg]:rotate-90',
className
)}
{...props}
>
{children}
<ChevronDownIcon className="text-muted-foreground h-4 w-4 shrink-0 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
));
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
{...props}
>
<div className={cn(className)}>{children}</div>
</AccordionPrimitive.Content>
));
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
@@ -0,0 +1,3 @@
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from './Accordion';
export { Accordion, AccordionContent, AccordionItem, AccordionTrigger };
@@ -0,0 +1,66 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Icons } from '../Icons';
import { useTranslation } from 'react-i18next';
import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '../Tooltip';
/**
* Displays a tooltip with a list of messages of a displaySet
* @param param0
* @returns
*/
const DisplaySetMessageListTooltip = ({ messages, id }): React.ReactNode => {
const { t } = useTranslation('Messages');
if (messages?.size()) {
return (
<>
<TooltipProvider>
<Tooltip>
<TooltipTrigger id={id}>
<Icons.StatusWarning className="h-[20px] w-[20px]" />
</TooltipTrigger>
<TooltipContent side="right">
<div className="max-w-64 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>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</>
);
}
return <></>;
};
DisplaySetMessageListTooltip.propTypes = {
messages: PropTypes.object,
id: PropTypes.string,
};
export default DisplaySetMessageListTooltip;
@@ -0,0 +1,3 @@
import DisplaySetMessageListTooltip from './DisplaySetMessageListTooltip';
export default DisplaySetMessageListTooltip;
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="8.88756485px" height="5.15088921px" viewBox="0 0 8.88756485 5.15088921" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>icon-disclosure-close</title>
<g id="Artboards" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="Segmentation---assets" transform="translate(-115.6464, -67.6464)">
<g id="icon-disclosure-close" transform="translate(110, 60)">
<polyline id="Path-2" stroke="#348CFD" points="6 8 10.090229 12.090229 14.1804581 8"></polyline>
<rect id="Rectangle" x="0" y="0" width="20" height="20"></rect>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 737 B

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="5.15088921px" height="8.88756485px" viewBox="0 0 5.15088921 8.88756485" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>icon-disclosure-open</title>
<g id="Artboards" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="Segmentation---assets" transform="translate(-152.338, -65.6013)">
<g id="icon-disclosure-open" transform="translate(145, 60)">
<polyline id="Path-2" stroke="#348CFD" transform="translate(10.0902, 10.0451) rotate(90) translate(-10.0902, -10.0451)" points="6 8 10.090229 12.090229 14.1804581 8"></polyline>
<rect id="Rectangle" x="0" y="0" width="20" height="20"></rect>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 815 B

@@ -0,0 +1,3 @@
import { Icons } from './Icons';
export { Icons };
@@ -0,0 +1,66 @@
import React, { useState } from 'react';
import { Icons } from '../Icons';
import PropTypes from 'prop-types';
const PanelSection = ({ title, children, actionIcons = [], childrenClassName }) => {
const [areChildrenVisible, setChildrenVisible] = useState(true);
const handleHeaderClick = () => {
setChildrenVisible(!areChildrenVisible);
};
return (
<>
<div
className="bg-secondary-dark flex h-7 cursor-pointer select-none items-center justify-between rounded-[4px] pl-2.5 text-[13px]"
onClick={handleHeaderClick}
>
<div className="text-aqua-pale">{title}</div>
<div className="flex items-center space-x-1">
{actionIcons.map((icon, index) => {
const Icon = Icons[icon.name];
return (
<Icon
key={index}
onClick={e => {
e.stopPropagation();
if (!areChildrenVisible) {
setChildrenVisible(true);
}
icon.onClick();
}}
/>
);
})}
<div className="grid h-[28px] w-[28px] place-items-center">
{areChildrenVisible ? <Icons.ChevronOpen /> : <Icons.ChevronClosed />}
</div>
</div>
</div>
{areChildrenVisible && (
<>
<div className="h-[2px] bg-black"></div>
<div
className={`bg-primary-dark flex flex-col overflow-hidden rounded-b-[4px] ${childrenClassName}`}
>
{children}
</div>
</>
)}
</>
);
};
PanelSection.propTypes = {
title: PropTypes.string,
children: PropTypes.node,
childrenClassName: PropTypes.string,
actionIcons: PropTypes.arrayOf(
PropTypes.shape({
name: PropTypes.string,
onClick: PropTypes.func,
})
),
};
export default PanelSection;
@@ -0,0 +1,3 @@
import PanelSection from './PanelSection';
export default PanelSection;
@@ -1,24 +1,33 @@
import * as React from 'react';
import * as SeparatorPrimitive from '@radix-ui/react-separator';
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from '../../lib/utils';
type SeparatorProps = React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root> & {
thickness?: string;
};
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(({ className, orientation = 'horizontal', decorative = true, ...props }, ref) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
'bg-primary/25 shrink-0',
orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
className
)}
{...props}
/>
));
Separator.displayName = SeparatorPrimitive.Root.displayName;
SeparatorProps
>(
(
{ className, orientation = "horizontal", decorative = true, thickness = "1px", ...props },
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? `h-[${thickness}] w-full` : `h-full w-[${thickness}]`,
className
)}
{...props}
/>
)
)
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator };
export { Separator }
@@ -0,0 +1,3 @@
import { Separator } from "./Separator";
export default Separator;
@@ -0,0 +1,422 @@
import classnames from 'classnames';
import PropTypes from 'prop-types';
import React, { useCallback, useEffect, useState } from 'react';
import { Icons } from '../Icons';
import { TooltipTrigger, TooltipContent, TooltipProvider, Tooltip } from '../Tooltip';
import Separator from '../Separator';
type StyleMap = {
open: {
left: { marginLeft: string };
right: { marginRight: string };
};
closed: {
left: { marginLeft: string };
right: { marginRight: string };
};
};
const borderSize = 4;
const collapsedWidth = 25;
const closeIconWidth = 30;
const gridHorizontalPadding = 10;
const tabSpacerWidth = 2;
const baseClasses =
'transition-all duration-300 ease-in-out bg-black border-black justify-start box-content flex flex-col';
const classesMap = {
open: {
left: `mr-1`,
right: `ml-1`,
},
closed: {
left: `mr-2 items-end`,
right: `ml-2 items-start`,
},
};
const openStateIconName = {
left: 'SidePanelCloseLeft',
right: 'SidePanelCloseRight',
};
const getTabWidth = (numTabs: number) => {
if (numTabs < 3) {
return 68;
} else {
return 40;
}
};
const getGridWidth = (numTabs: number, gridAvailableWidth: number) => {
const spacersWidth = (numTabs - 1) * tabSpacerWidth;
const tabsWidth = getTabWidth(numTabs) * numTabs;
if (gridAvailableWidth > tabsWidth + spacersWidth) {
return tabsWidth + spacersWidth;
}
return gridAvailableWidth;
};
const getNumGridColumns = (numTabs: number, gridWidth: number) => {
if (numTabs === 1) {
return 1;
}
// Start by calculating the number of tabs assuming each tab was accompanied by a spacer.
const tabWidth = getTabWidth(numTabs);
const numTabsWithOneSpacerEach = Math.floor(gridWidth / (tabWidth + tabSpacerWidth));
// But there is always one less spacer than tabs, so now check if an extra tab with one less spacer fits.
if (
(numTabsWithOneSpacerEach + 1) * tabWidth + numTabsWithOneSpacerEach * tabSpacerWidth <=
gridWidth
) {
return numTabsWithOneSpacerEach + 1;
}
return numTabsWithOneSpacerEach;
};
const getTabClassNames = (
numColumns: number,
numTabs: number,
tabIndex: number,
isActiveTab: boolean,
isTabDisabled: boolean
) =>
classnames('h-[28px] mb-[2px] cursor-pointer text-white bg-black', {
'hover:text-primary-active': !isActiveTab && !isTabDisabled,
'rounded-l': tabIndex % numColumns === 0,
'rounded-r': (tabIndex + 1) % numColumns === 0 || tabIndex === numTabs - 1,
});
const getTabStyle = (numTabs: number) => {
return {
width: `${getTabWidth(numTabs)}px`,
};
};
const getTabIconClassNames = (numTabs: number, isActiveTab: boolean) => {
return classnames('h-full w-full flex items-center justify-center', {
'bg-customblue-40': isActiveTab,
rounded: isActiveTab,
});
};
const createStyleMap = (
expandedWidth: number,
borderSize: number,
collapsedWidth: number
): StyleMap => {
const collapsedHideWidth = expandedWidth - collapsedWidth - borderSize;
return {
open: {
left: { marginLeft: '0px' },
right: { marginRight: '0px' },
},
closed: {
left: { marginLeft: `-${collapsedHideWidth}px` },
right: { marginRight: `-${collapsedHideWidth}px` },
},
};
};
const getToolTipContent = (label: string, disabled: boolean) => {
return (
<>
<div>{label}</div>
{disabled && <div className="text-white">{'Not available based on current context'}</div>}
</>
);
};
const createBaseStyle = (expandedWidth: number) => {
return {
maxWidth: `${expandedWidth}px`,
width: `${expandedWidth}px`,
// To align the top of the side panel with the top of the viewport grid, use position relative and offset the
// top by the same top offset as the viewport grid. Also adjust the height so that there is no overflow.
position: 'relative',
top: '0.2%',
height: '99.8%',
};
};
const SidePanel = ({
side,
className,
activeTabIndex: activeTabIndexProp = null,
tabs,
onOpen,
expandedWidth = 280,
onActiveTabIndexChange,
}) => {
const [panelOpen, setPanelOpen] = useState(activeTabIndexProp !== null);
const [renderHeader, setRenderHeader] = useState(false);
const [activeTabIndex, setActiveTabIndex] = useState(0);
const styleMap = createStyleMap(expandedWidth, borderSize, collapsedWidth);
const baseStyle = createBaseStyle(expandedWidth);
const gridAvailableWidth = expandedWidth - closeIconWidth - gridHorizontalPadding;
const gridWidth = getGridWidth(tabs.length, gridAvailableWidth);
const openStatus = panelOpen ? 'open' : 'closed';
const style = Object.assign({}, styleMap[openStatus][side], baseStyle);
const updatePanelOpen = useCallback(
(panelOpen: boolean) => {
setPanelOpen(panelOpen);
if (panelOpen && onOpen) {
onOpen();
}
},
[onOpen]
);
const updateActiveTabIndex = useCallback(
(activeTabIndex: number) => {
if (activeTabIndex === null) {
updatePanelOpen(false);
return;
}
setActiveTabIndex(activeTabIndex);
updatePanelOpen(true);
if (onActiveTabIndexChange) {
onActiveTabIndexChange({ activeTabIndex });
}
},
[onActiveTabIndexChange, updatePanelOpen]
);
useEffect(() => {
setRenderHeader(tabs.length === 1);
}, [tabs]);
useEffect(() => {
updateActiveTabIndex(activeTabIndexProp);
}, [activeTabIndexProp, updateActiveTabIndex]);
const getCloseStateComponent = () => {
const _childComponents = Array.isArray(tabs) ? tabs : [tabs];
return (
<>
<div
className={classnames(
'bg-secondary-dark flex h-[28px] w-full cursor-pointer items-center rounded-md',
side === 'left' ? 'justify-end pr-2' : 'justify-start pl-2'
)}
onClick={() => {
updatePanelOpen(!panelOpen);
}}
data-cy={`side-panel-header-${side}`}
>
<Icons.NavigationPanelReveal
className={classnames('text-primary-active', side === 'left' && 'rotate-180 transform')}
/>
</div>
<div className={classnames('mt-3 flex flex-col space-y-3')}>
<TooltipProvider>
{_childComponents.map((childComponent, index) => (
<Tooltip key={index}>
<TooltipTrigger>
<div
id={`${childComponent.name}-btn`}
data-cy={`${childComponent.name}-btn`}
className="text-primary-active hover:cursor-pointer"
onClick={() => {
return childComponent.disabled ? null : updateActiveTabIndex(index);
}}
>
{React.createElement(Icons[childComponent.iconName] || Icons.MissingIcon, {
className: classnames({
'text-primary-active': true,
'ohif-disabled': childComponent.disabled,
}),
style: {
width: '22px',
height: '22px',
},
})}
</div>
</TooltipTrigger>
<TooltipContent side={side === 'left' ? 'right' : 'left'}>
<div
className={classnames(
'flex items-center',
side === 'left' ? 'justify-end' : 'justify-start'
)}
>
{getToolTipContent(childComponent.label, childComponent.disabled)}
</div>
</TooltipContent>
</Tooltip>
))}
</TooltipProvider>
</div>
</>
);
};
const getCloseIcon = () => {
return (
<div
className={classnames(
'absolute flex h-[24px] cursor-pointer items-center justify-center',
side === 'left' ? 'right-0' : 'left-0'
)}
style={{ width: `${closeIconWidth}px` }}
onClick={() => {
updatePanelOpen(!panelOpen);
}}
data-cy={`side-panel-header-${side}`}
>
{React.createElement(Icons[openStateIconName[side]] || Icons.MissingIcon, {
className: 'text-primary-active',
})}
</div>
);
};
const getTabGridComponent = () => {
const numCols = getNumGridColumns(tabs.length, gridWidth);
return (
<>
{getCloseIcon()}
<div className={classnames('flex grow justify-center')}>
<div className={classnames('bg-primary-dark text-primary-active flex flex-wrap')}>
{tabs.map((tab, tabIndex) => {
const { disabled } = tab;
return (
<React.Fragment key={tabIndex}>
{tabIndex % numCols !== 0 && (
<div
className={classnames(
'flex h-[28px] w-[2px] items-center bg-black',
tabSpacerWidth
)}
>
<div className="bg-primary-dark h-[20px] w-full"></div>
</div>
)}
<TooltipProvider>
<Tooltip key={tabIndex}>
<TooltipTrigger>
<div
className={getTabClassNames(
numCols,
tabs.length,
tabIndex,
tabIndex === activeTabIndex,
disabled
)}
style={getTabStyle(tabs.length)}
onClick={() => {
return disabled ? null : updateActiveTabIndex(tabIndex);
}}
data-cy={`${tab.name}-btn`}
>
<div
className={getTabIconClassNames(
tabs.length,
tabIndex === activeTabIndex
)}
>
{React.createElement(Icons[tab.iconName] || Icons.MissingIcon, {
className: classnames({
'text-primary-active': true,
'ohif-disabled': disabled,
}),
style: {
width: '22px',
height: '22px',
},
})}
</div>
</div>
</TooltipTrigger>
<TooltipContent side="bottom">
{getToolTipContent(tab.label, disabled)}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</React.Fragment>
);
})}
</div>
</div>
</>
);
};
const getOpenStateComponent = () => {
if (tabs.length === 1) {
return null;
}
return (
<>
<div className="bg-bkg-med flex h-[40px] select-none rounded-t p-2">
{getTabGridComponent()}
</div>
<Separator
orientation="horizontal"
className="bg-black"
thickness="2px"
/>
</>
);
};
return (
<div
className={classnames(className, baseClasses, classesMap[openStatus][side])}
style={style}
>
{panelOpen ? (
<>
{getOpenStateComponent()}
{tabs.map((tab, tabIndex) => {
if (tabIndex === activeTabIndex) {
return (
<tab.content
key={tabIndex}
getCloseIcon={getCloseIcon}
tab={tab}
renderHeader={renderHeader}
/>
);
}
return null;
})}
</>
) : (
<React.Fragment>{getCloseStateComponent()}</React.Fragment>
)}
</div>
);
};
SidePanel.propTypes = {
side: PropTypes.oneOf(['left', 'right']).isRequired,
className: PropTypes.string,
activeTabIndex: PropTypes.number,
tabs: PropTypes.oneOfType([
PropTypes.arrayOf(
PropTypes.shape({
iconName: PropTypes.string.isRequired,
iconLabel: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
content: PropTypes.func, // TODO: Should be node, but it keeps complaining?
})
),
]),
onOpen: PropTypes.func,
onActiveTabIndexChange: PropTypes.func,
expandedWidth: PropTypes.number,
};
export default SidePanel;
@@ -0,0 +1,2 @@
import SidePanel from './SidePanel';
export default SidePanel;
@@ -0,0 +1,143 @@
import React from 'react';
import PropTypes from 'prop-types';
import StudyItem from '../StudyItem';
import StudyBrowserSort from '../StudyBrowserSort';
import StudyBrowserViewOptions from '../StudyBrowserViewOptions';
const getTrackedSeries = displaySets => {
let trackedSeries = 0;
displaySets.forEach(displaySet => {
if (displaySet.isTracked) {
trackedSeries++;
}
});
return trackedSeries;
};
const noop = () => {};
const StudyBrowser = ({
tabs,
activeTabName,
expandedStudyInstanceUIDs,
onClickTab = noop,
onClickStudy = noop,
onClickThumbnail = noop,
onDoubleClickThumbnail = noop,
onClickUntrack = noop,
activeDisplaySetInstanceUIDs,
servicesManager,
showSettings,
viewPresets,
}: withAppTypes) => {
const getTabContent = () => {
const tabData = tabs.find(tab => tab.name === activeTabName);
const viewPreset = viewPresets
? viewPresets.filter(preset => preset.selected)[0]?.id
: 'thumbnails';
return tabData.studies.map(
({ studyInstanceUid, date, description, numInstances, modalities, displaySets }) => {
const isExpanded = expandedStudyInstanceUIDs.includes(studyInstanceUid);
return (
<React.Fragment key={studyInstanceUid}>
<StudyItem
date={date}
description={description}
numInstances={numInstances}
isExpanded={isExpanded}
displaySets={displaySets}
modalities={modalities}
trackedSeries={getTrackedSeries(displaySets)}
isActive={isExpanded}
onClick={() => {
onClickStudy(studyInstanceUid);
}}
onClickThumbnail={onClickThumbnail}
onDoubleClickThumbnail={onDoubleClickThumbnail}
onClickUntrack={onClickUntrack}
activeDisplaySetInstanceUIDs={activeDisplaySetInstanceUIDs}
data-cy="thumbnail-list"
viewPreset={viewPreset}
/>
</React.Fragment>
);
}
);
};
return (
<React.Fragment>
{showSettings && (
<div
className="w-100 bg-bkg-low flex h-[48px] items-center justify-center gap-[10px] px-[8px] py-[10px]"
data-cy={'studyBrowser-panel'}
>
<StudyBrowserViewOptions
tabs={tabs}
onSelectTab={onClickTab}
activeTabName={activeTabName}
/>
<StudyBrowserSort servicesManager={servicesManager} />
</div>
)}
<div className="ohif-scrollbar invisible-scrollbar bg-bkg-low flex flex-1 flex-col gap-[4px] overflow-auto">
{getTabContent()}
</div>
</React.Fragment>
);
};
StudyBrowser.propTypes = {
onClickTab: PropTypes.func.isRequired,
onClickStudy: PropTypes.func,
onClickThumbnail: PropTypes.func,
onDoubleClickThumbnail: PropTypes.func,
onClickUntrack: PropTypes.func,
activeTabName: PropTypes.string.isRequired,
expandedStudyInstanceUIDs: PropTypes.arrayOf(PropTypes.string).isRequired,
activeDisplaySetInstanceUIDs: PropTypes.arrayOf(PropTypes.string),
tabs: PropTypes.arrayOf(
PropTypes.shape({
name: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
studies: PropTypes.arrayOf(
PropTypes.shape({
studyInstanceUid: PropTypes.string.isRequired,
date: PropTypes.string,
numInstances: PropTypes.number,
modalities: PropTypes.string,
description: PropTypes.string,
displaySets: PropTypes.arrayOf(
PropTypes.shape({
displaySetInstanceUID: PropTypes.string.isRequired,
imageSrc: PropTypes.string,
imageAltText: PropTypes.string,
seriesDate: PropTypes.string,
seriesNumber: PropTypes.any,
numInstances: PropTypes.number,
description: PropTypes.string,
componentType: PropTypes.oneOf(['thumbnail', 'thumbnailTracked', 'thumbnailNoImage'])
.isRequired,
isTracked: PropTypes.bool,
/**
* Data the thumbnail should expose to a receiving drop target. Use a matching
* `dragData.type` to identify which targets can receive this draggable item.
* If this is not set, drag-n-drop will be disabled for this thumbnail.
*
* Ref: https://react-dnd.github.io/react-dnd/docs/api/use-drag#specification-object-members
*/
dragData: PropTypes.shape({
/** Must match the "type" a dropTarget expects */
type: PropTypes.string.isRequired,
}),
})
),
})
).isRequired,
})
),
};
export default StudyBrowser;
@@ -0,0 +1,2 @@
import StudyBrowser from './StudyBrowser';
export default StudyBrowser;
@@ -0,0 +1,75 @@
import React, { useEffect, useState } from 'react';
import { Icons } from '../Icons';
export default function StudyBrowserSort({ servicesManager }: withAppTypes) {
const { customizationService, displaySetService } = servicesManager.services;
const { values: sortFunctions } = customizationService.get('studyBrowser.sortFunctions');
const [selectedSort, setSelectedSort] = useState(sortFunctions[0]);
const [sortDirection, setSortDirection] = useState('ascending');
const handleSortChange = event => {
const selectedSortFunction = sortFunctions.find(sort => sort.label === event.target.value);
setSelectedSort(selectedSortFunction);
};
const toggleSortDirection = e => {
e.stopPropagation();
setSortDirection(prevDirection => (prevDirection === 'ascending' ? 'descending' : 'ascending'));
};
useEffect(() => {
displaySetService.sortDisplaySets(selectedSort.sortFunction, sortDirection);
}, [displaySetService, selectedSort, sortDirection]);
useEffect(() => {
const SubscriptionDisplaySetsChanged = displaySetService.subscribe(
displaySetService.EVENTS.DISPLAY_SETS_CHANGED,
() => {
displaySetService.sortDisplaySets(selectedSort.sortFunction, sortDirection, true);
}
);
const SubscriptionDisplaySetMetaDataInvalidated = displaySetService.subscribe(
displaySetService.EVENTS.DISPLAY_SET_SERIES_METADATA_INVALIDATED,
() => {
displaySetService.sortDisplaySets(selectedSort.sortFunction, sortDirection, true);
}
);
return () => {
SubscriptionDisplaySetsChanged.unsubscribe();
SubscriptionDisplaySetMetaDataInvalidated.unsubscribe();
};
}, [displaySetService, selectedSort, sortDirection]);
return (
<div className="border-inputfield-main focus:border-inputfield-main flex h-[26px] w-[125px] items-center justify-center rounded border bg-black p-2">
<select
onChange={handleSortChange}
value={selectedSort.label}
onClick={e => e.stopPropagation()}
className="w-full appearance-none bg-transparent text-sm leading-tight text-white shadow transition duration-300 focus:outline-none"
>
{sortFunctions.map(sort => (
<option
className="appearance-none bg-black text-white"
value={sort.label}
key={sort.label}
>
{sort.label}
</option>
))}
</select>
<button
onClick={toggleSortDirection}
className="flex items-center justify-center"
>
{sortDirection === 'ascending' ? (
<Icons.SortingAscending className="text-primary-main w-2" />
) : (
<Icons.SortingDescending className="text-primary-main w-2" />
)}
</button>
</div>
);
}
@@ -0,0 +1,3 @@
import StudyBrowserSort from './StudyBrowserSort';
export default StudyBrowserSort;
@@ -0,0 +1,40 @@
import React from 'react';
export default function StudyBrowserViewOptions({
tabs,
onSelectTab,
activeTabName,
}: withAppTypes) {
const handleTabChange = (tabName: string) => {
onSelectTab(tabName);
};
return (
<div className="border-inputfield-main focus:border-inputfield-main flex h-[26px] w-[125px] items-center justify-center rounded border bg-black p-2">
<select
onChange={e => handleTabChange(e.target.value)}
value={activeTabName}
onClick={e => e.stopPropagation()}
className="w-full appearance-none bg-transparent text-sm leading-tight text-white shadow transition duration-300 focus:outline-none"
>
{tabs.map(tab => {
const { name, label, studies } = tab;
const isActive = activeTabName === name;
const isDisabled = !studies.length;
if (isDisabled) {
return null;
}
return (
<option
className={`appearance-none bg-black text-white ${isActive ? 'font-bold' : ''}`}
value={name}
key={name}
>
{label}
</option>
);
})}
</select>
</div>
);
}
@@ -0,0 +1,3 @@
import StudyBrowserViewOptions from './StudyBrowserViewOptions';
export default StudyBrowserViewOptions;
@@ -0,0 +1,88 @@
import React from 'react';
import PropTypes from 'prop-types';
import classnames from 'classnames';
import ThumbnailList from '../ThumbnailList';
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '../Accordion';
const StudyItem = ({
date,
description,
numInstances,
modalities,
isActive,
onClick,
isExpanded,
displaySets,
activeDisplaySetInstanceUIDs,
onClickThumbnail,
onDoubleClickThumbnail,
onClickUntrack,
viewPreset = 'thumbnails',
}) => {
return (
<Accordion
type="single"
collapsible
onClick={onClick}
onKeyDown={onClick}
role="button"
tabIndex={0}
defaultValue={isActive ? 'study-item' : undefined}
>
<AccordionItem value="study-item">
<AccordionTrigger className={classnames('hover:bg-accent bg-popover rounded')}>
<div className="flex h-[40px] flex-1 flex-row">
<div className="flex w-full flex-row items-center justify-between">
<div className="flex flex-col items-start text-[13px]">
<div className="text-white">{date}</div>
<div className="text-muted-foreground max-w-[160px] overflow-hidden truncate whitespace-nowrap">
{description}
</div>
</div>
<div className="text-muted-foreground mr-2 flex flex-col items-end text-[12px]">
<div>{modalities}</div>
<div>{numInstances}</div>
</div>
</div>
</div>
</AccordionTrigger>
<AccordionContent
onClick={event => {
event.stopPropagation();
}}
>
{isExpanded && displaySets && (
<ThumbnailList
thumbnails={displaySets}
activeDisplaySetInstanceUIDs={activeDisplaySetInstanceUIDs}
onThumbnailClick={onClickThumbnail}
onThumbnailDoubleClick={onDoubleClickThumbnail}
onClickUntrack={onClickUntrack}
viewPreset={viewPreset}
/>
)}
</AccordionContent>
</AccordionItem>
</Accordion>
);
};
StudyItem.propTypes = {
date: PropTypes.string.isRequired,
description: PropTypes.string,
modalities: PropTypes.string.isRequired,
numInstances: PropTypes.number.isRequired,
trackedSeries: PropTypes.number,
isActive: PropTypes.bool,
onClick: PropTypes.func.isRequired,
isExpanded: PropTypes.bool,
displaySets: PropTypes.array,
activeDisplaySetInstanceUIDs: PropTypes.array,
onClickThumbnail: PropTypes.func,
onDoubleClickThumbnail: PropTypes.func,
onClickUntrack: PropTypes.func,
viewPreset: PropTypes.string,
};
export default StudyItem;
@@ -0,0 +1,2 @@
import StudyItem from './StudyItem';
export default StudyItem;
@@ -0,0 +1,3 @@
import { Tabs, TabsList, TabsTrigger, TabsContent } from "./Tabs"
export { Tabs, TabsList, TabsTrigger, TabsContent }
@@ -0,0 +1,294 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import classnames from 'classnames';
import { useDrag } from 'react-dnd';
import { Icons } from '../Icons';
import DisplaySetMessageListTooltip from '../DisplaySetMessageListTooltip';
import { TooltipTrigger, TooltipContent, TooltipProvider, Tooltip } from '../Tooltip';
/**
* Display a thumbnail for a display set.
*/
const Thumbnail = ({
displaySetInstanceUID,
className,
imageSrc,
imageAltText,
description,
seriesNumber,
numInstances,
loadingProgress,
countIcon,
messages,
dragData = {},
isActive,
onClick,
onDoubleClick,
viewPreset = 'thumbnails',
modality,
isHydratedForDerivedDisplaySet = false,
canReject = false,
onReject = () => {},
isTracked = false,
onClickUntrack = () => {},
}): React.ReactNode => {
// TODO: We should wrap our thumbnail to create a "DraggableThumbnail", as
// this will still allow for "drag", even if there is no drop target for the
// specified item.
const [collectedProps, drag, dragPreview] = useDrag({
type: 'displayset',
item: { ...dragData },
canDrag: function (monitor) {
return Object.keys(dragData).length !== 0;
},
});
const [lastTap, setLastTap] = useState(0);
const handleTouchEnd = e => {
const currentTime = new Date().getTime();
const tapLength = currentTime - lastTap;
if (tapLength < 300 && tapLength > 0) {
onDoubleClick(e);
} else {
onClick(e);
}
setLastTap(currentTime);
};
const renderThumbnailPreset = () => {
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-[2px] p-[4px]">
<div className="h-[114px] w-[128px]">
<div className="relative">
{imageSrc ? (
<img
src={imageSrc}
alt={imageAltText}
className="h-[114px] w-[128px] rounded"
crossOrigin="anonymous"
/>
) : (
<div className="bg-background h-[114px] w-[128px] rounded"></div>
)}
{/* bottom left */}
<div className="bg-muted absolute bottom-0 left-0 flex h-[14px] items-center gap-[4px] p-[4px]">
<div
className={classnames(
'h-[10px] w-[10px] rounded-[2px]',
isActive || isHydratedForDerivedDisplaySet ? 'bg-highlight' : 'bg-primary/65',
loadingProgress && loadingProgress < 1 && 'bg-primary/25'
)}
></div>
<div className="text-[11px] text-white">{modality}</div>
</div>
{/* top right */}
<div className="absolute top-0 right-0 flex items-center gap-[4px]">
<DisplaySetMessageListTooltip
messages={messages}
id={`display-set-tooltip-${displaySetInstanceUID}`}
/>
{canReject && (
<Icons.Trash
className="h-[20px] w-[20px] text-red-500"
onClick={onReject}
/>
)}
{isTracked && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<div className="group">
<Icons.StatusTracking className="text-primary-light h-[20px] w-[20px] group-hover:hidden" />
<Icons.Cancel
className="text-primary-light hidden h-[15px] w-[15px] group-hover:block"
onClick={onClickUntrack}
/>
</div>
</TooltipTrigger>
<TooltipContent side="right">
<div className="flex flex-1 flex-row">
<div className="flex-2 flex items-center justify-center pr-4">
<Icons.InfoLink className="text-primary-active" />
</div>
<div className="flex flex-1 flex-col">
<span>
<span className="text-white">
{isTracked ? 'Series is tracked' : 'Series is untracked'}
</span>
</span>
</div>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
</div>
</div>
<div className="flex h-[52px] w-[128px] flex-col">
<div className="text-[12px] text-white">{description}</div>
<div className="flex h-[12px] items-center gap-[7px] overflow-hidden">
<div className="text-muted-foreground text-[12px]"> S:{seriesNumber}</div>
<div className="text-muted-foreground text-[12px]">
<div className="flex items-center gap-[4px]">
{' '}
{countIcon ? (
React.createElement(Icons[countIcon] || Icons.MissingIcon, { className: 'w-3' })
) : (
<Icons.InfoSeries className="w-3" />
)}
<div>{numInstances}</div>
</div>
</div>
</div>
</div>
</div>
);
};
const renderListPreset = () => {
return (
<div className="flex h-full w-full items-center justify-between pr-[8px] pl-[8px] pt-[4px] pb-[4px]">
<div className="relative flex h-[32px] items-center gap-[8px]">
<div
className={classnames(
'h-[32px] w-[4px] rounded-[2px]',
isActive || isHydratedForDerivedDisplaySet ? 'bg-highlight' : 'bg-primary/65',
loadingProgress && loadingProgress < 1 && 'bg-primary/25'
)}
></div>
<div className="flex h-full flex-col">
<div className="flex items-center gap-[7px]">
<div className="text-[13px] text-white">{modality}</div>
<div className="max-w-[160px] overflow-hidden overflow-ellipsis whitespace-nowrap text-[13px] text-white">
{description}
</div>
</div>
<div className="flex h-[12px] items-center gap-[7px] overflow-hidden">
<div className="text-muted-foreground text-[12px]"> S:{seriesNumber}</div>
<div className="text-muted-foreground text-[12px]">
<div className="flex items-center gap-[4px]">
{' '}
{countIcon ? (
React.createElement(Icons[countIcon] || Icons.MissingIcon, { className: 'w-3' })
) : (
<Icons.InfoSeries className="w-3" />
)}
<div>{numInstances}</div>
</div>
</div>
</div>
</div>
</div>
<div className="flex h-full items-center gap-[4px]">
<DisplaySetMessageListTooltip
messages={messages}
id={`display-set-tooltip-${displaySetInstanceUID}`}
/>
{canReject && (
<Icons.Trash
className="h-[20px] w-[20px] text-red-500"
onClick={onReject}
/>
)}
{isTracked && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<div className="group">
<Icons.StatusTracking className="text-primary-light h-[20px] w-[20px] group-hover:hidden" />
<Icons.Cancel
className="text-primary-light hidden h-[15px] w-[15px] group-hover:block"
onClick={onClickUntrack}
/>
</div>
</TooltipTrigger>
<TooltipContent side="right">
<div className="flex flex-1 flex-row">
<div className="flex-2 flex items-center justify-center pr-4">
<Icons.InfoLink className="text-primary-active" />
</div>
<div className="flex flex-1 flex-col">
<span>
<span className="text-white">
{isTracked ? 'Series is tracked' : 'Series is untracked'}
</span>
</span>
</div>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
</div>
);
};
return (
<div
className={classnames(
className,
'bg-muted hover:bg-primary/30 flex cursor-pointer select-none flex-col outline-none',
viewPreset === 'thumbnails' && 'h-[170px] w-[135px]',
viewPreset === 'list' && 'h-[40px] w-[275px]'
)}
id={`thumbnail-${displaySetInstanceUID}`}
data-cy={`study-browser-thumbnail`}
data-series={seriesNumber}
onClick={onClick}
onDoubleClick={onDoubleClick}
onTouchEnd={handleTouchEnd}
role="button"
>
<div
ref={drag}
className="h-full w-full"
>
{viewPreset === 'thumbnails' && renderThumbnailPreset()}
{viewPreset === 'list' && renderListPreset()}
</div>
</div>
);
};
Thumbnail.propTypes = {
displaySetInstanceUID: PropTypes.string.isRequired,
className: PropTypes.string,
imageSrc: 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.
* If this is not set, drag-n-drop will be disabled for this thumbnail.
*
* Ref: https://react-dnd.github.io/react-dnd/docs/api/use-drag#specification-object-members
*/
dragData: PropTypes.shape({
/** Must match the "type" a dropTarget expects */
type: PropTypes.string.isRequired,
}),
imageAltText: PropTypes.string,
description: PropTypes.string.isRequired,
seriesNumber: PropTypes.any,
numInstances: PropTypes.number.isRequired,
loadingProgress: PropTypes.number,
messages: PropTypes.object,
isActive: PropTypes.bool.isRequired,
onClick: PropTypes.func.isRequired,
onDoubleClick: PropTypes.func.isRequired,
viewPreset: PropTypes.string,
modality: PropTypes.string,
isHydratedForDerivedDisplaySet: PropTypes.bool,
canReject: PropTypes.bool,
onReject: PropTypes.func,
isTracked: PropTypes.bool,
onClickUntrack: PropTypes.func,
countIcon: PropTypes.string,
};
export default Thumbnail;
@@ -0,0 +1,2 @@
import Thumbnail from './Thumbnail';
export default Thumbnail;
@@ -0,0 +1,162 @@
import React from 'react';
import PropTypes from 'prop-types';
import Thumbnail from '../Thumbnail';
const ThumbnailList = ({
thumbnails,
onThumbnailClick,
onThumbnailDoubleClick,
onClickUntrack,
activeDisplaySetInstanceUIDs = [],
viewPreset,
}) => {
return (
<div
id="ohif-thumbnail-list"
className={`ohif-scrollbar bg-bkg-low grid place-items-center overflow-y-hidden pt-[4px] pr-[2.5px] pl-[2.5px] ${viewPreset === 'thumbnails' ? 'grid-cols-2 gap-[4px] pb-[12px]' : 'grid-cols-1 gap-[2px]'}`}
>
{thumbnails.map(
({
displaySetInstanceUID,
description,
dragData,
seriesNumber,
numInstances,
loadingProgress,
modality,
componentType,
seriesDate,
countIcon,
isTracked,
canReject,
onReject,
imageSrc,
messages,
imageAltText,
isHydratedForDerivedDisplaySet,
}) => {
const isActive = activeDisplaySetInstanceUIDs.includes(displaySetInstanceUID);
switch (componentType) {
case 'thumbnail':
return (
<Thumbnail
key={displaySetInstanceUID}
displaySetInstanceUID={displaySetInstanceUID}
dragData={dragData}
description={description}
seriesNumber={seriesNumber}
numInstances={numInstances || 1}
countIcon={countIcon}
imageSrc={imageSrc}
imageAltText={imageAltText}
messages={messages}
isActive={isActive}
onClick={() => onThumbnailClick(displaySetInstanceUID)}
onDoubleClick={() => onThumbnailDoubleClick(displaySetInstanceUID)}
viewPreset={viewPreset}
modality={modality}
/>
);
case 'thumbnailTracked':
return (
<Thumbnail
key={displaySetInstanceUID}
displaySetInstanceUID={displaySetInstanceUID}
dragData={dragData}
description={description}
seriesNumber={seriesNumber}
numInstances={numInstances}
loadingProgress={loadingProgress}
countIcon={countIcon}
imageSrc={imageSrc}
imageAltText={imageAltText}
messages={messages}
isTracked={isTracked}
isActive={isActive}
onClick={() => onThumbnailClick(displaySetInstanceUID)}
onDoubleClick={() => onThumbnailDoubleClick(displaySetInstanceUID)}
onClickUntrack={() => onClickUntrack(displaySetInstanceUID)}
viewPreset={viewPreset}
modality={modality}
/>
);
case 'thumbnailNoImage':
return (
<Thumbnail
isActive={isActive}
key={displaySetInstanceUID}
displaySetInstanceUID={displaySetInstanceUID}
dragData={dragData}
modality={modality}
messages={messages}
description={description}
onClick={() => onThumbnailClick(displaySetInstanceUID)}
onDoubleClick={() => onThumbnailDoubleClick(displaySetInstanceUID)}
viewPreset={viewPreset}
countIcon={countIcon}
seriesNumber={seriesNumber}
numInstances={numInstances || 1}
isHydratedForDerivedDisplaySet={isHydratedForDerivedDisplaySet}
canReject={canReject}
onReject={onReject}
/>
);
default:
return <></>;
}
}
)}
</div>
);
};
ThumbnailList.propTypes = {
thumbnails: PropTypes.arrayOf(
PropTypes.shape({
displaySetInstanceUID: PropTypes.string.isRequired,
imageSrc: PropTypes.string,
imageAltText: PropTypes.string,
seriesDate: PropTypes.string,
seriesNumber: PropTypes.any,
numInstances: PropTypes.number,
description: PropTypes.string,
componentType: PropTypes.any,
isTracked: PropTypes.bool,
/**
* Data the thumbnail should expose to a receiving drop target. Use a matching
* `dragData.type` to identify which targets can receive this draggable item.
* If this is not set, drag-n-drop will be disabled for this thumbnail.
*
* Ref: https://react-dnd.github.io/react-dnd/docs/api/use-drag#specification-object-members
*/
dragData: PropTypes.shape({
/** Must match the "type" a dropTarget expects */
type: PropTypes.string.isRequired,
}),
})
),
activeDisplaySetInstanceUIDs: PropTypes.arrayOf(PropTypes.string),
onThumbnailClick: PropTypes.func.isRequired,
onThumbnailDoubleClick: PropTypes.func.isRequired,
onClickUntrack: PropTypes.func.isRequired,
viewPreset: PropTypes.string,
};
// TODO: Support "Viewport Identificator"?
function _getModalityTooltip(modality) {
if (_modalityTooltips.hasOwnProperty(modality)) {
return _modalityTooltips[modality];
}
return 'Unknown';
}
const _modalityTooltips = {
SR: 'Structured Report',
SEG: 'Segmentation',
OT: 'Other',
RTSTRUCT: 'RT Structure Set',
};
export default ThumbnailList;
@@ -0,0 +1,2 @@
import ThumbnailList from './ThumbnailList';
export default ThumbnailList;
@@ -9,12 +9,12 @@ const toggleVariants = cva(
{
variants: {
variant: {
default: 'bg-transparent',
default: 'bg-transparent hover:text-primary',
outline:
'border border-input bg-transparent shadow-sm hover:bg-primary/20 hover:text-primary-foreground',
},
size: {
default: 'h-9 px-3 mx-0.5',
default: 'h-[24px] w-[28px]',
sm: 'h-8 px-2',
lg: 'h-10 px-3',
},
@@ -0,0 +1,3 @@
import { Toggle, toggleVariants } from "./Toggle";
export { Toggle, toggleVariants };
@@ -0,0 +1,55 @@
import * as React from 'react';
import * as ToggleGroupPrimitive from '@radix-ui/react-toggle-group';
import { VariantProps } from 'class-variance-authority';
import { cn } from '../../lib/utils';
import { toggleVariants } from '../Toggle';
const ToggleGroupContext = React.createContext<VariantProps<typeof toggleVariants>>({
size: 'default',
variant: 'default',
});
const ToggleGroup = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants>
>(({ className, variant, size, children, ...props }, ref) => (
<ToggleGroupPrimitive.Root
ref={ref}
className={cn('bg-primary/10 flex items-center justify-center rounded-md', className)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size }}>{children}</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
));
ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName;
const ToggleGroupItem = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>
>(({ className, children, variant, size, ...props }, ref) => {
const context = React.useContext(ToggleGroupContext);
return (
<ToggleGroupPrimitive.Item
ref={ref}
className={cn(
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
className
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
);
});
ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName;
export { ToggleGroup, ToggleGroupItem };
@@ -0,0 +1,3 @@
import { ToggleGroup, ToggleGroupItem } from "./ToggleGroup";
export { ToggleGroup, ToggleGroupItem };
@@ -0,0 +1,183 @@
import React, { useEffect, useRef } from 'react';
import { useToolbar } from '@ohif/core';
import { ToolboxUI } from './';
// Migrate this file to the new UI eventually
import { useToolbox } from '@ohif/ui';
import Separator from '../Separator';
/**
* A toolbox is a collection of buttons and commands that they invoke, used to provide
* custom control panels to users. This component is a generic UI component that
* interacts with services and commands in a generic fashion. While it might
* seem unconventional to import it from the UI and integrate it into the JSX,
* it belongs in the UI components as there isn't anything in this component that
* couldn't be used for a completely different type of app. It plays a crucial
* role in enhancing the app with a toolbox by providing a way to integrate
* and display various tools and their corresponding options
*/
function Toolbox({
servicesManager,
buttonSectionId,
commandsManager,
title,
renderHeader,
getCloseIcon,
tab,
...props
}: withAppTypes) {
const { state: toolboxState, api } = useToolbox(buttonSectionId);
const { onInteraction, toolbarButtons } = useToolbar({
servicesManager,
buttonSection: buttonSectionId,
});
const prevButtonIdsRef = useRef('');
const prevToolboxStateRef = useRef('');
useEffect(() => {
const currentButtonIdsStr = JSON.stringify(
toolbarButtons.map(button => {
const { id, componentProps } = button;
if (componentProps.items?.length) {
return componentProps.items.map(item => `${item.id}-${item.disabled}`);
}
return `${id}-${componentProps.disabled}`;
})
);
const currentToolBoxStateStr = JSON.stringify(
Object.keys(toolboxState.toolOptions).map(tool => {
const options = toolboxState.toolOptions[tool];
if (Array.isArray(options)) {
return options?.map(option => `${option.id}-${option.value}`);
}
})
);
if (
prevButtonIdsRef.current === currentButtonIdsStr &&
prevToolboxStateRef.current === currentToolBoxStateStr
) {
return;
}
prevButtonIdsRef.current = currentButtonIdsStr;
prevToolboxStateRef.current = currentToolBoxStateStr;
const initializeOptionsWithEnhancements = toolbarButtons.reduce(
(accumulator, toolbarButton) => {
const { id: buttonId, componentProps } = toolbarButton;
const createEnhancedOptions = (options, parentId) => {
const optionsToUse = Array.isArray(options) ? options : [options];
return optionsToUse.map(option => {
if (typeof option.optionComponent === 'function') {
return option;
}
const value =
toolboxState.toolOptions?.[parentId]?.find(prop => prop.id === option.id)?.value ??
option.value;
const updatedOptions = toolboxState.toolOptions?.[parentId];
return {
...option,
value,
commands: value => {
api.handleToolOptionChange(parentId, option.id, value);
const { isArray } = Array;
const cmds = isArray(option.commands) ? option.commands : [option.commands];
cmds.forEach(command => {
const isString = typeof command === 'string';
const isObject = typeof command === 'object';
const isFunction = typeof command === 'function';
if (isString) {
commandsManager.run(command, { value });
} else if (isObject) {
commandsManager.run({
...command,
commandOptions: {
...command.commandOptions,
...option,
value,
options: updatedOptions,
},
});
} else if (isFunction) {
command({ value, commandsManager, servicesManager, options: updatedOptions });
}
});
},
};
});
};
const { items, options } = componentProps;
if (items?.length) {
items.forEach(({ options, id }) => {
accumulator[id] = createEnhancedOptions(options, id);
});
} else if (options?.length) {
accumulator[buttonId] = createEnhancedOptions(options, buttonId);
} else if (options?.optionComponent) {
accumulator[buttonId] = options.optionComponent;
}
return accumulator;
},
{}
);
api.initializeToolOptions(initializeOptionsWithEnhancements);
}, [toolbarButtons, api, toolboxState, commandsManager, servicesManager]);
const handleToolOptionChange = (toolName, optionName, newValue) => {
api.handleToolOptionChange(toolName, optionName, newValue);
};
useEffect(() => {
return () => {
api.handleToolSelect(null);
};
}, []);
return (
<>
{renderHeader && (
<>
<div className="bg-primary-dark flex select-none rounded-t pt-1.5 pb-[2px]">
<div className="flex h-[24px] w-full cursor-pointer select-none justify-center self-center text-[14px]">
<div className="text-primary-active flex grow cursor-pointer select-none justify-center self-center text-[13px]">
<span>{tab.label}</span>
</div>
</div>
{getCloseIcon()}
</div>
<Separator
orientation="horizontal"
className="bg-black"
thickness="2px"
/>
</>
)}
<ToolboxUI
{...props}
title={title}
toolbarButtons={toolbarButtons}
toolboxState={toolboxState}
handleToolSelect={id => api.handleToolSelect(id)}
handleToolOptionChange={handleToolOptionChange}
onInteraction={onInteraction}
/>
</>
);
}
export default Toolbox;
@@ -0,0 +1,126 @@
import React, { useEffect, useRef } from 'react';
import { PanelSection } from '../../components';
// Migrate this file to the new UI eventually
import { ToolSettings } from '@ohif/ui';
import classnames from 'classnames';
const ItemsPerRow = 4;
function usePrevious(value) {
const ref = useRef();
useEffect(() => {
ref.current = value;
});
return ref.current;
}
/**
* Just refactoring from the toolbox component to make it more readable
*/
function ToolboxUI(props: withAppTypes) {
const {
toolbarButtons,
handleToolSelect,
toolboxState,
numRows,
servicesManager,
title,
useCollapsedPanel = true,
} = props;
const { activeTool, toolOptions, selectedEvent } = toolboxState;
const activeToolOptions = toolOptions?.[activeTool];
const prevToolOptions = usePrevious(activeToolOptions);
useEffect(() => {
if (!activeToolOptions || Array.isArray(activeToolOptions) === false) {
return;
}
activeToolOptions.forEach((option, index) => {
const prevOption = prevToolOptions ? prevToolOptions[index] : undefined;
if (!prevOption || option.value !== prevOption.value || selectedEvent) {
const isOptionValid = option.condition
? option.condition({ options: activeToolOptions })
: true;
if (isOptionValid) {
const { commands } = option;
commands(option.value);
}
}
});
}, [activeToolOptions, selectedEvent]);
const render = () => {
return (
<>
<div className="flex flex-col bg-black">
<div className="bg-primary-dark mt-0.5 flex flex-wrap py-2">
{toolbarButtons.map((toolDef, index) => {
if (!toolDef) {
return null;
}
const { id, Component, componentProps } = toolDef;
const isLastRow = Math.floor(index / ItemsPerRow) + 1 === numRows;
const toolClasses = `ml-1 ${isLastRow ? '' : 'mb-2'}`;
const onInteraction = ({ itemId, id, commands }) => {
const idToUse = itemId || id;
handleToolSelect(idToUse);
props.onInteraction({
itemId,
commands,
});
};
return (
<div
key={id}
className={classnames({
[toolClasses]: true,
'border-secondary-light flex flex-col items-center justify-center rounded-md border':
true,
})}
>
<div className="flex rounded-md bg-black">
<Component
{...componentProps}
{...props}
id={id}
servicesManager={servicesManager}
onInteraction={onInteraction}
size="toolbox"
/>
</div>
</div>
);
})}
</div>
</div>
<div className="bg-primary-dark h-auto px-2">
{activeToolOptions && <ToolSettings options={activeToolOptions} />}
</div>
</>
);
};
return (
<>
{useCollapsedPanel ? (
<PanelSection
childrenClassName="flex-shrink-0"
title={title}
>
{render()}
</PanelSection>
) : (
render()
)}
</>
);
}
export { ToolboxUI };
@@ -0,0 +1,3 @@
import { ToolboxUI } from './ToolboxUI';
import Toolbox from './Toolbox';
export { ToolboxUI, Toolbox };
@@ -17,7 +17,7 @@ const TooltipContent = React.forwardRef<
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-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',
'bg-primary-dark border-secondary-light text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-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 overflow-hidden rounded-md border px-3 py-1.5 text-xs',
className
)}
{...props}
+35 -3
View File
@@ -6,15 +6,28 @@ import Combobox from './Combobox';
import Popover from './Popover';
import Calendar from './Calendar';
import DatePickerWithRange from './DateRange';
import Separator from './Separator';
import { Tabs, TabsContent, TabsList, TabsTrigger } from './Tabs';
import { Toggle, toggleVariants } from './Toggle';
import { ToggleGroup, ToggleGroupItem } from './ToggleGroup';
import { Input } from './Input';
import { Label } from './Label';
import { Tabs, TabsList, TabsTrigger, TabsContent } from './Tabs';
import { Separator } from './Separator';
import { Switch } from './Switch';
import { Checkbox } from './Checkbox';
import { Toggle, toggleVariants } from './Toggle';
import { Slider } from './Slider';
import { ScrollArea, ScrollBar } from './ScrollArea';
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from './Accordion';
import { Icons } from './Icons';
import SidePanel from './SidePanel';
import StudyItem from './StudyItem';
import StudyBrowser from './StudyBrowser';
import StudyBrowserSort from './StudyBrowserSort';
import StudyBrowserViewOptions from './StudyBrowserViewOptions';
import Thumbnail from './Thumbnail';
import ThumbnailList from './ThumbnailList';
import PanelSection from './PanelSection';
import DisplaySetMessageListTooltip from './DisplaySetMessageListTooltip';
import { Toolbox, ToolboxUI } from './Toolbox';
export {
Button,
@@ -39,4 +52,23 @@ export {
toggleVariants,
Slider,
ScrollArea,
ToggleGroup,
ToggleGroupItem,
ScrollBar,
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
Icons,
SidePanel,
StudyItem,
StudyBrowser,
StudyBrowserSort,
StudyBrowserViewOptions,
Thumbnail,
ThumbnailList,
PanelSection,
DisplaySetMessageListTooltip,
Toolbox,
ToolboxUI,
};
+51 -1
View File
@@ -8,9 +8,34 @@ import {
Combobox,
Calendar,
DatePickerWithRange,
Separator,
Tabs,
TabsContent,
TabsList,
TabsTrigger,
Toggle,
toggleVariants,
ToggleGroup,
ToggleGroupItem,
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
Icons,
SidePanel,
StudyItem,
StudyBrowser,
StudyBrowserSort,
StudyBrowserViewOptions,
Thumbnail,
ThumbnailList,
PanelSection,
DisplaySetMessageListTooltip,
Toolbox,
ToolboxUI,
} from './components';
import { useNotification, NotificationProvider } from './contextProviders';
import { Toggle, toggleVariants } from './Toggle';
export {
// components
@@ -26,4 +51,29 @@ export {
// contextProviders
NotificationProvider,
useNotification,
Separator,
Tabs,
TabsContent,
TabsList,
TabsTrigger,
Toggle,
toggleVariants,
ToggleGroup,
ToggleGroupItem,
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
Icons,
SidePanel,
StudyItem,
StudyBrowser,
StudyBrowserSort,
StudyBrowserViewOptions,
Thumbnail,
ThumbnailList,
PanelSection,
DisplaySetMessageListTooltip,
Toolbox,
ToolboxUI,
};
+14
View File
@@ -90,6 +90,20 @@ module.exports = {
'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out',
},
bkg: {
low: '#050615',
med: '#090C29',
full: '#041C4A',
},
info: {
primary: '#FFFFFF',
secondary: '#7BB2CE',
},
actions: {
primary: '#348CFD',
highlight: '#5ACCE6',
hover: 'rgba(52, 140, 253, 0.2)',
},
},
},
plugins: [require('tailwindcss-animate')],
+3 -1
View File
@@ -3,7 +3,9 @@
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@ui/*": ["./src/*"]
"@ui/*": ["./src/*"],
"@ohif/core": ["../core/src"],
"@ohif/ui": ["../ui/src"]
}
},
"include": ["src"],
+14 -1
View File
@@ -59,7 +59,20 @@ module.exports = {
dark: '#726f7e',
active: '#2c3074',
},
bkg: {
low: '#050615',
med: '#090C29',
full: '#041C4A',
},
info: {
primary: '#FFFFFF',
secondary: '#7BB2CE',
},
actions: {
primary: '#348CFD',
highlight: '#5ACCE6',
hover: 'rgba(52, 140, 253, 0.2)',
},
customgreen: {
100: '#05D97C',
200: '#0FD97C',